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
@@ -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
@@ -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"),
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user