feat: complete program platform experience
This commit is contained in:
1 parent
e50b8b480b
commit
383c066d86
9 files changed
+419
-67
No files matched your search
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user