"""Синхронизация потоков с go2rtc + рестрим основного потока. Камеры Hikvision ограничивают основной поток (нельзя открыть его дважды: запись + live дают RTSP 453). Решение: go2rtc держит ОДНО соединение с камерой и раздаёт нескольким потребителям. И запись, и live основного потока читают из go2rtc (rtsp-рестрим на :8554), поэтому камера видит одну сессию. go2rtc-потоки на камеру: _main — основной поток (101), единственный источник записи и live-HD. — субпоток (102) для живой сетки; если субпотока нет — ссылка на _main (повторное использование, без второго коннекта к камере). _h264 — субпоток, перекодированный в H.264 (для WebRTC/браузеров без HEVC). """ from __future__ import annotations import asyncio import logging import os import urllib.parse import urllib.request from urllib.parse import urlparse import yaml from ..config import Camera, Config log = logging.getLogger("nvr.go2rtc") GO2RTC_YAML = os.environ.get("GO2RTC_CONFIG", "/app/go2rtc.yaml") def _rtsp_host(config: Config) -> str: return urlparse(config.live.go2rtc_url).hostname or "127.0.0.1" def restream_main_url(config: Config, cam_id: str) -> str: """RTSP-адрес основного потока камеры через go2rtc (для записи и live-HD).""" return f"rtsp://{_rtsp_host(config)}:8554/{cam_id}_main" def restream_sub_url(config: Config, cam_id: str) -> str: """RTSP-адрес живого (суб)потока через go2rtc (для транскода сабпотока).""" return f"rtsp://{_rtsp_host(config)}:8554/{cam_id}" def _streams_for(cam: Camera) -> dict[str, str]: """Имя go2rtc-потока -> источник, для одной камеры.""" out = {f"{cam.id}_main": cam.rtsp_url(cam.main_path)} if cam.sub_path: out[cam.id] = cam.rtsp_url(cam.sub_path) else: # нет субпотока — берём основной из go2rtc, не открывая второй коннект к камере out[cam.id] = f"rtsp://127.0.0.1:8554/{cam.id}_main" out[f"{cam.id}_h264"] = f"ffmpeg:{cam.id}#video=h264#audio=copy" return out def build_streams(config: Config) -> dict: streams: dict = {} for c in config.cameras: for name, src in _streams_for(c).items(): streams[name] = [src] return streams def write_go2rtc_yaml(config: Config) -> None: data = { "api": {"listen": ":1984"}, "rtsp": {"listen": ":8554"}, "webrtc": {"listen": ":8555"}, "log": {"level": "info"}, "streams": build_streams(config), } try: with open(GO2RTC_YAML, "w", encoding="utf-8") as fh: yaml.safe_dump(data, fh, allow_unicode=True, sort_keys=False) except OSError as exc: log.warning("не удалось записать go2rtc.yaml: %s", exc) def _http(method: str, url: str) -> int: req = urllib.request.Request(url, method=method) with urllib.request.urlopen(req, timeout=5) as resp: return resp.status async def _call(method: str, url: str) -> None: try: await asyncio.to_thread(_http, method, url) except Exception as exc: # noqa: BLE001 — go2rtc может быть недоступен/вернуть 4xx при ro-конфиге log.debug("go2rtc API %s -> %s", method, exc) def _put(base: str, name: str, src: str) -> str: return f"{base}/api/streams?" + urllib.parse.urlencode({"name": name, "src": src}) def _del(base: str, name: str) -> str: return f"{base}/api/streams?" + urllib.parse.urlencode({"src": name}) async def add_camera_streams(config: Config, cam: Camera) -> None: base = config.live.go2rtc_url.rstrip("/") for name, src in _streams_for(cam).items(): await _call("PUT", _put(base, name, src)) write_go2rtc_yaml(config) async def remove_camera_streams(config: Config, cam_id: str) -> None: base = config.live.go2rtc_url.rstrip("/") for name in (f"{cam_id}_main", cam_id, f"{cam_id}_h264"): await _call("DELETE", _del(base, name)) write_go2rtc_yaml(config) async def sync_all(config: Config) -> None: """При старте: записать конфиг и добавить все потоки в go2rtc через API.""" write_go2rtc_yaml(config) base = config.live.go2rtc_url.rstrip("/") for cam in config.cameras: for name, src in _streams_for(cam).items(): await _call("PUT", _put(base, name, src))