Browse Source

more guide templates; new song metadata library (#1603)

* refactor template processing

* use template for song xmltv entries

* use template for other video xmltv entries

* update changelog
pull/1604/head
Jason Dove 2 years ago committed by GitHub
parent
commit
a15854d0ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      CHANGELOG.md
  2. 593
      ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs
  3. 2
      ErsatzTV.Application/MediaCards/Mapper.cs
  4. 2
      ErsatzTV.Application/Playouts/Mapper.cs
  5. 6
      ErsatzTV.Core/Domain/Metadata/SongMetadata.cs
  6. 3
      ErsatzTV.Core/Domain/Metadata/SongTag.cs
  7. 20
      ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs
  8. 2
      ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs
  9. 17
      ErsatzTV.Core/Scheduling/ChronologicalMediaComparer.cs
  10. 4925
      ErsatzTV.Infrastructure.MySql/Migrations/20240209170144_Update_SongMetadataParity.Designer.cs
  11. 48
      ErsatzTV.Infrastructure.MySql/Migrations/20240209170144_Update_SongMetadataParity.cs
  12. 4925
      ErsatzTV.Infrastructure.MySql/Migrations/20240209170532_Reset_SongMetadataTagLib.Designer.cs
  13. 34
      ErsatzTV.Infrastructure.MySql/Migrations/20240209170532_Reset_SongMetadataTagLib.cs
  14. 6
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  15. 4923
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240209164734_Update_SongMetadataParity.Designer.cs
  16. 48
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240209164734_Update_SongMetadataParity.cs
  17. 4923
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240209170434_Reset_SongMetadataTagLib.Designer.cs
  18. 34
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240209170434_Reset_SongMetadataTagLib.cs
  19. 6
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  20. 11
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  21. 1
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  22. 131
      ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs
  23. 15
      ErsatzTV.Infrastructure/Metadata/MetadataSongTag.cs
  24. 6
      ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs
  25. 12
      ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs
  26. 2
      ErsatzTV.Infrastructure/Search/Models/ElasticSearchItem.cs
  27. 16
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  28. 88
      ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs
  29. 2
      ErsatzTV/ErsatzTV.csproj
  30. 52
      ErsatzTV/Resources/Templates/_otherVideo.sbntxt
  31. 49
      ErsatzTV/Resources/Templates/_song.sbntxt
  32. 12
      ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

3
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 `sub_language` and `sub_language_tag` fields to search index
- Add `/iptv` request logging in its own log category at debug level - Add `/iptv` request logging in its own log category at debug level
- Add channel guide (XMLTV) template system - Add channel guide (XMLTV) template system
- Templates should be copied from `_channel.sbntxt`, `_movie.sbntxt`, `_episode.sbntxt`, or `_musicVideo.sbntxt` which are located in the config subfolder `templates/channel-guide` - Templates should be copied from `_channel.sbntxt`, `_movie.sbntxt`, `_episode.sbntxt`, `_musicVideo.sbntxt`, `_song.sbntxt`, or `_otherVideo.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 - 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 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 - The templates use [scribian](https://github.com/scriban/scriban/tree/master/doc) template syntax
@ -35,6 +35,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Previously, search index updates would slowly process over minutes/hours after library scans completed - Previously, search index updates would slowly process over minutes/hours after library scans completed
- Search index updates should now complete at the same time as library scans - Search index updates should now complete at the same time as library scans
- Do not unnecessarily update the search index during media server library scans - Do not unnecessarily update the search index during media server library scans
- Use different library for reading song metadata that supports multiple tag entries
## [0.8.5-beta] - 2024-01-30 ## [0.8.5-beta] - 2024-01-30
### Added ### Added

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

@ -44,11 +44,14 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string movieTemplateFileName = GetMovieTemplateFileName(); string movieTemplateFileName = GetMovieTemplateFileName();
string episodeTemplateFileName = GetEpisodeTemplateFileName(); string episodeTemplateFileName = GetEpisodeTemplateFileName();
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName(); string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
if (movieTemplateFileName is null || episodeTemplateFileName is null || musicVideoTemplateFileName is null) string songTemplateFileName = GetSongTemplateFileName();
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
if (movieTemplateFileName is null || episodeTemplateFileName is null || musicVideoTemplateFileName is null ||
songTemplateFileName is null || otherVideoTemplateFileName is null)
{ {
return; return;
} }
var minifier = new XmlMinifier( var minifier = new XmlMinifier(
new XmlMinificationSettings new XmlMinificationSettings
{ {
@ -68,6 +71,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken); string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken);
var musicVideoTemplate = Template.Parse(musicVideoText, musicVideoTemplateFileName); var musicVideoTemplate = Template.Parse(musicVideoText, musicVideoTemplateFileName);
string songText = await File.ReadAllTextAsync(songTemplateFileName, cancellationToken);
var songTemplate = Template.Parse(songText, songTemplateFileName);
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<Playout> playouts = await dbContext.Playouts List<Playout> playouts = await dbContext.Playouts
@ -140,6 +149,14 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.ThenInclude(i => i.MediaItem) .ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata) .ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(vm => vm.Artwork) .ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Studios)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
List<PlayoutItem> sorted = []; List<PlayoutItem> sorted = [];
@ -217,243 +234,353 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string title = GetTitle(displayItem); string title = GetTitle(displayItem);
string subtitle = GetSubtitle(displayItem); string subtitle = GetSubtitle(displayItem);
string description = GetDescription(displayItem);
Option<ContentRating> contentRating = GetContentRating(displayItem);
if (displayItem.MediaItem is Movie templateMovie) Option<string> maybeTemplateOutput = displayItem.MediaItem switch
{ {
foreach (MovieMetadata metadata in templateMovie.MovieMetadata.HeadOrNone()) Movie templateMovie => await ProcessMovieTemplate(
{ request,
metadata.Genres ??= []; templateMovie,
metadata.Guids ??= []; start,
stop,
string poster = Optional(metadata.Artwork).Flatten() hasCustomTitle,
.Filter(a => a.ArtworkKind == ArtworkKind.Poster) displayItem,
.HeadOrNone() title,
.Match(a => GetArtworkUrl(a, ArtworkKind.Poster), () => string.Empty); templateContext,
movieTemplate),
var data = new Episode templateEpisode => await ProcessEpisodeTemplate(
{ request,
ProgrammeStart = start, templateEpisode,
ProgrammeStop = stop, start,
ChannelNumber = request.ChannelNumber, stop,
HasCustomTitle = hasCustomTitle, hasCustomTitle,
CustomTitle = displayItem.CustomTitle, displayItem,
MovieTitle = title, title,
MovieHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot), subtitle,
MoviePlot = metadata.Plot, templateContext,
MovieHasYear = metadata.Year.HasValue, episodeTemplate),
MovieYear = metadata.Year, MusicVideo templateMusicVideo => await ProcessMusicVideoTemplate(
MovieGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n), request,
MovieHasArtwork = !string.IsNullOrWhiteSpace(poster), templateMusicVideo,
MovieArtworkUrl = poster, start,
MovieHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating), stop,
MovieContentRating = metadata.ContentRating, hasCustomTitle,
MovieGuids = metadata.Guids.Map(g => g.Guid) displayItem,
}; title,
subtitle,
var scriptObject = new ScriptObject(); templateContext,
scriptObject.Import(data); musicVideoTemplate),
templateContext.PushGlobal(scriptObject); Song templateSong => await ProcessSongTemplate(
request,
string result = await movieTemplate.RenderAsync(templateContext); templateSong,
start,
MarkupMinificationResult minified = minifier.Minify(result); stop,
await xml.WriteRawAsync(minified.MinifiedContent); hasCustomTitle,
} displayItem,
title,
subtitle,
templateContext,
songTemplate),
OtherVideo templateOtherVideo => await ProcessOtherVideoTemplate(
request,
templateOtherVideo,
start,
stop,
hasCustomTitle,
displayItem,
title,
templateContext,
otherVideoTemplate),
_ => Option<string>.None
};
i++; foreach (string templateOutput in maybeTemplateOutput)
continue; {
MarkupMinificationResult minified = minifier.Minify(templateOutput);
await xml.WriteRawAsync(minified.MinifiedContent);
} }
if (displayItem.MediaItem is Episode templateEpisode) i++;
{ }
foreach (EpisodeMetadata metadata in templateEpisode.EpisodeMetadata.HeadOrNone())
{
metadata.Genres ??= [];
metadata.Guids ??= [];
foreach (ShowMetadata showMetadata in Optional( await xml.FlushAsync();
templateEpisode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten())
{
showMetadata.Genres ??= [];
showMetadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(metadata); string tempFile = Path.GetTempFileName();
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
var data = new string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
{ File.Move(tempFile, targetFile, true);
ProgrammeStart = start, }
ProgrammeStop = stop,
ChannelNumber = request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
CustomTitle = displayItem.CustomTitle,
ShowTitle = title,
EpisodeHasTitle = !string.IsNullOrWhiteSpace(subtitle),
EpisodeTitle = subtitle,
EpisodeHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
EpisodePlot = metadata.Plot,
ShowHasYear = showMetadata.Year.HasValue,
ShowYear = showMetadata.Year,
ShowGenres = showMetadata.Genres.Map(g => g.Name).OrderBy(n => n),
EpisodeHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
EpisodeArtworkUrl = artworkPath,
SeasonNumber = templateEpisode.Season?.SeasonNumber ?? 0,
EpisodeNumber = metadata.EpisodeNumber,
ShowHasContentRating = !string.IsNullOrWhiteSpace(showMetadata.ContentRating),
ShowContentRating = showMetadata.ContentRating,
ShowGuids = showMetadata.Guids.Map(g => g.Guid),
EpisodeGuids = metadata.Guids.Map(g => g.Guid)
};
var scriptObject = new ScriptObject();
scriptObject.Import(data);
templateContext.PushGlobal(scriptObject);
string result = await episodeTemplate.RenderAsync(templateContext);
MarkupMinificationResult minified = minifier.Minify(result);
await xml.WriteRawAsync(minified.MinifiedContent);
}
}
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<ArtistMetadata> maybeMetadata = private static async Task<Option<string>> ProcessMovieTemplate(
Optional(templateMusicVideo.Artist?.ArtistMetadata.HeadOrNone()).Flatten(); RefreshChannelData request,
Movie templateMovie,
string start,
string stop,
bool hasCustomTitle,
PlayoutItem displayItem,
string title,
XmlTemplateContext templateContext,
Template movieTemplate)
{
foreach (MovieMetadata metadata in templateMovie.MovieMetadata.HeadOrNone())
{
metadata.Genres ??= [];
metadata.Guids ??= [];
string poster = Optional(metadata.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
.HeadOrNone()
.Match(a => GetArtworkUrl(a, ArtworkKind.Poster), () => string.Empty);
var data = new var data = new
{ {
ProgrammeStart = start, ProgrammeStart = start,
ProgrammeStop = stop, ProgrammeStop = stop,
ChannelNumber = request.ChannelNumber, ChannelNumber = request.ChannelNumber,
HasCustomTitle = hasCustomTitle, HasCustomTitle = hasCustomTitle,
CustomTitle = displayItem.CustomTitle, CustomTitle = displayItem.CustomTitle,
ArtistTitle = title, MovieTitle = title,
MusicVideoTitle = subtitle, MovieHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
MusicVideoHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot), MoviePlot = metadata.Plot,
MusicVideoPlot = metadata.Plot, MovieHasYear = metadata.Year.HasValue,
MusicVideoHasYear = metadata.Year.HasValue, MovieYear = metadata.Year,
MusicVideoYear = metadata.Year, MovieGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
MusicVideoGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n), MovieHasArtwork = !string.IsNullOrWhiteSpace(poster),
ArtistGenres = maybeMetadata.SelectMany(m => m.Genres.Map(g => g.Name)).OrderBy(n => n), MovieArtworkUrl = poster,
MusicVideoHasArtwork = !string.IsNullOrWhiteSpace(artworkPath), MovieHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
MusicVideoArtworkUrl = artworkPath, MovieContentRating = metadata.ContentRating,
MusicVideoHasTrack = metadata.Track.HasValue, MovieGuids = metadata.Guids.Map(g => g.Guid)
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++; var scriptObject = new ScriptObject();
continue; scriptObject.Import(data);
} templateContext.PushGlobal(scriptObject);
await xml.WriteStartElementAsync(null, "programme", null); return await movieTemplate.RenderAsync(templateContext);
await xml.WriteAttributeStringAsync(null, "start", null, start); }
await xml.WriteAttributeStringAsync(null, "stop", null, stop);
await xml.WriteAttributeStringAsync(null, "channel", null, $"{request.ChannelNumber}.etv");
await xml.WriteStartElementAsync(null, "title", null); return Option<string>.None;
await xml.WriteAttributeStringAsync(null, "lang", null, "en"); }
await xml.WriteStringAsync(title);
await xml.WriteEndElementAsync(); // title private static async Task<Option<string>> ProcessEpisodeTemplate(
RefreshChannelData request,
Episode templateEpisode,
string start,
string stop,
bool hasCustomTitle,
PlayoutItem displayItem,
string title,
string subtitle,
XmlTemplateContext templateContext,
Template episodeTemplate)
{
foreach (EpisodeMetadata metadata in templateEpisode.EpisodeMetadata.HeadOrNone())
{
metadata.Genres ??= [];
metadata.Guids ??= [];
if (!string.IsNullOrWhiteSpace(subtitle)) foreach (ShowMetadata showMetadata in Optional(
templateEpisode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten())
{ {
await xml.WriteStartElementAsync(null, "sub-title", null); showMetadata.Genres ??= [];
await xml.WriteAttributeStringAsync(null, "lang", null, "en"); showMetadata.Guids ??= [];
await xml.WriteStringAsync(subtitle);
await xml.WriteEndElementAsync(); // subtitle
}
if (!isSameCustomShow) string artworkPath = GetPrioritizedArtworkPath(metadata);
{
if (!string.IsNullOrWhiteSpace(description)) var data = new
{ {
await xml.WriteStartElementAsync(null, "desc", null); ProgrammeStart = start,
await xml.WriteAttributeStringAsync(null, "lang", null, "en"); ProgrammeStop = stop,
await xml.WriteStringAsync(description); ChannelNumber = request.ChannelNumber,
await xml.WriteEndElementAsync(); // desc HasCustomTitle = hasCustomTitle,
} CustomTitle = displayItem.CustomTitle,
ShowTitle = title,
EpisodeHasTitle = !string.IsNullOrWhiteSpace(subtitle),
EpisodeTitle = subtitle,
EpisodeHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
EpisodePlot = metadata.Plot,
ShowHasYear = showMetadata.Year.HasValue,
ShowYear = showMetadata.Year,
ShowGenres = showMetadata.Genres.Map(g => g.Name).OrderBy(n => n),
EpisodeHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
EpisodeArtworkUrl = artworkPath,
SeasonNumber = templateEpisode.Season?.SeasonNumber ?? 0,
EpisodeNumber = metadata.EpisodeNumber,
ShowHasContentRating = !string.IsNullOrWhiteSpace(showMetadata.ContentRating),
ShowContentRating = showMetadata.ContentRating,
ShowGuids = showMetadata.Guids.Map(g => g.Guid),
EpisodeGuids = metadata.Guids.Map(g => g.Guid)
};
var scriptObject = new ScriptObject();
scriptObject.Import(data);
templateContext.PushGlobal(scriptObject);
return await episodeTemplate.RenderAsync(templateContext);
} }
}
if (!hasCustomTitle && displayItem.MediaItem is Song song) return Option<string>.None;
{ }
await xml.WriteStartElementAsync(null, "category", null);
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
await xml.WriteStringAsync("Music");
await xml.WriteEndElementAsync(); // category
foreach (SongMetadata metadata in song.SongMetadata.HeadOrNone()) private static async Task<Option<string>> ProcessMusicVideoTemplate(
{ RefreshChannelData request,
string artworkPath = GetPrioritizedArtworkPath(metadata); MusicVideo templateMusicVideo,
if (!string.IsNullOrWhiteSpace(artworkPath)) string start,
{ string stop,
await xml.WriteStartElementAsync(null, "icon", null); bool hasCustomTitle,
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath); PlayoutItem displayItem,
await xml.WriteEndElementAsync(); // icon string title,
} string subtitle,
} XmlTemplateContext templateContext,
} Template musicVideoTemplate)
{
foreach (MusicVideoMetadata metadata in templateMusicVideo.MusicVideoMetadata.HeadOrNone())
{
metadata.Genres ??= [];
metadata.Artists ??= [];
metadata.Studios ??= [];
metadata.Directors ??= [];
string artworkPath = GetPrioritizedArtworkPath(metadata);
await xml.WriteStartElementAsync(null, "previously-shown", null); Option<ArtistMetadata> maybeMetadata =
await xml.WriteEndElementAsync(); // previously-shown Optional(templateMusicVideo.Artist?.ArtistMetadata.HeadOrNone()).Flatten();
foreach (ContentRating rating in contentRating) var data = new
{ {
await xml.WriteStartElementAsync(null, "rating", null); ProgrammeStart = start,
foreach (string system in rating.System) ProgrammeStop = stop,
{ ChannelNumber = request.ChannelNumber,
await xml.WriteAttributeStringAsync(null, "system", null, system); 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)
};
await xml.WriteStartElementAsync(null, "value", null); var scriptObject = new ScriptObject();
await xml.WriteStringAsync(rating.Value); scriptObject.Import(data);
await xml.WriteEndElementAsync(); // value templateContext.PushGlobal(scriptObject);
await xml.WriteEndElementAsync(); // rating
}
await xml.WriteEndElementAsync(); // programme return await musicVideoTemplate.RenderAsync(templateContext);
}
i++; return Option<string>.None;
}
private async Task<Option<string>> ProcessSongTemplate(
RefreshChannelData request,
Song templateSong,
string start,
string stop,
bool hasCustomTitle,
PlayoutItem displayItem,
string title,
string subtitle,
XmlTemplateContext templateContext,
Template songTemplate)
{
foreach (SongMetadata metadata in templateSong.SongMetadata.HeadOrNone())
{
metadata.Genres ??= [];
metadata.Studios ??= [];
string artworkPath = GetPrioritizedArtworkPath(metadata);
var data = new
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelNumber = request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
CustomTitle = displayItem.CustomTitle,
SongTitle = subtitle,
SongArtists = metadata.Artists,
SongAlbumArtists = metadata.AlbumArtists,
SongHasYear = metadata.Year.HasValue,
SongYear = metadata.Year,
SongGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
SongHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
SongArtworkUrl = artworkPath,
SongHasTrack = !string.IsNullOrWhiteSpace(metadata.Track),
SongTrack = metadata.Track,
SongHasComment = !string.IsNullOrWhiteSpace(metadata.Comment),
SongComment = metadata.Comment,
SongHasAlbum = !string.IsNullOrWhiteSpace(metadata.Album),
SongAlbum = metadata.Album,
SongHasReleaseDate = metadata.ReleaseDate.HasValue,
SongReleaseDate = metadata.ReleaseDate,
SongStudios = metadata.Studios.Map(s => s.Name),
};
var scriptObject = new ScriptObject();
scriptObject.Import(data);
templateContext.PushGlobal(scriptObject);
return await songTemplate.RenderAsync(templateContext);
} }
await xml.FlushAsync(); return Option<string>.None;
}
private static async Task<Option<string>> ProcessOtherVideoTemplate(
RefreshChannelData request,
OtherVideo templateOtherVideo,
string start,
string stop,
bool hasCustomTitle,
PlayoutItem displayItem,
string title,
XmlTemplateContext templateContext,
Template otherVideoTemplate)
{
foreach (OtherVideoMetadata metadata in templateOtherVideo.OtherVideoMetadata.HeadOrNone())
{
metadata.Genres ??= [];
metadata.Guids ??= [];
var data = new
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelNumber = request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
CustomTitle = displayItem.CustomTitle,
OtherVideoTitle = title,
OtherVideoHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
OtherVideoPlot = metadata.Plot,
OtherVideoHasYear = metadata.Year.HasValue,
OtherVideoYear = metadata.Year,
OtherVideoGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
OtherVideoHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
OtherVideoContentRating = metadata.ContentRating
};
string tempFile = Path.GetTempFileName(); var scriptObject = new ScriptObject();
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken); scriptObject.Import(data);
templateContext.PushGlobal(scriptObject);
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml"); return await otherVideoTemplate.RenderAsync(templateContext);
File.Move(tempFile, targetFile, true); }
return Option<string>.None;
} }
private string GetMovieTemplateFileName() private string GetMovieTemplateFileName()
@ -524,6 +651,52 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
return templateFileName; return templateFileName;
} }
private string GetSongTemplateFileName()
{
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "song.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_song.sbntxt");
}
// fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName))
{
_logger.LogError(
"Unable to generate song XMLTV fragment without template file {File}; please restart ErsatzTV",
templateFileName);
return null;
}
return templateFileName;
}
private string GetOtherVideoTemplateFileName()
{
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "otherVideo.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_otherVideo.sbntxt");
}
// fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName))
{
_logger.LogError(
"Unable to generate other video XMLTV fragment without template file {File}; please restart ErsatzTV",
templateFileName);
return null;
}
return templateFileName;
}
private static string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind) private static string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind)
{ {
@ -578,8 +751,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.IfNone("[unknown artist]"), .IfNone("[unknown artist]"),
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty) OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown video]"), .IfNone("[unknown video]"),
Song s => s.SongMetadata.HeadOrNone().Map(sm => sm.Artist ?? string.Empty)
.IfNone("[unknown artist]"),
_ => "[unknown]" _ => "[unknown]"
}; };
} }

2
ErsatzTV.Application/MediaCards/Mapper.cs

@ -131,7 +131,7 @@ internal static class Mapper
return new SongCardViewModel( return new SongCardViewModel(
songMetadata.SongId, songMetadata.SongId,
songMetadata.Title, songMetadata.Title,
songMetadata.Artist + album, string.Join(", ", songMetadata.Artists) + album,
songMetadata.SortTitle, songMetadata.SortTitle,
GetThumbnail(songMetadata, None, None), GetThumbnail(songMetadata, None, None),
songMetadata.Song.State); songMetadata.Song.State);

2
ErsatzTV.Application/Playouts/Mapper.cs

@ -66,7 +66,7 @@ internal static class Mapper
.IfNone("[unknown video]"); .IfNone("[unknown video]");
case Song s: case Song s:
string songArtist = s.SongMetadata.HeadOrNone() string songArtist = s.SongMetadata.HeadOrNone()
.Map(sm => string.IsNullOrWhiteSpace(sm.Artist) ? string.Empty : $"{sm.Artist} - ") .Map(sm => $"{string.Join(", ", sm.Artists)} - ")
.IfNone(string.Empty); .IfNone(string.Empty);
return s.SongMetadata.HeadOrNone() return s.SongMetadata.HeadOrNone()
.Map(sm => $"{songArtist}{sm.Title ?? string.Empty}") .Map(sm => $"{songArtist}{sm.Title ?? string.Empty}")

6
ErsatzTV.Core/Domain/Metadata/SongMetadata.cs

@ -3,10 +3,10 @@
public class SongMetadata : Metadata public class SongMetadata : Metadata
{ {
public string Album { get; set; } public string Album { get; set; }
public string Artist { get; set; } public IList<string> Artists { get; set; }
public string AlbumArtist { get; set; } public IList<string> AlbumArtists { get; set; }
public string Date { get; set; }
public string Track { get; set; } public string Track { get; set; }
public string Comment { get; set; }
public int SongId { get; set; } public int SongId { get; set; }
public Song Song { get; set; } public Song Song { get; set; }
} }

3
ErsatzTV.Core/Domain/Metadata/SongTag.cs

@ -0,0 +1,3 @@
namespace ErsatzTV.Core.Domain;
public record SongTag(string Tag, string Value);

20
ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs

@ -88,16 +88,18 @@ public class SongVideoGenerator : ISongVideoGenerator
sb.Append(CultureInfo.InvariantCulture, $"{{\\fs{largeFontSize}}}{metadata.Title}"); sb.Append(CultureInfo.InvariantCulture, $"{{\\fs{largeFontSize}}}{metadata.Title}");
} }
if (!string.IsNullOrWhiteSpace(metadata.Artist)) if (metadata.Artists.Count > 0)
{ {
sb.Append(CultureInfo.InvariantCulture, $"\\N{{\\fs{fontSize}}}{metadata.Artist}"); var allArtists = string.Join(", ", metadata.Artists);
sb.Append(CultureInfo.InvariantCulture, $"\\N{{\\fs{fontSize}}}{allArtists}");
} }
} }
else else
{ {
if (!string.IsNullOrWhiteSpace(metadata.Artist)) if (metadata.Artists.Count > 0)
{ {
sb.Append(metadata.Artist); var allArtists = string.Join(", ", metadata.Artists);
sb.Append(allArtists);
} }
if (!string.IsNullOrWhiteSpace(metadata.Title)) if (!string.IsNullOrWhiteSpace(metadata.Title))
@ -105,12 +107,12 @@ public class SongVideoGenerator : ISongVideoGenerator
sb.Append(CultureInfo.InvariantCulture, $"\\N\"{metadata.Title}\""); sb.Append(CultureInfo.InvariantCulture, $"\\N\"{metadata.Title}\"");
} }
if (!string.IsNullOrWhiteSpace(metadata.AlbumArtist) && !string.Equals( if (metadata.AlbumArtists.Count > 0)
metadata.Artist,
metadata.AlbumArtist,
StringComparison.Ordinal))
{ {
sb.Append(CultureInfo.InvariantCulture, $"\\N{metadata.AlbumArtist}"); var allAlbumArtists = string.Join(
", ",
metadata.AlbumArtists.Filter(aa => !metadata.Artists.Contains(aa)));
sb.Append(CultureInfo.InvariantCulture, $"\\N{allAlbumArtists}");
} }
if (!string.IsNullOrWhiteSpace(metadata.Album)) if (!string.IsNullOrWhiteSpace(metadata.Album))

2
ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs

@ -9,5 +9,5 @@ public interface ILocalStatisticsProvider
Task<Either<BaseError, bool>> RefreshStatistics(string ffmpegPath, string ffprobePath, MediaItem mediaItem); Task<Either<BaseError, bool>> RefreshStatistics(string ffmpegPath, string ffprobePath, MediaItem mediaItem);
Task<Either<BaseError, Dictionary<string, string>>> GetSongTags(string ffprobePath, MediaItem mediaItem); Either<BaseError, List<SongTag>> GetSongTags(string ffprobePath, MediaItem mediaItem);
} }

17
ErsatzTV.Core/Scheduling/ChronologicalMediaComparer.cs

@ -45,23 +45,6 @@ internal class ChronologicalMediaComparer : IComparer<MediaItem>
return date1.CompareTo(date2); return date1.CompareTo(date2);
} }
string songDate1 = x switch
{
Song s => s.SongMetadata.HeadOrNone().Match(sm => sm.Date ?? string.Empty, () => string.Empty),
_ => string.Empty
};
string songDate2 = y switch
{
Song s => s.SongMetadata.HeadOrNone().Match(sm => sm.Date ?? string.Empty, () => string.Empty),
_ => string.Empty
};
if (songDate1 != songDate2)
{
return string.Compare(songDate1, songDate2, StringComparison.Ordinal);
}
int season1 = x switch int season1 = x switch
{ {
Episode e => e.Season?.SeasonNumber ?? int.MaxValue, Episode e => e.Season?.SeasonNumber ?? int.MaxValue,

4925
ErsatzTV.Infrastructure.MySql/Migrations/20240209170144_Update_SongMetadataParity.Designer.cs generated

File diff suppressed because it is too large Load Diff

48
ErsatzTV.Infrastructure.MySql/Migrations/20240209170144_Update_SongMetadataParity.cs

@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Update_SongMetadataParity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Date",
table: "SongMetadata",
newName: "Comment");
migrationBuilder.RenameColumn(
name: "Artist",
table: "SongMetadata",
newName: "Artists");
migrationBuilder.RenameColumn(
name: "AlbumArtist",
table: "SongMetadata",
newName: "AlbumArtists");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Comment",
table: "SongMetadata",
newName: "Date");
migrationBuilder.RenameColumn(
name: "Artists",
table: "SongMetadata",
newName: "Artist");
migrationBuilder.RenameColumn(
name: "AlbumArtists",
table: "SongMetadata",
newName: "AlbumArtist");
}
}
}

4925
ErsatzTV.Infrastructure.MySql/Migrations/20240209170532_Reset_SongMetadataTagLib.Designer.cs generated

File diff suppressed because it is too large Load Diff

34
ErsatzTV.Infrastructure.MySql/Migrations/20240209170532_Reset_SongMetadataTagLib.cs

@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Reset_SongMetadataTagLib : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00' WHERE Id IN
(SELECT LP.Id FROM LibraryPath LP INNER JOIN Library L on L.Id = LP.LibraryId WHERE MediaKind = 5)");
migrationBuilder.Sql(
@"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE MediaKind = 5");
migrationBuilder.Sql(
@"UPDATE Artwork SET DateUpdated = '0001-01-01 00:00:00' WHERE SongMetadataId IS NOT NULL");
migrationBuilder.Sql(
@"UPDATE LibraryFolder SET Etag = NULL WHERE Id IN
(SELECT LF.Id FROM LibraryFolder LF INNER JOIN LibraryPath LP on LF.LibraryPathId = LP.Id INNER JOIN Library L on LP.LibraryId = L.Id WHERE MediaKind = 5)");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

6
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -2190,13 +2190,13 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("Album") b.Property<string>("Album")
.HasColumnType("longtext"); .HasColumnType("longtext");
b.Property<string>("AlbumArtist") b.Property<string>("AlbumArtists")
.HasColumnType("longtext"); .HasColumnType("longtext");
b.Property<string>("Artist") b.Property<string>("Artists")
.HasColumnType("longtext"); .HasColumnType("longtext");
b.Property<string>("Date") b.Property<string>("Comment")
.HasColumnType("longtext"); .HasColumnType("longtext");
b.Property<DateTime>("DateAdded") b.Property<DateTime>("DateAdded")

4923
ErsatzTV.Infrastructure.Sqlite/Migrations/20240209164734_Update_SongMetadataParity.Designer.cs generated

File diff suppressed because it is too large Load Diff

48
ErsatzTV.Infrastructure.Sqlite/Migrations/20240209164734_Update_SongMetadataParity.cs

@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Update_SongMetadataParity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Date",
table: "SongMetadata",
newName: "Comment");
migrationBuilder.RenameColumn(
name: "Artist",
table: "SongMetadata",
newName: "Artists");
migrationBuilder.RenameColumn(
name: "AlbumArtist",
table: "SongMetadata",
newName: "AlbumArtists");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Comment",
table: "SongMetadata",
newName: "Date");
migrationBuilder.RenameColumn(
name: "Artists",
table: "SongMetadata",
newName: "Artist");
migrationBuilder.RenameColumn(
name: "AlbumArtists",
table: "SongMetadata",
newName: "AlbumArtist");
}
}
}

4923
ErsatzTV.Infrastructure.Sqlite/Migrations/20240209170434_Reset_SongMetadataTagLib.Designer.cs generated

File diff suppressed because it is too large Load Diff

34
ErsatzTV.Infrastructure.Sqlite/Migrations/20240209170434_Reset_SongMetadataTagLib.cs

@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Reset_SongMetadataTagLib : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00' WHERE Id IN
(SELECT LP.Id FROM LibraryPath LP INNER JOIN Library L on L.Id = LP.LibraryId WHERE MediaKind = 5)");
migrationBuilder.Sql(
@"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE MediaKind = 5");
migrationBuilder.Sql(
@"UPDATE Artwork SET DateUpdated = '0001-01-01 00:00:00' WHERE SongMetadataId IS NOT NULL");
migrationBuilder.Sql(
@"UPDATE LibraryFolder SET Etag = NULL WHERE Id IN
(SELECT LF.Id FROM LibraryFolder LF INNER JOIN LibraryPath LP on LF.LibraryPathId = LP.Id INNER JOIN Library L on LP.LibraryId = L.Id WHERE MediaKind = 5)");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

6
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -2188,13 +2188,13 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("Album") b.Property<string>("Album")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("AlbumArtist") b.Property<string>("AlbumArtists")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Artist") b.Property<string>("Artists")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Date") b.Property<string>("Comment")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<DateTime>("DateAdded") b.Property<DateTime>("DateAdded")

11
ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs

@ -397,7 +397,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository
var allArtists = items.OfType<Song>() var allArtists = items.OfType<Song>()
.SelectMany(s => s.SongMetadata) .SelectMany(s => s.SongMetadata)
.Map(sm => sm.AlbumArtist) .Map(sm => sm.AlbumArtists.HeadOrNone().Match(aa => aa, string.Empty))
.Distinct() .Distinct()
.ToList(); .ToList();
@ -409,11 +409,16 @@ public class MediaCollectionRepository : IMediaCollectionRepository
var songArtistCollections = new Dictionary<int, List<MediaItem>>(); var songArtistCollections = new Dictionary<int, List<MediaItem>>();
foreach (Song song in items.OfType<Song>()) foreach (Song song in items.OfType<Song>())
{ {
int key = allArtists.IndexOf(song.SongMetadata.HeadOrNone().Match(sm => sm.AlbumArtist, string.Empty)); string firstArtist = song.SongMetadata
.SelectMany(sm => sm.AlbumArtists)
.HeadOrNone()
.Match(aa => aa, string.Empty);
int key = allArtists.IndexOf(firstArtist);
List<MediaItem> list = songArtistCollections.TryGetValue(key, out List<MediaItem> collection) List<MediaItem> list = songArtistCollections.TryGetValue(key, out List<MediaItem> collection)
? collection ? collection
: new List<MediaItem>(); : [];
if (list.All(i => i.Id != song.Id)) if (list.All(i => i.Id != song.Id))
{ {

1
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -32,6 +32,7 @@
<PackageReference Include="Refit.Newtonsoft.Json" Version="7.0.0" /> <PackageReference Include="Refit.Newtonsoft.Json" Version="7.0.0" />
<PackageReference Include="Refit.Xml" Version="7.0.0" /> <PackageReference Include="Refit.Xml" Version="7.0.0" />
<PackageReference Include="Scriban.Signed" Version="5.9.1" /> <PackageReference Include="Scriban.Signed" Version="5.9.1" />
<PackageReference Include="TagLibSharp" Version="2.3.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

131
ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs

@ -11,8 +11,10 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using Lucene.Net.Util.Fst;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Newtonsoft.Json; using Newtonsoft.Json;
using File = TagLib.File;
namespace ErsatzTV.Infrastructure.Metadata; namespace ErsatzTV.Infrastructure.Metadata;
@ -61,111 +63,44 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
} }
} }
public async Task<Either<BaseError, Dictionary<string, string>>> GetSongTags( public Either<BaseError, List<SongTag>> GetSongTags(string ffprobePath, MediaItem mediaItem)
string ffprobePath,
MediaItem mediaItem)
{ {
try try
{ {
string mediaItemPath = mediaItem.GetHeadVersion().MediaFiles.Head().Path;
Either<BaseError, FFprobe> maybeProbe = await GetProbeOutput(ffprobePath, mediaItemPath);
foreach (BaseError error in maybeProbe.LeftToSeq())
{
return error;
}
Option<FFprobeTags> maybeFormatTags = maybeProbe.RightToSeq() string mediaItemPath = mediaItem.GetHeadVersion().MediaFiles.Head().Path;
.Map(p => p?.format?.tags ?? FFprobeTags.Empty) var song = File.Create(mediaItemPath);
.HeadOrNone();
Option<FFprobeTags> maybeAudioTags = maybeProbe.RightToSeq() var result = new List<SongTag>();
.Bind(p => p.streams.Filter(s => s.codec_type == "audio").HeadOrNone())
.Map(s => s.tags ?? FFprobeTags.Empty)
.HeadOrNone();
foreach (FFprobeTags formatTags in maybeFormatTags) // album
foreach (FFprobeTags audioTags in maybeAudioTags) if (!string.IsNullOrWhiteSpace(song.Tag.Album))
{ {
var result = new Dictionary<string, string>(); result.Add(new SongTag(MetadataSongTag.Album, song.Tag.Album));
}
// album
if (!string.IsNullOrWhiteSpace(formatTags.album)) // album artist(s)
{ IEnumerable<string> albumArtists = song.Tag.AlbumArtists.Filter(a => !string.IsNullOrWhiteSpace(a));
result.Add(MetadataSongTag.Album, formatTags.album); result.AddRange(albumArtists.Map(albumArtist => new SongTag(MetadataSongTag.AlbumArtist, albumArtist)));
}
else if (!string.IsNullOrWhiteSpace(audioTags.album)) // artist(s)
{ IEnumerable<string> artists = song.Tag.Performers.Filter(p => !string.IsNullOrWhiteSpace(p));
result.Add(MetadataSongTag.Album, audioTags.album); result.AddRange(artists.Map(artist => new SongTag(MetadataSongTag.Artist, artist)));
}
// genre(s)
// album artist IEnumerable<string> genres = song.Tag.Genres.Filter(g => !string.IsNullOrWhiteSpace(g));
if (!string.IsNullOrWhiteSpace(formatTags.albumArtist)) result.AddRange(genres.Map(genre => new SongTag(MetadataSongTag.Genre, genre)));
{
result.Add(MetadataSongTag.AlbumArtist, formatTags.albumArtist); // title
} if (!string.IsNullOrWhiteSpace(song.Tag.Title))
else if (!string.IsNullOrWhiteSpace(audioTags.albumArtist)) {
{ result.Add(new SongTag(MetadataSongTag.Title, song.Tag.Title));
result.Add(MetadataSongTag.AlbumArtist, audioTags.albumArtist);
}
// artist
if (!string.IsNullOrWhiteSpace(formatTags.artist))
{
result.Add(MetadataSongTag.Artist, formatTags.artist);
// if no album artist is present, use the track artist
result.TryAdd(MetadataSongTag.AlbumArtist, formatTags.artist);
}
else if (!string.IsNullOrWhiteSpace(audioTags.artist))
{
result.Add(MetadataSongTag.Artist, audioTags.artist);
// if no album artist is present, use the track artist
result.TryAdd(MetadataSongTag.AlbumArtist, audioTags.artist);
}
// date
if (!string.IsNullOrWhiteSpace(formatTags.date))
{
result.Add(MetadataSongTag.Date, formatTags.date);
}
else if (!string.IsNullOrWhiteSpace(audioTags.date))
{
result.Add(MetadataSongTag.Date, audioTags.date);
}
// genre
if (!string.IsNullOrWhiteSpace(formatTags.genre))
{
result.Add(MetadataSongTag.Genre, formatTags.genre);
}
else if (!string.IsNullOrWhiteSpace(audioTags.genre))
{
result.Add(MetadataSongTag.Genre, audioTags.genre);
}
// title
if (!string.IsNullOrWhiteSpace(formatTags.title))
{
result.Add(MetadataSongTag.Title, formatTags.title);
}
else if (!string.IsNullOrWhiteSpace(audioTags.title))
{
result.Add(MetadataSongTag.Title, audioTags.title);
}
// track
if (!string.IsNullOrWhiteSpace(formatTags.track))
{
result.Add(MetadataSongTag.Track, formatTags.track);
}
else if (!string.IsNullOrWhiteSpace(audioTags.track))
{
result.Add(MetadataSongTag.Track, audioTags.track);
}
return result;
} }
// track
result.Add(new SongTag(MetadataSongTag.Track, song.Tag.Track.ToString(CultureInfo.InvariantCulture)));
return result;
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -173,8 +108,6 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
_client.Notify(ex); _client.Notify(ex);
return BaseError.New(ex.Message); return BaseError.New(ex.Message);
} }
return BaseError.New("BUG - this should never happen");
} }
private async Task<Either<BaseError, bool>> RefreshStatistics( private async Task<Either<BaseError, bool>> RefreshStatistics(

15
ErsatzTV.Infrastructure/Metadata/MetadataSongTag.cs

@ -2,11 +2,12 @@
public static class MetadataSongTag public static class MetadataSongTag
{ {
public static readonly string Album = "album"; public const string Album = "album";
public static readonly string Artist = "artist"; public const string Artist = "artist";
public static readonly string AlbumArtist = "albumartist"; public const string AlbumArtist = "albumartist";
public static readonly string Date = "date"; public const string Date = "date";
public static readonly string Genre = "genre"; public const string Genre = "genre";
public static readonly string Title = "title"; public const string Title = "title";
public static readonly string Track = "track"; public const string Track = "track";
public const string Comment = "comment";
} }

6
ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs

@ -46,7 +46,7 @@ public class ElasticSearchIndex : ISearchIndex
return exists.IsValidResponse; return exists.IsValidResponse;
} }
public int Version => 40; public int Version => 41;
public async Task<bool> Initialize( public async Task<bool> Initialize(
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
@ -703,8 +703,8 @@ public class ElasticSearchIndex : ISearchIndex
SubLanguageTag = GetSubLanguageTags(song.MediaVersions), SubLanguageTag = GetSubLanguageTags(song.MediaVersions),
AddedDate = GetAddedDate(metadata.DateAdded), AddedDate = GetAddedDate(metadata.DateAdded),
Album = metadata.Album ?? string.Empty, Album = metadata.Album ?? string.Empty,
Artist = !string.IsNullOrWhiteSpace(metadata.Artist) ? new List<string> { metadata.Artist } : null, Artist = metadata.Artists.ToList(),
AlbumArtist = metadata.AlbumArtist, AlbumArtist = metadata.AlbumArtists.ToList(),
Genre = metadata.Genres.Map(g => g.Name).ToList(), Genre = metadata.Genres.Map(g => g.Name).ToList(),
Tag = metadata.Tags.Map(t => t.Name).ToList() Tag = metadata.Tags.Map(t => t.Name).ToList()
}; };

12
ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs

@ -111,7 +111,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
return Task.FromResult(directoryExists && fileExists); return Task.FromResult(directoryExists && fileExists);
} }
public int Version => 40; public int Version => 41;
public async Task<bool> Initialize( public async Task<bool> Initialize(
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
@ -1238,14 +1238,14 @@ public sealed class LuceneSearchIndex : ISearchIndex
doc.Add(new TextField(AlbumField, metadata.Album, Field.Store.NO)); doc.Add(new TextField(AlbumField, metadata.Album, Field.Store.NO));
} }
if (!string.IsNullOrWhiteSpace(metadata.Artist)) foreach (string artist in metadata.Artists)
{ {
doc.Add(new TextField(ArtistField, metadata.Artist, Field.Store.NO)); doc.Add(new TextField(ArtistField, artist, Field.Store.NO));
} }
if (!string.IsNullOrWhiteSpace(metadata.AlbumArtist)) foreach (string albumArtist in metadata.AlbumArtists)
{ {
doc.Add(new TextField(AlbumArtistField, metadata.AlbumArtist, Field.Store.NO)); doc.Add(new TextField(AlbumArtistField, albumArtist, Field.Store.NO));
} }
foreach (Tag tag in metadata.Tags) foreach (Tag tag in metadata.Tags)

2
ErsatzTV.Infrastructure/Search/Models/ElasticSearchItem.cs

@ -71,7 +71,7 @@ public class ElasticSearchItem : MinimalElasticSearchItem
public string Album { get; set; } public string Album { get; set; }
[JsonPropertyName(LuceneSearchIndex.AlbumArtistField)] [JsonPropertyName(LuceneSearchIndex.AlbumArtistField)]
public string AlbumArtist { get; set; } public List<string> AlbumArtist { get; set; }
[JsonPropertyName(LuceneSearchIndex.PlotField)] [JsonPropertyName(LuceneSearchIndex.PlotField)]
public string Plot { get; set; } public string Plot { get; set; }

16
ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -25,8 +25,6 @@ using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Images; using ErsatzTV.Infrastructure.Images;
using ErsatzTV.Infrastructure.Metadata; using ErsatzTV.Infrastructure.Metadata;
using ErsatzTV.Infrastructure.Runtime; using ErsatzTV.Infrastructure.Runtime;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using ErsatzTV.Scanner.Core.Metadata;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -290,16 +288,16 @@ public class TranscodingTests
var song = new Song var song = new Song
{ {
SongMetadata = new List<SongMetadata> SongMetadata =
{ [
new() new SongMetadata
{ {
Title = "Song Title", Title = "Song Title",
Artist = "Song Artist", Artists = ["Song Artist"],
Artwork = new List<Artwork>() Artwork = []
} }
}, ],
MediaVersions = new List<MediaVersion> { songVersion } MediaVersions = [songVersion]
}; };
(string videoPath, MediaVersion videoVersion) = await songVideoGenerator.GenerateSongVideo( (string videoPath, MediaVersion videoVersion) = await songVideoGenerator.GenerateSongVideo(

88
ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs

@ -198,7 +198,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
public async Task<bool> RefreshTagMetadata(Song song, string ffprobePath) public async Task<bool> RefreshTagMetadata(Song song, string ffprobePath)
{ {
Option<SongMetadata> maybeMetadata = await LoadSongMetadata(song, ffprobePath); Option<SongMetadata> maybeMetadata = LoadSongMetadata(song, ffprobePath);
foreach (SongMetadata metadata in maybeMetadata) foreach (SongMetadata metadata in maybeMetadata)
{ {
return await ApplyMetadataUpdate(song, metadata); return await ApplyMetadataUpdate(song, metadata);
@ -296,19 +296,17 @@ public class LocalMetadataProvider : ILocalMetadataProvider
} }
} }
private async Task<Option<SongMetadata>> LoadSongMetadata(Song song, string ffprobePath) private Option<SongMetadata> LoadSongMetadata(Song song, string ffprobePath)
{ {
string path = song.GetHeadVersion().MediaFiles.Head().Path; string path = song.GetHeadVersion().MediaFiles.Head().Path;
try try
{ {
Either<BaseError, Dictionary<string, string>> maybeTags = var maybeTags = _localStatisticsProvider.GetSongTags(ffprobePath, song);
await _localStatisticsProvider.GetSongTags(ffprobePath, song);
foreach (Dictionary<string, string> tags in maybeTags.RightToSeq()) foreach (List<SongTag> tags in maybeTags.RightToSeq())
{ {
Option<SongMetadata> maybeFallbackMetadata = Option<SongMetadata> maybeFallbackMetadata = _fallbackMetadataProvider.GetFallbackMetadata(song);
_fallbackMetadataProvider.GetFallbackMetadata(song);
var result = new SongMetadata var result = new SongMetadata
{ {
@ -316,46 +314,42 @@ public class LocalMetadataProvider : ILocalMetadataProvider
DateAdded = DateTime.UtcNow, DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(path), DateUpdated = File.GetLastWriteTimeUtc(path),
Artwork = new List<Artwork>(), Artists = [],
Actors = new List<Actor>(), AlbumArtists = [],
Genres = new List<Genre>(),
Studios = new List<Studio>(),
Tags = new List<Tag>()
};
if (tags.TryGetValue(MetadataSongTag.Album, out string? album))
{
result.Album = album;
}
if (tags.TryGetValue(MetadataSongTag.Artist, out string? artist))
{
result.Artist = artist;
}
if (tags.TryGetValue(MetadataSongTag.AlbumArtist, out string? albumArtist))
{
result.AlbumArtist = albumArtist;
}
if (tags.TryGetValue(MetadataSongTag.Date, out string? date)) Artwork = [],
{ Actors = [],
result.Date = date; Genres = [],
} Studios = [],
Tags = []
if (tags.TryGetValue(MetadataSongTag.Genre, out string? genre)) };
{
result.Genres.AddRange(SplitGenres(genre).Map(n => new Genre { Name = n }));
}
if (tags.TryGetValue(MetadataSongTag.Title, out string? title))
{
result.Title = title;
}
if (tags.TryGetValue(MetadataSongTag.Track, out string? track)) foreach (SongTag tag in tags)
{ {
result.Track = track; switch (tag.Tag)
{
case MetadataSongTag.Album:
result.Album = tag.Value;
break;
case MetadataSongTag.Artist:
result.Artists.Add(tag.Value);
break;
case MetadataSongTag.AlbumArtist:
result.AlbumArtists.Add(tag.Value);
break;
case MetadataSongTag.Genre:
result.Genres.Add(new Genre { Name = tag.Value });
break;
case MetadataSongTag.Title:
result.Title = tag.Value;
break;
case MetadataSongTag.Track:
result.Track = tag.Value;
break;
case MetadataSongTag.Comment:
result.Comment = tag.Value;
break;
}
} }
foreach (SongMetadata fallbackMetadata in maybeFallbackMetadata) foreach (SongMetadata fallbackMetadata in maybeFallbackMetadata)
@ -981,11 +975,11 @@ public class LocalMetadataProvider : ILocalMetadataProvider
foreach (SongMetadata existing in maybeMetadata) foreach (SongMetadata existing in maybeMetadata)
{ {
existing.Title = metadata.Title; existing.Title = metadata.Title;
existing.Artist = metadata.Artist; existing.Artists = metadata.Artists;
existing.AlbumArtist = metadata.AlbumArtist; existing.AlbumArtists = metadata.AlbumArtists;
existing.Album = metadata.Album; existing.Album = metadata.Album;
existing.Date = metadata.Date;
existing.Track = metadata.Track; existing.Track = metadata.Track;
existing.Comment = metadata.Comment;
if (existing.DateAdded == SystemTime.MinValueUtc) if (existing.DateAdded == SystemTime.MinValueUtc)
{ {

2
ErsatzTV/ErsatzTV.csproj

@ -77,6 +77,8 @@
<EmbeddedResource Include="Resources\Templates\_episode.sbntxt" /> <EmbeddedResource Include="Resources\Templates\_episode.sbntxt" />
<EmbeddedResource Include="Resources\Templates\_movie.sbntxt" /> <EmbeddedResource Include="Resources\Templates\_movie.sbntxt" />
<EmbeddedResource Include="Resources\Templates\_musicVideo.sbntxt" /> <EmbeddedResource Include="Resources\Templates\_musicVideo.sbntxt" />
<EmbeddedResource Include="Resources\Templates\_otherVideo.sbntxt" />
<EmbeddedResource Include="Resources\Templates\_song.sbntxt" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

52
ErsatzTV/Resources/Templates/_otherVideo.sbntxt

@ -0,0 +1,52 @@
{{ ##
Available values:
- programme_start
- programme_stop
- channel_number
- has_custom_title
- custom_title
- other_video_title
- other_video_has_plot
- other_video_plot
- other_video_has_year
- other_video_year
- other_video_genres
- other_video_has_content_rating
- other_video_content_rating
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">{{ other_video_title }}</title>
{{ if other_video_has_plot }}
<desc lang="en">{{ other_video_plot }}</desc>
{{ end }}
{{ if other_video_has_year }}
<date>{{ other_video_year }}</date>
{{ end }}
{{ for genre in other_video_genres }}
<category lang="en">{{ genre }}</category>
{{ end }}
{{ if movie_has_artwork }}
<icon src="{{ movie_artwork_url }}" />
{{ end }}
{{ end }}
{{ if other_video_has_content_rating }}
{{ for rating in other_video_content_rating | string.split '/' }}
{{ if rating | string.starts_with 'us:' }}
<rating system="MPAA">
{{ else }}
<rating>
{{ end }}
<value>{{ rating | string.replace 'us:' '' }}</value>
</rating>
{{ end }}
{{ end }}
<previously-shown />
</programme>

49
ErsatzTV/Resources/Templates/_song.sbntxt

@ -0,0 +1,49 @@
{{ ##
Available values:
- programme_start
- programme_stop
- channel_number
- has_custom_title
- custom_title
- song_title
- song_artists
- song_album_artists
- song_has_year
- song_year
- song_genres
- song_has_artwork
- song_artwork_url
- song_has_track
- song_track
- song_has_comment
- song_comment
- song_has_album
- song_album
- song_has_release_date
- song_release_date
- song_studios
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">{{ song_artists | array.join ', ' }}</title>
<sub-title lang="en">{{ song_title }}</sub-title>
{{ if song_has_year }}
<date>{{ song_year }}</date>
{{ end }}
<category lang="en">Music</category>
{{ for genre in song_genres }}
<category lang="en">{{ genre }}</category>
{{ end }}
{{ if song_has_artwork }}
<icon src="{{ song_artwork_url }}" />
{{ end }}
{{ end }}
<previously-shown />
</programme>

12
ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

@ -67,6 +67,18 @@ public class ResourceExtractorService : BackgroundService
FileSystemLayout.ChannelGuideTemplatesFolder, FileSystemLayout.ChannelGuideTemplatesFolder,
stoppingToken); stoppingToken);
await ExtractTemplateResource(
assembly,
"_song.sbntxt",
FileSystemLayout.ChannelGuideTemplatesFolder,
stoppingToken);
await ExtractTemplateResource(
assembly,
"_otherVideo.sbntxt",
FileSystemLayout.ChannelGuideTemplatesFolder,
stoppingToken);
await ExtractScriptResource( await ExtractScriptResource(
assembly, assembly,
"_threePartEpisodes.js", "_threePartEpisodes.js",

Loading…
Cancel
Save