diff --git a/VERSION b/VERSION index 3ac9c0d..5e7d8c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.127 +0.0.129 diff --git a/backend/app/api/cameras.py b/backend/app/api/cameras.py index 25a4bc7..c1a97f7 100644 --- a/backend/app/api/cameras.py +++ b/backend/app/api/cameras.py @@ -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} diff --git a/backend/app/config.py b/backend/app/config.py index 698beaf..a7049ea 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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, } diff --git a/backend/app/recorder/ffmpeg.py b/backend/app/recorder/ffmpeg.py index 4422194..f9b638e 100644 --- a/backend/app/recorder/ffmpeg.py +++ b/backend/app/recorder/ffmpeg.py @@ -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") + # Подпапка даты: ///.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", diff --git a/backend/app/recorder/indexer.py b/backend/app/recorder/indexer.py index 6cd81ce..0d0d1aa 100644 --- a/backend/app/recorder/indexer.py +++ b/backend/app/recorder/indexer.py @@ -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, отсортированы]}. + + Файлы лежат в подпапке даты //*.mp4; старые «плоские» + /*.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() diff --git a/backend/app/recorder/recorder_task.py b/backend/app/recorder/recorder_task.py index c7447e6..e0fae83 100644 --- a/backend/app/recorder/recorder_task.py +++ b/backend/app/recorder/recorder_task.py @@ -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) + # подпапки даты / — сегодня и завтра (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) diff --git a/backend/app/recorder/supervisor.py b/backend/app/recorder/supervisor.py index 3600430..ad994a1 100644 --- a/backend/app/recorder/supervisor.py +++ b/backend/app/recorder/supervisor.py @@ -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: diff --git a/frontend/settings.html b/frontend/settings.html index d24ba17..cfc7b34 100644 --- a/frontend/settings.html +++ b/frontend/settings.html @@ -56,7 +56,7 @@ - +
IDКамераIPПотокиВклЗаписьСтатус
IDКамераIPПотокиВклЗаписьХранилищеСтатус
@@ -140,16 +140,16 @@
+ +
+

Активное хранилище — сводка

+
загрузка…
+