Add GET /api/guide returning a per-channel EPG grid (ChannelGuideResponseModel). start defaults to now, end defaults to now + XmltvDaysToBuild. Extract the guide-group/filler-merge projection (ChannelGuideProjector) and programme-metadata resolution (ChannelGuideMetadata) plus the metadata eager-load (IncludeGuideMetadata) out of RefreshChannelDataHandler so the XMLTV cache builder and the JSON guide share one source of truth and cannot drift. XMLTV output is unchanged (ChannelGuideGoldenTests pass without regeneration). The JSON handler mirrors XMLTV semantics: ShowInEpg-only channels, decimal channel ordering, ExternalJson playouts skipped, mirror playout offset applied to copies (never mutating the loaded entities). SubTitle/Category are nullable. refs #102 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
165 lines
6.8 KiB
C#
165 lines
6.8 KiB
C#
using ErsatzTV.Application.Configuration;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Domain.Filler;
|
|
|
|
namespace ErsatzTV.Application.Channels;
|
|
|
|
/// <summary>
|
|
/// A single guide programme resolved from one or more <see cref="PlayoutItem" />s: the
|
|
/// <see cref="DisplayItem" /> whose metadata is shown, plus the coalesced <see cref="Start" />/
|
|
/// <see cref="Stop" /> window and whether the originating item carried a custom title.
|
|
/// </summary>
|
|
public readonly record struct ChannelGuideEntry(
|
|
PlayoutItem DisplayItem,
|
|
DateTimeOffset Start,
|
|
DateTimeOffset Stop,
|
|
bool HasCustomTitle);
|
|
|
|
/// <summary>
|
|
/// Shared guide-group / filler-merge projection. This is the single source of truth for turning a
|
|
/// channel's sorted <see cref="PlayoutItem" />s into guide programmes; both the XMLTV cache builder
|
|
/// (<see cref="RefreshChannelDataHandler" />) and the JSON guide query
|
|
/// (<see cref="GetChannelGuideDataHandler" />) consume it so the two representations cannot drift.
|
|
/// The XMLTV path formats <see cref="ChannelGuideEntry.Start" />/<see cref="ChannelGuideEntry.Stop" />
|
|
/// into the XMLTV timestamp strings; the JSON path returns them (and the display item's
|
|
/// <see cref="FillerKind" />) directly and lets the UI decide how to render filler.
|
|
/// </summary>
|
|
public static class ChannelGuideProjector
|
|
{
|
|
public static IEnumerable<ChannelGuideEntry> Project(
|
|
PlayoutScheduleKind scheduleKind,
|
|
IReadOnlyList<PlayoutItem> 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<ChannelGuideEntry> ProjectFlood(
|
|
IReadOnlyList<PlayoutItem> 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<ChannelGuideEntry> ProjectBlock(
|
|
IReadOnlyList<PlayoutItem> 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;
|
|
}
|
|
}
|
|
}
|
|
}
|