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

+20
View File
@@ -16,6 +16,15 @@ class Settings(BaseSettings):
media_origin_rtmp_port: int = 1935
media_origin_api_port: int = 9997
media_origin_hls_port: int = 8888
# MediaMTX is supervised by the backend: started at boot if the origin is not
# already answering, and restarted if it dies. Set LS_MEDIA_ORIGIN_AUTOSTART=false
# to manage it externally (e.g. systemd, or an edge node's own launcher).
media_origin_autostart: bool = True
# "mediamtx" resolves via PATH; the repo-local copies are the fallback so a
# checkout works without a system-wide install. On Windows the binary sits in
# mediamtx\, on Linux/macOS it is usually a bare `mediamtx` on PATH.
media_origin_binary: str = "mediamtx"
media_origin_config: str = str(APP_DIR.parent / "mediamtx" / "mediamtx.yml")
replay_media_dir: str = str(APP_DIR.parent / "media")
hls_runtime_dir: str = str(APP_DIR.parent / "runtime" / "hls")
hls_segment_seconds: int = 4
@@ -43,6 +52,17 @@ class Settings(BaseSettings):
{"label": "360p", "width": 640, "height": 360, "bitrate": "600k"},
]
# Extra rendition that is published alongside the configured ones: the
# publisher's own video+audio stream remuxed into HLS without re-encoding.
# It costs almost no CPU (stream copy) and gives viewers a lossless
# "原画" (source) option even when every configured rendition downsizes.
source_rendition_label: str = "原画"
source_rendition_enabled: bool = True
# On-disk/URL name for that rendition. It IS the HLS path segment, and MediaMTX
# rejects any path containing non-alphanumeric characters, so the Chinese
# display label above cannot be used here — a CI would 500 trying to serve it.
source_rendition_slug: str = "source"
transcode_profiles: dict[str, list[dict]] = {
"standard": qualities,
"bandwidth-saver": [
+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"),
)
+25 -29
View File
@@ -2,6 +2,7 @@ import os
import shutil
from pathlib import Path
import psutil
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import Optional
@@ -88,33 +89,27 @@ async def update_transcode_profiles(
@router.get("/system-metrics")
async def get_system_metrics(_: bool = Depends(require_auth)):
load_1m, load_5m, load_15m = os.getloadavg()
memory_total = 0
memory_available = 0
meminfo = Path("/proc/meminfo")
if meminfo.exists():
values = {}
for line in meminfo.read_text(encoding="ascii").splitlines():
key, _, value = line.partition(":")
if value:
values[key] = int(value.strip().split()[0]) * 1024
memory_total = values.get("MemTotal", 0)
memory_available = values.get("MemAvailable", 0)
cpu_count = os.cpu_count() or 1
# There is no load average on Windows: os.getloadavg does not exist there, and
# psutil's emulation reads as 0.0 until its background sampler warms up (and
# never means quite the same thing). Report real CPU utilisation instead, so
# the dashboard's "CPU 使用率" card is not permanently stuck at zero.
if hasattr(os, "getloadavg"):
load_1m, load_5m, load_15m = os.getloadavg()
load_percent = round(min(100, load_1m / cpu_count * 100), 1)
else:
load_1m = load_5m = load_15m = None
# interval=None never blocks; it measures since the previous call anywhere
# in the process, which the supervisor's 10s metrics tick keeps fresh.
load_percent = round(psutil.cpu_percent(), 1)
memory = psutil.virtual_memory()
network = psutil.net_io_counters()
metrics_path = Path(settings.replay_media_dir).resolve()
disk_path = metrics_path if metrics_path.exists() else Path.cwd()
disk = shutil.disk_usage(disk_path)
cpu_count = os.cpu_count() or 1
network_in_bytes = 0
network_out_bytes = 0
netdev = Path("/proc/net/dev")
if netdev.exists():
for line in netdev.read_text(encoding="ascii").splitlines()[2:]:
_, _, counters = line.partition(":")
fields = counters.split()
if len(fields) >= 9:
network_in_bytes += int(fields[0])
network_out_bytes += int(fields[8])
return {
"cpu": {
@@ -122,12 +117,13 @@ async def get_system_metrics(_: bool = Depends(require_auth)):
"load_1m": load_1m,
"load_5m": load_5m,
"load_15m": load_15m,
"load_percent": round(min(100, load_1m / cpu_count * 100), 1),
"load_percent": load_percent,
"percent": load_percent,
},
"memory": {
"total": memory_total,
"available": memory_available,
"used_percent": round((1 - memory_available / memory_total) * 100, 1) if memory_total else 0,
"total": memory.total,
"available": memory.available,
"used_percent": round((1 - memory.available / memory.total) * 100, 1) if memory.total else 0,
},
"disk": {
"total": disk.total,
@@ -135,7 +131,7 @@ async def get_system_metrics(_: bool = Depends(require_auth)):
"used_percent": round(disk.used / disk.total * 100, 1) if disk.total else 0,
},
"network": {
"bytes_recv": network_in_bytes,
"bytes_sent": network_out_bytes,
"bytes_recv": network.bytes_recv,
"bytes_sent": network.bytes_sent,
},
}
+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")
+10 -2
View File
@@ -22,8 +22,16 @@ class MediaMTXService:
if resp.status_code == 200:
data = resp.json()
for item in data.get("items", []):
if item.get("name") == stream_key:
return {"ready": item.get("ready", False), "source_ready": item.get("sourceReady", False)}
# Paths are named after the RTMP URL key, so a publisher on
# rtmp://host/live/<key> shows up as "live/<key>", never as
# the bare key. This MediaMTX exposes the reader list itself
# rather than a readersCount/sourceReady pair.
if item.get("name") in (stream_key, f"live/{stream_key}"):
return {
"ready": item.get("ready", False),
"source_ready": bool(item.get("source")),
"readers": len(item.get("readers") or []),
}
except Exception:
return None
return None
+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()
+73 -5
View File
@@ -7,9 +7,24 @@ from services.multicast_detector import multicast_detector
from services.transcoder import transcode_mgr
from config import settings
import json
import re
from datetime import datetime
# MediaMTX rejects any HLS path that is not alphanumeric ("invalid path name: can
# contain only alphanumeric characters"), and the label is also used as a directory
# name, so every rendition needs a path-safe slug of its own.
def hls_path_slug(label: str) -> str:
"""Filesystem- and MediaMTX-safe rendition path for a display label."""
slug = re.sub(r"[^A-Za-z0-9]", "", label or "")
return slug or "rendition"
def with_hls_path(rendition: dict) -> dict:
"""Attach the `path` an admin-configured rendition is stored and served under."""
return {**rendition, "path": hls_path_slug(rendition.get("label", ""))}
class StreamService:
def __init__(self, db: AsyncSession):
self.db = db
@@ -25,6 +40,56 @@ class StreamService:
pass
return settings.transcode_profiles.get(stream.transcode_profile, settings.qualities)
@staticmethod
def source_label() -> str:
"""Display name of the stream-copy rendition, or '' when it is disabled.
A blank label would leak into the HLS path and produce a URL like
/hls/<key>//index.m3u8, so it degrades to "off" instead.
"""
return settings.source_rendition_label.strip() if settings.source_rendition_enabled else ""
@classmethod
def source_slug(cls) -> str:
"""Path segment for that rendition, safe for MediaMTX and the filesystem."""
if not cls.source_label():
return ""
return hls_path_slug(settings.source_rendition_slug)
def watch_renditions(self, stream: Stream) -> list:
"""Renditions a viewer may pick: the stream-copy rendition first, then the configured ones.
Each entry carries both the label shown to viewers and the `path` used in
HLS URLs — MediaMTX only accepts alphanumeric HLS paths, so a Chinese label
such as "原画" cannot double as a directory name.
The source entry carries no size: the input resolution is only known to
ffmpeg, so the player measures it from the media it ends up decoding.
"""
renditions = [with_hls_path(r) for r in self.effective_renditions(stream)]
label, slug = self.source_label(), self.source_slug()
if not label or not slug or not renditions or renditions[0]["path"] == slug:
return renditions
return [{"label": label, "path": slug, "width": None, "height": None, "copy": True}] + renditions
def transcode_renditions(self, stream: Stream) -> list:
"""Renditions to hand to the transcoder, including the stream-copy source rendition.
Carries the same `path` values as watch_renditions so the files ffmpeg
writes are the files the player asks for.
The copy entry is the top rendition with its label replaced, so it carries
every key the transcoder reads. ffmpeg ignores those numbers for a stream
copy, but the supervisor in main.py compares TranscodeManager's recorded
signature against this list every tick — an entry missing a field would
both crash the start and make the signature drift.
"""
renditions = [with_hls_path(r) for r in self.effective_renditions(stream)]
label, slug = self.source_label(), self.source_slug()
if not label or not slug or not renditions or renditions[0]["path"] == slug:
return renditions
return [{**renditions[0], "label": label, "path": slug, "copy": True}] + renditions
async def create_stream(self, title: str, description: str | None = None,
cover_url: str | None = None,
scheduled_at: str | None = None,
@@ -32,6 +97,7 @@ class StreamService:
replay_url: str | None = None,
chapters: list[dict] | None = None,
transcode_profile: str = "standard",
renditions_json: str | None = None,
homepage_visible: bool = True,
remote_control_enabled: bool = False) -> Stream:
import uuid
@@ -49,6 +115,7 @@ class StreamService:
replay_url=replay_url,
chapters_json=json.dumps(chapters or [], ensure_ascii=False),
transcode_profile=transcode_profile,
renditions_json=renditions_json,
homepage_visible=homepage_visible,
remote_control_enabled=remote_control_enabled,
stream_key=uuid.uuid4().hex,
@@ -108,7 +175,7 @@ class StreamService:
started_at=datetime.utcnow(),
)
rtmp_url = media_service.get_rtmp_url(stream_key)
transcode_mgr.start_transcode(stream_key, rtmp_url, self.effective_renditions(stream))
transcode_mgr.start_transcode(stream_key, rtmp_url, self.transcode_renditions(stream))
async def set_stream_ended(self, stream_key: str):
stream = await self.get_stream_by_key(stream_key)
@@ -125,7 +192,7 @@ class StreamService:
stream = await self.get_stream_by_key(stream_key)
if not stream:
return {"lines": [], "qualities": [], "multicast_available": False, "selected_node": None}
qualities = self.effective_renditions(stream)
qualities = self.watch_renditions(stream)
best_node = lb.select_best_node(client_ip)
if not best_node:
best_node = lb.get_enabled_nodes()[0] if lb.get_enabled_nodes() else None
@@ -142,12 +209,13 @@ class StreamService:
"qualities": [],
}
for quality in qualities:
label = quality["label"]
# The URL segment is the rendition's path slug, not its label:
# labels are display text and may be Chinese.
line_info["qualities"].append({
"label": label,
"label": quality["label"],
"width": quality["width"],
"height": quality["height"],
"url": f"{base_hls}/{label}/index.m3u8",
"url": f"{base_hls}/{quality['path']}/index.m3u8",
})
lines.append(line_info)
+97 -15
View File
@@ -5,15 +5,67 @@ from config import settings
class TranscodeManager:
_processes: dict[str, list[subprocess.Popen]] = {}
# Signature of the last successful start, so a supervisor can tell whether a
# running set of processes still matches the stream's current configuration.
_signatures: dict[str, str] = {}
_failed_starts: dict[str, int] = {}
@staticmethod
def _signature(qualities: list[dict]) -> str:
return "|".join(
f"{q.get('label')}:{q.get('width')}x{q.get('height')}:{q.get('bitrate')}:"
f"{'copy' if q.get('copy') else q.get('codec', 'libx264')}"
for q in qualities
)
def is_running(self, stream_key: str) -> bool:
"""True when at least one transcode process exists and none have exited."""
procs = self._processes.get(stream_key)
if not procs:
return False
return any(proc.poll() is None for proc in procs)
def needs_restart(self, stream_key: str, qualities: list[dict]) -> bool:
if not qualities:
return False
return self._signatures.get(stream_key) != self._signature(qualities) or not self.is_running(stream_key)
def _clear_stale_output(self, hls_dir: str) -> None:
"""Drop the previous run's playlist and segments.
ffmpeg writes a live playlist with no #EXT-X-ENDLIST, so a playlist left
behind by an encoder that died looks to hls.js exactly like a stream that
simply stopped: it keeps reloading a file that will never change and
buffers forever. Removing it makes the player see a missing manifest
instead, which it retries until the supervisor has the encoder back up.
"""
if not os.path.isdir(hls_dir):
return
for name in os.listdir(hls_dir):
if name.endswith(".ts") or name.endswith(".m3u8") or name.endswith(".m3u8.tmp"):
try:
os.remove(os.path.join(hls_dir, name))
except OSError:
# A player holding the file open (Windows) is fine: it will be
# overwritten by the restarting encoder.
pass
def start_transcode(self, stream_key: str, input_url: str, qualities: list[dict]):
if stream_key in self._processes:
if stream_key in self._processes and self.is_running(stream_key):
return
self.stop_transcode(stream_key)
procs = []
segment_seconds = max(1, settings.hls_segment_seconds)
for quality in qualities:
label = quality["label"]
# The stream-copy ("原画") rendition carries the top rendition's numbers
# only so the signature stays complete; ffmpeg copies the input rather
# than encoding to them. See StreamService.transcode_renditions.
is_copy = bool(quality.get("copy"))
# Files land under the rendition's path slug, never its label: MediaMTX
# refuses non-alphanumeric HLS paths and the label may be Chinese.
path_slug = quality.get("path") or label
width = quality["width"]
height = quality["height"]
fps = quality.get("fps", 30)
@@ -24,20 +76,10 @@ class TranscodeManager:
audio_bitrate = quality.get("audio_bitrate", "128k")
keyframe_seconds = quality.get("keyframe_seconds", segment_seconds)
keyframe_frames = keyframe_seconds * fps
hls_dir = os.path.join(settings.hls_runtime_dir, stream_key, label)
hls_dir = os.path.join(settings.hls_runtime_dir, stream_key, path_slug)
os.makedirs(hls_dir, exist_ok=True)
cmd = [
"ffmpeg", "-y",
"-i", input_url,
"-c:v", codec, "-preset", preset,
"-r", str(fps), "-b:v", bitrate, "-crf", str(crf),
"-s", f"{width}x{height}",
"-sc_threshold", "0",
"-g", str(keyframe_frames),
"-keyint_min", str(keyframe_frames),
"-force_key_frames", f"expr:gte(t,n_forced*{keyframe_seconds})",
"-copyts", "-start_at_zero",
"-c:a", "aac", "-b:a", audio_bitrate,
self._clear_stale_output(hls_dir)
hls_output = [
"-f", "hls",
"-hls_time", str(segment_seconds),
"-hls_list_size", "6",
@@ -45,6 +87,44 @@ class TranscodeManager:
"-hls_segment_filename", os.path.join(hls_dir, "segment_%03d.ts"),
os.path.join(hls_dir, "index.m3u8"),
]
# NOTE: ffmpeg's -reconnect* flags are HTTP/HTTPS-only protocol
# options and make RTMP inputs fail with "Option not found".
# RTMP drop recovery is handled by the supervisor in main.py,
# which restarts exited encoders on its next tick.
hls_input = ["-rw_timeout", "10000000", "-i", input_url]
if is_copy:
# Source ("原画") rendition: keep the publisher's video as-is and only
# re-wrap it, so the output keeps the exact incoming resolution,
# frame rate and bitrate. Video copy costs no CPU; audio is re-encoded
# because MPEG-TS cannot carry every codec a publisher might send, and
# one AAC track is a rounding error next to the video encoders.
cmd = (
["ffmpeg", "-y"] + hls_input
+ [
"-c:v", "copy",
"-c:a", "aac", "-b:a", "192k",
"-copyts", "-start_at_zero",
"-map", "0:v:0", "-map", "0:a:0?",
]
+ hls_output
)
else:
cmd = (
["ffmpeg", "-y"] + hls_input
+ [
"-c:v", codec, "-preset", preset,
"-r", str(fps), "-b:v", bitrate, "-crf", str(crf),
"-s", f"{width}x{height}",
"-sc_threshold", "0",
"-g", str(keyframe_frames),
"-keyint_min", str(keyframe_frames),
"-force_key_frames", f"expr:gte(t,n_forced*{keyframe_seconds})",
"-copyts", "-start_at_zero",
"-c:a", "aac", "-b:a", audio_bitrate,
]
+ hls_output
)
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
@@ -54,6 +134,8 @@ class TranscodeManager:
pass
self._processes[stream_key] = procs
self._signatures[stream_key] = self._signature(qualities)
self._failed_starts[stream_key] = 0 if procs else self._failed_starts.get(stream_key, 0) + 1
def stop_transcode(self, stream_key: str):
procs = self._processes.pop(stream_key, [])
@@ -72,4 +154,4 @@ class TranscodeManager:
self.stop_transcode(key)
transcode_mgr = TranscodeManager()
transcode_mgr = TranscodeManager()