466 lines
16 KiB
Python
466 lines
16 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 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", "title"), ("Occupation", "profession"),
|
|
("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", ""),
|
|
"profession": data.get("profession", ""),
|
|
"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}
|