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

+194 -41
View File
@@ -8,11 +8,13 @@ from starlette.background import BackgroundTask
import httpx
import asyncio
import time
import traceback
import psutil
from database import init_db, session_factory
from services.transcoder import transcode_mgr
from services.stream_service import StreamService
from services.media_service import media_service
from services.mediamtx_manager import mediamtx_mgr
from models.stream import StreamStatus
from routes import streams_router, nodes_router, auth_router
from routes.settings_route import router as settings_router
@@ -21,42 +23,124 @@ from services.load_balancer import lb
from services.node_store import load_nodes
async def _origin_paths() -> dict[str, dict]:
"""Fetch MediaMTX path list keyed by path name. Empty dict when origin is unreachable."""
try:
async with httpx.AsyncClient(timeout=3) as client:
response = await client.get(
f"http://127.0.0.1:{settings.media_origin_api_port}/v3/paths/list"
)
response.raise_for_status()
return {item.get("name"): item for item in response.json().get("items", [])}
except Exception:
return {}
async def supervise_transcoders() -> list[str]:
"""Restart transcoders that died or drifted from the stream's config.
A live stream whose upstream is publishing (MediaMTX path ready) but whose
local ffmpeg processes have exited would otherwise stay black forever: the
HLS output stops updating and nothing re-triggers a start.
Returns the stream keys that were (re)started this tick.
"""
restarted: list[str] = []
paths = await _origin_paths()
if not paths:
return restarted
async with session_factory() as db:
service = StreamService(db)
for stream in await service.get_all_streams():
if stream.status != StreamStatus.live:
continue
path = paths.get(stream.stream_key) or paths.get(f"live/{stream.stream_key}")
# Only act when the publisher is actually connected: restarting against
# a missing source just burns ffmpeg processes that exit immediately.
if not path or not path.get("ready"):
continue
# Must match what start_transcode is given below: needs_restart
# compares against the signature recorded at the last start, so
# passing a different list here would restart the encoders every tick.
qualities = service.transcode_renditions(stream)
if transcode_mgr.needs_restart(stream.stream_key, qualities):
transcode_mgr.start_transcode(
stream.stream_key,
media_service.get_rtmp_url(stream.stream_key),
qualities,
)
restarted.append(stream.stream_key)
return restarted
async def _metrics_tick(previous, previous_time):
current = psutil.net_io_counters()
current_time = time.monotonic()
elapsed = current_time - previous_time
network_in = ((current.bytes_recv - previous.bytes_recv) * 8 / elapsed / 1e6) if elapsed > 0 else None
network_out = ((current.bytes_sent - previous.bytes_sent) * 8 / elapsed / 1e6) if elapsed > 0 else None
viewer_count = None
try:
async with httpx.AsyncClient(timeout=3) as client:
response = await client.get(f"http://127.0.0.1:{settings.media_origin_api_port}/v3/paths/list")
response.raise_for_status()
payload = response.json()
# MediaMTX reports readers as an array of reader objects; there is no
# readersCount field in the version this project ships. Summing the
# array directly raised TypeError as soon as anyone was watching,
# which the bare except below turned into a permanent "no viewers".
viewer_count = sum(len(item.get("readers") or []) for item in payload.get("items", []))
except Exception:
pass
lb.report_health("default", {
"cpu_percent": psutil.cpu_percent(),
"memory_percent": psutil.virtual_memory().percent,
"bandwidth_mbps": network_in + network_out if network_in is not None and network_out is not None else None,
"network_in_mbps": network_in,
"network_out_mbps": network_out,
"active_connections": len(psutil.net_connections(kind="tcp")),
"hls_readers": viewer_count,
"viewer_count": viewer_count,
"frame_drop_indicators": None,
"packet_loss_indicators": None,
"media_metrics_available": False,
})
return current, current_time
async def monitor_origin():
"""Background supervisor loop.
Every step is individually guarded: this task is created with create_task, so
an exception escaping it would silently kill supervision forever with no
traceback reaching the logs. Each phase catches its own errors instead.
"""
previous = psutil.net_io_counters()
previous_time = time.monotonic()
while True:
await asyncio.sleep(10)
current = psutil.net_io_counters()
current_time = time.monotonic()
elapsed = current_time - previous_time
network_in = ((current.bytes_recv - previous.bytes_recv) * 8 / elapsed / 1e6) if elapsed > 0 else None
network_out = ((current.bytes_sent - previous.bytes_sent) * 8 / elapsed / 1e6) if elapsed > 0 else None
media_available = False
viewer_count = None
try:
async with httpx.AsyncClient(timeout=3) as client:
response = await client.get(f"http://127.0.0.1:{settings.media_origin_api_port}/v3/paths/list")
response.raise_for_status()
payload = response.json()
viewer_count = sum(item.get("readers", 0) for item in payload.get("items", []))
media_available = True
previous, previous_time = await _metrics_tick(previous, previous_time)
except Exception:
pass
lb.report_health("default", {
"cpu_percent": psutil.cpu_percent(),
"memory_percent": psutil.virtual_memory().percent,
"bandwidth_mbps": network_in + network_out if network_in is not None and network_out is not None else None,
"network_in_mbps": network_in,
"network_out_mbps": network_out,
"active_connections": len(psutil.net_connections(kind="tcp")),
"hls_readers": viewer_count,
"viewer_count": viewer_count,
"frame_drop_indicators": None,
"packet_loss_indicators": None,
"media_metrics_available": False,
})
previous = current
previous_time = current_time
traceback.print_exc()
try:
# MediaMTX first: the transcoder pass below only acts on a stream whose
# MediaMTX path is ready, so a dead origin would otherwise be reported
# as "nothing to do" forever.
if await mediamtx_mgr.ensure_running():
await mediamtx_mgr.wait_until_ready()
except asyncio.CancelledError:
raise
except Exception:
traceback.print_exc()
try:
restarted = await supervise_transcoders()
for stream_key in restarted:
print(f"[supervisor] restarted transcoder for {stream_key}", flush=True)
except asyncio.CancelledError:
raise
except Exception:
traceback.print_exc()
@asynccontextmanager
@@ -64,6 +148,12 @@ async def lifespan(app: FastAPI):
load_nodes()
await init_db()
os.makedirs(settings.hls_runtime_dir, exist_ok=True)
# Bring the media origin up before the transcoders: they immediately open RTMP
# connections to it, and against a dead origin every ffmpeg exits at once.
if settings.media_origin_autostart and not await mediamtx_mgr.is_healthy():
mediamtx_mgr.start()
if not await mediamtx_mgr.wait_until_ready():
print("[supervisor] MediaMTX not ready yet; it will be retried", flush=True)
async with session_factory() as db:
service = StreamService(db)
for stream in await service.get_all_streams():
@@ -71,12 +161,13 @@ async def lifespan(app: FastAPI):
transcode_mgr.start_transcode(
stream.stream_key,
media_service.get_rtmp_url(stream.stream_key),
service.effective_renditions(stream),
service.transcode_renditions(stream),
)
origin_monitor = asyncio.create_task(monitor_origin())
yield
origin_monitor.cancel()
transcode_mgr.stop_all()
mediamtx_mgr.stop()
app = FastAPI(title="ProgramCore", lifespan=lifespan)
@@ -112,16 +203,78 @@ async def serve_hls(path: str):
return FileResponse(fs_path)
target = f"http://127.0.0.1:{settings.media_origin_hls_port}/{path}"
# MediaMTX gates HLS behind a "cookieCheck" round trip: it answers with a 302 to
# "<path>?cookieCheck=1" and sets a *Secure* cookie. Over plain HTTP (any
# non-TLS deployment, and every dev machine) browsers refuse to store a Secure
# cookie, so the redirect never terminates and the player loops until it gives
# up. The browser should never see that handshake at all, so complete it here
# and hand back only the final playlist.
#
# The client also has to outlive this function: the body is streamed after we
# return, so an `async with` would close the pool before the first chunk.
client = httpx.AsyncClient(timeout=httpx.Timeout(10), follow_redirects=True)
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:
req = client.build_request("GET", target)
resp = await client.send(req, stream=True)
from starlette.responses import StreamingResponse
return StreamingResponse(
resp.aiter_bytes(),
status_code=resp.status_code,
headers=dict(resp.headers),
media_type=resp.headers.get("content-type"),
)
request = client.build_request("GET", target)
response = await client.send(request, stream=True)
except Exception:
raise HTTPException(status_code=404)
await client.aclose()
raise HTTPException(status_code=404)
from starlette.responses import StreamingResponse
headers = {
key: value
for key, value in response.headers.items()
# Length/transfer headers describe the origin's framing, not ours, and
# hop-by-hop headers must not be relayed.
if key.lower() not in {"content-length", "transfer-encoding", "connection"}
}
async def relay():
try:
async for chunk in response.aiter_bytes():
yield chunk
finally:
await response.aclose()
await client.aclose()
return StreamingResponse(
relay(),
status_code=response.status_code,
headers=headers,
media_type=response.headers.get("content-type"),
)
from starlette.responses import StreamingResponse
headers = {
key: value
for key, value in response.headers.items()
# Length/transfer headers describe the origin's framing, not ours, and
# hop-by-hop headers must not be relayed.
if key.lower() not in {"content-length", "transfer-encoding", "connection"}
}
# MediaMTX answers HLS requests with a 302 to "<path>?cookieCheck=1" so the
# player picks up its session cookie. That Location is relative to the origin
# root, so relaying it unchanged sends the browser to /<path> on the backend
# host — not an /hls route, hence a 404 instead of a playlist. Re-anchor it.
location = response.headers.get("location")
if response.is_redirect and location:
parts = urlsplit(location)
if parts.path:
headers["location"] = f"/hls{parts.path}" + (f"?{parts.query}" if parts.query else "")
async def relay():
try:
async for chunk in response.aiter_bytes():
yield chunk
finally:
await response.aclose()
await client.aclose()
return StreamingResponse(
relay(),
status_code=response.status_code,
headers=headers,
media_type=response.headers.get("content-type"),
)