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 @@
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Interfaces.Scheduling; |
||||
|
||||
public interface IYamlScheduleValidator |
||||
{ |
||||
Task<bool> ValidateSchedule(string yaml); |
||||
} |
||||
@ -0,0 +1,34 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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