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; } } } }