fix(audiobook): add AudiobookPlan.chapter_count so import doesn't 500 (#544)

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>
This commit is contained in:
Palash Debnath
2026-06-20 09:15:40 +05:30
committed by GitHub
co-authored by mergetest Claude Opus 4.8
parent 6dccf0390d
commit 7678d4cca3
3 changed files with 42 additions and 1 deletions
+5 -1
View File
@@ -65,10 +65,14 @@ class AudiobookPlan:
def char_count(self) -> int:
return sum(c.char_count for c in self.chapters)
@property
def chapter_count(self) -> int:
return len(self.chapters)
def to_dict(self) -> dict:
return {
"chapters": [c.to_dict() for c in self.chapters],
"chapter_count": len(self.chapters),
"chapter_count": self.chapter_count,
"char_count": self.char_count,
}
+9
View File
@@ -76,6 +76,15 @@ def test_plan_to_dict_shape():
assert d["chapters"][0]["spans"][0]["text"] == "Hi there."
def test_plan_chapter_count_property():
# Regression for #543: the /audiobook/import endpoint reads plan.chapter_count
# directly (not via to_dict), so the attribute must exist and stay in lockstep
# with the serialized key.
plan = parse_audiobook_script("# A\nHi.\n# B\nBye.")
assert plan.chapter_count == 2
assert plan.chapter_count == plan.to_dict()["chapter_count"]
# ── FFMETADATA ───────────────────────────────────────────────────────────────
def test_ffmetadata_cumulative_offsets():
+28
View File
@@ -5,10 +5,12 @@ EPUB zip in memory (no fixture file, no new dep).
"""
from __future__ import annotations
import asyncio
import io
import zipfile
import pytest
from fastapi import UploadFile
from services.longform_import import (
chapterize_plaintext,
@@ -16,6 +18,7 @@ from services.longform_import import (
pdf_to_chapter_script,
)
from services.audiobook import parse_audiobook_script
from api.routers.audiobook import audiobook_import
# ── PDF fixture builder ───────────────────────────────────────────────────
@@ -219,3 +222,28 @@ def test_pdf_too_many_pages_guard():
data = _make_pdf(["Chapter 1", "Hi."])
with pytest.raises(ValueError, match="too many pages"):
pdf_to_chapter_script(data, max_pages=0)
# ── import endpoint ────────────────────────────────────────────────────────
# Calls the handler directly (no TestClient → no main+torch import), mirroring
# tests/test_audiobook_cover.py.
def _upload(name: str, data: bytes) -> UploadFile:
return UploadFile(io.BytesIO(data), filename=name)
def test_import_endpoint_returns_chapter_count():
# Regression for #543: parsing succeeded but the endpoint then read
# plan.chapter_count, which didn't exist → 500 AttributeError. Covers the
# whole class — every import format hits this same return path.
pdf = _make_pdf(["Chapter 1", "Once upon a time.", "Chapter 2", "The end."])
for name, data in [
("book.pdf", pdf),
("book.md", b"# One\nhello\n\n# Two\nworld"),
("book.txt", b"just a flat blob of narration with no headings"),
]:
res = asyncio.run(audiobook_import(_upload(name, data)))
assert isinstance(res["chapters"], int) and res["chapters"] >= 1
assert res["text"].strip()
# The two-chapter inputs parse to exactly two chapters.
assert asyncio.run(audiobook_import(_upload("book.pdf", pdf)))["chapters"] == 2