using Bugsnag; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata.Nfo; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Metadata.Nfo; using Microsoft.Extensions.Logging; namespace ErsatzTV.Core.Metadata; public class LocalMetadataProvider : ILocalMetadataProvider { private readonly IArtistNfoReader _artistNfoReader; private readonly IArtistRepository _artistRepository; private readonly IClient _client; private readonly IEpisodeNfoReader _episodeNfoReader; private readonly IFallbackMetadataProvider _fallbackMetadataProvider; private readonly ILocalFileSystem _localFileSystem; private readonly ILocalStatisticsProvider _localStatisticsProvider; private readonly ILogger _logger; private readonly IMetadataRepository _metadataRepository; private readonly IMovieNfoReader _movieNfoReader; 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; private readonly ITvShowNfoReader _tvShowNfoReader; public LocalMetadataProvider( IMetadataRepository metadataRepository, IMovieRepository movieRepository, ITelevisionRepository televisionRepository, IArtistRepository artistRepository, IMusicVideoRepository musicVideoRepository, IOtherVideoRepository otherVideoRepository, ISongRepository songRepository, IFallbackMetadataProvider fallbackMetadataProvider, ILocalFileSystem localFileSystem, IMovieNfoReader movieNfoReader, IEpisodeNfoReader episodeNfoReader, IArtistNfoReader artistNfoReader, IMusicVideoNfoReader musicVideoNfoReader, ITvShowNfoReader tvShowNfoReader, IOtherVideoNfoReader otherVideoNfoReader, ILocalStatisticsProvider localStatisticsProvider, IClient client, ILogger logger) { _metadataRepository = metadataRepository; _movieRepository = movieRepository; _televisionRepository = televisionRepository; _artistRepository = artistRepository; _musicVideoRepository = musicVideoRepository; _otherVideoRepository = otherVideoRepository; _songRepository = songRepository; _fallbackMetadataProvider = fallbackMetadataProvider; _localFileSystem = localFileSystem; _movieNfoReader = movieNfoReader; _episodeNfoReader = episodeNfoReader; _artistNfoReader = artistNfoReader; _musicVideoNfoReader = musicVideoNfoReader; _tvShowNfoReader = tvShowNfoReader; _otherVideoNfoReader = otherVideoNfoReader; _localStatisticsProvider = localStatisticsProvider; _client = client; _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); } foreach (ShowMetadata metadata in maybeMetadata) { metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title); return metadata; } ShowMetadata fallbackMetadata = _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder); fallbackMetadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(fallbackMetadata.Title); return fallbackMetadata; } public async Task GetMetadataForArtist(string artistFolder) { string nfoFileName = Path.Combine(artistFolder, "artist.nfo"); Option maybeMetadata = None; if (_localFileSystem.FileExists(nfoFileName)) { maybeMetadata = await LoadArtistMetadata(nfoFileName); } foreach (ArtistMetadata metadata in maybeMetadata) { metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title); return metadata; } ArtistMetadata fallbackMetadata = _fallbackMetadataProvider.GetFallbackMetadataForArtist(artistFolder); fallbackMetadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(fallbackMetadata.Title); return fallbackMetadata; } public async Task RefreshSidecarMetadata(Movie movie, string nfoFileName) { Option maybeMetadata = await LoadMovieMetadata(movie, nfoFileName); foreach (MovieMetadata metadata in maybeMetadata) { return await ApplyMetadataUpdate(movie, metadata); } return false; } public async Task RefreshSidecarMetadata(Show televisionShow, string nfoFileName) { Option maybeMetadata = await LoadTelevisionShowMetadata(nfoFileName); foreach (ShowMetadata metadata in maybeMetadata) { return await ApplyMetadataUpdate(televisionShow, metadata); } return false; } public async Task RefreshSidecarMetadata(Episode episode, string nfoFileName) { List metadata = await LoadEpisodeMetadata(episode, nfoFileName); return await ApplyMetadataUpdate(episode, metadata); } public async Task RefreshSidecarMetadata(Artist artist, string nfoFileName) { Option maybeMetadata = await LoadArtistMetadata(nfoFileName); foreach (ArtistMetadata metadata in maybeMetadata) { return await ApplyMetadataUpdate(artist, metadata); } return false; } public async Task RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName) { Option maybeMetadata = await LoadMusicVideoMetadata(nfoFileName); foreach (MusicVideoMetadata metadata in maybeMetadata) { return await ApplyMetadataUpdate(musicVideo, metadata); } return await RefreshFallbackMetadata(musicVideo); } public async Task RefreshSidecarMetadata(OtherVideo otherVideo, string nfoFileName) { Option maybeMetadata = await LoadOtherVideoMetadata(nfoFileName); foreach (OtherVideoMetadata metadata in maybeMetadata) { return await ApplyMetadataUpdate(otherVideo, metadata); } return await RefreshFallbackMetadata(otherVideo); } public async Task RefreshTagMetadata(Song song, string ffprobePath) { Option maybeMetadata = await LoadSongMetadata(song, ffprobePath); foreach (SongMetadata metadata in maybeMetadata) { return await ApplyMetadataUpdate(song, metadata); } return await RefreshFallbackMetadata(song); } public Task RefreshFallbackMetadata(Movie movie) => ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie)); public Task RefreshFallbackMetadata(Episode episode) => ApplyMetadataUpdate(episode, _fallbackMetadataProvider.GetFallbackMetadata(episode)); public Task RefreshFallbackMetadata(Artist artist, string artistFolder) => ApplyMetadataUpdate(artist, _fallbackMetadataProvider.GetFallbackMetadataForArtist(artistFolder)); public async Task RefreshFallbackMetadata(OtherVideo otherVideo) { Option maybeMetadata = _fallbackMetadataProvider.GetFallbackMetadata(otherVideo); foreach (OtherVideoMetadata metadata in maybeMetadata) { return await ApplyMetadataUpdate(otherVideo, metadata); } return false; } public async Task RefreshFallbackMetadata(Song song) { Option maybeMetadata = _fallbackMetadataProvider.GetFallbackMetadata(song); foreach (SongMetadata metadata in maybeMetadata) { return await ApplyMetadataUpdate(song, metadata); } return false; } public async Task RefreshFallbackMetadata(MusicVideo musicVideo) { Option maybeMetadata = _fallbackMetadataProvider.GetFallbackMetadata(musicVideo); foreach (MusicVideoMetadata metadata in maybeMetadata) { return await ApplyMetadataUpdate(musicVideo, metadata); } return false; } public Task RefreshFallbackMetadata(Show televisionShow, string showFolder) => ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder)); private async Task> LoadMusicVideoMetadata(string nfoFileName) { try { Either maybeNfo = await _musicVideoNfoReader.ReadFromFile(nfoFileName); foreach (BaseError error in maybeNfo.LeftToSeq()) { _logger.LogInformation( "Failed to read MusicVideo nfo metadata from {Path}: {Error}", nfoFileName, error.ToString()); } foreach (MusicVideoNfo nfo in maybeNfo.RightToSeq()) { return new MusicVideoMetadata { MetadataKind = MetadataKind.Sidecar, DateAdded = DateTime.UtcNow, DateUpdated = File.GetLastWriteTimeUtc(nfoFileName), Album = nfo.Album, Title = nfo.Title, Plot = nfo.Plot, Track = nfo.Track, Year = GetYear(nfo.Year, nfo.Aired), ReleaseDate = GetAired(nfo.Year, nfo.Aired), Artists = nfo.Artists.Map(a => new MusicVideoArtist { Name = a }).ToList(), 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() }; } return None; } catch (Exception ex) { _logger.LogInformation(ex, "Failed to read music video nfo metadata from {Path}", nfoFileName); _client.Notify(ex); return None; } } private async Task> LoadSongMetadata(Song song, string ffprobePath) { string path = song.GetHeadVersion().MediaFiles.Head().Path; try { Either> maybeTags = await _localStatisticsProvider.GetFormatTags(ffprobePath, song); foreach (Dictionary tags in maybeTags.RightToSeq()) { Option maybeFallbackMetadata = _fallbackMetadataProvider.GetFallbackMetadata(song); var result = new SongMetadata { MetadataKind = MetadataKind.Embedded, DateAdded = DateTime.UtcNow, DateUpdated = File.GetLastWriteTimeUtc(path), Artwork = new List(), Actors = new List(), Genres = new List(), Studios = new List(), Tags = new List() }; if (tags.TryGetValue(MetadataFormatTag.Album, out string album)) { result.Album = album; } if (tags.TryGetValue(MetadataFormatTag.Artist, out string artist)) { result.Artist = artist; } if (tags.TryGetValue(MetadataFormatTag.AlbumArtist, out string albumArtist)) { result.AlbumArtist = albumArtist; } if (tags.TryGetValue(MetadataFormatTag.Date, out string date)) { result.Date = date; } if (tags.TryGetValue(MetadataFormatTag.Genre, out string genre)) { result.Genres.AddRange(SplitGenres(genre).Map(n => new Genre { Name = n })); } if (tags.TryGetValue(MetadataFormatTag.Title, out string title)) { result.Title = title; } if (tags.TryGetValue(MetadataFormatTag.Track, out string track)) { result.Track = track; } foreach (SongMetadata fallbackMetadata in maybeFallbackMetadata) { if (string.IsNullOrWhiteSpace(result.Title)) { result.Title = fallbackMetadata.Title; } result.OriginalTitle = fallbackMetadata.OriginalTitle; // preserve folder tagging - maybe someone uses this foreach (Tag tag in fallbackMetadata.Tags) { result.Tags.Add(tag); } } return result; } return Option.None; } catch (Exception ex) { _logger.LogInformation(ex, "Failed to read embedded song metadata from {Path}", path); _client.Notify(ex); return None; } } private async Task ApplyMetadataUpdate(Episode episode, List episodeMetadata) { var updated = false; episode.EpisodeMetadata ??= new List(); var toUpdate = episode.EpisodeMetadata .Where(em => episodeMetadata.Any(em2 => em2.EpisodeNumber == em.EpisodeNumber)) .ToList(); var toRemove = episode.EpisodeMetadata.Except(toUpdate).ToList(); var toAdd = episodeMetadata .Where(em => episode.EpisodeMetadata.All(em2 => em2.EpisodeNumber != em.EpisodeNumber)) .ToList(); foreach (EpisodeMetadata metadata in toRemove) { await _televisionRepository.RemoveMetadata(episode, metadata); updated = true; } foreach (EpisodeMetadata metadata in toAdd) { metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) ? _fallbackMetadataProvider.GetSortTitle(metadata.Title) : metadata.SortTitle; metadata.EpisodeId = episode.Id; metadata.Episode = episode; episode.EpisodeMetadata.Add(metadata); updated = await _metadataRepository.Add(metadata) || updated; } foreach (EpisodeMetadata existing in toUpdate) { Option maybeIncoming = episodeMetadata.Find(em => em.EpisodeNumber == existing.EpisodeNumber); foreach (EpisodeMetadata metadata in maybeIncoming) { existing.Outline = metadata.Outline; existing.Plot = metadata.Plot; existing.Tagline = metadata.Tagline; existing.Title = metadata.Title; if (existing.DateAdded == SystemTime.MinValueUtc) { 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; updated = await UpdateMetadataCollections( existing, metadata, (_, _) => Task.FromResult(false), (_, _) => Task.FromResult(false), (_, _) => Task.FromResult(false), _televisionRepository.AddActor) || updated; 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 _televisionRepository.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 _televisionRepository.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; } } return updated; } private async Task ApplyMetadataUpdate(Movie movie, MovieMetadata metadata) { Option maybeMetadata = Optional(movie.MovieMetadata).Flatten().HeadOrNone(); foreach (MovieMetadata 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) { 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, _movieRepository.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 _movieRepository.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 _movieRepository.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; } 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 async Task ApplyMetadataUpdate(Show show, ShowMetadata metadata) { Option maybeMetadata = Optional(show.ShowMetadata).Flatten().HeadOrNone(); foreach (ShowMetadata 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) { 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, _televisionRepository.AddActor); 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; } 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 async Task ApplyMetadataUpdate(Artist artist, ArtistMetadata metadata) { Option maybeMetadata = Optional(artist.ArtistMetadata).Flatten().HeadOrNone(); foreach (ArtistMetadata existing in maybeMetadata) { existing.Title = metadata.Title; existing.Disambiguation = metadata.Disambiguation; existing.Biography = metadata.Biography; if (existing.DateAdded == SystemTime.MinValueUtc) { existing.DateAdded = metadata.DateAdded; } existing.DateUpdated = metadata.DateUpdated; existing.MetadataKind = metadata.MetadataKind; existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) ? _fallbackMetadataProvider.GetSortTitle(metadata.Title) : metadata.SortTitle; var updated = false; foreach (Genre genre in existing.Genres.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name)) .ToList()) { existing.Genres.Remove(genre); if (await _metadataRepository.RemoveGenre(genre)) { updated = true; } } foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name)) .ToList()) { existing.Genres.Add(genre); if (await _artistRepository.AddGenre(existing, genre)) { updated = true; } } foreach (Style style in existing.Styles.Filter(s => metadata.Styles.All(s2 => s2.Name != s.Name)) .ToList()) { existing.Styles.Remove(style); if (await _metadataRepository.RemoveStyle(style)) { updated = true; } } foreach (Style style in metadata.Styles.Filter(s => existing.Styles.All(s2 => s2.Name != s.Name)) .ToList()) { existing.Styles.Add(style); if (await _artistRepository.AddStyle(existing, style)) { updated = true; } } foreach (Mood mood in existing.Moods.Filter(m => metadata.Moods.All(m2 => m2.Name != m.Name)) .ToList()) { existing.Moods.Remove(mood); if (await _metadataRepository.RemoveMood(mood)) { updated = true; } } foreach (Mood mood in metadata.Moods.Filter(s => existing.Moods.All(m2 => m2.Name != s.Name)) .ToList()) { existing.Moods.Add(mood); if (await _artistRepository.AddMood(existing, mood)) { updated = true; } } return await _metadataRepository.Update(existing) || updated; } metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) ? _fallbackMetadataProvider.GetSortTitle(metadata.Title) : metadata.SortTitle; metadata.ArtistId = artist.Id; artist.ArtistMetadata = new List { metadata }; return await _metadataRepository.Add(metadata); } private async Task ApplyMetadataUpdate(MusicVideo musicVideo, MusicVideoMetadata metadata) { Option maybeMetadata = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone(); foreach (MusicVideoMetadata existing in maybeMetadata) { existing.Title = metadata.Title; existing.Year = metadata.Year; existing.Plot = metadata.Plot; existing.Track = metadata.Track; existing.Album = metadata.Album; if (existing.DateAdded == SystemTime.MinValueUtc) { 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, (_, _) => Task.FromResult(false)); foreach (MusicVideoArtist artist in existing.Artists .Filter(g => metadata.Artists.All(g2 => g2.Name != g.Name)) .ToList()) { existing.Artists.Remove(artist); if (await _musicVideoRepository.RemoveArtist(artist)) { updated = true; } } foreach (MusicVideoArtist artist in metadata.Artists .Filter(g => existing.Artists.All(g2 => g2.Name != g.Name)) .ToList()) { existing.Artists.Add(artist); if (await _musicVideoRepository.AddArtist(existing, artist)) { updated = true; } } return await _metadataRepository.Update(existing) || updated; } 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 ApplyMetadataUpdate(OtherVideo otherVideo, OtherVideoMetadata metadata) { Option 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) { 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; existing.OriginalTitle = metadata.OriginalTitle; bool updated = await UpdateMetadataCollections( existing, metadata, _otherVideoRepository.AddGenre, _otherVideoRepository.AddTag, _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; } metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) ? _fallbackMetadataProvider.GetSortTitle(metadata.Title) : metadata.SortTitle; metadata.OtherVideoId = otherVideo.Id; otherVideo.OtherVideoMetadata = new List { metadata }; return await _metadataRepository.Add(metadata); } private async Task ApplyMetadataUpdate(Song song, SongMetadata metadata) { Option maybeMetadata = Optional(song.SongMetadata).Flatten().HeadOrNone(); foreach (SongMetadata existing in maybeMetadata) { existing.Title = metadata.Title; existing.Artist = metadata.Artist; existing.AlbumArtist = metadata.AlbumArtist; existing.Album = metadata.Album; existing.Date = metadata.Date; existing.Track = metadata.Track; if (existing.DateAdded == SystemTime.MinValueUtc) { existing.DateAdded = metadata.DateAdded; } existing.DateUpdated = metadata.DateUpdated; existing.MetadataKind = metadata.MetadataKind; existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) ? _fallbackMetadataProvider.GetSortTitle(metadata.Title) : metadata.SortTitle; existing.OriginalTitle = metadata.OriginalTitle; bool updated = await UpdateMetadataCollections( existing, metadata, _songRepository.AddGenre, _songRepository.AddTag, (_, _) => Task.FromResult(false), (_, _) => Task.FromResult(false)); return await _metadataRepository.Update(existing) || updated; } metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) ? _fallbackMetadataProvider.GetSortTitle(metadata.Title) : metadata.SortTitle; metadata.SongId = song.Id; song.SongMetadata = new List { metadata }; return await _metadataRepository.Add(metadata); } private async Task> LoadTelevisionShowMetadata(string nfoFileName) { try { Either maybeNfo = await _tvShowNfoReader.ReadFromFile(nfoFileName); foreach (BaseError error in maybeNfo.LeftToSeq()) { _logger.LogInformation( "Failed to read TvShow nfo metadata from {Path}: {Error}", nfoFileName, error.ToString()); } foreach (TvShowNfo nfo in maybeNfo.RightToSeq()) { DateTime dateAdded = DateTime.UtcNow; DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName); return new ShowMetadata { MetadataKind = MetadataKind.Sidecar, DateAdded = dateAdded, DateUpdated = dateUpdated, Title = nfo.Title, Plot = nfo.Plot, Outline = nfo.Outline, Tagline = nfo.Tagline, ContentRating = nfo.ContentRating, 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(), Actors = Actors(nfo.Actors, dateAdded, dateUpdated), Guids = nfo.UniqueIds .Map(id => new MetadataGuid { Guid = $"{id.Type}://{id.Guid}" }) .ToList() }; } return None; } catch (Exception ex) { _logger.LogInformation(ex, "Failed to read TV show nfo metadata from {Path}", nfoFileName); _client.Notify(ex); return None; } } private async Task> LoadArtistMetadata(string nfoFileName) { try { Either maybeNfo = await _artistNfoReader.ReadFromFile(nfoFileName); foreach (BaseError error in maybeNfo.LeftToSeq()) { _logger.LogInformation( "Failed to read Artist nfo metadata from {Path}: {Error}", nfoFileName, error.ToString()); } foreach (ArtistNfo nfo in maybeNfo.RightToSeq()) { return new ArtistMetadata { MetadataKind = MetadataKind.Sidecar, DateAdded = DateTime.UtcNow, DateUpdated = File.GetLastWriteTimeUtc(nfoFileName), Title = nfo.Name, Disambiguation = nfo.Disambiguation, Biography = nfo.Biography, Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(), Styles = nfo.Styles.Map(s => new Style { Name = s }).ToList(), Moods = nfo.Moods.Map(m => new Mood { Name = m }).ToList() }; } return None; } catch (Exception ex) { _logger.LogInformation(ex, "Failed to read artist nfo metadata from {Path}", nfoFileName); _client.Notify(ex); return None; } } private async Task> LoadEpisodeMetadata(Episode episode, string nfoFileName) { try { Either> maybeNfo = await _episodeNfoReader.ReadFromFile(nfoFileName); foreach (BaseError error in maybeNfo.LeftToSeq()) { _logger.LogInformation( "Failed to read Episode nfo metadata from {Path}: {Error}", nfoFileName, error.ToString()); } var result = new List(); foreach (TvShowEpisodeNfo nfo in maybeNfo.RightToSeq().Flatten()) { DateTime dateAdded = DateTime.UtcNow; DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName); var metadata = new EpisodeMetadata { MetadataKind = MetadataKind.Sidecar, DateAdded = dateAdded, DateUpdated = dateUpdated, Title = nfo.Title, SortTitle = _fallbackMetadataProvider.GetSortTitle(nfo.Title), EpisodeNumber = nfo.Episode, Year = GetYear(0, nfo.Aired), ReleaseDate = GetAired(0, nfo.Aired), Plot = nfo.Plot, Actors = Actors(nfo.Actors, dateAdded, dateUpdated), Guids = nfo.UniqueIds .Map(id => new MetadataGuid { Guid = $"{id.Type}://{id.Guid}" }) .ToList(), Directors = nfo.Directors.Map(d => new Director { Name = d }).ToList(), Writers = nfo.Writers.Map(w => new Writer { Name = w }).ToList(), Genres = new List(), Tags = new List(), Studios = new List(), Artwork = new List() }; result.Add(metadata); } return result; } catch (Exception ex) { _logger.LogInformation(ex, "Failed to read TV episode nfo metadata from {Path}", nfoFileName); _client.Notify(ex); return _fallbackMetadataProvider.GetFallbackMetadata(episode); } } private async Task> LoadMovieMetadata(Movie movie, string nfoFileName) { try { Either maybeNfo = await _movieNfoReader.ReadFromFile(nfoFileName); foreach (BaseError error in maybeNfo.LeftToSeq()) { _logger.LogInformation( "Failed to read Movie nfo metadata from {Path}: {Error}", nfoFileName, error.ToString()); return _fallbackMetadataProvider.GetFallbackMetadata(movie); } foreach (MovieNfo 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 MovieMetadata { 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() }; } return None; } catch (Exception ex) { _logger.LogInformation(ex, "Failed to read Movie nfo metadata from {Path}", nfoFileName); _client.Notify(ex); return _fallbackMetadataProvider.GetFallbackMetadata(movie); } } private async Task> LoadOtherVideoMetadata(string nfoFileName) { try { Either maybeNfo = await _otherVideoNfoReader.ReadFromFile(nfoFileName); 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 premiered) { if (year is > 1000) { return year; } foreach (DateTime p in premiered) { return p.Year; } return null; } private static DateTime? GetAired(int? year, Option aired) { DateTime? fallback = year is > 1000 ? new DateTime(year.Value, 1, 1) : null; foreach (DateTime a in aired) { return a; } return fallback; } private async Task UpdateMetadataCollections( T existing, T incoming, Func> addGenre, Func> addTag, Func> addStudio, Func> addActor) where T : Domain.Metadata { var updated = false; if (existing is not EpisodeMetadata) { 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; } } } if (existing is not MusicVideoMetadata and not SongMetadata) { foreach (Actor actor in existing.Actors .Filter(a => incoming.Actors.All(a2 => a2.Name != a.Name)) .ToList()) { existing.Actors.Remove(actor); if (await _metadataRepository.RemoveActor(actor)) { updated = true; } } foreach (Actor actor in incoming.Actors .Filter(a => existing.Actors.All(a2 => a2.Name != a.Name)) .ToList()) { existing.Actors.Add(actor); if (await addActor(existing, actor)) { updated = true; } } } return updated; } private static List Actors(List actorNfos, DateTime dateAdded, DateTime dateUpdated) { var result = new List(); for (var i = 0; i < actorNfos.Count; i++) { ActorNfo actorNfo = actorNfos[i]; var actor = new Actor { Name = actorNfo.Name, Role = actorNfo.Role, Order = actorNfo.Order ?? i }; if (!string.IsNullOrWhiteSpace(actorNfo.Thumb)) { actor.Artwork = new Artwork { Path = actorNfo.Thumb, ArtworkKind = ArtworkKind.Thumbnail, DateAdded = dateAdded, DateUpdated = dateUpdated }; } result.Add(actor); } return result; } private static IEnumerable SplitGenres(string genre) { char[] delimiters = new[] { '/', '|', ';', '\\' } .Filter(d => genre.IndexOf(d, StringComparison.OrdinalIgnoreCase) != -1) .DefaultIfEmpty(',') .ToArray(); return genre.Split(delimiters, StringSplitOptions.RemoveEmptyEntries) .Where(i => !string.IsNullOrWhiteSpace(i)) .Select(i => i.Trim()); } }