From 847c87fd7e3989446f23fa00c3fece9c21953bf6 Mon Sep 17 00:00:00 2001 From: core Date: Tue, 2 Jun 2026 14:02:09 +0500 Subject: [PATCH] v158 --- VERSION | 2 +- backend/app/api/cameras.py | 11 ++++++-- backend/app/config.py | 23 +++++++++++++++++ backend/app/recorder/ffmpeg.py | 7 ++--- backend/app/recorder/indexer.py | 37 ++++++++++++++++++--------- backend/app/recorder/recorder_task.py | 10 +++++--- backend/app/recorder/supervisor.py | 3 ++- frontend/index.html | 1 + frontend/static/app.js | 14 +++++----- 9 files changed, 79 insertions(+), 29 deletions(-) diff --git a/VERSION b/VERSION index 41debd6..96fb5fd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.155 +0.0.158 diff --git a/backend/app/api/cameras.py b/backend/app/api/cameras.py index c1a97f7..9ba458b 100644 --- a/backend/app/api/cameras.py +++ b/backend/app/api/cameras.py @@ -285,12 +285,19 @@ async def update_camera(cam_id: str, request: Request) -> dict: @router.delete("/{cam_id}") async def delete_camera(cam_id: str, request: Request) -> dict: require_role(request, "admin") - if not runtime.config.camera(cam_id): + cams = runtime.config.cameras + idx = next((i for i, c in enumerate(cams) if c.id == cam_id), None) + if idx is None: raise HTTPException(404, "camera not found") - runtime.config.cameras = [c for c in runtime.config.cameras if c.id != cam_id] + # удаление из середины сдвигает слоты последующих камер → их папки записи + # (номер слота) меняются. Перезапускаем запись, чтобы ffmpeg писал в новую папку. + shifts = idx < len(cams) - 1 + runtime.config.cameras = [c for c in cams if c.id != cam_id] save_config(runtime.config) await runtime.supervisor.remove_camera(cam_id) await go2rtc.remove_camera_streams(runtime.config, cam_id) + if shifts: + await runtime.supervisor.restart_all() return {"ok": True} diff --git a/backend/app/config.py b/backend/app/config.py index a7049ea..08f1470 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -89,6 +89,29 @@ class Config: def enabled_cameras(self) -> list[Camera]: return [c for c in self.cameras if c.enabled] + # ── слоты записи ──────────────────────────────────────────── + # Папка камеры в хранилище = номер её слота (позиция в списке, 1-based) с + # ведущим нулём: слот 5 → «05», слот 12 → «12». Так совпадает со столбцом ID в UI. + def slot_of(self, cam_id: str) -> int | None: + for i, c in enumerate(self.cameras, start=1): + if c.id == cam_id: + return i + return None + + def archive_dir(self, cam_id: str) -> str: + """Имя подпапки записи камеры в хранилище. Откат на id, если камеры нет в списке.""" + slot = self.slot_of(cam_id) + return f"{slot:02d}" if slot is not None else cam_id + + def camera_by_archive_dir(self, name: str) -> Camera | None: + """Обратное: имя папки-слота («05») → камера в этом слоте. None для нечисловых + (легаси-папки по старому id индексируются под своим именем как раньше).""" + if name.isdigit(): + idx = int(name) + if 1 <= idx <= len(self.cameras): + return self.cameras[idx - 1] + return None + # ── хранилища ─────────────────────────────────────────────── def storage_by_id(self, sid: str) -> StorageItem | None: return next((s for s in self.storage.items if s.id == sid), None) diff --git a/backend/app/recorder/ffmpeg.py b/backend/app/recorder/ffmpeg.py index f9b638e..1d377f9 100644 --- a/backend/app/recorder/ffmpeg.py +++ b/backend/app/recorder/ffmpeg.py @@ -9,17 +9,18 @@ from ..config import Camera def record_args(cam: Camera, out_root: str, segment_seconds: int, - input_url: str | None = None) -> list[str]: + input_url: str | None = None, sub_dir: str | None = None) -> list[str]: """ffmpeg для записи основного потока сегментами mp4 копированием. -c copy => CPU почти не используется. -strftime даёт имена сегментов по времени. out_root — корень активного хранилища (внутри пишем подкаталог камеры). + sub_dir — имя подпапки камеры (номер слота, напр. «05»); по умолчанию — id камеры. input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию). """ - # Подпапка даты: ///.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") + out_template = os.path.join(out_root, sub_dir or 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 0d0d1aa..3d7e6e0 100644 --- a/backend/app/recorder/indexer.py +++ b/backend/app/recorder/indexer.py @@ -32,16 +32,17 @@ def _parse_started_at(path: str) -> int: def _scan_segments(root: str) -> dict[str, list[str]]: - """Возвращает {camera_id: [пути mp4, отсортированы]}. + """Возвращает {имя_папки: [пути mp4, отсортированы]} (папка = номер слота или + легаси-id камеры — сопоставление в камеру делает вызывающий). - Файлы лежат в подпапке даты //*.mp4; старые «плоские» - /*.mp4 тоже подхватываем (обратная совместимость). Сортировка по полному + Файлы лежат в подпапке даты //*.mp4; старые «плоские» + /*.mp4 тоже подхватываем (обратная совместимость). Сортировка по полному пути == хронологическая (ISO-даты и в каталоге, и в имени).""" 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) + for folder in os.listdir(root): + cam_dir = os.path.join(root, folder) if not os.path.isdir(cam_dir): continue files: list[str] = [] @@ -58,7 +59,7 @@ def _scan_segments(root: str) -> dict[str, list[str]]: # из-за чего пишущийся сейчас файл в подпапке не оказывался бы последним) files.sort(key=os.path.basename) if files: - result[cam_id] = files + result[folder] = files return result @@ -102,9 +103,10 @@ class Indexer: 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 + sub = self.config.archive_dir(cam.id) # папка-слот, напр. «05» for d in days: try: - os.makedirs(os.path.join(store.path, cam.id, d), exist_ok=True) + os.makedirs(os.path.join(store.path, sub, d), exist_ok=True) except OSError: pass @@ -115,12 +117,23 @@ class Indexer: # сканируем ВСЕ хранилища: после смены активного старые записи не теряются for root in self.config.storage_paths(): - by_cam = _scan_segments(root) + by_dir = _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 folder, files in by_dir.items(): + # Сопоставление папки с камерой по приоритету: + # 1) точное совпадение с id камеры — легаси-папка по id (в т.ч. числовому), + # иначе папка камеры с id «5» спуталась бы со слотом 5; + # 2) папка-слот «NN» → камера в этом слоте (новая схема); + # 3) иначе оставляем имя папки (осиротевший архив индексируется как раньше). + cam = self.config.camera(folder) or self.config.camera_by_archive_dir(folder) + cam_id = cam.id if cam else folder + # последний файл ещё пишется только в активной папке-слоте ТЕКУЩЕЙ камеры; + # легаси-папки и неактивное хранилище дописаны — индексируем целиком. + being_written = ( + is_active and cam is not None + and self.config.archive_dir(cam.id) == folder + ) + complete = files[:-1] if being_written else files for path in complete: if path in known: continue diff --git a/backend/app/recorder/recorder_task.py b/backend/app/recorder/recorder_task.py index e0fae83..7dfad9a 100644 --- a/backend/app/recorder/recorder_task.py +++ b/backend/app/recorder/recorder_task.py @@ -23,10 +23,11 @@ class RecorderTask: """Держит один процесс ffmpeg на камеру, перезапускает при падении.""" def __init__(self, cam: Camera, store: StorageItem, segment_seconds: int, - on_status=None, input_url: str | None = None): + on_status=None, input_url: str | None = None, sub_dir: str | None = None): self.cam = cam self.store = store # активное хранилище (куда пишем) self.segment_seconds = segment_seconds + self.sub_dir = sub_dir or cam.id # папка камеры в хранилище (номер слота, напр. «05») self.input_url = input_url # источник записи (рестрим go2rtc) self.on_status = on_status # callback(CameraStatus) для WS-уведомлений self.status = CameraStatus( @@ -84,9 +85,9 @@ class RecorderTask: continue try: - # подпапки даты / — сегодня и завтра (ffmpeg их не создаёт); + # подпапки даты / — сегодня и завтра (ffmpeg их не создаёт); # индексатор продолжает поддерживать их каждые 30с (переживает полночь) - base = os.path.join(self.store.path, self.cam.id) + base = os.path.join(self.store.path, self.sub_dir) 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) @@ -97,7 +98,8 @@ class RecorderTask: continue self._set_state(CameraState.STARTING) - args = record_args(self.cam, self.store.path, self.segment_seconds, self.input_url) + args = record_args(self.cam, self.store.path, self.segment_seconds, + self.input_url, sub_dir=self.sub_dir) log.info("[%s] start ffmpeg: %s", self.cam.id, _redact(args, self.cam)) try: self._proc = await asyncio.create_subprocess_exec( diff --git a/backend/app/recorder/supervisor.py b/backend/app/recorder/supervisor.py index ad994a1..2aa6977 100644 --- a/backend/app/recorder/supervisor.py +++ b/backend/app/recorder/supervisor.py @@ -26,7 +26,8 @@ class Supervisor: # 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)) + on_status=self.on_status, input_url=self._input_url(cam), + sub_dir=self.config.archive_dir(cam.id)) def start_all(self) -> None: for cam in self.config.cameras: diff --git a/frontend/index.html b/frontend/index.html index a18eb86..6560f3c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -28,6 +28,7 @@
+ diff --git a/frontend/static/app.js b/frontend/static/app.js index 7176556..04f6923 100644 --- a/frontend/static/app.js +++ b/frontend/static/app.js @@ -547,13 +547,13 @@ const Live = { initialDim() { const max = this.maxDim || 8; const q = parseInt(new URLSearchParams(location.search).get("grid"), 10); - if (q >= 2 && q <= 8) return Math.min(q, max); + if (q >= 1 && q <= 8) return Math.min(q, max); const saved = parseInt(localStorage.getItem("corevision.grid"), 10); - return saved >= 2 ? Math.min(saved, max) : 2; + return saved >= 1 ? Math.min(saved, max) : 2; }, setDim(dim, rerender = true) { - this.dim = Math.min(this.maxDim || 8, Math.max(2, dim)); + this.dim = Math.min(this.maxDim || 8, Math.max(1, dim)); this.layout = this.dim * this.dim; localStorage.setItem("corevision.grid", String(this.dim)); this.page = 0; // смена размера сетки → первая страница @@ -1167,6 +1167,8 @@ const Archive = { // ════════════════════════════════════════════════════════════════ const CamSettings = { SLOTS: 64, + // номер слота с ведущим нулём: 1‑9 → 01‑09, 10+ без изменений + slotId(n) { return String(n).padStart(2, "0"); }, editing: false, // идёт инлайн-редактирование — не перерисовываем по WS bulk: false, // режим массового редактирования всей таблицы cams: [], @@ -1266,7 +1268,7 @@ const CamSettings = { const record = cam ? cam.record : true; const storageId = cam ? cam.storage_id : ""; return ( - `${slotNo}` + + `${this.slotId(slotNo)}` + `` + `` + `` + @@ -1462,7 +1464,7 @@ const CamSettings = { : `выключена`; tr.dataset.id = c.id; tr.dataset.slot = i + 1; tr.innerHTML = - `${i + 1}` + + `${this.slotId(i + 1)}` + `${c.name}` + `${c.ip}` + `${streams}` + @@ -1476,7 +1478,7 @@ const CamSettings = { tr.className = "slot-empty"; tr.dataset.slot = i + 1; tr.innerHTML = - `${i + 1}` + + `${this.slotId(i + 1)}` + `——————` + `пусто` + ``;