From 383c066d86076c297962d963b73277b489692aa6 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 19 Aug 2026 17:25:00 +0800 Subject: [PATCH] feat: complete program platform experience --- backend/routes/settings_route.py | 97 ++++++++++++++- backend/tests/test_access_and_sessions.py | 71 +++++++++++ backend/tests/test_settings_route.py | 33 +++++ frontend/src/admin/pages/LiveBoard.tsx | 17 ++- frontend/src/admin/pages/Settings.css | 11 ++ frontend/src/admin/pages/Settings.tsx | 144 +++++++++++++--------- frontend/src/api/system.ts | 14 +++ frontend/src/viewer/pages/WatchPage.tsx | 65 ++++++++-- frontend/src/viewer/pages/viewer.css | 34 +++++ 9 files changed, 419 insertions(+), 67 deletions(-) create mode 100644 backend/tests/test_access_and_sessions.py create mode 100644 backend/tests/test_settings_route.py create mode 100644 frontend/src/admin/pages/Settings.css create mode 100644 frontend/src/api/system.ts diff --git a/backend/routes/settings_route.py b/backend/routes/settings_route.py index 2067e6d..4887dac 100644 --- a/backend/routes/settings_route.py +++ b/backend/routes/settings_route.py @@ -1,5 +1,9 @@ -from fastapi import APIRouter, Depends -from pydantic import BaseModel +import os +import shutil +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field from typing import Optional from config import settings from routes.auth import require_auth @@ -13,6 +17,17 @@ class BrandUpdate(BaseModel): brand_logo_url: Optional[str] = None +class TranscodeQuality(BaseModel): + label: str = Field(min_length=1, max_length=32) + width: int = Field(ge=160, le=7680) + height: int = Field(ge=90, le=4320) + bitrate: str = Field(pattern=r"^[1-9][0-9]*(?:k|M)$") + + +class TranscodeProfilesUpdate(BaseModel): + profiles: dict[str, list[TranscodeQuality]] + + @router.get("/brand") async def get_brand(): return { @@ -31,3 +46,81 @@ async def update_brand(body: BrandUpdate, _: bool = Depends(require_auth)): if body.brand_logo_url is not None: settings.brand_logo_url = body.brand_logo_url return {"ok": True} + + +@router.get("/transcode-profiles") +async def get_transcode_profiles(_: bool = Depends(require_auth)): + return settings.transcode_profiles + + +@router.put("/transcode-profiles") +async def update_transcode_profiles( + body: TranscodeProfilesUpdate, + _: bool = Depends(require_auth), +): + if not body.profiles: + raise HTTPException(status_code=422, detail="至少需要一个转码配置") + + normalized: dict[str, list[dict]] = {} + for raw_name, qualities in body.profiles.items(): + name = raw_name.strip() + if not name or len(name) > 64: + raise HTTPException(status_code=422, detail="转码配置名称无效") + if not qualities: + raise HTTPException(status_code=422, detail=f"转码配置 {name} 至少需要一个清晰度") + labels = [quality.label.strip() for quality in qualities] + if any(not label for label in labels) or len(labels) != len(set(labels)): + raise HTTPException(status_code=422, detail=f"转码配置 {name} 的清晰度名称无效或重复") + normalized[name] = [ + { + "label": quality.label.strip(), + "width": quality.width, + "height": quality.height, + "bitrate": quality.bitrate, + } + for quality in sorted(qualities, key=lambda item: item.height, reverse=True) + ] + + settings.transcode_profiles = normalized + settings.qualities = next(iter(normalized.values())) + return {"ok": True, "profiles": normalized} + + +@router.get("/system-metrics") +async def get_system_metrics(_: bool = Depends(require_auth)): + load_1m, load_5m, load_15m = os.getloadavg() + memory_total = 0 + memory_available = 0 + meminfo = Path("/proc/meminfo") + if meminfo.exists(): + values = {} + for line in meminfo.read_text(encoding="ascii").splitlines(): + key, _, value = line.partition(":") + if value: + values[key] = int(value.strip().split()[0]) * 1024 + memory_total = values.get("MemTotal", 0) + memory_available = values.get("MemAvailable", 0) + + metrics_path = Path(settings.replay_media_dir).resolve() + disk_path = metrics_path if metrics_path.exists() else Path.cwd() + disk = shutil.disk_usage(disk_path) + cpu_count = os.cpu_count() or 1 + return { + "cpu": { + "count": cpu_count, + "load_1m": load_1m, + "load_5m": load_5m, + "load_15m": load_15m, + "load_percent": round(min(100, load_1m / cpu_count * 100), 1), + }, + "memory": { + "total": memory_total, + "available": memory_available, + "used_percent": round((1 - memory_available / memory_total) * 100, 1) if memory_total else 0, + }, + "disk": { + "total": disk.total, + "free": disk.free, + "used_percent": round(disk.used / disk.total * 100, 1) if disk.total else 0, + }, + } diff --git a/backend/tests/test_access_and_sessions.py b/backend/tests/test_access_and_sessions.py new file mode 100644 index 0000000..ab2e0f7 --- /dev/null +++ b/backend/tests/test_access_and_sessions.py @@ -0,0 +1,71 @@ +import unittest +from datetime import datetime +from types import SimpleNamespace + +from routes.streams import _serialize + + +class StreamAccessSerializationTest(unittest.TestCase): + def make_stream(self, **overrides): + values = { + "id": 1, + "title": "Hidden program", + "description": None, + "cover_url": None, + "status": SimpleNamespace(value="pending"), + "scheduled_at": datetime(2026, 8, 20, 12, 0), + "started_at": None, + "ended_at": None, + "multicast_enabled": False, + "replay_file": None, + "chapters_json": "[]", + "transcode_profile": "default", + "homepage_visible": False, + "remote_control_enabled": True, + "stream_key": "private-key", + "created_at": datetime(2026, 8, 19, 12, 0), + } + values.update(overrides) + return SimpleNamespace(**values) + + def test_public_serialization_excludes_private_stream_key(self): + payload = _serialize(self.make_stream(), include_private=False) + self.assertNotIn("stream_key", payload) + self.assertFalse(payload["homepage_visible"]) + self.assertTrue(payload["remote_control_enabled"]) + + def test_admin_serialization_includes_private_stream_key(self): + payload = _serialize(self.make_stream(), include_private=True) + self.assertEqual(payload["stream_key"], "private-key") + + def test_program_statuses_are_preserved(self): + for status in ("pending", "live", "ended", "cancelled"): + with self.subTest(status=status): + payload = _serialize(self.make_stream(status=SimpleNamespace(value=status)), include_private=False) + self.assertEqual(payload["status"], status) + + +class AccessContractTest(unittest.TestCase): + def test_hidden_program_uses_private_direct_url_contract(self): + stream = StreamAccessSerializationTest().make_stream(homepage_visible=False) + public_payload = _serialize(stream, include_private=False) + admin_payload = _serialize(stream, include_private=True) + self.assertFalse(public_payload["homepage_visible"]) + self.assertNotIn("stream_key", public_payload) + self.assertEqual(admin_payload["stream_key"], "private-key") + + def test_remote_control_flag_is_enforced_by_contract(self): + enabled = _serialize( + StreamAccessSerializationTest().make_stream(remote_control_enabled=True), + include_private=False, + ) + disabled = _serialize( + StreamAccessSerializationTest().make_stream(remote_control_enabled=False), + include_private=False, + ) + self.assertTrue(enabled["remote_control_enabled"]) + self.assertFalse(disabled["remote_control_enabled"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_settings_route.py b/backend/tests/test_settings_route.py new file mode 100644 index 0000000..015b36f --- /dev/null +++ b/backend/tests/test_settings_route.py @@ -0,0 +1,33 @@ +import asyncio +import unittest + +from pydantic import ValidationError + +from routes.settings_route import TranscodeQuality, get_system_metrics + + +class SettingsRouteTest(unittest.TestCase): + def test_transcode_quality_accepts_supported_values(self): + quality = TranscodeQuality(label="高清", width=1920, height=1080, bitrate="5000k") + self.assertEqual(quality.width, 1920) + self.assertEqual(quality.bitrate, "5000k") + + def test_transcode_quality_rejects_invalid_bitrate_and_dimensions(self): + invalid_values = [ + dict(label="高清", width=100, height=1080, bitrate="5000k"), + dict(label="高清", width=1920, height=1080, bitrate="5000"), + dict(label="", width=1920, height=1080, bitrate="5M"), + ] + for value in invalid_values: + with self.subTest(value=value), self.assertRaises(ValidationError): + TranscodeQuality(**value) + + def test_system_metrics_contains_bounded_resource_values(self): + metrics = asyncio.run(get_system_metrics(True)) + self.assertGreaterEqual(metrics["cpu"]["count"], 1) + self.assertGreaterEqual(metrics["cpu"]["load_percent"], 0) + self.assertLessEqual(metrics["cpu"]["load_percent"], 100) + self.assertGreaterEqual(metrics["memory"]["used_percent"], 0) + self.assertLessEqual(metrics["memory"]["used_percent"], 100) + self.assertGreater(metrics["disk"]["total"], 0) + self.assertGreaterEqual(metrics["disk"]["free"], 0) diff --git a/frontend/src/admin/pages/LiveBoard.tsx b/frontend/src/admin/pages/LiveBoard.tsx index c1b0da8..a682bb8 100644 --- a/frontend/src/admin/pages/LiveBoard.tsx +++ b/frontend/src/admin/pages/LiveBoard.tsx @@ -1,6 +1,7 @@ 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 { DashboardOutlined, DatabaseOutlined, FullscreenExitOutlined, FullscreenOutlined, ReloadOutlined, SignalFilled, TeamOutlined, WarningOutlined } from '@ant-design/icons' +import { getSystemMetrics, type SystemMetrics } from '../../api/system' import { createRemoteSession, fetchStreams, @@ -18,7 +19,9 @@ export default function LiveBoard() { const [sessions, setSessions] = useState([]) const [selected, setSelected] = useState([]) const [loading, setLoading] = useState(true) + const [systemMetrics, setSystemMetrics] = useState(null) const [quality, setQuality] = useState('auto') + const [wallMode, setWallMode] = useState(false) async function load() { try { @@ -34,6 +37,11 @@ export default function LiveBoard() { } catch { message.error('看板数据加载失败') } finally { + try { + setSystemMetrics(await getSystemMetrics()) + } catch { + setSystemMetrics(null) + } setLoading(false) } } @@ -75,6 +83,7 @@ export default function LiveBoard() { options={streams.map(program => ({ value: program.id, label: `${program.title} · ${program.status}` }))} onChange={value => { setStreamId(value); setSelected([]) }} /> + @@ -85,6 +94,12 @@ export default function LiveBoard() { } valueStyle={{ color: buffering.length ? '#cf5c36' : undefined }} /> + + } /> + } /> + } /> + + {!streamId ? : ( * { min-width: 0; max-width: 100%; } +@media (max-width: 720px) { + .settings-card { padding: 16px; } + .quality-row { grid-template-columns: 1fr 1fr; } + .quality-row > :first-child, .quality-row > :nth-child(4), .quality-row > :last-child { grid-column: 1 / -1; width: 100%; } + .settings-page .ant-select, .settings-page .ant-input-number { width: 100% !important; } +} \ No newline at end of file diff --git a/frontend/src/admin/pages/Settings.tsx b/frontend/src/admin/pages/Settings.tsx index ce6f506..0842c4e 100644 --- a/frontend/src/admin/pages/Settings.tsx +++ b/frontend/src/admin/pages/Settings.tsx @@ -1,10 +1,14 @@ -import { useState, useEffect } from 'react' -import { Select, Switch, Form, Input, Button, Typography, message } from 'antd' +import { useEffect, useState } from 'react' +import { Button, Form, Input, InputNumber, Select, Switch, Typography, message } from 'antd' import api from '../../api/client' +import './Settings.css' import { useBrand } from '../../brand/BrandContext' const { Title, Text } = Typography +type Quality = { label: string; width: number; height: number; bitrate: string } +type Profiles = Record + const strategies = [ { value: 'subnet', label: '子网优先(推荐)' }, { value: 'weighted', label: '权重均衡' }, @@ -17,77 +21,107 @@ export default function SettingsPage() { const [strategy, setStrategy] = useState('subnet') const [multicastEnabled, setMulticastEnabled] = useState(true) const [multicastAvailable, setMulticastAvailable] = useState(false) - const [saving, setSaving] = useState(false) const [brandName, setBrandName] = useState(brand.brand_name) const [brandTagline, setBrandTagline] = useState(brand.brand_tagline) const [brandLogoUrl, setBrandLogoUrl] = useState(brand.brand_logo_url) - const [brandSaving, setBrandSaving] = useState(false) + const [profiles, setProfiles] = useState({}) + const [saving, setSaving] = useState(false) - useEffect(() => { loadSettings() }, []) - useEffect(() => { setBrandName(brand.brand_name); setBrandTagline(brand.brand_tagline); setBrandLogoUrl(brand.brand_logo_url) }, [brand]) + useEffect(() => { + void Promise.all([api.get('/nodes/strategy'), api.get('/settings/transcode-profiles')]) + .then(([nodes, transcode]) => { + setStrategy(nodes.data.strategy) + setMulticastEnabled(nodes.data.multicast_enabled) + setMulticastAvailable(nodes.data.multicast_available) + setProfiles(transcode.data.profiles || {}) + }) + .catch(() => message.error('系统设置加载失败')) + }, []) - async function loadSettings() { - try { const { data } = await api.get('/nodes/strategy'); setStrategy(data.strategy); setMulticastEnabled(data.multicast_enabled); setMulticastAvailable(data.multicast_available) } catch { /* */ } + useEffect(() => { + setBrandName(brand.brand_name) + setBrandTagline(brand.brand_tagline) + setBrandLogoUrl(brand.brand_logo_url) + }, [brand]) + + function replaceProfile(profile: string, qualities: Quality[]) { + setProfiles(current => Object.assign({}, current, Object.fromEntries([[profile, qualities]]))) } - async function saveStrategy(val: string) { + + function updateQuality(profile: string, index: number, patch: Partial) { + replaceProfile(profile, profiles[profile].map((quality, i) => i === index ? { ...quality, ...patch } : quality)) + } + + function addQuality(profile: string) { + replaceProfile(profile, [...profiles[profile], { label: '新清晰度', width: 1280, height: 720, bitrate: '2000k' }]) + } + + function removeQuality(profile: string, index: number) { + replaceProfile(profile, profiles[profile].filter((_, i) => i !== index)) + } + + async function saveAll() { setSaving(true) - try { await api.put('/nodes/strategy', { strategy: val, multicast_enabled: multicastEnabled }); setStrategy(val); message.success('策略已更新') } - catch { message.error('更新失败') } - setSaving(false) - } - async function toggleMulticast(val: boolean) { - setMulticastEnabled(val) - try { await api.put('/nodes/strategy', { strategy, multicast_enabled: val }); message.success('已更新') } catch { message.error('更新失败') } - } - async function saveBrand() { - setBrandSaving(true) - try { await api.put('/settings/brand', { brand_name: brandName, brand_tagline: brandTagline, brand_logo_url: brandLogoUrl }); await refresh(); message.success('品牌设置已保存') } - catch { message.error('保存失败') } - setBrandSaving(false) + try { + await Promise.all([ + api.put('/nodes/strategy', { strategy, multicast_enabled: multicastEnabled }), + api.put('/settings/brand', { brand_name: brandName, brand_tagline: brandTagline, brand_logo_url: brandLogoUrl }), + api.put('/settings/transcode-profiles', { profiles }), + ]) + await refresh() + message.success('设置已保存') + } catch { + message.error('保存失败,请检查转码参数') + } finally { + setSaving(false) + } } return ( -
-
- 系统设置 - 配置负载均衡策略与品牌信息 -
+
+ 系统设置 + 配置分发策略、转码清晰度与平台品牌 -
- 负载均衡 +
+ 负载均衡
- - - - - {multicastAvailable ? '可用 — 优先使用组播分发' : '不可用 — 回退至单播'} - + + {multicastAvailable ? '可用' : '不可用,将回退至单播'}
-
+ -
- 品牌设置 +
+ 自定义转码配置 + {Object.entries(profiles).map(([profile, qualities]) => ( +
+ {profile} + {qualities.map((quality, index) => ( +
+ updateQuality(profile, index, { label: e.target.value })} /> + updateQuality(profile, index, { width: v || 160 })} /> + updateQuality(profile, index, { height: v || 90 })} /> + updateQuality(profile, index, { bitrate: e.target.value })} /> + +
+ ))} + +
+ ))} +
+ +
+ 品牌设置
- - setBrandName(e.target.value)} size="large" style={{ borderRadius: 8 }} /> - - - setBrandTagline(e.target.value)} size="large" style={{ borderRadius: 8 }} /> - - - setBrandLogoUrl(e.target.value)} style={{ borderRadius: 8 }} placeholder="https://..." /> - - - - 保存后全站立即生效 - + setBrandName(e.target.value)} /> + setBrandTagline(e.target.value)} /> + setBrandLogoUrl(e.target.value)} />
-
+ + +
) } diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts new file mode 100644 index 0000000..deb24c4 --- /dev/null +++ b/frontend/src/api/system.ts @@ -0,0 +1,14 @@ +import api from './client' + +export interface SystemMetrics { + cpu_percent: number + memory_percent: number + disk_percent: number + disk_total_bytes: number + disk_used_bytes: number +} + +export async function getSystemMetrics(): Promise { + const { data } = await api.get('/settings/system-metrics') + return data +} diff --git a/frontend/src/viewer/pages/WatchPage.tsx b/frontend/src/viewer/pages/WatchPage.tsx index 223f1b7..1cd91ae 100644 --- a/frontend/src/viewer/pages/WatchPage.tsx +++ b/frontend/src/viewer/pages/WatchPage.tsx @@ -29,10 +29,12 @@ export default function WatchPage() { const [activeLine, setActiveLine] = useState('') const [activeQuality, setActiveQuality] = useState('auto') const [autoQuality, setAutoQuality] = useState('') - const bufferEventsRef = useRef([]) + const bufferEventsRef = useRef>([]) const bufferStartedRef = useRef(null) const playbackStartedRef = useRef(0) const [remoteAdjusted, setRemoteAdjusted] = useState(false) + const [showPlaybackStats, setShowPlaybackStats] = useState(false) + const [playbackStats, setPlaybackStats] = useState({ buffered: 0, currentTime: 0, droppedFrames: 0, resolution: '未知' }) const hlsRef = useRef(null) const viewerSessionRef = useRef('') const currentLine = info?.lines.find(line => line.node_id === activeLine) @@ -182,6 +184,14 @@ export default function WatchPage() { const video = videoRef.current if (!info || !video) return setPlaybackError('') + const previousTime = Number.isFinite(video.currentTime) ? video.currentTime : 0 + const wasPaused = video.paused + const restorePlaybackState = () => { + if (previousTime > 0 && Number.isFinite(video.duration)) { + video.currentTime = Math.min(previousTime, Math.max(0, video.duration - 0.1)) + } + if (!wasPaused) video.play().catch(() => {}) + } hlsRef.current?.destroy() hlsRef.current = null video.removeAttribute('src') @@ -208,13 +218,14 @@ export default function WatchPage() { const hi = new Hls({ enableWorker: false, lowLatencyMode: false }) hi.loadSource(source) hi.attachMedia(video) - hi.on(Hls.Events.MANIFEST_PARSED, () => { video.play().catch(() => {}) }) + hi.on(Hls.Events.MANIFEST_PARSED, restorePlaybackState) hi.on(Hls.Events.ERROR, (_e, detail) => { if (detail.fatal) setPlaybackError('播放流加载失败,请尝试切换线路或稍后重试') }) hlsRef.current = hi } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = source + video.addEventListener('loadedmetadata', restorePlaybackState, { once: true }) video.load() } else { setPlaybackError('当前浏览器不支持此播放格式') @@ -250,10 +261,12 @@ export default function WatchPage() { const onPlaying = () => { const now = Date.now() if (!playbackStartedRef.current) playbackStartedRef.current = now - if (bufferStartedRef.current && now - playbackStartedRef.current > 3000) { + if (bufferStartedRef.current && now - playbackStartedRef.current > 10000) { const duration = now - bufferStartedRef.current - bufferEventsRef.current = bufferEventsRef.current.filter(at => now - at <= 10000) - if (duration >= 2000 || bufferEventsRef.current.length >= 3) { + bufferEventsRef.current.push({ at: now, duration }) + bufferEventsRef.current = bufferEventsRef.current.filter(event => now - event.at <= 10000) + const accumulatedBuffering = bufferEventsRef.current.reduce((total, event) => total + event.duration, 0) + if (accumulatedBuffering >= 2000 || bufferEventsRef.current.length >= 3) { lowerQuality() bufferEventsRef.current = [] } @@ -261,9 +274,9 @@ export default function WatchPage() { bufferStartedRef.current = null } const onWaiting = () => { - if (Date.now() - playbackStartedRef.current <= 3000) return - bufferStartedRef.current = Date.now() - bufferEventsRef.current.push(Date.now()) + const now = Date.now() + if (!playbackStartedRef.current || now - playbackStartedRef.current <= 10000 || bufferStartedRef.current) return + bufferStartedRef.current = now } const recoveryTimer = window.setInterval(() => { if (!video.paused && !video.seeking && video.readyState >= 3) higherQuality() @@ -278,6 +291,26 @@ export default function WatchPage() { } }, [activeQuality, info]) + useEffect(() => { + const video = videoRef.current + if (!video) return + const updateStats = () => { + const buffered = video.buffered.length + ? Math.max(0, video.buffered.end(video.buffered.length - 1) - video.currentTime) + : 0 + const quality = video.getVideoPlaybackQuality?.() + setPlaybackStats({ + buffered, + currentTime: video.currentTime || 0, + droppedFrames: quality?.droppedVideoFrames || 0, + resolution: video.videoWidth && video.videoHeight ? `${video.videoWidth}×${video.videoHeight}` : '未知', + }) + } + updateStats() + const timer = window.setInterval(updateStats, 1000) + return () => window.clearInterval(timer) + }, [activeLine, activeQuality, autoQuality]) + function jumpToChapter(time: number) { if (!videoRef.current) return videoRef.current.currentTime = time @@ -335,7 +368,21 @@ export default function WatchPage() {
-