- 语音:序数解析(第一个/第二个等)、解析失败计数与 API detail.retry_remaining; 百度 ASR 固定 dev_pid 为普通话;SurgeryPipelineError 支持 extra 并入 HTTP detail。 - Demo:demo 路由与假 RTSP、客户端 index 与 README;BackendResolver 与配置调整。 - 可观测:消耗 TSV 日志、语音文件日志、终端 Markdown 辅助;相关测试与依赖更新。 - 注意:.env 仍被 gitignore,本地密钥不会进入本提交。 Made-with: Cursor
243 lines
8.2 KiB
Python
243 lines
8.2 KiB
Python
"""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)
|