feat: improve replay workflow and viewer UI
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
28 files changed
+1496
-227
No files matched your search
+42
-10
@@ -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,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user