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:
adminandClaude Opus 4.8 committed 2026-09-22 18:07:56 +08:00
1 parent 186e32caf1
commit 996e3fea39
70 files changed
+4844 -3070

No files matched your search

+55 -7
View File
@@ -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")