# Per-channel fault detection (#415) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Surface four more per-channel fault classes (never-built, build-failed, empty-upcoming, broken-source) beyond today's "no playout", on the channel API resource and as a single "Problems" filter in the SPA. **Architecture:** Detect every fault from the *built* timeline (`Playout.BuildStatus` + upcoming `PlayoutItem`s joined to `MediaItem.State`) rather than per-kind config, so all five `PlayoutScheduleKind` values are covered by construction and broken-source attribution is exact. A server-derived `health` object is folded onto `ChannelResponseModel`/`ChannelDetailResponseModel` so SPA and MCP read one authoritative verdict. **Tech Stack:** C# (.NET, LanguageExt, EF Core, MediatR-style handlers), NUnit + Shouldly + NSubstitute; React + TypeScript SPA, Vitest + @testing-library/react; OpenAPI-generated TS types. ## Global Constraints - Full design + rationale: `docs/superpowers/specs/2026-07-23-channel-fault-detection-design.md`. Read it first. - "Upcoming" window is `PlayoutItem.Finish >= nowUtc` (current + future built items); no arbitrary N-hour horizon. - Enum-like API fields are **const-string classes** (mirror `ChannelPreviewAvailability`), NOT C# enums — keeps OpenAPI simple; the SPA hand-maintains its union. - New API fields are **additive trailing params** on the existing positional records — never reorder existing params. `/api/v1` is additive-only after freeze. - Assessable gate: `isOnDemand = channel.PlayoutMode == ChannelPlayoutMode.OnDemand`. On-demand suppresses `neverBuilt`+`emptyUpcoming` (absence signals); `buildFailed`+`brokenSource` (presence signals) always apply. - `MediaItemState` bad values: `FileNotFound` (1), `Unavailable` (2). - Backend tests: NUnit `[TestFixture]`/`[Test]`, Shouldly, NSubstitute. **Channel-health tests live in `ErsatzTV.Tests/Application/Channels/`** (namespace `ErsatzTV.Tests.Application.Channels`) — that project has `InternalsVisibleTo` access to `ErsatzTV.Application` (needed for `internal GetHealth`); `ErsatzTV.Core.Tests` does NOT. Real-DB tests use the **`InMemoryTvContext`** harness (`ErsatzTV.Tests/Support/InMemoryTvContext.cs`): real `:memory:` SQLite with FKs OFF (seed partial graphs), exposing `_db.Factory` (`IDbContextFactory`) and `_db.CreateContext()`. Reusable seed helpers in `ErsatzTV.Tests/Support/ChannelHandlerTestBase.cs` (`SeedChannel`, `SeedPlayout`, `SeedFFmpegProfile`). Mirror `ErsatzTV.Tests/Application/Channels/GetChannelStatesForApiHandlerTests.cs` for the setup/teardown/seed pattern. `Channel` has NO parameterless ctor — always `new Channel(Guid.NewGuid()) { ... }`. - SPA tests: Vitest + testing-library, mock `window.fetch`; fixtures use runtime camelCase. - PR routine for API changes: build `ErsatzTV` project → `./scripts/update-openapi.sh` → `cd web && npm run generate:api`. BOM-check touched `.cs` + format gate under `bash -c` before push. Rebase (never merge) on `origin/main`; regenerate generated artifacts after any conflicted rebase. - Independent review is mandatory (API write-adjacent + >150 C# lines likely). Live-E2E before push via `scripts/e2e-local.sh` (fresh config dir, curl endpoints). - Never set `ETV_UPDATE_GOLDENS`. Commit with `-c core.hooksPath=/dev/null` + `--no-verify` in the worktree; verify format manually. --- ### Task 1: Health DTO, constants, and the pure `GetHealth` derivation The core of the feature: a pure function mapping a `Channel` (+ its playout-upcoming aggregate) to a `ChannelHealthResponseModel`. Fully unit-testable with domain objects, no DB. **Files:** - Create: `ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs` - Modify: `ErsatzTV.Application/Channels/Mapper.cs` (add `GetHealth` + a small aggregate type) - Test: `ErsatzTV.Tests/Application/Channels/ChannelHealthTests.cs` (new; namespace `ErsatzTV.Tests.Application.Channels`) **Interfaces:** - Produces: - `record ChannelHealthResponseModel(string Status, string[] Faults, int PlayoutCount, int BrokenSourceItemCount)` (in `ErsatzTV.Core/Api/Channels/`) - `static class ChannelHealthStatus { const string Healthy="Healthy", Problems="Problems", Unknown="Unknown"; }` - `static class ChannelFault { const string NoPlayout="NoPlayout", NeverBuilt="NeverBuilt", BuildFailed="BuildFailed", EmptyUpcoming="EmptyUpcoming", BrokenSource="BrokenSource"; }` - `readonly record struct PlayoutUpcoming(int TotalUpcoming, int BrokenUpcoming)` — **define in Core** (`ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs`), NOT Application, so the Task 2 repository interface (Core) can use it. This supersedes any "in Application" mention below. - `internal static ChannelHealthResponseModel GetHealth(Channel channel, int playoutCount, IReadOnlyDictionary upcoming)` — `internal` is visible to `ErsatzTV.Tests` via existing `InternalsVisibleTo`. - [ ] **Step 1: Create the DTO + constant classes** Create `ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs`: ```csharp #nullable enable 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). // 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( // One of ChannelHealthStatus's values. string Status, // The specific fault classes that fired (each a ChannelFault value); empty when Healthy/Unknown. string[] Faults, // Retained #72 fact: total playouts (mirror-aware). int PlayoutCount, // Count of upcoming built items pointing at a FileNotFound/Unavailable MediaItem; 0 when none. int BrokenSourceItemCount); public static class ChannelHealthStatus { public const string Healthy = "Healthy"; public const string Problems = "Problems"; public const string Unknown = "Unknown"; } public static class ChannelFault { public const string NoPlayout = "NoPlayout"; public const string NeverBuilt = "NeverBuilt"; public const string BuildFailed = "BuildFailed"; public const string EmptyUpcoming = "EmptyUpcoming"; public const string BrokenSource = "BrokenSource"; } // Per-playout upcoming-item aggregate (Task 2's repository query fills this). Lives in Core so both // the repository interface (Core) and Mapper (Application) can reference it. public readonly record struct PlayoutUpcoming(int TotalUpcoming, int BrokenUpcoming); ``` - [ ] **Step 2: Write the failing derivation tests** Create `ErsatzTV.Tests/Application/Channels/ChannelHealthTests.cs`. `GetHealth` is `internal` in `ErsatzTV.Application`; `ErsatzTV.Tests` already has `InternalsVisibleTo` access, so `using static ErsatzTV.Application.Channels.Mapper;` works. `PlayoutUpcoming` and the DTO live in `ErsatzTV.Core.Api.Channels`. This is a PURE test — no DB, build domain objects directly (`Channel` needs the `Guid` ctor). ```csharp using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Domain; using NUnit.Framework; using Shouldly; using static ErsatzTV.Application.Channels.Mapper; namespace ErsatzTV.Tests.Application.Channels; [TestFixture] public class ChannelHealthTests { private static Channel ChannelWith( ChannelPlayoutMode mode, params Playout[] playouts) => new(Guid.NewGuid()) { Id = 1, Number = "1", Name = "Test", PlayoutSource = ChannelPlayoutSource.Generated, PlayoutMode = mode, Playouts = playouts.ToList() }; private static Playout BuiltPlayout( int id, PlayoutScheduleKind kind = PlayoutScheduleKind.Classic, bool success = true) => new() { Id = id, ScheduleKind = kind, BuildStatus = new PlayoutBuildStatus { PlayoutId = id, LastBuild = new DateTimeOffset(2026, 7, 23, 12, 0, 0, TimeSpan.Zero), Success = success } }; private static Playout NeverBuiltPlayout(int id) => new() { Id = id, ScheduleKind = PlayoutScheduleKind.Classic, BuildStatus = null }; private static IReadOnlyDictionary Upcoming( params (int playoutId, int total, int broken)[] rows) => rows.ToDictionary(r => r.playoutId, r => new PlayoutUpcoming(r.total, r.broken)); [Test] public void No_Playout_Is_Problems_With_NoPlayout_Fault() { Channel channel = ChannelWith(ChannelPlayoutMode.Continuous); ChannelHealthResponseModel health = GetHealth(channel, 0, Upcoming()); health.Status.ShouldBe(ChannelHealthStatus.Problems); health.Faults.ShouldBe([ChannelFault.NoPlayout]); } [Test] public void Continuous_Never_Built_Is_Problems() { Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, NeverBuiltPlayout(10)); ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming()); health.Status.ShouldBe(ChannelHealthStatus.Problems); health.Faults.ShouldContain(ChannelFault.NeverBuilt); } [Test] public void Build_Failed_Is_Problems_In_Any_Mode() { Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, BuiltPlayout(10, success: false)); ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 5, 0))); health.Status.ShouldBe(ChannelHealthStatus.Problems); health.Faults.ShouldContain(ChannelFault.BuildFailed); } [Test] public void Built_But_Zero_Upcoming_Is_EmptyUpcoming_When_Continuous() { Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10)); ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 0, 0))); health.Status.ShouldBe(ChannelHealthStatus.Problems); health.Faults.ShouldContain(ChannelFault.EmptyUpcoming); } [Test] public void Broken_Upcoming_Item_Is_BrokenSource_With_Count() { Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10)); ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 8, 3))); health.Status.ShouldBe(ChannelHealthStatus.Problems); health.Faults.ShouldContain(ChannelFault.BrokenSource); health.BrokenSourceItemCount.ShouldBe(3); } [Test] public void Built_NonEmpty_Clean_Is_Healthy() { Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10)); ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 12, 0))); health.Status.ShouldBe(ChannelHealthStatus.Healthy); health.Faults.ShouldBeEmpty(); } [Test] public void OnDemand_Never_Built_Is_Unknown_Not_Problems() { Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, NeverBuiltPlayout(10)); ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming()); health.Status.ShouldBe(ChannelHealthStatus.Unknown); health.Faults.ShouldBeEmpty(); } [Test] public void OnDemand_Empty_Upcoming_Is_Unknown_Not_Problems() { Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, BuiltPlayout(10)); ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 0, 0))); health.Status.ShouldBe(ChannelHealthStatus.Unknown); } [Test] public void OnDemand_With_Broken_Upcoming_Is_Problems() { Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, BuiltPlayout(10)); ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 4, 2))); health.Status.ShouldBe(ChannelHealthStatus.Problems); health.Faults.ShouldContain(ChannelFault.BrokenSource); } [Test] public void Multi_Playout_Unions_Faults_And_Problems_Wins() { Channel channel = ChannelWith( ChannelPlayoutMode.Continuous, BuiltPlayout(10), BuiltPlayout(11)); // playout 10 healthy, playout 11 empty -> union has EmptyUpcoming, status Problems ChannelHealthResponseModel health = GetHealth(channel, 2, Upcoming((10, 5, 0), (11, 0, 0))); health.Status.ShouldBe(ChannelHealthStatus.Problems); health.Faults.ShouldContain(ChannelFault.EmptyUpcoming); } // #71 guard: the seam is kind-agnostic — an empty upcoming window flags EmptyUpcoming identically // regardless of PlayoutScheduleKind (the four non-Classic kinds have no ProgramSchedule). [TestCase(PlayoutScheduleKind.Classic)] [TestCase(PlayoutScheduleKind.Block)] [TestCase(PlayoutScheduleKind.Sequential)] [TestCase(PlayoutScheduleKind.Scripted)] [TestCase(PlayoutScheduleKind.ExternalJson)] public void Empty_Upcoming_Is_Detected_For_Every_Schedule_Kind(PlayoutScheduleKind kind) { Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10, kind)); ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 0, 0))); health.Faults.ShouldContain(ChannelFault.EmptyUpcoming); } } ``` - [ ] **Step 3: Run the tests, verify they fail** Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ChannelHealthTests` Expected: FAIL to compile (`GetHealth` / `PlayoutUpcoming` undefined). - [ ] **Step 4: Implement `PlayoutUpcoming` + `GetHealth` in Mapper.cs** Add to `ErsatzTV.Application/Channels/Mapper.cs` the derivation method (the `PlayoutUpcoming` struct is already in Core from Step 1 — add `using ErsatzTV.Core.Api.Channels;` to Mapper.cs if absent). Iterate the channel's contributing playouts (own + mirror source's, matching `GetPlayoutsCount`), classify each, union faults, roll up status. ```csharp internal static ChannelHealthResponseModel GetHealth( Channel channel, int playoutCount, IReadOnlyDictionary upcoming) { if (playoutCount == 0) { return new ChannelHealthResponseModel( ChannelHealthStatus.Problems, [ChannelFault.NoPlayout], 0, 0); } var faults = new HashSet(); var brokenSourceItemCount = 0; var sawAssessable = false; var isOnDemand = channel.PlayoutMode == ChannelPlayoutMode.OnDemand; foreach (Playout playout in ContributingPlayouts(channel)) { upcoming.TryGetValue(playout.Id, out PlayoutUpcoming u); brokenSourceItemCount += u.BrokenUpcoming; bool built = playout.BuildStatus is not null && playout.BuildStatus.LastBuild != default; // Presence signals — always live. if (built && playout.BuildStatus.Success == false) { faults.Add(ChannelFault.BuildFailed); } if (u.BrokenUpcoming > 0) { faults.Add(ChannelFault.BrokenSource); } // Absence signals — suppressed for on-demand (drains between tune-ins). if (!isOnDemand) { if (!built) { faults.Add(ChannelFault.NeverBuilt); } else if (u.TotalUpcoming == 0) { faults.Add(ChannelFault.EmptyUpcoming); } else { sawAssessable = true; } } else if (built && u.TotalUpcoming > 0) { sawAssessable = true; } } string status = faults.Count > 0 ? ChannelHealthStatus.Problems : sawAssessable ? ChannelHealthStatus.Healthy : ChannelHealthStatus.Unknown; return new ChannelHealthResponseModel( status, faults.ToArray(), playoutCount, brokenSourceItemCount); } private static IEnumerable ContributingPlayouts(Channel channel) { if (channel.Playouts is not null) { foreach (Playout p in channel.Playouts) { yield return p; } } if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts is not null) { foreach (Playout p in channel.MirrorSourceChannel.Playouts) { yield return p; } } } ``` Note: `Faults.ShouldBe([ChannelFault.NoPlayout])` in the NoPlayout test relies on order; every other test uses `ShouldContain`. `HashSet` iteration order is insertion-ish but not guaranteed — the NoPlayout case returns a hardcoded single-element array so that test is stable. If any multi-fault test asserts exact array equality, sort `faults.ToArray()` or use `ShouldContain`; the tests above only use `ShouldContain` for multi-fault cases. - [ ] **Step 5: Run the tests, verify they pass** Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ChannelHealthTests` Expected: PASS (all 15+ cases). - [ ] **Step 6: Commit** ```bash git add ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs ErsatzTV.Application/Channels/Mapper.cs ErsatzTV.Core.Tests/Channels/ChannelHealthTests.cs git -c core.hooksPath=/dev/null commit --no-verify -m "feat(415): pure channel-health derivation + health DTO" ``` --- ### Task 2: Repository upcoming-item aggregate + BuildStatus includes Add the one bounded aggregate query (`GROUP BY PlayoutId` over upcoming `PlayoutItem`s with broken-source counts) and include `BuildStatus` on the channel reads. **Files:** - Modify: `ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs` - Modify: `ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs` - Test: `ErsatzTV.Tests/Application/Channels/ChannelRepositoryHealthTests.cs` (new; namespace `ErsatzTV.Tests.Application.Channels`; uses `InMemoryTvContext`). **Interfaces:** - Consumes: `PlayoutUpcoming` (Task 1, in Core). - Produces on `IChannelRepository`: `Task> GetPlayoutUpcomingHealth(IReadOnlyCollection playoutIds, DateTime nowUtc, CancellationToken cancellationToken)` - [ ] **Step 1: Add the interface method** `PlayoutUpcoming` already lives in `ErsatzTV.Core/Api/Channels/` (Task 1), so the Core interface can use it directly. In `ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs` add `using ErsatzTV.Core.Api.Channels;` and the method: ```csharp Task> GetPlayoutUpcomingHealth( IReadOnlyCollection playoutIds, DateTime nowUtc, CancellationToken cancellationToken); ``` - [ ] **Step 2: Write the failing repository test (InMemoryTvContext)** Create the test using the `InMemoryTvContext` harness (real `:memory:` SQLite, FKs OFF — seed partial graphs). Mirror the `[SetUp]`/`[TearDown]` pattern from `GetChannelStatesForApiHandlerTests`. Seed two playouts + a `Movie` (a `MediaItem` subtype) with a set `State`, and `PlayoutItem`s. `PlayoutItem.Finish` is a `DateTime` (UTC); use a fixed `now`. ```csharp using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Domain; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data.Repositories; using ErsatzTV.Tests.Support; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Application.Channels; [TestFixture] public class ChannelRepositoryHealthTests { private static readonly DateTime Now = new(2026, 7, 23, 12, 0, 0, DateTimeKind.Utc); private InMemoryTvContext _db = null!; [SetUp] public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); [TearDown] public async Task TearDown() => await _db.DisposeAsync(); [Test] public async Task GetPlayoutUpcomingHealth_Counts_Upcoming_And_Broken() { await using (TvContext context = _db.CreateContext()) { var normal = new Movie { Id = 1, State = MediaItemState.Normal }; var missing = new Movie { Id = 2, State = MediaItemState.FileNotFound }; var playout1 = new Playout { Id = 10, ChannelId = 1, Items = [] }; var playout2 = new Playout { Id = 11, ChannelId = 1, Items = [] }; context.Movies.AddRange(normal, missing); context.Playouts.AddRange(playout1, playout2); context.PlayoutItems.AddRange( new PlayoutItem { Id = 100, PlayoutId = 10, MediaItemId = 1, Start = Now, Finish = Now.AddHours(1), ChapterTitle = "" }, new PlayoutItem { Id = 101, PlayoutId = 10, MediaItemId = 2, Start = Now.AddHours(1), Finish = Now.AddHours(2), ChapterTitle = "" }, new PlayoutItem { Id = 102, PlayoutId = 10, MediaItemId = 1, Start = Now.AddHours(-2), Finish = Now.AddHours(-1), ChapterTitle = "" }, // past — excluded new PlayoutItem { Id = 103, PlayoutId = 11, MediaItemId = 1, Start = Now.AddHours(-3), Finish = Now.AddHours(-2), ChapterTitle = "" }); // playout2 all past await context.SaveChangesAsync(); } var repo = new ChannelRepository(_db.Factory); Dictionary result = await repo.GetPlayoutUpcomingHealth([10, 11], Now, CancellationToken.None); result[10].TotalUpcoming.ShouldBe(2); result[10].BrokenUpcoming.ShouldBe(1); result.ContainsKey(11).ShouldBeFalse(); // no upcoming rows -> absent from dict } } ``` - [ ] **Step 3: Run it, verify it fails** Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ChannelRepositoryHealthTests` Expected: FAIL to compile (`GetPlayoutUpcomingHealth` undefined). - [ ] **Step 4: Implement the aggregate query + includes** In `ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs`: Add `.ThenInclude(p => p.BuildStatus)` to `GetAll` and `GetChannel` (both the direct `Playouts` and the `MirrorSourceChannel.Playouts` chains): ```csharp // GetAll — after .Include(c => c.Playouts): .Include(c => c.Playouts).ThenInclude(p => p.BuildStatus) .Include(c => c.MirrorSourceChannel).ThenInclude(mc => mc.Playouts).ThenInclude(p => p.BuildStatus) ``` (Keep the existing includes; add the `BuildStatus` ThenIncludes. Verify the exact fluent shape compiles — EF allows repeating `.Include(c => c.Playouts).ThenInclude(...)`.) Add the aggregate method: ```csharp public async Task> GetPlayoutUpcomingHealth( IReadOnlyCollection playoutIds, DateTime nowUtc, CancellationToken cancellationToken) { if (playoutIds.Count == 0) { return new Dictionary(); } await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); return await dbContext.PlayoutItems .AsNoTracking() .Where(pi => playoutIds.Contains(pi.PlayoutId) && pi.Finish >= nowUtc) .GroupBy(pi => pi.PlayoutId) .Select(g => new { PlayoutId = g.Key, Total = g.Count(), Broken = g.Count(pi => pi.MediaItem.State == MediaItemState.FileNotFound || pi.MediaItem.State == MediaItemState.Unavailable) }) .ToDictionaryAsync( x => x.PlayoutId, x => new PlayoutUpcoming(x.Total, x.Broken), cancellationToken); } ``` Add `using ErsatzTV.Core.Api.Channels;` for `PlayoutUpcoming` if not already present. - [ ] **Step 5: Run it, verify it passes** Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ChannelRepositoryHealthTests` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs ErsatzTV.Application/Channels/Mapper.cs git -c core.hooksPath=/dev/null commit --no-verify -m "feat(415): upcoming-item aggregate query + BuildStatus includes" ``` --- ### Task 3: Wire the aggregate through the mappers and handlers Thread the aggregate into `ProjectToResponseModel`/`ProjectToDetailResponseModel` and populate `Health` on both DTOs from the two API handlers. **Files:** - Modify: `ErsatzTV.Application/Channels/Mapper.cs` (signatures of `ProjectToResponseModel`, `ProjectToDetailResponseModel`) - Modify: `ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs`, `ChannelDetailResponseModel.cs` (add trailing `Health`) - Modify: `ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs`, `GetChannelByIdForApiHandler.cs` - Test: `ErsatzTV.Tests/Application/Channels/GetAllChannelsForApiHandlerHealthTests.cs` (new; NSubstitute mock of `IChannelRepository` — this task tests handler wiring in isolation; Task 2 already covers the real query) **Interfaces:** - Consumes: `IChannelRepository.GetPlayoutUpcomingHealth` (Task 2), `Mapper.GetHealth` (Task 1). - Produces: `ChannelResponseModel.Health`, `ChannelDetailResponseModel.Health` populated. - [ ] **Step 1: Add `Health` as a trailing param on both DTOs** `ChannelResponseModel.cs` — append after `ChannelOrigin Origin`: ```csharp ChannelOrigin Origin, // Server-derived health rollup (api.channel-health-signal). See ChannelHealthResponseModel. ChannelHealthResponseModel Health); ``` `ChannelDetailResponseModel.cs` — append after `int[] GraphicsElementIds`: ```csharp int[] GraphicsElementIds, ChannelHealthResponseModel Health); ``` - [ ] **Step 2: Update mapper signatures to accept the aggregate and set Health** Change `ProjectToResponseModel` and `ProjectToDetailResponseModel` to take `IReadOnlyDictionary upcoming` and pass `GetHealth(channel, playoutCount, upcoming)` as the trailing arg: ```csharp internal static ChannelResponseModel ProjectToResponseModel( Channel channel, int playoutCount, IReadOnlyDictionary upcoming) => new( // ... existing args unchanged, up to and including ... channel.Origin, GetHealth(channel, playoutCount, upcoming)); ``` ```csharp internal static ChannelDetailResponseModel ProjectToDetailResponseModel( Channel channel, int playoutCount, IReadOnlyDictionary upcoming) { // ... existing body ... return new ChannelDetailResponseModel( // ... existing args unchanged, up to and including GraphicsElementIds arg ... channel.ChannelGraphicsElements?.Map(x => x.GraphicsElementId).ToArray() ?? [], GetHealth(channel, playoutCount, upcoming)); } ``` - [ ] **Step 3: Write the failing handler test (Style-A)** `ErsatzTV.Core.Tests/Channels/GetAllChannelsForApiHandlerHealthTests.cs`: ```csharp using ErsatzTV.Application.Channels; using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using NSubstitute; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Application.Channels; [TestFixture] public class GetAllChannelsForApiHandlerHealthTests { [Test] public async Task Populates_Health_From_Aggregate() { var channel = new Channel(Guid.NewGuid()) { Id = 1, Number = "1", Name = "News", Group = "ETV", IsEnabled = true, StreamingMode = StreamingMode.HttpLiveStreamingDirect, PlayoutSource = ChannelPlayoutSource.Generated, PlayoutMode = ChannelPlayoutMode.Continuous, Artwork = [], FFmpegProfile = new FFmpegProfile { Name = "P" }, Playouts = [ new Playout { Id = 10, BuildStatus = new PlayoutBuildStatus { PlayoutId = 10, Success = true, LastBuild = new DateTimeOffset(2026, 7, 23, 0, 0, 0, TimeSpan.Zero) } } ] }; var repo = Substitute.For(); repo.GetAll(Arg.Any()).Returns([channel]); repo.GetPlayoutUpcomingHealth(Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(new Dictionary { [10] = new(6, 2) }); var handler = new GetAllChannelsForApiHandler(repo); List result = await handler.Handle(new GetAllChannelsForApi(), CancellationToken.None); result[0].Health.Status.ShouldBe(ChannelHealthStatus.Problems); result[0].Health.Faults.ShouldContain(ChannelFault.BrokenSource); result[0].Health.BrokenSourceItemCount.ShouldBe(2); } } ``` - [ ] **Step 4: Run it, verify it fails** Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~GetAllChannelsForApiHandlerHealthTests` Expected: FAIL (handler still calls 2-arg mapper; won't compile). - [ ] **Step 5: Update both handlers to fetch + pass the aggregate** `GetAllChannelsForApiHandler.cs`: ```csharp public async Task> Handle( GetAllChannelsForApi request, CancellationToken cancellationToken) { List channels = await channelRepository.GetAll(cancellationToken); var playoutIds = channels .SelectMany(ContributingPlayoutIds) .Distinct() .ToList(); Dictionary upcoming = await channelRepository.GetPlayoutUpcomingHealth(playoutIds, DateTime.UtcNow, cancellationToken); return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c), upcoming)).ToList(); } private static IEnumerable ContributingPlayoutIds(Channel channel) { if (channel.Playouts is not null) { foreach (Playout p in channel.Playouts) yield return p.Id; } if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts is not null) { foreach (Playout p in channel.MirrorSourceChannel.Playouts) yield return p.Id; } } ``` (If a shared `ContributingPlayoutIds` is cleaner, put it as a `public static` helper on `Mapper` and reuse from both handlers — DRY with `ContributingPlayouts` from Task 1. Prefer exposing `Mapper.ContributingPlayouts` and doing `.Select(p => p.Id)`.) `GetChannelByIdForApiHandler.cs`: ```csharp public async Task> Handle( GetChannelByIdForApi request, CancellationToken cancellationToken) { Option maybeChannel = await channelRepository.GetChannel(request.Id); return await maybeChannel.MapAsync(async channel => { var playoutIds = Mapper.ContributingPlayouts(channel).Select(p => p.Id).Distinct().ToList(); Dictionary upcoming = await channelRepository.GetPlayoutUpcomingHealth(playoutIds, DateTime.UtcNow, cancellationToken); return ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel), upcoming); }); } ``` (If `MapAsync` on `Option` isn't available in this LanguageExt version, fall back to `maybeChannel.Match(...)` / manual `if (maybeChannel.IsSome)`. Change `ContributingPlayouts` visibility to `internal`/`public` on Mapper so the handler can use it. Handler signature changes from expression-bodied to async — update accordingly.) - [ ] **Step 6: Run the handler test + full Channels test suite** Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~Channels` Expected: PASS. Then build the whole solution: `dotnet build ErsatzTV.sln` — fix any other callers of the two mapper methods the signature change broke (grep `ProjectToResponseModel(` / `ProjectToDetailResponseModel(` across the solution and thread `upcoming` — likely only these two handlers call them; if another caller exists that has no aggregate, pass an empty dict). - [ ] **Step 7: Commit** ```bash git add -A git -c core.hooksPath=/dev/null commit --no-verify -m "feat(415): populate Health on channel list + detail DTOs" ``` --- ### Task 4: Regenerate OpenAPI + TypeScript types **Files:** - Modify (generated): `ErsatzTV/wwwroot/.../v1.json` (or wherever the spec lands), `docs/endpoint-index.md`, `web/src/api/generated/v1.d.ts` - [ ] **Step 1: Regenerate the OpenAPI document** Run: `./scripts/update-openapi.sh` Expected: builds `ErsatzTV`, regenerates the v1 spec + endpoint index with the new `ChannelHealthResponseModel` schema and `health` on the two channel models. - [ ] **Step 2: Regenerate TS types** Run: `cd web && npm run generate:api` Expected: `src/api/generated/v1.d.ts` now has `ChannelHealthResponseModel` and `health` on `ChannelResponseModel`/`ChannelDetailResponseModel`. - [ ] **Step 3: Verify the gate is clean** Run: `cd web && npm run check:api` Expected: exit 0 (generated file matches). - [ ] **Step 4: Commit generated artifacts** ```bash git add -A git -c core.hooksPath=/dev/null commit --no-verify -m "chore(415): regenerate OpenAPI + TS types for channel health" ``` --- ### Task 5: SPA — Problems filter, per-fault badges, tests **Files:** - Modify: `web/src/screens/ChannelsScreen.tsx` - Test: `web/src/screens/ChannelsScreen.test.tsx` - (Types come free from Task 4's `v1.d.ts`; `ChannelSummary` now carries `health`.) **Interfaces:** - Consumes: `ChannelSummary['health']` = `{ status, faults, playoutCount, brokenSourceItemCount }` (nullable per core-dtos-generate-nullable-in-spa — coerce). - [ ] **Step 1: Write the failing SPA tests** In `ChannelsScreen.test.tsx`, first extend the `channelRow` fixture default to include a healthy `health` object (add to the returned object): ```tsx health: { status: 'Healthy', faults: [], playoutCount: 1, brokenSourceItemCount: 0 }, ``` Then add tests (mirroring the existing "No playout" test structure): ```tsx it('flags channels with problems and filters the lineup down to them', async () => { mockApi({ channels: [ channelRow({ id: 1, name: 'Healthy', number: '1', health: { status: 'Healthy', faults: [], playoutCount: 1, brokenSourceItemCount: 0 } }), channelRow({ id: 2, name: 'Empty', number: '2', playoutCount: 1, health: { status: 'Problems', faults: ['EmptyUpcoming'], playoutCount: 1, brokenSourceItemCount: 0 } }), channelRow({ id: 3, name: 'Dead', number: '3', playoutCount: 0, health: { status: 'Problems', faults: ['NoPlayout'], playoutCount: 0, brokenSourceItemCount: 0 } }) ] }); render(); const table = await screen.findByRole('table', { name: 'Channels lineup' }); expect(within(table).getByText('Empty schedule')).toBeInTheDocument(); expect(within(table).getByText('No playout')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Problems 2' })); expect(screen.getByText('Empty')).toBeInTheDocument(); expect(screen.getByText('Dead')).toBeInTheDocument(); expect(screen.queryByText('Healthy')).not.toBeInTheDocument(); }); it('does not render a problem badge for an unknown-health channel', async () => { mockApi({ channels: [channelRow({ id: 1, name: 'OnDemandIdle', number: '1', health: { status: 'Unknown', faults: [], playoutCount: 1, brokenSourceItemCount: 0 } })] }); render(); const table = await screen.findByRole('table', { name: 'Channels lineup' }); expect(within(table).queryByText('No playout')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Problems 0' })).toBeInTheDocument(); }); ``` - [ ] **Step 2: Run them, verify they fail** Run: `cd web && npx vitest run src/screens/ChannelsScreen.test.tsx` Expected: FAIL (no "Problems" button; badges say "No playout" only). - [ ] **Step 3: Update the filter type, predicate, badge mapping** In `ChannelsScreen.tsx`: Replace the filter union (line 34): `'noplayout'` → `'problems'`: ```tsx type ChannelViewFilter = 'all' | 'onair' | 'disabled' | 'problems'; ``` Replace `willNeverPlay` + its comment (lines 36-48) with `hasProblems` + a fault-label map: ```tsx // #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. function hasProblems(channel: ChannelSummary): boolean { return channel.health?.status === 'Problems'; } const FAULT_LABELS: Record = { NoPlayout: 'No playout', NeverBuilt: 'Never built', BuildFailed: 'Build failed', EmptyUpcoming: 'Empty schedule', BrokenSource: 'Broken source' }; ``` Update the count + filter (lines 215, 225-227): ```tsx const problemsCount = channels.filter(hasProblems).length; ``` ```tsx if (filter === 'problems') { return hasProblems(channel); } ``` Update the filter tab button (line 432): ```tsx ``` Replace the badge render (line 616) — render one badge per fault: ```tsx {(channel.health?.faults ?? []).map((fault) => ( {FAULT_LABELS[fault] ?? fault} ))} ``` - [ ] **Step 4: Run SPA tests + typecheck** Run: `cd web && npx vitest run src/screens/ChannelsScreen.test.tsx && npm run typecheck` Expected: PASS. Fix any other `noplayout`/`willNeverPlay` references the compiler flags (grep both across `web/src`). - [ ] **Step 5: Commit** ```bash git add -A git -c core.hooksPath=/dev/null commit --no-verify -m "feat(415): SPA Problems filter + per-fault channel badges" ``` --- ### Task 6: Documentation **Files:** - Modify: `docs/decisions.md` (supersede `api.channel-health-signal`) - Modify: `docs/domain-model.md`, `docs/api-conventions.md`, `docs/spa-conventions.md` - [ ] **Step 1: Supersede the decision record** In `docs/decisions.md`, follow the append/supersede convention (grep the file header for the exact `[decisions-edit]` / supersession mechanics; do NOT rename an existing heading — add a new dated record and mark the old one superseded per `decisions-lifecycle-matches-by-heading`). New record `api.channel-health-signal` (v2): the derived `health` object on the channel DTOs (Status/Faults/PlayoutCount/BrokenSourceItemCount), built-timeline detection, the three accepted defaults, and the assessable gate (absence vs presence signals). Note it supersedes the #72 raw-fact-only stance now that #383 has cleared. - [ ] **Step 2: Update the other three convention docs** - `docs/domain-model.md`: update the `PlayoutCount`/channel-health row to describe the `health` derivation from `Playout.BuildStatus` + upcoming `PlayoutItem`→`MediaItem.State`. - `docs/api-conventions.md`: document `ChannelHealthResponseModel` shape + the const-string `status`/`faults` (SPA hand-maintains the union, like `ChannelPreviewAvailability`). - `docs/spa-conventions.md`: the `Problems` rollup filter + per-fault badge convention; note `health` is nullable-coerced at the boundary. - [ ] **Step 3: Commit** ```bash git add docs/decisions.md docs/domain-model.md docs/api-conventions.md docs/spa-conventions.md git -c core.hooksPath=/dev/null commit --no-verify -m "docs(415): channel health taxonomy (decision + conventions)" ``` --- ### Task 7: Live-E2E verification + cost measurement **Files:** none (verification only). - [ ] **Step 1: Build backend + SPA** Run: `dotnet build ErsatzTV.sln && (cd web && npm run build)` Expected: both succeed. - [ ] **Step 2: Launch a fresh local instance** Run: `scripts/e2e-local.sh` (captures a fresh temp CONFIG_DIR). Record `PORT`, `PID`, `LOG`. - [ ] **Step 3: Verify the health object on the API** Run: `curl -s http://localhost:$PORT/api/v1/channels | python3 -m json.tool | grep -A6 '"health"'` Expected: each channel carries a `health` object. Induce faults on a seeded channel (create a channel with no playout → `NoPlayout`; if feasible, point an item at a missing file to exercise `BrokenSource`) and re-curl to confirm `status: "Problems"` + the expected `faults`. - [ ] **Step 4: Measure cost — confirm no N+1** Run: inspect `$LOG` for the EF query log around a `/api/v1/channels` request (or set `Logging:LogLevel:Microsoft.EntityFrameworkCore.Database.Command=Information` in the fresh config). Confirm the upcoming aggregate is a **single** `GROUP BY` query, not one-per-channel. Record the observation in the closing record. - [ ] **Step 5: Stop the instance** Run: `kill $PID` --- ## Self-review notes (author) - **Spec coverage:** all 5 faults (Task 1), broken-source attribution (Tasks 1-2), all-kinds correctness (Task 1 `[TestCase]` per kind), cost/no-N+1 (Tasks 2 + 7 step 4), SPA rename + comment (Task 5), docs incl. decisions + domain-model (Task 6), adversarial review (post-plan gate). ✓ - **Cross-task type consistency:** `PlayoutUpcoming` is defined in Core (`ErsatzTV.Core/Api/Channels/`) from Task 1 Step 1, so the Task 2 repository interface (Core) and Mapper (Application) both reference it without a layering violation. `GetHealth`, `ContributingPlayouts`, `GetPlayoutUpcomingHealth`, `ChannelHealthStatus`/`ChannelFault` names used identically across tasks. ✓ - **Test harness (corrected pre-flight):** all backend tests live in `ErsatzTV.Tests` (has `InternalsVisibleTo`); real-DB tests use `InMemoryTvContext` (`:memory:` SQLite, FKs off); `Channel` uses `new Channel(Guid.NewGuid())`. ✓ - **Known follow-up flagged, not silently dropped:** possible `(PlayoutId, Finish)` index is a tuning follow-up only if Task 7 step 4 shows the aggregate is hot.