342 lines
11 KiB
Python
342 lines
11 KiB
Python
"""Demo 联调:MediaMTX 将 RTSP 转为 HLS,由 API 反代给浏览器(hls.js)。
|
||
|
||
推荐(Docker Compose):固定 ``mediamtx-hls`` 服务 + ``DEMO_HLS_PREVIEW_UPSTREAM=http://mediamtx-hls:8888``,
|
||
API 只写共享配置并 ``docker restart``,无需 ``host.docker.internal`` / ``-p`` 绑定。
|
||
|
||
本机开发(无 upstream):按需 ``docker run`` 临时容器,端口发布在 ``127.0.0.1``。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
import shutil
|
||
import socket
|
||
import subprocess
|
||
import time
|
||
import uuid
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import ClassVar, Literal
|
||
from urllib.parse import urlparse
|
||
from urllib.request import Request, urlopen
|
||
|
||
from loguru import logger
|
||
|
||
from app.services.synthetic_rtsp import MEDIAMTX_IMAGE
|
||
|
||
CONTAINER_NAME_PREFIX = "orm-hls-preview-"
|
||
_MEDIAMTX_HLS_INTERNAL_PORT = 8888
|
||
_PATH_SAFE = re.compile(r"[^a-zA-Z0-9._-]+")
|
||
_DOCKER_PUBLISH_ALIAS_HOSTS = frozenset(
|
||
{"host.docker.internal", "host.containers.internal", "localhost"}
|
||
)
|
||
|
||
|
||
def normalize_upstream_base(url: str) -> str:
|
||
raw = (url or "").strip().rstrip("/")
|
||
if not raw:
|
||
raise ValueError("empty HLS upstream URL")
|
||
parsed = urlparse(raw)
|
||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||
raise ValueError(f"invalid HLS upstream URL: {url!r}")
|
||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||
host = parsed.hostname
|
||
return f"{parsed.scheme}://{host}:{port}"
|
||
|
||
|
||
def upstream_host_port(upstream_base: str) -> tuple[str, int]:
|
||
parsed = urlparse(normalize_upstream_base(upstream_base))
|
||
assert parsed.hostname
|
||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||
return parsed.hostname, port
|
||
|
||
|
||
def docker_publish_bind_host(hls_host: str) -> str:
|
||
"""本机临时容器 ``docker run -p`` 的绑定地址(勿用主机名)。"""
|
||
h = (hls_host or "").strip().lower()
|
||
if h in _DOCKER_PUBLISH_ALIAS_HOSTS:
|
||
return "127.0.0.1"
|
||
return (hls_host or "").strip() or "127.0.0.1"
|
||
|
||
|
||
def resolve_ephemeral_upstream_host(settings_host: str) -> str:
|
||
"""API 在容器内且未配置 upstream 时,用 host.docker.internal 访问宿主机 published 端口。"""
|
||
host = (settings_host or "127.0.0.1").strip() or "127.0.0.1"
|
||
if Path("/.dockerenv").is_file() and host.lower() in ("127.0.0.1", "localhost", "::1"):
|
||
return "host.docker.internal"
|
||
return host
|
||
|
||
|
||
def sanitize_mediamtx_path(name: str) -> str:
|
||
raw = (name or "").strip() or "live"
|
||
cleaned = _PATH_SAFE.sub("_", raw)
|
||
return cleaned or "live"
|
||
|
||
|
||
def _wait_tcp(host: str, port: int, *, timeout: float) -> None:
|
||
deadline = time.monotonic() + max(1.0, timeout)
|
||
last: OSError | None = None
|
||
while time.monotonic() < deadline:
|
||
try:
|
||
with socket.create_connection((host, port), timeout=1.5):
|
||
return
|
||
except OSError as exc:
|
||
last = exc
|
||
time.sleep(0.2)
|
||
msg = f"等待 {host}:{port} 超时({timeout:g}s)"
|
||
if last is not None:
|
||
raise RuntimeError(f"{msg}: {last}") from last
|
||
raise RuntimeError(msg)
|
||
|
||
|
||
def _write_mediamtx_config(path: Path, paths: dict[str, str]) -> None:
|
||
lines = [
|
||
"# Generated for OR demo HLS preview",
|
||
"pathDefaults:",
|
||
" sourceOnDemand: yes",
|
||
"paths:",
|
||
]
|
||
for mtx_path, source in paths.items():
|
||
lines.append(f" {mtx_path}:")
|
||
lines.append(f" source: {source}")
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
|
||
|
||
@dataclass
|
||
class HlsPreviewSession:
|
||
upstream_base: str
|
||
path_by_camera: dict[str, str]
|
||
mode: Literal["attached", "ephemeral"]
|
||
container_name: str | None = None
|
||
config_dir: Path | None = None
|
||
|
||
@property
|
||
def hls_host(self) -> str:
|
||
return upstream_host_port(self.upstream_base)[0]
|
||
|
||
@property
|
||
def hls_port(self) -> int:
|
||
return upstream_host_port(self.upstream_base)[1]
|
||
|
||
def camera_upstream_base(self, camera_id: str) -> str:
|
||
mtx = self.path_by_camera[camera_id]
|
||
return f"{self.upstream_base.rstrip('/')}/{mtx}"
|
||
|
||
|
||
class HlsPreviewManager:
|
||
_active: ClassVar[HlsPreviewSession | None] = None
|
||
|
||
@classmethod
|
||
def active(cls) -> HlsPreviewSession | None:
|
||
return cls._active
|
||
|
||
@classmethod
|
||
def stop(cls) -> None:
|
||
sess = cls._active
|
||
if sess is None:
|
||
return
|
||
if sess.mode == "ephemeral" and sess.container_name and shutil.which("docker"):
|
||
try:
|
||
subprocess.run(
|
||
["docker", "rm", "-f", sess.container_name],
|
||
capture_output=True,
|
||
timeout=30,
|
||
check=False,
|
||
)
|
||
except (OSError, subprocess.SubprocessError) as exc:
|
||
logger.debug("hls preview docker rm: {}", exc)
|
||
if sess.config_dir and sess.config_dir.is_dir() and sess.mode == "ephemeral":
|
||
shutil.rmtree(sess.config_dir, ignore_errors=True)
|
||
cls._active = None
|
||
|
||
@classmethod
|
||
def start(
|
||
cls,
|
||
*,
|
||
sources: dict[str, str],
|
||
upstream_url: str = "",
|
||
config_dir: str = "",
|
||
attached_container_name: str = "orm-mediamtx-hls",
|
||
ephemeral_access_host: str = "127.0.0.1",
|
||
ephemeral_port: int = 18888,
|
||
ready_timeout_sec: float = 30.0,
|
||
) -> HlsPreviewSession:
|
||
if (upstream_url or "").strip():
|
||
return cls._start_attached(
|
||
sources=sources,
|
||
upstream_url=upstream_url.strip(),
|
||
config_dir=config_dir,
|
||
container_name=attached_container_name,
|
||
ready_timeout_sec=ready_timeout_sec,
|
||
)
|
||
return cls._start_ephemeral(
|
||
sources=sources,
|
||
access_host=ephemeral_access_host,
|
||
port=ephemeral_port,
|
||
ready_timeout_sec=ready_timeout_sec,
|
||
)
|
||
|
||
@classmethod
|
||
def _build_path_maps(
|
||
cls, sources: dict[str, str]
|
||
) -> tuple[dict[str, str], dict[str, str]]:
|
||
path_by_camera: dict[str, str] = {}
|
||
mtx_paths: dict[str, str] = {}
|
||
for camera_id, rtsp_url in sources.items():
|
||
mtx = sanitize_mediamtx_path(camera_id)
|
||
path_by_camera[camera_id] = mtx
|
||
mtx_paths[mtx] = rtsp_url
|
||
return path_by_camera, mtx_paths
|
||
|
||
@classmethod
|
||
def _start_attached(
|
||
cls,
|
||
*,
|
||
sources: dict[str, str],
|
||
upstream_url: str,
|
||
config_dir: str,
|
||
container_name: str,
|
||
ready_timeout_sec: float,
|
||
) -> HlsPreviewSession:
|
||
if not sources:
|
||
raise ValueError("no camera sources for HLS preview")
|
||
if not shutil.which("docker"):
|
||
raise RuntimeError(
|
||
"docker not in PATH (required to reload mediamtx-hls; mount docker.sock into api)"
|
||
)
|
||
|
||
cls.stop()
|
||
upstream_base = normalize_upstream_base(upstream_url)
|
||
path_by_camera, mtx_paths = cls._build_path_maps(sources)
|
||
|
||
cfg_root = Path((config_dir or "/hls-preview-config").strip())
|
||
cfg_file = cfg_root / "mediamtx.yml"
|
||
_write_mediamtx_config(cfg_file, mtx_paths)
|
||
|
||
logger.info(
|
||
"HLS preview (attached) writing {} and restarting {}",
|
||
cfg_file,
|
||
container_name,
|
||
)
|
||
r = subprocess.run(
|
||
["docker", "restart", container_name],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=60,
|
||
)
|
||
if r.returncode != 0:
|
||
err = (r.stderr or r.stdout or "").strip()
|
||
raise RuntimeError(
|
||
f"failed to restart {container_name} (is mediamtx-hls running in compose?): {err}"
|
||
)
|
||
|
||
host, port = upstream_host_port(upstream_base)
|
||
try:
|
||
_wait_tcp(host, port, timeout=ready_timeout_sec)
|
||
except Exception:
|
||
raise RuntimeError(
|
||
f"HLS preview not reachable at {upstream_base} after restarting {container_name}"
|
||
) from None
|
||
|
||
sess = HlsPreviewSession(
|
||
upstream_base=upstream_base,
|
||
path_by_camera=path_by_camera,
|
||
mode="attached",
|
||
container_name=container_name,
|
||
config_dir=cfg_root,
|
||
)
|
||
cls._active = sess
|
||
logger.info(
|
||
"HLS preview (attached) ready upstream={} cameras={}",
|
||
upstream_base,
|
||
list(path_by_camera),
|
||
)
|
||
return sess
|
||
|
||
@classmethod
|
||
def _start_ephemeral(
|
||
cls,
|
||
*,
|
||
sources: dict[str, str],
|
||
access_host: str,
|
||
port: int,
|
||
ready_timeout_sec: float,
|
||
) -> HlsPreviewSession:
|
||
if not sources:
|
||
raise ValueError("no camera sources for HLS preview")
|
||
if not shutil.which("docker"):
|
||
raise RuntimeError("docker not in PATH (required for HLS preview MediaMTX)")
|
||
|
||
cls.stop()
|
||
path_by_camera, mtx_paths = cls._build_path_maps(sources)
|
||
|
||
work = Path(os.environ.get("TMPDIR", "/tmp")) / f"orm-hls-preview-{uuid.uuid4().hex[:10]}"
|
||
work.mkdir(parents=True, exist_ok=True)
|
||
cfg = work / "mediamtx.yml"
|
||
_write_mediamtx_config(cfg, mtx_paths)
|
||
|
||
publish_host = docker_publish_bind_host("127.0.0.1")
|
||
container = CONTAINER_NAME_PREFIX + uuid.uuid4().hex[:12]
|
||
cmd = [
|
||
"docker",
|
||
"run",
|
||
"-d",
|
||
"--name",
|
||
container,
|
||
"-v",
|
||
f"{cfg.resolve()}:/mediamtx.yml:ro",
|
||
"-p",
|
||
f"{publish_host}:{port}:{_MEDIAMTX_HLS_INTERNAL_PORT}",
|
||
MEDIAMTX_IMAGE,
|
||
]
|
||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||
if r.returncode != 0:
|
||
shutil.rmtree(work, ignore_errors=True)
|
||
err = (r.stderr or r.stdout or "").strip()
|
||
raise RuntimeError(f"HLS preview MediaMTX failed (publish {publish_host}:{port}): {err}")
|
||
|
||
access = resolve_ephemeral_upstream_host(access_host)
|
||
upstream_base = normalize_upstream_base(f"http://{access}:{port}")
|
||
host, _ = upstream_host_port(upstream_base)
|
||
try:
|
||
_wait_tcp(host, port, timeout=ready_timeout_sec)
|
||
except Exception:
|
||
subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False)
|
||
shutil.rmtree(work, ignore_errors=True)
|
||
raise
|
||
|
||
sess = HlsPreviewSession(
|
||
upstream_base=upstream_base,
|
||
path_by_camera=path_by_camera,
|
||
mode="ephemeral",
|
||
container_name=container,
|
||
config_dir=work,
|
||
)
|
||
cls._active = sess
|
||
logger.info(
|
||
"HLS preview (ephemeral) container={} upstream={} cameras={}",
|
||
container,
|
||
upstream_base,
|
||
list(path_by_camera),
|
||
)
|
||
return sess
|
||
|
||
@classmethod
|
||
def resolve_upstream_url(cls, camera_id: str, subpath: str) -> str:
|
||
sess = cls._active
|
||
if sess is None or camera_id not in sess.path_by_camera:
|
||
raise KeyError(camera_id)
|
||
base = sess.camera_upstream_base(camera_id).rstrip("/")
|
||
sub = (subpath or "").lstrip("/")
|
||
return f"{base}/{sub}" if sub else base
|
||
|
||
|
||
def fetch_hls_upstream(url: str) -> tuple[bytes, str]:
|
||
req = Request(url, headers={"User-Agent": "operation-room-monitor-hls-proxy/1.0"})
|
||
with urlopen(req, timeout=15) as resp: # noqa: S310
|
||
body = resp.read()
|
||
ctype = resp.headers.get_content_type() or "application/octet-stream"
|
||
return body, ctype
|