Files
ersatztv/docs/domain-model.md
T
timothyandtimothy 65c0e09179
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 15m30s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 15m48s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m32s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
feat(415): per-channel fault detection — server-derived health object + Problems filter (#581)
Closes #415. Server-derived health object on the channel list + detail DTOs (built-timeline detection, kind-agnostic across all 5 PlayoutScheduleKind; assessable gate keyed to the owning channel's mode), single "Problems" SPA filter with per-fault badges. Supersedes #72's api.channel-health-signal decision.

Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-23 20:45:19 +00:00

146 lines
25 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.
# 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 FisherYates 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).