Files
mediaplayer/sentence_api/repository.py
2026-10-25 10:30:22 +08:00

1590 lines
60 KiB
Python

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)