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:
@@ -8,6 +8,14 @@ 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,
|
||||
@@ -105,6 +113,24 @@ class Database:
|
||||
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,)
|
||||
|
||||
Reference in New Issue
Block a user