Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions backend/api/bindery.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ class BinderyItem(BaseModel):
series: str | None
series_index: float | None
title: str
author: str | None
folder: str | None


Expand Down Expand Up @@ -208,6 +209,7 @@ def bindery_list(
series=parsed.series,
series_index=parsed.series_index,
title=parsed.title,
author=parsed.author,
folder=folder,
)
)
Expand Down
2 changes: 1 addition & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
131 changes: 101 additions & 30 deletions backend/services/filename_parser.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")


# ---------------------------------------------------------------------------
Expand All @@ -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",
Expand All @@ -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)
Expand Down Expand Up @@ -95,89 +124,131 @@ 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)

content_type = "chapter" if in_chapters_dir else "volume"
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,
)


Expand Down
56 changes: 50 additions & 6 deletions frontend/src/pages/BinderyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ interface BinderyItem {
series: string | null
series_index: number | null
title: string
author: string | null
folder: string | null
}

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand All @@ -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<string, unknown>; 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++
Expand Down Expand Up @@ -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<string, unknown>, 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
Expand Down
Loading
Loading