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
@@ -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] = [
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user