From 9ecefd75f331741626e17036b44b62a5b71ea9e1 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 2 Jul 2026 21:27:37 +0200 Subject: [PATCH] feat(api): add JSON channel guide endpoint (#102) Add GET /api/guide returning a per-channel EPG grid (ChannelGuideResponseModel). start defaults to now, end defaults to now + XmltvDaysToBuild. Extract the guide-group/filler-merge projection (ChannelGuideProjector) and programme-metadata resolution (ChannelGuideMetadata) plus the metadata eager-load (IncludeGuideMetadata) out of RefreshChannelDataHandler so the XMLTV cache builder and the JSON guide share one source of truth and cannot drift. XMLTV output is unchanged (ChannelGuideGoldenTests pass without regeneration). The JSON handler mirrors XMLTV semantics: ShowInEpg-only channels, decimal channel ordering, ExternalJson playouts skipped, mirror playout offset applied to copies (never mutating the loaded entities). SubTitle/Category are nullable. refs #102 Co-Authored-By: Claude Fable 5 --- .../Channels/ChannelGuideMetadata.cs | 71 ++++ .../Channels/ChannelGuideProjector.cs | 164 ++++++++ .../Commands/RefreshChannelDataHandler.cs | 353 ++---------------- .../Channels/Queries/GetChannelGuideData.cs | 10 + .../Queries/GetChannelGuideDataHandler.cs | 145 +++++++ .../Api/Channels/ChannelGuideResponseModel.cs | 25 ++ .../PlayoutGuideQueryableExtensions.cs | 98 +++++ ErsatzTV/Controllers/Api/ChannelController.cs | 14 + 8 files changed, 565 insertions(+), 315 deletions(-) create mode 100644 ErsatzTV.Application/Channels/ChannelGuideMetadata.cs create mode 100644 ErsatzTV.Application/Channels/ChannelGuideProjector.cs create mode 100644 ErsatzTV.Application/Channels/Queries/GetChannelGuideData.cs create mode 100644 ErsatzTV.Application/Channels/Queries/GetChannelGuideDataHandler.cs create mode 100644 ErsatzTV.Core/Api/Channels/ChannelGuideResponseModel.cs create mode 100644 ErsatzTV.Infrastructure/Extensions/PlayoutGuideQueryableExtensions.cs diff --git a/ErsatzTV.Application/Channels/ChannelGuideMetadata.cs b/ErsatzTV.Application/Channels/ChannelGuideMetadata.cs new file mode 100644 index 000000000..6c4e12e4c --- /dev/null +++ b/ErsatzTV.Application/Channels/ChannelGuideMetadata.cs @@ -0,0 +1,71 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.Channels; + +/// +/// Shared programme-metadata projection for guide output. Both the XMLTV cache builder +/// () and the JSON guide query +/// () resolve the display title/subtitle/category from a +/// here so the two representations stay consistent. +/// +public static class ChannelGuideMetadata +{ + public static string GetTitle(PlayoutItem playoutItem) + { + if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle)) + { + return playoutItem.CustomTitle; + } + + return playoutItem.MediaItem switch + { + Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty) + .IfNone("[unknown movie]"), + Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty) + .IfNone("[unknown show]"), + MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty) + .IfNone("[unknown artist]"), + OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty) + .IfNone("[unknown video]"), + RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty) + .IfNone("[unknown remote stream]"), + _ => "[unknown]" + }; + } + + public static string GetSubtitle(PlayoutItem playoutItem) + { + if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle)) + { + return string.Empty; + } + + return playoutItem.MediaItem switch + { + Episode e => e.EpisodeMetadata.HeadOrNone().Match( + em => em.Title ?? string.Empty, + () => string.Empty), + MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match( + mvm => mvm.Title ?? string.Empty, + () => string.Empty), + Song s => s.SongMetadata.HeadOrNone().Match( + mvm => mvm.Title ?? string.Empty, + () => string.Empty), + _ => string.Empty + }; + } + + /// + /// The primary guide category, mirroring the fixed <category> the XMLTV templates + /// emit per media kind (Movie / Series / Music). Media kinds without a fixed category return null. + /// + public static string GetCategory(PlayoutItem playoutItem) => + playoutItem.MediaItem switch + { + Movie => "Movie", + Episode => "Series", + MusicVideo => "Music", + Song => "Music", + _ => null + }; +} diff --git a/ErsatzTV.Application/Channels/ChannelGuideProjector.cs b/ErsatzTV.Application/Channels/ChannelGuideProjector.cs new file mode 100644 index 000000000..4ece0abb9 --- /dev/null +++ b/ErsatzTV.Application/Channels/ChannelGuideProjector.cs @@ -0,0 +1,164 @@ +using ErsatzTV.Application.Configuration; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; + +namespace ErsatzTV.Application.Channels; + +/// +/// A single guide programme resolved from one or more s: the +/// whose metadata is shown, plus the coalesced / +/// window and whether the originating item carried a custom title. +/// +public readonly record struct ChannelGuideEntry( + PlayoutItem DisplayItem, + DateTimeOffset Start, + DateTimeOffset Stop, + bool HasCustomTitle); + +/// +/// Shared guide-group / filler-merge projection. This is the single source of truth for turning a +/// channel's sorted s into guide programmes; both the XMLTV cache builder +/// () and the JSON guide query +/// () consume it so the two representations cannot drift. +/// The XMLTV path formats / +/// into the XMLTV timestamp strings; the JSON path returns them (and the display item's +/// ) directly and lets the UI decide how to render filler. +/// +public static class ChannelGuideProjector +{ + public static IEnumerable Project( + PlayoutScheduleKind scheduleKind, + IReadOnlyList sorted, + XmltvTimeZone timeZone, + XmltvBlockBehavior blockBehavior) => + scheduleKind switch + { + PlayoutScheduleKind.Block => ProjectBlock(sorted, timeZone, blockBehavior), + _ => ProjectFlood(sorted, timeZone) + }; + + // Classic / Sequential / Scripted / ExternalJson: skip leading non-preroll filler, then coalesce + // each guide group (following filler) into a single programme using the display item's GuideFinish + // override when present. + private static IEnumerable ProjectFlood( + IReadOnlyList sorted, + XmltvTimeZone timeZone) + { + // skip all filler that isn't pre-roll + var i = 0; + while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None && + sorted[i].FillerKind != FillerKind.PreRoll) + { + i++; + } + + while (i < sorted.Count) + { + PlayoutItem startItem = sorted[i]; + int j = i; + while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count) + { + j++; + } + + PlayoutItem displayItem = sorted[j]; + bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle); + + int finishIndex = j; + while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup + || sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode + or FillerKind.PostRoll or FillerKind.Tail + or FillerKind.Fallback or FillerKind.DecoDefault)) + { + finishIndex++; + } + + PlayoutItem finishItem = sorted[finishIndex]; + i = finishIndex; + + DateTimeOffset startTime = timeZone switch + { + XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero), + _ => startItem.StartOffset + }; + + DateTimeOffset stopTime = (timeZone, displayItem.GuideFinishOffset.HasValue) switch + { + (XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero), + (XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero), + (_, true) => displayItem.GuideFinishOffset!.Value, + (_, false) => finishItem.FinishOffset + }; + + yield return new ChannelGuideEntry(displayItem, startTime, stopTime, hasCustomTitle); + + i++; + } + } + + // Block: group by guide window, drop filler entirely, then either use the items' actual times or + // split the group window evenly across the non-filler items. + private static IEnumerable ProjectBlock( + IReadOnlyList sorted, + XmltvTimeZone timeZone, + XmltvBlockBehavior blockBehavior) + { + var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup }); + foreach (var group in groups) + { + var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList(); + if (itemsToInclude.Count == 0) + { + continue; + } + + switch (blockBehavior) + { + case XmltvBlockBehavior.UseActualTimes: + foreach (PlayoutItem item in itemsToInclude) + { + DateTimeOffset actualStart = timeZone switch + { + XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero), + _ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime() + }; + + DateTimeOffset actualFinish = timeZone switch + { + XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero), + _ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime() + }; + + yield return new ChannelGuideEntry(item, actualStart, actualFinish, false); + } + + break; + case XmltvBlockBehavior.SplitTimeEvenly: + default: + DateTime groupStart = group.Key.GuideStart!.Value; + DateTime groupFinish = group.Key.GuideFinish!.Value; + TimeSpan groupDuration = groupFinish - groupStart; + + TimeSpan perItem = groupDuration / itemsToInclude.Count; + + DateTimeOffset currentStart = timeZone switch + { + XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero), + _ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime() + }; + + DateTimeOffset currentFinish = currentStart + perItem; + + foreach (PlayoutItem item in itemsToInclude) + { + yield return new ChannelGuideEntry(item, currentStart, currentFinish, false); + + currentStart = currentFinish; + currentFinish += perItem; + } + + break; + } + } + } +} diff --git a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs index 5d0207327..9d50f1201 100644 --- a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs @@ -129,89 +129,7 @@ public class RefreshChannelDataHandler : IRequestHandler List playouts = await dbContext.Playouts .AsNoTracking() .Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber)) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Episode).EpisodeMetadata) - .ThenInclude(em => em.Guids) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Episode).EpisodeMetadata) - .ThenInclude(em => em.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Episode).Season) - .ThenInclude(s => s.Show) - .ThenInclude(s => s.ShowMetadata) - .ThenInclude(sm => sm.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Episode).Season) - .ThenInclude(s => s.Show) - .ThenInclude(s => s.ShowMetadata) - .ThenInclude(sm => sm.Genres) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Episode).Season) - .ThenInclude(s => s.Show) - .ThenInclude(s => s.ShowMetadata) - .ThenInclude(sm => sm.Guids) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Movie).MovieMetadata) - .ThenInclude(mm => mm.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Movie).MovieMetadata) - .ThenInclude(mm => mm.Genres) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Movie).MovieMetadata) - .ThenInclude(mm => mm.Guids) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) - .ThenInclude(mm => mm.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) - .ThenInclude(mvm => mvm.Genres) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) - .ThenInclude(mvm => mvm.Studios) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) - .ThenInclude(mvm => mvm.Directors) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) - .ThenInclude(mvm => mvm.Artists) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as MusicVideo).Artist) - .ThenInclude(a => a.ArtistMetadata) - .ThenInclude(am => am.Genres) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as OtherVideo).OtherVideoMetadata) - .ThenInclude(vm => vm.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata) - .ThenInclude(vm => vm.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Song).SongMetadata) - .ThenInclude(vm => vm.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Song).SongMetadata) - .ThenInclude(sm => sm.Genres) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Song).SongMetadata) - .ThenInclude(sm => sm.Studios) + .IncludeGuideMetadata() .AsSplitQuery() .ToListAsync(cancellationToken); @@ -244,8 +162,9 @@ public class RefreshChannelDataHandler : IRequestHandler item.Finish += playoutOffset; } - await WritePlayoutXml( + await WriteScheduleXml( request, + playout.ScheduleKind, floodSorted, templateContext, movieTemplate, @@ -270,8 +189,9 @@ public class RefreshChannelDataHandler : IRequestHandler item.Finish += playoutOffset; } - await WriteBlockPlayoutXml( + await WriteScheduleXml( request, + playout.ScheduleKind, blockSorted, templateContext, movieTemplate, @@ -294,8 +214,9 @@ public class RefreshChannelDataHandler : IRequestHandler item.Finish += playoutOffset; } - await WritePlayoutXml( + await WriteScheduleXml( request, + playout.ScheduleKind, externalJsonSorted, templateContext, movieTemplate, @@ -324,100 +245,9 @@ public class RefreshChannelDataHandler : IRequestHandler } } - private async Task WritePlayoutXml( - RefreshChannelData request, - List sorted, - XmlTemplateContext templateContext, - Template movieTemplate, - Template episodeTemplate, - Template musicVideoTemplate, - Template songTemplate, - Template otherVideoTemplate, - Template remoteStreamTemplate, - XmlMinifier minifier, - XmlWriter xml, - CancellationToken cancellationToken) - { - XmltvTimeZone xmltvTimeZone = await _configElementRepository - .GetValue(ConfigElementKey.XmltvTimeZone, cancellationToken) - .IfNoneAsync(XmltvTimeZone.Local); - - // skip all filler that isn't pre-roll - var i = 0; - while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None && - sorted[i].FillerKind != FillerKind.PreRoll) - { - i++; - } - - while (i < sorted.Count) - { - PlayoutItem startItem = sorted[i]; - int j = i; - while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count) - { - j++; - } - - PlayoutItem displayItem = sorted[j]; - bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle); - - int finishIndex = j; - while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup - || sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode - or FillerKind.PostRoll or FillerKind.Tail - or FillerKind.Fallback or FillerKind.DecoDefault)) - { - finishIndex++; - } - - PlayoutItem finishItem = sorted[finishIndex]; - i = finishIndex; - - DateTimeOffset startTime = xmltvTimeZone switch - { - XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero), - _ => startItem.StartOffset - }; - - DateTimeOffset stopTime = (xmltvTimeZone, displayItem.GuideFinishOffset.HasValue) switch - { - (XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero), - (XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero), - (_, true) => displayItem.GuideFinishOffset!.Value, - (_, false) => finishItem.FinishOffset - }; - - string start = startTime - .ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) - .Replace(":", string.Empty); - - string stop = stopTime - .ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) - .Replace(":", string.Empty); - - await WriteItemToXml( - request, - displayItem, - start, - stop, - hasCustomTitle, - templateContext, - movieTemplate, - episodeTemplate, - musicVideoTemplate, - songTemplate, - otherVideoTemplate, - remoteStreamTemplate, - minifier, - xml); - - i++; - } - } - - private async Task WriteBlockPlayoutXml( + private async Task WriteScheduleXml( RefreshChannelData request, + PlayoutScheduleKind scheduleKind, List sorted, XmlTemplateContext templateContext, Template movieTemplate, @@ -438,98 +268,36 @@ public class RefreshChannelDataHandler : IRequestHandler .GetValue(ConfigElementKey.XmltvBlockBehavior, cancellationToken) .IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly); - var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup }); - foreach (var group in groups) + // guide-group / filler-merge logic is shared with the JSON guide query so the two cannot drift + foreach (ChannelGuideEntry entry in ChannelGuideProjector.Project( + scheduleKind, + sorted, + xmltvTimeZone, + xmltvBlockBehavior)) { - var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList(); - if (itemsToInclude.Count == 0) - { - continue; - } + string start = entry.Start + .ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) + .Replace(":", string.Empty); - switch (xmltvBlockBehavior) - { - case XmltvBlockBehavior.UseActualTimes: - foreach (PlayoutItem item in itemsToInclude) - { - DateTimeOffset actualStart = xmltvTimeZone switch - { - XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero), - _ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime() - }; + string stop = entry.Stop + .ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) + .Replace(":", string.Empty); - DateTimeOffset actualFinish = xmltvTimeZone switch - { - XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero), - _ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime() - }; - - string start = actualStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) - .Replace(":", string.Empty); - string stop = actualFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) - .Replace(":", string.Empty); - - await WriteItemToXml( - request, - item, - start, - stop, - false, - templateContext, - movieTemplate, - episodeTemplate, - musicVideoTemplate, - songTemplate, - otherVideoTemplate, - remoteStreamTemplate, - minifier, - xml); - } - break; - case XmltvBlockBehavior.SplitTimeEvenly: - default: - DateTime groupStart = group.Key.GuideStart!.Value; - DateTime groupFinish = group.Key.GuideFinish!.Value; - TimeSpan groupDuration = groupFinish - groupStart; - - TimeSpan perItem = groupDuration / itemsToInclude.Count; - - DateTimeOffset currentStart = xmltvTimeZone switch - { - XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero), - _ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime() - }; - - DateTimeOffset currentFinish = currentStart + perItem; - - foreach (PlayoutItem item in itemsToInclude) - { - string start = currentStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) - .Replace(":", string.Empty); - string stop = currentFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) - .Replace(":", string.Empty); - - await WriteItemToXml( - request, - item, - start, - stop, - false, - templateContext, - movieTemplate, - episodeTemplate, - musicVideoTemplate, - songTemplate, - otherVideoTemplate, - remoteStreamTemplate, - minifier, - xml); - - currentStart = currentFinish; - currentFinish += perItem; - } - break; - } + await WriteItemToXml( + request, + entry.DisplayItem, + start, + stop, + entry.HasCustomTitle, + templateContext, + movieTemplate, + episodeTemplate, + musicVideoTemplate, + songTemplate, + otherVideoTemplate, + remoteStreamTemplate, + minifier, + xml); } } @@ -549,8 +317,8 @@ public class RefreshChannelDataHandler : IRequestHandler XmlMinifier minifier, XmlWriter xml) { - string title = GetTitle(displayItem); - string subtitle = GetSubtitle(displayItem); + string title = ChannelGuideMetadata.GetTitle(displayItem); + string subtitle = ChannelGuideMetadata.GetSubtitle(displayItem); Option maybeTemplateOutput = displayItem.MediaItem switch { @@ -1117,51 +885,6 @@ public class RefreshChannelDataHandler : IRequestHandler return artworkPath; } - private static string GetTitle(PlayoutItem playoutItem) - { - if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle)) - { - return playoutItem.CustomTitle; - } - - return playoutItem.MediaItem switch - { - Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty) - .IfNone("[unknown movie]"), - Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty) - .IfNone("[unknown show]"), - MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty) - .IfNone("[unknown artist]"), - OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty) - .IfNone("[unknown video]"), - RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty) - .IfNone("[unknown remote stream]"), - _ => "[unknown]" - }; - } - - private static string GetSubtitle(PlayoutItem playoutItem) - { - if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle)) - { - return string.Empty; - } - - return playoutItem.MediaItem switch - { - Episode e => e.EpisodeMetadata.HeadOrNone().Match( - em => em.Title ?? string.Empty, - () => string.Empty), - MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match( - mvm => mvm.Title ?? string.Empty, - () => string.Empty), - Song s => s.SongMetadata.HeadOrNone().Match( - mvm => mvm.Title ?? string.Empty, - () => string.Empty), - _ => string.Empty - }; - } - private static string GetPrioritizedArtworkPath(Metadata metadata) { Option maybeArtwork = Optional(metadata.Artwork).Flatten() diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelGuideData.cs b/ErsatzTV.Application/Channels/Queries/GetChannelGuideData.cs new file mode 100644 index 000000000..19dd89d38 --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelGuideData.cs @@ -0,0 +1,10 @@ +using ErsatzTV.Core.Api.Channels; + +namespace ErsatzTV.Application.Channels; + +/// +/// JSON channel-guide query for the EPG grid. defaults to now and +/// defaults to now + the configured XmltvDaysToBuild window. +/// +public record GetChannelGuideData(DateTimeOffset? Start, DateTimeOffset? End) + : IRequest; diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelGuideDataHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelGuideDataHandler.cs new file mode 100644 index 000000000..b4294b068 --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelGuideDataHandler.cs @@ -0,0 +1,145 @@ +using System.Globalization; +using ErsatzTV.Application.Configuration; +using ErsatzTV.Core.Api.Channels; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Channels; + +/// +/// Builds the JSON channel guide directly from items, using the shared +/// guide-group/filler-merge logic (the same logic the XMLTV +/// cache builder uses) so the two representations cannot drift. Only channels with +/// are included, mirroring GetChannelGuideHandler. +/// Unlike XMLTV, filler programmes are returned (with their ) +/// so the UI can decide how to render them. +/// +public class GetChannelGuideDataHandler( + IDbContextFactory dbContextFactory, + IConfigElementRepository configElementRepository) + : IRequestHandler +{ + public async Task Handle( + GetChannelGuideData request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + int daysToBuild = await configElementRepository + .GetValue(ConfigElementKey.XmltvDaysToBuild, cancellationToken) + .IfNoneAsync(2); + + XmltvTimeZone xmltvTimeZone = await configElementRepository + .GetValue(ConfigElementKey.XmltvTimeZone, cancellationToken) + .IfNoneAsync(XmltvTimeZone.Local); + + XmltvBlockBehavior xmltvBlockBehavior = await configElementRepository + .GetValue(ConfigElementKey.XmltvBlockBehavior, cancellationToken) + .IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly); + + DateTimeOffset start = request.Start ?? DateTimeOffset.UtcNow; + DateTimeOffset end = request.End ?? start.AddDays(daysToBuild); + + // Visible channels only (mirror GetChannelGuideHandler's ShowInEpg == false skip). + List channels = await dbContext.Channels + .AsNoTracking() + .Where(c => c.ShowInEpg) + .Include(c => c.MirrorSourceChannel) + .ToListAsync(cancellationToken); + + // Order channels by their decimal channel number so "2" precedes "10", matching + // ChannelGuide.ToXml (which orders XMLTV channels by decimal.Parse of the number). + channels = channels + .OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture)) + .ToList(); + + var responseChannels = new List(); + + foreach (Channel channel in channels) + { + bool isMirror = channel.PlayoutSource == ChannelPlayoutSource.Mirror + && channel.MirrorSourceChannel is not null; + + string sourceChannelNumber = isMirror ? channel.MirrorSourceChannel.Number : channel.Number; + TimeSpan playoutOffset = isMirror ? channel.PlayoutOffset ?? TimeSpan.Zero : TimeSpan.Zero; + + List playouts = await dbContext.Playouts + .AsNoTracking() + .Filter(p => p.Channel.Number == sourceChannelNumber) + .IncludeGuideMetadata() + .AsSplitQuery() + .ToListAsync(cancellationToken); + + var programmes = new List(); + + foreach (Playout playout in playouts) + { + // ExternalJson playouts materialize items from a file rather than Playout.Items; they are + // out of scope for the JSON guide (see issue #102 notes). + if (playout.ScheduleKind is PlayoutScheduleKind.ExternalJson) + { + continue; + } + + // Filter to the window (on the pre-offset time, mirroring the XMLTV builder) then apply the + // mirror playout offset without mutating the loaded (shared, AsNoTracking) entities. + List sorted = playout.Items + .OrderBy(pi => pi.Start) + .Filter(pi => pi.StartOffset <= end) + .Select(pi => playoutOffset == TimeSpan.Zero ? pi : WithPlayoutOffset(pi, playoutOffset)) + .ToList(); + + foreach (ChannelGuideEntry entry in ChannelGuideProjector.Project( + playout.ScheduleKind, + sorted, + xmltvTimeZone, + xmltvBlockBehavior)) + { + // drop programmes that finish before the requested window starts + if (entry.Stop <= start) + { + continue; + } + + string subtitle = ChannelGuideMetadata.GetSubtitle(entry.DisplayItem); + + programmes.Add( + new ChannelGuideProgrammeResponseModel( + entry.Start, + entry.Stop, + ChannelGuideMetadata.GetTitle(entry.DisplayItem), + string.IsNullOrWhiteSpace(subtitle) ? null : subtitle, + ChannelGuideMetadata.GetCategory(entry.DisplayItem), + entry.DisplayItem.FillerKind)); + } + } + + responseChannels.Add( + new ChannelGuideChannelResponseModel( + channel.Number, + channel.Name, + programmes.OrderBy(p => p.Start).ToList())); + } + + return new ChannelGuideResponseModel(start, end, responseChannels); + } + + // Copy (don't mutate) the loaded PlayoutItem when shifting by the mirror playout offset. The loaded + // entities are AsNoTracking and shared; mutating them in place would corrupt the guide projection. + // Mirrors the XMLTV builder, which shifts only Start/Finish (not the Guide* window). + private static PlayoutItem WithPlayoutOffset(PlayoutItem item, TimeSpan offset) => + new() + { + MediaItem = item.MediaItem, + Start = item.Start + offset, + Finish = item.Finish + offset, + GuideStart = item.GuideStart, + GuideFinish = item.GuideFinish, + GuideGroup = item.GuideGroup, + FillerKind = item.FillerKind, + CustomTitle = item.CustomTitle + }; +} diff --git a/ErsatzTV.Core/Api/Channels/ChannelGuideResponseModel.cs b/ErsatzTV.Core/Api/Channels/ChannelGuideResponseModel.cs new file mode 100644 index 000000000..cc968cff0 --- /dev/null +++ b/ErsatzTV.Core/Api/Channels/ChannelGuideResponseModel.cs @@ -0,0 +1,25 @@ +#nullable enable +using ErsatzTV.Core.Domain.Filler; + +namespace ErsatzTV.Core.Api.Channels; + +/// A single guide programme for the JSON EPG grid. +public record ChannelGuideProgrammeResponseModel( + DateTimeOffset Start, + DateTimeOffset Stop, + string Title, + string? SubTitle, + string? Category, + FillerKind FillerKind); + +/// One channel's guide programmes for the requested window. +public record ChannelGuideChannelResponseModel( + string Number, + string Name, + List Programmes); + +/// The JSON channel-guide response: the resolved window plus per-channel programme arrays. +public record ChannelGuideResponseModel( + DateTimeOffset Start, + DateTimeOffset End, + List Channels); diff --git a/ErsatzTV.Infrastructure/Extensions/PlayoutGuideQueryableExtensions.cs b/ErsatzTV.Infrastructure/Extensions/PlayoutGuideQueryableExtensions.cs new file mode 100644 index 000000000..68c14080d --- /dev/null +++ b/ErsatzTV.Infrastructure/Extensions/PlayoutGuideQueryableExtensions.cs @@ -0,0 +1,98 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Infrastructure.Extensions; + +public static class PlayoutGuideQueryableExtensions +{ + /// + /// Eager-loads the full playout-item metadata graph needed to render guide programme + /// titles/subtitles/categories/artwork. Shared by the XMLTV cache builder and the JSON guide + /// query so both surfaces see identical data. Callers should apply AsSplitQuery(). + /// + public static IQueryable IncludeGuideMetadata(this IQueryable playouts) => + playouts + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Episode).EpisodeMetadata) + .ThenInclude(em => em.Guids) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Episode).EpisodeMetadata) + .ThenInclude(em => em.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Episode).Season) + .ThenInclude(s => s.Show) + .ThenInclude(s => s.ShowMetadata) + .ThenInclude(sm => sm.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Episode).Season) + .ThenInclude(s => s.Show) + .ThenInclude(s => s.ShowMetadata) + .ThenInclude(sm => sm.Genres) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Episode).Season) + .ThenInclude(s => s.Show) + .ThenInclude(s => s.ShowMetadata) + .ThenInclude(sm => sm.Guids) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Movie).MovieMetadata) + .ThenInclude(mm => mm.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Movie).MovieMetadata) + .ThenInclude(mm => mm.Genres) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Movie).MovieMetadata) + .ThenInclude(mm => mm.Guids) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mm => mm.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Genres) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Studios) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Directors) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Artists) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).Artist) + .ThenInclude(a => a.ArtistMetadata) + .ThenInclude(am => am.Genres) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as OtherVideo).OtherVideoMetadata) + .ThenInclude(vm => vm.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata) + .ThenInclude(vm => vm.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Song).SongMetadata) + .ThenInclude(vm => vm.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Song).SongMetadata) + .ThenInclude(sm => sm.Genres) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Song).SongMetadata) + .ThenInclude(sm => sm.Studios); +} diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index 76577ca10..272764e70 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -22,6 +22,20 @@ public class ChannelController(ChannelWriter workerCh [EndpointGroupName("general")] public async Task> GetAll() => await mediator.Send(new GetAllChannelsForApi()); + [HttpGet("/api/guide")] + [Tags("Channels")] + [EndpointSummary("Get the JSON channel guide (EPG)")] + [EndpointDescription( + "Returns per-channel programme arrays for the EPG grid. When omitted, start defaults to now " + + "and end defaults to now plus the configured XmltvDaysToBuild window.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(ChannelGuideResponseModel), StatusCodes.Status200OK)] + public async Task GetGuide( + [FromQuery] DateTimeOffset? start, + [FromQuery] DateTimeOffset? end, + CancellationToken cancellationToken) => + await mediator.Send(new GetChannelGuideData(start, end), cancellationToken); + [HttpGet("/api/channels/{id:int}", Name = "GetChannelById")] [Tags("Channels")] [EndpointSummary("Get a channel by id")]