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>
158 lines
5.8 KiB
Python
158 lines
5.8 KiB
Python
"""SQLite-индекс архива (aiosqlite)."""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import time
|
||
|
||
import aiosqlite
|
||
|
||
from .models import Recording
|
||
|
||
|
||
def _like_prefix(prefix: str) -> str:
|
||
"""LIKE-шаблон для путей внутри хранилища: '<path>/%' с экранированием %, _, \\."""
|
||
p = prefix.rstrip("/") + "/"
|
||
p = p.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||
return p + "%"
|
||
|
||
|
||
SCHEMA = """
|
||
CREATE TABLE IF NOT EXISTS recordings (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
camera_id TEXT NOT NULL,
|
||
path TEXT NOT NULL UNIQUE,
|
||
started_at INTEGER NOT NULL,
|
||
ended_at INTEGER,
|
||
duration_s REAL,
|
||
size_bytes INTEGER,
|
||
created_at INTEGER NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_rec_cam_time ON recordings(camera_id, started_at);
|
||
|
||
CREATE TABLE IF NOT EXISTS events (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
camera_id TEXT NOT NULL,
|
||
type TEXT NOT NULL,
|
||
ts INTEGER NOT NULL,
|
||
meta TEXT
|
||
);
|
||
"""
|
||
|
||
|
||
class Database:
|
||
def __init__(self, path: str | None = None):
|
||
self.path = path or os.environ.get("NVR_DB", "/data/nvr.db")
|
||
self._conn: aiosqlite.Connection | None = None
|
||
|
||
async def connect(self) -> None:
|
||
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
|
||
self._conn = await aiosqlite.connect(self.path)
|
||
self._conn.row_factory = aiosqlite.Row
|
||
await self._conn.executescript(SCHEMA)
|
||
await self._conn.commit()
|
||
|
||
async def close(self) -> None:
|
||
if self._conn:
|
||
await self._conn.close()
|
||
self._conn = None
|
||
|
||
@property
|
||
def conn(self) -> aiosqlite.Connection:
|
||
if self._conn is None:
|
||
raise RuntimeError("Database not connected")
|
||
return self._conn
|
||
|
||
# ── recordings ─────────────────────────────────────────────
|
||
async def has_path(self, path: str) -> bool:
|
||
cur = await self.conn.execute("SELECT 1 FROM recordings WHERE path = ?", (path,))
|
||
return await cur.fetchone() is not None
|
||
|
||
async def add_recording(
|
||
self, camera_id: str, path: str, started_at: int,
|
||
duration_s: float | None, size_bytes: int | None,
|
||
) -> None:
|
||
ended_at = int(started_at + duration_s) if duration_s else None
|
||
await self.conn.execute(
|
||
"""INSERT OR IGNORE INTO recordings
|
||
(camera_id, path, started_at, ended_at, duration_s, size_bytes, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||
(camera_id, path, started_at, ended_at, duration_s, size_bytes, int(time.time())),
|
||
)
|
||
await self.conn.commit()
|
||
|
||
async def list_recordings(
|
||
self, camera_id: str | None, ts_from: int | None, ts_to: int | None,
|
||
limit: int = 2000,
|
||
) -> list[Recording]:
|
||
sql = "SELECT * FROM recordings WHERE 1=1"
|
||
args: list = []
|
||
if camera_id:
|
||
sql += " AND camera_id = ?"
|
||
args.append(camera_id)
|
||
if ts_from is not None:
|
||
sql += " AND started_at >= ?"
|
||
args.append(ts_from)
|
||
if ts_to is not None:
|
||
sql += " AND started_at <= ?"
|
||
args.append(ts_to)
|
||
sql += " ORDER BY started_at ASC LIMIT ?"
|
||
args.append(limit)
|
||
cur = await self.conn.execute(sql, args)
|
||
rows = await cur.fetchall()
|
||
return [Recording(**dict(r)) for r in rows]
|
||
|
||
async def get_recording(self, rec_id: int) -> Recording | None:
|
||
cur = await self.conn.execute("SELECT * FROM recordings WHERE id = ?", (rec_id,))
|
||
row = await cur.fetchone()
|
||
return Recording(**dict(row)) if row else None
|
||
|
||
async def oldest_recordings(self, limit: int = 50) -> list[Recording]:
|
||
cur = await self.conn.execute(
|
||
"SELECT * FROM recordings ORDER BY started_at ASC LIMIT ?", (limit,)
|
||
)
|
||
rows = await cur.fetchall()
|
||
return [Recording(**dict(r)) for r in rows]
|
||
|
||
# ── per-storage (по префиксу пути) — для квоты на каждое хранилище ──
|
||
async def total_size_under(self, prefix: str) -> int:
|
||
cur = await self.conn.execute(
|
||
"SELECT COALESCE(SUM(size_bytes), 0) FROM recordings WHERE path LIKE ? ESCAPE '\\'",
|
||
(_like_prefix(prefix),),
|
||
)
|
||
row = await cur.fetchone()
|
||
return int(row[0]) if row else 0
|
||
|
||
async def oldest_recordings_under(self, prefix: str, limit: int = 50) -> list[Recording]:
|
||
cur = await self.conn.execute(
|
||
"SELECT * FROM recordings WHERE path LIKE ? ESCAPE '\\' "
|
||
"ORDER BY started_at ASC LIMIT ?",
|
||
(_like_prefix(prefix), limit),
|
||
)
|
||
rows = await cur.fetchall()
|
||
return [Recording(**dict(r)) for r in rows]
|
||
|
||
async def recordings_before(self, ts: int) -> list[Recording]:
|
||
cur = await self.conn.execute(
|
||
"SELECT * FROM recordings WHERE started_at < ? ORDER BY started_at ASC", (ts,)
|
||
)
|
||
rows = await cur.fetchall()
|
||
return [Recording(**dict(r)) for r in rows]
|
||
|
||
async def delete_recording(self, rec_id: int) -> None:
|
||
await self.conn.execute("DELETE FROM recordings WHERE id = ?", (rec_id,))
|
||
await self.conn.commit()
|
||
|
||
async def delete_missing(self, path: str) -> None:
|
||
await self.conn.execute("DELETE FROM recordings WHERE path = ?", (path,))
|
||
await self.conn.commit()
|
||
|
||
async def total_size(self) -> int:
|
||
cur = await self.conn.execute("SELECT COALESCE(SUM(size_bytes), 0) FROM recordings")
|
||
row = await cur.fetchone()
|
||
return int(row[0]) if row else 0
|
||
|
||
async def known_paths(self) -> set[str]:
|
||
cur = await self.conn.execute("SELECT path FROM recordings")
|
||
rows = await cur.fetchall()
|
||
return {r[0] for r in rows}
|