mirror of https://github.com/ErsatzTV/ErsatzTV.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
349 lines
14 KiB
349 lines
14 KiB
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<GraphicsElementLoader> logger) |
|
: IGraphicsElementLoader |
|
{ |
|
public async Task<GraphicsEngineContext> LoadAll( |
|
GraphicsEngineContext context, |
|
List<PlayoutItemGraphicsElement> elements, |
|
CancellationToken cancellationToken) |
|
{ |
|
try |
|
{ |
|
// get max epg entries |
|
int epgEntries = await GetMaxEpgEntries(elements); |
|
|
|
// init template element variables once |
|
Dictionary<string, object> 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<TextGraphicsElement> 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<ImageGraphicsElement> 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<MotionGraphicsElement> 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<SubtitleGraphicsElement> 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<string, string>(); |
|
if (!string.IsNullOrWhiteSpace(reference.Variables)) |
|
{ |
|
variables = JsonConvert.DeserializeObject<Dictionary<string, string>>( |
|
reference.Variables); |
|
} |
|
|
|
context.Elements.Add(new SubtitleElementDataContext(element, variables)); |
|
} |
|
|
|
break; |
|
} |
|
case GraphicsElementKind.Script: |
|
{ |
|
Option<ScriptGraphicsElement> 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<Option<string>> 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<BaseGraphicsElement> maybeElement = FromYamlIgnoreUnmatched<BaseGraphicsElement>(builder.ToString()); |
|
foreach (BaseGraphicsElement element in maybeElement.Where(e => !string.IsNullOrWhiteSpace(e.Name))) |
|
{ |
|
return element.Name; |
|
} |
|
} |
|
catch (Exception) |
|
{ |
|
// do nothing |
|
} |
|
|
|
return Option<string>.None; |
|
} |
|
|
|
private async Task<int> GetMaxEpgEntries(List<PlayoutItemGraphicsElement> elements) |
|
{ |
|
var epgEntries = 0; |
|
|
|
IEnumerable<PlayoutItemGraphicsElement> elementsWithEpg = elements.Where(e => |
|
e.GraphicsElement.Kind is GraphicsElementKind.Text or GraphicsElementKind.Subtitle |
|
or GraphicsElementKind.Motion or GraphicsElementKind.Script); |
|
|
|
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<Option<ImageGraphicsElement>> LoadImage(string fileName, Dictionary<string, object> variables) => |
|
GetTemplatedYaml(fileName, variables).BindT(FromYaml<ImageGraphicsElement>); |
|
|
|
private Task<Option<TextGraphicsElement>> LoadText(string fileName, Dictionary<string, object> variables) => |
|
GetTemplatedYaml(fileName, variables).BindT(FromYaml<TextGraphicsElement>); |
|
|
|
private Task<Option<MotionGraphicsElement>> LoadMotion(string fileName, Dictionary<string, object> variables) => |
|
GetTemplatedYaml(fileName, variables).BindT(FromYaml<MotionGraphicsElement>); |
|
|
|
private Task<Option<SubtitleGraphicsElement>> LoadSubtitle(string fileName, Dictionary<string, object> variables) => |
|
GetTemplatedYaml(fileName, variables).BindT(FromYaml<SubtitleGraphicsElement>); |
|
|
|
private Task<Option<ScriptGraphicsElement>> LoadScript(string fileName, Dictionary<string, object> variables) => |
|
GetTemplatedYaml(fileName, variables).BindT(FromYaml<ScriptGraphicsElement>); |
|
|
|
private async Task<Dictionary<string, object>> InitTemplateVariables( |
|
GraphicsEngineContext context, |
|
int epgEntries, |
|
CancellationToken cancellationToken) |
|
{ |
|
// common variables |
|
var result = new Dictionary<string, object> |
|
{ |
|
[FFmpegProfileTemplateDataKey.Resolution] = context.FrameSize, |
|
[FFmpegProfileTemplateDataKey.ScaledResolution] = context.SquarePixelFrameSize, |
|
[FFmpegProfileTemplateDataKey.RFrameRate] = context.FrameRate.RFrameRate, |
|
[FFmpegProfileTemplateDataKey.FrameRate] = context.FrameRate.ParsedFrameRate, |
|
[ChannelTemplateDataKey.ChannelStartTime] = context.ChannelStartTime, |
|
[MediaItemTemplateDataKey.StreamSeek] = context.Seek, |
|
[MediaItemTemplateDataKey.Start] = context.ContentStartTime, |
|
[MediaItemTemplateDataKey.Stop] = context.ContentStartTime + context.Duration |
|
}; |
|
|
|
// 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<Option<string>> GetTemplatedYaml(string fileName, Dictionary<string, object> 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<string>.None; |
|
} |
|
} |
|
|
|
private Option<T> FromYaml<T>(string yaml) |
|
{ |
|
try |
|
{ |
|
// TODO: validate schema |
|
// if (await yamlScheduleValidator.ValidateSchedule(yaml, isImport) == false) |
|
// { |
|
// return Option<YamlPlayoutDefinition>.None; |
|
// } |
|
|
|
IDeserializer deserializer = new DeserializerBuilder() |
|
.WithNamingConvention(CamelCaseNamingConvention.Instance) |
|
.Build(); |
|
|
|
return deserializer.Deserialize<T>(yaml); |
|
} |
|
catch (Exception ex) |
|
{ |
|
logger.LogWarning(ex, "Failed to load graphics element YAML definition"); |
|
return Option<T>.None; |
|
} |
|
} |
|
|
|
private static Option<T> FromYamlIgnoreUnmatched<T>(string yaml) |
|
{ |
|
try |
|
{ |
|
IDeserializer deserializer = new DeserializerBuilder() |
|
.WithNamingConvention(CamelCaseNamingConvention.Instance) |
|
.IgnoreUnmatchedProperties() |
|
.Build(); |
|
|
|
return deserializer.Deserialize<T>(yaml); |
|
} |
|
catch (Exception) |
|
{ |
|
return Option<T>.None; |
|
} |
|
} |
|
|
|
[GeneratedRegex(@"epg_entries:\s*(\d+)")] |
|
private static partial Regex EpgEntriesRegex(); |
|
}
|
|
|