Browse Source

Added `schedules` to YAML/Sequenced Playback

pull/2935/head
Carson Kompon 1 month ago
parent
commit
6c8a0af2a1
  1. 2
      ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutDefinition.cs
  2. 18
      ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutScheduleItem.cs
  3. 58
      ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs
  4. 121
      ErsatzTV.Core/Scheduling/YamlScheduling/SequentialScheduleSelector.cs
  5. 156
      ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs
  6. 48
      ErsatzTV/Resources/sequential-schedule.schema.json

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; } = [];
} }

18
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<YamlPlayoutInstruction> Playout { get; set; } = [];
}

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

121
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"];
/// <summary>
/// Returns the highest-priority schedule whose date range contains the given time.
/// Ties on priority are broken by definition order (earlier wins).
/// </summary>
public static Option<YamlPlayoutScheduleItem> GetActiveSchedule(
IReadOnlyList<YamlPlayoutScheduleItem> schedules,
DateTimeOffset time)
{
if (schedules is null || schedules.Count == 0)
{
return Option<YamlPlayoutScheduleItem>.None;
}
var date = DateOnly.FromDateTime(time.LocalDateTime);
Option<YamlPlayoutScheduleItem> best = Option<YamlPlayoutScheduleItem>.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);
}

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