v158
This commit is contained in:
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user