cameras: surface per-camera REC (record-to-disk) toggle
`enabled` already gated recording only (go2rtc/live work regardless), but
it was a buried "Вкл" checkbox. Make it an explicit REC control:
- New "Запись" column with an inline REC switch in each camera row.
- Relabel the add/edit form toggle "Вкл" -> "REC" + tooltip.
- New POST /api/cameras/{id}/record {enabled} that flips recording through
the supervisor only (no go2rtc re-sync, so live view doesn't blip).
Off = view-only (live via go2rtc); on = record segments to disk.
VERSION -> 0.0.124.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
@@ -298,3 +299,23 @@ async def clear_cameras(request: Request) -> dict:
|
|||||||
await runtime.supervisor.remove_camera(cam_id)
|
await runtime.supervisor.remove_camera(cam_id)
|
||||||
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
||||||
return {"ok": True, "removed": len(ids)}
|
return {"ok": True, "removed": len(ids)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{cam_id}/record")
|
||||||
|
async def set_record(cam_id: str, request: Request) -> dict:
|
||||||
|
"""Включает/выключает запись камеры на диск (тумблер REC).
|
||||||
|
|
||||||
|
enabled=False — запись не идёт, но go2rtc/live не трогаем → просмотр работает.
|
||||||
|
Меняет только рекордер (без пересоздания go2rtc-потоков, чтобы 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))
|
||||||
|
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}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<table class="cams">
|
<table class="cams">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th style="width:48px">ID</th><th>Камера</th><th>IP</th><th>Потоки</th><th>Статус</th><th></th></tr>
|
<tr><th style="width:48px">ID</th><th>Камера</th><th>IP</th><th>Потоки</th><th style="width:72px">Запись</th><th>Статус</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="cam-rows"></tbody>
|
<tbody id="cam-rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+23
-6
@@ -1178,7 +1178,7 @@ const CamSettings = {
|
|||||||
bulkRowHtml(slotNo, cam) {
|
bulkRowHtml(slotNo, cam) {
|
||||||
const v = (x) => this.esc(x);
|
const v = (x) => this.esc(x);
|
||||||
return (
|
return (
|
||||||
`<td colspan="6" class="cam-edit">` +
|
`<td colspan="7" class="cam-edit">` +
|
||||||
`<div class="cam-bulk-head">#${slotNo} · ID ${v(cam.id)}</div>` +
|
`<div class="cam-bulk-head">#${slotNo} · ID ${v(cam.id)}</div>` +
|
||||||
`<div class="cam-edit-grid" data-cam="${v(cam.id)}">` +
|
`<div class="cam-edit-grid" data-cam="${v(cam.id)}">` +
|
||||||
`<label>Имя<input type="text" data-f="name" value="${v(cam.name)}"></label>` +
|
`<label>Имя<input type="text" data-f="name" value="${v(cam.name)}"></label>` +
|
||||||
@@ -1187,7 +1187,7 @@ const CamSettings = {
|
|||||||
`<label>Пароль<input type="password" data-f="password" value="${v(cam.password)}"></label>` +
|
`<label>Пароль<input type="password" data-f="password" value="${v(cam.password)}"></label>` +
|
||||||
`<label>Осн. поток<input type="text" data-f="main_path" value="${v(cam.main_path)}"></label>` +
|
`<label>Осн. поток<input type="text" data-f="main_path" value="${v(cam.main_path)}"></label>` +
|
||||||
`<label>Субпоток<input type="text" data-f="sub_path" value="${v(cam.sub_path)}" placeholder="можно пусто"></label>` +
|
`<label>Субпоток<input type="text" data-f="sub_path" value="${v(cam.sub_path)}" placeholder="можно пусто"></label>` +
|
||||||
`<label class="sw">Вкл<input type="checkbox" data-f="enabled" ${cam.enabled ? "checked" : ""}></label>` +
|
`<label class="sw" title="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" data-f="enabled" ${cam.enabled ? "checked" : ""}></label>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`</td>`
|
`</td>`
|
||||||
);
|
);
|
||||||
@@ -1235,7 +1235,7 @@ const CamSettings = {
|
|||||||
const v = (x) => this.esc(x);
|
const v = (x) => this.esc(x);
|
||||||
const isEdit = !!cam;
|
const isEdit = !!cam;
|
||||||
return (
|
return (
|
||||||
`<td colspan="6" class="cam-edit">` +
|
`<td colspan="7" class="cam-edit">` +
|
||||||
`<div class="cam-edit-grid">` +
|
`<div class="cam-edit-grid">` +
|
||||||
`<label>ID<input type="text" id="e-id" value="${cam ? v(cam.id) : ""}" ${isEdit ? "disabled" : ""} placeholder="cam${slotNo}"></label>` +
|
`<label>ID<input type="text" id="e-id" value="${cam ? v(cam.id) : ""}" ${isEdit ? "disabled" : ""} placeholder="cam${slotNo}"></label>` +
|
||||||
`<label>Имя<input type="text" id="e-name" value="${cam ? v(cam.name) : ""}" placeholder="Камера ${slotNo}"></label>` +
|
`<label>Имя<input type="text" id="e-name" value="${cam ? v(cam.name) : ""}" placeholder="Камера ${slotNo}"></label>` +
|
||||||
@@ -1244,7 +1244,7 @@ const CamSettings = {
|
|||||||
`<label>Пароль<input type="password" id="e-pass" value="${cam ? v(cam.password) : ""}"></label>` +
|
`<label>Пароль<input type="password" id="e-pass" value="${cam ? v(cam.password) : ""}"></label>` +
|
||||||
`<label>Осн. поток<input type="text" id="e-main" value="${cam ? v(cam.main_path) : "/Streaming/Channels/101"}"></label>` +
|
`<label>Осн. поток<input type="text" id="e-main" value="${cam ? v(cam.main_path) : "/Streaming/Channels/101"}"></label>` +
|
||||||
`<label>Субпоток<input type="text" id="e-sub" value="${cam ? v(cam.sub_path) : "/Streaming/Channels/102"}" placeholder="можно пусто"></label>` +
|
`<label>Субпоток<input type="text" id="e-sub" value="${cam ? v(cam.sub_path) : "/Streaming/Channels/102"}" placeholder="можно пусто"></label>` +
|
||||||
`<label class="sw">Вкл<input type="checkbox" id="e-enabled" ${(!cam || cam.enabled) ? "checked" : ""}></label>` +
|
`<label class="sw" title="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" id="e-enabled" ${(!cam || cam.enabled) ? "checked" : ""}></label>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="cam-edit-foot">` +
|
`<div class="cam-edit-foot">` +
|
||||||
`<span class="err" id="e-err"></span>` +
|
`<span class="err" id="e-err"></span>` +
|
||||||
@@ -1344,6 +1344,7 @@ const CamSettings = {
|
|||||||
`<td>${c.name}</td>` +
|
`<td>${c.name}</td>` +
|
||||||
`<td class="muted">${c.ip}</td>` +
|
`<td class="muted">${c.ip}</td>` +
|
||||||
`<td class="muted">${streams}</td>` +
|
`<td class="muted">${streams}</td>` +
|
||||||
|
`<td><input type="checkbox" class="rec-toggle" data-id="${c.id}" title="Запись на диск (выкл — только просмотр)" ${c.enabled ? "checked" : ""}></td>` +
|
||||||
`<td><span class="badge"><span class="${dotClass(st)}"></span>${STATE_RU[st] || st}</span></td>` +
|
`<td><span class="badge"><span class="${dotClass(st)}"></span>${STATE_RU[st] || st}</span></td>` +
|
||||||
`<td class="actions">` +
|
`<td class="actions">` +
|
||||||
`<button data-act="log" data-id="${c.id}">Лог</button> ` +
|
`<button data-act="log" data-id="${c.id}">Лог</button> ` +
|
||||||
@@ -1354,14 +1355,14 @@ const CamSettings = {
|
|||||||
const logTr = document.createElement("tr");
|
const logTr = document.createElement("tr");
|
||||||
logTr.className = "logrow";
|
logTr.className = "logrow";
|
||||||
const hid = openLogs.has("log-" + c.id) ? "" : "hidden";
|
const hid = openLogs.has("log-" + c.id) ? "" : "hidden";
|
||||||
logTr.innerHTML = `<td colspan="6" style="padding:0"><div class="logbox" id="log-${c.id}" ${hid}></div></td>`;
|
logTr.innerHTML = `<td colspan="7" style="padding:0"><div class="logbox" id="log-${c.id}" ${hid}></div></td>`;
|
||||||
tb.appendChild(logTr);
|
tb.appendChild(logTr);
|
||||||
} else {
|
} else {
|
||||||
tr.className = "slot-empty";
|
tr.className = "slot-empty";
|
||||||
tr.dataset.slot = i + 1;
|
tr.dataset.slot = i + 1;
|
||||||
tr.innerHTML =
|
tr.innerHTML =
|
||||||
`<td class="muted">${i + 1}</td>` +
|
`<td class="muted">${i + 1}</td>` +
|
||||||
`<td class="muted">—</td><td class="muted">—</td><td class="muted">—</td>` +
|
`<td class="muted">—</td><td class="muted">—</td><td class="muted">—</td><td class="muted">—</td>` +
|
||||||
`<td class="muted">пусто</td>` +
|
`<td class="muted">пусто</td>` +
|
||||||
`<td class="actions"><button data-act="add">+ Добавить</button></td>`;
|
`<td class="actions"><button data-act="add">+ Добавить</button></td>`;
|
||||||
tb.appendChild(tr);
|
tb.appendChild(tr);
|
||||||
@@ -1395,6 +1396,22 @@ const CamSettings = {
|
|||||||
box.hidden = false;
|
box.hidden = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
tb.onchange = async (e) => { // тумблер REC в строке — старт/стоп записи на диск
|
||||||
|
const cb = e.target.closest(".rec-toggle");
|
||||||
|
if (!cb) return;
|
||||||
|
const id = cb.dataset.id;
|
||||||
|
cb.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/cameras/${id}/record`, {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ enabled: cb.checked }),
|
||||||
|
});
|
||||||
|
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; }
|
||||||
|
} catch (err) { cb.checked = !cb.checked; alert("Сбой запроса"); }
|
||||||
|
finally { cb.disabled = false; }
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user