282 lines
8.9 KiB
Python
282 lines
8.9 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
|
|
|
|
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) ───────────────────────────────────────────────────
|
|
# Saves the LLM-cast result (segments + roster) as a Markdown file under
|
|
# <reader_library>/<doc_id>/Scripts/cast.md
|
|
# so it survives browser-cache clears and works across devices.
|
|
|
|
def _scripts_dir(doc_id: str) -> Path:
|
|
d = _doc_dir(doc_id) / "Scripts"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return d
|
|
|
|
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):
|
|
sd = _scripts_dir(doc_id)
|
|
scripts = []
|
|
for f in sorted(sd.glob("*.md")):
|
|
try:
|
|
data = _md_to_cast(f.read_text(encoding="utf-8"))
|
|
scripts.append({
|
|
"name": f.stem,
|
|
"savedAt": data.get("savedAt"),
|
|
"segments": len(data.get("segments", [])),
|
|
"roster": data.get("roster", []),
|
|
})
|
|
except Exception:
|
|
scripts.append({"name": f.stem})
|
|
return scripts
|
|
|
|
|
|
@router.get("/api/reader/docs/{doc_id}/scripts/{name}")
|
|
async def reader_get_script(doc_id: str, name: str):
|
|
if not name.replace("-", "").replace("_", "").isalnum():
|
|
raise HTTPException(400, "Bad script name")
|
|
f = _scripts_dir(doc_id) / f"{name}.md"
|
|
if not f.is_file():
|
|
raise HTTPException(404, "Script not found")
|
|
try:
|
|
return _md_to_cast(f.read_text(encoding="utf-8"))
|
|
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 name.replace("-", "").replace("_", "").isalnum():
|
|
raise HTTPException(400, "Bad script name")
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
raise HTTPException(400, "Invalid JSON")
|
|
f = _scripts_dir(doc_id) / f"{name}.md"
|
|
f.write_text(_cast_to_md(data), encoding="utf-8")
|
|
return {"saved": str(f.relative_to(_LIB))}
|
|
|
|
|
|
@router.delete("/api/reader/docs/{doc_id}/scripts/{name}")
|
|
async def reader_delete_script(doc_id: str, name: str):
|
|
if not name.replace("-", "").replace("_", "").isalnum():
|
|
raise HTTPException(400, "Bad script name")
|
|
f = _scripts_dir(doc_id) / f"{name}.md"
|
|
if f.is_file():
|
|
f.unlink()
|
|
return {"deleted": name}
|