init
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
"""Синхронизация потоков с go2rtc + рестрим основного потока.
|
||||
|
||||
Камеры Hikvision ограничивают основной поток (нельзя открыть его дважды:
|
||||
запись + live дают RTSP 453). Решение: go2rtc держит ОДНО соединение с камерой
|
||||
и раздаёт нескольким потребителям. И запись, и live основного потока читают
|
||||
из go2rtc (rtsp-рестрим на :8554), поэтому камера видит одну сессию.
|
||||
|
||||
go2rtc-потоки на камеру:
|
||||
<cam>_main — основной поток (101), единственный источник записи и live-HD.
|
||||
<cam> — субпоток (102) для живой сетки; если субпотока нет — ссылка на
|
||||
<cam>_main (повторное использование, без второго коннекта к камере).
|
||||
<cam>_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))
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Очистка архива по квоте диска и по сроку хранения."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
from ..config import Config
|
||||
from ..db import Database
|
||||
|
||||
log = logging.getLogger("nvr.retention")
|
||||
|
||||
CHECK_INTERVAL = 300 # сек
|
||||
|
||||
|
||||
class Retention:
|
||||
def __init__(self, config: Config, db: Database):
|
||||
self.config = config
|
||||
self.db = db
|
||||
self._task: asyncio.Task | None = None
|
||||
self._stopping = False
|
||||
|
||||
def start(self) -> None:
|
||||
self._stopping = False
|
||||
self._task = asyncio.create_task(self._loop(), name="retention")
|
||||
|
||||
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:
|
||||
try:
|
||||
await self.enforce()
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("retention failed")
|
||||
await asyncio.sleep(CHECK_INTERVAL)
|
||||
|
||||
async def enforce(self) -> int:
|
||||
removed = 0
|
||||
removed += await self._enforce_age()
|
||||
removed += await self._enforce_quota()
|
||||
if removed:
|
||||
log.info("retention: удалено сегментов %d", removed)
|
||||
return removed
|
||||
|
||||
async def _enforce_age(self) -> int:
|
||||
days = self.config.storage.retention_days
|
||||
if not days:
|
||||
return 0
|
||||
cutoff = int(time.time()) - days * 86400
|
||||
old = await self.db.recordings_before(cutoff)
|
||||
for rec in old:
|
||||
await self._remove(rec.id, rec.path)
|
||||
return len(old)
|
||||
|
||||
async def _enforce_quota(self) -> int:
|
||||
quota = self.config.storage.quota_gb * 1024 ** 3
|
||||
if not quota:
|
||||
return 0
|
||||
removed = 0
|
||||
# удаляем самые старые, пока суммарный размер не уложится в квоту
|
||||
while await self.db.total_size() > quota:
|
||||
batch = await self.db.oldest_recordings(limit=20)
|
||||
if not batch:
|
||||
break
|
||||
for rec in batch:
|
||||
await self._remove(rec.id, rec.path)
|
||||
removed += 1
|
||||
if await self.db.total_size() <= quota:
|
||||
break
|
||||
return removed
|
||||
|
||||
async def _remove(self, rec_id: int, path: str) -> None:
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
log.warning("не удалось удалить %s: %s", path, exc)
|
||||
await self.db.delete_recording(rec_id)
|
||||
_cleanup_empty_dirs(os.path.dirname(path))
|
||||
|
||||
|
||||
def _cleanup_empty_dirs(path: str) -> None:
|
||||
"""Удаляет пустой каталог дня (и камеры), оставшийся после очистки."""
|
||||
for _ in range(2):
|
||||
try:
|
||||
if path and os.path.isdir(path) and not os.listdir(path):
|
||||
os.rmdir(path)
|
||||
path = os.path.dirname(path)
|
||||
else:
|
||||
break
|
||||
except OSError:
|
||||
break
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Системная статистика: диск хранилища, CPU, RAM, аптайм."""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import time
|
||||
|
||||
import psutil
|
||||
|
||||
from ..config import Config
|
||||
|
||||
_START = time.time()
|
||||
|
||||
|
||||
def system_stats(config: Config) -> dict:
|
||||
path = config.storage.path
|
||||
try:
|
||||
usage = shutil.disk_usage(path)
|
||||
disk = {
|
||||
"total_gb": round(usage.total / 1024 ** 3, 1),
|
||||
"used_gb": round(usage.used / 1024 ** 3, 1),
|
||||
"free_gb": round(usage.free / 1024 ** 3, 1),
|
||||
"percent": round(usage.used / usage.total * 100, 1) if usage.total else 0,
|
||||
}
|
||||
except OSError:
|
||||
disk = {"total_gb": 0, "used_gb": 0, "free_gb": 0, "percent": 0}
|
||||
|
||||
vm = psutil.virtual_memory()
|
||||
return {
|
||||
"uptime_s": int(time.time() - _START),
|
||||
"cpu_percent": psutil.cpu_percent(interval=None),
|
||||
"ram_percent": vm.percent,
|
||||
"ram_used_gb": round(vm.used / 1024 ** 3, 1),
|
||||
"ram_total_gb": round(vm.total / 1024 ** 3, 1),
|
||||
"storage_path": path,
|
||||
"disk": disk,
|
||||
"quota_gb": config.storage.quota_gb,
|
||||
"retention_days": config.storage.retention_days,
|
||||
"segment_seconds": config.storage.segment_seconds,
|
||||
}
|
||||
Reference in New Issue
Block a user