Browse Source

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>
pull/2937/head
Carson Kompon 1 month ago committed by GitHub
parent
commit
42937ac372
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 48
      ErsatzTV.Core.Tests/Scheduling/AlternateScheduleSelectorTests.cs
  2. 77
      ErsatzTV.Core.Tests/Scheduling/YamlPlayoutContextTests.cs
  3. 168
      ErsatzTV.Core.Tests/Scheduling/YamlPlayoutScheduleSelectionTests.cs
  4. 6
      ErsatzTV.Core/Scheduling/AlternateScheduleSelector.cs
  5. 10
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutShuffleSequenceHandler.cs
  6. 2
      ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutDefinition.cs
  7. 118
      ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutScheduleItem.cs
  8. 58
      ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs
  9. 156
      ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs
  10. 48
      ErsatzTV/Resources/sequential-schedule.schema.json

48
ErsatzTV.Core.Tests/Scheduling/AlternateScheduleSelectorTests.cs

@ -863,5 +863,53 @@ public static class AlternateScheduleSelectorTests
result.IsNone.ShouldBeFalse(); 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<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { 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<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2023, 3, 1, 0, 0, 0, Offset));
result.IsNone.ShouldBeTrue();
}
} }
} }

77
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");
}
}
}

168
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<YamlPlayoutScheduleItem>(),
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();
}
}
}

6
ErsatzTV.Core/Scheduling/AlternateScheduleSelector.cs

@ -26,6 +26,12 @@ public static class AlternateScheduleSelector
{ {
if (item.LimitToDateRange) 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 > bool reverse = item.StartMonth * 100 + item.StartDay >
item.EndMonth * 100 + item.EndDay; item.EndMonth * 100 + item.EndDay;

10
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutShuffleSequenceHandler.cs

@ -28,7 +28,9 @@ public class YamlPlayoutShuffleSequenceHandler : IYamlPlayoutHandler
string sequenceKey = shuffleSequenceInstruction.ShuffleSequence; string sequenceKey = shuffleSequenceInstruction.ShuffleSequence;
var groupedSequenceItems = context.Definition.Playout List<YamlPlayoutInstruction> playout = context.CurrentInstructions;
var groupedSequenceItems = playout
.Where(i => i.SequenceKey == sequenceKey) .Where(i => i.SequenceKey == sequenceKey)
.GroupBy(i => i.SequenceGuid) .GroupBy(i => i.SequenceGuid)
.ToList(); .ToList();
@ -43,9 +45,9 @@ public class YamlPlayoutShuffleSequenceHandler : IYamlPlayoutHandler
shuffledGroup = grouping.OrderBy(_ => Guid.NewGuid()).ToList(); shuffledGroup = grouping.OrderBy(_ => Guid.NewGuid()).ToList();
} }
int firstIndex = context.Definition.Playout.FindIndex(i => i.SequenceGuid == grouping.Key); int firstIndex = playout.FindIndex(i => i.SequenceGuid == grouping.Key);
context.Definition.Playout.RemoveRange(firstIndex, shuffledGroup.Count); playout.RemoveRange(firstIndex, shuffledGroup.Count);
context.Definition.Playout.InsertRange(firstIndex, shuffledGroup); playout.InsertRange(firstIndex, shuffledGroup);
} }
return Task.FromResult(true); return Task.FromResult(true);

2
ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutDefinition.cs

@ -11,4 +11,6 @@ public class YamlPlayoutDefinition
public List<YamlPlayoutInstruction> Reset { get; set; } = []; public List<YamlPlayoutInstruction> Reset { get; set; } = [];
public List<YamlPlayoutInstruction> Playout { get; set; } = []; public List<YamlPlayoutInstruction> Playout { get; set; } = [];
public List<YamlPlayoutScheduleItem> Schedules { get; set; } = [];
} }

118
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<YamlPlayoutInstruction> Playout { get; set; } = [];
[YamlIgnore]
public int Index => -Priority;
// schedules are purely date-range based, so every day/month is eligible
[YamlIgnore]
public ICollection<DayOfWeek> DaysOfWeek => AlternateScheduleSelector.AllDaysOfWeek();
[YamlIgnore]
public ICollection<int> DaysOfMonth => AlternateScheduleSelector.AllDaysOfMonth();
[YamlIgnore]
public ICollection<int> 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;
}
}
}

58
ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs

