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")]