Files
live/backend/routes/settings_route.py
T
adminandClaude Opus 4.8 cdf591f933 fix: serve replay media from nginx and unbreak settings saves
Replay
- Hand the replay file to nginx with X-Accel-Redirect instead of streaming it
  through the backend. A replay is tens of gigabytes, so Python was copying the
  whole file and holding a worker for the entire transfer: measured 8 MB in
  8.46s (~1 MB/s). nginx serves it from disk with sendfile at ~400 MB/s (1 GB in
  2.68s) and the backend process does not appear in CPU usage during the transfer.
  Authentication and the path-traversal check still run in Python; only the bytes
  moved. The internal location is what keeps the path from being reachable
  directly and bypassing that check.
- nginx reloads and the media directory is aliased internally.

Settings
- GET /settings/transcode-profiles returned the bare mapping while the settings
  page reads `data.profiles`, so it always saw undefined, fell back to an empty
  object, and every save posted `{"profiles": {}}` — which the PUT rejects as
  "at least one profile". Saving settings could never succeed. The response now
  matches the shape PUT accepts, verified by round-tripping it.

Auth
- Derive the admin token instead of storing it. The token is
  sha256(password + ":token"), but it was kept in a module-level set, which only
  works with one process: with uvicorn --workers N, logging in on one worker left
  the others rejecting the token and the admin appeared logged out at random.

Viewer
- Show the real session id in the stats panel; the previous code assigned it
  from a local variable that was never populated, so it always read "不可用".
- Send the first heartbeat before the first command poll. Both fired together on
  mount, so the poll reached the backend before the session existed and 404'd on
  every page load.

Housekeeping
- Ignore backend/nodes.json; the admin node manager writes it at runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-09-29 11:47:52 +08:00

142 lines
5.0 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)):
# Wrapped in "profiles" to match what PUT accepts. Returning the bare mapping
# meant the settings page read `data.profiles`, got undefined, fell back to an
# empty object, and then every save sent `{"profiles": {}}` — which the PUT
# below rejects as "at least one profile", so saving settings could never work.
return {"profiles": 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,
},
}