Browse Source

process graphics element yaml files with scriban (#2410)

* add content rating to media item template

* process graphics element yaml files with scriban
pull/2411/head
Jason Dove 4 months ago committed by GitHub
parent
commit
07a55da76e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 7
      ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs
  3. 85
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  4. 4
      ErsatzTV.Core/Graphics/ImageGraphicsElement.cs
  5. 4
      ErsatzTV.Core/Graphics/SubtitleGraphicsElement.cs
  6. 4
      ErsatzTV.Core/Graphics/TextGraphicsElement.cs
  7. 1
      ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs
  8. 1
      ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs
  9. 1
      ErsatzTV.Core/Metadata/LocalFileSystem.cs
  10. 3
      ErsatzTV.Core/Metadata/MediaItemTemplateDataKey.cs
  11. 3
      ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs
  12. 182
      ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs
  13. 2
      ErsatzTV.Scanner.Tests/Core/Fakes/FakeLocalFileSystem.cs

2
CHANGELOG.md

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

7
ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs

@ -63,6 +63,13 @@ public class FakeLocalFileSystem : ILocalFileSystem @@ -63,6 +63,13 @@ public class FakeLocalFileSystem : ILocalFileSystem
.Select(f => f.Contents)
.IfNoneAsync(string.Empty);
public async Task<string[]> 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<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();
private static List<DirectoryInfo> Split(DirectoryInfo path)

85
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -2,7 +2,6 @@ @@ -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; @@ -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 @@ -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<TextGraphicsElement> 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<string, string>();
if (!string.IsNullOrWhiteSpace(playoutItemGraphicsElement.Variables))
{
variables = JsonConvert.DeserializeObject<Dictionary<string, string>>(
playoutItemGraphicsElement.Variables);
}
graphicsElementContexts.Add(new TextElementDataContext(element, variables));
}
break;
}
case GraphicsElementKind.Image:
{
Option<ImageGraphicsElement> 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<SubtitlesGraphicsElement> 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<string, string>();
if (!string.IsNullOrWhiteSpace(playoutItemGraphicsElement.Variables))
{
variables = JsonConvert.DeserializeObject<Dictionary<string, string>>(
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 @@ -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),

4
ErsatzTV.Core/Graphics/ImageGraphicsElement.cs

@ -36,12 +36,10 @@ public class ImageGraphicsElement @@ -36,12 +36,10 @@ public class ImageGraphicsElement
[YamlMember(Alias = "scale_width_percent", ApplyNamingConventions = false)]
public double? ScaleWidthPercent { get; set; }
public static async Task<Option<ImageGraphicsElement>> FromFile(string fileName)
public static Option<ImageGraphicsElement> FromYaml(string yaml)
{
try
{
string yaml = await File.ReadAllTextAsync(fileName);
// TODO: validate schema
// if (await yamlScheduleValidator.ValidateSchedule(yaml, isImport) == false)
// {

4
ErsatzTV.Core/Graphics/SubtitleGraphicsElement.cs

@ -13,12 +13,10 @@ public class SubtitlesGraphicsElement @@ -13,12 +13,10 @@ public class SubtitlesGraphicsElement
public string Template { get; set; }
public static async Task<Option<SubtitlesGraphicsElement>> FromFile(string fileName)
public static Option<SubtitlesGraphicsElement> FromYaml(string yaml)
{
try
{
string yaml = await File.ReadAllTextAsync(fileName);
// TODO: validate schema
// if (await yamlScheduleValidator.ValidateSchedule(yaml, isImport) == false)
// {

4
ErsatzTV.Core/Graphics/TextGraphicsElement.cs

@ -45,12 +45,10 @@ public class TextGraphicsElement @@ -45,12 +45,10 @@ public class TextGraphicsElement
public string Text { get; set; }
public static async Task<Option<TextGraphicsElement>> FromFile(string fileName)
public static Option<TextGraphicsElement> FromYaml(string yaml)
{
try
{
string yaml = await File.ReadAllTextAsync(fileName);
// TODO: validate schema
// if (await yamlScheduleValidator.ValidateSchedule(yaml, isImport) == false)
// {

1
ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs

@ -15,4 +15,5 @@ public interface ILocalFileSystem @@ -15,4 +15,5 @@ public interface ILocalFileSystem
Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
Unit EmptyFolder(string folder);
Task<string> ReadAllText(string path);
Task<string[]> ReadAllLines(string path);
}

1
ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs

@ -8,6 +8,7 @@ public record GraphicsEngineContext( @@ -8,6 +8,7 @@ public record GraphicsEngineContext(
string ChannelNumber,
MediaItem MediaItem,
List<GraphicsElementContext> Elements,
List<PlayoutItemGraphicsElement> ElementReferences,
Resolution SquarePixelFrameSize,
Resolution FrameSize,
int FrameRate,

1
ErsatzTV.Core/Metadata/LocalFileSystem.cs

@ -157,4 +157,5 @@ public class LocalFileSystem(IClient client, ILogger<LocalFileSystem> logger) : @@ -157,4 +157,5 @@ public class LocalFileSystem(IClient client, ILogger<LocalFileSystem> logger) :
}
public Task<string> ReadAllText(string path) => File.ReadAllTextAsync(path);
public Task<string[]> ReadAllLines(string path) => File.ReadAllLinesAsync(path);
}

3
ErsatzTV.Core/Metadata/MediaItemTemplateDataKey.cs

@ -15,6 +15,9 @@ public static class MediaItemTemplateDataKey @@ -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";

3
ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs

@ -118,7 +118,8 @@ public class TemplateDataRepository(ILocalFileSystem localFileSystem, IDbContext @@ -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
};
}
}

182
ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs

@ -1,19 +1,27 @@ @@ -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<GraphicsEngine> logger)
: IGraphicsEngine
{
@ -21,37 +29,104 @@ public class GraphicsEngine( @@ -21,37 +29,104 @@ public class GraphicsEngine(
{
graphicsEngineFonts.LoadFonts(FileSystemLayout.FontsCacheFolder);
var templateVariables = new Dictionary<string, object>();
// init template element variables once
if (context.Elements.OfType<ITemplateDataContext>().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<Dictionary<string, object>> maybeTemplateData =
await templateDataRepository.GetMediaItemTemplateData(context.MediaItem, cancellationToken);
foreach (Dictionary<string, object> templateData in maybeTemplateData)
if (reference.GraphicsElement.Kind is GraphicsElementKind.Text or GraphicsElementKind.Subtitle)
{
foreach (KeyValuePair<string, object> 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<ITemplateDataContext>().Max(c => c.EpgEntries);
DateTimeOffset startTime = context.ContentStartTime + context.Seek;
Option<Dictionary<string, object>> maybeEpgData =
await templateDataRepository.GetEpgTemplateData(context.ChannelNumber, startTime, maxEpg);
foreach (Dictionary<string, object> templateData in maybeEpgData)
// init template element variables once
Dictionary<string, object> templateVariables =
await InitTemplateVariables(context, epgEntries, cancellationToken);
// fully process references (using template variables)
foreach (var reference in context.ElementReferences)
{
switch (reference.GraphicsElement.Kind)
{
foreach (KeyValuePair<string, object> variable in templateData)
case GraphicsElementKind.Text:
{
Option<TextGraphicsElement> 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<string, string>();
if (!string.IsNullOrWhiteSpace(reference.Variables))
{
variables = JsonConvert.DeserializeObject<Dictionary<string, string>>(reference.Variables);
}
context.Elements.Add(new TextElementDataContext(element, variables));
}
break;
}
case GraphicsElementKind.Image:
{
templateVariables.Add(variable.Key, variable.Value);
Option<ImageGraphicsElement> 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<SubtitlesGraphicsElement> 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<string, string>();
if (!string.IsNullOrWhiteSpace(reference.Variables))
{
variables = JsonConvert.DeserializeObject<Dictionary<string, string>>(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( @@ -230,4 +305,65 @@ public class GraphicsEngine(
}
}
}
private async Task<Dictionary<string, object>> InitTemplateVariables(
GraphicsEngineContext context,
int epgEntries,
CancellationToken cancellationToken)
{
// common variables
var result = new Dictionary<string, object>
{
[MediaItemTemplateDataKey.Resolution] = context.FrameSize,
[MediaItemTemplateDataKey.StreamSeek] = context.Seek
};
// media item variables
Option<Dictionary<string, object>> maybeTemplateData =
await templateDataRepository.GetMediaItemTemplateData(context.MediaItem, cancellationToken);
foreach (Dictionary<string, object> templateData in maybeTemplateData)
{
foreach (KeyValuePair<string, object> variable in templateData)
{
result.Add(variable.Key, variable.Value);
}
}
// epg variables
DateTimeOffset startTime = context.ContentStartTime + context.Seek;
Option<Dictionary<string, object>> maybeEpgData =
await templateDataRepository.GetEpgTemplateData(context.ChannelNumber, startTime, epgEntries);
foreach (Dictionary<string, object> templateData in maybeEpgData)
{
foreach (KeyValuePair<string, object> variable in templateData)
{
result.Add(variable.Key, variable.Value);
}
}
return result;
}
private async Task<string> GetTemplatedYaml(string fileName, Dictionary<string, object> 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();
}

2
ErsatzTV.Scanner.Tests/Core/Fakes/FakeLocalFileSystem.cs

@ -58,6 +58,8 @@ public class FakeLocalFileSystem : ILocalFileSystem @@ -58,6 +58,8 @@ public class FakeLocalFileSystem : ILocalFileSystem
public Unit EmptyFolder(string folder) => Unit.Default;
public Task<string> ReadAllText(string path) => throw new NotImplementedException();
public Task<string[]> ReadAllLines(string path) => throw new NotImplementedException();
public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();
private static List<DirectoryInfo> Split(DirectoryInfo path)

Loading…
Cancel
Save