tts-voice-creator-clone-and.../core/database.py

728 lines
28 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 sqlite3
import tempfile
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"
_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 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()),
)
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."""
safe_prompt = str(prompt or "")[:12000]
safe_result = _j(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 _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