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 `/iptv` request logging in its own log category at debug level
|
||||
- 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
|
||||
- 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
|
||||
@@ -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
|
||||
- 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
|
||||
- Use different library for reading song metadata that supports multiple tag entries
|
||||
|
||||
## [0.8.5-beta] - 2024-01-30
|
||||
### Added
|
||||
|
||||
@@ -44,11 +44,14 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
string movieTemplateFileName = GetMovieTemplateFileName();
|
||||
string episodeTemplateFileName = GetEpisodeTemplateFileName();
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
var minifier = new XmlMinifier(
|
||||
new XmlMinificationSettings
|
||||
{
|
||||
@@ -68,6 +71,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken);
|
||||
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);
|
||||
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
@@ -140,6 +149,14 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.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);
|
||||
|
||||
List<PlayoutItem> sorted = [];
|
||||
@@ -217,232 +234,70 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
string title = GetTitle(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())
|
||||
{
|
||||
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);
|
||||
Movie templateMovie => await ProcessMovieTemplate(
|
||||
request,
|
||||
templateMovie,
|
||||
start,
|
||||
stop,
|
||||
hasCustomTitle,
|
||||
displayItem,
|
||||
title,
|
||||
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
|
||||
{
|
||||
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 (string templateOutput in maybeTemplateOutput)
|
||||
{
|
||||
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);
|
||||
|
||||
string result = await episodeTemplate.RenderAsync(templateContext);
|
||||
|
||||
MarkupMinificationResult minified = minifier.Minify(result);
|
||||
await xml.WriteRawAsync(minified.MinifiedContent);
|
||||
}
|
||||
}
|
||||
|
||||
i++;
|
||||
continue;
|
||||
MarkupMinificationResult minified = minifier.Minify(templateOutput);
|
||||
await xml.WriteRawAsync(minified.MinifiedContent);
|
||||
}
|
||||
|
||||
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++;
|
||||
}
|
||||
@@ -456,6 +311,278 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
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()
|
||||
{
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "movie.sbntxt");
|
||||
@@ -524,6 +651,52 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -578,8 +751,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
.IfNone("[unknown artist]"),
|
||||
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown video]"),
|
||||
Song s => s.SongMetadata.HeadOrNone().Map(sm => sm.Artist ?? string.Empty)
|
||||
.IfNone("[unknown artist]"),
|
||||
_ => "[unknown]"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ internal static class Mapper
|
||||
return new SongCardViewModel(
|
||||
songMetadata.SongId,
|
||||
songMetadata.Title,
|
||||
songMetadata.Artist + album,
|
||||
string.Join(", ", songMetadata.Artists) + album,
|
||||
songMetadata.SortTitle,
|
||||
GetThumbnail(songMetadata, None, None),
|
||||
songMetadata.Song.State);
|
||||
|
||||
@@ -66,7 +66,7 @@ internal static class Mapper
|
||||
.IfNone("[unknown video]");
|
||||
case Song s:
|
||||
string songArtist = s.SongMetadata.HeadOrNone()
|
||||
.Map(sm => string.IsNullOrWhiteSpace(sm.Artist) ? string.Empty : $"{sm.Artist} - ")
|
||||
.Map(sm => $"{string.Join(", ", sm.Artists)} - ")
|
||||
.IfNone(string.Empty);
|
||||
return s.SongMetadata.HeadOrNone()
|
||||
.Map(sm => $"{songArtist}{sm.Title ?? string.Empty}")
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
public class SongMetadata : Metadata
|
||||
{
|
||||
public string Album { get; set; }
|
||||
public string Artist { get; set; }
|
||||
public string AlbumArtist { get; set; }
|
||||
public string Date { get; set; }
|
||||
public IList<string> Artists { get; set; }
|
||||
public IList<string> AlbumArtists { get; set; }
|
||||
public string Track { get; set; }
|
||||
public string Comment { get; set; }
|
||||
public int SongId { 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}");
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
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))
|
||||
@@ -105,12 +107,12 @@ public class SongVideoGenerator : ISongVideoGenerator
|
||||
sb.Append(CultureInfo.InvariantCulture, $"\\N\"{metadata.Title}\"");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.AlbumArtist) && !string.Equals(
|
||||
metadata.Artist,
|
||||
metadata.AlbumArtist,
|
||||
StringComparison.Ordinal))
|
||||
if (metadata.AlbumArtists.Count > 0)
|
||||
{
|
||||
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))
|
||||
|
||||
@@ -9,5 +9,5 @@ public interface ILocalStatisticsProvider
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
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")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("AlbumArtist")
|
||||
b.Property<string>("AlbumArtists")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("Artist")
|
||||
b.Property<string>("Artists")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("Date")
|
||||
b.Property<string>("Comment")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
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")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AlbumArtist")
|
||||
b.Property<string>("AlbumArtists")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Artist")
|
||||
b.Property<string>("Artists")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Date")
|
||||
b.Property<string>("Comment")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
|
||||
@@ -397,7 +397,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository
|
||||
|
||||
var allArtists = items.OfType<Song>()
|
||||
.SelectMany(s => s.SongMetadata)
|
||||
.Map(sm => sm.AlbumArtist)
|
||||
.Map(sm => sm.AlbumArtists.HeadOrNone().Match(aa => aa, string.Empty))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
@@ -409,11 +409,16 @@ public class MediaCollectionRepository : IMediaCollectionRepository
|
||||
var songArtistCollections = new Dictionary<int, List<MediaItem>>();
|
||||
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)
|
||||
? collection
|
||||
: new List<MediaItem>();
|
||||
: [];
|
||||
|
||||
if (list.All(i => i.Id != song.Id))
|
||||
{
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<PackageReference Include="Refit.Newtonsoft.Json" Version="7.0.0" />
|
||||
<PackageReference Include="Refit.Xml" Version="7.0.0" />
|
||||
<PackageReference Include="Scriban.Signed" Version="5.9.1" />
|
||||
<PackageReference Include="TagLibSharp" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -11,8 +11,10 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Lucene.Net.Util.Fst;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using File = TagLib.File;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Metadata;
|
||||
|
||||
@@ -61,111 +63,44 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Dictionary<string, string>>> GetSongTags(
|
||||
string ffprobePath,
|
||||
MediaItem mediaItem)
|
||||
public Either<BaseError, List<SongTag>> GetSongTags(string ffprobePath, MediaItem mediaItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
string mediaItemPath = mediaItem.GetHeadVersion().MediaFiles.Head().Path;
|
||||
Either<BaseError, FFprobe> maybeProbe = await GetProbeOutput(ffprobePath, mediaItemPath);
|
||||
foreach (BaseError error in maybeProbe.LeftToSeq())
|
||||
var song = File.Create(mediaItemPath);
|
||||
|
||||
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()
|
||||
.Map(p => p?.format?.tags ?? FFprobeTags.Empty)
|
||||
.HeadOrNone();
|
||||
// genre(s)
|
||||
IEnumerable<string> genres = song.Tag.Genres.Filter(g => !string.IsNullOrWhiteSpace(g));
|
||||
result.AddRange(genres.Map(genre => new SongTag(MetadataSongTag.Genre, genre)));
|
||||
|
||||
Option<FFprobeTags> maybeAudioTags = maybeProbe.RightToSeq()
|
||||
.Bind(p => p.streams.Filter(s => s.codec_type == "audio").HeadOrNone())
|
||||
.Map(s => s.tags ?? FFprobeTags.Empty)
|
||||
.HeadOrNone();
|
||||
|
||||
foreach (FFprobeTags formatTags in maybeFormatTags)
|
||||
foreach (FFprobeTags audioTags in maybeAudioTags)
|
||||
// title
|
||||
if (!string.IsNullOrWhiteSpace(song.Tag.Title))
|
||||
{
|
||||
var result = new Dictionary<string, string>();
|
||||
|
||||
// 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;
|
||||
result.Add(new SongTag(MetadataSongTag.Title, song.Tag.Title));
|
||||
}
|
||||
|
||||
// track
|
||||
result.Add(new SongTag(MetadataSongTag.Track, song.Tag.Track.ToString(CultureInfo.InvariantCulture)));
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -173,8 +108,6 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
|
||||
_client.Notify(ex);
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
|
||||
return BaseError.New("BUG - this should never happen");
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, bool>> RefreshStatistics(
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
public static class MetadataSongTag
|
||||
{
|
||||
public static readonly string Album = "album";
|
||||
public static readonly string Artist = "artist";
|
||||
public static readonly string AlbumArtist = "albumartist";
|
||||
public static readonly string Date = "date";
|
||||
public static readonly string Genre = "genre";
|
||||
public static readonly string Title = "title";
|
||||
public static readonly string Track = "track";
|
||||
public const string Album = "album";
|
||||
public const string Artist = "artist";
|
||||
public const string AlbumArtist = "albumartist";
|
||||
public const string Date = "date";
|
||||
public const string Genre = "genre";
|
||||
public const string Title = "title";
|
||||
public const string Track = "track";
|
||||
public const string Comment = "comment";
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ public class ElasticSearchIndex : ISearchIndex
|
||||
return exists.IsValidResponse;
|
||||
}
|
||||
|
||||
public int Version => 40;
|
||||
public int Version => 41;
|
||||
|
||||
public async Task<bool> Initialize(
|
||||
ILocalFileSystem localFileSystem,
|
||||
@@ -703,8 +703,8 @@ public class ElasticSearchIndex : ISearchIndex
|
||||
SubLanguageTag = GetSubLanguageTags(song.MediaVersions),
|
||||
AddedDate = GetAddedDate(metadata.DateAdded),
|
||||
Album = metadata.Album ?? string.Empty,
|
||||
Artist = !string.IsNullOrWhiteSpace(metadata.Artist) ? new List<string> { metadata.Artist } : null,
|
||||
AlbumArtist = metadata.AlbumArtist,
|
||||
Artist = metadata.Artists.ToList(),
|
||||
AlbumArtist = metadata.AlbumArtists.ToList(),
|
||||
Genre = metadata.Genres.Map(g => g.Name).ToList(),
|
||||
Tag = metadata.Tags.Map(t => t.Name).ToList()
|
||||
};
|
||||
|
||||
@@ -111,7 +111,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
|
||||
return Task.FromResult(directoryExists && fileExists);
|
||||
}
|
||||
|
||||
public int Version => 40;
|
||||
public int Version => 41;
|
||||
|
||||
public async Task<bool> Initialize(
|
||||
ILocalFileSystem localFileSystem,
|
||||
@@ -1238,14 +1238,14 @@ public sealed class LuceneSearchIndex : ISearchIndex
|
||||
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)
|
||||
|
||||
@@ -71,7 +71,7 @@ public class ElasticSearchItem : MinimalElasticSearchItem
|
||||
public string Album { get; set; }
|
||||
|
||||
[JsonPropertyName(LuceneSearchIndex.AlbumArtistField)]
|
||||
public string AlbumArtist { get; set; }
|
||||
public List<string> AlbumArtist { get; set; }
|
||||
|
||||
[JsonPropertyName(LuceneSearchIndex.PlotField)]
|
||||
public string Plot { get; set; }
|
||||
|
||||
@@ -25,8 +25,6 @@ using ErsatzTV.FFmpeg.State;
|
||||
using ErsatzTV.Infrastructure.Images;
|
||||
using ErsatzTV.Infrastructure.Metadata;
|
||||
using ErsatzTV.Infrastructure.Runtime;
|
||||
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Scanner.Core.Metadata;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -290,16 +288,16 @@ public class TranscodingTests
|
||||
|
||||
var song = new Song
|
||||
{
|
||||
SongMetadata = new List<SongMetadata>
|
||||
{
|
||||
new()
|
||||
SongMetadata =
|
||||
[
|
||||
new SongMetadata
|
||||
{
|
||||
Title = "Song Title",
|
||||
Artist = "Song Artist",
|
||||
Artwork = new List<Artwork>()
|
||||
Artists = ["Song Artist"],
|
||||
Artwork = []
|
||||
}
|
||||
},
|
||||
MediaVersions = new List<MediaVersion> { songVersion }
|
||||
],
|
||||
MediaVersions = [songVersion]
|
||||
};
|
||||
|
||||
(string videoPath, MediaVersion videoVersion) = await songVideoGenerator.GenerateSongVideo(
|
||||
|
||||
@@ -198,7 +198,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
|
||||
try
|
||||
{
|
||||
Either<BaseError, Dictionary<string, string>> maybeTags =
|
||||
await _localStatisticsProvider.GetSongTags(ffprobePath, song);
|
||||
var maybeTags = _localStatisticsProvider.GetSongTags(ffprobePath, song);
|
||||
|
||||
foreach (Dictionary<string, string> tags in maybeTags.RightToSeq())
|
||||
foreach (List<SongTag> tags in maybeTags.RightToSeq())
|
||||
{
|
||||
Option<SongMetadata> maybeFallbackMetadata =
|
||||
_fallbackMetadataProvider.GetFallbackMetadata(song);
|
||||
Option<SongMetadata> maybeFallbackMetadata = _fallbackMetadataProvider.GetFallbackMetadata(song);
|
||||
|
||||
var result = new SongMetadata
|
||||
{
|
||||
@@ -316,46 +314,42 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
DateAdded = DateTime.UtcNow,
|
||||
DateUpdated = File.GetLastWriteTimeUtc(path),
|
||||
|
||||
Artwork = new List<Artwork>(),
|
||||
Actors = new List<Actor>(),
|
||||
Genres = new List<Genre>(),
|
||||
Studios = new List<Studio>(),
|
||||
Tags = new List<Tag>()
|
||||
Artists = [],
|
||||
AlbumArtists = [],
|
||||
|
||||
Artwork = [],
|
||||
Actors = [],
|
||||
Genres = [],
|
||||
Studios = [],
|
||||
Tags = []
|
||||
};
|
||||
|
||||
if (tags.TryGetValue(MetadataSongTag.Album, out string? album))
|
||||
foreach (SongTag tag in tags)
|
||||
{
|
||||
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))
|
||||
{
|
||||
result.Date = date;
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
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)
|
||||
@@ -981,11 +975,11 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
foreach (SongMetadata existing in maybeMetadata)
|
||||
{
|
||||
existing.Title = metadata.Title;
|
||||
existing.Artist = metadata.Artist;
|
||||
existing.AlbumArtist = metadata.AlbumArtist;
|
||||
existing.Artists = metadata.Artists;
|
||||
existing.AlbumArtists = metadata.AlbumArtists;
|
||||
existing.Album = metadata.Album;
|
||||
existing.Date = metadata.Date;
|
||||
existing.Track = metadata.Track;
|
||||
existing.Comment = metadata.Comment;
|
||||
|
||||
if (existing.DateAdded == SystemTime.MinValueUtc)
|
||||
{
|
||||
|
||||
@@ -77,6 +77,8 @@
|
||||
<EmbeddedResource Include="Resources\Templates\_episode.sbntxt" />
|
||||
<EmbeddedResource Include="Resources\Templates\_movie.sbntxt" />
|
||||
<EmbeddedResource Include="Resources\Templates\_musicVideo.sbntxt" />
|
||||
<EmbeddedResource Include="Resources\Templates\_otherVideo.sbntxt" />
|
||||
<EmbeddedResource Include="Resources\Templates\_song.sbntxt" />
|
||||
</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,
|
||||
stoppingToken);
|
||||
|
||||
await ExtractTemplateResource(
|
||||
assembly,
|
||||
"_song.sbntxt",
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
stoppingToken);
|
||||
|
||||
await ExtractTemplateResource(
|
||||
assembly,
|
||||
"_otherVideo.sbntxt",
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
stoppingToken);
|
||||
|
||||
await ExtractScriptResource(
|
||||
assembly,
|
||||
"_threePartEpisodes.js",
|
||||
|
||||
Reference in New Issue
Block a user