@ -249,20 +249,62 @@ public class SequentialPlayoutBuilder(
break; break;
} }
FlattenSequences(context); FlattenSequences(context, context.Definition.Playout);
flattenCount++; 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 // handle all playout instructions
while (context.CurrentTime < finish) 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<YamlPlayoutInstruction> instructions = context.CurrentInstructions;
if (context.InstructionIndex >= instructions.Count)
{ {
logger.LogInformation("Reached the end of the YAML playout definition; stopping"); logger.LogInformation("Reached the end of the YAML playout definition; stopping");
break; break;
} }
YamlPlayoutInstruction instruction = playoutDefinition.Playout[context.InstructionIndex]; YamlPlayoutInstruction instruction = instructions[context.InstructionIndex];
//logger.LogDebug("Current playout instruction: {Instruction}", instruction.GetType().Name); //logger.LogDebug("Current playout instruction: {Instruction}", instruction.GetType().Name);
Option<IYamlPlayoutHandler> maybeHandler = GetHandlerForInstruction(handlers, enumeratorCache, instruction); Option<IYamlPlayoutHandler> maybeHandler = GetHandlerForInstruction(handlers, enumeratorCache, instruction);
@ -382,10 +424,10 @@ public class SequentialPlayoutBuilder(
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild, cancellationToken) .GetValue<int>(ConfigElementKey.PlayoutDaysToBuild, cancellationToken)
.IfNoneAsync(2); .IfNoneAsync(2);
private static void FlattenSequences(YamlPlayoutContext context) private static void FlattenSequences(YamlPlayoutContext context, List<YamlPlayoutInstruction> playout)
{ {
var rawInstructions = context.Definition.Playout.ToImmutableList(); var rawInstructions = playout.ToImmutableList();
context.Definition.Playout.Clear(); playout.Clear();
foreach (YamlPlayoutInstruction instruction in rawInstructions) foreach (YamlPlayoutInstruction instruction in rawInstructions)
{ {
@ -417,13 +459,13 @@ public class SequentialPlayoutBuilder(
i.CustomTitle = sequenceInstruction.CustomTitle; i.CustomTitle = sequenceInstruction.CustomTitle;
} }
context.Definition.Playout.Add(i); playout.Add(i);
} }
} }
break; break;
default: default:
context.Definition.Playout.Add(instruction); playout.Add(instruction);
break; break;
} }
} }

156
ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs

@ -17,7 +17,7 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
private readonly Stack<FillerKind> _fillerKind = new(); private readonly Stack<FillerKind> _fillerKind = new();
private readonly Dictionary<int, string> _graphicsElements = []; private readonly Dictionary<int, string> _graphicsElements = [];
private readonly System.Collections.Generic.HashSet<int> _visitedInstructions = []; private System.Collections.Generic.HashSet<int> _visitedInstructions = [];
private int _guideGroup = guideGroup; private int _guideGroup = guideGroup;
private bool _guideGroupLocked; private bool _guideGroupLocked;
private int _instructionIndex; private int _instructionIndex;
@ -25,6 +25,14 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
private Option<string> _postRollSequence; private Option<string> _postRollSequence;
private Option<string> _preRollSequence; private Option<string> _preRollSequence;
// null active schedule => default playout
private string _activeSchedule;
private List<YamlPlayoutInstruction> _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<string, ListState> _listStates = [];
public Playout Playout { get; } = playout; public Playout Playout { get; } = playout;
public List<PlayoutItem> AddedItems { get; } = []; public List<PlayoutItem> 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<YamlPlayoutInstruction> 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<string>.None;
_postRollSequence = Option<string>.None;
_midRollSequence = Option<MidRollSequence>.None;
}
}
private ListState CaptureState() =>
new(
_instructionIndex,
[.._visitedInstructions],
[.._channelWatermarkIds],
new Dictionary<int, string>(_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() public int PeekNextGuideGroup()
{ {
@ -94,7 +190,7 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
public void ClearChannelWatermarkIds() => _channelWatermarkIds.Clear(); public void ClearChannelWatermarkIds() => _channelWatermarkIds.Clear();
public List<int> GetChannelWatermarkIds() => _channelWatermarkIds.ToList(); public List<int> 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 RemoveGraphicsElement(int id) => _graphicsElements.Remove(id);
public void ClearGraphicsElements() => _graphicsElements.Clear(); public void ClearGraphicsElements() => _graphicsElements.Clear();
public IReadOnlyDictionary<int, string> GetGraphicsElements() => _graphicsElements; public IReadOnlyDictionary<int, string> GetGraphicsElements() => _graphicsElements;
@ -125,12 +221,18 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
preRollSequence = sequence; 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( var state = new State(
_instructionIndex, _instructionIndex,
_guideGroup, _guideGroup,
_guideGroupLocked, _guideGroupLocked,
_channelWatermarkIds.ToList(), _channelWatermarkIds.ToList(),
preRollSequence); preRollSequence,
_activeSchedule,
scheduleIndices);
return JsonConvert.SerializeObject(state, Formatting.None, JsonSettings); return JsonConvert.SerializeObject(state, Formatting.None, JsonSettings);
} }
@ -174,6 +276,34 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
{ {
_preRollSequence = preRollSequence; _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<int, string>(),
[],
Option<string>.None,
Option<string>.None,
Option<MidRollSequence>.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( public record State(
@ -181,7 +311,21 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
int? GuideGroup, int? GuideGroup,
bool? GuideGroupLocked, bool? GuideGroupLocked,
List<int> ChannelWatermarkIds, List<int> ChannelWatermarkIds,
string PreRollSequence); string PreRollSequence,
string ActiveSchedule = null,
Dictionary<string, int> ScheduleIndices = null);
public record MidRollSequence(string Sequence, string Expression); 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<int> VisitedInstructions,
System.Collections.Generic.HashSet<int> ChannelWatermarkIds,
Dictionary<int, string> GraphicsElements,
List<FillerKind> FillerKind,
Option<string> PreRollSequence,
Option<string> PostRollSequence,
Option<MidRollSequence> MidRollSequence);
}

48
ErsatzTV/Resources/sequential-schedule.schema.json

@ -104,6 +104,54 @@
] ]
}, },
"minItems": 1 "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": [ "allOf": [

Loading…
Cancel
Save