diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cbbc5904..aafe31dd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add `Troubleshoot Playback` buttons on movie and episode detail pages - Add song background and missing album art customization - Default files start with an underscore; custom versions must remove the underscore +- Expose arbitrary EPG data to graphics engine via channel guide templates + - XML nodes using the `etv:` namespace will be passed to the graphics engine EPG template data + - For example, adding `{{ episode_number }}` to `episode.sbntxt` will also add the `episode_number_key` field to all EPG items in the graphics engine + - All values parsed from XMLTV will be available as strings in the graphics engine (not numbers) + - All `etv:` nodes will be stripped from the XMLTV data when requested by a client ### Fixed - Fix HLS Direct playback with Jellyfin 10.11 diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs index 362e7f261..d088c32d2 100644 --- a/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Text; +using System.Text.RegularExpressions; using ErsatzTV.Core; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Iptv; @@ -9,7 +10,7 @@ using Microsoft.IO; namespace ErsatzTV.Application.Channels; -public class GetChannelGuideHandler : IRequestHandler> +public partial class GetChannelGuideHandler : IRequestHandler> { private readonly IDbContextFactory _dbContextFactory; private readonly ILocalFileSystem _localFileSystem; @@ -78,9 +79,14 @@ public class GetChannelGuideHandler : IRequestHandler]+?>.*?<\/etv:[^>]+?>|]+?\/>", RegexOptions.Singleline)] + private static partial Regex EtvTagRegex(); } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 86188fe90..b5313d72e 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -534,7 +534,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService context = await _graphicsElementLoader.LoadAll(context, graphicsElements, cancellationToken); - if (context.Elements.Count > 0) + if (context?.Elements?.Count > 0) { graphicsEngineInput = new GraphicsEngineInput(); graphicsEngineContext = context; diff --git a/ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs index f6bbbc764..8092660a7 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs @@ -69,20 +69,29 @@ public class TemplateDataRepository(ILocalFileSystem localFileSystem, IDbContext { await using FileStream stream = File.OpenRead(targetFile); List xmlProgrammes = EpgReader.FindProgrammesAt(stream, time, count); - var result = new List(); + var result = new List>(); foreach (EpgProgramme epgProgramme in xmlProgrammes) { - var data = new EpgProgrammeTemplateData + Dictionary data = new() { - Title = epgProgramme.Title?.Value, - SubTitle = epgProgramme.SubTitle?.Value, - Description = epgProgramme.Description?.Value, - Rating = epgProgramme.Rating?.Value, - Categories = (epgProgramme.Categories ?? []).Map(c => c.Value).ToArray(), - Date = epgProgramme.Date?.Value + ["Title"] = epgProgramme.Title?.Value, + ["SubTitle"] = epgProgramme.SubTitle?.Value, + ["Description"] = epgProgramme.Description?.Value, + ["Rating"] = epgProgramme.Rating?.Value, + ["Categories"] = (epgProgramme.Categories ?? []).Map(c => c.Value).ToArray(), + ["Date"] = epgProgramme.Date?.Value }; + if (epgProgramme.OtherElements?.Length > 0) + { + foreach (var otherElement in epgProgramme.OtherElements.Where(e => + e.NamespaceURI == EpgReader.XmlTvCustomNamespace)) + { + data[otherElement.LocalName] = otherElement.InnerText; + } + } + if (DateTimeOffset.TryParseExact( epgProgramme.Start, EpgReader.XmlTvDateFormat, @@ -90,7 +99,7 @@ public class TemplateDataRepository(ILocalFileSystem localFileSystem, IDbContext DateTimeStyles.None, out DateTimeOffset start)) { - data.Start = start; + data["Start"] = start; } if (DateTimeOffset.TryParseExact( @@ -100,7 +109,7 @@ public class TemplateDataRepository(ILocalFileSystem localFileSystem, IDbContext DateTimeStyles.None, out DateTimeOffset stop)) { - data.Stop = stop; + data["Stop"] = stop; } result.Add(data); diff --git a/ErsatzTV.Infrastructure/Epg/EpgReader.cs b/ErsatzTV.Infrastructure/Epg/EpgReader.cs index 05d6759e6..2ce1369f8 100644 --- a/ErsatzTV.Infrastructure/Epg/EpgReader.cs +++ b/ErsatzTV.Infrastructure/Epg/EpgReader.cs @@ -8,6 +8,7 @@ namespace ErsatzTV.Infrastructure.Epg; public static class EpgReader { public const string XmlTvDateFormat = "yyyyMMddHHmmss zzz"; + public const string XmlTvCustomNamespace = "https://ersatztv.org/xmltv/extensions"; public static List FindProgrammesAt(Stream xmlStream, DateTimeOffset targetTime, int count) { @@ -16,10 +17,15 @@ public static class EpgReader var serializer = new XmlSerializer(typeof(EpgProgramme)); var settings = new XmlReaderSettings { - ConformanceLevel = ConformanceLevel.Fragment + ConformanceLevel = ConformanceLevel.Fragment, }; - using var reader = XmlReader.Create(xmlStream, settings); + var nt = new NameTable(); + var nsmgr = new XmlNamespaceManager(nt); + nsmgr.AddNamespace("etv", XmlTvCustomNamespace); + var context = new XmlParserContext(nt, nsmgr, null, XmlSpace.None); + + using var reader = XmlReader.Create(xmlStream, settings, context); var foundCurrent = false; diff --git a/ErsatzTV.Infrastructure/Epg/Models/EpgProgramme.cs b/ErsatzTV.Infrastructure/Epg/Models/EpgProgramme.cs index 8afd3b289..42a4dad87 100644 --- a/ErsatzTV.Infrastructure/Epg/Models/EpgProgramme.cs +++ b/ErsatzTV.Infrastructure/Epg/Models/EpgProgramme.cs @@ -1,3 +1,4 @@ +using System.Xml; using System.Xml.Serialization; namespace ErsatzTV.Infrastructure.Epg.Models; @@ -36,8 +37,11 @@ public class EpgProgramme public EpgRating Rating { get; set; } [XmlElement("previously-shown")] - public object PreviouslyShown { get; set; } // Use object for presence check + public object PreviouslyShown { get; set; } [XmlElement("date")] public EpgDate Date { get; set; } + + [XmlAnyElement] + public XmlElement[] OtherElements { get; set; } } diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs index 5d0681396..a82585294 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs @@ -28,109 +28,119 @@ public partial class GraphicsElementLoader( List elements, CancellationToken cancellationToken) { - // get max epg entries - int epgEntries = await GetMaxEpgEntries(elements); + try + { + // get max epg entries + int epgEntries = await GetMaxEpgEntries(elements); - // init template element variables once - Dictionary templateVariables = - await InitTemplateVariables(context, epgEntries, cancellationToken); + // 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 }; + // 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) + // fully process references (using template variables) + foreach (PlayoutItemGraphicsElement reference in elements) { - case GraphicsElementKind.Text: + switch (reference.GraphicsElement.Kind) { - Option maybeElement = await LoadText( - reference.GraphicsElement.Path, - templateVariables); - if (maybeElement.IsNone) + case GraphicsElementKind.Text: { - logger.LogWarning( - "Failed to load text graphics element from file {Path}; ignoring", - reference.GraphicsElement.Path); - } + 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)); - } + 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); + 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))); + 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); + break; } - - foreach (MotionGraphicsElement element in maybeElement) + case GraphicsElementKind.Motion: { - context.Elements.Add(new MotionElementDataContext(element)); - } + 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); + } - 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 (MotionGraphicsElement element in maybeElement) + { + context.Elements.Add(new MotionElementDataContext(element)); + } - foreach (SubtitleGraphicsElement element in maybeElement) + break; + } + case GraphicsElementKind.Subtitle: { - var variables = new Dictionary(); - if (!string.IsNullOrWhiteSpace(reference.Variables)) + Option maybeElement = await LoadSubtitle( + reference.GraphicsElement.Path, + templateVariables); + if (maybeElement.IsNone) { - variables = JsonConvert.DeserializeObject>(reference.Variables); + logger.LogWarning( + "Failed to load subtitle graphics element from file {Path}; ignoring", + reference.GraphicsElement.Path); } - context.Elements.Add(new SubtitleElementDataContext(element, variables)); - } + 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; + break; + } + default: + logger.LogInformation( + "Ignoring unsupported graphics element kind {Kind}", + nameof(reference.GraphicsElement.Kind)); + break; } - default: - logger.LogInformation( - "Ignoring unsupported graphics element kind {Kind}", - nameof(reference.GraphicsElement.Kind)); - break; } + + return context; + } + catch (OperationCanceledException) + { + // do nothing } - return context; + return null; } public async Task> TryLoadName(string fileName, CancellationToken cancellationToken)