From 1a40b4a8e669689c40e752628c6f7fe5bf8c3246 Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 17 Jul 2026 12:54:14 +0200 Subject: [PATCH 1/6] fix(72): report a real per-channel playout count on the channels API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channels API could not answer "will this channel play?", which #72 needs to flag a broken channel in the lineup at a glance. Two defects, one root cause each: 1. `ChannelRepository.GetChannel` never included `Playouts`. The read is AsNoTracking with no lazy-loading proxies, so the navigation came back empty and `GetChannelByIdForApiHandler`'s `channel.Playouts?.Count ?? 0` could only ever evaluate 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:820, gated on `playoutCount > 0`), so the "Cannot be changed once a generated channel has a playout" control was always live. The server still enforces the invariant (UpdateChannelHandler coerces Mirror back to Generated), so nothing was corrupted — but the user's change was silently discarded. That silent coercion is filed separately as #401. 2. The detail path counted only the channel's own playouts, never the mirror source's, so a working Mirror channel would read as "no playout" even once the include landed. Both call sites now share `Mapper.GetPlayoutsCount` (previously private to GetAllChannelsHandler), which handles the Mirror case. `ChannelResponseModel` gains `PlayoutCount` so the list — #72's actual surface — can render it; the count is free there, since `GetAll` already includes `Playouts` and `MirrorSourceChannel.Playouts` and simply discarded them. Tests run the real repository against a real context on purpose: a handler test with a substituted IChannelRepository populates `Playouts` itself, so it passes whether or not the query includes them. Proven non-vacuous — removing the include again turns the 2-playout and mirror cases red (0 CS errors, so no stale-dll false pass). Refs #72 --- ErsatzTV.Application/Channels/Mapper.cs | 28 +++- .../Queries/GetAllChannelsForApiHandler.cs | 2 +- .../Channels/Queries/GetAllChannelsHandler.cs | 17 --- .../Queries/GetChannelByIdForApiHandler.cs | 2 +- .../Api/Channels/ChannelResponseModel.cs | 3 +- .../Data/Repositories/ChannelRepository.cs | 3 + .../OpenApiSerializerContractTests.cs | 3 +- .../ChannelRepositoryPlayoutIncludeTests.cs | 140 ++++++++++++++++++ ErsatzTV/wwwroot/openapi/v1.json | 7 +- web/src/api/generated/v1.d.ts | 1 + 10 files changed, 182 insertions(+), 24 deletions(-) create mode 100644 ErsatzTV.Tests/Infrastructure/ChannelRepositoryPlayoutIncludeTests.cs diff --git a/ErsatzTV.Application/Channels/Mapper.cs b/ErsatzTV.Application/Channels/Mapper.cs index 558f9f061..3f1b5a75e 100644 --- a/ErsatzTV.Application/Channels/Mapper.cs +++ b/ErsatzTV.Application/Channels/Mapper.cs @@ -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..d8fe82855 100644 --- a/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs @@ -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..52e0dcb3f 100644 --- a/ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs @@ -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..4d284dd97 100644 --- a/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs +++ b/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs @@ -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..4bf371005 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs @@ -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..c4c247681 --- /dev/null +++ b/ErsatzTV.Tests/Infrastructure/ChannelRepositoryPlayoutIncludeTests.cs @@ -0,0 +1,140 @@ +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() + { + // The "will never play" signal #72 renders — must stay 0 rather than becoming vacuously non-zero. + 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/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": { -- 2.47.3 From 6cd5471500e8a2fd5e0da8d761a2d31c4bf74926 Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 17 Jul 2026 12:58:17 +0200 Subject: [PATCH 2/6] feat(72): flag channels that will never play in the lineup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the per-channel playout count the previous commit put on the API as the fault state #72 calls "the important one": a channel with no playout can never play, and until now the lineup gave no sign of it. - "No playout" renders as a Badge with visible text rather than another single-letter marker like D/H. D/H flag states the user chose deliberately; this is a fault they need to notice and act on, and a tooltip-only glyph is invisible on touch and easy to miss in a long lineup. - A "Problems" segmented filter answers the issue's actual goal — seeing the health of a LARGE lineup at a glance — by collapsing it to just the faults. - `willNeverPlay` is one predicate shared by the badge, the count and the filter, so the three cannot disagree. Mirror channels relay their source's playouts and the server folds those into playoutCount, so a working mirror is not false-flagged (covered by a test). Deliberately not approximated here, per the design pass: an empty schedule behind an existing playout (EmptyScheduleHealthCheck only understands Classic ProgramSchedule playouts — a badge driven by it would silently lie on Block, Sequential, Scripted and ExternalJson channels) and broken/missing sources (FileNotFound/Unavailable are server-wide counts with no channel attribution). Both stay open on #72 rather than shipping a signal that is wrong for four of the five schedule kinds. The test fixture now sends playoutCount, matching the API, so the fault state is opt-in per test instead of depending on an absent field. Refs #72 --- web/src/screens/ChannelsScreen.test.tsx | 40 +++++++++++++++++++++++++ web/src/screens/ChannelsScreen.tsx | 23 +++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/web/src/screens/ChannelsScreen.test.tsx b/web/src/screens/ChannelsScreen.test.tsx index 4c1568c39..56fcef2ea 100644 --- a/web/src/screens/ChannelsScreen.test.tsx +++ b/web/src/screens/ChannelsScreen.test.tsx @@ -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(); + + expect(await screen.findByRole('table', { name: 'Channels lineup' })).toBeInTheDocument(); + // Visible text, not a title-only affordance. + expect(screen.getByText('No playout')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Problems 1' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Problems 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(); + + expect(await screen.findByRole('table', { name: 'Channels lineup' })).toBeInTheDocument(); + expect(screen.queryByText('No playout')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Problems 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..b2a256db1 100644 --- a/web/src/screens/ChannelsScreen.tsx +++ b/web/src/screens/ChannelsScreen.tsx @@ -27,7 +27,16 @@ import { import { navigateToPath } from '../routing'; import { usePrimaryAction } from '../primaryAction'; -type ChannelViewFilter = 'all' | 'onair' | 'disabled'; +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 here: an empty schedule behind an existing playout, and broken/missing +// sources — see #72 for why both are deferred rather than approximated. +function willNeverPlay(channel: ChannelSummary): boolean { + return channel.playoutCount === 0; +} const streamingModeBadgeTones: Record = { 'HLS Direct': 'neutral', @@ -193,6 +202,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 problemCount = channels.filter(willNeverPlay).length; const visibleChannels = channels.filter((channel) => { if (filter === 'onair') { return statesById.get(channel.id)?.onAir === true; @@ -202,6 +212,10 @@ export function ChannelsScreen() { return !channel.isEnabled; } + if (filter === 'problems') { + return willNeverPlay(channel); + } + return true; }); const groups = groupedChannels(visibleChannels); @@ -396,6 +410,7 @@ export function ChannelsScreen() { + {onAirCount} on air @@ -539,6 +554,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'} -- 2.47.3 From 9b3121f0c216eeaea962b39d9e2a20576e4be148 Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 17 Jul 2026 13:00:01 +0200 Subject: [PATCH 3/6] docs(72): record the channel-health API shape + mirror the lineup fault state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same-PR docs obligations for #72: - decisions.md — why health is the raw `PlayoutCount` fact on the lean list DTO rather than a derived status enum or a new endpoint, and, more importantly, why empty-schedule / broken-source / auto-tuned-origin are deliberately NOT computed. The empty-schedule one is the trap worth writing down: it only understands Classic ProgramSchedule playouts, so a badge driven off it would silently lie on the other four schedule kinds. - domain-model.md — a glossary row for channel health, drawing the line against /channels/state's OnAir (runtime liveness, not "would play if tuned"). - design-system — mirror the "No playout" badge + Problems filter into the Channels prototype so design-system/ stays a faithful mirror of prod (design-sync #388). Nature Docs carries playouts: 0 so the fault state is visible in the canvas. Refs #72 --- .../templates/chicorytv-admin/Channels.jsx | 6 ++- .../templates/chicorytv-admin/data.js | 18 ++++---- docs/decisions.md | 44 +++++++++++++++++++ docs/domain-model.md | 1 + 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/design-system/templates/chicorytv-admin/Channels.jsx b/design-system/templates/chicorytv-admin/Channels.jsx index 0a0193e9e..8bd916d6a 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 === "problems") 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, problems: 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: "problems", l: "Problems", c: counts.problems }].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..d7b225615 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 **Problems** filter on the channels list. 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`) | -- 2.47.3 From 7e9c52b0353305286057fe004bc994170106a8fa Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 17 Jul 2026 13:11:24 +0200 Subject: [PATCH 4/6] fix(72): name the filter for the one fault it can prove (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review, Medium: the "Problems" filter over-promised. The badge is honestly named ("No playout"), but the filter claimed a taxonomy that does not exist — decisions.md enumerates three fault classes this deliberately does NOT compute (empty schedule behind a playout, broken source, origin). A user whose Classic schedule is empty would read "Problems 0" as "lineup healthy" and ship a dead channel. A false all-clear is worse than no affordance. Renamed the filter to "No playout" so badge and filter both name exactly the one fault the API can prove, leaving "Problems" free for when the taxonomy behind it actually exists. Rationale recorded at the predicate so the next person doesn't "improve" the label back. Also from the review: - The zero-playout test's comment claimed include coverage it does not provide (it passes with or without the include — 0 == 0). Re-stated as what it is: a mapper boundary check. Its two siblings are the include coverage. - UpdateChannelHandler:179 is a fourth call site the "shared by all three" framing excluded. Harmless (the controller discards the view model and re-projects through GetChannelByIdForApi), but a trap: its query lacks the MirrorSourceChannel include, so swapping in the shared helper would report 0 for a working mirror. Documented that the fix there is a QUERY change. - Mirrored the rename into the design-system prototype. The rename collided the badge and filter labels, so the screen tests now scope the badge assertion to the table (spa-conventions §6). This also fixed a real weakness: the mirror test's unscoped queryByText would have matched the filter button and asserted nothing. Review note: the reviewer's strongest hypothesis — that PUT and GET could disagree on a mirror channel's count — was investigated and does NOT hold. Refs #72 --- .../Channels/Commands/UpdateChannelHandler.cs | 7 +++++++ .../ChannelRepositoryPlayoutIncludeTests.cs | 5 ++++- .../templates/chicorytv-admin/Channels.jsx | 6 +++--- web/src/screens/ChannelsScreen.test.tsx | 21 +++++++++++-------- web/src/screens/ChannelsScreen.tsx | 17 +++++++++------ 5 files changed, 37 insertions(+), 19 deletions(-) diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs index 39dfc1ad8..9cf5081b1 100644 --- a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs @@ -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.Tests/Infrastructure/ChannelRepositoryPlayoutIncludeTests.cs b/ErsatzTV.Tests/Infrastructure/ChannelRepositoryPlayoutIncludeTests.cs index c4c247681..ae095cffa 100644 --- a/ErsatzTV.Tests/Infrastructure/ChannelRepositoryPlayoutIncludeTests.cs +++ b/ErsatzTV.Tests/Infrastructure/ChannelRepositoryPlayoutIncludeTests.cs @@ -120,7 +120,10 @@ public class ChannelRepositoryPlayoutIncludeTests [Test] public async Task GetChannel_Should_Report_Zero_For_A_Channel_With_No_Playouts() { - // The "will never play" signal #72 renders — must stay 0 rather than becoming vacuously non-zero. + // 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()) diff --git a/design-system/templates/chicorytv-admin/Channels.jsx b/design-system/templates/chicorytv-admin/Channels.jsx index 8bd916d6a..2abc6944a 100644 --- a/design-system/templates/chicorytv-admin/Channels.jsx +++ b/design-system/templates/chicorytv-admin/Channels.jsx @@ -31,7 +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 === "problems") rows = rows.filter((c) => c.playouts === 0); + else if (view === "noplayout") rows = rows.filter((c) => c.playouts === 0); // group, preserving first-seen order const groups = []; @@ -44,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, problems: D.channels.filter((c) => c.playouts === 0).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 }, { v: "problems", l: "Problems", c: counts.problems }].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 ( - +
{onAirCount} on air -- 2.47.3 From d92e717ebbd9f280225f56a0ee5283f2b573d5f0 Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 17 Jul 2026 13:30:27 +0200 Subject: [PATCH 5/6] docs(72): fix the stale "Problems" filter name in domain-model (re-review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review, Low: domain-model.md still documented a "Problems" filter — a line added by 50005b12 earlier in this same PR, so the PR contradicted itself once 38483681 renamed the filter to "No playout". Ironic in exactly the way the rename is about: the next person implementing the deferred fault classes would read the doc, see "Problems" documented, and restore the over-promising label that ChannelsScreen's own comment asks them not to. Stale docs re-introduce the bug the code comment defends against. decisions.md needed no change — it only ever named the badge, never the filter. Refs #72 --- docs/domain-model.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/domain-model.md b/docs/domain-model.md index d7b225615..fbeb70f68 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -79,7 +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 **Problems** filter on the channels list. 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) | +| **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`) | -- 2.47.3 From 1e35248edf28fb25b8b8b259cdeeca6e7acb6d99 Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 17 Jul 2026 14:06:48 +0200 Subject: [PATCH 6/6] style(72): de-BOM the six touched .cs files (#311 formatting gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's "Formatting (changed .cs conform to .editorconfig)" job failed with `error CHARSET: Fix file encoding` on all six pre-existing BOM'd files this PR touches. The #311 gate is fix-as-you-touch: any .cs a PR touches must conform to .editorconfig (charset=utf-8), and these carried BOMs inherited from upstream. BOM removal only — six files, one line each, zero content change. The three files already without a BOM (GetChannelByIdForApiHandler + both new/changed Tests files) needed nothing. Note for the next person: this cannot be validated locally on this Mac — `dotnet format --include` silently no-ops here, so the gate is only observable in CI. Check `head -c3 | xxd -p` for `efbbbf` on every touched .cs before pushing instead of trusting a local format run. Refs #72 --- ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs | 2 +- ErsatzTV.Application/Channels/Mapper.cs | 2 +- .../Channels/Queries/GetAllChannelsForApiHandler.cs | 2 +- ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs | 2 +- ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs | 2 +- ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs index 9cf5081b1..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; diff --git a/ErsatzTV.Application/Channels/Mapper.cs b/ErsatzTV.Application/Channels/Mapper.cs index 3f1b5a75e..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; diff --git a/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs b/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs index d8fe82855..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; diff --git a/ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs b/ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs index 52e0dcb3f..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; diff --git a/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs b/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs index 4d284dd97..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; diff --git a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs index 4bf371005..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; -- 2.47.3