storage: multi-storage management with per-storage quota

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>
This commit is contained in:
2026-06-01 19:31:15 +05:00
parent 24155a8492
commit a64749ccc8
16 changed files with 746 additions and 126 deletions
+6 -4
View File
@@ -5,19 +5,21 @@ import asyncio
import json
import os
from ..config import Camera, Storage
from ..config import Camera
def record_args(cam: Camera, storage: Storage, input_url: str | None = None) -> list[str]:
def record_args(cam: Camera, out_root: str, segment_seconds: int,
input_url: str | None = None) -> list[str]:
"""ffmpeg для записи основного потока сегментами mp4 копированием.
-c copy => CPU почти не используется. -strftime даёт имена сегментов по времени.
out_root — корень активного хранилища (внутри пишем подкаталог камеры).
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
"""
# Плоско в каталоге камеры: дата в имени файла. Каталог создаётся один раз
# (recorder_task), новых подкаталогов не нужно — корректно переживает полночь.
# Подкаталог даты не используем: этот ffmpeg-билд не знает -strftime_mkdir.
out_template = os.path.join(storage.path, cam.id, "%Y-%m-%d_%H-%M-%S.mp4")
out_template = os.path.join(out_root, cam.id, "%Y-%m-%d_%H-%M-%S.mp4")
return [
"ffmpeg",
"-nostdin",
@@ -29,7 +31,7 @@ def record_args(cam: Camera, storage: Storage, input_url: str | None = None) ->
"-an", # звук не пишем (камеры обычно без него)
"-c:v", "copy",
"-f", "segment",
"-segment_time", str(storage.segment_seconds),
"-segment_time", str(segment_seconds),
"-segment_atclocktime", "1", # резать по часам: кратно segment_time от 00:00
"-segment_clocktime_offset", "0",
"-segment_format", "mp4",
+19 -15
View File
@@ -80,24 +80,28 @@ class Indexer:
await asyncio.sleep(SCAN_INTERVAL)
async def scan_once(self) -> int:
root = self.config.storage.path
by_cam = _scan_segments(root)
active_path = self.config.active_storage().path
known = await self.db.known_paths()
added = 0
for cam_id, files in by_cam.items():
# последний файл может писаться прямо сейчас — не индексируем
complete = files[:-1] if len(files) > 1 else []
for path in complete:
if path in known:
continue
duration, size = await probe_duration(path)
if size is None or size == 0:
continue
await self.db.add_recording(
cam_id, path, _parse_started_at(path), duration, size
)
added += 1
# сканируем ВСЕ хранилища: после смены активного старые записи не теряются
for root in self.config.storage_paths():
by_cam = _scan_segments(root)
is_active = (root == active_path)
for cam_id, files in by_cam.items():
# в активном хранилище последний файл ещё пишется — пропускаем;
# в неактивных все сегменты завершены, индексируем целиком.
complete = files[:-1] if is_active else files
for path in complete:
if path in known:
continue
duration, size = await probe_duration(path)
if size is None or size == 0:
continue
await self.db.add_recording(
cam_id, path, _parse_started_at(path), duration, size
)
added += 1
# уборка пропавших файлов
for path in known:
+23 -5
View File
@@ -7,7 +7,7 @@ import os
import time
from collections import deque
from ..config import Camera, Storage
from ..config import Camera, StorageItem
from ..models import CameraState, CameraStatus
from .ffmpeg import record_args
@@ -21,9 +21,11 @@ MIN_HEALTHY_UPTIME = 15.0 # процесс прожил столько => сч
class RecorderTask:
"""Держит один процесс ffmpeg на камеру, перезапускает при падении."""
def __init__(self, cam: Camera, storage: Storage, on_status=None, input_url: str | None = None):
def __init__(self, cam: Camera, store: StorageItem, segment_seconds: int,
on_status=None, input_url: str | None = None):
self.cam = cam
self.storage = storage
self.store = store # активное хранилище (куда пишем)
self.segment_seconds = segment_seconds
self.input_url = input_url # источник записи (рестрим go2rtc)
self.on_status = on_status # callback(CameraStatus) для WS-уведомлений
self.status = CameraStatus(
@@ -69,11 +71,27 @@ class RecorderTask:
# ── основной цикл ───────────────────────────────────────────
async def _run(self) -> None:
backoff = BACKOFF_START
os.makedirs(os.path.join(self.storage.path, self.cam.id), exist_ok=True)
while not self._stopping:
# NAS: пишем только если том реально смонтирован на хосте. Иначе запись
# ушла бы в overlay контейнера (потеря при рестарте) — ждём монтаж, каталог
# тоже не создаём.
if self.store.type == "nas" and not os.path.ismount(self.store.path):
self._set_state(CameraState.RETRYING, "NAS не смонтирован")
backoff = min(backoff * 2, BACKOFF_MAX)
await asyncio.sleep(backoff)
continue
try:
os.makedirs(os.path.join(self.store.path, self.cam.id), exist_ok=True)
except OSError as exc:
self._set_state(CameraState.RETRYING, f"mkdir: {exc}")
backoff = min(backoff * 2, BACKOFF_MAX)
await asyncio.sleep(backoff)
continue
self._set_state(CameraState.STARTING)
args = record_args(self.cam, self.storage, self.input_url)
args = record_args(self.cam, self.store.path, self.segment_seconds, self.input_url)
log.info("[%s] start ffmpeg: %s", self.cam.id, _redact(args, self.cam))
try:
self._proc = await asyncio.create_subprocess_exec(
+8 -4
View File
@@ -21,10 +21,15 @@ class Supervisor:
# запись основного потока идёт через рестрим 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 = RecorderTask(cam, self.config.storage, on_status=self.on_status,
input_url=self._input_url(cam))
task = self._new_task(cam)
self.tasks[cam.id] = task
if cam.enabled and cam.record:
task.start()
@@ -57,8 +62,7 @@ class Supervisor:
def add_camera(self, cam: Camera) -> None:
"""Создаёт задачу записи для новой камеры и запускает (если включена)."""
task = RecorderTask(cam, self.config.storage, on_status=self.on_status,
input_url=self._input_url(cam))
task = self._new_task(cam)
self.tasks[cam.id] = task
if cam.enabled and cam.record:
task.start()