From 73455aae28df7afd482c04c40c5c5efdc441fb1c Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 2 Jul 2026 21:53:21 +0200 Subject: [PATCH] fix(api): surface programs not filler in channel state, tighten queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review fixes for the #97 endpoint: - nowPlaying now resolves like the XMLTV guide: when the wall-clock item is pre/mid-roll filler, surface the guide group's program item (title + full program bounds) instead of the filler; a guide entry with no program item (e.g. fallback loop) reports null. Previously an ad break would retitle the channel and reset progress every 30 seconds. - covering-item lookup now runs one cheap projected query per distinct playout offset instead of hydrating the full metadata include tree for a window widened by the largest offset across ALL channels (up to 24h); metadata is fetched in a second query scoped to the covering guide groups, time-bounded to stay safe against GuideGroup recycling (mod 10000). Regression caught while testing: covering items are keyed by (source channel, lookup time) so a source channel no longer inherits its offset mirror's lookup result. - GetChannelStatesForApi now carries Now explicitly (controller passes DateTime.UtcNow), making boundary semantics testable: new tests pin now == Start (playing) and now == Finish (not playing), plus mid-roll, filler-only, and mirror-source-row cases. - ChannelNowPlayingResponseModel title is non-nullable in the schema (#nullable enable) — GetDisplayTitle always returns a string. - GetState uses the typed-return controller convention; Playouts.Mapper reverted to internal (handler lives in the same assembly). - Spec doc notes the OnDemand drift caveat and filler semantics. Co-Authored-By: Claude Fable 5 --- .../Queries/GetChannelStatesForApi.cs | 2 +- .../Queries/GetChannelStatesForApiHandler.cs | 140 +++++++++++------ ErsatzTV.Application/Playouts/Mapper.cs | 4 +- .../ChannelNowPlayingResponseModel.cs | 2 + .../GetChannelStatesForApiHandlerTests.cs | 145 ++++++++++++++++-- .../Controllers/ChannelControllerTests.cs | 4 +- ErsatzTV/Controllers/Api/ChannelController.cs | 9 +- ErsatzTV/wwwroot/openapi/v1.json | 5 +- .../2026-07-02-channel-state-api-design.md | 4 + web/src/api/generated/v1.d.ts | 2 +- 10 files changed, 241 insertions(+), 76 deletions(-) diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelStatesForApi.cs b/ErsatzTV.Application/Channels/Queries/GetChannelStatesForApi.cs index 26a0c92fb..077ef9201 100644 --- a/ErsatzTV.Application/Channels/Queries/GetChannelStatesForApi.cs +++ b/ErsatzTV.Application/Channels/Queries/GetChannelStatesForApi.cs @@ -2,4 +2,4 @@ using ErsatzTV.Core.Api.Channels; namespace ErsatzTV.Application.Channels; -public record GetChannelStatesForApi : IRequest>; +public record GetChannelStatesForApi(DateTime Now) : IRequest>; diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelStatesForApiHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelStatesForApiHandler.cs index 32e0c2c04..0aafff89b 100644 --- a/ErsatzTV.Application/Channels/Queries/GetChannelStatesForApiHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetChannelStatesForApiHandler.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; @@ -12,12 +13,15 @@ public class GetChannelStatesForApiHandler( IFFmpegSegmenterService ffmpegSegmenterService) : IRequestHandler> { + // a guide entry (program + surrounding filler) never spans anywhere near a day; the time + // bound also protects against GuideGroup values recycling (mod 10000) elsewhere in a playout + private static readonly TimeSpan GuideEntryBound = TimeSpan.FromDays(1); + public async Task> Handle( GetChannelStatesForApi request, CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - DateTime now = DateTime.UtcNow; List channels = await dbContext.Channels .AsNoTracking() @@ -36,67 +40,103 @@ public class GetChannelStatesForApiHandler( c => { TimeSpan offset = c.PlayoutOffset ?? TimeSpan.Zero; - return (c.MirrorSourceChannelId ?? c.Id, offset, now - offset); + return (c.MirrorSourceChannelId ?? c.Id, offset, request.Now - offset); }); - int[] sourceChannelIds = channelLookup.Values - .Map(v => v.SourceChannelId) - .Distinct() - .ToArray(); + // one covering-item query per distinct lookup time (i.e. per distinct playout offset, + // typically just one) with a cheap projection; metadata is hydrated in a second query + // for only the covering items' guide groups + var coveringBySourceAndTime = new Dictionary<(int SourceChannelId, DateTime LookupTime), CoveringItem>(); + foreach (IGrouping timeGroup in + channelLookup.Values.GroupBy(v => v.LookupTime)) + { + DateTime lookupTime = timeGroup.Key; + int[] sourceChannelIds = timeGroup.Map(v => v.SourceChannelId).Distinct().ToArray(); - DateTime minLookupTime = channelLookup.Values.Min(v => v.LookupTime); - DateTime maxLookupTime = channelLookup.Values.Max(v => v.LookupTime); + List covering = await dbContext.PlayoutItems + .AsNoTracking() + .Where(pi => sourceChannelIds.Contains(pi.Playout.ChannelId)) + .Where(pi => pi.Start <= lookupTime && pi.Finish > lookupTime) + .Select(pi => new CoveringItem(pi.Id, pi.PlayoutId, pi.Playout.ChannelId, pi.GuideGroup, pi.Start)) + .ToListAsync(cancellationToken); - List currentItems = await dbContext.PlayoutItems - .AsNoTracking() - .Include(pi => pi.Playout) - .Include(pi => pi.MediaItem) - .ThenInclude(mi => (mi as Episode).EpisodeMetadata) - .Include(pi => pi.MediaItem) - .ThenInclude(mi => (mi as Episode).Season) - .ThenInclude(s => s.Show) - .ThenInclude(s => s.ShowMetadata) - .Include(pi => pi.MediaItem) - .ThenInclude(mi => (mi as Movie).MovieMetadata) - .Include(pi => pi.MediaItem) - .ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata) - .Include(pi => pi.MediaItem) - .ThenInclude(mi => (mi as MusicVideo).Artist) - .ThenInclude(a => a.ArtistMetadata) - .Include(pi => pi.MediaItem) - .ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata) - .Include(pi => pi.MediaItem) - .ThenInclude(mi => (mi as Song).SongMetadata) - .Include(pi => pi.MediaItem) - .ThenInclude(mi => (mi as Image).ImageMetadata) - .Include(pi => pi.MediaItem) - .ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata) - .Where(pi => sourceChannelIds.Contains(pi.Playout.ChannelId)) - .Where(pi => pi.Start <= maxLookupTime && pi.Finish > minLookupTime) - .OrderBy(pi => pi.Start) - .ToListAsync(cancellationToken); + foreach (CoveringItem item in covering.OrderBy(ci => ci.Start)) + { + coveringBySourceAndTime.TryAdd((item.ChannelId, lookupTime), item); + } + } - Dictionary> currentItemsBySourceChannelId = currentItems - .GroupBy(pi => pi.Playout.ChannelId) - .ToDictionary(g => g.Key, g => g.OrderBy(pi => pi.Start).ToList()); + Dictionary<(int PlayoutId, int GuideGroup), List> itemsByGuideEntry = []; + if (coveringBySourceAndTime.Count > 0) + { + int[] playoutIds = coveringBySourceAndTime.Values.Map(ci => ci.PlayoutId).Distinct().ToArray(); + int[] guideGroups = coveringBySourceAndTime.Values.Map(ci => ci.GuideGroup).Distinct().ToArray(); + DateTime windowStart = channelLookup.Values.Min(v => v.LookupTime) - GuideEntryBound; + DateTime windowFinish = channelLookup.Values.Max(v => v.LookupTime) + GuideEntryBound; + + List guideEntryItems = await dbContext.PlayoutItems + .AsNoTracking() + .Include(pi => pi.MediaItem) + .ThenInclude(mi => (mi as Episode).EpisodeMetadata) + .Include(pi => pi.MediaItem) + .ThenInclude(mi => (mi as Episode).Season) + .ThenInclude(s => s.Show) + .ThenInclude(s => s.ShowMetadata) + .Include(pi => pi.MediaItem) + .ThenInclude(mi => (mi as Movie).MovieMetadata) + .Include(pi => pi.MediaItem) + .ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata) + .Include(pi => pi.MediaItem) + .ThenInclude(mi => (mi as MusicVideo).Artist) + .ThenInclude(a => a.ArtistMetadata) + .Include(pi => pi.MediaItem) + .ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata) + .Include(pi => pi.MediaItem) + .ThenInclude(mi => (mi as Song).SongMetadata) + .Include(pi => pi.MediaItem) + .ThenInclude(mi => (mi as Image).ImageMetadata) + .Include(pi => pi.MediaItem) + .ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata) + .Where(pi => playoutIds.Contains(pi.PlayoutId) && guideGroups.Contains(pi.GuideGroup)) + .Where(pi => pi.Start <= windowFinish && pi.Finish > windowStart) + .ToListAsync(cancellationToken); + + itemsByGuideEntry = guideEntryItems + .GroupBy(pi => (pi.PlayoutId, pi.GuideGroup)) + .ToDictionary(g => g.Key, g => g.OrderBy(pi => pi.Start).ToList()); + } return channels .Map(channel => { (int sourceChannelId, TimeSpan offset, DateTime lookupTime) = channelLookup[channel.Id]; - PlayoutItem playoutItem = null; + ChannelNowPlayingResponseModel nowPlaying = null; - if (currentItemsBySourceChannelId.TryGetValue(sourceChannelId, out List candidates)) + if (coveringBySourceAndTime.TryGetValue((sourceChannelId, lookupTime), out CoveringItem covering) && + itemsByGuideEntry.TryGetValue( + (covering.PlayoutId, covering.GuideGroup), + out List guideEntry)) { - playoutItem = candidates.FirstOrDefault(pi => pi.Start <= lookupTime && pi.Finish > lookupTime); - } + // like the XMLTV guide, surface the program rather than its filler: use the + // covering item itself when it is a program part, otherwise the guide entry's + // first program item; an entry with no program item (e.g. offline fallback + // filler) stays null + PlayoutItem coveringItem = guideEntry.Find(pi => pi.Id == covering.Id); + PlayoutItem displayItem = coveringItem?.FillerKind == FillerKind.None + ? coveringItem + : guideEntry.Find(pi => pi.FillerKind == FillerKind.None); - ChannelNowPlayingResponseModel nowPlaying = playoutItem is null - ? null - : new ChannelNowPlayingResponseModel( - PlayoutMapper.GetDisplayTitle(playoutItem.MediaItem, Optional(playoutItem.ChapterTitle)), - new DateTimeOffset(playoutItem.Start + offset, TimeSpan.Zero), - new DateTimeOffset(playoutItem.Finish + offset, TimeSpan.Zero)); + if (displayItem is not null) + { + DateTime start = guideEntry[0].Start; + DateTime finish = displayItem.GuideFinish ?? guideEntry.Max(pi => pi.Finish); + + nowPlaying = new ChannelNowPlayingResponseModel( + PlayoutMapper.GetDisplayTitle(displayItem.MediaItem, Optional(displayItem.ChapterTitle)), + new DateTimeOffset(start + offset, TimeSpan.Zero), + new DateTimeOffset(finish + offset, TimeSpan.Zero)); + } + } return new ChannelStateResponseModel( channel.Id, @@ -106,4 +146,6 @@ public class GetChannelStatesForApiHandler( }) .ToList(); } + + private sealed record CoveringItem(int Id, int PlayoutId, int ChannelId, int GuideGroup, DateTime Start); } diff --git a/ErsatzTV.Application/Playouts/Mapper.cs b/ErsatzTV.Application/Playouts/Mapper.cs index 8cb95aab7..679ef7900 100644 --- a/ErsatzTV.Application/Playouts/Mapper.cs +++ b/ErsatzTV.Application/Playouts/Mapper.cs @@ -3,7 +3,7 @@ using ErsatzTV.Core.Domain.Scheduling; namespace ErsatzTV.Application.Playouts; -public static class Mapper +internal static class Mapper { internal static PlayoutNameViewModel ProjectToViewModel(Playout playout) => new( @@ -51,7 +51,7 @@ public static class Mapper playoutHistory.Key, playoutHistory.Details); - public static string GetDisplayTitle(MediaItem mediaItem, Option maybeChapterTitle) + internal static string GetDisplayTitle(MediaItem mediaItem, Option maybeChapterTitle) { string chapterTitle = maybeChapterTitle.IfNone(string.Empty); diff --git a/ErsatzTV.Core/Api/Channels/ChannelNowPlayingResponseModel.cs b/ErsatzTV.Core/Api/Channels/ChannelNowPlayingResponseModel.cs index fcc3dc851..3c5205395 100644 --- a/ErsatzTV.Core/Api/Channels/ChannelNowPlayingResponseModel.cs +++ b/ErsatzTV.Core/Api/Channels/ChannelNowPlayingResponseModel.cs @@ -1,5 +1,7 @@ namespace ErsatzTV.Core.Api.Channels; +#nullable enable + public record ChannelNowPlayingResponseModel( string Title, DateTimeOffset StartUtc, diff --git a/ErsatzTV.Tests/Application/Channels/GetChannelStatesForApiHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/GetChannelStatesForApiHandlerTests.cs index 411cc0e66..0a237b30f 100644 --- a/ErsatzTV.Tests/Application/Channels/GetChannelStatesForApiHandlerTests.cs +++ b/ErsatzTV.Tests/Application/Channels/GetChannelStatesForApiHandlerTests.cs @@ -1,6 +1,7 @@ using ErsatzTV.Application.Channels; using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Tests.Support; @@ -13,6 +14,8 @@ namespace ErsatzTV.Tests.Application.Channels; [TestFixture] public class GetChannelStatesForApiHandlerTests { + private static readonly DateTime Now = new(2026, 7, 2, 12, 0, 0, DateTimeKind.Utc); + private InMemoryTvContext _db = null!; private IFFmpegSegmenterService _segmenter = null!; @@ -29,14 +32,14 @@ public class GetChannelStatesForApiHandlerTests [Test] public async Task Handle_Should_Return_OnAir_And_Current_NowPlaying() { - DateTime start = DateTime.UtcNow.AddMinutes(-10); - DateTime finish = DateTime.UtcNow.AddMinutes(20); + DateTime start = Now.AddMinutes(-10); + DateTime finish = Now.AddMinutes(20); await SeedChannelWithMovie(start, finish); _segmenter.IsActive("7.1").Returns(true); var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter); List result = - await handler.Handle(new GetChannelStatesForApi(), CancellationToken.None); + await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None); ChannelStateResponseModel state = result.ShouldHaveSingleItem(); state.ChannelId.ShouldBe(7); @@ -48,6 +51,30 @@ public class GetChannelStatesForApiHandlerTests state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(finish, TimeSpan.Zero)); } + [Test] + public async Task Handle_Should_Match_Item_When_Now_Equals_Start() + { + await SeedChannelWithMovie(Now, Now.AddMinutes(30)); + var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter); + + List result = + await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None); + + result.ShouldHaveSingleItem().NowPlaying.ShouldNotBeNull(); + } + + [Test] + public async Task Handle_Should_Not_Match_Item_When_Now_Equals_Finish() + { + await SeedChannelWithMovie(Now.AddMinutes(-30), Now); + var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter); + + List result = + await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None); + + result.ShouldHaveSingleItem().NowPlaying.ShouldBeNull(); + } + [Test] public async Task Handle_Should_Return_Null_NowPlaying_When_No_Current_Item() { @@ -57,18 +84,111 @@ public class GetChannelStatesForApiHandlerTests var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter); List result = - await handler.Handle(new GetChannelStatesForApi(), CancellationToken.None); + await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None); ChannelStateResponseModel state = result.ShouldHaveSingleItem(); state.OnAir.ShouldBeFalse(); state.NowPlaying.ShouldBeNull(); } + [Test] + public async Task Handle_Should_Surface_Program_Not_Filler_During_MidRoll_Break() + { + // program part 1 / mid-roll filler / program part 2, all in one guide group + await using TvContext context = _db.CreateContext(); + Channel channel = MakeChannel(12, "12"); + var movie = new Movie + { + Id = 120, + MovieMetadata = [new MovieMetadata { Title = "Feature Presentation" }] + }; + var filler = new OtherVideo + { + Id = 121, + OtherVideoMetadata = [new OtherVideoMetadata { Title = "Some Bumper" }] + }; + var playout = new Playout { Id = 122, Channel = channel, ChannelId = channel.Id, Items = [] }; + PlayoutItem MakeItem(int id, MediaItem mediaItem, DateTime start, DateTime finish, FillerKind fillerKind) => + new() + { + Id = id, + MediaItem = mediaItem, + MediaItemId = mediaItem.Id, + Playout = playout, + PlayoutId = playout.Id, + Start = start, + Finish = finish, + FillerKind = fillerKind, + GuideGroup = 1, + ChapterTitle = string.Empty + }; + + context.Channels.Add(channel); + context.Movies.Add(movie); + context.OtherVideos.Add(filler); + context.Playouts.Add(playout); + context.PlayoutItems.AddRange( + MakeItem(123, movie, Now.AddMinutes(-20), Now.AddMinutes(-2), FillerKind.None), + MakeItem(124, filler, Now.AddMinutes(-2), Now.AddMinutes(2), FillerKind.MidRoll), + MakeItem(125, movie, Now.AddMinutes(2), Now.AddMinutes(40), FillerKind.None)); + await context.SaveChangesAsync(); + + var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter); + + List result = + await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None); + + ChannelStateResponseModel state = result.ShouldHaveSingleItem(); + state.NowPlaying.ShouldNotBeNull(); + state.NowPlaying.Title.ShouldBe("Feature Presentation"); + state.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(Now.AddMinutes(-20), TimeSpan.Zero)); + state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(Now.AddMinutes(40), TimeSpan.Zero)); + } + + [Test] + public async Task Handle_Should_Return_Null_NowPlaying_For_Guide_Entry_With_Only_Filler() + { + await using TvContext context = _db.CreateContext(); + Channel channel = MakeChannel(13, "13"); + var filler = new OtherVideo + { + Id = 130, + OtherVideoMetadata = [new OtherVideoMetadata { Title = "Offline Loop" }] + }; + var playout = new Playout { Id = 131, Channel = channel, ChannelId = channel.Id, Items = [] }; + var item = new PlayoutItem + { + Id = 132, + MediaItem = filler, + MediaItemId = filler.Id, + Playout = playout, + PlayoutId = playout.Id, + Start = Now.AddMinutes(-5), + Finish = Now.AddMinutes(5), + FillerKind = FillerKind.Fallback, + GuideGroup = 1, + ChapterTitle = string.Empty + }; + + context.Channels.Add(channel); + context.OtherVideos.Add(filler); + context.Playouts.Add(playout); + context.PlayoutItems.Add(item); + await context.SaveChangesAsync(); + + var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter); + + List result = + await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None); + + result.ShouldHaveSingleItem().NowPlaying.ShouldBeNull(); + } + [Test] public async Task Handle_Should_Map_Remote_Stream_NowPlaying_Title() { - DateTime start = DateTime.UtcNow.AddMinutes(-10); - DateTime finish = DateTime.UtcNow.AddMinutes(20); + DateTime start = Now.AddMinutes(-10); + DateTime finish = Now.AddMinutes(20); await using TvContext context = _db.CreateContext(); Channel channel = MakeChannel(9, "9"); @@ -98,7 +218,8 @@ public class GetChannelStatesForApiHandlerTests var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter); - ChannelStateResponseModel state = (await handler.Handle(new GetChannelStatesForApi(), CancellationToken.None)) + ChannelStateResponseModel state = + (await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None)) .ShouldHaveSingleItem(); state.NowPlaying.ShouldNotBeNull(); @@ -109,8 +230,8 @@ public class GetChannelStatesForApiHandlerTests public async Task Handle_Should_Resolve_Mirror_Channel_NowPlaying_From_Source_With_Offset() { TimeSpan offset = TimeSpan.FromHours(1); - DateTime sourceStart = DateTime.UtcNow.AddMinutes(-70); - DateTime sourceFinish = DateTime.UtcNow.AddMinutes(-40); + DateTime sourceStart = Now.AddMinutes(-70); + DateTime sourceFinish = Now.AddMinutes(-40); await using TvContext context = _db.CreateContext(); Channel source = MakeChannel(10, "10"); @@ -146,13 +267,17 @@ public class GetChannelStatesForApiHandlerTests var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter); List result = - await handler.Handle(new GetChannelStatesForApi(), CancellationToken.None); + await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None); ChannelStateResponseModel mirrorState = result.Single(s => s.ChannelId == mirror.Id); mirrorState.NowPlaying.ShouldNotBeNull(); mirrorState.NowPlaying.Title.ShouldBe("Offset Feature"); mirrorState.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(sourceStart + offset, TimeSpan.Zero)); mirrorState.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(sourceFinish + offset, TimeSpan.Zero)); + + // the source's own item is 40+ minutes in the past with no offset, so its own row is off-air + ChannelStateResponseModel sourceState = result.Single(s => s.ChannelId == source.Id); + sourceState.NowPlaying.ShouldBeNull(); } private async Task SeedChannelWithMovie(DateTime start, DateTime finish) diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs index 505faac17..dde508850 100644 --- a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs @@ -49,9 +49,9 @@ public class ChannelControllerTests _mediator.Send(Arg.Any(), Arg.Any()) .Returns([state]); - IActionResult result = await _controller.GetState(CancellationToken.None); + List result = await _controller.GetState(CancellationToken.None); - result.ShouldBeOfType().Value.ShouldBe(new List { state }); + result.ShouldBe(new List { state }); } [Test] diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index bdd03861b..30deca9e8 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -25,13 +25,8 @@ public class ChannelController(ChannelWriter workerCh [Tags("Channels")] [EndpointSummary("Get channel runtime state")] [EndpointGroupName("general")] - [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] - public async Task GetState(CancellationToken cancellationToken) - { - List result = - await mediator.Send(new GetChannelStatesForApi(), cancellationToken); - return new OkObjectResult(result); - } + public async Task> GetState(CancellationToken cancellationToken) => + await mediator.Send(new GetChannelStatesForApi(DateTime.UtcNow), cancellationToken); [HttpGet("/api/channels/{id:int}", Name = "GetChannelById")] [Tags("Channels")] diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 107d1ec89..5046a5153 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -3435,10 +3435,7 @@ "type": "object", "properties": { "title": { - "type": [ - "null", - "string" - ] + "type": "string" }, "startUtc": { "type": "string", diff --git a/docs/superpowers/specs/2026-07-02-channel-state-api-design.md b/docs/superpowers/specs/2026-07-02-channel-state-api-design.md index cc3459ce4..36a18d359 100644 --- a/docs/superpowers/specs/2026-07-02-channel-state-api-design.md +++ b/docs/superpowers/specs/2026-07-02-channel-state-api-design.md @@ -84,6 +84,10 @@ The direct streaming modes can be actively streaming while `onAir` reports false When no runtime data is available, the response should degrade to `onAir: false` and `nowPlaying: null`. The SPA can render that as Idle / Off air. +`nowPlaying` is also `null` when the current playout item is filler with no program item in its guide group (for example a channel looping fallback filler). When the current item is filler *inside* a program's guide group (pre/mid-roll), the endpoint surfaces the program — matching the XMLTV guide — not the filler. + +Known imprecision: OnDemand channels report `nowPlaying` from stored playout items, which are only re-anchored when a viewer tunes in. While such a channel sits idle, the reported item and progress drift from what a new viewer will actually see. This is inherent to the stored data and accepted, like the #99 `onAir` caveat. + ## Implementation Shape Add a new MediatR query in `ErsatzTV.Application.Channels`, for example `GetChannelStatesForApi`, returning `List`. diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 7a5773e82..e3072deef 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -39,7 +39,7 @@ export interface components { "ChannelIdleBehavior": "StopOnDisconnect" | "KeepRunning"; "ChannelMusicVideoCreditsMode": "None" | "GenerateSubtitles"; "ChannelNowPlayingResponseModel": { - "title": null | string; + "title": string; "startUtc": string; "finishUtc": string; };