# Domain model — glossary + concept map Purpose: what the app IS — the entity chain and vocabulary an agent needs before touching scheduling, playout, or IPTV code. **Update this doc in the same PR that changes any fact below.** ## The pipeline in one paragraph Media libraries (Local/Plex/Jellyfin/Emby) are scanned into media items, which are grouped into collections/playlists/etc. Those groupings are arranged onto a **playout** by one of several scheduling engines (classic schedule, block/template calendar, sequential YAML, scripted, external-JSON) — a playout belongs to exactly one **channel**. Channels are exposed to IPTV clients (Jellyfin, Dispatcharr) as an M3U playlist + XMLTV guide, and streamed on demand via FFmpeg, all under the `/iptv/*` routes (`ErsatzTV/Controllers/IptvController.cs`). **Auto-tuning (#69)** is a second, automatic-first channel-creation mode alongside the manual single-channel flow: it enumerates library metadata along an axis (TV Show / TV Genre / Movie Genre), previews the proposed channels (name, allocated number, item count) with no writes, and bulk-creates the selected ones via the #63 composite `CreateChannelFromLineup` primitive. Each generated channel is backed by a newly-created, live **SmartCollection** query, so it keeps tracking the library as it grows rather than freezing a static item list. ## Entity chain sketch ``` Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishes Generated vs Mirror) │ ├─ ScheduleKind: None/Classic/Block/Sequential/Scripted/ExternalJson │ (PlayoutScheduleKind, ErsatzTV.Core/Domain/PlayoutScheduleKind.cs) │ ├─ Classic: ProgramSchedule ──< ProgramScheduleItem (TPT: One/Multiple/Flood/Duration) │ └─< ProgramScheduleAlternate (day/date-conditional alt schedule) │ ├─ Block: PlayoutTemplate (day/date-conditional, like ProgramScheduleAlternate) │ ──> Template ──< TemplateItem (time-of-day) ──> Block │ Block ──< BlockItem (ordered collection items) │ PlayoutTemplate also optionally points at a DecoTemplate │ Playout can also point directly at a default Deco (DecoId) │ ├─ Sequential: Playout.ScheduleFile (YAML, validated via the SPA's Schedule │ Validator / TroubleshootController) │ ├─ Scripted: ScriptedScheduleController-backed (not yet detailed here) │ └─ ExternalJson: Playout.ScheduleFile (JSON) Playout.Items = List (the built, materialized schedule) Playout.PlayoutHistory = rotation/rerun state (per Block for block playouts) ``` `Deco`/`DecoTemplate` are orthogonal to the schedule kind (mostly used with Block playouts but `Playout.DecoId` and `Playout.Templates` (`PlayoutTemplate.DecoTemplateId`) are independent FKs on `Playout`, not nested inside `ProgramSchedule`). ## Glossary | Term | Meaning | Key entity | Edited at (SPA) | |---|---|---|---| | **Classic playout** | Schedule = ordered `ProgramScheduleItem` rows on a `ProgramSchedule`, played in sequence/loop. Alternate schedules let day-of-week/day-of-month/month/date-range conditions pick a different `ProgramScheduleAlternate` by `Index` order, **first match wins**; the row with the broadest/no conditions, placed **last**, acts as the catch-all default. | `ProgramSchedule`, `ProgramScheduleItem`, `ProgramScheduleAlternate` | `/app/schedules` | | **Block playout** | A calendar of `Template`s assigned to times of day (`TemplateItem.StartTime`) and to a playout via `PlayoutTemplate` (day/date-conditional, same first-match/catch-all-last pattern as alternate schedules). Each `Template` is a day-grid of `Block`s; each `Block` is an ordered list of `BlockItem`s (collection/media/search references) with a `Minutes` duration and a `BlockStopScheduling` rule (`AfterDurationEnd` vs `BeforeDurationEnd`). | `Template`, `TemplateItem`, `Block`, `BlockItem`, `PlayoutTemplate` | `/app/templates`, `/app/blocks`, `/app/playouts/{id}/templates` | | **Sequential playout** | Driven by a YAML file (`Playout.ScheduleFile`); validated via the Schedule Validator screen. | `Playout.ScheduleFile` | `/app/troubleshooting/yaml` (validate only; file itself is server-side) | | **Scripted playout** | `PlayoutScheduleKind.Scripted`; backed by `ScriptedScheduleController`. | — | — | | **External-JSON playout** | `PlayoutScheduleKind.ExternalJson = 20`; JSON-driven, same shape idea as Sequential but JSON instead of YAML. | `Playout.ScheduleFile` | — | | **Reshuffle** | `Playout.Seed` (the master shuffle seed) is surfaced on the playout list + detail API so the SPA can show it; a per-playout **Reshuffle** action reseeds and rebuilds via `POST /api/v1/playouts/{id}/reshuffle` (enqueues `BuildPlayout(id, PlayoutBuildMode.Reset)`), distinct from `reset-all`'s `Refresh` mode for Classic. | `Playout.Seed` | `/app/playouts` | | **Deco** | Per-playout "decoration": one of 4 independently-modal sections — watermark, graphics elements, default filler, dead-air fallback — plus break content. Each mode section is `Inherit`/`Disable`/`Override`/`Merge` (`DecoMode`). Can attach directly to a Block playout via `Playout.DecoId`. | `Deco`, `DecoGroup`, `DecoBreakContent` | `/app/decos` | | **DecoTemplate** | Time-of-day (`DecoTemplateItem.StartTime`/`EndTime`) calendar of `Deco`s, assigned to a playout via `PlayoutTemplate.DecoTemplateId` (same row as the Block-template assignment — one `PlayoutTemplate` entry carries both a `Template` and an optional `DecoTemplate`). | `DecoTemplate`, `DecoTemplateItem`, `DecoTemplateGroup` | `/app/deco-templates` | | **Default deco vs deco templates** | `Playout.DecoId` = one static deco for the whole playout; `PlayoutTemplate.DecoTemplateId` = a time-varying deco schedule. Both are optional and independent. | `Playout`, `PlayoutTemplate` | `/app/playouts/{id}/templates` | | **FillerPreset** | A reusable filler definition: `FillerKind` (PreRoll/MidRoll/PostRoll/Tail/Fallback; also `GuideMode=99`, `DecoDefault=100`) × `FillerMode` (None/Duration/Count/Pad/RandomCount) over a collection/media-item/multi-collection/smart-collection/playlist source, with an optional `Expression` (NCalc). Referenced from `ProgramScheduleItem` (Pre/Mid/Post/Tail/FallbackFillerId) and `Channel.FallbackFillerId`. | `FillerPreset`, `FillerKind`, `FillerMode` | `/app/filler-presets` | | **Clock-boundary padding** (#77) | Snapping a schedule to clean `:00/:15/:30`-style guide times is **not a separate feature** — it is `FillerMode.Pad` + `PadToNearestMinute` on a `FillerPreset` (Classic), or the `pad_to_next`/`pad_until` YAML instructions (Sequential). Block playouts are inherently clock-anchored via `TemplateItem.StartTime`. The EPG reflects the padded boundary automatically (`ChannelGuideProjector` coalesces trailing filler into the programme window). No one-click per-channel toggle yet — deferred to a UI follow-up blocked on #388. See `decisions.md` 2026-07-17. | `FillerPreset` (`Pad`) | `/app/filler-presets` | | **Seasonal / date-conditional scheduling** (#73) | Holiday/seasonal channels are **not a separate feature** — they are the existing date predicate on `IAlternateScheduleItem`, implemented by `ProgramScheduleAlternate` (Classic) and `PlayoutTemplate` (Block), evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match by `Index`, catch-all last). **Leaving `StartYear`/`EndYear` empty makes the range repeat every year** — the "set once, works every December" switch; explicit years (required in pairs) mean a one-off window and disable wrap-around detection. Wrap-around (Nov→Feb) and invalid/leap dates (Feb 31) are handled. No *soft* prioritization primitive exists (binary first-match-wins); that ask belongs to #70's weighting work. See `channels.md` → "Recipe: seasonal / holiday programming" and `decisions.md` 2026-07-17. | `IAlternateScheduleItem`, `ProgramScheduleAlternate`, `PlayoutTemplate` | `/app/playouts/{id}/alternate-schedules`, `/app/playouts/{id}/templates` | | **Playback order** | How a schedule item's source(s) are sequenced (`PlaybackOrder`). Note three that are easily confused: **`Shuffle`** is Fisher–Yates over the flattened items, so airtime is implicitly proportional to collection size (a 200-episode show swamps a 20-episode one). **`ShuffleInOrder`** is a balanced shuffle (keyj) that pads sources to equal length with non-emitting spacers — it plays every item exactly once per cycle, so it prevents *clumping* but leaves airtime proportional to size; it is **not** fair-share. **`WeightedShuffle`** (#70) picks a *source* by smooth weighted round-robin then takes its next item, so each source's `Weight` is its share of airtime — equal weights (the default) mean equal airtime regardless of library size, with small sources looping. Classic engine only; rejected at the write path for playlist/block items. See `decisions.md` 2026-07-17. | `PlaybackOrder`, `MultiCollectionItem.Weight`, `MultiCollectionSmartItem.Weight` | `WeightedShuffle` is offered as a Playback Order **only** on classic schedule items whose source is a MultiCollection (`web/src/schedules/itemRules.ts`, #404); the per-source weights themselves are edited at `/app/multi-collections` | | **Watermark** | `ChannelWatermark` image overlay; attached at channel, schedule-item, block-item, deco, or playout-item level with position/size/opacity. | `ChannelWatermark`, `DecoWatermark`, `BlockItemWatermark`, `ProgramScheduleItemWatermark` | `/app/watermarks` | | **Collection** | Manual list of media items (`CollectionItem`). | `Collection` | `/app/collections` | | **SmartCollection** | Saved search — a `Query` string, no static item list. | `SmartCollection` | `/app/collections` | | **MultiCollection** | Combines multiple `Collection`s and/or `SmartCollection`s (with grouping via `MultiCollectionItem`/`MultiCollectionSmartItem`). Both join entities carry a per-source `Weight` (default 1) used by `PlaybackOrder.WeightedShuffle` (#70) and ignored by every other order — the two are mirrors, so a change to one belongs on the other. The editor exposes a per-source weight input (1..1000, mirroring the API validator) with a computed % share and a "Reset to fair share" action; it round-trips `weight` from the GET because the PUT replaces the item list (#404). | `MultiCollection` | `/app/multi-collections` (#151, weight UI #404) | | **RerunCollection** | One source (collection/media item/multi/smart) with separate `FirstRunPlaybackOrder` vs `RerunPlaybackOrder`. | `RerunCollection` | `/app/rerun-collections` (#152) | | **Playlist** / **PlaylistGroup** | Ordered `PlaylistItem`s; `IsSystem` flag marks built-in/non-deletable playlists and groups. | `Playlist`, `PlaylistGroup`, `PlaylistItem` | `/app/playlists` | | **Media kinds** | `CollectionType` enum distinguishes container kinds (Collection/TelevisionShow/TelevisionSeason/Artist/MultiCollection/SmartCollection/Playlist/RerunFirstRun/RerunRerun/SearchQuery) from leaf media kinds (Movie/Episode/MusicVideo/OtherVideo/Song/Image/RemoteStream) plus synthetic `FakeCollection`/`FakePlaylistItem`. Concrete media entities: `Movie`, `Show`/`Season`/`Episode`, `Artist`/`MusicVideo`/`Song`, `OtherVideo`, `Image`, `RemoteStream` (`ErsatzTV.Core/Domain/MediaItem/`). | `MediaItem` subclasses | `/app/media?kind=...` | | **Library / LibraryPath / LibraryFolder** | `Library` (abstract; Local/Plex/Jellyfin/Emby subclasses) owns one or more `LibraryPath`s (scan roots); each path has a `LibraryFolder` tree used for browsing and image-folder duration metadata. | `Library`, `LibraryPath`, `LibraryFolder` | `/app/libraries` (hub: list + scan); `/app/libraries/local/{id}` (`/new`) for local library name/media-kind/path CRUD + move-path (#202) | | **Media source kind** | `MediaSourceKind`: Local/Plex/Jellyfin/Emby — the origin server type for a `Library`. | `MediaSourceKind` | `/app/libraries` (hub); connect/edit/disconnect and per-source library-sync + path-replacements now live at `/app/libraries/{plex\|jellyfin\|emby}` (+ `/connection`, `/{id}/sync`, `/{id}/path-replacements`) — #202, previously Blazor-only | | **MediaItemState** | Health flag on a media item: Normal/FileNotFound/Unavailable/RemoteOnly. Drives the Trash screen. | `MediaItemState` | `/app/trash` | | **PlayoutItem** | One materialized, built entry in a playout's timeline (the actual thing that will play at a given time). | `PlayoutItem` | (generated, not directly edited) | | **PlayoutHistory** | Rotation/rerun bookkeeping per block (`BlockId`) + collection `Key`/`ChildKey`, used by block-playout schedulers to avoid repeats; inspectable via Troubleshooting. | `PlayoutHistory` | `/app/troubleshooting/blocks` | | **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel`, `/app/auto-tune` (bulk-generate from library metadata, #69; per-channel DetailPanel content-source members read via `GET /api/v1/channels/auto-tune/members`, #384; per-channel `templateId`/`advanced`/`logo` overrides accepted by `POST /api/v1/channels/auto-tune`, #385; per-source rotation weights + query corrections via an optional `sources: [{sourceId, weight, excluded}]` on that same request, #425 — a customized channel is backed by a system-owned `MultiCollection` of per-source `SmartCollection`s with `PlaybackOrder.WeightedShuffle`, `OwnedByChannelId`-tagged so it's hidden from collection lists and cleaned up on channel delete) | | **Channel health / `PlayoutCount`** (#72) | Whether a channel can play at all. `PlayoutCount` (channel's own playouts, **plus the mirror source's** when `PlayoutSource is Mirror` — computed by `Mapper.GetPlayoutsCount`) rides on both `ChannelResponseModel` (list) and `ChannelDetailResponseModel`; `0` ⇒ the channel can never play, rendered as a "No playout" badge + a matching **No playout** filter on the channels list (both name only the one fault the API can prove — a broader "Problems" label would read as a false all-clear to a user whose *other* fault classes below are uncomputed). It is a **raw fact, not a status enum** — see `decisions.md` 2026-07-17. Distinct from `/api/v1/channels/state`'s `OnAir`, which is runtime liveness ("someone is streaming right now"), not "would play if tuned". Empty-schedule, broken-source and auto-tuned-vs-user origin are deliberately **not** computed (see that decision entry for why each is unsafe today). | `Channel.Playouts` | `/app/channels` (read-only signal) | | **Guide / EPG (XMLTV)** | Per-channel programme guide generated from playout items; channels with `ShowInEpg=false` are excluded. The SPA's JSON guide grid is built by the sibling `GetChannelGuideDataHandler`. Both the JSON guide (`ChannelGuideChannelResponseModel`) and the channels-list DTO (`ChannelResponseModel`) expose a rooted, directly-usable `Logo` URL (#464) via `Mapper.GetLogoUrl` — `/iptv/logos/{file}` for an uploaded logo, the absolute URL passed through for an external one, `null` when unset (SPA then draws the generated initials "bug"). | `GetChannelGuideHandler`, `GetChannelGuideDataHandler` | `/app/guide` (viewer); settings at `/app/settings/xmltv` | | **M3U** | The channel lineup playlist Jellyfin/Dispatcharr consume. | `ChannelPlaylist.ToM3U()` | — | | **IPTV base URL** | Optional advertised base URL for the IPTV surface (#340). Stored as a single `ConfigElement` (`ConfigElementKey.IptvBaseUrl`, key `iptv.base_url`, no EF migration); when set, `GetChannelPlaylistHandler` (M3U) and `GetChannelGuideHandler` (XMLTV) pin their absolute URLs to its scheme/host/base instead of the request `Host` (blank/invalid → request-derived). Not applied to HDHomeRun; distinct from `ETV_BASE_URL`. Parsed by `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`. | `ConfigElementKey.IptvBaseUrl` | `/app/settings` → IPTV (`GET`/`PUT /api/v1/settings/iptv`) | | **Aggregate `Version`** | Optimistic-concurrency token (issue #253): a plain `int Version` on the 9 replace-all roots — `ProgramSchedule`, `Block`, `Template`, `DecoTemplate`, `Playlist`, `Collection`, `Playout`, `MultiCollection`, `RerunCollection` — implementing `IVersionedAggregate`, EF-mapped `.IsConcurrencyToken()`. Surfaced as a strong ETag on the aggregate's GET and checked against `If-Match` on the PUT (mismatch → 412). See `api-conventions.md` §7a. | `IVersionedAggregate` | (not user-edited) | ## Where things are edited (SPA routes) Primary nav: `/app` (dashboard), `/app/channels`, `/app/new-channel`, `/app/auto-tune` (#69), `/app/guide`, `/app/schedules`, `/app/blocks`, `/app/templates`, `/app/decos`, `/app/deco-templates`, `/app/playouts` (+ sub-paths `/app/playouts/{id}/alternate-schedules`, `/app/playouts/{id}/templates`). Media nav: `/app/media` (generic kind-filtered browse, `?kind=movies|shows|artists|music-videos| other-videos|remote-streams|images`), `/app/search`, `/app/trash`, `/app/collections` (manual + smart), `/app/multi-collections` (#151), `/app/rerun-collections` (#152), `/app/filler-presets`, `/app/libraries` (+ sub-paths, see below), `/app/trakt-lists`. **Media sources / libraries (#202, 2026-07-11)**: `/app/libraries` is an `allowSubPaths` route (`LibrariesRouteScreen`, App-owned popstate — see `spa-conventions.md` §2/§8). The hub itself lists sources/libraries and triggers scans; connecting/editing/removing a source and its libraries now happens at: - `/app/libraries/local/new`, `/app/libraries/local/{id}` — `LocalLibraryEditScreen`: name, media-kind (create-only), path add/move/delete. - `/app/libraries/plex` — `PlexSourceScreen`: OAuth pin-flow sign-in/fix-credentials/sign-out. - `/app/libraries/{jellyfin|emby}` — `RemoteSourceScreen`; `/app/libraries/{jellyfin|emby}/connection` — `RemoteConnectionEditScreen` (address + api-key, key never displayed once set). - `/app/libraries/{plex|jellyfin|emby}/{id}/sync` — `RemoteLibrariesEditScreen` (per-library sync toggle); `/app/libraries/{plex|jellyfin|emby}/{id}/path-replacements` — `PathReplacementsEditScreen`. These replace the Blazor `/media/sources/{local,plex,jellyfin,emby}/...` pages over the new `/api/v1/libraries/local/*` and `/api/v1/media-sources/{plex|jellyfin|emby}/*` write endpoints — see `docs/blazor-route-parity.md` Section 2 for the full per-route mapping. System nav: `/app/settings` (sub-tabs: streaming/system/logging/playout/scanner/general/xmltv — all mapped 1:1 from legacy `/settings/*` Blazor routes — plus a new **IPTV** section, #340, that edits the advertised IPTV base URL via `GET`/`PUT /api/v1/settings/iptv`), `/app/logs`, `/app/troubleshooting` (+ `/app/troubleshooting/blocks` block-playout history, `/app/troubleshooting/yaml` sequential-schedule validator; playback troubleshooting still gated on API #145), `/app/ffmpeg-profiles`, `/app/watermarks`. `/app/edit-channel/{id}` (edit) is reached from the channels table, not the primary sidebar nav. Per-item media detail pages and the image-folder browser (`MediaDetailScreen`'s `MovieDetailScreen`/`ShowDetailScreen`/`SeasonDetailScreen`/`ArtistDetailScreen`, `ImageBrowserScreen`) landed via #141 (PR #183) at `/app/media/{movies|shows|seasons|artists}/{id}` and `/app/media/images/browser`. Multi-collection (#151), rerun-collection (#152), and playlist (#153) editors now live in the SPA at `/app/multi-collections`, `/app/rerun-collections`, and `/app/playlists`. See `docs/blazor-route-parity.md` for the full route-by-route tracker. ## Key handler / file locations - **Classic scheduling engine**: `ErsatzTV.Core/Scheduling/PlayoutBuilder.cs`, `PlayoutModeSchedulerBase.cs` (+ `One`/`Multiple`/`Flood`/`Duration` variants), `AlternateScheduleSelector.cs` (first-match-wins alternate/template selection). - **Block scheduling engine**: `ErsatzTV.Core/Scheduling/PlayoutModeBlock.cs`. - **Build entry point**: `ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs`. - **M3U generation**: `ErsatzTV.Core/Iptv/ChannelPlaylist.cs` → `ToM3U()`. - **XMLTV generation**: `ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs`. - **Streaming / IPTV routes**: `ErsatzTV/Controllers/IptvController.cs` (`/iptv/channels.m3u`, `/iptv/xmltv.xml`, `/iptv/channel/{number}.ts`, `/iptv/session/{number}/hls.m3u8`, HDHR routes, logos).