1415 lines
50 KiB
Python
1415 lines
50 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: "下一句。"},
|
|
)
|
|
(settings.videos_dir / f"{VIDEO_HASH}.mp4").write_bytes(b"video")
|
|
|
|
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 [item.zh_text for item in document.sentences] == ["之前。", None, "下一句。"]
|
|
|
|
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_batch_applies_merge_and_split_once(tmp_path, monkeypatch):
|
|
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="Batch edit 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: "下一句。"},
|
|
)
|
|
(settings.videos_dir / f"{VIDEO_HASH}.mp4").write_bytes(b"video")
|
|
|
|
calls = []
|
|
from sentence_api.transcription import Transcript, TranscriptionSegment, WordTimestamp
|
|
|
|
class OnceTranscriber:
|
|
available = True
|
|
|
|
def transcribe(self, audio_path, language=None):
|
|
calls.append(audio_path)
|
|
return Transcript(
|
|
text="Next sentence.",
|
|
segments=[
|
|
TranscriptionSegment(
|
|
start_seconds=0.0,
|
|
end_seconds=1.0,
|
|
text="Next sentence.",
|
|
words=[
|
|
WordTimestamp(0.0, 0.2, "Next"),
|
|
WordTimestamp(0.2, 1.0, "sentence."),
|
|
],
|
|
)
|
|
],
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"sentence_api.processing.extract_audio_segment",
|
|
lambda media_path, output_path, *, start_ms, duration_ms: output_path.write_bytes(b"audio"),
|
|
)
|
|
client = TestClient(
|
|
create_app(
|
|
BoundaryStore(tmp_path / "boundaries.json"),
|
|
settings=settings,
|
|
repository=repository,
|
|
transcriber=OnceTranscriber(),
|
|
)
|
|
)
|
|
response = client.put(
|
|
f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/batch",
|
|
json={
|
|
"sentences": [
|
|
{"start_ms": 0, "end_ms": 2000, "text": "Before.", "language": "en", "reference_speech_duration_ms": 1800},
|
|
{"start_ms": 2000, "end_ms": 9000, "text": "First part second part", "language": "en", "reference_speech_duration_ms": 6400},
|
|
{"start_ms": 9000, "end_ms": 9200, "text": "Next", "language": "en", "reference_speech_duration_ms": 1000, "source_start_ms": 9000, "source_end_ms": 12000},
|
|
{"start_ms": 9200, "end_ms": 12000, "text": "sentence.", "language": "en", "reference_speech_duration_ms": 1000, "source_start_ms": 9000, "source_end_ms": 12000},
|
|
]
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
assert [item["text"] for item in payload] == [
|
|
"Before.",
|
|
"First part second part",
|
|
"Next",
|
|
"sentence.",
|
|
]
|
|
assert [item["start_ms"] for item in payload] == [0, 2000, 9000, 9600]
|
|
assert [item["end_ms"] for item in payload] == [2000, 9000, 9600, 12000]
|
|
assert [item["reference_speech_duration_ms"] for item in payload] == [1800, 6400, 862, 1938]
|
|
assert len(calls) == 1
|
|
document = repository.get_document(VIDEO_HASH)
|
|
assert [item.text for item in document.sentences] == [
|
|
"Before.",
|
|
"First part second part",
|
|
"Next",
|
|
"sentence.",
|
|
]
|
|
assert all(not (item.zh_text or "").strip() for item in document.sentences)
|
|
|
|
|
|
def test_admin_batch_can_persist_provisional_split_without_processing(tmp_path, monkeypatch):
|
|
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="Provisional 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=5000, text="First part second part", language="en", reference_speech_duration_ms=4600),
|
|
SentenceBoundary(index=1, start_ms=5000, end_ms=12000, text="Unchanged.", language="en", reference_speech_duration_ms=6400),
|
|
],
|
|
),
|
|
None,
|
|
)
|
|
repository.save_sentence_translations(VIDEO_HASH, {1: "未变化。"})
|
|
(settings.videos_dir / f"{VIDEO_HASH}.mp4").write_bytes(b"video")
|
|
from sentence_api.transcription import Transcript, TranscriptionSegment, WordTimestamp
|
|
|
|
calls = []
|
|
|
|
class DeferredTranscriber:
|
|
available = True
|
|
allowed = False
|
|
|
|
def transcribe(self, audio_path, language=None):
|
|
assert self.allowed, "provisional save must not transcribe"
|
|
calls.append(audio_path)
|
|
return Transcript(
|
|
text="First part second part",
|
|
segments=[
|
|
TranscriptionSegment(
|
|
start_seconds=0.0,
|
|
end_seconds=1.0,
|
|
text="First part second part",
|
|
words=[
|
|
WordTimestamp(0.0, 0.2, "First"),
|
|
WordTimestamp(0.2, 1.0, "part second part"),
|
|
],
|
|
)
|
|
],
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"sentence_api.processing.extract_audio_segment",
|
|
lambda media_path, output_path, *, start_ms, duration_ms: output_path.write_bytes(b"audio"),
|
|
)
|
|
client = TestClient(
|
|
create_app(
|
|
BoundaryStore(tmp_path / "boundaries.json"),
|
|
settings=settings,
|
|
repository=repository,
|
|
transcriber=DeferredTranscriber(),
|
|
)
|
|
)
|
|
response = client.put(
|
|
f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/batch",
|
|
json={
|
|
"process_timings": False,
|
|
"sentences": [
|
|
{"start_ms": 0, "end_ms": 2500, "text": "First part", "language": "en", "zh_text": None, "reference_speech_duration_ms": 2300, "source_start_ms": 0, "source_end_ms": 5000},
|
|
{"start_ms": 2500, "end_ms": 5000, "text": "second part", "language": "en", "zh_text": None, "reference_speech_duration_ms": 2300, "source_start_ms": 0, "source_end_ms": 5000},
|
|
{"start_ms": 5000, "end_ms": 12000, "text": "Unchanged.", "language": "en", "zh_text": "未变化。", "reference_speech_duration_ms": 6400},
|
|
],
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
assert [item["text"] for item in payload] == ["First part", "second part", "Unchanged."]
|
|
assert [item["start_ms"] for item in payload] == [0, 2500, 5000]
|
|
assert [item["zh_text"] for item in payload] == [None, None, "未变化。"]
|
|
assert calls == []
|
|
document = repository.get_document(VIDEO_HASH)
|
|
assert [item.text for item in document.sentences] == ["First part", "second part", "Unchanged."]
|
|
assert [item.zh_text for item in document.sentences] == [None, None, "未变化。"]
|
|
|
|
client.app.state.transcriber.allowed = True
|
|
processed = client.put(
|
|
f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/batch",
|
|
json={
|
|
"process_timings": True,
|
|
"sentences": [
|
|
{"start_ms": 0, "end_ms": 2500, "text": "First part", "language": "en", "zh_text": None, "reference_speech_duration_ms": 2300, "source_start_ms": 0, "source_end_ms": 5000},
|
|
{"start_ms": 2500, "end_ms": 5000, "text": "second part", "language": "en", "zh_text": None, "reference_speech_duration_ms": 2300, "source_start_ms": 0, "source_end_ms": 5000},
|
|
{"start_ms": 5000, "end_ms": 12000, "text": "Unchanged.", "language": "en", "zh_text": "未变化。", "reference_speech_duration_ms": 6400},
|
|
],
|
|
},
|
|
)
|
|
|
|
assert processed.status_code == 200, processed.text
|
|
processed_payload = processed.json()
|
|
assert [item["start_ms"] for item in processed_payload] == [0, 1000, 5000]
|
|
assert [item["end_ms"] for item in processed_payload] == [1000, 5000, 12000]
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_admin_sentence_merge_does_not_start_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."
|
|
|
|
snapshot = client.get(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
|
|
assert snapshot.status_code == 200
|
|
assert snapshot.json()["status"] == "idle"
|
|
assert snapshot.json()["complete"] is False
|
|
assert snapshot.json()["translations"] == []
|
|
assert len(requested) == 1
|
|
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] == [None]
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
def test_admin_can_repair_sentence_gaps(tmp_path, monkeypatch):
|
|
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="Repair gaps lesson",
|
|
filename="lesson.mp4",
|
|
stored_filename=f"{VIDEO_HASH}.mp4",
|
|
content_type="video/mp4",
|
|
size_bytes=5,
|
|
language="en",
|
|
)
|
|
(settings.videos_dir / f"{VIDEO_HASH}.mp4").write_bytes(b"fake-video")
|
|
repository.save_processing_result(
|
|
SentenceBoundaryDocument(
|
|
video_hash=VIDEO_HASH,
|
|
duration_ms=18000,
|
|
algorithm_version="test",
|
|
sentences=[
|
|
SentenceBoundary(
|
|
index=0,
|
|
start_ms=0,
|
|
end_ms=2000,
|
|
text="Hello.",
|
|
language="en",
|
|
reference_speech_duration_ms=1800,
|
|
),
|
|
SentenceBoundary(
|
|
index=1,
|
|
start_ms=15000,
|
|
end_ms=18000,
|
|
text="Goodbye.",
|
|
language="en",
|
|
reference_speech_duration_ms=2500,
|
|
),
|
|
],
|
|
),
|
|
"Hello. Goodbye.",
|
|
)
|
|
repository.save_sentence_translations(VIDEO_HASH, {0: "你好。", 1: "再见。"})
|
|
|
|
from sentence_api.transcription import Transcript, TranscriptionSegment, _wav_bytes
|
|
import numpy as np
|
|
|
|
class GapTranscriber:
|
|
available = True
|
|
|
|
def transcribe(self, audio_path, language=None):
|
|
return Transcript(
|
|
text="The missing sentence.",
|
|
segments=[
|
|
TranscriptionSegment(
|
|
start_seconds=1.0,
|
|
end_seconds=5.0,
|
|
text="The missing sentence.",
|
|
)
|
|
],
|
|
)
|
|
|
|
def fake_extract(media_path, output_path, *, start_ms, duration_ms):
|
|
sample_rate = 16000
|
|
t = np.arange(int(max(1.0, duration_ms / 1000) * sample_rate)) / sample_rate
|
|
samples = (0.25 * np.sin(2 * np.pi * 220 * t)).astype(np.float32)
|
|
output_path.write_bytes(_wav_bytes(samples, sample_rate))
|
|
|
|
monkeypatch.setattr("sentence_api.processing.extract_audio_segment", fake_extract)
|
|
client = TestClient(
|
|
create_app(
|
|
BoundaryStore(tmp_path / "boundaries.json"),
|
|
settings=settings,
|
|
repository=repository,
|
|
transcriber=GapTranscriber(),
|
|
)
|
|
)
|
|
|
|
response = client.post(f"/api/v1/admin/videos/{VIDEO_HASH}/repair-gaps")
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
assert payload["gap_count"] == 1
|
|
assert payload["inserted_count"] == 1
|
|
assert payload["sentence_count"] == 3
|
|
document = repository.get_document(VIDEO_HASH)
|
|
assert [item.text for item in document.sentences] == [
|
|
"Hello.",
|
|
"The missing sentence.",
|
|
"Goodbye.",
|
|
]
|
|
assert [item.zh_text for item in document.sentences] == ["你好。", None, "再见。"]
|
|
|
|
|
|
def test_admin_gap_repair_requires_transcriber(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="Repair gaps lesson",
|
|
filename="lesson.mp4",
|
|
stored_filename=f"{VIDEO_HASH}.mp4",
|
|
content_type="video/mp4",
|
|
size_bytes=5,
|
|
language="en",
|
|
)
|
|
(settings.videos_dir / f"{VIDEO_HASH}.mp4").write_bytes(b"fake-video")
|
|
repository.save_processing_result(
|
|
SentenceBoundaryDocument(
|
|
video_hash=VIDEO_HASH,
|
|
duration_ms=18000,
|
|
algorithm_version="test",
|
|
sentences=[
|
|
SentenceBoundary(index=0, start_ms=0, end_ms=2000, text="Hello."),
|
|
SentenceBoundary(index=1, start_ms=15000, end_ms=18000, text="Goodbye."),
|
|
],
|
|
),
|
|
None,
|
|
)
|
|
|
|
class UnavailableTranscriber:
|
|
available = False
|
|
|
|
client = TestClient(
|
|
create_app(
|
|
BoundaryStore(tmp_path / "boundaries.json"),
|
|
settings=settings,
|
|
repository=repository,
|
|
transcriber=UnavailableTranscriber(),
|
|
)
|
|
)
|
|
|
|
response = client.post(f"/api/v1/admin/videos/{VIDEO_HASH}/repair-gaps")
|
|
assert response.status_code == 503
|
|
|
|
|
|
def test_admin_can_repair_missing_and_english_translations(tmp_path, monkeypatch):
|
|
client = make_client(tmp_path)
|
|
repository = client.app.state.video_repository
|
|
_ready_video(repository)
|
|
repository.save_sentence_translations(
|
|
VIDEO_HASH,
|
|
{0: "你好。", 1: "Nice to meet you."},
|
|
)
|
|
|
|
requested = []
|
|
|
|
def translate_sentences(sentences, **kwargs):
|
|
requested.append([index for index, _text in sentences])
|
|
existing = dict(kwargs.get("existing") or {})
|
|
result = {}
|
|
for index, _text in sentences:
|
|
current = existing.get(index, "")
|
|
if current and any("\u4e00" <= char <= "\u9fff" for char in current):
|
|
result[index] = current
|
|
else:
|
|
result[index] = f"补译{index}"
|
|
return result
|
|
|
|
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
|
|
|
|
snapshot = client.get(f"/api/v1/admin/videos/{VIDEO_HASH}/subtitle-translation")
|
|
assert snapshot.status_code == 200
|
|
assert snapshot.json()["missing_count"] == 1
|
|
|
|
response = client.post(
|
|
f"/api/v1/admin/videos/{VIDEO_HASH}/subtitle-translation/repair"
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
if not payload.get("complete"):
|
|
finished = wait_for_subtitle_translation(client, VIDEO_HASH)
|
|
payload = client.get(f"/api/v1/admin/videos/{VIDEO_HASH}/subtitle-translation").json()
|
|
payload["attempted_count"] = response.json()["attempted_count"]
|
|
payload["complete"] = finished.json()["complete"]
|
|
|
|
assert payload["attempted_count"] == 1
|
|
assert payload["complete"] is True
|
|
assert payload["missing_count"] == 0
|
|
document = repository.get_document(VIDEO_HASH)
|
|
assert [sentence.zh_text for sentence in document.sentences] == ["你好。", "补译1"]
|
|
assert requested
|
|
|
|
|
|
def test_admin_translation_repair_keeps_complete_chinese(tmp_path, monkeypatch):
|
|
client = make_client(tmp_path)
|
|
repository = client.app.state.video_repository
|
|
_ready_video(repository)
|
|
repository.save_sentence_translations(
|
|
VIDEO_HASH,
|
|
{0: "你好。", 1: "见到你很高兴。"},
|
|
)
|
|
called = {"count": 0}
|
|
|
|
def translate_sentences(sentences, **kwargs):
|
|
called["count"] += 1
|
|
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)
|
|
client.app.state.subtitle_translations.ai_tutor = stub
|
|
|
|
response = client.post(
|
|
f"/api/v1/admin/videos/{VIDEO_HASH}/subtitle-translation/repair"
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["attempted_count"] == 0
|
|
assert response.json()["missing_count"] == 0
|
|
assert called["count"] == 0
|
|
document = repository.get_document(VIDEO_HASH)
|
|
assert [sentence.zh_text for sentence in document.sentences] == ["你好。", "见到你很高兴。"]
|