Files
live/backend/routes/nodes.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

166 lines
5.1 KiB
Python

from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
from config import settings
from services.settings_store import save_settings
from routes.auth import require_auth
from services.load_balancer import lb
from services.multicast_detector import multicast_detector
from services.node_store import save_nodes
router = APIRouter(prefix="/api/nodes", tags=["nodes"])
class NodeUpdate(BaseModel):
weight: int | None = None
enabled: bool | None = None
subnet: str | None = None
name: str | None = None
class StrategyUpdate(BaseModel):
strategy: str
multicast_enabled: bool | None = None
class NodeHeartbeat(BaseModel):
node_id: str
cpu_percent: float | None = None
memory_percent: float | None = None
bandwidth_mbps: float | None = None
active_connections: int | None = None
hls_readers: int | None = None
network_in_mbps: float | None = None
network_out_mbps: float | None = None
viewer_count: int | None = None
frame_drop_indicators: int | None = None
packet_loss_indicators: int | None = None
media_metrics_available: bool = False
class NodeRegister(BaseModel):
node_id: str
name: str
host: str
hls_port: int
api_port: int
weight: int = 100
subnet: str = "0.0.0.0/0"
@router.get("")
async def list_nodes(_: bool = Depends(require_auth)):
nodes = []
for n in settings.edge_nodes:
nid = n["id"]
nodes.append({
"id": nid,
"name": n["name"],
"host": n["host"],
"hls_port": n["hls_port"],
"api_port": n["api_port"],
"weight": n["weight"],
"enabled": n.get("enabled", True),
"subnet": n.get("subnet", "0.0.0.0/0"),
"is_origin": n.get("is_origin", False),
"online": lb.is_online(nid),
"metrics": lb._node_health.get(nid),
})
return nodes
@router.get("/strategy")
async def get_strategy(_: bool = Depends(require_auth)):
return {
"strategy": settings.load_balance_strategy,
"multicast_enabled": settings.multicast_enabled,
"multicast_available": multicast_detector.is_available(),
}
@router.put("/strategy")
async def update_strategy(body: StrategyUpdate, _: bool = Depends(require_auth)):
if body.strategy in ("round-robin", "weighted", "least-conn", "subnet"):
settings.load_balance_strategy = body.strategy
if body.multicast_enabled is not None:
settings.multicast_enabled = body.multicast_enabled
# Persisted for the same reason as the brand: in-memory only meant a restart
# silently restored the defaults.
save_settings()
return {"ok": True}
@router.put("/{node_id}")
async def update_node(node_id: str, body: NodeUpdate, _: bool = Depends(require_auth)):
found = False
for n in settings.edge_nodes:
if n["id"] == node_id:
if body.weight is not None:
n["weight"] = body.weight
if body.enabled is not None:
n["enabled"] = body.enabled
if body.subnet is not None:
n["subnet"] = body.subnet
if body.name is not None:
name = body.name.strip()
if not name:
raise HTTPException(status_code=422, detail="Node name cannot be empty")
n["name"] = name
found = True
break
if not found:
raise HTTPException(status_code=404, detail="Node not found")
save_nodes()
return {"ok": True}
@router.post("/register")
async def register_node(body: NodeRegister):
for n in settings.edge_nodes:
if n["id"] == body.node_id:
n.update({
"host": body.host,
"hls_port": body.hls_port,
"api_port": body.api_port,
"weight": body.weight,
"subnet": body.subnet,
})
save_nodes()
return {"ok": True, "action": "updated"}
settings.edge_nodes.append({
"id": body.node_id,
"name": body.name,
"host": body.host,
"hls_port": body.hls_port,
"api_port": body.api_port,
"weight": body.weight,
"enabled": True,
"subnet": body.subnet,
"is_origin": False,
})
save_nodes()
return {"ok": True, "action": "registered"}
@router.post("/heartbeat")
async def node_heartbeat(body: NodeHeartbeat):
lb.report_health(body.node_id, {
"cpu_percent": body.cpu_percent,
"memory_percent": body.memory_percent,
"bandwidth_mbps": body.bandwidth_mbps,
"active_connections": body.active_connections,
"hls_readers": body.hls_readers,
"network_in_mbps": body.network_in_mbps,
"network_out_mbps": body.network_out_mbps,
"viewer_count": body.viewer_count,
"frame_drop_indicators": body.frame_drop_indicators,
"packet_loss_indicators": body.packet_loss_indicators,
"media_metrics_available": body.media_metrics_available,
})
return {"ok": True}
@router.get("/health")
async def get_all_health():
return lb.get_health_summary()