Two independent reviews (cross-family Codex + cold Opus) both returned BLOCKED. Findings, all verified against source before acting: 1. Resource arm could throw ArgumentNullException (Codex, Medium). Making the channel/global Resource arm reachable exposed that CreateWatermarkHandler and UpdateWatermarkHandler write `Image = null` for EVERY non-Custom watermark, so an API-created Resource watermark reached Path.Combine(folder, null). Added the blank/null guard the arm never had. This was live at the playout-item level too, not just newly-reachable code. 2. "Routing is unaffected" was false (Codex, Low but sharp). The predicate is unchanged, but CanUseFFmpegNativeWatermark also tests Count == 1, and dropping an unresolvable watermark shortens the list. A deco with one valid and one missing permanent watermark now routes ffmpeg-native where it previously routed to the graphics engine. Intended, but observable -- so it is documented and pinned by a test rather than claimed away. 3. "Exactly one resolver" over-claimed (Opus, High). True of the selector, not the application: the song-progress overlay is built as a WatermarkOptions directly by the streaming and troubleshooting handlers, unchecked, and can still hand ffmpeg a nonexistent -i. Pre-existing; scoped the claim in the record and channels.md and filed #653. 4. Undeclared crash->degrade change (Opus, Medium). Channel/global Custom had no blank-image guard, so a cleared image hit ImageCache's fileName[..2] and threw out of stream startup. Now declared in the record and tested. 5. Contradictory rule text (Opus, Medium) -- the catalog one-liner said "always no bug" while the body documents the playout-item fall-through exception. Qualified; catalog regenerated. 6. History was wrong in both the record and the XML docs: the three precedence levels did NOT all check every source -- channel/global had no Resource arm and threw. Corrected. Tests: 30 in the fixture now (was 18). New coverage for the preserved blank-Custom fall-through (to channel AND to global), the complement case (missing-but-named must NOT fall through), null/blank Resource, and the valid+missing routing case. 17 of 24 failed against the pre-fix resolver before this round; the fixture stays mutation-sensitive. Also: hoisted the mock-filesystem Initialize() out of its loop so a multi-file case cannot silently seed only the last file, and marked the generated-URL test honestly as redundant-by-construction rather than claiming independent coverage. The decision record is now 81 prose lines, over the 60-line ceiling. Declared as a legitimate decline per docs.corpus-size-signal: the length is the review findings above, each a distinct fact, not redundancy. refs #510 #652 #653
263 lines
17 KiB
Markdown
263 lines
17 KiB
Markdown
# Channel Architecture
|
||
|
||
## Channel Entity
|
||
|
||
Defined in `ErsatzTV.Core/Domain/Channel.cs`. Key fields:
|
||
|
||
- **Identity**: `Number` (e.g., "1", "2.1"), `Name`, `UniqueId` (GUID for M3U/XMLTV)
|
||
- **Encoding**: `FFmpegProfileId` — video/audio codec, bitrate, resolution, hardware acceleration
|
||
- **Streaming**: `StreamingMode` (TransportStream, HLS Direct, HLS Segmenter, TS Hybrid)
|
||
- **Behavior**: `PlayoutMode` (Continuous vs OnDemand — OnDemand *is* the "resume where I left off" mode, see [On-demand resume](#on-demand-resume-freeze-the-clock-when-unwatched)), `IdleBehavior` (StopOnDisconnect vs KeepRunning)
|
||
- **Visual**: `WatermarkId`, `FallbackFillerId`, artwork (logos)
|
||
- **Mirroring**: `PlayoutSource` (Generated vs Mirror) — a mirror channel copies another with optional time offset
|
||
- **Display**: `Group`, `Categories`, `ShowInEpg`, `IsEnabled`
|
||
- **Audio/Subtitle defaults**: preferred language codes, subtitle mode
|
||
|
||
## Content Sources
|
||
|
||
Channels get content through a **Playout** → **ProgramSchedule** → **ProgramScheduleItem** chain.
|
||
|
||
### Collection Types
|
||
|
||
| Type | Description |
|
||
|------|-------------|
|
||
| `Collection` | Manual grouping of media items with custom playback order |
|
||
| `MultiCollection` | Aggregate of collections + smart collections |
|
||
| `SmartCollection` | Query-based (Lucene.Net) dynamic filtering |
|
||
| `Playlist` | Ordered items with per-item config (count, fillers, playback order) |
|
||
| `TelevisionShow` / `TelevisionSeason` | Structured TV hierarchy |
|
||
| `Movie`, `Episode`, `MusicVideo`, `OtherVideo`, `Song`, `Image` | Individual media items |
|
||
| `RerunCollection` | Wraps any collection with separate first-run/rerun playback orders |
|
||
| `SearchQuery` | Dynamic results from a search |
|
||
| `RemoteStream` | External stream URLs |
|
||
|
||
### Media Sources
|
||
|
||
Media items are imported from configured libraries (Jellyfin, Plex, Emby, or local filesystem). Each source type has its own entity variants (e.g., `JellyfinMovie`, `PlexEpisode`).
|
||
|
||
## Scheduling
|
||
|
||
### Schedule Kinds
|
||
|
||
- **Classic**: Traditional ProgramSchedule with items — the most common
|
||
- **Block**: Template-based block scheduling
|
||
- **Sequential**: Strict sequential ordering
|
||
- **Scripted**: External script-driven playout
|
||
- **ExternalJson**: Playout defined by external JSON file
|
||
|
||
### Schedule Item Types
|
||
|
||
Each `ProgramScheduleItem` is one of four concrete types:
|
||
|
||
1. **One** — Play exactly 1 item per cycle
|
||
2. **Multiple** — Play N items (fixed count, collection size, or playlist item size)
|
||
3. **Duration** — Fill a time window (with tail mode: none, offline, slate, or filler)
|
||
4. **Flood** — Play items continuously until the next fixed-start item
|
||
|
||
Items can have `StartType` of Fixed (anchored to clock time) or Dynamic (follows previous item).
|
||
|
||
### Playback Orders
|
||
|
||
`Chronological`, `Random`, `Shuffle`, `ShuffleInOrder`, `MultiEpisodeShuffle`, `SeasonEpisode`, `RandomRotation`, `Marathon` (group by show/season/artist/album/director).
|
||
|
||
### Filler System
|
||
|
||
`FillerPreset` defines content to fill gaps. Each schedule item can have:
|
||
- **PreRoll** — before main content
|
||
- **MidRoll** — during (chapter breaks)
|
||
- **PostRoll** — after main content
|
||
- **Tail** — pad remaining time in a duration block
|
||
- **Fallback** — channel-level default when nothing else available
|
||
|
||
Filler modes: Duration, Count, Pad (to nearest minute), RandomCount.
|
||
|
||
### Alternate Schedules
|
||
|
||
`ProgramScheduleAlternate` overrides the main schedule for specific days of week, days of month, months of year, or date ranges. Useful for seasonal programming or weekend variations.
|
||
|
||
The date predicate itself is `IAlternateScheduleItem` (`ErsatzTV.Core/Domain/Scheduling/`), implemented by
|
||
`ProgramScheduleAlternate` (classic playouts) and `PlayoutTemplate` (block playouts). Both are evaluated by
|
||
`AlternateScheduleSelector.GetScheduleForDate`: rows are tested in `Index` order, **first match wins**, and
|
||
the broadest/unconditional row placed **last** acts as the catch-all default. `DaysOfWeek`, `DaysOfMonth`
|
||
and `MonthsOfYear` are ANDed with the date range, so a row only matches when *every* condition holds.
|
||
|
||
### Recipe: seasonal / holiday programming
|
||
|
||
Date-conditional scheduling is fully supported — there is no need to hand-build it seasonally. The
|
||
mechanism is named "Alternate Schedules" (classic) / "Playout Templates" (block), which is why it isn't
|
||
obvious if you go looking for "seasonal".
|
||
|
||
**Classic playout — a December holiday channel:**
|
||
|
||
1. Build the seasonal schedule at `/app/schedules` (clone your normal one, swap in the holiday content).
|
||
2. Go to `/app/playouts/{id}/alternate-schedules`.
|
||
3. Add a row pointing at the seasonal schedule; tick **Limit to date range**; set start `12/1`, end `12/31`.
|
||
4. **Leave the start/end year fields empty** — see the gotcha below; this is what makes it recur.
|
||
5. Add (or keep) a row for your normal schedule with no conditions, ordered **last** — the catch-all for the
|
||
other eleven months.
|
||
|
||
**Block playout — same idea, plus holiday branding:**
|
||
|
||
1. Build the seasonal `Template` at `/app/templates` (a day-grid of `Block`s).
|
||
2. Go to `/app/playouts/{id}/templates`, add a date-limited `PlayoutTemplate` row for it (same
|
||
date-range fields, same empty-years rule, same catch-all-last ordering).
|
||
3. Optionally set that row's **DecoTemplate** for seasonal watermarks/filler — one `PlayoutTemplate` row
|
||
carries both a `Template` and an optional `DecoTemplate`, so branding is date-gated along with content.
|
||
|
||
**Gotchas** (all enforced in `AlternateScheduleSelector.cs:32-40`):
|
||
|
||
- **Empty years = repeats every year.** Years default to the *queried date's* year, so `12/1 → 12/31` with
|
||
blank years fires every December, forever. Set explicit years only for a genuine one-off window.
|
||
- **It's both years or neither.** The explicit-year branch requires `StartYear` **and** `EndYear` to be set.
|
||
Filling in only one silently falls back to the yearly-repeat behaviour.
|
||
- **Wrap-around ranges work — but only with blank years.** `11/1 → 2/1` correctly spans the new year. Setting
|
||
explicit years disables wrap detection (`reverse = false`), turning the range into a plain start→end window.
|
||
- **Invalid dates are clamped, not rejected.** A start of `2/31` rolls to the 1st of the next month; an end of
|
||
`2/31` reduces to the last day of February (leap-year aware).
|
||
- **Decos have no dates of their own.** `DecoTemplateItem.StartTime`/`EndTime` are *time-of-day*. Date-gate a
|
||
deco via the `PlayoutTemplate` row that carries its `DecoTemplateId`; a playout's default `Playout.DecoId`
|
||
is deliberately never date-gated (it's the fallback).
|
||
|
||
Not supported: *soft* prioritization ("prefer collection X in December" without swapping the schedule).
|
||
Selection is binary first-match-wins — see `decisions.md` 2026-07-17 (#73) and the weighting work in #70.
|
||
|
||
## Playout Pipeline
|
||
|
||
```
|
||
Channel
|
||
└── Playout
|
||
├── ProgramSchedule
|
||
│ └── ProgramScheduleItems (One|Multiple|Duration|Flood)
|
||
│ └── Content source (Collection, Playlist, SmartCollection, etc.)
|
||
├── PlayoutItems (generated — the actual timeline)
|
||
│ └── MediaItem + start/finish times + filler kind + watermarks
|
||
├── PlayoutGaps (time periods with no content)
|
||
└── ProgramScheduleAlternates (day/date overrides)
|
||
```
|
||
|
||
The scheduling engine (`ErsatzTV.Core/Scheduling/`) resolves schedule items into concrete `PlayoutItem` entries with precise start/finish times. Each `PlayoutItem` references a specific `MediaItem` and includes trim points (`InPoint`/`OutPoint`), filler classification, and per-item audio/subtitle overrides.
|
||
|
||
## On-demand resume (freeze the clock when unwatched)
|
||
|
||
A channel with `PlayoutMode = OnDemand` is the built-in "resume / bookmark" behavior (issue #68): its
|
||
playout clock advances only while someone is watching and freezes when nobody is, so on the next
|
||
tune-in it resumes where the last viewer stopped rather than jumping to a live wall-clock point.
|
||
|
||
- **Resume position**: `Playout.OnDemandCheckpoint` (a `DateTimeOffset?`) persists the viewer's spot.
|
||
`UpdateOnDemandCheckpointHandler` advances it — monotonically, minus one segmenter-timeout of
|
||
rewind-for-context — on each transcode iteration while watching.
|
||
- **Freeze/thaw**: on tune-in, `HlsSessionWorker.Run` sends `TimeShiftOnDemandPlayout`, and
|
||
`PlayoutTimeShifter.TimeShift` slides the **whole** materialized timeline (`PlayoutItem.Start/Finish`
|
||
**and** `GuideStart/GuideFinish`, plus history/anchors) forward by `now − checkpoint`, so the item the
|
||
viewer had reached is active at `now` again.
|
||
- **Guide stays in sync — the make-or-break requirement.** Guide and playback both read the same stored
|
||
`PlayoutItem.Start/Finish`, so freezing them together avoids the classic desync (guide ticking on
|
||
wall-clock while playback resumes from a saved spot). Because the XMLTV guide is served from a
|
||
**cached** fragment, the shift also enqueues `RefreshChannelData` for the channel — and for any
|
||
channels that mirror it — so every affected cache is rebuilt from the just-shifted rows; see
|
||
`decisions.md` → `scheduling.ondemand-guide-refresh-on-thaw` (#68).
|
||
- **Scope**: resume is **per-channel** (a single checkpoint on the playout), not per-viewer. Pair it with
|
||
a sequential playout (Sequential/Chronological order) for a "continue watching" channel of ordered
|
||
content.
|
||
|
||
## Watermarks
|
||
|
||
`ChannelWatermark` supports modes: Permanent, Intermittent, OpacityExpression. Image sources: custom
|
||
upload, channel logo, or built-in resource. Positioned with percentage-based margins and z-index.
|
||
|
||
A watermark is a **shared, named entity** (unique `Name`), referenced by playout items, schedule
|
||
items, block items and decos; a channel points at one via `Channel.WatermarkId`. It is not
|
||
per-channel state.
|
||
|
||
`DbInitializer` seeds one shared preset named **`Channel Bug`** (`ImageSource = ChannelLogo`,
|
||
Permanent, TopLeft, Scaled 5% width, 1%/1% margins, 80% opacity). Because `ChannelLogo` resolves
|
||
each channel's own `ArtworkKind.Logo` artwork at render time, this single row makes every channel
|
||
that points at it use its own logo as its on-screen bug — one uploaded image drives both the guide
|
||
listing and the bug (#67). An existing `Channel Bug` row is adopted untouched, never overwritten, and
|
||
a `ConfigElement` marker (`watermark.channel_bug_seeded`) makes the seed run once per database, so a
|
||
deliberately deleted preset is not resurrected on the next restart.
|
||
|
||
Quick-add channel creation defaults to the preset (if the preset has been deleted, creation falls
|
||
back to no watermark rather than failing), and the channel editor's Branding tab exposes it as a
|
||
"Use logo as on-screen bug" toggle. The default applies to **newly created** channels — an existing
|
||
channel with no watermark does not gain one retroactively when a logo is uploaded; flip the toggle.
|
||
On a **fresh** install the seed also stamps the preset onto
|
||
the system channel templates it creates, so the library-to-lineup builder (which inherits
|
||
`WatermarkId` from the selected template) gets the default too. On an existing install the templates
|
||
are left alone, so builder-created and auto-tuned channels there inherit whatever the template
|
||
already specifies.
|
||
|
||
**An external logo URL drives the bug too — it is downloaded and cached at save time (#525).** When
|
||
you save a channel whose logo is an **External logo URL**, the URL is fetched, decode-validated, and
|
||
stored in the image cache under a content-hash name — after which it is byte-identical to an
|
||
uploaded logo. So `Artwork.Path` never holds a URL: the on-screen bug renders, the editor previews
|
||
it, and M3U/XMLTV emit the cached `/iptv/logos/…` URL like any uploaded logo. A URL that is dead,
|
||
slow (>10s), oversized (>10 MiB), a non-image, or a decode bomb (over 50 MP total or 600 frames)
|
||
**fails the save with a specific 422** in the editor — you see it immediately, rather than a silent
|
||
render-time drop at 3am (the pre-#502/#511 behavior). To change the remote image, re-enter the URL;
|
||
there is no refresh button by design. Existing channels that still hold a raw URL are converted by a
|
||
one-time startup migration; one that fails to download is left alone (a warning names it) and renders
|
||
with no bug until you re-save it. Historical context (the old `File.Exists`-on-a-URL drop, and the
|
||
bounded render-time fetch that preceded caching) is in `docs/decisions.md` under
|
||
`graphics.channel-logo-caching`, #502 and #511.
|
||
|
||
**No usable logo means no on-screen bug — from every attachment point (#510).** A `ChannelLogo`
|
||
watermark resolves through one shared resolver (`WatermarkSelector.ResolveWatermark`) whether it is
|
||
attached via a playout item, the channel, the global setting, **or a deco**. All four agree: an
|
||
un-migrated external URL, a missing cached file, and a channel with no logo artwork each render
|
||
*without* a bug and log a warning. The selector never hands a dead path or a URL downstream — a dead
|
||
local path could otherwise reach ffmpeg as a bare `-i` argument and break the stream, which is worse
|
||
than a skipped overlay. (One watermark is built *outside* the selector and is still unchecked: the
|
||
song-progress overlay — see #653.)
|
||
|
||
Before #510 the deco path had its own unchecked copy of that resolution, so the same channel could
|
||
disagree with itself about whether a bug rendered based only on how the watermark was attached. The
|
||
divergence covered `Custom` and `Resource` image sources too, not just `ChannelLogo`.
|
||
|
||
That change switched off one thing that *did* work: a channel with **no** logo artwork used to get a
|
||
generated-initials nameplate (`/iptv/logos/gen`, drawn by `ChannelLogoGenerator`) when — and only
|
||
when — the watermark came from a deco. It is now off everywhere, because serving it means an HTTP
|
||
fetch inside stream startup, exactly what `graphics.channel-logo-caching` (#525) removed for logos,
|
||
and because `ChannelLogoGenerator.GenerateChannelLogoUrl()` hardcodes `localhost` (issue #1, closed
|
||
as a topology problem without removing the hardcode). Reviving it properly means generating the image
|
||
into the image cache so it resolves to a local path — tracked as **#652**; the rationale is in
|
||
`ffmpeg.watermark-resolution-unified`.
|
||
|
||
## On Now / Next overlay (#74)
|
||
|
||
A transient "On Now / Next" text bug — the current program (title + episode/subtitle) and the
|
||
next program (title + start time) — burned onto a channel's transcoded stream for a few seconds
|
||
at each program transition, for a channel-surf feel. Toggled per channel at the channel editor's
|
||
**Branding** tab, "Show On Now / Next overlay" switch, beside the logo-bug toggle.
|
||
|
||
This is a **graphics element** (`GraphicsElement`, `Text` kind), not a watermark — the existing
|
||
watermark system is image-only and a channel already uses its one `WatermarkId` for the #67 logo
|
||
bug, so multi-line EPG text needed the separate graphics-element system, which previously had no
|
||
channel-level attachment. #74 added one: `ChannelGraphicsElement`, a join table structurally
|
||
identical to the existing `PlayoutItemGraphicsElement`/`ProgramScheduleItemGraphicsElement`/
|
||
`BlockItemGraphicsElement`/`DecoGraphicsElement` joins. See `docs/domain-model.md` → "Graphics
|
||
element" and `docs/decisions.md` → `graphics.channel-level-attachment`.
|
||
|
||
- **Seeded, built-in element.** A text graphics element YAML (`on-now-next.yml`) is written to the
|
||
graphics-elements templates folder and its `GraphicsElement` row created once per database
|
||
(`GraphicsElementSeeder.SeedOnNowNext`, guarded by the `graphics.on_now_next_seeded`
|
||
`ConfigElement` marker) — mirroring the #67 watermark-preset seed's adopt-not-clobber semantics.
|
||
The API marks it with `GraphicsElementResponseModel.builtIn = true` so the SPA finds it reliably
|
||
rather than matching on name.
|
||
- **Toggle mechanics.** Turning the switch on adds the built-in element's id to the channel's
|
||
`graphicsElementIds` (`ChannelDetailResponseModel`/update DTO); turning it off removes it. This
|
||
follows the same pattern as the existing "Use logo as on-screen bug" watermark toggle.
|
||
- **EPG source is the same cached guide the channel serves** — the overlay reads the cached XMLTV
|
||
fragment (`ChannelGuideCacheFolder/{number}.xml`), same as the guide grid, so it is only as fresh
|
||
as that cache. On-demand/time-shifted channels can lag until the guide is refreshed on thaw (see
|
||
On-demand resume, above); a normal continuous channel stays in sync.
|
||
- **Does not apply to HLS-Direct.** `StreamingMode.HttpLiveStreamingDirect` streams the source
|
||
file(s) without transcoding, so there is no frame pipeline to burn text into — the selector
|
||
always returns no graphics elements in that mode, and the editor's toggle is disabled with an
|
||
explanatory caption when the channel is in HLS-Direct.
|
||
- **A deco can suppress it.** A `Deco` in `Override` or `Disable` mode for its graphics-elements
|
||
section takes precedence over the channel-level overlay (channel elements are a base layer, not
|
||
the final word). Additionally, on a **filler** item a deco whose graphics-elements section is not
|
||
set to run during filler (`UseGraphicsElementsDuringFiller` false) also clears the overlay, for
|
||
`Merge` and `Override` alike — see `docs/domain-model.md` → "Graphics element".
|