From 4a1e6ce6330b54e1fd9f1fb615b8ff855b1006e3 Mon Sep 17 00:00:00 2001 From: jordan Date: Sun, 27 Sep 2026 01:54:42 +0000 Subject: [PATCH] Reject a vanished file in a manual import instead of aborting the whole batch ExecuteManualImport read fileInfo.Length for each selected file with no Exists check, so one file that disappeared between the preview and the execute threw FileNotFoundException and none of the other files were imported. Reject just that file with a 'Selected file no longer exists' reason (as the grouped-suggestion path already does) and continue with the rest of the batch. --- .../ManualImportExecuteVanishedFileFixture.cs | 159 ++++++++++++++++++ .../BookImport/Manual/ManualImportService.cs | 19 +++ 2 files changed, 178 insertions(+) create mode 100644 src/Chaptarr.Core.Test/MediaFiles/BookImport/ManualImportExecuteVanishedFileFixture.cs 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);