Frontend
- Replace the Ant Design app with the shadcn/ui app as the single frontend,
keeping the portfolio side-by-side revertible in this commit.
- Drop the abandoned mdui experiment and the AntD tree so there is one app,
one router and one design system to maintain.
Live playback
- Stop racing the encoder. lowLatencyMode + liveSyncDurationCount:1 targeted a
single 4s segment, so playback ran at up to 1.10x and the forward buffer
bottomed out at 0.1s, re-buffering every segment. Measured over 40s:
buffer min 0.1s -> 8.9s, rate 1.00 constant, 0 stalls.
- Remove the 落后 labels and the page-level edge-snapping timer that fought
hls.js over two different definitions of "latency".
- Live can no longer be paused: the control is not rendered while it plays, so
the icon can never misreport state.
- Fix autoplay: muted autoplay started silently and never unmuted, and the
gesture that restored sound also toggled playback off. One owner now.
Source ("原画") rendition
- Publish the publisher's stream remuxed losslessly via -c:v copy alongside the
transcoded ones, so a source option exists even when every ladder entry
downsizes.
- Give renditions an ASCII path slug: the HLS path is part of the URL and
MediaMTX rejects non-alphanumeric paths, so 原画 could never be served.
Backend
- Supervise MediaMTX from the backend: start it at boot and restart it on exit,
never killing an origin it did not start.
- Clear stale HLS output before restarting an encoder; an ENDLIST-less playlist
left by a dead encoder hangs hls.js forever.
- Mark JSON timestamps as UTC. utcnow().isoformat() emitted a naive string that
JS read as local time, so every viewer heartbeat looked 8 hours old in UTC+8
and the live board's active-viewer count was permanently zero.
- Rebind a viewer session when it arrives on another stream instead of failing,
and survive the StrictMode double-heartbeat race on first insert.
- Replace the Linux-only /proc and os.getloadavg metrics with psutil.
- Complete MediaMTX's cookieCheck redirect inside /hls proxying, and hold the
httpx client open for the streamed body.
Housekeeping
- Ignore RainOSS (it stores accessKeySecret), runtime output, TLS keys and
.venv; remove generated screenshots, load test scripts and stray files.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
138 lines
4.7 KiB
Python
138 lines
4.7 KiB
Python
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import psutil
|
|
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
|
|
|
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
|
|
|
|
|
class BrandUpdate(BaseModel):
|
|
brand_name: Optional[str] = None
|
|
brand_tagline: Optional[str] = None
|
|
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 {
|
|
"brand_name": settings.brand_name,
|
|
"brand_tagline": settings.brand_tagline,
|
|
"brand_logo_url": settings.brand_logo_url,
|
|
}
|
|
|
|
|
|
@router.put("/brand")
|
|
async def update_brand(body: BrandUpdate, _: bool = Depends(require_auth)):
|
|
if body.brand_name is not None:
|
|
settings.brand_name = body.brand_name
|
|
if body.brand_tagline is not None:
|
|
settings.brand_tagline = body.brand_tagline
|
|
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)):
|
|
cpu_count = os.cpu_count() or 1
|
|
|
|
# There is no load average on Windows: os.getloadavg does not exist there, and
|
|
# psutil's emulation reads as 0.0 until its background sampler warms up (and
|
|
# never means quite the same thing). Report real CPU utilisation instead, so
|
|
# the dashboard's "CPU 使用率" card is not permanently stuck at zero.
|
|
if hasattr(os, "getloadavg"):
|
|
load_1m, load_5m, load_15m = os.getloadavg()
|
|
load_percent = round(min(100, load_1m / cpu_count * 100), 1)
|
|
else:
|
|
load_1m = load_5m = load_15m = None
|
|
# interval=None never blocks; it measures since the previous call anywhere
|
|
# in the process, which the supervisor's 10s metrics tick keeps fresh.
|
|
load_percent = round(psutil.cpu_percent(), 1)
|
|
|
|
memory = psutil.virtual_memory()
|
|
network = psutil.net_io_counters()
|
|
|
|
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)
|
|
|
|
return {
|
|
"cpu": {
|
|
"count": cpu_count,
|
|
"load_1m": load_1m,
|
|
"load_5m": load_5m,
|
|
"load_15m": load_15m,
|
|
"load_percent": load_percent,
|
|
"percent": load_percent,
|
|
},
|
|
"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,
|
|
},
|
|
"network": {
|
|
"bytes_recv": network.bytes_recv,
|
|
"bytes_sent": network.bytes_sent,
|
|
},
|
|
}
|