From 1510c56e696b4221e5fe229afca1817735413a9d Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Mon, 5 Feb 2024 19:56:19 -0600 Subject: [PATCH] generate music video xmltv fragment from template (#1598) * generate music video xmltv fragment from template * load all music video data --- CHANGELOG.md | 2 +- .../Commands/RefreshChannelDataHandler.cs | 228 +++++++----------- ErsatzTV/ErsatzTV.csproj | 1 + .../Resources/Templates/_musicVideo.sbntxt | 57 +++++ .../RunOnce/ResourceExtractorService.cs | 6 + 5 files changed, 157 insertions(+), 137 deletions(-) create mode 100644 ErsatzTV/Resources/Templates/_musicVideo.sbntxt diff --git a/CHANGELOG.md b/CHANGELOG.md index b29e67ae..14941ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add `sub_language` and `sub_language_tag` fields to search index - Add `/iptv` request logging to streaming log category at debug level - Add channel guide (XMLTV) template system - - Templates should be copied from `_channel.sbntxt`, `_movie.sbntxt`, or `_episode.sbntxt` which are located in the config subfolder `templates/channel-guide` + - Templates should be copied from `_channel.sbntxt`, `_movie.sbntxt`, `_episode.sbntxt`, or `_musicVideo.sbntxt` which are located in the config subfolder `templates/channel-guide` - Copy the file, remove the leading underscore from the name, and only make edits to the copied file - The default templates will be extracted and overwritten every time ErsatzTV is started - The templates use [scribian](https://github.com/scriban/scriban/tree/master/doc) template syntax diff --git a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs index a58d42c3..2e372341 100644 --- a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs @@ -43,7 +43,8 @@ public class RefreshChannelDataHandler : IRequestHandler string movieTemplateFileName = GetMovieTemplateFileName(); string episodeTemplateFileName = GetEpisodeTemplateFileName(); - if (movieTemplateFileName is null || episodeTemplateFileName is null) + string musicVideoTemplateFileName = GetMusicVideoTemplateFileName(); + if (movieTemplateFileName is null || episodeTemplateFileName is null || musicVideoTemplateFileName is null) { return; } @@ -64,6 +65,9 @@ public class RefreshChannelDataHandler : IRequestHandler string episodeText = await File.ReadAllTextAsync(episodeTemplateFileName, cancellationToken); var episodeTemplate = Template.Parse(episodeText, episodeTemplateFileName); + string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken); + var musicVideoTemplate = Template.Parse(musicVideoText, musicVideoTemplateFileName); + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); List playouts = await dbContext.Playouts @@ -113,6 +117,14 @@ public class RefreshChannelDataHandler : IRequestHandler .ThenInclude(mvm => mvm.Genres) .Include(p => p.Items) .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Studios) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Directors) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) .ThenInclude(i => (i as MusicVideo).Artist) .ThenInclude(a => a.ArtistMetadata) .ThenInclude(am => am.Genres) @@ -304,6 +316,62 @@ public class RefreshChannelDataHandler : IRequestHandler i++; continue; } + + if (displayItem.MediaItem is MusicVideo templateMusicVideo) + { + foreach (MusicVideoMetadata metadata in templateMusicVideo.MusicVideoMetadata.HeadOrNone()) + { + metadata.Genres ??= []; + metadata.Artists ??= []; + metadata.Studios ??= []; + metadata.Directors ??= []; + + string artworkPath = GetPrioritizedArtworkPath(metadata); + + Option maybeMetadata = + Optional(templateMusicVideo.Artist?.ArtistMetadata.HeadOrNone()).Flatten(); + + var data = new + { + ProgrammeStart = start, + ProgrammeStop = stop, + ChannelNumber = request.ChannelNumber, + HasCustomTitle = hasCustomTitle, + CustomTitle = displayItem.CustomTitle, + ArtistTitle = title, + MusicVideoTitle = subtitle, + MusicVideoHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot), + MusicVideoPlot = metadata.Plot, + MusicVideoHasYear = metadata.Year.HasValue, + MusicVideoYear = metadata.Year, + MusicVideoGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n), + ArtistGenres = maybeMetadata.SelectMany(m => m.Genres.Map(g => g.Name)).OrderBy(n => n), + MusicVideoHasArtwork = !string.IsNullOrWhiteSpace(artworkPath), + MusicVideoArtworkUrl = artworkPath, + MusicVideoHasTrack = metadata.Track.HasValue, + MusicVideoTrack = metadata.Track, + MusicVideoHasAlbum = !string.IsNullOrWhiteSpace(metadata.Album), + MusicVideoAlbum = metadata.Album, + MusicVideoHasReleaseDate = metadata.ReleaseDate.HasValue, + MusicVideoReleaseDate = metadata.ReleaseDate, + MusicVideoAllArtists = metadata.Artists.Map(a => a.Name), + MusicVideoStudios = metadata.Studios.Map(s => s.Name), + MusicVideoDirectors = metadata.Directors.Map(d => d.Name) + }; + + var scriptObject = new ScriptObject(); + scriptObject.Import(data); + templateContext.PushGlobal(scriptObject); + + string result = await musicVideoTemplate.RenderAsync(templateContext); + + MarkupMinificationResult minified = minifier.Minify(result); + await xml.WriteRawAsync(minified.MinifiedContent); + } + + i++; + continue; + } await xml.WriteStartElementAsync(null, "programme", null); await xml.WriteAttributeStringAsync(null, "start", null, start); @@ -334,93 +402,6 @@ public class RefreshChannelDataHandler : IRequestHandler } } - if (!hasCustomTitle && displayItem.MediaItem is Movie movie) - { - foreach (MovieMetadata metadata in movie.MovieMetadata.HeadOrNone()) - { - if (metadata.Year.HasValue) - { - await xml.WriteStartElementAsync(null, "date", null); - await xml.WriteStringAsync(metadata.Year.Value.ToString(CultureInfo.InvariantCulture)); - await xml.WriteEndElementAsync(); // date - } - - await xml.WriteStartElementAsync(null, "category", null); - await xml.WriteAttributeStringAsync(null, "lang", null, "en"); - await xml.WriteStringAsync("Movie"); - await xml.WriteEndElementAsync(); // category - - foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name)) - { - await xml.WriteStartElementAsync(null, "category", null); - await xml.WriteAttributeStringAsync(null, "lang", null, "en"); - await xml.WriteStringAsync(genre.Name); - await xml.WriteEndElementAsync(); // category - } - - string poster = Optional(metadata.Artwork).Flatten() - .Filter(a => a.ArtworkKind == ArtworkKind.Poster) - .HeadOrNone() - .Match(a => GetArtworkUrl(a, ArtworkKind.Poster), () => string.Empty); - - if (!string.IsNullOrWhiteSpace(poster)) - { - await xml.WriteStartElementAsync(null, "icon", null); - await xml.WriteAttributeStringAsync(null, "src", null, poster); - await xml.WriteEndElementAsync(); // icon - } - } - } - - if (!hasCustomTitle && displayItem.MediaItem is MusicVideo musicVideo) - { - foreach (MusicVideoMetadata metadata in musicVideo.MusicVideoMetadata.HeadOrNone()) - { - if (metadata.Year.HasValue) - { - await xml.WriteStartElementAsync(null, "date", null); - await xml.WriteStringAsync(metadata.Year.Value.ToString(CultureInfo.InvariantCulture)); - await xml.WriteEndElementAsync(); // date - } - - await xml.WriteStartElementAsync(null, "category", null); - await xml.WriteAttributeStringAsync(null, "lang", null, "en"); - await xml.WriteStringAsync("Music"); - await xml.WriteEndElementAsync(); // category - - // music video genres - foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name)) - { - await xml.WriteStartElementAsync(null, "category", null); - await xml.WriteAttributeStringAsync(null, "lang", null, "en"); - await xml.WriteStringAsync(genre.Name); - await xml.WriteEndElementAsync(); // category - } - - // artist genres - Option maybeMetadata = - Optional(musicVideo.Artist?.ArtistMetadata.HeadOrNone()).Flatten(); - foreach (ArtistMetadata artistMetadata in maybeMetadata) - { - foreach (Genre genre in Optional(artistMetadata.Genres).Flatten().OrderBy(g => g.Name)) - { - await xml.WriteStartElementAsync(null, "category", null); - await xml.WriteAttributeStringAsync(null, "lang", null, "en"); - await xml.WriteStringAsync(genre.Name); - await xml.WriteEndElementAsync(); // category - } - } - - string artworkPath = GetPrioritizedArtworkPath(metadata); - if (!string.IsNullOrWhiteSpace(artworkPath)) - { - await xml.WriteStartElementAsync(null, "icon", null); - await xml.WriteAttributeStringAsync(null, "src", null, artworkPath); - await xml.WriteEndElementAsync(); // icon - } - } - } - if (!hasCustomTitle && displayItem.MediaItem is Song song) { await xml.WriteStartElementAsync(null, "category", null); @@ -440,54 +421,6 @@ public class RefreshChannelDataHandler : IRequestHandler } } - if (displayItem.MediaItem is Episode episode && (!hasCustomTitle || isSameCustomShow)) - { - Option maybeMetadata = - Optional(episode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten(); - foreach (ShowMetadata metadata in maybeMetadata) - { - await xml.WriteStartElementAsync(null, "category", null); - await xml.WriteAttributeStringAsync(null, "lang", null, "en"); - await xml.WriteStringAsync("Series"); - await xml.WriteEndElementAsync(); // category - - foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name)) - { - await xml.WriteStartElementAsync(null, "category", null); - await xml.WriteAttributeStringAsync(null, "lang", null, "en"); - await xml.WriteStringAsync(genre.Name); - await xml.WriteEndElementAsync(); // category - } - - string artworkPath = GetPrioritizedArtworkPath(metadata); - if (!string.IsNullOrWhiteSpace(artworkPath)) - { - await xml.WriteStartElementAsync(null, "icon", null); - await xml.WriteAttributeStringAsync(null, "src", null, artworkPath); - await xml.WriteEndElementAsync(); // icon - } - } - - if (!isSameCustomShow) - { - int s = await Optional(episode.Season?.SeasonNumber).IfNoneAsync(-1); - // TODO: multi-episode? - int e = episode.EpisodeMetadata.HeadOrNone().Match(em => em.EpisodeNumber, -1); - if (s >= 0 && e > 0) - { - await xml.WriteStartElementAsync(null, "episode-num", null); - await xml.WriteAttributeStringAsync(null, "system", null, "onscreen"); - await xml.WriteStringAsync($"S{s:00}E{e:00}"); - await xml.WriteEndElementAsync(); // episode-num - - await xml.WriteStartElementAsync(null, "episode-num", null); - await xml.WriteAttributeStringAsync(null, "system", null, "xmltv_ns"); - await xml.WriteStringAsync($"{s - 1}.{e - 1}.0/1"); - await xml.WriteEndElementAsync(); // episode-num - } - } - } - await xml.WriteStartElementAsync(null, "previously-shown", null); await xml.WriteEndElementAsync(); // previously-shown @@ -564,6 +497,29 @@ public class RefreshChannelDataHandler : IRequestHandler return templateFileName; } + + private string GetMusicVideoTemplateFileName() + { + string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "musicVideo.sbntxt"); + + // fall back to default template + if (!_localFileSystem.FileExists(templateFileName)) + { + templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_musicVideo.sbntxt"); + } + + // fail if file doesn't exist + if (!_localFileSystem.FileExists(templateFileName)) + { + _logger.LogError( + "Unable to generate music video XMLTV fragment without template file {File}; please restart ErsatzTV", + templateFileName); + + return null; + } + + return templateFileName; + } private static string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind) { diff --git a/ErsatzTV/ErsatzTV.csproj b/ErsatzTV/ErsatzTV.csproj index 42e51c8d..cf74ae95 100644 --- a/ErsatzTV/ErsatzTV.csproj +++ b/ErsatzTV/ErsatzTV.csproj @@ -76,6 +76,7 @@ + diff --git a/ErsatzTV/Resources/Templates/_musicVideo.sbntxt b/ErsatzTV/Resources/Templates/_musicVideo.sbntxt new file mode 100644 index 00000000..735c9e24 --- /dev/null +++ b/ErsatzTV/Resources/Templates/_musicVideo.sbntxt @@ -0,0 +1,57 @@ +{{ ## + +Available values: + - programme_start + - programme_stop + - channel_number + - has_custom_title + - custom_title + - artist_title + - music_video_title + - music_video_has_plot + - music_video_plot + - music_video_has_year + - music_video_year + - music_video_genres + - artist_genres + - music_video_has_artwork + - music_video_artwork_url + - music_video_has_track + - music_video_track + - music_video_has_album + - music_video_album + - music_video_has_release_date + - music_video_release_date + - music_video_all_artists + - music_video_studios + - music_video_directors + +The resulting XML will be minified by ErsatzTV - so feel free to keep things nicely formatted here. + +## }} + + + {{ if has_custom_title }} + {{ custom_title }} + {{ else }} + {{ artist_title }} + {{ music_video_title }} + {{ if music_video_has_plot }} + {{ music_video_plot }} + {{ end }} + {{ if music_video_has_year }} + {{ music_video_year }} + {{ end }} + Music + {{ for genre in music_video_genres }} + {{ genre }} + {{ end }} + {{ for genre in artist_genres }} + {{ genre }} + {{ end }} + {{ if music_video_has_artwork }} + + {{ end }} + {{ end }} + + diff --git a/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs b/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs index 7fe1b110..a61d92ad 100644 --- a/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs +++ b/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs @@ -61,6 +61,12 @@ public class ResourceExtractorService : BackgroundService FileSystemLayout.ChannelGuideTemplatesFolder, stoppingToken); + await ExtractTemplateResource( + assembly, + "_musicVideo.sbntxt", + FileSystemLayout.ChannelGuideTemplatesFolder, + stoppingToken); + await ExtractScriptResource( assembly, "_threePartEpisodes.js",