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 e39aff7cb..7d135deec 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -88,6 +88,7 @@ in-file entries.
- [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 — Auto-Tune DetailPanel member list = live search-index roll-up, not EF enumeration (#384)](#2026-07-17--auto-tune-detailpanel-member-list--live-search-index-roll-up-not-ef-enumeration-384)
- [2026-07-17 — No persistent compiler servers in CI; every `services:` container gets an explicit cap; #390's small-lane move reversed (#406)](#2026-07-17--no-persistent-compiler-servers-in-ci-every-services-container-gets-an-explicit-cap-390s-small-lane-move-reversed-406)
+- [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)
---
@@ -1343,3 +1344,46 @@ not size the `small` lane expecting otherwise.
**Not addressed here**: the 12–35 min queue waits (server-management#604) and the redundant
triple-build (#398).
+## 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 6e48bdb14..fbeb70f68 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; per-channel DetailPanel content-source members read via `GET /api/v1/channels/auto-tune/members`, #384) |
+| **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. | `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`) |
diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts
index 377b15581..52ade2f22 100644
--- a/web/src/api/generated/v1.d.ts
+++ b/web/src/api/generated/v1.d.ts
@@ -239,6 +239,7 @@ export interface components {
"streamingMode": string;
"isEnabled": boolean;
"showInEpg": boolean;
+ "playoutCount": number;
};
"ChannelSongVideoMode": "Default" | "WithProgress";
"ChannelStateResponseModel": {
diff --git a/web/src/screens/ChannelsScreen.test.tsx b/web/src/screens/ChannelsScreen.test.tsx
index 4c1568c39..86716f5f9 100644
--- a/web/src/screens/ChannelsScreen.test.tsx
+++ b/web/src/screens/ChannelsScreen.test.tsx
@@ -1,4 +1,4 @@
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChannelsScreen } from './ChannelsScreen';
@@ -16,6 +16,9 @@ function channelRow(overrides: Record = {}): Record {
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/channels/state', expect.any(Object));
});
+ // #72: a channel with no playout can never play. This is the fault state the issue calls
+ // "the important one" — it must be visible without hovering.
+ it('flags a channel with no playout and lets the lineup be filtered down to the faults', async () => {
+ mockApi({
+ channels: [
+ channelRow({ id: 1, name: 'Healthy', number: '1', playoutCount: 1 }),
+ channelRow({ id: 2, name: 'Broken', number: '2', playoutCount: 0 })
+ ]
+ });
+
+ render();
+
+ const table = await screen.findByRole('table', { name: 'Channels lineup' });
+ // Scoped to the table: the filter button carries the same label, and an unscoped getByText
+ // matches both (spa-conventions §6). Visible text, not a title-only affordance.
+ expect(within(table).getByText('No playout')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'No playout 1' })).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'No playout 1' }));
+
+ expect(screen.getByText('Broken')).toBeInTheDocument();
+ expect(screen.queryByText('Healthy')).not.toBeInTheDocument();
+ });
+
+ it('does not flag a mirror channel that relays its source playouts', async () => {
+ // The server folds the mirror source's playouts into playoutCount, so a working mirror reports
+ // a non-zero count and must not be flagged. Guards the mirror half of the API contract.
+ mockApi({
+ channels: [channelRow({ id: 1, name: 'Mirror Of One', number: '9', playoutCount: 1 })]
+ });
+
+ render();
+
+ const table = await screen.findByRole('table', { name: 'Channels lineup' });
+ // Scoped: the filter button always renders this label, so an unscoped query would find it
+ // even when no row is flagged, making the assertion meaningless.
+ expect(within(table).queryByText('No playout')).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'No playout 0' })).toBeInTheDocument();
+ });
+
it('polls only channel state after the initial load', async () => {
const intervalHandlers: Array<() => void> = [];
vi.spyOn(window, 'setInterval').mockImplementation((handler) => {
diff --git a/web/src/screens/ChannelsScreen.tsx b/web/src/screens/ChannelsScreen.tsx
index 1721705b9..817d07b87 100644
--- a/web/src/screens/ChannelsScreen.tsx
+++ b/web/src/screens/ChannelsScreen.tsx
@@ -27,7 +27,21 @@ import {
import { navigateToPath } from '../routing';
import { usePrimaryAction } from '../primaryAction';
-type ChannelViewFilter = 'all' | 'onair' | 'disabled';
+type ChannelViewFilter = 'all' | 'onair' | 'disabled' | 'noplayout';
+
+// #72: a channel with no playout can never play — the dominant "misconfigured lineup" case, and the
+// only per-channel failure the API can answer cheaply today. A mirror channel relays its source's
+// playouts, and the server already folds those into playoutCount, so mirrors are not false-flagged.
+//
+// Deliberately NOT covered: an empty schedule behind an existing playout, and broken/missing sources
+// (see decisions.md 2026-07-17 for why approximating either would be wrong today). Because those
+// fault classes are real but invisible here, the badge AND the filter are both named for the single
+// fault this can actually prove — "No playout", never "Problems". A "Problems 0" reading on a lineup
+// that holds a dead empty-schedule channel is a false all-clear, which is worse than no affordance.
+// Do not broaden the label until the taxonomy behind it exists.
+function willNeverPlay(channel: ChannelSummary): boolean {
+ return channel.playoutCount === 0;
+}
const streamingModeBadgeTones: Record = {
'HLS Direct': 'neutral',
@@ -193,6 +207,7 @@ export function ChannelsScreen() {
const statesById = stateByChannelId(query.data.channelStates);
const onAirCount = query.data.channelStates.filter((state) => state.onAir).length;
const disabledCount = channels.filter((channel) => !channel.isEnabled).length;
+ const noPlayoutCount = channels.filter(willNeverPlay).length;
const visibleChannels = channels.filter((channel) => {
if (filter === 'onair') {
return statesById.get(channel.id)?.onAir === true;
@@ -202,6 +217,10 @@ export function ChannelsScreen() {
return !channel.isEnabled;
}
+ if (filter === 'noplayout') {
+ return willNeverPlay(channel);
+ }
+
return true;
});
const groups = groupedChannels(visibleChannels);
@@ -396,6 +415,7 @@ export function ChannelsScreen() {
+
{onAirCount} on air
@@ -539,6 +559,12 @@ function ChannelTableRow({
{!channel.isEnabled && D}
{!channel.showInEpg && H}
+ {/*
+ Spelled out rather than compressed to a letter marker like D/H: those flag states the
+ user chose deliberately, whereas this one is a fault they need to notice and act on.
+ A tooltip-only glyph would be invisible on touch and easy to miss in a long lineup.
+ */}
+ {willNeverPlay(channel) && No playout}
{channel.language || 'Language unset'}