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, }, }