Browse Source

generate music video xmltv fragment from template (#1598)

* generate music video xmltv fragment from template

* load all music video data
pull/1599/head
Jason Dove 1 year ago committed by GitHub
parent
commit
1510c56e69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 228
      ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs
  3. 1
      ErsatzTV/ErsatzTV.csproj
  4. 57
      ErsatzTV/Resources/Templates/_musicVideo.sbntxt
  5. 6
      ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

2
CHANGELOG.md

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

228
ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs

@ -43,7 +43,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -43,7 +43,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
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<RefreshChannelData> @@ -64,6 +65,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
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<Playout> playouts = await dbContext.Playouts
@ -113,6 +117,14 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -113,6 +117,14 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.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)
@ -305,6 +317,62 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -305,6 +317,62 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
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<ArtistMetadata> 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);
await xml.WriteAttributeStringAsync(null, "stop", null, stop);
@ -334,93 +402,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -334,93 +402,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
}
}
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<ArtistMetadata> 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<RefreshChannelData> @@ -440,54 +421,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
}
}
if (displayItem.MediaItem is Episode episode && (!hasCustomTitle || isSameCustomShow))
{
Option<ShowMetadata> 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
@ -565,6 +498,29 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -565,6 +498,29 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
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)
{
string artworkPath = artwork.Path;

1
ErsatzTV/ErsatzTV.csproj

@ -76,6 +76,7 @@ @@ -76,6 +76,7 @@
<EmbeddedResource Include="Resources\Templates\_channel.sbntxt" />
<EmbeddedResource Include="Resources\Templates\_episode.sbntxt" />
<EmbeddedResource Include="Resources\Templates\_movie.sbntxt" />
<EmbeddedResource Include="Resources\Templates\_musicVideo.sbntxt" />
</ItemGroup>
<ItemGroup>

57
ErsatzTV/Resources/Templates/_musicVideo.sbntxt

@ -0,0 +1,57 @@ @@ -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.
## }}
<programme start="{{ programme_start }}" stop="{{ programme_stop }}" channel="{{ channel_number }}.etv">
{{ if has_custom_title }}
<title lang="en">{{ custom_title }}</title>
{{ else }}
<title lang="en">{{ artist_title }}</title>
<sub-title lang="en">{{ music_video_title }}</sub-title>
{{ if music_video_has_plot }}
<desc lang="en">{{ music_video_plot }}</desc>
{{ end }}
{{ if music_video_has_year }}
<date>{{ music_video_year }}</date>
{{ end }}
<category lang="en">Music</category>
{{ for genre in music_video_genres }}
<category lang="en">{{ genre }}</category>
{{ end }}
{{ for genre in artist_genres }}
<category lang="en">{{ genre }}</category>
{{ end }}
{{ if music_video_has_artwork }}
<icon src="{{ music_video_artwork_url }}" />
{{ end }}
{{ end }}
<previously-shown />
</programme>

6
ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

@ -61,6 +61,12 @@ public class ResourceExtractorService : BackgroundService @@ -61,6 +61,12 @@ public class ResourceExtractorService : BackgroundService
FileSystemLayout.ChannelGuideTemplatesFolder,
stoppingToken);
await ExtractTemplateResource(
assembly,
"_musicVideo.sbntxt",
FileSystemLayout.ChannelGuideTemplatesFolder,
stoppingToken);
await ExtractScriptResource(
assembly,
"_threePartEpisodes.js",

Loading…
Cancel
Save