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>
25 KiB
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 Templates 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 Blocks; each Block is an ordered list of BlockItems (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 Decos, 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 Collections and/or SmartCollections (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 PlaylistItems; 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 LibraryPaths (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 SmartCollections 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 PlayoutItems 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/Durationvariants),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).