import re import sqlite3 import logging from contextlib import contextmanager from core.config import DB_PATH logger = logging.getLogger("omnivoice.db") _IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _TYPE_RE = re.compile(r"^[A-Za-z0-9_ '\"\(\)\-\.]+$") def get_db(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") return conn @contextmanager def db_conn(): """Context-managed SQLite connection that commits on clean exit and always closes.""" conn = get_db() try: yield conn conn.commit() except Exception: try: conn.rollback() except Exception: pass raise finally: conn.close() _BASE_SCHEMA = """ CREATE TABLE IF NOT EXISTS voice_profiles ( id TEXT PRIMARY KEY, name TEXT NOT NULL, ref_audio_path TEXT, ref_text TEXT DEFAULT '', instruct TEXT DEFAULT '', language TEXT DEFAULT 'Auto', locked_audio_path TEXT DEFAULT '', seed INTEGER DEFAULT NULL, is_locked INTEGER DEFAULT 0, personality TEXT DEFAULT '', created_at REAL ); CREATE TABLE IF NOT EXISTS generation_history ( id TEXT PRIMARY KEY, text TEXT, mode TEXT, language TEXT, instruct TEXT, profile_id TEXT, audio_path TEXT, duration_seconds REAL, generation_time REAL, seed INTEGER DEFAULT NULL, created_at REAL, FOREIGN KEY (profile_id) REFERENCES voice_profiles(id) ); CREATE TABLE IF NOT EXISTS dub_history ( id TEXT PRIMARY KEY, filename TEXT, duration REAL, segments_count INTEGER, language TEXT, language_code TEXT, tracks TEXT DEFAULT '[]', job_data TEXT, content_hash TEXT DEFAULT '', created_at REAL ); CREATE TABLE IF NOT EXISTS studio_projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, video_path TEXT, audio_path TEXT, duration REAL, state_json TEXT, created_at REAL, updated_at REAL ); CREATE TABLE IF NOT EXISTS export_history ( id TEXT PRIMARY KEY, filename TEXT, destination_path TEXT, mode TEXT, created_at REAL ); CREATE TABLE IF NOT EXISTS glossary_terms ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, source TEXT NOT NULL, target TEXT NOT NULL, note TEXT DEFAULT '', auto INTEGER DEFAULT 0, created_at REAL ); CREATE INDEX IF NOT EXISTS idx_glossary_project ON glossary_terms(project_id); CREATE TABLE IF NOT EXISTS jobs ( id TEXT PRIMARY KEY, type TEXT NOT NULL, project_id TEXT, status TEXT NOT NULL, created_at REAL NOT NULL, updated_at REAL NOT NULL, finished_at REAL, error TEXT, meta_json TEXT DEFAULT '{}' ); CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); CREATE INDEX IF NOT EXISTS idx_jobs_project ON jobs(project_id); CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at); CREATE TABLE IF NOT EXISTS job_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT NOT NULL, seq INTEGER NOT NULL, created_at REAL NOT NULL, payload TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_job_events_job_seq ON job_events(job_id, seq); -- Phase 1 AUTH-02: encrypted per-install key/value store. Used today -- for the HF token row + the per-install Fernet salt. Both fresh -- installs (this CREATE) and v0.2.7 upgrades (alembic -- 0001_phase1_settings) converge on the same schema. CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at REAL NOT NULL ); """ # Only tables/columns this module is allowed to ALTER. Prevents SQL injection via # the f-string ALTER below if these helpers ever get exposed to user input. _ALLOWED_MIGRATIONS = { ("voice_profiles", "locked_audio_path"), ("voice_profiles", "seed"), ("voice_profiles", "is_locked"), ("voice_profiles", "personality"), ("generation_history", "seed"), ("dub_history", "content_hash"), } def _add_column_if_missing(conn, table: str, column: str, typedef: str): if (table, column) not in _ALLOWED_MIGRATIONS: raise ValueError(f"Migration not allowed: {table}.{column}") if not _IDENT_RE.match(table) or not _IDENT_RE.match(column): raise ValueError(f"Invalid identifier: {table}.{column}") if not _TYPE_RE.match(typedef): raise ValueError(f"Invalid typedef: {typedef!r}") try: conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {typedef}") except sqlite3.OperationalError as e: if "duplicate column" not in str(e).lower(): logger.warning("ALTER %s.%s failed: %s", table, column, e) def _migrate(conn, current: int) -> int: """Apply migrations sequentially. Return new version.""" if current < 1: _add_column_if_missing(conn, "voice_profiles", "locked_audio_path", "TEXT DEFAULT ''") _add_column_if_missing(conn, "voice_profiles", "seed", "INTEGER DEFAULT NULL") _add_column_if_missing(conn, "voice_profiles", "is_locked", "INTEGER DEFAULT 0") _add_column_if_missing(conn, "generation_history", "seed", "INTEGER DEFAULT NULL") current = 1 if current < 2: _add_column_if_missing(conn, "dub_history", "content_hash", "TEXT DEFAULT ''") current = 2 # v3: glossary_terms table lives in _BASE_SCHEMA (IF NOT EXISTS), so an old # DB simply picks it up on the next init — no ALTER needed. if current < 3: current = 3 if current < 4: _add_column_if_missing(conn, "voice_profiles", "personality", "TEXT DEFAULT ''") current = 4 return current def init_db(): conn = get_db() try: conn.executescript(_BASE_SCHEMA) version = conn.execute("PRAGMA user_version").fetchone()[0] new_version = _migrate(conn, version) if new_version != version: conn.execute(f"PRAGMA user_version = {new_version}") conn.commit() finally: conn.close() # Phase 1: also run any pending alembic migrations. Fresh installs land # the schema via _BASE_SCHEMA above; v0.2.7 → v0.3.0 upgrades pick up # the same end-state via the alembic versions/ chain. Both paths # converge because every migration uses `CREATE TABLE IF NOT EXISTS` # or explicit existence checks. _run_alembic_upgrade() def _run_alembic_upgrade() -> None: """Best-effort `alembic upgrade head` on startup. Non-fatal: if alembic isn't reachable (e.g. user running a stripped-down install or migrations were already applied out-of-band), log a warning and move on. The _BASE_SCHEMA CREATE TABLE IF NOT EXISTS above guarantees the runtime schema is correct regardless.""" try: import os from alembic import command from alembic.config import Config # Walk up from backend/core/db.py to find the alembic.ini at the # project root. here = os.path.dirname(os.path.abspath(__file__)) root = os.path.dirname(os.path.dirname(here)) ini = os.path.join(root, "alembic.ini") if not os.path.isfile(ini): logger.debug("alembic.ini not found at %s; skipping migrations", ini) return cfg = Config(ini) cfg.set_main_option("sqlalchemy.url", f"sqlite:///{DB_PATH}") command.upgrade(cfg, "head") except Exception as exc: # Don't block startup on a migration tooling problem. The runtime # schema is already correct via _BASE_SCHEMA. logger.warning("alembic upgrade head skipped: %s", exc)