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>
104 lines
3.4 KiB
Python
104 lines
3.4 KiB
Python
"""Очистка архива по квоте диска и по сроку хранения."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import time
|
|
|
|
from ..config import Config
|
|
from ..db import Database
|
|
|
|
log = logging.getLogger("nvr.retention")
|
|
|
|
CHECK_INTERVAL = 300 # сек
|
|
|
|
|
|
class Retention:
|
|
def __init__(self, config: Config, db: Database):
|
|
self.config = config
|
|
self.db = db
|
|
self._task: asyncio.Task | None = None
|
|
self._stopping = False
|
|
|
|
def start(self) -> None:
|
|
self._stopping = False
|
|
self._task = asyncio.create_task(self._loop(), name="retention")
|
|
|
|
async def stop(self) -> None:
|
|
self._stopping = True
|
|
if self._task:
|
|
self._task.cancel()
|
|
try:
|
|
await self._task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
async def _loop(self) -> None:
|
|
while not self._stopping:
|
|
try:
|
|
await self.enforce()
|
|
except Exception: # noqa: BLE001
|
|
log.exception("retention failed")
|
|
await asyncio.sleep(CHECK_INTERVAL)
|
|
|
|
async def enforce(self) -> int:
|
|
removed = 0
|
|
removed += await self._enforce_age()
|
|
removed += await self._enforce_quota()
|
|
if removed:
|
|
log.info("retention: удалено сегментов %d", removed)
|
|
return removed
|
|
|
|
async def _enforce_age(self) -> int:
|
|
days = self.config.storage.retention_days
|
|
if not days:
|
|
return 0
|
|
cutoff = int(time.time()) - days * 86400
|
|
old = await self.db.recordings_before(cutoff)
|
|
for rec in old:
|
|
await self._remove(rec.id, rec.path)
|
|
return len(old)
|
|
|
|
async def _enforce_quota(self) -> int:
|
|
# квота — на КАЖДОЕ хранилище отдельно (0 = без лимита). Для каждого удаляем
|
|
# самые старые записи ВНУТРИ него, пока его размер не уложится в его квоту.
|
|
removed = 0
|
|
for item in self.config.storage.items:
|
|
if not item.quota_gb:
|
|
continue
|
|
quota = item.quota_gb * 1024 ** 3
|
|
while await self.db.total_size_under(item.path) > quota:
|
|
batch = await self.db.oldest_recordings_under(item.path, limit=20)
|
|
if not batch:
|
|
break
|
|
for rec in batch:
|
|
await self._remove(rec.id, rec.path)
|
|
removed += 1
|
|
if await self.db.total_size_under(item.path) <= quota:
|
|
break
|
|
return removed
|
|
|
|
async def _remove(self, rec_id: int, path: str) -> None:
|
|
try:
|
|
os.remove(path)
|
|
except FileNotFoundError:
|
|
pass
|
|
except OSError as exc:
|
|
log.warning("не удалось удалить %s: %s", path, exc)
|
|
await self.db.delete_recording(rec_id)
|
|
_cleanup_empty_dirs(os.path.dirname(path))
|
|
|
|
|
|
def _cleanup_empty_dirs(path: str) -> None:
|
|
"""Удаляет пустой каталог дня (и камеры), оставшийся после очистки."""
|
|
for _ in range(2):
|
|
try:
|
|
if path and os.path.isdir(path) and not os.listdir(path):
|
|
os.rmdir(path)
|
|
path = os.path.dirname(path)
|
|
else:
|
|
break
|
|
except OSError:
|
|
break
|