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