"""Server-side Read Aloud library so saved books sync across devices. Stores each document under the writable config volume: /reader_library// meta.json metadata (title, kind, idx, voice, counts, …) source.pdf | source.txt the original document (for re-rendering) audio/.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 io import json import shutil import uuid import zipfile from datetime import datetime 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 ( char_get_all, 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: """Render the cast as a readable Markdown script — narrator lines as plain paragraphs, dialogue as **SPEAKER** (emotion): "line", grouped under page headings from each segment's .page. This is a one-way export for reading/ sharing; the app's own persistence is the SQLite script store, so the file doesn't need to round-trip back through _md_to_cast (that function only still matters for pre-migration legacy .md files).""" segments = data.get("segments") or [] roster = data.get("roster") or [] saved_at = data.get("savedAt") saved_str = "" if saved_at: try: saved_str = datetime.fromtimestamp(float(saved_at) / 1000).strftime("%Y-%m-%d %H:%M") except Exception: saved_str = str(saved_at) speakers = sorted({ str(s.get("speaker") or "Narrator") for s in segments if isinstance(s, dict) and s.get("type") == "dialogue" and s.get("speaker") }) lines = [f"# {data.get('title') or 'Cast Script'}", ""] meta_bits = [] if saved_str: meta_bits.append(f"exported {saved_str}") meta_bits.append(f"{len(speakers)} character{'s' if len(speakers) != 1 else ''}") meta_bits.append(f"{len(segments)} segment{'s' if len(segments) != 1 else ''}") lines.append(f"*{' · '.join(meta_bits)}*") lines.append("") if speakers: lines.append(f"**Characters:** {', '.join(speakers)}") lines.append("") last_page = None for seg in segments: if not isinstance(seg, dict): continue text = str(seg.get("text") or "").strip() if not text: continue page = seg.get("page") if page is not None and page != last_page: lines.append("---") lines.append("") lines.append(f"## Page {page}") lines.append("") last_page = page is_dialogue = seg.get("type") == "dialogue" speaker = str(seg.get("speaker") or "Narrator") if is_dialogue: emotion = str(seg.get("emotion") or "").strip() tag = f" *({emotion})*" if emotion else "" lines.append(f'**{speaker.upper()}**{tag}: "{text}"') else: lines.append(text) lines.append("") 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)) _CHAR_MD_SECTIONS = [ ("Identity", [("Aliases / also known as", "aliases"), ("Full name", "full_name"), ("Title / role", "title"), ("Archetype", "archetype")]), ("Appearance", [("Physical", "physical"), ("Clothing", "clothing")]), ("Personality", [("Mannerisms & habits", "mannerisms"), ("Voice & speech", "voice_pattern"), ("Motivation", "motivation"), ("Fears", "fears")]), ("Story", [("Backstory", "backstory"), ("Relationships", "relationships"), ("Dark secret / fatal flaw", "secret"), ("Character arc", "arc_note")]), ("Abilities", [("Skills", "skills"), ("Capabilities", "capabilities"), ("Conflict style", "conflict_style"), ("Win condition", "win_condition")]), ("Generation prompts", [("Voice design prompt", "voice_design_prompt"), ("Image prompt", "image_prompt"), ("SillyTavern prompt", "silly_tavern_prompt"), ("Concept art prompt", "concept_art_prompt")]), ] def _character_to_md(rec: dict) -> str: """Render one character-library record as a readable Markdown sheet.""" sheet = rec.get("sheet") or {} def _s(key: str) -> str: v = sheet.get(key) if isinstance(v, list): return ", ".join(str(x) for x in v if x) return str(v or "").strip() lines = [f"# {rec.get('name') or 'Character'}", ""] if rec.get("book"): lines.append(f"*{rec['book']}*") lines.append("") for section, fields in _CHAR_MD_SECTIONS: rows = [(label, _s(key)) for label, key in fields] rows = [(label, v) for label, v in rows if v] if not rows: continue lines.append(f"## {section}") lines.append("") for label, v in rows: lines.append(f"**{label}:** {v}") lines.append("") inv = sheet.get("inventory") or [] if inv: lines.append("## Inventory") lines.append("") lines.extend(f"- {item}" for item in inv if item) lines.append("") return "\n".join(lines) def _characters_for_title(title: str) -> list[dict]: """Character records belonging to a book, by origin book OR tag membership (mirrors clGetAllByTagOrBook in characters-library.js).""" key = str(title or "").strip().lower() if not key: return [] out = [] try: for rec in char_get_all(): book = str(rec.get("book") or "").strip().lower() tags = [t.strip().lower() for t in str(rec.get("tags") or "").split(",")] if book == key or key in tags: out.append(rec) except Exception: return [] return out @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)) title = str(data.get("title") or "").strip() safe_title = "".join(c if c.isalnum() or c in "._- " else "_" for c in title).strip().replace(" ", "_") or name # One zip bundle: the cast script plus a sheet per character — instead of # a separate browser download (and save-dialog) for every single file. characters = _characters_for_title(title) buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: zf.writestr(f"{safe_title}_cast.md", _cast_to_md(data)) seen = set() for rec in characters: cname = "".join(c if c.isalnum() or c in "._- " else "_" for c in str(rec.get("name") or "character")).strip() or "character" base = cname i = 2 while cname.lower() in seen: cname = f"{base}_{i}" i += 1 seen.add(cname.lower()) zf.writestr(f"characters/{cname}.md", _character_to_md(rec)) return Response( buf.getvalue(), media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{safe_title}_{name}.zip"'}, ) @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}