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
This commit is contained in:
+2
-1
@@ -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
|
||||||
|
|||||||
@@ -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,232 +234,70 @@ 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),
|
||||||
|
Episode templateEpisode => await ProcessEpisodeTemplate(
|
||||||
|
request,
|
||||||
|
templateEpisode,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
hasCustomTitle,
|
||||||
|
displayItem,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
templateContext,
|
||||||
|
episodeTemplate),
|
||||||
|
MusicVideo templateMusicVideo => await ProcessMusicVideoTemplate(
|
||||||
|
request,
|
||||||
|
templateMusicVideo,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
hasCustomTitle,
|
||||||
|
displayItem,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
templateContext,
|
||||||
|
musicVideoTemplate),
|
||||||
|
Song templateSong => await ProcessSongTemplate(
|
||||||
|
request,
|
||||||
|
templateSong,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
hasCustomTitle,
|
||||||
|
displayItem,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
templateContext,
|
||||||
|
songTemplate),
|
||||||
|
OtherVideo templateOtherVideo => await ProcessOtherVideoTemplate(
|
||||||
|
request,
|
||||||
|
templateOtherVideo,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
hasCustomTitle,
|
||||||
|
displayItem,
|
||||||
|
title,
|
||||||
|
templateContext,
|
||||||
|
otherVideoTemplate),
|
||||||
|
_ => Option<string>.None
|
||||||
|
};
|
||||||
|
|
||||||
var data = new
|
foreach (string templateOutput in maybeTemplateOutput)
|
||||||
{
|
|
||||||
ProgrammeStart = start,
|
|
||||||
ProgrammeStop = stop,
|
|
||||||
ChannelNumber = request.ChannelNumber,
|
|
||||||
HasCustomTitle = hasCustomTitle,
|
|
||||||
CustomTitle = displayItem.CustomTitle,
|
|
||||||
MovieTitle = title,
|
|
||||||
MovieHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
|
|
||||||
MoviePlot = metadata.Plot,
|
|
||||||
MovieHasYear = metadata.Year.HasValue,
|
|
||||||
MovieYear = metadata.Year,
|
|
||||||
MovieGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
|
|
||||||
MovieHasArtwork = !string.IsNullOrWhiteSpace(poster),
|
|
||||||
MovieArtworkUrl = poster,
|
|
||||||
MovieHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
|
|
||||||
MovieContentRating = metadata.ContentRating,
|
|
||||||
MovieGuids = metadata.Guids.Map(g => g.Guid)
|
|
||||||
};
|
|
||||||
|
|
||||||
var scriptObject = new ScriptObject();
|
|
||||||
scriptObject.Import(data);
|
|
||||||
templateContext.PushGlobal(scriptObject);
|
|
||||||
|
|
||||||
string result = await movieTemplate.RenderAsync(templateContext);
|
|
||||||
|
|
||||||
MarkupMinificationResult minified = minifier.Minify(result);
|
|
||||||
await xml.WriteRawAsync(minified.MinifiedContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (displayItem.MediaItem is Episode templateEpisode)
|
|
||||||
{
|
{
|
||||||
foreach (EpisodeMetadata metadata in templateEpisode.EpisodeMetadata.HeadOrNone())
|
MarkupMinificationResult minified = minifier.Minify(templateOutput);
|
||||||
{
|
await xml.WriteRawAsync(minified.MinifiedContent);
|
||||||
metadata.Genres ??= [];
|
|
||||||
metadata.Guids ??= [];
|
|
||||||
|
|
||||||
foreach (ShowMetadata showMetadata in Optional(
|
|
||||||
templateEpisode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten())
|
|
||||||
{
|
|
||||||
showMetadata.Genres ??= [];
|
|
||||||
showMetadata.Guids ??= [];
|
|
||||||
|
|
||||||
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
|
||||||
|
|
||||||
var data = new
|
|
||||||
{
|
|
||||||
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 =
|
|
||||||
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);
|
|
||||||
await xml.WriteAttributeStringAsync(null, "channel", null, $"{request.ChannelNumber}.etv");
|
|
||||||
|
|
||||||
await xml.WriteStartElementAsync(null, "title", null);
|
|
||||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
|
||||||
await xml.WriteStringAsync(title);
|
|
||||||
await xml.WriteEndElementAsync(); // title
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(subtitle))
|
|
||||||
{
|
|
||||||
await xml.WriteStartElementAsync(null, "sub-title", null);
|
|
||||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
|
||||||
await xml.WriteStringAsync(subtitle);
|
|
||||||
await xml.WriteEndElementAsync(); // subtitle
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isSameCustomShow)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(description))
|
|
||||||
{
|
|
||||||
await xml.WriteStartElementAsync(null, "desc", null);
|
|
||||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
|
||||||
await xml.WriteStringAsync(description);
|
|
||||||
await xml.WriteEndElementAsync(); // desc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasCustomTitle && displayItem.MediaItem is Song song)
|
|
||||||
{
|
|
||||||
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())
|
|
||||||
{
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await xml.WriteStartElementAsync(null, "previously-shown", null);
|
|
||||||
await xml.WriteEndElementAsync(); // previously-shown
|
|
||||||
|
|
||||||
foreach (ContentRating rating in contentRating)
|
|
||||||
{
|
|
||||||
await xml.WriteStartElementAsync(null, "rating", null);
|
|
||||||
foreach (string system in rating.System)
|
|
||||||
{
|
|
||||||
await xml.WriteAttributeStringAsync(null, "system", null, system);
|
|
||||||
}
|
|
||||||
|
|
||||||
await xml.WriteStartElementAsync(null, "value", null);
|
|
||||||
await xml.WriteStringAsync(rating.Value);
|
|
||||||
await xml.WriteEndElementAsync(); // value
|
|
||||||
await xml.WriteEndElementAsync(); // rating
|
|
||||||
}
|
|
||||||
|
|
||||||
await xml.WriteEndElementAsync(); // programme
|
|
||||||
|
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
@@ -456,6 +311,278 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
|||||||
File.Move(tempFile, targetFile, true);
|
File.Move(tempFile, targetFile, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<Option<string>> ProcessMovieTemplate(
|
||||||
|
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
|
||||||
|
{
|
||||||
|
ProgrammeStart = start,
|
||||||
|
ProgrammeStop = stop,
|
||||||
|
ChannelNumber = request.ChannelNumber,
|
||||||
|
HasCustomTitle = hasCustomTitle,
|
||||||
|
CustomTitle = displayItem.CustomTitle,
|
||||||
|
MovieTitle = title,
|
||||||
|
MovieHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
|
||||||
|
MoviePlot = metadata.Plot,
|
||||||
|
MovieHasYear = metadata.Year.HasValue,
|
||||||
|
MovieYear = metadata.Year,
|
||||||
|
MovieGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
|
||||||
|
MovieHasArtwork = !string.IsNullOrWhiteSpace(poster),
|
||||||
|
MovieArtworkUrl = poster,
|
||||||
|
MovieHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
|
||||||
|
MovieContentRating = metadata.ContentRating,
|
||||||
|
MovieGuids = metadata.Guids.Map(g => g.Guid)
|
||||||
|
};
|
||||||
|
|
||||||
|
var scriptObject = new ScriptObject();
|
||||||
|
scriptObject.Import(data);
|
||||||
|
templateContext.PushGlobal(scriptObject);
|
||||||
|
|
||||||
|
return await movieTemplate.RenderAsync(templateContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Option<string>.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ??= [];
|
||||||
|
|
||||||
|
foreach (ShowMetadata showMetadata in Optional(
|
||||||
|
templateEpisode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten())
|
||||||
|
{
|
||||||
|
showMetadata.Genres ??= [];
|
||||||
|
showMetadata.Guids ??= [];
|
||||||
|
|
||||||
|
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||||
|
|
||||||
|
var data = new
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
return await episodeTemplate.RenderAsync(templateContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Option<string>.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Option<string>> ProcessMusicVideoTemplate(
|
||||||
|
RefreshChannelData request,
|
||||||
|
MusicVideo templateMusicVideo,
|
||||||
|
string start,
|
||||||
|
string stop,
|
||||||
|
bool hasCustomTitle,
|
||||||
|
PlayoutItem displayItem,
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return await musicVideoTemplate.RenderAsync(templateContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
var scriptObject = new ScriptObject();
|
||||||
|
scriptObject.Import(data);
|
||||||
|
templateContext.PushGlobal(scriptObject);
|
||||||
|
|
||||||
|
return await otherVideoTemplate.RenderAsync(templateContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Option<string>.None;
|
||||||
|
}
|
||||||
|
|
||||||
private string GetMovieTemplateFileName()
|
private string GetMovieTemplateFileName()
|
||||||
{
|
{
|
||||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "movie.sbntxt");
|
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "movie.sbntxt");
|
||||||
@@ -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]"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ErsatzTV.Core.Domain;
|
||||||
|
|
||||||
|
public record SongTag(string Tag, string Value);
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Generated
+4925
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4925
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
Generated
+4923
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4923
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
string mediaItemPath = mediaItem.GetHeadVersion().MediaFiles.Head().Path;
|
||||||
Either<BaseError, FFprobe> maybeProbe = await GetProbeOutput(ffprobePath, mediaItemPath);
|
var song = File.Create(mediaItemPath);
|
||||||
foreach (BaseError error in maybeProbe.LeftToSeq())
|
|
||||||
|
var result = new List<SongTag>();
|
||||||
|
|
||||||
|
// album
|
||||||
|
if (!string.IsNullOrWhiteSpace(song.Tag.Album))
|
||||||
{
|
{
|
||||||
return error;
|
result.Add(new SongTag(MetadataSongTag.Album, song.Tag.Album));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// album artist(s)
|
||||||
|
IEnumerable<string> albumArtists = song.Tag.AlbumArtists.Filter(a => !string.IsNullOrWhiteSpace(a));
|
||||||
|
result.AddRange(albumArtists.Map(albumArtist => new SongTag(MetadataSongTag.AlbumArtist, albumArtist)));
|
||||||
|
|
||||||
|
// artist(s)
|
||||||
|
IEnumerable<string> artists = song.Tag.Performers.Filter(p => !string.IsNullOrWhiteSpace(p));
|
||||||
|
result.AddRange(artists.Map(artist => new SongTag(MetadataSongTag.Artist, artist)));
|
||||||
|
|
||||||
Option<FFprobeTags> maybeFormatTags = maybeProbe.RightToSeq()
|
// genre(s)
|
||||||
.Map(p => p?.format?.tags ?? FFprobeTags.Empty)
|
IEnumerable<string> genres = song.Tag.Genres.Filter(g => !string.IsNullOrWhiteSpace(g));
|
||||||
.HeadOrNone();
|
result.AddRange(genres.Map(genre => new SongTag(MetadataSongTag.Genre, genre)));
|
||||||
|
|
||||||
Option<FFprobeTags> maybeAudioTags = maybeProbe.RightToSeq()
|
// title
|
||||||
.Bind(p => p.streams.Filter(s => s.codec_type == "audio").HeadOrNone())
|
if (!string.IsNullOrWhiteSpace(song.Tag.Title))
|
||||||
.Map(s => s.tags ?? FFprobeTags.Empty)
|
|
||||||
.HeadOrNone();
|
|
||||||
|
|
||||||
foreach (FFprobeTags formatTags in maybeFormatTags)
|
|
||||||
foreach (FFprobeTags audioTags in maybeAudioTags)
|
|
||||||
{
|
{
|
||||||
var result = new Dictionary<string, string>();
|
result.Add(new SongTag(MetadataSongTag.Title, song.Tag.Title));
|
||||||
|
|
||||||
// album
|
|
||||||
if (!string.IsNullOrWhiteSpace(formatTags.album))
|
|
||||||
{
|
|
||||||
result.Add(MetadataSongTag.Album, formatTags.album);
|
|
||||||
}
|
|
||||||
else if (!string.IsNullOrWhiteSpace(audioTags.album))
|
|
||||||
{
|
|
||||||
result.Add(MetadataSongTag.Album, audioTags.album);
|
|
||||||
}
|
|
||||||
|
|
||||||
// album artist
|
|
||||||
if (!string.IsNullOrWhiteSpace(formatTags.albumArtist))
|
|
||||||
{
|
|
||||||
result.Add(MetadataSongTag.AlbumArtist, formatTags.albumArtist);
|
|
||||||
}
|
|
||||||
else if (!string.IsNullOrWhiteSpace(audioTags.albumArtist))
|
|
||||||
{
|
|
||||||
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(
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>(),
|
Artwork = [],
|
||||||
Tags = new List<Tag>()
|
Actors = [],
|
||||||
|
Genres = [],
|
||||||
|
Studios = [],
|
||||||
|
Tags = []
|
||||||
};
|
};
|
||||||
|
|
||||||
if (tags.TryGetValue(MetadataSongTag.Album, out string? album))
|
foreach (SongTag tag in tags)
|
||||||
{
|
{
|
||||||
result.Album = album;
|
switch (tag.Tag)
|
||||||
}
|
{
|
||||||
|
case MetadataSongTag.Album:
|
||||||
if (tags.TryGetValue(MetadataSongTag.Artist, out string? artist))
|
result.Album = tag.Value;
|
||||||
{
|
break;
|
||||||
result.Artist = artist;
|
case MetadataSongTag.Artist:
|
||||||
}
|
result.Artists.Add(tag.Value);
|
||||||
|
break;
|
||||||
if (tags.TryGetValue(MetadataSongTag.AlbumArtist, out string? albumArtist))
|
case MetadataSongTag.AlbumArtist:
|
||||||
{
|
result.AlbumArtists.Add(tag.Value);
|
||||||
result.AlbumArtist = albumArtist;
|
break;
|
||||||
}
|
case MetadataSongTag.Genre:
|
||||||
|
result.Genres.Add(new Genre { Name = tag.Value });
|
||||||
if (tags.TryGetValue(MetadataSongTag.Date, out string? date))
|
break;
|
||||||
{
|
case MetadataSongTag.Title:
|
||||||
result.Date = date;
|
result.Title = tag.Value;
|
||||||
}
|
break;
|
||||||
|
case MetadataSongTag.Track:
|
||||||
if (tags.TryGetValue(MetadataSongTag.Genre, out string? genre))
|
result.Track = tag.Value;
|
||||||
{
|
break;
|
||||||
result.Genres.AddRange(SplitGenres(genre).Map(n => new Genre { Name = n }));
|
case MetadataSongTag.Comment:
|
||||||
}
|
result.Comment = tag.Value;
|
||||||
|
break;
|
||||||
if (tags.TryGetValue(MetadataSongTag.Title, out string? title))
|
}
|
||||||
{
|
|
||||||
result.Title = title;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tags.TryGetValue(MetadataSongTag.Track, out string? track))
|
|
||||||
{
|
|
||||||
result.Track = track;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user