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:
+111
-5
@@ -29,12 +29,45 @@ class Camera:
|
||||
return self.rtsp_url(self.main_path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StorageItem:
|
||||
"""Одно хранилище записи: локальная папка или сетевой диск (SMB/NAS).
|
||||
|
||||
`path` — путь ВНУТРИ контейнера, куда пишутся сегменты. `quota_gb` — лимит
|
||||
размера ЭТОГО хранилища (0 = без лимита). Для NAS поля server/share/username/
|
||||
password/subpath — метаданные (монтаж делается на хосте, backend ими не
|
||||
монтирует, только показывает статус и генерирует команду mount).
|
||||
"""
|
||||
id: str
|
||||
name: str
|
||||
type: str # "local" | "nas"
|
||||
path: str
|
||||
quota_gb: int = 0
|
||||
server: str = ""
|
||||
share: str = ""
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
subpath: str = ""
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Storage:
|
||||
path: str = "/records"
|
||||
quota_gb: int = 50
|
||||
"""Глобальная политика записи + список хранилищ и id активного.
|
||||
|
||||
Квота — на каждом StorageItem (не здесь). retention_days/segment_seconds —
|
||||
глобальные: срок хранения по возрасту и длина сегмента для всех хранилищ.
|
||||
"""
|
||||
active: str = "local"
|
||||
retention_days: int = 7
|
||||
segment_seconds: int = 600
|
||||
items: tuple[StorageItem, ...] = ()
|
||||
|
||||
|
||||
# Дефолтное хранилище (когда конфиг пуст / битый) — совпадает с историческим /records.
|
||||
_DEFAULT_STORAGE = StorageItem(
|
||||
id="local", name="Локальное хранилище", type="local", path="/records", quota_gb=30,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -55,6 +88,21 @@ class Config:
|
||||
def enabled_cameras(self) -> list[Camera]:
|
||||
return [c for c in self.cameras if c.enabled]
|
||||
|
||||
# ── хранилища ───────────────────────────────────────────────
|
||||
def storage_by_id(self, sid: str) -> StorageItem | None:
|
||||
return next((s for s in self.storage.items if s.id == sid), None)
|
||||
|
||||
def active_storage(self) -> StorageItem:
|
||||
"""Хранилище, куда пишутся новые записи (fallback — первый/дефолтный)."""
|
||||
return (
|
||||
self.storage_by_id(self.storage.active)
|
||||
or (self.storage.items[0] if self.storage.items else _DEFAULT_STORAGE)
|
||||
)
|
||||
|
||||
def storage_paths(self) -> list[str]:
|
||||
"""Пути всех хранилищ — индексатор сканирует их все (старые записи не теряются)."""
|
||||
return [s.path for s in self.storage.items] or [_DEFAULT_STORAGE.path]
|
||||
|
||||
|
||||
def load_config(path: str | None = None) -> Config:
|
||||
path = path or os.environ.get("NVR_CONFIG", "cameras.yaml")
|
||||
@@ -69,7 +117,7 @@ def load_config(path: str | None = None) -> Config:
|
||||
except (OSError, yaml.YAMLError):
|
||||
raw = {}
|
||||
|
||||
storage = Storage(**(raw.get("storage") or {}))
|
||||
storage = _storage_from_raw(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)
|
||||
@@ -96,6 +144,49 @@ def _camera_from_raw(c: dict) -> Camera:
|
||||
)
|
||||
|
||||
|
||||
def _storage_item_from_raw(s: dict) -> StorageItem:
|
||||
return StorageItem(
|
||||
id=s["id"],
|
||||
name=s.get("name", s["id"]),
|
||||
type=s.get("type", "local"),
|
||||
path=s["path"],
|
||||
quota_gb=int(s.get("quota_gb", 0) or 0),
|
||||
server=s.get("server", "") or "",
|
||||
share=s.get("share", "") or "",
|
||||
username=s.get("username", "") or "",
|
||||
password=s.get("password", "") or "",
|
||||
subpath=s.get("subpath", "") or "",
|
||||
enabled=bool(s.get("enabled", True)),
|
||||
)
|
||||
|
||||
|
||||
def _storage_from_raw(raw: dict) -> Storage:
|
||||
# Новая схема: есть список items.
|
||||
if raw.get("items"):
|
||||
items = tuple(_storage_item_from_raw(s) for s in raw["items"])
|
||||
active = raw.get("active") or (items[0].id if items else "local")
|
||||
return Storage(
|
||||
active=active,
|
||||
retention_days=int(raw.get("retention_days", 7)),
|
||||
segment_seconds=int(raw.get("segment_seconds", 600)),
|
||||
items=items,
|
||||
)
|
||||
# Миграция со старой одиночной схемы {path, quota_gb, retention_days, segment_seconds}:
|
||||
# один локальный item, старый глобальный quota → квота этого хранилища.
|
||||
legacy_path = raw.get("path") or "/records"
|
||||
legacy_quota = int(raw.get("quota_gb", 30) or 30)
|
||||
local = StorageItem(
|
||||
id="local", name="Локальное хранилище", type="local",
|
||||
path=legacy_path, quota_gb=legacy_quota,
|
||||
)
|
||||
return Storage(
|
||||
active="local",
|
||||
retention_days=int(raw.get("retention_days", 7)),
|
||||
segment_seconds=int(raw.get("segment_seconds", 600)),
|
||||
items=(local,),
|
||||
)
|
||||
|
||||
|
||||
def camera_to_yaml(c: Camera) -> dict:
|
||||
return {
|
||||
"id": c.id,
|
||||
@@ -110,15 +201,30 @@ def camera_to_yaml(c: Camera) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def storage_item_to_yaml(s: StorageItem) -> dict:
|
||||
d = {
|
||||
"id": s.id, "name": s.name, "type": s.type,
|
||||
"path": s.path, "quota_gb": s.quota_gb,
|
||||
}
|
||||
if s.type == "nas":
|
||||
d.update({
|
||||
"server": s.server, "share": s.share, "username": s.username,
|
||||
"password": s.password, "subpath": s.subpath,
|
||||
})
|
||||
if not s.enabled:
|
||||
d["enabled"] = False
|
||||
return d
|
||||
|
||||
|
||||
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,
|
||||
"active": config.storage.active,
|
||||
"retention_days": config.storage.retention_days,
|
||||
"segment_seconds": config.storage.segment_seconds,
|
||||
"items": [storage_item_to_yaml(s) for s in config.storage.items],
|
||||
},
|
||||
"live": {"go2rtc_url": config.live.go2rtc_url},
|
||||
"cameras": [camera_to_yaml(c) for c in config.cameras],
|
||||
|
||||
Reference in New Issue
Block a user