364 lines
14 KiB
Python
364 lines
14 KiB
Python
import json
|
|
import secrets
|
|
import asyncio
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
|
from fastapi.responses import FileResponse, StreamingResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from pydantic import BaseModel, Field
|
|
from typing import Optional
|
|
from database import get_db
|
|
from routes.auth import require_auth
|
|
from services.stream_service import StreamService
|
|
from services.media_service import media_service
|
|
from services.load_balancer import lb
|
|
from config import settings
|
|
|
|
router = APIRouter(prefix="/api/streams", tags=["streams"])
|
|
|
|
|
|
class StreamCreate(BaseModel):
|
|
title: str
|
|
description: Optional[str] = None
|
|
cover_url: Optional[str] = None
|
|
scheduled_at: Optional[str] = None
|
|
replay_file: Optional[str] = None
|
|
chapters: list[dict] = Field(default_factory=list)
|
|
transcode_profile: str = "standard"
|
|
homepage_visible: bool = True
|
|
remote_control_enabled: bool = False
|
|
|
|
|
|
class StreamUpdate(BaseModel):
|
|
title: Optional[str] = None
|
|
description: Optional[str] = None
|
|
cover_url: Optional[str] = None
|
|
scheduled_at: Optional[str] = None
|
|
replay_file: Optional[str] = None
|
|
chapters: Optional[list[dict]] = None
|
|
transcode_profile: Optional[str] = None
|
|
homepage_visible: Optional[bool] = None
|
|
remote_control_enabled: Optional[bool] = None
|
|
|
|
|
|
class RemoteSessionCreate(BaseModel):
|
|
expires_in: int = 300
|
|
|
|
|
|
class RemoteCommand(BaseModel):
|
|
token: str
|
|
action: str
|
|
value: float | None = None
|
|
|
|
|
|
_remote_sessions: dict[str, dict] = {}
|
|
_remote_commands: dict[int, list[dict]] = {}
|
|
|
|
|
|
def _media_path(filename: str | None) -> Path | None:
|
|
if not filename:
|
|
return None
|
|
root = Path(settings.replay_media_dir).resolve()
|
|
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,
|
|
"cover_url": s.cover_url, "status": s.status.value,
|
|
"scheduled_at": str(s.scheduled_at) if s.scheduled_at else None,
|
|
"started_at": str(s.started_at) if s.started_at else None,
|
|
"ended_at": str(s.ended_at) if s.ended_at else None,
|
|
"multicast_enabled": s.multicast_enabled,
|
|
"replay_file": s.replay_file,
|
|
"chapters": json.loads(s.chapters_json or "[]"),
|
|
"transcode_profile": s.transcode_profile,
|
|
"homepage_visible": s.homepage_visible,
|
|
"remote_control_enabled": s.remote_control_enabled,
|
|
"created_at": str(s.created_at),
|
|
}
|
|
if include_private:
|
|
data["stream_key"] = s.stream_key
|
|
return data
|
|
|
|
|
|
def _get_client_ip(request: Request) -> str | None:
|
|
forwarded = request.headers.get("X-Forwarded-For")
|
|
if forwarded:
|
|
return forwarded.split(",")[0].strip()
|
|
client = request.headers.get("X-Real-IP")
|
|
if client:
|
|
return client
|
|
if request.client:
|
|
return request.client.host
|
|
return None
|
|
|
|
|
|
@router.get("")
|
|
async def list_streams(admin: bool = False, db: AsyncSession = Depends(get_db)):
|
|
svc = StreamService(db)
|
|
streams = await svc.get_all_streams() if admin else await svc.get_public_streams()
|
|
return [_serialize(s, include_private=admin) for s in streams]
|
|
|
|
|
|
@router.get("/media-files")
|
|
async def list_media_files(_: bool = Depends(require_auth)):
|
|
root = Path(settings.replay_media_dir)
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
extensions = {".mp4", ".m4v", ".mov", ".webm", ".mkv"}
|
|
return [p.name for p in root.iterdir() if p.is_file() and p.suffix.lower() in extensions]
|
|
|
|
|
|
@router.post("")
|
|
async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
|
|
_: bool = Depends(require_auth)):
|
|
title = body.title.strip()
|
|
if not title:
|
|
raise HTTPException(status_code=422, detail="节目标题不能为空")
|
|
_media_path(body.replay_file)
|
|
if body.transcode_profile not in settings.transcode_profiles:
|
|
raise HTTPException(status_code=422, detail="转码配置不存在")
|
|
svc = StreamService(db)
|
|
try:
|
|
stream = await svc.create_stream(
|
|
title=title,
|
|
description=body.description,
|
|
cover_url=body.cover_url,
|
|
scheduled_at=body.scheduled_at,
|
|
replay_file=body.replay_file,
|
|
chapters=body.chapters,
|
|
transcode_profile=body.transcode_profile,
|
|
homepage_visible=body.homepage_visible,
|
|
remote_control_enabled=body.remote_control_enabled,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
except Exception as exc:
|
|
await db.rollback()
|
|
raise HTTPException(status_code=500, detail="创建节目失败,请检查日期格式和数据库状态") from exc
|
|
rtmp_url = media_service.get_rtmp_url(stream.stream_key)
|
|
return {
|
|
"id": stream.id,
|
|
"title": stream.title,
|
|
"stream_key": stream.stream_key,
|
|
"rtmp_url": rtmp_url,
|
|
"status": stream.status.value,
|
|
}
|
|
|
|
|
|
@router.get("/{stream_id}")
|
|
async def get_stream(stream_id: int, db: AsyncSession = Depends(get_db)):
|
|
svc = StreamService(db)
|
|
s = await svc.get_stream(stream_id)
|
|
if not s:
|
|
raise HTTPException(status_code=404, detail="Stream not found")
|
|
return _serialize(s)
|
|
|
|
|
|
@router.put("/{stream_id}")
|
|
async def update_stream(stream_id: int, body: StreamUpdate, db: AsyncSession = Depends(get_db),
|
|
_: bool = Depends(require_auth)):
|
|
svc = StreamService(db)
|
|
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":
|
|
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:
|
|
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}
|
|
|
|
|
|
@router.delete("/{stream_id}")
|
|
async def delete_stream(stream_id: int, db: AsyncSession = Depends(get_db),
|
|
_: bool = Depends(require_auth)):
|
|
svc = StreamService(db)
|
|
existing = await svc.get_stream(stream_id)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail="Stream not found")
|
|
await svc.delete_stream(stream_id)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/{stream_id}/watch")
|
|
async def get_watch_urls(stream_id: int, request: Request, db: AsyncSession = Depends(get_db)):
|
|
svc = StreamService(db)
|
|
stream = await svc.get_stream(stream_id)
|
|
if not stream:
|
|
raise HTTPException(status_code=404, detail="Stream not found")
|
|
client_ip = _get_client_ip(request)
|
|
urls = await svc.get_watch_urls(stream.stream_key, client_ip)
|
|
return {
|
|
"stream_id": stream.id,
|
|
"title": stream.title,
|
|
"status": stream.status.value,
|
|
"multicast_available": urls["multicast_available"],
|
|
"selected_node": urls.get("selected_node"),
|
|
"lines": urls["lines"],
|
|
"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,
|
|
"chapters": json.loads(stream.chapters_json or "[]"),
|
|
"remote_control_enabled": stream.remote_control_enabled,
|
|
}
|
|
|
|
|
|
@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, 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(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)
|
|
|
|
|
|
@router.post("/{stream_id}/remote-session")
|
|
async def create_remote_session(stream_id: int, body: RemoteSessionCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: bool = Depends(require_auth)):
|
|
stream = await StreamService(db).get_stream(stream_id)
|
|
if not stream or not stream.remote_control_enabled:
|
|
raise HTTPException(status_code=409, detail="节目未启用远程控制")
|
|
token = secrets.token_urlsafe(24)
|
|
_remote_sessions[token] = {
|
|
"stream_id": stream_id,
|
|
"expires_at": time.time() + min(max(body.expires_in, 30), 3600),
|
|
}
|
|
return {"token": token, "expires_in": min(max(body.expires_in, 30), 3600)}
|
|
|
|
|
|
@router.post("/{stream_id}/remote-command")
|
|
async def send_remote_command(stream_id: int, body: RemoteCommand,
|
|
_: bool = Depends(require_auth)):
|
|
session = _remote_sessions.get(body.token)
|
|
if not session or session["stream_id"] != stream_id or session["expires_at"] < time.time():
|
|
raise HTTPException(status_code=403, detail="远程控制会话无效或已过期")
|
|
if body.action not in {"play", "pause", "seek"}:
|
|
raise HTTPException(status_code=422, detail="不支持的远程命令")
|
|
command = {"action": body.action, "value": body.value, "created_at": time.time()}
|
|
_remote_commands.setdefault(stream_id, []).append(command)
|
|
return {"ok": True, "command": command}
|
|
|
|
|
|
@router.get("/{stream_id}/remote-command")
|
|
async def poll_remote_command(stream_id: int, token: str):
|
|
session = _remote_sessions.get(token)
|
|
if not session or session["stream_id"] != stream_id or session["expires_at"] < time.time():
|
|
raise HTTPException(status_code=403, detail="远程控制会话无效或已过期")
|
|
commands = _remote_commands.get(stream_id, [])
|
|
return commands[-1] if commands else None
|
|
|
|
|
|
@router.post("/{stream_id}/go-live")
|
|
async def stream_go_live(stream_id: int, db: AsyncSession = Depends(get_db),
|
|
_: bool = Depends(require_auth)):
|
|
svc = StreamService(db)
|
|
stream = await svc.get_stream(stream_id)
|
|
if not stream:
|
|
raise HTTPException(status_code=404, detail="Stream not found")
|
|
await svc.set_stream_live(stream.stream_key)
|
|
return {"ok": True, "status": "live"}
|
|
|
|
|
|
@router.post("/{stream_id}/end")
|
|
async def stream_end(stream_id: int, db: AsyncSession = Depends(get_db),
|
|
_: bool = Depends(require_auth)):
|
|
svc = StreamService(db)
|
|
stream = await svc.get_stream(stream_id)
|
|
if not stream:
|
|
raise HTTPException(status_code=404, detail="Stream not found")
|
|
await svc.set_stream_ended(stream.stream_key)
|
|
return {"ok": True, "status": "ended"}
|
|
|
|
|
|
@router.get("/detail/{stream_key}")
|
|
async def get_stream_by_key(stream_key: str, request: Request, db: AsyncSession = Depends(get_db)):
|
|
svc = StreamService(db)
|
|
s = await svc.get_stream_by_key(stream_key)
|
|
if not s:
|
|
raise HTTPException(status_code=404, detail="Stream not found")
|
|
client_ip = _get_client_ip(request)
|
|
urls = await svc.get_watch_urls(stream_key, client_ip)
|
|
return {
|
|
"stream_id": s.id,
|
|
"title": s.title,
|
|
"status": s.status.value,
|
|
"multicast_available": urls["multicast_available"],
|
|
"selected_node": urls.get("selected_node"),
|
|
"lines": urls["lines"],
|
|
"qualities": urls["qualities"],
|
|
}
|