Browse Source

add yaml import section (#2248)

pull/2249/head
Jason Dove 6 days ago committed by GitHub
parent
commit
16dd2c2d81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 2
      ErsatzTV.Core/Interfaces/Scheduling/IYamlScheduleValidator.cs
  3. 2
      ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutDefinition.cs
  4. 55
      ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutBuilder.cs
  5. 5
      ErsatzTV.Infrastructure/Scheduling/YamlScheduleValidator.cs
  6. 1
      ErsatzTV/ErsatzTV.csproj
  7. 356
      ErsatzTV/Resources/yaml-playout-import.schema.json
  8. 12
      ErsatzTV/Resources/yaml-playout.schema.json
  9. 1
      ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

1
CHANGELOG.md

@ -25,6 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

2
ErsatzTV.Core/Interfaces/Scheduling/IYamlScheduleValidator.cs

@ -2,5 +2,5 @@ namespace ErsatzTV.Core.Interfaces.Scheduling; @@ -2,5 +2,5 @@ namespace ErsatzTV.Core.Interfaces.Scheduling;
public interface IYamlScheduleValidator
{
Task<bool> ValidateSchedule(string yaml);
Task<bool> ValidateSchedule(string yaml, bool isImport);
}

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

@ -2,6 +2,8 @@ namespace ErsatzTV.Core.Scheduling.YamlScheduling.Models; @@ -2,6 +2,8 @@ namespace ErsatzTV.Core.Scheduling.YamlScheduling.Models;
public class YamlPlayoutDefinition
{
public List<string> Import { get; set; }
public List<YamlPlayoutContentItem> Content { get; set; } = [];
public List<YamlPlayoutSequenceItem> Sequence { get; set; } = [];

55
ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutBuilder.cs

@ -31,7 +31,8 @@ public class YamlPlayoutBuilder( @@ -31,7 +31,8 @@ public class YamlPlayoutBuilder(
return playout;
}
Option<YamlPlayoutDefinition> maybePlayoutDefinition = await LoadYamlDefinition(playout, cancellationToken);
Option<YamlPlayoutDefinition> 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( @@ -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( @@ -369,12 +413,15 @@ public class YamlPlayoutBuilder(
return Optional(handler);
}
private async Task<Option<YamlPlayoutDefinition>> LoadYamlDefinition(Playout playout, CancellationToken cancellationToken)
private async Task<Option<YamlPlayoutDefinition>> 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<YamlPlayoutDefinition>.None;
}

5
ErsatzTV.Infrastructure/Scheduling/YamlScheduleValidator.cs

@ -13,11 +13,12 @@ namespace ErsatzTV.Infrastructure.Scheduling; @@ -13,11 +13,12 @@ namespace ErsatzTV.Infrastructure.Scheduling;
public class YamlScheduleValidator(ILogger<YamlScheduleValidator> logger) : IYamlScheduleValidator
{
public async Task<bool> ValidateSchedule(string yaml)
public async Task<bool> 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);

1
ErsatzTV/ErsatzTV.csproj

@ -81,6 +81,7 @@ @@ -81,6 +81,7 @@
<EmbeddedResource Include="Resources\Templates\_musicVideo.sbntxt" />
<EmbeddedResource Include="Resources\Templates\_otherVideo.sbntxt" />
<EmbeddedResource Include="Resources\Templates\_song.sbntxt" />
<EmbeddedResource Include="Resources\yaml-playout-import.schema.json" />
<EmbeddedResource Include="Resources\yaml-playout.schema.json" />
</ItemGroup>

356
ErsatzTV/Resources/yaml-playout-import.schema.json

@ -0,0 +1,356 @@ @@ -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
}
}
}
}

12
ErsatzTV/Resources/yaml-playout.schema.json

@ -5,6 +5,13 @@ @@ -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 @@ @@ -95,7 +102,10 @@
"minItems": 1
}
},
"required": [ "content", "playout" ],
"oneOf": [
{ "required": [ "content", "playout" ] },
{ "required": [ "import", "playout" ] }
],
"additionalProperties": false,
"$defs": {
"enums": {

1
ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

@ -25,6 +25,7 @@ public class ResourceExtractorService : BackgroundService @@ -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);

Loading…
Cancel
Save