mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* add yaml playout pre_roll instruction * add basic yaml validation * validate all yaml playout content items * fix yaml to json conversion * update changelogpull/2229/head
34 changed files with 12781 additions and 27 deletions
@ -0,0 +1,6 @@ |
|||||||
|
namespace ErsatzTV.Core.Interfaces.Scheduling; |
||||||
|
|
||||||
|
public interface IYamlScheduleValidator |
||||||
|
{ |
||||||
|
Task<bool> ValidateSchedule(string yaml); |
||||||
|
} |
||||||
@ -0,0 +1,34 @@ |
|||||||
|
using ErsatzTV.Core.Scheduling.YamlScheduling.Models; |
||||||
|
using Microsoft.Extensions.Logging; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Scheduling.YamlScheduling.Handlers; |
||||||
|
|
||||||
|
public class YamlPlayoutPreRollHandler : IYamlPlayoutHandler |
||||||
|
{ |
||||||
|
public bool Reset => false; |
||||||
|
|
||||||
|
public Task<bool> Handle( |
||||||
|
YamlPlayoutContext context, |
||||||
|
YamlPlayoutInstruction instruction, |
||||||
|
PlayoutBuildMode mode, |
||||||
|
Func<string, Task> executeSequence, |
||||||
|
ILogger<YamlPlayoutBuilder> logger, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
if (instruction is not YamlPreRollInstruction preRoll) |
||||||
|
{ |
||||||
|
return Task.FromResult(false); |
||||||
|
} |
||||||
|
|
||||||
|
if (preRoll.PreRoll && !string.IsNullOrWhiteSpace(preRoll.Sequence)) |
||||||
|
{ |
||||||
|
context.SetPreRollSequence(preRoll.Sequence); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
context.ClearPreRollSequence(); |
||||||
|
} |
||||||
|
|
||||||
|
return Task.FromResult(true); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
using YamlDotNet.Serialization; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Scheduling.YamlScheduling.Models; |
||||||
|
|
||||||
|
public class YamlPreRollInstruction : YamlPlayoutInstruction |
||||||
|
{ |
||||||
|
[YamlMember(Alias = "pre_roll", ApplyNamingConventions = false)] |
||||||
|
public bool PreRoll { get; set; } |
||||||
|
|
||||||
|
public string Sequence { get; set; } |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,29 @@ |
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
#nullable disable |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.MySql.Migrations |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Add_PlayoutAnchorContext : Migration |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.AddColumn<string>( |
||||||
|
name: "Context", |
||||||
|
table: "PlayoutAnchor", |
||||||
|
type: "longtext", |
||||||
|
nullable: true) |
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "Context", |
||||||
|
table: "PlayoutAnchor"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,28 @@ |
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
#nullable disable |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Sqlite.Migrations |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Add_PlayoutAnchorContext : Migration |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.AddColumn<string>( |
||||||
|
name: "Context", |
||||||
|
table: "PlayoutAnchor", |
||||||
|
type: "TEXT", |
||||||
|
nullable: true); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "Context", |
||||||
|
table: "PlayoutAnchor"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,131 @@ |
|||||||
|
using System.Globalization; |
||||||
|
using System.Text.Json; |
||||||
|
using ErsatzTV.Core; |
||||||
|
using ErsatzTV.Core.Interfaces.Scheduling; |
||||||
|
using Microsoft.Extensions.Logging; |
||||||
|
using Newtonsoft.Json; |
||||||
|
using Newtonsoft.Json.Linq; |
||||||
|
using Newtonsoft.Json.Schema; |
||||||
|
using YamlDotNet.RepresentationModel; |
||||||
|
using JsonSerializer = System.Text.Json.JsonSerializer; |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Scheduling; |
||||||
|
|
||||||
|
public class YamlScheduleValidator(ILogger<YamlScheduleValidator> logger) : IYamlScheduleValidator |
||||||
|
{ |
||||||
|
public async Task<bool> ValidateSchedule(string yaml) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
string schemaFileName = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "yaml-playout.schema.json"); |
||||||
|
using StreamReader sr = File.OpenText(schemaFileName); |
||||||
|
await using var reader = new JsonTextReader(sr); |
||||||
|
var schema = JSchema.Load(reader); |
||||||
|
|
||||||
|
using var textReader = new StringReader(yaml); |
||||||
|
var yamlStream = new YamlStream(); |
||||||
|
yamlStream.Load(textReader); |
||||||
|
var schedule = JObject.Parse(Convert(yamlStream)); |
||||||
|
|
||||||
|
if (!schedule.IsValid(schema, out IList<string> errorMessages)) |
||||||
|
{ |
||||||
|
logger.LogWarning("Failed to validate YAML schedule definition: {ErrorMessages}", errorMessages); |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
return true; |
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
logger.LogWarning(ex, "Unexpected error while validating YAML schedule definition"); |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
private static string Convert(YamlStream yamlStream) |
||||||
|
{ |
||||||
|
var visitor = new YamlToJsonVisitor(); |
||||||
|
yamlStream.Accept(visitor); |
||||||
|
return visitor.JsonString; |
||||||
|
} |
||||||
|
|
||||||
|
private sealed class YamlToJsonVisitor : IYamlVisitor |
||||||
|
{ |
||||||
|
private readonly JsonSerializerOptions _options = new() { WriteIndented = false, }; |
||||||
|
private object _currentValue; |
||||||
|
|
||||||
|
public string JsonString => JsonSerializer.Serialize(_currentValue, _options); |
||||||
|
|
||||||
|
public void Visit(YamlScalarNode scalar) |
||||||
|
{ |
||||||
|
var value = scalar.Value; |
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(value)) |
||||||
|
{ |
||||||
|
_currentValue = null; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Try to parse in order of most specific to most general
|
||||||
|
if (value.Equals("true", StringComparison.OrdinalIgnoreCase)) |
||||||
|
{ |
||||||
|
_currentValue = true; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (value.Equals("false", StringComparison.OrdinalIgnoreCase)) |
||||||
|
{ |
||||||
|
_currentValue = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intResult)) |
||||||
|
{ |
||||||
|
_currentValue = intResult; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (double.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var doubleResult)) |
||||||
|
{ |
||||||
|
_currentValue = doubleResult; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
_currentValue = value; |
||||||
|
} |
||||||
|
|
||||||
|
public void Visit(YamlSequenceNode sequence) |
||||||
|
{ |
||||||
|
var array = new List<object>(); |
||||||
|
foreach (var node in sequence.Children) |
||||||
|
{ |
||||||
|
node.Accept(this); |
||||||
|
array.Add(_currentValue); |
||||||
|
} |
||||||
|
|
||||||
|
_currentValue = array; |
||||||
|
} |
||||||
|
|
||||||
|
public void Visit(YamlMappingNode mapping) |
||||||
|
{ |
||||||
|
Dictionary<string, object> dict = new(StringComparer.OrdinalIgnoreCase); |
||||||
|
foreach (var entry in mapping.Children) |
||||||
|
{ |
||||||
|
var key = entry.Key switch |
||||||
|
{ |
||||||
|
YamlScalarNode scalar => scalar.Value, |
||||||
|
_ => entry.Key.ToString(), |
||||||
|
}; |
||||||
|
|
||||||
|
entry.Value.Accept(this); |
||||||
|
dict[key!] = _currentValue; |
||||||
|
} |
||||||
|
|
||||||
|
_currentValue = dict; |
||||||
|
} |
||||||
|
|
||||||
|
public void Visit(YamlDocument document) => document.RootNode.Accept(this); |
||||||
|
public void Visit(YamlStream stream) => stream.Documents[0].RootNode.Accept(this); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,146 @@ |
|||||||
|
{ |
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema", |
||||||
|
"$id": "https://ersatztv.org/yaml-playout.schema.json", |
||||||
|
"title": "YAML Playout", |
||||||
|
"description": "An ErsatzTV YAML playout definition", |
||||||
|
"type": "object", |
||||||
|
"properties": { |
||||||
|
"content": { |
||||||
|
"description": "Content definitions", |
||||||
|
"type": "array", |
||||||
|
"items": { |
||||||
|
"oneOf": [ |
||||||
|
{ "$ref": "#/$defs/showContent" }, |
||||||
|
{ "$ref": "#/$defs/searchContent" }, |
||||||
|
{ "$ref": "#/$defs/collectionContent" }, |
||||||
|
{ "$ref": "#/$defs/multiCollectionContent" }, |
||||||
|
{ "$ref": "#/$defs/smartCollectionContent" }, |
||||||
|
{ "$ref": "#/$defs/playlistContent" }, |
||||||
|
{ "$ref": "#/$defs/marathonContent" } |
||||||
|
] |
||||||
|
}, |
||||||
|
"minItems": 1 |
||||||
|
}, |
||||||
|
"sequence": { |
||||||
|
"description": "Sequence definitions", |
||||||
|
"type": "array" |
||||||
|
}, |
||||||
|
"reset": { |
||||||
|
"description": "Reset instructions", |
||||||
|
"type": "array" |
||||||
|
}, |
||||||
|
"playout": { |
||||||
|
"description": "Playout instructions", |
||||||
|
"type": "array", |
||||||
|
"minItems": 1 |
||||||
|
} |
||||||
|
}, |
||||||
|
"required": [ "content", "playout" ], |
||||||
|
"additionalProperties": false, |
||||||
|
"$defs": { |
||||||
|
"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 |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue