Files
CoRE.Vision/backend/app/recorder/indexer.py
T
git_admin a64749ccc8 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>
2026-06-01 19:31:15 +05:00

114 lines
4.1 KiB
Python

"""Индексатор архива: периодически сканирует /records, заносит новые сегменты в БД.
Логика: самый свежий сегмент каждой камеры ещё пишется ffmpeg-ом — его пропускаем.
Все остальные mp4, которых нет в БД, пробуем через ffprobe и добавляем.
Параллельно убираем из БД записи о пропавших файлах.
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from datetime import datetime
from ..config import Config
from ..db import Database
from .ffmpeg import probe_duration
log = logging.getLogger("nvr.indexer")
SCAN_INTERVAL = 30 # сек
def _parse_started_at(path: str) -> int:
"""Время начала сегмента из имени <cam>/<YYYY-MM-DD_HH-MM-SS>.mp4."""
try:
stem = os.path.splitext(os.path.basename(path))[0]
dt = datetime.strptime(stem, "%Y-%m-%d_%H-%M-%S")
return int(dt.timestamp())
except ValueError:
return int(os.path.getmtime(path))
def _scan_segments(root: str) -> dict[str, list[str]]:
"""Возвращает {camera_id: [пути mp4, отсортированы]}."""
result: dict[str, list[str]] = {}
if not os.path.isdir(root):
return result
for cam_id in os.listdir(root):
cam_dir = os.path.join(root, cam_id)
if not os.path.isdir(cam_dir):
continue
files = [
os.path.join(cam_dir, fn)
for fn in os.listdir(cam_dir)
if fn.endswith(".mp4")
]
files.sort()
if files:
result[cam_id] = files
return result
class Indexer:
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="indexer")
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.scan_once()
except Exception: # noqa: BLE001
log.exception("indexer scan failed")
await asyncio.sleep(SCAN_INTERVAL)
async def scan_once(self) -> int:
active_path = self.config.active_storage().path
known = await self.db.known_paths()
added = 0
# сканируем ВСЕ хранилища: после смены активного старые записи не теряются
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:
if not os.path.exists(path):
await self.db.delete_missing(path)
if added:
log.info("indexer: добавлено сегментов %d", added)
return added