完善节目平台与直播监控体验

This commit is contained in:
admin committed 2026-08-18 09:26:41 +08:00
1 parent 229a8d0791
commit e50b8b480b
11 files changed
+642 -37

No files matched your search

+2 -1
View File
@@ -1,3 +1,4 @@
from .stream import Stream from .stream import Stream
from .audit import ControlCommand, ControlSession, ViewerSession
__all__ = ["Stream"] __all__ = ["Stream", "ViewerSession", "ControlSession", "ControlCommand"]
+50
View File
@@ -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
View File
@@ -3,10 +3,12 @@ import secrets
import asyncio import asyncio
import time import time
import ntpath import ntpath
from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException, Depends, Request from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
@@ -16,6 +18,7 @@ from services.stream_service import StreamService
from services.media_service import media_service from services.media_service import media_service
from services.load_balancer import lb from services.load_balancer import lb
from config import settings from config import settings
from models.audit import ControlCommand, ControlSession, ViewerSession
router = APIRouter(prefix="/api/streams", tags=["streams"]) router = APIRouter(prefix="/api/streams", tags=["streams"])
@@ -46,16 +49,31 @@ class StreamUpdate(BaseModel):
class RemoteSessionCreate(BaseModel): class RemoteSessionCreate(BaseModel):
expires_in: int = 300 expires_in: int = 300
operator: str = "administrator"
class RemoteCommand(BaseModel): class RemoteCommand(BaseModel):
token: str token: str
action: str action: str
value: float | None = None value: str | None = None
targets: list[str] = Field(default_factory=list)
_remote_sessions: dict[str, dict] = {} class ViewerHeartbeat(BaseModel):
_remote_commands: dict[int, list[dict]] = {} 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: 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] 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") @router.get("/media-files")
async def list_media_files(_: bool = Depends(require_auth)): async def list_media_files(_: bool = Depends(require_auth)):
root = Path(settings.replay_media_dir) 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) stream = await StreamService(db).get_stream(stream_id)
if not stream or not stream.remote_control_enabled: if not stream or not stream.remote_control_enabled:
raise HTTPException(status_code=409, detail="节目未启用远程控制") raise HTTPException(status_code=409, detail="节目未启用远程控制")
token = secrets.token_urlsafe(24) expires_in = min(max(body.expires_in, 30), 3600)
_remote_sessions[token] = { session = ControlSession(
"stream_id": stream_id, token=secrets.token_urlsafe(24),
"expires_at": time.time() + min(max(body.expires_in, 30), 3600), stream_id=stream_id,
} operator=body.operator.strip() or "administrator",
return {"token": token, "expires_in": min(max(body.expires_in, 30), 3600)} 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") @router.post("/{stream_id}/remote-command")
async def send_remote_command(stream_id: int, body: RemoteCommand, async def send_remote_command(stream_id: int, body: RemoteCommand,
db: AsyncSession = Depends(get_db),
_: bool = Depends(require_auth)): _: bool = Depends(require_auth)):
session = _remote_sessions.get(body.token) session = (await db.execute(select(ControlSession).where(
if not session or session["stream_id"] != stream_id or session["expires_at"] < time.time(): 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="远程控制会话无效或已过期") 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="不支持的远程命令") raise HTTPException(status_code=422, detail="不支持的远程命令")
command = {"action": body.action, "value": body.value, "created_at": time.time()} if not body.targets:
_remote_commands.setdefault(stream_id, []).append(command) raise HTTPException(status_code=422, detail="至少选择一个观众会话")
return {"ok": True, "command": command} 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") @router.get("/{stream_id}/remote-command")
async def poll_remote_command(stream_id: int, token: str): async def poll_remote_command(stream_id: int, session_id: str,
session = _remote_sessions.get(token) db: AsyncSession = Depends(get_db)):
if not session or session["stream_id"] != stream_id or session["expires_at"] < time.time(): viewer = await db.get(ViewerSession, session_id)
raise HTTPException(status_code=403, detail="远程控制会话无效或已过期") if not viewer or viewer.stream_id != stream_id:
commands = _remote_commands.get(stream_id, []) raise HTTPException(status_code=404, detail="观众会话不存在")
return commands[-1] if commands else None 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") @router.post("/{stream_id}/go-live")
+2
View File
@@ -12,6 +12,7 @@ const WatchPage = lazy(() => import('./viewer/pages/WatchPage'))
const AdminLayout = lazy(() => import('./admin/AdminLayout')) const AdminLayout = lazy(() => import('./admin/AdminLayout'))
const LoginPage = lazy(() => import('./admin/LoginPage')) const LoginPage = lazy(() => import('./admin/LoginPage'))
const Dashboard = lazy(() => import('./admin/pages/Dashboard')) const Dashboard = lazy(() => import('./admin/pages/Dashboard'))
const LiveBoard = lazy(() => import('./admin/pages/LiveBoard'))
const StreamList = lazy(() => import('./admin/pages/StreamList')) const StreamList = lazy(() => import('./admin/pages/StreamList'))
const StreamForm = lazy(() => import('./admin/pages/StreamForm')) const StreamForm = lazy(() => import('./admin/pages/StreamForm'))
const ReplayEditor = lazy(() => import('./admin/pages/ReplayEditor')) const ReplayEditor = lazy(() => import('./admin/pages/ReplayEditor'))
@@ -74,6 +75,7 @@ function AppRoutes() {
</AdminGuard> </AdminGuard>
}> }>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="board" element={<LiveBoard />} />
<Route path="streams" element={<StreamList />} /> <Route path="streams" element={<StreamList />} />
<Route path="streams/new" element={<StreamForm />} /> <Route path="streams/new" element={<StreamForm />} />
<Route path="streams/:id/edit" element={<ReplayEditor />} /> <Route path="streams/:id/edit" element={<ReplayEditor />} />
+2 -1
View File
@@ -2,7 +2,7 @@ import { useState } from 'react'
import { Outlet, useNavigate, useLocation } from 'react-router-dom' import { Outlet, useNavigate, useLocation } from 'react-router-dom'
import { Layout, Menu, Button, Typography } from 'antd' import { Layout, Menu, Button, Typography } from 'antd'
import { import {
DashboardOutlined, VideoCameraOutlined, PlusOutlined, DashboardOutlined, VideoCameraOutlined, PlusOutlined, MonitorOutlined,
CloudServerOutlined, SettingOutlined, LogoutOutlined, CloudServerOutlined, SettingOutlined, LogoutOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { clearToken } from '../api/client' import { clearToken } from '../api/client'
@@ -13,6 +13,7 @@ const { Text } = Typography
const menuItems = [ const menuItems = [
{ key: '/admin', icon: <DashboardOutlined />, label: '概览' }, { key: '/admin', icon: <DashboardOutlined />, label: '概览' },
{ key: '/admin/board', icon: <MonitorOutlined />, label: '直播看板' },
{ key: '/admin/streams', icon: <VideoCameraOutlined />, label: '节目列表' }, { key: '/admin/streams', icon: <VideoCameraOutlined />, label: '节目列表' },
{ key: '/admin/streams/new', icon: <PlusOutlined />, label: '创建节目' }, { key: '/admin/streams/new', icon: <PlusOutlined />, label: '创建节目' },
{ key: '/admin/nodes', icon: <CloudServerOutlined />, label: '边缘节点' }, { key: '/admin/nodes', icon: <CloudServerOutlined />, label: '边缘节点' },
+127
View File
@@ -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>
)
}
+6 -6
View File
@@ -78,15 +78,15 @@ export default function StreamForm() {
style={{ marginBottom: 20, color: '#5E5A52' }}>返回列表</Button> style={{ marginBottom: 20, color: '#5E5A52' }}>返回列表</Button>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 36 }}> <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 }}> <Text style={{ color: '#5E5A52', display: 'block', marginBottom: 36 }}>
填写直播信息,系统将生成推流码与 RTMP 地址 填写节目信息并预约播出,系统将生成推流码与 RTMP 地址
</Text> </Text>
<Form layout="vertical" onFinish={handleSubmit} size="large" initialValues={{ transcode_profile: 'standard', homepage_visible: true, remote_control_enabled: false }}> <Form layout="vertical" onFinish={handleSubmit} size="large" initialValues={{ transcode_profile: 'standard', homepage_visible: true, remote_control_enabled: false }}>
<Form.Item name="title" label="直播标题" <Form.Item name="title" label="节目标题"
rules={[{ required: true, message: '请输入直播标题' }]}> rules={[{ required: true, message: '请输入节目标题' }]}>
<Input placeholder="输入直播标题" style={{ borderRadius: 8 }} /> <Input placeholder="输入节目标题" style={{ borderRadius: 8 }} />
</Form.Item> </Form.Item>
<Form.Item name="description" label="简介"> <Form.Item name="description" label="简介">
<TextArea rows={3} placeholder="直播内容简介(可选)" style={{ borderRadius: 8 }} /> <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' }} /> <div style={{ borderTop: '1px solid #EBE5DB', margin: '8px 0 24px' }} />
<Form.Item style={{ marginBottom: 0 }}> <Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{ height: 48, borderRadius: 8 }}> <Button type="primary" htmlType="submit" loading={loading} block style={{ height: 48, borderRadius: 8 }}>
创建直播 创建节目
</Button> </Button>
</Form.Item> </Form.Item>
</Form> </Form>
+64
View File
@@ -74,6 +74,37 @@ export interface QualityInfo {
height: number 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[]> { export async function fetchStreams(admin = false): Promise<StreamInfo[]> {
const { data } = await api.get('/streams', { params: admin ? { admin: true } : undefined }) const { data } = await api.get('/streams', { params: admin ? { admin: true } : undefined })
return data return data
@@ -120,3 +151,36 @@ export async function getWatchUrls(streamId: number): Promise<WatchInfo> {
export function getStreamStatusUrl(streamId: number): string { export function getStreamStatusUrl(streamId: number): string {
return `/api/streams/${streamId}/events` 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 })
}
+33 -2
View File
@@ -16,11 +16,42 @@ export default function HomePage() {
useEffect(() => { useEffect(() => {
let active = true let active = true
fetchStreams() let pollTimer: ReturnType<typeof setInterval> | undefined
const load = () => fetchStreams()
.then(data => { if (active) setStreams(data) }) .then(data => { if (active) setStreams(data) })
.catch(() => {}) .catch(() => {})
.finally(() => { if (active) setLoading(false) }) .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) => { const sorted = [...streams].sort((a, b) => {
+145 -6
View File
@@ -3,7 +3,15 @@ import { useParams, useNavigate } from 'react-router-dom'
import { Select, Button, Typography, Tag, Spin, Empty, Alert } from 'antd' import { Select, Button, Typography, Tag, Spin, Empty, Alert } from 'antd'
import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons' import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons'
import Hls from 'hls.js' 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 { useBrand } from '../../brand/BrandContext'
import './viewer.css' import './viewer.css'
@@ -19,10 +27,83 @@ export default function WatchPage() {
const [loadError, setLoadError] = useState('') const [loadError, setLoadError] = useState('')
const [playbackError, setPlaybackError] = useState('') const [playbackError, setPlaybackError] = useState('')
const [activeLine, setActiveLine] = useState<string>('') 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 hlsRef = useRef<Hls | null>(null)
const viewerSessionRef = useRef<string>('')
const currentLine = info?.lines.find(line => line.node_id === activeLine) 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(() => { useEffect(() => {
const streamId = Number(id) const streamId = Number(id)
if (!Number.isInteger(streamId) || streamId <= 0) { if (!Number.isInteger(streamId) || streamId <= 0) {
@@ -116,7 +197,8 @@ export default function WatchPage() {
setPlaybackError('所选播放线路不可用') setPlaybackError('所选播放线路不可用')
return 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 const source = quality?.url || line.url || line.unicast_fallback
if (!source) { if (!source) {
setPlaybackError('当前节目没有可用的播放地址') setPlaybackError('当前节目没有可用的播放地址')
@@ -141,7 +223,60 @@ export default function WatchPage() {
hlsRef.current?.destroy() hlsRef.current?.destroy()
hlsRef.current = null 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) { function jumpToChapter(time: number) {
if (!videoRef.current) return if (!videoRef.current) return
@@ -180,13 +315,17 @@ export default function WatchPage() {
</div> </div>
<div className="watch-options"> <div className="watch-options">
<Tag style={{ margin: 0 }}>节点:{currentLine?.node_name || info.selected_node?.name || '不可用'}</Tag> <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 }}> <Text style={{ color: '#949088', fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}>
<SignalFilled style={{ color: info.multicast_available ? '#5B8C5A' : '#5E5A52', fontSize: 12 }} /> <SignalFilled style={{ color: info.multicast_available ? '#5B8C5A' : '#5E5A52', fontSize: 12 }} />
{info.multicast_available ? '组播' : '单播'} {info.multicast_available ? '组播' : '单播'}
</Text> </Text>
<Select value={activeQuality || undefined} onChange={setActiveQuality} className="watch-quality" size="small" <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" <Select value={activeLine || undefined} onChange={setActiveLine} className="watch-line" size="small"
options={info.lines.map(l => ({ value: l.node_id, label: l.node_name }))} options={info.lines.map(l => ({ value: l.node_id, label: l.node_name }))}
prefix={<WifiOutlined />} placeholder="线路" /> prefix={<WifiOutlined />} placeholder="线路" />
+28
View File
@@ -1,5 +1,7 @@
.viewer-shell { .viewer-shell {
min-height: 100vh; min-height: 100vh;
width: 100%;
max-width: 100vw;
overflow-x: hidden; overflow-x: hidden;
background: #f4f0e8; background: #f4f0e8;
color: #211d18; color: #211d18;
@@ -463,6 +465,32 @@ button.stream-card:focus-visible {
} }
@media (max-width: 600px) { @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-nav { margin-bottom: 48px; }
.viewer-directory-tools { align-items: stretch; flex-direction: column; } .viewer-directory-tools { align-items: stretch; flex-direction: column; }