add volcengine OSS support

This commit is contained in:
2026-08-30 06:52:14 +08:00
parent 6de1d4a3d0
commit 856aa38026
9 changed files with 339 additions and 10 deletions

View File

@@ -16,3 +16,13 @@ MOSS_TIMEOUT_SECONDS=1800
MOSS_MAX_NEW_TOKENS=65536
# 每句结束时间向后顺延的毫秒数,避免句子末尾发音被切掉;按需在 200-500 之间调整
MOSS_END_PADDING_MS=300
# 火山引擎对象存储TOS / 火山 OSS。桶请设置为公共读或把下方地址配置为可公开访问的 CDN 域名。
VOLCANO_OSS_ACCESS_KEY=replace-with-volcano-access-key
VOLCANO_OSS_SECRET_KEY=replace-with-volcano-secret-key
VOLCANO_OSS_BUCKET=your-course-videos
VOLCANO_OSS_ENDPOINT=tos-cn-beijing.volces.com
VOLCANO_OSS_REGION=cn-beijing
VOLCANO_OSS_KEY_PREFIX=videos/
# 可选:绑定自定义域名或 CDN 后填写,例如 https://media.example.com
# VOLCANO_OSS_PUBLIC_BASE_URL=https://media.example.com

View File

@@ -4,6 +4,9 @@ FastAPI service for the Android oral-training SDK. It provides:
- chunked video upload with SHA-256 calculation;
- files stored under `data/v/` and metadata stored in SQLite;
- after the first processing pass, videos can be uploaded automatically to
Volcano Engine Object Storage (TOS / 火山 OSS) and the catalog/share UI will
use that public URL;
- background sentence transcription through MOSS-Transcribe-Diarize;
- a browser-based administration page at `/admin`;
- video catalog and HTTP Range playback;
@@ -33,6 +36,12 @@ http://127.0.0.1:8000/docs
When `MOSS_TRANSCRIBE_URL` is empty, uploaded videos still receive silence-based
boundaries, but no reference transcript is produced and assessment is disabled.
When all `VOLCANO_OSS_*` credentials in `.env.example` are configured, the first
processing pass saves sentence boundaries, uploads the source video, and only
then marks it ready. `stream_url` and the legacy `/content` endpoint then point
to the OSS/CDN URL. If the upload fails, the video is marked failed so clients
do not fall back to server-local playback.
## Main Endpoints
```text

View File

@@ -1,6 +1,7 @@
import os
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import quote
def _bool_env(name: str, default: bool) -> bool:
@@ -26,6 +27,13 @@ class Settings:
moss_max_new_tokens: int
moss_end_padding_ms: int
pass_score: float
volcano_oss_access_key: str
volcano_oss_secret_key: str
volcano_oss_bucket: str
volcano_oss_endpoint: str
volcano_oss_region: str
volcano_oss_key_prefix: str
volcano_oss_public_base_url: str
@classmethod
def from_env(cls) -> "Settings":
@@ -54,6 +62,19 @@ class Settings:
moss_max_new_tokens=int(os.getenv("MOSS_MAX_NEW_TOKENS", "65536")),
moss_end_padding_ms=int(os.getenv("MOSS_END_PADDING_MS", "300")),
pass_score=float(os.getenv("ASSESSMENT_PASS_SCORE", "70")),
volcano_oss_access_key=os.getenv("VOLCANO_OSS_ACCESS_KEY", "").strip(),
volcano_oss_secret_key=os.getenv("VOLCANO_OSS_SECRET_KEY", "").strip(),
volcano_oss_bucket=os.getenv("VOLCANO_OSS_BUCKET", "").strip(),
volcano_oss_endpoint=os.getenv(
"VOLCANO_OSS_ENDPOINT", "tos-cn-beijing.volces.com"
).strip().strip("/"),
volcano_oss_region=os.getenv("VOLCANO_OSS_REGION", "cn-beijing").strip(),
volcano_oss_key_prefix=Settings._normalize_key_prefix(
os.getenv("VOLCANO_OSS_KEY_PREFIX", "videos/")
),
volcano_oss_public_base_url=os.getenv(
"VOLCANO_OSS_PUBLIC_BASE_URL", ""
).strip().rstrip("/"),
)
@property
@@ -76,6 +97,34 @@ class Settings:
def database_path(self) -> Path:
return self.data_dir / "oral_trainer.sqlite3"
@property
def volcano_oss_enabled(self) -> bool:
return all(
(
self.volcano_oss_access_key,
self.volcano_oss_secret_key,
self.volcano_oss_bucket,
self.volcano_oss_endpoint,
self.volcano_oss_region,
)
)
def volcano_oss_public_url(self, object_key: str) -> str:
quoted_key = quote(object_key)
if self.volcano_oss_public_base_url:
return f"{self.volcano_oss_public_base_url}/{quoted_key}"
endpoint = self.volcano_oss_endpoint
if "://" in endpoint:
scheme, host = endpoint.split("://", 1)
else:
scheme, host = "https", endpoint
return f"{scheme}://{self.volcano_oss_bucket}.{host}/{quoted_key}"
@staticmethod
def _normalize_key_prefix(value: str) -> str:
normalized = value.strip().strip("/")
return f"{normalized}/" if normalized else ""
def ensure_directories(self) -> None:
for path in (self.data_dir, self.videos_dir, self.work_dir, self.attempts_dir, self.dub_shares_dir):
path.mkdir(parents=True, exist_ok=True)

View File

@@ -22,7 +22,7 @@ from fastapi import (
Request,
UploadFile,
)
from fastapi.responses import FileResponse, Response
from fastapi.responses import FileResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from starlette.concurrency import run_in_threadpool
from starlette.requests import ClientDisconnect
@@ -40,6 +40,7 @@ from .models import (
VideoSummary,
VideoUploadResponse,
)
from .oss import VolcanoOSSUploader
from .processing import VideoProcessor
from .repository import VideoRepository
from .store import BoundaryStore
@@ -67,6 +68,7 @@ def create_app(
settings: Optional[Settings] = None,
repository: Optional[VideoRepository] = None,
transcriber: Optional[Transcriber] = None,
oss_uploader: Optional[VolcanoOSSUploader] = None,
) -> FastAPI:
service_settings = settings or Settings.from_env()
service_settings.ensure_directories()
@@ -78,7 +80,8 @@ def create_app(
timeout_seconds=service_settings.moss_timeout_seconds,
max_new_tokens=service_settings.moss_max_new_tokens,
)
processor = VideoProcessor(service_settings, video_repository, moss)
oss = oss_uploader or VolcanoOSSUploader(service_settings)
processor = VideoProcessor(service_settings, video_repository, moss, oss)
assessment_service = AssessmentService(
video_repository,
moss,
@@ -94,6 +97,7 @@ def create_app(
application.state.boundary_store = legacy_store
application.state.video_repository = video_repository
application.state.transcriber = moss
application.state.oss_uploader = oss
application.state.video_processor = processor
application.state.assessment_service = assessment_service
@@ -130,6 +134,7 @@ def create_app(
"video_count": len(videos) + legacy_store.count(),
"managed_video_count": len(videos),
"moss_configured": moss.available,
"volcano_oss_configured": oss.enabled,
"scoring_version": "asr-fluency-v1",
}
@@ -157,10 +162,12 @@ def create_app(
)
@application.get("/api/v1/videos/{video_hash}/content")
def stream_video(video_hash: str = SHA256_PATH) -> FileResponse:
def stream_video(video_hash: str = SHA256_PATH):
row = video_repository.get_video(video_hash)
if row is None:
raise HTTPException(status_code=404, detail="Video was not found.")
if row.get("remote_url"):
return RedirectResponse(row["remote_url"], status_code=307)
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.")
@@ -702,8 +709,15 @@ def _process_safely(processor: VideoProcessor, video_hash: str) -> None:
def _video_summary(row: Dict[str, Any], settings: Settings) -> VideoSummary:
if row.get("remote_url"):
stream_url = row["remote_url"]
else:
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
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"],

77
sentence_api/oss.py Normal file
View File

@@ -0,0 +1,77 @@
import logging
from pathlib import Path
from typing import Optional
from urllib.parse import quote
from .config import Settings
logger = logging.getLogger(__name__)
class VolcanoOSSUploader:
"""Upload course videos to Volcano Engine Object Storage (TOS)."""
def __init__(self, settings: Settings):
self.settings = settings
@property
def enabled(self) -> bool:
return self.settings.volcano_oss_enabled
def object_key(self, video_hash: str, stored_filename: str) -> str:
normalized_hash = video_hash.strip().lower()
suffix = Path(stored_filename).suffix or ".mp4"
return f"{self.settings.volcano_oss_key_prefix}{normalized_hash}{suffix}"
def public_url(self, object_key: str) -> str:
return self.settings.volcano_oss_public_url(object_key)
def upload(
self,
*,
video_hash: str,
stored_filename: str,
local_path: Path,
content_type: Optional[str] = None,
) -> str:
"""Upload one local file and return its permanent public URL."""
if not self.enabled:
raise RuntimeError("Volcano OSS is not configured.")
if not local_path.is_file():
raise FileNotFoundError(f"Cannot upload a missing video: {local_path}")
# Keep the SDK import optional for development machines that do not run
# the production service. It is declared in sentence_api/requirements.txt.
try:
import tos
except ImportError as exc:
raise RuntimeError(
"The Volcano TOS SDK is not installed. Run: pip install tos"
) from exc
object_key = self.object_key(video_hash, stored_filename)
endpoint = self.settings.volcano_oss_endpoint
client = tos.TosClientV2(
self.settings.volcano_oss_access_key,
self.settings.volcano_oss_secret_key,
endpoint,
self.settings.volcano_oss_region,
)
try:
client.put_object_from_file(
self.settings.volcano_oss_bucket,
object_key,
str(local_path),
)
except Exception:
logger.exception(
"Volcano OSS upload failed: bucket=%s key=%s endpoint=%s",
self.settings.volcano_oss_bucket,
object_key,
endpoint,
)
raise
logger.info("Uploaded %s to Volcano OSS object %s", local_path.name, object_key)
return self.public_url(object_key)

View File

@@ -14,6 +14,7 @@ from .audio_metrics import (
from .config import Settings
from .generate_boundaries import ALGORITHM_VERSION, make_entry
from .models import SentenceBoundary, SentenceBoundaryDocument
from .oss import VolcanoOSSUploader
from .repository import VideoRepository
from .transcription import Transcript, Transcriber, split_sentences_at_punctuation
@@ -27,10 +28,12 @@ class VideoProcessor:
settings: Settings,
repository: VideoRepository,
transcriber: Transcriber,
oss_uploader: Optional[VolcanoOSSUploader] = None,
):
self.settings = settings
self.repository = repository
self.transcriber = transcriber
self.oss_uploader = oss_uploader
def process(self, video_hash: str) -> None:
video = self.repository.get_video(video_hash)
@@ -57,7 +60,7 @@ class VideoProcessor:
)
if not document.sentences:
raise RuntimeError("MOSS returned no timestamped speech segments.")
self.repository.save_processing_result(document, transcript.text)
self._finish_processing(document, transcript.text)
else:
entry, _ = make_entry(media_path, video_hash=video_hash)
document = SentenceBoundaryDocument(
@@ -66,7 +69,7 @@ class VideoProcessor:
algorithm_version=ALGORITHM_VERSION,
sentences=entry["sentences"],
)
self.repository.save_processing_result(document, None)
self._finish_processing(document, None)
except Exception as exc:
self.repository.mark_failed(video_hash, str(exc))
raise
@@ -74,6 +77,28 @@ class VideoProcessor:
if work_path is not None:
work_path.unlink(missing_ok=True)
def _finish_processing(
self,
document: SentenceBoundaryDocument,
transcription: Optional[str],
) -> None:
if self.oss_uploader is None or not self.oss_uploader.enabled:
self.repository.save_processing_result(document, transcription, ready=True)
return
# Keep the course invisible while the mandatory OSS upload is running.
self.repository.save_processing_result(document, transcription, ready=False)
video = self.repository.get_video(document.video_hash)
if video is None:
raise ValueError(f"Unknown video: {document.video_hash}")
remote_url = self.oss_uploader.upload(
video_hash=document.video_hash,
stored_filename=video["stored_filename"],
local_path=self.settings.videos_dir / video["stored_filename"],
content_type=video.get("content_type"),
)
self.repository.mark_oss_uploaded(document.video_hash, remote_url)
def extract_audio(media_path: Path, output_path: Path) -> None:
ffmpeg = shutil.which("ffmpeg")

View File

@@ -43,6 +43,7 @@ class VideoRepository:
error_message TEXT,
algorithm_version TEXT,
transcription TEXT,
remote_url TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -113,6 +114,14 @@ class VideoRepository:
"ALTER TABLE dub_share_segments ADD COLUMN score_details TEXT NOT NULL DEFAULT '{}'"
)
with self._connect() as connection:
video_columns = {
row["name"]
for row in connection.execute("PRAGMA table_info(videos)")
}
if "remote_url" not in video_columns:
connection.execute("ALTER TABLE videos ADD COLUMN remote_url TEXT")
def upsert_upload(
self,
*,
@@ -137,8 +146,8 @@ class VideoRepository:
INSERT INTO videos (
video_hash, title, filename, stored_filename, content_type,
size_bytes, duration_ms, language, status, error_message,
algorithm_version, transcription, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, 'uploaded', NULL, NULL, NULL, ?, ?)
algorithm_version, transcription, remote_url, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, 'uploaded', NULL, NULL, NULL, NULL, ?, ?)
ON CONFLICT(video_hash) DO UPDATE SET
title = excluded.title,
filename = excluded.filename,
@@ -180,6 +189,7 @@ class VideoRepository:
self,
document: SentenceBoundaryDocument,
transcription: Optional[str],
ready: bool = True,
) -> None:
with self._connect() as connection:
connection.execute(
@@ -208,12 +218,13 @@ class VideoRepository:
)
connection.execute(
"""
UPDATE videos SET duration_ms = ?, status = 'ready', error_message = NULL,
UPDATE videos SET duration_ms = ?, status = ?, error_message = NULL,
algorithm_version = ?, transcription = ?, updated_at = ?
WHERE video_hash = ?
""",
(
document.duration_ms,
"ready" if ready else "processing",
document.algorithm_version,
transcription,
utc_now(),
@@ -221,6 +232,17 @@ class VideoRepository:
),
)
def mark_oss_uploaded(self, video_hash: str, remote_url: str) -> None:
with self._connect() as connection:
connection.execute(
"""
UPDATE videos
SET remote_url = ?, status = 'ready', error_message = NULL, updated_at = ?
WHERE video_hash = ?
""",
(remote_url, utc_now(), normalize_video_hash(video_hash)),
)
def list_videos(self) -> List[Dict[str, Any]]:
with self._connect() as connection:
rows = connection.execute(

View File

@@ -6,3 +6,6 @@ httpx>=0.27,<1
python-multipart>=0.0.9,<1
av>=12.0
numpy>=1.26
tos>=2.8,<3
requests>=2.31,<3
Deprecated>=1.2,<2

View File

@@ -5,6 +5,9 @@ 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
@@ -68,3 +71,120 @@ def test_invalid_hash_is_rejected(tmp_path):
response = client.get("/api/v1/videos/not-a-sha256/sentence-boundaries")
assert response.status_code == 422
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"