Files
mediaplayer/sentence_api/main.py
2026-08-17 14:28:37 +08:00

508 lines
19 KiB
Python

import hashlib
import hmac
import logging
import mimetypes
import uuid
from pathlib import Path
from typing import Any, BinaryIO, Dict, Optional
from fastapi import (
BackgroundTasks,
Depends,
FastAPI,
File,
Form,
Header,
HTTPException,
Path as ApiPath,
Query,
Request,
UploadFile,
)
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from starlette.concurrency import run_in_threadpool
from starlette.requests import ClientDisconnect
from .assessment import AssessmentService
from .audio_metrics import AudioAnalysisError
from .config import Settings
from .models import (
AssessmentResult,
SentenceBoundary,
SentenceBoundaryDocument,
SentenceTextUpdate,
VideoDetailResponse,
VideoListResponse,
VideoSummary,
VideoUploadResponse,
)
from .processing import VideoProcessor
from .repository import VideoRepository
from .store import BoundaryStore
from .transcription import MossTranscriber, Transcriber
logger = logging.getLogger(__name__)
SHA256_PATH = ApiPath(
min_length=64,
max_length=64,
pattern=r"^[A-Fa-f0-9]{64}$",
description="SHA-256 hex digest of the exact uploaded video bytes",
)
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".m4v", ".mov", ".mkv", ".webm"}
class UploadTooLargeError(ValueError):
pass
def create_app(
store: Optional[BoundaryStore] = None,
*,
settings: Optional[Settings] = None,
repository: Optional[VideoRepository] = None,
transcriber: Optional[Transcriber] = None,
) -> FastAPI:
service_settings = settings or Settings.from_env()
service_settings.ensure_directories()
video_repository = repository or VideoRepository(service_settings.database_path)
legacy_store = store or BoundaryStore(service_settings.legacy_boundaries_file)
moss = transcriber or MossTranscriber(
endpoint=service_settings.moss_transcribe_url,
model=service_settings.moss_model,
timeout_seconds=service_settings.moss_timeout_seconds,
max_new_tokens=service_settings.moss_max_new_tokens,
)
processor = VideoProcessor(service_settings, video_repository, moss)
assessment_service = AssessmentService(
video_repository,
moss,
service_settings.pass_score,
)
application = FastAPI(
title="Oral Trainer Video Service",
version="2.0.0",
description="Video ingestion, sentence transcription, streaming, and oral-reading assessment.",
)
application.state.settings = service_settings
application.state.boundary_store = legacy_store
application.state.video_repository = video_repository
application.state.transcriber = moss
application.state.video_processor = processor
application.state.assessment_service = assessment_service
static_dir = Path(__file__).resolve().parent / "static"
if static_dir.is_dir():
application.mount("/static", StaticFiles(directory=static_dir), name="static")
@application.exception_handler(ClientDisconnect)
async def handle_client_disconnect(request: Request, exc: ClientDisconnect) -> Response:
logger.warning(
"Upload interrupted: the client or an intermediate proxy closed the "
"connection before the request body completed."
)
return Response(status_code=400, content="Upload connection was interrupted.")
def require_admin(x_admin_key: Optional[str] = Header(default=None)) -> None:
expected = service_settings.admin_api_key
if expected and not hmac.compare_digest(x_admin_key or "", expected):
raise HTTPException(status_code=401, detail="A valid X-Admin-Key header is required.")
def require_client(x_client_key: Optional[str] = Header(default=None)) -> None:
expected = service_settings.client_api_key
if expected and not hmac.compare_digest(x_client_key or "", expected):
raise HTTPException(status_code=401, detail="A valid X-Client-Key header is required.")
def find_document(video_hash: str) -> Optional[SentenceBoundaryDocument]:
return video_repository.get_document(video_hash) or legacy_store.get(video_hash)
@application.get("/healthz")
def healthz() -> Dict[str, Any]:
videos = video_repository.list_videos()
return {
"status": "ok",
"video_count": len(videos) + legacy_store.count(),
"managed_video_count": len(videos),
"moss_configured": moss.available,
"scoring_version": "asr-fluency-v1",
}
@application.get("/admin", include_in_schema=False)
def admin_page() -> FileResponse:
page = static_dir / "admin.html"
if not page.is_file():
raise HTTPException(status_code=404, detail="Admin UI is not installed.")
return FileResponse(page)
@application.get("/api/v1/videos", response_model=VideoListResponse)
def list_videos() -> VideoListResponse:
return VideoListResponse(
videos=[_video_summary(row, service_settings) for row in video_repository.list_videos()]
)
@application.get("/api/v1/videos/{video_hash}", response_model=VideoDetailResponse)
def get_video(video_hash: str = SHA256_PATH) -> VideoDetailResponse:
row = video_repository.get_video(video_hash)
if row is None:
raise HTTPException(status_code=404, detail="Video was not found.")
return VideoDetailResponse(
video=_video_summary(row, service_settings),
boundaries=video_repository.get_document(video_hash),
)
@application.get("/api/v1/videos/{video_hash}/content")
def stream_video(video_hash: str = SHA256_PATH) -> FileResponse:
row = video_repository.get_video(video_hash)
if row is None:
raise HTTPException(status_code=404, detail="Video was not found.")
media_path = service_settings.videos_dir / row["stored_filename"]
if not media_path.is_file():
raise HTTPException(status_code=404, detail="Stored video file is missing.")
return FileResponse(
media_path,
media_type=row["content_type"],
headers={"Accept-Ranges": "bytes", "Cache-Control": "public, max-age=3600"},
)
@application.get(
"/api/v1/videos/{video_hash}/sentence-boundaries",
response_model=SentenceBoundaryDocument,
)
@application.get(
"/api/v1/sentence-boundaries/{video_hash}",
response_model=SentenceBoundaryDocument,
include_in_schema=False,
)
def get_sentence_boundaries(video_hash: str = SHA256_PATH) -> SentenceBoundaryDocument:
document = find_document(video_hash)
if document is None:
raise HTTPException(
status_code=404,
detail={
"code": "SENTENCE_BOUNDARIES_NOT_FOUND",
"message": "No sentence boundaries are registered for this video hash.",
},
)
return document
@application.post(
"/api/v1/admin/videos",
response_model=VideoUploadResponse,
status_code=202,
dependencies=[Depends(require_admin)],
)
async def upload_video(
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
title: Optional[str] = Form(default=None),
language: Optional[str] = Form(default=None),
) -> VideoUploadResponse:
original_name = Path(file.filename or "video.mp4").name
suffix = _validate_video_extension(original_name)
temporary_path = service_settings.work_dir / f"upload-{uuid.uuid4().hex}.part"
try:
video_hash, size_bytes = await run_in_threadpool(
_persist_upload,
file.file,
temporary_path,
service_settings.max_upload_bytes,
)
except UploadTooLargeError as exc:
temporary_path.unlink(missing_ok=True)
raise HTTPException(status_code=413, detail=str(exc)) from exc
except ValueError as exc:
temporary_path.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail=str(exc)) from exc
finally:
await file.close()
content_type = file.content_type or mimetypes.guess_type(original_name)[0] or "video/mp4"
response = _register_uploaded_video(
settings=service_settings,
repository=video_repository,
temporary_path=temporary_path,
video_hash=video_hash,
size_bytes=size_bytes,
original_name=original_name,
suffix=suffix,
content_type=content_type,
title=title,
language=language,
)
background_tasks.add_task(_process_safely, processor, video_hash)
return response
@application.put(
"/api/v1/admin/videos/raw",
response_model=VideoUploadResponse,
status_code=202,
dependencies=[Depends(require_admin)],
)
async def upload_video_stream(
request: Request,
background_tasks: BackgroundTasks,
filename: str = Query(min_length=1, max_length=500),
title: Optional[str] = Query(default=None, max_length=200),
language: Optional[str] = Query(default=None, max_length=32),
) -> VideoUploadResponse:
original_name = Path(filename).name
suffix = _validate_video_extension(original_name)
content_length = request.headers.get("content-length")
if content_length:
try:
declared_size = int(content_length)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid Content-Length header.") from exc
if declared_size > service_settings.max_upload_bytes:
raise HTTPException(status_code=413, detail="Upload exceeds the configured limit.")
temporary_path = service_settings.work_dir / f"upload-{uuid.uuid4().hex}.part"
try:
video_hash, size_bytes = await _persist_request_stream(
request,
temporary_path,
service_settings.max_upload_bytes,
)
except UploadTooLargeError as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
response = _register_uploaded_video(
settings=service_settings,
repository=video_repository,
temporary_path=temporary_path,
video_hash=video_hash,
size_bytes=size_bytes,
original_name=original_name,
suffix=suffix,
content_type=request.headers.get("content-type") or "application/octet-stream",
title=title,
language=language,
)
background_tasks.add_task(_process_safely, processor, video_hash)
return response
@application.post(
"/api/v1/admin/videos/{video_hash}/process",
status_code=202,
dependencies=[Depends(require_admin)],
)
def reprocess_video(
background_tasks: BackgroundTasks,
video_hash: str = SHA256_PATH,
) -> Dict[str, str]:
if video_repository.get_video(video_hash) is None:
raise HTTPException(status_code=404, detail="Video was not found.")
background_tasks.add_task(_process_safely, processor, video_hash)
return {"video_hash": video_hash.lower(), "status": "processing_queued"}
@application.put(
"/api/v1/admin/videos/{video_hash}/sentences/{sentence_index}",
response_model=SentenceBoundary,
dependencies=[Depends(require_admin)],
)
def update_sentence(
payload: SentenceTextUpdate,
video_hash: str = SHA256_PATH,
sentence_index: int = ApiPath(ge=0),
) -> SentenceBoundary:
sentence = video_repository.update_sentence_text(
video_hash,
sentence_index,
payload.text,
payload.language,
)
if sentence is None:
raise HTTPException(status_code=404, detail="Sentence was not found.")
return sentence
@application.delete(
"/api/v1/admin/videos/{video_hash}",
status_code=204,
dependencies=[Depends(require_admin)],
)
def delete_video(video_hash: str = SHA256_PATH) -> Response:
row = video_repository.delete_video(video_hash)
if row is None:
raise HTTPException(status_code=404, detail="Video was not found.")
(service_settings.videos_dir / row["stored_filename"]).unlink(missing_ok=True)
return Response(status_code=204)
@application.post(
"/api/v1/videos/{video_hash}/sentences/{sentence_index}/assessments",
response_model=AssessmentResult,
dependencies=[Depends(require_client)],
)
async def assess_sentence(
video_hash: str = SHA256_PATH,
sentence_index: int = ApiPath(ge=0),
audio: UploadFile = File(...),
language: Optional[str] = Form(default=None),
) -> AssessmentResult:
document = find_document(video_hash)
if document is None:
raise HTTPException(status_code=404, detail="Video or sentence boundaries were not found.")
if not moss.available:
raise HTTPException(status_code=503, detail="MOSS transcription is not configured.")
suffix = Path(audio.filename or "attempt.wav").suffix.lower() or ".wav"
temporary_name = f"attempt-{uuid.uuid4().hex}{suffix}"
temporary_path = service_settings.attempts_dir / temporary_name
completed = False
try:
await run_in_threadpool(
_persist_upload,
audio.file,
temporary_path,
service_settings.max_attempt_bytes,
)
result = await run_in_threadpool(
assessment_service.assess,
document=document,
sentence_index=sentence_index,
audio_path=temporary_path,
language=language,
retained_audio_filename=temporary_name if service_settings.keep_attempt_audio else None,
)
completed = True
return result
except UploadTooLargeError as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ValueError, AudioAnalysisError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
finally:
await audio.close()
if not service_settings.keep_attempt_audio or not completed:
temporary_path.unlink(missing_ok=True)
return application
def _persist_upload(source: BinaryIO, destination: Path, max_bytes: int) -> tuple[str, int]:
digest = hashlib.sha256()
size = 0
destination.parent.mkdir(parents=True, exist_ok=True)
try:
with destination.open("wb") as output:
while True:
chunk = source.read(4 * 1024 * 1024)
if not chunk:
break
size += len(chunk)
if size > max_bytes:
raise UploadTooLargeError(f"Upload exceeds the {max_bytes}-byte limit.")
digest.update(chunk)
output.write(chunk)
if size == 0:
raise ValueError("Uploaded file is empty.")
except Exception:
destination.unlink(missing_ok=True)
raise
return digest.hexdigest(), size
async def _persist_request_stream(
request: Request,
destination: Path,
max_bytes: int,
) -> tuple[str, int]:
digest = hashlib.sha256()
size = 0
destination.parent.mkdir(parents=True, exist_ok=True)
try:
with destination.open("wb") as output:
async for chunk in request.stream():
if not chunk:
continue
size += len(chunk)
if size > max_bytes:
raise UploadTooLargeError(f"Upload exceeds the {max_bytes}-byte limit.")
digest.update(chunk)
output.write(chunk)
if size == 0:
raise ValueError("Uploaded file is empty.")
except Exception:
destination.unlink(missing_ok=True)
raise
return digest.hexdigest(), size
def _validate_video_extension(filename: str) -> str:
suffix = Path(filename).suffix.lower()
if suffix not in ALLOWED_VIDEO_EXTENSIONS:
raise HTTPException(
status_code=415,
detail=f"Unsupported video extension: {suffix or '(none)'}",
)
return suffix
def _register_uploaded_video(
*,
settings: Settings,
repository: VideoRepository,
temporary_path: Path,
video_hash: str,
size_bytes: int,
original_name: str,
suffix: str,
content_type: str,
title: Optional[str],
language: Optional[str],
) -> VideoUploadResponse:
stored_filename = f"{video_hash}{suffix}"
destination = settings.videos_dir / stored_filename
if destination.exists():
temporary_path.unlink(missing_ok=True)
else:
temporary_path.replace(destination)
repository.upsert_upload(
video_hash=video_hash,
title=(title or Path(original_name).stem).strip() or video_hash,
filename=original_name,
stored_filename=stored_filename,
content_type=content_type,
size_bytes=size_bytes,
language=language.strip() if language else None,
)
return VideoUploadResponse(
video_hash=video_hash,
status="uploaded",
detail_url=f"/api/v1/videos/{video_hash}",
)
def _process_safely(processor: VideoProcessor, video_hash: str) -> None:
try:
processor.process(video_hash)
except Exception:
logger.exception("Video processing failed for %s", video_hash)
def _video_summary(row: Dict[str, Any], settings: Settings) -> VideoSummary:
relative_stream_url = f"/api/v1/videos/{row['video_hash']}/content"
stream_url = f"{settings.public_base_url}{relative_stream_url}" if settings.public_base_url else relative_stream_url
return VideoSummary(
video_hash=row["video_hash"],
title=row["title"],
filename=row["filename"],
content_type=row["content_type"],
size_bytes=row["size_bytes"],
duration_ms=row["duration_ms"],
language=row["language"],
status=row["status"],
error_message=row["error_message"],
sentence_count=row["sentence_count"],
stream_url=stream_url,
created_at=row["created_at"],
updated_at=row["updated_at"],
)
app = create_app()