mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 13:21:13 +01:00
### Summary Fixes #1176 When downloading an audiobook with many chaptered tracks (e.g. 250+ `.flac` or `.mp3` files), indexer XML or release metadata often caps the file list at ~100-110 entries. When the release extracts on disk, `match_plan_to_files()` matched those first ~110 files to the planned book group, while the remaining 140+ files fell into `unmatched` and triggered fallback heuristic grouping. Because heuristic grouping parsed the folder name (`Westwell - Hot & Cold (2023)`) and stripped the series/author prefix, it generated a second book titled `Hot & Cold` containing the remaining tracks, resulting in two split book folders. ### Changes - In `match_plan_to_files()` (`shelfmark/download/postprocess/packs.py`), check `unmatched` files before falling back to heuristic multi-book splitting. - If an unmatched file is chaptered audio (`.flac`, `.mp3`, `.aac`, etc.) and shares the directory with an existing book group, or if the plan was a single-book plan, append it to that group instead of creating a secondary book. - Non-chaptered standalone books (e.g. `.m4b`, `.epub`) or files in separate subfolders continue to fall back to heuristic grouping as before. - Added unit tests in `tests/download/test_packs.py` verifying: 1. Truncated track list in single folder properly appends remaining chaptered tracks without splitting. 2. Single-book plan with multi-disc audio files (`CD1`/`CD2`) groups together cleanly. 3. Multi-book packs with unmatched chaptered tracks route each track to its respective book folder. ### Testing Ran `uv run pytest tests/download/test_packs.py` and `uv run pytest tests/core/test_processing_packs.py` (all passed cleanly). Checked type annotations with `basedpyright` (0 errors) and formatting with `ruff`. Co-authored-by: amasen02 <amasen02@users.noreply.github.com>
386 lines
16 KiB
Python
386 lines
16 KiB
Python
"""Tests for multi-book pack planning (shelfmark.download.postprocess.packs)."""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from shelfmark.download.postprocess.packs import (
|
|
PackBook,
|
|
PackFile,
|
|
group_files_into_books,
|
|
match_plan_to_files,
|
|
parse_pack_book_name,
|
|
plan_pack,
|
|
)
|
|
|
|
AUDIO = {"m4b", "mp3"}
|
|
|
|
|
|
class TestParsePackBookName:
|
|
@pytest.mark.parametrize(
|
|
("name", "expected"),
|
|
[
|
|
("Book 3 - Howling Dark", ("Howling Dark", 3.0, None)),
|
|
("Book 03: Howling Dark", ("Howling Dark", 3.0, None)),
|
|
("03 - Empire of Silence", ("Empire of Silence", 3.0, None)),
|
|
("2.5 - Interlude", ("Interlude", 2.5, None)),
|
|
("[03] Empire of Silence", ("Empire of Silence", 3.0, None)),
|
|
("#3 Empire of Silence", ("Empire of Silence", 3.0, None)),
|
|
("3. Empire of Silence", ("Empire of Silence", 3.0, None)),
|
|
("Empire of Silence", ("Empire of Silence", None, None)),
|
|
("Empire of Silence (2018)", ("Empire of Silence", None, 2018)),
|
|
],
|
|
)
|
|
def test_strips_series_markers(self, name, expected):
|
|
assert parse_pack_book_name(name, series_name=None) == expected
|
|
|
|
def test_strips_leading_series_name_and_trailing_year(self):
|
|
assert parse_pack_book_name(
|
|
"The Expanse 1.0 - Leviathan Wakes (2011)", series_name="The Expanse"
|
|
) == ("Leviathan Wakes", 1.0, 2011)
|
|
|
|
def test_series_name_match_is_case_insensitive(self):
|
|
assert parse_pack_book_name(
|
|
"the expanse 2.5 - Gods of Risk", series_name="The Expanse"
|
|
) == (
|
|
"Gods of Risk",
|
|
2.5,
|
|
None,
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
"name",
|
|
[
|
|
"The Expanse 0.2 - An Expanse Novella - The Churn (2014)",
|
|
"The Expanse 0.2 - The Expanse Novella - The Churn (2014)",
|
|
"The Expanse 0.2 - An Expanse Short Story - The Churn (2014)",
|
|
],
|
|
)
|
|
def test_strips_series_novella_label(self, name):
|
|
assert parse_pack_book_name(name, series_name="The Expanse") == ("The Churn", 0.2, 2014)
|
|
|
|
@pytest.mark.parametrize(
|
|
("name", "expected"),
|
|
[
|
|
("Uncrowned Cradle, Book 7", ("Uncrowned", 7.0, None)),
|
|
("Reaper Cradle, Volume 10", ("Reaper", 10.0, None)),
|
|
("Soulsmith Cradle, Book 2", ("Soulsmith", 2.0, None)),
|
|
("Wintersteel - Cradle Book 8", ("Wintersteel", 8.0, None)),
|
|
("Wintersteel (Cradle, Book 8)", ("Wintersteel", 8.0, None)),
|
|
# A trailing bare number is a chapter/part, never a series position.
|
|
("Unsouled - 02", ("Unsouled - 02", None, None)),
|
|
],
|
|
)
|
|
def test_trailing_series_marker(self, name, expected):
|
|
assert parse_pack_book_name(name, series_name="Cradle") == expected
|
|
|
|
def test_trailing_series_name_only_stripped_with_a_marker(self):
|
|
# "Stories from Cradle" is the title; nothing marks a position, so keep it.
|
|
assert parse_pack_book_name("Threshold: Stories from Cradle", series_name="Cradle") == (
|
|
"Threshold: Stories from Cradle",
|
|
None,
|
|
None,
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
("name", "expected"),
|
|
[
|
|
# AudiobookBay renders "<folder> <file>" as one flat string.
|
|
(
|
|
"Will Wight - Unsouled Cradle, Book 1 Will Wight - Unsouled Cradle, Book 1",
|
|
("Unsouled", 1.0, None),
|
|
),
|
|
(
|
|
"Will Wight - Skysworn Cradle, Book 4 Skysworn Cradle, Book 4",
|
|
("Skysworn", 4.0, None),
|
|
),
|
|
("Will Wight - Bloodline Cradle, Book 9", ("Bloodline", 9.0, None)),
|
|
],
|
|
)
|
|
def test_strips_author_prefix_and_glued_folder_name(self, name, expected):
|
|
assert (
|
|
parse_pack_book_name(name, series_name="Cradle", author_name="Will Wight") == expected
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
("name", "expected"),
|
|
[
|
|
("Gods of Risk 2.5 - Gods of Risk", ("Gods of Risk", 2.5, None)),
|
|
("Cibola Burn 4 - Cibola Burn (2014)", ("Cibola Burn", 4.0, 2014)),
|
|
("cibola burn 4 - Cibola Burn", ("Cibola Burn", 4.0, None)),
|
|
# Different text on each side is a real "Series N - Title" name, not a repeat.
|
|
("Sun Eater 2 - Howling Dark", ("Sun Eater 2 - Howling Dark", None, None)),
|
|
],
|
|
)
|
|
def test_collapses_title_repeated_around_the_position(self, name, expected):
|
|
assert parse_pack_book_name(name, series_name=None) == expected
|
|
|
|
def test_bare_numeric_title_is_left_alone(self):
|
|
assert parse_pack_book_name("1984", series_name=None) == ("1984", None, None)
|
|
|
|
def test_marker_that_would_leave_nothing_is_left_alone(self):
|
|
assert parse_pack_book_name("Book 3", series_name=None) == ("Book 3", None, None)
|
|
|
|
|
|
class TestPlanPack:
|
|
def test_nested_subfolders_become_separate_books(self):
|
|
files = [
|
|
PackFile("Sun Eater/Book 1 - Empire of Silence/Empire of Silence.m4b", 10),
|
|
PackFile("Sun Eater/Book 2 - Howling Dark/Howling Dark.m4b", 20),
|
|
PackFile("Sun Eater/cover.jpg", 1),
|
|
]
|
|
plan = plan_pack(files, supported_extensions=AUDIO, series_name=None)
|
|
assert plan.is_pack
|
|
assert [b.title for b in plan.books] == ["Empire of Silence", "Howling Dark"]
|
|
assert [b.series_position for b in plan.books] == [1.0, 2.0]
|
|
assert plan.books[0].files == ["Sun Eater/Book 1 - Empire of Silence/Empire of Silence.m4b"]
|
|
assert plan.ignored == ["Sun Eater/cover.jpg"]
|
|
|
|
def test_flat_pack_becomes_one_book_per_file_and_ignores_sidecars(self):
|
|
files = [
|
|
PackFile("The Expanse 1.0 - Leviathan Wakes (2011).m4b", 100),
|
|
PackFile("The Expanse 1.0 - Leviathan Wakes (2011).txt", 1),
|
|
PackFile("The Expanse 2.0 - Caliban's War (2012).m4b", 100),
|
|
]
|
|
plan = plan_pack(files, supported_extensions=AUDIO, series_name="The Expanse")
|
|
assert plan.is_pack
|
|
assert [(b.title, b.series_position, b.year) for b in plan.books] == [
|
|
("Leviathan Wakes", 1.0, 2011),
|
|
("Caliban's War", 2.0, 2012),
|
|
]
|
|
assert plan.ignored == ["The Expanse 1.0 - Leviathan Wakes (2011).txt"]
|
|
|
|
def test_flat_chaptered_mp3_tracks_are_one_book_not_a_pack(self):
|
|
# A single audiobook whose chapters are named "01 - <chapter>.mp3" must not be
|
|
# split into one "book" per track just because each name carries a number.
|
|
files = [
|
|
PackFile("The Hobbit/01 - An Unexpected Party.mp3", 10),
|
|
PackFile("The Hobbit/02 - Roast Mutton.mp3", 10),
|
|
PackFile("The Hobbit/03 - A Short Rest.mp3", 10),
|
|
]
|
|
plan = plan_pack(files, supported_extensions=AUDIO, series_name=None)
|
|
assert not plan.is_pack
|
|
assert len(plan.books) == 1
|
|
assert plan.books[0].files == [
|
|
"The Hobbit/01 - An Unexpected Party.mp3",
|
|
"The Hobbit/02 - Roast Mutton.mp3",
|
|
"The Hobbit/03 - A Short Rest.mp3",
|
|
]
|
|
|
|
def test_flat_repeated_title_mp3_tracks_are_one_book(self):
|
|
# Same title on every track ("01 - The Hobbit.mp3") is a chaptered book too.
|
|
files = [
|
|
PackFile("01 - The Hobbit.mp3", 10),
|
|
PackFile("02 - The Hobbit.mp3", 10),
|
|
]
|
|
plan = plan_pack(files, supported_extensions=AUDIO, series_name=None)
|
|
assert not plan.is_pack
|
|
assert len(plan.books) == 1
|
|
|
|
def test_audiobookbay_flat_list_with_trailing_markers_is_a_pack(self):
|
|
# ABB's file table has no folder separators: "<folder> <file> <size>".
|
|
files = [
|
|
PackFile(
|
|
"Will Wight - Unsouled Cradle, Book 1 Will Wight - Unsouled Cradle, Book 1.sfv", 1
|
|
),
|
|
PackFile(
|
|
"Will Wight - Unsouled Cradle, Book 1 Will Wight - Unsouled Cradle, Book 1.m4a", 9
|
|
),
|
|
PackFile("Will Wight - Skysworn Cradle, Book 4 Skysworn Cradle, Book 4.m4b", 9),
|
|
PackFile("Uncrowned Cradle, Book 7.m4b", 9),
|
|
PackFile(
|
|
"Will Wight - Reaper Cradle, Volume 10 Will Wight - Reaper Cradle, Volume 10.m4b", 9
|
|
),
|
|
]
|
|
plan = plan_pack(
|
|
files,
|
|
supported_extensions={"m4a", "m4b"},
|
|
series_name="Cradle",
|
|
author_name="Will Wight",
|
|
)
|
|
assert plan.is_pack
|
|
assert [(b.title, b.series_position) for b in plan.books] == [
|
|
("Unsouled", 1.0),
|
|
("Skysworn", 4.0),
|
|
("Uncrowned", 7.0),
|
|
("Reaper", 10.0),
|
|
]
|
|
|
|
def test_deeper_nesting_collapses_onto_book_folder(self):
|
|
files = [
|
|
PackFile("Book 1/CD1/01.mp3"),
|
|
PackFile("Book 1/CD2/01.mp3"),
|
|
PackFile("Book 2/01.mp3"),
|
|
]
|
|
plan = plan_pack(files, supported_extensions=AUDIO, series_name=None)
|
|
assert [b.files for b in plan.books] == [
|
|
["Book 1/CD1/01.mp3", "Book 1/CD2/01.mp3"],
|
|
["Book 2/01.mp3"],
|
|
]
|
|
|
|
def test_single_wrapping_folder_is_not_a_book_boundary(self):
|
|
# A torrent named "Series" containing one multi-part book is a single book.
|
|
files = [PackFile("Series/Book 1/01.mp3"), PackFile("Series/Book 1/02.mp3")]
|
|
plan = plan_pack(files, supported_extensions=AUDIO, series_name=None)
|
|
assert not plan.is_pack
|
|
assert len(plan.books) == 1
|
|
|
|
def test_root_files_and_subfolders_coexist(self):
|
|
files = [PackFile("Novella.m4b"), PackFile("Book 1/a.m4b"), PackFile("Book 1/b.m4b")]
|
|
plan = plan_pack(files, supported_extensions=AUDIO, series_name=None)
|
|
assert [b.files for b in plan.books] == [["Novella.m4b"], ["Book 1/a.m4b", "Book 1/b.m4b"]]
|
|
|
|
def test_single_file_is_not_a_pack(self):
|
|
plan = plan_pack([PackFile("Book.m4b")], supported_extensions=AUDIO, series_name=None)
|
|
assert not plan.is_pack
|
|
assert plan.books[0].title == "Book"
|
|
|
|
def test_empty_input(self):
|
|
plan = plan_pack([], supported_extensions=AUDIO, series_name=None)
|
|
assert plan.books == []
|
|
assert not plan.is_pack
|
|
|
|
|
|
class TestGroupFilesIntoBooks:
|
|
def test_groups_on_disk_files_by_top_level_folder(self, tmp_path: Path):
|
|
a = tmp_path / "Book 1 - A" / "a.m4b"
|
|
b = tmp_path / "Book 2 - B" / "b.m4b"
|
|
for f in (a, b):
|
|
f.parent.mkdir(parents=True)
|
|
f.write_bytes(b"x")
|
|
groups = group_files_into_books([a, b], series_name=None)
|
|
assert [(g.title, g.series_position, g.files) for g in groups] == [
|
|
("A", 1.0, [a]),
|
|
("B", 2.0, [b]),
|
|
]
|
|
|
|
|
|
class TestMatchPlanToFiles:
|
|
def test_matches_by_relative_path_then_basename(self, tmp_path: Path):
|
|
root = tmp_path / "staging" / "Sun Eater"
|
|
a = root / "Book 1 - A" / "a.m4b"
|
|
b = root / "Book 2 - B" / "b.m4b"
|
|
for f in (a, b):
|
|
f.parent.mkdir(parents=True)
|
|
f.write_bytes(b"x")
|
|
plan = [
|
|
PackBook(title="Alpha", series_position=1.0, year=2001, files=["Book 1 - A/a.m4b"]),
|
|
PackBook(title="Beta", series_position=2.0, year=None, files=["b.m4b"]),
|
|
]
|
|
groups = match_plan_to_files(plan, [a, b])
|
|
assert [(g.title, g.series_position, g.year, g.files) for g in groups] == [
|
|
("Alpha", 1.0, 2001, [a]),
|
|
("Beta", 2.0, None, [b]),
|
|
]
|
|
|
|
def test_matches_glued_plan_path_by_basename_suffix(self, tmp_path: Path):
|
|
# The plan came from ABB's "<folder> <file>" strings; on disk the file sits in a folder.
|
|
root = tmp_path / "Cradle - Will Wight Books 1-10"
|
|
a = root / "Will Wight - Skysworn Cradle, Book 4" / "Skysworn Cradle, Book 4.m4b"
|
|
b = root / "Uncrowned Cradle, Book 7.m4b"
|
|
for f in (a, b):
|
|
f.parent.mkdir(parents=True, exist_ok=True)
|
|
f.write_bytes(b"x")
|
|
plan = [
|
|
PackBook(
|
|
title="Skysworn",
|
|
series_position=4.0,
|
|
year=None,
|
|
files=["Will Wight - Skysworn Cradle, Book 4 Skysworn Cradle, Book 4.m4b"],
|
|
),
|
|
PackBook(
|
|
title="Uncrowned",
|
|
series_position=7.0,
|
|
year=None,
|
|
files=["Uncrowned Cradle, Book 7.m4b"],
|
|
),
|
|
]
|
|
groups = match_plan_to_files(plan, [a, b])
|
|
assert [(g.title, g.files) for g in groups] == [("Skysworn", [a]), ("Uncrowned", [b])]
|
|
|
|
def test_unmatched_files_fall_back_to_heuristic_groups(self, tmp_path: Path):
|
|
a = tmp_path / "Book 1 - A" / "a.m4b"
|
|
c = tmp_path / "Book 3 - C" / "c.m4b"
|
|
for f in (a, c):
|
|
f.parent.mkdir(parents=True)
|
|
f.write_bytes(b"x")
|
|
plan = [PackBook(title="Alpha", series_position=1.0, year=None, files=["Book 1 - A/a.m4b"])]
|
|
groups = match_plan_to_files(plan, [a, c])
|
|
assert [(g.title, g.files) for g in groups] == [("Alpha", [a]), ("C", [c])]
|
|
|
|
def test_unmatched_chaptered_audio_files_share_group_in_same_folder(self, tmp_path: Path):
|
|
# Issue #1176: Indexer/metadata lists only a subset of tracks, remaining tracks in
|
|
# the same folder must append to the existing book group rather than splitting.
|
|
root = tmp_path / "Westwell - Hot & Cold (2023)"
|
|
root.mkdir(parents=True)
|
|
files = [root / f"track_{i:03d}.flac" for i in range(1, 6)]
|
|
for f in files:
|
|
f.write_bytes(b"x")
|
|
|
|
plan = [
|
|
PackBook(
|
|
title="Westwell - Hot & Cold (2023)",
|
|
series_position=None,
|
|
year=2023,
|
|
files=[
|
|
f"Westwell - Hot & Cold (2023)/{files[0].name}",
|
|
f"Westwell - Hot & Cold (2023)/{files[1].name}",
|
|
],
|
|
)
|
|
]
|
|
groups = match_plan_to_files(plan, files)
|
|
assert len(groups) == 1
|
|
assert groups[0].title == "Westwell - Hot & Cold (2023)"
|
|
assert groups[0].files == files
|
|
|
|
def test_single_book_plan_chaptered_audio_multi_disc_grouped_together(self, tmp_path: Path):
|
|
# Single-book plan with multi-disc audio files: unmatched CD2 tracks belong to the book
|
|
root = tmp_path / "Audiobook"
|
|
cd1_file = root / "CD1" / "01.mp3"
|
|
cd2_file = root / "CD2" / "01.mp3"
|
|
for f in (cd1_file, cd2_file):
|
|
f.parent.mkdir(parents=True)
|
|
f.write_bytes(b"x")
|
|
|
|
plan = [
|
|
PackBook(
|
|
title="Audiobook",
|
|
series_position=1.0,
|
|
year=None,
|
|
files=["Audiobook/CD1/01.mp3"],
|
|
)
|
|
]
|
|
groups = match_plan_to_files(plan, [cd1_file, cd2_file])
|
|
assert len(groups) == 1
|
|
assert groups[0].title == "Audiobook"
|
|
assert cd1_file in groups[0].files
|
|
assert cd2_file in groups[0].files
|
|
|
|
def test_multi_book_pack_unmatched_chaptered_audio_attaches_to_corresponding_book(
|
|
self, tmp_path: Path
|
|
):
|
|
# In a multi-book pack, unmatched tracks in Book 1's folder stay in Book 1
|
|
root = tmp_path / "Series Pack"
|
|
b1_t1 = root / "Book 1" / "01.mp3"
|
|
b1_t2 = root / "Book 1" / "02.mp3"
|
|
b2_t1 = root / "Book 2" / "01.mp3"
|
|
b2_t2 = root / "Book 2" / "02.mp3"
|
|
for f in (b1_t1, b1_t2, b2_t1, b2_t2):
|
|
f.parent.mkdir(parents=True, exist_ok=True)
|
|
f.write_bytes(b"x")
|
|
|
|
plan = [
|
|
PackBook(
|
|
title="Book 1", series_position=1.0, year=None, files=["Series Pack/Book 1/01.mp3"]
|
|
),
|
|
PackBook(
|
|
title="Book 2", series_position=2.0, year=None, files=["Series Pack/Book 2/01.mp3"]
|
|
),
|
|
]
|
|
groups = match_plan_to_files(plan, [b1_t1, b1_t2, b2_t1, b2_t2])
|
|
assert len(groups) == 2
|
|
assert groups[0].title == "Book 1"
|
|
assert set(groups[0].files) == {b1_t1, b1_t2}
|
|
assert groups[1].title == "Book 2"
|
|
assert set(groups[1].files) == {b2_t1, b2_t2}
|