using System.IO.Abstractions; using System.Text; using System.Text.RegularExpressions; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Graphics; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Metadata; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Scriban; using Scriban.Runtime; using Scriban.Syntax; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; namespace ErsatzTV.Infrastructure.Streaming.Graphics; public partial class GraphicsElementLoader( TemplateFunctions templateFunctions, IFileSystem fileSystem, ITemplateDataRepository templateDataRepository, ILogger logger) : IGraphicsElementLoader { public async Task LoadAll( GraphicsEngineContext context, List elements, CancellationToken cancellationToken) { try { // get max epg entries int epgEntries = await GetMaxEpgEntries(elements); // init template element variables once Dictionary templateVariables = await InitTemplateVariables(context, epgEntries, cancellationToken); // subtitles are in separate files, so they need template variables for later processing context = context with { TemplateVariables = templateVariables }; // fully process references (using template variables) foreach (PlayoutItemGraphicsElement reference in elements) { switch (reference.GraphicsElement.Kind) { case GraphicsElementKind.Text: { Option maybeElement = await LoadText( reference.GraphicsElement.Path, templateVariables); if (maybeElement.IsNone) { logger.LogWarning( "Failed to load text graphics element from file {Path}; ignoring", reference.GraphicsElement.Path); } foreach (TextGraphicsElement element in maybeElement) { context.Elements.Add(new TextElementDataContext(element)); } break; } case GraphicsElementKind.Image: { Option maybeElement = await LoadImage( reference.GraphicsElement.Path, templateVariables); if (maybeElement.IsNone) { logger.LogWarning( "Failed to load image graphics element from file {Path}; ignoring", reference.GraphicsElement.Path); } context.Elements.AddRange(maybeElement.Select(element => new ImageElementContext(element))); break; } case GraphicsElementKind.Motion: { Option maybeElement = await LoadMotion( reference.GraphicsElement.Path, templateVariables); if (maybeElement.IsNone) { logger.LogWarning( "Failed to load motion graphics element from file {Path}; ignoring", reference.GraphicsElement.Path); } foreach (MotionGraphicsElement element in maybeElement) { context.Elements.Add(new MotionElementDataContext(element)); } break; } case GraphicsElementKind.Subtitle: { Option maybeElement = await LoadSubtitle( reference.GraphicsElement.Path, templateVariables); if (maybeElement.IsNone) { logger.LogWarning( "Failed to load subtitle graphics element from file {Path}; ignoring", reference.GraphicsElement.Path); } foreach (SubtitleGraphicsElement element in maybeElement) { var variables = new Dictionary(); if (!string.IsNullOrWhiteSpace(reference.Variables)) { variables = JsonConvert.DeserializeObject>( reference.Variables); } context.Elements.Add(new SubtitleElementDataContext(element, variables)); } break; } case GraphicsElementKind.Script: { Option maybeElement = await LoadScript( reference.GraphicsElement.Path, templateVariables); if (maybeElement.IsNone) { logger.LogWarning( "Failed to load script graphics element from file {Path}; ignoring", reference.GraphicsElement.Path); } foreach (ScriptGraphicsElement element in maybeElement) { context.Elements.Add(new ScriptElementDataContext(element)); } break; } default: logger.LogInformation( "Ignoring unsupported graphics element kind {Kind}", nameof(reference.GraphicsElement.Kind)); break; } } return context; } catch (OperationCanceledException) { // do nothing } return null; } public async Task> TryLoadName(string fileName, CancellationToken cancellationToken) { try { string yaml = await fileSystem.File.ReadAllTextAsync(fileName, cancellationToken); var template = Template.Parse(yaml); var builder = new StringBuilder(); var scriptPage = template.Page; if (scriptPage.Body != null) { foreach (var statement in scriptPage.Body.Statements) { if (statement is ScriptRawStatement rawStatement) { builder.Append(rawStatement.Text); } } } Option maybeElement = FromYamlIgnoreUnmatched(builder.ToString()); foreach (BaseGraphicsElement element in maybeElement.Where(e => !string.IsNullOrWhiteSpace(e.Name))) { return element.Name; } } catch (Exception) { // do nothing } return Option.None; } private async Task GetMaxEpgEntries(List elements) { var epgEntries = 0; IEnumerable elementsWithEpg = elements.Where(e => e.GraphicsElement.Kind is GraphicsElementKind.Text or GraphicsElementKind.Subtitle); foreach (var reference in elementsWithEpg) { foreach (string line in await fileSystem.File.ReadAllLinesAsync(reference.GraphicsElement.Path)) { Match match = EpgEntriesRegex().Match(line); if (!match.Success || !int.TryParse(match.Groups[1].Value, out int value)) { continue; } epgEntries = Math.Max(epgEntries, value); } } return epgEntries; } private Task> LoadImage(string fileName, Dictionary variables) => GetTemplatedYaml(fileName, variables).BindT(FromYaml); private Task> LoadText(string fileName, Dictionary variables) => GetTemplatedYaml(fileName, variables).BindT(FromYaml); private Task> LoadMotion(string fileName, Dictionary variables) => GetTemplatedYaml(fileName, variables).BindT(FromYaml); private Task> LoadSubtitle(string fileName, Dictionary variables) => GetTemplatedYaml(fileName, variables).BindT(FromYaml); private Task> LoadScript(string fileName, Dictionary variables) => GetTemplatedYaml(fileName, variables).BindT(FromYaml); private async Task> InitTemplateVariables( GraphicsEngineContext context, int epgEntries, CancellationToken cancellationToken) { // common variables var result = new Dictionary { [FFmpegProfileTemplateDataKey.Resolution] = context.FrameSize, [FFmpegProfileTemplateDataKey.ScaledResolution] = context.SquarePixelFrameSize, [MediaItemTemplateDataKey.StreamSeek] = context.Seek, [MediaItemTemplateDataKey.Start] = context.ContentStartTime, [MediaItemTemplateDataKey.Stop] = context.ContentStartTime + context.Duration }; // media item variables Option> maybeTemplateData = await templateDataRepository.GetMediaItemTemplateData(context.MediaItem, cancellationToken); foreach (Dictionary templateData in maybeTemplateData) { foreach (KeyValuePair variable in templateData) { result.Add(variable.Key, variable.Value); } } // epg variables DateTimeOffset startTime = context.ContentStartTime + context.Seek; Option> maybeEpgData = await templateDataRepository.GetEpgTemplateData(context.ChannelNumber, startTime, epgEntries); foreach (Dictionary templateData in maybeEpgData) { foreach (KeyValuePair variable in templateData) { result.Add(variable.Key, variable.Value); } } return result; } private async Task> GetTemplatedYaml(string fileName, Dictionary variables) { string yaml = await fileSystem.File.ReadAllTextAsync(fileName); try { var scriptObject = new ScriptObject(); scriptObject.Import(variables, renamer: member => member.Name); scriptObject.Import("convert_timezone", templateFunctions.ConvertTimeZone); scriptObject.Import("format_datetime", templateFunctions.FormatDateTime); scriptObject.Import("get_directory_name", (string path) => Path.GetDirectoryName(path)); scriptObject.Import("get_filename_without_extension", (string path) => Path.GetFileNameWithoutExtension(path)); var context = new TemplateContext { MemberRenamer = member => member.Name }; context.PushGlobal(scriptObject); return await Template.Parse(yaml).RenderAsync(context); } catch (Exception ex) { logger.LogWarning(ex, "Failed to render graphics element YAML definition as scriban template"); return Option.None; } } private Option FromYaml(string yaml) { try { // TODO: validate schema // if (await yamlScheduleValidator.ValidateSchedule(yaml, isImport) == false) // { // return Option.None; // } IDeserializer deserializer = new DeserializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) .Build(); return deserializer.Deserialize(yaml); } catch (Exception ex) { logger.LogWarning(ex, "Failed to load graphics element YAML definition"); return Option.None; } } private static Option FromYamlIgnoreUnmatched(string yaml) { try { IDeserializer deserializer = new DeserializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) .IgnoreUnmatchedProperties() .Build(); return deserializer.Deserialize(yaml); } catch (Exception) { return Option.None; } } [GeneratedRegex(@"epg_entries:\s*(\d+)")] private static partial Regex EpgEntriesRegex(); }