diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs index 39dfc1ad8..09b4589f1 100644 --- a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs @@ -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); } diff --git a/ErsatzTV.Application/Channels/Mapper.cs b/ErsatzTV.Application/Channels/Mapper.cs index 558f9f061..57b94a558 100644 --- a/ErsatzTV.Application/Channels/Mapper.cs +++ b/ErsatzTV.Application/Channels/Mapper.cs @@ -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 { + /// + /// 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 + /// and, for mirrors, . to be included + /// by the query — the repository reads are AsNoTracking, so an un-included navigation silently counts zero. + /// + 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); diff --git a/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs b/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs index c9f20b63f..08c5148c3 100644 --- a/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs @@ -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 channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten(); - return channels.Map(ProjectToResponseModel).ToList(); + return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c))).ToList(); } } diff --git a/ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs b/ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs index 66d02d669..e3f261fa7 100644 --- a/ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs @@ -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; - } } diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApiHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApiHandler.cs index 8773964db..065b695e6 100644 --- a/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApiHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApiHandler.cs @@ -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))); } diff --git a/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs b/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs index 55ceab9d8..55090149d 100644 --- a/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs +++ b/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs @@ -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); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs index 79ce5d889..f47685ca0 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs @@ -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 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); diff --git a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs index 1388a65ae..12788ea0d 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs @@ -151,7 +151,8 @@ public class OpenApiSerializerContractTests "en", "TransportStream", true, - true); + true, + 2); private static string FindOpenApiDocument() { diff --git a/ErsatzTV.Tests/Infrastructure/ChannelRepositoryPlayoutIncludeTests.cs b/ErsatzTV.Tests/Infrastructure/ChannelRepositoryPlayoutIncludeTests.cs new file mode 100644 index 000000000..ae095cffa --- /dev/null +++ b/ErsatzTV.Tests/Infrastructure/ChannelRepositoryPlayoutIncludeTests.cs @@ -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 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 SeedChannel(Channel channel) + { + await using TvContext context = _db.CreateContext(); + context.Channels.Add(channel); + await context.SaveChangesAsync(); + return channel.Id; + } + + private async Task GetDetail(int channelId) + { + var handler = new GetChannelByIdForApiHandler(_repository); + Option 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); + } +} diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index f5226eaad..097ca0181 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -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" } } }, diff --git a/design-system/templates/chicorytv-admin/Channels.jsx b/design-system/templates/chicorytv-admin/Channels.jsx index 0a0193e9e..2abc6944a 100644 --- a/design-system/templates/chicorytv-admin/Channels.jsx +++ b/design-system/templates/chicorytv-admin/Channels.jsx @@ -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 = () => (
- {[{ 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 (
{c.lang}
diff --git a/design-system/templates/chicorytv-admin/data.js b/design-system/templates/chicorytv-admin/data.js index 248a0f3bb..874e832c7 100644 --- a/design-system/templates/chicorytv-admin/data.js +++ b/design-system/templates/chicorytv-admin/data.js @@ -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" }, diff --git a/docs/decisions.md b/docs/decisions.md index e39aff7cb..7d135deec 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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. diff --git a/docs/domain-model.md b/docs/domain-model.md index 6e48bdb14..fbeb70f68 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -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`) | diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 377b15581..52ade2f22 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -239,6 +239,7 @@ export interface components { "streamingMode": string; "isEnabled": boolean; "showInEpg": boolean; + "playoutCount": number; }; "ChannelSongVideoMode": "Default" | "WithProgress"; "ChannelStateResponseModel": { diff --git a/web/src/screens/ChannelsScreen.test.tsx b/web/src/screens/ChannelsScreen.test.tsx index 4c1568c39..86716f5f9 100644 --- a/web/src/screens/ChannelsScreen.test.tsx +++ b/web/src/screens/ChannelsScreen.test.tsx @@ -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 = {}): Record { expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/channels/state', expect.any(Object)); }); + // #72: a channel with no playout can never play. This is the fault state the issue calls + // "the important one" — it must be visible without hovering. + 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(); + + 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(); + + 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) => { diff --git a/web/src/screens/ChannelsScreen.tsx b/web/src/screens/ChannelsScreen.tsx index 1721705b9..817d07b87 100644 --- a/web/src/screens/ChannelsScreen.tsx +++ b/web/src/screens/ChannelsScreen.tsx @@ -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 = { '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() { + {onAirCount} on air @@ -539,6 +559,12 @@ function ChannelTableRow({ {!channel.isEnabled && D} {!channel.showInEpg && H} + {/* + 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) && No playout} {channel.language || 'Language unset'}