mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 19:40:26 +01:00
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:
co-authored by
Matt Van Horn
CaliBrain
parent
e7007865a4
commit
f4421ff189
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 ====================
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user