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
+9 -2
View File
@@ -285,12 +285,19 @@ async def update_camera(cam_id: str, request: Request) -> dict:
@router.delete("/{cam_id}")
async def delete_camera(cam_id: str, request: Request) -> dict:
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")
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)
await runtime.supervisor.remove_camera(cam_id)
await go2rtc.remove_camera_streams(runtime.config, cam_id)
if shifts:
await runtime.supervisor.restart_all()
return {"ok": True}
+23
View File
@@ -89,6 +89,29 @@ class Config:
def enabled_cameras(self) -> list[Camera]:
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:
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,
input_url: str | None = None) -> list[str]:
input_url: str | None = None, sub_dir: str | None = None) -> list[str]:
"""ffmpeg для записи основного потока сегментами mp4 копированием.
-c copy => CPU почти не используется. -strftime даёт имена сегментов по времени.
out_root — корень активного хранилища (внутри пишем подкаталог камеры).
sub_dir — имя подпапки камеры (номер слота, напр. «05»); по умолчанию — id камеры.
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, поэтому каталоги даты (сегодня+завтра)
# заранее создаёт 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 [
"ffmpeg",
"-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]]:
"""Возвращает {camera_id: [пути mp4, отсортированы]}.
"""Возвращает {имя_папки: [пути mp4, отсортированы]} (папка = номер слота или
легаси-id камеры — сопоставление в камеру делает вызывающий).
Файлы лежат в подпапке даты <cam>/<YYYY-MM-DD>/*.mp4; старые «плоские»
<cam>/*.mp4 тоже подхватываем (обратная совместимость). Сортировка по полному
Файлы лежат в подпапке даты <folder>/<YYYY-MM-DD>/*.mp4; старые «плоские»
<folder>/*.mp4 тоже подхватываем (обратная совместимость). Сортировка по полному
пути == хронологическая (ISO-даты и в каталоге, и в имени)."""
result: dict[str, list[str]] = {}
if not os.path.isdir(root):
return result
for cam_id in os.listdir(root):
cam_dir = os.path.join(root, cam_id)
for folder in os.listdir(root):
cam_dir = os.path.join(root, folder)
if not os.path.isdir(cam_dir):
continue
files: list[str] = []
@@ -58,7 +59,7 @@ def _scan_segments(root: str) -> dict[str, list[str]]:
# из-за чего пишущийся сейчас файл в подпапке не оказывался бы последним)
files.sort(key=os.path.basename)
if files:
result[cam_id] = files
result[folder] = files
return result
@@ -102,9 +103,10 @@ class Indexer:
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
sub = self.config.archive_dir(cam.id) # папка-слот, напр. «05»
for d in days:
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:
pass
@@ -115,12 +117,23 @@ class Indexer:
# сканируем ВСЕ хранилища: после смены активного старые записи не теряются
for root in self.config.storage_paths():
by_cam = _scan_segments(root)
by_dir = _scan_segments(root)
is_active = (root == active_path)
for cam_id, files in by_cam.items():
# в активном хранилище последний файл ещё пишется — пропускаем;
# в неактивных все сегменты завершены, индексируем целиком.
complete = files[:-1] if is_active else files
for folder, files in by_dir.items():
# Сопоставление папки с камерой по приоритету:
# 1) точное совпадение с id камеры — легаси-папка по id (в т.ч. числовому),
# иначе папка камеры с 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:
if path in known:
continue
+6 -4
View File
@@ -23,10 +23,11 @@ class RecorderTask:
"""Держит один процесс ffmpeg на камеру, перезапускает при падении."""
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.store = store # активное хранилище (куда пишем)
self.segment_seconds = segment_seconds
self.sub_dir = sub_dir or cam.id # папка камеры в хранилище (номер слота, напр. «05»)
self.input_url = input_url # источник записи (рестрим go2rtc)
self.on_status = on_status # callback(CameraStatus) для WS-уведомлений
self.status = CameraStatus(
@@ -84,9 +85,9 @@ class RecorderTask:
continue
try:
# подпапки даты <cam>/<YYYY-MM-DD> — сегодня и завтра (ffmpeg их не создаёт);
# подпапки даты <slot>/<YYYY-MM-DD> — сегодня и завтра (ffmpeg их не создаёт);
# индексатор продолжает поддерживать их каждые 30с (переживает полночь)
base = os.path.join(self.store.path, self.cam.id)
base = os.path.join(self.store.path, self.sub_dir)
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)
@@ -97,7 +98,8 @@ class RecorderTask:
continue
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))
try:
self._proc = await asyncio.create_subprocess_exec(
+2 -1
View File
@@ -26,7 +26,8 @@ class Supervisor:
# id (удалённое хранилище) → тоже откат на активное.
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),
sub_dir=self.config.archive_dir(cam.id))
def start_all(self) -> None:
for cam in self.config.cameras: