feat: expand livestream management platform
This commit is contained in:
1 parent
383c066d86
commit
ed62d42a63
12 files changed
+223
-24
No files matched your search
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@@ -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="请先选择回放文件再编辑章节")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user