Lê Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
7.97 kB
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)