expose arbitrary epg data to graphics engine (#2633)

This commit is contained in:
Jason Dove
2025-11-11 12:41:45 -06:00
committed by GitHub
parent 1e0bba0dc6
commit 8b18f2a304
7 changed files with 146 additions and 106 deletions

View File

@@ -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 `<etv:episode_number_key>{{ episode_number }}</etv:episode_number_key>` 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

View File

@@ -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<GetChannelGuide, Either<BaseError, ChannelGuide>>
public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
@@ -78,9 +79,14 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
.Replace("{AccessTokenUri}", accessTokenUri);
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
}
return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
}
[GeneratedRegex(@"<etv:[^>]+?>.*?<\/etv:[^>]+?>|<etv:[^>]+?\/>", RegexOptions.Singleline)]
private static partial Regex EtvTagRegex();
}

View File

@@ -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;

View File

@@ -69,20 +69,29 @@ public class TemplateDataRepository(ILocalFileSystem localFileSystem, IDbContext
{
await using FileStream stream = File.OpenRead(targetFile);
List<EpgProgramme> xmlProgrammes = EpgReader.FindProgrammesAt(stream, time, count);
var result = new List<EpgProgrammeTemplateData>();
var result = new List<Dictionary<string, object>>();
foreach (EpgProgramme epgProgramme in xmlProgrammes)
{
var data = new EpgProgrammeTemplateData
Dictionary<string, object> 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);

View File

@@ -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<EpgProgramme> 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;

View File

@@ -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; }
}

View File

@@ -28,109 +28,119 @@ public partial class GraphicsElementLoader(
List<PlayoutItemGraphicsElement> elements,
CancellationToken cancellationToken)
{
// 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)
try
{
switch (reference.GraphicsElement.Kind)
// 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)
{
case GraphicsElementKind.Text:
switch (reference.GraphicsElement.Kind)
{
Option<TextGraphicsElement> 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);
}
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))
Option<TextGraphicsElement> maybeElement = await LoadText(
reference.GraphicsElement.Path,
templateVariables);
if (maybeElement.IsNone)
{
variables = JsonConvert.DeserializeObject<Dictionary<string, string>>(reference.Variables);
logger.LogWarning(
"Failed to load text graphics element from file {Path}; ignoring",
reference.GraphicsElement.Path);
}
context.Elements.Add(new SubtitleElementDataContext(element, variables));
}
foreach (TextGraphicsElement element in maybeElement)
{
context.Elements.Add(new TextElementDataContext(element));
}
break;
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;
}
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<Option<string>> TryLoadName(string fileName, CancellationToken cancellationToken)