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 `<destination>/<original
torrent directory>/` 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 <calibrain@l4n.xyz>
This commit is contained in:
Matt Van Horn
2026-08-21 09:44:08 -04:00
committed by GitHub
co-authored by Matt Van Horn CaliBrain
parent e7007865a4
commit f4421ff189
9 changed files with 262 additions and 9 deletions
+13 -2
View File
@@ -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
+1
View File
@@ -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:
+1 -1
View File
@@ -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:
+15 -1
View File
@@ -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,
+26
View File
@@ -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
+3 -2
View File
@@ -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}",
},
)
@@ -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
+112 -3
View File
@@ -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 ====================
+77
View File
@@ -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,