fix(packs): don't disrupt normal downloads when inspecting for packs (#1274)

Follow-ups to the multi-book pack feature (#1270), which inspects every
release before download. Two behaviours leaked into the ordinary
single-book
flow and are corrected here:

- A flat folder of chaptered audio (`01 - Chapter.mp3`, `02 - ...`) was
detected as a pack, because each track name parses to a series position,
so
clicking download popped the review panel for one normal audiobook. Flat
folders are now split one-book-per-file only with real evidence of
distinct
books: two or more series positions, more than one title, and no
chaptered
audio (only the single-file m4b/m4a containers and ebook formats
qualify).
  Subfolder packs and flat m4b/m4a packs are unchanged.

- Every release that couldn't be inspected (usenet, magnet-only, sources
  without a list_files hook, ABB single-file) showed an info toast on
download. That is now a console.warn, so a normal download is silent
again.

Adds regression tests for the chaptered-mp3 cases.
This commit is contained in:
CaliBrain
2026-08-27 01:00:54 -04:00
committed by GitHub
parent 7569aaecc5
commit b25acdb2ad
3 changed files with 54 additions and 5 deletions
+18 -1
View File
@@ -14,6 +14,16 @@ import re
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from shelfmark.core.utils import AUDIOBOOK_FORMATS
# m4b/m4a hold a whole audiobook in one file; every other audio format (mp3, flac, ...) is
# chaptered - many files make up one book. Ebook formats are always one file per book, so
# only chaptered *audio* matters here. A flat folder is split one-book-per-file only when
# none of its files are chaptered audio: a bare list of `01 - Chapter.mp3` tracks is a
# single chaptered audiobook, not a pack of books.
_SINGLE_FILE_AUDIO_CONTAINERS = frozenset({"m4b", "m4a"})
_CHAPTERED_AUDIO_EXTENSIONS = frozenset(AUDIOBOOK_FORMATS) - _SINGLE_FILE_AUDIO_CONTAINERS
_YEAR_SUFFIX_RE = re.compile(r"\s*\(\s*(?P<year>\d{4})\s*\)\s*$")
_SERIES_MARKER_RE = re.compile(
r"""
@@ -276,7 +286,14 @@ def plan_pack(
for f in root_files
]
positions = {p[1] for p in parsed if p[1] is not None}
if len(positions) >= 2:
titles = {p[0].strip().lower() for p in parsed if p[0]}
one_book_per_file = all(
rel.suffix.lower().lstrip(".") not in _CHAPTERED_AUDIO_EXTENSIONS for rel in root_files
)
# Split a flat folder into a book per file only with real evidence of distinct
# books: two or more series positions, more than one title, and no chaptered audio
# (a bare list of `01 - Chapter.mp3` tracks is one book, not a pack).
if len(positions) >= 2 and len(titles) >= 2 and one_book_per_file:
books.extend(
PackBook(title=title, series_position=position, year=year, files=[str(f)])
for f, (title, position, year) in zip(root_files, parsed, strict=True)
+9 -4
View File
@@ -1220,12 +1220,14 @@ const ReleaseModalSession = ({
// reviewed and filed as separate books instead of one mangled item.
let inspected = false;
let plan: PackPlan | null = null;
let reason: string | null = null;
try {
const inspection = await inspectRelease(
buildReleaseDownloadPayload(book, release, contentType),
);
inspected = inspection.inspected;
plan = inspection.plan;
reason = inspection.reason;
} catch (error) {
console.error('Release inspection failed:', error);
}
@@ -1233,10 +1235,14 @@ const ReleaseModalSession = ({
setPackReview({ release, plan, books: plan.books });
return;
}
// Not a pack (or couldn't be inspected): queue exactly as before. A release we
// couldn't inspect might still be an unnoticed pack, so leave a console breadcrumb
// rather than interrupting the user; the multi-book toggle forces the split.
if (!inspected && !multiBook) {
onShowToast?.(
"Couldn't check this release's files before download. If it contains several books, turn on the multi-book pack toggle first.",
'info',
console.warn(
`Could not inspect release "${release.title}" before download${
reason ? `: ${reason}` : ''
}. If it contains several books, enable the multi-book pack toggle.`,
);
}
await onDownload(book, release, contentType, multiBook ? { multiBook: true } : {});
@@ -1259,7 +1265,6 @@ const ReleaseModalSession = ({
contentType,
handleClose,
multiBook,
onShowToast,
],
);
+27
View File
@@ -150,6 +150,33 @@ class TestPlanPack:
]
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 = [