add volcengine OSS support
This commit is contained in:
@@ -16,3 +16,13 @@ MOSS_TIMEOUT_SECONDS=1800
|
|||||||
MOSS_MAX_NEW_TOKENS=65536
|
MOSS_MAX_NEW_TOKENS=65536
|
||||||
# 每句结束时间向后顺延的毫秒数,避免句子末尾发音被切掉;按需在 200-500 之间调整
|
# 每句结束时间向后顺延的毫秒数,避免句子末尾发音被切掉;按需在 200-500 之间调整
|
||||||
MOSS_END_PADDING_MS=300
|
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
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ FastAPI service for the Android oral-training SDK. It provides:
|
|||||||
|
|
||||||
- chunked video upload with SHA-256 calculation;
|
- chunked video upload with SHA-256 calculation;
|
||||||
- files stored under `data/v/` and metadata stored in SQLite;
|
- 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;
|
- background sentence transcription through MOSS-Transcribe-Diarize;
|
||||||
- a browser-based administration page at `/admin`;
|
- a browser-based administration page at `/admin`;
|
||||||
- video catalog and HTTP Range playback;
|
- 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
|
When `MOSS_TRANSCRIBE_URL` is empty, uploaded videos still receive silence-based
|
||||||
boundaries, but no reference transcript is produced and assessment is disabled.
|
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
|
## Main Endpoints
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
def _bool_env(name: str, default: bool) -> bool:
|
def _bool_env(name: str, default: bool) -> bool:
|
||||||
@@ -26,6 +27,13 @@ class Settings:
|
|||||||
moss_max_new_tokens: int
|
moss_max_new_tokens: int
|
||||||
moss_end_padding_ms: int
|
moss_end_padding_ms: int
|
||||||
pass_score: float
|
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
|
@classmethod
|
||||||
def from_env(cls) -> "Settings":
|
def from_env(cls) -> "Settings":
|
||||||
@@ -54,6 +62,19 @@ class Settings:
|
|||||||
moss_max_new_tokens=int(os.getenv("MOSS_MAX_NEW_TOKENS", "65536")),
|
moss_max_new_tokens=int(os.getenv("MOSS_MAX_NEW_TOKENS", "65536")),
|
||||||
moss_end_padding_ms=int(os.getenv("MOSS_END_PADDING_MS", "300")),
|
moss_end_padding_ms=int(os.getenv("MOSS_END_PADDING_MS", "300")),
|
||||||
pass_score=float(os.getenv("ASSESSMENT_PASS_SCORE", "70")),
|
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
|
@property
|
||||||
@@ -76,6 +97,34 @@ class Settings:
|
|||||||
def database_path(self) -> Path:
|
def database_path(self) -> Path:
|
||||||
return self.data_dir / "oral_trainer.sqlite3"
|
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:
|
def ensure_directories(self) -> None:
|
||||||
for path in (self.data_dir, self.videos_dir, self.work_dir, self.attempts_dir, self.dub_shares_dir):
|
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)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from fastapi import (
|
|||||||
Request,
|
Request,
|
||||||
UploadFile,
|
UploadFile,
|
||||||
)
|
)
|
||||||
from fastapi.responses import FileResponse, Response
|
from fastapi.responses import FileResponse, RedirectResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
from starlette.requests import ClientDisconnect
|
from starlette.requests import ClientDisconnect
|
||||||
@@ -40,6 +40,7 @@ from .models import (
|
|||||||
VideoSummary,
|
VideoSummary,
|
||||||
VideoUploadResponse,
|
VideoUploadResponse,
|
||||||
)
|
)
|
||||||
|
from .oss import VolcanoOSSUploader
|
||||||
from .processing import VideoProcessor
|
from .processing import VideoProcessor
|
||||||
from .repository import VideoRepository
|
from .repository import VideoRepository
|
||||||
from .store import BoundaryStore
|
from .store import BoundaryStore
|
||||||
@@ -67,6 +68,7 @@ def create_app(
|
|||||||
settings: Optional[Settings] = None,
|
settings: Optional[Settings] = None,
|
||||||
repository: Optional[VideoRepository] = None,
|
repository: Optional[VideoRepository] = None,
|
||||||
transcriber: Optional[Transcriber] = None,
|
transcriber: Optional[Transcriber] = None,
|
||||||
|
oss_uploader: Optional[VolcanoOSSUploader] = None,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
service_settings = settings or Settings.from_env()
|
service_settings = settings or Settings.from_env()
|
||||||
service_settings.ensure_directories()
|
service_settings.ensure_directories()
|
||||||
@@ -78,7 +80,8 @@ def create_app(
|
|||||||
timeout_seconds=service_settings.moss_timeout_seconds,
|
timeout_seconds=service_settings.moss_timeout_seconds,
|
||||||
max_new_tokens=service_settings.moss_max_new_tokens,
|
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(
|
assessment_service = AssessmentService(
|
||||||
video_repository,
|
video_repository,
|
||||||
moss,
|
moss,
|
||||||
@@ -94,6 +97,7 @@ def create_app(
|
|||||||
application.state.boundary_store = legacy_store
|
application.state.boundary_store = legacy_store
|
||||||
application.state.video_repository = video_repository
|
application.state.video_repository = video_repository
|
||||||
application.state.transcriber = moss
|
application.state.transcriber = moss
|
||||||
|
application.state.oss_uploader = oss
|
||||||
application.state.video_processor = processor
|
application.state.video_processor = processor
|
||||||
application.state.assessment_service = assessment_service
|
application.state.assessment_service = assessment_service
|
||||||
|
|
||||||
@@ -130,6 +134,7 @@ def create_app(
|
|||||||
"video_count": len(videos) + legacy_store.count(),
|
"video_count": len(videos) + legacy_store.count(),
|
||||||
"managed_video_count": len(videos),
|
"managed_video_count": len(videos),
|
||||||
"moss_configured": moss.available,
|
"moss_configured": moss.available,
|
||||||
|
"volcano_oss_configured": oss.enabled,
|
||||||
"scoring_version": "asr-fluency-v1",
|
"scoring_version": "asr-fluency-v1",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,10 +162,12 @@ def create_app(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@application.get("/api/v1/videos/{video_hash}/content")
|
@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)
|
row = video_repository.get_video(video_hash)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="Video was not found.")
|
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"]
|
media_path = service_settings.videos_dir / row["stored_filename"]
|
||||||
if not media_path.is_file():
|
if not media_path.is_file():
|
||||||
raise HTTPException(status_code=404, detail="Stored video file is missing.")
|
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:
|
def _video_summary(row: Dict[str, Any], settings: Settings) -> VideoSummary:
|
||||||
relative_stream_url = f"/api/v1/videos/{row['video_hash']}/content"
|
if row.get("remote_url"):
|
||||||
stream_url = f"{settings.public_base_url}{relative_stream_url}" if settings.public_base_url else relative_stream_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
|
||||||
|
)
|
||||||
return VideoSummary(
|
return VideoSummary(
|
||||||
video_hash=row["video_hash"],
|
video_hash=row["video_hash"],
|
||||||
title=row["title"],
|
title=row["title"],
|
||||||
|
|||||||
77
sentence_api/oss.py
Normal file
77
sentence_api/oss.py
Normal 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)
|
||||||
@@ -14,6 +14,7 @@ from .audio_metrics import (
|
|||||||
from .config import Settings
|
from .config import Settings
|
||||||
from .generate_boundaries import ALGORITHM_VERSION, make_entry
|
from .generate_boundaries import ALGORITHM_VERSION, make_entry
|
||||||
from .models import SentenceBoundary, SentenceBoundaryDocument
|
from .models import SentenceBoundary, SentenceBoundaryDocument
|
||||||
|
from .oss import VolcanoOSSUploader
|
||||||
from .repository import VideoRepository
|
from .repository import VideoRepository
|
||||||
from .transcription import Transcript, Transcriber, split_sentences_at_punctuation
|
from .transcription import Transcript, Transcriber, split_sentences_at_punctuation
|
||||||
|
|
||||||
@@ -27,10 +28,12 @@ class VideoProcessor:
|
|||||||
settings: Settings,
|
settings: Settings,
|
||||||
repository: VideoRepository,
|
repository: VideoRepository,
|
||||||
transcriber: Transcriber,
|
transcriber: Transcriber,
|
||||||
|
oss_uploader: Optional[VolcanoOSSUploader] = None,
|
||||||
):
|
):
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
self.transcriber = transcriber
|
self.transcriber = transcriber
|
||||||
|
self.oss_uploader = oss_uploader
|
||||||
|
|
||||||
def process(self, video_hash: str) -> None:
|
def process(self, video_hash: str) -> None:
|
||||||
video = self.repository.get_video(video_hash)
|
video = self.repository.get_video(video_hash)
|
||||||
@@ -57,7 +60,7 @@ class VideoProcessor:
|
|||||||
)
|
)
|
||||||
if not document.sentences:
|
if not document.sentences:
|
||||||
raise RuntimeError("MOSS returned no timestamped speech segments.")
|
raise RuntimeError("MOSS returned no timestamped speech segments.")
|
||||||
self.repository.save_processing_result(document, transcript.text)
|
self._finish_processing(document, transcript.text)
|
||||||
else:
|
else:
|
||||||
entry, _ = make_entry(media_path, video_hash=video_hash)
|
entry, _ = make_entry(media_path, video_hash=video_hash)
|
||||||
document = SentenceBoundaryDocument(
|
document = SentenceBoundaryDocument(
|
||||||
@@ -66,7 +69,7 @@ class VideoProcessor:
|
|||||||
algorithm_version=ALGORITHM_VERSION,
|
algorithm_version=ALGORITHM_VERSION,
|
||||||
sentences=entry["sentences"],
|
sentences=entry["sentences"],
|
||||||
)
|
)
|
||||||
self.repository.save_processing_result(document, None)
|
self._finish_processing(document, None)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.repository.mark_failed(video_hash, str(exc))
|
self.repository.mark_failed(video_hash, str(exc))
|
||||||
raise
|
raise
|
||||||
@@ -74,6 +77,28 @@ class VideoProcessor:
|
|||||||
if work_path is not None:
|
if work_path is not None:
|
||||||
work_path.unlink(missing_ok=True)
|
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:
|
def extract_audio(media_path: Path, output_path: Path) -> None:
|
||||||
ffmpeg = shutil.which("ffmpeg")
|
ffmpeg = shutil.which("ffmpeg")
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class VideoRepository:
|
|||||||
error_message TEXT,
|
error_message TEXT,
|
||||||
algorithm_version TEXT,
|
algorithm_version TEXT,
|
||||||
transcription TEXT,
|
transcription TEXT,
|
||||||
|
remote_url TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_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 '{}'"
|
"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(
|
def upsert_upload(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -137,8 +146,8 @@ class VideoRepository:
|
|||||||
INSERT INTO videos (
|
INSERT INTO videos (
|
||||||
video_hash, title, filename, stored_filename, content_type,
|
video_hash, title, filename, stored_filename, content_type,
|
||||||
size_bytes, duration_ms, language, status, error_message,
|
size_bytes, duration_ms, language, status, error_message,
|
||||||
algorithm_version, transcription, created_at, updated_at
|
algorithm_version, transcription, remote_url, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, 'uploaded', NULL, NULL, NULL, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, 'uploaded', NULL, NULL, NULL, NULL, ?, ?)
|
||||||
ON CONFLICT(video_hash) DO UPDATE SET
|
ON CONFLICT(video_hash) DO UPDATE SET
|
||||||
title = excluded.title,
|
title = excluded.title,
|
||||||
filename = excluded.filename,
|
filename = excluded.filename,
|
||||||
@@ -180,6 +189,7 @@ class VideoRepository:
|
|||||||
self,
|
self,
|
||||||
document: SentenceBoundaryDocument,
|
document: SentenceBoundaryDocument,
|
||||||
transcription: Optional[str],
|
transcription: Optional[str],
|
||||||
|
ready: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
@@ -208,12 +218,13 @@ class VideoRepository:
|
|||||||
)
|
)
|
||||||
connection.execute(
|
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 = ?
|
algorithm_version = ?, transcription = ?, updated_at = ?
|
||||||
WHERE video_hash = ?
|
WHERE video_hash = ?
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
document.duration_ms,
|
document.duration_ms,
|
||||||
|
"ready" if ready else "processing",
|
||||||
document.algorithm_version,
|
document.algorithm_version,
|
||||||
transcription,
|
transcription,
|
||||||
utc_now(),
|
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]]:
|
def list_videos(self) -> List[Dict[str, Any]]:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ httpx>=0.27,<1
|
|||||||
python-multipart>=0.0.9,<1
|
python-multipart>=0.0.9,<1
|
||||||
av>=12.0
|
av>=12.0
|
||||||
numpy>=1.26
|
numpy>=1.26
|
||||||
|
tos>=2.8,<3
|
||||||
|
requests>=2.31,<3
|
||||||
|
Deprecated>=1.2,<2
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ from fastapi.testclient import TestClient
|
|||||||
|
|
||||||
from sentence_api.main import create_app
|
from sentence_api.main import create_app
|
||||||
from sentence_api.config import Settings
|
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.repository import VideoRepository
|
||||||
from sentence_api.store import BoundaryStore
|
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")
|
response = client.get("/api/v1/videos/not-a-sha256/sentence-boundaries")
|
||||||
|
|
||||||
assert response.status_code == 422
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user