From 831000101eb4e8c1190e8d6690f9aed061d1aff4 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 22 May 2026 16:04:32 +0800 Subject: [PATCH] fix hls --- backend/.env.example | 8 +- backend/app/api.py | 26 +-- backend/app/config.py | 21 +- backend/app/services/hls_preview.py | 214 ++++++++++++++---- backend/docker-compose.yml | 29 ++- .../mediamtx_hls_preview.bootstrap.yml | 4 + backend/tests/test_hls_preview_api.py | 25 +- docs/video-backends.md | 16 +- 8 files changed, 262 insertions(+), 81 deletions(-) create mode 100644 backend/resources/mediamtx_hls_preview.bootstrap.yml diff --git a/backend/.env.example b/backend/.env.example index 4ff2953..d8183bd 100755 --- a/backend/.env.example +++ b/backend/.env.example @@ -89,12 +89,12 @@ POSTGRES_PORT=45432 # 链路 2(模拟实时)与链路 3(离线 batch)需开启;链路 1 真 RTSP 开录不依赖此项 # DEMO_ORCHESTRATOR_ENABLED=false # DEMO_ORCHESTRATOR_RTSP_PORT=18554 -# 联调台 HLS 预览(MediaMTX 8888 映射到宿主机端口;API 反代给浏览器) +# 联调台 HLS 预览(推荐 Compose 侧车 mediamtx-hls,API 只反代) +# DEMO_HLS_PREVIEW_UPSTREAM=http://mediamtx-hls:8888 +# DEMO_HLS_PREVIEW_CONFIG_DIR=/hls-preview-config +# 本机无 Compose 时:留空 upstream,临时 docker 容器 + 端口 18888 # DEMO_HLS_PREVIEW_PORT=18888 -# API 在 Docker 内访问宿主机 HLS:host.docker.internal(docker -p 会自动绑定 127.0.0.1) # DEMO_HLS_PREVIEW_HOST=127.0.0.1 -# 可选:显式指定 docker -p 绑定地址(一般留空) -# DEMO_HLS_PREVIEW_BIND_HOST=127.0.0.1 # Docker 内 API 访问宿主机假流时写入站点 JSON 的主机名(默认 host.docker.internal) # DOCKER_DEMO_ORCHESTRATOR_RTSP_JSON_HOST=host.docker.internal # 链路 2 simulated-start / fake_rtsp_from_file 起 MediaMTX 容器用 diff --git a/backend/app/api.py b/backend/app/api.py index dd354ea..da79b98 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -36,11 +36,7 @@ from app.schemas import ( build_consumption_summary, ) from app.services.recording_live import accept_live_recording -from app.services.hls_preview import ( - HlsPreviewManager, - docker_publish_bind_host, - fetch_hls_upstream, -) +from app.services.hls_preview import HlsPreviewManager, fetch_hls_upstream from app.services.rtsp_preview import capture_rtsp_jpeg_frame from app.services.surgery_pipeline import SurgeryPipeline from app.services.video.backend_resolver import BackendResolver @@ -162,6 +158,7 @@ async def recording_modes_status() -> dict: "orchestrator_rtsp_port": settings.demo_orchestrator_rtsp_port, "orchestrator_rtsp_json_host": settings.demo_orchestrator_rtsp_json_host, "hls_preview_port": settings.demo_hls_preview_port, + "hls_preview_upstream": (settings.demo_hls_preview_upstream or "").strip() or None, } @@ -193,8 +190,7 @@ def _rewrite_hls_playlist(body: bytes, *, camera_id: str, proxy_origin: str) -> return body if "#EXTM3U" not in text: return body - mtx_path = sess.path_by_camera[camera_id] - upstream = f"http://{sess.hls_host}:{sess.hls_port}/{mtx_path}" + upstream = sess.camera_upstream_base(camera_id) proxy_base = f"{proxy_origin.rstrip('/')}/internal/demo/hls-preview/{camera_id}" text = text.replace(upstream, proxy_base) return text.encode("utf-8") @@ -215,12 +211,8 @@ async def hls_preview_ensure( status_code=status.HTTP_400_BAD_REQUEST, detail="OR_SITE_CONFIG_JSON_FILE is not set", ) - hls_host = (settings.demo_hls_preview_host or "127.0.0.1").strip() or "127.0.0.1" hls_port = int(settings.demo_hls_preview_port) - bind_host = docker_publish_bind_host( - hls_host, - explicit_bind=(settings.demo_hls_preview_bind_host or "").strip(), - ) + upstream_url = (settings.demo_hls_preview_upstream or "").strip() resolver = BackendResolver(settings, hikvision_runtime=None) sources: dict[str, str] = {} @@ -242,11 +234,13 @@ async def hls_preview_ensure( ) def _start() -> None: - HlsPreviewManager.start_pull( + HlsPreviewManager.start( sources=sources, - hls_host=hls_host, - hls_port=hls_port, - bind_host=bind_host, + upstream_url=upstream_url, + config_dir=settings.demo_hls_preview_config_dir, + attached_container_name=settings.demo_hls_preview_container_name, + ephemeral_access_host=settings.demo_hls_preview_host, + ephemeral_port=hls_port, ) try: diff --git a/backend/app/config.py b/backend/app/config.py index 2726fff..06ccbf7 100755 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -188,16 +188,25 @@ class Settings(BaseSettings): demo_orchestrator_rtsp_port: int = Field(default=18554, ge=1, le=65535) demo_orchestrator_rtsp_json_host: str = "host.docker.internal" demo_hls_preview_port: int = Field(default=18888, ge=1, le=65535) - demo_hls_preview_host: str = Field( - default="127.0.0.1", - description="API 反代访问的 MediaMTX HLS 地址;容器内 API 可设为 host.docker.internal。", - ) - demo_hls_preview_bind_host: str = Field( + demo_hls_preview_upstream: str = Field( default="", description=( - "docker run -p 绑定到宿主机的地址;留空时 host.docker.internal/localhost 自动用 127.0.0.1。" + "推荐:Compose 内 MediaMTX 基址,如 http://mediamtx-hls:8888。" + "设此项后 API 不再 docker run -p,只写配置并重启 mediamtx-hls。" ), ) + demo_hls_preview_config_dir: str = Field( + default="/hls-preview-config", + description="与 mediamtx-hls 共享的配置目录(mediamtx.yml)。", + ) + demo_hls_preview_container_name: str = Field( + default="orm-mediamtx-hls", + description="Compose 中 MediaMTX HLS 预览容器名(docker restart 用)。", + ) + demo_hls_preview_host: str = Field( + default="127.0.0.1", + description="未设 upstream 时,本机临时容器的访问主机(容器内自动改为 host.docker.internal)。", + ) #: 视觉管线记账默认 ``doctor_id``(TSV 未提供医生列时的回退);语音确认仍为独立常量 ``voice``。 video_result_doctor_id: str = Field( diff --git a/backend/app/services/hls_preview.py b/backend/app/services/hls_preview.py index 1c5f24d..a380615 100644 --- a/backend/app/services/hls_preview.py +++ b/backend/app/services/hls_preview.py @@ -1,4 +1,10 @@ -"""Demo 联调:MediaMTX 将 RTSP 转为 HLS,由 API 反代给浏览器(hls.js)。""" +"""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 @@ -7,12 +13,12 @@ import re import shutil import socket import subprocess -import textwrap import time import uuid -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path -from typing import ClassVar +from typing import ClassVar, Literal +from urllib.parse import urlparse from urllib.request import Request, urlopen from loguru import logger @@ -22,24 +28,47 @@ 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 -p 只接受 IP/0.0.0.0,不能写 host.docker.internal _DOCKER_PUBLISH_ALIAS_HOSTS = frozenset( {"host.docker.internal", "host.containers.internal", "localhost"} ) -def docker_publish_bind_host(hls_host: str, *, explicit_bind: str = "") -> str: - """``docker run -p :port`` 用的宿主机地址;与 API 反代用的 ``hls_host`` 可不同。""" - if (explicit_bind or "").strip(): - return explicit_bind.strip() +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: - """MediaMTX 路径名:优先用 camera_id,仅保留安全字符。""" raw = (name or "").strip() or "live" cleaned = _PATH_SAFE.sub("_", raw) return cleaned or "live" @@ -62,7 +91,6 @@ def _wait_tcp(host: str, port: int, *, timeout: float) -> None: def _write_mediamtx_config(path: Path, paths: dict[str, str]) -> None: - """``paths``: mediamtx 路径名 -> RTSP source URL(拉流)。""" lines = [ "# Generated for OR demo HLS preview", "pathDefaults:", @@ -72,20 +100,29 @@ def _write_mediamtx_config(path: Path, paths: dict[str, str]) -> None: 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: - hls_host: str - hls_port: int + upstream_base: str path_by_camera: dict[str, str] + mode: Literal["attached", "ephemeral"] container_name: str | None = None config_dir: Path | None = None - def upstream_base(self, camera_id: str) -> str: + @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"http://{self.hls_host}:{self.hls_port}/{mtx}" + return f"{self.upstream_base.rstrip('/')}/{mtx}" class HlsPreviewManager: @@ -100,7 +137,7 @@ class HlsPreviewManager: sess = cls._active if sess is None: return - if sess.container_name and shutil.which("docker"): + if sess.mode == "ephemeral" and sess.container_name and shutil.which("docker"): try: subprocess.run( ["docker", "rm", "-f", sess.container_name], @@ -110,41 +147,137 @@ class HlsPreviewManager: ) except (OSError, subprocess.SubprocessError) as exc: logger.debug("hls preview docker rm: {}", exc) - if sess.config_dir and sess.config_dir.is_dir(): + 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_pull( + def start( cls, *, sources: dict[str, str], - hls_host: str, - hls_port: int, - bind_host: str | None = None, + 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: - """链路 1:独立 MediaMTX 容器按路径拉取各 RTSP。""" - 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() + 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(hls_host, explicit_bind=bind_host or "") + publish_host = docker_publish_bind_host("127.0.0.1") container = CONTAINER_NAME_PREFIX + uuid.uuid4().hex[:12] cmd = [ "docker", @@ -155,38 +288,38 @@ class HlsPreviewManager: "-v", f"{cfg.resolve()}:/mediamtx.yml:ro", "-p", - f"{publish_host}:{hls_port}:{_MEDIAMTX_HLS_INTERNAL_PORT}", + 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: {err}") + 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(hls_host, hls_port, timeout=ready_timeout_sec) + _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( - hls_host=hls_host, - hls_port=hls_port, + 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 (pull) started container={} cameras={} publish={}:{} upstream=http://{}:{}/", + "HLS preview (ephemeral) container={} upstream={} cameras={}", container, + upstream_base, list(path_by_camera), - publish_host, - hls_port, - hls_host, - hls_port, ) return sess @@ -195,13 +328,12 @@ class HlsPreviewManager: sess = cls._active if sess is None or camera_id not in sess.path_by_camera: raise KeyError(camera_id) - base = sess.upstream_base(camera_id).rstrip("/") + 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]: - """同步拉取 MediaMTX HLS 资源(m3u8 / ts)。""" 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() diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 691e713..fd5c012 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -21,6 +21,23 @@ services: retries: 20 start_period: 5s + mediamtx-hls: + image: ${MEDIAMTX_DOCKER_IMAGE:-m.daocloud.io/docker.io/bluenviron/mediamtx:latest} + container_name: orm-mediamtx-hls + restart: unless-stopped + entrypoint: ["/bin/sh", "-c"] + command: + - | + if [ ! -f /config/mediamtx.yml ]; then + cp /bootstrap/mediamtx.yml /config/mediamtx.yml + fi + exec /mediamtx /config/mediamtx.yml + ports: + - "127.0.0.1:${DEMO_HLS_PREVIEW_PORT:-18888}:8888" + volumes: + - hls_preview_config:/config + - ./resources/mediamtx_hls_preview.bootstrap.yml:/bootstrap/mediamtx.yml:ro + minio: image: m.daocloud.io/docker.io/minio/minio:latest command: server /data --console-address ":9001" @@ -88,19 +105,26 @@ services: DEMO_ORCHESTRATOR_ENABLED: ${DEMO_ORCHESTRATOR_ENABLED:-false} DEMO_ORCHESTRATOR_RTSP_PORT: ${DEMO_ORCHESTRATOR_RTSP_PORT:-18554} DEMO_ORCHESTRATOR_RTSP_JSON_HOST: ${DOCKER_DEMO_ORCHESTRATOR_RTSP_JSON_HOST:-host.docker.internal} - DEMO_HLS_PREVIEW_PORT: ${DEMO_HLS_PREVIEW_PORT:-18888} - DEMO_HLS_PREVIEW_HOST: ${DOCKER_DEMO_HLS_PREVIEW_HOST:-host.docker.internal} + # api 使用 network:host,经宿主机端口访问 mediamtx-hls(勿用 mediamtx-hls 主机名) + DEMO_HLS_PREVIEW_UPSTREAM: ${DEMO_HLS_PREVIEW_UPSTREAM:-http://127.0.0.1:18888} + DEMO_HLS_PREVIEW_CONFIG_DIR: ${DEMO_HLS_PREVIEW_CONFIG_DIR:-/hls-preview-config} + DEMO_HLS_PREVIEW_CONTAINER_NAME: ${DEMO_HLS_PREVIEW_CONTAINER_NAME:-orm-mediamtx-hls} MEDIAMTX_DOCKER_IMAGE: ${MEDIAMTX_DOCKER_IMAGE:-m.daocloud.io/docker.io/bluenviron/mediamtx:latest} command: > sh -c "uv run --no-sync alembic upgrade head && uv run --no-sync uvicorn main:app --host 0.0.0.0 --port 8000" ports: - "${API_PORT:-38080}:8000" + volumes: + - hls_preview_config:/hls-preview-config + - /var/run/docker.sock:/var/run/docker.sock depends_on: db: condition: service_healthy minio: condition: service_started + mediamtx-hls: + condition: service_started restart: unless-stopped healthcheck: test: @@ -118,3 +142,4 @@ services: volumes: pgdata: minio_data: + hls_preview_config: diff --git a/backend/resources/mediamtx_hls_preview.bootstrap.yml b/backend/resources/mediamtx_hls_preview.bootstrap.yml new file mode 100644 index 0000000..c825261 --- /dev/null +++ b/backend/resources/mediamtx_hls_preview.bootstrap.yml @@ -0,0 +1,4 @@ +# Demo HLS 预览:启动占位;ensure 会覆盖同卷中的 mediamtx.yml +pathDefaults: + sourceOnDemand: yes +paths: {} diff --git a/backend/tests/test_hls_preview_api.py b/backend/tests/test_hls_preview_api.py index 5738a2e..0324658 100644 --- a/backend/tests/test_hls_preview_api.py +++ b/backend/tests/test_hls_preview_api.py @@ -14,6 +14,8 @@ from app.services.hls_preview import ( HlsPreviewManager, HlsPreviewSession, docker_publish_bind_host, + normalize_upstream_base, + resolve_ephemeral_upstream_host, ) @@ -32,24 +34,28 @@ def hls_client(tmp_path) -> TestClient: HlsPreviewManager.stop() +def test_normalize_upstream_base() -> None: + assert normalize_upstream_base("http://mediamtx-hls:8888") == "http://mediamtx-hls:8888" + assert normalize_upstream_base("http://mediamtx-hls:8888/") == "http://mediamtx-hls:8888" + + def test_docker_publish_bind_host_maps_docker_internal() -> None: assert docker_publish_bind_host("host.docker.internal") == "127.0.0.1" assert docker_publish_bind_host("127.0.0.1") == "127.0.0.1" - assert docker_publish_bind_host("host.docker.internal", explicit_bind="0.0.0.0") == "0.0.0.0" def test_hls_preview_ensure_pull(hls_client: TestClient) -> None: fake_sess = HlsPreviewSession( - hls_host="127.0.0.1", - hls_port=18888, + upstream_base="http://127.0.0.1:18888", path_by_camera={"or-cam-01": "or-cam-01"}, + mode="ephemeral", ) def _fake_start(**_kwargs: object) -> HlsPreviewSession: HlsPreviewManager._active = fake_sess return fake_sess - with patch.object(HlsPreviewManager, "start_pull", side_effect=_fake_start): + with patch.object(HlsPreviewManager, "start", side_effect=_fake_start): r = hls_client.post( "/internal/demo/hls-preview/ensure", json={"camera_ids": ["or-cam-01"]}, @@ -62,9 +68,9 @@ def test_hls_preview_ensure_pull(hls_client: TestClient) -> None: def test_hls_proxy_rewrites_playlist(hls_client: TestClient) -> None: HlsPreviewManager._active = HlsPreviewSession( - hls_host="127.0.0.1", - hls_port=18888, + upstream_base="http://127.0.0.1:18888", path_by_camera={"or-cam-01": "or-cam-01"}, + mode="attached", ) playlist = ( "#EXTM3U\n#EXT-X-TARGETDURATION:1\n" @@ -77,3 +83,10 @@ def test_hls_proxy_rewrites_playlist(hls_client: TestClient) -> None: text = r.text assert "/internal/demo/hls-preview/or-cam-01/segment.ts" in text assert "127.0.0.1:18888" not in text + + +def test_resolve_ephemeral_upstream_host_local() -> None: + with patch("app.services.hls_preview.Path") as path_cls: + path_cls.return_value.is_file.return_value = False + assert resolve_ephemeral_upstream_host("127.0.0.1") == "127.0.0.1" + diff --git a/docs/video-backends.md b/docs/video-backends.md index 02d3607..5c785ce 100755 --- a/docs/video-backends.md +++ b/docs/video-backends.md @@ -61,16 +61,20 @@ SDK **不作为构建期依赖**:将厂商提供的 Linux x86_64 动态库挂 浏览器无法直接播放 RTSP。联调台通过 **MediaMTX** 将 RTSP 转为 **HLS**,由 FastAPI 反代 m3u8/ts 后由 **hls.js** 播放。 +| 部署方式 | 行为 | +|----------|------| +| **Docker Compose(推荐)** | 固定服务 `mediamtx-hls`(宿主机 `127.0.0.1:18888`);`ensure` 写共享 `mediamtx.yml` 并 `docker restart orm-mediamtx-hls`;`DEMO_HLS_PREVIEW_UPSTREAM=http://127.0.0.1:18888` | +| **本机 uvicorn** | 未设 upstream 时按需 `docker run` 临时 MediaMTX(端口默认 `127.0.0.1:18888`) | + | 链路 | 行为 | |------|------| -| **链路 1**(真 RTSP) | `POST /internal/demo/hls-preview/ensure` 在宿主机起独立 MediaMTX 容器,按 `or_site_config` 的 `video_rtsp_urls` 拉流 | -| **链路 2**(模拟实时) | 不使用 HLS/RTSP 预览;联调台在各路槽位用本地 `