This commit is contained in:
2026-06-02 14:02:09 +05:00
parent f263d84a8d
commit 847c87fd7e
9 changed files with 79 additions and 29 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.155 0.0.158
+9 -2
View File
@@ -285,12 +285,19 @@ async def update_camera(cam_id: str, request: Request) -> dict:
@router.delete("/{cam_id}") @router.delete("/{cam_id}")
async def delete_camera(cam_id: str, request: Request) -> dict: async def delete_camera(cam_id: str, request: Request) -> dict:
require_role(request, "admin") 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") 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) save_config(runtime.config)
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)
if shifts:
await runtime.supervisor.restart_all()
return {"ok": True} return {"ok": True}
+23
View File
@@ -89,6 +89,29 @@ class Config:
def enabled_cameras(self) -> list[Camera]: def enabled_cameras(self) -> list[Camera]:
return [c for c in self.cameras if c.enabled] 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: def storage_by_id(self, sid: str) -> StorageItem | None:
return next((s for s in self.storage.items if s.id == sid), None) return next((s for s in self.storage.items if s.id == sid), None)
+4 -3
View File
@@ -9,17 +9,18 @@ from ..config import Camera
def record_args(cam: Camera, out_root: str, segment_seconds: int, 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 копированием. """ffmpeg для записи основного потока сегментами mp4 копированием.
-c copy => CPU почти не используется. -strftime даёт имена сегментов по времени. -c copy => CPU почти не используется. -strftime даёт имена сегментов по времени.
out_root — корень активного хранилища (внутри пишем подкаталог камеры). out_root — корень активного хранилища (внутри пишем подкаталог камеры).
sub_dir — имя подпапки камеры (номер слота, напр. «05»); по умолчанию — id камеры.
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию). input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
""" """
# Подпапка даты: <root>/<cam>/<YYYY-MM-DD>/<YYYY-MM-DD_HH-MM-SS>.mp4. # Подпапка даты: <root>/<sub_dir>/<YYYY-MM-DD>/<YYYY-MM-DD_HH-MM-SS>.mp4.
# Этот ffmpeg-билд НЕ знает -strftime_mkdir, поэтому каталоги даты (сегодня+завтра) # Этот ffmpeg-билд НЕ знает -strftime_mkdir, поэтому каталоги даты (сегодня+завтра)
# заранее создаёт recorder_task при старте и индексатор каждые 30с (переживает полночь). # заранее создаёт 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 [ return [
"ffmpeg", "ffmpeg",
"-nostdin", "-nostdin",
+25 -12
View File
@@ -32,16 +32,17 @@ 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, отсортированы]}. """Возвращает {имя_папки: [пути mp4, отсортированы]} (папка = номер слота или
легаси-id камеры — сопоставление в камеру делает вызывающий).
Файлы лежат в подпапке даты <cam>/<YYYY-MM-DD>/*.mp4; старые «плоские» Файлы лежат в подпапке даты <folder>/<YYYY-MM-DD>/*.mp4; старые «плоские»
<cam>/*.mp4 тоже подхватываем (обратная совместимость). Сортировка по полному <folder>/*.mp4 тоже подхватываем (обратная совместимость). Сортировка по полному
пути == хронологическая (ISO-даты и в каталоге, и в имени).""" пути == хронологическая (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
for cam_id in os.listdir(root): for folder in os.listdir(root):
cam_dir = os.path.join(root, cam_id) cam_dir = os.path.join(root, folder)
if not os.path.isdir(cam_dir): if not os.path.isdir(cam_dir):
continue continue
files: list[str] = [] files: list[str] = []
@@ -58,7 +59,7 @@ def _scan_segments(root: str) -> dict[str, list[str]]:
# из-за чего пишущийся сейчас файл в подпапке не оказывался бы последним) # из-за чего пишущийся сейчас файл в подпапке не оказывался бы последним)
files.sort(key=os.path.basename) files.sort(key=os.path.basename)
if files: if files:
result[cam_id] = files result[folder] = files
return result return result
@@ -102,9 +103,10 @@ class Indexer:
store = self.config.storage_by_id(cam.storage_id) or self.config.active_storage() 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): if store.type == "nas" and not os.path.ismount(store.path):
continue continue
sub = self.config.archive_dir(cam.id) # папка-слот, напр. «05»
for d in days: for d in days:
try: 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: except OSError:
pass pass
@@ -115,12 +117,23 @@ class Indexer:
# сканируем ВСЕ хранилища: после смены активного старые записи не теряются # сканируем ВСЕ хранилища: после смены активного старые записи не теряются
for root in self.config.storage_paths(): for root in self.config.storage_paths():
by_cam = _scan_segments(root) by_dir = _scan_segments(root)
is_active = (root == active_path) is_active = (root == active_path)
for cam_id, files in by_cam.items(): for folder, files in by_dir.items():
# в активном хранилище последний файл ещё пишется — пропускаем; # Сопоставление папки с камерой по приоритету:
# в неактивных все сегменты завершены, индексируем целиком. # 1) точное совпадение с id камеры — легаси-папка по id (в т.ч. числовому),
complete = files[:-1] if is_active else files # иначе папка камеры с 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: for path in complete:
if path in known: if path in known:
continue continue
+6 -4
View File
@@ -23,10 +23,11 @@ class RecorderTask:
"""Держит один процесс ffmpeg на камеру, перезапускает при падении.""" """Держит один процесс ffmpeg на камеру, перезапускает при падении."""
def __init__(self, cam: Camera, store: StorageItem, segment_seconds: int, 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.cam = cam
self.store = store # активное хранилище (куда пишем) self.store = store # активное хранилище (куда пишем)
self.segment_seconds = segment_seconds self.segment_seconds = segment_seconds
self.sub_dir = sub_dir or cam.id # папка камеры в хранилище (номер слота, напр. «05»)
self.input_url = input_url # источник записи (рестрим go2rtc) self.input_url = input_url # источник записи (рестрим go2rtc)
self.on_status = on_status # callback(CameraStatus) для WS-уведомлений self.on_status = on_status # callback(CameraStatus) для WS-уведомлений
self.status = CameraStatus( self.status = CameraStatus(
@@ -84,9 +85,9 @@ class RecorderTask:
continue continue
try: try:
# подпапки даты <cam>/<YYYY-MM-DD> — сегодня и завтра (ffmpeg их не создаёт); # подпапки даты <slot>/<YYYY-MM-DD> — сегодня и завтра (ffmpeg их не создаёт);
# индексатор продолжает поддерживать их каждые 30с (переживает полночь) # индексатор продолжает поддерживать их каждые 30с (переживает полночь)
base = os.path.join(self.store.path, self.cam.id) base = os.path.join(self.store.path, self.sub_dir)
now = datetime.now() now = datetime.now()
for d in (now, now + timedelta(days=1)): for d in (now, now + timedelta(days=1)):
os.makedirs(os.path.join(base, d.strftime("%Y-%m-%d")), exist_ok=True) os.makedirs(os.path.join(base, d.strftime("%Y-%m-%d")), exist_ok=True)
@@ -97,7 +98,8 @@ class RecorderTask:
continue continue
self._set_state(CameraState.STARTING) 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)) log.info("[%s] start ffmpeg: %s", self.cam.id, _redact(args, self.cam))
try: try:
self._proc = await asyncio.create_subprocess_exec( self._proc = await asyncio.create_subprocess_exec(
+2 -1
View File
@@ -26,7 +26,8 @@ class Supervisor:
# id (удалённое хранилище) → тоже откат на активное. # id (удалённое хранилище) → тоже откат на активное.
store = self.config.storage_by_id(cam.storage_id) or self.config.active_storage() store = self.config.storage_by_id(cam.storage_id) or self.config.active_storage()
return RecorderTask(cam, store, self.config.storage.segment_seconds, 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: def start_all(self) -> None:
for cam in self.config.cameras: for cam in self.config.cameras:
+1
View File
@@ -28,6 +28,7 @@
<div class="gb-split"> <div class="gb-split">
<button class="gb-btn" id="gb-split" title="Раскладка сетки" aria-label="Раскладка сетки"></button> <button class="gb-btn" id="gb-split" title="Раскладка сетки" aria-label="Раскладка сетки"></button>
<div class="gb-split-menu" id="gb-split-menu"> <div class="gb-split-menu" id="gb-split-menu">
<button data-grid="1">1</button>
<button data-grid="2">4</button> <button data-grid="2">4</button>
<button data-grid="3">9</button> <button data-grid="3">9</button>
<button data-grid="4">16</button> <button data-grid="4">16</button>
+8 -6
View File
@@ -547,13 +547,13 @@ const Live = {
initialDim() { initialDim() {
const max = this.maxDim || 8; const max = this.maxDim || 8;
const q = parseInt(new URLSearchParams(location.search).get("grid"), 10); 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); 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) { 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; this.layout = this.dim * this.dim;
localStorage.setItem("corevision.grid", String(this.dim)); localStorage.setItem("corevision.grid", String(this.dim));
this.page = 0; // смена размера сетки → первая страница this.page = 0; // смена размера сетки → первая страница
@@ -1167,6 +1167,8 @@ const Archive = {
// ════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════
const CamSettings = { const CamSettings = {
SLOTS: 64, SLOTS: 64,
// номер слота с ведущим нулём: 1‑9 → 01‑09, 10+ без изменений
slotId(n) { return String(n).padStart(2, "0"); },
editing: false, // идёт инлайн-редактирование — не перерисовываем по WS editing: false, // идёт инлайн-редактирование — не перерисовываем по WS
bulk: false, // режим массового редактирования всей таблицы bulk: false, // режим массового редактирования всей таблицы
cams: [], cams: [],
@@ -1266,7 +1268,7 @@ const CamSettings = {
const record = cam ? cam.record : true; const record = cam ? cam.record : true;
const storageId = cam ? cam.storage_id : ""; const storageId = cam ? cam.storage_id : "";
return ( return (
`<td class="muted">${slotNo}</td>` + `<td class="muted">${this.slotId(slotNo)}</td>` +
`<td><input class="cell-in" type="text" data-f="name" value="${name}"${cam ? "" : ' placeholder="новая камера"'}></td>` + `<td><input class="cell-in" type="text" data-f="name" value="${name}"${cam ? "" : ' placeholder="новая камера"'}></td>` +
`<td><input class="cell-in" type="text" data-f="ip" value="${ip}"${cam ? "" : ' placeholder="IP"'}></td>` + `<td><input class="cell-in" type="text" data-f="ip" value="${ip}"${cam ? "" : ' placeholder="IP"'}></td>` +
`<td><select class="cell-sel" data-f="channel" title="Канал (поток)">${channelOptions(ch)}</select></td>` + `<td><select class="cell-sel" data-f="channel" title="Канал (поток)">${channelOptions(ch)}</select></td>` +
@@ -1462,7 +1464,7 @@ const CamSettings = {
: `<span class="muted">выключена</span>`; : `<span class="muted">выключена</span>`;
tr.dataset.id = c.id; tr.dataset.slot = i + 1; tr.dataset.id = c.id; tr.dataset.slot = i + 1;
tr.innerHTML = tr.innerHTML =
`<td class="muted">${i + 1}</td>` + `<td class="muted">${this.slotId(i + 1)}</td>` +
`<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>` +
@@ -1476,7 +1478,7 @@ const CamSettings = {
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">${this.slotId(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="muted">пусто</td>` +
`<td></td>`; `<td></td>`;