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

+1
View File
@@ -17,6 +17,7 @@ class Settings(BaseSettings):
media_origin_api_port: int = 9997
media_origin_hls_port: int = 8888
replay_media_dir: str = str(APP_DIR.parent / "media")
hls_runtime_dir: str = str(APP_DIR.parent / "runtime" / "hls")
hls_segment_seconds: int = 4
edge_nodes: list[dict] = [
+1
View File
@@ -23,6 +23,7 @@ async def init_db():
if settings.database_url.startswith("sqlite"):
columns = {
"replay_file": "VARCHAR(500)",
"replay_url": "VARCHAR(1000)",
"chapters_json": "TEXT",
"transcode_profile": "VARCHAR(64) NOT NULL DEFAULT 'standard'",
"renditions_json": "TEXT",
+14 -1
View File
@@ -9,8 +9,11 @@ import httpx
import asyncio
import time
import psutil
from database import init_db
from database import init_db, session_factory
from services.transcoder import transcode_mgr
from services.stream_service import StreamService
from services.media_service import media_service
from models.stream import StreamStatus
from routes import streams_router, nodes_router, auth_router
from routes.settings_route import router as settings_router
from config import settings
@@ -60,6 +63,16 @@ async def monitor_origin():
async def lifespan(app: FastAPI):
load_nodes()
await init_db()
os.makedirs(settings.hls_runtime_dir, exist_ok=True)
async with session_factory() as db:
service = StreamService(db)
for stream in await service.get_all_streams():
if stream.status == StreamStatus.live:
transcode_mgr.start_transcode(
stream.stream_key,
media_service.get_rtmp_url(stream.stream_key),
service.effective_renditions(stream),
)
origin_monitor = asyncio.create_task(monitor_origin())
yield
origin_monitor.cancel()
+1
View File
@@ -21,6 +21,7 @@ class Stream(Base):
description: Mapped[str | None] = mapped_column(Text, nullable=True)
cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
replay_file: Mapped[str | None] = mapped_column(String(500), nullable=True)
replay_url: Mapped[str | None] = mapped_column(String(1000), nullable=True)
chapters_json: Mapped[str | None] = mapped_column(Text, nullable=True)
transcode_profile: Mapped[str] = mapped_column(String(64), default="standard")
renditions_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+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,
}
+2
View File
@@ -29,6 +29,7 @@ class StreamService:
cover_url: str | None = None,
scheduled_at: str | None = None,
replay_file: str | None = None,
replay_url: str | None = None,
chapters: list[dict] | None = None,
transcode_profile: str = "standard",
homepage_visible: bool = True,
@@ -45,6 +46,7 @@ class StreamService:
description=description,
cover_url=cover_url,
replay_file=replay_file,
replay_url=replay_url,
chapters_json=json.dumps(chapters or [], ensure_ascii=False),
transcode_profile=transcode_profile,
homepage_visible=homepage_visible,
+16 -2
View File
@@ -49,7 +49,7 @@ class ReplayWorkflowTest(unittest.TestCase):
_normalize_chapters(chapters)
def test_status_payload_exposes_pending_live_and_ended_updates(self):
stream = SimpleNamespace(id=7, replay_file=None, chapters_json="[]")
stream = SimpleNamespace(id=7, replay_file=None, replay_url=None, chapters_json="[]")
for status in ("pending", "live", "ended"):
stream.status = SimpleNamespace(value=status)
self.assertEqual(_status_payload(stream)["status"], status)
@@ -57,5 +57,19 @@ class ReplayWorkflowTest(unittest.TestCase):
stream.replay_file = "session.mp4"
stream.chapters_json = json.dumps([{"title": "Intro", "time": 0}])
payload = _status_payload(stream)
self.assertEqual(payload["replay_url"], "/api/streams/7/replay")
self.assertEqual(payload["replay_local_url"], "/api/streams/7/replay")
self.assertIsNone(payload["replay_url"])
self.assertEqual(payload["chapters"], [{"title": "Intro", "time": 0}])
stream.replay_file = None
stream.replay_url = "https://example.com/vod/master.m3u8"
payload = _status_payload(stream)
self.assertIsNone(payload["replay_local_url"])
self.assertEqual(payload["replay_url"], "https://example.com/vod/master.m3u8")
def test_replay_url_rejects_non_http(self):
from routes.streams import _valid_replay_url
for unsafe in ("ftp://example.com/a.mp4", "file:///etc/passwd", "javascript:alert(1)"):
with self.subTest(unsafe=unsafe), self.assertRaises(HTTPException):
_valid_replay_url(unsafe)
self.assertEqual(_valid_replay_url(" https://cdn.example.com/a.mp4 "), "https://cdn.example.com/a.mp4")