added share function

This commit is contained in:
2026-08-26 09:47:52 +08:00
parent ccd8ec16bb
commit f80da8a5f6
5 changed files with 435 additions and 18 deletions

View File

@@ -4,7 +4,8 @@ import logging
import mimetypes
import uuid
from pathlib import Path
from typing import Any, BinaryIO, Dict, Optional
from typing import Any, BinaryIO, Dict, List, Optional
import json
from fastapi import (
BackgroundTasks,
@@ -51,6 +52,7 @@ SHA256_PATH = ApiPath(
description="SHA-256 hex digest of the exact uploaded video bytes",
)
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".m4v", ".mov", ".mkv", ".webm"}
ALLOWED_AUDIO_EXTENSIONS = {".m4a", ".mp4", ".wav"}
class UploadTooLargeError(ValueError):
@@ -380,6 +382,116 @@ def create_app(
if not service_settings.keep_attempt_audio or not completed:
temporary_path.unlink(missing_ok=True)
@application.post("/api/v1/dub-shares", status_code=201)
async def create_dub_share(
request: Request,
video_hash: str = Form(...),
title: str = Form(default="我的口语配音"),
segments: str = Form(...),
files: List[UploadFile] = File(...),
) -> Dict[str, Any]:
try:
segment_items = json.loads(segments)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="segments must be valid JSON.") from exc
if not isinstance(segment_items, list) or not segment_items:
raise HTTPException(status_code=400, detail="At least one dubbing segment is required.")
if len(segment_items) != len(files):
raise HTTPException(status_code=400, detail="Segment count does not match audio file count.")
document = find_document(video_hash.lower())
if document is None:
raise HTTPException(status_code=404, detail="Video or sentence boundaries were not found.")
prepared_segments: List[Dict[str, Any]] = []
saved_files: List[Path] = []
try:
for item, upload in zip(segment_items, files):
sentence_index = int(item["sentence_index"])
boundary = next((s for s in document.sentences if s.index == sentence_index), None)
if boundary is None:
raise HTTPException(status_code=404, detail=f"Sentence {sentence_index} was not found.")
suffix = Path(upload.filename or "dub.m4a").suffix.lower()
if suffix == ".mp4":
suffix = ".m4a"
if suffix not in ALLOWED_AUDIO_EXTENSIONS:
raise HTTPException(status_code=415, detail=f"Unsupported audio extension: {suffix or '(none)'}")
audio_name = f"{uuid.uuid4().hex}{suffix}"
audio_path = service_settings.dub_shares_dir / audio_name
await run_in_threadpool(_persist_upload, upload.file, audio_path, 50 * 1024 * 1024)
saved_files.append(audio_path)
prepared_segments.append({
"sentence_index": sentence_index,
"start_ms": boundary.start_ms,
"end_ms": boundary.end_ms,
"text": boundary.text,
"audio_filename": audio_name,
"audio_size_bytes": audio_path.stat().st_size,
})
share = video_repository.create_dub_share(
video_hash=video_hash.lower(),
title=title,
segments=prepared_segments,
)
if share is None:
raise HTTPException(status_code=404, detail="Video was not found.")
return {
**share,
"share_url": f"/dub-shares/{share['share_id']}",
}
except UploadTooLargeError as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
except HTTPException:
raise
finally:
for upload in files:
await upload.close()
# Files intentionally remain until share deletion because the web page references them.
del saved_files
@application.get("/api/v1/dub-shares/{share_id}")
def get_dub_share(share_id: str) -> Dict[str, Any]:
share = video_repository.get_dub_share(share_id)
if share is None:
raise HTTPException(status_code=404, detail="Dubbing share was not found.")
return {
"share_id": share["share_id"],
"title": share["title"],
"created_at": share["created_at"],
"segments": [
{
"index": row["sentence_index"],
"start_ms": row["start_ms"],
"end_ms": row["end_ms"],
"text": row["text"],
"audio_url": f"/api/v1/dub-shares/{share_id}/audio/{row['audio_filename']}",
}
for row in share["segments"]
],
}
@application.get("/api/v1/dub-shares/{share_id}/audio/{filename}")
def get_dub_share_audio(share_id: str, filename: str) -> FileResponse:
share = video_repository.get_dub_share(share_id)
if share is None:
raise HTTPException(status_code=404, detail="Dubbing share was not found.")
safe_name = Path(filename).name
row = next((item for item in share["segments"] if item["audio_filename"] == safe_name), None)
if row is None:
raise HTTPException(status_code=404, detail="Audio was not found.")
media_path = service_settings.dub_shares_dir / safe_name
if not media_path.is_file():
raise HTTPException(status_code=404, detail="Stored audio file is missing.")
return FileResponse(media_path, media_type="audio/mp4", headers={"Cache-Control": "public, max-age=31536000"})
@application.get("/dub-shares/{share_id}", include_in_schema=False)
def dub_share_page(share_id: str) -> FileResponse:
if video_repository.get_dub_share(share_id) is None:
raise HTTPException(status_code=404, detail="Dubbing share was not found.")
page = static_dir / "dub-share.html"
if not page.is_file():
raise HTTPException(status_code=404, detail="Dubbing share UI is not installed.")
return FileResponse(page)
return application