Files
live/backend/routes/streams.py
T

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"],
}