diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelStatesForApi.cs b/ErsatzTV.Application/Channels/Queries/GetChannelStatesForApi.cs new file mode 100644 index 000000000..077ef9201 --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelStatesForApi.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Api.Channels; + +namespace ErsatzTV.Application.Channels; + +public record GetChannelStatesForApi(DateTime Now) : IRequest>; diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelStatesForApiHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelStatesForApiHandler.cs new file mode 100644 index 000000000..0aafff89b --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelStatesForApiHandler.cs @@ -0,0 +1,151 @@ +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; +using PlayoutMapper = ErsatzTV.Application.Playouts.Mapper; + +namespace ErsatzTV.Application.Channels; + +public class GetChannelStatesForApiHandler( + IDbContextFactory dbContextFactory, + 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); + + List channels = await dbContext.Channels + .AsNoTracking() + .OrderBy(c => c.SortNumber) + .ThenBy(c => c.Number) + .ToListAsync(cancellationToken); + + if (channels.Count == 0) + { + return []; + } + + Dictionary channelLookup = channels + .ToDictionary( + c => c.Id, + c => + { + TimeSpan offset = c.PlayoutOffset ?? TimeSpan.Zero; + return (c.MirrorSourceChannelId ?? c.Id, offset, request.Now - offset); + }); + + // 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(); + + 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); + + foreach (CoveringItem item in covering.OrderBy(ci => ci.Start)) + { + coveringBySourceAndTime.TryAdd((item.ChannelId, lookupTime), item); + } + } + + 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]; + ChannelNowPlayingResponseModel nowPlaying = null; + + if (coveringBySourceAndTime.TryGetValue((sourceChannelId, lookupTime), out CoveringItem covering) && + itemsByGuideEntry.TryGetValue( + (covering.PlayoutId, covering.GuideGroup), + out List guideEntry)) + { + // 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); + + 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, + channel.Number, + ffmpegSegmenterService.IsActive(channel.Number), + nowPlaying); + }) + .ToList(); + } + + private sealed record CoveringItem(int Id, int PlayoutId, int ChannelId, int GuideGroup, DateTime Start); +} diff --git a/ErsatzTV.Core/Api/Channels/ChannelNowPlayingResponseModel.cs b/ErsatzTV.Core/Api/Channels/ChannelNowPlayingResponseModel.cs new file mode 100644 index 000000000..3c5205395 --- /dev/null +++ b/ErsatzTV.Core/Api/Channels/ChannelNowPlayingResponseModel.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Api.Channels; + +#nullable enable + +public record ChannelNowPlayingResponseModel( + string Title, + DateTimeOffset StartUtc, + DateTimeOffset FinishUtc); diff --git a/ErsatzTV.Core/Api/Channels/ChannelStateResponseModel.cs b/ErsatzTV.Core/Api/Channels/ChannelStateResponseModel.cs new file mode 100644 index 000000000..44c306078 --- /dev/null +++ b/ErsatzTV.Core/Api/Channels/ChannelStateResponseModel.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Api.Channels; + +#nullable enable + +public record ChannelStateResponseModel( + int ChannelId, + string ChannelNumber, + bool OnAir, + ChannelNowPlayingResponseModel? NowPlaying); diff --git a/ErsatzTV.Tests/Application/Channels/GetChannelStatesForApiHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/GetChannelStatesForApiHandlerTests.cs new file mode 100644 index 000000000..0a237b30f --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/GetChannelStatesForApiHandlerTests.cs @@ -0,0 +1,329 @@ +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; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +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!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _segmenter = Substitute.For(); + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task Handle_Should_Return_OnAir_And_Current_NowPlaying() + { + 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(Now), CancellationToken.None); + + ChannelStateResponseModel state = result.ShouldHaveSingleItem(); + state.ChannelId.ShouldBe(7); + state.ChannelNumber.ShouldBe("7.1"); + state.OnAir.ShouldBeTrue(); + state.NowPlaying.ShouldNotBeNull(); + state.NowPlaying.Title.ShouldBe("Retro Cartoons"); + state.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(start, TimeSpan.Zero)); + 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() + { + await using TvContext context = _db.CreateContext(); + context.Channels.Add(MakeChannel(8, "8")); + 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.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 = Now.AddMinutes(-10); + DateTime finish = Now.AddMinutes(20); + + await using TvContext context = _db.CreateContext(); + Channel channel = MakeChannel(9, "9"); + var remoteStream = new RemoteStream + { + Id = 90, + RemoteStreamMetadata = [new RemoteStreamMetadata { Title = "Live Source" }] + }; + var playout = new Playout { Id = 91, Channel = channel, ChannelId = channel.Id, Items = [] }; + var item = new PlayoutItem + { + Id = 92, + MediaItem = remoteStream, + MediaItemId = remoteStream.Id, + Playout = playout, + PlayoutId = playout.Id, + Start = start, + Finish = finish, + ChapterTitle = string.Empty + }; + + context.Channels.Add(channel); + context.RemoteStreams.Add(remoteStream); + context.Playouts.Add(playout); + context.PlayoutItems.Add(item); + await context.SaveChangesAsync(); + + var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter); + + ChannelStateResponseModel state = + (await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None)) + .ShouldHaveSingleItem(); + + state.NowPlaying.ShouldNotBeNull(); + state.NowPlaying.Title.ShouldBe("Live Source"); + } + + [Test] + public async Task Handle_Should_Resolve_Mirror_Channel_NowPlaying_From_Source_With_Offset() + { + TimeSpan offset = TimeSpan.FromHours(1); + DateTime sourceStart = Now.AddMinutes(-70); + DateTime sourceFinish = Now.AddMinutes(-40); + + await using TvContext context = _db.CreateContext(); + Channel source = MakeChannel(10, "10"); + Channel mirror = MakeChannel(11, "11"); + mirror.PlayoutSource = ChannelPlayoutSource.Mirror; + mirror.MirrorSourceChannelId = source.Id; + mirror.PlayoutOffset = offset; + + var movie = new Movie + { + Id = 110, + MovieMetadata = [new MovieMetadata { Title = "Offset Feature" }] + }; + var playout = new Playout { Id = 111, Channel = source, ChannelId = source.Id, Items = [] }; + var item = new PlayoutItem + { + Id = 112, + MediaItem = movie, + MediaItemId = movie.Id, + Playout = playout, + PlayoutId = playout.Id, + Start = sourceStart, + Finish = sourceFinish, + ChapterTitle = string.Empty + }; + + context.Channels.AddRange(source, mirror); + context.Movies.Add(movie); + 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); + + 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) + { + await using TvContext context = _db.CreateContext(); + Channel channel = MakeChannel(7, "7.1"); + var movie = new Movie + { + Id = 10, + MovieMetadata = [new MovieMetadata { Title = "Retro Cartoons" }] + }; + var playout = new Playout + { + Id = 20, + Channel = channel, + ChannelId = channel.Id, + Items = [] + }; + var item = new PlayoutItem + { + Id = 30, + MediaItem = movie, + MediaItemId = movie.Id, + Playout = playout, + PlayoutId = playout.Id, + Start = start, + Finish = finish, + ChapterTitle = string.Empty + }; + + context.Channels.Add(channel); + context.Movies.Add(movie); + context.Playouts.Add(playout); + context.PlayoutItems.Add(item); + await context.SaveChangesAsync(); + } + + private static Channel MakeChannel(int id, string number) => + new(Guid.NewGuid()) + { + Id = id, + Number = number, + SortNumber = id, + Name = $"Channel {number}", + Group = "Test", + Categories = string.Empty, + PreferredAudioLanguageCode = string.Empty + }; +} diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs index ccdbeee46..dde508850 100644 --- a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs @@ -6,6 +6,7 @@ using ErsatzTV.Application.Playouts; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; +using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; using LanguageExt; @@ -34,6 +35,25 @@ public class ChannelControllerTests _controller = new ChannelController(writer, _mediator); } + [Test] + public async Task GetState_Should_Return_200_With_Channel_State() + { + var state = new ChannelStateResponseModel( + 7, + "7.1", + true, + new ChannelNowPlayingResponseModel( + "Retro Cartoons", + new DateTimeOffset(2026, 7, 2, 17, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 7, 2, 17, 30, 0, TimeSpan.Zero))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([state]); + + List result = await _controller.GetState(CancellationToken.None); + + result.ShouldBe(new List { state }); + } + [Test] public async Task Create_Should_Return_201_With_Location_And_Body() { diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index 363b092e5..5585cc4f7 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -41,6 +41,64 @@ public class OpenApiErrorResponseContractTests schema.TryGetProperty("categories", out _).ShouldBeTrue(); } + [Test] + public void Static_OpenApi_Should_Document_Channel_State_Response() + { + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument())); + + JsonElement getState = document.RootElement + .GetProperty("paths") + .GetProperty("/api/channels/state") + .GetProperty("get"); + + JsonElement schema = getState + .GetProperty("responses") + .GetProperty("200") + .GetProperty("content") + .GetProperty("application/json") + .GetProperty("schema"); + + schema.GetProperty("type").GetString().ShouldBe("array"); + schema.GetProperty("items").GetProperty("$ref").GetString() + .ShouldBe("#/components/schemas/ChannelStateResponseModel"); + + JsonElement stateProperties = document.RootElement + .GetProperty("components") + .GetProperty("schemas") + .GetProperty("ChannelStateResponseModel") + .GetProperty("properties"); + + stateProperties.TryGetProperty("channelId", out _).ShouldBeTrue(); + stateProperties.TryGetProperty("channelNumber", out _).ShouldBeTrue(); + stateProperties.TryGetProperty("onAir", out _).ShouldBeTrue(); + stateProperties.TryGetProperty("nowPlaying", out JsonElement nowPlaying).ShouldBeTrue(); + + JsonElement nowPlayingOneOf = nowPlaying.GetProperty("oneOf"); + nowPlayingOneOf.EnumerateArray().Count().ShouldBe(2); + nowPlayingOneOf.EnumerateArray() + .Any(s => s.TryGetProperty("type", out JsonElement type) && type.GetString() == "null") + .ShouldBeTrue(); + nowPlayingOneOf.EnumerateArray() + .Any(s => s.TryGetProperty("$ref", out JsonElement schemaRef) && + schemaRef.GetString() == "#/components/schemas/ChannelNowPlayingResponseModel") + .ShouldBeTrue(); + + JsonElement nowPlayingProperties = document.RootElement + .GetProperty("components") + .GetProperty("schemas") + .GetProperty("ChannelNowPlayingResponseModel") + .GetProperty("properties"); + + nowPlayingProperties.TryGetProperty("title", out _).ShouldBeTrue(); + nowPlayingProperties.TryGetProperty("startUtc", out JsonElement startUtc).ShouldBeTrue(); + nowPlayingProperties.TryGetProperty("finishUtc", out JsonElement finishUtc).ShouldBeTrue(); + + startUtc.GetProperty("type").GetString().ShouldBe("string"); + startUtc.GetProperty("format").GetString().ShouldBe("date-time"); + finishUtc.GetProperty("type").GetString().ShouldBe("string"); + finishUtc.GetProperty("format").GetString().ShouldBe("date-time"); + } + [TestCase("/api/channels/{id}", "get", "404")] [TestCase("/api/channels", "post", "404")] [TestCase("/api/channels", "post", "422")] diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index 0386f2fd8..30deca9e8 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -21,6 +21,13 @@ public class ChannelController(ChannelWriter workerCh [EndpointGroupName("general")] public async Task> GetAll() => await mediator.Send(new GetAllChannelsForApi()); + [HttpGet("/api/channels/state")] + [Tags("Channels")] + [EndpointSummary("Get channel runtime state")] + [EndpointGroupName("general")] + public async Task> GetState(CancellationToken cancellationToken) => + await mediator.Send(new GetChannelStatesForApi(DateTime.UtcNow), cancellationToken); + [HttpGet("/api/channels/{id:int}", Name = "GetChannelById")] [Tags("Channels")] [EndpointSummary("Get a channel by id")] diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index d72e2cd8e..5046a5153 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -136,6 +136,45 @@ } } }, + "/api/channels/state": { + "get": { + "tags": [ + "Channels" + ], + "summary": "Get channel runtime state", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelStateResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelStateResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelStateResponseModel" + } + } + } + } + } + } + } + }, "/api/channels/{id}": { "get": { "tags": [ @@ -3387,6 +3426,27 @@ ], "type": "string" }, + "ChannelNowPlayingResponseModel": { + "required": [ + "title", + "startUtc", + "finishUtc" + ], + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "startUtc": { + "type": "string", + "format": "date-time" + }, + "finishUtc": { + "type": "string", + "format": "date-time" + } + } + }, "ChannelPlayoutMode": { "enum": [ "Continuous", @@ -3482,6 +3542,37 @@ ], "type": "string" }, + "ChannelStateResponseModel": { + "required": [ + "channelId", + "channelNumber", + "onAir", + "nowPlaying" + ], + "type": "object", + "properties": { + "channelId": { + "type": "integer", + "format": "int32" + }, + "channelNumber": { + "type": "string" + }, + "onAir": { + "type": "boolean" + }, + "nowPlaying": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChannelNowPlayingResponseModel" + } + ] + } + } + }, "ChannelStreamSelectorMode": { "enum": [ "Default", 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 c420f1238..e3072deef 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -38,6 +38,11 @@ export interface components { }; "ChannelIdleBehavior": "StopOnDisconnect" | "KeepRunning"; "ChannelMusicVideoCreditsMode": "None" | "GenerateSubtitles"; + "ChannelNowPlayingResponseModel": { + "title": string; + "startUtc": string; + "finishUtc": string; + }; "ChannelPlayoutMode": "Continuous" | "OnDemand"; "ChannelPlayoutSource": "Generated" | "Mirror"; "ChannelResponseModel": { @@ -54,6 +59,12 @@ export interface components { "showInEpg": boolean; }; "ChannelSongVideoMode": "Default" | "WithProgress"; + "ChannelStateResponseModel": { + "channelId": number; + "channelNumber": string; + "onAir": boolean; + "nowPlaying": null | components["schemas"]["ChannelNowPlayingResponseModel"]; + }; "ChannelStreamSelectorMode": "Default" | "Custom" | "Troubleshooting"; "ChannelSubtitleMode": "None" | "Forced" | "Default" | "Any"; "ChannelTranscodeMode": "OnDemand";