diff --git a/CHANGELOG.md b/CHANGELOG.md index 345b8d1c..7ad987e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Supported in playback troubleshooting and YAML playouts - Displays multi-line text in a specified font, color, location, z-index - Supports constant opacity and opacity expression - - Supports variable replacement for music videos + - Supports EPG and Media Item variable replacement + - EPG data is sourced from XMLTV + - Media Item data is sourced from the currently playing media item - Add `image` graphics element type - Supported in playback troubleshooting and YAML playouts - Displays an image, similar to a watermark diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 98fdc758..1d7df740 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -515,6 +515,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService graphicsEngineInput = new GraphicsEngineInput(); graphicsEngineContext = new GraphicsEngineContext( + channel.Number, audioVersion.MediaItem, graphicsElementContexts, new Resolution { Width = desiredState.ScaledSize.Width, Height = desiredState.ScaledSize.Height }, diff --git a/ErsatzTV.Core/Interfaces/Repositories/ITemplateDataRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ITemplateDataRepository.cs index 57322df6..e1da3e38 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/ITemplateDataRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/ITemplateDataRepository.cs @@ -4,8 +4,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories; public interface ITemplateDataRepository { - public Task>> GetMusicVideoTemplateData( - Resolution resolution, - TimeSpan streamSeek, - int musicVideoId); + public Task>> GetMediaItemTemplateData(MediaItem mediaItem); + + public Task>> GetEpgTemplateData(string channelNumber, DateTimeOffset time); } \ No newline at end of file diff --git a/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs b/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs index b4aa9b1a..1b171c39 100644 --- a/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs +++ b/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs @@ -5,6 +5,7 @@ using ErsatzTV.Core.Graphics; namespace ErsatzTV.Core.Interfaces.Streaming; public record GraphicsEngineContext( + string ChannelNumber, MediaItem MediaItem, List Elements, Resolution SquarePixelFrameSize, diff --git a/ErsatzTV.Core/Metadata/EpgTemplateDataKey.cs b/ErsatzTV.Core/Metadata/EpgTemplateDataKey.cs new file mode 100644 index 00000000..6e5240eb --- /dev/null +++ b/ErsatzTV.Core/Metadata/EpgTemplateDataKey.cs @@ -0,0 +1,11 @@ +namespace ErsatzTV.Core.Metadata; + +public static class EpgTemplateDataKey +{ + public static readonly string Title = "Epg_Title"; + public static readonly string SubTitle = "Epg_SubTitle"; + public static readonly string Description = "Epg_Description"; + public static readonly string Rating = "Epg_Rating"; + public static readonly string Categories = "Epg_Categories"; + public static readonly string Date = "Epg_Date"; +} diff --git a/ErsatzTV.Core/Metadata/MediaItemTemplateDataKey.cs b/ErsatzTV.Core/Metadata/MediaItemTemplateDataKey.cs new file mode 100644 index 00000000..89b37257 --- /dev/null +++ b/ErsatzTV.Core/Metadata/MediaItemTemplateDataKey.cs @@ -0,0 +1,29 @@ +namespace ErsatzTV.Core.Metadata; + +public static class MediaItemTemplateDataKey +{ + public static readonly string Resolution = "Resolution"; + + public static readonly string Duration = "MediaItem_Duration"; + public static readonly string StreamSeek = "MediaItem_StreamSeek"; + + // common + public static readonly string Title = "MediaItem_Title"; + public static readonly string Plot = "MediaItem_Plot"; + public static readonly string ReleaseDate = "MediaItem_ReleaseDate"; + public static readonly string Studios = "MediaItem_Studios"; + public static readonly string Directors = "MediaItem_Directors"; + public static readonly string Genres = "MediaItem_Genres"; + + // episode/show + public static readonly string ShowTitle = "MediaItem_ShowTitle"; + public static readonly string ShowYear = "MediaItem_ShowYear"; + public static readonly string ShowContentRating = "MediaItem_ShowContentRating"; + public static readonly string ShowGenres = "MediaItem_ShowGenres"; + + // music video + public static readonly string Track = "MediaItem_Track"; + public static readonly string Album = "MediaItem_Album"; + public static readonly string Artist = "MediaItem_Artist"; + public static readonly string Artists = "MediaItem_Artists"; +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs index 126b85e4..789f5d6c 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs @@ -1,17 +1,146 @@ +using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Metadata; +using ErsatzTV.Infrastructure.Epg; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Infrastructure.Data.Repositories; -public class TemplateDataRepository(IDbContextFactory dbContextFactory) : ITemplateDataRepository +public class TemplateDataRepository(ILocalFileSystem localFileSystem, IDbContextFactory dbContextFactory) + : ITemplateDataRepository { - public async Task>> GetMusicVideoTemplateData( - Resolution resolution, - TimeSpan streamSeek, - int musicVideoId) + public async Task>> GetMediaItemTemplateData(MediaItem mediaItem) => + mediaItem switch + { + Movie => await GetMovieTemplateData(mediaItem.Id), + Episode => await GetEpisodeTemplateData(mediaItem.Id), + MusicVideo => await GetMusicVideoTemplateData(mediaItem.Id), + _ => Option>.None + }; + + public async Task>> GetEpgTemplateData(string channelNumber, DateTimeOffset time) + { + try + { + string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channelNumber}.xml"); + if (localFileSystem.FileExists(targetFile)) + { + await using var stream = File.OpenRead(targetFile); + var maybeEpgProgramme = EpgReader.FindProgrammeAt(stream, time); + foreach (var epgProgramme in maybeEpgProgramme) + { + return new Dictionary + { + [EpgTemplateDataKey.Title] = epgProgramme.Title?.Value, + [EpgTemplateDataKey.SubTitle] = epgProgramme.SubTitle?.Value, + [EpgTemplateDataKey.Description] = epgProgramme.Description?.Value, + [EpgTemplateDataKey.Rating] = epgProgramme.Rating?.Value, + [EpgTemplateDataKey.Categories] = (epgProgramme.Categories ?? []).Map(c => c.Value).ToArray(), + [EpgTemplateDataKey.Date] = epgProgramme.Date?.Value + }; + } + } + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + + return Option>.None; + } + + private async Task>> GetMovieTemplateData(int movieId) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); + + Option maybeMovie = await dbContext.Movies + .AsNoTracking() + .Include(m => m.MediaVersions) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Studios) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Directors) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Genres) + .SelectOneAsync(m => m.Id, m => m.Id == movieId); + + foreach (var movie in maybeMovie) + { + foreach (var metadata in movie.MovieMetadata.HeadOrNone()) + { + return new Dictionary + { + [MediaItemTemplateDataKey.Title] = metadata.Title, + [MediaItemTemplateDataKey.Plot] = metadata.Plot, + [MediaItemTemplateDataKey.ReleaseDate] = metadata.ReleaseDate, + [MediaItemTemplateDataKey.Studios] = (metadata.Studios ?? []).Map(s => s.Name).OrderBy(identity), + [MediaItemTemplateDataKey.Directors] = (metadata.Directors ?? []).Map(d => d.Name).OrderBy(identity), + [MediaItemTemplateDataKey.Genres] = (metadata.Genres ?? []).Map(g => g.Name).OrderBy(identity), + [MediaItemTemplateDataKey.Duration] = movie.GetHeadVersion().Duration + }; + } + } + + return Option>.None; + } + + private async Task>> GetEpisodeTemplateData(int episodeId) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); + + Option maybeEpisode = await dbContext.Episodes + .AsNoTracking() + .Include(e => e.MediaVersions) + .Include(e => e.Season) + .ThenInclude(s => s.Show) + .ThenInclude(s => s.ShowMetadata) + .Include(e => e.EpisodeMetadata) + .ThenInclude(em => em.Studios) + .Include(e => e.EpisodeMetadata) + .ThenInclude(em => em.Directors) + .Include(e => e.EpisodeMetadata) + .ThenInclude(em => em.Genres) + .SelectOneAsync(e => e.Id, e => e.Id == episodeId); + + var result = new Dictionary(); + + foreach (var episode in maybeEpisode) + { + foreach (var showMetadata in Optional(episode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten()) + { + result.Add(MediaItemTemplateDataKey.ShowTitle, showMetadata.Title); + result.Add(MediaItemTemplateDataKey.ShowYear, showMetadata.Year); + result.Add(MediaItemTemplateDataKey.ShowContentRating, showMetadata.ContentRating); + result.Add(MediaItemTemplateDataKey.ShowGenres, + (showMetadata.Genres ?? []).Map(s => s.Name).OrderBy(identity)); + } + + foreach (var metadata in episode.EpisodeMetadata.HeadOrNone()) + { + result.Add(MediaItemTemplateDataKey.Title, metadata.Title); + result.Add(MediaItemTemplateDataKey.Plot, metadata.Plot); + result.Add(MediaItemTemplateDataKey.ReleaseDate, metadata.ReleaseDate); + result.Add(MediaItemTemplateDataKey.Studios, + (metadata.Studios ?? []).Map(s => s.Name).OrderBy(identity)); + result.Add(MediaItemTemplateDataKey.Directors, + (metadata.Directors ?? []).Map(s => s.Name).OrderBy(identity)); + result.Add(MediaItemTemplateDataKey.Genres, + (metadata.Genres ?? []).Map(s => s.Name).OrderBy(identity)); + result.Add(MediaItemTemplateDataKey.Duration, episode.GetHeadVersion().Duration); + } + + return result; + } + + return Option>.None; + } + + private async Task>> GetMusicVideoTemplateData(int musicVideoId) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); @@ -26,6 +155,8 @@ public class TemplateDataRepository(IDbContextFactory dbContextFactor .ThenInclude(mvm => mvm.Studios) .Include(mv => mv.MusicVideoMetadata) .ThenInclude(mvm => mvm.Directors) + .Include(mv => mv.MusicVideoMetadata) + .ThenInclude(mvm => mvm.Genres) .SelectOneAsync(mv => mv.Id, mv => mv.Id == musicVideoId); foreach (var musicVideo in maybeMusicVideo) @@ -40,18 +171,17 @@ public class TemplateDataRepository(IDbContextFactory dbContextFactor return new Dictionary { - ["Resolution"] = resolution, - ["Title"] = metadata.Title, - ["Track"] = metadata.Track, - ["Album"] = metadata.Album, - ["Plot"] = metadata.Plot, - ["ReleaseDate"] = metadata.ReleaseDate, - ["Artists"] = (metadata.Artists ?? []).Map(a => a.Name).ToList(), - ["Artist"] = artist, - ["Studios"] = (metadata.Studios ?? []).Map(s => s.Name).ToList(), - ["Directors"] = (metadata.Directors ?? []).Map(s => s.Name).ToList(), - ["Duration"] = musicVideo.GetHeadVersion().Duration, - ["StreamSeek"] = streamSeek + [MediaItemTemplateDataKey.Title] = metadata.Title, + [MediaItemTemplateDataKey.Track] = metadata.Track, + [MediaItemTemplateDataKey.Album] = metadata.Album, + [MediaItemTemplateDataKey.Plot] = metadata.Plot, + [MediaItemTemplateDataKey.ReleaseDate] = metadata.ReleaseDate, + [MediaItemTemplateDataKey.Artists] = (metadata.Artists ?? []).Map(a => a.Name).OrderBy(identity), + [MediaItemTemplateDataKey.Artist] = artist, + [MediaItemTemplateDataKey.Studios] = (metadata.Studios ?? []).Map(s => s.Name).OrderBy(identity), + [MediaItemTemplateDataKey.Directors] = (metadata.Directors ?? []).Map(d => d.Name).OrderBy(identity), + [MediaItemTemplateDataKey.Genres] = (metadata.Genres ?? []).Map(g => g.Name).OrderBy(identity), + [MediaItemTemplateDataKey.Duration] = musicVideo.GetHeadVersion().Duration }; } } diff --git a/ErsatzTV.Infrastructure/Epg/EpgReader.cs b/ErsatzTV.Infrastructure/Epg/EpgReader.cs new file mode 100644 index 00000000..4a9484a6 --- /dev/null +++ b/ErsatzTV.Infrastructure/Epg/EpgReader.cs @@ -0,0 +1,51 @@ +using System.Globalization; +using System.Xml; +using System.Xml.Serialization; +using ErsatzTV.Infrastructure.Epg.Models; + +namespace ErsatzTV.Infrastructure.Epg; + +public static class EpgReader +{ + private const string XmlTvDateFormat = "yyyyMMddHHmmss zzz"; + + public static Option FindProgrammeAt(Stream xmlStream, DateTimeOffset targetTime) + { + var serializer = new XmlSerializer(typeof(EpgProgramme)); + + var settings = new XmlReaderSettings + { + ConformanceLevel = ConformanceLevel.Fragment + }; + + using var reader = XmlReader.Create(xmlStream, settings); + + while (reader.Read()) + { + if (reader.NodeType != XmlNodeType.Element || reader.Name != "programme") + { + continue; + } + + string startStr = reader.GetAttribute("start"); + string stopStr = reader.GetAttribute("stop"); + + if (startStr == null || stopStr == null) + { + continue; + } + + if (DateTimeOffset.TryParseExact(startStr, XmlTvDateFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out var start) && + DateTimeOffset.TryParseExact(stopStr, XmlTvDateFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out var stop)) + { + if (start <= targetTime && targetTime < stop) + { + using var subtreeReader = reader.ReadSubtree(); + return Optional(serializer.Deserialize(subtreeReader) as EpgProgramme); + } + } + } + + return Option.None; + } +} diff --git a/ErsatzTV.Infrastructure/Epg/Models/EpgCategory.cs b/ErsatzTV.Infrastructure/Epg/Models/EpgCategory.cs new file mode 100644 index 00000000..25ef5ab0 --- /dev/null +++ b/ErsatzTV.Infrastructure/Epg/Models/EpgCategory.cs @@ -0,0 +1,12 @@ +using System.Xml.Serialization; + +namespace ErsatzTV.Infrastructure.Epg.Models; + +public class EpgCategory +{ + [XmlAttribute("lang")] + public string Lang { get; set; } + + [XmlText] + public string Value { get; set; } +} diff --git a/ErsatzTV.Infrastructure/Epg/Models/EpgDate.cs b/ErsatzTV.Infrastructure/Epg/Models/EpgDate.cs new file mode 100644 index 00000000..1e2b50c2 --- /dev/null +++ b/ErsatzTV.Infrastructure/Epg/Models/EpgDate.cs @@ -0,0 +1,9 @@ +using System.Xml.Serialization; + +namespace ErsatzTV.Infrastructure.Epg.Models; + +public class EpgDate +{ + [XmlText] + public string Value { get; set; } +} diff --git a/ErsatzTV.Infrastructure/Epg/Models/EpgDescription.cs b/ErsatzTV.Infrastructure/Epg/Models/EpgDescription.cs new file mode 100644 index 00000000..c7e20647 --- /dev/null +++ b/ErsatzTV.Infrastructure/Epg/Models/EpgDescription.cs @@ -0,0 +1,12 @@ +using System.Xml.Serialization; + +namespace ErsatzTV.Infrastructure.Epg.Models; + +public class EpgDescription +{ + [XmlAttribute("lang")] + public string Lang { get; set; } + + [XmlText] + public string Value { get; set; } +} diff --git a/ErsatzTV.Infrastructure/Epg/Models/EpgEpisodeNum.cs b/ErsatzTV.Infrastructure/Epg/Models/EpgEpisodeNum.cs new file mode 100644 index 00000000..f50b6d86 --- /dev/null +++ b/ErsatzTV.Infrastructure/Epg/Models/EpgEpisodeNum.cs @@ -0,0 +1,12 @@ +using System.Xml.Serialization; + +namespace ErsatzTV.Infrastructure.Epg.Models; + +public class EpgEpisodeNum +{ + [XmlAttribute("system")] + public string System { get; set; } + + [XmlText] + public string Value { get; set; } +} diff --git a/ErsatzTV.Infrastructure/Epg/Models/EpgIcon.cs b/ErsatzTV.Infrastructure/Epg/Models/EpgIcon.cs new file mode 100644 index 00000000..1a71f3f9 --- /dev/null +++ b/ErsatzTV.Infrastructure/Epg/Models/EpgIcon.cs @@ -0,0 +1,9 @@ +using System.Xml.Serialization; + +namespace ErsatzTV.Infrastructure.Epg.Models; + +public class EpgIcon +{ + [XmlAttribute("src")] + public string Src { get; set; } +} diff --git a/ErsatzTV.Infrastructure/Epg/Models/EpgProgramme.cs b/ErsatzTV.Infrastructure/Epg/Models/EpgProgramme.cs new file mode 100644 index 00000000..8afd3b28 --- /dev/null +++ b/ErsatzTV.Infrastructure/Epg/Models/EpgProgramme.cs @@ -0,0 +1,43 @@ +using System.Xml.Serialization; + +namespace ErsatzTV.Infrastructure.Epg.Models; + +[XmlRoot("programme")] +public class EpgProgramme +{ + [XmlAttribute("start")] + public string Start { get; set; } + + [XmlAttribute("stop")] + public string Stop { get; set; } + + [XmlAttribute("channel")] + public string Channel { get; set; } + + [XmlElement("title")] + public EpgTitle Title { get; set; } + + [XmlElement("sub-title")] + public EpgTitle SubTitle { get; set; } + + [XmlElement("desc")] + public EpgDescription Description { get; set; } + + [XmlElement("category")] + public List Categories { get; set; } + + [XmlElement("icon")] + public EpgIcon Icon { get; set; } + + [XmlElement("episode-num")] + public List EpisodeNums { get; set; } + + [XmlElement("rating")] + public EpgRating Rating { get; set; } + + [XmlElement("previously-shown")] + public object PreviouslyShown { get; set; } // Use object for presence check + + [XmlElement("date")] + public EpgDate Date { get; set; } +} diff --git a/ErsatzTV.Infrastructure/Epg/Models/EpgRating.cs b/ErsatzTV.Infrastructure/Epg/Models/EpgRating.cs new file mode 100644 index 00000000..d666f802 --- /dev/null +++ b/ErsatzTV.Infrastructure/Epg/Models/EpgRating.cs @@ -0,0 +1,9 @@ +using System.Xml.Serialization; + +namespace ErsatzTV.Infrastructure.Epg.Models; + +public class EpgRating +{ + [XmlElement("value")] + public string Value { get; set; } +} diff --git a/ErsatzTV.Infrastructure/Epg/Models/EpgTitle.cs b/ErsatzTV.Infrastructure/Epg/Models/EpgTitle.cs new file mode 100644 index 00000000..1d9f90f6 --- /dev/null +++ b/ErsatzTV.Infrastructure/Epg/Models/EpgTitle.cs @@ -0,0 +1,12 @@ +using System.Xml.Serialization; + +namespace ErsatzTV.Infrastructure.Epg.Models; + +public class EpgTitle +{ + [XmlAttribute("lang")] + public string Lang { get; set; } + + [XmlText] + public string Value { get; set; } +} diff --git a/ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs b/ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs index ffc7b594..82e5360a 100644 --- a/ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs +++ b/ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs @@ -1,8 +1,8 @@ using System.IO.Pipelines; using ErsatzTV.Core; -using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Streaming; +using ErsatzTV.Core.Metadata; using Microsoft.Extensions.Logging; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; @@ -16,6 +16,39 @@ public class GraphicsEngine(ITemplateDataRepository templateDataRepository, ILog { GraphicsEngineFonts.LoadFonts(FileSystemLayout.FontsCacheFolder); + var templateVariables = new Dictionary(); + + // init text element variables once + if (context.Elements.OfType().Any()) + { + // common variables + templateVariables[MediaItemTemplateDataKey.Resolution] = context.FrameSize; + templateVariables[MediaItemTemplateDataKey.StreamSeek] = context.Seek; + + // media item variables + var maybeTemplateData = + await templateDataRepository.GetMediaItemTemplateData(context.MediaItem); + foreach (var templateData in maybeTemplateData) + { + foreach (var variable in templateData) + { + templateVariables.Add(variable.Key, variable.Value); + } + } + + // epg variables + var startTime = context.ContentStartTime + context.Seek; + var maybeEpgData = + await templateDataRepository.GetEpgTemplateData(context.ChannelNumber, startTime); + foreach (var templateData in maybeEpgData) + { + foreach (var variable in templateData) + { + templateVariables.Add(variable.Key, variable.Value); + } + } + } + var elements = new List(); foreach (var element in context.Elements) { @@ -33,28 +66,12 @@ public class GraphicsEngine(ITemplateDataRepository templateDataRepository, ILog elements.Add(new ImageElement(imageElementContext.ImageElement, logger)); break; case TextElementContext textElementContext: - var variables = new Dictionary(); + var variables = templateVariables.ToDictionary(); foreach (var variable in textElementContext.Variables) { variables.Add(variable.Key, variable.Value); } - if (context.MediaItem is MusicVideo musicVideo) - { - var maybeTemplateData = await templateDataRepository.GetMusicVideoTemplateData( - context.FrameSize, - context.Seek, - musicVideo.Id); - - foreach (var templateData in maybeTemplateData) - { - foreach (var variable in templateData) - { - variables.Add(variable.Key, variable.Value); - } - } - - } elements.Add(new TextElement(textElementContext.TextElement, variables, logger)); break; }