storage: per-camera target + date subfolders; move active-storage widget
- Camera.storage_id: each camera chooses its storage ("" = active/default).
supervisor resolves per-camera (storage_by_id → active fallback); cameras
API (body/payload) + /toggle accept storage_id and repoint the recorder.
- Recordings now go to <root>/<cam>/<YYYY-MM-DD>/<file>.mp4. This ffmpeg build
lacks -strftime_mkdir, so recorder_task pre-creates today+tomorrow dirs at
start and the indexer re-ensures them each tick (survives midnight).
- indexer: scan date subdirs + legacy flat files; sort by basename so the
in-progress segment is detected correctly when both layouts coexist.
- frontend: per-camera "Хранилище" dropdown in the camera table (inline) and
in the add/edit + bulk forms. Move the active-storage summary widget from
the Система tab into the Хранилища tab.
VERSION 0.0.129.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@ def _camera_from_body(body: dict, cam_id: str) -> Camera:
|
|||||||
password=password,
|
password=password,
|
||||||
main_path=(body.get("main_path") or DEFAULT_MAIN).strip(),
|
main_path=(body.get("main_path") or DEFAULT_MAIN).strip(),
|
||||||
sub_path=sub,
|
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,
|
"password": cam.password,
|
||||||
"main_path": cam.main_path,
|
"main_path": cam.main_path,
|
||||||
"sub_path": cam.sub_path,
|
"sub_path": cam.sub_path,
|
||||||
|
"storage_id": cam.storage_id,
|
||||||
"has_sub": has_sub,
|
"has_sub": has_sub,
|
||||||
"status": status.to_dict() if status else None,
|
"status": status.to_dict() if status else None,
|
||||||
# live-источники go2rtc: raw (как есть) и h264 (перекодирование по опции)
|
# live-источники go2rtc: raw (как есть) и h264 (перекодирование по опции)
|
||||||
@@ -324,8 +326,10 @@ async def toggle_camera(cam_id: str, request: Request) -> dict:
|
|||||||
kw["enabled"] = bool(body["enabled"])
|
kw["enabled"] = bool(body["enabled"])
|
||||||
if "record" in body:
|
if "record" in body:
|
||||||
kw["record"] = bool(body["record"])
|
kw["record"] = bool(body["record"])
|
||||||
|
if "storage_id" in body: # смена хранилища записи камеры
|
||||||
|
kw["storage_id"] = (body["storage_id"] or "").strip()
|
||||||
if not kw:
|
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
|
enabled_changed = "enabled" in kw and kw["enabled"] != cam.enabled
|
||||||
new = replace(cam, **kw)
|
new = replace(cam, **kw)
|
||||||
idx = next(i for i, c in enumerate(runtime.config.cameras) if c.id == cam_id)
|
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)
|
await go2rtc.add_camera_streams(runtime.config, new)
|
||||||
else:
|
else:
|
||||||
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
||||||
await runtime.supervisor.replace_camera(new) # рекордер запускается при enabled AND record
|
await runtime.supervisor.replace_camera(new) # рекордер: репойнт на новое хранилище / enabled AND record
|
||||||
return {"ok": True, "enabled": new.enabled, "record": new.record}
|
return {"ok": True, "enabled": new.enabled, "record": new.record, "storage_id": new.storage_id}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class Camera:
|
|||||||
main_path: str
|
main_path: str
|
||||||
sub_path: str | None = None
|
sub_path: str | None = None
|
||||||
record: bool = True # REC: писать на диск. enabled — камера вообще включена (live+запись)
|
record: bool = True # REC: писать на диск. enabled — камера вообще включена (live+запись)
|
||||||
|
storage_id: str = "" # в какое хранилище писать ("" = активное/по умолчанию)
|
||||||
|
|
||||||
def rtsp_url(self, path: str) -> str:
|
def rtsp_url(self, path: str) -> str:
|
||||||
return f"rtsp://{self.user}:{self.password}@{self.ip}:554{path}"
|
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"],
|
password=c["password"],
|
||||||
main_path=c["main_path"],
|
main_path=c["main_path"],
|
||||||
sub_path=c.get("sub_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,
|
"password": c.password,
|
||||||
"main_path": c.main_path,
|
"main_path": c.main_path,
|
||||||
"sub_path": c.sub_path,
|
"sub_path": c.sub_path,
|
||||||
|
"storage_id": c.storage_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ def record_args(cam: Camera, out_root: str, segment_seconds: int,
|
|||||||
out_root — корень активного хранилища (внутри пишем подкаталог камеры).
|
out_root — корень активного хранилища (внутри пишем подкаталог камеры).
|
||||||
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
|
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
|
||||||
"""
|
"""
|
||||||
# Плоско в каталоге камеры: дата в имени файла. Каталог создаётся один раз
|
# Подпапка даты: <root>/<cam>/<YYYY-MM-DD>/<YYYY-MM-DD_HH-MM-SS>.mp4.
|
||||||
# (recorder_task), новых подкаталогов не нужно — корректно переживает полночь.
|
# Этот ffmpeg-билд НЕ знает -strftime_mkdir, поэтому каталоги даты (сегодня+завтра)
|
||||||
# Подкаталог даты не используем: этот ffmpeg-билд не знает -strftime_mkdir.
|
# заранее создаёт recorder_task при старте и индексатор каждые 30с (переживает полночь).
|
||||||
out_template = os.path.join(out_root, cam.id, "%Y-%m-%d_%H-%M-%S.mp4")
|
out_template = os.path.join(out_root, cam.id, "%Y-%m-%d", "%Y-%m-%d_%H-%M-%S.mp4")
|
||||||
return [
|
return [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-nostdin",
|
"-nostdin",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from ..config import Config
|
from ..config import Config
|
||||||
from ..db import Database
|
from ..db import Database
|
||||||
@@ -32,7 +32,11 @@ def _parse_started_at(path: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _scan_segments(root: str) -> dict[str, list[str]]:
|
def _scan_segments(root: str) -> dict[str, list[str]]:
|
||||||
"""Возвращает {camera_id: [пути mp4, отсортированы]}."""
|
"""Возвращает {camera_id: [пути mp4, отсортированы]}.
|
||||||
|
|
||||||
|
Файлы лежат в подпапке даты <cam>/<YYYY-MM-DD>/*.mp4; старые «плоские»
|
||||||
|
<cam>/*.mp4 тоже подхватываем (обратная совместимость). Сортировка по полному
|
||||||
|
пути == хронологическая (ISO-даты и в каталоге, и в имени)."""
|
||||||
result: dict[str, list[str]] = {}
|
result: dict[str, list[str]] = {}
|
||||||
if not os.path.isdir(root):
|
if not os.path.isdir(root):
|
||||||
return result
|
return result
|
||||||
@@ -40,12 +44,19 @@ def _scan_segments(root: str) -> dict[str, list[str]]:
|
|||||||
cam_dir = os.path.join(root, cam_id)
|
cam_dir = os.path.join(root, cam_id)
|
||||||
if not os.path.isdir(cam_dir):
|
if not os.path.isdir(cam_dir):
|
||||||
continue
|
continue
|
||||||
files = [
|
files: list[str] = []
|
||||||
os.path.join(cam_dir, fn)
|
for entry in os.listdir(cam_dir):
|
||||||
for fn in os.listdir(cam_dir)
|
p = os.path.join(cam_dir, entry)
|
||||||
if fn.endswith(".mp4")
|
if entry.endswith(".mp4") and os.path.isfile(p):
|
||||||
]
|
files.append(p) # старый плоский формат
|
||||||
files.sort()
|
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:
|
if files:
|
||||||
result[cam_id] = files
|
result[cam_id] = files
|
||||||
return result
|
return result
|
||||||
@@ -74,11 +85,29 @@ class Indexer:
|
|||||||
async def _loop(self) -> None:
|
async def _loop(self) -> None:
|
||||||
while not self._stopping:
|
while not self._stopping:
|
||||||
try:
|
try:
|
||||||
|
self._ensure_date_dirs()
|
||||||
await self.scan_once()
|
await self.scan_once()
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
log.exception("indexer scan failed")
|
log.exception("indexer scan failed")
|
||||||
await asyncio.sleep(SCAN_INTERVAL)
|
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:
|
async def scan_once(self) -> int:
|
||||||
active_path = self.config.active_storage().path
|
active_path = self.config.active_storage().path
|
||||||
known = await self.db.known_paths()
|
known = await self.db.known_paths()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from ..config import Camera, StorageItem
|
from ..config import Camera, StorageItem
|
||||||
from ..models import CameraState, CameraStatus
|
from ..models import CameraState, CameraStatus
|
||||||
@@ -83,7 +84,12 @@ class RecorderTask:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.join(self.store.path, self.cam.id), exist_ok=True)
|
# подпапки даты <cam>/<YYYY-MM-DD> — сегодня и завтра (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:
|
except OSError as exc:
|
||||||
self._set_state(CameraState.RETRYING, f"mkdir: {exc}")
|
self._set_state(CameraState.RETRYING, f"mkdir: {exc}")
|
||||||
backoff = min(backoff * 2, BACKOFF_MAX)
|
backoff = min(backoff * 2, BACKOFF_MAX)
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ class Supervisor:
|
|||||||
return go2rtc.restream_main_url(self.config, cam.id)
|
return go2rtc.restream_main_url(self.config, cam.id)
|
||||||
|
|
||||||
def _new_task(self, cam: Camera) -> RecorderTask:
|
def _new_task(self, cam: Camera) -> RecorderTask:
|
||||||
# пишем в активное хранилище; смена активного → restart_all репойнтит все задачи
|
# хранилище камеры: её storage_id, иначе активное (по умолчанию). Несуществующий
|
||||||
active = self.config.active_storage()
|
# id (удалённое хранилище) → тоже откат на активное.
|
||||||
return RecorderTask(cam, active, self.config.storage.segment_seconds,
|
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))
|
||||||
|
|
||||||
def start_all(self) -> None:
|
def start_all(self) -> None:
|
||||||
|
|||||||
@@ -56,7 +56,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 style="width:56px">Вкл</th><th style="width:64px">Запись</th><th>Статус</th><th></th></tr>
|
<tr><th style="width:48px">ID</th><th>Камера</th><th>IP</th><th>Потоки</th><th style="width:56px">Вкл</th><th style="width:64px">Запись</th><th style="width:150px">Хранилище</th><th>Статус</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="cam-rows"></tbody>
|
<tbody id="cam-rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -140,16 +140,16 @@
|
|||||||
<div style="margin-top:12px"><button id="pol-save">Сохранить</button></div>
|
<div style="margin-top:12px"><button id="pol-save">Сохранить</button></div>
|
||||||
<div class="muted" id="pol-saved" style="margin-top:8px;font-size:12px"></div>
|
<div class="muted" id="pol-saved" style="margin-top:8px;font-size:12px"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Активное хранилище <span class="muted" style="font-weight:400;font-size:12px">— сводка</span></h2>
|
||||||
|
<div class="kv" id="storage-kv"><div class="muted">загрузка…</div></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ВКЛАДКА 2: Система -->
|
<!-- ВКЛАДКА 2: Система -->
|
||||||
<div class="tab-panel" data-panel="general" data-min-role="operator" hidden>
|
<div class="tab-panel" data-panel="general" data-min-role="operator" hidden>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="card">
|
|
||||||
<h2>Хранилище <span class="muted" style="font-weight:400;font-size:12px">— активное (управление во вкладке «Хранилища»)</span></h2>
|
|
||||||
<div class="kv" id="storage-kv"><div class="muted">загрузка…</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Система</h2>
|
<h2>Система</h2>
|
||||||
<div class="kv" id="system-kv"><div class="muted">загрузка…</div></div>
|
<div class="kv" id="system-kv"><div class="muted">загрузка…</div></div>
|
||||||
|
|||||||
+45
-14
@@ -1123,6 +1123,7 @@ const CamSettings = {
|
|||||||
bulk: false, // режим массового редактирования всей таблицы
|
bulk: false, // режим массового редактирования всей таблицы
|
||||||
cams: [],
|
cams: [],
|
||||||
byId: {},
|
byId: {},
|
||||||
|
storages: [], // список хранилищ для выпадающего выбора в строке камеры
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
const add = document.getElementById("add-cam");
|
const add = document.getElementById("add-cam");
|
||||||
@@ -1133,10 +1134,24 @@ const CamSettings = {
|
|||||||
if (bc) bc.onclick = () => this.exitBulk();
|
if (bc) bc.onclick = () => this.exitBulk();
|
||||||
const cc = document.getElementById("clear-cams");
|
const cc = document.getElementById("clear-cams");
|
||||||
if (cc) cc.onclick = () => this.clearAll();
|
if (cc) cc.onclick = () => this.clearAll();
|
||||||
|
try { this.storages = (await api("/api/storages")).storages || []; } catch (e) { this.storages = []; }
|
||||||
await this.load();
|
await this.load();
|
||||||
connectStatusWS(() => { if (!this.editing) this.load(); });
|
connectStatusWS(() => { if (!this.editing) this.load(); });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// выпадающий список хранилищ; "" = по умолчанию (активное)
|
||||||
|
storageOptions(sel) {
|
||||||
|
const v = (x) => this.esc(x);
|
||||||
|
let html = `<option value=""${!sel ? " selected" : ""}>По умолчанию</option>`;
|
||||||
|
let found = false;
|
||||||
|
(this.storages || []).forEach((s) => {
|
||||||
|
const isSel = s.id === sel; if (isSel) found = true;
|
||||||
|
html += `<option value="${v(s.id)}"${isSel ? " selected" : ""}>${v(s.name)}</option>`;
|
||||||
|
});
|
||||||
|
if (sel && !found) html += `<option value="${v(sel)}" selected>${v(sel)} (нет)</option>`;
|
||||||
|
return html;
|
||||||
|
},
|
||||||
|
|
||||||
setBulkButtons() {
|
setBulkButtons() {
|
||||||
const be = document.getElementById("bulk-edit");
|
const be = document.getElementById("bulk-edit");
|
||||||
const bc = document.getElementById("bulk-cancel");
|
const bc = document.getElementById("bulk-cancel");
|
||||||
@@ -1183,7 +1198,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="8" class="cam-edit">` +
|
`<td colspan="9" 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>` +
|
||||||
@@ -1192,6 +1207,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>Хранилище<select data-f="storage_id">${this.storageOptions(cam.storage_id)}</select></label>` +
|
||||||
`<label class="sw" title="Камера включена (выкл — не работает совсем)">Вкл<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>` +
|
`<label class="sw" title="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" data-f="record" ${cam.record ? "checked" : ""}></label>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
@@ -1213,6 +1229,7 @@ const CamSettings = {
|
|||||||
password: get("password"),
|
password: get("password"),
|
||||||
main_path: get("main_path").trim(),
|
main_path: get("main_path").trim(),
|
||||||
sub_path: get("sub_path").trim(),
|
sub_path: get("sub_path").trim(),
|
||||||
|
storage_id: get("storage_id"),
|
||||||
enabled: g.querySelector('[data-f="enabled"]').checked,
|
enabled: g.querySelector('[data-f="enabled"]').checked,
|
||||||
record: g.querySelector('[data-f="record"]').checked,
|
record: g.querySelector('[data-f="record"]').checked,
|
||||||
};
|
};
|
||||||
@@ -1221,6 +1238,7 @@ const CamSettings = {
|
|||||||
payload.user !== orig.user || payload.password !== (orig.password || "") ||
|
payload.user !== orig.user || payload.password !== (orig.password || "") ||
|
||||||
payload.main_path !== orig.main_path ||
|
payload.main_path !== orig.main_path ||
|
||||||
(payload.sub_path || "") !== (orig.sub_path || "") ||
|
(payload.sub_path || "") !== (orig.sub_path || "") ||
|
||||||
|
(payload.storage_id || "") !== (orig.storage_id || "") ||
|
||||||
payload.enabled !== orig.enabled ||
|
payload.enabled !== orig.enabled ||
|
||||||
payload.record !== orig.record;
|
payload.record !== orig.record;
|
||||||
if (!changed) continue;
|
if (!changed) continue;
|
||||||
@@ -1243,7 +1261,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="8" class="cam-edit">` +
|
`<td colspan="9" 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>` +
|
||||||
@@ -1252,6 +1270,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>Хранилище<select id="e-storage">${this.storageOptions(cam ? cam.storage_id : "")}</select></label>` +
|
||||||
`<label class="sw" title="Камера включена (выкл — не работает совсем)">Вкл<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>` +
|
`<label class="sw" title="Запись на диск (выкл — только просмотр)">REC<input type="checkbox" id="e-record" ${(!cam || cam.record) ? "checked" : ""}></label>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
@@ -1295,6 +1314,7 @@ const CamSettings = {
|
|||||||
password: val("e-pass"),
|
password: val("e-pass"),
|
||||||
main_path: val("e-main").trim(),
|
main_path: val("e-main").trim(),
|
||||||
sub_path: val("e-sub").trim(),
|
sub_path: val("e-sub").trim(),
|
||||||
|
storage_id: val("e-storage"),
|
||||||
enabled: document.getElementById("e-enabled").checked,
|
enabled: document.getElementById("e-enabled").checked,
|
||||||
record: document.getElementById("e-record").checked,
|
record: document.getElementById("e-record").checked,
|
||||||
};
|
};
|
||||||
@@ -1359,6 +1379,7 @@ const CamSettings = {
|
|||||||
`<td class="muted">${streams}</td>` +
|
`<td class="muted">${streams}</td>` +
|
||||||
`<td><input type="checkbox" class="en-toggle" data-id="${c.id}" title="Камера включена (выкл — не работает совсем)" ${c.enabled ? "checked" : ""}></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><input type="checkbox" class="rec-toggle" data-id="${c.id}" title="Запись на диск (выкл — только просмотр)" ${c.record ? "checked" : ""} ${c.enabled ? "" : "disabled"}></td>` +
|
||||||
|
`<td><select class="stor-sel" data-id="${c.id}" title="В какое хранилище писать">${this.storageOptions(c.storage_id)}</select></td>` +
|
||||||
`<td>${statusCell}</td>` +
|
`<td>${statusCell}</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> ` +
|
||||||
@@ -1369,14 +1390,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="8" style="padding:0"><div class="logbox" id="log-${c.id}" ${hid}></div></td>`;
|
logTr.innerHTML = `<td colspan="9" 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="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);
|
||||||
@@ -1411,23 +1432,33 @@ const CamSettings = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
tb.onchange = async (e) => { // тумблеры в строке: «Вкл» (камера) и «REC» (запись)
|
tb.onchange = async (e) => { // в строке: «Вкл», «REC» (тумблеры) и «Хранилище» (select)
|
||||||
const en = e.target.closest(".en-toggle");
|
const en = e.target.closest(".en-toggle");
|
||||||
const cb = en || e.target.closest(".rec-toggle");
|
const rec = e.target.closest(".rec-toggle");
|
||||||
if (!cb) return;
|
const stor = e.target.closest(".stor-sel");
|
||||||
const id = cb.dataset.id;
|
const ctrl = en || rec || stor;
|
||||||
const patch = en ? { enabled: cb.checked } : { record: cb.checked };
|
if (!ctrl) return;
|
||||||
cb.disabled = true;
|
const id = ctrl.dataset.id;
|
||||||
|
let patch, revert;
|
||||||
|
if (stor) {
|
||||||
|
patch = { storage_id: stor.value };
|
||||||
|
const prev = (this.byId[id] && this.byId[id].storage_id) || "";
|
||||||
|
revert = () => { stor.value = prev; };
|
||||||
|
} else {
|
||||||
|
patch = en ? { enabled: ctrl.checked } : { record: ctrl.checked };
|
||||||
|
revert = () => { ctrl.checked = !ctrl.checked; };
|
||||||
|
}
|
||||||
|
ctrl.disabled = true;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/cameras/${id}/toggle`, {
|
const r = await fetch(`/api/cameras/${id}/toggle`, {
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
});
|
});
|
||||||
if (!r.ok) { cb.checked = !cb.checked; const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); return; }
|
if (!r.ok) { revert(); const d = await r.json().catch(() => ({})); alert("Не удалось: " + (d.detail || r.status)); return; }
|
||||||
if (this.byId[id]) Object.assign(this.byId[id], patch);
|
if (this.byId[id]) Object.assign(this.byId[id], patch);
|
||||||
await this.load(); // перерисовать: REC зависит от состояния «Вкл»
|
await this.load(); // перерисовать (REC зависит от «Вкл», строка хранилища)
|
||||||
} catch (err) { cb.checked = !cb.checked; alert("Сбой запроса"); }
|
} catch (err) { revert(); alert("Сбой запроса"); }
|
||||||
finally { cb.disabled = false; }
|
finally { ctrl.disabled = false; }
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user