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:
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user