- Add a 6-stage pipeline stepper (Source -> Cast Audiobook -> Cast Characters -> Script Rehearser -> Generate MP3s -> Audiobook) with direct, non-destructive jumps between stages and a prominent guided-tour look - Split PDF import into an explicit "load" then "Extract Text" step, with in-browser OCR (Tesseract.js, vendored) to recover chapter headlines baked into a PDF as images instead of real text - Fix casting feed silently merging pages after leaving/returning: segments now carry their own page number instead of re-guessing it from text - Fix excessive "Unknown" speaker attribution: restore the attribution LLM's output token budget, which had been cut roughly in half and was truncating dialogue-dense passages - Fix Theater Play library cards failing to open (dead pre-migration IndexedDB API calls, missing section navigation) - Fix bulk "Set tag" wiping a voice's existing tags instead of adding to them - Start merging Casting's feed with Script Rehearser's Stage UI: collapsible character sidebar, shared "paper" page styling, inline text editing - Fix a performance regression from that merge (per-row listeners on every redraw) by moving to event delegation - Various layout/clutter fixes: hide reader chrome until a document is loaded, collapse secondary settings by default, fix overlapping toolbar icons, fix duplicate "opening" notifications Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
341 lines
11 KiB
Python
341 lines
11 KiB
Python
"""Server-side Read Aloud library so saved books sync across devices.
|
|
|
|
Stores each document under the writable config volume:
|
|
<CONFIG_DIR>/reader_library/<id>/
|
|
meta.json metadata (title, kind, idx, voice, counts, …)
|
|
source.pdf | source.txt the original document (for re-rendering)
|
|
audio/<idx>.mp3 per-unit synthesised audio (incremental)
|
|
|
|
Endpoints are deliberately small (file I/O) and mirror the previous IndexedDB
|
|
shape so the frontend swap is mechanical.
|
|
"""
|
|
import json
|
|
import shutil
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from fastapi.responses import FileResponse, Response
|
|
|
|
from core.constants import CONFIG_DIR
|
|
from core.database import (
|
|
reader_script_delete as db_reader_script_delete,
|
|
reader_script_get as db_reader_script_get,
|
|
reader_script_list as db_reader_script_list,
|
|
reader_script_put as db_reader_script_put,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
_LIB = CONFIG_DIR / "reader_library"
|
|
|
|
|
|
def _lib() -> Path:
|
|
_LIB.mkdir(parents=True, exist_ok=True)
|
|
return _LIB
|
|
|
|
|
|
def _doc_dir(doc_id: str) -> Path:
|
|
# ids are server-generated uuid hex; reject anything else (path safety)
|
|
if not doc_id or not doc_id.isalnum() or len(doc_id) > 40:
|
|
raise HTTPException(400, "Bad document id")
|
|
return _lib() / doc_id
|
|
|
|
|
|
def _read_meta(d: Path) -> dict:
|
|
try:
|
|
return json.loads((d / "meta.json").read_text("utf-8"))
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _audio_indices(d: Path) -> list[int]:
|
|
ad = d / "audio"
|
|
if not ad.is_dir():
|
|
return []
|
|
out = []
|
|
for f in ad.glob("*.mp3"):
|
|
try:
|
|
out.append(int(f.stem))
|
|
except ValueError:
|
|
pass
|
|
return sorted(out)
|
|
|
|
|
|
@router.get("/api/reader/docs")
|
|
async def reader_list_docs():
|
|
lib = _lib()
|
|
docs = []
|
|
for d in lib.iterdir():
|
|
if not d.is_dir():
|
|
continue
|
|
meta = _read_meta(d)
|
|
if not meta:
|
|
continue
|
|
meta["id"] = d.name
|
|
meta["synthCount"] = len(_audio_indices(d))
|
|
meta["hasCover"] = (d / "cover.jpg").is_file()
|
|
docs.append(meta)
|
|
docs.sort(key=lambda m: m.get("updated") or "", reverse=True)
|
|
return {"docs": docs}
|
|
|
|
|
|
@router.post("/api/reader/docs")
|
|
async def reader_save_doc(request: Request):
|
|
meta = await request.json()
|
|
doc_id = str(meta.get("id") or "").strip()
|
|
if doc_id:
|
|
d = _doc_dir(doc_id)
|
|
if not d.is_dir():
|
|
raise HTTPException(404, "Document not found")
|
|
else:
|
|
doc_id = uuid.uuid4().hex
|
|
d = _lib() / doc_id
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
meta["created"] = meta.get("created")
|
|
meta.pop("id", None)
|
|
(d / "meta.json").write_text(json.dumps(meta, ensure_ascii=False), "utf-8")
|
|
return {"id": doc_id}
|
|
|
|
|
|
@router.get("/api/reader/docs/{doc_id}")
|
|
async def reader_get_doc(doc_id: str):
|
|
d = _doc_dir(doc_id)
|
|
meta = _read_meta(d)
|
|
if not meta:
|
|
raise HTTPException(404, "Document not found")
|
|
meta["id"] = doc_id
|
|
meta["audioIdx"] = _audio_indices(d)
|
|
return meta
|
|
|
|
|
|
@router.delete("/api/reader/docs/{doc_id}")
|
|
async def reader_delete_doc(doc_id: str):
|
|
d = _doc_dir(doc_id)
|
|
if d.is_dir():
|
|
shutil.rmtree(d, ignore_errors=True)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.patch("/api/reader/docs/{doc_id}/progress")
|
|
async def reader_progress(doc_id: str, request: Request):
|
|
d = _doc_dir(doc_id)
|
|
meta = _read_meta(d)
|
|
if not meta:
|
|
raise HTTPException(404, "Document not found")
|
|
body = await request.json()
|
|
if "idx" in body:
|
|
meta["idx"] = body["idx"]
|
|
if "updated" in body:
|
|
meta["updated"] = body["updated"]
|
|
(d / "meta.json").write_text(json.dumps(meta, ensure_ascii=False), "utf-8")
|
|
return {"ok": True}
|
|
|
|
|
|
@router.put("/api/reader/docs/{doc_id}/source")
|
|
async def reader_put_source(doc_id: str, request: Request, ext: str = "pdf"):
|
|
d = _doc_dir(doc_id)
|
|
if not d.is_dir():
|
|
raise HTTPException(404, "Document not found")
|
|
ext = "txt" if ext.lower() == "txt" else "pdf"
|
|
body = await request.body()
|
|
# clear any existing source of the other type
|
|
for old in d.glob("source.*"):
|
|
old.unlink()
|
|
(d / f"source.{ext}").write_bytes(body)
|
|
return {"ok": True, "bytes": len(body)}
|
|
|
|
|
|
@router.get("/api/reader/docs/{doc_id}/source")
|
|
async def reader_get_source(doc_id: str):
|
|
d = _doc_dir(doc_id)
|
|
for ext, media in (("pdf", "application/pdf"), ("txt", "text/plain")):
|
|
f = d / f"source.{ext}"
|
|
if f.is_file():
|
|
return FileResponse(str(f), media_type=media)
|
|
raise HTTPException(404, "Source not found")
|
|
|
|
|
|
@router.get("/api/reader/docs/{doc_id}/audio")
|
|
async def reader_list_audio(doc_id: str):
|
|
return {"idx": _audio_indices(_doc_dir(doc_id))}
|
|
|
|
|
|
@router.put("/api/reader/docs/{doc_id}/audio/{idx}")
|
|
async def reader_put_audio(doc_id: str, idx: int, request: Request):
|
|
d = _doc_dir(doc_id)
|
|
if not d.is_dir():
|
|
raise HTTPException(404, "Document not found")
|
|
(d / "audio").mkdir(exist_ok=True)
|
|
(d / "audio" / f"{idx}.mp3").write_bytes(await request.body())
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/api/reader/docs/{doc_id}/audio/{idx}")
|
|
async def reader_get_audio(doc_id: str, idx: int):
|
|
f = _doc_dir(doc_id) / "audio" / f"{idx}.mp3"
|
|
if not f.is_file():
|
|
raise HTTPException(404, "Audio not found")
|
|
return FileResponse(str(f), media_type="audio/mpeg")
|
|
|
|
|
|
@router.put("/api/reader/docs/{doc_id}/cover")
|
|
async def reader_put_cover(doc_id: str, request: Request):
|
|
d = _doc_dir(doc_id)
|
|
if not d.is_dir():
|
|
raise HTTPException(404, "Document not found")
|
|
body = await request.body()
|
|
(d / "cover.jpg").write_bytes(body)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/api/reader/docs/{doc_id}/cover")
|
|
async def reader_get_cover(doc_id: str):
|
|
f = _doc_dir(doc_id) / "cover.jpg"
|
|
if not f.is_file():
|
|
raise HTTPException(404, "Cover not found")
|
|
return FileResponse(str(f), media_type="image/jpeg", headers={"Cache-Control": "max-age=31536000"})
|
|
|
|
|
|
# ── Scripts (cast save/load/export) ────────────────────────────────────────────
|
|
# Durable storage lives in SQLite. Markdown files are export/import-compatible
|
|
# snapshots only: old files are still readable and get migrated into the DB on
|
|
# first access, while new autosaves no longer create files behind the user's back.
|
|
|
|
def _scripts_dir(doc_id: str) -> Path:
|
|
d = _doc_dir(doc_id) / "Scripts"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return d
|
|
|
|
|
|
def _legacy_scripts_dir(doc_id: str) -> Path:
|
|
return _doc_dir(doc_id) / "Scripts"
|
|
|
|
|
|
def _valid_script_name(name: str) -> bool:
|
|
return bool(name and name.replace("-", "").replace("_", "").isalnum())
|
|
|
|
def _cast_to_md(data: dict) -> str:
|
|
"""Serialise cast JSON as a fenced code-block Markdown file."""
|
|
meta = {k: v for k, v in data.items() if k != "segments"}
|
|
lines = [
|
|
"# Cast Script",
|
|
"",
|
|
f"**Book:** {data.get('title', '')} ",
|
|
f"**Saved:** {data.get('savedAt', '')} ",
|
|
f"**Segments:** {len(data.get('segments', []))} ",
|
|
"",
|
|
"```json",
|
|
json.dumps(data, ensure_ascii=False, indent=2),
|
|
"```",
|
|
"",
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
def _md_to_cast(text: str) -> dict:
|
|
"""Extract the JSON payload from a fenced Markdown cast file."""
|
|
import re
|
|
m = re.search(r"```json\s*\n([\s\S]+?)\n```", text)
|
|
if not m:
|
|
raise ValueError("No JSON block found in script file")
|
|
return json.loads(m.group(1))
|
|
|
|
|
|
@router.get("/api/reader/docs/{doc_id}/scripts")
|
|
async def reader_list_scripts(doc_id: str):
|
|
_doc_dir(doc_id)
|
|
scripts = db_reader_script_list(doc_id)
|
|
seen = {s.get("name") for s in scripts}
|
|
sd = _legacy_scripts_dir(doc_id)
|
|
if not sd.is_dir():
|
|
return scripts
|
|
for f in sorted(sd.glob("*.md")):
|
|
if f.stem in seen:
|
|
continue
|
|
try:
|
|
data = _md_to_cast(f.read_text(encoding="utf-8"))
|
|
scripts.append({
|
|
"name": f.stem,
|
|
"title": data.get("title", ""),
|
|
"savedAt": data.get("savedAt"),
|
|
"segments": len(data.get("segments", [])),
|
|
"roster": data.get("roster", []),
|
|
"legacy": True,
|
|
})
|
|
except Exception:
|
|
scripts.append({"name": f.stem, "legacy": True})
|
|
return scripts
|
|
|
|
|
|
@router.get("/api/reader/docs/{doc_id}/scripts/{name}")
|
|
async def reader_get_script(doc_id: str, name: str):
|
|
if not _valid_script_name(name):
|
|
raise HTTPException(400, "Bad script name")
|
|
_doc_dir(doc_id)
|
|
data = db_reader_script_get(doc_id, name)
|
|
if data is not None:
|
|
return data
|
|
f = _legacy_scripts_dir(doc_id) / f"{name}.md"
|
|
if not f.is_file():
|
|
raise HTTPException(404, "Script not found")
|
|
try:
|
|
data = _md_to_cast(f.read_text(encoding="utf-8"))
|
|
db_reader_script_put(doc_id, name, data)
|
|
return data
|
|
except Exception as e:
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@router.put("/api/reader/docs/{doc_id}/scripts/{name}")
|
|
async def reader_save_script(doc_id: str, name: str, request: Request):
|
|
if not _valid_script_name(name):
|
|
raise HTTPException(400, "Bad script name")
|
|
d = _doc_dir(doc_id)
|
|
if not d.is_dir():
|
|
raise HTTPException(404, "Document not found")
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
raise HTTPException(400, "Invalid JSON")
|
|
db_reader_script_put(doc_id, name, data)
|
|
return {"saved": f"database:{doc_id}/{name}"}
|
|
|
|
|
|
@router.get("/api/reader/docs/{doc_id}/scripts/{name}/export")
|
|
async def reader_export_script(doc_id: str, name: str):
|
|
if not _valid_script_name(name):
|
|
raise HTTPException(400, "Bad script name")
|
|
_doc_dir(doc_id)
|
|
data = db_reader_script_get(doc_id, name)
|
|
if data is None:
|
|
f = _legacy_scripts_dir(doc_id) / f"{name}.md"
|
|
if not f.is_file():
|
|
raise HTTPException(404, "Script not found")
|
|
try:
|
|
data = _md_to_cast(f.read_text(encoding="utf-8"))
|
|
except Exception as e:
|
|
raise HTTPException(500, str(e))
|
|
filename = f"{name}.md"
|
|
title = str(data.get("title") or "").strip()
|
|
if title:
|
|
safe_title = "".join(c if c.isalnum() or c in "._- " else "_" for c in title).strip().replace(" ", "_")
|
|
if safe_title:
|
|
filename = f"{safe_title}_{name}.md"
|
|
return Response(
|
|
_cast_to_md(data),
|
|
media_type="text/markdown; charset=utf-8",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
@router.delete("/api/reader/docs/{doc_id}/scripts/{name}")
|
|
async def reader_delete_script(doc_id: str, name: str):
|
|
if not _valid_script_name(name):
|
|
raise HTTPException(400, "Bad script name")
|
|
_doc_dir(doc_id)
|
|
db_reader_script_delete(doc_id, name)
|
|
f = _legacy_scripts_dir(doc_id) / f"{name}.md"
|
|
if f.is_file():
|
|
f.unlink()
|
|
return {"deleted": name}
|