完善跨平台回放与实时状态更新

This commit is contained in:
admin committed 2026-08-17 13:16:12 +08:00
1 parent a493ef2309
commit 3060828774
11 files changed
+244 -37

No files matched your search

+8 -2
View File
@@ -1,16 +1,22 @@
from pydantic_settings import BaseSettings
from pathlib import Path
from typing import Literal
APP_DIR = Path(__file__).resolve().parent
DEFAULT_DATA_DIR = APP_DIR / "data"
DEFAULT_DATA_DIR.mkdir(parents=True, exist_ok=True)
class Settings(BaseSettings):
database_url: str = "sqlite+aiosqlite:///./data.db"
database_url: str = f"sqlite+aiosqlite:///{(DEFAULT_DATA_DIR / 'streamcore.db').as_posix()}"
admin_password: str = "admin123"
media_origin_host: str = "127.0.0.1"
media_origin_rtmp_port: int = 1935
media_origin_api_port: int = 9997
media_origin_hls_port: int = 8888
replay_media_dir: str = "/workspace/media"
replay_media_dir: str = str(APP_DIR.parent / "media")
hls_segment_seconds: int = 4
edge_nodes: list[dict] = [
+67 -6
View File
@@ -1,10 +1,11 @@
import json
import secrets
import asyncio
import time
from pathlib import Path
from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from typing import Optional
@@ -60,12 +61,40 @@ def _media_path(filename: str | None) -> Path | None:
if not filename:
return None
root = Path(settings.replay_media_dir).resolve()
candidate = (root / filename).resolve()
if root not in candidate.parents or not candidate.is_file():
supplied = Path(filename)
if supplied.is_absolute() or supplied.drive or ".." in supplied.parts:
raise HTTPException(status_code=422, detail="回放文件不在允许的媒体目录中")
candidate = (root / supplied).resolve()
if candidate != root and root not in candidate.parents:
raise HTTPException(status_code=422, detail="回放文件不在允许的媒体目录中")
if not candidate.is_file():
raise HTTPException(status_code=422, detail="回放文件不在允许的媒体目录中")
return candidate
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,
"chapters": json.loads(stream.chapters_json or "[]"),
}
def _normalize_chapters(chapters: list[dict]) -> list:
normalized = []
for chapter in chapters:
title = str(chapter.get("title", "")).strip()
try:
chapter_time = float(chapter.get("time", 0))
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=422, detail="章节时间必须是非负数字") from exc
if not title or chapter_time < 0:
raise HTTPException(status_code=422, detail="章节标题不能为空,时间必须是非负数字")
normalized.append({"title": title, "time": chapter_time})
return sorted(normalized, key=lambda chapter: chapter["time"])
def _serialize(s, include_private: bool = True):
data = {
"id": s.id, "title": s.title, "description": s.description,
@@ -167,12 +196,17 @@ async def update_stream(stream_id: int, body: StreamUpdate, db: AsyncSession = D
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":
raise HTTPException(status_code=409, detail="仅已结束的节目可以配置回放和章节")
if "replay_file" in updates:
_media_path(updates["replay_file"])
if "transcode_profile" in updates and updates["transcode_profile"] not in settings.transcode_profiles:
raise HTTPException(status_code=422, detail="转码配置不存在")
if "chapters" in updates:
updates["chapters_json"] = json.dumps(updates.pop("chapters"), ensure_ascii=False)
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)
if updates:
await svc.update_stream(stream_id, **updates)
return {"ok": True}
@@ -213,12 +247,39 @@ async def get_watch_urls(stream_id: int, request: Request, db: AsyncSession = De
}
@router.get("/{stream_id}/events")
async def stream_events(stream_id: int, request: Request, db: AsyncSession = Depends(get_db)):
if not await StreamService(db).get_stream(stream_id):
raise HTTPException(status_code=404, detail="Stream not found")
async def events():
previous = None
while not await request.is_disconnected():
stream = await StreamService(db).get_stream(stream_id)
if not stream:
yield "event: deleted\ndata: {}\n\n"
return
payload = json.dumps(_status_payload(stream), ensure_ascii=False)
if payload != previous:
yield f"event: status\ndata: {payload}\n\n"
previous = payload
else:
yield ": keep-alive\n\n"
await asyncio.sleep(2)
return StreamingResponse(events(), media_type="text/event-stream", headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
})
@router.get("/{stream_id}/replay")
async def serve_replay(stream_id: int, db: AsyncSession = Depends(get_db)):
async def serve_replay(stream_id: int, preview: str | None = None,
db: AsyncSession = Depends(get_db)):
stream = await StreamService(db).get_stream(stream_id)
if not stream:
raise HTTPException(status_code=404, detail="Program not found")
path = _media_path(stream.replay_file)
path = _media_path(preview if preview is not None else stream.replay_file)
if not path:
raise HTTPException(status_code=404, detail="Replay not available")
return FileResponse(path)