using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using System.Xml.Serialization; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Metadata.Nfo; using LanguageExt; using Microsoft.Extensions.Logging; using static LanguageExt.Prelude; namespace ErsatzTV.Core.Metadata { public class LocalMetadataProvider : ILocalMetadataProvider { private static readonly XmlSerializer MovieSerializer = new(typeof(MovieNfo)); private static readonly XmlSerializer EpisodeSerializer = new(typeof(TvShowEpisodeNfo)); private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowNfo)); private static readonly XmlSerializer MusicVideoSerializer = new(typeof(MusicVideoNfo)); private readonly IFallbackMetadataProvider _fallbackMetadataProvider; private readonly ILocalFileSystem _localFileSystem; private readonly ILogger _logger; private readonly IMetadataRepository _metadataRepository; private readonly IMovieRepository _movieRepository; private readonly IMusicVideoRepository _musicVideoRepository; private readonly ITelevisionRepository _televisionRepository; public LocalMetadataProvider( IMetadataRepository metadataRepository, IMovieRepository movieRepository, ITelevisionRepository televisionRepository, IMusicVideoRepository musicVideoRepository, IFallbackMetadataProvider fallbackMetadataProvider, ILocalFileSystem localFileSystem, ILogger logger) { _metadataRepository = metadataRepository; _movieRepository = movieRepository; _televisionRepository = televisionRepository; _musicVideoRepository = musicVideoRepository; _fallbackMetadataProvider = fallbackMetadataProvider; _localFileSystem = localFileSystem; _logger = logger; } public async Task GetMetadataForShow(string showFolder) { string nfoFileName = Path.Combine(showFolder, "tvshow.nfo"); Option maybeMetadata = None; if (_localFileSystem.FileExists(nfoFileName)) { maybeMetadata = await LoadTelevisionShowMetadata(nfoFileName); } return maybeMetadata.Match( metadata => { metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title); return metadata; }, () => { ShowMetadata metadata = _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder); metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title); return metadata; }); } public Task RefreshSidecarMetadata(Movie movie, string nfoFileName) => LoadMovieMetadata(movie, nfoFileName).Bind( maybeMetadata => maybeMetadata.Match( metadata => ApplyMetadataUpdate(movie, metadata), () => Task.FromResult(false))); public Task RefreshSidecarMetadata(Show televisionShow, string nfoFileName) => LoadTelevisionShowMetadata(nfoFileName).Bind( maybeMetadata => maybeMetadata.Match( metadata => ApplyMetadataUpdate(televisionShow, metadata), () => Task.FromResult(false))); public Task RefreshSidecarMetadata(Episode episode, string nfoFileName) => LoadEpisodeMetadata(episode, nfoFileName).Bind( maybeMetadata => maybeMetadata.Match( metadata => ApplyMetadataUpdate(episode, metadata), () => Task.FromResult(false))); public Task RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName) => LoadMusicVideoMetadata(nfoFileName).Bind( maybeMetadata => maybeMetadata.Match( metadata => ApplyMetadataUpdate(musicVideo, metadata), () => RefreshFallbackMetadata(musicVideo))); public Task RefreshFallbackMetadata(Movie movie) => ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie)); public Task RefreshFallbackMetadata(Episode episode) => ApplyMetadataUpdate(episode, _fallbackMetadataProvider.GetFallbackMetadata(episode)); public Task RefreshFallbackMetadata(MusicVideo musicVideo) => ApplyMetadataUpdate(musicVideo, _fallbackMetadataProvider.GetFallbackMetadata(musicVideo)); public Task RefreshFallbackMetadata(Show televisionShow, string showFolder) => ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder)); private async Task> LoadMusicVideoMetadata(string nfoFileName) { try { await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read); Option maybeNfo = MusicVideoSerializer.Deserialize(fileStream) as MusicVideoNfo; return maybeNfo.Match>( nfo => new MusicVideoMetadata { MetadataKind = MetadataKind.Sidecar, DateAdded = DateTime.UtcNow, DateUpdated = File.GetLastWriteTimeUtc(nfoFileName), Artist = nfo.Artist, Album = nfo.Album, Title = nfo.Title, Plot = nfo.Plot, Year = GetYear(nfo.Year, nfo.Premiered), ReleaseDate = GetAired(nfo.Year, nfo.Premiered), 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() }, None); } catch (Exception ex) { _logger.LogInformation(ex, "Failed to read music video nfo metadata from {Path}", nfoFileName); return None; } } private async Task ApplyMetadataUpdate(Episode episode, Tuple metadataEpisodeNumber) { (EpisodeMetadata metadata, int episodeNumber) = metadataEpisodeNumber; if (episode.EpisodeNumber != episodeNumber) { await _televisionRepository.SetEpisodeNumber(episode, episodeNumber); } await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match( existing => { existing.Outline = metadata.Outline; existing.Plot = metadata.Plot; existing.Tagline = metadata.Tagline; existing.Title = metadata.Title; if (existing.DateAdded == DateTime.MinValue) { existing.DateAdded = metadata.DateAdded; } 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; return _metadataRepository.Update(existing); }, () => { metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) ? _fallbackMetadataProvider.GetSortTitle(metadata.Title) : metadata.SortTitle; metadata.EpisodeId = episode.Id; episode.EpisodeMetadata = new List { metadata }; return _metadataRepository.Add(metadata); }); return true; } private Task ApplyMetadataUpdate(Movie movie, MovieMetadata metadata) => Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match( async existing => { existing.Outline = metadata.Outline; existing.Plot = metadata.Plot; existing.Tagline = metadata.Tagline; existing.Title = metadata.Title; if (existing.DateAdded == DateTime.MinValue) { existing.DateAdded = metadata.DateAdded; } 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; bool updated = await UpdateMetadataCollections( existing, metadata, _movieRepository.AddGenre, _movieRepository.AddTag, _movieRepository.AddStudio); return await _metadataRepository.Update(existing) || updated; }, async () => { metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) ? _fallbackMetadataProvider.GetSortTitle(metadata.Title) : metadata.SortTitle; metadata.MovieId = movie.Id; movie.MovieMetadata = new List { metadata }; return await _metadataRepository.Add(metadata); }); private Task ApplyMetadataUpdate(Show show, ShowMetadata metadata) => Optional(show.ShowMetadata).Flatten().HeadOrNone().Match( async existing => { existing.Outline = metadata.Outline; existing.Plot = metadata.Plot; existing.Tagline = metadata.Tagline; existing.Title = metadata.Title; if (existing.DateAdded == DateTime.MinValue) { existing.DateAdded = metadata.DateAdded; } 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; bool updated = await UpdateMetadataCollections( existing, metadata, _televisionRepository.AddGenre, _televisionRepository.AddTag, _televisionRepository.AddStudio); return await _metadataRepository.Update(existing) || updated; }, async () => { metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) ? _fallbackMetadataProvider.GetSortTitle(metadata.Title) : metadata.SortTitle; metadata.ShowId = show.Id; show.ShowMetadata = new List { metadata }; return await _metadataRepository.Add(metadata); }); private Task ApplyMetadataUpdate(MusicVideo musicVideo, MusicVideoMetadata metadata) => Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match( async existing => { existing.Artist = metadata.Artist; existing.Title = metadata.Title; existing.Year = metadata.Year; existing.Plot = metadata.Plot; existing.Album = metadata.Album; if (existing.DateAdded == DateTime.MinValue) { existing.DateAdded = metadata.DateAdded; } existing.DateUpdated = metadata.DateUpdated; existing.MetadataKind = metadata.MetadataKind; existing.OriginalTitle = metadata.OriginalTitle; existing.ReleaseDate = metadata.ReleaseDate; existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) ? _fallbackMetadataProvider.GetSortTitle(metadata.Title) : metadata.SortTitle; bool updated = await UpdateMetadataCollections( existing, metadata, _musicVideoRepository.AddGenre, _musicVideoRepository.AddTag, _musicVideoRepository.AddStudio); return await _metadataRepository.Update(existing) || updated; }, async () => { metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) ? _fallbackMetadataProvider.GetSortTitle(metadata.Title) : metadata.SortTitle; metadata.MusicVideoId = musicVideo.Id; musicVideo.MusicVideoMetadata = new List { metadata }; return await _metadataRepository.Add(metadata); }); private async Task> LoadTelevisionShowMetadata(string nfoFileName) { try { await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read); Option maybeNfo = TvShowSerializer.Deserialize(fileStream) as TvShowNfo; return maybeNfo.Match>( nfo => new ShowMetadata { MetadataKind = MetadataKind.Sidecar, DateAdded = DateTime.UtcNow, DateUpdated = File.GetLastWriteTimeUtc(nfoFileName), Title = nfo.Title, Plot = nfo.Plot, Outline = nfo.Outline, Tagline = nfo.Tagline, Year = GetYear(nfo.Year, nfo.Premiered), ReleaseDate = GetAired(nfo.Year, nfo.Premiered), 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() }, None); } catch (Exception ex) { _logger.LogInformation(ex, "Failed to read TV show nfo metadata from {Path}", nfoFileName); return None; } } private async Task>> LoadEpisodeMetadata(Episode episode, string nfoFileName) { try { await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read); Option maybeNfo = EpisodeSerializer.Deserialize(fileStream) as TvShowEpisodeNfo; return maybeNfo.Match>>( nfo => { var metadata = new EpisodeMetadata { MetadataKind = MetadataKind.Sidecar, DateAdded = DateTime.UtcNow, DateUpdated = File.GetLastWriteTimeUtc(nfoFileName), Title = nfo.Title, ReleaseDate = GetAired(0, nfo.Aired), Plot = nfo.Plot }; return Tuple(metadata, nfo.Episode); }, None); } catch (Exception ex) { _logger.LogInformation(ex, "Failed to read TV episode nfo metadata from {Path}", nfoFileName); return _fallbackMetadataProvider.GetFallbackMetadata(episode); } } private async Task> LoadMovieMetadata(Movie movie, string nfoFileName) { try { await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read); Option maybeNfo = MovieSerializer.Deserialize(fileStream) as MovieNfo; return maybeNfo.Match>( nfo => new MovieMetadata { MetadataKind = MetadataKind.Sidecar, DateAdded = DateTime.UtcNow, DateUpdated = File.GetLastWriteTimeUtc(nfoFileName), Title = nfo.Title, Year = nfo.Year, ReleaseDate = nfo.Premiered, 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() }, None); } catch (Exception ex) { _logger.LogInformation(ex, "Failed to read Movie nfo metadata from {Path}", nfoFileName); return _fallbackMetadataProvider.GetFallbackMetadata(movie); } } private static int? GetYear(int year, string premiered) { if (year > 1000) { return year; } if (string.IsNullOrWhiteSpace(premiered)) { return null; } if (DateTime.TryParse(premiered, out DateTime parsed)) { return parsed.Year; } return null; } private static DateTime? GetAired(int year, string aired) { DateTime? fallback = year > 1000 ? new DateTime(year, 1, 1) : null; if (string.IsNullOrWhiteSpace(aired)) { return fallback; } return DateTime.TryParse(aired, out DateTime parsed) ? parsed : fallback; } private async Task UpdateMetadataCollections( T existing, T incoming, Func> addGenre, Func> addTag, Func> addStudio) where T : Domain.Metadata { var updated = false; foreach (Genre genre in existing.Genres.Filter(g => incoming.Genres.All(g2 => g2.Name != g.Name)) .ToList()) { existing.Genres.Remove(genre); if (await _metadataRepository.RemoveGenre(genre)) { updated = true; } } foreach (Genre genre in incoming.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name)) .ToList()) { existing.Genres.Add(genre); if (await addGenre(existing, genre)) { updated = true; } } foreach (Tag tag in existing.Tags.Filter(t => incoming.Tags.All(t2 => t2.Name != t.Name)) .ToList()) { existing.Tags.Remove(tag); if (await _metadataRepository.RemoveTag(tag)) { updated = true; } } foreach (Tag tag in incoming.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name)) .ToList()) { existing.Tags.Add(tag); if (await addTag(existing, tag)) { updated = true; } } foreach (Studio studio in existing.Studios .Filter(s => incoming.Studios.All(s2 => s2.Name != s.Name)) .ToList()) { existing.Studios.Remove(studio); if (await _metadataRepository.RemoveStudio(studio)) { updated = true; } } foreach (Studio studio in incoming.Studios .Filter(s => existing.Studios.All(s2 => s2.Name != s.Name)) .ToList()) { existing.Studios.Add(studio); if (await addStudio(existing, studio)) { updated = true; } } return updated; } } }