diff --git a/CHANGELOG.md b/CHANGELOG.md index cff333a93..8b96a8344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add chapter `title` to filler expression - This can be used to include or exclude chapters with specific (case-insensitive) titles - E.g. `title == 'here'`, `title != 'not here'`, `title like '%here%'` +- Local movie libraries: load fanart from `backdrop` files (created by Jellyfin) ### Changed - Disable automatic artwork database cleanup diff --git a/ErsatzTV.Scanner.Tests/Core/Metadata/MovieFolderScannerTests.cs b/ErsatzTV.Scanner.Tests/Core/Metadata/MovieFolderScannerTests.cs index 6a5d73092..6a7167cfa 100644 --- a/ErsatzTV.Scanner.Tests/Core/Metadata/MovieFolderScannerTests.cs +++ b/ErsatzTV.Scanner.Tests/Core/Metadata/MovieFolderScannerTests.cs @@ -79,7 +79,7 @@ public class MovieFolderScannerTests _localMetadataProvider.RefreshFallbackMetadata(Arg.Any()) .Returns(arg => { - ((Movie)arg.Arg()).MovieMetadata = new List { new() }; + ((Movie)arg.Arg()).MovieMetadata = [new MovieMetadata { Artwork = [] }]; return Task.FromResult(true); }); @@ -457,6 +457,310 @@ public class MovieFolderScannerTests await _imageCache.Received(1).CopyArtworkToCache(posterPath, ArtworkKind.Poster); } + [Test] + public async Task NewMovie_Statistics_And_FallbackMetadata_And_FanArt( + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] + string videoExtension, + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.ImageFileExtensions))] + string imageExtension) + { + string moviePath = Path.Combine( + FakeRoot, + Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); + + string fanArtPath = Path.Combine( + Path.GetDirectoryName(moviePath) ?? string.Empty, + $"fanart.{imageExtension}"); + + MovieFolderScanner service = GetService( + new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, + new FakeFileEntry(fanArtPath) { LastWriteTime = DateTime.Now } + ); + var libraryPath = new LibraryPath + { Id = 1, Path = FakeRoot, LibraryFolders = new List() }; + + Either result = await service.ScanFolder( + libraryPath, + FFmpegPath, + FFprobePath, + 0, + 1, + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + + await _movieRepository.Received(1).GetOrAdd( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + await _movieRepository.Received(1).GetOrAdd( + libraryPath, + Arg.Any(), + moviePath, + Arg.Any()); + + await _localStatisticsProvider.Received(1).RefreshStatistics( + FFmpegPath, + FFprobePath, + Arg.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)); + + await _localMetadataProvider.Received(1).RefreshFallbackMetadata( + Arg.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)); + + await _imageCache.Received(1).CopyArtworkToCache(fanArtPath, ArtworkKind.FanArt); + } + + [Test] + public async Task NewMovie_Statistics_And_FallbackMetadata_And_FanArt_Backdrop( + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] + string videoExtension, + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.ImageFileExtensions))] + string imageExtension) + { + string moviePath = Path.Combine( + FakeRoot, + Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); + + string fanArtPath = Path.Combine( + Path.GetDirectoryName(moviePath) ?? string.Empty, + $"backdrop.{imageExtension}"); + + MovieFolderScanner service = GetService( + new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, + new FakeFileEntry(fanArtPath) { LastWriteTime = DateTime.Now } + ); + var libraryPath = new LibraryPath + { Id = 1, Path = FakeRoot, LibraryFolders = new List() }; + + Either result = await service.ScanFolder( + libraryPath, + FFmpegPath, + FFprobePath, + 0, + 1, + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + + await _movieRepository.Received(1).GetOrAdd( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + await _movieRepository.Received(1).GetOrAdd( + libraryPath, + Arg.Any(), + moviePath, + Arg.Any()); + + await _localStatisticsProvider.Received(1).RefreshStatistics( + FFmpegPath, + FFprobePath, + Arg.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)); + + await _localMetadataProvider.Received(1).RefreshFallbackMetadata( + Arg.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)); + + await _imageCache.Received(1).CopyArtworkToCache(fanArtPath, ArtworkKind.FanArt); + } + + [Test] + public async Task NewMovie_Statistics_And_FallbackMetadata_And_MovieNameFanArt( + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] + string videoExtension, + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.ImageFileExtensions))] + string imageExtension) + { + string moviePath = Path.Combine( + FakeRoot, + Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); + + string fanArtPath = Path.Combine( + Path.GetDirectoryName(moviePath) ?? string.Empty, + $"Movie (2020)-fanart.{imageExtension}"); + + MovieFolderScanner service = GetService( + new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, + new FakeFileEntry(fanArtPath) { LastWriteTime = DateTime.Now } + ); + var libraryPath = new LibraryPath + { Id = 1, Path = FakeRoot, LibraryFolders = new List() }; + + Either result = await service.ScanFolder( + libraryPath, + FFmpegPath, + FFprobePath, + 0, + 1, + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + + await _movieRepository.Received(1).GetOrAdd( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + await _movieRepository.Received(1).GetOrAdd( + libraryPath, + Arg.Any(), + moviePath, + Arg.Any()); + + await _localStatisticsProvider.Received(1).RefreshStatistics( + FFmpegPath, + FFprobePath, + Arg.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)); + + await _localMetadataProvider.Received(1).RefreshFallbackMetadata( + Arg.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)); + + await _imageCache.Received(1).CopyArtworkToCache(fanArtPath, ArtworkKind.FanArt); + } + + [Test] + public async Task NewMovie_Statistics_And_FallbackMetadata_FanArt_Ignores_Folder_Image( + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] + string videoExtension, + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.ImageFileExtensions))] + string imageExtension) + { + string moviePath = Path.Combine( + FakeRoot, + Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); + + string folderImagePath = Path.Combine( + Path.GetDirectoryName(moviePath) ?? string.Empty, + $"folder.{imageExtension}"); + + MovieFolderScanner service = GetService( + new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, + new FakeFileEntry(folderImagePath) { LastWriteTime = DateTime.Now } + ); + var libraryPath = new LibraryPath + { Id = 1, Path = FakeRoot, LibraryFolders = new List() }; + + Either result = await service.ScanFolder( + libraryPath, + FFmpegPath, + FFprobePath, + 0, + 1, + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + + await _movieRepository.Received(1).GetOrAdd( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + await _movieRepository.Received(1).GetOrAdd( + libraryPath, + Arg.Any(), + moviePath, + Arg.Any()); + + await _localStatisticsProvider.Received(1).RefreshStatistics( + FFmpegPath, + FFprobePath, + Arg.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)); + + await _localMetadataProvider.Received(1).RefreshFallbackMetadata( + Arg.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)); + + // Should receive Poster call (as folder.ext is valid for poster) + await _imageCache.Received(1).CopyArtworkToCache(folderImagePath, ArtworkKind.Poster); + + // Should NOT receive FanArt call + await _imageCache.DidNotReceive().CopyArtworkToCache(Arg.Any(), ArtworkKind.FanArt); + } + + [Test] + public async Task NewMovie_Statistics_And_FallbackMetadata_Poster_Priority( + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] + string videoExtension, + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.ImageFileExtensions))] + string imageExtension) + { + string moviePath = Path.Combine( + FakeRoot, + Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); + + string posterPath = Path.Combine( + Path.GetDirectoryName(moviePath) ?? string.Empty, + $"poster.{imageExtension}"); + + string moviePosterPath = Path.Combine( + Path.GetDirectoryName(moviePath) ?? string.Empty, + $"Movie (2020)-poster.{imageExtension}"); + + MovieFolderScanner service = GetService( + new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, + new FakeFileEntry(posterPath) { LastWriteTime = DateTime.Now }, + new FakeFileEntry(moviePosterPath) { LastWriteTime = DateTime.Now } + ); + var libraryPath = new LibraryPath + { Id = 1, Path = FakeRoot, LibraryFolders = new List() }; + + Either result = await service.ScanFolder( + libraryPath, + FFmpegPath, + FFprobePath, + 0, + 1, + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + + // Should prefer "poster.ext" over "MovieName-poster.ext" + await _imageCache.Received(1).CopyArtworkToCache(posterPath, ArtworkKind.Poster); + await _imageCache.DidNotReceive().CopyArtworkToCache(moviePosterPath, ArtworkKind.Poster); + } + + [Test] + public async Task NewMovie_Statistics_And_FallbackMetadata_FanArt_Priority( + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] + string videoExtension, + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.ImageFileExtensions))] + string imageExtension) + { + string moviePath = Path.Combine( + FakeRoot, + Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); + + string fanArtPath = Path.Combine( + Path.GetDirectoryName(moviePath) ?? string.Empty, + $"fanart.{imageExtension}"); + + string movieFanArtPath = Path.Combine( + Path.GetDirectoryName(moviePath) ?? string.Empty, + $"Movie (2020)-fanart.{imageExtension}"); + + MovieFolderScanner service = GetService( + new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, + new FakeFileEntry(fanArtPath) { LastWriteTime = DateTime.Now }, + new FakeFileEntry(movieFanArtPath) { LastWriteTime = DateTime.Now } + ); + var libraryPath = new LibraryPath + { Id = 1, Path = FakeRoot, LibraryFolders = new List() }; + + Either result = await service.ScanFolder( + libraryPath, + FFmpegPath, + FFprobePath, + 0, + 1, + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + + // Should prefer "fanart.ext" over "MovieName-fanart.ext" + await _imageCache.Received(1).CopyArtworkToCache(fanArtPath, ArtworkKind.FanArt); + await _imageCache.DidNotReceive().CopyArtworkToCache(movieFanArtPath, ArtworkKind.FanArt); + } + [Test] public async Task Should_Ignore_Extra_Files( [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] diff --git a/ErsatzTV.Scanner/Core/Metadata/MovieFolderScanner.cs b/ErsatzTV.Scanner/Core/Metadata/MovieFolderScanner.cs index 4afe697c2..1bcb56b2f 100644 --- a/ErsatzTV.Scanner/Core/Metadata/MovieFolderScanner.cs +++ b/ErsatzTV.Scanner/Core/Metadata/MovieFolderScanner.cs @@ -381,26 +381,22 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner private Option LocateArtwork(Movie movie, ArtworkKind artworkKind) { - string segment = artworkKind switch - { - ArtworkKind.Poster => "poster", - ArtworkKind.FanArt => "fanart", - _ => throw new ArgumentOutOfRangeException(nameof(artworkKind)) - }; - string path = movie.MediaVersions.Head().MediaFiles.Head().Path; + string fileName = Path.GetFileNameWithoutExtension(path); string folder = Path.GetDirectoryName(path) ?? string.Empty; - IEnumerable possibleMoviePosters = ImageFileExtensions.Collect(ext => - new[] { $"{segment}.{ext}", Path.GetFileNameWithoutExtension(path) + $"-{segment}.{ext}" }) - .Map(f => Path.Combine(folder, f)); - Option result = possibleMoviePosters.Filter(p => _fileSystem.File.Exists(p)).HeadOrNone(); - if (result.IsNone && artworkKind == ArtworkKind.Poster) + + string[] segments = artworkKind switch { - IEnumerable possibleFolderPosters = ImageFileExtensions.Collect(ext => new[] { $"folder.{ext}" }) - .Map(f => Path.Combine(folder, f)); - result = possibleFolderPosters.Filter(p => _fileSystem.File.Exists(p)).HeadOrNone(); - } + ArtworkKind.Poster => ["poster", $"{fileName}-poster", "folder"], + ArtworkKind.FanArt => ["fanart", $"{fileName}-fanart", "backdrop"], + _ => throw new ArgumentOutOfRangeException(nameof(artworkKind)) + }; - return result; + return ImageFileExtensions + .Map(ext => segments.Map(segment => $"{segment}.{ext}")) + .Flatten() + .Map(f => Path.Combine(folder, f)) + .Filter(s => _fileSystem.File.Exists(s)) + .HeadOrNone(); } }