"""Start/stop local fake RTSP streams (MediaMTX + ffmpeg) for dev orchestration.""" from __future__ import annotations import json import os import shutil import socket import subprocess import time import uuid from dataclasses import dataclass, field from pathlib import Path from typing import ClassVar from loguru import logger MEDIAMTX_IMAGE = os.environ.get("MEDIAMTX_DOCKER_IMAGE", "bluenviron/mediamtx:latest") CONTAINER_NAME_PREFIX = "orm-fake-rtsp-" # 等待 127.0.0.1:host_port 可连接(避免开录时 Connection refused) _MEDIAMTX_TCP_READY_SEC = float(os.environ.get("MEDIAMTX_TCP_READY_SEC", "30")) def _wait_tcp_listening(host: str, port: int, *, total_timeout: float) -> None: """Block until something accepts TCP on host:port (MediaMTX 映射口就绪).""" deadline = time.monotonic() + max(1.0, total_timeout) last: OSError | None = None while time.monotonic() < deadline: try: with socket.create_connection((host, port), timeout=1.5): logger.info("RTSP port ready {}:{}", host, port) return except OSError as exc: last = exc time.sleep(0.2) hint = " MediaMTX 未监听:检查 docker 是否起成功、18554 是否被占用(orm-fake-rtsp-*) 已 docker ps。" if last is not None: raise RuntimeError( f"等待 {host}:{port} 可连接超时({total_timeout:g}s): {last}{hint}" ) from last raise RuntimeError( f"等待 {host}:{port} 可连接超时({total_timeout:g}s).{hint}" ) @dataclass class StreamSpec: camera_id: str file_path: Path rtsp_path: str # last segment, e.g. demo1 def __post_init__(self) -> None: self.rtsp_path = (self.rtsp_path or "demo").strip().strip("/") or "demo" @dataclass class SyntheticRtspRun: """Holds Popen handles and docker container for one multi-stream session.""" container_name: str procs: list[subprocess.Popen] = field(default_factory=list) work_dir: Path | None = None # temp dir for uploaded video files; removed on stop def stop(self) -> None: for p in self.procs: if p.poll() is None: p.terminate() try: p.wait(timeout=5.0) except subprocess.TimeoutExpired: p.kill() self.procs.clear() if self.work_dir is not None and self.work_dir.is_dir(): try: shutil.rmtree(self.work_dir, ignore_errors=True) except OSError as exc: logger.debug("rmtree work_dir: {}", exc) self.work_dir = None if shutil.which("docker") is not None: try: subprocess.run( ["docker", "rm", "-f", self.container_name], capture_output=True, timeout=30, ) except (OSError, subprocess.SubprocessError) as exc: logger.debug("docker rm: {}", exc) self.work_dir = None class SyntheticRtspManager: _instance: ClassVar[SyntheticRtspManager | None] = None _active: ClassVar[SyntheticRtspRun | None] = None @classmethod def get(cls) -> SyntheticRtspManager: if cls._instance is None: cls._instance = cls() return cls._instance @classmethod def active_run(cls) -> SyntheticRtspRun | None: return cls._active @classmethod def _cleanup_prefixed_containers(cls) -> None: """Remove stale MediaMTX containers left by earlier runs/reloads.""" if shutil.which("docker") is None: return try: listed = subprocess.run( [ "docker", "ps", "-aq", "--filter", f"name={CONTAINER_NAME_PREFIX}", ], capture_output=True, text=True, timeout=30, check=False, ) except (OSError, subprocess.SubprocessError) as exc: logger.debug("docker ps stale cleanup: {}", exc) return ids = [x.strip() for x in (listed.stdout or "").splitlines() if x.strip()] if not ids: return try: subprocess.run( ["docker", "rm", "-f", *ids], capture_output=True, text=True, timeout=60, check=False, ) logger.info("Removed stale fake RTSP containers: {}", ids) except (OSError, subprocess.SubprocessError) as exc: logger.debug("docker rm stale cleanup: {}", exc) @classmethod def stop_active(cls) -> None: if cls._active is not None: cls._active.stop() cls._active = None cls._cleanup_prefixed_containers() def start( self, streams: list[StreamSpec], *, host_port: int, work_dir: Path, ) -> tuple[SyntheticRtspRun, dict[str, str]]: """Start MediaMTX and one ffmpeg per stream. Returns (run, url_by_camera).""" if not streams: raise ValueError("no streams") if not shutil.which("ffmpeg"): raise RuntimeError("ffmpeg not in PATH") if not shutil.which("docker"): raise RuntimeError("docker not in PATH (required to run MediaMTX)") self.stop_active() for s in streams: if not s.file_path.is_file(): raise FileNotFoundError(str(s.file_path)) for ch in s.rtsp_path: if ch not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-": raise ValueError(f"invalid RTSP path segment: {s.rtsp_path!r}") container = CONTAINER_NAME_PREFIX + uuid.uuid4().hex[:12] cmd = [ "docker", "run", "-d", "--name", container, "-p", f"127.0.0.1:{host_port}:8554", MEDIAMTX_IMAGE, ] r = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if r.returncode != 0: try: subprocess.run( ["docker", "rm", "-f", container], capture_output=True, text=True, timeout=30, check=False, ) except (OSError, subprocess.SubprocessError) as exc: logger.debug("docker rm failed container cleanup: {}", exc) err = (r.stderr or r.stdout or "").strip() raise RuntimeError(f"MediaMTX docker failed: {err}") run = SyntheticRtspRun(container_name=container) url_map: dict[str, str] = {} time.sleep(0.5) _wait_tcp_listening("127.0.0.1", host_port, total_timeout=_MEDIAMTX_TCP_READY_SEC) run.work_dir = work_dir try: for s in streams: dest = f"rtsp://127.0.0.1:{host_port}/{s.rtsp_path}" url_map[s.camera_id] = dest pub = [ "ffmpeg", "-hide_banner", "-loglevel", "warning", "-re", "-stream_loop", "-1", "-i", str(s.file_path), "-c", "copy", "-f", "rtsp", "-rtsp_transport", "tcp", dest, ] p = subprocess.Popen(pub) # noqa: S603 run.procs.append(p) except Exception: run.stop() raise # 给 ffmpeg 一点时间连上 MediaMTX,减少首帧前 OpenCV 连上却 DESCRIBE 失败 time.sleep(0.4) self._active = run return run, url_map def write_rtsp_url_json_file( path: Path, url_map: dict[str, str], *, replace_host: str, ) -> None: """Write JSON map; replace 127.0.0.1 in values with `replace_host` (e.g. host.docker.internal).""" if replace_host in ("", "127.0.0.1"): out = url_map else: out = { k: v.replace("127.0.0.1", replace_host, 1) for k, v in url_map.items() } path.parent.mkdir(parents=True, exist_ok=True) text = json.dumps(out, ensure_ascii=False, indent=2, sort_keys=True) + "\n" temp = path.with_name(path.name + ".tmp") temp.write_text(text, encoding="utf-8") temp.replace(path) logger.info("Wrote RTSP map to {}", path)