cameras: split camera enable (Вкл) from REC (record-to-disk)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 18:35:26 +05:00
parent 7060f26e86
commit 24155a8492
7 changed files with 97 additions and 44 deletions
+32 -15
View File
@@ -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 (
`<td colspan="7" class="cam-edit">` +
`<td colspan="8" class="cam-edit">` +
`<div class="cam-bulk-head">#${slotNo} · ID ${v(cam.id)}</div>` +
`<div class="cam-edit-grid" data-cam="${v(cam.id)}">` +
`<label>Имя<input type="text" data-f="name" value="${v(cam.name)}"></label>` +
@@ -1187,7 +1192,8 @@ const CamSettings = {
`<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="sub_path" value="${v(cam.sub_path)}" placeholder="можно пусто"></label>` +
`<label class="sw" title="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" data-f="enabled" ${cam.enabled ? "checked" : ""}></label>` +
`<label class="sw" title="Камера включена (выкл — не работает совсем)">Вкл<input type="checkbox" data-f="enabled" ${cam.enabled ? "checked" : ""}></label>` +
`<label class="sw" title="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" data-f="record" ${cam.record ? "checked" : ""}></label>` +
`</div>` +
`</td>`
);
@@ -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 (
`<td colspan="7" class="cam-edit">` +
`<td colspan="8" class="cam-edit">` +
`<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>Имя<input type="text" id="e-name" value="${cam ? v(cam.name) : ""}" placeholder="Камера ${slotNo}"></label>` +
@@ -1244,7 +1252,8 @@ const CamSettings = {
`<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-sub" value="${cam ? v(cam.sub_path) : "/Streaming/Channels/102"}" placeholder="можно пусто"></label>` +
`<label class="sw" title="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" id="e-enabled" ${(!cam || cam.enabled) ? "checked" : ""}></label>` +
`<label class="sw" title="Камера включена (выкл — не работает совсем)">Вкл<input type="checkbox" id="e-enabled" ${(!cam || cam.enabled) ? "checked" : ""}></label>` +
`<label class="sw" title="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" id="e-record" ${(!cam || cam.record) ? "checked" : ""}></label>` +
`</div>` +
`<div class="cam-edit-foot">` +
`<span class="err" id="e-err"></span>` +
@@ -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
? `<span class="badge"><span class="${dotClass(st)}"></span>${STATE_RU[st] || st}</span>`
: `<span class="muted">выключена</span>`;
tr.dataset.id = c.id; tr.dataset.slot = i + 1;
tr.innerHTML =
`<td class="muted">${i + 1}</td>` +
`<td>${c.name}</td>` +
`<td class="muted">${c.ip}</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><input type="checkbox" class="en-toggle" data-id="${c.id}" title="Камера включена (выкл — не работает совсем)" ${c.enabled ? "checked" : ""}></td>` +
`<td><input type="checkbox" class="rec-toggle" data-id="${c.id}" title="Запись на диск (выкл — только просмотр)" ${c.record ? "checked" : ""} ${c.enabled ? "" : "disabled"}></td>` +
`<td>${statusCell}</td>` +
`<td class="actions">` +
`<button data-act="log" data-id="${c.id}">Лог</button> ` +
`<button data-act="restart" data-id="${c.id}">⟳</button> ` +
@@ -1355,14 +1369,14 @@ const CamSettings = {
const logTr = document.createElement("tr");
logTr.className = "logrow";
const hid = openLogs.has("log-" + c.id) ? "" : "hidden";
logTr.innerHTML = `<td colspan="7" style="padding:0"><div class="logbox" id="log-${c.id}" ${hid}></div></td>`;
logTr.innerHTML = `<td colspan="8" style="padding:0"><div class="logbox" id="log-${c.id}" ${hid}></div></td>`;
tb.appendChild(logTr);
} else {
tr.className = "slot-empty";
tr.dataset.slot = i + 1;
tr.innerHTML =
`<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="muted">пусто</td>` +
`<td class="actions"><button data-act="add">+ Добавить</button></td>`;
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; }
};