diff --git a/design-system/templates/chicorytv-admin/Channels.jsx b/design-system/templates/chicorytv-admin/Channels.jsx index 0a0193e9e..8bd916d6a 100644 --- a/design-system/templates/chicorytv-admin/Channels.jsx +++ b/design-system/templates/chicorytv-admin/Channels.jsx @@ -31,6 +31,7 @@ let rows = D.channels; if (view === "onair") rows = rows.filter((c) => c.live); else if (view === "disabled") rows = rows.filter((c) => !c.enabled); + else if (view === "problems") rows = rows.filter((c) => c.playouts === 0); // group, preserving first-seen order const groups = []; @@ -43,11 +44,11 @@ const toggleAll = () => { if (allOn) setSel({}); else { const n = {}; rows.forEach((c) => (n[c.num] = true)); setSel(n); } }; const toggleOne = (num) => setSel((s) => ({ ...s, [num]: !s[num] })); - const counts = { all: D.channels.length, onair: D.channels.filter((c) => c.live).length, disabled: D.channels.filter((c) => !c.enabled).length }; + const counts = { all: D.channels.length, onair: D.channels.filter((c) => c.live).length, disabled: D.channels.filter((c) => !c.enabled).length, problems: D.channels.filter((c) => c.playouts === 0).length }; const Segmented = () => (
- {[{ v: "all", l: "All", c: counts.all }, { v: "onair", l: "On air", c: counts.onair }, { v: "disabled", l: "Disabled", c: counts.disabled }].map((o) => { + {[{ v: "all", l: "All", c: counts.all }, { v: "onair", l: "On air", c: counts.onair }, { v: "disabled", l: "Disabled", c: counts.disabled }, { v: "problems", l: "Problems", c: counts.problems }].map((o) => { const on = o.v === view; return (
{c.lang}
diff --git a/design-system/templates/chicorytv-admin/data.js b/design-system/templates/chicorytv-admin/data.js index 248a0f3bb..874e832c7 100644 --- a/design-system/templates/chicorytv-admin/data.js +++ b/design-system/templates/chicorytv-admin/data.js @@ -3,15 +3,15 @@ window.CTV_DATA = { channels: [ - { num: "1.1", name: "Retro Cartoons", group: "Cartoons", lang: "English", mode: "HLS Segmenter", profile: "1080p H.264", live: true, enabled: true, epg: true }, - { num: "1.2", name: "Anime Block", group: "Cartoons", lang: "Japanese",mode: "HLS Segmenter", profile: "1080p H.264", live: false, enabled: true, epg: true }, - { num: "2.1", name: "News 24", group: "News", lang: "English", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true }, - { num: "3.1", name: "Late Night Movies",group: "Movies", lang: "English", mode: "MPEG-TS", profile: "1080p HEVC", live: false, enabled: true, epg: true }, - { num: "7.3", name: "Sci-Fi Marathon", group: "Movies", lang: "English", mode: "MPEG-TS", profile: "1080p H.264", live: false, enabled: true, epg: false }, - { num: "4.2", name: "Music Videos", group: "Music", lang: "English", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true }, - { num: "5.1", name: "Nature Docs", group: "Docs", lang: "Spanish", mode: "HLS Direct", profile: "—", live: false, enabled: true, epg: true }, - { num: "9.1", name: "Kids Block", group: "Kids", lang: "French", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true }, - { num: "12.1", name: "Test Pattern", group: "System", lang: "English", mode: "MPEG-TS", profile: "480p H.264", live: false, enabled: false, epg: false }, + { num: "1.1", name: "Retro Cartoons", group: "Cartoons", lang: "English", mode: "HLS Segmenter", profile: "1080p H.264", live: true, enabled: true, epg: true, playouts: 1 }, + { num: "1.2", name: "Anime Block", group: "Cartoons", lang: "Japanese",mode: "HLS Segmenter", profile: "1080p H.264", live: false, enabled: true, epg: true, playouts: 1 }, + { num: "2.1", name: "News 24", group: "News", lang: "English", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true, playouts: 1 }, + { num: "3.1", name: "Late Night Movies",group: "Movies", lang: "English", mode: "MPEG-TS", profile: "1080p HEVC", live: false, enabled: true, epg: true, playouts: 1 }, + { num: "7.3", name: "Sci-Fi Marathon", group: "Movies", lang: "English", mode: "MPEG-TS", profile: "1080p H.264", live: false, enabled: true, epg: false, playouts: 1 }, + { num: "4.2", name: "Music Videos", group: "Music", lang: "English", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true, playouts: 1 }, + { num: "5.1", name: "Nature Docs", group: "Docs", lang: "Spanish", mode: "HLS Direct", profile: "—", live: false, enabled: true, epg: true, playouts: 0 }, + { num: "9.1", name: "Kids Block", group: "Kids", lang: "French", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true, playouts: 1 }, + { num: "12.1", name: "Test Pattern", group: "System", lang: "English", mode: "MPEG-TS", profile: "480p H.264", live: false, enabled: false, epg: false, playouts: 1 }, ], playoutItems: [ { start: "20:00:00", finish: "20:24:00", title: "Looney Tunes — Rabbit Fire", dur: "00:24:00", kind: "content" }, diff --git a/docs/decisions.md b/docs/decisions.md index 434017e5b..f24c63b5e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -86,6 +86,7 @@ in-file entries. - [2026-07-17 — Clock-boundary schedule padding already exists (FillerMode.Pad); #77 verified, convenience toggle deferred](#2026-07-17--clock-boundary-schedule-padding-already-exists-fillermodepad-77-verified-convenience-toggle-deferred) - [2026-07-17 — Shuffle-source construction extracted to `ShuffleSourceBuilder`; per-family seam, not a god-factory (#380)](#2026-07-17--shuffle-source-construction-extracted-to-shufflesourcebuilder-per-family-seam-not-a-god-factory-380) - [2026-07-17 — Seasonal / date-conditional scheduling already exists (alternate schedules / playout templates); #73 closed as implemented](#2026-07-17--seasonal--date-conditional-scheduling-already-exists-alternate-schedules--playout-templates-73-closed-as-implemented) +- [2026-07-17 — Channel health on the API = the raw `PlayoutCount` fact on the list DTO, not a derived status enum (#72)](#2026-07-17--channel-health-on-the-api--the-raw-playoutcount-fact-on-the-list-dto-not-a-derived-status-enum-72) --- @@ -1198,3 +1199,49 @@ channel" has no reason to look under "Alternate Schedules", and on finding that Fixed with a task-shaped **"Recipe: seasonal / holiday programming"** section in `channels.md` (both engines, plus the gotchas above) and a `domain-model.md` glossary row. No production code changed, so no live-E2E (same reasoning as #77). + +--- + +## 2026-07-17 — Channel health on the API = the raw `PlayoutCount` fact on the list DTO, not a derived status enum (#72) + +#72 asks for per-channel status in the Channels list, "especially channels that will fail to play". + +**Where it lives: `ChannelResponseModel` (the lean list DTO), not a new endpoint and not +`/channels/state`.** Channel health is *config-derived* — it changes when someone edits a playout, not +tick to tick — whereas `/channels/state` is the fast-poll runtime-liveness feed (`OnAir` = someone is +streaming *right now*). Folding health into the polled feed would recompute rarely-changing data every +tick and mix two cadences in one DTO; a third endpoint is over-engineering for one derived integer on a +list whose consumer already reads it. Precedent: `api-conventions.md` §3a stamps the server-derived +`IsLocked` onto `PlayoutListItemResponseModel` for exactly this reason. **It is also free**: `GetAll` +already `Include`s `Playouts` and `MirrorSourceChannel.Playouts` and was discarding them, so no extra +query and no N+1 on a large lineup. + +**A raw fact (`int PlayoutCount`), not a `ChannelHealth` enum.** v1 has exactly one trustworthy negative +signal, and the auto-tune arc (#383/#384) is about to churn the status taxonomy (origin, auto-tune +outcomes) — freezing a server-side enum now guarantees a breaking rev of a frozen-additive `/api/v1` +surface. `PlayoutCount` mirrors the long-standing `ChannelViewModel.PlayoutCount` 1:1, is a fact rather +than a policy, and leaves the SPA to derive `0 ⇒ "No playout"` in one predicate. + +**What v1 deliberately does NOT compute** — each was considered and ruled out, so don't "finish" them +without reading this: +- **Empty schedule behind an existing playout.** `EmptyScheduleHealthCheck` only understands **Classic** + `ProgramSchedule` playouts. Block, Sequential, Scripted and ExternalJson channels have no + `ProgramSchedule` at all, so a badge driven off that query would be silently absent or wrong for four of + the five schedule kinds — the #71 "verify a shared primitive covers ALL variants" trap. Needs a + per-kind emptiness notion first. +- **Broken / missing source.** `FileNotFound`/`Unavailable` are server-wide media-item counts with no + channel attribution; mapping media → collection → schedule → channel is a project, not a field. +- **User-defined vs auto-generated origin** (#72 scope item a). No honest signal exists: + `Channel` has no origin column, and `ChannelPlayoutSource.Generated` is a *playout-strategy* value that + SPA-created blank channels also carry, so it would mislabel them. Requires a new column + a dual-provider + migration, and provenance belongs to the auto-tune arc that stamps it at creation. A join through the + `"Channel Lineups"` system playlist group was **rejected**: it is a heuristic that breaks the moment a + user edits the channel. Deferred to a follow-up blocked on the auto-tune backend. + +Keeping all three out held #72 to a **read-path-only** change: no migration, no write-handler live-E2E. + +**Corrected in passing:** `ChannelRepository.GetChannel` never included `Playouts`, so +`GET /api/v1/channels/{id}` reported `playoutCount: 0` for every channel, which silently disabled the +channel editor's playout-source guard. Both call sites now share `Mapper.GetPlayoutsCount` (Mirror-aware). +The related *silent* server-side coercion of Mirror→Generated (a 200 that discards the caller's intent, +against the §3 "surface it, don't silently filter" rule) is filed as **#401**, not fixed here. diff --git a/docs/domain-model.md b/docs/domain-model.md index 2df09aa38..463b30382 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -79,6 +79,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe | **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) | +| **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 **Problems** filter on the channels list. 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. | `GetChannelGuideHandler` | `/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`) |