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.Tests/Application/Channels/GetChannelGuideDataHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/GetChannelGuideDataHandlerTests.cs new file mode 100644 index 000000000..806c7a64d --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/GetChannelGuideDataHandlerTests.cs @@ -0,0 +1,316 @@ +using ErsatzTV.Application.Channels; +using ErsatzTV.Application.Configuration; +using ErsatzTV.Core.Api.Channels; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using DomainChannel = ErsatzTV.Core.Domain.Channel; + +namespace ErsatzTV.Tests.Application.Channels; + +[TestFixture] +public class GetChannelGuideDataHandlerTests +{ + private static readonly DateTime BaseTime = new(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc); + + private InMemoryTvContext _db = null!; + private IConfigElementRepository _config = null!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _config = Substitute.For(); + + // ConfigElementKey has reference equality and each static accessor returns a fresh instance, so we + // match by the generic value type (GetValue) with Arg.Any key. Pin the time zone to UTC so + // projected times are deterministic regardless of the machine/CI time zone, and split block time + // evenly. XmltvDaysToBuild is left unconfigured (falls back to 2) except in the default-window test. + _config.GetValue(Arg.Any(), Arg.Any()) + .Returns(Option.Some(XmltvTimeZone.Utc)); + _config.GetValue(Arg.Any(), Arg.Any()) + .Returns(Option.Some(XmltvBlockBehavior.SplitTimeEvenly)); + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + private GetChannelGuideDataHandler MakeHandler() => new(_db.Factory, _config); + + [Test] + public async Task Handle_Should_Only_Include_Visible_Channels() + { + await using (TvContext context = _db.CreateContext()) + { + DomainChannel visible = NewChannel("2", "Visible", showInEpg: true); + DomainChannel hidden = NewChannel("3", "Hidden", showInEpg: false); + visible.Playouts = [MakeFloodPlayout(visible, (BaseTime, BaseTime.AddHours(1), 1, "Visible Show"))]; + hidden.Playouts = [MakeFloodPlayout(hidden, (BaseTime, BaseTime.AddHours(1), 1, "Hidden Show"))]; + context.Channels.AddRange(visible, hidden); + await context.SaveChangesAsync(); + } + + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), + CancellationToken.None); + + result.Channels.Select(c => c.Number).ShouldBe(["2"]); + } + + [Test] + public async Task Handle_Should_Order_Channels_By_Decimal_Number() + { + await using (TvContext context = _db.CreateContext()) + { + context.Channels.Add(NewChannel("10", "Ten", showInEpg: true)); + context.Channels.Add(NewChannel("2", "Two", showInEpg: true)); + context.Channels.Add(NewChannel("5.1", "FiveOne", showInEpg: true)); + await context.SaveChangesAsync(); + } + + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), + CancellationToken.None); + + // decimal order: 2 < 5.1 < 10 (string order would put "10" first) + result.Channels.Select(c => c.Number).ShouldBe(["2", "5.1", "10"]); + } + + [Test] + public async Task Handle_Should_Filter_Programmes_To_Requested_Window() + { + await using (TvContext context = _db.CreateContext()) + { + DomainChannel channel = NewChannel("2", "Two", showInEpg: true); + channel.Playouts = + [ + MakeFloodPlayout( + channel, + (BaseTime, BaseTime.AddHours(1), 1, "Before Window"), + (BaseTime.AddHours(10), BaseTime.AddHours(11), 2, "In Window")) + ]; + context.Channels.Add(channel); + await context.SaveChangesAsync(); + } + + // window starts after the first programme has finished + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(BaseTime.AddHours(5), BaseTime.AddHours(20)), + CancellationToken.None); + + List programmes = result.Channels.Single().Programmes; + programmes.Select(p => p.Title).ShouldBe(["In Window"]); + } + + [Test] + public async Task Handle_Should_Passthrough_Custom_Title_And_Null_SubTitle() + { + await using (TvContext context = _db.CreateContext()) + { + DomainChannel channel = NewChannel("2", "Two", showInEpg: true); + PlayoutItem item = MakeItem(BaseTime, BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Ignored"); + item.CustomTitle = "Custom Title"; + channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Classic, [item])]; + context.Channels.Add(channel); + await context.SaveChangesAsync(); + } + + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), + CancellationToken.None); + + ChannelGuideProgrammeResponseModel programme = result.Channels.Single().Programmes.Single(); + programme.Title.ShouldBe("Custom Title"); + programme.SubTitle.ShouldBeNull(); + programme.Category.ShouldBe("Movie"); + } + + [Test] + public async Task Handle_Should_Merge_Leading_PreRoll_Filler_Into_Following_Programme() + { + await using (TvContext context = _db.CreateContext()) + { + DomainChannel channel = NewChannel("2", "Two", showInEpg: true); + PlayoutItem preRoll = MakeItem(BaseTime, BaseTime.AddMinutes(5), guideGroup: 1, movieTitle: "Bumper"); + preRoll.FillerKind = FillerKind.PreRoll; + PlayoutItem content = MakeItem(BaseTime.AddMinutes(5), BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Feature"); + channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Classic, [preRoll, content])]; + context.Channels.Add(channel); + await context.SaveChangesAsync(); + } + + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), + CancellationToken.None); + + ChannelGuideProgrammeResponseModel programme = result.Channels.Single().Programmes.Single(); + // filler is merged: the programme starts at the pre-roll start but displays the feature metadata + programme.Title.ShouldBe("Feature"); + programme.Start.ShouldBe(new DateTimeOffset(BaseTime, TimeSpan.Zero)); + programme.Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1), TimeSpan.Zero)); + programme.FillerKind.ShouldBe(FillerKind.None); + } + + [Test] + public async Task Handle_Should_Split_Block_Window_Evenly_Across_Content_Items() + { + await using (TvContext context = _db.CreateContext()) + { + DomainChannel channel = NewChannel("2", "Two", showInEpg: true); + PlayoutItem one = MakeItem(BaseTime, BaseTime.AddMinutes(20), guideGroup: 7, movieTitle: "One"); + PlayoutItem two = MakeItem(BaseTime.AddMinutes(20), BaseTime.AddMinutes(40), guideGroup: 7, movieTitle: "Two"); + foreach (PlayoutItem item in new[] { one, two }) + { + item.GuideStart = BaseTime; + item.GuideFinish = BaseTime.AddHours(1); + } + + channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Block, [one, two])]; + context.Channels.Add(channel); + await context.SaveChangesAsync(); + } + + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), + CancellationToken.None); + + List programmes = result.Channels.Single().Programmes; + programmes.Count.ShouldBe(2); + // 1-hour guide window split evenly across the 2 content items -> 30 minutes each + programmes[0].Start.ShouldBe(new DateTimeOffset(BaseTime, TimeSpan.Zero)); + programmes[0].Stop.ShouldBe(new DateTimeOffset(BaseTime.AddMinutes(30), TimeSpan.Zero)); + programmes[1].Start.ShouldBe(new DateTimeOffset(BaseTime.AddMinutes(30), TimeSpan.Zero)); + programmes[1].Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1), TimeSpan.Zero)); + } + + [Test] + public async Task Handle_Should_Default_Start_To_Now_And_End_To_DaysToBuild() + { + _config.GetValue(Arg.Any(), Arg.Any()) + .Returns(Option.Some(3)); + + DateTimeOffset before = DateTimeOffset.UtcNow; + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(null, null), + CancellationToken.None); + DateTimeOffset after = DateTimeOffset.UtcNow; + + result.Start.ShouldBeInRange(before, after); + (result.End - result.Start).ShouldBe(TimeSpan.FromDays(3)); + } + + [Test] + public async Task Handle_Should_Shift_Mirror_Channel_Programmes_By_PlayoutOffset_Without_Mutating_Source_Items() + { + PlayoutItem sourceItem = MakeItem(BaseTime, BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Live Show"); + var playoutOffset = TimeSpan.FromHours(3); + + await using (TvContext context = _db.CreateContext()) + { + DomainChannel source = NewChannel("1", "Source", showInEpg: false); + source.Playouts = [MakePlayout(source, PlayoutScheduleKind.Classic, [sourceItem])]; + + DomainChannel mirror = NewChannel("2", "Mirror", showInEpg: true); + mirror.PlayoutSource = ChannelPlayoutSource.Mirror; + mirror.MirrorSourceChannel = source; + mirror.PlayoutOffset = playoutOffset; + + context.Channels.AddRange(source, mirror); + await context.SaveChangesAsync(); + } + + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), + CancellationToken.None); + + ChannelGuideProgrammeResponseModel programme = result.Channels.Single(c => c.Number == "2").Programmes.Single(); + programme.Title.ShouldBe("Live Show"); + programme.Start.ShouldBe(new DateTimeOffset(BaseTime.Add(playoutOffset), TimeSpan.Zero)); + programme.Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1).Add(playoutOffset), TimeSpan.Zero)); + + // the WithPlayoutOffset copy fix: applying the mirror offset must not mutate the source playout's + // own (shared, AsNoTracking) PlayoutItem entities in place. + sourceItem.Start.ShouldBe(BaseTime); + sourceItem.Finish.ShouldBe(BaseTime.AddHours(1)); + } + + [Test] + public async Task Handle_Should_Return_Empty_Programmes_For_Channel_Without_Playout() + { + await using (TvContext context = _db.CreateContext()) + { + context.Channels.Add(NewChannel("2", "Two", showInEpg: true)); + await context.SaveChangesAsync(); + } + + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), + CancellationToken.None); + + result.Channels.Single().Programmes.ShouldBeEmpty(); + } + + // --- seeding helpers --- + + private static Playout MakeFloodPlayout( + DomainChannel channel, + params (DateTime Start, DateTime Finish, int GuideGroup, string Title)[] items) => + MakePlayout( + channel, + PlayoutScheduleKind.Classic, + items.Select(i => MakeItem(i.Start, i.Finish, i.GuideGroup, i.Title)).ToList()); + + private static Playout MakePlayout( + DomainChannel channel, + PlayoutScheduleKind scheduleKind, + List items) => + new() + { + Channel = channel, + ScheduleKind = scheduleKind, + ScheduleFile = string.Empty, + Items = items + }; + + private static PlayoutItem MakeItem( + DateTime start, + DateTime finish, + int guideGroup, + string movieTitle) => + new() + { + Start = start, + Finish = finish, + GuideGroup = guideGroup, + FillerKind = FillerKind.None, + MediaItem = new Movie + { + MovieMetadata = [new MovieMetadata { Title = movieTitle }], + MediaVersions = [] + } + }; + + private static DomainChannel NewChannel(string number, string name, bool showInEpg) => + new(Guid.NewGuid()) + { + Number = number, + Name = name, + Group = "ErsatzTV", + Categories = string.Empty, + StreamSelector = string.Empty, + PreferredAudioLanguageCode = string.Empty, + PreferredAudioTitle = string.Empty, + PreferredSubtitleLanguageCode = string.Empty, + MusicVideoCreditsTemplate = string.Empty, + PlayoutSource = ChannelPlayoutSource.Generated, + PlayoutMode = ChannelPlayoutMode.Continuous, + ShowInEpg = showInEpg, + Playouts = [] + }; +} diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs index 1a03ca2e0..65def054f 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 ErsatzTV.Core.Scheduling; @@ -126,6 +127,35 @@ public class ChannelControllerTests result.ShouldBeOfType(); } + [Test] + public async Task GetGuide_Should_Map_Query_And_Return_Model() + { + var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + var end = new DateTimeOffset(2026, 1, 3, 0, 0, 0, TimeSpan.Zero); + var model = new ChannelGuideResponseModel(start, end, []); + _mediator.Send(Arg.Any(), Arg.Any()).Returns(model); + + ChannelGuideResponseModel result = await _controller.GetGuide(start, end, CancellationToken.None); + + result.ShouldBe(model); + await _mediator.Received(1).Send( + Arg.Is(q => q.Start == start && q.End == end), + Arg.Any()); + } + + [Test] + public async Task GetGuide_Should_Pass_Null_Bounds_Through() + { + var model = new ChannelGuideResponseModel(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, []); + _mediator.Send(Arg.Any(), Arg.Any()).Returns(model); + + await _controller.GetGuide(null, null, CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(q => q.Start == null && q.End == null), + Arg.Any()); + } + [Test] public async Task GetById_Should_Return_200_For_Some() { diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index 76577ca10..c5d71d8ad 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 start 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")] diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 76730ed41..72654b915 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -215,6 +215,55 @@ } } }, + "/api/guide": { + "get": { + "tags": [ + "Channels" + ], + "summary": "Get the JSON channel guide (EPG)", + "description": "Returns per-channel programme arrays for the EPG grid. When omitted, start defaults to now, and end defaults to start plus the configured XmltvDaysToBuild window.", + "parameters": [ + { + "name": "start", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "end", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ChannelGuideResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelGuideResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ChannelGuideResponseModel" + } + } + } + } + } + } + }, "/api/channels/{id}": { "get": { "tags": [ @@ -3587,6 +3636,91 @@ } } }, + "ChannelGuideChannelResponseModel": { + "required": [ + "number", + "name", + "programmes" + ], + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "name": { + "type": "string" + }, + "programmes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelGuideProgrammeResponseModel" + } + } + } + }, + "ChannelGuideProgrammeResponseModel": { + "required": [ + "start", + "stop", + "title", + "subTitle", + "category", + "fillerKind" + ], + "type": "object", + "properties": { + "start": { + "type": "string", + "format": "date-time" + }, + "stop": { + "type": "string", + "format": "date-time" + }, + "title": { + "type": "string" + }, + "subTitle": { + "type": [ + "null", + "string" + ] + }, + "category": { + "type": [ + "null", + "string" + ] + }, + "fillerKind": { + "$ref": "#/components/schemas/FillerKind" + } + } + }, + "ChannelGuideResponseModel": { + "required": [ + "start", + "end", + "channels" + ], + "type": "object", + "properties": { + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + }, + "channels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelGuideChannelResponseModel" + } + } + } + }, "ChannelIdleBehavior": { "enum": [ "StopOnDisconnect",