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>
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/v1is additive-only after freeze. - Assessable gate:
isOnDemand = channel.PlayoutMode == ChannelPlayoutMode.OnDemand. On-demand suppressesneverBuilt+emptyUpcoming(absence signals);buildFailed+brokenSource(presence signals) always apply. MediaItemStatebad values:FileNotFound(1),Unavailable(2).- Backend tests: NUnit
[TestFixture]/[Test], Shouldly, NSubstitute. Channel-health tests live inErsatzTV.Tests/Application/Channels/(namespaceErsatzTV.Tests.Application.Channels) — that project hasInternalsVisibleToaccess toErsatzTV.Application(needed forinternal GetHealth);ErsatzTV.Core.Testsdoes NOT. Real-DB tests use theInMemoryTvContextharness (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 inErsatzTV.Tests/Support/ChannelHandlerTestBase.cs(SeedChannel,SeedPlayout,SeedFFmpegProfile). MirrorErsatzTV.Tests/Application/Channels/GetChannelStatesForApiHandlerTests.csfor the setup/teardown/seed pattern.Channelhas NO parameterless ctor — alwaysnew Channel(Guid.NewGuid()) { ... }. - SPA tests: Vitest + testing-library, mock
window.fetch; fixtures use runtime camelCase. - PR routine for API changes: build
ErsatzTVproject →./scripts/update-openapi.sh→cd web && npm run generate:api. BOM-check touched.cs+ format gate underbash -cbefore push. Rebase (never merge) onorigin/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-verifyin 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(addGetHealth+ a small aggregate type) - Test:
ErsatzTV.Tests/Application/Channels/ChannelHealthTests.cs(new; namespaceErsatzTV.Tests.Application.Channels)
Interfaces:
-
Produces:
record ChannelHealthResponseModel(string Status, string[] Faults, int PlayoutCount, int BrokenSourceItemCount)(inErsatzTV.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)—internalis visible toErsatzTV.Testsvia existingInternalsVisibleTo.
-
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+GetHealthin 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; namespaceErsatzTV.Tests.Application.Channels; usesInMemoryTvContext).
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 ofProjectToResponseModel,ProjectToDetailResponseModel) - Modify:
ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs,ChannelDetailResponseModel.cs(add trailingHealth) - Modify:
ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs,GetChannelByIdForApiHandler.cs - Test:
ErsatzTV.Tests/Application/Channels/GetAllChannelsForApiHandlerHealthTests.cs(new; NSubstitute mock ofIChannelRepository— 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.Healthpopulated. -
Step 1: Add
Healthas 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;ChannelSummarynow carrieshealth.)
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(supersedeapi.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 thePlayoutCount/channel-health row to describe thehealthderivation fromPlayout.BuildStatus+ upcomingPlayoutItem→MediaItem.State. -
docs/api-conventions.md: documentChannelHealthResponseModelshape + the const-stringstatus/faults(SPA hand-maintains the union, likeChannelPreviewAvailability). -
docs/spa-conventions.md: theProblemsrollup filter + per-fault badge convention; notehealthis 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:
PlayoutUpcomingis 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/ChannelFaultnames used identically across tasks. ✓ - Test harness (corrected pre-flight): all backend tests live in
ErsatzTV.Tests(hasInternalsVisibleTo); real-DB tests useInMemoryTvContext(:memory:SQLite, FKs off);Channelusesnew 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.