Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m33s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m11s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 36m5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
The guide/EPG grid (/app/guide) and the channels list (/app/channels) always drew the generated initials "bug" because the browse DTOs never carried a logo URL — GuideScreen/ChannelsScreen rendered <ChannelLogo> with no src. The logo data existed (it round-trips through the channel editor) but never reached these views. Add a rooted, directly-usable Logo URL to ChannelGuideChannelResponseModel and ChannelResponseModel, populated by a single Channels.Mapper.GetLogoUrl helper (#181 artwork convention): /iptv/logos/{file} for an uploaded logo, the absolute URL passed through for an external one, null when unset so the SPA keeps its generated-initials fallback. The guide query now includes Channel.Artwork. Regenerated OpenAPI + v1.d.ts; updated api-conventions.md + domain-model.md. fixes #464 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
144 lines
21 KiB
Markdown
144 lines
21 KiB
Markdown
# 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). 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).
|