Browse Source

expose arbitrary epg data to graphics engine (#2633)

pull/2634/head
Jason Dove 2 months ago committed by GitHub
parent
commit
8b18f2a304
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      CHANGELOG.md
  2. 8
      ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs
  3. 2
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  4. 29
      ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs
  5. 10
      ErsatzTV.Infrastructure/Epg/EpgReader.cs
  6. 6
      ErsatzTV.Infrastructure/Epg/Models/EpgProgramme.cs
  7. 168
      ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs

5
CHANGELOG.md

@ -43,6 +43,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

8
ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs

@ -1,5 +1,6 @@ @@ -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; @@ -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 @@ -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();
}

2
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -534,7 +534,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -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;

29
ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs

@ -69,20 +69,29 @@ public class TemplateDataRepository(ILocalFileSystem localFileSystem, IDbContext @@ -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 @@ -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 @@ -100,7 +109,7 @@ public class TemplateDataRepository(ILocalFileSystem localFileSystem, IDbContext
DateTimeStyles.None,
out DateTimeOffset stop))
{
data.Stop = stop;
data["Stop"] = stop;
}
result.Add(data);

10
ErsatzTV.Infrastructure/Epg/EpgReader.cs

@ -8,6 +8,7 @@ namespace ErsatzTV.Infrastructure.Epg; @@ -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 @@ -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;

6
ErsatzTV.Infrastructure/Epg/Models/EpgProgramme.cs

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
using System.Xml;
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Epg.Models;
@ -36,8 +37,11 @@ public class EpgProgramme @@ -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; }
}

168
ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs

@ -28,109 +28,119 @@ public partial class GraphicsElementLoader( @@ -28,109 +28,119 @@ public partial class GraphicsElementLoader(
List<PlayoutItemGraphicsElement> 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<string, object> templateVariables =
await InitTemplateVariables(context, epgEntries, cancellationToken);
// 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 };
// 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<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);
}
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));
}
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);
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)));
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);
break;
}
foreach (MotionGraphicsElement element in maybeElement)
case GraphicsElementKind.Motion:
{
context.Elements.Add(new MotionElementDataContext(element));
}
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);
}
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 (MotionGraphicsElement element in maybeElement)
{
context.Elements.Add(new MotionElementDataContext(element));
}
foreach (SubtitleGraphicsElement element in maybeElement)
break;
}
case GraphicsElementKind.Subtitle:
{
var variables = new Dictionary<string, string>();
if (!string.IsNullOrWhiteSpace(reference.Variables))
Option<SubtitleGraphicsElement> maybeElement = await LoadSubtitle(
reference.GraphicsElement.Path,
templateVariables);
if (maybeElement.IsNone)
{
variables = JsonConvert.DeserializeObject<Dictionary<string, string>>(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<string, string>();
if (!string.IsNullOrWhiteSpace(reference.Variables))
{
variables = JsonConvert.DeserializeObject<Dictionary<string, string>>(
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<Option<string>> TryLoadName(string fileName, CancellationToken cancellationToken)

Loading…
Cancel
Save