Whole-branch review found the stamping test was structurally vacuous: a bare foreach over ChannelTemplates.Where(IsSystem) passes with zero iterations, so the test would have stayed green if template seeding silently bailed out. Assert the collection is non-empty first. Proven non-vacuous by a negative control (forcing SeedChannelTemplates to bail makes exactly this test fail). Also cover the ACTUAL production sequence -- adopt an existing hand-made row, then delete it -- which the previous no-resurrect test did not exercise (it covered seed-then-delete). The marker is written on the adopt path too, so the deleted row must stay deleted. docs: note that a deleted preset degrades to no default rather than failing, and that the default applies to newly created channels, not retroactively. Refs #67
9.6 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 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:
- One — Play exactly 1 item per cycle
- Multiple — Play N items (fixed count, collection size, or playlist item size)
- Duration — Fill a time window (with tail mode: none, offline, slate, or filler)
- 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:
- Build the seasonal schedule at
/app/schedules(clone your normal one, swap in the holiday content). - Go to
/app/playouts/{id}/alternate-schedules. - Add a row pointing at the seasonal schedule; tick Limit to date range; set start
12/1, end12/31. - Leave the start/end year fields empty — see the gotcha below; this is what makes it recur.
- 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:
- Build the seasonal
Templateat/app/templates(a day-grid ofBlocks). - Go to
/app/playouts/{id}/templates, add a date-limitedPlayoutTemplaterow for it (same date-range fields, same empty-years rule, same catch-all-last ordering). - Optionally set that row's DecoTemplate for seasonal watermarks/filler — one
PlayoutTemplaterow carries both aTemplateand an optionalDecoTemplate, 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/31with 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
StartYearandEndYearto 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/1correctly 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/31rolls to the 1st of the next month; an end of2/31reduces to the last day of February (leap-year aware). - Decos have no dates of their own.
DecoTemplateItem.StartTime/EndTimeare time-of-day. Date-gate a deco via thePlayoutTemplaterow that carries itsDecoTemplateId; a playout's defaultPlayout.DecoIdis 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.
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.
Limitation: a logo set via External logo URL cannot drive the bug. WatermarkSelector
resolves it to the URL and then File.Exists-checks it, which is never true, so the watermark is
silently dropped — the URL wins for the guide listing but disables the on-screen bug. Tracked as
#502; the editor does not offer a bug preview in that case.
Note: ChannelLogoGenerator.GenerateChannelLogoUrl() hardcodes localhost for watermark logo
fetching — see issue #1 for details.