Files
ersatztv/docs/superpowers/plans/2026-07-23-channel-fault-detection.md
T
timothyandtimothy 65c0e09179
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
feat(415): per-channel fault detection — server-derived health object + Problems filter (#581)
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>
2026-07-23 20:45:19 +00:00

41 KiB

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 PlayoutItems 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<TvContext>) 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.shcd 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<int, PlayoutUpcoming> upcoming)internal is visible to ErsatzTV.Tests via existing InternalsVisibleTo.
  • Step 1: Create the DTO + constant classes

Create ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs:

#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).

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<int, PlayoutUpcoming> 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.

internal static ChannelHealthResponseModel GetHealth(
    Channel channel,
    int playoutCount,
    IReadOnlyDictionary<int, PlayoutUpcoming> upcoming)
{
    if (playoutCount == 0)
    {
        return new ChannelHealthResponseModel(
            ChannelHealthStatus.Problems,
            [ChannelFault.NoPlayout],
            0,
            0);
    }

    var faults = new HashSet<string>();
    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<Playout> 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
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 PlayoutItems 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<Dictionary<int, PlayoutUpcoming>> GetPlayoutUpcomingHealth(IReadOnlyCollection<int> 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:

Task<Dictionary<int, PlayoutUpcoming>> GetPlayoutUpcomingHealth(
    IReadOnlyCollection<int> 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 PlayoutItems. PlayoutItem.Finish is a DateTime (UTC); use a fixed now.

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<int, PlayoutUpcoming> 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):

// 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:

public async Task<Dictionary<int, PlayoutUpcoming>> GetPlayoutUpcomingHealth(
    IReadOnlyCollection<int> playoutIds,
    DateTime nowUtc,
    CancellationToken cancellationToken)
{
    if (playoutIds.Count == 0)
    {
        return new Dictionary<int, PlayoutUpcoming>();
    }

    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
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 <the repo test file>
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:

    ChannelOrigin Origin,
    // Server-derived health rollup (api.channel-health-signal). See ChannelHealthResponseModel.
    ChannelHealthResponseModel Health);

ChannelDetailResponseModel.cs — append after int[] GraphicsElementIds:

    int[] GraphicsElementIds,
    ChannelHealthResponseModel Health);
  • Step 2: Update mapper signatures to accept the aggregate and set Health

Change ProjectToResponseModel and ProjectToDetailResponseModel to take IReadOnlyDictionary<int, PlayoutUpcoming> upcoming and pass GetHealth(channel, playoutCount, upcoming) as the trailing arg:

internal static ChannelResponseModel ProjectToResponseModel(
    Channel channel,
    int playoutCount,
    IReadOnlyDictionary<int, PlayoutUpcoming> upcoming) =>
    new(
        // ... existing args unchanged, up to and including ...
        channel.Origin,
        GetHealth(channel, playoutCount, upcoming));
internal static ChannelDetailResponseModel ProjectToDetailResponseModel(
    Channel channel,
    int playoutCount,
    IReadOnlyDictionary<int, PlayoutUpcoming> 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:

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<IChannelRepository>();
        repo.GetAll(Arg.Any<CancellationToken>()).Returns([channel]);
        repo.GetPlayoutUpcomingHealth(Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
            .Returns(new Dictionary<int, PlayoutUpcoming> { [10] = new(6, 2) });

        var handler = new GetAllChannelsForApiHandler(repo);
        List<ChannelResponseModel> 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:

public async Task<List<ChannelResponseModel>> Handle(
    GetAllChannelsForApi request,
    CancellationToken cancellationToken)
{
    List<Channel> channels = await channelRepository.GetAll(cancellationToken);
    var playoutIds = channels
        .SelectMany(ContributingPlayoutIds)
        .Distinct()
        .ToList();
    Dictionary<int, PlayoutUpcoming> upcoming =
        await channelRepository.GetPlayoutUpcomingHealth(playoutIds, DateTime.UtcNow, cancellationToken);
    return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c), upcoming)).ToList();
}

private static IEnumerable<int> 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:

public async Task<Option<ChannelDetailResponseModel>> Handle(
    GetChannelByIdForApi request,
    CancellationToken cancellationToken)
{
    Option<Channel> maybeChannel = await channelRepository.GetChannel(request.Id);
    return await maybeChannel.MapAsync(async channel =>
    {
        var playoutIds = Mapper.ContributingPlayouts(channel).Select(p => p.Id).Distinct().ToList();
        Dictionary<int, PlayoutUpcoming> upcoming =
            await channelRepository.GetPlayoutUpcomingHealth(playoutIds, DateTime.UtcNow, cancellationToken);
        return ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel), upcoming);
    });
}

(If MapAsync on Option<T> 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
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
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):

    health: { status: 'Healthy', faults: [], playoutCount: 1, brokenSourceItemCount: 0 },

Then add tests (mirroring the existing "No playout" test structure):

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(<ChannelsScreen />);
  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(<ChannelsScreen />);
  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':

type ChannelViewFilter = 'all' | 'onair' | 'disabled' | 'problems';

Replace willNeverPlay + its comment (lines 36-48) with hasProblems + a fault-label map:

// #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<string, string> = {
  NoPlayout: 'No playout',
  NeverBuilt: 'Never built',
  BuildFailed: 'Build failed',
  EmptyUpcoming: 'Empty schedule',
  BrokenSource: 'Broken source'
};

Update the count + filter (lines 215, 225-227):

  const problemsCount = channels.filter(hasProblems).length;
    if (filter === 'problems') {
      return hasProblems(channel);
    }

Update the filter tab button (line 432):

              <button type="button" aria-pressed={filter === 'problems'} onClick={() => changeFilter('problems')}>Problems <code>{problemsCount}</code></button>

Replace the badge render (line 616) — render one badge per fault:

              {(channel.health?.faults ?? []).map((fault) => (
                <Badge key={fault} tone="error">{FAULT_LABELS[fault] ?? fault}</Badge>
              ))}
  • 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
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 PlayoutItemMediaItem.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

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.