a64749ccc8
Replace the single fixed /records path with a list of storages and an
active selection (where new recordings go). Quota is now per-storage.
- config: StorageItem(id,name,type,path,quota_gb,nas-meta) + Storage keeps
global retention_days/segment_seconds; active + items. Migration from the
old single {path,quota_gb} schema → one local item.
- recorder: write to the active storage; restart_all() repoints on switch;
NAS-not-mounted guard (never write into the container overlay).
- indexer: scan ALL storage roots so recordings survive an active switch.
- retention: enforce quota PER storage via db.total_size_under /
oldest_recordings_under (path-prefix LIKE).
- api/storages: GET/POST/PUT/DELETE + activate + policy. NAS uses host-mount
(backend stores SMB params, generates the mount/fstab command, reports
ismount status). Retire old POST /api/storage.
- frontend: new "Хранилища" settings tab — table with inline per-row quota,
add form (local folder / NAS SMB), global policy (segment + retention).
- compose: add shared ./storages:/storages root for extra local folders and
host-mounted NAS shares.
VERSION 0.0.127.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
85 lines
3.2 KiB
Python
85 lines
3.2 KiB
Python
"""Супервизор: владеет набором RecorderTask по всем включённым камерам."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from ..config import Camera, Config
|
|
from ..models import CameraStatus
|
|
from ..services import go2rtc
|
|
from .recorder_task import RecorderTask
|
|
|
|
log = logging.getLogger("nvr.supervisor")
|
|
|
|
|
|
class Supervisor:
|
|
def __init__(self, config: Config, on_status=None):
|
|
self.config = config
|
|
self.on_status = on_status
|
|
self.tasks: dict[str, RecorderTask] = {}
|
|
|
|
def _input_url(self, cam: Camera) -> str:
|
|
# запись основного потока идёт через рестрим go2rtc (одна сессия к камере)
|
|
return go2rtc.restream_main_url(self.config, cam.id)
|
|
|
|
def _new_task(self, cam: Camera) -> RecorderTask:
|
|
# пишем в активное хранилище; смена активного → restart_all репойнтит все задачи
|
|
active = self.config.active_storage()
|
|
return RecorderTask(cam, active, self.config.storage.segment_seconds,
|
|
on_status=self.on_status, input_url=self._input_url(cam))
|
|
|
|
def start_all(self) -> None:
|
|
for cam in self.config.cameras:
|
|
task = self._new_task(cam)
|
|
self.tasks[cam.id] = task
|
|
if cam.enabled and cam.record:
|
|
task.start()
|
|
log.info("recorder started: %s", cam.id)
|
|
else:
|
|
log.info("recorder skipped (enabled=%s record=%s): %s", cam.enabled, cam.record, cam.id)
|
|
|
|
async def stop_all(self) -> None:
|
|
for task in self.tasks.values():
|
|
await task.stop()
|
|
|
|
def statuses(self) -> list[CameraStatus]:
|
|
return [t.status for t in self.tasks.values()]
|
|
|
|
def status(self, cam_id: str) -> CameraStatus | None:
|
|
task = self.tasks.get(cam_id)
|
|
return task.status if task else None
|
|
|
|
def log_tail(self, cam_id: str) -> list[str]:
|
|
task = self.tasks.get(cam_id)
|
|
return task.log_tail if task else []
|
|
|
|
async def restart(self, cam_id: str) -> bool:
|
|
task = self.tasks.get(cam_id)
|
|
if not task:
|
|
return False
|
|
await task.stop()
|
|
task.start()
|
|
return True
|
|
|
|
def add_camera(self, cam: Camera) -> None:
|
|
"""Создаёт задачу записи для новой камеры и запускает (если включена)."""
|
|
task = self._new_task(cam)
|
|
self.tasks[cam.id] = task
|
|
if cam.enabled and cam.record:
|
|
task.start()
|
|
log.info("recorder added: %s", cam.id)
|
|
|
|
async def remove_camera(self, cam_id: str) -> None:
|
|
task = self.tasks.pop(cam_id, None)
|
|
if task:
|
|
await task.stop()
|
|
log.info("recorder removed: %s", cam_id)
|
|
|
|
async def replace_camera(self, cam: Camera) -> None:
|
|
await self.remove_camera(cam.id)
|
|
self.add_camera(cam)
|
|
|
|
async def restart_all(self) -> None:
|
|
"""Пересоздаёт все задачи (например, после смены настроек хранилища)."""
|
|
for cam in list(self.config.cameras):
|
|
await self.replace_camera(cam)
|