Files
mediaplayer/sentence_api/tests/test_api.py
2026-10-08 10:08:16 +08:00

1032 lines
35 KiB
Python

import json
import threading
import time
from dataclasses import replace
from fastapi.testclient import TestClient
from sentence_api.main import create_app
from sentence_api.config import Settings
from sentence_api.oss import VolcanoOSSUploader
from sentence_api.models import SentenceBoundary, SentenceBoundaryDocument
from sentence_api.processing import VideoProcessor
from sentence_api.repository import VideoRepository
from sentence_api.store import BoundaryStore
VIDEO_HASH = "a" * 64
def wait_for_subtitle_translation(client, video_hash, timeout=5.0):
deadline = time.time() + timeout
last = None
while time.time() < deadline:
last = client.get(f"/api/v1/videos/{video_hash}/subtitle-translation")
if last.status_code == 200 and last.json().get("complete"):
return last
time.sleep(0.05)
raise AssertionError(f"subtitle translation did not complete: {getattr(last, 'text', last)}")
def make_client(tmp_path, admin_api_key=""):
index_path = tmp_path / "boundaries.json"
index_path.write_text(
json.dumps({
"videos": {
VIDEO_HASH: {
"duration_ms": 9000,
"algorithm_version": "test-v1",
"sentences": [
{"index": 0, "start_ms": 0, "end_ms": 4000, "text": "One."},
{"index": 1, "start_ms": 4000, "end_ms": 9000, "text": "Two."},
],
}
}
}),
encoding="utf-8",
)
settings = replace(
Settings.from_env(),
data_dir=tmp_path / "data",
legacy_boundaries_file=index_path,
admin_api_key=admin_api_key,
)
settings.ensure_directories()
client = TestClient(
create_app(
BoundaryStore(index_path),
settings=settings,
repository=VideoRepository(settings.database_path),
)
)
response = client.post(
"/api/v1/auth/register",
json={"username": "tester", "password": "password-123"},
)
assert response.status_code == 201, response.text
client.headers.update({"Authorization": f"Bearer {response.json()['token']}"})
return client
def test_lookup_returns_boundaries(tmp_path):
client = make_client(tmp_path)
response = client.get(f"/api/v1/videos/{VIDEO_HASH}/sentence-boundaries")
assert response.status_code == 200
assert response.json()["sentences"][1]["start_ms"] == 4000
def test_unknown_hash_returns_not_found(tmp_path):
client = make_client(tmp_path)
response = client.get(f"/api/v1/videos/{'b' * 64}/sentence-boundaries")
assert response.status_code == 404
assert response.json()["detail"]["code"] == "SENTENCE_BOUNDARIES_NOT_FOUND"
def test_invalid_hash_is_rejected(tmp_path):
client = make_client(tmp_path)
response = client.get("/api/v1/videos/not-a-sha256/sentence-boundaries")
assert response.status_code == 422
def test_adhoc_audio_boundaries_do_not_persist_audio(tmp_path, monkeypatch):
client = make_client(tmp_path)
upload = tmp_path / "audio.wav"
upload.write_bytes(b"not-a-real-audio")
import sentence_api.processing as processing_module
monkeypatch.setattr(processing_module, "detect_sentence_boundaries", lambda path: [0.0, 1.0])
monkeypatch.setattr(processing_module, "media_duration_ms", lambda path: 2000)
response = client.post(
"/api/v1/audio/sentence-boundaries",
files={"file": ("audio.wav", upload.read_bytes(), "audio/wav")},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["algorithm_version"] == "silence-rms-v1"
assert payload["duration_ms"] == 2000
assert [(item["start_ms"], item["end_ms"]) for item in payload["sentences"]] == [(0, 1000), (1000, 2000)]
assert not (tmp_path / "data" / "work").exists() or not any((tmp_path / "data" / "work").iterdir())
assert not (tmp_path / "data" / "v").exists() or not any((tmp_path / "data" / "v").iterdir())
def test_adhoc_audio_boundaries_require_login(tmp_path):
client = make_client(tmp_path)
client.headers.clear()
response = client.post(
"/api/v1/audio/sentence-boundaries",
files={"file": ("audio.wav", b"audio", "audio/wav")},
)
assert response.status_code == 401
def test_admin_can_reset_sso_user_password(tmp_path):
client = make_client(tmp_path, admin_api_key="test-admin-key")
repository = client.app.state.video_repository
created = repository.create_sso_user(
username="bobcoc",
nickname="Bob",
sso_provider="d1kt",
)
assert created is not None
assert repository.authenticate_user("bobcoc", "anything-123") is None
response = client.put(
"/api/v1/admin/users/password",
json={"username": "bobcoc", "password": "new-password-123"},
headers={"X-Admin-Key": "test-admin-key"},
)
assert response.status_code == 204
assert repository.authenticate_user("bobcoc", "new-password-123") is not None
def test_sentence_explanation_requires_login(tmp_path):
index_path = tmp_path / "boundaries.json"
index_path.write_text("{}", encoding="utf-8")
settings = replace(
Settings.from_env(),
data_dir=tmp_path / "data",
legacy_boundaries_file=index_path,
)
settings.ensure_directories()
client = TestClient(
create_app(
BoundaryStore(index_path),
settings=settings,
repository=VideoRepository(settings.database_path),
)
)
response = client.post(
"/api/v1/sentences/explain",
json={"sentence_text": "Hello."},
)
assert response.status_code == 401
def test_sentence_explanation_uses_server_side_ai_client(tmp_path, monkeypatch):
client = make_client(tmp_path)
requested = []
def explain(text, **kwargs):
requested.append((text, kwargs))
return "这句话表示打招呼。"
class StubAITutor:
available = True
stub = StubAITutor()
stub.explain = explain
monkeypatch.setattr(client.app.state, "ai_tutor", stub)
response = client.post(
"/api/v1/sentences/explain",
json={
"sentence_text": "Hello.",
"course_title": "Greetings",
"sentence_index": 0,
"language": "en",
},
)
assert response.status_code == 200
assert response.json() == {"explanation": "这句话表示打招呼。"}
assert requested == [
(
"Hello.",
{
"course_title": "Greetings",
"sentence_index": 0,
"language": "en",
},
)
]
def _ready_video(repository):
repository.upsert_upload(
video_hash=VIDEO_HASH,
title="Greetings",
filename="lesson.mp4",
stored_filename=f"{VIDEO_HASH}.mp4",
content_type="video/mp4",
size_bytes=5,
language="en",
)
repository.save_processing_result(
SentenceBoundaryDocument(
video_hash=VIDEO_HASH,
duration_ms=9000,
algorithm_version="test",
sentences=[
SentenceBoundary(index=0, start_ms=0, end_ms=4000, text="Hello.", language="en", reference_speech_duration_ms=3000),
SentenceBoundary(index=1, start_ms=4000, end_ms=9000, text="Nice to meet you.", language="en", reference_speech_duration_ms=4000),
],
),
None,
)
def test_subtitle_translation_requires_login(tmp_path):
client = make_client(tmp_path)
client.headers.clear()
response = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert response.status_code == 401
LOCAL_SUBTITLE_HASH = "b" * 64
def _stub_translator(monkeypatch, client, requested=None):
requested = requested if requested is not None else []
def translate_sentences(sentences, **kwargs):
requested.append((sentences, kwargs))
return {index: f"中文{index}" for index, _text in sentences}
class StubAITutor:
available = True
stub = StubAITutor()
stub.translate_sentences = translate_sentences
monkeypatch.setattr(client.app.state, "ai_tutor", stub)
return requested
def test_local_english_subtitles_can_be_uploaded_for_translation(tmp_path, monkeypatch):
client = make_client(tmp_path)
requested = _stub_translator(monkeypatch, client)
missing = client.post(f"/api/v1/videos/{LOCAL_SUBTITLE_HASH}/subtitle-translation")
assert missing.status_code == 404
response = client.post(
f"/api/v1/videos/{LOCAL_SUBTITLE_HASH}/subtitle-translation",
json={
"duration_ms": 9000,
"algorithm_version": "local-pc",
"sentences": [
{"index": 0, "start_ms": 0, "end_ms": 4000, "text": "Hello.", "language": "en"},
{"index": 1, "start_ms": 4000, "end_ms": 9000, "text": "Nice to meet you.", "language": "en"},
],
},
)
assert response.status_code == 200, response.text
if not response.json().get("complete"):
response = wait_for_subtitle_translation(client, LOCAL_SUBTITLE_HASH)
payload = response.json()
assert payload["complete"] is True
assert payload["translations"] == [
{"index": 0, "zh_text": "中文0"},
{"index": 1, "zh_text": "中文1"},
]
assert requested[0][0] == [(0, "Hello."), (1, "Nice to meet you.")]
stored = client.get(f"/api/v1/videos/{LOCAL_SUBTITLE_HASH}/subtitle-translation")
assert stored.status_code == 200
assert stored.json()["translations"] == payload["translations"]
courses = client.get("/api/v1/courses")
assert courses.status_code == 200
assert all(item["video_hash"] != LOCAL_SUBTITLE_HASH for item in courses.json())
videos = client.get("/api/v1/videos")
assert videos.status_code == 200
assert all(item["video_hash"] != LOCAL_SUBTITLE_HASH for item in videos.json()["videos"])
def test_local_subtitle_upload_does_not_overwrite_course_sentences(tmp_path, monkeypatch):
client = make_client(tmp_path)
_ready_video(client.app.state.video_repository)
requested = _stub_translator(monkeypatch, client)
response = client.post(
f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation",
json={
"duration_ms": 9000,
"algorithm_version": "local-pc",
"sentences": [
{"index": 0, "start_ms": 0, "end_ms": 4000, "text": "Changed.", "language": "en"},
{"index": 1, "start_ms": 4000, "end_ms": 9000, "text": "Also changed.", "language": "en"},
],
},
)
assert response.status_code == 200, response.text
if not response.json().get("complete"):
response = wait_for_subtitle_translation(client, VIDEO_HASH)
assert requested[0][0] == [(0, "Hello."), (1, "Nice to meet you.")]
document = client.app.state.video_repository.get_document(VIDEO_HASH)
assert [sentence.text for sentence in document.sentences] == ["Hello.", "Nice to meet you."]
def test_subtitle_translation_uses_full_document_and_is_cached(tmp_path, monkeypatch):
client = make_client(tmp_path)
_ready_video(client.app.state.video_repository)
requested = []
def translate_sentences(sentences, **kwargs):
requested.append((sentences, kwargs))
return {index: f"中文{index}" for index, _text in sentences}
class StubAITutor:
available = True
stub = StubAITutor()
stub.translate_sentences = translate_sentences
monkeypatch.setattr(client.app.state, "ai_tutor", stub)
response = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert response.status_code == 200, response.text
if not response.json().get("complete"):
response = wait_for_subtitle_translation(client, VIDEO_HASH)
payload = response.json()
assert payload["video_hash"] == VIDEO_HASH
assert payload["complete"] is True
assert payload["status"] == "ready"
assert payload["translations"] == [
{"index": 0, "zh_text": "中文0"},
{"index": 1, "zh_text": "中文1"},
]
document = client.app.state.video_repository.get_document(VIDEO_HASH)
assert [sentence.zh_text for sentence in document.sentences] == ["中文0", "中文1"]
second = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert second.status_code == 200
assert second.json()["translations"] == payload["translations"]
assert second.json()["complete"] is True
assert len(requested) == 1
assert requested[0][0] == [(0, "Hello."), (1, "Nice to meet you.")]
def test_subtitle_translation_is_shared_with_other_users(tmp_path, monkeypatch):
client = make_client(tmp_path)
_ready_video(client.app.state.video_repository)
requested = []
def translate_sentences(sentences, **kwargs):
requested.append((sentences, kwargs))
return {index: f"中文{index}" for index, _text in sentences}
class StubAITutor:
available = True
stub = StubAITutor()
stub.translate_sentences = translate_sentences
monkeypatch.setattr(client.app.state, "ai_tutor", stub)
first = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert first.status_code == 200, first.text
if not first.json().get("complete"):
first = wait_for_subtitle_translation(client, VIDEO_HASH)
stored = client.get(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert stored.status_code == 200
assert stored.json()["translations"] == first.json()["translations"]
assert stored.json()["complete"] is True
other = TestClient(client.app)
signup = other.post(
"/api/v1/auth/register",
json={"username": "viewer2", "password": "password-123"},
)
assert signup.status_code == 201, signup.text
other.headers.update({"Authorization": f"Bearer {signup.json()['token']}"})
shared = other.get(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert shared.status_code == 200
assert shared.json()["translations"] == [
{"index": 0, "zh_text": "中文0"},
{"index": 1, "zh_text": "中文1"},
]
boundaries = other.get(f"/api/v1/videos/{VIDEO_HASH}/sentence-boundaries")
assert boundaries.status_code == 200
assert [item["zh_text"] for item in boundaries.json()["sentences"]] == ["中文0", "中文1"]
reused = other.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert reused.status_code == 200
assert reused.json()["translations"] == first.json()["translations"]
assert reused.json()["complete"] is True
assert len(requested) == 1
def test_subtitle_translation_does_not_block_http_on_long_jobs(tmp_path, monkeypatch):
client = make_client(tmp_path)
_ready_video(client.app.state.video_repository)
started = threading.Event()
release = threading.Event()
requested = []
def translate_sentences(sentences, **kwargs):
requested.append(sentences)
persist = kwargs.get("persist")
if persist is not None:
persist({0: "中文0"})
started.set()
assert release.wait(timeout=5)
if persist is not None:
persist({1: "中文1"})
return {0: "中文0", 1: "中文1"}
class StubAITutor:
available = True
stub = StubAITutor()
stub.translate_sentences = translate_sentences
monkeypatch.setattr(client.app.state, "ai_tutor", stub)
client.app.state.subtitle_translations.ai_tutor = stub
client.app.state.subtitle_translations.post_wait_seconds = 0.2
response = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert started.wait(timeout=2)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["complete"] is False
assert payload["status"] == "running"
if {"index": 0, "zh_text": "中文0"} not in payload["translations"]:
payload = client.get(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation").json()
assert {"index": 0, "zh_text": "中文0"} in payload["translations"]
stored = client.get(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert stored.status_code == 200
assert stored.json()["status"] == "running"
assert stored.json()["complete"] is False
release.set()
finished = wait_for_subtitle_translation(client, VIDEO_HASH)
assert finished.json()["complete"] is True
assert finished.json()["translations"] == [
{"index": 0, "zh_text": "中文0"},
{"index": 1, "zh_text": "中文1"},
]
assert len(requested) == 1
def test_admin_can_retranslate_subtitles_with_name_terms(tmp_path, monkeypatch):
client = make_client(tmp_path)
_ready_video(client.app.state.video_repository)
requested = []
def translate_sentences(sentences, **kwargs):
requested.append((sentences, kwargs.get("terms")))
terms = dict(kwargs.get("terms") or [])
return {
index: terms.get("Harimoto", "哈达马托") + f":{text}"
for index, text in sentences
}
class StubAITutor:
available = True
stub = StubAITutor()
stub.translate_sentences = translate_sentences
monkeypatch.setattr(client.app.state, "ai_tutor", stub)
client.app.state.subtitle_translations.ai_tutor = stub
client.app.state.subtitle_translations.post_wait_seconds = 0.2
first = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
if not first.json().get("complete"):
first = wait_for_subtitle_translation(client, VIDEO_HASH)
assert first.json()["translations"] == [
{"index": 0, "zh_text": "哈达马托:Hello."},
{"index": 1, "zh_text": "哈达马托:Nice to meet you."},
]
response = client.post(
f"/api/v1/admin/videos/{VIDEO_HASH}/subtitle-translation",
json={"terms": [{"source": "Harimoto", "zh": "哈瑞么托"}]},
)
assert response.status_code == 200, response.text
if not response.json().get("complete"):
response = wait_for_subtitle_translation(client, VIDEO_HASH)
stored = client.get(f"/api/v1/admin/videos/{VIDEO_HASH}/subtitle-translation")
assert stored.status_code == 200
payload = stored.json()
else:
payload = response.json()
assert payload["translations"] == [
{"index": 0, "zh_text": "哈瑞么托:Hello."},
{"index": 1, "zh_text": "哈瑞么托:Nice to meet you."},
]
assert payload["terms"] == [{"source": "Harimoto", "zh": "哈瑞么托"}]
assert requested[-1][1] == [("Harimoto", "哈瑞么托")]
assert client.app.state.video_repository.get_translation_glossary(VIDEO_HASH) == [
("Harimoto", "哈瑞么托")
]
def test_oss_video_url_is_used_for_catalog_and_content(tmp_path):
index_path = tmp_path / "boundaries.json"
index_path.write_text("{}", encoding="utf-8")
settings = replace(
Settings.from_env(),
data_dir=tmp_path / "data",
legacy_boundaries_file=index_path,
)
settings.ensure_directories()
repository = VideoRepository(settings.database_path)
repository.upsert_upload(
video_hash=VIDEO_HASH,
title="OSS lesson",
filename="lesson.mp4",
stored_filename=f"{VIDEO_HASH}.mp4",
content_type="video/mp4",
size_bytes=1,
language=None,
)
remote_url = f"https://course-media.example.com/videos/{VIDEO_HASH}.mp4"
repository.mark_oss_uploaded(VIDEO_HASH, remote_url)
client = TestClient(
create_app(
BoundaryStore(index_path),
settings=settings,
repository=repository,
)
)
catalog = client.get("/api/v1/videos")
stream = client.get(f"/api/v1/videos/{VIDEO_HASH}/content", follow_redirects=False)
assert catalog.status_code == 200
assert catalog.json()["videos"][0]["stream_url"] == remote_url
assert stream.status_code == 307
assert stream.headers["location"] == remote_url
def test_volcano_oss_object_key_and_public_url(tmp_path):
settings = replace(
Settings.from_env(),
volcano_oss_access_key="ak",
volcano_oss_secret_key="sk",
volcano_oss_bucket="course-media",
volcano_oss_endpoint="tos-cn-beijing.volces.com",
volcano_oss_region="cn-beijing",
volcano_oss_key_prefix="lessons/videos/",
)
uploader = VolcanoOSSUploader(settings)
key = uploader.object_key(VIDEO_HASH, f"{VIDEO_HASH}.mp4")
assert settings.volcano_oss_enabled is True
assert key == f"lessons/videos/{VIDEO_HASH}.mp4"
assert uploader.public_url(key) == (
f"https://course-media.tos-cn-beijing.volces.com/"
f"lessons/videos/{VIDEO_HASH}.mp4"
)
def test_processor_marks_ready_only_after_oss_upload(tmp_path, monkeypatch):
settings = replace(Settings.from_env(), data_dir=tmp_path / "data")
settings.ensure_directories()
media_path = settings.videos_dir / f"{VIDEO_HASH}.mp4"
media_path.write_bytes(b"video")
repository = VideoRepository(settings.database_path)
repository.upsert_upload(
video_hash=VIDEO_HASH,
title="OSS lesson",
filename="lesson.mp4",
stored_filename=f"{VIDEO_HASH}.mp4",
content_type="video/mp4",
size_bytes=5,
language=None,
)
document = SentenceBoundaryDocument(
video_hash=VIDEO_HASH,
duration_ms=1000,
algorithm_version="test",
sentences=[SentenceBoundary(index=0, start_ms=0, end_ms=1000)],
)
uploads = []
class EnabledUploader:
enabled = True
def upload(self, **kwargs):
uploads.append(kwargs)
return "https://media.example.com/lesson.mp4"
class UnavailableTranscriber:
available = False
monkeypatch.setattr(
"sentence_api.processing.make_entry",
lambda *args, **kwargs: (
{
"duration_ms": 1000,
"sentences": [{"index": 0, "start_ms": 0, "end_ms": 1000}],
},
{},
),
)
processor = VideoProcessor(
settings,
repository,
UnavailableTranscriber(),
EnabledUploader(),
)
processor.process(VIDEO_HASH)
video = repository.get_video(VIDEO_HASH)
assert uploads[0]["stored_filename"] == f"{VIDEO_HASH}.mp4"
assert video["status"] == "ready"
assert video["remote_url"] == "https://media.example.com/lesson.mp4"
def test_admin_can_split_sentence_and_reindexes_following_sentences(tmp_path):
settings = replace(Settings.from_env(), data_dir=tmp_path / "data")
settings.ensure_directories()
repository = VideoRepository(settings.database_path)
repository.upsert_upload(
video_hash=VIDEO_HASH,
title="Manual split lesson",
filename="lesson.mp4",
stored_filename=f"{VIDEO_HASH}.mp4",
content_type="video/mp4",
size_bytes=5,
language="en",
)
repository.save_processing_result(
SentenceBoundaryDocument(
video_hash=VIDEO_HASH,
duration_ms=12000,
algorithm_version="test",
sentences=[
SentenceBoundary(
index=0,
start_ms=0,
end_ms=2000,
text="Before.",
language="en",
reference_speech_duration_ms=1800,
),
SentenceBoundary(
index=1,
start_ms=2000,
end_ms=9000,
text="First part second part",
language="en",
reference_speech_duration_ms=6600,
),
SentenceBoundary(
index=2,
start_ms=9000,
end_ms=12000,
text="Next sentence.",
language="en",
reference_speech_duration_ms=2800,
),
],
),
None,
)
repository.save_sentence_translations(
VIDEO_HASH,
{0: "之前。", 1: "第一段第二段", 2: "下一句。"},
)
class UnavailableTranscriber:
available = False
client = TestClient(
create_app(
BoundaryStore(tmp_path / "boundaries.json"),
settings=settings,
repository=repository,
transcriber=UnavailableTranscriber(),
)
)
response = client.put(
f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/1/split",
json={"text": "First part\nsecond part", "language": "en"},
)
assert response.status_code == 200
replacements = response.json()
assert [item["text"] for item in replacements] == ["First part", "second part"]
assert replacements[0]["start_ms"] == 2000
assert replacements[0]["end_ms"] == replacements[1]["start_ms"]
assert replacements[1]["end_ms"] == 9000
document = repository.get_document(VIDEO_HASH)
assert [item.text for item in document.sentences] == [
"Before.",
"First part",
"second part",
"Next sentence.",
]
assert [item.index for item in document.sentences] == [0, 1, 2, 3]
assert all(not (item.zh_text or "").strip() for item in document.sentences)
def test_admin_can_merge_adjacent_sentences_and_reindexes_following_sentences(tmp_path):
settings = replace(Settings.from_env(), data_dir=tmp_path / "data")
settings.ensure_directories()
repository = VideoRepository(settings.database_path)
repository.upsert_upload(
video_hash=VIDEO_HASH,
title="Manual merge lesson",
filename="lesson.mp4",
stored_filename=f"{VIDEO_HASH}.mp4",
content_type="video/mp4",
size_bytes=5,
language="en",
)
repository.save_processing_result(
SentenceBoundaryDocument(
video_hash=VIDEO_HASH,
duration_ms=12000,
algorithm_version="test",
sentences=[
SentenceBoundary(
index=0,
start_ms=0,
end_ms=2000,
text="Before.",
language="en",
reference_speech_duration_ms=1800,
),
SentenceBoundary(
index=1,
start_ms=2000,
end_ms=5000,
text="First part",
language="en",
reference_speech_duration_ms=2800,
),
SentenceBoundary(
index=2,
start_ms=5000,
end_ms=9000,
text="second part",
language="en",
reference_speech_duration_ms=3600,
),
SentenceBoundary(
index=3,
start_ms=9000,
end_ms=12000,
text="Next sentence.",
language="en",
reference_speech_duration_ms=2800,
),
],
),
None,
)
repository.save_sentence_translations(
VIDEO_HASH,
{0: "之前。", 1: "第一段", 2: "第二段", 3: "下一句。"},
)
class UnavailableTranscriber:
available = False
client = TestClient(
create_app(
BoundaryStore(tmp_path / "boundaries.json"),
settings=settings,
repository=repository,
transcriber=UnavailableTranscriber(),
)
)
response = client.put(
f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/1/merge",
)
assert response.status_code == 200
merged = response.json()
assert merged["index"] == 1
assert merged["text"] == "First part second part"
assert merged["start_ms"] == 2000
assert merged["end_ms"] == 9000
assert merged["reference_speech_duration_ms"] == 6400
document = repository.get_document(VIDEO_HASH)
assert [item.text for item in document.sentences] == [
"Before.",
"First part second part",
"Next sentence.",
]
assert [item.index for item in document.sentences] == [0, 1, 2]
assert [item.start_ms for item in document.sentences] == [0, 2000, 9000]
assert [item.end_ms for item in document.sentences] == [2000, 9000, 12000]
assert all(not (item.zh_text or "").strip() for item in document.sentences)
last_response = client.put(
f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/2/merge",
)
assert last_response.status_code == 400
assert last_response.json()["detail"] == "No next sentence to merge with."
def test_admin_sentence_merge_restarts_subtitle_translation(tmp_path, monkeypatch):
client = make_client(tmp_path)
_ready_video(client.app.state.video_repository)
requested = []
def translate_sentences(sentences, **kwargs):
requested.append([(index, text) for index, text in sentences])
return {index: f"中文:{text}" for index, text in sentences}
class StubAITutor:
available = True
stub = StubAITutor()
stub.translate_sentences = translate_sentences
monkeypatch.setattr(client.app.state, "ai_tutor", stub)
client.app.state.subtitle_translations.ai_tutor = stub
client.app.state.subtitle_translations.post_wait_seconds = 0.2
first = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
if not first.json().get("complete"):
first = wait_for_subtitle_translation(client, VIDEO_HASH)
assert first.json()["translations"] == [
{"index": 0, "zh_text": "中文:Hello."},
{"index": 1, "zh_text": "中文:Nice to meet you."},
]
merge = client.put(f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/0/merge")
assert merge.status_code == 200, merge.text
assert merge.json()["text"] == "Hello. Nice to meet you."
finished = wait_for_subtitle_translation(client, VIDEO_HASH)
assert finished.json()["complete"] is True
assert finished.json()["translations"] == [
{"index": 0, "zh_text": "中文:Hello. Nice to meet you."},
]
document = client.app.state.video_repository.get_document(VIDEO_HASH)
assert [sentence.text for sentence in document.sentences] == ["Hello. Nice to meet you."]
assert [sentence.zh_text for sentence in document.sentences] == ["中文:Hello. Nice to meet you."]
assert requested[-1] == [(0, "Hello. Nice to meet you.")]
def test_admin_sentence_merge_drops_stale_in_flight_translation(tmp_path, monkeypatch):
client = make_client(tmp_path)
_ready_video(client.app.state.video_repository)
started = threading.Event()
release = threading.Event()
requested = []
def translate_sentences(sentences, **kwargs):
call_index = len(requested)
requested.append([(index, text) for index, text in sentences])
persist = kwargs.get("persist")
if call_index == 0:
if persist is not None:
persist({0: "旧0", 1: "旧1"})
started.set()
assert release.wait(timeout=5)
if persist is not None:
persist({0: "晚0", 1: "晚1"})
return {0: "晚0", 1: "晚1"}
return {index: f"新:{text}" for index, text in sentences}
class StubAITutor:
available = True
stub = StubAITutor()
stub.translate_sentences = translate_sentences
monkeypatch.setattr(client.app.state, "ai_tutor", stub)
client.app.state.subtitle_translations.ai_tutor = stub
client.app.state.subtitle_translations.post_wait_seconds = 0.2
response = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert started.wait(timeout=2)
assert response.status_code == 200, response.text
merge = client.put(f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/0/merge")
assert merge.status_code == 200, merge.text
release.set()
finished = wait_for_subtitle_translation(client, VIDEO_HASH)
assert finished.json()["complete"] is True
assert finished.json()["translations"] == [
{"index": 0, "zh_text": "新:Hello. Nice to meet you."},
]
document = client.app.state.video_repository.get_document(VIDEO_HASH)
assert [sentence.zh_text for sentence in document.sentences] == [
"新:Hello. Nice to meet you."
]
assert requested[0] == [(0, "Hello."), (1, "Nice to meet you.")]
assert requested[-1] == [(0, "Hello. Nice to meet you.")]
def test_admin_boundary_adjustment_updates_linked_next_start(tmp_path):
settings = replace(Settings.from_env(), data_dir=tmp_path / "data")
settings.ensure_directories()
repository = VideoRepository(settings.database_path)
repository.upsert_upload(
video_hash=VIDEO_HASH,
title="Boundary lesson",
filename="lesson.mp4",
stored_filename=f"{VIDEO_HASH}.mp4",
content_type="video/mp4",
size_bytes=5,
language="en",
)
repository.save_processing_result(
SentenceBoundaryDocument(
video_hash=VIDEO_HASH,
duration_ms=9000,
algorithm_version="test",
sentences=[
SentenceBoundary(
index=0,
start_ms=0,
end_ms=4000,
text="First.",
language="en",
reference_speech_duration_ms=3600,
),
SentenceBoundary(
index=1,
start_ms=4000,
end_ms=9000,
text="Second.",
language="en",
reference_speech_duration_ms=4600,
),
],
),
None,
)
class UnavailableTranscriber:
available = False
client = TestClient(
create_app(
BoundaryStore(tmp_path / "boundaries.json"),
settings=settings,
repository=repository,
transcriber=UnavailableTranscriber(),
)
)
response = client.put(
f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/0/boundary",
json={"delta_ms": 250},
)
assert response.status_code == 200
updated = response.json()
assert [item["index"] for item in updated] == [0, 1]
assert updated[0]["end_ms"] == 4250
assert updated[1]["start_ms"] == 4250
assert updated[0]["reference_speech_duration_ms"] == 3850
assert updated[1]["reference_speech_duration_ms"] == 4350
document = repository.get_document(VIDEO_HASH)
assert [item.end_ms for item in document.sentences] == [4250, 9000]
assert [item.start_ms for item in document.sentences] == [0, 4250]
def test_admin_can_update_course_title(tmp_path):
settings = replace(Settings.from_env(), data_dir=tmp_path / "data")
settings.ensure_directories()
repository = VideoRepository(settings.database_path)
repository.upsert_upload(
video_hash=VIDEO_HASH,
title="Old course title",
filename="lesson.mp4",
stored_filename=f"{VIDEO_HASH}.mp4",
content_type="video/mp4",
size_bytes=5,
language="en",
)
client = TestClient(
create_app(
BoundaryStore(tmp_path / "boundaries.json"),
settings=settings,
repository=repository,
)
)
response = client.put(
f"/api/v1/admin/videos/{VIDEO_HASH}/title",
json={"title": " New course title "},
)
assert response.status_code == 200
assert response.json()["title"] == "New course title"
assert repository.get_video(VIDEO_HASH)["title"] == "New course title"
missing_response = client.put(
f"/api/v1/admin/videos/{'b' * 64}/title",
json={"title": "Missing course"},
)
assert missing_response.status_code == 404