Files
operating-room-monitor-server/backend/app/services/hls_preview.py
2026-05-22 16:04:32 +08:00

342 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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