Files
ersatztv/docs/channels.md
T
timothyandClaude Opus 4.8 62c15da490
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been cancelled
docs(73): document seasonal / date-conditional scheduling (already implemented)
fixes #73

#73's premise ("ErsatzTV has no native date-conditional scheduling today")
is false. The predicate (IAlternateScheduleItem) is shipped on both engines,
evaluated by AlternateScheduleSelector, reachable from the SPA, and already
unit-tested — so the only real gap was discoverability, and it was a docs gap:
the mechanism was documented as a mechanism, never as a task.

- channels.md: task-shaped "Recipe: seasonal / holiday programming" for both
  engines, plus the gotchas — chiefly that blank StartYear/EndYear is what
  makes a range repeat every year (AlternateScheduleSelector.cs:32-40), that
  the override branch needs BOTH years, and that explicit years disable
  wrap-around detection.
- domain-model.md: glossary row (mirrors the #77 "already exists" row).
- decisions.md: entry recording the verdict and the rejected asks — notably
  that "prioritize collection X during a date range" is #70's weighting
  problem, not a second primitive built here while #70 is mid-flight.

No production code changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:20:05 +02:00

7.7 KiB

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), 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.

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.

Note: ChannelLogoGenerator.GenerateChannelLogoUrl() hardcodes localhost for watermark logo fetching — see issue #1 for details.