detect more local movie artwork (#2804)

* expand test coverage

* support "backdrop" files as local movie fanart fallback
This commit is contained in:
Jason Dove
2026-01-27 16:35:28 -06:00
committed by GitHub
parent f1072b70c7
commit a0f5d8d5d5
3 changed files with 319 additions and 18 deletions

View File

@@ -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

View File

@@ -79,7 +79,7 @@ public class MovieFolderScannerTests
_localMetadataProvider.RefreshFallbackMetadata(Arg.Any<Movie>())
.Returns(arg =>
{
((Movie)arg.Arg<MediaItem>()).MovieMetadata = new List<MovieMetadata> { new() };
((Movie)arg.Arg<MediaItem>()).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<LibraryFolder>() };
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFmpegPath,
FFprobePath,
0,
1,
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await _movieRepository.Received(1).GetOrAdd(
Arg.Any<LibraryPath>(),
Arg.Any<LibraryFolder>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
await _movieRepository.Received(1).GetOrAdd(
libraryPath,
Arg.Any<LibraryFolder>(),
moviePath,
Arg.Any<CancellationToken>());
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshFallbackMetadata(
Arg.Is<Movie>(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<LibraryFolder>() };
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFmpegPath,
FFprobePath,
0,
1,
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await _movieRepository.Received(1).GetOrAdd(
Arg.Any<LibraryPath>(),
Arg.Any<LibraryFolder>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
await _movieRepository.Received(1).GetOrAdd(
libraryPath,
Arg.Any<LibraryFolder>(),
moviePath,
Arg.Any<CancellationToken>());
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshFallbackMetadata(
Arg.Is<Movie>(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<LibraryFolder>() };
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFmpegPath,
FFprobePath,
0,
1,
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await _movieRepository.Received(1).GetOrAdd(
Arg.Any<LibraryPath>(),
Arg.Any<LibraryFolder>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
await _movieRepository.Received(1).GetOrAdd(
libraryPath,
Arg.Any<LibraryFolder>(),
moviePath,
Arg.Any<CancellationToken>());
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshFallbackMetadata(
Arg.Is<Movie>(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<LibraryFolder>() };
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFmpegPath,
FFprobePath,
0,
1,
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await _movieRepository.Received(1).GetOrAdd(
Arg.Any<LibraryPath>(),
Arg.Any<LibraryFolder>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
await _movieRepository.Received(1).GetOrAdd(
libraryPath,
Arg.Any<LibraryFolder>(),
moviePath,
Arg.Any<CancellationToken>());
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshFallbackMetadata(
Arg.Is<Movie>(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<string>(), 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<LibraryFolder>() };
Either<BaseError, Unit> 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<LibraryFolder>() };
Either<BaseError, Unit> 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))]

View File

@@ -381,26 +381,22 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
private Option<string> LocateArtwork(Movie movie, ArtworkKind artworkKind)
{
string segment = artworkKind switch
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileNameWithoutExtension(path);
string folder = Path.GetDirectoryName(path) ?? string.Empty;
string[] segments = artworkKind switch
{
ArtworkKind.Poster => "poster",
ArtworkKind.FanArt => "fanart",
ArtworkKind.Poster => ["poster", $"{fileName}-poster", "folder"],
ArtworkKind.FanArt => ["fanart", $"{fileName}-fanart", "backdrop"],
_ => throw new ArgumentOutOfRangeException(nameof(artworkKind))
};
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
string folder = Path.GetDirectoryName(path) ?? string.Empty;
IEnumerable<string> possibleMoviePosters = ImageFileExtensions.Collect(ext =>
new[] { $"{segment}.{ext}", Path.GetFileNameWithoutExtension(path) + $"-{segment}.{ext}" })
.Map(f => Path.Combine(folder, f));
Option<string> result = possibleMoviePosters.Filter(p => _fileSystem.File.Exists(p)).HeadOrNone();
if (result.IsNone && artworkKind == ArtworkKind.Poster)
{
IEnumerable<string> possibleFolderPosters = ImageFileExtensions.Collect(ext => new[] { $"folder.{ext}" })
.Map(f => Path.Combine(folder, f));
result = possibleFolderPosters.Filter(p => _fileSystem.File.Exists(p)).HeadOrNone();
}
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();
}
}