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