tts-voice-creator-clone-and.../routes/reader.py
mARTin-B78 9d80ec27e7 Merge Reader/Casting/Rehearser pipeline into one guided workflow (v1.12.84)
- 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>
2026-07-03 17:45:38 +02:00

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}