expose arbitrary epg data to graphics engine (#2633)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user