feat: complete program platform experience

This commit is contained in:
admin committed 2026-08-19 17:25:00 +08:00
1 parent e50b8b480b
commit 383c066d86
9 files changed
+419 -67

No files matched your search

+95 -2
View File
@@ -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,
},
}
+71
View File
@@ -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()
+33
View File
@@ -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)