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

+158
View File
@@ -0,0 +1,158 @@
import asyncio
import os
import shutil
import subprocess
import httpx
from config import settings
def _log(message: str) -> None:
print(f"[mediamtx] {message}", flush=True)
def resolve_binary(configured: str) -> str | None:
"""Find the MediaMTX executable: the configured name on PATH, else the repo copy.
The repository ships both an ELF `mediamtx` and a Windows `mediamtx.exe`, so a
bare-name fallback has to prefer the platform's own extension first — picking
the other one fails at exec time with "not a valid Win32 application".
"""
found = shutil.which(configured)
if found:
return found
if os.path.dirname(configured):
return configured if os.path.isfile(configured) else None
repo_dir = os.path.dirname(os.path.abspath(settings.media_origin_config))
candidates = [f"{configured}.exe", configured] if os.name == "nt" else [configured, f"{configured}.exe"]
for candidate in candidates:
path = os.path.join(repo_dir, candidate)
if os.path.isfile(path):
return path
return None
class MediaMTXManager:
"""Keeps the MediaMTX origin alive.
The backend owns the media origin: it is the only thing that can tell whether
the origin is actually serving (its own /v3 API), and RTMP ingest is dead
without it while transcoders keep burning CPU against a missing source. When
an external supervisor already runs MediaMTX, the health probe below sees it
and this manager stays out of the way.
Only the process this manager started is ever stopped, so an externally
managed origin is never killed on shutdown.
"""
def __init__(self) -> None:
self._process: subprocess.Popen | None = None
self._failures = 0
self._binary_missing_logged = False
async def is_healthy(self) -> bool:
"""True when the origin API answers, regardless of who started it."""
try:
async with httpx.AsyncClient(timeout=2) as client:
response = await client.get(
f"http://{settings.media_origin_host}:{settings.media_origin_api_port}/v3/paths/list"
)
return response.status_code == 200
except Exception:
return False
def _started_process_alive(self) -> bool:
return self._process is not None and self._process.poll() is None
def start(self) -> None:
"""Launch MediaMTX unless one is already running. Never raises."""
if self._process is not None:
# A process we started exited; clear it so the next tick can retry.
if self._process.poll() is not None:
_log(f"process exited with code {self._process.returncode}")
self._process = None
else:
return
binary = resolve_binary(settings.media_origin_binary)
if binary is None:
# No MediaMTX anywhere is a deployment gap, not a crash: the API still
# serves, the admin UI can explain it, and RTMP ingest is simply down.
if not self._binary_missing_logged:
_log(f"'{settings.media_origin_binary}' not found on PATH or in mediamtx/; RTMP ingest unavailable")
self._binary_missing_logged = True
return
config_path = settings.media_origin_config
command = [binary]
if config_path and os.path.isfile(config_path):
command.append(config_path)
else:
_log(f"config not found at {config_path}; running with defaults")
config_path = ""
try:
self._process = subprocess.Popen(
command,
cwd=os.path.dirname(os.path.abspath(config_path)) if config_path else None,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
if not self._binary_missing_logged:
_log(f"failed to launch {binary}; RTMP ingest unavailable")
self._binary_missing_logged = True
self._process = None
except Exception as error: # pragma: no cover - defensive
_log(f"failed to start: {error!r}")
self._process = None
async def ensure_running(self) -> bool:
"""Start the origin if it is neither healthy nor already running.
Returns True when this call started it.
"""
if not settings.media_origin_autostart:
return False
if await self.is_healthy() or self._started_process_alive():
self._failures = 0
return False
# Back off after repeated failures so a broken binary cannot be relaunched
# every supervisor tick, which would spam processes and logs.
if self._failures:
self._failures -= 1
return False
self.start()
if self._process is None:
self._failures = 12
return False
self._failures = 0
_log(f"started (pid {self._process.pid})")
return True
async def wait_until_ready(self, timeout: float = 15.0) -> bool:
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
if await self.is_healthy():
return True
await asyncio.sleep(0.5)
return False
def stop(self) -> None:
"""Stop only the process this manager started."""
process, self._process = self._process, None
if process is None or process.poll() is not None:
return
try:
process.terminate()
process.wait(timeout=5)
except Exception:
try:
process.kill()
except Exception:
pass
mediamtx_mgr = MediaMTXManager()