From 465e9d145ff9333b187f331984ae79d84ad91da7 Mon Sep 17 00:00:00 2001 From: Josh Archer Date: Sat, 29 Aug 2026 17:43:03 -0400 Subject: [PATCH 1/2] feat(media): extract archived downloads (.zip, .tgz, .tar.gz) during import when no media files are found --- .../DownloadedBooksImportServiceFixture.cs | 59 ++++++++++++++++++- .../DownloadedBooksImportService.cs | 41 ++++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/Chaptarr.Core.Test/MediaFiles/DownloadedBooksImportServiceFixture.cs b/src/Chaptarr.Core.Test/MediaFiles/DownloadedBooksImportServiceFixture.cs index e2755785..5f79000e 100644 --- a/src/Chaptarr.Core.Test/MediaFiles/DownloadedBooksImportServiceFixture.cs +++ b/src/Chaptarr.Core.Test/MediaFiles/DownloadedBooksImportServiceFixture.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using NLog; using NUnit.Framework; +using NzbDrone.Common; using NzbDrone.Common.Disk; using NzbDrone.Core.Books; using NzbDrone.Core.Books.Services; @@ -189,7 +190,7 @@ private sealed class StubDiskProvider : IDiskProvider public bool FolderWritable(string path) => throw new NotImplementedException(); public bool FolderEmpty(string path) => throw new NotImplementedException(); public IEnumerable GetDirectories(string path) => throw new NotImplementedException(); - public IEnumerable GetFiles(string path, bool recursive) => throw new NotImplementedException(); + public IEnumerable GetFiles(string path, bool recursive) => Directory.Exists(path) ? Directory.GetFiles(path, "*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly) : Array.Empty(); public long GetFolderSize(string path) => throw new NotImplementedException(); public void CreateFolder(string path) => throw new NotImplementedException(); public void DeleteFile(string path) => throw new NotImplementedException(); @@ -2638,5 +2639,61 @@ private static Dictionary> CreateAudioTags(string album, st ["TITLE"] = new List { title } }; } + + private sealed class RecordingArchiveService : IArchiveService + { + public List<(string CompressedFile, string Destination)> Extractions { get; } = new(); + + public void Extract(string compressedFile, string destination) + { + Extractions.Add((compressedFile, destination)); + } + + public void CreateZip(string path, IEnumerable files) => throw new NotImplementedException(); + } + + [Test] + public void should_extract_archives_when_folder_has_no_direct_importable_files() + { + var tempDir = Path.Combine(Path.GetTempPath(), "chaptarr-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + var zipPath = Path.Combine(tempDir, "Book.zip"); + File.WriteAllBytes(zipPath, new byte[] { 1, 2, 3, 4 }); + + var archiveService = new RecordingArchiveService(); + + try + { + var service = new DownloadedBooksImportService( + new StubDiskProvider(), + new StubDiskScanService(), + new StubFileMatchingService(), + new StubMetadataTagService(), + new RecordingImportApprovedBooks(), + DispatchProxy.Create>(), + DispatchProxy.Create>(), + DispatchProxy.Create>(), + DispatchProxy.Create>(), + new StubAuthorLibraryService(), + new StubRootFolderService(), + ConfigServiceTestProxy.Create(), + DispatchProxy.Create>(), + DispatchProxy.Create>(), + DispatchProxy.Create>(), + DispatchProxy.Create>(), + LogManager.GetCurrentClassLogger(), + archiveService); + + service.ProcessPath(tempDir, ImportMode.Auto, author: null, downloadClientItem: null); + + Assert.That(archiveService.Extractions, Has.Count.EqualTo(1)); + Assert.That(archiveService.Extractions[0].CompressedFile, Is.EqualTo(zipPath)); + Assert.That(archiveService.Extractions[0].Destination, Is.EqualTo(tempDir)); + } + finally + { + Directory.Delete(tempDir, true); + } + } } } diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedBooksImportService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedBooksImportService.cs index 123fe68d..fa1f58aa 100644 --- a/src/NzbDrone.Core/MediaFiles/DownloadedBooksImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/DownloadedBooksImportService.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using NLog; +using NzbDrone.Common; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Common.EnvironmentInfo; @@ -52,6 +53,7 @@ public class DownloadedBooksImportService : IDownloadedBooksImportService private readonly IEventAggregator _eventAggregator; private readonly IRuntimeInfo _runtimeInfo; private readonly IMediaInfoExtractor _mediaInfoExtractor; + private readonly IArchiveService _archiveService; private readonly Logger _logger; public DownloadedBooksImportService( @@ -71,7 +73,8 @@ public DownloadedBooksImportService( IEventAggregator eventAggregator, IRuntimeInfo runtimeInfo, IMediaInfoExtractor mediaInfoExtractor, - Logger logger) + Logger logger, + IArchiveService archiveService = null) { _diskProvider = diskProvider; _diskScanService = diskScanService; @@ -90,6 +93,7 @@ public DownloadedBooksImportService( _runtimeInfo = runtimeInfo; _mediaInfoExtractor = mediaInfoExtractor; _logger = logger; + _archiveService = archiveService ?? new ArchiveService(logger); } public List ProcessRootFolder(IDirectoryInfo directoryInfo) @@ -150,6 +154,15 @@ private List ProcessFolder(IDirectoryInfo directoryInfo, ImportMod .Where(f => MediaFileExtensions.AllExtensions.Contains(f.Extension)) .ToList(); + if (!mediaFiles.Any()) + { + ExtractArchivesInFolder(directoryInfo); + visibleFiles = _diskProvider.GetFileInfos(directoryInfo.FullName, true); + mediaFiles = visibleFiles + .Where(f => MediaFileExtensions.AllExtensions.Contains(f.Extension)) + .ToList(); + } + if (!mediaFiles.Any()) { _logger.Debug("[DOWNLOAD-IMPORT] No media files found in: {0}", directoryInfo.FullName); @@ -1411,5 +1424,31 @@ public List ProcessFile(string path, ImportMode importMode = Impor var fileInfo = _diskProvider.GetFileInfo(path); return ProcessFile(fileInfo, importMode, author, downloadClientItem, remoteBook, requireDefaultRootFolderForMissingAuthors: requireDefaultRootFolderForMissingAuthors); } + + private void ExtractArchivesInFolder(IDirectoryInfo directoryInfo) + { + var archives = _diskProvider.GetFiles(directoryInfo.FullName, true) + .Where(IsSupportedArchive) + .ToList(); + + foreach (var archive in archives) + { + try + { + _archiveService.Extract(archive, directoryInfo.FullName); + } + catch (Exception e) + { + _logger.Warn(e, "Failed to extract archive during import: {0}", archive); + } + } + } + + private static bool IsSupportedArchive(string path) + { + return path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase); + } } } From dc2c402500b0b0d960562aeceeca0471a5f3ce18 Mon Sep 17 00:00:00 2001 From: Josh Archer Date: Sat, 29 Aug 2026 17:43:03 -0400 Subject: [PATCH 2/2] fix(media): handle missing root folder during upgrade import with RootFolderNotFoundException --- .../UpgradeMediaFileServiceFixture.cs | 35 +++++++++++++++++++ .../MediaFiles/UpgradeMediaFileService.cs | 8 +++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/Chaptarr.Core.Test/MediaFiles/UpgradeMediaFileServiceFixture.cs b/src/Chaptarr.Core.Test/MediaFiles/UpgradeMediaFileServiceFixture.cs index 68cf76cf..c5b894b8 100644 --- a/src/Chaptarr.Core.Test/MediaFiles/UpgradeMediaFileServiceFixture.cs +++ b/src/Chaptarr.Core.Test/MediaFiles/UpgradeMediaFileServiceFixture.cs @@ -1,3 +1,4 @@ +using NzbDrone.Core.MediaFiles.BookImport; using System; using System.Collections.Generic; using System.IO; @@ -108,6 +109,40 @@ protected override object Invoke(MethodInfo targetMethod, object[] args) } } + private class NullRootFolderServiceProxy : DispatchProxy + { + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + if (targetMethod?.Name == nameof(IRootFolderService.GetBestRootFolder)) + { + return null; + } + + throw new NotImplementedException($"Test proxy does not implement IRootFolderService.{targetMethod?.Name}"); + } + } + + [Test] + public void should_throw_root_folder_not_found_when_best_root_folder_cannot_be_resolved() + { + var replacement = new BookFile { Id = 2, Path = "/downloads/Book.m4b" }; + var author = new Author { Id = 1, Path = "/books/Author" }; + var book = new Book { Id = 2, Author = author, BookFiles = new List() }; + var localBook = new LocalBook { Author = author, Book = book, Path = replacement.Path }; + + var subject = new UpgradeMediaFileService( + new RecordingRecycleBinProvider(), + DispatchProxy.Create(), + DispatchProxy.Create>(), + new StubBookFileMover(), + DispatchProxy.Create(), + DispatchProxy.Create(), + DispatchProxy.Create>(), + LogManager.GetCurrentClassLogger()); + + Assert.Throws(() => subject.UpgradeBookFile(replacement, localBook)); + } + [Test] public void should_not_delete_a_loose_path_match_while_replacing_its_stale_row() { diff --git a/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs b/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs index 277753f9..fec213e2 100644 --- a/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs @@ -66,9 +66,13 @@ public BookFileMoveResult UpgradeBookFile(BookFile bookFile, LocalBook localBook } var rootFolder = _rootFolderService.GetBestRootFolder(rootFolderPath); - var isCalibre = rootFolder?.IsCalibreLibrary == true && rootFolder.CalibreSettings != null; + if (rootFolder == null) + { + throw new RootFolderNotFoundException($"Root folder '{rootFolderPath}' was not found."); + } - var settings = rootFolder?.CalibreSettings; + var isCalibre = rootFolder.IsCalibreLibrary && rootFolder.CalibreSettings != null; + var settings = rootFolder.CalibreSettings; // If there are existing book files and the root folder is missing, throw, so the old file isn't left behind during the import process. if (existingFiles != null && existingFiles.Any() && !_diskProvider.FolderExists(rootFolderPath))