diff --git a/CHANGELOG.md b/CHANGELOG.md index 514a656e..bcea14a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Fixed +- Cleanly stop local library scan when service termination is requested + +### Changed +- Update Plex, Jellyfin and Emby movie library scanners to share a significant amount of code + - This should help maintain feature parity going forward + +### Added +- Add `unavailable` state for Emby movie libraries ## [0.5.3-beta] - 2022-04-24 ### Fixed diff --git a/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs b/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs index af37335b..9446542c 100644 --- a/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs +++ b/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs @@ -49,19 +49,24 @@ public class SynchronizeEmbyLibraryByIdHandler : public Task> Handle( ForceSynchronizeEmbyLibraryById request, - CancellationToken cancellationToken) => Handle(request); + CancellationToken cancellationToken) => HandleImpl(request, cancellationToken); public Task> Handle( SynchronizeEmbyLibraryByIdIfNeeded request, - CancellationToken cancellationToken) => Handle(request); + CancellationToken cancellationToken) => HandleImpl(request, cancellationToken); - private Task> - Handle(ISynchronizeEmbyLibraryById request) => - Validate(request) - .MapT(parameters => Synchronize(parameters).Map(_ => parameters.Library.Name)) - .Bind(v => v.ToEitherAsync()); + private async Task> + HandleImpl(ISynchronizeEmbyLibraryById request, CancellationToken cancellationToken) + { + Validation validation = await Validate(request); + return await validation.Match( + parameters => Synchronize(parameters, cancellationToken), + error => Task.FromResult>(error.Join())); + } - private async Task Synchronize(RequestParameters parameters) + private async Task> Synchronize( + RequestParameters parameters, + CancellationToken cancellationToken) { try { @@ -77,15 +82,17 @@ public class SynchronizeEmbyLibraryByIdHandler : parameters.ConnectionParameters.ApiKey, parameters.Library, parameters.FFmpegPath, - parameters.FFprobePath), + parameters.FFprobePath, + cancellationToken), LibraryMediaKind.Shows => await _embyTelevisionLibraryScanner.ScanLibrary( parameters.ConnectionParameters.ActiveConnection.Address, parameters.ConnectionParameters.ApiKey, parameters.Library, parameters.FFmpegPath, - parameters.FFprobePath), - _ => BaseError.New("Unsupported library media kind") + parameters.FFprobePath, + cancellationToken), + _ => Unit.Default }; if (result.IsRight) @@ -94,17 +101,18 @@ public class SynchronizeEmbyLibraryByIdHandler : await _libraryRepository.UpdateLastScan(parameters.Library); await _embyWorkerChannel.WriteAsync( - new SynchronizeEmbyCollections(parameters.Library.MediaSourceId)); + new SynchronizeEmbyCollections(parameters.Library.MediaSourceId), + cancellationToken); } + + return result.Map(_ => parameters.Library.Name); } else { - _logger.LogDebug( - "Skipping unforced scan of emby media library {Name}", - parameters.Library.Name); + _logger.LogDebug("Skipping unforced scan of emby media library {Name}", parameters.Library.Name); } - return Unit.Default; + return parameters.Library.Name; } finally { diff --git a/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsHandler.cs b/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsHandler.cs index d7861555..489872a6 100644 --- a/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsHandler.cs +++ b/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsHandler.cs @@ -13,7 +13,14 @@ public class GetAllHealthCheckResultsHandler : IRequestHandler results = await _healthCheckService.PerformHealthChecks(cancellationToken); - return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList(); + try + { + List results = await _healthCheckService.PerformHealthChecks(cancellationToken); + return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList(); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return new List(); + } } } diff --git a/ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs b/ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs index 47694aac..52b06cd1 100644 --- a/ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs +++ b/ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs @@ -49,19 +49,24 @@ public class SynchronizeJellyfinLibraryByIdHandler : public Task> Handle( ForceSynchronizeJellyfinLibraryById request, - CancellationToken cancellationToken) => Handle(request); + CancellationToken cancellationToken) => HandleImpl(request, cancellationToken); public Task> Handle( SynchronizeJellyfinLibraryByIdIfNeeded request, - CancellationToken cancellationToken) => Handle(request); + CancellationToken cancellationToken) => HandleImpl(request, cancellationToken); - private Task> - Handle(ISynchronizeJellyfinLibraryById request) => - Validate(request) - .MapT(parameters => Synchronize(parameters).Map(_ => parameters.Library.Name)) - .Bind(v => v.ToEitherAsync()); + private async Task> + HandleImpl(ISynchronizeJellyfinLibraryById request, CancellationToken cancellationToken) + { + Validation validation = await Validate(request); + return await validation.Match( + parameters => Synchronize(parameters, cancellationToken), + error => Task.FromResult>(error.Join())); + } - private async Task Synchronize(RequestParameters parameters) + private async Task> Synchronize( + RequestParameters parameters, + CancellationToken cancellationToken) { try { @@ -77,15 +82,17 @@ public class SynchronizeJellyfinLibraryByIdHandler : parameters.ConnectionParameters.ApiKey, parameters.Library, parameters.FFmpegPath, - parameters.FFprobePath), + parameters.FFprobePath, + cancellationToken), LibraryMediaKind.Shows => await _jellyfinTelevisionLibraryScanner.ScanLibrary( parameters.ConnectionParameters.ActiveConnection.Address, parameters.ConnectionParameters.ApiKey, parameters.Library, parameters.FFmpegPath, - parameters.FFprobePath), - _ => BaseError.New("Unsupported library media kind") + parameters.FFprobePath, + cancellationToken), + _ => Unit.Default }; if (result.IsRight) @@ -94,17 +101,18 @@ public class SynchronizeJellyfinLibraryByIdHandler : await _libraryRepository.UpdateLastScan(parameters.Library); await _jellyfinWorkerChannel.WriteAsync( - new SynchronizeJellyfinCollections(parameters.Library.MediaSourceId)); + new SynchronizeJellyfinCollections(parameters.Library.MediaSourceId), + cancellationToken); } + + return result.Map(_ => parameters.Library.Name); } else { - _logger.LogDebug( - "Skipping unforced scan of jellyfin media library {Name}", - parameters.Library.Name); + _logger.LogDebug("Skipping unforced scan of jellyfin media library {Name}", parameters.Library.Name); } - return Unit.Default; + return parameters.Library.Name; } finally { diff --git a/ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs b/ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs index 91671da4..f7d9ca9e 100644 --- a/ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs +++ b/ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs @@ -85,56 +85,56 @@ public class ScanLocalLibraryHandler : IRequestHandler result = localLibrary.MediaKind switch { - case LibraryMediaKind.Movies: + LibraryMediaKind.Movies => await _movieFolderScanner.ScanFolder( libraryPath, ffmpegPath, ffprobePath, progressMin, progressMax, - cancellationToken); - break; - case LibraryMediaKind.Shows: + cancellationToken), + LibraryMediaKind.Shows => await _televisionFolderScanner.ScanFolder( libraryPath, ffmpegPath, ffprobePath, progressMin, progressMax, - cancellationToken); - break; - case LibraryMediaKind.MusicVideos: + cancellationToken), + LibraryMediaKind.MusicVideos => await _musicVideoFolderScanner.ScanFolder( libraryPath, ffmpegPath, ffprobePath, progressMin, progressMax, - cancellationToken); - break; - case LibraryMediaKind.OtherVideos: + cancellationToken), + LibraryMediaKind.OtherVideos => await _otherVideoFolderScanner.ScanFolder( libraryPath, ffmpegPath, ffprobePath, progressMin, - progressMax); - break; - case LibraryMediaKind.Songs: + progressMax, + cancellationToken), + LibraryMediaKind.Songs => await _songFolderScanner.ScanFolder( libraryPath, ffprobePath, ffmpegPath, progressMin, progressMax, - cancellationToken); - break; - } + cancellationToken), + _ => Unit.Default + }; - libraryPath.LastScan = DateTime.UtcNow; - await _libraryRepository.UpdateLastScan(libraryPath); + if (result.IsRight) + { + libraryPath.LastScan = DateTime.UtcNow; + await _libraryRepository.UpdateLastScan(libraryPath); + } } await _mediator.Publish(new LibraryScanProgress(libraryPath.LibraryId, progressMax), cancellationToken); diff --git a/ErsatzTV.Core/Domain/MediaServer/MediaServerConnectionParameters.cs b/ErsatzTV.Core/Domain/MediaServer/MediaServerConnectionParameters.cs new file mode 100644 index 00000000..2db481e2 --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaServer/MediaServerConnectionParameters.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Domain.MediaServer; + +public abstract record MediaServerConnectionParameters; diff --git a/ErsatzTV.Core/Domain/Metadata/MediaServerItemEtag.cs b/ErsatzTV.Core/Domain/Metadata/MediaServerItemEtag.cs new file mode 100644 index 00000000..17f3ef52 --- /dev/null +++ b/ErsatzTV.Core/Domain/Metadata/MediaServerItemEtag.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Domain; + +public abstract class MediaServerItemEtag +{ + public abstract string MediaServerItemId { get; } + public abstract string Etag { get; set; } + public abstract MediaItemState State { get; set; } +} diff --git a/ErsatzTV.Core/Emby/EmbyConnectionParameters.cs b/ErsatzTV.Core/Emby/EmbyConnectionParameters.cs new file mode 100644 index 00000000..e5c0a041 --- /dev/null +++ b/ErsatzTV.Core/Emby/EmbyConnectionParameters.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Domain.MediaServer; + +namespace ErsatzTV.Core.Emby; + +public record EmbyConnectionParameters(string Address, string ApiKey) : MediaServerConnectionParameters; diff --git a/ErsatzTV.Core/Emby/EmbyItemEtag.cs b/ErsatzTV.Core/Emby/EmbyItemEtag.cs index 52740d0c..b3681d20 100644 --- a/ErsatzTV.Core/Emby/EmbyItemEtag.cs +++ b/ErsatzTV.Core/Emby/EmbyItemEtag.cs @@ -1,7 +1,11 @@ -namespace ErsatzTV.Core.Emby; +using ErsatzTV.Core.Domain; -public class EmbyItemEtag +namespace ErsatzTV.Core.Emby; + +public class EmbyItemEtag : MediaServerItemEtag { public string ItemId { get; set; } - public string Etag { get; set; } + public override string MediaServerItemId => ItemId; + public override string Etag { get; set; } + public override MediaItemState State { get; set; } } diff --git a/ErsatzTV.Core/Emby/EmbyMovieLibraryScanner.cs b/ErsatzTV.Core/Emby/EmbyMovieLibraryScanner.cs index 65777498..6dc5fc4d 100644 --- a/ErsatzTV.Core/Emby/EmbyMovieLibraryScanner.cs +++ b/ErsatzTV.Core/Emby/EmbyMovieLibraryScanner.cs @@ -1,53 +1,49 @@ using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Metadata; -using LanguageExt.UnsafeValueAccess; using MediatR; using Microsoft.Extensions.Logging; namespace ErsatzTV.Core.Emby; -public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner +public class EmbyMovieLibraryScanner : + MediaServerMovieLibraryScanner, + IEmbyMovieLibraryScanner { private readonly IEmbyApiClient _embyApiClient; - private readonly ILocalFileSystem _localFileSystem; - private readonly ILocalStatisticsProvider _localStatisticsProvider; - private readonly ILocalSubtitlesProvider _localSubtitlesProvider; - private readonly ILogger _logger; + private readonly IEmbyMovieRepository _embyMovieRepository; private readonly IMediaSourceRepository _mediaSourceRepository; - private readonly IMediator _mediator; - private readonly IMovieRepository _movieRepository; private readonly IEmbyPathReplacementService _pathReplacementService; - private readonly ISearchIndex _searchIndex; - private readonly ISearchRepository _searchRepository; public EmbyMovieLibraryScanner( IEmbyApiClient embyApiClient, ISearchIndex searchIndex, IMediator mediator, - IMovieRepository movieRepository, + IMediaSourceRepository mediaSourceRepository, + IEmbyMovieRepository embyMovieRepository, ISearchRepository searchRepository, IEmbyPathReplacementService pathReplacementService, - IMediaSourceRepository mediaSourceRepository, ILocalFileSystem localFileSystem, ILocalStatisticsProvider localStatisticsProvider, ILocalSubtitlesProvider localSubtitlesProvider, ILogger logger) + : base( + localStatisticsProvider, + localSubtitlesProvider, + localFileSystem, + mediator, + searchIndex, + searchRepository, + logger) { _embyApiClient = embyApiClient; - _searchIndex = searchIndex; - _mediator = mediator; - _movieRepository = movieRepository; - _searchRepository = searchRepository; - _pathReplacementService = pathReplacementService; _mediaSourceRepository = mediaSourceRepository; - _localFileSystem = localFileSystem; - _localStatisticsProvider = localStatisticsProvider; - _localSubtitlesProvider = localSubtitlesProvider; - _logger = logger; + _embyMovieRepository = embyMovieRepository; + _pathReplacementService = pathReplacementService; } public async Task> ScanLibrary( @@ -55,197 +51,52 @@ public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner string apiKey, EmbyLibrary library, string ffmpegPath, - string ffprobePath) + string ffprobePath, + CancellationToken cancellationToken) { - List existingMovies = await _movieRepository.GetExistingEmbyMovies(library); - - // TODO: maybe get quick list of item ids and etags from api to compare first - // TODO: paging? - - List pathReplacements = await _mediaSourceRepository - .GetEmbyPathReplacements(library.MediaSourceId); - - Either> maybeMovies = await _embyApiClient.GetMovieLibraryItems( - address, - apiKey, - library.ItemId); - - await maybeMovies.Match( - async movies => - { - var validMovies = new List(); - foreach (EmbyMovie movie in movies.OrderBy(m => m.MovieMetadata.Head().Title)) - { - string localPath = _pathReplacementService.GetReplacementEmbyPath( - pathReplacements, - movie.MediaVersions.Head().MediaFiles.Head().Path, - false); - - if (!_localFileSystem.FileExists(localPath)) - { - _logger.LogWarning("Skipping emby movie that does not exist at {Path}", localPath); - } - else - { - validMovies.Add(movie); - } - } - - foreach (EmbyMovie incoming in validMovies) - { - EmbyMovie incomingMovie = incoming; - - decimal percentCompletion = (decimal)validMovies.IndexOf(incoming) / validMovies.Count; - await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion)); - - Option maybeExisting = - existingMovies.Find(ie => ie.ItemId == incoming.ItemId); - - var updateStatistics = false; - - await maybeExisting.Match( - async existing => - { - try - { - if (existing.Etag == incoming.Etag) - { - // _logger.LogDebug( - // $"NOOP: Etag has not changed for movie {incoming.MovieMetadata.Head().Title}"); - return; - } - - _logger.LogDebug( - "UPDATE: Etag has changed for movie {Movie}", - incoming.MovieMetadata.Head().Title); - - updateStatistics = true; - incoming.LibraryPathId = library.Paths.Head().Id; - Option maybeUpdated = await _movieRepository.UpdateEmby(incoming); - foreach (EmbyMovie updated in maybeUpdated) - { - await _searchIndex.UpdateItems( - _searchRepository, - new List { updated }); - - incomingMovie = updated; - } - } - catch (Exception ex) - { - updateStatistics = false; - _logger.LogError( - ex, - "Error updating movie {Movie}", - incoming.MovieMetadata.Head().Title); - } - }, - async () => - { - try - { - // _logger.LogDebug( - // $"INSERT: Item id is new for movie {incoming.MovieMetadata.Head().Title}"); - - updateStatistics = true; - incoming.LibraryPathId = library.Paths.Head().Id; - if (await _movieRepository.AddEmby(incoming)) - { - await _searchIndex.AddItems( - _searchRepository, - new List { incoming }); - } - } - catch (Exception ex) - { - updateStatistics = false; - _logger.LogError( - ex, - "Error adding movie {Movie}", - incoming.MovieMetadata.Head().Title); - } - }); + List pathReplacements = + await _mediaSourceRepository.GetEmbyPathReplacements(library.MediaSourceId); - if (updateStatistics) - { - string localPath = _pathReplacementService.GetReplacementEmbyPath( - pathReplacements, - incoming.MediaVersions.Head().MediaFiles.Head().Path, - false); - - _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); - Either refreshResult = - await _localStatisticsProvider.RefreshStatistics( - ffmpegPath, - ffprobePath, - incomingMovie, - localPath); - - if (refreshResult.Map(t => t).IfLeft(false)) - { - refreshResult = await UpdateSubtitles(incomingMovie, localPath); - } - - await refreshResult.Match( - async _ => - { - Option updated = await _searchRepository.GetItemToIndex(incomingMovie.Id); - if (updated.IsSome) - { - await _searchIndex.UpdateItems( - _searchRepository, - new List { updated.ValueUnsafe() }); - } - }, - error => - { - _logger.LogWarning( - "Unable to refresh {Attribute} for media item {Path}. Error: {Error}", - "Statistics", - localPath, - error.Value); - - return Task.CompletedTask; - }); - } - - // TODO: figure out how to rebuild playlists - } - - var incomingMovieIds = validMovies.Map(s => s.ItemId).ToList(); - var movieIds = existingMovies - .Filter(i => !incomingMovieIds.Contains(i.ItemId)) - .Map(m => m.ItemId) - .ToList(); - List ids = await _movieRepository.RemoveMissingEmbyMovies(library, movieIds); - await _searchIndex.RemoveItems(ids); + string GetLocalPath(EmbyMovie movie) + { + return _pathReplacementService.GetReplacementEmbyPath( + pathReplacements, + movie.GetHeadVersion().MediaFiles.Head().Path, + false); + } - await _mediator.Publish(new LibraryScanProgress(library.Id, 0)); - _searchIndex.Commit(); - }, - error => - { - _logger.LogWarning( - "Error synchronizing emby library {Path}: {Error}", - library.Name, - error.Value); + return await ScanLibrary( + _embyMovieRepository, + new EmbyConnectionParameters(address, apiKey), + library, + GetLocalPath, + ffmpegPath, + ffprobePath, + false, + cancellationToken); + } - return Task.CompletedTask; - }); + protected override string MediaServerItemId(EmbyMovie movie) => movie.ItemId; + protected override string MediaServerEtag(EmbyMovie movie) => movie.Etag; - _searchIndex.Commit(); - return Unit.Default; - } + protected override Task>> GetMovieLibraryItems( + EmbyConnectionParameters connectionParameters, + EmbyLibrary library) => + _embyApiClient.GetMovieLibraryItems( + connectionParameters.Address, + connectionParameters.ApiKey, + library.ItemId); - private async Task> UpdateSubtitles(EmbyMovie movie, string localPath) - { - try - { - return await _localSubtitlesProvider.UpdateSubtitles(movie, localPath, false); - } - catch (Exception ex) - { - return BaseError.New(ex.ToString()); - } - } + protected override Task> GetFullMetadata( + EmbyConnectionParameters connectionParameters, + EmbyLibrary library, + MediaItemScanResult result, + EmbyMovie incoming, + bool deepScan) => + Task.FromResult>(None); + + protected override Task>> UpdateMetadata( + MediaItemScanResult result, + MovieMetadata fullMetadata) => + Task.FromResult>>(result); } diff --git a/ErsatzTV.Core/Emby/EmbyPathReplacementService.cs b/ErsatzTV.Core/Emby/EmbyPathReplacementService.cs index ead2ee8f..5cba7561 100644 --- a/ErsatzTV.Core/Emby/EmbyPathReplacementService.cs +++ b/ErsatzTV.Core/Emby/EmbyPathReplacementService.cs @@ -31,10 +31,7 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService return GetReplacementEmbyPath(replacements, path, log); } - public string GetReplacementEmbyPath( - List pathReplacements, - string path, - bool log = true) + public string GetReplacementEmbyPath(List pathReplacements, string path, bool log = true) { Option maybeReplacement = pathReplacements .SingleOrDefault( @@ -46,9 +43,7 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService } string separatorChar = IsWindows(r.EmbyMediaSource, path) ? @"\" : @"/"; - string prefix = r.EmbyPath.EndsWith(separatorChar) - ? r.EmbyPath - : r.EmbyPath + separatorChar; + string prefix = r.EmbyPath.EndsWith(separatorChar) ? r.EmbyPath : r.EmbyPath + separatorChar; return path.StartsWith(prefix); }); @@ -59,8 +54,7 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService { finalPath = finalPath.Replace(@"\", @"/"); } - else if (!IsWindows(replacement.EmbyMediaSource, path) && - _runtimeInfo.IsOSPlatform(OSPlatform.Windows)) + else if (!IsWindows(replacement.EmbyMediaSource, path) && _runtimeInfo.IsOSPlatform(OSPlatform.Windows)) { finalPath = finalPath.Replace(@"/", @"\"); } diff --git a/ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs b/ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs index 293aa499..62417cf9 100644 --- a/ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs +++ b/ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs @@ -1,10 +1,10 @@ using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Metadata; -using LanguageExt.UnsafeValueAccess; using MediatR; using Microsoft.Extensions.Logging; @@ -55,61 +55,81 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner string apiKey, EmbyLibrary library, string ffmpegPath, - string ffprobePath) + string ffprobePath, + CancellationToken cancellationToken) { - List existingShows = await _televisionRepository.GetExistingShows(library); + try + { + List existingShows = await _televisionRepository.GetExistingShows(library); + + // TODO: maybe get quick list of item ids and etags from api to compare first + // TODO: paging? + + List pathReplacements = await _mediaSourceRepository + .GetEmbyPathReplacements(library.MediaSourceId); + + Either> maybeShows = await _embyApiClient.GetShowLibraryItems( + address, + apiKey, + library.ItemId); + + foreach (BaseError error in maybeShows.LeftToSeq()) + { + _logger.LogWarning( + "Error synchronizing emby library {Path}: {Error}", + library.Name, + error.Value); + } + + foreach (List shows in maybeShows.RightToSeq()) + { + Either scanResult = await ProcessShows( + address, + apiKey, + library, + ffmpegPath, + ffprobePath, + pathReplacements, + existingShows, + shows, + cancellationToken); - // TODO: maybe get quick list of item ids and etags from api to compare first - // TODO: paging? + foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) + { + return error; + } - List pathReplacements = await _mediaSourceRepository - .GetEmbyPathReplacements(library.MediaSourceId); + foreach (Unit _ in scanResult.RightToSeq()) + { + var incomingShowIds = shows.Map(s => s.ItemId).ToList(); + var showIds = existingShows + .Filter(i => !incomingShowIds.Contains(i.ItemId)) + .Map(m => m.ItemId) + .ToList(); + List missingShowIds = await _televisionRepository.RemoveMissingShows(library, showIds); + await _searchIndex.RemoveItems(missingShowIds); - Either> maybeShows = await _embyApiClient.GetShowLibraryItems( - address, - apiKey, - library.ItemId); + await _televisionRepository.DeleteEmptySeasons(library); + List emptyShowIds = await _televisionRepository.DeleteEmptyShows(library); + await _searchIndex.RemoveItems(emptyShowIds); - foreach (BaseError error in maybeShows.LeftToSeq()) + await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken); + } + } + + return Unit.Default; + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) { - _logger.LogWarning( - "Error synchronizing emby library {Path}: {Error}", - library.Name, - error.Value); + return new ScanCanceled(); } - - foreach (List shows in maybeShows.RightToSeq()) + finally { - await ProcessShows( - address, - apiKey, - library, - ffmpegPath, - ffprobePath, - pathReplacements, - existingShows, - shows); - - var incomingShowIds = shows.Map(s => s.ItemId).ToList(); - var showIds = existingShows - .Filter(i => !incomingShowIds.Contains(i.ItemId)) - .Map(m => m.ItemId) - .ToList(); - List missingShowIds = await _televisionRepository.RemoveMissingShows(library, showIds); - await _searchIndex.RemoveItems(missingShowIds); - - await _televisionRepository.DeleteEmptySeasons(library); - List emptyShowIds = await _televisionRepository.DeleteEmptyShows(library); - await _searchIndex.RemoveItems(emptyShowIds); - - await _mediator.Publish(new LibraryScanProgress(library.Id, 0)); _searchIndex.Commit(); } - - return Unit.Default; } - private async Task ProcessShows( + private async Task> ProcessShows( string address, string apiKey, EmbyLibrary library, @@ -117,13 +137,19 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner string ffprobePath, List pathReplacements, List existingShows, - List shows) + List shows, + CancellationToken cancellationToken) { var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList(); foreach (EmbyShow incoming in sortedShows) { + if (cancellationToken.IsCancellationRequested) + { + return new ScanCanceled(); + } + decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / shows.Count; - await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion)); + await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken); Option maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId); if (maybeExisting.IsNone) @@ -140,19 +166,17 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner foreach (EmbyItemEtag existing in maybeExisting) { - if (existing.Etag == incoming.Etag) + if (existing.Etag != incoming.Etag) { - return; - } - - _logger.LogDebug("UPDATE: Etag has changed for show {Show}", incoming.ShowMetadata.Head().Title); + _logger.LogDebug("UPDATE: Etag has changed for show {Show}", incoming.ShowMetadata.Head().Title); - incoming.LibraryPathId = library.Paths.Head().Id; + incoming.LibraryPathId = library.Paths.Head().Id; - Option updated = await _televisionRepository.Update(incoming); - if (updated.IsSome) - { - await _searchIndex.UpdateItems(_searchRepository, new List { updated.ValueUnsafe() }); + Option maybeUpdated = await _televisionRepository.Update(incoming); + foreach (EmbyShow updated in maybeUpdated) + { + await _searchIndex.UpdateItems(_searchRepository, new List { updated }); + } } } @@ -172,7 +196,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner foreach (List seasons in maybeSeasons.RightToSeq()) { - await ProcessSeasons( + Either scanResult = await ProcessSeasons( address, apiKey, library, @@ -181,19 +205,30 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner pathReplacements, incoming, existingSeasons, - seasons); - - var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList(); - var seasonIds = existingSeasons - .Filter(i => !incomingSeasonIds.Contains(i.ItemId)) - .Map(m => m.ItemId) - .ToList(); - await _televisionRepository.RemoveMissingSeasons(library, seasonIds); + seasons, + cancellationToken); + + foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) + { + return error; + } + + foreach (Unit _ in scanResult.RightToSeq()) + { + var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList(); + var seasonIds = existingSeasons + .Filter(i => !incomingSeasonIds.Contains(i.ItemId)) + .Map(m => m.ItemId) + .ToList(); + await _televisionRepository.RemoveMissingSeasons(library, seasonIds); + } } } + + return Unit.Default; } - private async Task ProcessSeasons( + private async Task> ProcessSeasons( string address, string apiKey, EmbyLibrary library, @@ -202,19 +237,37 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner List pathReplacements, EmbyShow show, List existingSeasons, - List seasons) + List seasons, + CancellationToken cancellationToken) { foreach (EmbySeason incoming in seasons) { + if (cancellationToken.IsCancellationRequested) + { + return new ScanCanceled(); + } + Option maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId); - await maybeExisting.Match( - async existing => + if (maybeExisting.IsNone) + { + incoming.LibraryPathId = library.Paths.Head().Id; + + _logger.LogDebug( + "INSERT: Item id is new for show {Show} season {Season}", + show.ShowMetadata.Head().Title, + incoming.SeasonMetadata.Head().Title); + + if (await _televisionRepository.AddSeason(show, incoming)) { - if (existing.Etag == incoming.Etag) - { - return; - } + incoming.Show = show; + await _searchIndex.AddItems(_searchRepository, new List { incoming }); + } + } + foreach (EmbyItemEtag existing in maybeExisting) + { + if (existing.Etag != incoming.Etag) + { _logger.LogDebug( "UPDATE: Etag has changed for show {Show} season {Season}", show.ShowMetadata.Head().Title, @@ -232,22 +285,8 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner await _searchIndex.UpdateItems(_searchRepository, new List { toIndex }); } } - }, - async () => - { - incoming.LibraryPathId = library.Paths.Head().Id; - - _logger.LogDebug( - "INSERT: Item id is new for show {Show} season {Season}", - show.ShowMetadata.Head().Title, - incoming.SeasonMetadata.Head().Title); - - if (await _televisionRepository.AddSeason(show, incoming)) - { - incoming.Show = show; - await _searchIndex.AddItems(_searchRepository, new List { incoming }); - } - }); + } + } List existingEpisodes = await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId); @@ -255,40 +294,55 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner Either> maybeEpisodes = await _embyApiClient.GetEpisodeLibraryItems(address, apiKey, incoming.ItemId); - await maybeEpisodes.Match( - async episodes => + foreach (BaseError error in maybeEpisodes.LeftToSeq()) + { + _logger.LogWarning( + "Error synchronizing emby library {Path}: {Error}", + library.Name, + error.Value); + } + + foreach (List episodes in maybeEpisodes.RightToSeq()) + { + var validEpisodes = new List(); + foreach (EmbyEpisode episode in episodes) { - var validEpisodes = new List(); - foreach (EmbyEpisode episode in episodes) - { - string localPath = _pathReplacementService.GetReplacementEmbyPath( - pathReplacements, - episode.MediaVersions.Head().MediaFiles.Head().Path, - false); + string localPath = _pathReplacementService.GetReplacementEmbyPath( + pathReplacements, + episode.MediaVersions.Head().MediaFiles.Head().Path, + false); - if (!_localFileSystem.FileExists(localPath)) - { - _logger.LogWarning( - "Skipping emby episode that does not exist at {Path}", - localPath); - } - else - { - validEpisodes.Add(episode); - } + if (!_localFileSystem.FileExists(localPath)) + { + _logger.LogWarning( + "Skipping emby episode that does not exist at {Path}", + localPath); + } + else + { + validEpisodes.Add(episode); } + } - await ProcessEpisodes( - show.ShowMetadata.Head().Title, - incoming.SeasonMetadata.Head().Title, - library, - ffmpegPath, - ffprobePath, - pathReplacements, - incoming, - existingEpisodes, - validEpisodes); + Either scanResult = await ProcessEpisodes( + show.ShowMetadata.Head().Title, + incoming.SeasonMetadata.Head().Title, + library, + ffmpegPath, + ffprobePath, + pathReplacements, + incoming, + existingEpisodes, + validEpisodes, + cancellationToken); + + foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) + { + return error; + } + foreach (Unit _ in scanResult.RightToSeq()) + { var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList(); var episodeIds = existingEpisodes .Filter(i => !incomingEpisodeIds.Contains(i.ItemId)) @@ -298,20 +352,14 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner await _televisionRepository.RemoveMissingEpisodes(library, episodeIds); await _searchIndex.RemoveItems(missingEpisodeIds); _searchIndex.Commit(); - }, - error => - { - _logger.LogWarning( - "Error synchronizing emby library {Path}: {Error}", - library.Name, - error.Value); - - return Task.CompletedTask; - }); + } + } } + + return Unit.Default; } - private async Task ProcessEpisodes( + private async Task> ProcessEpisodes( string showName, string seasonName, EmbyLibrary library, @@ -320,24 +368,54 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner List pathReplacements, EmbySeason season, List existingEpisodes, - List episodes) + List episodes, + CancellationToken cancellationToken) { foreach (EmbyEpisode incoming in episodes) { + if (cancellationToken.IsCancellationRequested) + { + return new ScanCanceled(); + } + EmbyEpisode incomingEpisode = incoming; var updateStatistics = false; Option maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId); - await maybeExisting.Match( - async existing => + if (maybeExisting.IsNone) + { + try { - try + updateStatistics = true; + incoming.LibraryPathId = library.Paths.Head().Id; + + _logger.LogDebug( + "INSERT: Item id is new for show {Show} season {Season} episode {Episode}", + showName, + seasonName, + incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber)); + + if (await _televisionRepository.AddEpisode(season, incoming)) { - if (existing.Etag == incoming.Etag) - { - return; - } + await _searchIndex.AddItems(_searchRepository, new List { incoming }); + } + } + catch (Exception ex) + { + updateStatistics = false; + _logger.LogError( + ex, + "Error adding episode {Path}", + incoming.MediaVersions.Head().MediaFiles.Head().Path); + } + } + foreach (EmbyItemEtag existing in maybeExisting) + { + try + { + if (existing.Etag != incoming.Etag) + { _logger.LogDebug( "UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}", showName, @@ -352,46 +430,19 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner foreach (EmbyEpisode updated in maybeUpdated) { await _searchIndex.UpdateItems(_searchRepository, new List { updated }); - incomingEpisode = updated; } } - catch (Exception ex) - { - updateStatistics = false; - _logger.LogError( - ex, - "Error updating episode {Path}", - incoming.MediaVersions.Head().MediaFiles.Head().Path); - } - }, - async () => + } + catch (Exception ex) { - try - { - updateStatistics = true; - incoming.LibraryPathId = library.Paths.Head().Id; - - _logger.LogDebug( - "INSERT: Item id is new for show {Show} season {Season} episode {Episode}", - showName, - seasonName, - incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber)); - - if (await _televisionRepository.AddEpisode(season, incoming)) - { - await _searchIndex.AddItems(_searchRepository, new List { incoming }); - } - } - catch (Exception ex) - { - updateStatistics = false; - _logger.LogError( - ex, - "Error adding episode {Path}", - incoming.MediaVersions.Head().MediaFiles.Head().Path); - } - }); + updateStatistics = false; + _logger.LogError( + ex, + "Error updating episode {Path}", + incoming.MediaVersions.Head().MediaFiles.Head().Path); + } + } if (updateStatistics) { @@ -423,6 +474,8 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner } } } + + return Unit.Default; } private async Task> UpdateSubtitles(EmbyEpisode episode, string localPath) diff --git a/ErsatzTV.Core/Interfaces/Emby/IEmbyMovieLibraryScanner.cs b/ErsatzTV.Core/Interfaces/Emby/IEmbyMovieLibraryScanner.cs index 61837c00..8fe89d3a 100644 --- a/ErsatzTV.Core/Interfaces/Emby/IEmbyMovieLibraryScanner.cs +++ b/ErsatzTV.Core/Interfaces/Emby/IEmbyMovieLibraryScanner.cs @@ -9,5 +9,6 @@ public interface IEmbyMovieLibraryScanner string apiKey, EmbyLibrary library, string ffmpegPath, - string ffprobePath); + string ffprobePath, + CancellationToken cancellationToken); } diff --git a/ErsatzTV.Core/Interfaces/Emby/IEmbyTelevisionLibraryScanner.cs b/ErsatzTV.Core/Interfaces/Emby/IEmbyTelevisionLibraryScanner.cs index 7b838b99..b5f96818 100644 --- a/ErsatzTV.Core/Interfaces/Emby/IEmbyTelevisionLibraryScanner.cs +++ b/ErsatzTV.Core/Interfaces/Emby/IEmbyTelevisionLibraryScanner.cs @@ -9,5 +9,6 @@ public interface IEmbyTelevisionLibraryScanner string apiKey, EmbyLibrary library, string ffmpegPath, - string ffprobePath); + string ffprobePath, + CancellationToken cancellationToken); } diff --git a/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinMovieLibraryScanner.cs b/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinMovieLibraryScanner.cs index bcd66597..6c9fede4 100644 --- a/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinMovieLibraryScanner.cs +++ b/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinMovieLibraryScanner.cs @@ -9,5 +9,6 @@ public interface IJellyfinMovieLibraryScanner string apiKey, JellyfinLibrary library, string ffmpegPath, - string ffprobePath); + string ffprobePath, + CancellationToken cancellationToken); } diff --git a/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinTelevisionLibraryScanner.cs b/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinTelevisionLibraryScanner.cs index 14626fe0..366447eb 100644 --- a/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinTelevisionLibraryScanner.cs +++ b/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinTelevisionLibraryScanner.cs @@ -9,5 +9,6 @@ public interface IJellyfinTelevisionLibraryScanner string apiKey, JellyfinLibrary library, string ffmpegPath, - string ffprobePath); + string ffprobePath, + CancellationToken cancellationToken); } diff --git a/ErsatzTV.Core/Interfaces/Metadata/IOtherVideoFolderScanner.cs b/ErsatzTV.Core/Interfaces/Metadata/IOtherVideoFolderScanner.cs index 417c978f..c0910a98 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/IOtherVideoFolderScanner.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/IOtherVideoFolderScanner.cs @@ -9,5 +9,6 @@ public interface IOtherVideoFolderScanner string ffmpegPath, string ffprobePath, decimal progressMin, - decimal progressMax); + decimal progressMax, + CancellationToken cancellationToken); } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IEmbyMovieRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IEmbyMovieRepository.cs new file mode 100644 index 00000000..fe6fcd73 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IEmbyMovieRepository.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; + +namespace ErsatzTV.Core.Interfaces.Repositories; + +public interface IEmbyMovieRepository : IMediaServerMovieRepository +{ +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IJellyfinMovieRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IJellyfinMovieRepository.cs new file mode 100644 index 00000000..8d8e5c52 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IJellyfinMovieRepository.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Jellyfin; + +namespace ErsatzTV.Core.Interfaces.Repositories; + +public interface + IJellyfinMovieRepository : IMediaServerMovieRepository +{ +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaServerMovieRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaServerMovieRepository.cs new file mode 100644 index 00000000..fb2b94a1 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaServerMovieRepository.cs @@ -0,0 +1,16 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Metadata; + +namespace ErsatzTV.Core.Interfaces.Repositories; + +public interface IMediaServerMovieRepository where TLibrary : Library + where TMovie : Movie + where TEtag : MediaServerItemEtag +{ + Task> GetExistingMovies(TLibrary library); + Task FlagNormal(TLibrary library, TMovie movie); + Task> FlagUnavailable(TLibrary library, TMovie movie); + Task> FlagFileNotFound(TLibrary library, List movieItemIds); + Task>> GetOrAdd(TLibrary library, TMovie item); + Task SetEtag(TMovie movie, string etag); +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs index b9c0d399..6a71fc4d 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs @@ -1,8 +1,5 @@ using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Emby; -using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Metadata; -using ErsatzTV.Core.Plex; namespace ErsatzTV.Core.Interfaces.Repositories; @@ -11,7 +8,6 @@ public interface IMovieRepository Task AllMoviesExist(List movieIds); Task> GetMovie(int movieId); Task>> GetOrAdd(LibraryPath libraryPath, string path); - Task>> GetOrAdd(PlexLibrary library, PlexMovie item); Task> GetMoviesForCards(List ids); Task> FindMoviePaths(LibraryPath libraryPath); Task> DeleteByPath(LibraryPath libraryPath, string path); @@ -19,18 +15,8 @@ public interface IMovieRepository Task AddTag(MovieMetadata metadata, Tag tag); Task AddStudio(MovieMetadata metadata, Studio studio); Task AddActor(MovieMetadata metadata, Actor actor); - Task> GetExistingPlexMovies(PlexLibrary library); Task UpdateSortTitle(MovieMetadata movieMetadata); - Task> GetExistingJellyfinMovies(JellyfinLibrary library); - Task> RemoveMissingJellyfinMovies(JellyfinLibrary library, List movieIds); - Task AddJellyfin(JellyfinMovie movie); - Task> UpdateJellyfin(JellyfinMovie movie); - Task> GetExistingEmbyMovies(EmbyLibrary library); - Task> RemoveMissingEmbyMovies(EmbyLibrary library, List movieIds); - Task AddEmby(EmbyMovie movie); - Task> UpdateEmby(EmbyMovie movie); Task AddDirector(MovieMetadata metadata, Director director); Task AddWriter(MovieMetadata metadata, Writer writer); Task UpdatePath(int mediaFileId, string path); - Task SetPlexEtag(PlexMovie movie, string etag); } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IPlexMovieRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IPlexMovieRepository.cs index 2c2210a1..33ab38de 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IPlexMovieRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IPlexMovieRepository.cs @@ -1,10 +1,8 @@ using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Plex; namespace ErsatzTV.Core.Interfaces.Repositories; -public interface IPlexMovieRepository +public interface IPlexMovieRepository : IMediaServerMovieRepository { - Task FlagNormal(PlexLibrary library, PlexMovie movie); - Task> FlagUnavailable(PlexLibrary library, PlexMovie movie); - Task> FlagFileNotFound(PlexLibrary library, List plexMovieKeys); } diff --git a/ErsatzTV.Core/Jellyfin/JellyfinConnectionParameters.cs b/ErsatzTV.Core/Jellyfin/JellyfinConnectionParameters.cs new file mode 100644 index 00000000..4a103f5c --- /dev/null +++ b/ErsatzTV.Core/Jellyfin/JellyfinConnectionParameters.cs @@ -0,0 +1,6 @@ +using ErsatzTV.Core.Domain.MediaServer; + +namespace ErsatzTV.Core.Jellyfin; + +public record JellyfinConnectionParameters + (string Address, string ApiKey, int MediaSourceId) : MediaServerConnectionParameters; diff --git a/ErsatzTV.Core/Jellyfin/JellyfinItemEtag.cs b/ErsatzTV.Core/Jellyfin/JellyfinItemEtag.cs index dad2cad0..ccc347e6 100644 --- a/ErsatzTV.Core/Jellyfin/JellyfinItemEtag.cs +++ b/ErsatzTV.Core/Jellyfin/JellyfinItemEtag.cs @@ -1,7 +1,11 @@ -namespace ErsatzTV.Core.Jellyfin; +using ErsatzTV.Core.Domain; -public class JellyfinItemEtag +namespace ErsatzTV.Core.Jellyfin; + +public class JellyfinItemEtag : MediaServerItemEtag { public string ItemId { get; set; } - public string Etag { get; set; } + public override string MediaServerItemId => ItemId; + public override string Etag { get; set; } + public override MediaItemState State { get; set; } } diff --git a/ErsatzTV.Core/Jellyfin/JellyfinMovieLibraryScanner.cs b/ErsatzTV.Core/Jellyfin/JellyfinMovieLibraryScanner.cs index 61651bff..20d19fb9 100644 --- a/ErsatzTV.Core/Jellyfin/JellyfinMovieLibraryScanner.cs +++ b/ErsatzTV.Core/Jellyfin/JellyfinMovieLibraryScanner.cs @@ -1,34 +1,29 @@ using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Metadata; -using LanguageExt.UnsafeValueAccess; using MediatR; using Microsoft.Extensions.Logging; namespace ErsatzTV.Core.Jellyfin; -public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner +public class JellyfinMovieLibraryScanner : + MediaServerMovieLibraryScanner, + IJellyfinMovieLibraryScanner { private readonly IJellyfinApiClient _jellyfinApiClient; - private readonly ILocalFileSystem _localFileSystem; - private readonly ILocalStatisticsProvider _localStatisticsProvider; - private readonly ILocalSubtitlesProvider _localSubtitlesProvider; - private readonly ILogger _logger; + private readonly IJellyfinMovieRepository _jellyfinMovieRepository; private readonly IMediaSourceRepository _mediaSourceRepository; - private readonly IMediator _mediator; - private readonly IMovieRepository _movieRepository; private readonly IJellyfinPathReplacementService _pathReplacementService; - private readonly ISearchIndex _searchIndex; - private readonly ISearchRepository _searchRepository; public JellyfinMovieLibraryScanner( IJellyfinApiClient jellyfinApiClient, ISearchIndex searchIndex, IMediator mediator, - IMovieRepository movieRepository, + IJellyfinMovieRepository jellyfinMovieRepository, ISearchRepository searchRepository, IJellyfinPathReplacementService pathReplacementService, IMediaSourceRepository mediaSourceRepository, @@ -36,18 +31,19 @@ public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner ILocalStatisticsProvider localStatisticsProvider, ILocalSubtitlesProvider localSubtitlesProvider, ILogger logger) + : base( + localStatisticsProvider, + localSubtitlesProvider, + localFileSystem, + mediator, + searchIndex, + searchRepository, + logger) { _jellyfinApiClient = jellyfinApiClient; - _searchIndex = searchIndex; - _mediator = mediator; - _movieRepository = movieRepository; - _searchRepository = searchRepository; + _jellyfinMovieRepository = jellyfinMovieRepository; _pathReplacementService = pathReplacementService; _mediaSourceRepository = mediaSourceRepository; - _localFileSystem = localFileSystem; - _localStatisticsProvider = localStatisticsProvider; - _localSubtitlesProvider = localSubtitlesProvider; - _logger = logger; } public async Task> ScanLibrary( @@ -55,198 +51,54 @@ public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner string apiKey, JellyfinLibrary library, string ffmpegPath, - string ffprobePath) + string ffprobePath, + CancellationToken cancellationToken) { - List existingMovies = await _movieRepository.GetExistingJellyfinMovies(library); + List pathReplacements = + await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId); - // TODO: maybe get quick list of item ids and etags from api to compare first - // TODO: paging? - - List pathReplacements = await _mediaSourceRepository - .GetJellyfinPathReplacements(library.MediaSourceId); - - Either> maybeMovies = await _jellyfinApiClient.GetMovieLibraryItems( - address, - apiKey, - library.MediaSourceId, - library.ItemId); - - await maybeMovies.Match( - async movies => - { - var validMovies = new List(); - foreach (JellyfinMovie movie in movies.OrderBy(m => m.MovieMetadata.Head().Title)) - { - string localPath = _pathReplacementService.GetReplacementJellyfinPath( - pathReplacements, - movie.MediaVersions.Head().MediaFiles.Head().Path, - false); - - if (!_localFileSystem.FileExists(localPath)) - { - _logger.LogWarning("Skipping jellyfin movie that does not exist at {Path}", localPath); - } - else - { - validMovies.Add(movie); - } - } - - foreach (JellyfinMovie incoming in validMovies) - { - JellyfinMovie incomingMovie = incoming; - - decimal percentCompletion = (decimal)validMovies.IndexOf(incoming) / validMovies.Count; - await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion)); - - Option maybeExisting = - existingMovies.Find(ie => ie.ItemId == incoming.ItemId); - - var updateStatistics = false; - - await maybeExisting.Match( - async existing => - { - try - { - if (existing.Etag == incoming.Etag) - { - // _logger.LogDebug( - // $"NOOP: Etag has not changed for movie {incoming.MovieMetadata.Head().Title}"); - return; - } - - _logger.LogDebug( - "UPDATE: Etag has changed for movie {Movie}", - incoming.MovieMetadata.Head().Title); - - updateStatistics = true; - incoming.LibraryPathId = library.Paths.Head().Id; - Option maybeUpdated = await _movieRepository.UpdateJellyfin(incoming); - foreach (JellyfinMovie updated in maybeUpdated) - { - await _searchIndex.UpdateItems( - _searchRepository, - new List { updated }); - - incomingMovie = updated; - } - } - catch (Exception ex) - { - updateStatistics = false; - _logger.LogError( - ex, - "Error updating movie {Movie}", - incoming.MovieMetadata.Head().Title); - } - }, - async () => - { - try - { - // _logger.LogDebug( - // $"INSERT: Item id is new for movie {incoming.MovieMetadata.Head().Title}"); - - updateStatistics = true; - incoming.LibraryPathId = library.Paths.Head().Id; - if (await _movieRepository.AddJellyfin(incoming)) - { - await _searchIndex.AddItems( - _searchRepository, - new List { incoming }); - } - } - catch (Exception ex) - { - updateStatistics = false; - _logger.LogError( - ex, - "Error adding movie {Movie}", - incoming.MovieMetadata.Head().Title); - } - }); - - if (updateStatistics) - { - string localPath = _pathReplacementService.GetReplacementJellyfinPath( - pathReplacements, - incoming.MediaVersions.Head().MediaFiles.Head().Path, - false); - - _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); - Either refreshResult = - await _localStatisticsProvider.RefreshStatistics( - ffmpegPath, - ffprobePath, - incomingMovie, - localPath); - - if (refreshResult.Map(t => t).IfLeft(false)) - { - refreshResult = await UpdateSubtitles(incomingMovie, localPath); - } - - await refreshResult.Match( - async _ => - { - Option updated = await _searchRepository.GetItemToIndex(incomingMovie.Id); - if (updated.IsSome) - { - await _searchIndex.UpdateItems( - _searchRepository, - new List { updated.ValueUnsafe() }); - } - }, - error => - { - _logger.LogWarning( - "Unable to refresh {Attribute} for media item {Path}. Error: {Error}", - "Statistics", - localPath, - error.Value); - - return Task.CompletedTask; - }); - } - - // TODO: figure out how to rebuild playlists - } + string GetLocalPath(JellyfinMovie movie) + { + return _pathReplacementService.GetReplacementJellyfinPath( + pathReplacements, + movie.GetHeadVersion().MediaFiles.Head().Path, + false); + } - var incomingMovieIds = validMovies.Map(s => s.ItemId).ToList(); - var movieIds = existingMovies - .Filter(i => !incomingMovieIds.Contains(i.ItemId)) - .Map(m => m.ItemId) - .ToList(); - List ids = await _movieRepository.RemoveMissingJellyfinMovies(library, movieIds); - await _searchIndex.RemoveItems(ids); + return await ScanLibrary( + _jellyfinMovieRepository, + new JellyfinConnectionParameters(address, apiKey, library.MediaSourceId), + library, + GetLocalPath, + ffmpegPath, + ffprobePath, + false, + cancellationToken); + } - await _mediator.Publish(new LibraryScanProgress(library.Id, 0)); - _searchIndex.Commit(); - }, - error => - { - _logger.LogWarning( - "Error synchronizing jellyfin library {Path}: {Error}", - library.Name, - error.Value); + protected override string MediaServerItemId(JellyfinMovie movie) => movie.ItemId; - return Task.CompletedTask; - }); + protected override string MediaServerEtag(JellyfinMovie movie) => movie.Etag; - _searchIndex.Commit(); - return Unit.Default; - } + protected override Task>> GetMovieLibraryItems( + JellyfinConnectionParameters connectionParameters, + JellyfinLibrary library) => + _jellyfinApiClient.GetMovieLibraryItems( + connectionParameters.Address, + connectionParameters.ApiKey, + connectionParameters.MediaSourceId, + library.ItemId); - private async Task> UpdateSubtitles(JellyfinMovie movie, string localPath) - { - try - { - return await _localSubtitlesProvider.UpdateSubtitles(movie, localPath, false); - } - catch (Exception ex) - { - return BaseError.New(ex.ToString()); - } - } + protected override Task> GetFullMetadata( + JellyfinConnectionParameters connectionParameters, + JellyfinLibrary library, + MediaItemScanResult result, + JellyfinMovie incoming, + bool deepScan) => + Task.FromResult>(None); + + protected override Task>> UpdateMetadata( + MediaItemScanResult result, + MovieMetadata fullMetadata) => + Task.FromResult>>(result); } diff --git a/ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs b/ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs index e31f3b68..96919792 100644 --- a/ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs +++ b/ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs @@ -28,7 +28,7 @@ public class JellyfinPathReplacementService : IJellyfinPathReplacementService List replacements = await _mediaSourceRepository.GetJellyfinPathReplacementsByLibraryId(libraryPathId); - return GetReplacementJellyfinPath(replacements, path); + return GetReplacementJellyfinPath(replacements, path, log); } public string GetReplacementJellyfinPath( diff --git a/ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs b/ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs index cedf9da3..a2a5560d 100644 --- a/ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs +++ b/ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs @@ -1,10 +1,10 @@ using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Metadata; -using LanguageExt.UnsafeValueAccess; using MediatR; using Microsoft.Extensions.Logging; @@ -55,26 +55,36 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne string apiKey, JellyfinLibrary library, string ffmpegPath, - string ffprobePath) + string ffprobePath, + CancellationToken cancellationToken) { - List existingShows = await _televisionRepository.GetExistingShows(library); + try + { + List existingShows = await _televisionRepository.GetExistingShows(library); - // TODO: maybe get quick list of item ids and etags from api to compare first - // TODO: paging? + // TODO: maybe get quick list of item ids and etags from api to compare first + // TODO: paging? - List pathReplacements = await _mediaSourceRepository - .GetJellyfinPathReplacements(library.MediaSourceId); + List pathReplacements = await _mediaSourceRepository + .GetJellyfinPathReplacements(library.MediaSourceId); - Either> maybeShows = await _jellyfinApiClient.GetShowLibraryItems( - address, - apiKey, - library.MediaSourceId, - library.ItemId); + Either> maybeShows = await _jellyfinApiClient.GetShowLibraryItems( + address, + apiKey, + library.MediaSourceId, + library.ItemId); + + foreach (BaseError error in maybeShows.LeftToSeq()) + { + _logger.LogWarning( + "Error synchronizing jellyfin library {Path}: {Error}", + library.Name, + error.Value); + } - await maybeShows.Match( - async shows => + foreach (List shows in maybeShows.RightToSeq()) { - await ProcessShows( + Either scanResult = await ProcessShows( address, apiKey, library, @@ -82,37 +92,45 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne ffprobePath, pathReplacements, existingShows, - shows); - - var incomingShowIds = shows.Map(s => s.ItemId).ToList(); - var showIds = existingShows - .Filter(i => !incomingShowIds.Contains(i.ItemId)) - .Map(m => m.ItemId) - .ToList(); - List missingShowIds = await _televisionRepository.RemoveMissingShows(library, showIds); - await _searchIndex.RemoveItems(missingShowIds); - - await _televisionRepository.DeleteEmptySeasons(library); - List emptyShowIds = await _televisionRepository.DeleteEmptyShows(library); - await _searchIndex.RemoveItems(emptyShowIds); - - await _mediator.Publish(new LibraryScanProgress(library.Id, 0)); - _searchIndex.Commit(); - }, - error => - { - _logger.LogWarning( - "Error synchronizing jellyfin library {Path}: {Error}", - library.Name, - error.Value); + shows, + cancellationToken); - return Task.CompletedTask; - }); + foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) + { + return error; + } - return Unit.Default; + foreach (Unit _ in scanResult.RightToSeq()) + { + var incomingShowIds = shows.Map(s => s.ItemId).ToList(); + var showIds = existingShows + .Filter(i => !incomingShowIds.Contains(i.ItemId)) + .Map(m => m.ItemId) + .ToList(); + List missingShowIds = await _televisionRepository.RemoveMissingShows(library, showIds); + await _searchIndex.RemoveItems(missingShowIds); + + await _televisionRepository.DeleteEmptySeasons(library); + List emptyShowIds = await _televisionRepository.DeleteEmptyShows(library); + await _searchIndex.RemoveItems(emptyShowIds); + + await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken); + } + } + + return Unit.Default; + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return new ScanCanceled(); + } + finally + { + _searchIndex.Commit(); + } } - private async Task ProcessShows( + private async Task> ProcessShows( string address, string apiKey, JellyfinLibrary library, @@ -120,93 +138,98 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne string ffprobePath, List pathReplacements, List existingShows, - List shows) + List shows, + CancellationToken cancellationToken) { var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList(); foreach (JellyfinShow incoming in sortedShows) { + if (cancellationToken.IsCancellationRequested) + { + return new ScanCanceled(); + } + decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / shows.Count; - await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion)); + await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken); Option maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId); - await maybeExisting.Match( - async existing => - { - if (existing.Etag == incoming.Etag) - { - return; - } + if (maybeExisting.IsNone) + { + incoming.LibraryPathId = library.Paths.Head().Id; - _logger.LogDebug( - "UPDATE: Etag has changed for show {Show}", - incoming.ShowMetadata.Head().Title); + // _logger.LogDebug("INSERT: Item id is new for show {Show}", incoming.ShowMetadata.Head().Title); - incoming.LibraryPathId = library.Paths.Head().Id; + if (await _televisionRepository.AddShow(incoming)) + { + await _searchIndex.AddItems(_searchRepository, new List { incoming }); + } + } - Option updated = await _televisionRepository.Update(incoming); - if (updated.IsSome) - { - await _searchIndex.UpdateItems( - _searchRepository, - new List { updated.ValueUnsafe() }); - } - }, - async () => + foreach (JellyfinItemEtag existing in maybeExisting) + { + if (existing.Etag != incoming.Etag) { - incoming.LibraryPathId = library.Paths.Head().Id; + _logger.LogDebug("UPDATE: Etag has changed for show {Show}", incoming.ShowMetadata.Head().Title); - // _logger.LogDebug("INSERT: Item id is new for show {Show}", incoming.ShowMetadata.Head().Title); + incoming.LibraryPathId = library.Paths.Head().Id; - if (await _televisionRepository.AddShow(incoming)) + Option maybeUpdated = await _televisionRepository.Update(incoming); + foreach (JellyfinShow updated in maybeUpdated) { - await _searchIndex.AddItems(_searchRepository, new List { incoming }); + await _searchIndex.UpdateItems(_searchRepository, new List { updated }); } - }); + } + } List existingSeasons = await _televisionRepository.GetExistingSeasons(library, incoming.ItemId); Either> maybeSeasons = - await _jellyfinApiClient.GetSeasonLibraryItems( + await _jellyfinApiClient.GetSeasonLibraryItems(address, apiKey, library.MediaSourceId, incoming.ItemId); + + foreach (BaseError error in maybeSeasons.LeftToSeq()) + { + _logger.LogWarning( + "Error synchronizing jellyfin library {Path}: {Error}", + library.Name, + error.Value); + } + + foreach (List seasons in maybeSeasons.RightToSeq()) + { + Either scanResult = await ProcessSeasons( address, apiKey, - library.MediaSourceId, - incoming.ItemId); + library, + ffmpegPath, + ffprobePath, + pathReplacements, + incoming, + existingSeasons, + seasons, + cancellationToken); - await maybeSeasons.Match( - async seasons => + foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) { - await ProcessSeasons( - address, - apiKey, - library, - ffmpegPath, - ffprobePath, - pathReplacements, - incoming, - existingSeasons, - seasons); + return error; + } + foreach (Unit _ in scanResult.RightToSeq()) + { var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList(); var seasonIds = existingSeasons .Filter(i => !incomingSeasonIds.Contains(i.ItemId)) .Map(m => m.ItemId) .ToList(); await _televisionRepository.RemoveMissingSeasons(library, seasonIds); - }, - error => - { - _logger.LogWarning( - "Error synchronizing jellyfin library {Path}: {Error}", - library.Name, - error.Value); - - return Task.CompletedTask; - }); + } + } } + + return Unit.Default; } - private async Task ProcessSeasons( + private async Task> ProcessSeasons( string address, string apiKey, JellyfinLibrary library, @@ -215,19 +238,37 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne List pathReplacements, JellyfinShow show, List existingSeasons, - List seasons) + List seasons, + CancellationToken cancellationToken) { foreach (JellyfinSeason incoming in seasons) { + if (cancellationToken.IsCancellationRequested) + { + return new ScanCanceled(); + } + Option maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId); - await maybeExisting.Match( - async existing => + if (maybeExisting.IsNone) + { + incoming.LibraryPathId = library.Paths.Head().Id; + + _logger.LogDebug( + "INSERT: Item id is new for show {Show} season {Season}", + show.ShowMetadata.Head().Title, + incoming.SeasonMetadata.Head().Title); + + if (await _televisionRepository.AddSeason(show, incoming)) { - if (existing.Etag == incoming.Etag) - { - return; - } + incoming.Show = show; + await _searchIndex.AddItems(_searchRepository, new List { incoming }); + } + } + foreach (JellyfinItemEtag existing in maybeExisting) + { + if (existing.Etag != incoming.Etag) + { _logger.LogDebug( "UPDATE: Etag has changed for show {Show} season {Season}", show.ShowMetadata.Head().Title, @@ -242,27 +283,11 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne foreach (MediaItem toIndex in await _searchRepository.GetItemToIndex(updated.Id)) { - await _searchIndex.UpdateItems( - _searchRepository, - new List { toIndex }); + await _searchIndex.UpdateItems(_searchRepository, new List { toIndex }); } } - }, - async () => - { - incoming.LibraryPathId = library.Paths.Head().Id; - - _logger.LogDebug( - "INSERT: Item id is new for show {Show} season {Season}", - show.ShowMetadata.Head().Title, - incoming.SeasonMetadata.Head().Title); - - if (await _televisionRepository.AddSeason(show, incoming)) - { - incoming.Show = show; - await _searchIndex.AddItems(_searchRepository, new List { incoming }); - } - }); + } + } List existingEpisodes = await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId); @@ -274,64 +299,72 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne library.MediaSourceId, incoming.ItemId); - await maybeEpisodes.Match( - async episodes => + foreach (BaseError error in maybeEpisodes.LeftToSeq()) + { + _logger.LogWarning( + "Error synchronizing jellyfin library {Path}: {Error}", + library.Name, + error.Value); + } + + foreach (List episodes in maybeEpisodes.RightToSeq()) + { + var validEpisodes = new List(); + foreach (JellyfinEpisode episode in episodes) { - var validEpisodes = new List(); - foreach (JellyfinEpisode episode in episodes) - { - string localPath = _pathReplacementService.GetReplacementJellyfinPath( - pathReplacements, - episode.MediaVersions.Head().MediaFiles.Head().Path, - false); + string localPath = _pathReplacementService.GetReplacementJellyfinPath( + pathReplacements, + episode.MediaVersions.Head().MediaFiles.Head().Path, + false); - if (!_localFileSystem.FileExists(localPath)) - { - _logger.LogWarning( - "Skipping jellyfin episode that does not exist at {Path}", - localPath); - } - else - { - validEpisodes.Add(episode); - } + if (!_localFileSystem.FileExists(localPath)) + { + _logger.LogWarning( + "Skipping jellyfin episode that does not exist at {Path}", + localPath); } + else + { + validEpisodes.Add(episode); + } + } - await ProcessEpisodes( - show.ShowMetadata.Head().Title, - incoming.SeasonMetadata.Head().Title, - library, - ffmpegPath, - ffprobePath, - pathReplacements, - incoming, - existingEpisodes, - validEpisodes); + Either scanResult = await ProcessEpisodes( + show.ShowMetadata.Head().Title, + incoming.SeasonMetadata.Head().Title, + library, + ffmpegPath, + ffprobePath, + pathReplacements, + incoming, + existingEpisodes, + validEpisodes, + cancellationToken); + foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) + { + return error; + } + + foreach (Unit _ in scanResult.RightToSeq()) + { var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList(); var episodeIds = existingEpisodes .Filter(i => !incomingEpisodeIds.Contains(i.ItemId)) .Map(m => m.ItemId) .ToList(); - List missingEpisodeIds = await _televisionRepository.RemoveMissingEpisodes(library, episodeIds); await _searchIndex.RemoveItems(missingEpisodeIds); _searchIndex.Commit(); - }, - error => - { - _logger.LogWarning( - "Error synchronizing jellyfin library {Path}: {Error}", - library.Name, - error.Value); - - return Task.CompletedTask; - }); + } + } } + + return Unit.Default; } - private async Task ProcessEpisodes( + private async Task> ProcessEpisodes( string showName, string seasonName, JellyfinLibrary library, @@ -340,25 +373,54 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne List pathReplacements, JellyfinSeason season, List existingEpisodes, - List episodes) + List episodes, + CancellationToken cancellationToken) { foreach (JellyfinEpisode incoming in episodes) { - JellyfinEpisode incomingEpisode = incoming; + if (cancellationToken.IsCancellationRequested) + { + return new ScanCanceled(); + } + JellyfinEpisode incomingEpisode = incoming; var updateStatistics = false; Option maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId); - await maybeExisting.Match( - async existing => + if (maybeExisting.IsNone) + { + try { - try + updateStatistics = true; + incoming.LibraryPathId = library.Paths.Head().Id; + + _logger.LogDebug( + "INSERT: Item id is new for show {Show} season {Season} episode {Episode}", + showName, + seasonName, + incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber)); + + if (await _televisionRepository.AddEpisode(season, incoming)) { - if (existing.Etag == incoming.Etag) - { - return; - } + await _searchIndex.AddItems(_searchRepository, new List { incoming }); + } + } + catch (Exception ex) + { + updateStatistics = false; + _logger.LogError( + ex, + "Error adding episode {Path}", + incoming.MediaVersions.Head().MediaFiles.Head().Path); + } + } + foreach (JellyfinItemEtag existing in maybeExisting) + { + try + { + if (existing.Etag != incoming.Etag) + { _logger.LogDebug( "UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}", showName, @@ -372,49 +434,20 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne Option maybeUpdated = await _televisionRepository.Update(incoming); foreach (JellyfinEpisode updated in maybeUpdated) { - await _searchIndex.UpdateItems( - _searchRepository, - new List { updated }); - + await _searchIndex.UpdateItems(_searchRepository, new List { updated }); incomingEpisode = updated; } } - catch (Exception ex) - { - updateStatistics = false; - _logger.LogError( - ex, - "Error updating episode {Path}", - incoming.MediaVersions.Head().MediaFiles.Head().Path); - } - }, - async () => + } + catch (Exception ex) { - try - { - updateStatistics = true; - incoming.LibraryPathId = library.Paths.Head().Id; - - _logger.LogDebug( - "INSERT: Item id is new for show {Show} season {Season} episode {Episode}", - showName, - seasonName, - incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber)); - - if (await _televisionRepository.AddEpisode(season, incoming)) - { - await _searchIndex.AddItems(_searchRepository, new List { incoming }); - } - } - catch (Exception ex) - { - updateStatistics = false; - _logger.LogError( - ex, - "Error adding episode {Path}", - incoming.MediaVersions.Head().MediaFiles.Head().Path); - } - }); + updateStatistics = false; + _logger.LogError( + ex, + "Error updating episode {Path}", + incoming.MediaVersions.Head().MediaFiles.Head().Path); + } + } if (updateStatistics) { @@ -436,15 +469,18 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne refreshResult = await UpdateSubtitles(incomingEpisode, localPath); } - refreshResult.Match( - _ => { }, - error => _logger.LogWarning( + foreach (BaseError error in refreshResult.LeftToSeq()) + { + _logger.LogWarning( "Unable to refresh {Attribute} for media item {Path}. Error: {Error}", "Statistics", localPath, - error.Value)); + error.Value); + } } } + + return Unit.Default; } private async Task> UpdateSubtitles(JellyfinEpisode episode, string localPath) diff --git a/ErsatzTV.Core/Metadata/LocalSubtitlesProvider.cs b/ErsatzTV.Core/Metadata/LocalSubtitlesProvider.cs index 2385a5f2..9bbabaa9 100644 --- a/ErsatzTV.Core/Metadata/LocalSubtitlesProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalSubtitlesProvider.cs @@ -69,7 +69,7 @@ public class LocalSubtitlesProvider : ILocalSubtitlesProvider var subtitles = subtitleStreams.Map(Subtitle.FromMediaStream).ToList(); string mediaItemPath = await localPath.IfNoneAsync(() => mediaItem.GetHeadVersion().MediaFiles.Head().Path); subtitles.AddRange(LocateExternalSubtitles(_languageCodes, mediaItemPath, saveFullPath)); - await _metadataRepository.UpdateSubtitles(metadata, subtitles); + return await _metadataRepository.UpdateSubtitles(metadata, subtitles); } return false; diff --git a/ErsatzTV.Core/Metadata/MediaItemScanResult.cs b/ErsatzTV.Core/Metadata/MediaItemScanResult.cs index fdf34bf4..178799e1 100644 --- a/ErsatzTV.Core/Metadata/MediaItemScanResult.cs +++ b/ErsatzTV.Core/Metadata/MediaItemScanResult.cs @@ -7,6 +7,7 @@ public class MediaItemScanResult where T : MediaItem public MediaItemScanResult(T item) => Item = item; public T Item { get; set; } + public string LocalPath { get; set; } public bool IsAdded { get; set; } public bool IsUpdated { get; set; } diff --git a/ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs b/ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs new file mode 100644 index 00000000..ec787027 --- /dev/null +++ b/ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs @@ -0,0 +1,338 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.MediaServer; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Core.Metadata; + +public abstract class MediaServerMovieLibraryScanner + where TConnectionParameters : MediaServerConnectionParameters + where TLibrary : Library + where TMovie : Movie + where TEtag : MediaServerItemEtag +{ + private readonly ILocalFileSystem _localFileSystem; + private readonly ILocalStatisticsProvider _localStatisticsProvider; + private readonly ILocalSubtitlesProvider _localSubtitlesProvider; + private readonly ILogger _logger; + private readonly IMediator _mediator; + private readonly ISearchIndex _searchIndex; + private readonly ISearchRepository _searchRepository; + + protected MediaServerMovieLibraryScanner( + ILocalStatisticsProvider localStatisticsProvider, + ILocalSubtitlesProvider localSubtitlesProvider, + ILocalFileSystem localFileSystem, + IMediator mediator, + ISearchIndex searchIndex, + ISearchRepository searchRepository, + ILogger logger) + { + _localStatisticsProvider = localStatisticsProvider; + _localSubtitlesProvider = localSubtitlesProvider; + _localFileSystem = localFileSystem; + _mediator = mediator; + _searchIndex = searchIndex; + _searchRepository = searchRepository; + _logger = logger; + } + + protected async Task> ScanLibrary( + IMediaServerMovieRepository movieRepository, + TConnectionParameters connectionParameters, + TLibrary library, + Func getLocalPath, + string ffmpegPath, + string ffprobePath, + bool deepScan, + CancellationToken cancellationToken) + { + try + { + Either> entries = await GetMovieLibraryItems(connectionParameters, library); + + foreach (BaseError error in entries.LeftToSeq()) + { + return error; + } + + return await ScanLibrary( + movieRepository, + connectionParameters, + library, + getLocalPath, + ffmpegPath, + ffprobePath, + entries.RightToSeq().Flatten().ToList(), + deepScan, + cancellationToken); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return new ScanCanceled(); + } + finally + { + _searchIndex.Commit(); + } + } + + private async Task> ScanLibrary( + IMediaServerMovieRepository movieRepository, + TConnectionParameters connectionParameters, + TLibrary library, + Func getLocalPath, + string ffmpegPath, + string ffprobePath, + List movieEntries, + bool deepScan, + CancellationToken cancellationToken) + { + List existingMovies = await movieRepository.GetExistingMovies(library); + + foreach (TMovie incoming in movieEntries) + { + if (cancellationToken.IsCancellationRequested) + { + return new ScanCanceled(); + } + + decimal percentCompletion = (decimal)movieEntries.IndexOf(incoming) / movieEntries.Count; + await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken); + + string localPath = getLocalPath(incoming); + + if (await ShouldScanItem(movieRepository, library, existingMovies, incoming, localPath, deepScan) == false) + { + continue; + } + + Either> maybeMovie = await movieRepository + .GetOrAdd(library, incoming) + .MapT( + result => + { + result.LocalPath = localPath; + return result; + }) + .BindT(existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan)) + .BindT(existing => UpdateStatistics(existing, incoming, ffmpegPath, ffprobePath)) + .BindT(UpdateSubtitles); + + if (maybeMovie.IsLeft) + { + foreach (BaseError error in maybeMovie.LeftToSeq()) + { + _logger.LogWarning( + "Error processing movie {Title}: {Error}", + incoming.MovieMetadata.Head().Title, + error.Value); + } + + continue; + } + + foreach (MediaItemScanResult result in maybeMovie.RightToSeq()) + { + await movieRepository.SetEtag(result.Item, MediaServerEtag(incoming)); + + if (_localFileSystem.FileExists(result.LocalPath)) + { + if (await movieRepository.FlagNormal(library, result.Item)) + { + result.IsUpdated = true; + } + } + else + { + Option flagResult = await movieRepository.FlagUnavailable(library, result.Item); + if (flagResult.IsSome) + { + result.IsUpdated = true; + } + } + + if (result.IsAdded) + { + await _searchIndex.AddItems(_searchRepository, new List { result.Item }); + } + else if (result.IsUpdated) + { + await _searchIndex.UpdateItems(_searchRepository, new List { result.Item }); + } + } + } + + // trash items that are no longer present on the media server + var fileNotFoundItemIds = existingMovies.Map(m => m.MediaServerItemId) + .Except(movieEntries.Map(MediaServerItemId)).ToList(); + List ids = await movieRepository.FlagFileNotFound(library, fileNotFoundItemIds); + await _searchIndex.RebuildItems(_searchRepository, ids); + + await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken); + + return Unit.Default; + } + + protected abstract string MediaServerItemId(TMovie movie); + protected abstract string MediaServerEtag(TMovie movie); + + protected abstract Task>> GetMovieLibraryItems( + TConnectionParameters connectionParameters, + TLibrary library); + + protected abstract Task> GetFullMetadata( + TConnectionParameters connectionParameters, + TLibrary library, + MediaItemScanResult result, + TMovie incoming, + bool deepScan); + + protected abstract Task>> UpdateMetadata( + MediaItemScanResult result, + MovieMetadata fullMetadata); + + private async Task ShouldScanItem( + IMediaServerMovieRepository movieRepository, + TLibrary library, + List existingMovies, + TMovie incoming, + string localPath, + bool deepScan) + { + // deep scan will always pull every movie + if (deepScan) + { + return true; + } + + Option maybeExisting = + existingMovies.Find(m => m.MediaServerItemId == MediaServerItemId(incoming)); + string existingItemId = await maybeExisting.Map(e => e.MediaServerItemId).IfNoneAsync(string.Empty); + MediaItemState existingState = await maybeExisting.Map(e => e.State).IfNoneAsync(MediaItemState.Normal); + + if (existingState == MediaItemState.Unavailable) + { + // skip scanning unavailable items that still don't exist locally + if (!_localFileSystem.FileExists(localPath)) + { + return false; + } + } + else if (existingItemId == MediaServerItemId(incoming)) + { + // item is unchanged, but file does not exist + // don't scan, but mark as unavailable + if (!_localFileSystem.FileExists(localPath)) + { + foreach (int id in await movieRepository.FlagUnavailable(library, incoming)) + { + await _searchIndex.RebuildItems(_searchRepository, new List { id }); + } + } + + return false; + } + + if (maybeExisting.IsNone) + { + _logger.LogDebug("INSERT: new movie {Movie}", incoming.MovieMetadata.Head().Title); + } + else + { + _logger.LogDebug("UPDATE: Etag has changed for movie {Movie}", incoming.MovieMetadata.Head().Title); + } + + return true; + } + + private async Task>> UpdateMetadata( + TConnectionParameters connectionParameters, + TLibrary library, + MediaItemScanResult result, + TMovie incoming, + bool deepScan) + { + foreach (MovieMetadata fullMetadata in await GetFullMetadata( + connectionParameters, + library, + result, + incoming, + deepScan)) + { + // TODO: move some of this code into this scanner + // will have to merge JF, Emby, Plex logic + return await UpdateMetadata(result, fullMetadata); + } + + return result; + } + + private async Task>> UpdateStatistics( + MediaItemScanResult result, + TMovie incoming, + string ffmpegPath, + string ffprobePath) + { + TMovie existing = result.Item; + + if (result.IsAdded || MediaServerItemId(existing) != MediaServerItemId(incoming) || + existing.MediaVersions.Head().Streams.Count == 0) + { + if (_localFileSystem.FileExists(result.LocalPath)) + { + _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", result.LocalPath); + Either refreshResult = + await _localStatisticsProvider.RefreshStatistics( + ffmpegPath, + ffprobePath, + existing, + result.LocalPath); + + foreach (BaseError error in refreshResult.LeftToSeq()) + { + _logger.LogWarning( + "Unable to refresh {Attribute} for media item {Path}. Error: {Error}", + "Statistics", + result.LocalPath, + error.Value); + } + + foreach (bool _ in refreshResult.RightToSeq()) + { + result.IsUpdated = true; + } + } + } + + return result; + } + + private async Task>> UpdateSubtitles( + MediaItemScanResult existing) + { + try + { + // skip checking subtitles for files that don't exist locally + if (!_localFileSystem.FileExists(existing.LocalPath)) + { + return existing; + } + + if (await _localSubtitlesProvider.UpdateSubtitles(existing.Item, existing.LocalPath, false)) + { + return existing; + } + + return BaseError.New("Failed to update local subtitles"); + } + catch (Exception ex) + { + return BaseError.New(ex.ToString()); + } + } +} diff --git a/ErsatzTV.Core/Metadata/MovieFolderScanner.cs b/ErsatzTV.Core/Metadata/MovieFolderScanner.cs index 2fef49f2..81264a41 100644 --- a/ErsatzTV.Core/Metadata/MovieFolderScanner.cs +++ b/ErsatzTV.Core/Metadata/MovieFolderScanner.cs @@ -1,5 +1,6 @@ using Bugsnag; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Metadata; @@ -72,118 +73,132 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner decimal progressMax, CancellationToken cancellationToken) { - decimal progressSpread = progressMax - progressMin; - - var foldersCompleted = 0; - - var folderQueue = new Queue(); - foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path) - .Filter(ShouldIncludeFolder) - .OrderBy(identity)) - { - folderQueue.Enqueue(folder); - } - - while (folderQueue.Count > 0) + try { - decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count); - await _mediator.Publish( - new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread), - cancellationToken); - - string movieFolder = folderQueue.Dequeue(); - foldersCompleted++; + decimal progressSpread = progressMax - progressMin; - var filesForEtag = _localFileSystem.ListFiles(movieFolder).ToList(); + var foldersCompleted = 0; - var allFiles = filesForEtag - .Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))) - .Filter(f => !Path.GetFileName(f).StartsWith("._")) - .Filter( - f => !ExtraFiles.Any( - e => Path.GetFileNameWithoutExtension(f).EndsWith(e, StringComparison.OrdinalIgnoreCase))) - .ToList(); + var folderQueue = new Queue(); + foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path) + .Filter(ShouldIncludeFolder) + .OrderBy(identity)) + { + folderQueue.Enqueue(folder); + } - if (allFiles.Count == 0) + while (folderQueue.Count > 0) { - foreach (string subdirectory in _localFileSystem.ListSubdirectories(movieFolder) - .Filter(ShouldIncludeFolder) - .OrderBy(identity)) + if (cancellationToken.IsCancellationRequested) { - folderQueue.Enqueue(subdirectory); + return new ScanCanceled(); } - continue; - } + decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count); + await _mediator.Publish( + new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread), + cancellationToken); - string etag = FolderEtag.Calculate(movieFolder, _localFileSystem); - Option knownFolder = libraryPath.LibraryFolders - .Filter(f => f.Path == movieFolder) - .HeadOrNone(); + string movieFolder = folderQueue.Dequeue(); + foldersCompleted++; - // skip folder if etag matches - if (await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag) - { - continue; - } + var filesForEtag = _localFileSystem.ListFiles(movieFolder).ToList(); - _logger.LogDebug( - "UPDATE: Etag has changed for folder {Folder}", - movieFolder); + var allFiles = filesForEtag + .Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))) + .Filter(f => !Path.GetFileName(f).StartsWith("._")) + .Filter( + f => !ExtraFiles.Any( + e => Path.GetFileNameWithoutExtension(f).EndsWith(e, StringComparison.OrdinalIgnoreCase))) + .ToList(); - foreach (string file in allFiles.OrderBy(identity)) - { - // TODO: figure out how to rebuild playlists - Either> maybeMovie = await _movieRepository - .GetOrAdd(libraryPath, file) - .BindT(movie => UpdateStatistics(movie, ffmpegPath, ffprobePath)) - .BindT(UpdateMetadata) - .BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster, cancellationToken)) - .BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt, cancellationToken)) - .BindT(UpdateSubtitles) - .BindT(FlagNormal); - - foreach (BaseError error in maybeMovie.LeftToSeq()) + if (allFiles.Count == 0) { - _logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value); + foreach (string subdirectory in _localFileSystem.ListSubdirectories(movieFolder) + .Filter(ShouldIncludeFolder) + .OrderBy(identity)) + { + folderQueue.Enqueue(subdirectory); + } + + continue; } - foreach (MediaItemScanResult result in maybeMovie.RightToSeq()) + string etag = FolderEtag.Calculate(movieFolder, _localFileSystem); + Option knownFolder = libraryPath.LibraryFolders + .Filter(f => f.Path == movieFolder) + .HeadOrNone(); + + // skip folder if etag matches + if (await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag) { - if (result.IsAdded) + continue; + } + + _logger.LogDebug( + "UPDATE: Etag has changed for folder {Folder}", + movieFolder); + + foreach (string file in allFiles.OrderBy(identity)) + { + // TODO: figure out how to rebuild playlists + Either> maybeMovie = await _movieRepository + .GetOrAdd(libraryPath, file) + .BindT(movie => UpdateStatistics(movie, ffmpegPath, ffprobePath)) + .BindT(UpdateMetadata) + .BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster, cancellationToken)) + .BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt, cancellationToken)) + .BindT(UpdateSubtitles) + .BindT(FlagNormal); + + foreach (BaseError error in maybeMovie.LeftToSeq()) { - await _searchIndex.AddItems(_searchRepository, new List { result.Item }); + _logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value); } - else if (result.IsUpdated) + + foreach (MediaItemScanResult result in maybeMovie.RightToSeq()) { - await _searchIndex.UpdateItems(_searchRepository, new List { result.Item }); + if (result.IsAdded) + { + await _searchIndex.AddItems(_searchRepository, new List { result.Item }); + } + else if (result.IsUpdated) + { + await _searchIndex.UpdateItems(_searchRepository, new List { result.Item }); + } + + await _libraryRepository.SetEtag(libraryPath, knownFolder, movieFolder, etag); } - - await _libraryRepository.SetEtag(libraryPath, knownFolder, movieFolder, etag); } } - } - foreach (string path in await _movieRepository.FindMoviePaths(libraryPath)) - { - if (!_localFileSystem.FileExists(path)) + foreach (string path in await _movieRepository.FindMoviePaths(libraryPath)) { - _logger.LogInformation("Flagging missing movie at {Path}", path); - List ids = await FlagFileNotFound(libraryPath, path); - await _searchIndex.RebuildItems(_searchRepository, ids); - } - else if (Path.GetFileName(path).StartsWith("._")) - { - _logger.LogInformation("Removing dot underscore file at {Path}", path); - List ids = await _movieRepository.DeleteByPath(libraryPath, path); - await _searchIndex.RemoveItems(ids); + if (!_localFileSystem.FileExists(path)) + { + _logger.LogInformation("Flagging missing movie at {Path}", path); + List ids = await FlagFileNotFound(libraryPath, path); + await _searchIndex.RebuildItems(_searchRepository, ids); + } + else if (Path.GetFileName(path).StartsWith("._")) + { + _logger.LogInformation("Removing dot underscore file at {Path}", path); + List ids = await _movieRepository.DeleteByPath(libraryPath, path); + await _searchIndex.RemoveItems(ids); + } } - } - - await _libraryRepository.CleanEtagsForLibraryPath(libraryPath); - _searchIndex.Commit(); - return Unit.Default; + await _libraryRepository.CleanEtagsForLibraryPath(libraryPath); + return Unit.Default; + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return new ScanCanceled(); + } + finally + { + _searchIndex.Commit(); + } } private async Task>> UpdateMetadata( @@ -192,7 +207,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner try { Movie movie = result.Item; - + Option maybeNfoFile = LocateNfoFile(movie); if (maybeNfoFile.IsNone) { diff --git a/ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs b/ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs index 82040256..7758acc7 100644 --- a/ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs +++ b/ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs @@ -1,5 +1,6 @@ using Bugsnag; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Metadata; @@ -73,37 +74,55 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan decimal progressMax, CancellationToken cancellationToken) { - decimal progressSpread = progressMax - progressMin; + try + { + decimal progressSpread = progressMax - progressMin; - var allArtistFolders = _localFileSystem.ListSubdirectories(libraryPath.Path) - .Filter(ShouldIncludeFolder) - .OrderBy(identity) - .ToList(); + var allArtistFolders = _localFileSystem.ListSubdirectories(libraryPath.Path) + .Filter(ShouldIncludeFolder) + .OrderBy(identity) + .ToList(); - foreach (string artistFolder in allArtistFolders) - { - // _logger.LogDebug("Scanning artist folder {Folder}", artistFolder); - - decimal percentCompletion = (decimal)allArtistFolders.IndexOf(artistFolder) / allArtistFolders.Count; - await _mediator.Publish( - new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread)); - - Either> maybeArtist = - await FindOrCreateArtist(libraryPath.Id, artistFolder) - .BindT(artist => UpdateMetadataForArtist(artist, artistFolder)) - .BindT( - artist => UpdateArtworkForArtist( - artist, - artistFolder, - ArtworkKind.Thumbnail, - cancellationToken)) - .BindT( - artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.FanArt, cancellationToken)); - - await maybeArtist.Match( - async result => + foreach (string artistFolder in allArtistFolders) + { + // _logger.LogDebug("Scanning artist folder {Folder}", artistFolder); + if (cancellationToken.IsCancellationRequested) + { + return new ScanCanceled(); + } + + decimal percentCompletion = (decimal)allArtistFolders.IndexOf(artistFolder) / allArtistFolders.Count; + await _mediator.Publish( + new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread), + cancellationToken); + + Either> maybeArtist = + await FindOrCreateArtist(libraryPath.Id, artistFolder) + .BindT(artist => UpdateMetadataForArtist(artist, artistFolder)) + .BindT( + artist => UpdateArtworkForArtist( + artist, + artistFolder, + ArtworkKind.Thumbnail, + cancellationToken)) + .BindT( + artist => UpdateArtworkForArtist( + artist, + artistFolder, + ArtworkKind.FanArt, + cancellationToken)); + + foreach (BaseError error in maybeArtist.LeftToSeq()) + { + _logger.LogWarning( + "Error processing artist in folder {Folder}: {Error}", + artistFolder, + error.Value); + } + + foreach (MediaItemScanResult result in maybeArtist.RightToSeq()) { - await ScanMusicVideos( + Either scanResult = await ScanMusicVideos( libraryPath, ffmpegPath, ffprobePath, @@ -111,6 +130,11 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan artistFolder, cancellationToken); + foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) + { + return error; + } + if (result.IsAdded) { await _searchIndex.AddItems(_searchRepository, new List { result.Item }); @@ -119,47 +143,47 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan { await _searchIndex.UpdateItems(_searchRepository, new List { result.Item }); } - }, - error => - { - _logger.LogWarning( - "Error processing artist in folder {Folder}: {Error}", - artistFolder, - error.Value); - return Task.FromResult(Unit.Default); - }); - } - - foreach (string path in await _musicVideoRepository.FindOrphanPaths(libraryPath)) - { - _logger.LogInformation("Removing improperly named music video at {Path}", path); - List musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path); - await _searchIndex.RemoveItems(musicVideoIds); - } - - foreach (string path in await _musicVideoRepository.FindMusicVideoPaths(libraryPath)) - { - if (!_localFileSystem.FileExists(path)) - { - _logger.LogInformation("Flagging missing music video at {Path}", path); - List musicVideoIds = await FlagFileNotFound(libraryPath, path); - await _searchIndex.RebuildItems(_searchRepository, musicVideoIds); + } } - else if (Path.GetFileName(path).StartsWith("._")) + + foreach (string path in await _musicVideoRepository.FindOrphanPaths(libraryPath)) { - _logger.LogInformation("Removing dot underscore file at {Path}", path); + _logger.LogInformation("Removing improperly named music video at {Path}", path); List musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path); await _searchIndex.RemoveItems(musicVideoIds); } - } - await _libraryRepository.CleanEtagsForLibraryPath(libraryPath); + foreach (string path in await _musicVideoRepository.FindMusicVideoPaths(libraryPath)) + { + if (!_localFileSystem.FileExists(path)) + { + _logger.LogInformation("Flagging missing music video at {Path}", path); + List musicVideoIds = await FlagFileNotFound(libraryPath, path); + await _searchIndex.RebuildItems(_searchRepository, musicVideoIds); + } + else if (Path.GetFileName(path).StartsWith("._")) + { + _logger.LogInformation("Removing dot underscore file at {Path}", path); + List musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path); + await _searchIndex.RemoveItems(musicVideoIds); + } + } + + await _libraryRepository.CleanEtagsForLibraryPath(libraryPath); - List artistIds = await _artistRepository.DeleteEmptyArtists(libraryPath); - await _searchIndex.RemoveItems(artistIds); + List artistIds = await _artistRepository.DeleteEmptyArtists(libraryPath); + await _searchIndex.RemoveItems(artistIds); - _searchIndex.Commit(); - return Unit.Default; + return Unit.Default; + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return new ScanCanceled(); + } + finally + { + _searchIndex.Commit(); + } } private async Task>> FindOrCreateArtist( @@ -244,7 +268,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan } } - private async Task ScanMusicVideos( + private async Task> ScanMusicVideos( LibraryPath libraryPath, string ffmpegPath, string ffprobePath, @@ -257,6 +281,11 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan while (folderQueue.Count > 0) { + if (cancellationToken.IsCancellationRequested) + { + return new ScanCanceled(); + } + string musicVideoFolder = folderQueue.Dequeue(); // _logger.LogDebug("Scanning music video folder {Folder}", musicVideoFolder); @@ -293,27 +322,28 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan .BindT(UpdateSubtitles) .BindT(FlagNormal); - await maybeMusicVideo.Match( - async result => - { - if (result.IsAdded) - { - await _searchIndex.AddItems(_searchRepository, new List { result.Item }); - } - else if (result.IsUpdated) - { - await _searchIndex.UpdateItems(_searchRepository, new List { result.Item }); - } + foreach (BaseError error in maybeMusicVideo.LeftToSeq()) + { + _logger.LogWarning("Error processing music video at {Path}: {Error}", file, error.Value); + } - await _libraryRepository.SetEtag(libraryPath, knownFolder, musicVideoFolder, etag); - }, - error => + foreach (MediaItemScanResult result in maybeMusicVideo.RightToSeq()) + { + if (result.IsAdded) + { + await _searchIndex.AddItems(_searchRepository, new List { result.Item }); + } + else if (result.IsUpdated) { - _logger.LogWarning("Error processing music video at {Path}: {Error}", file, error.Value); - return Task.CompletedTask; - }); + await _searchIndex.UpdateItems(_searchRepository, new List { result.Item }); + } + + await _libraryRepository.SetEtag(libraryPath, knownFolder, musicVideoFolder, etag); + } } } + + return Unit.Default; } private async Task>> UpdateMetadata( @@ -322,37 +352,39 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan try { MusicVideo musicVideo = result.Item; - await LocateNfoFile(musicVideo).Match( - async nfoFile => + + Option maybeNfoFile = LocateNfoFile(musicVideo); + if (maybeNfoFile.IsNone) + { + if (!Optional(musicVideo.MusicVideoMetadata).Flatten().Any()) { - bool shouldUpdate = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match( - m => m.MetadataKind == MetadataKind.Fallback || - m.DateUpdated != _localFileSystem.GetLastWriteTime(nfoFile), - true); + musicVideo.MusicVideoMetadata ??= new List(); - if (shouldUpdate) + string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path; + _logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path); + if (await _localMetadataProvider.RefreshFallbackMetadata(musicVideo)) { - _logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile); - if (await _localMetadataProvider.RefreshSidecarMetadata(musicVideo, nfoFile)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } - }, - async () => + } + } + + foreach (string nfoFile in maybeNfoFile) + { + bool shouldUpdate = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match( + m => m.MetadataKind == MetadataKind.Fallback || + m.DateUpdated != _localFileSystem.GetLastWriteTime(nfoFile), + true); + + if (shouldUpdate) { - if (!Optional(musicVideo.MusicVideoMetadata).Flatten().Any()) + _logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile); + if (await _localMetadataProvider.RefreshSidecarMetadata(musicVideo, nfoFile)) { - musicVideo.MusicVideoMetadata ??= new List(); - - string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path; - _logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path); - if (await _localMetadataProvider.RefreshFallbackMetadata(musicVideo)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } - }); + } + } return result; } @@ -364,8 +396,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan } private Option LocateNfoFileForArtist(string artistFolder) => - Optional(Path.Combine(artistFolder, "artist.nfo")) - .Filter(s => _localFileSystem.FileExists(s)); + Optional(Path.Combine(artistFolder, "artist.nfo")).Filter(s => _localFileSystem.FileExists(s)); private Option LocateArtworkForArtist(string artistFolder, ArtworkKind artworkKind) { @@ -398,12 +429,13 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan try { MusicVideo musicVideo = result.Item; - await LocateThumbnail(musicVideo).IfSomeAsync( - async thumbnailFile => - { - MusicVideoMetadata metadata = musicVideo.MusicVideoMetadata.Head(); - await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None, None, cancellationToken); - }); + + Option maybeThumbnail = LocateThumbnail(musicVideo); + foreach (string thumbnailFile in maybeThumbnail) + { + MusicVideoMetadata metadata = musicVideo.MusicVideoMetadata.Head(); + await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None, None, cancellationToken); + } return result; } diff --git a/ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs b/ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs index c39f8f16..2a1adec1 100644 --- a/ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs +++ b/ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs @@ -1,5 +1,6 @@ using Bugsnag; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Metadata; @@ -67,75 +68,89 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan string ffmpegPath, string ffprobePath, decimal progressMin, - decimal progressMax) + decimal progressMax, + CancellationToken cancellationToken) { - decimal progressSpread = progressMax - progressMin; - - var foldersCompleted = 0; - - var folderQueue = new Queue(); - - if (ShouldIncludeFolder(libraryPath.Path)) - { - folderQueue.Enqueue(libraryPath.Path); - } - - foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path) - .Filter(ShouldIncludeFolder) - .OrderBy(identity)) - { - folderQueue.Enqueue(folder); - } - - while (folderQueue.Count > 0) + try { - decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count); - await _mediator.Publish( - new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread)); + decimal progressSpread = progressMax - progressMin; - string otherVideoFolder = folderQueue.Dequeue(); - foldersCompleted++; + var foldersCompleted = 0; - var filesForEtag = _localFileSystem.ListFiles(otherVideoFolder).ToList(); + var folderQueue = new Queue(); - var allFiles = filesForEtag - .Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))) - .Filter(f => !Path.GetFileName(f).StartsWith("._")) - .ToList(); + if (ShouldIncludeFolder(libraryPath.Path)) + { + folderQueue.Enqueue(libraryPath.Path); + } - foreach (string subdirectory in _localFileSystem.ListSubdirectories(otherVideoFolder) + foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path) .Filter(ShouldIncludeFolder) .OrderBy(identity)) { - folderQueue.Enqueue(subdirectory); + folderQueue.Enqueue(folder); } - string etag = FolderEtag.Calculate(otherVideoFolder, _localFileSystem); - Option knownFolder = libraryPath.LibraryFolders - .Filter(f => f.Path == otherVideoFolder) - .HeadOrNone(); - - // skip folder if etag matches - if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag) + while (folderQueue.Count > 0) { - continue; - } + if (cancellationToken.IsCancellationRequested) + { + return new ScanCanceled(); + } - _logger.LogDebug( - "UPDATE: Etag has changed for folder {Folder}", - otherVideoFolder); + decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count); + await _mediator.Publish( + new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread), + cancellationToken); - foreach (string file in allFiles.OrderBy(identity)) - { - Either> maybeVideo = await _otherVideoRepository - .GetOrAdd(libraryPath, file) - .BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath)) - .BindT(UpdateMetadata) - .BindT(UpdateSubtitles) - .BindT(FlagNormal); - - await maybeVideo.Match( - async result => + string otherVideoFolder = folderQueue.Dequeue(); + foldersCompleted++; + + var filesForEtag = _localFileSystem.ListFiles(otherVideoFolder).ToList(); + + var allFiles = filesForEtag + .Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))) + .Filter(f => !Path.GetFileName(f).StartsWith("._")) + .ToList(); + + foreach (string subdirectory in _localFileSystem.ListSubdirectories(otherVideoFolder) + .Filter(ShouldIncludeFolder) + .OrderBy(identity)) + { + folderQueue.Enqueue(subdirectory); + } + + string etag = FolderEtag.Calculate(otherVideoFolder, _localFileSystem); + Option knownFolder = libraryPath.LibraryFolders + .Filter(f => f.Path == otherVideoFolder) + .HeadOrNone(); + + // skip folder if etag matches + if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == + etag) + { + continue; + } + + _logger.LogDebug( + "UPDATE: Etag has changed for folder {Folder}", + otherVideoFolder); + + foreach (string file in allFiles.OrderBy(identity)) + { + Either> maybeVideo = await _otherVideoRepository + .GetOrAdd(libraryPath, file) + .BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath)) + .BindT(UpdateMetadata) + .BindT(UpdateSubtitles) + .BindT(FlagNormal); + + foreach (BaseError error in maybeVideo.LeftToSeq()) + { + _logger.LogWarning("Error processing other video at {Path}: {Error}", file, error.Value); + } + + foreach (MediaItemScanResult result in maybeVideo.RightToSeq()) { if (result.IsAdded) { @@ -147,35 +162,38 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan } await _libraryRepository.SetEtag(libraryPath, knownFolder, otherVideoFolder, etag); - }, - error => - { - _logger.LogWarning("Error processing other video at {Path}: {Error}", file, error.Value); - return Task.CompletedTask; - }); + } + } } - } - foreach (string path in await _otherVideoRepository.FindOtherVideoPaths(libraryPath)) - { - if (!_localFileSystem.FileExists(path)) + foreach (string path in await _otherVideoRepository.FindOtherVideoPaths(libraryPath)) { - _logger.LogInformation("Flagging missing other video at {Path}", path); - List otherVideoIds = await FlagFileNotFound(libraryPath, path); - await _searchIndex.RebuildItems(_searchRepository, otherVideoIds); - } - else if (Path.GetFileName(path).StartsWith("._")) - { - _logger.LogInformation("Removing dot underscore file at {Path}", path); - List otherVideoIds = await _otherVideoRepository.DeleteByPath(libraryPath, path); - await _searchIndex.RemoveItems(otherVideoIds); + if (!_localFileSystem.FileExists(path)) + { + _logger.LogInformation("Flagging missing other video at {Path}", path); + List otherVideoIds = await FlagFileNotFound(libraryPath, path); + await _searchIndex.RebuildItems(_searchRepository, otherVideoIds); + } + else if (Path.GetFileName(path).StartsWith("._")) + { + _logger.LogInformation("Removing dot underscore file at {Path}", path); + List otherVideoIds = await _otherVideoRepository.DeleteByPath(libraryPath, path); + await _searchIndex.RemoveItems(otherVideoIds); + } } - } - await _libraryRepository.CleanEtagsForLibraryPath(libraryPath); + await _libraryRepository.CleanEtagsForLibraryPath(libraryPath); - _searchIndex.Commit(); - return Unit.Default; + return Unit.Default; + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return new ScanCanceled(); + } + finally + { + _searchIndex.Commit(); + } } private async Task>> UpdateMetadata( diff --git a/ErsatzTV.Core/Metadata/SongFolderScanner.cs b/ErsatzTV.Core/Metadata/SongFolderScanner.cs index 5123280d..19551a47 100644 --- a/ErsatzTV.Core/Metadata/SongFolderScanner.cs +++ b/ErsatzTV.Core/Metadata/SongFolderScanner.cs @@ -1,5 +1,6 @@ using Bugsnag; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Images; @@ -68,73 +69,86 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner decimal progressMax, CancellationToken cancellationToken) { - decimal progressSpread = progressMax - progressMin; - - var foldersCompleted = 0; - - var folderQueue = new Queue(); - - if (ShouldIncludeFolder(libraryPath.Path)) - { - folderQueue.Enqueue(libraryPath.Path); - } - - foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path) - .Filter(ShouldIncludeFolder) - .OrderBy(identity)) - { - folderQueue.Enqueue(folder); - } - - while (folderQueue.Count > 0) + try { - decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count); - await _mediator.Publish( - new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread)); + decimal progressSpread = progressMax - progressMin; - string songFolder = folderQueue.Dequeue(); - foldersCompleted++; + var foldersCompleted = 0; - var filesForEtag = _localFileSystem.ListFiles(songFolder).ToList(); + var folderQueue = new Queue(); - var allFiles = filesForEtag - .Filter(f => AudioFileExtensions.Contains(Path.GetExtension(f))) - .Filter(f => !Path.GetFileName(f).StartsWith("._")) - .ToList(); + if (ShouldIncludeFolder(libraryPath.Path)) + { + folderQueue.Enqueue(libraryPath.Path); + } - foreach (string subdirectory in _localFileSystem.ListSubdirectories(songFolder) + foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path) .Filter(ShouldIncludeFolder) .OrderBy(identity)) { - folderQueue.Enqueue(subdirectory); + folderQueue.Enqueue(folder); } - string etag = FolderEtag.Calculate(songFolder, _localFileSystem); - Option knownFolder = libraryPath.LibraryFolders - .Filter(f => f.Path == songFolder) - .HeadOrNone(); - - // skip folder if etag matches - if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag) + while (folderQueue.Count > 0) { - continue; - } + if (cancellationToken.IsCancellationRequested) + { + return new ScanCanceled(); + } - _logger.LogDebug( - "UPDATE: Etag has changed for folder {Folder}", - songFolder); + decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count); + await _mediator.Publish( + new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread), + cancellationToken); - foreach (string file in allFiles.OrderBy(identity)) - { - Either> maybeSong = await _songRepository - .GetOrAdd(libraryPath, file) - .BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath)) - .BindT(video => UpdateMetadata(video, ffprobePath)) - .BindT(video => UpdateThumbnail(video, ffmpegPath, cancellationToken)) - .BindT(FlagNormal); - - await maybeSong.Match( - async result => + string songFolder = folderQueue.Dequeue(); + foldersCompleted++; + + var filesForEtag = _localFileSystem.ListFiles(songFolder).ToList(); + + var allFiles = filesForEtag + .Filter(f => AudioFileExtensions.Contains(Path.GetExtension(f))) + .Filter(f => !Path.GetFileName(f).StartsWith("._")) + .ToList(); + + foreach (string subdirectory in _localFileSystem.ListSubdirectories(songFolder) + .Filter(ShouldIncludeFolder) + .OrderBy(identity)) + { + folderQueue.Enqueue(subdirectory); + } + + string etag = FolderEtag.Calculate(songFolder, _localFileSystem); + Option knownFolder = libraryPath.LibraryFolders + .Filter(f => f.Path == songFolder) + .HeadOrNone(); + + // skip folder if etag matches + if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == + etag) + { + continue; + } + + _logger.LogDebug( + "UPDATE: Etag has changed for folder {Folder}", + songFolder); + + foreach (string file in allFiles.OrderBy(identity)) + { + Either> maybeSong = await _songRepository + .GetOrAdd(libraryPath, file) + .BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath)) + .BindT(video => UpdateMetadata(video, ffprobePath)) + .BindT(video => UpdateThumbnail(video, ffmpegPath, cancellationToken)) + .BindT(FlagNormal); + + foreach (BaseError error in maybeSong.LeftToSeq()) + { + _logger.LogWarning("Error processing song at {Path}: {Error}", file, error.Value); + } + + foreach (MediaItemScanResult result in maybeSong.RightToSeq()) { if (result.IsAdded) { @@ -146,35 +160,38 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner } await _libraryRepository.SetEtag(libraryPath, knownFolder, songFolder, etag); - }, - error => - { - _logger.LogWarning("Error processing song at {Path}: {Error}", file, error.Value); - return Task.CompletedTask; - }); + } + } } - } - foreach (string path in await _songRepository.FindSongPaths(libraryPath)) - { - if (!_localFileSystem.FileExists(path)) + foreach (string path in await _songRepository.FindSongPaths(libraryPath)) { - _logger.LogInformation("Flagging missing song at {Path}", path); - List songIds = await FlagFileNotFound(libraryPath, path); - await _searchIndex.RebuildItems(_searchRepository, songIds); - } - else if (Path.GetFileName(path).StartsWith("._")) - { - _logger.LogInformation("Removing dot underscore file at {Path}", path); - List songIds = await _songRepository.DeleteByPath(libraryPath, path); - await _searchIndex.RemoveItems(songIds); + if (!_localFileSystem.FileExists(path)) + { + _logger.LogInformation("Flagging missing song at {Path}", path); + List songIds = await FlagFileNotFound(libraryPath, path); + await _searchIndex.RebuildItems(_searchRepository, songIds); + } + else if (Path.GetFileName(path).StartsWith("._")) + { + _logger.LogInformation("Removing dot underscore file at {Path}", path); + List songIds = await _songRepository.DeleteByPath(libraryPath, path); + await _searchIndex.RemoveItems(songIds); + } } - } - await _libraryRepository.CleanEtagsForLibraryPath(libraryPath); + await _libraryRepository.CleanEtagsForLibraryPath(libraryPath); - _searchIndex.Commit(); - return Unit.Default; + return Unit.Default; + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return new ScanCanceled(); + } + finally + { + _searchIndex.Commit(); + } } private async Task>> UpdateMetadata( @@ -231,20 +248,24 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner } Song song = result.Item; + Option maybeThumbnail = LocateThumbnail(song); + if (maybeThumbnail.IsNone) + { + await ExtractEmbeddedArtwork(song, ffmpegPath, cancellationToken); + } - await LocateThumbnail(song).Match( - async thumbnailFile => - { - SongMetadata metadata = song.SongMetadata.Head(); - await RefreshArtwork( - thumbnailFile, - metadata, - ArtworkKind.Thumbnail, - ffmpegPath, - None, - cancellationToken); - }, - () => ExtractEmbeddedArtwork(song, ffmpegPath, cancellationToken)); + + foreach (string thumbnailFile in maybeThumbnail) + { + SongMetadata metadata = song.SongMetadata.Head(); + await RefreshArtwork( + thumbnailFile, + metadata, + ArtworkKind.Thumbnail, + ffmpegPath, + None, + cancellationToken); + } return result; } diff --git a/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs b/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs index 780551af..e7b992d9 100644 --- a/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs +++ b/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs @@ -1,5 +1,6 @@ using Bugsnag; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Metadata; @@ -72,73 +73,100 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan decimal progressMax, CancellationToken cancellationToken) { - decimal progressSpread = progressMax - progressMin; + try + { + decimal progressSpread = progressMax - progressMin; - var allShowFolders = _localFileSystem.ListSubdirectories(libraryPath.Path) - .Filter(ShouldIncludeFolder) - .OrderBy(identity) - .ToList(); + var allShowFolders = _localFileSystem.ListSubdirectories(libraryPath.Path) + .Filter(ShouldIncludeFolder) + .OrderBy(identity) + .ToList(); - foreach (string showFolder in allShowFolders) - { - decimal percentCompletion = (decimal)allShowFolders.IndexOf(showFolder) / allShowFolders.Count; - await _mediator.Publish( - new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread), - cancellationToken); - - Either> maybeShow = - await FindOrCreateShow(libraryPath.Id, showFolder) - .BindT(show => UpdateMetadataForShow(show, showFolder)) - .BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster, cancellationToken)) - .BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt, cancellationToken)) - .BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Thumbnail, cancellationToken)); - - foreach (BaseError error in maybeShow.LeftToSeq()) + foreach (string showFolder in allShowFolders) { - _logger.LogWarning( - "Error processing show in folder {Folder}: {Error}", - showFolder, - error.Value); - } + if (cancellationToken.IsCancellationRequested) + { + return new ScanCanceled(); + } - foreach (MediaItemScanResult result in maybeShow.RightToSeq()) - { - await ScanSeasons(libraryPath, ffmpegPath, ffprobePath, result.Item, showFolder, cancellationToken); + decimal percentCompletion = (decimal)allShowFolders.IndexOf(showFolder) / allShowFolders.Count; + await _mediator.Publish( + new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread), + cancellationToken); - if (result.IsAdded) + Either> maybeShow = + await FindOrCreateShow(libraryPath.Id, showFolder) + .BindT(show => UpdateMetadataForShow(show, showFolder)) + .BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster, cancellationToken)) + .BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt, cancellationToken)) + .BindT( + show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Thumbnail, cancellationToken)); + + foreach (BaseError error in maybeShow.LeftToSeq()) { - await _searchIndex.AddItems(_searchRepository, new List { result.Item }); + _logger.LogWarning( + "Error processing show in folder {Folder}: {Error}", + showFolder, + error.Value); } - else if (result.IsUpdated) + + foreach (MediaItemScanResult result in maybeShow.RightToSeq()) { - await _searchIndex.UpdateItems(_searchRepository, new List { result.Item }); + Either scanResult = await ScanSeasons( + libraryPath, + ffmpegPath, + ffprobePath, + result.Item, + showFolder, + cancellationToken); + + foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) + { + return error; + } + + if (result.IsAdded) + { + await _searchIndex.AddItems(_searchRepository, new List { result.Item }); + } + else if (result.IsUpdated) + { + await _searchIndex.UpdateItems(_searchRepository, new List { result.Item }); + } } } - } - foreach (string path in await _televisionRepository.FindEpisodePaths(libraryPath)) - { - if (!_localFileSystem.FileExists(path)) - { - _logger.LogInformation("Flagging missing episode at {Path}", path); - List episodeIds = await FlagFileNotFound(libraryPath, path); - await _searchIndex.RebuildItems(_searchRepository, episodeIds); - } - else if (Path.GetFileName(path).StartsWith("._")) + foreach (string path in await _televisionRepository.FindEpisodePaths(libraryPath)) { - _logger.LogInformation("Removing dot underscore file at {Path}", path); - await _televisionRepository.DeleteByPath(libraryPath, path); + if (!_localFileSystem.FileExists(path)) + { + _logger.LogInformation("Flagging missing episode at {Path}", path); + List episodeIds = await FlagFileNotFound(libraryPath, path); + await _searchIndex.RebuildItems(_searchRepository, episodeIds); + } + else if (Path.GetFileName(path).StartsWith("._")) + { + _logger.LogInformation("Removing dot underscore file at {Path}", path); + await _televisionRepository.DeleteByPath(libraryPath, path); + } } - } - await _libraryRepository.CleanEtagsForLibraryPath(libraryPath); + await _libraryRepository.CleanEtagsForLibraryPath(libraryPath); - await _televisionRepository.DeleteEmptySeasons(libraryPath); - List ids = await _televisionRepository.DeleteEmptyShows(libraryPath); - await _searchIndex.RemoveItems(ids); + await _televisionRepository.DeleteEmptySeasons(libraryPath); + List ids = await _televisionRepository.DeleteEmptyShows(libraryPath); + await _searchIndex.RemoveItems(ids); - _searchIndex.Commit(); - return Unit.Default; + return Unit.Default; + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return new ScanCanceled(); + } + finally + { + _searchIndex.Commit(); + } } private async Task>> FindOrCreateShow( @@ -147,12 +175,16 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan { ShowMetadata metadata = await _localMetadataProvider.GetMetadataForShow(showFolder); Option maybeShow = await _televisionRepository.GetShowByMetadata(libraryPathId, metadata); - return await maybeShow.Match( - show => Right>(new MediaItemScanResult(show)).AsTask(), - async () => await _televisionRepository.AddShow(libraryPathId, showFolder, metadata)); + + foreach (Show show in maybeShow) + { + return new MediaItemScanResult(show); + } + + return await _televisionRepository.AddShow(libraryPathId, showFolder, metadata); } - private async Task ScanSeasons( + private async Task> ScanSeasons( LibraryPath libraryPath, string ffmpegPath, string ffprobePath, @@ -163,6 +195,11 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showFolder).Filter(ShouldIncludeFolder) .OrderBy(identity)) { + if (cancellationToken.IsCancellationRequested) + { + return new ScanCanceled(); + } + string etag = FolderEtag.CalculateWithSubfolders(seasonFolder, _localFileSystem); Option knownFolder = libraryPath.LibraryFolders .Filter(f => f.Path == seasonFolder) @@ -175,44 +212,48 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan } Option maybeSeasonNumber = SeasonNumberForFolder(seasonFolder); - await maybeSeasonNumber.IfSomeAsync( - async seasonNumber => + foreach (int seasonNumber in maybeSeasonNumber) + { + Either maybeSeason = await _televisionRepository + .GetOrAddSeason(show, libraryPath.Id, seasonNumber) + .BindT(EnsureMetadataExists) + .BindT(season => UpdatePoster(season, seasonFolder, cancellationToken)); + + foreach (BaseError error in maybeSeason.LeftToSeq()) { - Either maybeSeason = await _televisionRepository - .GetOrAddSeason(show, libraryPath.Id, seasonNumber) - .BindT(EnsureMetadataExists) - .BindT(season => UpdatePoster(season, seasonFolder, cancellationToken)); - - await maybeSeason.Match( - async season => - { - await ScanEpisodes( - libraryPath, - ffmpegPath, - ffprobePath, - season, - seasonFolder, - cancellationToken); - await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag); - - season.Show = show; - await _searchIndex.UpdateItems(_searchRepository, new List { season }); - }, - error => - { - _logger.LogWarning( - "Error processing season in folder {Folder}: {Error}", - seasonFolder, - error.Value); - return Task.FromResult(Unit.Default); - }); - }); + _logger.LogWarning( + "Error processing season in folder {Folder}: {Error}", + seasonFolder, + error.Value); + } + + foreach (Season season in maybeSeason.RightToSeq()) + { + Either scanResult = await ScanEpisodes( + libraryPath, + ffmpegPath, + ffprobePath, + season, + seasonFolder, + cancellationToken); + + foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) + { + return error; + } + + await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag); + + season.Show = show; + await _searchIndex.UpdateItems(_searchRepository, new List { season }); + } + } } return Unit.Default; } - private async Task ScanEpisodes( + private async Task> ScanEpisodes( LibraryPath libraryPath, string ffmpegPath, string ffprobePath, @@ -266,7 +307,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan try { Show show = result.Item; - + Option maybeNfo = LocateNfoFileForShow(showFolder); if (maybeNfo.IsNone) { @@ -461,14 +502,12 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan } private Option LocateNfoFileForShow(string showFolder) => - Optional(Path.Combine(showFolder, "tvshow.nfo")) - .Filter(s => _localFileSystem.FileExists(s)); + Optional(Path.Combine(showFolder, "tvshow.nfo")).Filter(s => _localFileSystem.FileExists(s)); private Option LocateNfoFile(Episode episode) { string path = episode.MediaVersions.Head().MediaFiles.Head().Path; - return Optional(Path.ChangeExtension(path, "nfo")) - .Filter(s => _localFileSystem.FileExists(s)); + return Optional(Path.ChangeExtension(path, "nfo")).Filter(s => _localFileSystem.FileExists(s)); } private Option LocateArtworkForShow(string showFolder, ArtworkKind artworkKind) diff --git a/ErsatzTV.Core/Plex/PlexConnectionParameters.cs b/ErsatzTV.Core/Plex/PlexConnectionParameters.cs new file mode 100644 index 00000000..6b53b851 --- /dev/null +++ b/ErsatzTV.Core/Plex/PlexConnectionParameters.cs @@ -0,0 +1,7 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.MediaServer; + +namespace ErsatzTV.Core.Plex; + +public record PlexConnectionParameters + (PlexConnection Connection, PlexServerAuthToken Token) : MediaServerConnectionParameters; diff --git a/ErsatzTV.Core/Plex/PlexItemEtag.cs b/ErsatzTV.Core/Plex/PlexItemEtag.cs index de7dfac1..a398970a 100644 --- a/ErsatzTV.Core/Plex/PlexItemEtag.cs +++ b/ErsatzTV.Core/Plex/PlexItemEtag.cs @@ -2,9 +2,10 @@ namespace ErsatzTV.Core.Plex; -public class PlexItemEtag +public class PlexItemEtag : MediaServerItemEtag { public string Key { get; set; } - public string Etag { get; set; } - public MediaItemState State { get; set; } + public override string MediaServerItemId => Key; + public override string Etag { get; set; } + public override MediaItemState State { get; set; } } diff --git a/ErsatzTV.Core/Plex/PlexLibraryScanner.cs b/ErsatzTV.Core/Plex/PlexLibraryScanner.cs index 97674cca..12f88865 100644 --- a/ErsatzTV.Core/Plex/PlexLibraryScanner.cs +++ b/ErsatzTV.Core/Plex/PlexLibraryScanner.cs @@ -25,34 +25,34 @@ public abstract class PlexLibraryScanner Option maybeIncomingArtwork = Optional(incomingMetadata.Artwork).Flatten() .Find(a => a.ArtworkKind == artworkKind); - await maybeIncomingArtwork.Match( - async incomingArtwork => - { - _logger.LogDebug("Refreshing Plex {Attribute} from {Path}", artworkKind, incomingArtwork.Path); - - Option maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten() - .Find(a => a.ArtworkKind == artworkKind); - - await maybeExistingArtwork.Match( - async existingArtwork => - { - existingArtwork.Path = incomingArtwork.Path; - existingArtwork.DateUpdated = incomingArtwork.DateUpdated; - await _metadataRepository.UpdateArtworkPath(existingArtwork); - }, - async () => - { - existingMetadata.Artwork ??= new List(); - existingMetadata.Artwork.Add(incomingArtwork); - await _metadataRepository.AddArtwork(existingMetadata, incomingArtwork); - }); - }, - async () => + if (maybeIncomingArtwork.IsNone) + { + existingMetadata.Artwork ??= new List(); + existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind); + await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind); + } + + foreach (Artwork incomingArtwork in maybeIncomingArtwork) + { + _logger.LogDebug("Refreshing Plex {Attribute} from {Path}", artworkKind, incomingArtwork.Path); + + Option maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten() + .Find(a => a.ArtworkKind == artworkKind); + + if (maybeExistingArtwork.IsNone) { existingMetadata.Artwork ??= new List(); - existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind); - await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind); - }); + existingMetadata.Artwork.Add(incomingArtwork); + await _metadataRepository.AddArtwork(existingMetadata, incomingArtwork); + } + + foreach (Artwork existingArtwork in maybeExistingArtwork) + { + existingArtwork.Path = incomingArtwork.Path; + existingArtwork.DateUpdated = incomingArtwork.DateUpdated; + await _metadataRepository.UpdateArtworkPath(existingArtwork); + } + } return true; } diff --git a/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs b/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs index 140c0ae0..cc96e372 100644 --- a/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs +++ b/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs @@ -1,5 +1,5 @@ using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; @@ -10,21 +10,17 @@ using Microsoft.Extensions.Logging; namespace ErsatzTV.Core.Plex; -public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScanner +public class PlexMovieLibraryScanner : + MediaServerMovieLibraryScanner, + IPlexMovieLibraryScanner { - private readonly ILocalFileSystem _localFileSystem; - private readonly ILocalStatisticsProvider _localStatisticsProvider; - private readonly ILocalSubtitlesProvider _localSubtitlesProvider; private readonly ILogger _logger; private readonly IMediaSourceRepository _mediaSourceRepository; - private readonly IMediator _mediator; private readonly IMetadataRepository _metadataRepository; private readonly IMovieRepository _movieRepository; private readonly IPlexMovieRepository _plexMovieRepository; private readonly IPlexPathReplacementService _plexPathReplacementService; private readonly IPlexServerApiClient _plexServerApiClient; - private readonly ISearchIndex _searchIndex; - private readonly ISearchRepository _searchRepository; public PlexMovieLibraryScanner( IPlexServerApiClient plexServerApiClient, @@ -40,20 +36,21 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan ILocalStatisticsProvider localStatisticsProvider, ILocalSubtitlesProvider localSubtitlesProvider, ILogger logger) - : base(metadataRepository, logger) + : base( + localStatisticsProvider, + localSubtitlesProvider, + localFileSystem, + mediator, + searchIndex, + searchRepository, + logger) { _plexServerApiClient = plexServerApiClient; _movieRepository = movieRepository; _metadataRepository = metadataRepository; - _searchIndex = searchIndex; - _searchRepository = searchRepository; - _mediator = mediator; _mediaSourceRepository = mediaSourceRepository; _plexMovieRepository = plexMovieRepository; _plexPathReplacementService = plexPathReplacementService; - _localFileSystem = localFileSystem; - _localStatisticsProvider = localStatisticsProvider; - _localSubtitlesProvider = localSubtitlesProvider; _logger = logger; } @@ -66,262 +63,69 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan bool deepScan, CancellationToken cancellationToken) { - try - { - Either> entries = await _plexServerApiClient.GetMovieLibraryContents( - library, - connection, - token); + List pathReplacements = + await _mediaSourceRepository.GetPlexPathReplacements(library.MediaSourceId); - foreach (BaseError error in entries.LeftToSeq()) - { - return error; - } - - return await ScanLibrary( - connection, - token, - library, - ffmpegPath, - ffprobePath, - deepScan, - entries.RightToSeq().Flatten().ToList(), - cancellationToken); - } - catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + string GetLocalPath(PlexMovie movie) { - return new ScanCanceled(); - } - finally - { - // always commit the search index to prevent corruption - _searchIndex.Commit(); + return _plexPathReplacementService.GetReplacementPlexPath( + pathReplacements, + movie.GetHeadVersion().MediaFiles.Head().Path, + false); } - } - - private async Task> ScanLibrary( - PlexConnection connection, - PlexServerAuthToken token, - PlexLibrary library, - string ffmpegPath, - string ffprobePath, - bool deepScan, - List movieEntries, - CancellationToken cancellationToken) - { - List existingMovies = await _movieRepository.GetExistingPlexMovies(library); - - List pathReplacements = await _mediaSourceRepository - .GetPlexPathReplacements(library.MediaSourceId); - - foreach (PlexMovie incoming in movieEntries) - { - if (cancellationToken.IsCancellationRequested) - { - return new ScanCanceled(); - } - - decimal percentCompletion = (decimal)movieEntries.IndexOf(incoming) / movieEntries.Count; - await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken); - if (await ShouldScanItem(library, pathReplacements, existingMovies, incoming, deepScan) == false) - { - continue; - } - - // TODO: figure out how to rebuild playlists - Either> maybeMovie = await _movieRepository - .GetOrAdd(library, incoming) - .BindT( - existing => UpdateStatistics(pathReplacements, existing, incoming, ffmpegPath, ffprobePath)) - .BindT(existing => UpdateMetadata(existing, incoming, library, connection, token)) - .BindT(existing => UpdateSubtitles(pathReplacements, existing, incoming)) - .BindT(existing => UpdateArtwork(existing, incoming)); - - if (maybeMovie.IsLeft) - { - foreach (BaseError error in maybeMovie.LeftToSeq()) - { - _logger.LogWarning( - "Error processing plex movie at {Key}: {Error}", - incoming.Key, - error.Value); - } - - continue; - } - - foreach (MediaItemScanResult result in maybeMovie.RightToSeq()) - { - await _movieRepository.SetPlexEtag(result.Item, incoming.Etag); - - string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path; - - string localPath = _plexPathReplacementService.GetReplacementPlexPath( - pathReplacements, - plexPath, - false); - - if (_localFileSystem.FileExists(localPath)) - { - await _plexMovieRepository.FlagNormal(library, result.Item); - } - else - { - await _plexMovieRepository.FlagUnavailable(library, result.Item); - } - - if (result.IsAdded) - { - await _searchIndex.AddItems(_searchRepository, new List { result.Item }); - } - else if (result.IsUpdated) - { - await _searchIndex.UpdateItems(_searchRepository, new List { result.Item }); - } - } - } + return await ScanLibrary( + _plexMovieRepository, + new PlexConnectionParameters(connection, token), + library, + GetLocalPath, + ffmpegPath, + ffprobePath, + deepScan, + cancellationToken); + } - // trash items that are no longer present on the media server - var fileNotFoundKeys = existingMovies.Map(m => m.Key).Except(movieEntries.Map(m => m.Key)).ToList(); - List ids = await _plexMovieRepository.FlagFileNotFound(library, fileNotFoundKeys); - await _searchIndex.RebuildItems(_searchRepository, ids); + protected override string MediaServerItemId(PlexMovie movie) => movie.Key; - await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken); + protected override string MediaServerEtag(PlexMovie movie) => movie.Etag; - return Unit.Default; - } + protected override Task>> GetMovieLibraryItems( + PlexConnectionParameters connectionParameters, + PlexLibrary library) => + _plexServerApiClient.GetMovieLibraryContents( + library, + connectionParameters.Connection, + connectionParameters.Token); - private async Task ShouldScanItem( + protected override async Task> GetFullMetadata( + PlexConnectionParameters connectionParameters, PlexLibrary library, - List pathReplacements, - List existingMovies, + MediaItemScanResult result, PlexMovie incoming, bool deepScan) { - // deep scan will pull every movie individually from the plex api - if (!deepScan) + if (result.IsAdded || result.Item.Etag != incoming.Etag || deepScan) { - Option maybeExisting = existingMovies.Find(ie => ie.Key == incoming.Key); - string existingEtag = await maybeExisting - .Map(e => e.Etag ?? string.Empty) - .IfNoneAsync(string.Empty); - MediaItemState existingState = await maybeExisting - .Map(e => e.State) - .IfNoneAsync(MediaItemState.Normal); - - string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path; - - string localPath = _plexPathReplacementService.GetReplacementPlexPath( - pathReplacements, - plexPath, - false); - - // if media is unavailable, only scan if file now exists - if (existingState == MediaItemState.Unavailable) - { - if (!_localFileSystem.FileExists(localPath)) - { - return false; - } - } - else if (existingEtag == incoming.Etag) - { - if (!_localFileSystem.FileExists(localPath)) - { - foreach (int id in await _plexMovieRepository.FlagUnavailable(library, incoming)) - { - await _searchIndex.RebuildItems(_searchRepository, new List { id }); - } - } - - // _logger.LogDebug("NOOP: etag has not changed for plex movie with key {Key}", incoming.Key); - return false; - } - - _logger.LogDebug( - "UPDATE: Etag has changed for movie {Movie}", - incoming.MovieMetadata.Head().Title); - } - - return true; - } - - private async Task>> UpdateStatistics( - List pathReplacements, - MediaItemScanResult result, - PlexMovie incoming, - string ffmpegPath, - string ffprobePath) - { - PlexMovie existing = result.Item; - MediaVersion existingVersion = existing.MediaVersions.Head(); - MediaVersion incomingVersion = incoming.MediaVersions.Head(); + Either maybeMetadata = await _plexServerApiClient.GetMovieMetadata( + library, + incoming.Key.Split("/").Last(), + connectionParameters.Connection, + connectionParameters.Token); - if (result.IsAdded || existing.Etag != incoming.Etag || existingVersion.Streams.Count == 0) - { - foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone()) + foreach (BaseError error in maybeMetadata.LeftToSeq()) { - foreach (MediaFile existingFile in existingVersion.MediaFiles.HeadOrNone()) - { - if (incomingFile.Path != existingFile.Path) - { - _logger.LogDebug( - "Plex movie has moved from {OldPath} to {NewPath}", - existingFile.Path, - incomingFile.Path); - - existingFile.Path = incomingFile.Path; - - await _movieRepository.UpdatePath(existingFile.Id, incomingFile.Path); - } - } + _logger.LogWarning("Failed to get movie metadata from Plex: {Error}", error.ToString()); } - string localPath = _plexPathReplacementService.GetReplacementPlexPath( - pathReplacements, - incoming.MediaVersions.Head().MediaFiles.Head().Path, - false); - - // only refresh statistics if the file exists - if (_localFileSystem.FileExists(localPath)) - { - _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); - Either refreshResult = - await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, existing, localPath); - - foreach (BaseError error in refreshResult.LeftToSeq()) - { - _logger.LogWarning( - "Unable to refresh {Attribute} for media item {Path}. Error: {Error}", - "Statistics", - localPath, - error.Value); - } - - foreach (bool _ in refreshResult.RightToSeq()) - { - foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id)) - { - await _searchIndex.UpdateItems( - _searchRepository, - new List { updated }); - } - - await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, incomingVersion); - } - } + return maybeMetadata.ToOption(); } - return result; + return None; } - private async Task>> UpdateMetadata( + protected override async Task>> UpdateMetadata( MediaItemScanResult result, - PlexMovie incoming, - PlexLibrary library, - PlexConnection connection, - PlexServerAuthToken token) + MovieMetadata fullMetadata) { PlexMovie existing = result.Item; MovieMetadata existingMetadata = existing.MovieMetadata.Head(); @@ -329,243 +133,243 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan _logger.LogDebug( "Refreshing {Attribute} for {Title}", "Plex Metadata", - existing.MovieMetadata.Head().Title); + existingMetadata.Title); - Either maybeMetadata = - await _plexServerApiClient.GetMovieMetadata( - library, - incoming.Key.Split("/").Last(), - connection, - token); + if (existingMetadata.MetadataKind != MetadataKind.External) + { + existingMetadata.MetadataKind = MetadataKind.External; + await _metadataRepository.MarkAsExternal(existingMetadata); + } - foreach (MovieMetadata fullMetadata in maybeMetadata.RightToSeq()) + if (existingMetadata.ContentRating != fullMetadata.ContentRating) { - if (existingMetadata.MetadataKind != MetadataKind.External) - { - existingMetadata.MetadataKind = MetadataKind.External; - await _metadataRepository.MarkAsExternal(existingMetadata); - } + existingMetadata.ContentRating = fullMetadata.ContentRating; + await _metadataRepository.SetContentRating(existingMetadata, fullMetadata.ContentRating); + result.IsUpdated = true; + } - if (existingMetadata.ContentRating != fullMetadata.ContentRating) + foreach (Genre genre in existingMetadata.Genres + .Filter(g => fullMetadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Genres.Remove(genre); + if (await _metadataRepository.RemoveGenre(genre)) { - existingMetadata.ContentRating = fullMetadata.ContentRating; - await _metadataRepository.SetContentRating(existingMetadata, fullMetadata.ContentRating); result.IsUpdated = true; } + } - foreach (Genre genre in existingMetadata.Genres - .Filter(g => fullMetadata.Genres.All(g2 => g2.Name != g.Name)) - .ToList()) - { - existingMetadata.Genres.Remove(genre); - if (await _metadataRepository.RemoveGenre(genre)) - { - result.IsUpdated = true; - } - } - - foreach (Genre genre in fullMetadata.Genres - .Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name)) - .ToList()) + foreach (Genre genre in fullMetadata.Genres + .Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Genres.Add(genre); + if (await _movieRepository.AddGenre(existingMetadata, genre)) { - existingMetadata.Genres.Add(genre); - if (await _movieRepository.AddGenre(existingMetadata, genre)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Studio studio in existingMetadata.Studios - .Filter(s => fullMetadata.Studios.All(s2 => s2.Name != s.Name)) - .ToList()) + foreach (Studio studio in existingMetadata.Studios + .Filter(s => fullMetadata.Studios.All(s2 => s2.Name != s.Name)) + .ToList()) + { + existingMetadata.Studios.Remove(studio); + if (await _metadataRepository.RemoveStudio(studio)) { - existingMetadata.Studios.Remove(studio); - if (await _metadataRepository.RemoveStudio(studio)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Studio studio in fullMetadata.Studios - .Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name)) - .ToList()) + foreach (Studio studio in fullMetadata.Studios + .Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name)) + .ToList()) + { + existingMetadata.Studios.Add(studio); + if (await _movieRepository.AddStudio(existingMetadata, studio)) { - existingMetadata.Studios.Add(studio); - if (await _movieRepository.AddStudio(existingMetadata, studio)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Actor actor in existingMetadata.Actors - .Filter( - a => fullMetadata.Actors.All( - a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) - .ToList()) + foreach (Actor actor in existingMetadata.Actors + .Filter( + a => fullMetadata.Actors.All( + a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) + .ToList()) + { + existingMetadata.Actors.Remove(actor); + if (await _metadataRepository.RemoveActor(actor)) { - existingMetadata.Actors.Remove(actor); - if (await _metadataRepository.RemoveActor(actor)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Actor actor in fullMetadata.Actors - .Filter(a => existingMetadata.Actors.All(a2 => a2.Name != a.Name)) - .ToList()) + foreach (Actor actor in fullMetadata.Actors + .Filter(a => existingMetadata.Actors.All(a2 => a2.Name != a.Name)) + .ToList()) + { + existingMetadata.Actors.Add(actor); + if (await _movieRepository.AddActor(existingMetadata, actor)) { - existingMetadata.Actors.Add(actor); - if (await _movieRepository.AddActor(existingMetadata, actor)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Director director in existingMetadata.Directors - .Filter(g => fullMetadata.Directors.All(g2 => g2.Name != g.Name)) - .ToList()) + foreach (Director director in existingMetadata.Directors + .Filter(g => fullMetadata.Directors.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Directors.Remove(director); + if (await _metadataRepository.RemoveDirector(director)) { - existingMetadata.Directors.Remove(director); - if (await _metadataRepository.RemoveDirector(director)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Director director in fullMetadata.Directors - .Filter(g => existingMetadata.Directors.All(g2 => g2.Name != g.Name)) - .ToList()) + foreach (Director director in fullMetadata.Directors + .Filter(g => existingMetadata.Directors.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Directors.Add(director); + if (await _movieRepository.AddDirector(existingMetadata, director)) { - existingMetadata.Directors.Add(director); - if (await _movieRepository.AddDirector(existingMetadata, director)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Writer writer in existingMetadata.Writers - .Filter(g => fullMetadata.Writers.All(g2 => g2.Name != g.Name)) - .ToList()) + foreach (Writer writer in existingMetadata.Writers + .Filter(g => fullMetadata.Writers.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Writers.Remove(writer); + if (await _metadataRepository.RemoveWriter(writer)) { - existingMetadata.Writers.Remove(writer); - if (await _metadataRepository.RemoveWriter(writer)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Writer writer in fullMetadata.Writers - .Filter(g => existingMetadata.Writers.All(g2 => g2.Name != g.Name)) - .ToList()) + foreach (Writer writer in fullMetadata.Writers + .Filter(g => existingMetadata.Writers.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Writers.Add(writer); + if (await _movieRepository.AddWriter(existingMetadata, writer)) { - existingMetadata.Writers.Add(writer); - if (await _movieRepository.AddWriter(existingMetadata, writer)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (MetadataGuid guid in existingMetadata.Guids - .Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) + foreach (MetadataGuid guid in existingMetadata.Guids + .Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + existingMetadata.Guids.Remove(guid); + if (await _metadataRepository.RemoveGuid(guid)) { - existingMetadata.Guids.Remove(guid); - if (await _metadataRepository.RemoveGuid(guid)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (MetadataGuid guid in fullMetadata.Guids - .Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) + foreach (MetadataGuid guid in fullMetadata.Guids + .Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + existingMetadata.Guids.Add(guid); + if (await _metadataRepository.AddGuid(existingMetadata, guid)) { - existingMetadata.Guids.Add(guid); - if (await _metadataRepository.AddGuid(existingMetadata, guid)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Tag tag in existingMetadata.Tags - .Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name)) - .ToList()) + foreach (Tag tag in existingMetadata.Tags + .Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Tags.Remove(tag); + if (await _metadataRepository.RemoveTag(tag)) { - existingMetadata.Tags.Remove(tag); - if (await _metadataRepository.RemoveTag(tag)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Tag tag in fullMetadata.Tags - .Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name)) - .ToList()) + foreach (Tag tag in fullMetadata.Tags + .Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Tags.Add(tag); + if (await _movieRepository.AddTag(existingMetadata, tag)) { - existingMetadata.Tags.Add(tag); - if (await _movieRepository.AddTag(existingMetadata, tag)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - if (fullMetadata.SortTitle != existingMetadata.SortTitle) + if (fullMetadata.SortTitle != existingMetadata.SortTitle) + { + existingMetadata.SortTitle = fullMetadata.SortTitle; + if (await _movieRepository.UpdateSortTitle(existingMetadata)) { - existingMetadata.SortTitle = fullMetadata.SortTitle; - if (await _movieRepository.UpdateSortTitle(existingMetadata)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - if (result.IsUpdated) - { - await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated); - } + bool poster = await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.Poster); + bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.FanArt); + if (poster || fanArt) + { + result.IsUpdated = true; } - // TODO: update other metadata? + if (result.IsUpdated) + { + await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated); + } return result; } - private async Task>> UpdateSubtitles( - List pathReplacements, - MediaItemScanResult result, - PlexMovie incoming) + private async Task UpdateArtworkIfNeeded( + Domain.Metadata existingMetadata, + Domain.Metadata incomingMetadata, + ArtworkKind artworkKind) { - try + if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated) { - string localPath = _plexPathReplacementService.GetReplacementPlexPath( - pathReplacements, - incoming.MediaVersions.Head().MediaFiles.Head().Path, - false); + Option maybeIncomingArtwork = Optional(incomingMetadata.Artwork).Flatten() + .Find(a => a.ArtworkKind == artworkKind); - await _localSubtitlesProvider.UpdateSubtitles(result.Item, localPath, false); + if (maybeIncomingArtwork.IsNone) + { + existingMetadata.Artwork ??= new List(); + existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind); + await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind); + } - return result; - } - catch (Exception ex) - { - return BaseError.New(ex.ToString()); - } - } + foreach (Artwork incomingArtwork in maybeIncomingArtwork) + { + _logger.LogDebug("Refreshing Plex {Attribute} from {Path}", artworkKind, incomingArtwork.Path); - private async Task>> UpdateArtwork( - MediaItemScanResult result, - PlexMovie incoming) - { - PlexMovie existing = result.Item; - MovieMetadata existingMetadata = existing.MovieMetadata.Head(); - MovieMetadata incomingMetadata = incoming.MovieMetadata.Head(); + Option maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten() + .Find(a => a.ArtworkKind == artworkKind); - bool poster = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster); - bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt); - if (poster || fanArt) - { - await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated); + if (maybeExistingArtwork.IsNone) + { + existingMetadata.Artwork ??= new List(); + existingMetadata.Artwork.Add(incomingArtwork); + await _metadataRepository.AddArtwork(existingMetadata, incomingArtwork); + } + + foreach (Artwork existingArtwork in maybeExistingArtwork) + { + existingArtwork.Path = incomingArtwork.Path; + existingArtwork.DateUpdated = incomingArtwork.DateUpdated; + await _metadataRepository.UpdateArtworkPath(existingArtwork); + } + } + + return true; } - return result; + return false; } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs new file mode 100644 index 00000000..22a79cfa --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs @@ -0,0 +1,348 @@ +using Dapper; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Metadata; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Infrastructure.Data.Repositories; + +public class EmbyMovieRepository : IEmbyMovieRepository +{ + private readonly IDbContextFactory _dbContextFactory; + + public EmbyMovieRepository(IDbContextFactory dbContextFactory) => _dbContextFactory = dbContextFactory; + + public async Task> GetExistingMovies(EmbyLibrary library) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.QueryAsync( + @"SELECT ItemId, Etag, MI.State FROM EmbyMovie + INNER JOIN Movie M on EmbyMovie.Id = M.Id + INNER JOIN MediaItem MI on M.Id = MI.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id + WHERE LP.LibraryId = @LibraryId", + new { LibraryId = library.Id }) + .Map(result => result.ToList()); + } + + public async Task FlagNormal(EmbyLibrary library, EmbyMovie movie) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + movie.State = MediaItemState.Normal; + + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 0 WHERE Id IN + (SELECT EmbyMovie.Id FROM EmbyMovie + INNER JOIN MediaItem MI ON MI.Id = EmbyMovie.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE EmbyMovie.ItemId = @ItemId)", + new { LibraryId = library.Id, movie.ItemId }).Map(count => count > 0); + } + + public async Task> FlagUnavailable(EmbyLibrary library, EmbyMovie movie) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + movie.State = MediaItemState.Unavailable; + + Option maybeId = await dbContext.Connection.ExecuteScalarAsync( + @"SELECT EmbyMovie.Id FROM EmbyMovie + INNER JOIN MediaItem MI ON MI.Id = EmbyMovie.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE EmbyMovie.ItemId = @ItemId", + new { LibraryId = library.Id, movie.ItemId }); + + foreach (int id in maybeId) + { + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 2 WHERE Id = @Id", + new { Id = id }).Map(count => count > 0 ? Some(id) : None); + } + + return None; + } + + public async Task> FlagFileNotFound(EmbyLibrary library, List movieItemIds) + { + if (movieItemIds.Count == 0) + { + return new List(); + } + + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + List ids = await dbContext.Connection.QueryAsync( + @"SELECT M.Id + FROM MediaItem M + INNER JOIN EmbyMovie ON EmbyMovie.Id = M.Id + INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId + WHERE EmbyMovie.ItemId IN @MovieItemIds", + new { LibraryId = library.Id, MovieItemIds = movieItemIds }) + .Map(result => result.ToList()); + + await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids", + new { Ids = ids }); + + return ids; + } + + public async Task>> GetOrAdd(EmbyLibrary library, EmbyMovie item) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + Option maybeExisting = await dbContext.EmbyMovies + .Include(m => m.LibraryPath) + .ThenInclude(lp => lp.Library) + .Include(m => m.MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .Include(m => m.MediaVersions) + .ThenInclude(mv => mv.Streams) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Genres) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Tags) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Studios) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Actors) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Directors) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Writers) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Artwork) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Guids) + .Include(m => m.TraktListItems) + .ThenInclude(tli => tli.TraktList) + .SelectOneAsync(m => m.ItemId, m => m.ItemId == item.ItemId); + + foreach (EmbyMovie embyMovie in maybeExisting) + { + var result = new MediaItemScanResult(embyMovie) { IsAdded = false }; + if (embyMovie.Etag != item.Etag) + { + await UpdateMovie(dbContext, embyMovie, item); + result.IsUpdated = true; + } + + return result; + } + + return await AddMovie(dbContext, library, item); + } + + public async Task SetEtag(EmbyMovie movie, string etag) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.ExecuteAsync( + "UPDATE EmbyMovie SET Etag = @Etag WHERE Id = @Id", + new { Etag = etag, movie.Id }).Map(_ => Unit.Default); + } + + private async Task>> AddMovie( + TvContext dbContext, + EmbyLibrary library, + EmbyMovie movie) + { + try + { + // blank out etag for initial save in case other updates fail + movie.Etag = string.Empty; + + movie.LibraryPathId = library.Paths.Head().Id; + + await dbContext.AddAsync(movie); + await dbContext.SaveChangesAsync(); + + await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync(); + await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + return new MediaItemScanResult(movie) { IsAdded = true }; + } + catch (Exception ex) + { + return BaseError.New(ex.ToString()); + } + } + + private async Task UpdateMovie(TvContext dbContext, EmbyMovie existing, EmbyMovie incoming) + { + // library path is used for search indexing later + incoming.LibraryPath = existing.LibraryPath; + incoming.Id = existing.Id; + + // metadata + MovieMetadata metadata = existing.MovieMetadata.Head(); + MovieMetadata incomingMetadata = incoming.MovieMetadata.Head(); + metadata.MetadataKind = incomingMetadata.MetadataKind; + metadata.ContentRating = incomingMetadata.ContentRating; + metadata.Title = incomingMetadata.Title; + metadata.SortTitle = incomingMetadata.SortTitle; + metadata.Plot = incomingMetadata.Plot; + metadata.Year = incomingMetadata.Year; + metadata.Tagline = incomingMetadata.Tagline; + metadata.DateAdded = incomingMetadata.DateAdded; + metadata.DateUpdated = DateTime.UtcNow; + + // genres + foreach (Genre genre in metadata.Genres + .Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Genres.Remove(genre); + } + + foreach (Genre genre in incomingMetadata.Genres + .Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Genres.Add(genre); + } + + // tags + foreach (Tag tag in metadata.Tags + .Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name)) + .Filter(g => g.ExternalCollectionId is null) + .ToList()) + { + metadata.Tags.Remove(tag); + } + + foreach (Tag tag in incomingMetadata.Tags + .Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Tags.Add(tag); + } + + // studios + foreach (Studio studio in metadata.Studios + .Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Studios.Remove(studio); + } + + foreach (Studio studio in incomingMetadata.Studios + .Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Studios.Add(studio); + } + + // actors + foreach (Actor actor in metadata.Actors + .Filter( + a => incomingMetadata.Actors.All( + a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) + .ToList()) + { + metadata.Actors.Remove(actor); + } + + foreach (Actor actor in incomingMetadata.Actors + .Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name)) + .ToList()) + { + metadata.Actors.Add(actor); + } + + // directors + foreach (Director director in metadata.Directors + .Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name)) + .ToList()) + { + metadata.Directors.Remove(director); + } + + foreach (Director director in incomingMetadata.Directors + .Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)) + .ToList()) + { + metadata.Directors.Add(director); + } + + // writers + foreach (Writer writer in metadata.Writers + .Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name)) + .ToList()) + { + metadata.Writers.Remove(writer); + } + + foreach (Writer writer in incomingMetadata.Writers + .Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)) + .ToList()) + { + metadata.Writers.Add(writer); + } + + // guids + foreach (MetadataGuid guid in metadata.Guids + .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + metadata.Guids.Remove(guid); + } + + foreach (MetadataGuid guid in incomingMetadata.Guids + .Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + metadata.Guids.Add(guid); + } + + metadata.ReleaseDate = incomingMetadata.ReleaseDate; + + // poster + Artwork incomingPoster = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); + if (incomingPoster != null) + { + Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); + if (poster == null) + { + poster = new Artwork { ArtworkKind = ArtworkKind.Poster }; + metadata.Artwork.Add(poster); + } + + poster.Path = incomingPoster.Path; + poster.DateAdded = incomingPoster.DateAdded; + poster.DateUpdated = incomingPoster.DateUpdated; + } + + // fan art + Artwork incomingFanArt = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); + if (incomingFanArt != null) + { + Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); + if (fanArt == null) + { + fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt }; + metadata.Artwork.Add(fanArt); + } + + fanArt.Path = incomingFanArt.Path; + fanArt.DateAdded = incomingFanArt.DateAdded; + fanArt.DateUpdated = incomingFanArt.DateUpdated; + } + + // version + MediaVersion version = existing.MediaVersions.Head(); + MediaVersion incomingVersion = incoming.MediaVersions.Head(); + version.Name = incomingVersion.Name; + version.DateAdded = incomingVersion.DateAdded; + + // media file + MediaFile file = version.MediaFiles.Head(); + MediaFile incomingFile = incomingVersion.MediaFiles.Head(); + file.Path = incomingFile.Path; + + await dbContext.SaveChangesAsync(); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs new file mode 100644 index 00000000..1f1f1964 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs @@ -0,0 +1,353 @@ +using Dapper; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Jellyfin; +using ErsatzTV.Core.Metadata; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Infrastructure.Data.Repositories; + +public class JellyfinMovieRepository : IJellyfinMovieRepository +{ + private readonly IDbContextFactory _dbContextFactory; + + public JellyfinMovieRepository(IDbContextFactory dbContextFactory) => + _dbContextFactory = dbContextFactory; + + public async Task> GetExistingMovies(JellyfinLibrary library) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.QueryAsync( + @"SELECT ItemId, Etag, MI.State FROM JellyfinMovie + INNER JOIN Movie M on JellyfinMovie.Id = M.Id + INNER JOIN MediaItem MI on M.Id = MI.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id + WHERE LP.LibraryId = @LibraryId", + new { LibraryId = library.Id }) + .Map(result => result.ToList()); + } + + public async Task FlagNormal(JellyfinLibrary library, JellyfinMovie movie) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + movie.State = MediaItemState.Normal; + + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 0 WHERE Id IN + (SELECT JellyfinMovie.Id FROM JellyfinMovie + INNER JOIN MediaItem MI ON MI.Id = JellyfinMovie.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE JellyfinMovie.ItemId = @ItemId)", + new { LibraryId = library.Id, movie.ItemId }).Map(count => count > 0); + } + + public async Task> FlagUnavailable(JellyfinLibrary library, JellyfinMovie movie) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + movie.State = MediaItemState.Unavailable; + + Option maybeId = await dbContext.Connection.ExecuteScalarAsync( + @"SELECT JellyfinMovie.Id FROM JellyfinMovie + INNER JOIN MediaItem MI ON MI.Id = JellyfinMovie.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE JellyfinMovie.ItemId = @ItemId", + new { LibraryId = library.Id, movie.ItemId }); + + foreach (int id in maybeId) + { + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 2 WHERE Id = @Id", + new { Id = id }).Map(count => count > 0 ? Some(id) : None); + } + + return None; + } + + public async Task> FlagFileNotFound(JellyfinLibrary library, List movieItemIds) + { + if (movieItemIds.Count == 0) + { + return new List(); + } + + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + List ids = await dbContext.Connection.QueryAsync( + @"SELECT M.Id + FROM MediaItem M + INNER JOIN JellyfinMovie ON JellyfinMovie.Id = M.Id + INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId + WHERE JellyfinMovie.ItemId IN @MovieItemIds", + new { LibraryId = library.Id, MovieItemIds = movieItemIds }) + .Map(result => result.ToList()); + + await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids", + new { Ids = ids }); + + return ids; + } + + public async Task>> GetOrAdd( + JellyfinLibrary library, + JellyfinMovie item) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + Option maybeExisting = await dbContext.JellyfinMovies + .Include(m => m.LibraryPath) + .ThenInclude(lp => lp.Library) + .Include(m => m.MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .Include(m => m.MediaVersions) + .ThenInclude(mv => mv.Streams) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Genres) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Tags) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Studios) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Actors) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Directors) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Writers) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Artwork) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Guids) + .Include(m => m.TraktListItems) + .ThenInclude(tli => tli.TraktList) + .SelectOneAsync(m => m.ItemId, m => m.ItemId == item.ItemId); + + foreach (JellyfinMovie jellyfinMovie in maybeExisting) + { + var result = new MediaItemScanResult(jellyfinMovie) { IsAdded = false }; + if (jellyfinMovie.Etag != item.Etag) + { + await UpdateMovie(dbContext, jellyfinMovie, item); + result.IsUpdated = true; + } + + return result; + } + + return await AddMovie(dbContext, library, item); + } + + public async Task SetEtag(JellyfinMovie movie, string etag) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.ExecuteAsync( + "UPDATE JellyfinMovie SET Etag = @Etag WHERE Id = @Id", + new { Etag = etag, movie.Id }).Map(_ => Unit.Default); + } + + private async Task UpdateMovie(TvContext dbContext, JellyfinMovie existing, JellyfinMovie incoming) + { + // library path is used for search indexing later + incoming.LibraryPath = existing.LibraryPath; + incoming.Id = existing.Id; + + existing.Etag = incoming.Etag; + + // metadata + MovieMetadata metadata = existing.MovieMetadata.Head(); + MovieMetadata incomingMetadata = incoming.MovieMetadata.Head(); + metadata.MetadataKind = incomingMetadata.MetadataKind; + metadata.ContentRating = incomingMetadata.ContentRating; + metadata.Title = incomingMetadata.Title; + metadata.SortTitle = incomingMetadata.SortTitle; + metadata.Plot = incomingMetadata.Plot; + metadata.Year = incomingMetadata.Year; + metadata.Tagline = incomingMetadata.Tagline; + metadata.DateAdded = incomingMetadata.DateAdded; + metadata.DateUpdated = DateTime.UtcNow; + + // genres + foreach (Genre genre in metadata.Genres + .Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Genres.Remove(genre); + } + + foreach (Genre genre in incomingMetadata.Genres + .Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Genres.Add(genre); + } + + // tags + foreach (Tag tag in metadata.Tags + .Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name)) + .Filter(g => g.ExternalCollectionId is null) + .ToList()) + { + metadata.Tags.Remove(tag); + } + + foreach (Tag tag in incomingMetadata.Tags + .Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Tags.Add(tag); + } + + // studios + foreach (Studio studio in metadata.Studios + .Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Studios.Remove(studio); + } + + foreach (Studio studio in incomingMetadata.Studios + .Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Studios.Add(studio); + } + + // actors + foreach (Actor actor in metadata.Actors + .Filter( + a => incomingMetadata.Actors.All( + a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) + .ToList()) + { + metadata.Actors.Remove(actor); + } + + foreach (Actor actor in incomingMetadata.Actors + .Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name)) + .ToList()) + { + metadata.Actors.Add(actor); + } + + // directors + foreach (Director director in metadata.Directors + .Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name)) + .ToList()) + { + metadata.Directors.Remove(director); + } + + foreach (Director director in incomingMetadata.Directors + .Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)) + .ToList()) + { + metadata.Directors.Add(director); + } + + // writers + foreach (Writer writer in metadata.Writers + .Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name)) + .ToList()) + { + metadata.Writers.Remove(writer); + } + + foreach (Writer writer in incomingMetadata.Writers + .Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)) + .ToList()) + { + metadata.Writers.Add(writer); + } + + // guids + foreach (MetadataGuid guid in metadata.Guids + .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + metadata.Guids.Remove(guid); + } + + foreach (MetadataGuid guid in incomingMetadata.Guids + .Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + metadata.Guids.Add(guid); + } + + metadata.ReleaseDate = incomingMetadata.ReleaseDate; + + // poster + Artwork incomingPoster = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); + if (incomingPoster != null) + { + Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); + if (poster == null) + { + poster = new Artwork { ArtworkKind = ArtworkKind.Poster }; + metadata.Artwork.Add(poster); + } + + poster.Path = incomingPoster.Path; + poster.DateAdded = incomingPoster.DateAdded; + poster.DateUpdated = incomingPoster.DateUpdated; + } + + // fan art + Artwork incomingFanArt = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); + if (incomingFanArt != null) + { + Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); + if (fanArt == null) + { + fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt }; + metadata.Artwork.Add(fanArt); + } + + fanArt.Path = incomingFanArt.Path; + fanArt.DateAdded = incomingFanArt.DateAdded; + fanArt.DateUpdated = incomingFanArt.DateUpdated; + } + + // version + MediaVersion version = existing.MediaVersions.Head(); + MediaVersion incomingVersion = incoming.MediaVersions.Head(); + version.Name = incomingVersion.Name; + version.DateAdded = incomingVersion.DateAdded; + + // media file + MediaFile file = version.MediaFiles.Head(); + MediaFile incomingFile = incomingVersion.MediaFiles.Head(); + file.Path = incomingFile.Path; + + await dbContext.SaveChangesAsync(); + } + + private async Task>> AddMovie( + TvContext dbContext, + JellyfinLibrary library, + JellyfinMovie movie) + { + try + { + // blank out etag for initial save in case other updates fail + movie.Etag = string.Empty; + + movie.LibraryPathId = library.Paths.Head().Id; + + await dbContext.AddAsync(movie); + await dbContext.SaveChangesAsync(); + + await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync(); + await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + return new MediaItemScanResult(movie) { IsAdded = true }; + } + catch (Exception ex) + { + return BaseError.New(ex.ToString()); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs index 7c41f37d..a6ba1b38 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs @@ -513,30 +513,37 @@ public class MetadataRepository : IMetadataRepository .ToList(); var toUpdate = subtitles.Except(toAdd).ToList(); - // add - existing.Subtitles.AddRange(toAdd); + if (toAdd.Any() || toRemove.Any() || toUpdate.Any()) + { + // add + existing.Subtitles.AddRange(toAdd); - // remove - existing.Subtitles.RemoveAll(s => toRemove.Contains(s)); + // remove + existing.Subtitles.RemoveAll(s => toRemove.Contains(s)); - // update - foreach (Subtitle incomingSubtitle in toUpdate) - { - Subtitle existingSubtitle = - existing.Subtitles.First(s => s.StreamIndex == incomingSubtitle.StreamIndex); - - existingSubtitle.Codec = incomingSubtitle.Codec; - existingSubtitle.Default = incomingSubtitle.Default; - existingSubtitle.Forced = incomingSubtitle.Forced; - existingSubtitle.SDH = incomingSubtitle.SDH; - existingSubtitle.Language = incomingSubtitle.Language; - existingSubtitle.SubtitleKind = incomingSubtitle.SubtitleKind; - existingSubtitle.DateUpdated = incomingSubtitle.DateUpdated; + // update + foreach (Subtitle incomingSubtitle in toUpdate) + { + Subtitle existingSubtitle = + existing.Subtitles.First(s => s.StreamIndex == incomingSubtitle.StreamIndex); + + existingSubtitle.Codec = incomingSubtitle.Codec; + existingSubtitle.Default = incomingSubtitle.Default; + existingSubtitle.Forced = incomingSubtitle.Forced; + existingSubtitle.SDH = incomingSubtitle.SDH; + existingSubtitle.Language = incomingSubtitle.Language; + existingSubtitle.SubtitleKind = incomingSubtitle.SubtitleKind; + existingSubtitle.DateUpdated = incomingSubtitle.DateUpdated; + } + + return await dbContext.SaveChangesAsync() > 0; } - return await dbContext.SaveChangesAsync() > 0; + // nothing to do + return true; } + // no metadata return false; } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs index fdb86ae1..5f0f8f93 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs @@ -1,12 +1,8 @@ using Dapper; using ErsatzTV.Core; using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Emby; using ErsatzTV.Core.Interfaces.Repositories; -using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Metadata; -using ErsatzTV.Core.Plex; -using LanguageExt.UnsafeValueAccess; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Infrastructure.Data.Repositories; @@ -95,48 +91,6 @@ public class MovieRepository : IMovieRepository async () => await AddMovie(dbContext, libraryPath.Id, path)); } - public async Task>> GetOrAdd( - PlexLibrary library, - PlexMovie item) - { - await using TvContext context = await _dbContextFactory.CreateDbContextAsync(); - Option maybeExisting = await context.PlexMovies - .AsNoTracking() - .Include(i => i.MovieMetadata) - .ThenInclude(mm => mm.Genres) - .Include(i => i.MovieMetadata) - .ThenInclude(mm => mm.Tags) - .Include(i => i.MovieMetadata) - .ThenInclude(mm => mm.Studios) - .Include(i => i.MovieMetadata) - .ThenInclude(mm => mm.Actors) - .ThenInclude(a => a.Artwork) - .Include(i => i.MovieMetadata) - .ThenInclude(mm => mm.Artwork) - .Include(i => i.MovieMetadata) - .ThenInclude(mm => mm.Directors) - .Include(i => i.MovieMetadata) - .ThenInclude(mm => mm.Writers) - .Include(i => i.MovieMetadata) - .ThenInclude(mm => mm.Guids) - .Include(i => i.MediaVersions) - .ThenInclude(mv => mv.MediaFiles) - .Include(i => i.MediaVersions) - .ThenInclude(mv => mv.Streams) - .Include(i => i.LibraryPath) - .ThenInclude(lp => lp.Library) - .Include(i => i.TraktListItems) - .ThenInclude(tli => tli.TraktList) - .OrderBy(i => i.Key) - .SingleOrDefaultAsync(i => i.Key == item.Key); - - return await maybeExisting.Match( - plexMovie => - Right>( - new MediaItemScanResult(plexMovie) { IsAdded = false }).AsTask(), - async () => await AddPlexMovie(context, library, item)); - } - public async Task> GetMoviesForCards(List ids) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); @@ -241,19 +195,6 @@ public class MovieRepository : IMovieRepository .Map(result => result > 0); } - public async Task> GetExistingPlexMovies(PlexLibrary library) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - return await dbContext.Connection.QueryAsync( - @"SELECT Key, Etag, MI.State FROM PlexMovie - INNER JOIN Movie M on PlexMovie.Id = M.Id - INNER JOIN MediaItem MI on M.Id = MI.Id - INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id - WHERE LP.LibraryId = @LibraryId", - new { LibraryId = library.Id }) - .Map(result => result.ToList()); - } - public async Task UpdateSortTitle(MovieMetadata movieMetadata) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); @@ -262,530 +203,6 @@ public class MovieRepository : IMovieRepository new { movieMetadata.SortTitle, movieMetadata.Id }).Map(result => result > 0); } - public async Task> GetExistingJellyfinMovies(JellyfinLibrary library) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - return await dbContext.Connection.QueryAsync( - @"SELECT ItemId, Etag FROM JellyfinMovie - INNER JOIN Movie M on JellyfinMovie.Id = M.Id - INNER JOIN MediaItem MI on M.Id = MI.Id - INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id - WHERE LP.LibraryId = @LibraryId", - new { LibraryId = library.Id }) - .Map(result => result.ToList()); - } - - public async Task> RemoveMissingJellyfinMovies(JellyfinLibrary library, List movieIds) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - - List ids = await dbContext.Connection.QueryAsync( - @"SELECT JellyfinMovie.Id FROM JellyfinMovie - INNER JOIN Movie M on JellyfinMovie.Id = M.Id - INNER JOIN MediaItem MI on M.Id = MI.Id - INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id - WHERE LP.LibraryId = @LibraryId AND ItemId IN @ItemIds", - new { LibraryId = library.Id, ItemIds = movieIds }).Map(result => result.ToList()); - - await dbContext.Connection.ExecuteAsync( - "DELETE FROM MediaItem WHERE Id IN @Ids", - new { Ids = ids }); - - return ids; - } - - public async Task AddJellyfin(JellyfinMovie movie) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - await dbContext.AddAsync(movie); - if (await dbContext.SaveChangesAsync() <= 0) - { - return false; - } - - await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync(); - await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync(); - return true; - } - - public async Task> UpdateJellyfin(JellyfinMovie movie) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - Option maybeExisting = await dbContext.JellyfinMovies - .Include(m => m.LibraryPath) - .ThenInclude(lp => lp.Library) - .Include(m => m.MediaVersions) - .ThenInclude(mv => mv.MediaFiles) - .Include(m => m.MediaVersions) - .ThenInclude(mv => mv.Streams) - .Include(m => m.MovieMetadata) - .ThenInclude(mm => mm.Genres) - .Include(m => m.MovieMetadata) - .ThenInclude(mm => mm.Tags) - .Include(m => m.MovieMetadata) - .ThenInclude(mm => mm.Studios) - .Include(m => m.MovieMetadata) - .ThenInclude(mm => mm.Actors) - .Include(m => m.MovieMetadata) - .ThenInclude(mm => mm.Artwork) - .Include(m => m.MovieMetadata) - .ThenInclude(mm => mm.Directors) - .Include(m => m.MovieMetadata) - .ThenInclude(mm => mm.Writers) - .Include(m => m.MovieMetadata) - .ThenInclude(mm => mm.Guids) - .Include(m => m.TraktListItems) - .ThenInclude(tli => tli.TraktList) - .Filter(m => m.ItemId == movie.ItemId) - .OrderBy(m => m.ItemId) - .SingleOrDefaultAsync(); - - if (maybeExisting.IsSome) - { - JellyfinMovie existing = maybeExisting.ValueUnsafe(); - - // library path is used for search indexing later - movie.LibraryPath = existing.LibraryPath; - movie.Id = existing.Id; - - existing.Etag = movie.Etag; - - // metadata - MovieMetadata metadata = existing.MovieMetadata.Head(); - MovieMetadata incomingMetadata = movie.MovieMetadata.Head(); - metadata.MetadataKind = incomingMetadata.MetadataKind; - metadata.ContentRating = incomingMetadata.ContentRating; - metadata.Title = incomingMetadata.Title; - metadata.SortTitle = incomingMetadata.SortTitle; - metadata.Plot = incomingMetadata.Plot; - metadata.Year = incomingMetadata.Year; - metadata.Tagline = incomingMetadata.Tagline; - metadata.DateAdded = incomingMetadata.DateAdded; - metadata.DateUpdated = DateTime.UtcNow; - - // genres - foreach (Genre genre in metadata.Genres - .Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Genres.Remove(genre); - } - - foreach (Genre genre in incomingMetadata.Genres - .Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Genres.Add(genre); - } - - // tags - foreach (Tag tag in metadata.Tags - .Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name)) - .Filter(g => g.ExternalCollectionId is null) - .ToList()) - { - metadata.Tags.Remove(tag); - } - - foreach (Tag tag in incomingMetadata.Tags - .Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Tags.Add(tag); - } - - // studios - foreach (Studio studio in metadata.Studios - .Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Studios.Remove(studio); - } - - foreach (Studio studio in incomingMetadata.Studios - .Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Studios.Add(studio); - } - - // actors - foreach (Actor actor in metadata.Actors - .Filter( - a => incomingMetadata.Actors.All( - a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) - .ToList()) - { - metadata.Actors.Remove(actor); - } - - foreach (Actor actor in incomingMetadata.Actors - .Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name)) - .ToList()) - { - metadata.Actors.Add(actor); - } - - // directors - foreach (Director director in metadata.Directors - .Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name)) - .ToList()) - { - metadata.Directors.Remove(director); - } - - foreach (Director director in incomingMetadata.Directors - .Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)) - .ToList()) - { - metadata.Directors.Add(director); - } - - // writers - foreach (Writer writer in metadata.Writers - .Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name)) - .ToList()) - { - metadata.Writers.Remove(writer); - } - - foreach (Writer writer in incomingMetadata.Writers - .Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)) - .ToList()) - { - metadata.Writers.Add(writer); - } - - // guids - foreach (MetadataGuid guid in metadata.Guids - .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - metadata.Guids.Remove(guid); - } - - foreach (MetadataGuid guid in incomingMetadata.Guids - .Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - metadata.Guids.Add(guid); - } - - metadata.ReleaseDate = incomingMetadata.ReleaseDate; - - // poster - Artwork incomingPoster = - incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); - if (incomingPoster != null) - { - Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); - if (poster == null) - { - poster = new Artwork { ArtworkKind = ArtworkKind.Poster }; - metadata.Artwork.Add(poster); - } - - poster.Path = incomingPoster.Path; - poster.DateAdded = incomingPoster.DateAdded; - poster.DateUpdated = incomingPoster.DateUpdated; - } - - // fan art - Artwork incomingFanArt = - incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); - if (incomingFanArt != null) - { - Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); - if (fanArt == null) - { - fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt }; - metadata.Artwork.Add(fanArt); - } - - fanArt.Path = incomingFanArt.Path; - fanArt.DateAdded = incomingFanArt.DateAdded; - fanArt.DateUpdated = incomingFanArt.DateUpdated; - } - - // version - MediaVersion version = existing.MediaVersions.Head(); - MediaVersion incomingVersion = movie.MediaVersions.Head(); - version.Name = incomingVersion.Name; - version.DateAdded = incomingVersion.DateAdded; - - // media file - MediaFile file = version.MediaFiles.Head(); - MediaFile incomingFile = incomingVersion.MediaFiles.Head(); - file.Path = incomingFile.Path; - } - - await dbContext.SaveChangesAsync(); - - return maybeExisting; - } - - public async Task> GetExistingEmbyMovies(EmbyLibrary library) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - return await dbContext.Connection.QueryAsync( - @"SELECT ItemId, Etag FROM EmbyMovie - INNER JOIN Movie M on EmbyMovie.Id = M.Id - INNER JOIN MediaItem MI on M.Id = MI.Id - INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id - WHERE LP.LibraryId = @LibraryId", - new { LibraryId = library.Id }) - .Map(result => result.ToList()); - } - - public async Task> RemoveMissingEmbyMovies(EmbyLibrary library, List movieIds) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - - List ids = await dbContext.Connection.QueryAsync( - @"SELECT EmbyMovie.Id FROM EmbyMovie - INNER JOIN Movie M on EmbyMovie.Id = M.Id - INNER JOIN MediaItem MI on M.Id = MI.Id - INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id - WHERE LP.LibraryId = @LibraryId AND ItemId IN @ItemIds", - new { LibraryId = library.Id, ItemIds = movieIds }).Map(result => result.ToList()); - - await dbContext.Connection.ExecuteAsync( - "DELETE FROM MediaItem WHERE Id IN @Ids", - new { Ids = ids }); - - return ids; - } - - public async Task AddEmby(EmbyMovie movie) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - await dbContext.AddAsync(movie); - if (await dbContext.SaveChangesAsync() <= 0) - { - return false; - } - - await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync(); - await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync(); - return true; - } - - public async Task> UpdateEmby(EmbyMovie movie) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - Option maybeExisting = await dbContext.EmbyMovies - .Include(m => m.LibraryPath) - .ThenInclude(lp => lp.Library) - .Include(m => m.MediaVersions) - .ThenInclude(mv => mv.MediaFiles) - .Include(m => m.MediaVersions) - .ThenInclude(mv => mv.Streams) - .Include(m => m.MovieMetadata) - .ThenInclude(mm => mm.Genres) - .Include(m => m.MovieMetadata) - .ThenInclude(mm => mm.Tags) - .Include(m => m.MovieMetadata) - .ThenInclude(mm => mm.Studios) - .Include(m => m.MovieMetadata) - .ThenInclude(mm => mm.Actors) - .Include(m => m.MovieMetadata) - .ThenInclude(mm => mm.Directors) - .Include(m => m.MovieMetadata) - .ThenInclude(mm => mm.Writers) - .Include(m => m.MovieMetadata) - .ThenInclude(mm => mm.Artwork) - .Include(m => m.MovieMetadata) - .ThenInclude(mm => mm.Guids) - .Filter(m => m.ItemId == movie.ItemId) - .Include(m => m.TraktListItems) - .ThenInclude(tli => tli.TraktList) - .OrderBy(m => m.ItemId) - .SingleOrDefaultAsync(); - - if (maybeExisting.IsSome) - { - EmbyMovie existing = maybeExisting.ValueUnsafe(); - - // library path is used for search indexing later - movie.LibraryPath = existing.LibraryPath; - movie.Id = existing.Id; - - existing.Etag = movie.Etag; - - // metadata - MovieMetadata metadata = existing.MovieMetadata.Head(); - MovieMetadata incomingMetadata = movie.MovieMetadata.Head(); - metadata.MetadataKind = incomingMetadata.MetadataKind; - metadata.ContentRating = incomingMetadata.ContentRating; - metadata.Title = incomingMetadata.Title; - metadata.SortTitle = incomingMetadata.SortTitle; - metadata.Plot = incomingMetadata.Plot; - metadata.Year = incomingMetadata.Year; - metadata.Tagline = incomingMetadata.Tagline; - metadata.DateAdded = incomingMetadata.DateAdded; - metadata.DateUpdated = DateTime.UtcNow; - - // genres - foreach (Genre genre in metadata.Genres - .Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Genres.Remove(genre); - } - - foreach (Genre genre in incomingMetadata.Genres - .Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Genres.Add(genre); - } - - // tags - foreach (Tag tag in metadata.Tags - .Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name)) - .Filter(g => g.ExternalCollectionId is null) - .ToList()) - { - metadata.Tags.Remove(tag); - } - - foreach (Tag tag in incomingMetadata.Tags - .Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Tags.Add(tag); - } - - // studios - foreach (Studio studio in metadata.Studios - .Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Studios.Remove(studio); - } - - foreach (Studio studio in incomingMetadata.Studios - .Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Studios.Add(studio); - } - - // actors - foreach (Actor actor in metadata.Actors - .Filter( - a => incomingMetadata.Actors.All( - a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) - .ToList()) - { - metadata.Actors.Remove(actor); - } - - foreach (Actor actor in incomingMetadata.Actors - .Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name)) - .ToList()) - { - metadata.Actors.Add(actor); - } - - // directors - foreach (Director director in metadata.Directors - .Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name)) - .ToList()) - { - metadata.Directors.Remove(director); - } - - foreach (Director director in incomingMetadata.Directors - .Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)) - .ToList()) - { - metadata.Directors.Add(director); - } - - // writers - foreach (Writer writer in metadata.Writers - .Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name)) - .ToList()) - { - metadata.Writers.Remove(writer); - } - - foreach (Writer writer in incomingMetadata.Writers - .Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)) - .ToList()) - { - metadata.Writers.Add(writer); - } - - // guids - foreach (MetadataGuid guid in metadata.Guids - .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - metadata.Guids.Remove(guid); - } - - foreach (MetadataGuid guid in incomingMetadata.Guids - .Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - metadata.Guids.Add(guid); - } - - metadata.ReleaseDate = incomingMetadata.ReleaseDate; - - // poster - Artwork incomingPoster = - incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); - if (incomingPoster != null) - { - Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); - if (poster == null) - { - poster = new Artwork { ArtworkKind = ArtworkKind.Poster }; - metadata.Artwork.Add(poster); - } - - poster.Path = incomingPoster.Path; - poster.DateAdded = incomingPoster.DateAdded; - poster.DateUpdated = incomingPoster.DateUpdated; - } - - // fan art - Artwork incomingFanArt = - incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); - if (incomingFanArt != null) - { - Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); - if (fanArt == null) - { - fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt }; - metadata.Artwork.Add(fanArt); - } - - fanArt.Path = incomingFanArt.Path; - fanArt.DateAdded = incomingFanArt.DateAdded; - fanArt.DateUpdated = incomingFanArt.DateUpdated; - } - - // version - MediaVersion version = existing.MediaVersions.Head(); - MediaVersion incomingVersion = movie.MediaVersions.Head(); - version.Name = incomingVersion.Name; - version.DateAdded = incomingVersion.DateAdded; - - // media file - MediaFile file = version.MediaFiles.Head(); - MediaFile incomingFile = incomingVersion.MediaFiles.Head(); - file.Path = incomingFile.Path; - } - - await dbContext.SaveChangesAsync(); - - return maybeExisting; - } - public async Task AddDirector(MovieMetadata metadata, Director director) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); @@ -810,14 +227,6 @@ public class MovieRepository : IMovieRepository new { Path = path, MediaFileId = mediaFileId }).Map(_ => Unit.Default); } - public async Task SetPlexEtag(PlexMovie movie, string etag) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - return await dbContext.Connection.ExecuteAsync( - "UPDATE PlexMovie SET Etag = @Etag WHERE Id = @Id", - new { Etag = etag, movie.Id }).Map(_ => Unit.Default); - } - private static async Task>> AddMovie( TvContext dbContext, int libraryPathId, @@ -852,33 +261,4 @@ public class MovieRepository : IMovieRepository return BaseError.New(ex.Message); } } - - private async Task>> AddPlexMovie( - TvContext context, - PlexLibrary library, - PlexMovie item) - { - try - { - // blank out etag for initial save in case stats/metadata/etc updates fail - string etag = item.Etag; - item.Etag = string.Empty; - - item.LibraryPathId = library.Paths.Head().Id; - - await context.PlexMovies.AddAsync(item); - await context.SaveChangesAsync(); - - // restore etag - item.Etag = etag; - - await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); - await context.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); - return new MediaItemScanResult(item) { IsAdded = true }; - } - catch (Exception ex) - { - return BaseError.New(ex.ToString()); - } - } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs index 62768d48..0e9e4b67 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs @@ -1,6 +1,10 @@ using Dapper; +using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Metadata; +using ErsatzTV.Core.Plex; +using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Infrastructure.Data.Repositories; @@ -11,6 +15,19 @@ public class PlexMovieRepository : IPlexMovieRepository public PlexMovieRepository(IDbContextFactory dbContextFactory) => _dbContextFactory = dbContextFactory; + public async Task> GetExistingMovies(PlexLibrary library) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.QueryAsync( + @"SELECT Key, Etag, MI.State FROM PlexMovie + INNER JOIN Movie M on PlexMovie.Id = M.Id + INNER JOIN MediaItem MI on M.Id = MI.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id + WHERE LP.LibraryId = @LibraryId", + new { LibraryId = library.Id }) + .Map(result => result.ToList()); + } + public async Task FlagNormal(PlexLibrary library, PlexMovie movie) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); @@ -73,4 +90,77 @@ public class PlexMovieRepository : IPlexMovieRepository return ids; } + + public async Task>> GetOrAdd(PlexLibrary library, PlexMovie item) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + Option maybeExisting = await dbContext.PlexMovies + .AsNoTracking() + .Include(i => i.MovieMetadata) + .ThenInclude(mm => mm.Genres) + .Include(i => i.MovieMetadata) + .ThenInclude(mm => mm.Tags) + .Include(i => i.MovieMetadata) + .ThenInclude(mm => mm.Studios) + .Include(i => i.MovieMetadata) + .ThenInclude(mm => mm.Actors) + .ThenInclude(a => a.Artwork) + .Include(i => i.MovieMetadata) + .ThenInclude(mm => mm.Artwork) + .Include(i => i.MovieMetadata) + .ThenInclude(mm => mm.Directors) + .Include(i => i.MovieMetadata) + .ThenInclude(mm => mm.Writers) + .Include(i => i.MovieMetadata) + .ThenInclude(mm => mm.Guids) + .Include(i => i.MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .Include(i => i.MediaVersions) + .ThenInclude(mv => mv.Streams) + .Include(i => i.LibraryPath) + .ThenInclude(lp => lp.Library) + .Include(i => i.TraktListItems) + .ThenInclude(tli => tli.TraktList) + .SelectOneAsync(i => i.Key, i => i.Key == item.Key); + + foreach (PlexMovie plexMovie in maybeExisting) + { + return new MediaItemScanResult(plexMovie) { IsAdded = false }; + } + + return await AddMovie(dbContext, library, item); + } + + public async Task SetEtag(PlexMovie movie, string etag) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.ExecuteAsync( + "UPDATE PlexMovie SET Etag = @Etag WHERE Id = @Id", + new { Etag = etag, movie.Id }).Map(_ => Unit.Default); + } + + private async Task>> AddMovie( + TvContext dbContext, + PlexLibrary library, + PlexMovie item) + { + try + { + // blank out etag for initial save in case stats/metadata/etc updates fail + item.Etag = string.Empty; + + item.LibraryPathId = library.Paths.Head().Id; + + await dbContext.PlexMovies.AddAsync(item); + await dbContext.SaveChangesAsync(); + + await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); + await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + return new MediaItemScanResult(item) { IsAdded = true }; + } + catch (Exception ex) + { + return BaseError.New(ex.ToString()); + } + } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs index 7d4ff9e8..97565919 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs @@ -811,7 +811,6 @@ public class TelevisionRepository : ITelevisionRepository try { // blank out etag for initial save in case stats/metadata/etc updates fail - string etag = item.Etag; item.Etag = string.Empty; item.LibraryPathId = library.Paths.Head().Id; @@ -819,9 +818,6 @@ public class TelevisionRepository : ITelevisionRepository await dbContext.PlexShows.AddAsync(item); await dbContext.SaveChangesAsync(); - // restore etag - item.Etag = etag; - await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); return new MediaItemScanResult(item) { IsAdded = true }; @@ -840,7 +836,6 @@ public class TelevisionRepository : ITelevisionRepository try { // blank out etag for initial save in case stats/metadata/etc updates fail - string etag = item.Etag; item.Etag = string.Empty; item.LibraryPathId = library.Paths.Head().Id; @@ -848,9 +843,6 @@ public class TelevisionRepository : ITelevisionRepository await dbContext.PlexSeasons.AddAsync(item); await dbContext.SaveChangesAsync(); - // restore etag - item.Etag = etag; - await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); return item; @@ -874,7 +866,6 @@ public class TelevisionRepository : ITelevisionRepository } // blank out etag for initial save in case stats/metadata/etc updates fail - string etag = item.Etag; item.Etag = string.Empty; item.LibraryPathId = library.Paths.Head().Id; @@ -891,9 +882,6 @@ public class TelevisionRepository : ITelevisionRepository await dbContext.PlexEpisodes.AddAsync(item); await dbContext.SaveChangesAsync(); - // restore etag - item.Etag = etag; - await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); await dbContext.Entry(item).Reference(e => e.Season).LoadAsync(); diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 8f13c17c..8e66ebfd 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -382,6 +382,7 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -389,6 +390,7 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped();