From f4421ff189359886bae75e75e01443016f99e978 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Fri, 21 Aug 2026 14:44:08 +0100 Subject: [PATCH] fix: preserve multi-file audiobook folders (#1237) Pass the effective source root from `process_folder_output` into `transfer_book_files`, and have the transfer layer select a sanitized child directory named after that source root when an audiobook has multiple files and its organization mode is `none` or `rename`. Create that grouping directory before applying the existing hardlink/copy/move logic so operation accounting, torrent seeding preservation, collision handling, cleanup, and custom-script final paths continue to use the established production path. Completed multi-file audiobook torrents arrive as a directory whose chapter filenames may not identify the book, but folder output currently sends every discovered chapter directly to the configured destination in `none` and `rename` modes. This flattens chapters from unrelated books together and causes directory-oriented consumers such as Audiobookshelf to interpret individual chapters as separate books. A multi-file audiobook torrent in the default `rename` mode copies or hardlinks all supported chapter files beneath `//` with their original chapter filenames, and places no chapters directly in the destination root; A multi-file audiobook in `none` mode receives the same source-folder grouping without renaming its chapter files. Fixes #1181 --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: CaliBrain --- shelfmark/config/settings.py | 15 ++- shelfmark/download/outputs/folder.py | 1 + shelfmark/download/postprocess/policy.py | 2 +- shelfmark/download/postprocess/transfer.py | 16 ++- tests/config/test_download_settings.py | 26 ++++ tests/config/test_environment.py | 5 +- .../test_destination_file_organization.py | 14 +++ tests/core/test_hardlink.py | 115 +++++++++++++++++- tests/core/test_processing_integration.py | 77 ++++++++++++ 9 files changed, 262 insertions(+), 9 deletions(-) diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index d0b44d1..b510d17 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -766,7 +766,10 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]: } # Audiobooks are always folder output. - if effective.get("FILE_ORGANIZATION_AUDIOBOOK", "rename") == "rename": + if effective.get("FILE_ORGANIZATION_AUDIOBOOK", "rename") in { + "rename", + "rename_and_group", + }: template = effective.get("TEMPLATE_AUDIOBOOK_RENAME", "") if _contains_path_separators(template): return { @@ -1294,6 +1297,11 @@ def download_settings() -> list[SettingsField]: "label": "Rename and Organize", "description": "Create folders and rename files using a template. Recommended for Audiobookshelf. Do not use with ingest folders.", }, + { + "value": "rename_and_group", + "label": "Rename and Group", + "description": "Rename single-file downloads; keep multi-file downloads grouped in their source folder.", + }, ], default="rename", universal_only=True, @@ -1312,7 +1320,10 @@ def download_settings() -> list[SettingsField]: ), default="{Author} - {Title}", placeholder="{Author} - {Title}{ - Part }{PartNumber}", - show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"}, + show_when={ + "field": "FILE_ORGANIZATION_AUDIOBOOK", + "value": ["rename", "rename_and_group"], + }, universal_only=True, ), # Organize mode template - folders allowed diff --git a/shelfmark/download/outputs/folder.py b/shelfmark/download/outputs/folder.py index 692c349..7e3278f 100644 --- a/shelfmark/download/outputs/folder.py +++ b/shelfmark/download/outputs/folder.py @@ -205,6 +205,7 @@ def process_folder_output( is_torrent=is_torrent, preserve_source=preserve_source, organization_mode=plan.organization_mode, + source_root=source_path, ) if error: diff --git a/shelfmark/download/postprocess/policy.py b/shelfmark/download/postprocess/policy.py index 40f8e5c..0aa6b8f 100644 --- a/shelfmark/download/postprocess/policy.py +++ b/shelfmark/download/postprocess/policy.py @@ -52,7 +52,7 @@ def get_file_organization(*, is_audiobook: bool) -> str: """Get the file organization mode for the content type.""" key = "FILE_ORGANIZATION_AUDIOBOOK" if is_audiobook else "FILE_ORGANIZATION" mode = _config_text(core_config.config.get(key, "rename")).strip().lower() - return mode if mode in ("none", "rename", "organize") else "rename" + return mode if mode in ("none", "rename", "rename_and_group", "organize") else "rename" def get_template(*, is_audiobook: bool, organization_mode: str) -> str: diff --git a/shelfmark/download/postprocess/transfer.py b/shelfmark/download/postprocess/transfer.py index ba0a832..d4cd2c6 100644 --- a/shelfmark/download/postprocess/transfer.py +++ b/shelfmark/download/postprocess/transfer.py @@ -169,6 +169,7 @@ def transfer_book_files( is_torrent: bool, preserve_source: bool = False, organization_mode: str | None = None, + source_root: Path | None = None, ) -> tuple[list[Path], str | None, dict[str, int]]: """Transfer discovered book files into their final destination layout.""" if not book_files: @@ -238,6 +239,19 @@ def transfer_book_files( return final_paths, None, op_counts + transfer_destination = destination + if ( + is_audiobook + and len(book_files) > 1 + and organization_mode == "rename_and_group" + and source_root is not None + and run_blocking_io(source_root.is_dir) + ): + source_folder = sanitize_filename(source_root.name) + if source_folder: + transfer_destination = destination / source_folder + run_blocking_io(transfer_destination.mkdir, parents=True, exist_ok=True) + for book_file in book_files: if len(book_files) == 1 and organization_mode != "none": if not task.format: @@ -256,7 +270,7 @@ def transfer_book_files( else: filename = book_file.name - dest_path = destination / filename + dest_path = transfer_destination / filename final_path, op = _transfer_single_file( book_file, dest_path, diff --git a/tests/config/test_download_settings.py b/tests/config/test_download_settings.py index f294371..ea8666b 100644 --- a/tests/config/test_download_settings.py +++ b/tests/config/test_download_settings.py @@ -66,6 +66,32 @@ def test_download_settings_booklore_destination_field_defaults_to_library(): assert option_values == {"library", "bookdrop"} +def test_download_settings_audiobook_grouping_is_opt_in(): + from shelfmark.config.settings import download_settings + + fields = download_settings() + organization_field = next( + field for field in fields if getattr(field, "key", None) == "FILE_ORGANIZATION_AUDIOBOOK" + ) + rename_template = next( + field + for field in fields + if getattr(field, "key", None) == "template_audiobook_rename_editor" + ) + + assert organization_field.default == "rename" + assert [option["value"] for option in organization_field.options] == [ + "none", + "rename", + "organize", + "rename_and_group", + ] + assert rename_template.show_when == { + "field": "FILE_ORGANIZATION_AUDIOBOOK", + "value": ["rename", "rename_and_group"], + } + + def test_download_settings_grimmory_copy_is_exposed_in_ui_metadata(): from shelfmark.config.settings import download_settings diff --git a/tests/config/test_environment.py b/tests/config/test_environment.py index 8709a10..051fa7c 100644 --- a/tests/config/test_environment.py +++ b/tests/config/test_environment.py @@ -385,14 +385,15 @@ class TestSettingsValidation: assert "Naming Template" in result["message"] assert "Organize" in result["message"] - def test_downloads_audiobooks_rename_template_rejects_path_separators(self): + @pytest.mark.parametrize("organization_mode", ["rename", "rename_and_group"]) + def test_downloads_audiobooks_rename_template_rejects_path_separators(self, organization_mode): import shelfmark.config.settings # noqa: F401 from shelfmark.core.settings_registry import update_settings result = update_settings( "downloads", { - "FILE_ORGANIZATION_AUDIOBOOK": "rename", + "FILE_ORGANIZATION_AUDIOBOOK": organization_mode, "TEMPLATE_AUDIOBOOK_RENAME": "{Author}/{Title}", }, ) diff --git a/tests/core/test_destination_file_organization.py b/tests/core/test_destination_file_organization.py index 60b2854..a8b382a 100644 --- a/tests/core/test_destination_file_organization.py +++ b/tests/core/test_destination_file_organization.py @@ -66,6 +66,20 @@ def test_get_file_organization_uses_current_keys(monkeypatch): assert policy.get_file_organization(is_audiobook=True) == "none" +def test_get_file_organization_accepts_audiobook_grouping(monkeypatch): + import shelfmark.download.postprocess.policy as policy + + monkeypatch.setattr( + policy.core_config.config, + "get", + lambda key, default=None: { + "FILE_ORGANIZATION_AUDIOBOOK": "rename_and_group", + }.get(key, default), + ) + + assert policy.get_file_organization(is_audiobook=True) == "rename_and_group" + + def test_get_file_organization_ignores_pre_release_processing_mode_keys(monkeypatch): import shelfmark.download.postprocess.policy as policy diff --git a/tests/core/test_hardlink.py b/tests/core/test_hardlink.py index b52e398..ca6b139 100644 --- a/tests/core/test_hardlink.py +++ b/tests/core/test_hardlink.py @@ -1003,8 +1003,13 @@ class TestTorrentSourceCleanupProtection: Each test simulates a specific real-world content type and file structure. """ - def _make_config_mock(self, library_path: str, hardlink: bool = True): - """Create config mock for library/organize mode with hardlinking.""" + def _make_config_mock( + self, + library_path: str, + hardlink: bool = True, + organization_mode: str = "organize", + ): + """Create a folder-output config mock for torrent transfers.""" return MagicMock( side_effect=lambda key, default=None, **_kwargs: { # Destination paths (what _get_final_destination uses) @@ -1013,9 +1018,10 @@ class TestTorrentSourceCleanupProtection: # Templates (what _get_template uses) "TEMPLATE_ORGANIZE": "{Author}/{Title}", "TEMPLATE_AUDIOBOOK_ORGANIZE": "{Author}/{Title}{ - PartNumber}", + "TEMPLATE_AUDIOBOOK_RENAME": "{Author} - {Title}", # File organization mode "FILE_ORGANIZATION": "organize", - "FILE_ORGANIZATION_AUDIOBOOK": "organize", + "FILE_ORGANIZATION_AUDIOBOOK": organization_mode, # Hardlink toggle "HARDLINK_TORRENTS": hardlink, "HARDLINK_TORRENTS_AUDIOBOOK": hardlink, @@ -1122,6 +1128,108 @@ class TestTorrentSourceCleanupProtection: # ==================== AUDIOBOOK TESTS ==================== + @pytest.mark.parametrize( + ("organization_mode", "grouped"), + [("rename", False), ("rename_and_group", True)], + ) + @pytest.mark.parametrize("hardlink", [True, False]) + def test_torrent_audiobook_multifile_grouping_is_opt_in( + self, tmp_path, organization_mode, grouped, hardlink + ): + """Multi-file audiobooks retain their torrent folder only when opted in.""" + from shelfmark.core.models import DownloadTask, SearchMode + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) + + torrent_dir = tmp_path / "downloads" / "Project: Hail Mary Audiobook" + torrent_dir.mkdir(parents=True) + source_files = [torrent_dir / "Part 01.mp3", torrent_dir / "Part 02.mp3"] + for index, source_file in enumerate(source_files, start=1): + source_file.write_bytes(f"audio {index}".encode()) + + library = tmp_path / "library" + library.mkdir() + task = DownloadTask( + task_id=f"audiobook_{organization_mode}_{hardlink}", + source="prowlarr", + title="Project Hail Mary", + author="Andy Weir", + format="mp3", + content_type="audiobook", + search_mode=SearchMode.UNIVERSAL, + original_download_path=str(torrent_dir), + ) + + with patch("shelfmark.core.config.config") as mock_orch: + mock_orch.get = self._make_config_mock( + str(library), + hardlink=hardlink, + organization_mode=organization_mode, + ) + mock_orch.CUSTOM_SCRIPT = None + result = _post_process_download(torrent_dir, task, Event(), MagicMock()) + + transfer_dir = library / "Project_ Hail Mary Audiobook" if grouped else library + assert result is not None + assert Path(result).parent == transfer_dir + assert sorted(path.name for path in transfer_dir.glob("*.mp3")) == [ + "Part 01.mp3", + "Part 02.mp3", + ] + assert bool(list(library.glob("*.mp3"))) is not grouped + + for source_file in source_files: + destination_file = transfer_dir / source_file.name + assert source_file.exists() + assert destination_file.exists() + if hardlink: + assert source_file.stat().st_ino == destination_file.stat().st_ino + else: + assert source_file.stat().st_ino != destination_file.stat().st_ino + + @pytest.mark.parametrize("organization_mode", ["rename", "none", "rename_and_group"]) + def test_torrent_audiobook_single_file_stays_in_destination_root( + self, tmp_path, organization_mode + ): + """Single-file audiobooks do not gain a source-folder wrapper.""" + from shelfmark.core.models import DownloadTask, SearchMode + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) + + torrent_file = tmp_path / "downloads" / "Project Hail Mary.mp3" + torrent_file.parent.mkdir() + torrent_file.write_bytes(b"audio") + library = tmp_path / "library" + library.mkdir() + task = DownloadTask( + task_id=f"single_audiobook_{organization_mode}", + source="prowlarr", + title="Project Hail Mary", + author="Andy Weir", + format="mp3", + content_type="audiobook", + search_mode=SearchMode.UNIVERSAL, + original_download_path=str(torrent_file), + ) + + with patch("shelfmark.core.config.config") as mock_orch: + mock_orch.get = self._make_config_mock( + str(library), organization_mode=organization_mode + ) + mock_orch.CUSTOM_SCRIPT = None + result = _post_process_download(torrent_file, task, Event(), MagicMock()) + + expected_name = ( + "Andy Weir - Project Hail Mary.mp3" + if organization_mode in {"rename", "rename_and_group"} + else torrent_file.name + ) + assert result is not None + assert Path(result) == library / expected_name + assert torrent_file.exists() + def test_torrent_audiobook_multifile_hardlink(self, tmp_path): """Torrent: Multi-file audiobook - all source files preserved for seeding. @@ -1184,6 +1292,7 @@ class TestTorrentSourceCleanupProtection: # Verify library has all 12 files library_files = list((library / "Andy Weir").glob("*.mp3")) assert len(library_files) == 12 + assert not (library / torrent_dir.name).exists() # ==================== COMIC/CBZ TESTS ==================== diff --git a/tests/core/test_processing_integration.py b/tests/core/test_processing_integration.py index 0aad4a6..8cea26e 100644 --- a/tests/core/test_processing_integration.py +++ b/tests/core/test_processing_integration.py @@ -748,6 +748,83 @@ def test_archive_extraction_organize_multifile_assigns_part_numbers(tmp_path): assert files[1].name == "Archive Audio - 02.mp3" +def test_archive_extraction_grouping_does_not_use_archive_as_folder(tmp_path): + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) + + staging = tmp_path / "staging" + ingest = tmp_path / "ingest" + staging.mkdir() + ingest.mkdir() + + archive_path = staging / "Book.zip" + with zipfile.ZipFile(archive_path, "w") as zf: + zf.writestr("Part 1.mp3", "audio1") + zf.writestr("Part 2.mp3", "audio2") + + task = DownloadTask( + task_id="direct-archive-audio-grouped", + source="direct_download", + title="Archive Audio", + author="Tester", + format="mp3", + content_type="audiobook", + search_mode=SearchMode.DIRECT, + ) + + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + ): + mock_config.get = _build_config(ingest, organization="rename_and_group") + mock_config.CUSTOM_SCRIPT = None + _sync_config(mock_config, mock_config) + + result = _post_process_download(archive_path, task, Event(), lambda *_args: None) + + assert result is not None + assert sorted(path.name for path in ingest.glob("*.mp3")) == ["Part 1.mp3", "Part 2.mp3"] + assert not (ingest / "Book.zip").exists() + + +def test_usenet_audiobook_multifile_preserves_source_folder(tmp_path): + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) + + source_dir = tmp_path / "downloads" / "Usenet Audiobook" + source_dir.mkdir(parents=True) + for part in (1, 2): + (source_dir / f"Part {part}.mp3").write_text(f"audio{part}") + + ingest = tmp_path / "ingest" + ingest.mkdir() + task = DownloadTask( + task_id="usenet-audio-grouped", + source="prowlarr", + title="Usenet Audio", + author="Tester", + format="mp3", + content_type="audiobook", + search_mode=SearchMode.UNIVERSAL, + ) + + with patch("shelfmark.core.config.config") as mock_config: + mock_config.get = _build_config(ingest, organization="rename_and_group") + mock_config.CUSTOM_SCRIPT = None + + result = _post_process_download(source_dir, task, Event(), lambda *_args: None) + + grouped_dir = ingest / "Usenet Audiobook" + assert result is not None + assert Path(result).parent == grouped_dir + assert sorted(path.name for path in grouped_dir.glob("*.mp3")) == [ + "Part 1.mp3", + "Part 2.mp3", + ] + + def test_archive_extraction_organize_multifile_can_use_original_name(tmp_path): from shelfmark.download.postprocess.router import ( post_process_download as _post_process_download,