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
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:
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user