From 16dd2c2d8115ca4874addcfe4897bde5e708b98b Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Mon, 4 Aug 2025 02:21:56 +0000 Subject: [PATCH] add yaml import section (#2248) --- CHANGELOG.md | 1 + .../Scheduling/IYamlScheduleValidator.cs | 2 +- .../Models/YamlPlayoutDefinition.cs | 2 + .../YamlScheduling/YamlPlayoutBuilder.cs | 55 ++- .../Scheduling/YamlScheduleValidator.cs | 5 +- ErsatzTV/ErsatzTV.csproj | 1 + .../Resources/yaml-playout-import.schema.json | 356 ++++++++++++++++++ ErsatzTV/Resources/yaml-playout.schema.json | 12 +- .../RunOnce/ResourceExtractorService.cs | 1 + 9 files changed, 427 insertions(+), 8 deletions(-) create mode 100644 ErsatzTV/Resources/yaml-playout-import.schema.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 530ce5fb8..c0fdc6c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - YAML playout: add `rewind` instruction to set start of playout relative to the current time - Value should be formatted as `HH:MM:SS` e.g. `00:05:30` for 5 minutes 30 seconds (before now) - This is instruction is mostly useful for debugging transitions, and can only be used as a reset instruction +- YAML playout: add `import` section to allow importing partial YAML definitions that include `content` and `sequence` entries - Add YAML playout validation (using JSON Schema) - Invalid YAML playout definitions will fail to build and will log validation failures as warnings - `content` is fully validated diff --git a/ErsatzTV.Core/Interfaces/Scheduling/IYamlScheduleValidator.cs b/ErsatzTV.Core/Interfaces/Scheduling/IYamlScheduleValidator.cs index e1ee0b446..1fb4ab9d3 100644 --- a/ErsatzTV.Core/Interfaces/Scheduling/IYamlScheduleValidator.cs +++ b/ErsatzTV.Core/Interfaces/Scheduling/IYamlScheduleValidator.cs @@ -2,5 +2,5 @@ namespace ErsatzTV.Core.Interfaces.Scheduling; public interface IYamlScheduleValidator { - Task ValidateSchedule(string yaml); + Task ValidateSchedule(string yaml, bool isImport); } diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutDefinition.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutDefinition.cs index c704cfbda..6657497ac 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutDefinition.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutDefinition.cs @@ -2,6 +2,8 @@ namespace ErsatzTV.Core.Scheduling.YamlScheduling.Models; public class YamlPlayoutDefinition { + public List Import { get; set; } + public List Content { get; set; } = []; public List Sequence { get; set; } = []; diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutBuilder.cs index 2fb59e2c5..b43796ab2 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutBuilder.cs @@ -31,7 +31,8 @@ public class YamlPlayoutBuilder( return playout; } - Option maybePlayoutDefinition = await LoadYamlDefinition(playout, cancellationToken); + Option maybePlayoutDefinition = + await LoadYamlDefinition(playout.TemplateFile, isImport: false, cancellationToken); if (maybePlayoutDefinition.IsNone) { logger.LogWarning("YAML playout file {File} is invalid; aborting.", playout.TemplateFile); @@ -41,6 +42,49 @@ public class YamlPlayoutBuilder( // using ValueUnsafe to avoid nesting YamlPlayoutDefinition playoutDefinition = maybePlayoutDefinition.ValueUnsafe(); + foreach (var import in playoutDefinition.Import) + { + try + { + var path = import; + if (!File.Exists(import)) + { + path = Path.Combine( + Path.GetDirectoryName(playout.TemplateFile) ?? string.Empty, + import ?? string.Empty); + if (!File.Exists(path)) + { + logger.LogError("YAML playout import {File} does not exist.", path); + return playout; + } + } + + var maybeImportedDefinition = await LoadYamlDefinition(path, isImport: true, cancellationToken); + foreach (var importedDefinition in maybeImportedDefinition) + { + var contentToAdd = importedDefinition.Content + .Where(c => playoutDefinition.Content.All(c2 => !string.Equals(c2.Key, c.Key, StringComparison.OrdinalIgnoreCase))); + + playoutDefinition.Content.AddRange(contentToAdd); + + var sequencesToAdd = importedDefinition.Sequence + .Where(s => playoutDefinition.Sequence.All(s2 => !string.Equals(s2.Key, s.Key, StringComparison.OrdinalIgnoreCase))); + + playoutDefinition.Sequence.AddRange(sequencesToAdd); + } + + if (maybeImportedDefinition.IsNone) + { + logger.LogWarning("YAML playout import {File} is invalid; aborting.", import); + return playout; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected exception loading YAML playout import"); + } + } + DateTimeOffset start = DateTimeOffset.Now; int daysToBuild = await GetDaysToBuild(); @@ -369,12 +413,15 @@ public class YamlPlayoutBuilder( return Optional(handler); } - private async Task> LoadYamlDefinition(Playout playout, CancellationToken cancellationToken) + private async Task> LoadYamlDefinition( + string fileName, + bool isImport, + CancellationToken cancellationToken) { try { - string yaml = await File.ReadAllTextAsync(playout.TemplateFile, cancellationToken); - if (await yamlScheduleValidator.ValidateSchedule(yaml) == false) + string yaml = await File.ReadAllTextAsync(fileName, cancellationToken); + if (await yamlScheduleValidator.ValidateSchedule(yaml, isImport) == false) { return Option.None; } diff --git a/ErsatzTV.Infrastructure/Scheduling/YamlScheduleValidator.cs b/ErsatzTV.Infrastructure/Scheduling/YamlScheduleValidator.cs index f8001e141..32a6f97cd 100644 --- a/ErsatzTV.Infrastructure/Scheduling/YamlScheduleValidator.cs +++ b/ErsatzTV.Infrastructure/Scheduling/YamlScheduleValidator.cs @@ -13,11 +13,12 @@ namespace ErsatzTV.Infrastructure.Scheduling; public class YamlScheduleValidator(ILogger logger) : IYamlScheduleValidator { - public async Task ValidateSchedule(string yaml) + public async Task ValidateSchedule(string yaml, bool isImport) { try { - string schemaFileName = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "yaml-playout.schema.json"); + string schemaFileName = Path.Combine(FileSystemLayout.ResourcesCacheFolder, + isImport ? "yaml-playout-import.schema.json" : "yaml-playout.schema.json"); using StreamReader sr = File.OpenText(schemaFileName); await using var reader = new JsonTextReader(sr); var schema = JSchema.Load(reader); diff --git a/ErsatzTV/ErsatzTV.csproj b/ErsatzTV/ErsatzTV.csproj index 03ddfe7ce..842a0af0b 100644 --- a/ErsatzTV/ErsatzTV.csproj +++ b/ErsatzTV/ErsatzTV.csproj @@ -81,6 +81,7 @@ + diff --git a/ErsatzTV/Resources/yaml-playout-import.schema.json b/ErsatzTV/Resources/yaml-playout-import.schema.json new file mode 100644 index 000000000..6efbdb562 --- /dev/null +++ b/ErsatzTV/Resources/yaml-playout-import.schema.json @@ -0,0 +1,356 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ersatztv.org/yaml-playout.schema.json", + "title": "YAML Playout Import", + "description": "An ErsatzTV YAML playout import definition", + "type": "object", + "properties": { + "content": { + "description": "Content definitions", + "type": "array", + "items": { + "oneOf": [ + { "$ref": "#/$defs/content/showContent" }, + { "$ref": "#/$defs/content/searchContent" }, + { "$ref": "#/$defs/content/collectionContent" }, + { "$ref": "#/$defs/content/multiCollectionContent" }, + { "$ref": "#/$defs/content/smartCollectionContent" }, + { "$ref": "#/$defs/content/playlistContent" }, + { "$ref": "#/$defs/content/marathonContent" } + ] + }, + "minItems": 1 + }, + "sequence": { + "description": "Sequence definitions", + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { "type": "string" }, + "items": { + "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/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": [ "key" ], + "additionalProperties": false + } + } + }, + "oneOf": [ + { "required": [ "content" ] }, + { "required": [ "sequence" ] } + ], + "additionalProperties": false, + "$defs": { + "enums": { + "filler_kind": { "enum": [ "none", "preroll", "postroll", "midroll" ] } + }, + "content": { + "showContent": { + "type": "object", + "properties": { + "show": { "type": "null" }, + "key": { "type": "string" }, + "guids": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "source": { "type": "string" }, + "value": { "type": "string" } + }, + "required": [ "source", "value" ], + "additionalProperties": false + } + }, + "order": { "enum": [ "chronological", "shuffle" ] } + }, + "required": [ "show", "key", "guids", "order" ], + "additionalProperties": false + }, + "searchContent": { + "type": "object", + "properties": { + "search": { "type": "null" }, + "key": { "type": "string" }, + "query": { "type": "string" }, + "order": { "enum": [ "chronological", "shuffle" ] } + }, + "required": [ "search", "key", "query", "order" ], + "additionalProperties": false + }, + "collectionContent": { + "type": "object", + "properties": { + "collection": { "type": "string" }, + "key": { "type": "string" }, + "order": { "enum": [ "chronological", "shuffle" ] } + }, + "required": [ "collection", "key", "order" ], + "additionalProperties": false + }, + "multiCollectionContent": { + "type": "object", + "properties": { + "multi_collection": { "type": "string" }, + "key": { "type": "string" }, + "order": { "enum": [ "chronological", "shuffle" ] } + }, + "required": [ "multi_collection", "key", "order" ], + "additionalProperties": false + }, + "smartCollectionContent": { + "type": "object", + "properties": { + "smart_collection": { "type": "string" }, + "key": { "type": "string" }, + "order": { "enum": [ "chronological", "shuffle" ] } + }, + "required": [ "smart_collection", "key", "order" ], + "additionalProperties": false + }, + "playlistContent": { + "type": "object", + "properties": { + "playlist": { "type": "string" }, + "playlist_group": { "type": "string" }, + "key": { "type": "string" } + }, + "required": [ "playlist", "playlist_group", "key" ], + "additionalProperties": false + }, + "marathonContent": { + "type": "object", + "properties": { + "marathon": { "type": "null" }, + "key": { "type": "string" }, + "guids": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "source": { "type": "string" }, + "value": { "type": "string" } + }, + "required": [ "source", "value" ], + "additionalProperties": false + } + }, + "searches": { + "type": "array", + "items": { "type": "string" } + }, + "group_by": { "enum": [ "show", "season", "artist", "album" ] }, + "item_order": { "enum": [ "chronological", "shuffle" ] }, + "play_all_items": { "type": "boolean" }, + "shuffle_groups": { "type": "boolean" } + }, + "required": [ "marathon", "key" ], + "additionalProperties": false + } + }, + "scheduling": { + "allInstruction": { + "type": "object", + "properties": { + "all": { "type": "null" }, + "content": { "type": "string" }, + "custom_title": { "type": "string" }, + "filler_kind": { "$ref": "#/$defs/enums/filler_kind" } + }, + "required": [ "all", "content" ], + "additionalProperties": false + }, + "countInstruction": { + "type": "object", + "properties": { + "count": { "type": "integer" }, + "content": { "type": "string" }, + "custom_title": { "type": "string" }, + "filler_kind": { "$ref": "#/$defs/enums/filler_kind" } + }, + "required": [ "count", "content" ], + "additionalProperties": false + }, + "durationInstruction": { + "type": "object", + "properties": { + "duration": { "type": "string" }, + "content": { "type": "string" }, + "trim": { "type": "boolean" }, + "fallback": { "type": "string" }, + "discard_attempts": { "type": "integer" }, + "offline_tail": { "type": "boolean" }, + "stop_before_end": { "type": "boolean" }, + "filler_kind": { "$ref": "#/$defs/enums/filler_kind" } + }, + "required": [ "duration", "content" ], + "additionalProperties": false + }, + "padToNextInstruction": { + "type": "object", + "properties": { + "pad_to_next": { "type": "integer" }, + "content": { "type": "string" }, + "trim": { "type": "boolean" }, + "fallback": { "type": "string" }, + "discard_attempts": { "type": "integer" }, + "filler_kind": { "$ref": "#/$defs/enums/filler_kind" } + }, + "required": [ "pad_to_next", "content" ], + "additionalProperties": false + }, + "padUntilInstruction": { + "type": "object", + "properties": { + "pad_until": { "type": "string" }, + "content": { "type": "string" }, + "tomorrow": { "type": "string" }, + "offline_tail": { "type": "boolean" }, + "trim": { "type": "boolean" }, + "fallback": { "type": "string" }, + "discard_attempts": { "type": "integer" }, + "stop_before_end": { "type": "boolean" }, + "filler_kind": { "$ref": "#/$defs/enums/filler_kind" } + }, + "required": [ "pad_until", "content" ], + "additionalProperties": false + }, + "sequenceInstruction": { + "type": "object", + "properties": { + "sequence": { "type": "string" }, + "repeat": { "type": "integer" } + }, + "required": [ "sequence" ], + "additionalProperties": false + } + }, + "control": { + "rewindInstruction": { + "type": "object", + "properties": { + "rewind": { "type": "string" } + }, + "required": [ "rewind" ], + "additionalProperties": false + }, + "epgGroupInstruction": { + "type": "object", + "properties": { + "epg_group": { "type": "boolean" }, + "advance": { "type": "boolean" } + }, + "required": [ "epg_group" ], + "additionalProperties": false + }, + "preRollInstruction": { + "type": "object", + "properties": { + "pre_roll": { "type": "boolean" }, + "sequence": { "type": "string" } + }, + "required": [ "pre_roll" ], + "additionalProperties": false + }, + "postRollInstruction": { + "type": "object", + "properties": { + "post_roll": { "type": "boolean" }, + "sequence": { "type": "string" } + }, + "required": [ "post_roll" ], + "additionalProperties": false + }, + "midRollInstruction": { + "type": "object", + "properties": { + "mid_roll": { "type": "boolean" }, + "sequence": { "type": "string" }, + "expression": { "type": "string" } + }, + "required": [ "mid_roll" ], + "additionalProperties": false + }, + "repeatInstruction": { + "type": "object", + "properties": { + "repeat": { "type": "boolean" } + }, + "required": [ "repeat" ], + "additionalProperties": false + }, + "shuffleSequenceInstruction": { + "type": "object", + "properties": { + "shuffle_sequence": { "type": "string" } + }, + "required": [ "shuffle_sequence" ], + "additionalProperties": false + }, + "skipItemsInstruction": { + "type": "object", + "properties": { + "skip_items": { "type": "integer" }, + "content": { "type": "string" } + }, + "required": [ "skip_items", "content" ], + "additionalProperties": false + }, + "skipToItemInstruction": { + "type": "object", + "properties": { + "skip_to_item": { "type": "null" }, + "content": { "type": "string" }, + "season": { "type": "integer" }, + "episode": { "type": "integer" } + }, + "required": [ "skip_to_item", "content", "season", "episode" ], + "additionalProperties": false + }, + "waitUntilInstruction": { + "type": "object", + "properties": { + "wait_until": { "type": "string" }, + "tomorrow": { "type": "boolean" }, + "rewind_on_reset": { "type": "boolean" } + }, + "required": [ "wait_until" ], + "additionalProperties": false + }, + "watermarkInstruction": { + "type": "object", + "properties": { + "watermark": { "type": "boolean" }, + "name": { "type": "string" } + }, + "required": [ "watermark" ], + "additionalProperties": false + } + } + } +} diff --git a/ErsatzTV/Resources/yaml-playout.schema.json b/ErsatzTV/Resources/yaml-playout.schema.json index 74e1dda76..b119dcba2 100644 --- a/ErsatzTV/Resources/yaml-playout.schema.json +++ b/ErsatzTV/Resources/yaml-playout.schema.json @@ -5,6 +5,13 @@ "description": "An ErsatzTV YAML playout definition", "type": "object", "properties": { + "import": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, "content": { "description": "Content definitions", "type": "array", @@ -95,7 +102,10 @@ "minItems": 1 } }, - "required": [ "content", "playout" ], + "oneOf": [ + { "required": [ "content", "playout" ] }, + { "required": [ "import", "playout" ] } + ], "additionalProperties": false, "$defs": { "enums": { diff --git a/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs b/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs index 5ef3bed3f..4d07e0f85 100644 --- a/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs +++ b/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs @@ -25,6 +25,7 @@ public class ResourceExtractorService : BackgroundService await ExtractResource(assembly, "song_progress_overlay_43.png", stoppingToken); await ExtractResource(assembly, "ErsatzTV.png", stoppingToken); await ExtractResource(assembly, "yaml-playout.schema.json", stoppingToken); + await ExtractResource(assembly, "yaml-playout-import.schema.json", stoppingToken); await ExtractFontResource(assembly, "Sen.ttf", stoppingToken); await ExtractFontResource(assembly, "Roboto-Regular.ttf", stoppingToken);