From e0bf13eefcb5a186cbcebd985a49a6a34d0cd8f4 Mon Sep 17 00:00:00 2001 From: bndct-devops Date: Sun, 5 Jul 2026 21:02:11 +0200 Subject: [PATCH] fix(bindery): stop deriving a book's series from its own title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three compounding defects made Bindery imports come out with series = the book's own title (the "Good Guys" incident): - The bare-trailing-number heuristic ("Dukes and Ladders 5.epub") fabricated series=title and typed novels as chapters. It now only applies to comic formats; prose ebooks keep the index but no series. - The real series name in "(Series Book N)" parentheticals — standard Amazon/Calibre naming — was deleted as noise before parsing. It is now parsed first ("(The Bad Guys Book 8)", "(Cradle, Book 1)", "(Series #7)"). - The review UI extracted embedded metadata (calibre:series etc.) on preview and then discarded it; only the filename guess reached the form. file_metadata now merges into the form before the candidate auto-apply, with the same file-beats-filename precedence auto-import has always had (single review + Match All). The parser also understands the organizer's "NN. Title - Author (Year)" layout, so the review form now pre-fills the author (and auto-import uses it as fallback when nothing is embedded). Verified end-to-end via fixture EPUBs through the review flow: the original incident shape now lands as series="The Good Guys" idx 5. 859 tests green (13 new parser regressions incl. the literal prod filename), frontend build clean. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 14 +++ backend/api/bindery.py | 2 + backend/main.py | 2 +- backend/services/filename_parser.py | 131 +++++++++++++++++++++------- frontend/src/pages/BinderyPage.tsx | 56 ++++++++++-- tests/test_filename_parser.py | 100 +++++++++++++++++++++ 6 files changed, 268 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 618b2ce..0238494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ All notable changes to Tome are documented here. Format loosely follows ## [Unreleased] +### Fixed +- **Bindery no longer invents a series from the filename.** Prose ebooks named + `Title 5.epub` used to come out of review with their series set to their own + title (the bare-number manga-chapter heuristic misfiring on novels); the + real series name in `(Series Book N)` parentheticals — the common + Amazon/Calibre naming — was stripped as noise; and the metadata embedded in + the file itself (`calibre:series` etc.) was extracted but never reached the + review form. All three are fixed: the filename parser now understands + `(Series Book N)` / `(Series #N)` markers and the `NN. Title - Author (Year)` + layout (filling the author field too), bare trailing numbers on EPUB/PDF no + longer fabricate a series, and the review form pre-fills from embedded + metadata with the same "file beats filename" precedence auto-import has + always used. + ## [1.8.0] — 2026-07-05 — "Spine" ### Added diff --git a/backend/api/bindery.py b/backend/api/bindery.py index c965748..6d60345 100644 --- a/backend/api/bindery.py +++ b/backend/api/bindery.py @@ -106,6 +106,7 @@ class BinderyItem(BaseModel): series: str | None series_index: float | None title: str + author: str | None folder: str | None @@ -208,6 +209,7 @@ def bindery_list( series=parsed.series, series_index=parsed.series_index, title=parsed.title, + author=parsed.author, folder=folder, ) ) diff --git a/backend/main.py b/backend/main.py index 5aaf2f6..cce92d2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -428,7 +428,7 @@ def _ingest_file(file_path: Path) -> tuple[Book, str, str | None] | None: meta_series_index = meta.get("series_index") book = Book( title=meta.get("title") or parsed.title or dest.stem, - author=meta.get("author"), + author=meta.get("author") or parsed.author, series=meta.get("series") or parsed.series, series_index=meta_series_index if meta_series_index is not None else parsed.series_index, isbn=meta.get("isbn"), diff --git a/backend/services/filename_parser.py b/backend/services/filename_parser.py index 813a07d..22a5ae4 100644 --- a/backend/services/filename_parser.py +++ b/backend/services/filename_parser.py @@ -1,8 +1,8 @@ """ filename_parser.py — Parse book filenames to extract series metadata. -Detects content_type (chapter vs volume), series name, and series_index -from common ebook/manga/comic filename conventions. +Detects content_type (chapter vs volume), series name, series_index, and +author from common ebook/manga/comic filename conventions. """ from __future__ import annotations @@ -21,6 +21,7 @@ class ParsedFilename: series: str | None # detected series name (e.g. "War and Peace") series_index: float | None # detected number (e.g. 1179.0 or 18.0) content_type: str # "chapter" or "volume" + author: str | None = None # detected author (e.g. "Eric Ugland") # --------------------------------------------------------------------------- @@ -30,12 +31,35 @@ class ParsedFilename: # File extensions to strip _RE_EXT = re.compile(r"\.(cbz|cbr|epub|pdf|mobi|azw3|zip)$", re.IGNORECASE) +# Extensions where a bare trailing number does NOT imply a manga chapter — +# for prose ebooks "Title 5" is almost always volume 5 of something, and the +# something is NOT the title, so fabricating series=title is wrong. +_EBOOK_EXTS = {"epub", "pdf", "mobi", "azw3"} + # Parenthesised segments: (Digital), (2026), (1r0n), etc. _RE_PAREN = re.compile(r"\([^)]*\)") # Bracketed tags: [CBZ], [1r0n], etc. _RE_BRACKET = re.compile(r"\[[^\]]*\]") +# Series parenthetical, the Amazon/Calibre convention: +# "(The Bad Guys Book 8)", "(Cradle, Book 3)", "(Wandering Inn #7)" +_RE_PAREN_SERIES = re.compile( + r"\(\s*([^()]+?)\s*,?\s+(?:book|bk\.?|no\.?|#)\s*(\d+(?:\.\d+)?)\s*\)", + re.IGNORECASE, +) + +# Import-script layout: "NN. Title - Author (Year)" / "Title - Author (Year)" +_RE_TITLE_AUTHOR_YEAR = re.compile( + r"^(?:(\d{1,3})\.\s+)?(.+?)\s+-\s+([^\-()\[\]]+?)\s*\(\d{4}\)\s*$" +) + +# Same layout without the year — only trusted with the leading index, which +# fixes the orientation (title first, author last). +_RE_INDEX_TITLE_AUTHOR = re.compile( + r"^(\d{1,3})\.\s+(.+?)\s+-\s+([^\-()\[\]]+?)\s*$" +) + # Chapter indicators: Chapter 1134, Ch.230, Ch 230 _RE_CHAPTER_KEYWORD = re.compile( r"\b(?:chapter|ch)\.?\s*(\d+(?:\.\d+)?)\b", @@ -62,6 +86,11 @@ def _strip_extension(filename: str) -> str: return _RE_EXT.sub("", filename).strip() +def _extension(filename: str) -> str | None: + m = _RE_EXT.search(filename) + return m.group(1).lower() if m else None + + def _strip_noise(text: str) -> str: """Remove parenthesised metadata and bracketed tags.""" text = _RE_PAREN.sub(" ", text) @@ -95,50 +124,75 @@ def parse_filename(filename: str, in_chapters_dir: bool = False) -> ParsedFilena chapters/ subfolder). Returns: - ParsedFilename with title, series, series_index, and content_type. + ParsedFilename with title, series, series_index, content_type, author. """ + ext = _extension(filename) + # 1. Strip extension work = _strip_extension(filename) + author: str | None = None + series: str | None = None + series_index: float | None = None + + # 2. Series parenthetical — must run BEFORE noise-stripping, which would + # otherwise destroy the one place the real series name lives. + ps = _RE_PAREN_SERIES.search(work) + if ps: + series = _clean_series(ps.group(1)) + series_index = _normalise_number(ps.group(2)) + work = (work[: ps.start()] + " " + work[ps.end():]).strip() + + # 3. Structured "NN. Title - Author (Year)" layout (the organizer/import + # convention). The trailing (Year) or leading index anchors which side is + # the author. + sm = _RE_TITLE_AUTHOR_YEAR.match(work) or _RE_INDEX_TITLE_AUTHOR.match(work) + if sm: + leading_idx, title_part, author_part = sm.group(1), sm.group(2), sm.group(3) + author = author_part.strip() or None + work = title_part + if series_index is None and leading_idx is not None: + series_index = _normalise_number(leading_idx) + # Keep a "display title" from the stripped (but not noise-cleaned) name # so we can build a reasonable title string later. display_base = work - # 2 & 3. Strip parenthesised metadata and bracketed tags + # 4. Strip parenthesised metadata and bracketed tags work = _strip_noise(work) - # 4. Chapter detection (must come BEFORE volume) + # 5. Chapter detection (must come BEFORE volume) chapter_match = _RE_CHAPTER_KEYWORD.search(work) if chapter_match: num = _normalise_number(chapter_match.group(1)) - series_raw = work[: chapter_match.start()] - series = _clean_series(series_raw) + detected = _clean_series(work[: chapter_match.start()]) + series = series or detected # Build a clean title: series + "Chapter N" title_parts = [] - if series: - title_parts.append(series) + if detected: + title_parts.append(detected) title_parts.append(f"Chapter {_fmt_number(num)}") title = " ".join(title_parts) - content_type = "chapter" if not in_chapters_dir else "chapter" return ParsedFilename( title=title, series=series, - series_index=num, - content_type=content_type, + series_index=series_index if series_index is not None else num, + content_type="chapter", + author=author, ) - # 5. Volume detection + # 6. Volume detection volume_match = _RE_VOLUME.search(work) if volume_match: num = _normalise_number(volume_match.group(1)) - series_raw = work[: volume_match.start()] - series = _clean_series(series_raw) + detected = _clean_series(work[: volume_match.start()]) + series = series or detected title_parts = [] - if series: - title_parts.append(series) + if detected: + title_parts.append(detected) title_parts.append(f"v{_fmt_number(num)}") title = " ".join(title_parts) @@ -146,38 +200,55 @@ def parse_filename(filename: str, in_chapters_dir: bool = False) -> ParsedFilena return ParsedFilename( title=title, series=series, - series_index=num, + series_index=series_index if series_index is not None else num, content_type=content_type, + author=author, ) - # 6. Bare trailing number → chapter + # 7. Bare trailing number. For comics this is the standard chapter + # convention ("One Piece 1134.cbz"). For prose ebooks it is NOT a series + # signal — "Dukes and Ladders 5.epub" is volume 5 of some series the + # filename doesn't name, and deriving series=title poisons real metadata + # downstream (the original Bindery series bug). bare_match = _RE_BARE_NUMBER.match(work) if bare_match: - series_raw = bare_match.group(1) num = _normalise_number(bare_match.group(2)) - series = _clean_series(series_raw) + + if ext in _EBOOK_EXTS: + content_type = "chapter" if in_chapters_dir else "volume" + return ParsedFilename( + title=work, + series=series, + series_index=series_index if series_index is not None else num, + content_type=content_type, + author=author, + ) + + detected = _clean_series(bare_match.group(1)) + series = series or detected title_parts = [] - if series: - title_parts.append(series) + if detected: + title_parts.append(detected) title_parts.append(_fmt_number(num)) title = " ".join(title_parts) - content_type = "chapter" return ParsedFilename( title=title, series=series, - series_index=num, - content_type=content_type, + series_index=series_index if series_index is not None else num, + content_type="chapter", + author=author, ) - # 7. Fallback: no number detected → volume, series=None + # 8. Fallback: no number detected → volume content_type = "chapter" if in_chapters_dir else "volume" return ParsedFilename( - title=work, - series=None, - series_index=None, + title=work or display_base, + series=series, + series_index=series_index, content_type=content_type, + author=author, ) diff --git a/frontend/src/pages/BinderyPage.tsx b/frontend/src/pages/BinderyPage.tsx index 9e2b89f..2b31e36 100644 --- a/frontend/src/pages/BinderyPage.tsx +++ b/frontend/src/pages/BinderyPage.tsx @@ -41,6 +41,7 @@ interface BinderyItem { series: string | null series_index: number | null title: string + author: string | null folder: string | null } @@ -89,7 +90,7 @@ function itemToForm(item: BinderyItem, bookTypes: BookType[]): ItemForm { : '' return { title: item.title ?? '', - author: '', + author: item.author ?? '', series: item.series ?? '', series_index: item.series_index != null ? String(item.series_index) : '', content_type: item.content_type ?? 'volume', @@ -844,10 +845,13 @@ export function BinderyPage() { ) setCandidates(result.candidates) setSearchQuery(result.query_used) - // Auto-apply the best candidate on initial fetch (not manual re-search, - // and not when retargeting to a row the user may have hand-edited) - if (autoApply && !queryOverride && result.candidates.length > 0) { - applyCandidate(result.candidates[0], itemPath) + // Embedded metadata (calibre:series etc.) beats the filename guess — + // same precedence as auto-import. Candidate apply still wins on top. + if (autoApply && !queryOverride) { + applyEmbedded(result.file_metadata, itemPath) + if (result.candidates.length > 0) { + applyCandidate(result.candidates[0], itemPath) + } } } catch { // Non-fatal @@ -867,10 +871,11 @@ export function BinderyPage() { const item = reviewItems[i] setMatchingAll(i + 1) try { - const result = await api.post<{ candidates: MetadataCandidate[] }>( + const result = await api.post<{ file_metadata: Record; candidates: MetadataCandidate[] }>( '/bindery/preview', { path: item.path } ) + applyEmbedded(result.file_metadata, item.path) if (result.candidates.length > 0) { applyCandidate(result.candidates[0], item.path) matched++ @@ -901,6 +906,45 @@ export function BinderyPage() { // Candidate selection // --------------------------------------------------------------------------- + // Merge metadata embedded in the file itself (calibre:series, OPF dc:*) + // into a form row. Embedded values win over the filename parse — the + // filename is a guess, the file is ground truth. Candidates applied + // afterwards still override (unchanged semantics). + function applyEmbedded(meta: Record, targetPath: string) { + if (!meta) return + const str = (k: string): string | null => { + const v = meta[k] + return typeof v === 'string' && v.trim() ? v : null + } + const num = (k: string): number | null => { + const v = meta[k] + return typeof v === 'number' && Number.isFinite(v) ? v : null + } + setFormData(prev => { + const existing = prev[targetPath] + if (!existing) return prev + return { + ...prev, + [targetPath]: { + ...existing, + title: str('title') || existing.title, + author: str('author') || existing.author, + series: str('series') || existing.series, + series_index: num('series_index') != null ? String(num('series_index')) : existing.series_index, + description: str('description') || existing.description, + publisher: str('publisher') || existing.publisher, + year: num('year') != null ? String(num('year')) : existing.year, + isbn: str('isbn') || existing.isbn, + language: str('language') || existing.language, + }, + } + }) + const embeddedSeries = str('series') + if (embeddedSeries) setBatchSeries(prev => prev || embeddedSeries) + const embeddedAuthor = str('author') + if (embeddedAuthor) setBatchAuthor(prev => prev || embeddedAuthor) + } + function applyCandidate(c: MetadataCandidate, targetPath?: string) { setFormData(prev => { // Determine which path to apply to: explicit target, or the sole key in formData diff --git a/tests/test_filename_parser.py b/tests/test_filename_parser.py index 34e887c..29ae919 100644 --- a/tests/test_filename_parser.py +++ b/tests/test_filename_parser.py @@ -196,3 +196,103 @@ def test_series_is_none_for_fallback(self) -> None: assert r.series is None assert r.series_index is None assert r.content_type == "volume" + + +# --------------------------------------------------------------------------- +# Series parenthetical — "(Series Book N)" Amazon/Calibre convention +# --------------------------------------------------------------------------- + +class TestSeriesParenthetical: + def test_series_book_n(self) -> None: + # The exact prod filename shape behind the original Bindery series bug + r = _parse("Trick Of The Night A LitRPGGameLit Adventure (The Bad Guys Book 8) - Eric Ugland (2022).epub") + assert r.series == "The Bad Guys" + assert r.series_index == 8.0 + assert r.title == "Trick Of The Night A LitRPGGameLit Adventure" + assert r.author == "Eric Ugland" + assert r.content_type == "volume" + + def test_series_comma_book_n(self) -> None: + r = _parse("Unsouled (Cradle, Book 1).epub") + assert r.series == "Cradle" + assert r.series_index == 1.0 + + def test_series_hash_n(self) -> None: + r = _parse("The Wandering Inn (The Wandering Inn #7).epub") + assert r.series == "The Wandering Inn" + assert r.series_index == 7.0 + + def test_plain_year_paren_is_not_series(self) -> None: + r = _parse("Don Quixote v01 (2024) (Digital).cbz") + assert r.series == "Don Quixote" + assert r.series_index == 1.0 + + +# --------------------------------------------------------------------------- +# Structured "NN. Title - Author (Year)" layout (organizer/import convention) +# --------------------------------------------------------------------------- + +class TestStructuredLayout: + def test_index_title_author_year(self) -> None: + r = _parse("07. Back to One - Eric Ugland (2021).epub") + assert r.title == "Back to One" + assert r.author == "Eric Ugland" + assert r.series_index == 7.0 + assert r.series is None + assert r.content_type == "volume" + + def test_title_author_year_no_index(self) -> None: + r = _parse("Dukes and Ladders - Eric Ugland (2021).epub") + assert r.title == "Dukes and Ladders" + assert r.author == "Eric Ugland" + assert r.series is None + assert r.series_index is None + + def test_index_title_author_no_year(self) -> None: + r = _parse("01. Scamps & Scoundrels - Eric Ugland.epub") + assert r.title == "Scamps & Scoundrels" + assert r.author == "Eric Ugland" + assert r.series_index == 1.0 + + def test_structured_with_inner_volume_marker(self) -> None: + r = _parse("05. Omniscient Reader v05 - Sing Shong (2021).epub") + assert r.series == "Omniscient Reader" + assert r.series_index == 5.0 + assert r.author == "Sing Shong" + + def test_dash_title_without_year_stays_ambiguous(self) -> None: + # No (Year) anchor and no leading index — can't tell author from title + r = _parse("The Castle - Bram Stoker.epub") + assert r.author is None + assert r.series is None + + +# --------------------------------------------------------------------------- +# Bare trailing number on prose ebooks must NOT fabricate a series +# (the original Bindery bug: series became the book's own title) +# --------------------------------------------------------------------------- + +class TestBareNumberEbook: + def test_epub_bare_number_no_series(self) -> None: + r = _parse("Dukes and Ladders 5.epub") + assert r.series is None + assert r.series_index == 5.0 + assert r.title == "Dukes and Ladders 5" + assert r.content_type == "volume" + + def test_pdf_bare_number_no_series(self) -> None: + r = _parse("Design Patterns 2.pdf") + assert r.series is None + assert r.content_type == "volume" + + def test_cbz_bare_number_still_chapter(self) -> None: + # Comics keep the manga-chapter convention + r = _parse("One Piece 1050.cbz") + assert r.series == "One Piece" + assert r.series_index == 1050.0 + assert r.content_type == "chapter" + + def test_epub_bare_number_in_chapters_dir(self) -> None: + r = _parse("Some Webnovel 42.epub", in_chapters_dir=True) + assert r.content_type == "chapter" + assert r.series_index == 42.0