import json import sqlite3 from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional import uuid from .models import SentenceBoundary, SentenceBoundaryDocument from .store import normalize_video_hash def utc_now() -> str: return datetime.now(timezone.utc).isoformat() class VideoRepository: def __init__(self, database_path: Path): self.database_path = Path(database_path) self.database_path.parent.mkdir(parents=True, exist_ok=True) self._initialize() def _connect(self) -> sqlite3.Connection: connection = sqlite3.connect(self.database_path, timeout=30) connection.row_factory = sqlite3.Row connection.execute("PRAGMA foreign_keys = ON") connection.execute("PRAGMA journal_mode = WAL") return connection def _initialize(self) -> None: with self._connect() as connection: connection.executescript( """ CREATE TABLE IF NOT EXISTS videos ( video_hash TEXT PRIMARY KEY, title TEXT NOT NULL, filename TEXT NOT NULL, stored_filename TEXT NOT NULL, content_type TEXT NOT NULL, size_bytes INTEGER NOT NULL, duration_ms INTEGER, language TEXT, status TEXT NOT NULL, error_message TEXT, algorithm_version TEXT, transcription TEXT, remote_url TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sentences ( video_hash TEXT NOT NULL, sentence_index INTEGER NOT NULL, start_ms INTEGER NOT NULL, end_ms INTEGER NOT NULL, text TEXT, language TEXT, reference_speech_duration_ms INTEGER, PRIMARY KEY (video_hash, sentence_index), FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS attempts ( attempt_id TEXT PRIMARY KEY, video_hash TEXT NOT NULL, sentence_index INTEGER NOT NULL, result_json TEXT NOT NULL, audio_filename TEXT, created_at TEXT NOT NULL, FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS dub_shares ( share_id TEXT PRIMARY KEY, video_hash TEXT NOT NULL, title TEXT NOT NULL, created_at TEXT NOT NULL, FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS dub_share_segments ( id INTEGER PRIMARY KEY AUTOINCREMENT, share_id TEXT NOT NULL, sentence_index INTEGER NOT NULL, start_ms INTEGER NOT NULL, end_ms INTEGER NOT NULL, text TEXT, audio_filename TEXT NOT NULL, audio_size_bytes INTEGER NOT NULL, overall_score REAL NOT NULL DEFAULT 0, recognized_text TEXT NOT NULL DEFAULT '', score_details TEXT NOT NULL DEFAULT '{}', FOREIGN KEY (share_id) REFERENCES dub_shares(share_id) ON DELETE CASCADE, UNIQUE (share_id, sentence_index) ); """ ) with self._connect() as connection: columns = { row["name"] for row in connection.execute("PRAGMA table_info(dub_share_segments)") } if "overall_score" not in columns: connection.execute( "ALTER TABLE dub_share_segments ADD COLUMN overall_score REAL NOT NULL DEFAULT 0" ) if "recognized_text" not in columns: connection.execute( "ALTER TABLE dub_share_segments ADD COLUMN recognized_text TEXT NOT NULL DEFAULT ''" ) if "score_details" not in columns: connection.execute( "ALTER TABLE dub_share_segments ADD COLUMN score_details TEXT NOT NULL DEFAULT '{}'" ) with self._connect() as connection: video_columns = { row["name"] for row in connection.execute("PRAGMA table_info(videos)") } if "remote_url" not in video_columns: connection.execute("ALTER TABLE videos ADD COLUMN remote_url TEXT") def upsert_upload( self, *, video_hash: str, title: str, filename: str, stored_filename: str, content_type: str, size_bytes: int, language: Optional[str], ) -> None: normalized_hash = normalize_video_hash(video_hash) now = utc_now() with self._connect() as connection: existing = connection.execute( "SELECT created_at FROM videos WHERE video_hash = ?", (normalized_hash,), ).fetchone() created_at = existing["created_at"] if existing else now connection.execute( """ INSERT INTO videos ( video_hash, title, filename, stored_filename, content_type, size_bytes, duration_ms, language, status, error_message, algorithm_version, transcription, remote_url, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, 'uploaded', NULL, NULL, NULL, NULL, ?, ?) ON CONFLICT(video_hash) DO UPDATE SET title = excluded.title, filename = excluded.filename, stored_filename = excluded.stored_filename, content_type = excluded.content_type, size_bytes = excluded.size_bytes, language = excluded.language, status = 'uploaded', error_message = NULL, updated_at = excluded.updated_at """, ( normalized_hash, title, filename, stored_filename, content_type, size_bytes, language, created_at, now, ), ) def mark_processing(self, video_hash: str) -> None: self._update_status(video_hash, "processing", None) def mark_failed(self, video_hash: str, message: str) -> None: self._update_status(video_hash, "failed", message[:4000]) def _update_status(self, video_hash: str, status: str, error_message: Optional[str]) -> None: with self._connect() as connection: connection.execute( "UPDATE videos SET status = ?, error_message = ?, updated_at = ? WHERE video_hash = ?", (status, error_message, utc_now(), normalize_video_hash(video_hash)), ) def save_processing_result( self, document: SentenceBoundaryDocument, transcription: Optional[str], ready: bool = True, ) -> None: with self._connect() as connection: connection.execute( "DELETE FROM sentences WHERE video_hash = ?", (document.video_hash,), ) connection.executemany( """ INSERT INTO sentences ( video_hash, sentence_index, start_ms, end_ms, text, language, reference_speech_duration_ms ) VALUES (?, ?, ?, ?, ?, ?, ?) """, [ ( document.video_hash, sentence.index, sentence.start_ms, sentence.end_ms, sentence.text, sentence.language, sentence.reference_speech_duration_ms, ) for sentence in document.sentences ], ) connection.execute( """ UPDATE videos SET duration_ms = ?, status = ?, error_message = NULL, algorithm_version = ?, transcription = ?, updated_at = ? WHERE video_hash = ? """, ( document.duration_ms, "ready" if ready else "processing", document.algorithm_version, transcription, utc_now(), document.video_hash, ), ) def mark_oss_uploaded(self, video_hash: str, remote_url: str) -> None: with self._connect() as connection: connection.execute( """ UPDATE videos SET remote_url = ?, status = 'ready', error_message = NULL, updated_at = ? WHERE video_hash = ? """, (remote_url, utc_now(), normalize_video_hash(video_hash)), ) def list_videos(self) -> List[Dict[str, Any]]: with self._connect() as connection: rows = connection.execute( """ SELECT v.*, COUNT(s.sentence_index) AS sentence_count FROM videos v LEFT JOIN sentences s ON s.video_hash = v.video_hash GROUP BY v.video_hash ORDER BY v.created_at DESC """ ).fetchall() return [dict(row) for row in rows] def get_video(self, video_hash: str) -> Optional[Dict[str, Any]]: with self._connect() as connection: row = connection.execute( """ SELECT v.*, COUNT(s.sentence_index) AS sentence_count FROM videos v LEFT JOIN sentences s ON s.video_hash = v.video_hash WHERE v.video_hash = ? GROUP BY v.video_hash """, (normalize_video_hash(video_hash),), ).fetchone() return dict(row) if row else None def get_document(self, video_hash: str) -> Optional[SentenceBoundaryDocument]: video = self.get_video(video_hash) if video is None or video["duration_ms"] is None: return None with self._connect() as connection: rows = connection.execute( "SELECT * FROM sentences WHERE video_hash = ? ORDER BY sentence_index", (normalize_video_hash(video_hash),), ).fetchall() return SentenceBoundaryDocument( video_hash=video["video_hash"], duration_ms=video["duration_ms"], algorithm_version=video["algorithm_version"] or "unknown", sentences=[ SentenceBoundary( index=row["sentence_index"], start_ms=row["start_ms"], end_ms=row["end_ms"], text=row["text"], language=row["language"], reference_speech_duration_ms=row["reference_speech_duration_ms"], ) for row in rows ], ) def update_sentence_text( self, video_hash: str, sentence_index: int, text: str, language: Optional[str], ) -> Optional[SentenceBoundary]: normalized_hash = normalize_video_hash(video_hash) with self._connect() as connection: cursor = connection.execute( """ UPDATE sentences SET text = ?, language = COALESCE(?, language) WHERE video_hash = ? AND sentence_index = ? """, (text.strip(), language, normalized_hash, sentence_index), ) if cursor.rowcount == 0: return None connection.execute( "UPDATE videos SET updated_at = ? WHERE video_hash = ?", (utc_now(), normalized_hash), ) row = connection.execute( "SELECT * FROM sentences WHERE video_hash = ? AND sentence_index = ?", (normalized_hash, sentence_index), ).fetchone() return SentenceBoundary( index=row["sentence_index"], start_ms=row["start_ms"], end_ms=row["end_ms"], text=row["text"], language=row["language"], reference_speech_duration_ms=row["reference_speech_duration_ms"], ) def get_sentence( self, video_hash: str, sentence_index: int, ) -> Optional[SentenceBoundary]: normalized_hash = normalize_video_hash(video_hash) with self._connect() as connection: row = connection.execute( """ SELECT * FROM sentences WHERE video_hash = ? AND sentence_index = ? """, (normalized_hash, sentence_index), ).fetchone() if row is None: return None return SentenceBoundary( index=row["sentence_index"], start_ms=row["start_ms"], end_ms=row["end_ms"], text=row["text"], language=row["language"], reference_speech_duration_ms=row["reference_speech_duration_ms"], ) def replace_sentence( self, video_hash: str, sentence_index: int, replacements: List[SentenceBoundary], ) -> Optional[List[SentenceBoundary]]: """Replace one sentence row with one or more contiguous sentence rows.""" normalized_hash = normalize_video_hash(video_hash) if not replacements: raise ValueError("At least one replacement sentence is required.") with self._connect() as connection: source = connection.execute( """ SELECT * FROM sentences WHERE video_hash = ? AND sentence_index = ? """, (normalized_hash, sentence_index), ).fetchone() if source is None: return None rows = [ SentenceBoundary( index=index, start_ms=replacement.start_ms, end_ms=replacement.end_ms, text=replacement.text.strip(), language=replacement.language, reference_speech_duration_ms=max( 1, replacement.reference_speech_duration_ms or 1 ), ) for index, replacement in enumerate(replacements) ] previous_end = source["start_ms"] for replacement in rows: if ( replacement.start_ms < previous_end or replacement.end_ms <= replacement.start_ms ): raise ValueError("Replacement sentence times must be contiguous.") if replacement.end_ms > source["end_ms"]: raise ValueError( "Replacement sentences cannot exceed the source sentence." ) previous_end = replacement.end_ms if rows[-1].end_ms != source["end_ms"]: raise ValueError("Replacement sentences must cover the source sentence.") # Shift following rows down first. Descending indexes avoid violating # the (video_hash, sentence_index) primary key while making room. added_count = len(rows) - 1 if added_count: following = [ row["sentence_index"] for row in connection.execute( """ SELECT sentence_index FROM sentences WHERE video_hash = ? AND sentence_index > ? ORDER BY sentence_index DESC """, (normalized_hash, sentence_index), ).fetchall() ] for old_index in following: connection.execute( """ UPDATE sentences SET sentence_index = ? WHERE video_hash = ? AND sentence_index = ? """, (old_index + added_count, normalized_hash, old_index), ) first = rows[0] connection.execute( """ UPDATE sentences SET start_ms = ?, end_ms = ?, text = ?, language = ?, reference_speech_duration_ms = ? WHERE video_hash = ? AND sentence_index = ? """, ( first.start_ms, first.end_ms, first.text, first.language, first.reference_speech_duration_ms, normalized_hash, sentence_index, ), ) for replacement in rows[1:]: connection.execute( """ INSERT INTO sentences ( video_hash, sentence_index, start_ms, end_ms, text, language, reference_speech_duration_ms ) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( normalized_hash, sentence_index + replacement.index, replacement.start_ms, replacement.end_ms, replacement.text, replacement.language, replacement.reference_speech_duration_ms, ), ) connection.execute( "UPDATE videos SET updated_at = ? WHERE video_hash = ?", (utc_now(), normalized_hash), ) result_rows = connection.execute( """ SELECT * FROM sentences WHERE video_hash = ? AND sentence_index >= ? AND sentence_index < ? ORDER BY sentence_index """, (normalized_hash, sentence_index, sentence_index + len(rows)), ).fetchall() return [ SentenceBoundary( index=row["sentence_index"], start_ms=row["start_ms"], end_ms=row["end_ms"], text=row["text"], language=row["language"], reference_speech_duration_ms=row["reference_speech_duration_ms"], ) for row in result_rows ] def adjust_sentence_boundary( self, video_hash: str, sentence_index: int, delta_ms: int, ) -> Optional[List[SentenceBoundary]]: """Move one sentence end and link the next sentence's start to it.""" normalized_hash = normalize_video_hash(video_hash) with self._connect() as connection: video = connection.execute( "SELECT duration_ms FROM videos WHERE video_hash = ?", (normalized_hash,), ).fetchone() if video is None or video["duration_ms"] is None: return None current = connection.execute( """ SELECT * FROM sentences WHERE video_hash = ? AND sentence_index = ? """, (normalized_hash, sentence_index), ).fetchone() if current is None: return None following = connection.execute( """ SELECT * FROM sentences WHERE video_hash = ? AND sentence_index = ? """, (normalized_hash, sentence_index + 1), ).fetchone() duration_ms = int(video["duration_ms"]) minimum_end = current["start_ms"] + 1 maximum_end = duration_ms if following is not None: maximum_end = min(maximum_end, following["end_ms"] - 1) new_end_ms = current["end_ms"] + delta_ms if new_end_ms < minimum_end or new_end_ms > maximum_end: raise ValueError( "The adjusted boundary must remain inside both sentences." ) current_duration_ms = new_end_ms - current["start_ms"] current_reference_ms = current["reference_speech_duration_ms"] or current_duration_ms current_reference_ms = min( current_duration_ms, max(1, current_reference_ms + delta_ms), ) connection.execute( """ UPDATE sentences SET end_ms = ?, reference_speech_duration_ms = ? WHERE video_hash = ? AND sentence_index = ? """, ( new_end_ms, current_reference_ms, normalized_hash, sentence_index, ), ) updated_indexes = [sentence_index] if following is not None: old_start_ms = following["start_ms"] next_duration_ms = following["end_ms"] - new_end_ms next_reference_ms = following["reference_speech_duration_ms"] or next_duration_ms next_reference_ms = min( next_duration_ms, max(1, next_reference_ms - (new_end_ms - old_start_ms)), ) connection.execute( """ UPDATE sentences SET start_ms = ?, reference_speech_duration_ms = ? WHERE video_hash = ? AND sentence_index = ? """, ( new_end_ms, next_reference_ms, normalized_hash, sentence_index + 1, ), ) updated_indexes.append(sentence_index + 1) connection.execute( "UPDATE videos SET updated_at = ? WHERE video_hash = ?", (utc_now(), normalized_hash), ) placeholders = ", ".join("?" for _ in updated_indexes) result_rows = connection.execute( f""" SELECT * FROM sentences WHERE video_hash = ? AND sentence_index IN ({placeholders}) ORDER BY sentence_index """, (normalized_hash, *updated_indexes), ).fetchall() return [ SentenceBoundary( index=row["sentence_index"], start_ms=row["start_ms"], end_ms=row["end_ms"], text=row["text"], language=row["language"], reference_speech_duration_ms=row["reference_speech_duration_ms"], ) for row in result_rows ] def create_dub_share( self, *, video_hash: str, title: str, segments: List[Dict[str, Any]], ) -> Optional[Dict[str, Any]]: normalized_hash = normalize_video_hash(video_hash) if self.get_video(normalized_hash) is None: return None share_id = uuid.uuid4().hex now = utc_now() with self._connect() as connection: connection.execute( """ INSERT INTO dub_shares (share_id, video_hash, title, created_at) VALUES (?, ?, ?, ?) """, (share_id, normalized_hash, title[:200], now), ) connection.executemany( """ INSERT INTO dub_share_segments ( share_id, sentence_index, start_ms, end_ms, text, audio_filename, audio_size_bytes, overall_score, recognized_text, score_details ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ ( share_id, segment["sentence_index"], segment["start_ms"], segment["end_ms"], segment["text"], segment["audio_filename"], segment["audio_size_bytes"], segment.get("overall_score", 0), segment.get("recognized_text", ""), segment.get("score_details", "{}"), ) for segment in segments ], ) return { "share_id": share_id, "video_hash": normalized_hash, "title": title[:200], "created_at": now, } def get_dub_share(self, share_id: str) -> Optional[Dict[str, Any]]: with self._connect() as connection: share = connection.execute( "SELECT * FROM dub_shares WHERE share_id = ?", (share_id,) ).fetchone() if share is None: return None rows = connection.execute( """ SELECT * FROM dub_share_segments WHERE share_id = ? ORDER BY sentence_index """, (share_id,), ).fetchall() result = dict(share) result["segments"] = [dict(row) for row in rows] return result def record_attempt( self, *, attempt_id: str, video_hash: str, sentence_index: int, result: Dict[str, Any], audio_filename: Optional[str], ) -> None: with self._connect() as connection: connection.execute( """ INSERT INTO attempts ( attempt_id, video_hash, sentence_index, result_json, audio_filename, created_at ) VALUES (?, ?, ?, ?, ?, ?) """, ( attempt_id, normalize_video_hash(video_hash), sentence_index, json.dumps(result, ensure_ascii=False), audio_filename, utc_now(), ), ) def delete_video(self, video_hash: str) -> Optional[Dict[str, Any]]: video = self.get_video(video_hash) if video is None: return None with self._connect() as connection: connection.execute( "DELETE FROM videos WHERE video_hash = ?", (normalize_video_hash(video_hash),), ) return video