完善跨平台回放与实时状态更新
This commit is contained in:
1 parent
a493ef2309
commit
3060828774
11 files changed
+244
-37
No files matched your search
+8
-2
@@ -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] = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user