From 07a55da76ef60de3d330475f319bc837b9d59626 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Fri, 12 Sep 2025 22:04:46 -0500 Subject: [PATCH] process graphics element yaml files with scriban (#2410) * add content rating to media item template * process graphics element yaml files with scriban --- CHANGELOG.md | 2 + .../Fakes/FakeLocalFileSystem.cs | 7 + .../FFmpeg/FFmpegLibraryProcessService.cs | 85 +------- .../Graphics/ImageGraphicsElement.cs | 4 +- .../Graphics/SubtitleGraphicsElement.cs | 4 +- ErsatzTV.Core/Graphics/TextGraphicsElement.cs | 4 +- .../Interfaces/Metadata/ILocalFileSystem.cs | 1 + .../Streaming/GraphicsEngineContext.cs | 1 + ErsatzTV.Core/Metadata/LocalFileSystem.cs | 1 + .../Metadata/MediaItemTemplateDataKey.cs | 3 + .../Repositories/TemplateDataRepository.cs | 3 +- .../Streaming/Graphics/GraphicsEngine.cs | 182 +++++++++++++++--- .../Core/Fakes/FakeLocalFileSystem.cs | 2 + 13 files changed, 183 insertions(+), 116 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7b8bf40a..0116736a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add playout detail row coloring by @peterdey - Filler has unique row colors - Unscheduled gaps are now displayed and have a unique row color +- Process entire graphics element YAML files using scriban + - This allows things like different images based on `MediaItem_ContentRating` (movie) or `MediaItem_ShowContentRating` (episode) ### Fixed - Fix transcoding content with bt709/pc color metadata diff --git a/ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs b/ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs index ba8ce8ee6..2fd1f2590 100644 --- a/ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs +++ b/ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs @@ -63,6 +63,13 @@ public class FakeLocalFileSystem : ILocalFileSystem .Select(f => f.Contents) .IfNoneAsync(string.Empty); + public async Task ReadAllLines(string path) => await _files + .Filter(f => f.Path == path) + .HeadOrNone() + .Select(f => f.Contents) + .IfNoneAsync(string.Empty) + .Map(s => s.Split(Environment.NewLine)); + public Task ReadAllBytes(string path) => TestBytes.AsTask(); private static List Split(DirectoryInfo path) diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 3cacf6102..8940732d0 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -2,7 +2,6 @@ using CliWrap; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; -using ErsatzTV.Core.Graphics; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Streaming; @@ -14,7 +13,6 @@ using ErsatzTV.FFmpeg.Pipeline; using ErsatzTV.FFmpeg.Preset; using ErsatzTV.FFmpeg.State; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; using MediaStream = ErsatzTV.Core.Domain.MediaStream; namespace ErsatzTV.Core.FFmpeg; @@ -405,88 +403,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService playbackSettings.VideoTrackTimeScale, playbackSettings.Deinterlace); - foreach (PlayoutItemGraphicsElement playoutItemGraphicsElement in graphicsElements) - { - switch (playoutItemGraphicsElement.GraphicsElement.Kind) - { - case GraphicsElementKind.Text: - { - Option maybeElement = - await TextGraphicsElement.FromFile(playoutItemGraphicsElement.GraphicsElement.Path); - if (maybeElement.IsNone) - { - _logger.LogWarning( - "Failed to load text graphics element from file {Path}; ignoring", - playoutItemGraphicsElement.GraphicsElement.Path); - } - - foreach (TextGraphicsElement element in maybeElement) - { - var variables = new Dictionary(); - if (!string.IsNullOrWhiteSpace(playoutItemGraphicsElement.Variables)) - { - variables = JsonConvert.DeserializeObject>( - playoutItemGraphicsElement.Variables); - } - - graphicsElementContexts.Add(new TextElementDataContext(element, variables)); - } - - break; - } - case GraphicsElementKind.Image: - { - Option maybeElement = - await ImageGraphicsElement.FromFile(playoutItemGraphicsElement.GraphicsElement.Path); - if (maybeElement.IsNone) - { - _logger.LogWarning( - "Failed to load image graphics element from file {Path}; ignoring", - playoutItemGraphicsElement.GraphicsElement.Path); - } - - foreach (ImageGraphicsElement element in maybeElement) - { - graphicsElementContexts.Add(new ImageElementContext(element)); - } - - break; - } - case GraphicsElementKind.Subtitle: - { - Option maybeElement = - await SubtitlesGraphicsElement.FromFile(playoutItemGraphicsElement.GraphicsElement.Path); - if (maybeElement.IsNone) - { - _logger.LogWarning( - "Failed to load subtitle graphics element from file {Path}; ignoring", - playoutItemGraphicsElement.GraphicsElement.Path); - } - - foreach (SubtitlesGraphicsElement element in maybeElement) - { - var variables = new Dictionary(); - if (!string.IsNullOrWhiteSpace(playoutItemGraphicsElement.Variables)) - { - variables = JsonConvert.DeserializeObject>( - playoutItemGraphicsElement.Variables); - } - - graphicsElementContexts.Add(new SubtitleElementDataContext(element, variables)); - } - - break; - } - default: - _logger.LogInformation( - "Ignoring unsupported graphics element kind {Kind}", - nameof(playoutItemGraphicsElement.GraphicsElement.Kind)); - break; - } - } - // only use graphics engine when we have elements - if (graphicsElementContexts.Count > 0) + if (graphicsElementContexts.Count > 0 || graphicsElements.Count > 0) { graphicsEngineInput = new GraphicsEngineInput(); @@ -496,6 +414,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService channel.Number, audioVersion.MediaItem, graphicsElementContexts, + graphicsElements, new Resolution { Width = targetSize.Width, Height = targetSize.Height }, channel.FFmpegProfile.Resolution, await playbackSettings.FrameRate.IfNoneAsync(24), diff --git a/ErsatzTV.Core/Graphics/ImageGraphicsElement.cs b/ErsatzTV.Core/Graphics/ImageGraphicsElement.cs index 9cb45b08f..6acecc46b 100644 --- a/ErsatzTV.Core/Graphics/ImageGraphicsElement.cs +++ b/ErsatzTV.Core/Graphics/ImageGraphicsElement.cs @@ -36,12 +36,10 @@ public class ImageGraphicsElement [YamlMember(Alias = "scale_width_percent", ApplyNamingConventions = false)] public double? ScaleWidthPercent { get; set; } - public static async Task> FromFile(string fileName) + public static Option FromYaml(string yaml) { try { - string yaml = await File.ReadAllTextAsync(fileName); - // TODO: validate schema // if (await yamlScheduleValidator.ValidateSchedule(yaml, isImport) == false) // { diff --git a/ErsatzTV.Core/Graphics/SubtitleGraphicsElement.cs b/ErsatzTV.Core/Graphics/SubtitleGraphicsElement.cs index b5e6775e9..1987bca20 100644 --- a/ErsatzTV.Core/Graphics/SubtitleGraphicsElement.cs +++ b/ErsatzTV.Core/Graphics/SubtitleGraphicsElement.cs @@ -13,12 +13,10 @@ public class SubtitlesGraphicsElement public string Template { get; set; } - public static async Task> FromFile(string fileName) + public static Option FromYaml(string yaml) { try { - string yaml = await File.ReadAllTextAsync(fileName); - // TODO: validate schema // if (await yamlScheduleValidator.ValidateSchedule(yaml, isImport) == false) // { diff --git a/ErsatzTV.Core/Graphics/TextGraphicsElement.cs b/ErsatzTV.Core/Graphics/TextGraphicsElement.cs index b5c0affa4..fc2015bcf 100644 --- a/ErsatzTV.Core/Graphics/TextGraphicsElement.cs +++ b/ErsatzTV.Core/Graphics/TextGraphicsElement.cs @@ -45,12 +45,10 @@ public class TextGraphicsElement public string Text { get; set; } - public static async Task> FromFile(string fileName) + public static Option FromYaml(string yaml) { try { - string yaml = await File.ReadAllTextAsync(fileName); - // TODO: validate schema // if (await yamlScheduleValidator.ValidateSchedule(yaml, isImport) == false) // { diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs index 77425c057..df141b128 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs @@ -15,4 +15,5 @@ public interface ILocalFileSystem Task> CopyFile(string source, string destination); Unit EmptyFolder(string folder); Task ReadAllText(string path); + Task ReadAllLines(string path); } diff --git a/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs b/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs index c6719fcc2..50f6deb74 100644 --- a/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs +++ b/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs @@ -8,6 +8,7 @@ public record GraphicsEngineContext( string ChannelNumber, MediaItem MediaItem, List Elements, + List ElementReferences, Resolution SquarePixelFrameSize, Resolution FrameSize, int FrameRate, diff --git a/ErsatzTV.Core/Metadata/LocalFileSystem.cs b/ErsatzTV.Core/Metadata/LocalFileSystem.cs index 27cc35898..829d048b1 100644 --- a/ErsatzTV.Core/Metadata/LocalFileSystem.cs +++ b/ErsatzTV.Core/Metadata/LocalFileSystem.cs @@ -157,4 +157,5 @@ public class LocalFileSystem(IClient client, ILogger logger) : } public Task ReadAllText(string path) => File.ReadAllTextAsync(path); + public Task ReadAllLines(string path) => File.ReadAllLinesAsync(path); } diff --git a/ErsatzTV.Core/Metadata/MediaItemTemplateDataKey.cs b/ErsatzTV.Core/Metadata/MediaItemTemplateDataKey.cs index 89b37257e..4dedc237c 100644 --- a/ErsatzTV.Core/Metadata/MediaItemTemplateDataKey.cs +++ b/ErsatzTV.Core/Metadata/MediaItemTemplateDataKey.cs @@ -15,6 +15,9 @@ public static class MediaItemTemplateDataKey public static readonly string Directors = "MediaItem_Directors"; public static readonly string Genres = "MediaItem_Genres"; + // movie + public static readonly string ContentRating = "MediaItem_ContentRating"; + // episode/show public static readonly string ShowTitle = "MediaItem_ShowTitle"; public static readonly string ShowYear = "MediaItem_ShowYear"; diff --git a/ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs index 3a1d0ce2b..42ad1417a 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs @@ -118,7 +118,8 @@ public class TemplateDataRepository(ILocalFileSystem localFileSystem, IDbContext [MediaItemTemplateDataKey.Directors] = (metadata.Directors ?? []).Map(d => d.Name).OrderBy(identity), [MediaItemTemplateDataKey.Genres] = (metadata.Genres ?? []).Map(g => g.Name).OrderBy(identity), - [MediaItemTemplateDataKey.Duration] = movie.GetHeadVersion().Duration + [MediaItemTemplateDataKey.Duration] = movie.GetHeadVersion().Duration, + [MediaItemTemplateDataKey.ContentRating] = metadata.ContentRating }; } } diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs index dc20e6368..0d19ca657 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs @@ -1,19 +1,27 @@ using System.IO.Pipelines; +using System.Text.RegularExpressions; using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Graphics; using ErsatzTV.Core.Interfaces.FFmpeg; +using ErsatzTV.Core.Interfaces.Metadata; 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 SkiaSharp; namespace ErsatzTV.Infrastructure.Streaming.Graphics; -public class GraphicsEngine( +public partial class GraphicsEngine( TemplateFunctions templateFunctions, GraphicsEngineFonts graphicsEngineFonts, ITempFilePool tempFilePool, ITemplateDataRepository templateDataRepository, + ILocalFileSystem localFileSystem, ILogger logger) : IGraphicsEngine { @@ -21,37 +29,104 @@ public class GraphicsEngine( { graphicsEngineFonts.LoadFonts(FileSystemLayout.FontsCacheFolder); - var templateVariables = new Dictionary(); - - // init template element variables once - if (context.Elements.OfType().Any()) + // get max epg entries + int epgEntries = 0; + foreach (var reference in context.ElementReferences) { - // common variables - templateVariables[MediaItemTemplateDataKey.Resolution] = context.FrameSize; - templateVariables[MediaItemTemplateDataKey.StreamSeek] = context.Seek; - - // media item variables - Option> maybeTemplateData = - await templateDataRepository.GetMediaItemTemplateData(context.MediaItem, cancellationToken); - foreach (Dictionary templateData in maybeTemplateData) + if (reference.GraphicsElement.Kind is GraphicsElementKind.Text or GraphicsElementKind.Subtitle) { - foreach (KeyValuePair variable in templateData) + foreach (string line in await localFileSystem.ReadAllLines(reference.GraphicsElement.Path)) { - templateVariables.Add(variable.Key, variable.Value); + Match match = EpgEntriesRegex().Match(line); + if (match.Success && int.TryParse(match.Groups[1].Value, out int value)) + { + epgEntries = Math.Max(epgEntries, value); + break; + } } } + } - // epg variables - int maxEpg = context.Elements.OfType().Max(c => c.EpgEntries); - DateTimeOffset startTime = context.ContentStartTime + context.Seek; - Option> maybeEpgData = - await templateDataRepository.GetEpgTemplateData(context.ChannelNumber, startTime, maxEpg); - foreach (Dictionary templateData in maybeEpgData) + // init template element variables once + Dictionary templateVariables = + await InitTemplateVariables(context, epgEntries, cancellationToken); + + // fully process references (using template variables) + foreach (var reference in context.ElementReferences) + { + switch (reference.GraphicsElement.Kind) { - foreach (KeyValuePair variable in templateData) + case GraphicsElementKind.Text: + { + Option maybeElement = TextGraphicsElement.FromYaml( + await GetTemplatedYaml(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) + { + var variables = new Dictionary(); + if (!string.IsNullOrWhiteSpace(reference.Variables)) + { + variables = JsonConvert.DeserializeObject>(reference.Variables); + } + + context.Elements.Add(new TextElementDataContext(element, variables)); + } + + break; + } + case GraphicsElementKind.Image: { - templateVariables.Add(variable.Key, variable.Value); + Option maybeElement = ImageGraphicsElement.FromYaml( + await GetTemplatedYaml(reference.GraphicsElement.Path, templateVariables)); + if (maybeElement.IsNone) + { + logger.LogWarning( + "Failed to load image graphics element from file {Path}; ignoring", + reference.GraphicsElement.Path); + } + + foreach (ImageGraphicsElement element in maybeElement) + { + context.Elements.Add(new ImageElementContext(element)); + } + + break; } + case GraphicsElementKind.Subtitle: + { + Option maybeElement = SubtitlesGraphicsElement.FromYaml( + await GetTemplatedYaml(reference.GraphicsElement.Path, templateVariables)); + if (maybeElement.IsNone) + { + logger.LogWarning( + "Failed to load subtitle graphics element from file {Path}; ignoring", + reference.GraphicsElement.Path); + } + + foreach (SubtitlesGraphicsElement 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; + } + default: + logger.LogInformation( + "Ignoring unsupported graphics element kind {Kind}", + nameof(reference.GraphicsElement.Kind)); + break; } } @@ -230,4 +305,65 @@ public class GraphicsEngine( } } } + + private async Task> InitTemplateVariables( + GraphicsEngineContext context, + int epgEntries, + CancellationToken cancellationToken) + { + // common variables + var result = new Dictionary + { + [MediaItemTemplateDataKey.Resolution] = context.FrameSize, + [MediaItemTemplateDataKey.StreamSeek] = context.Seek + }; + + // 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 localFileSystem.ReadAllText(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); + + var context = new TemplateContext { MemberRenamer = member => member.Name }; + context.PushGlobal(scriptObject); + return await Template.Parse(yaml).RenderAsync(context); + } + catch (Exception) + { + return yaml; + } + } + + [GeneratedRegex(@"epg_entries:\s*(\d+)")] + private static partial Regex EpgEntriesRegex(); } diff --git a/ErsatzTV.Scanner.Tests/Core/Fakes/FakeLocalFileSystem.cs b/ErsatzTV.Scanner.Tests/Core/Fakes/FakeLocalFileSystem.cs index 93e87f204..d7480308e 100644 --- a/ErsatzTV.Scanner.Tests/Core/Fakes/FakeLocalFileSystem.cs +++ b/ErsatzTV.Scanner.Tests/Core/Fakes/FakeLocalFileSystem.cs @@ -58,6 +58,8 @@ public class FakeLocalFileSystem : ILocalFileSystem public Unit EmptyFolder(string folder) => Unit.Default; public Task ReadAllText(string path) => throw new NotImplementedException(); + public Task ReadAllLines(string path) => throw new NotImplementedException(); + public Task ReadAllBytes(string path) => TestBytes.AsTask(); private static List Split(DirectoryInfo path)