support movie nfo metadata in other video libraries (#788)

* add other video nfo metadata

* update docs
This commit is contained in:
Jason Dove
2022-05-05 20:38:23 -05:00
committed by GitHub
parent e81a8e58ea
commit 1431b33a98
23 changed files with 5470 additions and 73 deletions
+244 -42
View File
@@ -24,6 +24,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
private readonly IMovieRepository _movieRepository;
private readonly IMusicVideoNfoReader _musicVideoNfoReader;
private readonly IMusicVideoRepository _musicVideoRepository;
private readonly IOtherVideoNfoReader _otherVideoNfoReader;
private readonly IOtherVideoRepository _otherVideoRepository;
private readonly ISongRepository _songRepository;
private readonly ITelevisionRepository _televisionRepository;
@@ -44,6 +45,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
IArtistNfoReader artistNfoReader,
IMusicVideoNfoReader musicVideoNfoReader,
ITvShowNfoReader tvShowNfoReader,
IOtherVideoNfoReader otherVideoNfoReader,
ILocalStatisticsProvider localStatisticsProvider,
IClient client,
ILogger<LocalMetadataProvider> logger)
@@ -62,6 +64,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
_artistNfoReader = artistNfoReader;
_musicVideoNfoReader = musicVideoNfoReader;
_tvShowNfoReader = tvShowNfoReader;
_otherVideoNfoReader = otherVideoNfoReader;
_localStatisticsProvider = localStatisticsProvider;
_client = client;
_logger = logger;
@@ -107,38 +110,77 @@ public class LocalMetadataProvider : ILocalMetadataProvider
return fallbackMetadata;
}
public Task<bool> RefreshSidecarMetadata(Movie movie, string nfoFileName) =>
LoadMovieMetadata(movie, nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(movie, metadata),
() => Task.FromResult(false)));
public async Task<bool> RefreshSidecarMetadata(Movie movie, string nfoFileName)
{
Option<MovieMetadata> maybeMetadata = await LoadMovieMetadata(movie, nfoFileName);
foreach (MovieMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(movie, metadata);
}
public Task<bool> RefreshSidecarMetadata(Show televisionShow, string nfoFileName) =>
LoadTelevisionShowMetadata(nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(televisionShow, metadata),
() => Task.FromResult(false)));
return false;
}
public Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName) =>
LoadEpisodeMetadata(episode, nfoFileName).Bind(metadata => ApplyMetadataUpdate(episode, metadata));
public async Task<bool> RefreshSidecarMetadata(Show televisionShow, string nfoFileName)
{
Option<ShowMetadata> maybeMetadata = await LoadTelevisionShowMetadata(nfoFileName);
foreach (ShowMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(televisionShow, metadata);
}
public Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName) =>
LoadArtistMetadata(nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(artist, metadata),
() => Task.FromResult(false)));
return false;
}
public Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName) =>
LoadMusicVideoMetadata(nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(musicVideo, metadata),
() => RefreshFallbackMetadata(musicVideo)));
public async Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName)
{
List<EpisodeMetadata> metadata = await LoadEpisodeMetadata(episode, nfoFileName);
return await ApplyMetadataUpdate(episode, metadata);
}
public Task<bool> RefreshTagMetadata(Song song, string ffprobePath) =>
LoadSongMetadata(song, ffprobePath).Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(song, metadata),
() => RefreshFallbackMetadata(song)));
public async Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName)
{
Option<ArtistMetadata> maybeMetadata = await LoadArtistMetadata(nfoFileName);
foreach (ArtistMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(artist, metadata);
}
return false;
}
public async Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName)
{
Option<MusicVideoMetadata> maybeMetadata = await LoadMusicVideoMetadata(nfoFileName);
foreach (MusicVideoMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(musicVideo, metadata);
}
return await RefreshFallbackMetadata(musicVideo);
}
public async Task<bool> RefreshSidecarMetadata(OtherVideo otherVideo, string nfoFileName)
{
Option<OtherVideoMetadata> maybeMetadata = await LoadOtherVideoMetadata(nfoFileName);
foreach (OtherVideoMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(otherVideo, metadata);
}
return await RefreshFallbackMetadata(otherVideo);
}
public async Task<bool> RefreshTagMetadata(Song song, string ffprobePath)
{
Option<SongMetadata> maybeMetadata = await LoadSongMetadata(song, ffprobePath);
foreach (SongMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(song, metadata);
}
return await RefreshFallbackMetadata(song);
}
public Task<bool> RefreshFallbackMetadata(Movie movie) =>
ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie));
@@ -149,20 +191,38 @@ public class LocalMetadataProvider : ILocalMetadataProvider
public Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder) =>
ApplyMetadataUpdate(artist, _fallbackMetadataProvider.GetFallbackMetadataForArtist(artistFolder));
public Task<bool> RefreshFallbackMetadata(OtherVideo otherVideo) =>
_fallbackMetadataProvider.GetFallbackMetadata(otherVideo).Match(
metadata => ApplyMetadataUpdate(otherVideo, metadata),
() => Task.FromResult(false));
public async Task<bool> RefreshFallbackMetadata(OtherVideo otherVideo)
{
Option<OtherVideoMetadata> maybeMetadata = _fallbackMetadataProvider.GetFallbackMetadata(otherVideo);
foreach (OtherVideoMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(otherVideo, metadata);
}
public Task<bool> RefreshFallbackMetadata(Song song) =>
_fallbackMetadataProvider.GetFallbackMetadata(song).Match(
metadata => ApplyMetadataUpdate(song, metadata),
() => Task.FromResult(false));
return false;
}
public Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo) =>
_fallbackMetadataProvider.GetFallbackMetadata(musicVideo).Match(
metadata => ApplyMetadataUpdate(musicVideo, metadata),
() => Task.FromResult(false));
public async Task<bool> RefreshFallbackMetadata(Song song)
{
Option<SongMetadata> maybeMetadata = _fallbackMetadataProvider.GetFallbackMetadata(song);
foreach (SongMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(song, metadata);
}
return false;
}
public async Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo)
{
Option<MusicVideoMetadata> maybeMetadata = _fallbackMetadataProvider.GetFallbackMetadata(musicVideo);
foreach (MusicVideoMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(musicVideo, metadata);
}
return false;
}
public Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder));
@@ -767,6 +827,10 @@ public class LocalMetadataProvider : ILocalMetadataProvider
Option<OtherVideoMetadata> maybeMetadata = Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone();
foreach (OtherVideoMetadata existing in maybeMetadata)
{
existing.ContentRating = metadata.ContentRating;
existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot;
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
if (existing.DateAdded == SystemTime.MinValueUtc)
@@ -776,6 +840,9 @@ public class LocalMetadataProvider : ILocalMetadataProvider
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
@@ -784,10 +851,70 @@ public class LocalMetadataProvider : ILocalMetadataProvider
bool updated = await UpdateMetadataCollections(
existing,
metadata,
(_, _) => Task.FromResult(false),
_otherVideoRepository.AddGenre,
_otherVideoRepository.AddTag,
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false));
_otherVideoRepository.AddStudio,
_otherVideoRepository.AddActor);
foreach (Director director in existing.Directors
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Directors.Remove(director);
if (await _metadataRepository.RemoveDirector(director))
{
updated = true;
}
}
foreach (Director director in metadata.Directors
.Filter(d => existing.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Directors.Add(director);
if (await _otherVideoRepository.AddDirector(existing, director))
{
updated = true;
}
}
foreach (Writer writer in existing.Writers
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Writers.Remove(writer);
if (await _metadataRepository.RemoveWriter(writer))
{
updated = true;
}
}
foreach (Writer writer in metadata.Writers
.Filter(w => existing.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Writers.Add(writer);
if (await _otherVideoRepository.AddWriter(existing, writer))
{
updated = true;
}
}
foreach (MetadataGuid guid in existing.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
updated = true;
}
}
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => existing.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existing, guid))
{
updated = true;
}
}
return await _metadataRepository.Update(existing) || updated;
}
@@ -1068,6 +1195,81 @@ public class LocalMetadataProvider : ILocalMetadataProvider
}
}
private async Task<Option<OtherVideoMetadata>> LoadOtherVideoMetadata(string nfoFileName)
{
try
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Either<BaseError, OtherVideoNfo> maybeNfo = await _otherVideoNfoReader.Read(fileStream);
foreach (BaseError error in maybeNfo.LeftToSeq())
{
_logger.LogInformation(
"Failed to read OtherVideo nfo metadata from {Path}: {Error}",
nfoFileName,
error.ToString());
return None;
}
foreach (OtherVideoNfo nfo in maybeNfo.RightToSeq())
{
DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
var year = 0;
if (nfo.Year > 1000)
{
year = nfo.Year;
}
DateTime releaseDate = year > 0
? new DateTimeOffset(year, 1, 1, 0, 0, 0, TimeSpan.Zero).UtcDateTime
: SystemTime.MinValueUtc;
foreach (DateTime premiered in nfo.Premiered)
{
if (year == 0)
{
year = premiered.Year;
}
releaseDate = premiered;
}
return new OtherVideoMetadata
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = dateAdded,
DateUpdated = dateUpdated,
Title = nfo.Title,
SortTitle = nfo.SortTitle,
Year = year,
ContentRating = nfo.ContentRating,
ReleaseDate = releaseDate,
Plot = nfo.Plot,
Outline = nfo.Outline,
Tagline = nfo.Tagline,
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList(),
Actors = Actors(nfo.Actors, dateAdded, dateUpdated),
Directors = nfo.Directors.Map(d => new Director { Name = d }).ToList(),
Writers = nfo.Writers.Map(w => new Writer { Name = w }).ToList(),
Guids = nfo.UniqueIds
.Map(id => new MetadataGuid { Guid = $"{id.Type}://{id.Guid}" })
.ToList()
};
}
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Failed to read OtherVideo nfo metadata from {Path}", nfoFileName);
_client.Notify(ex);
}
return None;
}
private static int? GetYear(int? year, Option<DateTime> premiered)
{
if (year is > 1000)
@@ -0,0 +1,52 @@
using System.Xml.Serialization;
namespace ErsatzTV.Core.Metadata.Nfo;
[XmlRoot("movie")]
public class OtherVideoNfo
{
[XmlElement("title")]
public string Title { get; set; }
[XmlElement("sorttitle")]
public string SortTitle { get; set; }
[XmlElement("outline")]
public string Outline { get; set; }
[XmlElement("year")]
public int Year { get; set; }
[XmlElement("mpaa")]
public string ContentRating { get; set; }
[XmlElement("premiered")]
public Option<DateTime> Premiered { get; set; }
[XmlElement("plot")]
public string Plot { get; set; }
[XmlElement("tagline")]
public string Tagline { get; set; }
[XmlElement("genre")]
public List<string> Genres { get; set; }
[XmlElement("tag")]
public List<string> Tags { get; set; }
[XmlElement("studio")]
public List<string> Studios { get; set; }
[XmlElement("actor")]
public List<ActorNfo> Actors { get; set; }
[XmlElement("credits")]
public List<string> Writers { get; set; }
[XmlElement("director")]
public List<string> Directors { get; set; }
[XmlElement("uniqueid")]
public List<UniqueIdNfo> UniqueIds { get; set; }
}
@@ -0,0 +1,114 @@
using System.Xml;
using Bugsnag;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
namespace ErsatzTV.Core.Metadata.Nfo;
public class OtherVideoNfoReader : NfoReader<OtherVideoNfo>, IOtherVideoNfoReader
{
private readonly IClient _client;
public OtherVideoNfoReader(IClient client) => _client = client;
public async Task<Either<BaseError, OtherVideoNfo>> Read(Stream input)
{
try
{
var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment };
using var reader = XmlReader.Create(input, settings);
OtherVideoNfo nfo = null;
var done = false;
while (!done && await reader.ReadAsync())
{
switch (reader.NodeType)
{
case XmlNodeType.Element:
switch (reader.Name.ToLowerInvariant())
{
case "movie":
nfo = new OtherVideoNfo
{
Genres = new List<string>(),
Tags = new List<string>(),
Studios = new List<string>(),
Actors = new List<ActorNfo>(),
Writers = new List<string>(),
Directors = new List<string>(),
UniqueIds = new List<UniqueIdNfo>()
};
break;
case "title":
await ReadStringContent(reader, nfo, (movie, title) => movie.Title = title);
break;
case "sorttitle":
await ReadStringContent(reader, nfo, (movie, sortTitle) => movie.SortTitle = sortTitle);
break;
case "outline":
await ReadStringContent(reader, nfo, (movie, outline) => movie.Outline = outline);
break;
case "year":
await ReadIntContent(reader, nfo, (movie, year) => movie.Year = year);
break;
case "mpaa":
await ReadStringContent(
reader,
nfo,
(movie, contentRating) => movie.ContentRating = contentRating);
break;
case "premiered":
await ReadDateTimeContent(
reader,
nfo,
(movie, premiered) => movie.Premiered = premiered);
break;
case "plot":
await ReadStringContent(reader, nfo, (movie, plot) => movie.Plot = plot);
break;
case "genre":
await ReadStringContent(reader, nfo, (movie, genre) => movie.Genres.Add(genre));
break;
case "tag":
await ReadStringContent(reader, nfo, (movie, tag) => movie.Tags.Add(tag));
break;
case "studio":
await ReadStringContent(reader, nfo, (movie, studio) => movie.Studios.Add(studio));
break;
case "actor":
ReadActor(reader, nfo, (movie, actor) => movie.Actors.Add(actor));
break;
case "credits":
await ReadStringContent(reader, nfo, (movie, writer) => movie.Writers.Add(writer));
break;
case "director":
await ReadStringContent(
reader,
nfo,
(movie, director) => movie.Directors.Add(director));
break;
case "uniqueid":
await ReadUniqueId(reader, nfo, (movie, uniqueid) => movie.UniqueIds.Add(uniqueid));
break;
}
break;
case XmlNodeType.EndElement:
if (reader.Name == "movie")
{
done = true;
}
break;
}
}
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
}
catch (Exception ex)
{
_client.Notify(ex);
return new FailedToReadNfo(ex.ToString());
}
}
}
@@ -77,15 +77,17 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
var foldersCompleted = 0;
var allFolders = new System.Collections.Generic.HashSet<string>();
var folderQueue = new Queue<string>();
if (ShouldIncludeFolder(libraryPath.Path))
if (ShouldIncludeFolder(libraryPath.Path) && allFolders.Add(libraryPath.Path))
{
folderQueue.Enqueue(libraryPath.Path);
}
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.Filter(allFolders.Add)
.OrderBy(identity))
{
folderQueue.Enqueue(folder);
@@ -115,6 +117,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
foreach (string subdirectory in _localFileSystem.ListSubdirectories(otherVideoFolder)
.Filter(ShouldIncludeFolder)
.Filter(allFolders.Add)
.OrderBy(identity))
{
folderQueue.Enqueue(subdirectory);
@@ -205,15 +208,38 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
try
{
OtherVideo otherVideo = result.Item;
if (!Optional(otherVideo.OtherVideoMetadata).Flatten().Any())
{
otherVideo.OtherVideoMetadata ??= new List<OtherVideoMetadata>();
string path = otherVideo.MediaVersions.Head().MediaFiles.Head().Path;
string path = otherVideo.MediaVersions.Head().MediaFiles.Head().Path;
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
if (await _localMetadataProvider.RefreshFallbackMetadata(otherVideo))
Option<string> maybeNfoFile = new List<string> { Path.ChangeExtension(path, "nfo") }
.Filter(_localFileSystem.FileExists)
.HeadOrNone();
if (maybeNfoFile.IsNone)
{
if (!Optional(otherVideo.OtherVideoMetadata).Flatten().Any())
{
result.IsUpdated = true;
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
if (await _localMetadataProvider.RefreshFallbackMetadata(otherVideo))
{
result.IsUpdated = true;
}
}
}
foreach (string nfoFile in maybeNfoFile)
{
bool shouldUpdate = Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone().Match(
m => m.MetadataKind == MetadataKind.Fallback ||
m.DateUpdated != _localFileSystem.GetLastWriteTime(nfoFile),
true);
if (shouldUpdate)
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
if (await _localMetadataProvider.RefreshSidecarMetadata(otherVideo, nfoFile))
{
result.IsUpdated = true;
}
}
}