diff --git a/ErsatzTV.Application/Channels/Mapper.cs b/ErsatzTV.Application/Channels/Mapper.cs index f425a8dae..e5178f24b 100644 --- a/ErsatzTV.Application/Channels/Mapper.cs +++ b/ErsatzTV.Application/Channels/Mapper.cs @@ -29,6 +29,97 @@ 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; + 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; + } + } + } + internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) => new( channel.Id, diff --git a/ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs b/ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs new file mode 100644 index 000000000..e6ab8e183 --- /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-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); diff --git a/ErsatzTV.Tests/Application/Channels/ChannelHealthTests.cs b/ErsatzTV.Tests/Application/Channels/ChannelHealthTests.cs new file mode 100644 index 000000000..3f95cae49 --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/ChannelHealthTests.cs @@ -0,0 +1,155 @@ +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); + } +}