storage: per-camera target + date subfolders; move active-storage widget

- Camera.storage_id: each camera chooses its storage ("" = active/default).
  supervisor resolves per-camera (storage_by_id → active fallback); cameras
  API (body/payload) + /toggle accept storage_id and repoint the recorder.
- Recordings now go to <root>/<cam>/<YYYY-MM-DD>/<file>.mp4. This ffmpeg build
  lacks -strftime_mkdir, so recorder_task pre-creates today+tomorrow dirs at
  start and the indexer re-ensures them each tick (survives midnight).
- indexer: scan date subdirs + legacy flat files; sort by basename so the
  in-progress segment is detected correctly when both layouts coexist.
- frontend: per-camera "Хранилище" dropdown in the camera table (inline) and
  in the add/edit + bulk forms. Move the active-storage summary widget from
  the Система tab into the Хранилища tab.

VERSION 0.0.129.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 19:47:44 +05:00
parent a64749ccc8
commit 8e4844111b
9 changed files with 114 additions and 40 deletions
+7 -3
View File
@@ -39,6 +39,7 @@ def _camera_from_body(body: dict, cam_id: str) -> Camera:
password=password,
main_path=(body.get("main_path") or DEFAULT_MAIN).strip(),
sub_path=sub,
storage_id=(body.get("storage_id") or "").strip(),
)
@@ -57,6 +58,7 @@ def _camera_payload(cam_id: str) -> dict:
"password": cam.password,
"main_path": cam.main_path,
"sub_path": cam.sub_path,
"storage_id": cam.storage_id,
"has_sub": has_sub,
"status": status.to_dict() if status else None,
# live-источники go2rtc: raw (как есть) и h264 (перекодирование по опции)
@@ -324,8 +326,10 @@ async def toggle_camera(cam_id: str, request: Request) -> dict:
kw["enabled"] = bool(body["enabled"])
if "record" in body:
kw["record"] = bool(body["record"])
if "storage_id" in body: # смена хранилища записи камеры
kw["storage_id"] = (body["storage_id"] or "").strip()
if not kw:
raise HTTPException(400, "ожидается enabled и/или record")
raise HTTPException(400, "ожидается enabled, record и/или storage_id")
enabled_changed = "enabled" in kw and kw["enabled"] != cam.enabled
new = replace(cam, **kw)
idx = next(i for i, c in enumerate(runtime.config.cameras) if c.id == cam_id)
@@ -336,5 +340,5 @@ async def toggle_camera(cam_id: str, request: Request) -> dict:
await go2rtc.add_camera_streams(runtime.config, new)
else:
await go2rtc.remove_camera_streams(runtime.config, cam_id)
await runtime.supervisor.replace_camera(new) # рекордер запускается при enabled AND record
return {"ok": True, "enabled": new.enabled, "record": new.record}
await runtime.supervisor.replace_camera(new) # рекордер: репойнт на новое хранилище / enabled AND record
return {"ok": True, "enabled": new.enabled, "record": new.record, "storage_id": new.storage_id}
+3
View File
@@ -19,6 +19,7 @@ class Camera:
main_path: str
sub_path: str | None = None
record: bool = True # REC: писать на диск. enabled — камера вообще включена (live+запись)
storage_id: str = "" # в какое хранилище писать ("" = активное/по умолчанию)
def rtsp_url(self, path: str) -> str:
return f"rtsp://{self.user}:{self.password}@{self.ip}:554{path}"
@@ -141,6 +142,7 @@ def _camera_from_raw(c: dict) -> Camera:
password=c["password"],
main_path=c["main_path"],
sub_path=c.get("sub_path"),
storage_id=c.get("storage_id", "") or "",
)
@@ -198,6 +200,7 @@ def camera_to_yaml(c: Camera) -> dict:
"password": c.password,
"main_path": c.main_path,
"sub_path": c.sub_path,
"storage_id": c.storage_id,
}
+4 -4
View File
@@ -16,10 +16,10 @@ def record_args(cam: Camera, out_root: str, segment_seconds: int,
out_root — корень активного хранилища (внутри пишем подкаталог камеры).
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
"""
# Плоско в каталоге камеры: дата в имени файла. Каталог создаётся один раз
# (recorder_task), новых подкаталогов не нужно — корректно переживает полночь.
# Подкаталог даты не используем: этот ffmpeg-билд не знает -strftime_mkdir.
out_template = os.path.join(out_root, cam.id, "%Y-%m-%d_%H-%M-%S.mp4")
# Подпапка даты: <root>/<cam>/<YYYY-MM-DD>/<YYYY-MM-DD_HH-MM-SS>.mp4.
# Этот ffmpeg-билд НЕ знает -strftime_mkdir, поэтому каталоги даты (сегодня+завтра)
# заранее создаёт recorder_task при старте и индексатор каждые 30с (переживает полночь).
out_template = os.path.join(out_root, cam.id, "%Y-%m-%d", "%Y-%m-%d_%H-%M-%S.mp4")
return [
"ffmpeg",
"-nostdin",
+37 -8
View File
@@ -10,7 +10,7 @@ import asyncio
import logging
import os
import time
from datetime import datetime
from datetime import datetime, timedelta
from ..config import Config
from ..db import Database
@@ -32,7 +32,11 @@ def _parse_started_at(path: str) -> int:
def _scan_segments(root: str) -> dict[str, list[str]]:
"""Возвращает {camera_id: [пути mp4, отсортированы]}."""
"""Возвращает {camera_id: [пути mp4, отсортированы]}.
Файлы лежат в подпапке даты <cam>/<YYYY-MM-DD>/*.mp4; старые «плоские»
<cam>/*.mp4 тоже подхватываем (обратная совместимость). Сортировка по полному
пути == хронологическая (ISO-даты и в каталоге, и в имени)."""
result: dict[str, list[str]] = {}
if not os.path.isdir(root):
return result
@@ -40,12 +44,19 @@ def _scan_segments(root: str) -> dict[str, list[str]]:
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()
files: list[str] = []
for entry in os.listdir(cam_dir):
p = os.path.join(cam_dir, entry)
if entry.endswith(".mp4") and os.path.isfile(p):
files.append(p) # старый плоский формат
elif os.path.isdir(p): # подпапка даты
for fn in os.listdir(p):
if fn.endswith(".mp4"):
files.append(os.path.join(p, fn))
# сортируем по ИМЕНИ файла (YYYY-MM-DD_HH-MM-SS) — хронологически независимо от того,
# лежит файл плоско или в подпапке даты (полный путь сортировал бы '/' < '_' неверно,
# из-за чего пишущийся сейчас файл в подпапке не оказывался бы последним)
files.sort(key=os.path.basename)
if files:
result[cam_id] = files
return result
@@ -74,11 +85,29 @@ class Indexer:
async def _loop(self) -> None:
while not self._stopping:
try:
self._ensure_date_dirs()
await self.scan_once()
except Exception: # noqa: BLE001
log.exception("indexer scan failed")
await asyncio.sleep(SCAN_INTERVAL)
def _ensure_date_dirs(self) -> None:
"""Создаёт каталоги даты (сегодня+завтра) для пишущих камер заранее: ffmpeg
не умеет -strftime_mkdir, без готового каталога запись встала бы в полночь."""
today = datetime.now()
days = [today.strftime("%Y-%m-%d"), (today + timedelta(days=1)).strftime("%Y-%m-%d")]
for cam in self.config.cameras:
if not (cam.enabled and cam.record):
continue
store = self.config.storage_by_id(cam.storage_id) or self.config.active_storage()
if store.type == "nas" and not os.path.ismount(store.path):
continue
for d in days:
try:
os.makedirs(os.path.join(store.path, cam.id, d), exist_ok=True)
except OSError:
pass
async def scan_once(self) -> int:
active_path = self.config.active_storage().path
known = await self.db.known_paths()
+7 -1
View File
@@ -6,6 +6,7 @@ import logging
import os
import time
from collections import deque
from datetime import datetime, timedelta
from ..config import Camera, StorageItem
from ..models import CameraState, CameraStatus
@@ -83,7 +84,12 @@ class RecorderTask:
continue
try:
os.makedirs(os.path.join(self.store.path, self.cam.id), exist_ok=True)
# подпапки даты <cam>/<YYYY-MM-DD> — сегодня и завтра (ffmpeg их не создаёт);
# индексатор продолжает поддерживать их каждые 30с (переживает полночь)
base = os.path.join(self.store.path, self.cam.id)
now = datetime.now()
for d in (now, now + timedelta(days=1)):
os.makedirs(os.path.join(base, d.strftime("%Y-%m-%d")), exist_ok=True)
except OSError as exc:
self._set_state(CameraState.RETRYING, f"mkdir: {exc}")
backoff = min(backoff * 2, BACKOFF_MAX)
+4 -3
View File
@@ -22,9 +22,10 @@ class Supervisor:
return go2rtc.restream_main_url(self.config, cam.id)
def _new_task(self, cam: Camera) -> RecorderTask:
# пишем в активное хранилище; смена активного → restart_all репойнтит все задачи
active = self.config.active_storage()
return RecorderTask(cam, active, self.config.storage.segment_seconds,
# хранилище камеры: её storage_id, иначе активное (по умолчанию). Несуществующий
# id (удалённое хранилище) → тоже откат на активное.
store = self.config.storage_by_id(cam.storage_id) or self.config.active_storage()
return RecorderTask(cam, store, self.config.storage.segment_seconds,
on_status=self.on_status, input_url=self._input_url(cam))
def start_all(self) -> None: