go2rtc: auto-reconcile streams so live/recording self-heal

Live and recording both read from the go2rtc restream. If go2rtc's
runtime stream table gets emptied (camera clear via API, go2rtc restart
with a stale/empty config, or a silently-failed startup sync_all), every
restream returns 404 and both live and recording go dark until a manual
`docker restart nvr-go2rtc`.

Add a background Reconciler that every 30s diffs the desired streams
against go2rtc /api/streams and PUTs only the missing ones (existing
streams are left untouched so active producers/consumers are not
disrupted). VERSION -> 0.0.115.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 12:53:02 +05:00
parent 346d3b5b96
commit 00cfca1a57
4 changed files with 79 additions and 1 deletions
+73
View File
@@ -14,6 +14,7 @@ go2rtc-потоки на камеру:
from __future__ import annotations
import asyncio
import json
import logging
import os
import urllib.parse
@@ -27,6 +28,7 @@ from ..config import Camera, Config
log = logging.getLogger("nvr.go2rtc")
GO2RTC_YAML = os.environ.get("GO2RTC_CONFIG", "/app/go2rtc.yaml")
RECONCILE_INTERVAL = 30 # сек — период сверки рантайма go2rtc с конфигом
def _rtsp_host(config: Config) -> str:
@@ -120,3 +122,74 @@ async def sync_all(config: Config) -> None:
for cam in config.cameras:
for name, src in _streams_for(cam).items():
await _call("PUT", _put(base, name, src))
def _get_stream_names(base: str) -> set[str]:
"""Имена потоков, реально загруженных в рантайм go2rtc (/api/streams)."""
req = urllib.request.Request(f"{base}/api/streams", method="GET")
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8") or "{}")
return set(data.keys())
async def reconcile(config: Config) -> int:
"""Дотягивает в go2rtc недостающие потоки (самовосстановление).
go2rtc читает go2rtc.yaml только при старте, а рантайм-таблицу держит через API.
Если её опустошить (DELETE при чистке камер, перезапуск go2rtc со старым/пустым
файлом, молча провалившийся стартовый sync_all) — рестрим начинает отдавать 404,
и live, и запись «слепнут». Здесь сверяем желаемый набор с /api/streams и PUT-им
ТОЛЬКО отсутствующие — существующие не трогаем, чтобы не рвать активных
producer'ов/consumer'ов. Возвращает число восстановленных потоков."""
if not config.cameras:
return 0
base = config.live.go2rtc_url.rstrip("/")
try:
existing = await asyncio.to_thread(_get_stream_names, base)
except Exception as exc: # noqa: BLE001 — go2rtc может быть недоступен/перезапускаться
log.debug("go2rtc reconcile: /api/streams недоступен: %s", exc)
return 0
added = 0
for cam in config.cameras:
for name, src in _streams_for(cam).items():
if name not in existing:
await _call("PUT", _put(base, name, src))
added += 1
if added:
log.info("go2rtc reconcile: восстановлено потоков %d", added)
write_go2rtc_yaml(config) # и файл приводим в соответствие — для будущего рестарта
return added
class Reconciler:
"""Фоновая сверка потоков go2rtc с конфигом каждые RECONCILE_INTERVAL секунд.
Держит ссылку на тот же объект Config, что и остальной рантайм (его .cameras
мутируется in-place при add/clear), поэтому всегда видит актуальный список."""
def __init__(self, config: Config, interval: int = RECONCILE_INTERVAL):
self.config = config
self.interval = interval
self._task: asyncio.Task | None = None
self._stopping = False
def start(self) -> None:
self._stopping = False
self._task = asyncio.create_task(self._loop(), name="go2rtc-reconcile")
async def stop(self) -> None:
self._stopping = True
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
async def _loop(self) -> None:
while not self._stopping:
await asyncio.sleep(self.interval)
try:
await reconcile(self.config)
except Exception: # noqa: BLE001
log.exception("go2rtc reconcile failed")