From 42937ac37231c844907638f8401c654b289097a8 Mon Sep 17 00:00:00 2001 From: Carson Kompon Date: Wed, 1 Jul 2026 20:09:06 -0400 Subject: [PATCH] feat: YAML Scheduled Playouts (#2935) * Added `schedules` to YAML/Sequenced Playback * Add tests for yaml playback * `YamlPlayoutScheduleItem` now implements `IAlternateScheduleItem` * New tests for YamlPlayoutScheduleItem instead of the old SequentialScheduleSelector * add more safety and a couple tests * fix shuffle_sequence instruction handler --------- Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com> --- .../AlternateScheduleSelectorTests.cs | 48 +++++ .../Scheduling/YamlPlayoutContextTests.cs | 77 ++++++++ .../YamlPlayoutScheduleSelectionTests.cs | 168 ++++++++++++++++++ .../Scheduling/AlternateScheduleSelector.cs | 6 + .../YamlPlayoutShuffleSequenceHandler.cs | 10 +- .../Models/YamlPlayoutDefinition.cs | 2 + .../Models/YamlPlayoutScheduleItem.cs | 118 ++++++++++++ .../SequentialPlayoutBuilder.cs | 58 +++++- .../YamlScheduling/YamlPlayoutContext.cs | 156 +++++++++++++++- .../Resources/sequential-schedule.schema.json | 48 +++++ 10 files changed, 673 insertions(+), 18 deletions(-) create mode 100644 ErsatzTV.Core.Tests/Scheduling/YamlPlayoutContextTests.cs create mode 100644 ErsatzTV.Core.Tests/Scheduling/YamlPlayoutScheduleSelectionTests.cs create mode 100644 ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutScheduleItem.cs diff --git a/ErsatzTV.Core.Tests/Scheduling/AlternateScheduleSelectorTests.cs b/ErsatzTV.Core.Tests/Scheduling/AlternateScheduleSelectorTests.cs index 34af2a3f9..129ee07d7 100644 --- a/ErsatzTV.Core.Tests/Scheduling/AlternateScheduleSelectorTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/AlternateScheduleSelectorTests.cs @@ -863,5 +863,53 @@ public static class AlternateScheduleSelectorTests result.IsNone.ShouldBeFalse(); } + + [Test] + public void LimitToDateRange_13th_Month_Should_Not_Crash() + { + var template = new PlayoutTemplate + { + DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(), + DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(), + MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(), + LimitToDateRange = true, + StartMonth = 13, + StartDay = 30, + StartYear = 2022, + EndMonth = 13, + EndDay = 30, + EndYear = 2023 + }; + + Option result = AlternateScheduleSelector.GetScheduleForDate( + new List { template }, + new DateTimeOffset(2023, 3, 1, 0, 0, 0, Offset)); + + result.IsNone.ShouldBeTrue(); + } + + [Test] + public void LimitToDateRange_0th_Day_Should_Not_Crash() + { + var template = new PlayoutTemplate + { + DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(), + DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(), + MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(), + LimitToDateRange = true, + StartMonth = 12, + StartDay = 0, + StartYear = 2022, + EndMonth = 12, + EndDay = 0, + EndYear = 2023 + }; + + Option result = AlternateScheduleSelector.GetScheduleForDate( + new List { template }, + new DateTimeOffset(2023, 3, 1, 0, 0, 0, Offset)); + + result.IsNone.ShouldBeTrue(); + } } } diff --git a/ErsatzTV.Core.Tests/Scheduling/YamlPlayoutContextTests.cs b/ErsatzTV.Core.Tests/Scheduling/YamlPlayoutContextTests.cs new file mode 100644 index 000000000..107fdd4e4 --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/YamlPlayoutContextTests.cs @@ -0,0 +1,77 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling.YamlScheduling; +using ErsatzTV.Core.Scheduling.YamlScheduling.Models; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Core.Tests.Scheduling; + +public static class YamlPlayoutContextTests +{ + [TestFixture] + public class ScheduleSwitching + { + private static YamlPlayoutContext CreateContext() + { + var definition = new YamlPlayoutDefinition + { + Playout = [new YamlPlayoutInstruction()], + Schedules = + [ + new YamlPlayoutScheduleItem + { + Name = "Christmas", + StartDate = "12-25", + EndDate = "12-25", + Playout = [new YamlPlayoutInstruction()] + } + ] + }; + + return new YamlPlayoutContext(new Playout(), definition, 1); + } + + [Test] + public void Switching_Should_Not_Leak_Graphics_Elements_Across_Lists() + { + YamlPlayoutContext context = CreateContext(); + + // default playout turns a graphics element on + context.SetGraphicsElement(1, null); + context.GetGraphicsElements().ShouldContainKey(1); + + // crossing into the schedule should start with a clean ambient state + context.SwitchToSchedule("Christmas"); + context.GetGraphicsElements().ShouldNotContainKey(1); + + // the schedule can turn on the same element without throwing + Should.NotThrow(() => context.SetGraphicsElement(1, null)); + context.GetGraphicsElements().ShouldContainKey(1); + } + + [Test] + public void Returning_To_Default_Should_Restore_Its_Graphics_Elements() + { + YamlPlayoutContext context = CreateContext(); + + context.SetGraphicsElement(1, "default-vars"); + context.SwitchToSchedule("Christmas"); + context.SetGraphicsElement(1, "christmas-vars"); + + // returning to the default playout restores its ambient state + context.SwitchToSchedule(null); + context.GetGraphicsElements().ShouldContainKey(1); + context.GetGraphicsElements()[1].ShouldBe("default-vars"); + } + + [Test] + public void SetGraphicsElement_Should_Be_Idempotent() + { + YamlPlayoutContext context = CreateContext(); + + context.SetGraphicsElement(1, "a"); + Should.NotThrow(() => context.SetGraphicsElement(1, "b")); + context.GetGraphicsElements()[1].ShouldBe("b"); + } + } +} diff --git a/ErsatzTV.Core.Tests/Scheduling/YamlPlayoutScheduleSelectionTests.cs b/ErsatzTV.Core.Tests/Scheduling/YamlPlayoutScheduleSelectionTests.cs new file mode 100644 index 000000000..450dacd9b --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/YamlPlayoutScheduleSelectionTests.cs @@ -0,0 +1,168 @@ +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Core.Scheduling.YamlScheduling.Models; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Core.Tests.Scheduling; + +public static class YamlPlayoutScheduleSelectionTests +{ + [TestFixture] + public class GetScheduleForDate + { + private static readonly TimeSpan Offset = TimeSpan.FromHours(-5); + + [Test] + public void Should_Return_None_When_No_Schedules() + { + AlternateScheduleSelector + .GetScheduleForDate( + new List(), + new DateTimeOffset(2025, 4, 15, 12, 0, 0, Offset)) + .IsNone.ShouldBeTrue(); + } + + [Test] + public void Should_Match_Annual_Range_Every_Year() + { + var april = new YamlPlayoutScheduleItem + { + Name = "April", + StartDate = "04-01", + EndDate = "04-30" + }; + + AlternateScheduleSelector + .GetScheduleForDate([april], new DateTimeOffset(2025, 4, 15, 12, 0, 0, Offset)) + .IfNone(() => null).ShouldBe(april); + + AlternateScheduleSelector + .GetScheduleForDate([april], new DateTimeOffset(2030, 4, 1, 0, 0, 0, Offset)) + .IfNone(() => null).ShouldBe(april); + } + + [Test] + public void Should_Not_Match_Annual_Range_Outside_Dates() + { + var april = new YamlPlayoutScheduleItem + { + Name = "April", + StartDate = "04-01", + EndDate = "04-30" + }; + + AlternateScheduleSelector + .GetScheduleForDate([april], new DateTimeOffset(2025, 5, 1, 0, 0, 0, Offset)) + .IsNone.ShouldBeTrue(); + } + + [Test] + public void Should_Match_Specific_Year_Only() + { + var christmas = new YamlPlayoutScheduleItem + { + Name = "Christmas 2025", + StartDate = "2025-12-25", + EndDate = "2025-12-25" + }; + + AlternateScheduleSelector + .GetScheduleForDate([christmas], new DateTimeOffset(2025, 12, 25, 8, 0, 0, Offset)) + .IfNone(() => null).ShouldBe(christmas); + + AlternateScheduleSelector + .GetScheduleForDate([christmas], new DateTimeOffset(2026, 12, 25, 8, 0, 0, Offset)) + .IsNone.ShouldBeTrue(); + } + + [Test] + public void Should_Match_Annual_Range_That_Wraps_Year_Boundary() + { + var winter = new YamlPlayoutScheduleItem + { + Name = "Winter", + StartDate = "12-01", + EndDate = "01-15" + }; + + AlternateScheduleSelector + .GetScheduleForDate([winter], new DateTimeOffset(2025, 12, 20, 0, 0, 0, Offset)) + .IfNone(() => null).ShouldBe(winter); + + AlternateScheduleSelector + .GetScheduleForDate([winter], new DateTimeOffset(2026, 1, 10, 0, 0, 0, Offset)) + .IfNone(() => null).ShouldBe(winter); + + AlternateScheduleSelector + .GetScheduleForDate([winter], new DateTimeOffset(2026, 6, 1, 0, 0, 0, Offset)) + .IsNone.ShouldBeTrue(); + } + + [Test] + public void Should_Prefer_Higher_Priority_On_Overlap() + { + var december = new YamlPlayoutScheduleItem + { + Name = "December", + StartDate = "12-01", + EndDate = "12-31", + Priority = 0 + }; + + var christmasDay = new YamlPlayoutScheduleItem + { + Name = "Christmas Day", + StartDate = "12-25", + EndDate = "12-25", + Priority = 10 + }; + + AlternateScheduleSelector + .GetScheduleForDate([december, christmasDay], new DateTimeOffset(2025, 12, 25, 12, 0, 0, Offset)) + .IfNone(() => null).ShouldBe(christmasDay); + + AlternateScheduleSelector + .GetScheduleForDate([december, christmasDay], new DateTimeOffset(2025, 12, 20, 12, 0, 0, Offset)) + .IfNone(() => null).ShouldBe(december); + } + + [Test] + public void Should_Break_Priority_Ties_By_Definition_Order() + { + var first = new YamlPlayoutScheduleItem + { + Name = "First", + StartDate = "04-01", + EndDate = "04-30", + Priority = 5 + }; + + var second = new YamlPlayoutScheduleItem + { + Name = "Second", + StartDate = "04-01", + EndDate = "04-30", + Priority = 5 + }; + + AlternateScheduleSelector + .GetScheduleForDate([first, second], new DateTimeOffset(2025, 4, 15, 12, 0, 0, Offset)) + .IfNone(() => null).ShouldBe(first); + } + + [Test] + public void Should_Not_Match_Invalid_Or_Missing_Dates() + { + var invalid = new YamlPlayoutScheduleItem + { + Name = "Invalid", + StartDate = "not-a-date", + EndDate = "04-30" + }; + + AlternateScheduleSelector + .GetScheduleForDate([invalid], new DateTimeOffset(2025, 4, 15, 12, 0, 0, Offset)) + .IsNone.ShouldBeTrue(); + } + } +} diff --git a/ErsatzTV.Core/Scheduling/AlternateScheduleSelector.cs b/ErsatzTV.Core/Scheduling/AlternateScheduleSelector.cs index c9ca6b162..4818cd9ba 100644 --- a/ErsatzTV.Core/Scheduling/AlternateScheduleSelector.cs +++ b/ErsatzTV.Core/Scheduling/AlternateScheduleSelector.cs @@ -26,6 +26,12 @@ public static class AlternateScheduleSelector { if (item.LimitToDateRange) { + if (item.StartMonth is < 1 or > 12 || item.EndMonth is < 1 or > 12 || item.StartDay < 1 || + item.EndDay < 1) + { + continue; + } + bool reverse = item.StartMonth * 100 + item.StartDay > item.EndMonth * 100 + item.EndDay; diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutShuffleSequenceHandler.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutShuffleSequenceHandler.cs index 707ea8139..b0fab7f7a 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutShuffleSequenceHandler.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutShuffleSequenceHandler.cs @@ -28,7 +28,9 @@ public class YamlPlayoutShuffleSequenceHandler : IYamlPlayoutHandler string sequenceKey = shuffleSequenceInstruction.ShuffleSequence; - var groupedSequenceItems = context.Definition.Playout + List playout = context.CurrentInstructions; + + var groupedSequenceItems = playout .Where(i => i.SequenceKey == sequenceKey) .GroupBy(i => i.SequenceGuid) .ToList(); @@ -43,9 +45,9 @@ public class YamlPlayoutShuffleSequenceHandler : IYamlPlayoutHandler shuffledGroup = grouping.OrderBy(_ => Guid.NewGuid()).ToList(); } - int firstIndex = context.Definition.Playout.FindIndex(i => i.SequenceGuid == grouping.Key); - context.Definition.Playout.RemoveRange(firstIndex, shuffledGroup.Count); - context.Definition.Playout.InsertRange(firstIndex, shuffledGroup); + int firstIndex = playout.FindIndex(i => i.SequenceGuid == grouping.Key); + playout.RemoveRange(firstIndex, shuffledGroup.Count); + playout.InsertRange(firstIndex, shuffledGroup); } return Task.FromResult(true); 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..ea422ba33 --- /dev/null +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutScheduleItem.cs @@ -0,0 +1,118 @@ +using System.Globalization; +using ErsatzTV.Core.Domain.Scheduling; +using YamlDotNet.Serialization; + +namespace ErsatzTV.Core.Scheduling.YamlScheduling.Models; + +public class YamlPlayoutScheduleItem : IAlternateScheduleItem +{ + 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; } = []; + + [YamlIgnore] + public int Index => -Priority; + + // schedules are purely date-range based, so every day/month is eligible + [YamlIgnore] + public ICollection DaysOfWeek => AlternateScheduleSelector.AllDaysOfWeek(); + + [YamlIgnore] + public ICollection DaysOfMonth => AlternateScheduleSelector.AllDaysOfMonth(); + + [YamlIgnore] + public ICollection MonthsOfYear => AlternateScheduleSelector.AllMonthsOfYear(); + + [YamlIgnore] + public bool LimitToDateRange => true; + + [YamlIgnore] + public int StartMonth => Range.StartMonth; + + [YamlIgnore] + public int StartDay => Range.StartDay; + + [YamlIgnore] + public int? StartYear => Range.StartYear; + + [YamlIgnore] + public int EndMonth => Range.EndMonth; + + [YamlIgnore] + public int EndDay => Range.EndDay; + + [YamlIgnore] + public int? EndYear => Range.EndYear; + + private NormalizedRange Range => NormalizedRange.From(StartDate, EndDate); + + private readonly record struct NormalizedRange( + int StartMonth, + int StartDay, + int? StartYear, + int EndMonth, + int EndDay, + int? EndYear) + { + // an impossible specific-year range (year 1) so an invalid schedule never matches a real date + private static readonly NormalizedRange Invalid = new(1, 1, 1, 1, 1, 1); + + public static NormalizedRange From(string startValue, string endValue) + { + ParsedDate start = ParsedDate.Parse(startValue); + ParsedDate end = ParsedDate.Parse(endValue); + + if (!start.Valid || !end.Valid) + { + return Invalid; + } + + // a specific-year range requires a year on both endpoints, otherwise it repeats annually + if (start.Year.HasValue && end.Year.HasValue) + { + return new NormalizedRange(start.Month, start.Day, start.Year, end.Month, end.Day, end.Year); + } + + return new NormalizedRange(start.Month, start.Day, null, end.Month, end.Day, null); + } + } + + private readonly record struct ParsedDate(bool Valid, int? Year, int Month, int Day) + { + public static ParsedDate Parse(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return default; + } + + if (DateOnly.TryParseExact( + value.Trim(), + "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out DateOnly specific)) + { + return new ParsedDate(true, specific.Year, specific.Month, specific.Day); + } + + string[] parts = value.Trim().Split('-'); + if (parts.Length == 2 + && int.TryParse(parts[0], out int month) + && int.TryParse(parts[1], out int day)) + { + return new ParsedDate(true, null, month, day); + } + + return default; + } + } +} diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs index 232fb6c58..97e1f8bac 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 + AlternateScheduleSelector.GetScheduleForDate(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/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": [