diff --git a/src/Chaptarr.Core.Test/MediaFiles/BookImport/ManualImportExecuteVanishedFileFixture.cs b/src/Chaptarr.Core.Test/MediaFiles/BookImport/ManualImportExecuteVanishedFileFixture.cs new file mode 100644 index 00000000..89da06bb --- /dev/null +++ b/src/Chaptarr.Core.Test/MediaFiles/BookImport/ManualImportExecuteVanishedFileFixture.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using System.Reflection; +using NLog; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.BookImport; +using NzbDrone.Core.MediaFiles.BookImport.Manual; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; + +namespace Chaptarr.Core.Test.MediaFiles.BookImport +{ + [TestFixture] + public class ManualImportExecuteVanishedFileFixture + { + private const string Folder = "/library/Author/Book"; + private const string VanishedPath = Folder + "/Disc 1.mp3"; + private const string PresentPath = Folder + "/Disc 2.mp3"; + + private static List> _importedDecisions; + + private class FileInfoProxy : DispatchProxy + { + public string Path { get; set; } + + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + var vanished = Path == VanishedPath; + + return targetMethod?.Name switch + { + "get_FullName" => Path, + "get_Exists" => !vanished, + // Like System.IO.FileInfo, Length throws when the file is gone. + "get_Length" => vanished ? throw new FileNotFoundException("Could not find file", Path) : 1024L, + "get_LastWriteTimeUtc" => DateTime.UtcNow, + "get_Extension" => System.IO.Path.GetExtension(Path), + "get_Name" => System.IO.Path.GetFileName(Path), + _ => throw new NotImplementedException($"IFileInfo.{targetMethod?.Name}") + }; + } + } + + // Returns default values for everything the test does not care about. + private class DefaultProxy : DispatchProxy + { + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + var returnType = targetMethod?.ReturnType; + return returnType != null && returnType != typeof(void) && returnType.IsValueType + ? Activator.CreateInstance(returnType) + : null; + } + } + + private class DiskProviderProxy : DispatchProxy + { + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + var path = args?.FirstOrDefault() as string; + return targetMethod?.Name switch + { + nameof(IDiskProvider.FolderExists) => true, + nameof(IDiskProvider.FileExists) => true, + nameof(IDiskProvider.GetFileInfo) => CreateFileInfo(path), + _ => throw new NotImplementedException($"IDiskProvider.{targetMethod?.Name}") + }; + } + } + + private class ImportApprovedBooksProxy : DispatchProxy + { + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + if (targetMethod?.Name == nameof(IImportApprovedBooks.Import)) + { + _importedDecisions = (List>)args[0]; + return new List(); + } + + throw new NotImplementedException($"IImportApprovedBooks.{targetMethod?.Name}"); + } + } + + [SetUp] + public void SetUp() + { + _importedDecisions = null; + } + + [Test] + public void execute_should_reject_a_vanished_file_and_still_process_the_rest_of_the_batch() + { + var service = CreateService(); + + Assert.DoesNotThrow(() => service.Execute(new ManualImportCommand + { + ImportMode = ImportMode.Copy, + Files = new List + { + new ManualImportFile { Path = VanishedPath, Quality = new QualityModel(Quality.MP3) }, + new ManualImportFile { Path = PresentPath, Quality = new QualityModel(Quality.MP3) } + } + })); + + Assert.That(_importedDecisions, Is.Not.Null, "the batch must reach the import step"); + Assert.That(_importedDecisions.Select(d => d.Item.Path), Is.EquivalentTo(new[] { VanishedPath, PresentPath })); + + var vanished = _importedDecisions.Single(d => d.Item.Path == VanishedPath); + Assert.That(vanished.Approved, Is.False, "a vanished file must not be importable"); + Assert.That(vanished.Rejections.Select(r => r.Reason), Has.Some.Contains("no longer exists")); + Assert.That(vanished.Item.Size, Is.Zero); + + Assert.That(_importedDecisions.Single(d => d.Item.Path == PresentPath).Item.Size, Is.EqualTo(1024L)); + } + + private static ManualImportService CreateService() + { + return new ManualImportService( + DispatchProxy.Create(), + null, + DispatchProxy.Create(), + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + DispatchProxy.Create(), + null, + DispatchProxy.Create(), + null, + null, + null, + null, + null, + LogManager.GetCurrentClassLogger()); + } + + private static IFileInfo CreateFileInfo(string path) + { + var fileInfo = DispatchProxy.Create(); + ((FileInfoProxy)(object)fileInfo).Path = path; + return fileInfo; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportService.cs index 80b2f8e6..0101a73f 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportService.cs @@ -938,6 +938,25 @@ List ResolveSuggestedWorkBooks( _logger.Warn("Manual import recovered file path '{0}' as '{1}' during execution", requestedPath, actualPath); } + if (!fileInfo.Exists) + { + // The file was selected in the preview but is gone now. Reading its size would throw and abort the + // whole batch, so reject just this file (as the grouped-suggestion path above does) and go on. + var vanishedDecision = new ImportDecision(new LocalBook + { + Path = actualPath, + Part = 1, + PartCount = 1, + Quality = file.Quality, + IndexerFlags = (IndexerFlags)file.IndexerFlags + }); + vanishedDecision.Reject(new Rejection($"Selected file no longer exists: '{requestedPath}'.")); + decisions.Add(vanishedDecision); + fileCount += 1; + + continue; + } + var fileRootFolder = _rootFolderService.GetBestRootFolder(actualPath); var mediaType = GetMediaTypeFromPath(actualPath);