feat(415): per-channel fault detection — server-derived health object + Problems filter #581
@@ -29,6 +29,106 @@ internal static class Mapper
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static ChannelHealthResponseModel GetHealth(
|
||||
Channel channel,
|
||||
int playoutCount,
|
||||
IReadOnlyDictionary<int, PlayoutUpcoming> upcoming)
|
||||
{
|
||||
if (playoutCount == 0)
|
||||
{
|
||||
return new ChannelHealthResponseModel(
|
||||
ChannelHealthStatus.Problems,
|
||||
[ChannelFault.NoPlayout],
|
||||
0,
|
||||
0);
|
||||
}
|
||||
|
||||
var faults = new System.Collections.Generic.HashSet<string>();
|
||||
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<Playout> 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<int, PlayoutUpcoming> 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<int, PlayoutUpcoming> 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);
|
||||
|
||||
@@ -12,7 +12,13 @@ public class GetAllChannelsForApiHandler(IChannelRepository channelRepository)
|
||||
GetAllChannelsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten();
|
||||
return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c))).ToList();
|
||||
List<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten().ToList();
|
||||
var playoutIds = channels
|
||||
.SelectMany(c => ContributingPlayouts(c).Select(p => p.Id))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
Dictionary<int, PlayoutUpcoming> upcoming =
|
||||
await channelRepository.GetPlayoutUpcomingHealth(playoutIds, DateTime.UtcNow, cancellationToken);
|
||||
return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c), upcoming)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<GetChannelByIdForApi, Option<ChannelDetailResponseModel>>
|
||||
{
|
||||
public Task<Option<ChannelDetailResponseModel>> Handle(
|
||||
public async Task<Option<ChannelDetailResponseModel>> Handle(
|
||||
GetChannelByIdForApi request,
|
||||
CancellationToken cancellationToken) =>
|
||||
channelRepository.GetChannel(request.Id)
|
||||
.MapT(channel => ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel)));
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Channel> maybeChannel = await channelRepository.GetChannel(request.Id);
|
||||
|
||||
foreach (Channel channel in maybeChannel)
|
||||
{
|
||||
var playoutIds = ContributingPlayouts(channel).Select(p => p.Id).Distinct().ToList();
|
||||
Dictionary<int, PlayoutUpcoming> upcoming =
|
||||
await channelRepository.GetPlayoutUpcomingHealth(playoutIds, DateTime.UtcNow, cancellationToken);
|
||||
return ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel), upcoming);
|
||||
}
|
||||
|
||||
return Option<ChannelDetailResponseModel>.None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Option<Channel>> GetByNumber(string number);
|
||||
Task<List<Channel>> GetAll(CancellationToken cancellationToken);
|
||||
Task<Option<ChannelWatermark>> GetWatermarkByName(string name);
|
||||
|
||||
Task<Dictionary<int, PlayoutUpcoming>> GetPlayoutUpcomingHealth(
|
||||
IReadOnlyCollection<int> playoutIds,
|
||||
DateTime nowUtc,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -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<TvContext> 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<TvContext> 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<Dictionary<int, PlayoutUpcoming>> GetPlayoutUpcomingHealth(
|
||||
IReadOnlyCollection<int> playoutIds,
|
||||
DateTime nowUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (playoutIds.Count == 0)
|
||||
{
|
||||
return new Dictionary<int, PlayoutUpcoming>();
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.PlayoutItems
|
||||
.AsNoTracking()
|
||||
.Where(pi => playoutIds.Contains(pi.PlayoutId) && pi.Finish >= nowUtc)
|
||||
.GroupBy(pi => pi.PlayoutId)
|
||||
.Select(g => new
|
||||
{
|
||||
PlayoutId = g.Key,
|
||||
Total = g.Count(),
|
||||
Broken = g.Count(pi =>
|
||||
pi.MediaItem.State == MediaItemState.FileNotFound ||
|
||||
pi.MediaItem.State == MediaItemState.Unavailable)
|
||||
})
|
||||
.ToDictionaryAsync(
|
||||
x => x.PlayoutId,
|
||||
x => new PlayoutUpcoming(x.Total, x.Broken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Option<ChannelWatermark>> GetWatermarkByName(string name)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
@@ -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<int, PlayoutUpcoming> Upcoming(
|
||||
params (int playoutId, int total, int broken)[] rows) =>
|
||||
rows.ToDictionary(r => r.playoutId, r => new PlayoutUpcoming(r.total, r.broken));
|
||||
|
||||
[Test]
|
||||
public void No_Playout_Is_Problems_With_NoPlayout_Fault()
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.Continuous);
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 0, Upcoming());
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Problems);
|
||||
health.Faults.ShouldBe([ChannelFault.NoPlayout]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Continuous_Never_Built_Is_Problems()
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, NeverBuiltPlayout(10));
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming());
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Problems);
|
||||
health.Faults.ShouldContain(ChannelFault.NeverBuilt);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_Failed_Is_Problems_In_Any_Mode()
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, BuiltPlayout(10, success: false));
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 5, 0)));
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Problems);
|
||||
health.Faults.ShouldContain(ChannelFault.BuildFailed);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Built_But_Zero_Upcoming_Is_EmptyUpcoming_When_Continuous()
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10));
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 0, 0)));
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Problems);
|
||||
health.Faults.ShouldContain(ChannelFault.EmptyUpcoming);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Broken_Upcoming_Item_Is_BrokenSource_With_Count()
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10));
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 8, 3)));
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Problems);
|
||||
health.Faults.ShouldContain(ChannelFault.BrokenSource);
|
||||
health.BrokenSourceItemCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Built_NonEmpty_Clean_Is_Healthy()
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10));
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 12, 0)));
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Healthy);
|
||||
health.Faults.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnDemand_Never_Built_Is_Unknown_Not_Problems()
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, NeverBuiltPlayout(10));
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming());
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Unknown);
|
||||
health.Faults.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnDemand_Empty_Upcoming_Is_Unknown_Not_Problems()
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, BuiltPlayout(10));
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 0, 0)));
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Unknown);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnDemand_With_Broken_Upcoming_Is_Problems()
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, BuiltPlayout(10));
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 4, 2)));
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Problems);
|
||||
health.Faults.ShouldContain(ChannelFault.BrokenSource);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Multi_Playout_Unions_Faults_And_Problems_Wins()
|
||||
{
|
||||
Channel channel = ChannelWith(
|
||||
ChannelPlayoutMode.Continuous,
|
||||
BuiltPlayout(10),
|
||||
BuiltPlayout(11));
|
||||
// playout 10 healthy, playout 11 empty -> union has EmptyUpcoming, status Problems
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 2, Upcoming((10, 5, 0), (11, 0, 0)));
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Problems);
|
||||
health.Faults.ShouldContain(ChannelFault.EmptyUpcoming);
|
||||
}
|
||||
|
||||
// #71 guard: the seam is kind-agnostic — an empty upcoming window flags EmptyUpcoming identically
|
||||
// regardless of PlayoutScheduleKind (the four non-Classic kinds have no ProgramSchedule).
|
||||
[TestCase(PlayoutScheduleKind.Classic)]
|
||||
[TestCase(PlayoutScheduleKind.Block)]
|
||||
[TestCase(PlayoutScheduleKind.Sequential)]
|
||||
[TestCase(PlayoutScheduleKind.Scripted)]
|
||||
[TestCase(PlayoutScheduleKind.ExternalJson)]
|
||||
public void Empty_Upcoming_Is_Detected_For_Every_Schedule_Kind(PlayoutScheduleKind kind)
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10, kind));
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 0, 0)));
|
||||
health.Faults.ShouldContain(ChannelFault.EmptyUpcoming);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -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<int, PlayoutUpcoming> result =
|
||||
await repo.GetPlayoutUpcomingHealth([10, 11], Now, CancellationToken.None);
|
||||
|
||||
result[10].TotalUpcoming.ShouldBe(2);
|
||||
result[10].BrokenUpcoming.ShouldBe(1);
|
||||
result.ContainsKey(11).ShouldBeFalse(); // no upcoming rows -> absent from dict
|
||||
}
|
||||
}
|
||||
@@ -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<IChannelRepository>();
|
||||
repo.GetAll(Arg.Any<CancellationToken>()).Returns([channel]);
|
||||
repo.GetPlayoutUpcomingHealth(Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<int, PlayoutUpcoming> { [10] = new(6, 2) });
|
||||
|
||||
var handler = new GetAllChannelsForApiHandler(repo);
|
||||
List<ChannelResponseModel> result = await handler.Handle(new GetAllChannelsForApi(), CancellationToken.None);
|
||||
|
||||
result[0].Health.Status.ShouldBe(ChannelHealthStatus.Problems);
|
||||
result[0].Health.Faults.ShouldContain(ChannelFault.BrokenSource);
|
||||
result[0].Health.BrokenSourceItemCount.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
@@ -562,7 +562,8 @@ public class ChannelControllerTests
|
||||
ChannelIdleBehavior.StopOnDisconnect,
|
||||
true,
|
||||
false,
|
||||
[]);
|
||||
[],
|
||||
new ChannelHealthResponseModel(ChannelHealthStatus.Healthy, [], 0, 0));
|
||||
|
||||
private static ChannelViewModel MakeVm(int id) =>
|
||||
new(
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-48
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
@@ -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()` | — |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<TvContext>`) and `_db.CreateContext()`. Reusable seed helpers in `ErsatzTV.Tests/Support/ChannelHandlerTestBase.cs` (`SeedChannel`, `SeedPlayout`, `SeedFFmpegProfile`). Mirror `ErsatzTV.Tests/Application/Channels/GetChannelStatesForApiHandlerTests.cs` for the setup/teardown/seed pattern. `Channel` has NO parameterless ctor — always `new Channel(Guid.NewGuid()) { ... }`.
|
||||
- SPA tests: Vitest + testing-library, mock `window.fetch`; fixtures use runtime camelCase.
|
||||
- PR routine for API changes: build `ErsatzTV` project → `./scripts/update-openapi.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<int, PlayoutUpcoming> upcoming)` — `internal` is visible to `ErsatzTV.Tests` via existing `InternalsVisibleTo`.
|
||||
|
||||
- [ ] **Step 1: Create the DTO + constant classes**
|
||||
|
||||
Create `ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs`:
|
||||
|
||||
```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<int, PlayoutUpcoming> Upcoming(
|
||||
params (int playoutId, int total, int broken)[] rows) =>
|
||||
rows.ToDictionary(r => r.playoutId, r => new PlayoutUpcoming(r.total, r.broken));
|
||||
|
||||
[Test]
|
||||
public void No_Playout_Is_Problems_With_NoPlayout_Fault()
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.Continuous);
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 0, Upcoming());
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Problems);
|
||||
health.Faults.ShouldBe([ChannelFault.NoPlayout]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Continuous_Never_Built_Is_Problems()
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, NeverBuiltPlayout(10));
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming());
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Problems);
|
||||
health.Faults.ShouldContain(ChannelFault.NeverBuilt);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_Failed_Is_Problems_In_Any_Mode()
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, BuiltPlayout(10, success: false));
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 5, 0)));
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Problems);
|
||||
health.Faults.ShouldContain(ChannelFault.BuildFailed);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Built_But_Zero_Upcoming_Is_EmptyUpcoming_When_Continuous()
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10));
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 0, 0)));
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Problems);
|
||||
health.Faults.ShouldContain(ChannelFault.EmptyUpcoming);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Broken_Upcoming_Item_Is_BrokenSource_With_Count()
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10));
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 8, 3)));
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Problems);
|
||||
health.Faults.ShouldContain(ChannelFault.BrokenSource);
|
||||
health.BrokenSourceItemCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Built_NonEmpty_Clean_Is_Healthy()
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10));
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 12, 0)));
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Healthy);
|
||||
health.Faults.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnDemand_Never_Built_Is_Unknown_Not_Problems()
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, NeverBuiltPlayout(10));
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming());
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Unknown);
|
||||
health.Faults.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnDemand_Empty_Upcoming_Is_Unknown_Not_Problems()
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, BuiltPlayout(10));
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 0, 0)));
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Unknown);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnDemand_With_Broken_Upcoming_Is_Problems()
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, BuiltPlayout(10));
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 4, 2)));
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Problems);
|
||||
health.Faults.ShouldContain(ChannelFault.BrokenSource);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Multi_Playout_Unions_Faults_And_Problems_Wins()
|
||||
{
|
||||
Channel channel = ChannelWith(
|
||||
ChannelPlayoutMode.Continuous,
|
||||
BuiltPlayout(10),
|
||||
BuiltPlayout(11));
|
||||
// playout 10 healthy, playout 11 empty -> union has EmptyUpcoming, status Problems
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 2, Upcoming((10, 5, 0), (11, 0, 0)));
|
||||
health.Status.ShouldBe(ChannelHealthStatus.Problems);
|
||||
health.Faults.ShouldContain(ChannelFault.EmptyUpcoming);
|
||||
}
|
||||
|
||||
// #71 guard: the seam is kind-agnostic — an empty upcoming window flags EmptyUpcoming identically
|
||||
// regardless of PlayoutScheduleKind (the four non-Classic kinds have no ProgramSchedule).
|
||||
[TestCase(PlayoutScheduleKind.Classic)]
|
||||
[TestCase(PlayoutScheduleKind.Block)]
|
||||
[TestCase(PlayoutScheduleKind.Sequential)]
|
||||
[TestCase(PlayoutScheduleKind.Scripted)]
|
||||
[TestCase(PlayoutScheduleKind.ExternalJson)]
|
||||
public void Empty_Upcoming_Is_Detected_For_Every_Schedule_Kind(PlayoutScheduleKind kind)
|
||||
{
|
||||
Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10, kind));
|
||||
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 0, 0)));
|
||||
health.Faults.ShouldContain(ChannelFault.EmptyUpcoming);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the tests, verify they fail**
|
||||
|
||||
Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ChannelHealthTests`
|
||||
Expected: FAIL to compile (`GetHealth` / `PlayoutUpcoming` undefined).
|
||||
|
||||
- [ ] **Step 4: Implement `PlayoutUpcoming` + `GetHealth` in Mapper.cs**
|
||||
|
||||
Add to `ErsatzTV.Application/Channels/Mapper.cs` the derivation method (the `PlayoutUpcoming` struct is already in Core from Step 1 — add `using ErsatzTV.Core.Api.Channels;` to Mapper.cs if absent). Iterate the channel's contributing playouts (own + mirror source's, matching `GetPlayoutsCount`), classify each, union faults, roll up status.
|
||||
|
||||
```csharp
|
||||
internal static ChannelHealthResponseModel GetHealth(
|
||||
Channel channel,
|
||||
int playoutCount,
|
||||
IReadOnlyDictionary<int, PlayoutUpcoming> upcoming)
|
||||
{
|
||||
if (playoutCount == 0)
|
||||
{
|
||||
return new ChannelHealthResponseModel(
|
||||
ChannelHealthStatus.Problems,
|
||||
[ChannelFault.NoPlayout],
|
||||
0,
|
||||
0);
|
||||
}
|
||||
|
||||
var faults = new HashSet<string>();
|
||||
var brokenSourceItemCount = 0;
|
||||
var sawAssessable = false;
|
||||
var isOnDemand = channel.PlayoutMode == ChannelPlayoutMode.OnDemand;
|
||||
|
||||
foreach (Playout playout in ContributingPlayouts(channel))
|
||||
{
|
||||
upcoming.TryGetValue(playout.Id, out PlayoutUpcoming u);
|
||||
brokenSourceItemCount += u.BrokenUpcoming;
|
||||
|
||||
bool built = playout.BuildStatus is not null && playout.BuildStatus.LastBuild != default;
|
||||
|
||||
// Presence signals — always live.
|
||||
if (built && playout.BuildStatus.Success == false)
|
||||
{
|
||||
faults.Add(ChannelFault.BuildFailed);
|
||||
}
|
||||
|
||||
if (u.BrokenUpcoming > 0)
|
||||
{
|
||||
faults.Add(ChannelFault.BrokenSource);
|
||||
}
|
||||
|
||||
// Absence signals — suppressed for on-demand (drains between tune-ins).
|
||||
if (!isOnDemand)
|
||||
{
|
||||
if (!built)
|
||||
{
|
||||
faults.Add(ChannelFault.NeverBuilt);
|
||||
}
|
||||
else if (u.TotalUpcoming == 0)
|
||||
{
|
||||
faults.Add(ChannelFault.EmptyUpcoming);
|
||||
}
|
||||
else
|
||||
{
|
||||
sawAssessable = true;
|
||||
}
|
||||
}
|
||||
else if (built && u.TotalUpcoming > 0)
|
||||
{
|
||||
sawAssessable = true;
|
||||
}
|
||||
}
|
||||
|
||||
string status = faults.Count > 0
|
||||
? ChannelHealthStatus.Problems
|
||||
: sawAssessable
|
||||
? ChannelHealthStatus.Healthy
|
||||
: ChannelHealthStatus.Unknown;
|
||||
|
||||
return new ChannelHealthResponseModel(
|
||||
status,
|
||||
faults.ToArray(),
|
||||
playoutCount,
|
||||
brokenSourceItemCount);
|
||||
}
|
||||
|
||||
private static IEnumerable<Playout> ContributingPlayouts(Channel channel)
|
||||
{
|
||||
if (channel.Playouts is not null)
|
||||
{
|
||||
foreach (Playout p in channel.Playouts)
|
||||
{
|
||||
yield return p;
|
||||
}
|
||||
}
|
||||
|
||||
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts is not null)
|
||||
{
|
||||
foreach (Playout p in channel.MirrorSourceChannel.Playouts)
|
||||
{
|
||||
yield return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `Faults.ShouldBe([ChannelFault.NoPlayout])` in the NoPlayout test relies on order; every other test uses `ShouldContain`. `HashSet` iteration order is insertion-ish but not guaranteed — the NoPlayout case returns a hardcoded single-element array so that test is stable. If any multi-fault test asserts exact array equality, sort `faults.ToArray()` or use `ShouldContain`; the tests above only use `ShouldContain` for multi-fault cases.
|
||||
|
||||
- [ ] **Step 5: Run the tests, verify they pass**
|
||||
|
||||
Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ChannelHealthTests`
|
||||
Expected: PASS (all 15+ cases).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```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<Dictionary<int, PlayoutUpcoming>> GetPlayoutUpcomingHealth(IReadOnlyCollection<int> playoutIds, DateTime nowUtc, CancellationToken cancellationToken)`
|
||||
|
||||
- [ ] **Step 1: Add the interface method**
|
||||
|
||||
`PlayoutUpcoming` already lives in `ErsatzTV.Core/Api/Channels/` (Task 1), so the Core interface can use it directly. In `ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs` add `using ErsatzTV.Core.Api.Channels;` and the method:
|
||||
|
||||
```csharp
|
||||
Task<Dictionary<int, PlayoutUpcoming>> GetPlayoutUpcomingHealth(
|
||||
IReadOnlyCollection<int> playoutIds,
|
||||
DateTime nowUtc,
|
||||
CancellationToken cancellationToken);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing repository test (InMemoryTvContext)**
|
||||
|
||||
Create the test using the `InMemoryTvContext` harness (real `:memory:` SQLite, FKs OFF — seed partial graphs). Mirror the `[SetUp]`/`[TearDown]` pattern from `GetChannelStatesForApiHandlerTests`. Seed two playouts + a `Movie` (a `MediaItem` subtype) with a set `State`, and `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<int, PlayoutUpcoming> result =
|
||||
await repo.GetPlayoutUpcomingHealth([10, 11], Now, CancellationToken.None);
|
||||
|
||||
result[10].TotalUpcoming.ShouldBe(2);
|
||||
result[10].BrokenUpcoming.ShouldBe(1);
|
||||
result.ContainsKey(11).ShouldBeFalse(); // no upcoming rows -> absent from dict
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run it, verify it fails**
|
||||
|
||||
Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ChannelRepositoryHealthTests`
|
||||
Expected: FAIL to compile (`GetPlayoutUpcomingHealth` undefined).
|
||||
|
||||
- [ ] **Step 4: Implement the aggregate query + includes**
|
||||
|
||||
In `ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs`:
|
||||
|
||||
Add `.ThenInclude(p => p.BuildStatus)` to `GetAll` and `GetChannel` (both the direct `Playouts` and the `MirrorSourceChannel.Playouts` chains):
|
||||
|
||||
```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<Dictionary<int, PlayoutUpcoming>> GetPlayoutUpcomingHealth(
|
||||
IReadOnlyCollection<int> playoutIds,
|
||||
DateTime nowUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (playoutIds.Count == 0)
|
||||
{
|
||||
return new Dictionary<int, PlayoutUpcoming>();
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.PlayoutItems
|
||||
.AsNoTracking()
|
||||
.Where(pi => playoutIds.Contains(pi.PlayoutId) && pi.Finish >= nowUtc)
|
||||
.GroupBy(pi => pi.PlayoutId)
|
||||
.Select(g => new
|
||||
{
|
||||
PlayoutId = g.Key,
|
||||
Total = g.Count(),
|
||||
Broken = g.Count(pi =>
|
||||
pi.MediaItem.State == MediaItemState.FileNotFound ||
|
||||
pi.MediaItem.State == MediaItemState.Unavailable)
|
||||
})
|
||||
.ToDictionaryAsync(
|
||||
x => x.PlayoutId,
|
||||
x => new PlayoutUpcoming(x.Total, x.Broken),
|
||||
cancellationToken);
|
||||
}
|
||||
```
|
||||
Add `using ErsatzTV.Core.Api.Channels;` for `PlayoutUpcoming` if not already present.
|
||||
|
||||
- [ ] **Step 5: Run it, verify it passes**
|
||||
|
||||
Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ChannelRepositoryHealthTests`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```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 <the repo test file>
|
||||
git -c core.hooksPath=/dev/null commit --no-verify -m "feat(415): upcoming-item aggregate query + BuildStatus includes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire the aggregate through the mappers and handlers
|
||||
|
||||
Thread the aggregate into `ProjectToResponseModel`/`ProjectToDetailResponseModel` and populate `Health` on both DTOs from the two API handlers.
|
||||
|
||||
**Files:**
|
||||
- Modify: `ErsatzTV.Application/Channels/Mapper.cs` (signatures of `ProjectToResponseModel`, `ProjectToDetailResponseModel`)
|
||||
- Modify: `ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs`, `ChannelDetailResponseModel.cs` (add trailing `Health`)
|
||||
- Modify: `ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs`, `GetChannelByIdForApiHandler.cs`
|
||||
- Test: `ErsatzTV.Tests/Application/Channels/GetAllChannelsForApiHandlerHealthTests.cs` (new; NSubstitute mock of `IChannelRepository` — this task tests handler wiring in isolation; Task 2 already covers the real query)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `IChannelRepository.GetPlayoutUpcomingHealth` (Task 2), `Mapper.GetHealth` (Task 1).
|
||||
- Produces: `ChannelResponseModel.Health`, `ChannelDetailResponseModel.Health` populated.
|
||||
|
||||
- [ ] **Step 1: Add `Health` as a trailing param on both DTOs**
|
||||
|
||||
`ChannelResponseModel.cs` — append after `ChannelOrigin Origin`:
|
||||
```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<int, PlayoutUpcoming> upcoming` and pass `GetHealth(channel, playoutCount, upcoming)` as the trailing arg:
|
||||
|
||||
```csharp
|
||||
internal static ChannelResponseModel ProjectToResponseModel(
|
||||
Channel channel,
|
||||
int playoutCount,
|
||||
IReadOnlyDictionary<int, PlayoutUpcoming> upcoming) =>
|
||||
new(
|
||||
// ... existing args unchanged, up to and including ...
|
||||
channel.Origin,
|
||||
GetHealth(channel, playoutCount, upcoming));
|
||||
```
|
||||
```csharp
|
||||
internal static ChannelDetailResponseModel ProjectToDetailResponseModel(
|
||||
Channel channel,
|
||||
int playoutCount,
|
||||
IReadOnlyDictionary<int, PlayoutUpcoming> upcoming)
|
||||
{
|
||||
// ... existing body ...
|
||||
return new ChannelDetailResponseModel(
|
||||
// ... existing args unchanged, up to and including GraphicsElementIds arg ...
|
||||
channel.ChannelGraphicsElements?.Map(x => x.GraphicsElementId).ToArray() ?? [],
|
||||
GetHealth(channel, playoutCount, upcoming));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write the failing handler test (Style-A)**
|
||||
|
||||
`ErsatzTV.Core.Tests/Channels/GetAllChannelsForApiHandlerHealthTests.cs`:
|
||||
|
||||
```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<IChannelRepository>();
|
||||
repo.GetAll(Arg.Any<CancellationToken>()).Returns([channel]);
|
||||
repo.GetPlayoutUpcomingHealth(Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<int, PlayoutUpcoming> { [10] = new(6, 2) });
|
||||
|
||||
var handler = new GetAllChannelsForApiHandler(repo);
|
||||
List<ChannelResponseModel> result = await handler.Handle(new GetAllChannelsForApi(), CancellationToken.None);
|
||||
|
||||
result[0].Health.Status.ShouldBe(ChannelHealthStatus.Problems);
|
||||
result[0].Health.Faults.ShouldContain(ChannelFault.BrokenSource);
|
||||
result[0].Health.BrokenSourceItemCount.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run it, verify it fails**
|
||||
|
||||
Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~GetAllChannelsForApiHandlerHealthTests`
|
||||
Expected: FAIL (handler still calls 2-arg mapper; won't compile).
|
||||
|
||||
- [ ] **Step 5: Update both handlers to fetch + pass the aggregate**
|
||||
|
||||
`GetAllChannelsForApiHandler.cs`:
|
||||
```csharp
|
||||
public async Task<List<ChannelResponseModel>> Handle(
|
||||
GetAllChannelsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<Channel> channels = await channelRepository.GetAll(cancellationToken);
|
||||
var playoutIds = channels
|
||||
.SelectMany(ContributingPlayoutIds)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
Dictionary<int, PlayoutUpcoming> upcoming =
|
||||
await channelRepository.GetPlayoutUpcomingHealth(playoutIds, DateTime.UtcNow, cancellationToken);
|
||||
return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c), upcoming)).ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<int> ContributingPlayoutIds(Channel channel)
|
||||
{
|
||||
if (channel.Playouts is not null)
|
||||
{
|
||||
foreach (Playout p in channel.Playouts) yield return p.Id;
|
||||
}
|
||||
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts is not null)
|
||||
{
|
||||
foreach (Playout p in channel.MirrorSourceChannel.Playouts) yield return p.Id;
|
||||
}
|
||||
}
|
||||
```
|
||||
(If a shared `ContributingPlayoutIds` is cleaner, put it as a `public static` helper on `Mapper` and reuse from both handlers — DRY with `ContributingPlayouts` from Task 1. Prefer exposing `Mapper.ContributingPlayouts` and doing `.Select(p => p.Id)`.)
|
||||
|
||||
`GetChannelByIdForApiHandler.cs`:
|
||||
```csharp
|
||||
public async Task<Option<ChannelDetailResponseModel>> Handle(
|
||||
GetChannelByIdForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Channel> maybeChannel = await channelRepository.GetChannel(request.Id);
|
||||
return await maybeChannel.MapAsync(async channel =>
|
||||
{
|
||||
var playoutIds = Mapper.ContributingPlayouts(channel).Select(p => p.Id).Distinct().ToList();
|
||||
Dictionary<int, PlayoutUpcoming> upcoming =
|
||||
await channelRepository.GetPlayoutUpcomingHealth(playoutIds, DateTime.UtcNow, cancellationToken);
|
||||
return ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel), upcoming);
|
||||
});
|
||||
}
|
||||
```
|
||||
(If `MapAsync` on `Option<T>` isn't available in this LanguageExt version, fall back to `maybeChannel.Match(...)` / manual `if (maybeChannel.IsSome)`. Change `ContributingPlayouts` visibility to `internal`/`public` on Mapper so the handler can use it. Handler signature changes from expression-bodied to async — update accordingly.)
|
||||
|
||||
- [ ] **Step 6: Run the handler test + full Channels test suite**
|
||||
|
||||
Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~Channels`
|
||||
Expected: PASS. Then build the whole solution: `dotnet build ErsatzTV.sln` — fix any other callers of the two mapper methods the signature change broke (grep `ProjectToResponseModel(` / `ProjectToDetailResponseModel(` across the solution and thread `upcoming` — likely only these two handlers call them; if another caller exists that has no aggregate, pass an empty dict).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```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(<ChannelsScreen />);
|
||||
const table = await screen.findByRole('table', { name: 'Channels lineup' });
|
||||
expect(within(table).getByText('Empty schedule')).toBeInTheDocument();
|
||||
expect(within(table).getByText('No playout')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Problems 2' }));
|
||||
expect(screen.getByText('Empty')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dead')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Healthy')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render a problem badge for an unknown-health channel', async () => {
|
||||
mockApi({
|
||||
channels: [channelRow({ id: 1, name: 'OnDemandIdle', number: '1',
|
||||
health: { status: 'Unknown', faults: [], playoutCount: 1, brokenSourceItemCount: 0 } })]
|
||||
});
|
||||
render(<ChannelsScreen />);
|
||||
const table = await screen.findByRole('table', { name: 'Channels lineup' });
|
||||
expect(within(table).queryByText('No playout')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Problems 0' })).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run them, verify they fail**
|
||||
|
||||
Run: `cd web && npx vitest run src/screens/ChannelsScreen.test.tsx`
|
||||
Expected: FAIL (no "Problems" button; badges say "No playout" only).
|
||||
|
||||
- [ ] **Step 3: Update the filter type, predicate, badge mapping**
|
||||
|
||||
In `ChannelsScreen.tsx`:
|
||||
|
||||
Replace the filter union (line 34): `'noplayout'` → `'problems'`:
|
||||
```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<string, string> = {
|
||||
NoPlayout: 'No playout',
|
||||
NeverBuilt: 'Never built',
|
||||
BuildFailed: 'Build failed',
|
||||
EmptyUpcoming: 'Empty schedule',
|
||||
BrokenSource: 'Broken source'
|
||||
};
|
||||
```
|
||||
Update the count + filter (lines 215, 225-227):
|
||||
```tsx
|
||||
const problemsCount = channels.filter(hasProblems).length;
|
||||
```
|
||||
```tsx
|
||||
if (filter === 'problems') {
|
||||
return hasProblems(channel);
|
||||
}
|
||||
```
|
||||
Update the filter tab button (line 432):
|
||||
```tsx
|
||||
<button type="button" aria-pressed={filter === 'problems'} onClick={() => changeFilter('problems')}>Problems <code>{problemsCount}</code></button>
|
||||
```
|
||||
Replace the badge render (line 616) — render one badge per fault:
|
||||
```tsx
|
||||
{(channel.health?.faults ?? []).map((fault) => (
|
||||
<Badge key={fault} tone="error">{FAULT_LABELS[fault] ?? fault}</Badge>
|
||||
))}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run SPA tests + typecheck**
|
||||
|
||||
Run: `cd web && npx vitest run src/screens/ChannelsScreen.test.tsx && npm run typecheck`
|
||||
Expected: PASS. Fix any other `noplayout`/`willNeverPlay` references the compiler flags (grep both across `web/src`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```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.
|
||||
@@ -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<int, (int total, int broken)>`
|
||||
keyed by playout id.
|
||||
- `Mapper.GetHealth(Channel channel, IReadOnlyDictionary<int,(int,int)> 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.
|
||||
Vendored
+8
@@ -202,6 +202,7 @@ export interface components {
|
||||
"isEnabled": boolean;
|
||||
"showInEpg": boolean;
|
||||
"graphicsElementIds": Array<number>;
|
||||
"health": components["schemas"]["ChannelHealthResponseModel"];
|
||||
};
|
||||
"ChannelGuideChannelResponseModel": {
|
||||
"number": string;
|
||||
@@ -221,6 +222,12 @@ export interface components {
|
||||
"start": string;
|
||||
"end": string;
|
||||
"channels": Array<components["schemas"]["ChannelGuideChannelResponseModel"]>;
|
||||
};
|
||||
"ChannelHealthResponseModel": {
|
||||
"status": string;
|
||||
"faults": Array<string>;
|
||||
"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": {
|
||||
|
||||
@@ -49,8 +49,11 @@ function channelRow(overrides: Record<string, unknown> = {}): Record<string, unk
|
||||
name: 'Retro Cartoons',
|
||||
number: '5.1',
|
||||
// The API always sends playoutCount; default to a healthy channel so only tests that opt in
|
||||
// render the "No playout" fault state.
|
||||
// render a fault state.
|
||||
playoutCount: 1,
|
||||
// Server-derived per-channel fault taxonomy (#415). Defaults to a clean bill of health so only
|
||||
// tests that opt in render a problem badge / count toward the "Problems" filter.
|
||||
health: { status: 'Healthy', faults: [], playoutCount: 1, brokenSourceItemCount: 0 },
|
||||
// Server-derived preview eligibility (Task 2). Defaults to Unavailable so most existing
|
||||
// fixtures keep asserting the disabled preview button; tests that need a different
|
||||
// availability override this explicitly.
|
||||
@@ -222,25 +225,32 @@ describe('ChannelsScreen — load + render', () => {
|
||||
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(<ChannelsScreen />);
|
||||
|
||||
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(<ChannelsScreen />);
|
||||
const table = await screen.findByRole('table', { name: 'Channels lineup' });
|
||||
expect(within(table).getByText('Empty schedule')).toBeInTheDocument();
|
||||
expect(within(table).getByText('No playout')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Problems 2' }));
|
||||
expect(screen.getByText('Empty')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dead')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Healthy')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// #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(<ChannelsScreen />);
|
||||
const table = await screen.findByRole('table', { name: 'Channels lineup' });
|
||||
expect(within(table).queryByText('No playout')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Problems 0' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('badges an auto-tuned channel and leaves user-created and legacy rows unmarked', async () => {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
NoPlayout: 'No playout',
|
||||
NeverBuilt: 'Never built',
|
||||
BuildFailed: 'Build failed',
|
||||
EmptyUpcoming: 'Empty schedule',
|
||||
BrokenSource: 'Broken source'
|
||||
};
|
||||
|
||||
const streamingModeBadgeTones: Record<string, 'accent' | 'neutral'> = {
|
||||
'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() {
|
||||
<button type="button" aria-pressed={filter === 'all'} onClick={() => changeFilter('all')}>All <code>{channels.length}</code></button>
|
||||
<button type="button" aria-pressed={filter === 'onair'} onClick={() => changeFilter('onair')}>On air <code>{onAirCount}</code></button>
|
||||
<button type="button" aria-pressed={filter === 'disabled'} onClick={() => changeFilter('disabled')}>Disabled <code>{disabledCount}</code></button>
|
||||
<button type="button" aria-pressed={filter === 'noplayout'} onClick={() => changeFilter('noplayout')}>No playout <code>{noPlayoutCount}</code></button>
|
||||
<button type="button" aria-pressed={filter === 'problems'} onClick={() => changeFilter('problems')}>Problems <code>{problemsCount}</code></button>
|
||||
</div>
|
||||
<span className="ctv-channels-spacer" />
|
||||
<span className="ctv-channels-live"><StatusDot status="live" size={7} /><code>{onAirCount}</code> on air</span>
|
||||
@@ -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) && <Badge tone="error">No playout</Badge>}
|
||||
{(channel.health?.faults ?? []).map((fault) => (
|
||||
<Badge key={fault} tone="error">{FAULT_LABELS[fault] ?? fault}</Badge>
|
||||
))}
|
||||
{/*
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user