feat: expand livestream management platform

This commit is contained in:
admin committed 2026-08-19 22:24:01 +08:00
1 parent 383c066d86
commit ed62d42a63
12 files changed
+223 -24

No files matched your search

+1
View File
@@ -25,6 +25,7 @@ async def init_db():
"replay_file": "VARCHAR(500)", "replay_file": "VARCHAR(500)",
"chapters_json": "TEXT", "chapters_json": "TEXT",
"transcode_profile": "VARCHAR(64) NOT NULL DEFAULT 'standard'", "transcode_profile": "VARCHAR(64) NOT NULL DEFAULT 'standard'",
"renditions_json": "TEXT",
"homepage_visible": "BOOLEAN NOT NULL DEFAULT 1", "homepage_visible": "BOOLEAN NOT NULL DEFAULT 1",
"remote_control_enabled": "BOOLEAN NOT NULL DEFAULT 0", "remote_control_enabled": "BOOLEAN NOT NULL DEFAULT 0",
} }
+1
View File
@@ -23,6 +23,7 @@ class Stream(Base):
replay_file: Mapped[str | None] = mapped_column(String(500), nullable=True) replay_file: Mapped[str | None] = mapped_column(String(500), nullable=True)
chapters_json: Mapped[str | None] = mapped_column(Text, nullable=True) chapters_json: Mapped[str | None] = mapped_column(Text, nullable=True)
transcode_profile: Mapped[str] = mapped_column(String(64), default="standard") transcode_profile: Mapped[str] = mapped_column(String(64), default="standard")
renditions_json: Mapped[str | None] = mapped_column(Text, nullable=True)
homepage_visible: Mapped[bool] = mapped_column(Boolean, default=True) homepage_visible: Mapped[bool] = mapped_column(Boolean, default=True)
remote_control_enabled: Mapped[bool] = mapped_column(Boolean, default=False) remote_control_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
stream_key: Mapped[str] = mapped_column(String(64), unique=True, default=lambda: uuid.uuid4().hex) stream_key: Mapped[str] = mapped_column(String(64), unique=True, default=lambda: uuid.uuid4().hex)
+15
View File
@@ -105,6 +105,17 @@ async def get_system_metrics(_: bool = Depends(require_auth)):
disk_path = metrics_path if metrics_path.exists() else Path.cwd() disk_path = metrics_path if metrics_path.exists() else Path.cwd()
disk = shutil.disk_usage(disk_path) disk = shutil.disk_usage(disk_path)
cpu_count = os.cpu_count() or 1 cpu_count = os.cpu_count() or 1
network_in_bytes = 0
network_out_bytes = 0
netdev = Path("/proc/net/dev")
if netdev.exists():
for line in netdev.read_text(encoding="ascii").splitlines()[2:]:
_, _, counters = line.partition(":")
fields = counters.split()
if len(fields) >= 9:
network_in_bytes += int(fields[0])
network_out_bytes += int(fields[8])
return { return {
"cpu": { "cpu": {
"count": cpu_count, "count": cpu_count,
@@ -123,4 +134,8 @@ async def get_system_metrics(_: bool = Depends(require_auth)):
"free": disk.free, "free": disk.free,
"used_percent": round(disk.used / disk.total * 100, 1) if disk.total else 0, "used_percent": round(disk.used / disk.total * 100, 1) if disk.total else 0,
}, },
"network": {
"bytes_recv": network_in_bytes,
"bytes_sent": network_out_bytes,
},
} }
+50
View File
@@ -23,6 +23,20 @@ from models.audit import ControlCommand, ControlSession, ViewerSession
router = APIRouter(prefix="/api/streams", tags=["streams"]) router = APIRouter(prefix="/api/streams", tags=["streams"])
class RenditionInput(BaseModel):
label: str
enabled: bool = True
width: int
height: int
fps: int = 30
codec: str = "libx264"
bitrate: str = "2500k"
crf: int = 23
preset: str = "veryfast"
audio_bitrate: str = "128k"
keyframe_seconds: int = 2
class StreamCreate(BaseModel): class StreamCreate(BaseModel):
title: str title: str
description: Optional[str] = None description: Optional[str] = None
@@ -31,6 +45,7 @@ class StreamCreate(BaseModel):
replay_file: Optional[str] = None replay_file: Optional[str] = None
chapters: list[dict] = Field(default_factory=list) chapters: list[dict] = Field(default_factory=list)
transcode_profile: str = "standard" transcode_profile: str = "standard"
renditions: Optional[list[RenditionInput]] = None
homepage_visible: bool = True homepage_visible: bool = True
remote_control_enabled: bool = False remote_control_enabled: bool = False
@@ -43,6 +58,7 @@ class StreamUpdate(BaseModel):
replay_file: Optional[str] = None replay_file: Optional[str] = None
chapters: Optional[list[dict]] = None chapters: Optional[list[dict]] = None
transcode_profile: Optional[str] = None transcode_profile: Optional[str] = None
renditions: Optional[list[RenditionInput]] = None
homepage_visible: Optional[bool] = None homepage_visible: Optional[bool] = None
remote_control_enabled: Optional[bool] = None remote_control_enabled: Optional[bool] = None
@@ -115,6 +131,35 @@ def _normalize_chapters(chapters: list[dict]) -> list:
return sorted(normalized, key=lambda chapter: chapter["time"]) return sorted(normalized, key=lambda chapter: chapter["time"])
def _normalize_renditions(renditions: list[dict]) -> list:
codecs = {"libx264", "libx265"}
presets = {"ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow"}
normalized = []
labels = set()
for rendition in renditions:
label = str(rendition.get("label", "")).strip()
if not label or not label.replace("-", "").replace("_", "").isalnum() or label in labels:
raise HTTPException(status_code=422, detail="清晰度标签无效或重复")
labels.add(label)
if not rendition.get("enabled", True):
continue
width, height = int(rendition["width"]), int(rendition["height"])
fps, crf = int(rendition.get("fps", 30)), int(rendition.get("crf", 23))
keyframe_seconds = int(rendition.get("keyframe_seconds", 2))
codec, preset = rendition.get("codec", "libx264"), rendition.get("preset", "veryfast")
bitrate, audio_bitrate = str(rendition.get("bitrate", "")), str(rendition.get("audio_bitrate", ""))
if not (160 <= width <= 7680 and 90 <= height <= 4320 and 1 <= fps <= 120 and 0 <= crf <= 51 and 1 <= keyframe_seconds <= 10):
raise HTTPException(status_code=422, detail="清晰度参数超出允许范围")
if codec not in codecs or preset not in presets:
raise HTTPException(status_code=422, detail="编码器或预设不受支持")
if not bitrate[:-1].isdigit() or bitrate[-1:] not in {"k", "M"} or not audio_bitrate[:-1].isdigit() or audio_bitrate[-1:] not in {"k", "M"}:
raise HTTPException(status_code=422, detail="码率格式必须类似 2500k 或 5M")
normalized.append({"label": label, "enabled": True, "width": width, "height": height, "fps": fps, "codec": codec, "bitrate": bitrate, "crf": crf, "preset": preset, "audio_bitrate": audio_bitrate, "keyframe_seconds": keyframe_seconds})
if not normalized:
raise HTTPException(status_code=422, detail="至少启用一个清晰度")
return normalized
def _serialize(s, include_private: bool = True): def _serialize(s, include_private: bool = True):
data = { data = {
"id": s.id, "title": s.title, "description": s.description, "id": s.id, "title": s.title, "description": s.description,
@@ -126,6 +171,7 @@ def _serialize(s, include_private: bool = True):
"replay_file": s.replay_file, "replay_file": s.replay_file,
"chapters": json.loads(s.chapters_json or "[]"), "chapters": json.loads(s.chapters_json or "[]"),
"transcode_profile": s.transcode_profile, "transcode_profile": s.transcode_profile,
"renditions": json.loads(getattr(s, "renditions_json", None)) if getattr(s, "renditions_json", None) else None,
"homepage_visible": s.homepage_visible, "homepage_visible": s.homepage_visible,
"remote_control_enabled": s.remote_control_enabled, "remote_control_enabled": s.remote_control_enabled,
"created_at": str(s.created_at), "created_at": str(s.created_at),
@@ -272,6 +318,7 @@ async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
_media_path(body.replay_file) _media_path(body.replay_file)
if body.transcode_profile not in settings.transcode_profiles: if body.transcode_profile not in settings.transcode_profiles:
raise HTTPException(status_code=422, detail="转码配置不存在") raise HTTPException(status_code=422, detail="转码配置不存在")
renditions = _normalize_renditions([item.model_dump() for item in body.renditions]) if body.renditions is not None else None
svc = StreamService(db) svc = StreamService(db)
try: try:
stream = await svc.create_stream( stream = await svc.create_stream(
@@ -282,6 +329,7 @@ async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
replay_file=body.replay_file, replay_file=body.replay_file,
chapters=body.chapters, chapters=body.chapters,
transcode_profile=body.transcode_profile, transcode_profile=body.transcode_profile,
renditions_json=json.dumps(renditions, ensure_ascii=False) if renditions is not None else None,
homepage_visible=body.homepage_visible, homepage_visible=body.homepage_visible,
remote_control_enabled=body.remote_control_enabled, remote_control_enabled=body.remote_control_enabled,
) )
@@ -323,6 +371,8 @@ async def update_stream(stream_id: int, body: StreamUpdate, db: AsyncSession = D
_media_path(updates["replay_file"]) _media_path(updates["replay_file"])
if "transcode_profile" in updates and updates["transcode_profile"] not in settings.transcode_profiles: if "transcode_profile" in updates and updates["transcode_profile"] not in settings.transcode_profiles:
raise HTTPException(status_code=422, detail="转码配置不存在") raise HTTPException(status_code=422, detail="转码配置不存在")
if "renditions" in updates:
updates["renditions_json"] = json.dumps(_normalize_renditions(updates.pop("renditions")), ensure_ascii=False)
if "chapters" in updates: if "chapters" in updates:
if not updates.get("replay_file") and not existing.replay_file: if not updates.get("replay_file") and not existing.replay_file:
raise HTTPException(status_code=409, detail="请先选择回放文件再编辑章节") raise HTTPException(status_code=409, detail="请先选择回放文件再编辑章节")
+13 -2
View File
@@ -14,6 +14,17 @@ class StreamService:
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
self.db = db self.db = db
@staticmethod
def effective_renditions(stream: Stream) -> list:
if stream.renditions_json:
try:
renditions = json.loads(stream.renditions_json)
if isinstance(renditions, list) and renditions:
return renditions
except (TypeError, ValueError, json.JSONDecodeError):
pass
return settings.transcode_profiles.get(stream.transcode_profile, settings.qualities)
async def create_stream(self, title: str, description: str | None = None, async def create_stream(self, title: str, description: str | None = None,
cover_url: str | None = None, cover_url: str | None = None,
scheduled_at: str | None = None, scheduled_at: str | None = None,
@@ -95,7 +106,7 @@ class StreamService:
started_at=datetime.utcnow(), started_at=datetime.utcnow(),
) )
rtmp_url = media_service.get_rtmp_url(stream_key) rtmp_url = media_service.get_rtmp_url(stream_key)
transcode_mgr.start_transcode(stream_key, rtmp_url, stream.transcode_profile) transcode_mgr.start_transcode(stream_key, rtmp_url, self.effective_renditions(stream))
async def set_stream_ended(self, stream_key: str): async def set_stream_ended(self, stream_key: str):
stream = await self.get_stream_by_key(stream_key) stream = await self.get_stream_by_key(stream_key)
@@ -112,7 +123,7 @@ class StreamService:
stream = await self.get_stream_by_key(stream_key) stream = await self.get_stream_by_key(stream_key)
if not stream: if not stream:
return {"lines": [], "qualities": [], "multicast_available": False, "selected_node": None} return {"lines": [], "qualities": [], "multicast_available": False, "selected_node": None}
qualities = settings.transcode_profiles.get(stream.transcode_profile, settings.qualities) qualities = self.effective_renditions(stream)
best_node = lb.select_best_node(client_ip) best_node = lb.select_best_node(client_ip)
if not best_node: if not best_node:
best_node = lb.get_enabled_nodes()[0] if lb.get_enabled_nodes() else None best_node = lb.get_enabled_nodes()[0] if lb.get_enabled_nodes() else None
+12 -7
View File
@@ -6,33 +6,38 @@ from config import settings
class TranscodeManager: class TranscodeManager:
_processes: dict[str, list[subprocess.Popen]] = {} _processes: dict[str, list[subprocess.Popen]] = {}
def start_transcode(self, stream_key: str, input_url: str, profile: str = "standard"): def start_transcode(self, stream_key: str, input_url: str, qualities: list[dict]):
if stream_key in self._processes: if stream_key in self._processes:
return return
procs = [] procs = []
qualities = settings.transcode_profiles.get(profile, settings.qualities)
segment_seconds = max(1, settings.hls_segment_seconds) segment_seconds = max(1, settings.hls_segment_seconds)
keyframe_frames = segment_seconds * 30
for quality in qualities: for quality in qualities:
label = quality["label"] label = quality["label"]
width = quality["width"] width = quality["width"]
height = quality["height"] height = quality["height"]
fps = quality.get("fps", 30)
codec = quality.get("codec", "libx264")
bitrate = quality["bitrate"] bitrate = quality["bitrate"]
crf = quality.get("crf", 23)
preset = quality.get("preset", "ultrafast")
audio_bitrate = quality.get("audio_bitrate", "128k")
keyframe_seconds = quality.get("keyframe_seconds", segment_seconds)
keyframe_frames = keyframe_seconds * fps
hls_dir = f"/tmp/hls/{stream_key}/{label}" hls_dir = f"/tmp/hls/{stream_key}/{label}"
os.makedirs(hls_dir, exist_ok=True) os.makedirs(hls_dir, exist_ok=True)
cmd = [ cmd = [
"ffmpeg", "-y", "ffmpeg", "-y",
"-i", input_url, "-i", input_url,
"-c:v", "libx264", "-preset", "ultrafast", "-c:v", codec, "-preset", preset,
"-b:v", bitrate, "-r", str(fps), "-b:v", bitrate, "-crf", str(crf),
"-s", f"{width}x{height}", "-s", f"{width}x{height}",
"-sc_threshold", "0", "-sc_threshold", "0",
"-g", str(keyframe_frames), "-g", str(keyframe_frames),
"-keyint_min", str(keyframe_frames), "-keyint_min", str(keyframe_frames),
"-force_key_frames", f"expr:gte(t,n_forced*{segment_seconds})", "-force_key_frames", f"expr:gte(t,n_forced*{keyframe_seconds})",
"-copyts", "-start_at_zero", "-copyts", "-start_at_zero",
"-c:a", "aac", "-b:a", "128k", "-c:a", "aac", "-b:a", audio_bitrate,
"-f", "hls", "-f", "hls",
"-hls_time", str(segment_seconds), "-hls_time", str(segment_seconds),
"-hls_list_size", "6", "-hls_list_size", "6",
+1 -1
View File
@@ -65,7 +65,7 @@ function AppRoutes() {
return ( return (
<BrowserRouter> <BrowserRouter>
<PageErrorBoundary> <PageErrorBoundary>
<Suspense fallback={<div className="route-loading" role="status" aria-label="页面加载中" />}> <Suspense fallback={<div className="route-loading" role="status" aria-label="页面加载中"><span /></div>}>
<Routes> <Routes>
<Route path="/watch/:id" element={<WatchPage />} /> <Route path="/watch/:id" element={<WatchPage />} />
<Route path="/admin/login" element={<LoginPage />} /> <Route path="/admin/login" element={<LoginPage />} />
+11 -1
View File
@@ -1,4 +1,4 @@
import { useState } from 'react' import { Suspense, useEffect, 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 {
@@ -20,6 +20,16 @@ const menuItems = [
{ key: '/admin/settings', icon: <SettingOutlined />, label: '系统设置' }, { key: '/admin/settings', icon: <SettingOutlined />, label: '系统设置' },
] ]
const adminPageImports = [
() => import('./pages/Dashboard'),
() => import('./pages/LiveBoard'),
() => import('./pages/StreamList'),
() => import('./pages/StreamForm'),
() => import('./pages/ReplayEditor'),
() => import('./pages/NodeManager'),
() => import('./pages/Settings'),
]
export default function AdminLayout() { export default function AdminLayout() {
const { brand } = useBrand() const { brand } = useBrand()
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
+45 -8
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Button, Card, Col, Empty, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd' import { Button, Card, Col, Empty, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd'
import { DashboardOutlined, DatabaseOutlined, FullscreenExitOutlined, FullscreenOutlined, ReloadOutlined, SignalFilled, TeamOutlined, WarningOutlined } from '@ant-design/icons' import { DashboardOutlined, DatabaseOutlined, FullscreenExitOutlined, FullscreenOutlined, ReloadOutlined, SignalFilled, TeamOutlined, WarningOutlined } from '@ant-design/icons'
import api from '../../api/client'
import { getSystemMetrics, type SystemMetrics } from '../../api/system' import { getSystemMetrics, type SystemMetrics } from '../../api/system'
import { import {
createRemoteSession, createRemoteSession,
@@ -13,6 +14,27 @@ import {
const { Title, Text } = Typography const { Title, Text } = Typography
interface NodeMetric {
id: string
name: string
online: boolean
metrics?: {
cpu_percent?: number | null
memory_percent?: number | null
network_in_mbps?: number | null
network_out_mbps?: number | null
viewer_count?: number | null
} | null
}
const formatBytes = (value = 0) => {
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let amount = Math.max(0, value)
let index = 0
while (amount >= 1024 && index < units.length - 1) { amount /= 1024; index += 1 }
return `${amount.toFixed(index ? 1 : 0)} ${units[index]}`
}
export default function LiveBoard() { export default function LiveBoard() {
const [streams, setStreams] = useState<StreamInfo[]>([]) const [streams, setStreams] = useState<StreamInfo[]>([])
const [streamId, setStreamId] = useState<number>() const [streamId, setStreamId] = useState<number>()
@@ -20,7 +42,9 @@ export default function LiveBoard() {
const [selected, setSelected] = useState<React.Key[]>([]) const [selected, setSelected] = useState<React.Key[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [systemMetrics, setSystemMetrics] = useState<SystemMetrics | null>(null) const [systemMetrics, setSystemMetrics] = useState<SystemMetrics | null>(null)
const [nodes, setNodes] = useState<NodeMetric[]>([])
const [quality, setQuality] = useState('auto') const [quality, setQuality] = useState('auto')
const [nodeId, setNodeId] = useState('auto')
const [wallMode, setWallMode] = useState(false) const [wallMode, setWallMode] = useState(false)
async function load() { async function load() {
@@ -38,7 +62,9 @@ export default function LiveBoard() {
message.error('看板数据加载失败') message.error('看板数据加载失败')
} finally { } finally {
try { try {
setSystemMetrics(await getSystemMetrics()) const [metrics, nodeResponse] = await Promise.all([getSystemMetrics(), api.get<NodeMetric[]>('/nodes')])
setSystemMetrics(metrics)
setNodes(nodeResponse.data)
} catch { } catch {
setSystemMetrics(null) setSystemMetrics(null)
} }
@@ -56,9 +82,11 @@ export default function LiveBoard() {
if (!streamId || selected.length === 0) return if (!streamId || selected.length === 0) return
try { try {
const session = await createRemoteSession(streamId) const session = await createRemoteSession(streamId)
const action = quality === 'auto' ? 'restore_auto' : 'switch_quality' const targets = selected.map(String)
await sendRemoteCommand(streamId, session.token, action, selected.map(String), quality === 'auto' ? undefined : quality) if (nodeId !== 'auto') await sendRemoteCommand(streamId, session.token, 'switch_node', targets, nodeId)
message.success(`已向 ${selected.length} 个观众下发播放策略`) if (quality !== 'auto') await sendRemoteCommand(streamId, session.token, 'switch_quality', targets, quality)
if (nodeId === 'auto' && quality === 'auto') await sendRemoteCommand(streamId, session.token, 'restore_auto', targets)
message.success(`已向 ${selected.length} 个观众下发节点与清晰度策略`)
} catch { } catch {
message.error('控制命令下发失败,请确认节目已启用审计式播放控制') message.error('控制命令下发失败,请确认节目已启用审计式播放控制')
} }
@@ -95,17 +123,26 @@ export default function LiveBoard() {
</Row> </Row>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={24} sm={8}><Card><Statistic title="CPU 使用率" value={systemMetrics?.cpu_percent ?? 0} precision={1} suffix="%" prefix={<DashboardOutlined />} /></Card></Col> <Col xs={24} md={8}><Card><Statistic title="CPU 使用率" value={systemMetrics?.cpu_percent ?? 0} precision={1} suffix="%" prefix={<DashboardOutlined />} /></Card></Col>
<Col xs={24} sm={8}><Card><Statistic title="内存使用率" value={systemMetrics?.memory_percent ?? 0} precision={1} suffix="%" prefix={<DatabaseOutlined />} /></Card></Col> <Col xs={24} md={8}><Card><Statistic title="内存使用率" value={systemMetrics?.memory_percent ?? 0} precision={1} suffix="%" prefix={<DatabaseOutlined />} /><Text type="secondary">{formatBytes(systemMetrics?.memory_used_bytes)} / {formatBytes(systemMetrics?.memory_total_bytes)}</Text></Card></Col>
<Col xs={24} sm={8}><Card><Statistic title="存储使用率" value={systemMetrics?.disk_percent ?? 0} precision={1} suffix="%" prefix={<DatabaseOutlined />} /></Card></Col> <Col xs={24} md={8}><Card><Statistic title="存储使用率" value={systemMetrics?.disk_percent ?? 0} precision={1} suffix="%" prefix={<DatabaseOutlined />} /><Text type="secondary">{formatBytes(systemMetrics?.disk_used_bytes)} / {formatBytes(systemMetrics?.disk_total_bytes)}</Text></Card></Col>
<Col xs={24} md={12}><Card><Statistic title="NET I/O 下载总量" value={formatBytes(systemMetrics?.network_received_bytes)} /></Card></Col>
<Col xs={24} md={12}><Card><Statistic title="NET I/O 上传总量" value={formatBytes(systemMetrics?.network_sent_bytes)} /></Card></Col>
</Row> </Row>
<Card title="节点实时指标">
<Row gutter={[12, 12]}>
{nodes.map(node => <Col xs={24} md={12} xl={8} key={node.id}><Card size="small" title={<Space>{node.name}<Tag color={node.online ? 'green' : 'default'}>{node.online ? '在线' : '离线'}</Tag></Space>}><Space direction="vertical"><Text>CPU:{node.metrics?.cpu_percent?.toFixed(1) ?? '-'}%</Text><Text>内存:{node.metrics?.memory_percent?.toFixed(1) ?? '-'}%</Text><Text>NET IN:{node.metrics?.network_in_mbps?.toFixed(2) ?? '-'} Mbps</Text><Text>NET OUT:{node.metrics?.network_out_mbps?.toFixed(2) ?? '-'} Mbps</Text><Text>观众:{node.metrics?.viewer_count ?? 0}</Text></Space></Card></Col>)}
</Row>
</Card>
{!streamId ? <Empty description="当前没有可查看的直播节目" /> : ( {!streamId ? <Empty description="当前没有可查看的直播节目" /> : (
<Card <Card
title="审计式观众会话" title="审计式观众会话"
extra={ extra={
<Space wrap> <Space wrap>
<Select value={quality} onChange={setQuality} style={{ width: 150 }} options={[ <Select value={nodeId} onChange={setNodeId} style={{ width: 180 }} options={[{ value: 'auto', label: '节点:保持自动' }, ...nodes.filter(node => node.online).map(node => ({ value: node.id, label: `节点:${node.name}` }))]} />
<Select value={quality} onChange={setQuality} style={{ width: 160 }} options={[
{ value: 'auto', label: '恢复自动' }, { value: 'auto', label: '恢复自动' },
{ value: '1080p', label: '1080p' }, { value: '1080p', label: '1080p' },
{ value: '720p', label: '720p' }, { value: '720p', label: '720p' },
+35 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Form, Input, Button, DatePicker, Typography, message, Space, Alert, Select, Switch } from 'antd' import { Form, Input, InputNumber, Button, DatePicker, Typography, message, Space, Alert, Select, Switch, Card, Row, Col } from 'antd'
import { CopyOutlined, ArrowLeftOutlined, CheckCircleOutlined } from '@ant-design/icons' import { CopyOutlined, ArrowLeftOutlined, CheckCircleOutlined } from '@ant-design/icons'
import { createStream } from '../../api/streams' import { createStream } from '../../api/streams'
import axios from 'axios' import axios from 'axios'
@@ -24,6 +24,7 @@ export default function StreamForm() {
transcode_profile: values.transcode_profile, transcode_profile: values.transcode_profile,
homepage_visible: values.homepage_visible, homepage_visible: values.homepage_visible,
remote_control_enabled: values.remote_control_enabled, remote_control_enabled: values.remote_control_enabled,
renditions: values.renditions,
}) })
setResult({ rtmp_url: res.rtmp_url, stream_key: res.stream_key }) setResult({ rtmp_url: res.rtmp_url, stream_key: res.stream_key })
message.success('直播已创建') message.success('直播已创建')
@@ -83,7 +84,13 @@ export default function StreamForm() {
填写节目信息并预约播出,系统将生成推流码与 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,
renditions: [
{ label: '1080p', enabled: true, width: 1920, height: 1080, fps: 30, codec: 'libx264', bitrate: '5000k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 },
{ label: '720p', enabled: true, width: 1280, height: 720, fps: 30, codec: 'libx264', bitrate: '2500k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 },
],
}}>
<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 }} />
@@ -97,9 +104,34 @@ export default function StreamForm() {
<Form.Item name="scheduled_at" label="预定开播时间"> <Form.Item name="scheduled_at" label="预定开播时间">
<DatePicker showTime style={{ width: '100%', borderRadius: 8 }} placeholder="选择日期(可选)" /> <DatePicker showTime style={{ width: '100%', borderRadius: 8 }} placeholder="选择日期(可选)" />
</Form.Item> </Form.Item>
<Form.Item name="transcode_profile" label="转码配置"> <Form.Item name="transcode_profile" label="预设配置" extra="可直接使用预设,也可在下方逐档调整参数">
<Select options={[{ value: 'standard', label: '标准多档' }, { value: 'bandwidth-saver', label: '带宽节省' }]} /> <Select options={[{ value: 'standard', label: '标准多档' }, { value: 'bandwidth-saver', label: '带宽节省' }]} />
</Form.Item> </Form.Item>
<Form.List name="renditions">
{(fields, { add, remove }) => (
<Space direction="vertical" size={12} style={{ width: '100%', marginBottom: 24 }}>
<Text strong>自定义清晰度与码率</Text>
{fields.map(({ key, name, ...restField }) => (
<Card key={key} size="small" extra={<Button danger type="link" onClick={() => remove(name)}>删除</Button>}>
<Row gutter={[12, 0]}>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'enabled']} label="启用" valuePropName="checked"><Switch /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'label']} label="标签" rules={[{ required: true }]}><Input /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'width']} label="宽度" rules={[{ required: true }]}><InputNumber min={160} max={7680} style={{ width: '100%' }} /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'height']} label="高度" rules={[{ required: true }]}><InputNumber min={90} max={4320} style={{ width: '100%' }} /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'fps']} label="FPS"><InputNumber min={1} max={120} style={{ width: '100%' }} /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'codec']} label="编码"><Select options={[{ value: 'libx264', label: 'H.264' }, { value: 'libx265', label: 'H.265' }]} /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'bitrate']} label="视频码率"><Input placeholder="2500k" /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'crf']} label="CRF"><InputNumber min={0} max={51} style={{ width: '100%' }} /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'preset']} label="预设"><Select options={['ultrafast','superfast','veryfast','faster','fast','medium','slow'].map(value => ({ value, label: value }))} /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'audio_bitrate']} label="音频码率"><Input placeholder="128k" /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'keyframe_seconds']} label="关键帧秒数"><InputNumber min={1} max={10} style={{ width: '100%' }} /></Form.Item></Col>
</Row>
</Card>
))}
<Button onClick={() => add({ label: '480p', enabled: true, width: 854, height: 480, fps: 30, codec: 'libx264', bitrate: '1000k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 })}>添加清晰度</Button>
</Space>
)}
</Form.List>
<Form.Item name="homepage_visible" label="首页公开" valuePropName="checked"> <Form.Item name="homepage_visible" label="首页公开" valuePropName="checked">
<Switch checkedChildren="公开" unCheckedChildren="仅私有链接" /> <Switch checkedChildren="公开" unCheckedChildren="仅私有链接" />
</Form.Item> </Form.Item>
+16
View File
@@ -1,5 +1,19 @@
import api from './client' import api from './client'
export interface RenditionConfig {
label: string
enabled: boolean
width: number
height: number
fps: number
codec: 'libx264' | 'libx265'
bitrate: string
crf: number
preset: 'ultrafast' | 'superfast' | 'veryfast' | 'faster' | 'fast' | 'medium' | 'slow'
audio_bitrate: string
keyframe_seconds: number
}
export interface StreamInfo { export interface StreamInfo {
id: number id: number
title: string title: string
@@ -15,6 +29,7 @@ export interface StreamInfo {
replay_file?: string replay_file?: string
chapters: Chapter[] chapters: Chapter[]
transcode_profile: string transcode_profile: string
renditions?: RenditionConfig[] | null
homepage_visible: boolean homepage_visible: boolean
remote_control_enabled: boolean remote_control_enabled: boolean
} }
@@ -32,6 +47,7 @@ export interface StreamCreateReq {
replay_file?: string replay_file?: string
chapters?: Chapter[] chapters?: Chapter[]
transcode_profile?: string transcode_profile?: string
renditions?: RenditionConfig[]
homepage_visible?: boolean homepage_visible?: boolean
remote_control_enabled?: boolean remote_control_enabled?: boolean
} }
+23 -2
View File
@@ -3,12 +3,33 @@ import api from './client'
export interface SystemMetrics { export interface SystemMetrics {
cpu_percent: number cpu_percent: number
memory_percent: number memory_percent: number
memory_total_bytes: number
memory_used_bytes: number
disk_percent: number disk_percent: number
disk_total_bytes: number disk_total_bytes: number
disk_used_bytes: number disk_used_bytes: number
network_received_bytes: number
network_sent_bytes: number
}
interface SystemMetricsResponse {
cpu: { load_percent: number }
memory: { total: number; available: number; used_percent: number }
disk: { total: number; free: number; used_percent: number }
network: { bytes_recv: number; bytes_sent: number }
} }
export async function getSystemMetrics(): Promise<SystemMetrics> { export async function getSystemMetrics(): Promise<SystemMetrics> {
const { data } = await api.get<SystemMetrics>('/settings/system-metrics') const { data } = await api.get<SystemMetricsResponse>('/settings/system-metrics')
return data return {
cpu_percent: data.cpu.load_percent,
memory_percent: data.memory.used_percent,
memory_total_bytes: data.memory.total,
memory_used_bytes: Math.max(0, data.memory.total - data.memory.available),
disk_percent: data.disk.used_percent,
disk_total_bytes: data.disk.total,
disk_used_bytes: Math.max(0, data.disk.total - data.disk.free),
network_received_bytes: data.network.bytes_recv,
network_sent_bytes: data.network.bytes_sent,
}
} }