import json import sqlite3 from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Sequence, Tuple import uuid from .auth import hash_password, hash_token, new_token, verify_password 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, zh_text 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 users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE COLLATE NOCASE, nickname TEXT NOT NULL, phone TEXT, password_hash TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS auth_tokens ( token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS enrollments ( user_id TEXT NOT NULL, video_hash TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (user_id, video_hash), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, 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) ); CREATE TABLE IF NOT EXISTS moss_segments ( video_hash TEXT NOT NULL, segment_index INTEGER NOT NULL, start_seconds REAL NOT NULL, end_seconds REAL NOT NULL, start_ms INTEGER NOT NULL, end_ms INTEGER NOT NULL, text TEXT NOT NULL, speaker TEXT, words_json TEXT, PRIMARY KEY (video_hash, segment_index), FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE ); """ ) 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: attempt_columns = { row["name"] for row in connection.execute("PRAGMA table_info(attempts)") } if "user_id" not in attempt_columns: connection.execute("ALTER TABLE attempts ADD COLUMN user_id TEXT NOT NULL DEFAULT ''") with self._connect() as connection: user_columns = { row["name"] for row in connection.execute("PRAGMA table_info(users)") } if "phone" not in user_columns: connection.execute("ALTER TABLE users ADD COLUMN phone TEXT") if "sso_provider" not in user_columns: connection.execute("ALTER TABLE users ADD COLUMN sso_provider TEXT") if "phone_bound" not in user_columns: connection.execute("ALTER TABLE users ADD COLUMN phone_bound INTEGER NOT NULL DEFAULT 0") connection.execute("CREATE INDEX IF NOT EXISTS idx_attempts_user ON attempts(user_id)") with self._connect() as connection: dub_columns = { row["name"] for row in connection.execute("PRAGMA table_info(dub_shares)") } if "user_id" not in dub_columns: connection.execute("ALTER TABLE dub_shares ADD COLUMN user_id TEXT NOT NULL DEFAULT ''") connection.execute("CREATE INDEX IF NOT EXISTS idx_dub_shares_user ON dub_shares(user_id)") 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") with self._connect() as connection: sentence_columns = { row["name"] for row in connection.execute("PRAGMA table_info(sentences)") } if "zh_text" not in sentence_columns: connection.execute("ALTER TABLE sentences ADD COLUMN zh_text TEXT") with self._connect() as connection: video_columns = { row["name"] for row in connection.execute("PRAGMA table_info(videos)") } if "translation_glossary" not in video_columns: connection.execute("ALTER TABLE videos ADD COLUMN translation_glossary 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: normalized_hash = normalize_video_hash(video_hash) with self._connect() as connection: connection.execute( "DELETE FROM sentences WHERE video_hash = ?", (normalized_hash,), ) connection.execute( "DELETE FROM moss_segments WHERE video_hash = ?", (normalized_hash,), ) connection.execute( """ UPDATE videos SET status = ?, error_message = NULL, transcription = NULL, updated_at = ? WHERE video_hash = ? """, ("processing", utc_now(), normalized_hash), ) 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, moss_segments: Optional[List[Dict[str, Any]]] = None, ) -> None: with self._connect() as connection: connection.execute( "DELETE FROM sentences WHERE video_hash = ?", (document.video_hash,), ) connection.execute( "DELETE FROM moss_segments 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, ), ) if moss_segments: connection.executemany( """ INSERT INTO moss_segments ( video_hash, segment_index, start_seconds, end_seconds, start_ms, end_ms, text, speaker, words_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ _moss_segment_row(document.video_hash, index, segment) for index, segment in enumerate(moss_segments) ], ) def import_local_subtitle_document( self, document: SentenceBoundaryDocument, ) -> SentenceBoundaryDocument: """Save locally generated English subtitles for translation. Official course videos are left unchanged. A previous local-only import is replaced when the English cues changed. """ normalized_hash = normalize_video_hash(document.video_hash) document = document.model_copy(update={"video_hash": normalized_hash}) existing_video = self.get_video(normalized_hash) existing_document = self.get_document(normalized_hash) if existing_video is not None and existing_video.get("status") != "local_subtitles": if existing_document is None: raise KeyError(normalized_hash) return existing_document if existing_document is not None and _english_fingerprint(existing_document) == _english_fingerprint(document): return existing_document now = utc_now() language = next((sentence.language for sentence in document.sentences if sentence.language), None) with self._connect() as connection: if existing_video is None: 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 (?, ?, ?, ?, ?, ?, ?, ?, 'local_subtitles', NULL, ?, NULL, NULL, ?, ?) """, ( normalized_hash, "Local subtitles", "local-subtitles.json", "", "application/json", 0, document.duration_ms, language, document.algorithm_version, now, now, ), ) else: connection.execute( """ UPDATE videos SET duration_ms = ?, language = COALESCE(?, language), algorithm_version = ?, status = 'local_subtitles', error_message = NULL, updated_at = ? WHERE video_hash = ? """, ( document.duration_ms, language, document.algorithm_version, now, normalized_hash, ), ) connection.execute( "DELETE FROM sentences WHERE video_hash = ?", (normalized_hash,), ) connection.executemany( """ INSERT INTO sentences ( video_hash, sentence_index, start_ms, end_ms, text, language, zh_text, reference_speech_duration_ms ) VALUES (?, ?, ?, ?, ?, ?, NULL, ?) """, [ ( normalized_hash, sentence.index, sentence.start_ms, sentence.end_ms, sentence.text, sentence.language, sentence.reference_speech_duration_ms, ) for sentence in document.sentences ], ) imported = self.get_document(normalized_hash) if imported is None: raise KeyError(normalized_hash) return imported 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 update_title(self, video_hash: str, title: str) -> Optional[Dict[str, Any]]: normalized_hash = normalize_video_hash(video_hash) cleaned_title = title.strip()[:200] if not cleaned_title: raise ValueError("Title cannot be empty.") with self._connect() as connection: cursor = connection.execute( "UPDATE videos SET title = ?, updated_at = ? WHERE video_hash = ?", (cleaned_title, utc_now(), normalized_hash), ) if cursor.rowcount == 0: return None return self.get_video(normalized_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 WHERE v.status != 'local_subtitles' 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_moss_segments(self, video_hash: str) -> List[Dict[str, Any]]: with self._connect() as connection: rows = connection.execute( """ SELECT * FROM moss_segments WHERE video_hash = ? ORDER BY segment_index """, (normalize_video_hash(video_hash),), ).fetchall() segments = [] for row in rows: item = dict(row) raw_words = item.pop("words_json", None) words = None if raw_words: try: parsed = json.loads(raw_words) except json.JSONDecodeError: parsed = None if isinstance(parsed, list): words = parsed item["words"] = words segments.append(item) return segments 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=[_sentence_from_row(row) for row in rows], ) def replace_sentences( self, document: SentenceBoundaryDocument, ) -> SentenceBoundaryDocument: """Replace stored sentences while preserving video status and translations.""" normalized_hash = normalize_video_hash(document.video_hash) with self._connect() as connection: existing = connection.execute( "SELECT video_hash FROM videos WHERE video_hash = ?", (normalized_hash,), ).fetchone() if existing is None: raise KeyError(normalized_hash) connection.execute( "DELETE FROM sentences WHERE video_hash = ?", (normalized_hash,), ) connection.executemany( """ INSERT INTO sentences ( video_hash, sentence_index, start_ms, end_ms, text, language, zh_text, reference_speech_duration_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, [ ( normalized_hash, sentence.index, sentence.start_ms, sentence.end_ms, sentence.text, sentence.language, sentence.zh_text, sentence.reference_speech_duration_ms, ) for sentence in document.sentences ], ) connection.execute( "UPDATE videos SET updated_at = ? WHERE video_hash = ?", (utc_now(), normalized_hash), ) saved = self.get_document(normalized_hash) if saved is None: raise KeyError(normalized_hash) return saved 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 sentences SET zh_text = NULL WHERE video_hash = ?", (normalized_hash,), ) 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 _sentence_from_row(row) 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 _sentence_from_row(row) 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 sentences SET zh_text = NULL WHERE video_hash = ?", (normalized_hash,), ) 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 [_sentence_from_row(row) for row in result_rows] def merge_with_next_sentence( self, video_hash: str, sentence_index: int, ) -> Optional[SentenceBoundary]: """Merge sentence_index with the immediately following sentence.""" normalized_hash = normalize_video_hash(video_hash) with self._connect() as connection: current = connection.execute( """ SELECT * FROM sentences WHERE video_hash = ? AND sentence_index = ? """, (normalized_hash, sentence_index), ).fetchone() if current is None: return None nxt = connection.execute( """ SELECT * FROM sentences WHERE video_hash = ? AND sentence_index = ? """, (normalized_hash, sentence_index + 1), ).fetchone() if nxt is None: raise ValueError("No next sentence to merge with.") merged_text = _join_adjacent_sentence_text( current["text"] or "", nxt["text"] or "", current["language"] or nxt["language"], ) merged_language = current["language"] or nxt["language"] merged_speech_duration = max( 1, int(current["reference_speech_duration_ms"] or 0) + int(nxt["reference_speech_duration_ms"] or 0), ) if nxt["end_ms"] <= current["start_ms"]: raise ValueError("Merged sentence times must increase.") connection.execute( """ UPDATE sentences SET end_ms = ?, text = ?, language = ?, reference_speech_duration_ms = ? WHERE video_hash = ? AND sentence_index = ? """, ( nxt["end_ms"], merged_text, merged_language, merged_speech_duration, normalized_hash, sentence_index, ), ) connection.execute( """ DELETE FROM sentences WHERE video_hash = ? AND sentence_index = ? """, (normalized_hash, sentence_index + 1), ) # Compact following indexes upward so sentence_index stays contiguous. following = [ row["sentence_index"] for row in connection.execute( """ SELECT sentence_index FROM sentences WHERE video_hash = ? AND sentence_index > ? ORDER BY sentence_index ASC """, (normalized_hash, sentence_index + 1), ).fetchall() ] for old_index in following: connection.execute( """ UPDATE sentences SET sentence_index = ? WHERE video_hash = ? AND sentence_index = ? """, (old_index - 1, normalized_hash, old_index), ) connection.execute( "UPDATE sentences SET zh_text = NULL WHERE video_hash = ?", (normalized_hash,), ) 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 _sentence_from_row(row) 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 [_sentence_from_row(row) for row in result_rows] def get_translation_glossary(self, video_hash: str) -> List[Tuple[str, str]]: video = self.get_video(video_hash) if video is None: return [] return _parse_translation_glossary(video.get("translation_glossary")) def save_translation_glossary( self, video_hash: str, terms: Sequence[Any], ) -> bool: normalized_hash = normalize_video_hash(video_hash) payload = json.dumps( [ {"source": source, "zh": zh} for source, zh in _normalize_translation_glossary(terms) ], ensure_ascii=False, ) with self._connect() as connection: cursor = connection.execute( """ UPDATE videos SET translation_glossary = ?, updated_at = ? WHERE video_hash = ? """, (payload, utc_now(), normalized_hash), ) return cursor.rowcount > 0 def clear_sentence_translations( self, video_hash: str, indexes: Optional[Sequence[int]] = None, ) -> None: normalized_hash = normalize_video_hash(video_hash) with self._connect() as connection: if indexes is None: connection.execute( "UPDATE sentences SET zh_text = NULL WHERE video_hash = ?", (normalized_hash,), ) return unique_indexes = list(dict.fromkeys(indexes)) if not unique_indexes: return connection.executemany( """ UPDATE sentences SET zh_text = NULL WHERE video_hash = ? AND sentence_index = ? """, [(normalized_hash, index) for index in unique_indexes], ) def save_sentence_translations( self, video_hash: str, translations: Dict[int, str], ) -> Optional[SentenceBoundaryDocument]: normalized_hash = normalize_video_hash(video_hash) with self._connect() as connection: for index, zh_text in translations.items(): connection.execute( """ UPDATE sentences SET zh_text = ? WHERE video_hash = ? AND sentence_index = ? """, (zh_text.strip(), normalized_hash, index), ) connection.execute( "UPDATE videos SET updated_at = ? WHERE video_hash = ?", (utc_now(), normalized_hash), ) return self.get_document(normalized_hash) def create_dub_share( self, *, video_hash: str, title: str, segments: List[Dict[str, Any]], user_id: str, ) -> 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, user_id, created_at) VALUES (?, ?, ?, ?, ?) """, (share_id, normalized_hash, title[:200], user_id, 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], user_id: str, ) -> None: with self._connect() as connection: connection.execute( """ INSERT INTO attempts ( attempt_id, video_hash, sentence_index, result_json, audio_filename, user_id, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( attempt_id, normalize_video_hash(video_hash), sentence_index, json.dumps(result, ensure_ascii=False), audio_filename, user_id, utc_now(), ), ) def create_user( self, *, username: str, password: str, nickname: Optional[str] = None, phone: Optional[str] = None, ) -> Optional[Dict[str, Any]]: user_id = uuid.uuid4().hex now = utc_now() try: with self._connect() as connection: row = connection.execute( """ INSERT INTO users (id, username, nickname, phone, password_hash, created_at) VALUES (?, ?, ?, ?, ?, ?) """, ( user_id, username.strip().lower(), (nickname or username).strip()[:80], (phone or "").strip()[:20] or None, hash_password(password), now, ), ).fetchone() except sqlite3.IntegrityError: return None return self.get_user_by_id(user_id) def create_sso_user( self, *, username: str, nickname: str, email: str = "", sso_provider: str = "d1kt", ) -> Optional[Dict[str, Any]]: user_id = uuid.uuid4().hex now = utc_now() try: with self._connect() as connection: connection.execute( """ INSERT INTO users (id, username, nickname, phone, sso_provider, phone_bound, password_hash, created_at) VALUES (?, ?, ?, NULL, ?, 0, '', ?) """, ( user_id, username.strip().lower(), (nickname or username).strip()[:80], sso_provider, now, ), ) except sqlite3.IntegrityError: return None return self.get_user_by_id(user_id) def bind_phone(self, user_id: str, phone: str) -> Optional[Dict[str, Any]]: with self._connect() as connection: connection.execute( "UPDATE users SET phone = ?, phone_bound = 1 WHERE id = ?", (phone.strip()[:20], user_id), ) return self.get_user_by_id(user_id) def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]: with self._connect() as connection: row = connection.execute( "SELECT * FROM users WHERE username = ? COLLATE NOCASE", (username.strip(),), ).fetchone() return dict(row) if row else None def get_user_by_id(self, user_id: str) -> Optional[Dict[str, Any]]: with self._connect() as connection: row = connection.execute( "SELECT id, username, nickname, phone, created_at FROM users WHERE id = ?", (user_id,), ).fetchone() return dict(row) if row else None def list_users(self) -> List[Dict[str, Any]]: with self._connect() as connection: rows = connection.execute( """ SELECT u.id, u.username, u.nickname, u.phone, u.created_at, COUNT(DISTINCT e.video_hash) AS enrollment_count FROM users u LEFT JOIN enrollments e ON e.user_id = u.id GROUP BY u.id ORDER BY u.created_at DESC """ ).fetchall() return [dict(row) for row in rows] def delete_user(self, user_id: str) -> bool: with self._connect() as connection: cursor = connection.execute("DELETE FROM users WHERE id = ?", (user_id,)) return cursor.rowcount > 0 def authenticate_user(self, username: str, password: str) -> Optional[Dict[str, Any]]: with self._connect() as connection: row = connection.execute( "SELECT * FROM users WHERE username = ? COLLATE NOCASE", (username.strip(),), ).fetchone() if row is None or not verify_password(password, row["password_hash"]): return None return self.get_user_by_id(row["id"]) def set_user_password(self, username: str, password: str) -> bool: """Set a local password for either a normal or SSO-linked account.""" username = username.strip() if not username: return False with self._connect() as connection: cursor = connection.execute( """ UPDATE users SET password_hash = ? WHERE username = ? COLLATE NOCASE """, (hash_password(password), username), ) return cursor.rowcount > 0 def create_session(self, user_id: str) -> str: token = new_token() now = datetime.now(timezone.utc) with self._connect() as connection: connection.execute( """ INSERT INTO auth_tokens (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?) """, ( hash_token(token), user_id, now.isoformat(), (now + timedelta(days=30)).isoformat(), ), ) return token def resolve_session(self, token: str) -> Optional[Dict[str, Any]]: with self._connect() as connection: row = connection.execute( """ SELECT u.id, u.username, u.nickname, u.created_at FROM auth_tokens t JOIN users u ON u.id = t.user_id WHERE t.token_hash = ? AND t.expires_at > ? """, (hash_token(token), datetime.now(timezone.utc).isoformat()), ).fetchone() return dict(row) if row else None def delete_session(self, token: str) -> None: with self._connect() as connection: connection.execute( "DELETE FROM auth_tokens WHERE token_hash = ?", (hash_token(token),), ) def list_courses(self, user_id: str) -> List[Dict[str, Any]]: with self._connect() as connection: rows = connection.execute( """ SELECT v.*, COUNT(s.sentence_index) AS sentence_count, CASE WHEN e.user_id IS NULL THEN 0 ELSE 1 END AS enrolled FROM videos v LEFT JOIN sentences s ON s.video_hash = v.video_hash LEFT JOIN enrollments e ON e.video_hash = v.video_hash AND e.user_id = ? WHERE v.status = 'ready' GROUP BY v.video_hash ORDER BY e.created_at DESC, v.created_at DESC """, (user_id,), ).fetchall() return [dict(row) for row in rows] def enroll_course(self, user_id: str, video_hash: str) -> bool: normalized_hash = normalize_video_hash(video_hash) with self._connect() as connection: video = connection.execute( "SELECT 1 FROM videos WHERE video_hash = ? AND status = 'ready'", (normalized_hash,), ).fetchone() if video is None: return False connection.execute( """ INSERT OR IGNORE INTO enrollments (user_id, video_hash, created_at) VALUES (?, ?, ?) """, (user_id, normalized_hash, utc_now()), ) return True def unenroll_course(self, user_id: str, video_hash: str) -> bool: normalized_hash = normalize_video_hash(video_hash) with self._connect() as connection: cursor = connection.execute( "DELETE FROM enrollments WHERE user_id = ? AND video_hash = ?", (user_id, normalized_hash), ) return cursor.rowcount > 0 def list_user_results(self, user_id: str) -> List[Dict[str, Any]]: with self._connect() as connection: rows = connection.execute( """ SELECT a.attempt_id, a.video_hash, a.sentence_index, a.result_json, a.created_at, v.title AS course_title, s.text AS sentence_text FROM attempts a JOIN videos v ON v.video_hash = a.video_hash LEFT JOIN sentences s ON s.video_hash = a.video_hash AND s.sentence_index = a.sentence_index WHERE a.user_id = ? ORDER BY a.created_at DESC LIMIT 500 """, (user_id,), ).fetchall() results = [] for row in rows: payload = json.loads(row["result_json"] or "{}") results.append( { "attempt_id": row["attempt_id"], "video_hash": row["video_hash"], "course_title": row["course_title"], "sentence_index": row["sentence_index"], "sentence_text": row["sentence_text"] or "", "overall_score": float(payload.get("overall_score", 0)), "passed": bool(payload.get("passed", False)), "created_at": row["created_at"], } ) return results def list_user_dub_shares(self, user_id: str) -> List[Dict[str, Any]]: with self._connect() as connection: rows = connection.execute( """ SELECT d.share_id, d.video_hash, d.title, d.created_at, v.title AS course_title, COUNT(g.id) AS segment_count, COALESCE(AVG(NULLIF(g.overall_score, 0)), 0) AS average_score FROM dub_shares d JOIN videos v ON v.video_hash = d.video_hash LEFT JOIN dub_share_segments g ON g.share_id = d.share_id WHERE d.user_id = ? GROUP BY d.share_id ORDER BY d.created_at DESC LIMIT 500 """, (user_id,), ).fetchall() return [dict(row) for row in rows] def delete_user_result(self, user_id: str, attempt_id: str) -> Optional[Dict[str, Any]]: """Delete one attempt owned by the user and return its stored audio filename.""" with self._connect() as connection: row = connection.execute( """ SELECT audio_filename FROM attempts WHERE attempt_id = ? AND user_id = ? """, (attempt_id, user_id), ).fetchone() if row is None: return None connection.execute( "DELETE FROM attempts WHERE attempt_id = ? AND user_id = ?", (attempt_id, user_id), ) return {"audio_filename": row["audio_filename"]} def delete_user_dub_share(self, user_id: str, share_id: str) -> Optional[List[str]]: """Delete one dub share owned by the user and return stored audio filenames.""" with self._connect() as connection: share = connection.execute( "SELECT share_id FROM dub_shares WHERE share_id = ? AND user_id = ?", (share_id, user_id), ).fetchone() if share is None: return None filenames = [ row["audio_filename"] for row in connection.execute( "SELECT audio_filename FROM dub_share_segments WHERE share_id = ?", (share_id,), ).fetchall() ] # Segments are removed by ON DELETE CASCADE. connection.execute( "DELETE FROM dub_shares WHERE share_id = ? AND user_id = ?", (share_id, user_id), ) return filenames 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 def _english_fingerprint(document: SentenceBoundaryDocument) -> tuple: return tuple( (sentence.index, sentence.start_ms, sentence.end_ms, (sentence.text or "").strip()) for sentence in document.sentences ) def _sentence_from_row(row: sqlite3.Row) -> SentenceBoundary: keys = set(row.keys()) zh_text = row["zh_text"] if "zh_text" in keys else None return SentenceBoundary( index=row["sentence_index"], start_ms=row["start_ms"], end_ms=row["end_ms"], text=row["text"], language=row["language"], zh_text=zh_text, reference_speech_duration_ms=row["reference_speech_duration_ms"], ) def _join_adjacent_sentence_text( left: str, right: str, language: Optional[str], ) -> str: """Join two adjacent sentence texts with language-aware spacing.""" left_text = left.strip() right_text = right.strip() if not left_text: return right_text if not right_text: return left_text lang = (language or "").strip().lower() if lang.startswith(("zh", "ja", "ko")): return f"{left_text}{right_text}" left_end = left_text[-1] right_start = right_text[0] if _is_cjk_char(left_end) or _is_cjk_char(right_start): return f"{left_text}{right_text}" if left_end.isspace() or right_start in ",.;:!?)]}、,。!?;:”’》〉】": return f"{left_text}{right_text}" return f"{left_text} {right_text}" def _is_cjk_char(char: str) -> bool: code = ord(char) return ( 0x3400 <= code <= 0x4DBF or 0x4E00 <= code <= 0x9FFF or 0xF900 <= code <= 0xFAFF or 0x20000 <= code <= 0x2CEAF ) def _moss_segment_row(video_hash: str, index: int, segment: Dict[str, Any]) -> tuple: start_seconds = float(segment["start_seconds"]) end_seconds = float(segment["end_seconds"]) words = segment.get("words") words_json = json.dumps(words, ensure_ascii=False) if words else None speaker = segment.get("speaker") return ( video_hash, index, start_seconds, end_seconds, int(round(start_seconds * 1000)), int(round(end_seconds * 1000)), str(segment.get("text") or "").strip(), str(speaker) if speaker is not None else None, words_json, ) def _normalize_translation_glossary(terms: Sequence[Any]) -> List[Tuple[str, str]]: cleaned: List[Tuple[str, str]] = [] seen: Dict[str, int] = {} for item in terms or []: source = "" zh = "" if isinstance(item, dict): source = str(item.get("source") or "") zh = str(item.get("zh") or "") elif isinstance(item, (tuple, list)) and len(item) >= 2: source = str(item[0] or "") zh = str(item[1] or "") source = source.strip()[:120] zh = zh.strip()[:120] if not source or not zh: continue key = source.casefold() if key in seen: cleaned[seen[key]] = (source, zh) continue seen[key] = len(cleaned) cleaned.append((source, zh)) if len(cleaned) >= 50: break return cleaned def _parse_translation_glossary(raw: Any) -> List[Tuple[str, str]]: if not raw: return [] if isinstance(raw, str): try: raw = json.loads(raw) except json.JSONDecodeError: return [] if not isinstance(raw, list): return [] return _normalize_translation_glossary(raw)