diff --git a/ErsatzTV.Application/Channels/Mapper.cs b/ErsatzTV.Application/Channels/Mapper.cs index f425a8dae..8e45cce6e 100644 --- a/ErsatzTV.Application/Channels/Mapper.cs +++ b/ErsatzTV.Application/Channels/Mapper.cs @@ -29,6 +29,106 @@ internal static class Mapper return result; } + 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 System.Collections.Generic.HashSet(); + var brokenSourceItemCount = 0; + var sawAssessable = false; + + foreach ((Playout playout, ChannelPlayoutMode ownerMode) in ContributingPlayoutsWithOwnerMode(channel)) + { + bool isOnDemand = ownerMode == ChannelPlayoutMode.OnDemand; + + 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); + } + + internal static IEnumerable ContributingPlayouts(Channel channel) => + ContributingPlayoutsWithOwnerMode(channel).Select(x => x.Playout); + + // Mirror channels are forced Continuous (UpdateChannelHandler), but a mirror of an on-demand SOURCE relays + // playouts that legitimately drain between tune-ins. Absence-signal suppression must key off the mode of the + // channel that OWNS each playout, not the mirror's own (always-Continuous) mode — so pair each playout with + // its owner's mode here, once, rather than re-deriving it at each call site. + private static IEnumerable<(Playout Playout, ChannelPlayoutMode OwnerMode)> ContributingPlayoutsWithOwnerMode( + Channel channel) + { + if (channel.Playouts is not null) + { + foreach (Playout p in channel.Playouts) + { + yield return (p, channel.PlayoutMode); + } + } + + if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts is not null) + { + foreach (Playout p in channel.MirrorSourceChannel.Playouts) + { + yield return (p, channel.MirrorSourceChannel.PlayoutMode); + } + } + } + internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) => new( channel.Id, @@ -61,7 +161,10 @@ internal static class Mapper channel.IsEnabled, channel.ShowInEpg); - internal static ChannelDetailResponseModel ProjectToDetailResponseModel(Channel channel, int playoutCount) + internal static ChannelDetailResponseModel ProjectToDetailResponseModel( + Channel channel, + int playoutCount, + IReadOnlyDictionary upcoming) { ArtworkContentTypeModel logo = GetLogo(channel); return new ChannelDetailResponseModel( @@ -94,12 +197,14 @@ internal static class Mapper channel.IdleBehavior, channel.IsEnabled, channel.ShowInEpg, - channel.ChannelGraphicsElements?.Map(x => x.GraphicsElementId).ToArray() ?? []); + channel.ChannelGraphicsElements?.Map(x => x.GraphicsElementId).ToArray() ?? [], + GetHealth(channel, playoutCount, upcoming)); } internal static ChannelResponseModel ProjectToResponseModel( Channel channel, - int playoutCount) => + int playoutCount, + IReadOnlyDictionary upcoming) => new( channel.Id, channel.Number, @@ -115,7 +220,8 @@ internal static class Mapper playoutCount, GetLogoUrl(channel), GetPreview(channel.StreamingMode, channel.Number, channel.IsEnabled, playoutCount), - channel.Origin); + channel.Origin, + GetHealth(channel, playoutCount, upcoming)); internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) => new(resolution.Height, resolution.Width); diff --git a/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs b/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs index 08c5148c3..d688b57fe 100644 --- a/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs @@ -12,7 +12,13 @@ public class GetAllChannelsForApiHandler(IChannelRepository channelRepository) GetAllChannelsForApi request, CancellationToken cancellationToken) { - IEnumerable channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten(); - return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c))).ToList(); + List channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten().ToList(); + var playoutIds = channels + .SelectMany(c => ContributingPlayouts(c).Select(p => p.Id)) + .Distinct() + .ToList(); + Dictionary upcoming = + await channelRepository.GetPlayoutUpcomingHealth(playoutIds, DateTime.UtcNow, cancellationToken); + return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c), upcoming)).ToList(); } } diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApiHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApiHandler.cs index 065b695e6..bf091f08d 100644 --- a/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApiHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApiHandler.cs @@ -1,4 +1,5 @@ using ErsatzTV.Core.Api.Channels; +using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using static ErsatzTV.Application.Channels.Mapper; @@ -7,9 +8,20 @@ namespace ErsatzTV.Application.Channels; public class GetChannelByIdForApiHandler(IChannelRepository channelRepository) : IRequestHandler> { - public Task> Handle( + public async Task> Handle( GetChannelByIdForApi request, - CancellationToken cancellationToken) => - channelRepository.GetChannel(request.Id) - .MapT(channel => ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel))); + CancellationToken cancellationToken) + { + Option maybeChannel = await channelRepository.GetChannel(request.Id); + + foreach (Channel channel in maybeChannel) + { + var playoutIds = ContributingPlayouts(channel).Select(p => p.Id).Distinct().ToList(); + Dictionary upcoming = + await channelRepository.GetPlayoutUpcomingHealth(playoutIds, DateTime.UtcNow, cancellationToken); + return ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel), upcoming); + } + + return Option.None; + } } diff --git a/ErsatzTV.Core/Api/Channels/ChannelDetailResponseModel.cs b/ErsatzTV.Core/Api/Channels/ChannelDetailResponseModel.cs index 9386f3dc7..8b7cc9ca6 100644 --- a/ErsatzTV.Core/Api/Channels/ChannelDetailResponseModel.cs +++ b/ErsatzTV.Core/Api/Channels/ChannelDetailResponseModel.cs @@ -39,7 +39,9 @@ public record ChannelDetailResponseModel( ChannelIdleBehavior IdleBehavior, bool IsEnabled, bool ShowInEpg, - int[] GraphicsElementIds); + int[] GraphicsElementIds, + // Server-derived health rollup (api.channel-health-object). See ChannelHealthResponseModel. + ChannelHealthResponseModel Health); // Wire-compatible mirror of the Application-layer ArtworkContentTypeModel (which lives in // ErsatzTV.Application and therefore can't be referenced from Core). Same serialized shape the diff --git a/ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs b/ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs new file mode 100644 index 000000000..2a97af781 --- /dev/null +++ b/ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs @@ -0,0 +1,36 @@ +#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-object decision, superseding #72's raw-fact-only stance). +// Faults are plain strings (see ChannelFault) not a C# enum, to keep the OpenAPI schema simple — the +// SPA hand-maintains its own union, matching the ChannelPreviewAvailability pattern. +public record ChannelHealthResponseModel( + // 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); diff --git a/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs b/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs index 5a1a7a0f9..afd81ba29 100644 --- a/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs +++ b/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs @@ -25,4 +25,6 @@ public record ChannelResponseModel( ChannelPreviewResponseModel Preview, // Immutable creation-provenance (auto-tuned vs user-created). Raw fact; the SPA decides how to render it. // Unknown for rows created before the origin column existed (never back-filled). - ChannelOrigin Origin); + ChannelOrigin Origin, + // Server-derived health rollup (api.channel-health-object). See ChannelHealthResponseModel. + ChannelHealthResponseModel Health); diff --git a/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs index 65cb708fc..cfe6e5b60 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs @@ -1,4 +1,5 @@ -using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Api.Channels; +using ErsatzTV.Core.Domain; namespace ErsatzTV.Core.Interfaces.Repositories; @@ -8,4 +9,9 @@ public interface IChannelRepository Task> GetByNumber(string number); Task> GetAll(CancellationToken cancellationToken); Task> GetWatermarkByName(string name); + + Task> GetPlayoutUpcomingHealth( + IReadOnlyCollection playoutIds, + DateTime nowUtc, + CancellationToken cancellationToken); } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs index 99cd6a6d8..e7fb35b4b 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs @@ -1,3 +1,4 @@ +using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using Microsoft.EntityFrameworkCore; @@ -15,8 +16,10 @@ public class ChannelRepository(IDbContextFactory dbContextFactory) : .Include(c => c.Artwork) .Include(c => c.Watermark) .Include(c => c.Playouts) + .ThenInclude(p => p.BuildStatus) .Include(c => c.MirrorSourceChannel) .ThenInclude(mc => mc.Playouts) + .ThenInclude(p => p.BuildStatus) .Include(c => c.ChannelGraphicsElements) .ThenInclude(x => x.GraphicsElement) .OrderBy(c => c.Id) @@ -46,11 +49,42 @@ public class ChannelRepository(IDbContextFactory dbContextFactory) : .Include(c => c.FFmpegProfile) .Include(c => c.Artwork) .Include(c => c.Playouts) + .ThenInclude(p => p.BuildStatus) .Include(c => c.MirrorSourceChannel) .ThenInclude(mc => mc.Playouts) + .ThenInclude(p => p.BuildStatus) .ToListAsync(cancellationToken); } + 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); + } + public async Task> GetWatermarkByName(string name) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); diff --git a/ErsatzTV.Tests/Application/Channels/ChannelHealthTests.cs b/ErsatzTV.Tests/Application/Channels/ChannelHealthTests.cs new file mode 100644 index 000000000..b1f88bb52 --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/ChannelHealthTests.cs @@ -0,0 +1,212 @@ +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); + } + + // Mirror channels are forced Continuous (UpdateChannelHandler), but a mirror of an on-demand SOURCE relays + // playouts that legitimately drain between tune-ins. Absence-signal suppression must key off the OWNING + // channel's mode, not the mirror's own (always-Continuous) mode — otherwise this false-flags Problems. + [Test] + public void Mirror_Of_OnDemand_Source_NeverBuilt_Is_Unknown_Not_Problems() + { + Channel channel = new(Guid.NewGuid()) + { + Id = 1, + Number = "1", + Name = "Mirror", + PlayoutSource = ChannelPlayoutSource.Mirror, + PlayoutMode = ChannelPlayoutMode.Continuous, + MirrorSourceChannel = new Channel(Guid.NewGuid()) + { + Id = 2, + Number = "2", + Name = "Source", + PlayoutSource = ChannelPlayoutSource.Generated, + PlayoutMode = ChannelPlayoutMode.OnDemand, + Playouts = [NeverBuiltPlayout(10)] + } + }; + + ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming()); + + health.Status.ShouldBe(ChannelHealthStatus.Unknown); + health.Faults.ShouldBeEmpty(); + } + + [Test] + public void Mirror_Of_OnDemand_Source_With_Broken_Upcoming_Is_Problems() + { + Channel channel = new(Guid.NewGuid()) + { + Id = 1, + Number = "1", + Name = "Mirror", + PlayoutSource = ChannelPlayoutSource.Mirror, + PlayoutMode = ChannelPlayoutMode.Continuous, + MirrorSourceChannel = new Channel(Guid.NewGuid()) + { + Id = 2, + Number = "2", + Name = "Source", + PlayoutSource = ChannelPlayoutSource.Generated, + PlayoutMode = ChannelPlayoutMode.OnDemand, + Playouts = [BuiltPlayout(10)] + } + }; + + ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 4, 2))); + + health.Status.ShouldBe(ChannelHealthStatus.Problems); + health.Faults.ShouldContain(ChannelFault.BrokenSource); + } +} diff --git a/ErsatzTV.Tests/Application/Channels/ChannelRepositoryHealthTests.cs b/ErsatzTV.Tests/Application/Channels/ChannelRepositoryHealthTests.cs new file mode 100644 index 000000000..178878446 --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/ChannelRepositoryHealthTests.cs @@ -0,0 +1,50 @@ +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 + } +} diff --git a/ErsatzTV.Tests/Application/Channels/GetAllChannelsForApiHandlerHealthTests.cs b/ErsatzTV.Tests/Application/Channels/GetAllChannelsForApiHandlerHealthTests.cs new file mode 100644 index 000000000..3f6423ca6 --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/GetAllChannelsForApiHandlerHealthTests.cs @@ -0,0 +1,51 @@ +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); + } +} diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs index 9a426ccc0..4070906d9 100644 --- a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs @@ -562,7 +562,8 @@ public class ChannelControllerTests ChannelIdleBehavior.StopOnDisconnect, true, false, - []); + [], + new ChannelHealthResponseModel(ChannelHealthStatus.Healthy, [], 0, 0)); private static ChannelViewModel MakeVm(int id) => new( diff --git a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs index a1ef7884a..366391b9e 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs @@ -105,7 +105,8 @@ public class OpenApiSerializerContractTests default, true, true, - [1]); + [1], + new ChannelHealthResponseModel(ChannelHealthStatus.Healthy, [], 1, 0)); private static FFmpegSettingsResponseModel FullyPopulatedFFmpegSettings() => new( "/usr/bin/ffmpeg", @@ -158,7 +159,8 @@ public class OpenApiSerializerContractTests 2, "/iptv/logos/logo.png", Mapper.GetPreview(StreamingMode.HttpLiveStreamingSegmenter, "1", true, 2), - ChannelOrigin.AutoTuned); + ChannelOrigin.AutoTuned, + new ChannelHealthResponseModel(ChannelHealthStatus.Healthy, [], 2, 0)); private static string FindOpenApiDocument() { diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 4cd1e73a8..02dec0207 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -23714,7 +23714,8 @@ "idleBehavior", "isEnabled", "showInEpg", - "graphicsElementIds" + "graphicsElementIds", + "health" ], "type": "object", "properties": { @@ -23834,6 +23835,9 @@ "type": "integer", "format": "int32" } + }, + "health": { + "$ref": "#/components/schemas/ChannelHealthResponseModel" } } }, @@ -23929,6 +23933,34 @@ } } }, + "ChannelHealthResponseModel": { + "required": [ + "status", + "faults", + "playoutCount", + "brokenSourceItemCount" + ], + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "faults": { + "type": "array", + "items": { + "type": "string" + } + }, + "playoutCount": { + "type": "integer", + "format": "int32" + }, + "brokenSourceItemCount": { + "type": "integer", + "format": "int32" + } + } + }, "ChannelIdleBehavior": { "enum": [ "StopOnDisconnect", @@ -24054,7 +24086,8 @@ "playoutCount", "logo", "preview", - "origin" + "origin", + "health" ], "type": "object", "properties": { @@ -24108,6 +24141,9 @@ }, "origin": { "$ref": "#/components/schemas/ChannelOrigin" + }, + "health": { + "$ref": "#/components/schemas/ChannelHealthResponseModel" } } }, diff --git a/docs/api-conventions.md b/docs/api-conventions.md index e69953482..636ae60f9 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -90,6 +90,16 @@ Exemplars: than the SPA inferring it from a display string. Examples: health-check remediation (`api.healthcheck-remediation-dto`) and channel preview capability (`ChannelPreviewResponseModel`). +- **Server-derived rollup verdict + hand-maintained string union**: when a DTO needs a computed + verdict rather than a raw fact, add a nested response model carrying a `Status` plus a `Faults`/ + detail array, both typed as plain `string`/`string[]` backed by a `const string` class (e.g. + `ChannelHealthStatus`, `ChannelFault`), **not** a C# enum — keeps the OpenAPI schema a bare + `string`/`string[]` and lets the SPA hand-maintain its own TS union, exactly like + `ChannelPreviewAvailability`. Exemplar: `ChannelHealthResponseModel` (`{Status, Faults[], + PlayoutCount, BrokenSourceItemCount}`) nested as `health` on `ChannelResponseModel` / + `ChannelDetailResponseModel` — computed **read-time** from the built playout timeline (never + cached at build time, since the underlying `MediaItem.State` flips on scan) via one bounded + aggregate query, not per-row N+1. See `docs/decisions.md` → `api.channel-health-object` (#415). - **Flatten polymorphic VMs for the wire**: when an Application ViewModel is an abstract/polymorphic record (subtypes carrying extra fields), the OpenAPI schema only captures the base shape — promote every subtype field to a nullable top-level member on a flat response DTO and pattern-match the diff --git a/docs/decisions.md b/docs/decisions.md index 31c6c539f..1c6f24a0a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1568,54 +1568,6 @@ not size the `small` lane expecting otherwise. **Not addressed here**: the 12–35 min queue waits (server-management#604) and the redundant triple-build (#398). -## 2026-07-17 — Channel health on the API = the raw `PlayoutCount` fact on the list DTO, not a derived status enum (#72) -`key: api.channel-health-signal` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none` -**Rule:** Channel health rides `ChannelResponseModel`/`ChannelListItem` DTOs as a raw `int PlayoutCount` fact (free — `GetAll` already `Include`s `Playouts`), not a new endpoint, not `/channels/state` (runtime-liveness cadence), and not a derived `ChannelHealth` enum (would freeze policy before the #383/#384 auto-tune status taxonomy lands). -**Signals:** channel health, PlayoutCount, config-derived vs runtime-liveness cadence, frozen /api/v1 · paths: `ChannelRepository.GetChannel`, `Mapper.GetPlayoutsCount`, `api-conventions.md` §3a · issues: #72, #383, #384, #401 -**Mechanics:** `api-conventions.md` §3a (precedent: `IsLocked` on `PlayoutListItemResponseModel`); `ChannelResponseModel.PlayoutCount` - -#72 asks for per-channel status in the Channels list, "especially channels that will fail to play". - -**Where it lives: `ChannelResponseModel` (the lean list DTO), not a new endpoint and not -`/channels/state`.** Channel health is *config-derived* — it changes when someone edits a playout, not -tick to tick — whereas `/channels/state` is the fast-poll runtime-liveness feed (`OnAir` = someone is -streaming *right now*). Folding health into the polled feed would recompute rarely-changing data every -tick and mix two cadences in one DTO; a third endpoint is over-engineering for one derived integer on a -list whose consumer already reads it. Precedent: `api-conventions.md` §3a stamps the server-derived -`IsLocked` onto `PlayoutListItemResponseModel` for exactly this reason. **It is also free**: `GetAll` -already `Include`s `Playouts` and `MirrorSourceChannel.Playouts` and was discarding them, so no extra -query and no N+1 on a large lineup. - -**A raw fact (`int PlayoutCount`), not a `ChannelHealth` enum.** v1 has exactly one trustworthy negative -signal, and the auto-tune arc (#383/#384) is about to churn the status taxonomy (origin, auto-tune -outcomes) — freezing a server-side enum now guarantees a breaking rev of a frozen-additive `/api/v1` -surface. `PlayoutCount` mirrors the long-standing `ChannelViewModel.PlayoutCount` 1:1, is a fact rather -than a policy, and leaves the SPA to derive `0 ⇒ "No playout"` in one predicate. - -**What v1 deliberately does NOT compute** — each was considered and ruled out, so don't "finish" them -without reading this: -- **Empty schedule behind an existing playout.** `EmptyScheduleHealthCheck` only understands **Classic** - `ProgramSchedule` playouts. Block, Sequential, Scripted and ExternalJson channels have no - `ProgramSchedule` at all, so a badge driven off that query would be silently absent or wrong for four of - the five schedule kinds — the #71 "verify a shared primitive covers ALL variants" trap. Needs a - per-kind emptiness notion first. -- **Broken / missing source.** `FileNotFound`/`Unavailable` are server-wide media-item counts with no - channel attribution; mapping media → collection → schedule → channel is a project, not a field. -- **User-defined vs auto-generated origin** (#72 scope item a). No honest signal exists: - `Channel` has no origin column, and `ChannelPlayoutSource.Generated` is a *playout-strategy* value that - SPA-created blank channels also carry, so it would mislabel them. Requires a new column + a dual-provider - migration, and provenance belongs to the auto-tune arc that stamps it at creation. A join through the - `"Channel Lineups"` system playlist group was **rejected**: it is a heuristic that breaks the moment a - user edits the channel. Deferred to a follow-up blocked on the auto-tune backend. - -Keeping all three out held #72 to a **read-path-only** change: no migration, no write-handler live-E2E. - -**Corrected in passing:** `ChannelRepository.GetChannel` never included `Playouts`, so -`GET /api/v1/channels/{id}` reported `playoutCount: 0` for every channel, which silently disabled the -channel editor's playout-source guard. Both call sites now share `Mapper.GetPlayoutsCount` (Mirror-aware). -The related *silent* server-side coercion of Mirror→Generated (a 200 that discards the caller's intent, -against the §3 "surface it, don't silently filter" rule) is filed as **#401**, not fixed here. - ## 2026-07-17 — Weighted / fair-share distribution is a new `WeightedShuffle` order; `ShuffleInOrder` is anti-clumping, not fair-share (#70) `key: sched.weighted-shuffle` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none` **Rule:** Fair-share/weighted airtime distribution ships as one new `PlaybackOrder.WeightedShuffle = 9` order (equal weights = fair-share), not a retrofit of `ShuffleInOrder` (which only anti-clumps, since its padding spacers emit nothing) and not a separate orthogonal "distribution" setting; weights live on `MultiCollectionItem`/`MultiCollectionSmartItem` (DB default 1, dual-provider migration), bounded at write (1..1000) and clamped again in the enumerator, and the write path rejects `WeightedShuffle` at every dispatch site that doesn't handle it rather than let it silently degrade to unweighted random. @@ -3666,3 +3618,19 @@ the rule builder exists. This keeps `rulebuilder.relative-date-macros` symmetric `spa.smartcollection-rule-builder`'s "compile-only, no new stored AST" stance (#176): a relative-date rule is just another point in the same closed grammar subset, proven by the same compile→parse round-trip discipline (`dateMacro.test.ts`, and the property test in `roundtrip.test.ts`, #438) rather than a special case. + +## 2026-07-23 — Channel health = a server-derived `health` object on the channel DTOs, built-timeline detection (#415) +`key: api.channel-health-object` · `status: active` · `since: 2026-07-23` · `supersedes: api.channel-health-signal@2026-07-17` · `superseded-by: none` +**Rule:** `ChannelResponseModel`/`ChannelDetailResponseModel` carry a server-derived `health` object (`ChannelHealthResponseModel { Status, Faults[], PlayoutCount, BrokenSourceItemCount }`) computed **read-time** from the built timeline (`Playout.BuildStatus` + upcoming `PlayoutItem → MediaItem.State`, `Finish >= now`), kind-agnostic across all 5 `PlayoutScheduleKind` values; `Status`/`Faults` are const-string classes (`ChannelHealthStatus`, `ChannelFault`), not C# enums, so the SPA hand-maintains the union (mirrors `ChannelPreviewAvailability`). This supersedes #72's "raw fact only, no derived enum, empty-schedule/broken-source deliberately not computed" stance now that the auto-tune taxonomy churn (#383/#384) it was waiting on has landed (see `channel.origin-marker` sibling record, #414). +**Signals:** channel health, ChannelHealthResponseModel, ChannelHealthStatus, ChannelFault, Healthy/Problems/Unknown, NoPlayout/NeverBuilt/BuildFailed/EmptyUpcoming/BrokenSource, built-timeline detection, BuildStatus, PlayoutItem MediaItem.State FileNotFound Unavailable, assessable gate, on-demand suppresses absence signals, Problems rollup filter, willNeverPlay hasProblems · paths: `ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs`, `ChannelRepository.GetAll`, `Mapper.GetHealth`, `GetAllChannelsForApiHandler`, `web/src/screens/ChannelsScreen.tsx`, `api-conventions.md`, `domain-model.md`, `spa-conventions.md` · issues: #415, #72, #71, #383, #384, #414 +**Mechanics:** `docs/superpowers/specs/2026-07-23-channel-fault-detection-design.md` (full design); `api-conventions.md` (health object shape); `domain-model.md` (channel-health row); `spa-conventions.md` (Problems filter + badge convention) + +#415 was deferred from #72 scope item (b): "empty schedule" and "broken/missing source" faults were real but uncomputed, each explicitly ruled out in the superseded record for a stated reason. This record reverses both rulings now that the blocking condition — the #383/#384 auto-tune status taxonomy churning the DTO shape — has resolved (#414 landed the origin column as a sibling, non-health field). + +**Built-timeline (kind-agnostic) detection, not per-kind config introspection.** Every fault falls out of what the scheduler has already materialized — `Playout.BuildStatus` (`{LastBuild, Success, Message}`) for never-built/build-failed, and `Playout.Items` (the built `PlayoutItem` timeline, each carrying `MediaItemId`/`MediaItem`) for empty-upcoming and broken-source. Because the timeline is the same shape for all five `PlayoutScheduleKind` values (Classic, Block, Sequential, Scripted, ExternalJson), coverage is *by construction* — the #71 "verify a shared primitive covers ALL variants" trap, which the superseded record's own `EmptyScheduleHealthCheck` (Classic-only) fell into, cannot bite here. Scripted, which has no schedule entity to introspect at all, needs no special case. `MediaItem.State` flips on scan (not build), which rules out a build-time snapshot — detection is necessarily read-time, costed as one bounded `GROUP BY PlayoutId` aggregate query (not an N+1) over upcoming `PlayoutItem`s. + +**Five-fault taxonomy, rolled up to one `status`.** `NoPlayout` (0 playouts, the absorbed #72 fact), `NeverBuilt` (assessable playout never built), `BuildFailed` (last build `Success == false`), `EmptyUpcoming` (built OK, 0 upcoming items), `BrokenSource` (≥1 upcoming item pointing at a `FileNotFound`/`Unavailable` `MediaItem`). Rollup: `Problems` if any contributing playout has a fault, `Healthy` if any is assessable-and-clean with none, `Unknown` if nothing is assessable — never a false `Healthy` and never a false `Problems`. + +**The assessable gate distinguishes absence signals from presence signals.** `NeverBuilt`/`EmptyUpcoming` are inferred from *missing* content and are suppressed for `PlayoutMode == OnDemand` (an idle on-demand playout legitimately has no fresh build and drains its timeline between tune-ins — without suppression this is a false-positive storm across every on-demand channel; a suppressed absence signal contributes `Unknown`, not a false `Problems`). `BuildFailed`/`BrokenSource` are proven by content that *is* there and is bad, so they stay live in every `PlayoutMode` — they only fire when the bad thing actually exists and so cannot false-positive on legitimate idleness. + +**Server owns the rollup so SPA and MCP read one verdict.** `health` rides the same `list channels`/`get channel` response both clients already fetch — no second endpoint to correlate by id, and no client re-deriving policy from raw facts (the thing the superseded record explicitly avoided freezing before the taxonomy existed). `PlayoutCount` is retained unchanged on the DTO for backward compatibility (additive-only `/api/v1` freeze); the SPA's "Problems" filter (`web/src/screens/ChannelsScreen.tsx`, `hasProblems`, replacing the old single-fault `willNeverPlay`/"No playout" filter) and per-row badges read `health.status`/`health.faults` instead. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index d1fd8cde9..f85cb29f2 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -10,7 +10,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | --- | --- | --- | --- | | `api.artwork-rooted-urls` | API response DTOs return artwork as rooted, directly-usable URLs (plus passthrough for absolute/proxy URLs), never relative Blazor-convention paths. | 2026-07-07 | [link](../decisions.md#2026-07-07--api-artwork-contract-rooted-urls-produced-server-side) | | `api.async-op-contract` | Queue-triggering `/api/*` endpoints normalize onto one contract — 202 Accepted (queued), 404 (missing entity), 409 (lock held), 422 (domain precondition) — with Trakt as the reference implementation; playout list/detail GETs also carry an `isLocked` observability flag as the HTTP-observable substitute for a live push channel. | 2026-07-11 | [link](../decisions.md#2026-07-11--async-op-api-contract-normalization--playout-build-observability--f9-scan-endpoints-235) | -| `api.channel-health-signal` | Channel health rides `ChannelResponseModel`/`ChannelListItem` DTOs as a raw `int PlayoutCount` fact (free — `GetAll` already `Include`s `Playouts`), not a new endpoint, not `/channels/state` (runtime-liveness cadence), and not a derived `ChannelHealth` enum (would freeze policy before the #383/#384 auto-tune status taxonomy lands). | 2026-07-17 | [link](../decisions.md#2026-07-17--channel-health-on-the-api--the-raw-playoutcount-fact-on-the-list-dto-not-a-derived-status-enum-72) | +| `api.channel-health-object` | `ChannelResponseModel`/`ChannelDetailResponseModel` carry a server-derived `health` object (`ChannelHealthResponseModel { Status, Faults[], PlayoutCount, BrokenSourceItemCount }`) computed **read-time** from the built timeline (`Playout.BuildStatus` + upcoming `PlayoutItem → MediaItem.State`, `Finish >= now`), kind-agnostic across all 5 `PlayoutScheduleKind` values; `Status`/`Faults` are const-string classes (`ChannelHealthStatus`, `ChannelFault`), not C# enums, so the SPA hand-maintains the union (mirrors `ChannelPreviewAvailability`). This supersedes #72's "raw fact only, no derived enum, empty-schedule/broken-source deliberately not computed" stance now that the auto-tune taxonomy churn (#383/#384) it was waiting on has landed (see `channel.origin-marker` sibling record, #414). | 2026-07-23 | [link](../decisions.md#2026-07-23--channel-health--a-server-derived-health-object-on-the-channel-dtos-built-timeline-detection-415) | | `api.channel-preview-capability` | Whether a channel can be previewed in the browser is declared by the server, not derived by the SPA, as an additive `Preview` field (`{Availability, ManifestUrl, UnavailableReason}`) on `ChannelResponseModel`. | 2026-07-21 | [link](../decisions.md#2026-07-21--browser-channel-preview-is-a-server-declared-per-channel-capability-60) | | `api.decode-by-id` | Endpoints that decode/expand opaque stored state accept a database row id and resolve it server-side rather than round-tripping client-supplied serialized state. | 2026-07-07 | [link](../decisions.md#2026-07-07--decode-style-endpoints-take-a-row-id-and-look-up-server-side) | | `api.from-lineup-clear-to-none` | `POST /api/v1/channels/from-lineup` (and the Auto-Tune per-channel `advanced`, which reuses the same DTO) distinguishes *inherit* from *clear-to-none* with a typed `clear` enum list on `advanced`. A field left null/omitted still inherits the template value (unchanged for every existing client); naming a field in `clear` forces it to none on the new channel even when the template sets one. Sending both a set value and a clear for the same field is a validation error. | 2026-07-21 | [link](../decisions.md#2026-07-21--from-lineup-advanced-overrides-express-clear-to-none-via-a-typed-clear-enum-list-135) | diff --git a/docs/decisions/archive/api.md b/docs/decisions/archive/api.md new file mode 100644 index 000000000..f1a3d8883 --- /dev/null +++ b/docs/decisions/archive/api.md @@ -0,0 +1,56 @@ +# Archive — API surface / DTO conventions + +Superseded/retired records for `/api/v1` DTO shape and channel-resource decisions. See +`docs/decisions/archive/README.md` for the archive's general rules (rationale kept verbatim, never +in the active read-path). Active successor for channel health: `api.channel-health-object` in +`docs/decisions.md`. + +--- + +## 2026-07-17 — Channel health on the API = the raw `PlayoutCount` fact on the list DTO, not a derived status enum (#72) +`key: api.channel-health-signal` · `status: superseded` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: api.channel-health-object@2026-07-23` +**Rule:** (superseded) Channel health rides `ChannelResponseModel`/`ChannelListItem` DTOs as a raw `int PlayoutCount` fact (free — `GetAll` already `Include`s `Playouts`), not a new endpoint, not `/channels/state` (runtime-liveness cadence), and not a derived `ChannelHealth` enum (would freeze policy before the #383/#384 auto-tune status taxonomy lands). +**Signals:** channel health, PlayoutCount, config-derived vs runtime-liveness cadence, frozen /api/v1 · paths: `ChannelRepository.GetChannel`, `Mapper.GetPlayoutsCount`, `api-conventions.md` §3a · issues: #72, #383, #384, #401 +**Mechanics:** superseded by `api.channel-health-object` (ersatztv#415); see `docs/decisions.md` → that record for the `health` object that replaces `PlayoutCount`-as-verdict + +#72 asks for per-channel status in the Channels list, "especially channels that will fail to play". + +**Where it lives: `ChannelResponseModel` (the lean list DTO), not a new endpoint and not +`/channels/state`.** Channel health is *config-derived* — it changes when someone edits a playout, not +tick to tick — whereas `/channels/state` is the fast-poll runtime-liveness feed (`OnAir` = someone is +streaming *right now*). Folding health into the polled feed would recompute rarely-changing data every +tick and mix two cadences in one DTO; a third endpoint is over-engineering for one derived integer on a +list whose consumer already reads it. Precedent: `api-conventions.md` §3a stamps the server-derived +`IsLocked` onto `PlayoutListItemResponseModel` for exactly this reason. **It is also free**: `GetAll` +already `Include`s `Playouts` and `MirrorSourceChannel.Playouts` and was discarding them, so no extra +query and no N+1 on a large lineup. + +**A raw fact (`int PlayoutCount`), not a `ChannelHealth` enum.** v1 has exactly one trustworthy negative +signal, and the auto-tune arc (#383/#384) is about to churn the status taxonomy (origin, auto-tune +outcomes) — freezing a server-side enum now guarantees a breaking rev of a frozen-additive `/api/v1` +surface. `PlayoutCount` mirrors the long-standing `ChannelViewModel.PlayoutCount` 1:1, is a fact rather +than a policy, and leaves the SPA to derive `0 ⇒ "No playout"` in one predicate. + +**What v1 deliberately does NOT compute** — each was considered and ruled out, so don't "finish" them +without reading this: +- **Empty schedule behind an existing playout.** `EmptyScheduleHealthCheck` only understands **Classic** + `ProgramSchedule` playouts. Block, Sequential, Scripted and ExternalJson channels have no + `ProgramSchedule` at all, so a badge driven off that query would be silently absent or wrong for four of + the five schedule kinds — the #71 "verify a shared primitive covers ALL variants" trap. Needs a + per-kind emptiness notion first. +- **Broken / missing source.** `FileNotFound`/`Unavailable` are server-wide media-item counts with no + channel attribution; mapping media → collection → schedule → channel is a project, not a field. +- **User-defined vs auto-generated origin** (#72 scope item a). No honest signal exists: + `Channel` has no origin column, and `ChannelPlayoutSource.Generated` is a *playout-strategy* value that + SPA-created blank channels also carry, so it would mislabel them. Requires a new column + a dual-provider + migration, and provenance belongs to the auto-tune arc that stamps it at creation. A join through the + `"Channel Lineups"` system playlist group was **rejected**: it is a heuristic that breaks the moment a + user edits the channel. Deferred to a follow-up blocked on the auto-tune backend. + +Keeping all three out held #72 to a **read-path-only** change: no migration, no write-handler live-E2E. + +**Corrected in passing:** `ChannelRepository.GetChannel` never included `Playouts`, so +`GET /api/v1/channels/{id}` reported `playoutCount: 0` for every channel, which silently disabled the +channel editor's playout-source guard. Both call sites now share `Mapper.GetPlayoutsCount` (Mirror-aware). +The related *silent* server-side coercion of Mirror→Generated (a 200 that discards the caller's intent, +against the §3 "surface it, don't silently filter" rule) is filed as **#401**, not fixed here. diff --git a/docs/domain-model.md b/docs/domain-model.md index 5efa5d2f9..16eb8295f 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -81,7 +81,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe | **PlayoutItem** | One materialized, built entry in a playout's timeline (the actual thing that will play at a given time). | `PlayoutItem` | (generated, not directly edited) | | **PlayoutHistory** | Rotation/rerun bookkeeping per block (`BlockId`) + collection `Key`/`ChildKey`, used by block-playout schedulers to avoid repeats; inspectable via Troubleshooting. | `PlayoutHistory` | `/app/troubleshooting/blocks` | | **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand — OnDemand is the per-channel "resume where I left off" freeze/thaw mode, #68; see `channels.md` → On-demand resume), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel`, `/app/auto-tune` (bulk-generate from library metadata, #69; per-channel DetailPanel content-source members read via `GET /api/v1/channels/auto-tune/members`, #384; per-channel `templateId`/`advanced`/`logo` overrides accepted by `POST /api/v1/channels/auto-tune`, #385; per-source rotation weights + query corrections via an optional `sources: [{sourceId, weight, excluded}]` on that same request, #425 — a customized channel is backed by a system-owned `MultiCollection` of per-source `SmartCollection`s with `PlaybackOrder.WeightedShuffle`, `OwnedByChannelId`-tagged so it's hidden from collection lists and cleaned up on channel delete) | -| **Channel health / `PlayoutCount`** (#72) | Whether a channel can play at all. `PlayoutCount` (channel's own playouts, **plus the mirror source's** when `PlayoutSource is Mirror` — computed by `Mapper.GetPlayoutsCount`) rides on both `ChannelResponseModel` (list) and `ChannelDetailResponseModel`; `0` ⇒ the channel can never play, rendered as a "No playout" badge + a matching **No playout** filter on the channels list (both name only the one fault the API can prove — a broader "Problems" label would read as a false all-clear to a user whose *other* fault classes below are uncomputed). It is a **raw fact, not a status enum** — see `decisions.md` 2026-07-17. Distinct from `/api/v1/channels/state`'s `OnAir`, which is runtime liveness ("someone is streaming right now"), not "would play if tuned". Empty-schedule and broken-source faults are still deliberately **not** computed (#415 — unsafe today; see that decision entry). Auto-tuned-vs-user **origin** *is* now computed, but as immutable creation-provenance rather than a health signal — see the **Channel origin** row below. | `Channel.Playouts` | `/app/channels` (read-only signal) | +| **Channel health** (`health`, #415; supersedes the #72 raw-`PlayoutCount` stance) | Whether a channel will actually play, as one server-derived verdict. `ChannelHealthResponseModel { Status, Faults[], PlayoutCount, BrokenSourceItemCount }` rides on both `ChannelResponseModel` (list) and `ChannelDetailResponseModel`, computed **read-time** (not cached at build time — `MediaItem.State` flips on scan, not build) from the **built timeline**: `Playout.BuildStatus` (`NeverBuilt`/`BuildFailed`) + upcoming `PlayoutItem`s with `Finish >= now` (`EmptyUpcoming` when none; `BrokenSource` when any point at a `FileNotFound`/`Unavailable` `MediaItem`), plus the retained `PlayoutCount` fact (`NoPlayout` when 0, mirror-aware via `Mapper.GetPlayoutsCount`). Kind-agnostic by construction — the built timeline has the same shape for all 5 `PlayoutScheduleKind` values, closing the #71 "shared primitive must cover ALL variants" gap the old Classic-only `EmptyScheduleHealthCheck` had. An **assessable gate** applies first: for `PlayoutMode == OnDemand`, the absence signals `NeverBuilt`/`EmptyUpcoming` are suppressed (an idle on-demand playout legitimately has no fresh build) and contribute `Unknown`, never a false `Problems`; the presence signals `BuildFailed`/`BrokenSource` stay live in every mode. Rollup → `Status ∈ {Healthy, Problems, Unknown}`; SPA shows a single **Problems** filter/count plus a per-fault badge. Costed as one bounded `GROUP BY PlayoutId` aggregate query, not per-row N+1. Distinct from `/api/v1/channels/state`'s `OnAir`, which is runtime liveness ("someone is streaming right now"), not "would play if tuned". See `decisions.md` → `api.channel-health-object` (#415; supersedes `api.channel-health-signal`). Auto-tuned-vs-user **origin** is a separate, non-health signal — see the **Channel origin** row below. | `Channel.Playouts`, `Playout.BuildStatus`, `PlayoutItem` → `MediaItem.State` | `/app/channels` (Problems filter + per-fault badges) | | **Channel origin** (#414) | Immutable creation-provenance: `Channel.Origin` (`ChannelOrigin` enum — `Unknown`/`UserCreated`/`AutoTuned`) records **how the row was born** and is stamped once at insert (`AutoTuned` in `CreateChannelFromLineupHandler`, the single primitive bulk auto-tune delegates to; `UserCreated` in `CreateChannelHandler`). A later user edit never changes it (so "auto-generated then user-edited" stays `AutoTuned`; any future "diverged from its auto-tune template" signal belongs to the auto-tune arc, not this column). `Unknown` is the honest default for rows predating the column — provenance is **not** back-filled (inferring it from the `"Channel Lineups"` playlist group is the heuristic #414 rejected). Surfaced as a raw `origin` on `ChannelResponseModel` (the SPA badges only `AutoTuned`; absence ⇒ user-created or legacy). | `Channel.Origin` | `/app/channels` (read-only badge) | | **Guide / EPG (XMLTV)** | Per-channel programme guide generated from playout items; channels with `ShowInEpg=false` are excluded. The SPA's JSON guide grid is built by the sibling `GetChannelGuideDataHandler`. Both the JSON guide (`ChannelGuideChannelResponseModel`) and the channels-list DTO (`ChannelResponseModel`) expose a rooted, directly-usable `Logo` URL (#464) via `Mapper.GetLogoUrl` — `/iptv/logos/{file}` for an uploaded logo, the absolute URL passed through for an external one, `null` when unset (SPA then draws the generated initials "bug"). | `GetChannelGuideHandler`, `GetChannelGuideDataHandler` | `/app/guide` (viewer); settings at `/app/settings/xmltv` | | **M3U** | The channel lineup playlist Jellyfin/Dispatcharr consume. | `ChannelPlaylist.ToM3U()` | — | diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md index 6b95569d4..d375682be 100644 --- a/docs/spa-conventions.md +++ b/docs/spa-conventions.md @@ -559,3 +559,31 @@ Both are shell chrome — no screen participates. `beforeEach` **seeds both groups open** (`ctv-sidebar-groups`); the default-collapsed / accordion / rail behavior is covered in its own `describe('collapsible sidebar (#396)')`, and the persistence helpers have a colocated `sidebarState.test.ts`. + +## 14. Server-derived rollup verdict: one filter, per-item fault badges (#415) + +When the server folds several fault classes into one verdict object (see `api-conventions.md` → +"server-derived rollup verdict" and `decisions.md` → `api.channel-health-object`), the SPA gets **one** +list filter/count off the rollup `status`, never a filter per individual fault: + +- **One rollup filter, not N fault filters.** `ChannelsScreen.tsx`'s `problems` filter/count reads only + `health.status === 'Problems'` (`hasProblems(channel)`); it replaced the earlier single-fault + `willNeverPlay`/"No playout" filter now that the taxonomy is real (`#415` superseded the "don't + broaden the label" comment that guarded against a false all-clear). Do not add a second filter per + fault class — that re-fragments the one signal the rollup exists to unify. +- **Per-row badges still name the specific fault(s)**, spelling out `health.faults` (`No playout` / + `Never built` / `Build failed` / `Empty` / `Broken source`) rather than a generic "Problems" chip — + the rollup unifies the *filter*, not the *detail* a user reads on a given row. `Unknown` renders no + badge (it is deliberately not `Healthy` and not `Problems`). +- **Defensive coercion at the read boundary, even though the generated type is non-null.** `health` is in + the OpenAPI `required` list, so the generated `ChannelSummary['health']` types as + `{status, faults, playoutCount, brokenSourceItemCount}` — not nullable (contrast + `core-dtos-generate-nullable-in-spa`, which covers *other* Core DTO fields that do generate + `| null | undefined`). Still always read it through optional chaining with an explicit fallback at the + point of use (`channel.health?.status`, `channel.health?.faults ?? []`) rather than asserting it + non-null — a genuinely absent `health` (e.g. an older cached response predating this field) must read + as "no verdict", never crash or silently read as `Healthy`. +- **Status/fault string unions are hand-maintained**, matching `ChannelPreviewAvailability`: the server + emits plain strings backed by a `const string` class (`ChannelHealthStatus`, `ChannelFault`), not a + generated enum, so the SPA's local TS union (`'Healthy' | 'Problems' | 'Unknown'`, and the fault + union) is kept in sync by hand when the server adds a value. diff --git a/docs/superpowers/plans/2026-07-23-channel-fault-detection.md b/docs/superpowers/plans/2026-07-23-channel-fault-detection.md new file mode 100644 index 000000000..07ee36b8c --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-channel-fault-detection.md @@ -0,0 +1,903 @@ +# 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. diff --git a/docs/superpowers/specs/2026-07-23-channel-fault-detection-design.md b/docs/superpowers/specs/2026-07-23-channel-fault-detection-design.md new file mode 100644 index 000000000..5f608bd75 --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-channel-fault-detection-design.md @@ -0,0 +1,183 @@ +# Per-channel fault detection beyond "no playout" (#415) + +**Status:** approved design (2026-07-23) +**Issue:** ersatztv#415 — deferred from #72 scope item (b); bundle sibling #414 merged (#575). +**Supersedes:** the temporary "raw fact only, no derived health enum" stance of `api.channel-health-signal` (#72), which was explicitly deferred pending the auto-tune status taxonomy (#383, now closed). + +## Problem + +The channels list surfaces exactly one per-channel fault today: **no playout** (`PlayoutCount == 0`, +#72). Three other real fault classes are invisible: + +- **Empty schedule behind an existing playout.** The only existing detector, + `EmptyScheduleHealthCheck`, is Classic-only (keyed on `ProgramSchedule.Name`, no channel id) and is + silently absent/wrong for the other four `PlayoutScheduleKind` values (Block, Sequential, Scripted, + ExternalJson) — the #71 "shared primitive must cover ALL variants" trap. +- **Broken / missing source.** `FileNotFoundHealthCheck` / `UnavailableHealthCheck` are server-wide + `MediaItem` roll-ups with zero channel attribution. +- **Build failures / never-built playouts** are not surfaced per channel at all. + +The `willNeverPlay` comment in `ChannelsScreen.tsx` deliberately keeps the badge/filter named +"No playout" (not "Problems") until a real taxonomy exists, because a "Problems 0" reading over a +lineup that holds a dead channel is a false all-clear — worse than no affordance. + +## Chosen approach — built-timeline observation (Approach A) + +Derive **every** per-channel fault from what the scheduler has already materialized, rather than +introspecting per-kind configuration: + +- `Playout.BuildStatus` (`{ LastBuild, Success, Message }`) — never-built / build-failed signals. +- `Playout.Items` (`PlayoutItem`, the built timeline) — each item carries `MediaItemId`/`MediaItem`, + so "empty upcoming" and "broken source" both fall out of one seam. +- `Playout.OnDemandCheckpoint` — distinguishes a legitimately-idle on-demand playout from a fault. + +**Why this and not per-kind config introspection (Approach B):** the built timeline is kind-agnostic, +so all five `PlayoutScheduleKind` values are covered *by construction* — the #71 trap cannot bite. It +also yields **exact** broken-source attribution (`PlayoutItem → MediaItem.State`) with no fragile +media→collection→schedule→channel join and no query-time YAML/JSON parsing. Scripted, which has no +schedule entity to introspect, needs no special case. + +**Cost/staleness note:** `MediaItem.State` flips on **scan**, not on build, which rules out a +build-time snapshot (it would go stale after a scan). Detection is therefore **read-time**. + +### Trade-off accepted + +The badge means *"this channel won't actually play something good going forward,"* not *"its config +is empty."* A config error in an unbuilt or far-future portion of a schedule surfaces only once built. +This is the honest reading for an operator and is why unbuilt/on-demand-idle playouts report +`unknown` rather than a false `healthy`. + +## Fault taxonomy + +Evaluated per playout, rolled up per channel. "Upcoming" = `PlayoutItem.Finish >= now`. + +| Fault | Definition | Source | +|---|---|---| +| `noPlayout` | channel has 0 playouts (mirror-aware, via existing `GetPlayoutsCount`) | #72 fact, absorbed | +| `neverBuilt` | assessable playout with `BuildStatus.LastBuild == null` (see assessable gate below) | `BuildStatus`, `OnDemandCheckpoint` | +| `buildFailed` | last build `Success == false` (`Message` retained for detail) | `BuildStatus` | +| `emptyUpcoming` | built OK but **0 upcoming `PlayoutItem`s** | `Playout.Items` window | +| `brokenSource` | ≥1 upcoming `PlayoutItem` with `MediaItem.State ∈ {FileNotFound, Unavailable}` | `PlayoutItem → MediaItem.State` | + +### The "assessable" gate (applied first, per playout) + +Distinguish **absence** signals (a fault inferred from *missing* content) from **presence** signals +(a fault proven by content that *is* there and is bad): + +- **Absence signals — `neverBuilt`, `emptyUpcoming`** — are suppressed for an **on-demand channel** + (`PlayoutMode == OnDemand`), because an on-demand playout legitimately has no fresh build and drains + its built timeline between tune-ins. Suppressing them is what prevents a false-positive storm across + on-demand channels. A suppressed absence signal contributes `unknown`, never a false `problems`. +- **Presence signals — `buildFailed`, `brokenSource`** — stay live in **all** modes: a failed build or + an upcoming item that points at a `FileNotFound`/`Unavailable` `MediaItem` is a real problem whether + the channel is continuous or on-demand (these only fire when the bad thing actually exists, so they + cannot false-positive on idleness). + +Operationally: `isOnDemand = channel.PlayoutMode == ChannelPlayoutMode.OnDemand`. For a continuous +channel every fault applies. For an on-demand channel, `neverBuilt`/`emptyUpcoming` are dropped; +`buildFailed`/`brokenSource` still apply. + +### Rollup → `status` + +- `problems` — any fault fired on any assessable contributing playout. +- `healthy` — has ≥1 assessable playout, all built OK, non-empty upcoming, no broken upcoming items. +- `unknown` — nothing assessable to judge (e.g. only idle on-demand playouts). Never a false + `healthy` and never a false `problems`. + +Multi-playout / mirror channels: `status = problems` if *any* contributing playout has a problem; +otherwise `healthy` if *any* contributing playout is assessable-and-clean; otherwise `unknown`. +`faults` = the union across contributing playouts. Mirror channels evaluate the mirror source's +playouts, consistent with `GetPlayoutsCount`. + +### Accepted judgment defaults + +1. **Window = `Finish >= now`** (current + future built items) — no arbitrary N-hour horizon. + Index-friendly via the existing `PlayoutItem (Start, Finish)` index; the scheduler builds ahead, so + a healthy channel always has upcoming items. +2. **On-demand idle → `unknown`, not a fault.** +3. **Successfully-built playout whose entire timeline is now in the past → `emptyUpcoming`.** Honest + but potentially noisy (a stale build pending its next rebuild reads as empty). Shipped as a fault; + limitation documented. + +## API design + +Fold a server-derived `health` object onto the channel resource so **SPA and MCP read one +authoritative verdict** — machine clients get health in the same `list channels` / `get channel` call +they already make, with no second endpoint to correlate by id. Additive to the frozen `/api/v1`. + +```jsonc +"health": { + "status": "problems", // healthy | problems | unknown + "faults": ["emptyUpcoming", "brokenSource"], + "playoutCount": 2, // existing #72 fact, retained + "brokenSourceItemCount": 7 // underlying raw count for machine triage +} +``` + +- Added to `ChannelResponseModel` (list) and `ChannelDetailResponseModel` (detail) via + `Mapper.ProjectToResponseModel` / `ProjectToDetailResponseModel`. +- `PlayoutCount` remains for backward compatibility (additive-only freeze); the SPA and MCP migrate to + `health`. +- OpenAPI regenerated (`./scripts/update-openapi.sh`), then `npm run generate:api` for `v1.d.ts`. + +## Server implementation + +- `ChannelRepository.GetAll`: add `.ThenInclude(p => p.BuildStatus)` on `Playouts` (and on + `MirrorSourceChannel.Playouts`). +- **One bounded aggregate query** (not an N+1): `GROUP BY PlayoutId` over `PlayoutItem` where + `Finish >= now`, projecting `(totalUpcoming, brokenUpcoming)` where `brokenUpcoming` counts + `MediaItem.State ∈ {FileNotFound, Unavailable}`. Returned as a `Dictionary` + keyed by playout id. +- `Mapper.GetHealth(Channel channel, IReadOnlyDictionary upcoming)` derives the object; + `GetAllChannelsHandler` and the detail handler pass the aggregate in. +- Cost measured on a realistic lineup per Done-when; add a `(PlayoutId, Finish)` index only if the + aggregate shows up as hot (noted as a tuning follow-up, not a blocker). + +## SPA implementation (`web/src/screens/ChannelsScreen.tsx`) + +- Replace the `noplayout` filter with a single `problems` filter reading `health.status === 'problems'`; + count = channels whose status is `problems`. +- Per-row badge text names the specific fault (No playout / Never built / Build failed / Empty / + Broken source), preserving the existing "spell out the fault" convention. +- Rewrite the `willNeverPlay` predicate/comment: it becomes `hasProblems(channel)`; the comment's + "do not broaden the label until the taxonomy exists" line is replaced with a pointer to this design + + the decision record, since the taxonomy now exists. +- Coerce nullable Core-DTO fields at the hook boundary per `core-dtos-generate-nullable-in-spa`. + +## Testing + +- **Unit — derivation:** one test per fault class, **plus one per `PlayoutScheduleKind`** proving the + seam is kind-agnostic (the #71 guard: an empty Block/Sequential/Scripted/ExternalJson playout flags + `emptyUpcoming` exactly like Classic). On-demand-idle → `unknown`; multi-playout/mirror rollup. +- **Unit/integration — aggregate query:** the `GROUP BY` returns correct `(total, broken)`; no N+1 + (single query for the whole list). +- **SPA:** `ChannelsScreen.test.tsx` — `problems` filter + count, per-fault badge text, `unknown` + renders no false badge. +- **Live-E2E** (`scripts/e2e-local.sh`, fresh config dir): build a channel → `healthy`; induce an + empty upcoming window and a `FileNotFound` item → `problems` with the right faults + count. Curl the + channel endpoints; do not drive a browser tab. + +## Docs to update (same PR) + +- `docs/decisions.md` — supersede `api.channel-health-signal`: record the derived `health` object + + the built-timeline approach + the three accepted defaults. +- `docs/domain-model.md` — health derivation from the built timeline; update the `PlayoutCount` row. +- `docs/api-conventions.md` — the `health` object shape on the channel DTOs. +- `docs/spa-conventions.md` — the `problems` filter + per-fault badge convention. + +## Out of scope + +- Config-time / pre-build emptiness detection (Approach B) — explicitly not built. +- A dedicated `(PlayoutId, Finish)` index unless the cost measurement demands it. +- Any change to the server-wide health checks (`EmptyScheduleHealthCheck` etc.) — left as-is; this + work is purely additive on the channel resource. + +## Done-when (from #415) + +- [ ] Per-channel "empty schedule" signal correct for all 5 `PlayoutScheduleKind` values (unbuilt/ + on-demand report `unknown`, never `healthy`). +- [ ] Broken-source attribution implemented (built-timeline seam; decision recorded here). +- [ ] Cost measured — no N+1 on a large lineup. +- [ ] SPA filter renamed to "Problems"; `willNeverPlay` comment/predicate updated. +- [ ] `docs/decisions.md` + `docs/domain-model.md` (+ api/spa conventions) updated. +- [ ] Adversarial review passed. diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 76bfc5545..5e4ad129b 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -202,6 +202,7 @@ export interface components { "isEnabled": boolean; "showInEpg": boolean; "graphicsElementIds": Array; + "health": components["schemas"]["ChannelHealthResponseModel"]; }; "ChannelGuideChannelResponseModel": { "number": string; @@ -221,6 +222,12 @@ export interface components { "start": string; "end": string; "channels": Array; + }; + "ChannelHealthResponseModel": { + "status": string; + "faults": Array; + "playoutCount": number; + "brokenSourceItemCount": number; }; "ChannelIdleBehavior": "StopOnDisconnect" | "KeepRunning"; "ChannelLogoResponseModel": { @@ -260,6 +267,7 @@ export interface components { "logo": null | string; "preview": components["schemas"]["ChannelPreviewResponseModel"]; "origin": components["schemas"]["ChannelOrigin"]; + "health": components["schemas"]["ChannelHealthResponseModel"]; }; "ChannelSongVideoMode": "Default" | "WithProgress"; "ChannelStateResponseModel": { diff --git a/web/src/screens/ChannelsScreen.test.tsx b/web/src/screens/ChannelsScreen.test.tsx index 2c1b1357f..8c721b209 100644 --- a/web/src/screens/ChannelsScreen.test.tsx +++ b/web/src/screens/ChannelsScreen.test.tsx @@ -49,8 +49,11 @@ function channelRow(overrides: Record = {}): Record { expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/channels/state', expect.any(Object)); }); - // #72: a channel with no playout can never play. This is the fault state the issue calls - // "the important one" — it must be visible without hovering. + // #72/#415: a channel with no playout can never play. This is the fault state the issue calls + // "the important one" — it must be visible without hovering. The rollup filter is now "Problems" + // (server-derived health.status), but the per-row badge still spells out the specific fault. it('flags a channel with no playout and lets the lineup be filtered down to the faults', async () => { mockApi({ channels: [ channelRow({ id: 1, name: 'Healthy', number: '1', playoutCount: 1 }), - channelRow({ id: 2, name: 'Broken', number: '2', playoutCount: 0 }) + channelRow({ + id: 2, + name: 'Broken', + number: '2', + playoutCount: 0, + health: { status: 'Problems', faults: ['NoPlayout'], playoutCount: 0, brokenSourceItemCount: 0 } + }) ] }); render(); const table = await screen.findByRole('table', { name: 'Channels lineup' }); - // Scoped to the table: the filter button carries the same label, and an unscoped getByText - // matches both (spa-conventions §6). Visible text, not a title-only affordance. + // Scoped to the table: the filter button carries a different label ("Problems"), so an unscoped + // getByText would still be unambiguous, but scoping keeps this test symmetric with the others. expect(within(table).getByText('No playout')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'No playout 1' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Problems 1' })).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'No playout 1' })); + fireEvent.click(screen.getByRole('button', { name: 'Problems 1' })); expect(screen.getByText('Broken')).toBeInTheDocument(); expect(screen.queryByText('Healthy')).not.toBeInTheDocument(); @@ -259,7 +269,44 @@ describe('ChannelsScreen — load + render', () => { // Scoped: the filter button always renders this label, so an unscoped query would find it // even when no row is flagged, making the assertion meaningless. expect(within(table).queryByText('No playout')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'No playout 0' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Problems 0' })).toBeInTheDocument(); + }); + + // #415: the Problems rollup covers the full server-derived fault taxonomy, not just NoPlayout. + 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(); + }); + + // #415: 'Unknown' health (e.g. OnDemand channels the health check can't evaluate yet) must not be + // mistaken for a proven fault — no badge, and it doesn't count toward "Problems". + 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(); }); it('badges an auto-tuned channel and leaves user-created and legacy rows unmarked', async () => { diff --git a/web/src/screens/ChannelsScreen.tsx b/web/src/screens/ChannelsScreen.tsx index 70ce5cc46..dca711919 100644 --- a/web/src/screens/ChannelsScreen.tsx +++ b/web/src/screens/ChannelsScreen.tsx @@ -31,22 +31,24 @@ import { navigateToPath } from '../routing'; import { usePrimaryAction } from '../primaryAction'; import { ChannelPreviewPanel } from './channels/ChannelPreviewPanel'; -type ChannelViewFilter = 'all' | 'onair' | 'disabled' | 'noplayout'; +type ChannelViewFilter = 'all' | 'onair' | 'disabled' | 'problems'; -// #72: a channel with no playout can never play — the dominant "misconfigured lineup" case, and the -// only per-channel failure the API can answer cheaply today. A mirror channel relays its source's -// playouts, and the server already folds those into playoutCount, so mirrors are not false-flagged. -// -// Deliberately NOT covered: an empty schedule behind an existing playout, and broken/missing sources -// (see decisions.md 2026-07-17 for why approximating either would be wrong today). Because those -// fault classes are real but invisible here, the badge AND the filter are both named for the single -// fault this can actually prove — "No playout", never "Problems". A "Problems 0" reading on a lineup -// that holds a dead empty-schedule channel is a false all-clear, which is worse than no affordance. -// Do not broaden the label until the taxonomy behind it exists. -function willNeverPlay(channel: ChannelSummary): boolean { - return channel.playoutCount === 0; +// #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-object. +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' +}; + const streamingModeBadgeTones: Record = { 'HLS Direct': 'neutral', 'HLS Segmenter': 'accent', @@ -212,7 +214,7 @@ export function ChannelsScreen() { const statesById = stateByChannelId(query.data.channelStates); const onAirCount = query.data.channelStates.filter((state) => state.onAir).length; const disabledCount = channels.filter((channel) => !channel.isEnabled).length; - const noPlayoutCount = channels.filter(willNeverPlay).length; + const problemsCount = channels.filter(hasProblems).length; const visibleChannels = channels.filter((channel) => { if (filter === 'onair') { return statesById.get(channel.id)?.onAir === true; @@ -222,8 +224,8 @@ export function ChannelsScreen() { return !channel.isEnabled; } - if (filter === 'noplayout') { - return willNeverPlay(channel); + if (filter === 'problems') { + return hasProblems(channel); } return true; @@ -429,7 +431,7 @@ export function ChannelsScreen() { - + {onAirCount} on air @@ -613,7 +615,9 @@ function ChannelTableRow({ user chose deliberately, whereas this one is a fault they need to notice and act on. A tooltip-only glyph would be invisible on touch and easy to miss in a long lineup. */} - {willNeverPlay(channel) && No playout} + {(channel.health?.faults ?? []).map((fault) => ( + {FAULT_LABELS[fault] ?? fault} + ))} {/* Creation-provenance marker. Rendered only for AutoTuned: its absence means user-created (or a legacy row predating the origin column, which reads as 'Unknown' — we never