"""SQLite persistence layer for user-owned app data. Single-file database at CONFIG_DIR/tts_creator.db. Designed for easy migration to PostgreSQL / Supabase later: - JSON columns map 1-to-1 to JSONB - TEXT PKs (characters) and INTEGER PKs (rehearsals) both survive - No SQLite-specific pragmas that would break compat """ from __future__ import annotations import json import os import re import shutil import sqlite3 import tempfile import time import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any from core.constants import CONFIG_DIR from core.validation import _validate_character_record _DB_PATH = CONFIG_DIR / "tts_creator.db" _BACKGROUND_ARTIFACT_DIR = CONFIG_DIR / "background-artifacts" _BACKGROUND_ARTIFACT_MAX_BYTES = 50 * 1024 * 1024 _DDL = """ CREATE TABLE IF NOT EXISTS characters ( id TEXT PRIMARY KEY, book TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '', voice TEXT, image TEXT, sheet TEXT NOT NULL DEFAULT '{}', analysis TEXT, created TEXT, updated TEXT ); CREATE INDEX IF NOT EXISTS idx_characters_book ON characters(book); CREATE TABLE IF NOT EXISTS rehearsals ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL DEFAULT '', script TEXT NOT NULL DEFAULT '', cast TEXT NOT NULL DEFAULT '{}', emotions TEXT NOT NULL DEFAULT '{}', notes TEXT NOT NULL DEFAULT '{}', ignored TEXT NOT NULL DEFAULT '{}', hidden_lines TEXT NOT NULL DEFAULT '{}', backend TEXT NOT NULL DEFAULT '', narrator_voice TEXT NOT NULL DEFAULT '', line_index INTEGER NOT NULL DEFAULT 0, created TEXT, updated TEXT ); CREATE INDEX IF NOT EXISTS idx_rehearsals_title ON rehearsals(title); CREATE TABLE IF NOT EXISTS reader_scripts ( doc_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT 'cast', title TEXT NOT NULL DEFAULT '', payload TEXT NOT NULL DEFAULT '{}', saved_at TEXT, created TEXT, updated TEXT, PRIMARY KEY (doc_id, name) ); CREATE INDEX IF NOT EXISTS idx_reader_scripts_doc ON reader_scripts(doc_id); CREATE TABLE IF NOT EXISTS app_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT 'null', updated TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS generation_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL DEFAULT '', target_id TEXT NOT NULL DEFAULT '', prompt TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'completed', result TEXT, created TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_generation_history_target ON generation_history(target_id, created DESC); CREATE TABLE IF NOT EXISTS background_jobs ( job_id TEXT PRIMARY KEY, kind TEXT NOT NULL DEFAULT '', stage TEXT NOT NULL DEFAULT 'queued', detail TEXT NOT NULL DEFAULT '', done INTEGER NOT NULL DEFAULT 0, cancelled INTEGER NOT NULL DEFAULT 0, error TEXT, result TEXT, fingerprint TEXT NOT NULL DEFAULT '', created REAL NOT NULL, updated REAL NOT NULL ); CREATE INDEX IF NOT EXISTS idx_background_jobs_updated ON background_jobs(updated DESC); CREATE TABLE IF NOT EXISTS voice_index ( voice_id TEXT PRIMARY KEY, path TEXT NOT NULL, sort_key TEXT NOT NULL DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1, file_type TEXT NOT NULL DEFAULT '', file_mtime REAL NOT NULL DEFAULT 0, file_size INTEGER NOT NULL DEFAULT 0, meta_mtime REAL NOT NULL DEFAULT 0, ref_mtime REAL NOT NULL DEFAULT 0, picture_mtime REAL NOT NULL DEFAULT 0, entry_json TEXT NOT NULL DEFAULT '{}', indexed_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_voice_index_enabled ON voice_index(enabled); CREATE INDEX IF NOT EXISTS idx_voice_index_sort_key ON voice_index(sort_key); """ def _open() -> sqlite3.Connection: _DB_PATH.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(str(_DB_PATH), check_same_thread=False) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") return conn def _init() -> None: with _open() as conn: conn.executescript(_DDL) # Additive migrations are deliberately explicit and idempotent. This # keeps databases created by older releases usable without silently # changing record meaning or requiring a manual export/import. columns = { "characters": { "analysis": "TEXT", "created": "TEXT", "updated": "TEXT", }, "rehearsals": { "hidden_lines": "TEXT NOT NULL DEFAULT '{}'", "narrator_voice": "TEXT NOT NULL DEFAULT ''", "line_index": "INTEGER NOT NULL DEFAULT 0", }, } for table, expected in columns.items(): present = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")} for name, declaration in expected.items(): if name not in present: conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {declaration}") conn.execute( "INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)", (1, datetime.now(timezone.utc).isoformat()), ) # A process restart cannot safely resume provider calls, but it must # not leave clients polling a job that can never finish. conn.execute( "UPDATE background_jobs SET stage='error', detail='Interrupted by server restart', done=1, error=? " "WHERE done=0", ("Background job interrupted by server restart",), ) conn.commit() _init() # ── Helpers ─────────────────────────────────────────────────────────────────── def _j(v: Any) -> str: return json.dumps(v, ensure_ascii=False, default=str) def _now() -> str: return datetime.now(timezone.utc).isoformat() def generation_history_add(*, kind: str, target_id: str = "", prompt: str = "", provider: str = "", status: str = "completed", result: Any = None) -> int: """Persist a bounded-audit record without storing secrets or source text.""" # Prompts can contain book text and result payloads can contain provider # credentials. History is an audit trail, not a content archive. safe_prompt = "" sensitive = ("key", "token", "secret", "password", "authorization", "prompt", "text", "content") def _metadata(value: Any) -> Any: if isinstance(value, dict): return {str(k): _metadata(v) for k, v in value.items() if not any(part in str(k).lower() for part in sensitive)} if isinstance(value, (list, tuple)): return [_metadata(item) for item in value[:100]] if isinstance(value, (str, int, float, bool)) or value is None: return value if not isinstance(value, str) else value[:500] return str(value)[:200] safe_result = _j(_metadata(result))[:12000] if result is not None else None with _open() as conn: cur = conn.execute( """INSERT INTO generation_history (kind,target_id,prompt,provider,status,result,created) VALUES (?,?,?,?,?,?,?)""", (str(kind or "")[:80], str(target_id or "")[:240], safe_prompt, str(provider or "")[:120], str(status or "")[:40], safe_result, _now()), ) conn.commit() return int(cur.lastrowid) def generation_history_list(target_id: str = "", limit: int = 100) -> list[dict]: limit = max(1, min(int(limit), 500)) with _open() as conn: if target_id: rows = conn.execute( "SELECT * FROM generation_history WHERE target_id=? ORDER BY created DESC LIMIT ?", (str(target_id), limit), ).fetchall() else: rows = conn.execute( "SELECT * FROM generation_history ORDER BY created DESC LIMIT ?", (limit,) ).fetchall() return [dict(row) for row in rows] def _safe_background_result(value: Any) -> str | None: if value is None: return None def clean(item: Any) -> Any: if isinstance(item, dict): return {str(key): clean(val) for key, val in item.items() if str(key).lower() not in {"prompt", "text", "content", "audio", "image", "profile", "reference"}} if isinstance(item, (list, tuple)): return [clean(val) for val in item[:100]] if isinstance(item, (str, int, float, bool)) or item is None: return item if not isinstance(item, str) else item[:500] return str(item)[:200] return _j(clean(value))[:12000] def background_job_store_artifact(job_id: str, name: str, payload: bytes, media_type: str = "application/octet-stream") -> dict: """Persist one bounded binary result outside SQLite and return safe metadata.""" safe_job = str(job_id or "") safe_name = str(name or "") if not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", safe_job) or not re.fullmatch(r"[A-Za-z0-9_-]+\.[A-Za-z0-9]{1,8}", safe_name): raise ValueError("invalid background artifact name") data = bytes(payload or b"") if not data: raise ValueError("background artifact is empty") if len(data) > _BACKGROUND_ARTIFACT_MAX_BYTES: raise ValueError("background artifact exceeds 50 MB") target_dir = _BACKGROUND_ARTIFACT_DIR / safe_job target_dir.mkdir(parents=True, exist_ok=True) target = target_dir / safe_name fd, tmp_name = tempfile.mkstemp(prefix=f".{safe_name}.", suffix=".tmp", dir=target_dir) try: with os.fdopen(fd, "wb") as handle: handle.write(data) handle.flush() os.fsync(handle.fileno()) os.replace(tmp_name, target) except Exception: try: os.unlink(tmp_name) except OSError: pass raise return {"name": safe_name, "media_type": str(media_type or "application/octet-stream")[:100], "size": len(data)} def background_job_artifact_path(job_id: str, name: str) -> Path | None: """Resolve an artifact only when it belongs to an existing job.""" if not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", str(job_id or "")) or not re.fullmatch(r"[A-Za-z0-9_-]+\.[A-Za-z0-9]{1,8}", str(name or "")): return None with _open() as conn: exists = conn.execute("SELECT 1 FROM background_jobs WHERE job_id=?", (str(job_id),)).fetchone() if exists is None: return None target = (_BACKGROUND_ARTIFACT_DIR / str(job_id) / str(name)).resolve() try: target.relative_to(_BACKGROUND_ARTIFACT_DIR.resolve()) except ValueError: return None return target if target.is_file() else None def _remove_background_artifacts(job_id: str) -> None: """Remove artifacts when the corresponding bounded history row expires.""" safe_job = str(job_id or "") if re.fullmatch(r"[A-Za-z0-9_-]{1,80}", safe_job): shutil.rmtree(_BACKGROUND_ARTIFACT_DIR / safe_job, ignore_errors=True) def background_job_create(job_id: str, kind: str, *, fingerprint: str = "", created: float | None = None) -> None: now = float(created if created is not None else time.time()) with _open() as conn: conn.execute( "INSERT OR REPLACE INTO background_jobs(job_id,kind,stage,detail,done,cancelled,error,result,fingerprint,created,updated) " "VALUES (?,?,?,?,?,?,?,?,?,?,?)", (str(job_id), str(kind)[:80], "queued", "Queued", 0, 0, None, None, str(fingerprint)[:128], now, now), ) expired = conn.execute( "SELECT job_id FROM background_jobs WHERE done=1 ORDER BY updated ASC LIMIT -1 OFFSET 255" ).fetchall() for row in expired: conn.execute("DELETE FROM background_jobs WHERE job_id=?", (row["job_id"],)) _remove_background_artifacts(row["job_id"]) conn.commit() def background_job_update(job_id: str, *, stage: str | None = None, detail: str | None = None, done: bool | None = None, cancelled: bool | None = None, error: str | None = None, result: Any = None, updated: float | None = None) -> None: fields = {"updated": float(updated if updated is not None else time.time())} if stage is not None: fields["stage"] = str(stage)[:40] if detail is not None: fields["detail"] = str(detail)[:500] if done is not None: fields["done"] = int(bool(done)) if cancelled is not None: fields["cancelled"] = int(bool(cancelled)) if error is not None: fields["error"] = str(error)[:1000] if result is not None: fields["result"] = _safe_background_result(result) assignments = ", ".join(f"{key}=?" for key in fields) with _open() as conn: conn.execute(f"UPDATE background_jobs SET {assignments} WHERE job_id=?", (*fields.values(), str(job_id))) conn.commit() def background_job_get(job_id: str) -> dict | None: with _open() as conn: row = conn.execute("SELECT * FROM background_jobs WHERE job_id=?", (str(job_id),)).fetchone() if row is None: return None result = dict(row) result["done"] = bool(result["done"]) result["cancelled"] = bool(result["cancelled"]) if result.get("result"): try: result["result"] = json.loads(result["result"]) except (TypeError, ValueError): pass return result def background_job_list(kind: str = "", limit: int = 50) -> list[dict]: limit = max(1, min(int(limit), 100)) with _open() as conn: if kind: rows = conn.execute( "SELECT * FROM background_jobs WHERE kind=? ORDER BY updated DESC LIMIT ?", (str(kind)[:80], limit), ).fetchall() else: rows = conn.execute( "SELECT * FROM background_jobs ORDER BY updated DESC LIMIT ?", (limit,) ).fetchall() return [background_job_get(row["job_id"]) for row in rows] def _row_to_char(row: sqlite3.Row) -> dict: d = dict(row) d["sheet"] = json.loads(d.get("sheet") or "{}") d["analysis"] = json.loads(d.get("analysis") or "null") d["color"] = d["sheet"].get("color") return d def _row_to_reh(row: sqlite3.Row) -> dict: d = dict(row) d["cast"] = json.loads(d.get("cast") or "{}") d["emotions"] = json.loads(d.get("emotions") or "{}") d["notes"] = json.loads(d.get("notes") or "{}") d["ignored"] = json.loads(d.get("ignored") or "{}") d["hidden"] = json.loads(d.get("hidden_lines") or "{}") d["narratorVoice"] = d.pop("narrator_voice", "") d["lineIndex"] = d.pop("line_index", 0) d.pop("hidden_lines", None) return d def _reh_title_key(title: Any) -> str: return " ".join(str(title or "").split()).casefold() def _backup_character_record(record: dict, reason: str) -> Path: """Write a recoverable snapshot before changing or deleting a character. Character images and sheets can be large, so snapshots are individual JSON files rather than an ever-growing database column. The temporary file and replace keep a partially-written backup from looking valid after a crash. A failed backup deliberately aborts the mutation: destructive operations must not proceed when recovery cannot be prepared. """ backup_dir = CONFIG_DIR / "backups" / "characters" backup_dir.mkdir(parents=True, exist_ok=True) stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") safe_reason = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(reason or "update"))[:32] target = backup_dir / f"{stamp}-{safe_reason}-{uuid.uuid4().hex[:8]}.json" payload = {"saved_at": datetime.now(timezone.utc).isoformat(), "reason": reason, "record": record} fd, tmp_name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=backup_dir) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(payload, handle, ensure_ascii=False) handle.write("\n") handle.flush() os.fsync(handle.fileno()) os.replace(tmp_name, target) except Exception: try: os.unlink(tmp_name) except OSError: pass raise return target def _backup_rehearsal_record(record: dict, reason: str) -> Path: """Snapshot a rehearsal before recast, overwrite, compaction or delete.""" backup_dir = CONFIG_DIR / "backups" / "rehearsals" backup_dir.mkdir(parents=True, exist_ok=True) stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") safe_reason = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(reason or "update"))[:32] target = backup_dir / f"{stamp}-{safe_reason}-{uuid.uuid4().hex[:8]}.json" payload = {"saved_at": datetime.now(timezone.utc).isoformat(), "reason": reason, "record": record} fd, tmp_name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=backup_dir) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(payload, handle, ensure_ascii=False) handle.write("\n") handle.flush() os.fsync(handle.fileno()) os.replace(tmp_name, target) except Exception: try: os.unlink(tmp_name) except OSError: pass raise return target # ── Characters ──────────────────────────────────────────────────────────────── def char_get_all() -> list[dict]: with _open() as conn: rows = conn.execute("SELECT * FROM characters ORDER BY book, name").fetchall() return [_row_to_char(r) for r in rows] def char_get(char_id: str) -> dict | None: with _open() as conn: row = conn.execute("SELECT * FROM characters WHERE id=?", (char_id,)).fetchone() return _row_to_char(row) if row else None def char_put(rec: dict) -> dict: _validate_character_record(rec) existing = char_get(rec.get("id", "")) if rec.get("id") else None image = rec.get("image") sheet = rec.get("sheet") or {} concept_art_image = sheet.get("concept_art_image") needs_existing = ( (isinstance(image, str) and image.startswith("/api/characters/")) or (isinstance(concept_art_image, str) and concept_art_image.startswith("/api/characters/")) ) if needs_existing: # /api/characters (the bulk list) hands out lightweight URLs instead # of the real base64 blobs for BOTH the portrait and the reference # sheet image (see routes/characters.py) — the reference sheet was # added later and, until this guard, had no equivalent protection: a # record round-tripped from that list and saved back here would # silently overwrite the real stored reference image with this # placeholder URL string, permanently losing it. Keep whatever is # already on the row for either field instead. if isinstance(image, str) and image.startswith("/api/characters/"): image = existing.get("image") if existing else None if isinstance(concept_art_image, str) and concept_art_image.startswith("/api/characters/"): sheet = {**sheet, "concept_art_image": (existing.get("sheet") or {}).get("concept_art_image") if existing else None} rec = {**rec, "image": image, "sheet": sheet} if existing is not None and existing != rec: _backup_character_record(existing, "update") with _open() as conn: conn.execute(""" INSERT INTO characters (id, book, name, tags, voice, image, sheet, analysis, created, updated) VALUES (:id,:book,:name,:tags,:voice,:image,:sheet,:analysis,:created,:updated) ON CONFLICT(id) DO UPDATE SET book=excluded.book, name=excluded.name, tags=excluded.tags, voice=excluded.voice, image=excluded.image, sheet=excluded.sheet, analysis=excluded.analysis, updated=excluded.updated """, { "id": rec.get("id", ""), "book": rec.get("book", ""), "name": rec.get("name", ""), "tags": rec.get("tags", ""), "voice": rec.get("voice"), "image": rec.get("image"), "sheet": _j(rec.get("sheet", {})), "analysis": _j(rec.get("analysis")) if rec.get("analysis") is not None else None, "created": str(rec.get("created", "")), "updated": str(rec.get("updated", "")), }) conn.commit() return rec def char_delete(char_id: str) -> bool: existing = char_get(char_id) if existing is None: return False _backup_character_record(existing, "delete") with _open() as conn: conn.execute("DELETE FROM characters WHERE id=?", (char_id,)) conn.commit() return True # ── Rehearsals ──────────────────────────────────────────────────────────────── def reh_get_all() -> list[dict]: with _open() as conn: rows = conn.execute( "SELECT * FROM rehearsals ORDER BY updated DESC" ).fetchall() return [_row_to_reh(r) for r in rows] def reh_get(reh_id: int) -> dict | None: with _open() as conn: row = conn.execute("SELECT * FROM rehearsals WHERE id=?", (reh_id,)).fetchone() return _row_to_reh(row) if row else None def reh_find_by_title(title: str) -> dict | None: key = _reh_title_key(title) if not key: return None with _open() as conn: rows = conn.execute( "SELECT * FROM rehearsals ORDER BY updated DESC, id DESC" ).fetchall() for row in rows: rec = _row_to_reh(row) if _reh_title_key(rec.get("title")) == key: return rec return None def reh_compact_titles() -> int: """Remove older duplicate rehearsals that share the same title. We keep the most recently updated row for each normalized title and delete the rest. This prevents the library from accumulating repeated copies when auto-save/import paths reuse the same book title. """ with _open() as conn: rows = conn.execute( "SELECT id, title, updated FROM rehearsals ORDER BY updated DESC, id DESC" ).fetchall() seen: set[str] = set() delete_ids: list[int] = [] for row in rows: key = _reh_title_key(row["title"]) if not key: continue if key in seen: delete_ids.append(int(row["id"])) else: seen.add(key) for reh_id in delete_ids: old = reh_get(reh_id) if old is not None: _backup_rehearsal_record(old, "compact") conn.execute("DELETE FROM rehearsals WHERE id=?", (reh_id,)) if delete_ids: conn.commit() return len(delete_ids) def reh_put(rec: dict) -> dict: """Insert (no id) or full replace (id present). Returns record with id.""" reh_id = rec.get("id") title = str(rec.get("title", "") or "") if not reh_id and title.strip(): existing = reh_find_by_title(title) if existing: reh_id = existing["id"] # Keep the original created timestamp when a title-based save updates # an existing rehearsal rather than creating a new library copy. rec = dict(rec) rec["created"] = existing.get("created") or rec.get("created") existing_by_id = reh_get(int(reh_id)) if reh_id else None row = { "title": title, "script": rec.get("script", ""), "cast": _j(rec.get("cast", {})), "emotions": _j(rec.get("emotions", {})), "notes": _j(rec.get("notes", {})), "ignored": _j(rec.get("ignored", {})), "hidden_lines": _j(rec.get("hidden", {})), "backend": rec.get("backend", ""), "narrator_voice": rec.get("narratorVoice", ""), "line_index": int(rec.get("lineIndex", 0)), "created": str(rec.get("created", "")), "updated": str(rec.get("updated", "")), } with _open() as conn: if reh_id: if existing_by_id is not None: candidate = dict(rec) candidate["id"] = int(reh_id) if candidate != existing_by_id: _backup_rehearsal_record(existing_by_id, "update") row["id"] = int(reh_id) conn.execute(""" INSERT INTO rehearsals (id,title,script,cast,emotions,notes,ignored,hidden_lines, backend,narrator_voice,line_index,created,updated) VALUES (:id,:title,:script,:cast,:emotions,:notes,:ignored,:hidden_lines, :backend,:narrator_voice,:line_index,:created,:updated) ON CONFLICT(id) DO UPDATE SET title=excluded.title, script=excluded.script, cast=excluded.cast, emotions=excluded.emotions, notes=excluded.notes, ignored=excluded.ignored, hidden_lines=excluded.hidden_lines, backend=excluded.backend, narrator_voice=excluded.narrator_voice, line_index=excluded.line_index, updated=excluded.updated """, row) conn.commit() else: cur = conn.execute(""" INSERT INTO rehearsals (title,script,cast,emotions,notes,ignored,hidden_lines, backend,narrator_voice,line_index,created,updated) VALUES (:title,:script,:cast,:emotions,:notes,:ignored,:hidden_lines, :backend,:narrator_voice,:line_index,:created,:updated) """, row) conn.commit() reh_id = cur.lastrowid rec = dict(rec) rec["id"] = reh_id return rec def reh_delete(reh_id: int) -> bool: existing = reh_get(reh_id) if existing is None: return False _backup_rehearsal_record(existing, "delete") with _open() as conn: conn.execute("DELETE FROM rehearsals WHERE id=?", (reh_id,)) conn.commit() return True # ── Reader scripts / audiobook casts ───────────────────────────────────────── def reader_script_list(doc_id: str) -> list[dict]: with _open() as conn: rows = conn.execute( "SELECT name, title, payload, saved_at, updated FROM reader_scripts WHERE doc_id=? ORDER BY updated DESC", (doc_id,), ).fetchall() out: list[dict] = [] for row in rows: payload: dict = {} try: payload = json.loads(row["payload"] or "{}") except Exception: payload = {} out.append({ "name": row["name"], "title": row["title"], "savedAt": row["saved_at"] or payload.get("savedAt"), "updated": row["updated"], "segments": len(payload.get("segments", [])) if isinstance(payload.get("segments"), list) else 0, "roster": payload.get("roster", []), }) return out def reader_script_get(doc_id: str, name: str = "cast") -> dict | None: with _open() as conn: row = conn.execute( "SELECT payload FROM reader_scripts WHERE doc_id=? AND name=?", (doc_id, name), ).fetchone() if not row: return None try: data = json.loads(row["payload"] or "{}") return data if isinstance(data, dict) else None except Exception: return None def reader_script_put(doc_id: str, name: str, data: dict) -> dict: now = _now() with _open() as conn: existing = conn.execute( "SELECT created FROM reader_scripts WHERE doc_id=? AND name=?", (doc_id, name), ).fetchone() created = existing["created"] if existing else now conn.execute(""" INSERT INTO reader_scripts (doc_id, name, title, payload, saved_at, created, updated) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(doc_id, name) DO UPDATE SET title=excluded.title, payload=excluded.payload, saved_at=excluded.saved_at, updated=excluded.updated """, ( doc_id, name, str(data.get("title", "")), _j(data), str(data.get("savedAt", "")), created, now, )) conn.commit() return data def reader_script_delete(doc_id: str, name: str = "cast") -> bool: with _open() as conn: conn.execute("DELETE FROM reader_scripts WHERE doc_id=? AND name=?", (doc_id, name)) conn.commit() return True # ── Generic app state ───────────────────────────────────────────────────────── def state_get(key: str, default: Any = None) -> Any: with _open() as conn: row = conn.execute("SELECT value FROM app_state WHERE key=?", (key,)).fetchone() if not row: return default try: return json.loads(row["value"]) except Exception: return default def state_updated(key: str) -> str: with _open() as conn: row = conn.execute("SELECT updated FROM app_state WHERE key=?", (key,)).fetchone() return str(row["updated"]) if row else "" def state_put(key: str, value: Any) -> Any: updated = _now() with _open() as conn: conn.execute(""" INSERT INTO app_state (key, value, updated) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated=excluded.updated """, (key, _j(value), updated)) conn.commit() return value def state_delete(key: str) -> bool: with _open() as conn: conn.execute("DELETE FROM app_state WHERE key=?", (key,)) conn.commit() return True # ── Voice library index ─────────────────────────────────────────────────────── def voice_index_get_all() -> list[dict]: with _open() as conn: rows = conn.execute( "SELECT entry_json FROM voice_index ORDER BY sort_key, voice_id" ).fetchall() voices: list[dict] = [] for row in rows: try: entry = json.loads(row["entry_json"] or "{}") if isinstance(entry, dict): voices.append(entry) except Exception: continue return voices def voice_index_count() -> int: with _open() as conn: row = conn.execute("SELECT COUNT(*) AS n FROM voice_index").fetchone() return int(row["n"] if row else 0) def voice_index_replace_all(rows: list[dict]) -> None: indexed_at = _now() with _open() as conn: conn.execute("DELETE FROM voice_index") conn.executemany(""" INSERT INTO voice_index ( voice_id, path, sort_key, enabled, file_type, file_mtime, file_size, meta_mtime, ref_mtime, picture_mtime, entry_json, indexed_at ) VALUES ( :voice_id, :path, :sort_key, :enabled, :file_type, :file_mtime, :file_size, :meta_mtime, :ref_mtime, :picture_mtime, :entry_json, :indexed_at ) """, [ { "voice_id": str(row.get("voice_id", "")), "path": str(row.get("path", "")), "sort_key": str(row.get("sort_key", "")), "enabled": 1 if row.get("enabled", True) else 0, "file_type": str(row.get("file_type", "")), "file_mtime": float(row.get("file_mtime") or 0), "file_size": int(row.get("file_size") or 0), "meta_mtime": float(row.get("meta_mtime") or 0), "ref_mtime": float(row.get("ref_mtime") or 0), "picture_mtime": float(row.get("picture_mtime") or 0), "entry_json": _j(row.get("entry", {})), "indexed_at": indexed_at, } for row in rows if row.get("voice_id") and row.get("path") ]) conn.execute(""" INSERT INTO app_state (key, value, updated) VALUES ('voice_index_status', ?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated=excluded.updated """, (_j({"count": len(rows), "indexed_at": indexed_at}), indexed_at)) conn.commit() def voice_index_upsert(row: dict) -> None: indexed_at = _now() with _open() as conn: conn.execute(""" INSERT INTO voice_index ( voice_id, path, sort_key, enabled, file_type, file_mtime, file_size, meta_mtime, ref_mtime, picture_mtime, entry_json, indexed_at ) VALUES ( :voice_id, :path, :sort_key, :enabled, :file_type, :file_mtime, :file_size, :meta_mtime, :ref_mtime, :picture_mtime, :entry_json, :indexed_at ) ON CONFLICT(voice_id) DO UPDATE SET path=excluded.path, sort_key=excluded.sort_key, enabled=excluded.enabled, file_type=excluded.file_type, file_mtime=excluded.file_mtime, file_size=excluded.file_size, meta_mtime=excluded.meta_mtime, ref_mtime=excluded.ref_mtime, picture_mtime=excluded.picture_mtime, entry_json=excluded.entry_json, indexed_at=excluded.indexed_at """, { "voice_id": str(row.get("voice_id", "")), "path": str(row.get("path", "")), "sort_key": str(row.get("sort_key", "")), "enabled": 1 if row.get("enabled", True) else 0, "file_type": str(row.get("file_type", "")), "file_mtime": float(row.get("file_mtime") or 0), "file_size": int(row.get("file_size") or 0), "meta_mtime": float(row.get("meta_mtime") or 0), "ref_mtime": float(row.get("ref_mtime") or 0), "picture_mtime": float(row.get("picture_mtime") or 0), "entry_json": _j(row.get("entry", {})), "indexed_at": indexed_at, }) conn.commit() def voice_index_delete(voice_id: str) -> bool: with _open() as conn: conn.execute("DELETE FROM voice_index WHERE voice_id=?", (voice_id,)) conn.commit() return True def voice_index_status() -> dict: status = state_get("voice_index_status", {}) or {} if not isinstance(status, dict): status = {} status.setdefault("count", voice_index_count()) status.setdefault("indexed_at", state_updated("voice_index_status")) return status