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",