# 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<PlayoutItem> (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). `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) is the per-schedule convenience layer (#392): optional clock-boundary padding divisor (minutes); when set, the Classic builder pads each content item up to the next boundary, filling with the item's FallbackFiller else offline. See `decisions.md` 2026-07-17 and 2026-07-22. | `FillerPreset` (`Pad`), `ProgramSchedule` (`PadToNearestMinute`) | `/app/filler-presets`, `/app/schedules` |
| **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` |
| **Graphics element** | YAML-authored (`Text`/`Image`/`Subtitle`/`Motion`/`Script`) render-engine overlay, distinct from the image-only `ChannelWatermark` system. Attaches via 5 parallel join tables: `PlayoutItemGraphicsElement`, `ProgramScheduleItemGraphicsElement`, `BlockItemGraphicsElement`, `DecoGraphicsElement`, and (#74) **`ChannelGraphicsElement`** — a direct `Channel`-level attachment that did not exist before #74. `GraphicsElementSelector.SelectGraphicsElements` treats channel-level elements as the final fall-through **base layer**: they merge with `Merge`-mode deco elements and per-playout-item elements, but a deco in `Override`/`Disable` mode returns before that fall-through and so **suppresses** the channel overlay (and on a **filler** item, a deco whose graphics-elements section is not set to run during filler — `UseGraphicsElementsDuringFiller` false — clears it too, for `Merge` and `Override` alike); `HttpLiveStreamingDirect` always returns empty (ErsatzTV isn't transcoding, so nothing can be burned in). A built-in seeded text element, `on-now-next.yml` (`GraphicsElementDefaults.OnNowNextFileName`), is written once (`GraphicsElementSeeder.SeedOnNowNext`, guarded by the `graphics.on_now_next_seeded` ConfigElement marker, adopt-not-clobber like the #67 watermark seed) and identified to API clients via a server-derived `GraphicsElementResponseModel.builtIn` flag (path-name comparison, not name matching). Edited per-channel at Channel editor → Branding → "Show On Now / Next overlay". See `decisions.md` → `graphics.channel-level-attachment` (#74). | `GraphicsElement`, `ChannelGraphicsElement` | `/app/edit-channel/{id}` (Branding tab); YAML files under `GraphicsElementsTextTemplatesFolder` etc. are not directly SPA-edited |
| **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 — OnDemand is the per-channel "resume where I left off" freeze/thaw mode, #68; see `channels.md` → On-demand resume), `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** (`health`, #415; supersedes the #72 raw-`PlayoutCount` stance) | Whether a channel will actually play, as one server-derived verdict. `ChannelHealthResponseModel { Status, Faults[], PlayoutCount, BrokenSourceItemCount }` rides on both `ChannelResponseModel` (list) and `ChannelDetailResponseModel`, computed **read-time** (not cached at build time — `MediaItem.State` flips on scan, not build) from the **built timeline**: `Playout.BuildStatus` (`NeverBuilt`/`BuildFailed`) + upcoming `PlayoutItem`s with `Finish >= now` (`EmptyUpcoming` when none; `BrokenSource` when any point at a `FileNotFound`/`Unavailable` `MediaItem`), plus the retained `PlayoutCount` fact (`NoPlayout` when 0, mirror-aware via `Mapper.GetPlayoutsCount`). Kind-agnostic by construction — the built timeline has the same shape for all 5 `PlayoutScheduleKind` values, closing the #71 "shared primitive must cover ALL variants" gap the old Classic-only `EmptyScheduleHealthCheck` had. An **assessable gate** applies first: for `PlayoutMode == OnDemand`, the absence signals `NeverBuilt`/`EmptyUpcoming` are suppressed (an idle on-demand playout legitimately has no fresh build) and contribute `Unknown`, never a false `Problems`; the presence signals `BuildFailed`/`BrokenSource` stay live in every mode. Rollup → `Status ∈ {Healthy, Problems, Unknown}`; SPA shows a single **Problems** filter/count plus a per-fault badge. Costed as one bounded `GROUP BY PlayoutId` aggregate query, not per-row N+1. Distinct from `/api/v1/channels/state`'s `OnAir`, which is runtime liveness ("someone is streaming right now"), not "would play if tuned". See `decisions.md` → `api.channel-health-object` (#415; supersedes `api.channel-health-signal`). Auto-tuned-vs-user **origin** is a separate, non-health signal — see the **Channel origin** row below. | `Channel.Playouts`, `Playout.BuildStatus`, `PlayoutItem` → `MediaItem.State` | `/app/channels` (Problems filter + per-fault badges) |
| **Channel origin** (#414) | Immutable creation-provenance: `Channel.Origin` (`ChannelOrigin` enum — `Unknown`/`UserCreated`/`AutoTuned`) records **how the row was born** and is stamped once at insert (`AutoTuned` in `CreateChannelFromLineupHandler`, the single primitive bulk auto-tune delegates to; `UserCreated` in `CreateChannelHandler`). A later user edit never changes it (so "auto-generated then user-edited" stays `AutoTuned`; any future "diverged from its auto-tune template" signal belongs to the auto-tune arc, not this column). `Unknown` is the honest default for rows predating the column — provenance is **not** back-filled (inferring it from the `"Channel Lineups"` playlist group is the heuristic #414 rejected). Surfaced as a raw `origin` on `ChannelResponseModel` (the SPA badges only `AutoTuned`; absence ⇒ user-created or legacy). | `Channel.Origin` | `/app/channels` (read-only badge) |
| **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).
