921 lines
36 KiB
Python
921 lines
36 KiB
Python
"""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);
|
|
|
|
-- Derived data only: English versions of character text for image prompts.
|
|
-- Keyed by sha256(source)|lang|model|prompt-version, so editing the source text
|
|
-- (or changing the translator) simply misses; character records are never touched.
|
|
CREATE TABLE IF NOT EXISTS prompt_translation_cache (
|
|
cache_key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
created TEXT NOT NULL
|
|
);
|
|
"""
|
|
|
|
|
|
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 prompt_translation_cache_get(key: str) -> Any | None:
|
|
with _open() as conn:
|
|
row = conn.execute("SELECT value FROM prompt_translation_cache WHERE cache_key=?", (key,)).fetchone()
|
|
return json.loads(row["value"]) if row else None
|
|
|
|
|
|
def prompt_translation_cache_put(key: str, value: Any) -> None:
|
|
with _open() as conn:
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO prompt_translation_cache(cache_key, value, created) VALUES (?, ?, ?)",
|
|
(key, _j(value), _now()),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
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
|