feat(415): per-channel fault detection — server-derived health object + Problems filter (#581)
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
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
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>
This commit was merged in pull request #581.
This commit is contained in:
+16
-48
@@ -1568,54 +1568,6 @@ 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)
|
||||
`key: api.channel-health-signal` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Channel health rides `ChannelResponseModel`/`ChannelListItem` DTOs as a raw `int PlayoutCount` fact (free — `GetAll` already `Include`s `Playouts`), not a new endpoint, not `/channels/state` (runtime-liveness cadence), and not a derived `ChannelHealth` enum (would freeze policy before the #383/#384 auto-tune status taxonomy lands).
|
||||
**Signals:** channel health, PlayoutCount, config-derived vs runtime-liveness cadence, frozen /api/v1 · paths: `ChannelRepository.GetChannel`, `Mapper.GetPlayoutsCount`, `api-conventions.md` §3a · issues: #72, #383, #384, #401
|
||||
**Mechanics:** `api-conventions.md` §3a (precedent: `IsLocked` on `PlayoutListItemResponseModel`); `ChannelResponseModel.PlayoutCount`
|
||||
|
||||
#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.
|
||||
|
||||
## 2026-07-17 — Weighted / fair-share distribution is a new `WeightedShuffle` order; `ShuffleInOrder` is anti-clumping, not fair-share (#70)
|
||||
`key: sched.weighted-shuffle` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Fair-share/weighted airtime distribution ships as one new `PlaybackOrder.WeightedShuffle = 9` order (equal weights = fair-share), not a retrofit of `ShuffleInOrder` (which only anti-clumps, since its padding spacers emit nothing) and not a separate orthogonal "distribution" setting; weights live on `MultiCollectionItem`/`MultiCollectionSmartItem` (DB default 1, dual-provider migration), bounded at write (1..1000) and clamped again in the enumerator, and the write path rejects `WeightedShuffle` at every dispatch site that doesn't handle it rather than let it silently degrade to unweighted random.
|
||||
@@ -3666,3 +3618,19 @@ the rule builder exists. This keeps `rulebuilder.relative-date-macros` symmetric
|
||||
`spa.smartcollection-rule-builder`'s "compile-only, no new stored AST" stance (#176): a relative-date rule is
|
||||
just another point in the same closed grammar subset, proven by the same compile→parse round-trip discipline
|
||||
(`dateMacro.test.ts`, and the property test in `roundtrip.test.ts`, #438) rather than a special case.
|
||||
|
||||
## 2026-07-23 — Channel health = a server-derived `health` object on the channel DTOs, built-timeline detection (#415)
|
||||
`key: api.channel-health-object` · `status: active` · `since: 2026-07-23` · `supersedes: api.channel-health-signal@2026-07-17` · `superseded-by: none`
|
||||
**Rule:** `ChannelResponseModel`/`ChannelDetailResponseModel` carry a server-derived `health` object (`ChannelHealthResponseModel { Status, Faults[], PlayoutCount, BrokenSourceItemCount }`) computed **read-time** from the built timeline (`Playout.BuildStatus` + upcoming `PlayoutItem → MediaItem.State`, `Finish >= now`), kind-agnostic across all 5 `PlayoutScheduleKind` values; `Status`/`Faults` are const-string classes (`ChannelHealthStatus`, `ChannelFault`), not C# enums, so the SPA hand-maintains the union (mirrors `ChannelPreviewAvailability`). This supersedes #72's "raw fact only, no derived enum, empty-schedule/broken-source deliberately not computed" stance now that the auto-tune taxonomy churn (#383/#384) it was waiting on has landed (see `channel.origin-marker` sibling record, #414).
|
||||
**Signals:** channel health, ChannelHealthResponseModel, ChannelHealthStatus, ChannelFault, Healthy/Problems/Unknown, NoPlayout/NeverBuilt/BuildFailed/EmptyUpcoming/BrokenSource, built-timeline detection, BuildStatus, PlayoutItem MediaItem.State FileNotFound Unavailable, assessable gate, on-demand suppresses absence signals, Problems rollup filter, willNeverPlay hasProblems · paths: `ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs`, `ChannelRepository.GetAll`, `Mapper.GetHealth`, `GetAllChannelsForApiHandler`, `web/src/screens/ChannelsScreen.tsx`, `api-conventions.md`, `domain-model.md`, `spa-conventions.md` · issues: #415, #72, #71, #383, #384, #414
|
||||
**Mechanics:** `docs/superpowers/specs/2026-07-23-channel-fault-detection-design.md` (full design); `api-conventions.md` (health object shape); `domain-model.md` (channel-health row); `spa-conventions.md` (Problems filter + badge convention)
|
||||
|
||||
#415 was deferred from #72 scope item (b): "empty schedule" and "broken/missing source" faults were real but uncomputed, each explicitly ruled out in the superseded record for a stated reason. This record reverses both rulings now that the blocking condition — the #383/#384 auto-tune status taxonomy churning the DTO shape — has resolved (#414 landed the origin column as a sibling, non-health field).
|
||||
|
||||
**Built-timeline (kind-agnostic) detection, not per-kind config introspection.** Every fault falls out of what the scheduler has already materialized — `Playout.BuildStatus` (`{LastBuild, Success, Message}`) for never-built/build-failed, and `Playout.Items` (the built `PlayoutItem` timeline, each carrying `MediaItemId`/`MediaItem`) for empty-upcoming and broken-source. Because the timeline is the same shape for all five `PlayoutScheduleKind` values (Classic, Block, Sequential, Scripted, ExternalJson), coverage is *by construction* — the #71 "verify a shared primitive covers ALL variants" trap, which the superseded record's own `EmptyScheduleHealthCheck` (Classic-only) fell into, cannot bite here. Scripted, which has no schedule entity to introspect at all, needs no special case. `MediaItem.State` flips on scan (not build), which rules out a build-time snapshot — detection is necessarily read-time, costed as one bounded `GROUP BY PlayoutId` aggregate query (not an N+1) over upcoming `PlayoutItem`s.
|
||||
|
||||
**Five-fault taxonomy, rolled up to one `status`.** `NoPlayout` (0 playouts, the absorbed #72 fact), `NeverBuilt` (assessable playout never built), `BuildFailed` (last build `Success == false`), `EmptyUpcoming` (built OK, 0 upcoming items), `BrokenSource` (≥1 upcoming item pointing at a `FileNotFound`/`Unavailable` `MediaItem`). Rollup: `Problems` if any contributing playout has a fault, `Healthy` if any is assessable-and-clean with none, `Unknown` if nothing is assessable — never a false `Healthy` and never a false `Problems`.
|
||||
|
||||
**The assessable gate distinguishes absence signals from presence signals.** `NeverBuilt`/`EmptyUpcoming` are inferred from *missing* content and are suppressed for `PlayoutMode == OnDemand` (an idle on-demand playout legitimately has no fresh build and drains its timeline between tune-ins — without suppression this is a false-positive storm across every on-demand channel; a suppressed absence signal contributes `Unknown`, not a false `Problems`). `BuildFailed`/`BrokenSource` are proven by content that *is* there and is bad, so they stay live in every `PlayoutMode` — they only fire when the bad thing actually exists and so cannot false-positive on legitimate idleness.
|
||||
|
||||
**Server owns the rollup so SPA and MCP read one verdict.** `health` rides the same `list channels`/`get channel` response both clients already fetch — no second endpoint to correlate by id, and no client re-deriving policy from raw facts (the thing the superseded record explicitly avoided freezing before the taxonomy existed). `PlayoutCount` is retained unchanged on the DTO for backward compatibility (additive-only `/api/v1` freeze); the SPA's "Problems" filter (`web/src/screens/ChannelsScreen.tsx`, `hasProblems`, replacing the old single-fault `willNeverPlay`/"No playout" filter) and per-row badges read `health.status`/`health.faults` instead.
|
||||
|
||||
Reference in New Issue
Block a user