Files
shelfmark/tests/core/test_processing_packs.py
T
amura11andClaude Sonnet 5.5 51f32b374d feat(packs): let users choose which files, books and formats to download (#1480)
## Summary
- **Skip files or books in a multi-book pack.** The pack review panel
gets a switch per file and a "Skip this book" switch per book, using the
shared `ToggleSwitch`. A partly skipped book shows e.g. "3 of 5 files"
in the Files column. A fully skipped book is greyed out.
- **Choose formats for a single book.** A new "Choose formats" header
toggle, next to the multi-book one, opens a format picker for a release
that has several book formats (EPUB, MOBI, PDF and so on). It has one
switch per format with file count and size. Releases with one format, or
that can't be inspected, download as before.
- **Skipped files are sent as `ignored_files`.** They are stored on the
task (including restart-safe retry) and removed before grouping and
transfer. This matters for packs because files missing from `book_plan`
would otherwise be filed by the heuristic fallback. Ignored entries
match by release-relative path, then path suffix, then unique basename.
- A pack that ends up with one book after skips keeps the titles you
reviewed instead of falling back to the searched title.

## Screenshots
<img width="1277" height="413" alt="Screenshot 2026-10-19 at 09-39-28
Shelfmark"
src="https://github.com/user-attachments/assets/c005ca4b-3dac-45dd-b2ff-d02fc0f9fcc9"
/>
<img width="1277" height="1210" alt="Screenshot 2026-10-19 at 09-40-23
Shelfmark"
src="https://github.com/user-attachments/assets/2145f3ba-e5fc-4635-9364-1790feb0c8b8"
/>
<img width="1277" height="748" alt="Screenshot 2026-10-19 at 09-39-03
Shelfmark"
src="https://github.com/user-attachments/assets/39023fac-70e3-46d8-af89-1160ebd6e415"
/>


## Test plan
- [x] `pytest` pack tests (new `drop_ignored_files` cases added)
- [x] `vitest`, `tsc`, `oxlint`, `oxfmt`, `ruff`
- [x] Manual check in the UI with a real multi-book pack

🤖 (mostly) Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 02:03:05 -04:00

258 lines
8.6 KiB
Python

"""Multi-book packs are filed one book at a time through the normal pipeline."""
import os
from pathlib import Path
from threading import Event
from unittest.mock import patch
from shelfmark.core.models import DownloadTask, SearchMode
from tests.core.test_processing_integration import _build_config, _sync_config
def _run(temp_path: Path, task: DownloadTask, ingest: Path, staging: Path, **config_kwargs):
from shelfmark.download.postprocess.router import post_process_download
statuses: list[tuple[str, str | None]] = []
with (
patch("shelfmark.core.config.config") as mock_config,
patch("shelfmark.config.env.TMP_DIR", staging),
):
mock_config.get = _build_config(
ingest,
organization=config_kwargs.pop("organization", "organize"),
supported_audiobook_formats=["m4b", "mp3"],
audiobook_organize_template=config_kwargs.pop(
"audiobook_organize_template", "{Author}/{Title}/{Title}{ - PartNumber}"
),
**config_kwargs,
)
mock_config.CUSTOM_SCRIPT = None
_sync_config(mock_config, mock_config)
result = post_process_download(
temp_path, task, Event(), lambda s, m=None: statuses.append((s, m))
)
return result, statuses
def _nested_pack(root: Path) -> Path:
pack = root / "Sun Eater"
for folder, name in (
("Book 1 - Empire of Silence", "empire.m4b"),
("Book 2 - Howling Dark", "howling.m4b"),
):
(pack / folder).mkdir(parents=True)
(pack / folder / name).write_text(name)
(pack / "cover.jpg").write_text("img")
return pack
def _audiobook_task(**overrides) -> DownloadTask:
fields = {
"task_id": "pack-1",
"source": "direct_download",
"title": "Drive",
"author": "James S. A. Corey",
"content_type": "audiobook",
"series_name": "The Expanse",
"series_position": 2.6,
"search_mode": SearchMode.UNIVERSAL,
}
fields.update(overrides)
return DownloadTask(**fields)
def test_approved_plan_files_each_book_with_its_own_title(tmp_path):
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
staging.mkdir()
ingest.mkdir()
pack = staging / "Expanse"
pack.mkdir()
for name in (
"The Expanse 1.0 - Leviathan Wakes (2011).m4b",
"The Expanse 2.0 - Caliban's War (2012).m4b",
):
(pack / name).write_text(name)
(pack / "The Expanse 1.0 - Leviathan Wakes (2011).txt").write_text("notes")
task = _audiobook_task(
title="Sun Eater", # the searched book; must not name the pack's books
book_plan=[
{
"title": "Leviathan Wakes (edited)",
"series_position": 1.0,
"year": 2011,
"files": ["The Expanse 1.0 - Leviathan Wakes (2011).m4b"],
},
{
"title": "Caliban's War",
"series_position": 2.0,
"year": 2012,
"files": ["The Expanse 2.0 - Caliban's War (2012).m4b"],
},
],
)
result, statuses = _run(pack, task, ingest, staging)
assert result is not None
author_dir = ingest / "James S. A. Corey"
assert sorted(p.name for p in author_dir.iterdir()) == [
"Caliban's War",
"Leviathan Wakes (edited)",
]
assert (author_dir / "Leviathan Wakes (edited)" / "Leviathan Wakes (edited).m4b").exists()
assert (author_dir / "Caliban's War" / "Caliban's War.m4b").exists()
assert statuses[-1] == ("complete", "Complete (2 books, 2 files)")
def test_multi_book_flag_splits_nested_pack_heuristically(tmp_path):
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
staging.mkdir()
ingest.mkdir()
pack = _nested_pack(staging)
result, _ = _run(pack, _audiobook_task(multi_book=True), ingest, staging)
assert result is not None
author_dir = ingest / "James S. A. Corey"
assert (author_dir / "Empire of Silence" / "Empire of Silence.m4b").exists()
assert (author_dir / "Howling Dark" / "Howling Dark.m4b").exists()
def test_pack_book_series_position_does_not_leak_from_searched_book(tmp_path):
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
staging.mkdir()
ingest.mkdir()
pack = staging / "Two"
for folder in ("Alpha", "Beta"):
(pack / folder).mkdir(parents=True)
(pack / folder / f"{folder.lower()}.m4b").write_text(folder)
result, _ = _run(
pack,
_audiobook_task(multi_book=True),
ingest,
staging,
audiobook_organize_template="{Author}/{SeriesPosition - }{Title}/{Title}",
)
assert result is not None
assert sorted(p.name for p in (ingest / "James S. A. Corey").iterdir()) == ["Alpha", "Beta"]
def test_multifile_book_inside_pack_keeps_part_numbers_per_book(tmp_path):
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
staging.mkdir()
ingest.mkdir()
pack = staging / "Pack"
(pack / "Book 1 - One").mkdir(parents=True)
(pack / "Book 2 - Two").mkdir(parents=True)
for i in (1, 2, 3):
(pack / "Book 1 - One" / f"part{i}.mp3").write_text(str(i))
(pack / "Book 2 - Two" / "two.mp3").write_text("t")
result, statuses = _run(pack, _audiobook_task(multi_book=True), ingest, staging)
assert result is not None
one = ingest / "James S. A. Corey" / "One"
assert sorted(p.name for p in one.iterdir()) == ["One - 01.mp3", "One - 02.mp3", "One - 03.mp3"]
assert (ingest / "James S. A. Corey" / "Two" / "Two.mp3").exists()
assert statuses[-1] == ("complete", "Complete (2 books, 4 files)")
def test_hardlinked_torrent_pack_leaves_source_tree_intact(tmp_path):
downloads = tmp_path / "downloads"
ingest = tmp_path / "ingest"
downloads.mkdir()
ingest.mkdir()
pack = _nested_pack(downloads)
task = _audiobook_task(source="prowlarr", multi_book=True, original_download_path=str(pack))
result, _ = _run(pack, task, ingest, tmp_path / "staging", hardlink=True)
assert result is not None
empire_src = pack / "Book 1 - Empire of Silence" / "empire.m4b"
empire_dst = ingest / "James S. A. Corey" / "Empire of Silence" / "Empire of Silence.m4b"
assert empire_src.exists()
assert empire_dst.exists()
assert os.stat(empire_src).st_ino == os.stat(empire_dst).st_ino
def test_without_pack_fields_nested_pack_is_still_one_book(tmp_path):
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
staging.mkdir()
ingest.mkdir()
pack = _nested_pack(staging)
result, _ = _run(pack, _audiobook_task(), ingest, staging)
assert result is not None
drive = ingest / "James S. A. Corey" / "Drive"
assert sorted(p.name for p in drive.iterdir()) == ["Drive - 01.m4b", "Drive - 02.m4b"]
def test_single_group_with_multi_book_flag_uses_searched_title(tmp_path):
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
staging.mkdir()
ingest.mkdir()
pack = staging / "Series" / "Book 1 - Solo"
pack.mkdir(parents=True)
(pack / "solo.m4b").write_text("s")
result, statuses = _run(pack.parent, _audiobook_task(multi_book=True), ingest, staging)
assert result is not None
assert (ingest / "James S. A. Corey" / "Drive" / "Drive.m4b").exists()
assert statuses[-1] == ("complete", "Complete")
def test_ignored_book_is_not_filed_even_though_the_plan_omits_it(tmp_path):
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
staging.mkdir()
ingest.mkdir()
pack = _nested_pack(tmp_path)
task = _audiobook_task(
book_plan=[
{
"title": "Empire of Silence",
"series_position": 1.0,
"year": None,
"files": ["Book 1 - Empire of Silence/empire.m4b"],
}
],
ignored_files=["Book 2 - Howling Dark/howling.m4b"],
)
result, _statuses = _run(pack, task, ingest, staging)
assert result is not None
filed = sorted(p.name for p in ingest.rglob("*.m4b"))
assert filed == ["Empire of Silence.m4b"]
def test_ignored_file_is_skipped_from_a_single_remaining_book(tmp_path):
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
staging.mkdir()
ingest.mkdir()
pack = _nested_pack(tmp_path)
task = _audiobook_task(
multi_book=True,
ignored_files=["Book 2 - Howling Dark/howling.m4b"],
)
result, _statuses = _run(pack, task, ingest, staging)
assert result is not None
assert [p.name for p in ingest.rglob("*.m4b")] != []
assert not any("howling" in p.name.lower() for p in ingest.rglob("*"))