Files
ersatztv/docs/channels.md
T
timothy 1a7f15fb27 fix(510): address independent review — Resource null guard, honest routing claim
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
2026-07-26 21:12:13 +02:00

17 KiB
Raw Blame History

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), 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 PlayoutProgramScheduleProgramScheduleItem 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 Blocks).
  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.mdscheduling.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.mdgraphics.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".