Files
VoiceStudio/backend/core/db.py
T
debpalashandClaude Opus 4.7 67328d04fe refactor: split backend into api/core/services/schemas, harden security + fd pressure, add searchable language picker, fix segment fragmentation
Backend:
- Split monolithic main.py into backend/{api/routers,core,schemas,services}
- core/db.py: allowlist-gated migrations, db_conn context manager (kills SQL injection on ALTER)
- core/tasks.py: lock-guarded listener add/remove/push, snapshot-before-iterate
- services/ffmpeg_utils.py: run_ffmpeg helper with concurrency semaphore, EAGAIN retry, guaranteed reap
- services/segmentation.py: Bengali/CJK/Arabic punctuation, ultra-short tier, stitch_adjacent_shorts,
  bounded-loop merge; public clean_up_segments API
- services/model_manager.py: robust lock.locked() handling
- api/routers/dub_core.py: job_id traversal guard, thread-safe _active_procs, timeouts on ffmpeg/demucs,
  POST /dub/cleanup-segments endpoint
- api/routers/dub_export.py: guarded SSE listener remove, ffmpeg timeouts via run_ffmpeg
- api/routers/exports.py: destination_path validation, safe source resolver, subprocess list-form
- api/routers/generation.py: contextlib.suppress on tempfile cleanup, db_conn usage, safe output-path helper
- api/routers/system.py: try/finally tmp cleanup, subprocess timeouts
- schemas/requests.py: TranslateSegment.id int->str to match hex segment IDs
- main.py: threading.Lock around crash log writes

Frontend:
- components/SearchableSelect.jsx: popover combobox with search, keyboard nav, popular+recent pins, 200-item cap
- App.jsx: wire SearchableSelect for dub language / ISO code / voice-gen language; Clean Up segments button;
  fix blob URL leak (object-shaped prev in setter, unmount cleanup via ref)
- components/WaveformTimeline.jsx: explicit <video> detach instead of innerHTML='' to release decoder
- index.css: ss-* combobox styles matching Gruvbox theme

Tests:
- tests/test_segmentation.py (26 cases), test_dub_transcribe.py, test_dub_export_unique.py, conftest.py

Chore:
- .gitignore: exclude omnivoice.zip, /research/ reference clones
- Remove tracked stray root test scripts + crash_log.txt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:20:19 +05:30

142 lines
4.2 KiB
Python

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,
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,
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
);
"""
# 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"),
("generation_history", "seed"),
}
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
# Future migrations: if current < 2: ...; current = 2
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()