From 590da4ab246645b878d1173c95972b94f4323d93 Mon Sep 17 00:00:00 2001 From: Carson Kompon Date: Sun, 30 Nov 2025 12:08:44 -0500 Subject: [PATCH] You can now have `schedules` in a yaml playback which define playouts during a period of time --- .../Models/YamlPlayoutDefinition.cs | 11 ++ .../Models/YamlPlayoutSchedule.cs | 28 ++++ .../SequentialPlayoutBuilder.cs | 158 +++++++++++++++++- .../Resources/sequential-schedule.schema.json | 87 +++++++++- 4 files changed, 276 insertions(+), 8 deletions(-) create mode 100644 ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutSchedule.cs diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutDefinition.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutDefinition.cs index 4769fe305..1792c39b4 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutDefinition.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutDefinition.cs @@ -8,7 +8,18 @@ public class YamlPlayoutDefinition public List Sequence { get; set; } = []; + /// + /// Scheduled playouts that apply to specific date ranges + /// + public List Schedules { get; set; } = []; + + /// + /// Default reset instructions (used when no schedule matches) + /// public List Reset { get; set; } = []; + /// + /// Default playout instructions (used when no schedule matches) + /// public List Playout { get; set; } = []; } diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutSchedule.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutSchedule.cs new file mode 100644 index 000000000..e97aa2fa0 --- /dev/null +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutSchedule.cs @@ -0,0 +1,28 @@ +using YamlDotNet.Serialization; + +namespace ErsatzTV.Core.Scheduling.YamlScheduling.Models; + +public class YamlPlayoutSchedule +{ + 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; } + + /// + /// Specific year(s) this schedule applies to. If empty, applies to all years. + /// + public List Years { get; set; } = []; + + /// + /// Whether this schedule repeats annually (default: true when no years are specified) + /// + public bool Recurring { get; set; } = true; + + public List Reset { 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..bbe96863f 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Globalization; using System.IO.Abstractions; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Scheduling; @@ -201,10 +202,37 @@ public class SequentialPlayoutBuilder( cancellationToken); } + // Determine which schedule to use based on the start date + YamlPlayoutSchedule activeSchedule = GetActiveSchedule(playoutDefinition, start); + List resetInstructions; + List playoutInstructions; + + if (activeSchedule != null) + { + logger.LogInformation( + "Using scheduled playout '{Name}' for date {Date}", + string.IsNullOrWhiteSpace(activeSchedule.Name) ? "Unnamed" : activeSchedule.Name, + start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + resetInstructions = activeSchedule.Reset; + playoutInstructions = activeSchedule.Playout; + } + else + { + logger.LogDebug("Using default playout for date {Date}", start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + resetInstructions = playoutDefinition.Reset; + playoutInstructions = playoutDefinition.Playout; + } + + if (playoutInstructions.Count == 0) + { + logger.LogWarning("No playout instructions found for date {Date}", start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + return BaseError.New($"No playout instructions found for date {start:yyyy-MM-dd}"); + } + if (mode is PlayoutBuildMode.Reset) { // handle all on-reset instructions - foreach (YamlPlayoutInstruction instruction in playoutDefinition.Reset) + foreach (YamlPlayoutInstruction instruction in resetInstructions) { Option maybeHandler = GetHandlerForInstruction( handlers, @@ -233,6 +261,23 @@ public class SequentialPlayoutBuilder( } } + // Create a temporary modified definition with the selected instructions + var effectiveDefinition = new YamlPlayoutDefinition + { + Import = playoutDefinition.Import, + Content = playoutDefinition.Content, + Sequence = playoutDefinition.Sequence, + Reset = resetInstructions, + Playout = playoutInstructions + }; + + // Update context to use the effective definition + context = new YamlPlayoutContext(playout, effectiveDefinition, 1) + { + CurrentTime = context.CurrentTime, + InstructionIndex = context.InstructionIndex + }; + if (DetectCycle(context.Definition)) { logger.LogError("YAML sequence contains a cycle; unable to build playout"); @@ -256,13 +301,13 @@ public class SequentialPlayoutBuilder( // handle all playout instructions while (context.CurrentTime < finish) { - if (context.InstructionIndex >= playoutDefinition.Playout.Count) + if (context.InstructionIndex >= effectiveDefinition.Playout.Count) { logger.LogInformation("Reached the end of the YAML playout definition; stopping"); break; } - YamlPlayoutInstruction instruction = playoutDefinition.Playout[context.InstructionIndex]; + YamlPlayoutInstruction instruction = effectiveDefinition.Playout[context.InstructionIndex]; //logger.LogDebug("Current playout instruction: {Instruction}", instruction.GetType().Name); Option maybeHandler = GetHandlerForInstruction(handlers, enumeratorCache, instruction); @@ -582,4 +627,111 @@ public class SequentialPlayoutBuilder( return result; } + + /// + /// Finds the active schedule for the given date, or returns null if no schedule matches. + /// + private static YamlPlayoutSchedule GetActiveSchedule(YamlPlayoutDefinition definition, DateTimeOffset date) + { + if (definition.Schedules.Count == 0) + { + return null; + } + + foreach (YamlPlayoutSchedule schedule in definition.Schedules) + { + if (IsDateInSchedule(schedule, date)) + { + return schedule; + } + } + + return null; + } + + /// + /// Determines if a date falls within a scheduled playout's date range. + /// + private static bool IsDateInSchedule(YamlPlayoutSchedule schedule, DateTimeOffset date) + { + if (string.IsNullOrWhiteSpace(schedule.StartDate) || string.IsNullOrWhiteSpace(schedule.EndDate)) + { + return false; + } + + (int startMonth, int startDay, int? startYear) = ParseDate(schedule.StartDate); + (int endMonth, int endDay, int? endYear) = ParseDate(schedule.EndDate); + + int currentYear = date.Year; + int currentMonth = date.Month; + int currentDay = date.Day; + + // Check if years are specified + if (schedule.Years.Count > 0) + { + if (!schedule.Years.Contains(currentYear)) + { + return false; + } + } + else if (!schedule.Recurring) + { + // If not recurring and no years specified, the schedule doesn't apply + return false; + } + + // Handle year-specific dates + if (startYear.HasValue && endYear.HasValue) + { + var startDate = new DateTime(startYear.Value, startMonth, startDay); + var endDate = new DateTime(endYear.Value, endMonth, endDay); + var currentDate = new DateTime(currentYear, currentMonth, currentDay); + + return currentDate >= startDate && currentDate <= endDate; + } + + if (startYear.HasValue || endYear.HasValue) + { + // Mixed format - not supported + return false; + } + + // Both dates are MM-DD format (recurring) + var currentDayOfYear = new DateTime(currentYear, currentMonth, currentDay).DayOfYear; + var startDayOfYear = new DateTime(currentYear, startMonth, startDay).DayOfYear; + var endDayOfYear = new DateTime(currentYear, endMonth, endDay).DayOfYear; + + // Handle date ranges that span across the new year + if (startDayOfYear <= endDayOfYear) + { + // Normal range (e.g., Jan 1 - March 31) + return currentDayOfYear >= startDayOfYear && currentDayOfYear <= endDayOfYear; + } + + // Range wraps around the year (e.g., Dec 1 - Jan 31) + return currentDayOfYear >= startDayOfYear || currentDayOfYear <= endDayOfYear; + } + + /// + /// Parses a date string in MM-DD or YYYY-MM-DD format. + /// Returns (month, day, year) where year is null for MM-DD format. + /// + private static (int month, int day, int? year) ParseDate(string dateStr) + { + string[] parts = dateStr.Split('-'); + + if (parts.Length == 2) + { + // MM-DD format + return (int.Parse(parts[0], CultureInfo.InvariantCulture), int.Parse(parts[1], CultureInfo.InvariantCulture), null); + } + + if (parts.Length == 3) + { + // YYYY-MM-DD format + return (int.Parse(parts[1], CultureInfo.InvariantCulture), int.Parse(parts[2], CultureInfo.InvariantCulture), int.Parse(parts[0], CultureInfo.InvariantCulture)); + } + + throw new FormatException($"Invalid date format: {dateStr}. Expected MM-DD or YYYY-MM-DD."); + } } diff --git a/ErsatzTV/Resources/sequential-schedule.schema.json b/ErsatzTV/Resources/sequential-schedule.schema.json index 004933eca..dfa4d5157 100644 --- a/ErsatzTV/Resources/sequential-schedule.schema.json +++ b/ErsatzTV/Resources/sequential-schedule.schema.json @@ -66,8 +66,81 @@ "additionalProperties": false } }, + "schedules": { + "description": "Scheduled playouts for specific date ranges", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Optional name for this schedule (for debugging/logging)" + }, + "start_date": { + "type": "string", + "description": "Start date in MM-DD (e.g., '01-01') or YYYY-MM-DD format (e.g., '2024-12-25')", + "pattern": "^((\\d{4}-)?(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01]))$" + }, + "end_date": { + "type": "string", + "description": "End date in MM-DD (e.g., '01-31') or YYYY-MM-DD format (e.g., '2024-12-25')", + "pattern": "^((\\d{4}-)?(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01]))$" + }, + "years": { + "type": "array", + "items": { "type": "integer" }, + "description": "Specific years this schedule applies to. If empty, applies to all years (when recurring is true)." + }, + "recurring": { + "type": "boolean", + "description": "Whether this schedule repeats annually (default: true when no years are specified)" + }, + "reset": { + "description": "Reset instructions for this scheduled playout", + "type": "array", + "items": { + "oneOf": [ + { "$ref": "#/$defs/control/rewindInstruction" }, + { "$ref": "#/$defs/control/skipItemsInstruction" }, + { "$ref": "#/$defs/control/skipToItemInstruction" }, + { "$ref": "#/$defs/control/waitUntilInstruction" } + ] + } + }, + "playout": { + "description": "Playout instructions for this scheduled playout", + "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": [ "start_date", "end_date", "playout" ], + "additionalProperties": false + } + }, "reset": { - "description": "Reset instructions", + "description": "Default reset instructions (used when no schedule matches)", "type": "array", "items": { "oneOf": [ @@ -79,7 +152,7 @@ } }, "playout": { - "description": "Playout instructions", + "description": "Default playout instructions (used when no schedule matches)", "type": "array", "items": { "oneOf": [ @@ -102,8 +175,7 @@ { "$ref": "#/$defs/control/waitUntilInstruction" }, { "$ref": "#/$defs/control/watermarkInstruction" } ] - }, - "minItems": 1 + } } }, "allOf": [ @@ -113,7 +185,12 @@ { "required": [ "content" ] } ] }, - { "required": [ "playout" ] } + { + "anyOf": [ + { "required": [ "playout" ] }, + { "required": [ "schedules" ] } + ] + } ], "additionalProperties": false, "$defs": {