fix(postprocess): attach unmatched chaptered audio files to existing book group (#1176) (#1309)

### 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>
This commit is contained in:
Ama Senevirathne
2026-09-04 11:01:55 -04:00
committed by GitHub
co-authored by amasen02
parent 97d1bb0df4
commit c18da92569
2 changed files with 97 additions and 2 deletions
+21 -2
View File
@@ -414,10 +414,29 @@ def match_plan_to_files(
)
unmatched = [p for p in book_files if p not in claimed]
if unmatched:
still_unmatched: list[Path] = []
for p in unmatched:
p_ext = p.suffix.lower().lstrip(".")
is_chaptered = p_ext in _CHAPTERED_AUDIO_EXTENSIONS
matching_groups = [g for g in groups if any(f.parent == p.parent for f in g.files)]
target_group: BookGroup | None = None
if len(matching_groups) == 1 and is_chaptered:
target_group = matching_groups[0]
elif len(plan) == 1 and len(groups) == 1 and is_chaptered:
target_group = groups[0]
if target_group is not None:
target_group.files.append(p)
claimed.add(p)
else:
still_unmatched.append(p)
if still_unmatched:
groups.extend(
group_files_into_books(
unmatched, series_name=series_name, author_name=author_name, root=root
still_unmatched, series_name=series_name, author_name=author_name, root=root
)
)
return groups
+76
View File
@@ -307,3 +307,79 @@ class TestMatchPlanToFiles:
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}