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
@@ -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