24155a8492
Add a separate `record` field so a camera can be registered but fully
off, or on but not recording:
- enabled (Вкл): camera on/off. Disabled = no go2rtc stream (no live),
no recording; entry is just stored. build_streams/sync_all/reconcile
skip disabled cameras; live grid shows "камера выключена".
- record (REC): write segments to disk. Recorder runs only when
enabled AND record.
Unified POST /api/cameras/{id}/toggle {enabled?, record?} (replaces the
/record endpoint); go2rtc is touched only when `enabled` changes, so
flipping REC doesn't blip live. Camera table gets Вкл + Запись toggle
columns; add/edit forms get both switches.
load_config migrates the old schema (no `record` key): old `enabled`
meant "record" with live always on -> enabled=True, record=old enabled.
VERSION -> 0.0.126.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
135 lines
4.5 KiB
Python
135 lines
4.5 KiB
Python
"""Загрузка и валидация конфигурации NVR из cameras.yaml."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from functools import lru_cache
|
|
|
|
import yaml
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Camera:
|
|
id: str
|
|
name: str
|
|
enabled: bool
|
|
ip: str
|
|
user: str
|
|
password: str
|
|
main_path: str
|
|
sub_path: str | None = None
|
|
record: bool = True # REC: писать на диск. enabled — камера вообще включена (live+запись)
|
|
|
|
def rtsp_url(self, path: str) -> str:
|
|
return f"rtsp://{self.user}:{self.password}@{self.ip}:554{path}"
|
|
|
|
@property
|
|
def record_url(self) -> str:
|
|
"""Поток для записи (основной, H.265, копированием)."""
|
|
return self.rtsp_url(self.main_path)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Storage:
|
|
path: str = "/records"
|
|
quota_gb: int = 50
|
|
retention_days: int = 7
|
|
segment_seconds: int = 600
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Live:
|
|
go2rtc_url: str = "http://localhost:1984"
|
|
|
|
|
|
@dataclass
|
|
class Config:
|
|
storage: Storage
|
|
live: Live
|
|
cameras: list[Camera] = field(default_factory=list)
|
|
|
|
def camera(self, cam_id: str) -> Camera | None:
|
|
return next((c for c in self.cameras if c.id == cam_id), None)
|
|
|
|
@property
|
|
def enabled_cameras(self) -> list[Camera]:
|
|
return [c for c in self.cameras if c.enabled]
|
|
|
|
|
|
def load_config(path: str | None = None) -> Config:
|
|
path = path or os.environ.get("NVR_CONFIG", "cameras.yaml")
|
|
# Толерантно к отсутствию/повреждению: если файла нет, на его месте каталог
|
|
# (битый bind-mount) или YAML невалиден — стартуем без камер (добавляются из UI),
|
|
# а не падаем с ошибкой на старте.
|
|
raw: dict = {}
|
|
if os.path.isfile(path):
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|
raw = yaml.safe_load(fh) or {}
|
|
except (OSError, yaml.YAMLError):
|
|
raw = {}
|
|
|
|
storage = Storage(**(raw.get("storage") or {}))
|
|
live = Live(**(raw.get("live") or {}))
|
|
cameras = [_camera_from_raw(c) for c in (raw.get("cameras") or [])]
|
|
return Config(storage=storage, live=live, cameras=cameras)
|
|
|
|
|
|
def _camera_from_raw(c: dict) -> Camera:
|
|
# Миграция со старой схемы: раньше `enabled` означал «писать на диск» (live был всегда),
|
|
# отдельного «камера выключена» не существовало. Старый файл (без ключа record) трактуем
|
|
# так: запись = старый enabled, а сама камера включена. Новый файл (с record) — как есть.
|
|
if "record" in c:
|
|
enabled, record = bool(c.get("enabled", True)), bool(c["record"])
|
|
else:
|
|
enabled, record = True, bool(c.get("enabled", True))
|
|
return Camera(
|
|
id=c["id"],
|
|
name=c.get("name", c["id"]),
|
|
enabled=enabled,
|
|
record=record,
|
|
ip=c["ip"],
|
|
user=c["user"],
|
|
password=c["password"],
|
|
main_path=c["main_path"],
|
|
sub_path=c.get("sub_path"),
|
|
)
|
|
|
|
|
|
def camera_to_yaml(c: Camera) -> dict:
|
|
return {
|
|
"id": c.id,
|
|
"name": c.name,
|
|
"enabled": c.enabled,
|
|
"record": c.record,
|
|
"ip": c.ip,
|
|
"user": c.user,
|
|
"password": c.password,
|
|
"main_path": c.main_path,
|
|
"sub_path": c.sub_path,
|
|
}
|
|
|
|
|
|
def save_config(config: Config, path: str | None = None) -> None:
|
|
"""Сохраняет конфиг обратно в cameras.yaml (комментарии теряются)."""
|
|
path = path or os.environ.get("NVR_CONFIG", "cameras.yaml")
|
|
data = {
|
|
"storage": {
|
|
"path": config.storage.path,
|
|
"quota_gb": config.storage.quota_gb,
|
|
"retention_days": config.storage.retention_days,
|
|
"segment_seconds": config.storage.segment_seconds,
|
|
},
|
|
"live": {"go2rtc_url": config.live.go2rtc_url},
|
|
"cameras": [camera_to_yaml(c) for c in config.cameras],
|
|
}
|
|
# пишем на месте: cameras.yaml примонтирован как отдельный файл (bind mount),
|
|
# переименование поверх точки монтирования даёт EBUSY.
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
yaml.safe_dump(data, fh, allow_unicode=True, sort_keys=False)
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def get_config() -> Config:
|
|
return load_config()
|