fix(72): report a real per-channel playout count and flag channels that will never play #405
@@ -1,4 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Subtitles;
|
||||
@@ -176,6 +176,13 @@ public class UpdateChannelHandler(
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), CancellationToken.None);
|
||||
}
|
||||
|
||||
// Deliberately NOT Mapper.GetPlayoutsCount: this handler's query (see Handle) doesn't include
|
||||
// MirrorSourceChannel, so the shared helper would read that navigation as null and return the
|
||||
// same own-playouts-only count anyway — with a false air of Mirror-awareness. Harmless today
|
||||
// because ChannelController discards this view model and re-projects through
|
||||
// GetChannelByIdForApi, so this count never reaches the wire. If you ever return it directly,
|
||||
// fix the QUERY first (add the MirrorSourceChannel ThenInclude) — swapping in the helper alone
|
||||
// would report 0 playouts for a working mirror channel.
|
||||
return ProjectToViewModel(c, c.Playouts?.Count ?? 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -6,6 +6,29 @@ namespace ErsatzTV.Application.Channels;
|
||||
|
||||
internal static class Mapper
|
||||
{
|
||||
/// <summary>
|
||||
/// A mirror channel has no playouts of its own; it relays the playouts of its mirror source, so both must be
|
||||
/// counted for the total to answer "can this channel play anything?". Requires <see cref="Channel.Playouts" />
|
||||
/// and, for mirrors, <see cref="Channel.MirrorSourceChannel" />.<see cref="Channel.Playouts" /> to be included
|
||||
/// by the query — the repository reads are AsNoTracking, so an un-included navigation silently counts zero.
|
||||
/// </summary>
|
||||
internal static int GetPlayoutsCount(Channel channel)
|
||||
{
|
||||
var result = 0;
|
||||
|
||||
if (channel.Playouts != null)
|
||||
{
|
||||
result += channel.Playouts.Count;
|
||||
}
|
||||
|
||||
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts != null)
|
||||
{
|
||||
result += channel.MirrorSourceChannel.Playouts.Count;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) =>
|
||||
new(
|
||||
channel.Id,
|
||||
@@ -73,7 +96,7 @@ internal static class Mapper
|
||||
channel.ShowInEpg);
|
||||
}
|
||||
|
||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
|
||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel, int playoutCount) =>
|
||||
new(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
@@ -85,7 +108,8 @@ internal static class Mapper
|
||||
channel.PreferredAudioLanguageCode,
|
||||
GetStreamingMode(channel),
|
||||
channel.IsEnabled,
|
||||
channel.ShowInEpg);
|
||||
channel.ShowInEpg,
|
||||
playoutCount);
|
||||
|
||||
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
|
||||
new(resolution.Height, resolution.Width);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
@@ -13,6 +13,6 @@ public class GetAllChannelsForApiHandler(IChannelRepository channelRepository)
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten();
|
||||
return channels.Map(ProjectToResponseModel).ToList();
|
||||
return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c))).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
@@ -11,21 +11,4 @@ public class GetAllChannelsHandler(IChannelRepository channelRepository)
|
||||
await channelRepository.GetAll(cancellationToken)
|
||||
.Map(list => list.Where(c => c.IsEnabled || request.ShowDisabled)
|
||||
.Map(c => ProjectToViewModel(c, GetPlayoutsCount(c))).ToList());
|
||||
|
||||
private static int GetPlayoutsCount(Channel channel)
|
||||
{
|
||||
var result = 0;
|
||||
|
||||
if (channel.Playouts != null)
|
||||
{
|
||||
result += channel.Playouts.Count;
|
||||
}
|
||||
|
||||
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts != null)
|
||||
{
|
||||
result += channel.MirrorSourceChannel.Playouts.Count;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ public class GetChannelByIdForApiHandler(IChannelRepository channelRepository)
|
||||
GetChannelByIdForApi request,
|
||||
CancellationToken cancellationToken) =>
|
||||
channelRepository.GetChannel(request.Id)
|
||||
.MapT(channel => ProjectToDetailResponseModel(channel, channel.Playouts?.Count ?? 0));
|
||||
.MapT(channel => ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel)));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#nullable enable
|
||||
#nullable enable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
@@ -15,4 +15,5 @@ public record ChannelResponseModel(
|
||||
string Language,
|
||||
string StreamingMode,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg);
|
||||
bool ShowInEpg,
|
||||
int PlayoutCount);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -14,6 +14,9 @@ public class ChannelRepository(IDbContextFactory<TvContext> dbContextFactory) :
|
||||
.Include(c => c.FFmpegProfile)
|
||||
.Include(c => c.Artwork)
|
||||
.Include(c => c.Watermark)
|
||||
.Include(c => c.Playouts)
|
||||
.Include(c => c.MirrorSourceChannel)
|
||||
.ThenInclude(mc => mc.Playouts)
|
||||
.OrderBy(c => c.Id)
|
||||
.SingleOrDefaultAsync(c => c.Id == id)
|
||||
.Map(Optional);
|
||||
|
||||
@@ -151,7 +151,8 @@ public class OpenApiSerializerContractTests
|
||||
"en",
|
||||
"TransportStream",
|
||||
true,
|
||||
true);
|
||||
true,
|
||||
2);
|
||||
|
||||
private static string FindOpenApiDocument()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Infrastructure;
|
||||
|
||||
// #72: the channels API reports a per-channel playout count so the SPA can flag a channel that will
|
||||
// never play. GetChannel omitted the Playouts include, and the read is AsNoTracking with no lazy-loading
|
||||
// proxies, so the navigation came back empty and every caller's count collapsed to 0 — GET
|
||||
// /api/v1/channels/{id} reported playoutCount: 0 for every channel on the system. That silently disabled
|
||||
// the channel editor's playout-source guard (ChannelEditScreen "Cannot be changed once a generated
|
||||
// channel has a playout"), which is gated on playoutCount > 0.
|
||||
//
|
||||
// These run the REAL repository against a real context on purpose: a handler test with a substituted
|
||||
// IChannelRepository hands the mapper a channel whose Playouts the test itself populated, so it passes
|
||||
// whether or not the query includes them. Only a repository-level read can catch a missing include.
|
||||
[TestFixture]
|
||||
public class ChannelRepositoryPlayoutIncludeTests
|
||||
{
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_repository = new ChannelRepository(_db.Factory);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private InMemoryTvContext _db = null!;
|
||||
private ChannelRepository _repository = null!;
|
||||
|
||||
// FFmpegProfileId is non-nullable, so GetChannel's Include(c => c.FFmpegProfile) is an INNER JOIN —
|
||||
// a channel with no profile row is filtered out of the result entirely. Every fixture needs a real one.
|
||||
private async Task<int> SeedFFmpegProfile()
|
||||
{
|
||||
var profile = new FFmpegProfile { Name = "Test Profile" };
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.FFmpegProfiles.Add(profile);
|
||||
await context.SaveChangesAsync();
|
||||
return profile.Id;
|
||||
}
|
||||
|
||||
private async Task<int> SeedChannel(Channel channel)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.Channels.Add(channel);
|
||||
await context.SaveChangesAsync();
|
||||
return channel.Id;
|
||||
}
|
||||
|
||||
private async Task<ChannelDetailResponseModel> GetDetail(int channelId)
|
||||
{
|
||||
var handler = new GetChannelByIdForApiHandler(_repository);
|
||||
Option<ChannelDetailResponseModel> result =
|
||||
await handler.Handle(new GetChannelByIdForApi(channelId), CancellationToken.None);
|
||||
return result.IfNone(() => throw new InvalidOperationException($"channel {channelId} not found"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetChannel_Should_Include_Playouts_So_The_Detail_Api_Reports_A_Real_Count()
|
||||
{
|
||||
int profileId = await SeedFFmpegProfile();
|
||||
int channelId = await SeedChannel(
|
||||
new Channel(Guid.NewGuid())
|
||||
{
|
||||
Number = "1",
|
||||
Name = "Generated",
|
||||
PlayoutSource = ChannelPlayoutSource.Generated,
|
||||
FFmpegProfileId = profileId,
|
||||
Artwork = [],
|
||||
Playouts = [new Playout(), new Playout()]
|
||||
});
|
||||
|
||||
ChannelDetailResponseModel detail = await GetDetail(channelId);
|
||||
|
||||
detail.PlayoutCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetChannel_Should_Count_The_Mirror_Sources_Playouts_For_A_Mirror_Channel()
|
||||
{
|
||||
// A mirror channel owns no playouts; it relays its source's. Counting only its own navigation
|
||||
// would report 0 and wrongly flag a working mirror channel as "will never play".
|
||||
int profileId = await SeedFFmpegProfile();
|
||||
int sourceId = await SeedChannel(
|
||||
new Channel(Guid.NewGuid())
|
||||
{
|
||||
Number = "1",
|
||||
Name = "Source",
|
||||
PlayoutSource = ChannelPlayoutSource.Generated,
|
||||
FFmpegProfileId = profileId,
|
||||
Artwork = [],
|
||||
Playouts = [new Playout()]
|
||||
});
|
||||
|
||||
int mirrorId = await SeedChannel(
|
||||
new Channel(Guid.NewGuid())
|
||||
{
|
||||
Number = "2",
|
||||
Name = "Mirror",
|
||||
PlayoutSource = ChannelPlayoutSource.Mirror,
|
||||
MirrorSourceChannelId = sourceId,
|
||||
FFmpegProfileId = profileId,
|
||||
Artwork = [],
|
||||
Playouts = []
|
||||
});
|
||||
|
||||
ChannelDetailResponseModel detail = await GetDetail(mirrorId);
|
||||
|
||||
detail.PlayoutCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetChannel_Should_Report_Zero_For_A_Channel_With_No_Playouts()
|
||||
{
|
||||
// Boundary check on the mapper only — this one passes with or WITHOUT the Playouts include
|
||||
// (0 == 0 either way), so unlike its two siblings it is NOT include coverage. It earns its
|
||||
// place by pinning that the "will never play" signal stays 0 and never drifts vacuously
|
||||
// non-zero, which would silently retire the badge.
|
||||
int profileId = await SeedFFmpegProfile();
|
||||
int channelId = await SeedChannel(
|
||||
new Channel(Guid.NewGuid())
|
||||
{
|
||||
Number = "3",
|
||||
Name = "Empty",
|
||||
PlayoutSource = ChannelPlayoutSource.Generated,
|
||||
FFmpegProfileId = profileId,
|
||||
Artwork = [],
|
||||
Playouts = []
|
||||
});
|
||||
|
||||
ChannelDetailResponseModel detail = await GetDetail(channelId);
|
||||
|
||||
detail.PlayoutCount.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -23763,7 +23763,8 @@
|
||||
"language",
|
||||
"streamingMode",
|
||||
"isEnabled",
|
||||
"showInEpg"
|
||||
"showInEpg",
|
||||
"playoutCount"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -23801,6 +23802,10 @@
|
||||
},
|
||||
"showInEpg": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"playoutCount": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
let rows = D.channels;
|
||||
if (view === "onair") rows = rows.filter((c) => c.live);
|
||||
else if (view === "disabled") rows = rows.filter((c) => !c.enabled);
|
||||
else if (view === "noplayout") rows = rows.filter((c) => c.playouts === 0);
|
||||
|
||||
// group, preserving first-seen order
|
||||
const groups = [];
|
||||
@@ -43,11 +44,11 @@
|
||||
const toggleAll = () => { if (allOn) setSel({}); else { const n = {}; rows.forEach((c) => (n[c.num] = true)); setSel(n); } };
|
||||
const toggleOne = (num) => setSel((s) => ({ ...s, [num]: !s[num] }));
|
||||
|
||||
const counts = { all: D.channels.length, onair: D.channels.filter((c) => c.live).length, disabled: D.channels.filter((c) => !c.enabled).length };
|
||||
const counts = { all: D.channels.length, onair: D.channels.filter((c) => c.live).length, disabled: D.channels.filter((c) => !c.enabled).length, noplayout: D.channels.filter((c) => c.playouts === 0).length };
|
||||
|
||||
const Segmented = () => (
|
||||
<div style={{ display: "inline-flex", gap: 2, padding: 2, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
|
||||
{[{ v: "all", l: "All", c: counts.all }, { v: "onair", l: "On air", c: counts.onair }, { v: "disabled", l: "Disabled", c: counts.disabled }].map((o) => {
|
||||
{[{ v: "all", l: "All", c: counts.all }, { v: "onair", l: "On air", c: counts.onair }, { v: "disabled", l: "Disabled", c: counts.disabled }, { v: "noplayout", l: "No playout", c: counts.noplayout }].map((o) => {
|
||||
const on = o.v === view;
|
||||
return (
|
||||
<button key={o.v} type="button" onClick={() => setView(o.v)}
|
||||
@@ -83,6 +84,7 @@
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{c.name}</span>
|
||||
{!c.enabled && <Marker title="Disabled">D</Marker>}
|
||||
{!c.epg && <Marker title="Hidden from EPG">H</Marker>}
|
||||
{c.playouts === 0 && <Badge tone="error">No playout</Badge>}
|
||||
</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{c.lang}</div>
|
||||
</div>
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
|
||||
window.CTV_DATA = {
|
||||
channels: [
|
||||
{ num: "1.1", name: "Retro Cartoons", group: "Cartoons", lang: "English", mode: "HLS Segmenter", profile: "1080p H.264", live: true, enabled: true, epg: true },
|
||||
{ num: "1.2", name: "Anime Block", group: "Cartoons", lang: "Japanese",mode: "HLS Segmenter", profile: "1080p H.264", live: false, enabled: true, epg: true },
|
||||
{ num: "2.1", name: "News 24", group: "News", lang: "English", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true },
|
||||
{ num: "3.1", name: "Late Night Movies",group: "Movies", lang: "English", mode: "MPEG-TS", profile: "1080p HEVC", live: false, enabled: true, epg: true },
|
||||
{ num: "7.3", name: "Sci-Fi Marathon", group: "Movies", lang: "English", mode: "MPEG-TS", profile: "1080p H.264", live: false, enabled: true, epg: false },
|
||||
{ num: "4.2", name: "Music Videos", group: "Music", lang: "English", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true },
|
||||
{ num: "5.1", name: "Nature Docs", group: "Docs", lang: "Spanish", mode: "HLS Direct", profile: "—", live: false, enabled: true, epg: true },
|
||||
{ num: "9.1", name: "Kids Block", group: "Kids", lang: "French", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true },
|
||||
{ num: "12.1", name: "Test Pattern", group: "System", lang: "English", mode: "MPEG-TS", profile: "480p H.264", live: false, enabled: false, epg: false },
|
||||
{ num: "1.1", name: "Retro Cartoons", group: "Cartoons", lang: "English", mode: "HLS Segmenter", profile: "1080p H.264", live: true, enabled: true, epg: true, playouts: 1 },
|
||||
{ num: "1.2", name: "Anime Block", group: "Cartoons", lang: "Japanese",mode: "HLS Segmenter", profile: "1080p H.264", live: false, enabled: true, epg: true, playouts: 1 },
|
||||
{ num: "2.1", name: "News 24", group: "News", lang: "English", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true, playouts: 1 },
|
||||
{ num: "3.1", name: "Late Night Movies",group: "Movies", lang: "English", mode: "MPEG-TS", profile: "1080p HEVC", live: false, enabled: true, epg: true, playouts: 1 },
|
||||
{ num: "7.3", name: "Sci-Fi Marathon", group: "Movies", lang: "English", mode: "MPEG-TS", profile: "1080p H.264", live: false, enabled: true, epg: false, playouts: 1 },
|
||||
{ num: "4.2", name: "Music Videos", group: "Music", lang: "English", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true, playouts: 1 },
|
||||
{ num: "5.1", name: "Nature Docs", group: "Docs", lang: "Spanish", mode: "HLS Direct", profile: "—", live: false, enabled: true, epg: true, playouts: 0 },
|
||||
{ num: "9.1", name: "Kids Block", group: "Kids", lang: "French", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true, playouts: 1 },
|
||||
{ num: "12.1", name: "Test Pattern", group: "System", lang: "English", mode: "MPEG-TS", profile: "480p H.264", live: false, enabled: false, epg: false, playouts: 1 },
|
||||
],
|
||||
playoutItems: [
|
||||
{ start: "20:00:00", finish: "20:24:00", title: "Looney Tunes — Rabbit Fire", dur: "00:24:00", kind: "content" },
|
||||
|
||||
@@ -88,6 +88,7 @@ in-file entries.
|
||||
- [2026-07-17 — Seasonal / date-conditional scheduling already exists (alternate schedules / playout templates); #73 closed as implemented](#2026-07-17--seasonal--date-conditional-scheduling-already-exists-alternate-schedules--playout-templates-73-closed-as-implemented)
|
||||
- [2026-07-17 — Auto-Tune DetailPanel member list = live search-index roll-up, not EF enumeration (#384)](#2026-07-17--auto-tune-detailpanel-member-list--live-search-index-roll-up-not-ef-enumeration-384)
|
||||
- [2026-07-17 — No persistent compiler servers in CI; every `services:` container gets an explicit cap; #390's small-lane move reversed (#406)](#2026-07-17--no-persistent-compiler-servers-in-ci-every-services-container-gets-an-explicit-cap-390s-small-lane-move-reversed-406)
|
||||
- [2026-07-17 — Channel health on the API = the raw `PlayoutCount` fact on the list DTO, not a derived status enum (#72)](#2026-07-17--channel-health-on-the-api--the-raw-playoutcount-fact-on-the-list-dto-not-a-derived-status-enum-72)
|
||||
|
||||
---
|
||||
|
||||
@@ -1343,3 +1344,46 @@ 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)
|
||||
|
||||
#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.
|
||||
|
||||
@@ -79,6 +79,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), `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) |
|
||||
| **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, broken-source and auto-tuned-vs-user origin are deliberately **not** computed (see that decision entry for why each is unsafe today). | `Channel.Playouts` | `/app/channels` (read-only signal) |
|
||||
| **Guide / EPG (XMLTV)** | Per-channel programme guide generated from playout items; channels with `ShowInEpg=false` are excluded. | `GetChannelGuideHandler` | `/app/guide` (viewer); settings at `/app/settings/xmltv` |
|
||||
| **M3U** | The channel lineup playlist Jellyfin/Dispatcharr consume. | `ChannelPlaylist.ToM3U()` | — |
|
||||
| **IPTV base URL** | Optional advertised base URL for the IPTV surface (#340). Stored as a single `ConfigElement` (`ConfigElementKey.IptvBaseUrl`, key `iptv.base_url`, no EF migration); when set, `GetChannelPlaylistHandler` (M3U) and `GetChannelGuideHandler` (XMLTV) pin their absolute URLs to its scheme/host/base instead of the request `Host` (blank/invalid → request-derived). Not applied to HDHomeRun; distinct from `ETV_BASE_URL`. Parsed by `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`. | `ConfigElementKey.IptvBaseUrl` | `/app/settings` → IPTV (`GET`/`PUT /api/v1/settings/iptv`) |
|
||||
|
||||
Vendored
+1
@@ -239,6 +239,7 @@ export interface components {
|
||||
"streamingMode": string;
|
||||
"isEnabled": boolean;
|
||||
"showInEpg": boolean;
|
||||
"playoutCount": number;
|
||||
};
|
||||
"ChannelSongVideoMode": "Default" | "WithProgress";
|
||||
"ChannelStateResponseModel": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChannelsScreen } from './ChannelsScreen';
|
||||
|
||||
@@ -16,6 +16,9 @@ function channelRow(overrides: Record<string, unknown> = {}): Record<string, unk
|
||||
language: 'en',
|
||||
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.
|
||||
playoutCount: 1,
|
||||
showInEpg: true,
|
||||
sortNumber: 5.1,
|
||||
streamingMode: 'HLS Direct',
|
||||
@@ -174,6 +177,46 @@ 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.
|
||||
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 })
|
||||
]
|
||||
});
|
||||
|
||||
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.
|
||||
expect(within(table).getByText('No playout')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'No playout 1' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'No playout 1' }));
|
||||
|
||||
expect(screen.getByText('Broken')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Healthy')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not flag a mirror channel that relays its source playouts', async () => {
|
||||
// The server folds the mirror source's playouts into playoutCount, so a working mirror reports
|
||||
// a non-zero count and must not be flagged. Guards the mirror half of the API contract.
|
||||
mockApi({
|
||||
channels: [channelRow({ id: 1, name: 'Mirror Of One', number: '9', playoutCount: 1 })]
|
||||
});
|
||||
|
||||
render(<ChannelsScreen />);
|
||||
|
||||
const table = await screen.findByRole('table', { name: 'Channels lineup' });
|
||||
// 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();
|
||||
});
|
||||
|
||||
it('polls only channel state after the initial load', async () => {
|
||||
const intervalHandlers: Array<() => void> = [];
|
||||
vi.spyOn(window, 'setInterval').mockImplementation((handler) => {
|
||||
|
||||
@@ -27,7 +27,21 @@ import {
|
||||
import { navigateToPath } from '../routing';
|
||||
import { usePrimaryAction } from '../primaryAction';
|
||||
|
||||
type ChannelViewFilter = 'all' | 'onair' | 'disabled';
|
||||
type ChannelViewFilter = 'all' | 'onair' | 'disabled' | 'noplayout';
|
||||
|
||||
// #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;
|
||||
}
|
||||
|
||||
const streamingModeBadgeTones: Record<string, 'accent' | 'neutral'> = {
|
||||
'HLS Direct': 'neutral',
|
||||
@@ -193,6 +207,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 visibleChannels = channels.filter((channel) => {
|
||||
if (filter === 'onair') {
|
||||
return statesById.get(channel.id)?.onAir === true;
|
||||
@@ -202,6 +217,10 @@ export function ChannelsScreen() {
|
||||
return !channel.isEnabled;
|
||||
}
|
||||
|
||||
if (filter === 'noplayout') {
|
||||
return willNeverPlay(channel);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
const groups = groupedChannels(visibleChannels);
|
||||
@@ -396,6 +415,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>
|
||||
</div>
|
||||
<span className="ctv-channels-spacer" />
|
||||
<span className="ctv-channels-live"><StatusDot status="live" size={7} /><code>{onAirCount}</code> on air</span>
|
||||
@@ -539,6 +559,12 @@ function ChannelTableRow({
|
||||
<span className="ctv-channel-markers">
|
||||
{!channel.isEnabled && <Marker title="Disabled">D</Marker>}
|
||||
{!channel.showInEpg && <Marker title="Hidden from EPG">H</Marker>}
|
||||
{/*
|
||||
Spelled out rather than compressed to a letter marker like D/H: those flag states the
|
||||
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>}
|
||||
</span>
|
||||
<small>{channel.language || 'Language unset'}</small>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user