完善节目平台与直播监控体验
This commit is contained in:
1 parent
229a8d0791
commit
e50b8b480b
11 files changed
+642
-37
No files matched your search
@@ -1,3 +1,4 @@
|
||||
from .stream import Stream
|
||||
from .audit import ControlCommand, ControlSession, ViewerSession
|
||||
|
||||
__all__ = ["Stream"]
|
||||
__all__ = ["Stream", "ViewerSession", "ControlSession", "ControlCommand"]
|
||||
@@ -0,0 +1,50 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from database import Base
|
||||
|
||||
|
||||
class ViewerSession(Base):
|
||||
__tablename__ = "viewer_sessions"
|
||||
|
||||
session_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
stream_id: Mapped[int] = mapped_column(ForeignKey("streams.id"), index=True)
|
||||
node_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
node_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
quality: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
playback_state: Mapped[str] = mapped_column(String(32), default="idle")
|
||||
buffered_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
remote_adjusted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
last_heartbeat: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class ControlSession(Base):
|
||||
__tablename__ = "control_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
token: Mapped[str] = mapped_column(String(128), unique=True, default=lambda: uuid.uuid4().hex)
|
||||
stream_id: Mapped[int] = mapped_column(ForeignKey("streams.id"), index=True)
|
||||
operator: Mapped[str] = mapped_column(String(128), default="administrator")
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class ControlCommand(Base):
|
||||
__tablename__ = "control_commands"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
control_session_id: Mapped[int] = mapped_column(ForeignKey("control_sessions.id"), index=True)
|
||||
stream_id: Mapped[int] = mapped_column(ForeignKey("streams.id"), index=True)
|
||||
target_session_id: Mapped[str] = mapped_column(ForeignKey("viewer_sessions.session_id"), index=True)
|
||||
operator: Mapped[str] = mapped_column(String(128), default="administrator")
|
||||
action: Mapped[str] = mapped_column(String(32))
|
||||
value: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||
result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
claimed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
+183
-21
@@ -3,10 +3,12 @@ import secrets
|
||||
import asyncio
|
||||
import time
|
||||
import ntpath
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
@@ -16,6 +18,7 @@ from services.stream_service import StreamService
|
||||
from services.media_service import media_service
|
||||
from services.load_balancer import lb
|
||||
from config import settings
|
||||
from models.audit import ControlCommand, ControlSession, ViewerSession
|
||||
|
||||
router = APIRouter(prefix="/api/streams", tags=["streams"])
|
||||
|
||||
@@ -46,16 +49,31 @@ class StreamUpdate(BaseModel):
|
||||
|
||||
class RemoteSessionCreate(BaseModel):
|
||||
expires_in: int = 300
|
||||
operator: str = "administrator"
|
||||
|
||||
|
||||
class RemoteCommand(BaseModel):
|
||||
token: str
|
||||
action: str
|
||||
value: float | None = None
|
||||
value: str | None = None
|
||||
targets: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
_remote_sessions: dict[str, dict] = {}
|
||||
_remote_commands: dict[int, list[dict]] = {}
|
||||
class ViewerHeartbeat(BaseModel):
|
||||
session_id: str
|
||||
node_id: str | None = None
|
||||
node_name: str | None = None
|
||||
quality: str | None = None
|
||||
playback_state: str = "idle"
|
||||
buffered_seconds: float | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class CommandResult(BaseModel):
|
||||
result: str = "applied"
|
||||
|
||||
|
||||
VIEWER_ONLINE_SECONDS = 45
|
||||
|
||||
|
||||
def _media_path(filename: str | None) -> Path | None:
|
||||
@@ -136,6 +154,107 @@ async def list_streams(admin: bool = False, db: AsyncSession = Depends(get_db)):
|
||||
return [_serialize(s, include_private=admin) for s in streams]
|
||||
|
||||
|
||||
@router.get("/catalog/events")
|
||||
async def catalog_events(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
async def events():
|
||||
previous = None
|
||||
while not await request.is_disconnected():
|
||||
streams = await StreamService(db).get_public_streams()
|
||||
payload = json.dumps(
|
||||
[_serialize(stream, include_private=False) for stream in streams],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
if payload != previous:
|
||||
yield f"event: catalog\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.post("/{stream_id}/viewer-heartbeat")
|
||||
async def viewer_heartbeat(stream_id: int, body: ViewerHeartbeat,
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
stream = await StreamService(db).get_stream(stream_id)
|
||||
if not stream:
|
||||
raise HTTPException(status_code=404, detail="节目不存在")
|
||||
session_id = body.session_id.strip()
|
||||
if not session_id or len(session_id) > 64:
|
||||
raise HTTPException(status_code=422, detail="观众会话 ID 无效")
|
||||
viewer = await db.get(ViewerSession, session_id)
|
||||
if viewer and viewer.stream_id != stream_id:
|
||||
raise HTTPException(status_code=409, detail="观众会话已用于其他节目")
|
||||
if not viewer:
|
||||
viewer = ViewerSession(session_id=session_id, stream_id=stream_id)
|
||||
db.add(viewer)
|
||||
viewer.node_id = body.node_id
|
||||
viewer.node_name = body.node_name
|
||||
viewer.quality = body.quality
|
||||
viewer.playback_state = body.playback_state[:32]
|
||||
viewer.buffered_seconds = body.buffered_seconds
|
||||
viewer.error = body.error[:1000] if body.error else None
|
||||
viewer.last_heartbeat = datetime.utcnow()
|
||||
await db.commit()
|
||||
return {"ok": True, "remote_adjusted": viewer.remote_adjusted}
|
||||
|
||||
|
||||
@router.get("/{stream_id}/viewer-sessions")
|
||||
async def list_viewer_sessions(stream_id: int, db: AsyncSession = Depends(get_db),
|
||||
_: bool = Depends(require_auth)):
|
||||
cutoff = datetime.utcnow() - timedelta(seconds=VIEWER_ONLINE_SECONDS)
|
||||
viewers = (await db.execute(select(ViewerSession).where(
|
||||
ViewerSession.stream_id == stream_id,
|
||||
ViewerSession.last_heartbeat >= cutoff,
|
||||
).order_by(ViewerSession.last_heartbeat.desc()))).scalars().all()
|
||||
return [{
|
||||
"session_id": viewer.session_id,
|
||||
"node_id": viewer.node_id,
|
||||
"node_name": viewer.node_name,
|
||||
"quality": viewer.quality,
|
||||
"playback_state": viewer.playback_state,
|
||||
"buffered_seconds": viewer.buffered_seconds,
|
||||
"error": viewer.error,
|
||||
"remote_adjusted": viewer.remote_adjusted,
|
||||
"last_heartbeat": viewer.last_heartbeat.isoformat(),
|
||||
} for viewer in viewers]
|
||||
|
||||
|
||||
@router.get("/{stream_id}/control-audit")
|
||||
async def control_audit(stream_id: int, db: AsyncSession = Depends(get_db),
|
||||
_: bool = Depends(require_auth)):
|
||||
commands = (await db.execute(select(ControlCommand).where(
|
||||
ControlCommand.stream_id == stream_id,
|
||||
).order_by(ControlCommand.created_at.desc()).limit(100))).scalars().all()
|
||||
return [{
|
||||
"id": command.id,
|
||||
"operator": command.operator,
|
||||
"target_session_id": command.target_session_id,
|
||||
"action": command.action,
|
||||
"value": command.value,
|
||||
"status": command.status,
|
||||
"result": command.result,
|
||||
"created_at": command.created_at.isoformat(),
|
||||
"claimed_at": command.claimed_at.isoformat() if command.claimed_at else None,
|
||||
} for command in commands]
|
||||
|
||||
|
||||
@router.post("/{stream_id}/remote-command/{command_id}/result")
|
||||
async def report_command_result(stream_id: int, command_id: int, body: CommandResult,
|
||||
session_id: str, db: AsyncSession = Depends(get_db)):
|
||||
command = await db.get(ControlCommand, command_id)
|
||||
if not command or command.stream_id != stream_id or command.target_session_id != session_id:
|
||||
raise HTTPException(status_code=404, detail="控制命令不存在")
|
||||
command.status = "completed"
|
||||
command.result = body.result[:1000]
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/media-files")
|
||||
async def list_media_files(_: bool = Depends(require_auth)):
|
||||
root = Path(settings.replay_media_dir)
|
||||
@@ -294,34 +413,77 @@ async def create_remote_session(stream_id: int, body: RemoteSessionCreate,
|
||||
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)}
|
||||
expires_in = min(max(body.expires_in, 30), 3600)
|
||||
session = ControlSession(
|
||||
token=secrets.token_urlsafe(24),
|
||||
stream_id=stream_id,
|
||||
operator=body.operator.strip() or "administrator",
|
||||
expires_at=datetime.utcnow() + timedelta(seconds=expires_in),
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
return {"token": session.token, "expires_in": expires_in}
|
||||
|
||||
|
||||
@router.post("/{stream_id}/remote-command")
|
||||
async def send_remote_command(stream_id: int, body: RemoteCommand,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: 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():
|
||||
session = (await db.execute(select(ControlSession).where(
|
||||
ControlSession.token == body.token,
|
||||
ControlSession.stream_id == stream_id,
|
||||
))).scalar_one_or_none()
|
||||
if not session or session.expires_at < datetime.utcnow():
|
||||
raise HTTPException(status_code=403, detail="远程控制会话无效或已过期")
|
||||
if body.action not in {"play", "pause", "seek"}:
|
||||
stream = await StreamService(db).get_stream(stream_id)
|
||||
if not stream or not stream.remote_control_enabled:
|
||||
raise HTTPException(status_code=409, detail="节目未启用远程控制")
|
||||
if body.action not in {"switch_node", "switch_quality", "restore_auto"}:
|
||||
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}
|
||||
if not body.targets:
|
||||
raise HTTPException(status_code=422, detail="至少选择一个观众会话")
|
||||
viewers = (await db.execute(select(ViewerSession).where(
|
||||
ViewerSession.stream_id == stream_id,
|
||||
ViewerSession.session_id.in_(body.targets),
|
||||
))).scalars().all()
|
||||
found = {viewer.session_id for viewer in viewers}
|
||||
if found != set(body.targets):
|
||||
raise HTTPException(status_code=404, detail="部分观众会话不存在")
|
||||
commands = []
|
||||
for target in body.targets:
|
||||
command = ControlCommand(
|
||||
control_session_id=session.id,
|
||||
stream_id=stream_id,
|
||||
target_session_id=target,
|
||||
operator=session.operator,
|
||||
action=body.action,
|
||||
value=body.value,
|
||||
)
|
||||
db.add(command)
|
||||
commands.append(command)
|
||||
await db.commit()
|
||||
return {"ok": True, "count": len(commands), "command_ids": [command.id for command in commands]}
|
||||
|
||||
|
||||
@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
|
||||
async def poll_remote_command(stream_id: int, session_id: str,
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
viewer = await db.get(ViewerSession, session_id)
|
||||
if not viewer or viewer.stream_id != stream_id:
|
||||
raise HTTPException(status_code=404, detail="观众会话不存在")
|
||||
command = (await db.execute(select(ControlCommand).where(
|
||||
ControlCommand.stream_id == stream_id,
|
||||
ControlCommand.target_session_id == session_id,
|
||||
ControlCommand.status == "pending",
|
||||
).order_by(ControlCommand.created_at).limit(1))).scalar_one_or_none()
|
||||
if not command:
|
||||
return None
|
||||
command.status = "claimed"
|
||||
command.claimed_at = datetime.utcnow()
|
||||
viewer.remote_adjusted = command.action != "restore_auto"
|
||||
await db.commit()
|
||||
return {"id": command.id, "action": command.action, "value": command.value,
|
||||
"created_at": command.created_at.isoformat()}
|
||||
|
||||
|
||||
@router.post("/{stream_id}/go-live")
|
||||
|
||||
@@ -12,6 +12,7 @@ const WatchPage = lazy(() => import('./viewer/pages/WatchPage'))
|
||||
const AdminLayout = lazy(() => import('./admin/AdminLayout'))
|
||||
const LoginPage = lazy(() => import('./admin/LoginPage'))
|
||||
const Dashboard = lazy(() => import('./admin/pages/Dashboard'))
|
||||
const LiveBoard = lazy(() => import('./admin/pages/LiveBoard'))
|
||||
const StreamList = lazy(() => import('./admin/pages/StreamList'))
|
||||
const StreamForm = lazy(() => import('./admin/pages/StreamForm'))
|
||||
const ReplayEditor = lazy(() => import('./admin/pages/ReplayEditor'))
|
||||
@@ -74,6 +75,7 @@ function AppRoutes() {
|
||||
</AdminGuard>
|
||||
}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="board" element={<LiveBoard />} />
|
||||
<Route path="streams" element={<StreamList />} />
|
||||
<Route path="streams/new" element={<StreamForm />} />
|
||||
<Route path="streams/:id/edit" element={<ReplayEditor />} />
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Layout, Menu, Button, Typography } from 'antd'
|
||||
import {
|
||||
DashboardOutlined, VideoCameraOutlined, PlusOutlined,
|
||||
DashboardOutlined, VideoCameraOutlined, PlusOutlined, MonitorOutlined,
|
||||
CloudServerOutlined, SettingOutlined, LogoutOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { clearToken } from '../api/client'
|
||||
@@ -13,6 +13,7 @@ const { Text } = Typography
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/admin', icon: <DashboardOutlined />, label: '概览' },
|
||||
{ key: '/admin/board', icon: <MonitorOutlined />, label: '直播看板' },
|
||||
{ key: '/admin/streams', icon: <VideoCameraOutlined />, label: '节目列表' },
|
||||
{ key: '/admin/streams/new', icon: <PlusOutlined />, label: '创建节目' },
|
||||
{ key: '/admin/nodes', icon: <CloudServerOutlined />, label: '边缘节点' },
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Col, Empty, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd'
|
||||
import { ReloadOutlined, SignalFilled, TeamOutlined, WarningOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
createRemoteSession,
|
||||
fetchStreams,
|
||||
fetchViewerSessions,
|
||||
sendRemoteCommand,
|
||||
type StreamInfo,
|
||||
type ViewerSessionInfo,
|
||||
} from '../../api/streams'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
export default function LiveBoard() {
|
||||
const [streams, setStreams] = useState<StreamInfo[]>([])
|
||||
const [streamId, setStreamId] = useState<number>()
|
||||
const [sessions, setSessions] = useState<ViewerSessionInfo[]>([])
|
||||
const [selected, setSelected] = useState<React.Key[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [quality, setQuality] = useState('auto')
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const programs = await fetchStreams(true)
|
||||
setStreams(programs)
|
||||
const nextId = streamId || programs.find(program => program.status === 'live')?.id
|
||||
if (nextId) {
|
||||
setStreamId(nextId)
|
||||
setSessions(await fetchViewerSessions(nextId))
|
||||
} else {
|
||||
setSessions([])
|
||||
}
|
||||
} catch {
|
||||
message.error('看板数据加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
const timer = window.setInterval(load, 5000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [streamId])
|
||||
|
||||
async function controlViewers() {
|
||||
if (!streamId || selected.length === 0) return
|
||||
try {
|
||||
const session = await createRemoteSession(streamId)
|
||||
const action = quality === 'auto' ? 'restore_auto' : 'switch_quality'
|
||||
await sendRemoteCommand(streamId, session.token, action, selected.map(String), quality === 'auto' ? undefined : quality)
|
||||
message.success(`已向 ${selected.length} 个观众下发播放策略`)
|
||||
} catch {
|
||||
message.error('控制命令下发失败,请确认节目已启用审计式播放控制')
|
||||
}
|
||||
}
|
||||
|
||||
const activeSessions = sessions.filter(item => Date.now() - new Date(item.last_heartbeat).getTime() < 30000)
|
||||
const buffering = activeSessions.filter(item => item.playback_state === 'buffering' || Number(item.buffered_seconds || 0) < 1)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space direction="vertical" size={24} style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<Title level={2} style={{ margin: 0 }}>直播看板</Title>
|
||||
<Text type="secondary">实时检查节目、观众会话、线路与播放健康度</Text>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Select
|
||||
value={streamId}
|
||||
placeholder="选择直播节目"
|
||||
style={{ minWidth: 220 }}
|
||||
options={streams.map(program => ({ value: program.id, label: `${program.title} · ${program.status}` }))}
|
||||
onChange={value => { setStreamId(value); setSelected([]) }}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={8}><Card><Statistic title="活跃观众" value={activeSessions.length} prefix={<TeamOutlined />} /></Card></Col>
|
||||
<Col xs={24} sm={8}><Card><Statistic title="播放正常" value={Math.max(0, activeSessions.length - buffering.length)} prefix={<SignalFilled />} /></Card></Col>
|
||||
<Col xs={24} sm={8}><Card><Statistic title="疑似卡顿" value={buffering.length} prefix={<WarningOutlined />} valueStyle={{ color: buffering.length ? '#cf5c36' : undefined }} /></Card></Col>
|
||||
</Row>
|
||||
|
||||
{!streamId ? <Empty description="当前没有可查看的直播节目" /> : (
|
||||
<Card
|
||||
title="审计式观众会话"
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Select value={quality} onChange={setQuality} style={{ width: 150 }} options={[
|
||||
{ value: 'auto', label: '恢复自动' },
|
||||
{ value: '1080p', label: '1080p' },
|
||||
{ value: '720p', label: '720p' },
|
||||
{ value: '480p', label: '480p' },
|
||||
{ value: '360p', label: '360p' },
|
||||
]} />
|
||||
<Button type="primary" disabled={!selected.length} onClick={controlViewers}>批量切换</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="session_id"
|
||||
loading={loading}
|
||||
dataSource={sessions}
|
||||
scroll={{ x: 850 }}
|
||||
rowSelection={{ selectedRowKeys: selected, onChange: setSelected }}
|
||||
pagination={{ pageSize: 20 }}
|
||||
columns={[
|
||||
{ title: '会话', dataIndex: 'session_id', ellipsis: true, width: 180 },
|
||||
{ title: '节点', dataIndex: 'node_name', render: value => value || '未知' },
|
||||
{ title: '清晰度', dataIndex: 'quality', render: value => value || '自动' },
|
||||
{ title: '状态', dataIndex: 'playback_state', render: value => <Tag color={value === 'playing' ? 'green' : value === 'buffering' ? 'orange' : 'default'}>{value}</Tag> },
|
||||
{ title: '缓冲', dataIndex: 'buffered_seconds', render: value => `${Number(value || 0).toFixed(1)}s` },
|
||||
{ title: '远程调整', dataIndex: 'remote_adjusted', render: value => value ? <Tag color="gold">已调整</Tag> : '否' },
|
||||
{ title: '最后心跳', dataIndex: 'last_heartbeat', render: value => new Date(value).toLocaleTimeString() },
|
||||
{ title: '错误', dataIndex: 'error', ellipsis: true, render: value => value || '-' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -78,15 +78,15 @@ export default function StreamForm() {
|
||||
style={{ marginBottom: 20, color: '#5E5A52' }}>返回列表</Button>
|
||||
|
||||
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 36 }}>
|
||||
<Title level={2} style={{ margin: '0 0 8px', fontWeight: 600 }}>创建直播</Title>
|
||||
<Title level={2} style={{ margin: '0 0 8px', fontWeight: 600 }}>创建节目</Title>
|
||||
<Text style={{ color: '#5E5A52', display: 'block', marginBottom: 36 }}>
|
||||
填写直播信息,系统将生成推流码与 RTMP 地址
|
||||
填写节目信息并预约播出,系统将生成推流码与 RTMP 地址
|
||||
</Text>
|
||||
|
||||
<Form layout="vertical" onFinish={handleSubmit} size="large" initialValues={{ transcode_profile: 'standard', homepage_visible: true, remote_control_enabled: false }}>
|
||||
<Form.Item name="title" label="直播标题"
|
||||
rules={[{ required: true, message: '请输入直播标题' }]}>
|
||||
<Input placeholder="输入直播标题" style={{ borderRadius: 8 }} />
|
||||
<Form.Item name="title" label="节目标题"
|
||||
rules={[{ required: true, message: '请输入节目标题' }]}>
|
||||
<Input placeholder="输入节目标题" style={{ borderRadius: 8 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="简介">
|
||||
<TextArea rows={3} placeholder="直播内容简介(可选)" style={{ borderRadius: 8 }} />
|
||||
@@ -109,7 +109,7 @@ export default function StreamForm() {
|
||||
<div style={{ borderTop: '1px solid #EBE5DB', margin: '8px 0 24px' }} />
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block style={{ height: 48, borderRadius: 8 }}>
|
||||
创建直播
|
||||
创建节目
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -74,6 +74,37 @@ export interface QualityInfo {
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface ViewerSessionInfo {
|
||||
session_id: string
|
||||
node_id?: string
|
||||
node_name?: string
|
||||
quality?: string
|
||||
playback_state: string
|
||||
buffered_seconds?: number
|
||||
error?: string
|
||||
remote_adjusted: boolean
|
||||
last_heartbeat: string
|
||||
}
|
||||
|
||||
export interface ControlAuditInfo {
|
||||
id: number
|
||||
operator: string
|
||||
target_session_id: string
|
||||
action: string
|
||||
value?: string
|
||||
status: string
|
||||
result?: string
|
||||
created_at: string
|
||||
claimed_at?: string
|
||||
}
|
||||
|
||||
export interface RemoteCommandInfo {
|
||||
id: number
|
||||
action: 'switch_node' | 'switch_quality' | 'restore_auto'
|
||||
value?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export async function fetchStreams(admin = false): Promise<StreamInfo[]> {
|
||||
const { data } = await api.get('/streams', { params: admin ? { admin: true } : undefined })
|
||||
return data
|
||||
@@ -120,3 +151,36 @@ export async function getWatchUrls(streamId: number): Promise<WatchInfo> {
|
||||
export function getStreamStatusUrl(streamId: number): string {
|
||||
return `/api/streams/${streamId}/events`
|
||||
}
|
||||
|
||||
export async function sendViewerHeartbeat(streamId: number, payload: Record<string, unknown>): Promise<{ remote_adjusted: boolean }> {
|
||||
const { data } = await api.post(`/streams/${streamId}/viewer-heartbeat`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function pollRemoteCommand(streamId: number, sessionId: string): Promise<RemoteCommandInfo | null> {
|
||||
const { data } = await api.get(`/streams/${streamId}/remote-command`, { params: { session_id: sessionId } })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function reportRemoteCommandResult(streamId: number, commandId: number, sessionId: string, result: string): Promise<void> {
|
||||
await api.post(`/streams/${streamId}/remote-command/${commandId}/result`, { result }, { params: { session_id: sessionId } })
|
||||
}
|
||||
|
||||
export async function fetchViewerSessions(streamId: number): Promise<ViewerSessionInfo[]> {
|
||||
const { data } = await api.get(`/streams/${streamId}/viewer-sessions`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchControlAudit(streamId: number): Promise<ControlAuditInfo[]> {
|
||||
const { data } = await api.get(`/streams/${streamId}/control-audit`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createRemoteSession(streamId: number, expiresIn = 300): Promise<{ token: string; expires_in: number }> {
|
||||
const { data } = await api.post(`/streams/${streamId}/remote-session`, { expires_in: expiresIn })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function sendRemoteCommand(streamId: number, token: string, action: string, targets: string[], value?: string): Promise<void> {
|
||||
await api.post(`/streams/${streamId}/remote-command`, { token, action, targets, value })
|
||||
}
|
||||
@@ -16,11 +16,42 @@ export default function HomePage() {
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
fetchStreams()
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined
|
||||
const load = () => fetchStreams()
|
||||
.then(data => { if (active) setStreams(data) })
|
||||
.catch(() => {})
|
||||
.finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
const startPolling = () => {
|
||||
if (!pollTimer) pollTimer = setInterval(load, 10000)
|
||||
}
|
||||
const stopPolling = () => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
pollTimer = undefined
|
||||
}
|
||||
|
||||
void load()
|
||||
const events = new EventSource('/api/streams/catalog/events')
|
||||
events.addEventListener('catalog', event => {
|
||||
if (!active) return
|
||||
try {
|
||||
const data = JSON.parse((event as MessageEvent<string>).data)
|
||||
if (Array.isArray(data)) {
|
||||
setStreams(data)
|
||||
setLoading(false)
|
||||
stopPolling()
|
||||
}
|
||||
} catch {
|
||||
startPolling()
|
||||
}
|
||||
})
|
||||
events.onopen = stopPolling
|
||||
events.onerror = startPolling
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
events.close()
|
||||
stopPolling()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const sorted = [...streams].sort((a, b) => {
|
||||
|
||||
@@ -3,7 +3,15 @@ import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Select, Button, Typography, Tag, Spin, Empty, Alert } from 'antd'
|
||||
import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons'
|
||||
import Hls from 'hls.js'
|
||||
import { getStreamStatusUrl, getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams'
|
||||
import {
|
||||
getStreamStatusUrl,
|
||||
getWatchUrls,
|
||||
pollRemoteCommand,
|
||||
reportRemoteCommandResult,
|
||||
sendViewerHeartbeat,
|
||||
type WatchInfo,
|
||||
type LineInfo,
|
||||
} from '../../api/streams'
|
||||
import { useBrand } from '../../brand/BrandContext'
|
||||
import './viewer.css'
|
||||
|
||||
@@ -19,10 +27,83 @@ export default function WatchPage() {
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [playbackError, setPlaybackError] = useState('')
|
||||
const [activeLine, setActiveLine] = useState<string>('')
|
||||
const [activeQuality, setActiveQuality] = useState<string>('')
|
||||
const [activeQuality, setActiveQuality] = useState<string>('auto')
|
||||
const [autoQuality, setAutoQuality] = useState<string>('')
|
||||
const bufferEventsRef = useRef<number[]>([])
|
||||
const bufferStartedRef = useRef<number | null>(null)
|
||||
const playbackStartedRef = useRef<number>(0)
|
||||
const [remoteAdjusted, setRemoteAdjusted] = useState(false)
|
||||
const hlsRef = useRef<Hls | null>(null)
|
||||
const viewerSessionRef = useRef<string>('')
|
||||
const currentLine = info?.lines.find(line => line.node_id === activeLine)
|
||||
|
||||
useEffect(() => {
|
||||
const storageKey = 'viewer-session-id'
|
||||
const storedSessionId = window.localStorage.getItem(storageKey)
|
||||
const sessionId = storedSessionId || (window.crypto?.randomUUID?.().replace(/-/g, '') ?? `${Date.now()}${Math.random().toString(16).slice(2)}`)
|
||||
if (!storedSessionId) window.localStorage.setItem(storageKey, sessionId)
|
||||
viewerSessionRef.current = sessionId.slice(0, 64)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const streamId = Number(id)
|
||||
if (!Number.isInteger(streamId) || streamId <= 0 || !info) return
|
||||
|
||||
const heartbeat = async () => {
|
||||
const video = videoRef.current
|
||||
const bufferedSeconds = video && video.buffered.length
|
||||
? Math.max(0, video.buffered.end(video.buffered.length - 1) - video.currentTime)
|
||||
: 0
|
||||
try {
|
||||
const result = await sendViewerHeartbeat(streamId, {
|
||||
session_id: viewerSessionRef.current,
|
||||
node_id: activeLine || null,
|
||||
node_name: currentLine?.node_name || null,
|
||||
quality: activeQuality || null,
|
||||
playback_state: video?.paused ? 'paused' : video?.readyState ? 'playing' : 'idle',
|
||||
buffered_seconds: bufferedSeconds,
|
||||
error: playbackError || null,
|
||||
})
|
||||
setRemoteAdjusted(result.remote_adjusted)
|
||||
} catch {
|
||||
// Telemetry must never interrupt playback.
|
||||
}
|
||||
}
|
||||
|
||||
const pollCommand = async () => {
|
||||
try {
|
||||
const command = await pollRemoteCommand(streamId, viewerSessionRef.current)
|
||||
if (!command) return
|
||||
let result = 'applied'
|
||||
if (command.action === 'switch_quality' && command.value) {
|
||||
setActiveQuality(command.value)
|
||||
setRemoteAdjusted(true)
|
||||
} else if (command.action === 'switch_node' && command.value) {
|
||||
setActiveLine(command.value)
|
||||
setRemoteAdjusted(true)
|
||||
} else if (command.action === 'restore_auto') {
|
||||
setRemoteAdjusted(false)
|
||||
setActiveLine(info.lines.length ? autoSelectLine(info.lines).node_id : '')
|
||||
setActiveQuality('auto')
|
||||
} else {
|
||||
result = 'unsupported'
|
||||
}
|
||||
await reportRemoteCommandResult(streamId, command.id, viewerSessionRef.current, result)
|
||||
} catch {
|
||||
// Command polling is optional and must fail closed.
|
||||
}
|
||||
}
|
||||
|
||||
void heartbeat()
|
||||
void pollCommand()
|
||||
const heartbeatTimer = window.setInterval(heartbeat, 10000)
|
||||
const commandTimer = window.setInterval(pollCommand, 3000)
|
||||
return () => {
|
||||
window.clearInterval(heartbeatTimer)
|
||||
window.clearInterval(commandTimer)
|
||||
}
|
||||
}, [id, info, activeLine, activeQuality, playbackError, currentLine?.node_name])
|
||||
|
||||
useEffect(() => {
|
||||
const streamId = Number(id)
|
||||
if (!Number.isInteger(streamId) || streamId <= 0) {
|
||||
@@ -116,7 +197,8 @@ export default function WatchPage() {
|
||||
setPlaybackError('所选播放线路不可用')
|
||||
return
|
||||
}
|
||||
const quality = activeQuality ? line.qualities?.find(q => q.label === activeQuality) : line.qualities?.[0]
|
||||
const selectedLabel = activeQuality === 'auto' ? (autoQuality || line.qualities?.[0]?.label) : activeQuality
|
||||
const quality = selectedLabel ? line.qualities?.find(q => q.label === selectedLabel) : line.qualities?.[0]
|
||||
const source = quality?.url || line.url || line.unicast_fallback
|
||||
if (!source) {
|
||||
setPlaybackError('当前节目没有可用的播放地址')
|
||||
@@ -141,7 +223,60 @@ export default function WatchPage() {
|
||||
hlsRef.current?.destroy()
|
||||
hlsRef.current = null
|
||||
}
|
||||
}, [activeLine, activeQuality, info])
|
||||
}, [activeLine, activeQuality, autoQuality, info])
|
||||
|
||||
useEffect(() => {
|
||||
if (!info?.qualities.length) return
|
||||
setAutoQuality(current => current || info.qualities[0].label)
|
||||
}, [info])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video || !info || activeQuality !== 'auto') return
|
||||
|
||||
const qualityLabels = info.qualities.map(quality => quality.label)
|
||||
const lowerQuality = () => {
|
||||
setAutoQuality(current => {
|
||||
const index = Math.max(0, qualityLabels.indexOf(current))
|
||||
return qualityLabels[Math.min(index + 1, qualityLabels.length - 1)] || current
|
||||
})
|
||||
}
|
||||
const higherQuality = () => {
|
||||
setAutoQuality(current => {
|
||||
const index = Math.max(0, qualityLabels.indexOf(current))
|
||||
return qualityLabels[Math.max(index - 1, 0)] || current
|
||||
})
|
||||
}
|
||||
const onPlaying = () => {
|
||||
const now = Date.now()
|
||||
if (!playbackStartedRef.current) playbackStartedRef.current = now
|
||||
if (bufferStartedRef.current && now - playbackStartedRef.current > 3000) {
|
||||
const duration = now - bufferStartedRef.current
|
||||
bufferEventsRef.current = bufferEventsRef.current.filter(at => now - at <= 10000)
|
||||
if (duration >= 2000 || bufferEventsRef.current.length >= 3) {
|
||||
lowerQuality()
|
||||
bufferEventsRef.current = []
|
||||
}
|
||||
}
|
||||
bufferStartedRef.current = null
|
||||
}
|
||||
const onWaiting = () => {
|
||||
if (Date.now() - playbackStartedRef.current <= 3000) return
|
||||
bufferStartedRef.current = Date.now()
|
||||
bufferEventsRef.current.push(Date.now())
|
||||
}
|
||||
const recoveryTimer = window.setInterval(() => {
|
||||
if (!video.paused && !video.seeking && video.readyState >= 3) higherQuality()
|
||||
}, 60000)
|
||||
|
||||
video.addEventListener('waiting', onWaiting)
|
||||
video.addEventListener('playing', onPlaying)
|
||||
return () => {
|
||||
window.clearInterval(recoveryTimer)
|
||||
video.removeEventListener('waiting', onWaiting)
|
||||
video.removeEventListener('playing', onPlaying)
|
||||
}
|
||||
}, [activeQuality, info])
|
||||
|
||||
function jumpToChapter(time: number) {
|
||||
if (!videoRef.current) return
|
||||
@@ -180,13 +315,17 @@ export default function WatchPage() {
|
||||
</div>
|
||||
<div className="watch-options">
|
||||
<Tag style={{ margin: 0 }}>节点:{currentLine?.node_name || info.selected_node?.name || '不可用'}</Tag>
|
||||
<Tag style={{ margin: 0 }}>清晰度:{activeQuality || '不可用'}</Tag>
|
||||
<Tag style={{ margin: 0 }}>清晰度:{activeQuality === 'auto' ? `自动(${autoQuality || '检测中'})` : activeQuality || '不可用'}</Tag>
|
||||
{remoteAdjusted && <Tag color="gold" style={{ margin: 0 }}>播放由管理员临时调整</Tag>}
|
||||
<Text style={{ color: '#949088', fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<SignalFilled style={{ color: info.multicast_available ? '#5B8C5A' : '#5E5A52', fontSize: 12 }} />
|
||||
{info.multicast_available ? '组播' : '单播'}
|
||||
</Text>
|
||||
<Select value={activeQuality || undefined} onChange={setActiveQuality} className="watch-quality" size="small"
|
||||
options={info.qualities.map(q => ({ value: q.label, label: q.label }))} placeholder="清晰度" />
|
||||
options={[
|
||||
{ value: 'auto', label: `自动(${autoQuality || '检测中'})` },
|
||||
...info.qualities.map(q => ({ value: q.label, label: q.label })),
|
||||
]} placeholder="清晰度" />
|
||||
<Select value={activeLine || undefined} onChange={setActiveLine} className="watch-line" size="small"
|
||||
options={info.lines.map(l => ({ value: l.node_id, label: l.node_name }))}
|
||||
prefix={<WifiOutlined />} placeholder="线路" />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
.viewer-shell {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
background: #f4f0e8;
|
||||
color: #211d18;
|
||||
@@ -463,6 +465,32 @@ button.stream-card:focus-visible {
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.watch-shell,
|
||||
.watch-stage,
|
||||
.watch-content,
|
||||
.watch-frame,
|
||||
.watch-video {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.watch-header {
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.watch-options {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.watch-quality,
|
||||
.watch-line {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.viewer-nav { margin-bottom: 48px; }
|
||||
|
||||
.viewer-directory-tools { align-items: stretch; flex-direction: column; }
|
||||
|
||||
Reference in new issue
Block a user