feat: improve replay workflow and viewer UI

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
adminandClaude Fable 5 committed 2026-08-30 15:35:28 +08:00
1 parent 57b00affbd
commit 186e32caf1
28 files changed
+1496 -227

No files matched your search

+42 -10
View File
@@ -43,6 +43,7 @@ class StreamCreate(BaseModel):
cover_url: Optional[str] = None
scheduled_at: Optional[str] = None
replay_file: Optional[str] = None
replay_url: Optional[str] = None
chapters: list[dict] = Field(default_factory=list)
transcode_profile: str = "standard"
renditions: Optional[list[RenditionInput]] = None
@@ -56,6 +57,7 @@ class StreamUpdate(BaseModel):
cover_url: Optional[str] = None
scheduled_at: Optional[str] = None
replay_file: Optional[str] = None
replay_url: Optional[str] = None
chapters: Optional[list[dict]] = None
transcode_profile: Optional[str] = None
renditions: Optional[list[RenditionInput]] = None
@@ -108,11 +110,23 @@ def _media_path(filename: str | None) -> Path | None:
return candidate
def _valid_replay_url(url: str | None) -> str | None:
if not url:
return None
url = url.strip()
if not (url.startswith("http://") or url.startswith("https://")):
raise HTTPException(status_code=422, detail="回放 URL 必须是 http(s) 地址")
if " " in url or "\n" in url or "\r" in url:
raise HTTPException(status_code=422, detail="回放 URL 格式无效")
return url
def _status_payload(stream) -> dict:
return {
"stream_id": stream.id,
"status": stream.status.value,
"replay_url": f"/api/streams/{stream.id}/replay" if stream.replay_file else None,
"replay_local_url": f"/api/streams/{stream.id}/replay" if stream.replay_file else None,
"replay_url": stream.replay_url,
"chapters": json.loads(stream.chapters_json or "[]"),
}
@@ -169,6 +183,7 @@ def _serialize(s, include_private: bool = True):
"ended_at": str(s.ended_at) if s.ended_at else None,
"multicast_enabled": s.multicast_enabled,
"replay_file": s.replay_file,
"replay_url": s.replay_url,
"chapters": json.loads(s.chapters_json or "[]"),
"transcode_profile": s.transcode_profile,
"renditions": json.loads(getattr(s, "renditions_json", None)) if getattr(s, "renditions_json", None) else None,
@@ -317,6 +332,7 @@ async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
if not title:
raise HTTPException(status_code=422, detail="节目标题不能为空")
_media_path(body.replay_file)
_valid_replay_url(body.replay_url)
if body.transcode_profile not in settings.transcode_profiles:
raise HTTPException(status_code=422, detail="转码配置不存在")
renditions = _normalize_renditions([item.model_dump() for item in body.renditions]) if body.renditions is not None else None
@@ -328,6 +344,7 @@ async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
cover_url=body.cover_url,
scheduled_at=body.scheduled_at,
replay_file=body.replay_file,
replay_url=body.replay_url,
chapters=body.chapters,
transcode_profile=body.transcode_profile,
renditions_json=json.dumps(renditions, ensure_ascii=False) if renditions is not None else None,
@@ -365,20 +382,34 @@ async def update_stream(stream_id: int, body: StreamUpdate, db: AsyncSession = D
existing = await svc.get_stream(stream_id)
if not existing:
raise HTTPException(status_code=404, detail="Stream not found")
updates = {k: v for k, v in body.model_dump().items() if v is not None}
if ("replay_file" in updates or "chapters" in updates) and existing.status.value != "ended":
updates = body.model_dump(exclude_unset=True)
replay_fields = {"replay_file", "replay_url", "chapters"}
if replay_fields.intersection(updates) and existing.status.value != "ended":
raise HTTPException(status_code=409, detail="仅已结束的节目可以配置回放和章节")
if "replay_file" in updates:
if updates.get("replay_file"):
_media_path(updates["replay_file"])
if "replay_url" in updates and updates["replay_url"] is not None:
updates["replay_url"] = _valid_replay_url(updates["replay_url"])
final_replay_file = updates.get("replay_file", existing.replay_file)
final_replay_url = updates.get("replay_url", existing.replay_url)
if final_replay_file and final_replay_url:
raise HTTPException(status_code=409, detail="本地回放文件和远端回放地址只能选择一种")
if "transcode_profile" in updates and updates["transcode_profile"] not in settings.transcode_profiles:
raise HTTPException(status_code=422, detail="转码配置不存在")
if "renditions" in updates:
updates["renditions_json"] = json.dumps(_normalize_renditions(updates.pop("renditions")), ensure_ascii=False)
renditions = updates.pop("renditions")
updates["renditions_json"] = (
json.dumps(_normalize_renditions(renditions), ensure_ascii=False)
if renditions is not None else None
)
if "chapters" in updates:
if not updates.get("replay_file") and not existing.replay_file:
raise HTTPException(status_code=409, detail="请先选择回放文件再编辑章节")
chapters = _normalize_chapters(updates.pop("chapters"))
updates["chapters_json"] = json.dumps(chapters, ensure_ascii=False)
chapters = updates.pop("chapters")
if chapters and not (final_replay_file or final_replay_url):
raise HTTPException(status_code=409, detail="请先配置回放来源再编辑章节")
updates["chapters_json"] = json.dumps(_normalize_chapters(chapters or []), ensure_ascii=False)
if updates:
await svc.update_stream(stream_id, **updates)
return {"ok": True}
@@ -413,7 +444,8 @@ async def get_watch_urls(stream_id: int, request: Request, db: AsyncSession = De
"qualities": urls["qualities"],
"description": stream.description,
"scheduled_at": str(stream.scheduled_at) if stream.scheduled_at else None,
"replay_url": f"/api/streams/{stream.id}/replay" if stream.replay_file else None,
"replay_local_url": f"/api/streams/{stream.id}/replay" if stream.replay_file else None,
"replay_url": stream.replay_url,
"chapters": json.loads(stream.chapters_json or "[]"),
"remote_control_enabled": stream.remote_control_enabled,
}