Files
live/backend/routes/settings_route.py
T
adminandClaude Opus 4.8 8e61dc111c feat: keyboard seek, playback speed, and persist edited settings
Player
- Arrow keys seek by 5s, Shift for a 30s jump on long recordings, J/L for +-30s,
  Space toggles playback, and up/down adjust volume. Dragging a scrubber across a
  multi-hour replay is unusable — one pixel is tens of seconds — so keys are the
  practical way to move around a long recording.
- The shortcuts ignore key events from inputs, so typing a chapter time in the
  replay editor no longer scrubs the video.
- Add a playback speed selector. It is a replay tool: speeding a live stream up
  would only push it further ahead of the encoder's buffer.

Replay editor
- Stop playback restarting on every edit. The player's source effect depended on
  the `lines` and `qualities` arrays, and callers such as the replay editor pass
  fresh literals on every render, so adding a chapter or typing a title was
  treated as a source change and reloaded the stream from the beginning. The
  effect now depends on the resolved source URL instead of the arrays it came
  from. Measured: adding three chapters leaves the media requests unchanged and
  the playhead advancing.

Settings persistence
- Settings edited in the UI were applied to the in-memory settings object and
  never written down, so the brand, transcode ladder and load-balancing strategy
  silently reverted to the config.py defaults on every restart. They are now
  saved to backend/settings.json (atomic temp-file replace) and re-applied at
  startup, with only known keys accepted so a hand-edited file cannot inject
  arbitrary attributes.

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

147 lines
5.3 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
from services.settings_store import save_settings
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
# Without this the change lives only in memory and the next restart silently
# reverts the platform back to the config.py defaults.
save_settings()
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()))
save_settings()
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,
},
}