POST /audiobook/import ended with `plan.chapter_count`, but AudiobookPlan
only exposed `char_count` (a property) and emitted `chapter_count` from
`to_dict()` — so the attribute access raised AttributeError, surfacing as
"500 Internal Server Error: 'AudiobookPlan' object has no attribute
'chapter_count'". The parse itself succeeded, so this hit every import
format (.txt/.md/.epub/.pdf), not just PDF.
Add a `chapter_count` property mirroring `char_count`, and have `to_dict()`
derive its key from it so the attribute and serialized key can't drift.
No API/schema/data change.
Tests: unit property test + a direct-handler /audiobook/import regression
(pdf/md/txt) that fails-before with the AttributeError.
Fixes#543
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The audiobook importer accepted .txt/.md/.epub but not PDF — the single most
common "ebook in" format. Add a pure `pdf_to_chapter_script(data)` that
extracts the text layer page-by-page and runs it through the existing
chapterizer, so PDFs land in the same `# Heading` + body grammar EPUB and
plaintext already produce (one front door onto the unchanged render pipeline).
- Dep: `pypdf>=4.0` — pure-Python, MIT, zero native deps, so PDF import behaves
identically on macOS/Windows/Linux (default-feature cross-platform rule).
EPUB + plaintext stay stdlib-only; only PDF needs a real parser.
- Robustness, surfaced as actionable 400s rather than silent empty imports:
corrupt file, password-protected (empty-password decrypt attempted first),
scanned/image-only (no text layer → clear "scanned PDF" message), and a
page-count ceiling. A single unparseable page is skipped, not fatal.
- Route: `.pdf` branch in audiobook_import; frontend accept filter +
api-client doc updated to `.txt,.md,.epub,.pdf`.
tests/test_longform_import.py: 5 PDF cases (extract+chapterize, no-marker
single chapter, corrupt, image-only, page-cap) using a hand-built in-memory
PDF — no PDF-authoring test dep, mirroring the in-memory-EPUB approach.
16 passed; frontend suite 401; CJK guard green.
* fix(longform): correctness + robustness fixes from adversarial review
Fixes the confirmed findings from a multi-agent review of the convergence:
HIGH (correctness/output):
- MP3 + cover produced a corrupt file (-map 2:v -c:v copy is invalid for mp3).
Cover art is now embedded for M4B only; mp3 skips it (m4b is the cover format).
- Chapter cache key omitted ref_text — editing only a profile's ref_text served
stale audio. ref_text is now part of the voice signature.
- Preview wrote audiobook_cache/ but the render reads longform_cache/ (rename
missed in PR 5) → cache-warming silently broke. Unified to longform_cache/.
Robustness (DoS/OOM guards):
- /audiobook/import caps upload at 64 MB; epub_to_chapter_script bounds per-entry
(25 MB) and cumulative (300 MB) uncompressed reads (zip-bomb guard).
- /longform/render rejects > 10,000 chapters (422).
Frontend leaks:
- StoriesEditor.removeTrack revokes the line's preview blob URL.
- AudiobookTab revokes the cover blob URL on replace/unmount.
Deferred fast-follows (also from review): render-cache disk eviction; restoring
the standalone chapter cue-sheet export (needs chapter times in the done event).
Tests: mp3-drops-cover, epub entry/total caps, import + chapter-count limits;
updated the cache-hit test for the 4-field voice sig. 70 backend + 334 frontend green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(longform): pass EPUB caps as params, not monkeypatch (CI import-path fix)
The cap tests monkeypatched module constants, but in the full-suite CI context
the module loads under a different import path so the patch missed the function
(it used the real 300 MB cap → tests failed). epub_to_chapter_script now takes
max_entry_bytes/max_total_bytes kwargs (default to the constants); tests pass
small values directly — deterministic regardless of import path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Spec PR 4. A front door onto the existing chapter parser: import a file, get a
chapter-delimited script in the editor.
Backend (new services/longform_import.py — pure, stdlib only, no new dep):
- chapterize_plaintext(text): inserts `# ` headings ahead of short standalone
chapter-title lines (Chapter/Part/Prologue/…); no-op if the text already has
H1s; long "Chapter …" sentences stay prose. ReDoS-safe (anchored, per-line).
- epub_to_chapter_script(bytes): parses EPUB (zipfile + ElementTree +
html.parser) in spine order → `# Title` + stripped body per document; skips
empty/nav pages; the heading becomes the chapter title (not narrated). Raises
ValueError on a malformed EPUB. ET.fromstring annotated `# nosec B314` (local
user file, no external-entity expansion).
- POST /audiobook/import (UploadFile) → {text, chapters}.
Frontend: an Import button (.txt/.md/.epub) that fills the script editor.
Tests: tests/test_longform_import.py (9) incl. an in-memory synthetic EPUB
(spine order, empty-doc skip, tag stripping, bad-zip). 64 backend + 326 frontend
green; build clean; en.json valid.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>