Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 11s
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 1m6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m25s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m1s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m19s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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
174 lines
9.6 KiB
Markdown
174 lines
9.6 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), `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.
|
|
|
|
## 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.
|