From 6c8a0af2a1688cad19232f351935d75d8a75b31a Mon Sep 17 00:00:00 2001 From: Carson Kompon Date: Wed, 1 Jul 2026 08:41:22 -0400 Subject: [PATCH] Added `schedules` to YAML/Sequenced Playback --- .../Models/YamlPlayoutDefinition.cs | 2 + .../Models/YamlPlayoutScheduleItem.cs | 18 ++ .../SequentialPlayoutBuilder.cs | 58 ++++++- .../SequentialScheduleSelector.cs | 121 ++++++++++++++ .../YamlScheduling/YamlPlayoutContext.cs | 156 +++++++++++++++++- .../Resources/sequential-schedule.schema.json | 48 ++++++ 6 files changed, 389 insertions(+), 14 deletions(-) create mode 100644 ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutScheduleItem.cs create mode 100644 ErsatzTV.Core/Scheduling/YamlScheduling/SequentialScheduleSelector.cs diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutDefinition.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutDefinition.cs index 4769fe305..50bbf8d23 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutDefinition.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutDefinition.cs @@ -11,4 +11,6 @@ public class YamlPlayoutDefinition public List Reset { get; set; } = []; public List Playout { get; set; } = []; + + public List Schedules { get; set; } = []; } diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutScheduleItem.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutScheduleItem.cs new file mode 100644 index 000000000..853e0e2ab --- /dev/null +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutScheduleItem.cs @@ -0,0 +1,18 @@ +using YamlDotNet.Serialization; + +namespace ErsatzTV.Core.Scheduling.YamlScheduling.Models; + +public class YamlPlayoutScheduleItem +{ + public string Name { get; set; } + + [YamlMember(Alias = "start_date", ApplyNamingConventions = false)] + public string StartDate { get; set; } + + [YamlMember(Alias = "end_date", ApplyNamingConventions = false)] + public string EndDate { get; set; } + + public int Priority { get; set; } + + public List Playout { get; set; } = []; +} diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs index 232fb6c58..67b42dbed 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs @@ -249,20 +249,62 @@ public class SequentialPlayoutBuilder( break; } - FlattenSequences(context); + FlattenSequences(context, context.Definition.Playout); flattenCount++; } + // flatten each schedule's playout the same way as the default playout + foreach (YamlPlayoutScheduleItem schedule in context.Definition.Schedules) + { + var scheduleFlattenCount = 0; + while (schedule.Playout.Any(x => x is YamlPlayoutSequenceInstruction)) + { + if (scheduleFlattenCount > 100) + { + logger.LogError( + "YAML schedule '{Schedule}' contains sequence nesting that is too deep; this introduces undefined behavior", + schedule.Name); + break; + } + + FlattenSequences(context, schedule.Playout); + scheduleFlattenCount++; + } + } + // handle all playout instructions while (context.CurrentTime < finish) { - if (context.InstructionIndex >= playoutDefinition.Playout.Count) + // select the active schedule (if any) for the current time; a null name means the default playout + string activeScheduleName = null; + foreach (YamlPlayoutScheduleItem activeSchedule in + SequentialScheduleSelector.GetActiveSchedule(context.Definition.Schedules, context.CurrentTime)) + { + activeScheduleName = activeSchedule.Name; + } + + if (!string.Equals(activeScheduleName, context.ActiveSchedule, StringComparison.Ordinal)) + { + logger.LogDebug( + "Switching sequential playout to schedule {Schedule} at {Time}", + activeScheduleName ?? "(default)", + context.CurrentTime); + + context.SwitchToSchedule(activeScheduleName); + + // split the EPG guide group at the schedule boundary + context.AdvanceGuideGroup(); + } + + List instructions = context.CurrentInstructions; + + if (context.InstructionIndex >= instructions.Count) { logger.LogInformation("Reached the end of the YAML playout definition; stopping"); break; } - YamlPlayoutInstruction instruction = playoutDefinition.Playout[context.InstructionIndex]; + YamlPlayoutInstruction instruction = instructions[context.InstructionIndex]; //logger.LogDebug("Current playout instruction: {Instruction}", instruction.GetType().Name); Option maybeHandler = GetHandlerForInstruction(handlers, enumeratorCache, instruction); @@ -382,10 +424,10 @@ public class SequentialPlayoutBuilder( .GetValue(ConfigElementKey.PlayoutDaysToBuild, cancellationToken) .IfNoneAsync(2); - private static void FlattenSequences(YamlPlayoutContext context) + private static void FlattenSequences(YamlPlayoutContext context, List playout) { - var rawInstructions = context.Definition.Playout.ToImmutableList(); - context.Definition.Playout.Clear(); + var rawInstructions = playout.ToImmutableList(); + playout.Clear(); foreach (YamlPlayoutInstruction instruction in rawInstructions) { @@ -417,13 +459,13 @@ public class SequentialPlayoutBuilder( i.CustomTitle = sequenceInstruction.CustomTitle; } - context.Definition.Playout.Add(i); + playout.Add(i); } } break; default: - context.Definition.Playout.Add(instruction); + playout.Add(instruction); break; } } diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialScheduleSelector.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialScheduleSelector.cs new file mode 100644 index 000000000..e5632822d --- /dev/null +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialScheduleSelector.cs @@ -0,0 +1,121 @@ +using System.Globalization; +using ErsatzTV.Core.Scheduling.YamlScheduling.Models; + +namespace ErsatzTV.Core.Scheduling.YamlScheduling; + +public static class SequentialScheduleSelector +{ + private static readonly string[] SpecificFormats = ["yyyy-MM-dd"]; + private static readonly string[] AnnualFormats = ["MM-dd"]; + + /// + /// Returns the highest-priority schedule whose date range contains the given time. + /// Ties on priority are broken by definition order (earlier wins). + /// + public static Option GetActiveSchedule( + IReadOnlyList schedules, + DateTimeOffset time) + { + if (schedules is null || schedules.Count == 0) + { + return Option.None; + } + + var date = DateOnly.FromDateTime(time.LocalDateTime); + + Option best = Option.None; + int bestPriority = int.MinValue; + + for (var i = 0; i < schedules.Count; i++) + { + YamlPlayoutScheduleItem schedule = schedules[i]; + if (!Matches(schedule, date)) + { + continue; + } + + // strictly greater keeps the earliest schedule on ties + if (schedule.Priority > bestPriority) + { + bestPriority = schedule.Priority; + best = schedule; + } + } + + return best; + } + + private static bool Matches(YamlPlayoutScheduleItem schedule, DateOnly date) + { + ParsedDate? start = ParseDate(schedule.StartDate); + ParsedDate? end = ParseDate(schedule.EndDate); + if (start is null || end is null) + { + return false; + } + + // a date is "specific" if either endpoint includes a year + bool specific = start.Value.Year.HasValue || end.Value.Year.HasValue; + if (specific) + { + int startYear = start.Value.Year ?? end.Value.Year ?? date.Year; + int endYear = end.Value.Year ?? start.Value.Year ?? date.Year; + + try + { + var startDate = new DateOnly(startYear, start.Value.Month, start.Value.Day); + var endDate = new DateOnly(endYear, end.Value.Month, end.Value.Day); + return date >= startDate && date <= endDate; + } + catch (ArgumentOutOfRangeException) + { + // e.g. Feb 29 in a non-leap year + return false; + } + } + + // annual range: compare by month/day so it repeats every year + (int Month, int Day) startMd = (start.Value.Month, start.Value.Day); + (int Month, int Day) endMd = (end.Value.Month, end.Value.Day); + (int Month, int Day) currentMd = (date.Month, date.Day); + + if (CompareMonthDay(startMd, endMd) <= 0) + { + // range does not wrap the year boundary + return CompareMonthDay(currentMd, startMd) >= 0 && CompareMonthDay(currentMd, endMd) <= 0; + } + + // range wraps the year boundary (e.g. 12-01 through 01-15) + return CompareMonthDay(currentMd, startMd) >= 0 || CompareMonthDay(currentMd, endMd) <= 0; + } + + private static int CompareMonthDay((int Month, int Day) left, (int Month, int Day) right) + { + int monthComparison = left.Month.CompareTo(right.Month); + return monthComparison != 0 ? monthComparison : left.Day.CompareTo(right.Day); + } + + private static ParsedDate? ParseDate(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + value = value.Trim(); + + if (DateOnly.TryParseExact(value, SpecificFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly specific)) + { + return new ParsedDate(specific.Year, specific.Month, specific.Day); + } + + if (DateOnly.TryParseExact(value, AnnualFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly annual)) + { + return new ParsedDate(null, annual.Month, annual.Day); + } + + return null; + } + + private readonly record struct ParsedDate(int? Year, int Month, int Day); +} diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs index 63e30f016..c0cdb3e12 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs @@ -17,7 +17,7 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio private readonly Stack _fillerKind = new(); private readonly Dictionary _graphicsElements = []; - private readonly System.Collections.Generic.HashSet _visitedInstructions = []; + private System.Collections.Generic.HashSet _visitedInstructions = []; private int _guideGroup = guideGroup; private bool _guideGroupLocked; private int _instructionIndex; @@ -25,6 +25,14 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio private Option _postRollSequence; private Option _preRollSequence; + // null active schedule => default playout + private string _activeSchedule; + private List _currentInstructions; + + // saved state for each playout list (default keyed by empty string) so switching + // between schedules resumes each list's position and ambient modifiers cleanly + private readonly Dictionary _listStates = []; + public Playout Playout { get; } = playout; public List AddedItems { get; } = []; @@ -45,7 +53,95 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio } } - public bool VisitedAll => _visitedInstructions.Count >= Definition.Playout.Count; + public bool VisitedAll => _visitedInstructions.Count >= CurrentInstructions.Count; + + // the instruction list currently being executed (default playout or an active schedule) + public List CurrentInstructions => _currentInstructions ?? Definition.Playout; + + public string ActiveSchedule => _activeSchedule; + + // switch to the playout list for the given schedule (null => default playout) + public void SwitchToSchedule(string scheduleName) + { + // snapshot the state (position + ambient modifiers) of the list we're leaving + string currentKey = _activeSchedule ?? string.Empty; + _listStates[currentKey] = CaptureState(); + + _activeSchedule = scheduleName; + + if (scheduleName is null) + { + _currentInstructions = null; + } + else + { + _currentInstructions = Definition.Schedules + .Filter(s => string.Equals(s.Name, scheduleName, StringComparison.Ordinal)) + .Map(s => s.Playout) + .HeadOrNone() + .IfNone(Definition.Playout); + } + + string targetKey = scheduleName ?? string.Empty; + if (_listStates.TryGetValue(targetKey, out ListState savedState)) + { + // resume where this list left off, including its ambient modifiers + RestoreState(savedState); + } + else + { + // first time entering this list; start fresh with no ambient modifiers + _instructionIndex = 0; + _visitedInstructions = []; + _channelWatermarkIds.Clear(); + _graphicsElements.Clear(); + _fillerKind.Clear(); + _preRollSequence = Option.None; + _postRollSequence = Option.None; + _midRollSequence = Option.None; + } + } + + private ListState CaptureState() => + new( + _instructionIndex, + [.._visitedInstructions], + [.._channelWatermarkIds], + new Dictionary(_graphicsElements), + [.._fillerKind], + _preRollSequence, + _postRollSequence, + _midRollSequence); + + private void RestoreState(ListState state) + { + _instructionIndex = state.InstructionIndex; + + _visitedInstructions = [..state.VisitedInstructions]; + + _channelWatermarkIds.Clear(); + foreach (int id in state.ChannelWatermarkIds) + { + _channelWatermarkIds.Add(id); + } + + _graphicsElements.Clear(); + foreach ((int id, string variables) in state.GraphicsElements) + { + _graphicsElements[id] = variables; + } + + _fillerKind.Clear(); + // stack was captured top-first; push in reverse to preserve order + for (int i = state.FillerKind.Count - 1; i >= 0; i--) + { + _fillerKind.Push(state.FillerKind[i]); + } + + _preRollSequence = state.PreRollSequence; + _postRollSequence = state.PostRollSequence; + _midRollSequence = state.MidRollSequence; + } public int PeekNextGuideGroup() { @@ -94,7 +190,7 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio public void ClearChannelWatermarkIds() => _channelWatermarkIds.Clear(); public List GetChannelWatermarkIds() => _channelWatermarkIds.ToList(); - public void SetGraphicsElement(int id, string variablesJson) => _graphicsElements.Add(id, variablesJson); + public void SetGraphicsElement(int id, string variablesJson) => _graphicsElements[id] = variablesJson; public void RemoveGraphicsElement(int id) => _graphicsElements.Remove(id); public void ClearGraphicsElements() => _graphicsElements.Clear(); public IReadOnlyDictionary GetGraphicsElements() => _graphicsElements; @@ -125,12 +221,18 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio preRollSequence = sequence; } + // capture the current active list index alongside the other saved list indices + var scheduleIndices = _listStates.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.InstructionIndex); + scheduleIndices[_activeSchedule ?? string.Empty] = _instructionIndex; + var state = new State( _instructionIndex, _guideGroup, _guideGroupLocked, _channelWatermarkIds.ToList(), - preRollSequence); + preRollSequence, + _activeSchedule, + scheduleIndices); return JsonConvert.SerializeObject(state, Formatting.None, JsonSettings); } @@ -174,6 +276,34 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio { _preRollSequence = preRollSequence; } + + // restore saved instruction indices for each playout list + if (state.ScheduleIndices is not null) + { + foreach ((string key, int index) in state.ScheduleIndices) + { + _listStates[key] = new ListState( + index, + [], + [], + new Dictionary(), + [], + Option.None, + Option.None, + Option.None); + } + } + + // restore the active schedule and point the current instruction list at it + _activeSchedule = state.ActiveSchedule; + if (_activeSchedule is not null) + { + _currentInstructions = Definition.Schedules + .Filter(s => string.Equals(s.Name, _activeSchedule, StringComparison.Ordinal)) + .Map(s => s.Playout) + .HeadOrNone() + .IfNone(Definition.Playout); + } } public record State( @@ -181,7 +311,21 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio int? GuideGroup, bool? GuideGroupLocked, List ChannelWatermarkIds, - string PreRollSequence); + string PreRollSequence, + string ActiveSchedule = null, + Dictionary ScheduleIndices = null); public record MidRollSequence(string Sequence, string Expression); -} + + // in-memory snapshot of a playout list's position and ambient modifier state, + // used to resume each list cleanly when switching between schedules during a build + private sealed record ListState( + int InstructionIndex, + System.Collections.Generic.HashSet VisitedInstructions, + System.Collections.Generic.HashSet ChannelWatermarkIds, + Dictionary GraphicsElements, + List FillerKind, + Option PreRollSequence, + Option PostRollSequence, + Option MidRollSequence); +} \ No newline at end of file diff --git a/ErsatzTV/Resources/sequential-schedule.schema.json b/ErsatzTV/Resources/sequential-schedule.schema.json index 004933eca..fc38fa64d 100644 --- a/ErsatzTV/Resources/sequential-schedule.schema.json +++ b/ErsatzTV/Resources/sequential-schedule.schema.json @@ -104,6 +104,54 @@ ] }, "minItems": 1 + }, + "schedules": { + "description": "Date-based schedule overrides", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "start_date": { + "type": "string", + "pattern": "^(\\d{4}-)?\\d{2}-\\d{2}$" + }, + "end_date": { + "type": "string", + "pattern": "^(\\d{4}-)?\\d{2}-\\d{2}$" + }, + "priority": { "type": "integer" }, + "playout": { + "description": "Playout instructions", + "type": "array", + "items": { + "oneOf": [ + { "$ref": "#/$defs/scheduling/allInstruction" }, + { "$ref": "#/$defs/scheduling/countInstruction" }, + { "$ref": "#/$defs/scheduling/durationInstruction" }, + { "$ref": "#/$defs/scheduling/padToNextInstruction" }, + { "$ref": "#/$defs/scheduling/padUntilInstruction" }, + { "$ref": "#/$defs/scheduling/sequenceInstruction" }, + { "$ref": "#/$defs/control/epgGroupInstruction" }, + { "$ref": "#/$defs/control/graphicsOnInstruction" }, + { "$ref": "#/$defs/control/graphicsOffInstruction" }, + { "$ref": "#/$defs/control/preRollInstruction"}, + { "$ref": "#/$defs/control/postRollInstruction"}, + { "$ref": "#/$defs/control/midRollInstruction"}, + { "$ref": "#/$defs/control/repeatInstruction" }, + { "$ref": "#/$defs/control/shuffleSequenceInstruction" }, + { "$ref": "#/$defs/control/skipItemsInstruction" }, + { "$ref": "#/$defs/control/skipToItemInstruction" }, + { "$ref": "#/$defs/control/waitUntilInstruction" }, + { "$ref": "#/$defs/control/watermarkInstruction" } + ] + }, + "minItems": 1 + } + }, + "required": [ "name", "start_date", "end_date", "playout" ], + "additionalProperties": false + } } }, "allOf": [