using System.Runtime.InteropServices; using Bugsnag; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Metadata; using ErsatzTV.Scanner.Core.Interfaces.FFmpeg; using ErsatzTV.Scanner.Core.Interfaces.Metadata; using ErsatzTV.Scanner.Core.Metadata; using ErsatzTV.Scanner.Tests.Core.Fakes; using FluentAssertions; using MediatR; using Microsoft.Extensions.Logging; using NSubstitute; using NUnit.Framework; namespace ErsatzTV.Scanner.Tests.Core.Metadata; [TestFixture] public class MovieFolderScannerTests { private static readonly string BadFakeRoot = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\Movies-That-Dont-Exist" : @"/movies-that-dont-exist"; private static readonly string FakeRoot = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\Movies" : "/movies"; private static readonly string FFmpegPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\bin\ffmpeg.exe" : "/bin/ffmpeg"; private static readonly string FFprobePath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\bin\ffprobe.exe" : "/bin/ffprobe"; [TestFixture] public class ScanFolder { [SetUp] public void SetUp() { _movieRepository = Substitute.For(); _movieRepository.GetOrAdd(Arg.Any(), Arg.Any(), Arg.Any()) .Returns( args => Right>(new FakeMovieWithPath(args.Arg())) .AsTask()); _movieRepository.FindMoviePaths(Arg.Any()) .Returns(new List().AsEnumerable().AsTask()); _mediaItemRepository = Substitute.For(); _mediaItemRepository.FlagFileNotFound(Arg.Any(), Arg.Any()) .Returns(new List().AsTask()); _localStatisticsProvider = Substitute.For(); _localMetadataProvider = Substitute.For(); _localStatisticsProvider.RefreshStatistics(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Right(true).AsTask()); // fallback metadata adds metadata to a movie, so we need to replicate that here _localMetadataProvider.RefreshFallbackMetadata(Arg.Any()) .Returns( arg => { ((Movie)arg.Arg()).MovieMetadata = new List { new() }; return Task.FromResult(true); }); _imageCache = Substitute.For(); _libraryRepository = Substitute.For(); _libraryRepository.GetOrAddFolder(Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(new LibraryFolder()); } private IMovieRepository _movieRepository; private IMediaItemRepository _mediaItemRepository; private ILocalStatisticsProvider _localStatisticsProvider; private ILocalMetadataProvider _localMetadataProvider; private IImageCache _imageCache; private ILibraryRepository _libraryRepository; [Test] public async Task NewMovie_Statistics_And_FallbackMetadata( [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] string videoExtension) { string moviePath = Path.Combine( FakeRoot, Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); MovieFolderScanner service = GetService( new FakeFileEntry(moviePath) { 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.Should().BeTrue(); await _movieRepository.Received(1).GetOrAdd( Arg.Any(), Arg.Any(), Arg.Any()); await _movieRepository.Received(1).GetOrAdd(libraryPath, Arg.Any(), moviePath); 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)); } [Test] public async Task NewMovie_Statistics_And_FallbackMetadata_MixedCase( [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] string videoExtension) { char[] mixedCaseExtension = videoExtension.ToLowerInvariant().ToArray(); mixedCaseExtension[2] = char.ToUpper(mixedCaseExtension[2]); videoExtension = new string(mixedCaseExtension); string moviePath = Path.Combine( FakeRoot, Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); MovieFolderScanner service = GetService( new FakeFileEntry(moviePath) { 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.Should().BeTrue(); await _movieRepository.Received(1).GetOrAdd( Arg.Any(), Arg.Any(), Arg.Any()); await _movieRepository.Received(1).GetOrAdd(libraryPath, Arg.Any(), moviePath); 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)); } [Test] public async Task NewMovie_Statistics_And_SidecarMetadata_MovieNameNfo( [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] string videoExtension) { string moviePath = Path.Combine( FakeRoot, Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); string metadataPath = Path.ChangeExtension(moviePath, "nfo"); MovieFolderScanner service = GetService( new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, new FakeFileEntry(metadataPath) ); 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.Should().BeTrue(); await _movieRepository.Received(1).GetOrAdd( Arg.Any(), Arg.Any(), Arg.Any()); await _movieRepository.Received(1).GetOrAdd(libraryPath, Arg.Any(), moviePath); await _localStatisticsProvider.Received(1).RefreshStatistics( FFmpegPath, FFprobePath, Arg.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)); await _localMetadataProvider.Received(1).RefreshSidecarMetadata( Arg.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath), metadataPath); } [Test] public async Task NewMovie_Statistics_And_SidecarMetadata_MovieNfo( [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] string videoExtension) { string moviePath = Path.Combine( FakeRoot, Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); string metadataPath = Path.Combine(Path.GetDirectoryName(moviePath) ?? string.Empty, "movie.nfo"); MovieFolderScanner service = GetService( new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, new FakeFileEntry(metadataPath) ); 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.Should().BeTrue(); await _movieRepository.Received(1).GetOrAdd( Arg.Any(), Arg.Any(), Arg.Any()); await _movieRepository.Received(1).GetOrAdd(libraryPath, Arg.Any(), moviePath); await _localStatisticsProvider.Received(1).RefreshStatistics( FFmpegPath, FFprobePath, Arg.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)); await _localMetadataProvider.Received(1).RefreshSidecarMetadata( Arg.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath), metadataPath); } [Test] public async Task NewMovie_Statistics_And_FallbackMetadata_And_Poster( [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}"); MovieFolderScanner service = GetService( new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, new FakeFileEntry(posterPath) { 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.Should().BeTrue(); await _movieRepository.Received(1).GetOrAdd( Arg.Any(), Arg.Any(), Arg.Any()); await _movieRepository.Received(1).GetOrAdd(libraryPath, Arg.Any(), moviePath); 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(posterPath, ArtworkKind.Poster); } [Test] public async Task NewMovie_Statistics_And_FallbackMetadata_And_FolderPoster( [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, $"folder.{imageExtension}"); MovieFolderScanner service = GetService( new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, new FakeFileEntry(posterPath) { 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.Should().BeTrue(); await _movieRepository.Received(1).GetOrAdd( Arg.Any(), Arg.Any(), Arg.Any()); await _movieRepository.Received(1).GetOrAdd(libraryPath, Arg.Any(), moviePath); 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(posterPath, ArtworkKind.Poster); } [Test] public async Task NewMovie_Statistics_And_FallbackMetadata_And_MovieNamePoster( [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, $"Movie (2020)-poster.{imageExtension}"); MovieFolderScanner service = GetService( new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, new FakeFileEntry(posterPath) { 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.Should().BeTrue(); await _movieRepository.Received(1).GetOrAdd( Arg.Any(), Arg.Any(), Arg.Any()); await _movieRepository.Received(1).GetOrAdd(libraryPath, Arg.Any(), moviePath); 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(posterPath, ArtworkKind.Poster); } [Test] public async Task Should_Ignore_Extra_Files( [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] string videoExtension, [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.ExtraFiles))] string extraFile) { string moviePath = Path.Combine( FakeRoot, Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); MovieFolderScanner service = GetService( new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, new FakeFileEntry( Path.Combine( Path.GetDirectoryName(moviePath) ?? string.Empty, $"Movie (2020)-{extraFile}{videoExtension}")) ); 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.Should().BeTrue(); await _movieRepository.Received(1).GetOrAdd( Arg.Any(), Arg.Any(), Arg.Any()); await _movieRepository.Received(1).GetOrAdd(libraryPath, Arg.Any(), moviePath); 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)); } [Test] public async Task Should_Ignore_Dot_Underscore_Files( [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] string videoExtension) { string moviePath = Path.Combine( FakeRoot, Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); MovieFolderScanner service = GetService( new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, new FakeFileEntry( Path.Combine( Path.GetDirectoryName(moviePath) ?? string.Empty, $"._Movie (2020){videoExtension}")) ); 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.Should().BeTrue(); await _movieRepository.Received(1).GetOrAdd( Arg.Any(), Arg.Any(), Arg.Any()); await _movieRepository.Received(1).GetOrAdd(libraryPath, Arg.Any(), moviePath); 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)); } [Test] public async Task Should_Ignore_Extra_Folders( [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] string videoExtension, [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.ExtraDirectories))] string extraFolder) { string moviePath = Path.Combine( FakeRoot, Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); MovieFolderScanner service = GetService( new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }, new FakeFileEntry( Path.Combine( Path.GetDirectoryName(moviePath) ?? string.Empty, Path.Combine(extraFolder, $"Movie (2020){videoExtension}"))) ); 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.Should().BeTrue(); await _movieRepository.Received(1).GetOrAdd( Arg.Any(), Arg.Any(), Arg.Any()); await _movieRepository.Received(1).GetOrAdd(libraryPath, Arg.Any(), moviePath); 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)); } [Test] public async Task Should_Work_With_Nested_Folders( [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] string videoExtension) { string moviePath = Path.Combine( Path.Combine(FakeRoot, "L-P"), Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); MovieFolderScanner service = GetService( new FakeFileEntry(moviePath) { 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.Should().BeTrue(); await _movieRepository.Received(1).GetOrAdd( Arg.Any(), Arg.Any(), Arg.Any()); await _movieRepository.Received(1).GetOrAdd(libraryPath, Arg.Any(), moviePath); 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)); } [Test] public async Task RenamedMovie_Should_Delete_Old_Movie() { // TODO: handle this case more elegantly // ideally, detect that the movie was renamed and still delete the old one (or update the path?) string movieFolder = Path.Combine(FakeRoot, "Movie (2020)"); string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi"); _movieRepository.FindMoviePaths(Arg.Any()) .Returns(new List { oldMoviePath }.AsEnumerable().AsTask()); string moviePath = Path.Combine(movieFolder, "Movie (2020).mkv"); MovieFolderScanner service = GetService( new FakeFileEntry(moviePath) { 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.Should().BeTrue(); await _mediaItemRepository.Received(1).FlagFileNotFound(Arg.Any(), Arg.Any()); await _mediaItemRepository.Received(1).FlagFileNotFound(libraryPath, oldMoviePath); } [Test] public async Task DeletedMovieAndFolder_Should_Flag_File_Not_Found() { string movieFolder = Path.Combine(FakeRoot, "Movie (2020)"); string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi"); _movieRepository.FindMoviePaths(Arg.Any()) .Returns(new List { oldMoviePath }.AsEnumerable().AsTask()); MovieFolderScanner service = GetService( new FakeFolderEntry(FakeRoot) ); 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.Should().BeTrue(); await _mediaItemRepository.Received(1).FlagFileNotFound(Arg.Any(), Arg.Any()); await _mediaItemRepository.Received(1).FlagFileNotFound(libraryPath, oldMoviePath); } private MovieFolderScanner GetService(params FakeFileEntry[] files) => new( new FakeLocalFileSystem(new List(files)), _movieRepository, _localStatisticsProvider, Substitute.For(), _localMetadataProvider, Substitute.For(), _imageCache, _libraryRepository, _mediaItemRepository, Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For>() ); private MovieFolderScanner GetService(params FakeFolderEntry[] folders) => new( new FakeLocalFileSystem(new List(), new List(folders)), _movieRepository, _localStatisticsProvider, Substitute.For(), _localMetadataProvider, Substitute.For(), _imageCache, _libraryRepository, _mediaItemRepository, Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For>() ); } }