From 24155a84921ca35b7d921abcfa71594dc2676ce1 Mon Sep 17 00:00:00 2001 From: core Date: Mon, 1 Jun 2026 18:35:26 +0500 Subject: [PATCH] =?UTF-8?q?cameras:=20split=20camera=20enable=20(=D0=92?= =?UTF-8?q?=D0=BA=D0=BB)=20from=20REC=20(record-to-disk)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a separate `record` field so a camera can be registered but fully off, or on but not recording: - enabled (Вкл): camera on/off. Disabled = no go2rtc stream (no live), no recording; entry is just stored. build_streams/sync_all/reconcile skip disabled cameras; live grid shows "камера выключена". - record (REC): write segments to disk. Recorder runs only when enabled AND record. Unified POST /api/cameras/{id}/toggle {enabled?, record?} (replaces the /record endpoint); go2rtc is touched only when `enabled` changes, so flipping REC doesn't blip live. Camera table gets Вкл + Запись toggle columns; add/edit forms get both switches. load_config migrates the old schema (no `record` key): old `enabled` meant "record" with live always on -> enabled=True, record=old enabled. VERSION -> 0.0.126. Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSION | 2 +- backend/app/api/cameras.py | 41 +++++++++++++++++++------- backend/app/config.py | 37 ++++++++++++++--------- backend/app/recorder/supervisor.py | 6 ++-- backend/app/services/go2rtc.py | 6 ++++ frontend/settings.html | 2 +- frontend/static/app.js | 47 ++++++++++++++++++++---------- 7 files changed, 97 insertions(+), 44 deletions(-) diff --git a/VERSION b/VERSION index 8624408..f8ee452 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.124 +0.0.126 diff --git a/backend/app/api/cameras.py b/backend/app/api/cameras.py index bb17a73..25a4bc7 100644 --- a/backend/app/api/cameras.py +++ b/backend/app/api/cameras.py @@ -33,6 +33,7 @@ def _camera_from_body(body: dict, cam_id: str) -> Camera: id=cam_id, name=(body.get("name") or cam_id).strip(), enabled=bool(body.get("enabled", True)), + record=bool(body.get("record", True)), ip=ip, user=user, password=password, @@ -50,6 +51,7 @@ def _camera_payload(cam_id: str) -> dict: "id": cam.id, "name": cam.name, "enabled": cam.enabled, + "record": cam.record, "ip": cam.ip, "user": cam.user, "password": cam.password, @@ -255,7 +257,8 @@ async def create_camera(request: Request) -> dict: runtime.config.cameras.append(cam) save_config(runtime.config) runtime.supervisor.add_camera(cam) - await go2rtc.add_camera_streams(runtime.config, cam) + if cam.enabled: + await go2rtc.add_camera_streams(runtime.config, cam) return _camera_payload(cam_id) @@ -270,7 +273,10 @@ async def update_camera(cam_id: str, request: Request) -> dict: runtime.config.cameras[idx] = cam save_config(runtime.config) await runtime.supervisor.replace_camera(cam) - await go2rtc.add_camera_streams(runtime.config, cam) + if cam.enabled: + await go2rtc.add_camera_streams(runtime.config, cam) + else: + await go2rtc.remove_camera_streams(runtime.config, cam_id) return _camera_payload(cam_id) @@ -301,21 +307,34 @@ async def clear_cameras(request: Request) -> dict: return {"ok": True, "removed": len(ids)} -@router.post("/{cam_id}/record") -async def set_record(cam_id: str, request: Request) -> dict: - """Включает/выключает запись камеры на диск (тумблер REC). +@router.post("/{cam_id}/toggle") +async def toggle_camera(cam_id: str, request: Request) -> dict: + """Тумблеры камеры: enabled (вкл/выкл камеру) и/или record (REC — запись на диск). - enabled=False — запись не идёт, но go2rtc/live не трогаем → просмотр работает. - Меняет только рекордер (без пересоздания go2rtc-потоков, чтобы live не мигал).""" + enabled=False — камера выключена полностью: убираем go2rtc-потоки → нет live и записи + (камера остаётся зарегистрированной). record управляет только записью. go2rtc трогаем + лишь при смене enabled, поэтому переключение REC не мигает live.""" require_role(request, "admin") cam = runtime.config.camera(cam_id) if not cam: raise HTTPException(404, "camera not found") body = await request.json() - on = bool(body.get("enabled", True)) + kw = {} + if "enabled" in body: + kw["enabled"] = bool(body["enabled"]) + if "record" in body: + kw["record"] = bool(body["record"]) + if not kw: + raise HTTPException(400, "ожидается enabled и/или record") + 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) - new = replace(cam, enabled=on) runtime.config.cameras[idx] = new save_config(runtime.config) - await runtime.supervisor.replace_camera(new) # старт/стоп записи; go2rtc не трогаем - return {"ok": True, "enabled": on} + if enabled_changed: # потоки go2rtc только при смене вкл/выкл камеры + if new.enabled: + 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} diff --git a/backend/app/config.py b/backend/app/config.py index 3a4c74d..c9a90ec 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -18,6 +18,7 @@ class Camera: password: str main_path: str sub_path: str | None = None + record: bool = True # REC: писать на диск. enabled — камера вообще включена (live+запись) def rtsp_url(self, path: str) -> str: return f"rtsp://{self.user}:{self.password}@{self.ip}:554{path}" @@ -70,27 +71,37 @@ def load_config(path: str | None = None) -> Config: storage = Storage(**(raw.get("storage") or {})) live = Live(**(raw.get("live") or {})) - cameras = [ - Camera( - id=c["id"], - name=c.get("name", c["id"]), - enabled=c.get("enabled", True), - ip=c["ip"], - user=c["user"], - password=c["password"], - main_path=c["main_path"], - sub_path=c.get("sub_path"), - ) - for c in (raw.get("cameras") or []) - ] + cameras = [_camera_from_raw(c) for c in (raw.get("cameras") or [])] return Config(storage=storage, live=live, cameras=cameras) +def _camera_from_raw(c: dict) -> Camera: + # Миграция со старой схемы: раньше `enabled` означал «писать на диск» (live был всегда), + # отдельного «камера выключена» не существовало. Старый файл (без ключа record) трактуем + # так: запись = старый enabled, а сама камера включена. Новый файл (с record) — как есть. + if "record" in c: + enabled, record = bool(c.get("enabled", True)), bool(c["record"]) + else: + enabled, record = True, bool(c.get("enabled", True)) + return Camera( + id=c["id"], + name=c.get("name", c["id"]), + enabled=enabled, + record=record, + ip=c["ip"], + user=c["user"], + password=c["password"], + main_path=c["main_path"], + sub_path=c.get("sub_path"), + ) + + def camera_to_yaml(c: Camera) -> dict: return { "id": c.id, "name": c.name, "enabled": c.enabled, + "record": c.record, "ip": c.ip, "user": c.user, "password": c.password, diff --git a/backend/app/recorder/supervisor.py b/backend/app/recorder/supervisor.py index 6383dee..38e52a0 100644 --- a/backend/app/recorder/supervisor.py +++ b/backend/app/recorder/supervisor.py @@ -26,11 +26,11 @@ class Supervisor: task = RecorderTask(cam, self.config.storage, on_status=self.on_status, input_url=self._input_url(cam)) self.tasks[cam.id] = task - if cam.enabled: + if cam.enabled and cam.record: task.start() log.info("recorder started: %s", cam.id) else: - log.info("recorder skipped (disabled): %s", cam.id) + log.info("recorder skipped (enabled=%s record=%s): %s", cam.enabled, cam.record, cam.id) async def stop_all(self) -> None: for task in self.tasks.values(): @@ -60,7 +60,7 @@ class Supervisor: task = RecorderTask(cam, self.config.storage, on_status=self.on_status, input_url=self._input_url(cam)) self.tasks[cam.id] = task - if cam.enabled: + if cam.enabled and cam.record: task.start() log.info("recorder added: %s", cam.id) diff --git a/backend/app/services/go2rtc.py b/backend/app/services/go2rtc.py index 5721564..ae8fcd7 100644 --- a/backend/app/services/go2rtc.py +++ b/backend/app/services/go2rtc.py @@ -60,6 +60,8 @@ def _streams_for(cam: Camera) -> dict[str, str]: def build_streams(config: Config) -> dict: streams: dict = {} for c in config.cameras: + if not c.enabled: # выключенная камера — без потоков (ни live, ни записи) + continue for name, src in _streams_for(c).items(): streams[name] = [src] return streams @@ -120,6 +122,8 @@ async def sync_all(config: Config) -> None: write_go2rtc_yaml(config) base = config.live.go2rtc_url.rstrip("/") for cam in config.cameras: + if not cam.enabled: + continue for name, src in _streams_for(cam).items(): await _call("PUT", _put(base, name, src)) @@ -151,6 +155,8 @@ async def reconcile(config: Config) -> int: return 0 added = 0 for cam in config.cameras: + if not cam.enabled: # выключенные камеры в go2rtc не держим + continue for name, src in _streams_for(cam).items(): if name not in existing: await _call("PUT", _put(base, name, src)) diff --git a/frontend/settings.html b/frontend/settings.html index ce9cc47..2ce93d2 100644 --- a/frontend/settings.html +++ b/frontend/settings.html @@ -55,7 +55,7 @@ - +
IDКамераIPПотокиЗаписьСтатус
IDКамераIPПотокиВклЗаписьСтатус
diff --git a/frontend/static/app.js b/frontend/static/app.js index 032bcaa..170d44a 100644 --- a/frontend/static/app.js +++ b/frontend/static/app.js @@ -632,6 +632,11 @@ const Live = { setTileMedia(tile, cam, useMain) { this.destroyTilePlayers(tile); tile.querySelectorAll("video, video-stream, .hevc-player, .empty, .hevc-hint").forEach((e) => e.remove()); + if (cam && !cam.enabled) { // камера выключена — ни записи, ни live + const d = document.createElement("div"); + d.className = "empty"; d.textContent = "камера выключена"; + tile.insertBefore(d, tile.firstChild); return; + } if (!this.playing) { const d = document.createElement("div"); d.className = "empty"; d.textContent = "⏸ просмотр остановлен"; @@ -1178,7 +1183,7 @@ const CamSettings = { bulkRowHtml(slotNo, cam) { const v = (x) => this.esc(x); return ( - `` + + `` + `
#${slotNo} · ID ${v(cam.id)}
` + `
` + `` + @@ -1187,7 +1192,8 @@ const CamSettings = { `` + `` + `` + - `` + + `` + + `` + `
` + `` ); @@ -1208,13 +1214,15 @@ const CamSettings = { main_path: get("main_path").trim(), sub_path: get("sub_path").trim(), enabled: g.querySelector('[data-f="enabled"]').checked, + record: g.querySelector('[data-f="record"]').checked, }; const changed = payload.name !== orig.name || payload.ip !== orig.ip || payload.user !== orig.user || payload.password !== (orig.password || "") || payload.main_path !== orig.main_path || (payload.sub_path || "") !== (orig.sub_path || "") || - payload.enabled !== orig.enabled; + payload.enabled !== orig.enabled || + payload.record !== orig.record; if (!changed) continue; const r = await fetch(`/api/cameras/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -1235,7 +1243,7 @@ const CamSettings = { const v = (x) => this.esc(x); const isEdit = !!cam; return ( - `` + + `` + `
` + `` + `` + @@ -1244,7 +1252,8 @@ const CamSettings = { `` + `` + `` + - `` + + `` + + `` + `
` + `
` + `` + @@ -1287,6 +1296,7 @@ const CamSettings = { main_path: val("e-main").trim(), sub_path: val("e-sub").trim(), enabled: document.getElementById("e-enabled").checked, + record: document.getElementById("e-record").checked, }; const url = this.editId ? `/api/cameras/${this.editId}` : "/api/cameras"; const method = this.editId ? "PUT" : "POST"; @@ -1338,14 +1348,18 @@ const CamSettings = { if (c) { const st = (c.status && c.status.state) || "offline"; const streams = "осн. 101" + (c.has_sub ? " + суб. 102" : ""); + const statusCell = c.enabled + ? `${STATE_RU[st] || st}` + : `выключена`; tr.dataset.id = c.id; tr.dataset.slot = i + 1; tr.innerHTML = `${i + 1}` + `${c.name}` + `${c.ip}` + `${streams}` + - `` + - `${STATE_RU[st] || st}` + + `` + + `` + + `${statusCell}` + `` + ` ` + ` ` + @@ -1355,14 +1369,14 @@ const CamSettings = { const logTr = document.createElement("tr"); logTr.className = "logrow"; const hid = openLogs.has("log-" + c.id) ? "" : "hidden"; - logTr.innerHTML = `
`; + logTr.innerHTML = `
`; tb.appendChild(logTr); } else { tr.className = "slot-empty"; tr.dataset.slot = i + 1; tr.innerHTML = `${i + 1}` + - `————` + + `—————` + `пусто` + ``; tb.appendChild(tr); @@ -1397,18 +1411,21 @@ const CamSettings = { } }; - tb.onchange = async (e) => { // тумблер REC в строке — старт/стоп записи на диск - const cb = e.target.closest(".rec-toggle"); + tb.onchange = async (e) => { // тумблеры в строке: «Вкл» (камера) и «REC» (запись) + const en = e.target.closest(".en-toggle"); + const cb = en || e.target.closest(".rec-toggle"); if (!cb) return; const id = cb.dataset.id; + const patch = en ? { enabled: cb.checked } : { record: cb.checked }; cb.disabled = true; try { - const r = await fetch(`/api/cameras/${id}/record`, { + const r = await fetch(`/api/cameras/${id}/toggle`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enabled: cb.checked }), + body: JSON.stringify(patch), }); - if (!r.ok) { cb.checked = !cb.checked; const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); } - else if (this.byId[id]) { this.byId[id].enabled = cb.checked; } + if (!r.ok) { cb.checked = !cb.checked; const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); return; } + if (this.byId[id]) Object.assign(this.byId[id], patch); + await this.load(); // перерисовать: REC зависит от состояния «Вкл» } catch (err) { cb.checked = !cb.checked; alert("Сбой запроса"); } finally { cb.disabled = false; } };