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
+194
-41
@@ -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"),
|
||||
)
|
||||
Reference in new issue
Block a user