Files
ersatztv/docs/channels.md
T
timothyandClaude Opus 4.8 dfed9a393b
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
PR Gates / Docs update reminder (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 27s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m26s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m38s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m52s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(68): rebuild on-demand channel guide (and mirrors) on thaw
An on-demand channel (`PlayoutMode.OnDemand`) already is the "resume where I
left off" feature: `Playout.OnDemandCheckpoint` persists the viewer's position
and `PlayoutTimeShifter.TimeShift` slides the materialized timeline forward on
tune-in so the paused item is active again. Because it rewrites `GuideStart`/
`GuideFinish` alongside `Start`/`Finish`, guide and playback freeze together —
structurally avoiding the free-running-wall-clock desync #68 was filed about.

The one gap: `TimeShift` rewrote the stored `PlayoutItem` rows but the XMLTV
guide is served from a cached fragment that only `RefreshChannelData` rebuilds,
and the tune-in path never enqueued it. So an external EPG client polling after
a thaw could see a stale timeline until the next incidental rebuild.

Fix: `IPlayoutTimeShifter.TimeShift` now returns the channel numbers whose cached
guide is stale — the shifted channel plus any channels that mirror it (the same
fan-out `BuildPlayoutHandler` already does) — and `TimeShiftOnDemandPlayoutHandler`
enqueues a `RefreshChannelData` for each on `CancellationToken.None` (post-commit
side effect must not be abandoned if the session token cancels).

Tests: handler enqueues a rebuild per stale channel (+ mirror + no-shift cases);
`PlayoutTimeShifter` reports source+mirrors on a shift, empty on Continuous /
zero-offset / active-unforced, and correctly seeds+shifts a never-watched playout.
Non-vacuity of the enqueue proven by a compiling negative control.

Docs: channels.md (On-demand resume section), domain-model.md, decisions.md
(scheduling.ondemand-guide-refresh-on-thaw). Per-viewer resume is out of scope
(single per-channel checkpoint; #68 says per-channel suffices).

fixes #68

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 19:12:42 +02:00

206 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
Note: `ChannelLogoGenerator.GenerateChannelLogoUrl()` hardcodes `localhost` for watermark logo
fetching — see issue #1 for details.