fix(415): key on-demand suppression to owning channel (mirror-of-on-demand) + review cleanups
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m53s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

This commit is contained in:
2026-07-23 22:05:09 +02:00
parent 2ed1698bb2
commit 6c90e7caf7
8 changed files with 84 additions and 17 deletions
+14 -5
View File
@@ -46,10 +46,11 @@ internal static class Mapper
var faults = new System.Collections.Generic.HashSet<string>();
var brokenSourceItemCount = 0;
var sawAssessable = false;
var isOnDemand = channel.PlayoutMode == ChannelPlayoutMode.OnDemand;
foreach (Playout playout in ContributingPlayouts(channel))
foreach ((Playout playout, ChannelPlayoutMode ownerMode) in ContributingPlayoutsWithOwnerMode(channel))
{
bool isOnDemand = ownerMode == ChannelPlayoutMode.OnDemand;
upcoming.TryGetValue(playout.Id, out PlayoutUpcoming u);
brokenSourceItemCount += u.BrokenUpcoming;
@@ -101,13 +102,21 @@ internal static class Mapper
brokenSourceItemCount);
}
internal static IEnumerable<Playout> ContributingPlayouts(Channel channel)
internal static IEnumerable<Playout> ContributingPlayouts(Channel channel) =>
ContributingPlayoutsWithOwnerMode(channel).Select(x => x.Playout);
// Mirror channels are forced Continuous (UpdateChannelHandler), but a mirror of an on-demand SOURCE relays
// playouts that legitimately drain between tune-ins. Absence-signal suppression must key off the mode of the
// channel that OWNS each playout, not the mirror's own (always-Continuous) mode — so pair each playout with
// its owner's mode here, once, rather than re-deriving it at each call site.
private static IEnumerable<(Playout Playout, ChannelPlayoutMode OwnerMode)> ContributingPlayoutsWithOwnerMode(
Channel channel)
{
if (channel.Playouts is not null)
{
foreach (Playout p in channel.Playouts)
{
yield return p;
yield return (p, channel.PlayoutMode);
}
}
@@ -115,7 +124,7 @@ internal static class Mapper
{
foreach (Playout p in channel.MirrorSourceChannel.Playouts)
{
yield return p;
yield return (p, channel.MirrorSourceChannel.PlayoutMode);
}
}
}
@@ -40,7 +40,7 @@ public record ChannelDetailResponseModel(
bool IsEnabled,
bool ShowInEpg,
int[] GraphicsElementIds,
// Server-derived health rollup (api.channel-health-signal). See ChannelHealthResponseModel.
// Server-derived health rollup (api.channel-health-object). See ChannelHealthResponseModel.
ChannelHealthResponseModel Health);
// Wire-compatible mirror of the Application-layer ArtworkContentTypeModel (which lives in
@@ -2,7 +2,7 @@
namespace ErsatzTV.Core.Api.Channels;
// Server-derived per-channel health. The SPA and MCP both read Status/Faults rather than deriving a
// verdict themselves (the api.channel-health-signal decision, superseding #72's raw-fact-only stance).
// verdict themselves (the api.channel-health-object decision, superseding #72's raw-fact-only stance).
// Faults are plain strings (see ChannelFault) not a C# enum, to keep the OpenAPI schema simple — the
// SPA hand-maintains its own union, matching the ChannelPreviewAvailability pattern.
public record ChannelHealthResponseModel(
@@ -26,5 +26,5 @@ public record ChannelResponseModel(
// Immutable creation-provenance (auto-tuned vs user-created). Raw fact; the SPA decides how to render it.
// Unknown for rows created before the origin column existed (never back-filled).
ChannelOrigin Origin,
// Server-derived health rollup (api.channel-health-signal). See ChannelHealthResponseModel.
// Server-derived health rollup (api.channel-health-object). See ChannelHealthResponseModel.
ChannelHealthResponseModel Health);
@@ -152,4 +152,61 @@ public class ChannelHealthTests
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 0, 0)));
health.Faults.ShouldContain(ChannelFault.EmptyUpcoming);
}
// Mirror channels are forced Continuous (UpdateChannelHandler), but a mirror of an on-demand SOURCE relays
// playouts that legitimately drain between tune-ins. Absence-signal suppression must key off the OWNING
// channel's mode, not the mirror's own (always-Continuous) mode — otherwise this false-flags Problems.
[Test]
public void Mirror_Of_OnDemand_Source_NeverBuilt_Is_Unknown_Not_Problems()
{
Channel channel = new(Guid.NewGuid())
{
Id = 1,
Number = "1",
Name = "Mirror",
PlayoutSource = ChannelPlayoutSource.Mirror,
PlayoutMode = ChannelPlayoutMode.Continuous,
MirrorSourceChannel = new Channel(Guid.NewGuid())
{
Id = 2,
Number = "2",
Name = "Source",
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.OnDemand,
Playouts = [NeverBuiltPlayout(10)]
}
};
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming());
health.Status.ShouldBe(ChannelHealthStatus.Unknown);
health.Faults.ShouldBeEmpty();
}
[Test]
public void Mirror_Of_OnDemand_Source_With_Broken_Upcoming_Is_Problems()
{
Channel channel = new(Guid.NewGuid())
{
Id = 1,
Number = "1",
Name = "Mirror",
PlayoutSource = ChannelPlayoutSource.Mirror,
PlayoutMode = ChannelPlayoutMode.Continuous,
MirrorSourceChannel = new Channel(Guid.NewGuid())
{
Id = 2,
Number = "2",
Name = "Source",
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.OnDemand,
Playouts = [BuiltPlayout(10)]
}
};
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 4, 2)));
health.Status.ShouldBe(ChannelHealthStatus.Problems);
health.Faults.ShouldContain(ChannelFault.BrokenSource);
}
}
+1 -1
View File
@@ -3622,7 +3622,7 @@ just another point in the same closed grammar subset, proven by the same compile
## 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`, `GetAllChannelsHandler`, `web/src/screens/ChannelsScreen.tsx`, `api-conventions.md`, `domain-model.md`, `spa-conventions.md` · issues: #415, #72, #71, #383, #384, #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).
+8 -7
View File
@@ -575,13 +575,14 @@ list filter/count off the rollup `status`, never a filter per individual fault:
`Never built` / `Build failed` / `Empty` / `Broken source`) rather than a generic "Problems" chip —
the rollup unifies the *filter*, not the *detail* a user reads on a given row. `Unknown` renders no
badge (it is deliberately not `Healthy` and not `Problems`).
- **Nullable Core DTO, coerce once at the read boundary.** `ChannelResponseModel`/
`ChannelDetailResponseModel` live in `ErsatzTV.Core` (Nullable-disabled for that TFM — see
`core-dtos-generate-nullable-in-spa`), so the generated `ChannelSummary['health']` types as
`{status, faults, playoutCount, brokenSourceItemCount} | null | undefined`. Always read it through
optional chaining with an explicit fallback at the point of use (`channel.health?.status`,
`channel.health?.faults ?? []`) rather than asserting it non-null — an absent `health` (e.g. an older
cached response) must read as "no verdict", never crash or silently read as `Healthy`.
- **Defensive coercion at the read boundary, even though the generated type is non-null.** `health` is in
the OpenAPI `required` list, so the generated `ChannelSummary['health']` types as
`{status, faults, playoutCount, brokenSourceItemCount}` — not nullable (contrast
`core-dtos-generate-nullable-in-spa`, which covers *other* Core DTO fields that do generate
`| null | undefined`). Still always read it through optional chaining with an explicit fallback at the
point of use (`channel.health?.status`, `channel.health?.faults ?? []`) rather than asserting it
non-null — a genuinely absent `health` (e.g. an older cached response predating this field) must read
as "no verdict", never crash or silently read as `Healthy`.
- **Status/fault string unions are hand-maintained**, matching `ChannelPreviewAvailability`: the server
emits plain strings backed by a `const string` class (`ChannelHealthStatus`, `ChannelFault`), not a
generated enum, so the SPA's local TS union (`'Healthy' | 'Problems' | 'Unknown'`, and the fault
+1 -1
View File
@@ -36,7 +36,7 @@ type ChannelViewFilter = 'all' | 'onair' | 'disabled' | 'problems';
// #415: per-channel fault taxonomy derived server-side from the built timeline (health.status).
// The SPA reads the verdict; it no longer derives eligibility from raw playoutCount. Each row's badge
// still spells out the specific fault (below); the filter rolls them up under "Problems".
// Design: docs/superpowers/specs/2026-07-23-channel-fault-detection-design.md; decision: api.channel-health-signal.
// Design: docs/superpowers/specs/2026-07-23-channel-fault-detection-design.md; decision: api.channel-health-object.
function hasProblems(channel: ChannelSummary): boolean {
return channel.health?.status === 'Problems';
}