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()