feat: consolidate on the shadcn frontend and harden playback
Frontend
- Replace the Ant Design app with the shadcn/ui app as the single frontend,
keeping the portfolio side-by-side revertible in this commit.
- Drop the abandoned mdui experiment and the AntD tree so there is one app,
one router and one design system to maintain.
Live playback
- Stop racing the encoder. lowLatencyMode + liveSyncDurationCount:1 targeted a
single 4s segment, so playback ran at up to 1.10x and the forward buffer
bottomed out at 0.1s, re-buffering every segment. Measured over 40s:
buffer min 0.1s -> 8.9s, rate 1.00 constant, 0 stalls.
- Remove the 落后 labels and the page-level edge-snapping timer that fought
hls.js over two different definitions of "latency".
- Live can no longer be paused: the control is not rendered while it plays, so
the icon can never misreport state.
- Fix autoplay: muted autoplay started silently and never unmuted, and the
gesture that restored sound also toggled playback off. One owner now.
Source ("原画") rendition
- Publish the publisher's stream remuxed losslessly via -c:v copy alongside the
transcoded ones, so a source option exists even when every ladder entry
downsizes.
- Give renditions an ASCII path slug: the HLS path is part of the URL and
MediaMTX rejects non-alphanumeric paths, so 原画 could never be served.
Backend
- Supervise MediaMTX from the backend: start it at boot and restart it on exit,
never killing an origin it did not start.
- Clear stale HLS output before restarting an encoder; an ENDLIST-less playlist
left by a dead encoder hangs hls.js forever.
- Mark JSON timestamps as UTC. utcnow().isoformat() emitted a naive string that
JS read as local time, so every viewer heartbeat looked 8 hours old in UTC+8
and the live board's active-viewer count was permanently zero.
- Rebind a viewer session when it arrives on another stream instead of failing,
and survive the StrictMode double-heartbeat race on first insert.
- Replace the Linux-only /proc and os.getloadavg metrics with psutil.
- Complete MediaMTX's cookieCheck redirect inside /hls proxying, and hold the
httpx client open for the streamed body.
Housekeeping
- Ignore RainOSS (it stores accessKeySecret), runtime output, TLS keys and
.venv; remove generated screenshots, load test scripts and stray files.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
1 parent
186e32caf1
commit
996e3fea39
70 files changed
+4844
-3070
No files matched your search
@@ -2,6 +2,7 @@ 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
|
||||
@@ -88,33 +89,27 @@ async def update_transcode_profiles(
|
||||
|
||||
@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)
|
||||
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)
|
||||
cpu_count = os.cpu_count() or 1
|
||||
network_in_bytes = 0
|
||||
network_out_bytes = 0
|
||||
netdev = Path("/proc/net/dev")
|
||||
if netdev.exists():
|
||||
for line in netdev.read_text(encoding="ascii").splitlines()[2:]:
|
||||
_, _, counters = line.partition(":")
|
||||
fields = counters.split()
|
||||
if len(fields) >= 9:
|
||||
network_in_bytes += int(fields[0])
|
||||
network_out_bytes += int(fields[8])
|
||||
|
||||
return {
|
||||
"cpu": {
|
||||
@@ -122,12 +117,13 @@ async def get_system_metrics(_: bool = Depends(require_auth)):
|
||||
"load_1m": load_1m,
|
||||
"load_5m": load_5m,
|
||||
"load_15m": load_15m,
|
||||
"load_percent": round(min(100, load_1m / cpu_count * 100), 1),
|
||||
"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,
|
||||
"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,
|
||||
@@ -135,7 +131,7 @@ async def get_system_metrics(_: bool = Depends(require_auth)):
|
||||
"used_percent": round(disk.used / disk.total * 100, 1) if disk.total else 0,
|
||||
},
|
||||
"network": {
|
||||
"bytes_recv": network_in_bytes,
|
||||
"bytes_sent": network_out_bytes,
|
||||
"bytes_recv": network.bytes_recv,
|
||||
"bytes_sent": network.bytes_sent,
|
||||
},
|
||||
}
|
||||
@@ -9,10 +9,11 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from database import get_db
|
||||
from database import get_db, session_factory
|
||||
from routes.auth import require_auth
|
||||
from services.stream_service import StreamService
|
||||
from services.media_service import media_service
|
||||
@@ -121,6 +122,21 @@ def _valid_replay_url(url: str | None) -> str | None:
|
||||
return url
|
||||
|
||||
|
||||
def _utc_iso(moment: datetime) -> str:
|
||||
"""Render a stored timestamp as an explicit UTC instant for JSON.
|
||||
|
||||
Rows are written with datetime.utcnow(), so the value is UTC but carries no
|
||||
tzinfo. `isoformat()` therefore emits "2026-09-22T07:47:11" with no offset,
|
||||
and JavaScript's Date() reads that as *local* time — in UTC+8 every heartbeat
|
||||
looked eight hours old and the live board's "active viewer" filter discarded
|
||||
every session, so the count was permanently zero. The trailing Z is what makes
|
||||
the client parse it as UTC.
|
||||
"""
|
||||
if moment is None:
|
||||
return None
|
||||
return moment.replace(microsecond=0).isoformat() + "Z"
|
||||
|
||||
|
||||
def _status_payload(stream) -> dict:
|
||||
return {
|
||||
"stream_id": stream.id,
|
||||
@@ -250,10 +266,33 @@ async def viewer_heartbeat(stream_id: int, body: ViewerHeartbeat,
|
||||
raise HTTPException(status_code=422, detail="观众会话 ID 无效")
|
||||
viewer = await db.get(ViewerSession, session_id)
|
||||
if viewer and viewer.stream_id != stream_id:
|
||||
raise HTTPException(status_code=409, detail="观众会话已用于其他节目")
|
||||
# A session id lives in the viewer's browser, not per programme, so anyone
|
||||
# who switches streams arrives still bound to the one they started on.
|
||||
# Re-bind them rather than 409: "this session is watching something else"
|
||||
# is not a conflict a viewer can act on, and leaving it stuck meant every
|
||||
# later heartbeat and command poll for this viewer failed for good.
|
||||
viewer.stream_id = stream_id
|
||||
viewer.remote_adjusted = False
|
||||
if not viewer:
|
||||
viewer = ViewerSession(session_id=session_id, stream_id=stream_id)
|
||||
db.add(viewer)
|
||||
try:
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
# A first heartbeat can be sent twice in quick succession (React
|
||||
# StrictMode runs mount effects twice in development), and both
|
||||
# requests can miss the lookup above before either commits. The loser
|
||||
# adopts the winner's row instead of returning a 500.
|
||||
#
|
||||
# Recovery runs on its own session: this one is now in a failed
|
||||
# transaction, and a session that is still dirty at teardown makes
|
||||
# get_db commit on exit and re-raise the very IntegrityError we just
|
||||
# handled.
|
||||
async with session_factory() as recovery:
|
||||
viewer = await recovery.get(ViewerSession, session_id)
|
||||
if not viewer:
|
||||
raise HTTPException(status_code=409, detail="观众会话创建失败,请刷新页面")
|
||||
return {"ok": True, "remote_adjusted": viewer.remote_adjusted}
|
||||
viewer.node_id = body.node_id
|
||||
viewer.node_name = body.node_name
|
||||
viewer.quality = body.quality
|
||||
@@ -282,7 +321,7 @@ async def list_viewer_sessions(stream_id: int, db: AsyncSession = Depends(get_db
|
||||
"buffered_seconds": viewer.buffered_seconds,
|
||||
"error": viewer.error,
|
||||
"remote_adjusted": viewer.remote_adjusted,
|
||||
"last_heartbeat": viewer.last_heartbeat.isoformat(),
|
||||
"last_heartbeat": _utc_iso(viewer.last_heartbeat),
|
||||
} for viewer in viewers]
|
||||
|
||||
|
||||
@@ -300,8 +339,8 @@ async def control_audit(stream_id: int, db: AsyncSession = Depends(get_db),
|
||||
"value": command.value,
|
||||
"status": command.status,
|
||||
"result": command.result,
|
||||
"created_at": command.created_at.isoformat(),
|
||||
"claimed_at": command.claimed_at.isoformat() if command.claimed_at else None,
|
||||
"created_at": _utc_iso(command.created_at),
|
||||
"claimed_at": _utc_iso(command.claimed_at),
|
||||
} for command in commands]
|
||||
|
||||
|
||||
@@ -552,8 +591,17 @@ async def send_remote_command(stream_id: int, body: RemoteCommand,
|
||||
async def poll_remote_command(stream_id: int, session_id: str,
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
viewer = await db.get(ViewerSession, session_id)
|
||||
if not viewer or viewer.stream_id != stream_id:
|
||||
if not viewer:
|
||||
raise HTTPException(status_code=404, detail="观众会话不存在")
|
||||
if viewer.stream_id != stream_id:
|
||||
# A session id is stored per browser, not per programme, so a viewer who
|
||||
# switches streams arrives with the id they registered on the old one.
|
||||
# Remote control is meaningless if that 404s forever, so re-bind them
|
||||
# instead — they are watching this stream now, which is the only fact the
|
||||
# admin panel needs. Commands aimed at the old stream are not carried over.
|
||||
viewer.stream_id = stream_id
|
||||
viewer.remote_adjusted = False
|
||||
await db.commit()
|
||||
command = (await db.execute(select(ControlCommand).where(
|
||||
ControlCommand.stream_id == stream_id,
|
||||
ControlCommand.target_session_id == session_id,
|
||||
@@ -566,7 +614,7 @@ async def poll_remote_command(stream_id: int, session_id: str,
|
||||
viewer.remote_adjusted = command.action != "restore_auto"
|
||||
await db.commit()
|
||||
return {"id": command.id, "action": command.action, "value": command.value,
|
||||
"created_at": command.created_at.isoformat()}
|
||||
"created_at": _utc_iso(command.created_at)}
|
||||
|
||||
|
||||
@router.post("/{stream_id}/go-live")
|
||||
|
||||
Reference in new issue
Block a user