commit d3afd698b6bb0621fcab3c0eed3cb7181e4d3d55 Author: core Date: Sat May 30 00:08:59 2026 +0500 init diff --git a/.env.json b/.env.json new file mode 100644 index 0000000..4a40a4a --- /dev/null +++ b/.env.json @@ -0,0 +1,19 @@ +{ + "auth": { + "username": "admin", + "password": "admin" + }, + "users": [ + { + "username": "admin", + "password": "admin", + "role": "superadmin" + } + ], + "optimization": { + "single_main_stream": true + }, + "ui": { + "live_transcode": false + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..468b9fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +DESIGN.md +README.md +cameras.yaml \ No newline at end of file diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..16b5f26 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.112 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..ae69205 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +# ffmpeg/ffprobe — движок записи и перекодирования +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/app ./app +COPY frontend ./frontend + +EXPOSE 8090 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8090"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/cameras.py b/backend/app/api/cameras.py new file mode 100644 index 0000000..0c5c56e --- /dev/null +++ b/backend/app/api/cameras.py @@ -0,0 +1,282 @@ +"""REST: список камер, статус, live-источники, перезапуск, логи + CRUD.""" +from __future__ import annotations + +import asyncio +import re + +from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect +from fastapi.responses import StreamingResponse + +from ..auth import require_role, auth_manager, COOKIE_NAME +from ..config import Camera, save_config +from ..runtime import runtime +from ..services import go2rtc +from ..transcode import (encode_args, norm_profile, norm_bitrate, norm_preset, + norm_keyint, norm_codec) + +router = APIRouter(prefix="/api/cameras", tags=["cameras"]) + +ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$") +DEFAULT_MAIN = "/Streaming/Channels/101" +DEFAULT_SUB = "/Streaming/Channels/102" + + +def _camera_from_body(body: dict, cam_id: str) -> Camera: + ip = (body.get("ip") or "").strip() + user = (body.get("user") or "").strip() + password = body.get("password") or "" + if not ip or not user: + raise HTTPException(400, "обязательны поля ip и user") + sub = (body.get("sub_path") or "").strip() or None + return Camera( + id=cam_id, + name=(body.get("name") or cam_id).strip(), + enabled=bool(body.get("enabled", True)), + ip=ip, + user=user, + password=password, + main_path=(body.get("main_path") or DEFAULT_MAIN).strip(), + sub_path=sub, + ) + + +def _camera_payload(cam_id: str) -> dict: + cfg = runtime.config + cam = cfg.camera(cam_id) + status = runtime.supervisor.status(cam_id) + has_sub = bool(cam and cam.sub_path) + return { + "id": cam.id, + "name": cam.name, + "enabled": cam.enabled, + "ip": cam.ip, + "user": cam.user, + "password": cam.password, + "main_path": cam.main_path, + "sub_path": cam.sub_path, + "has_sub": has_sub, + "status": status.to_dict() if status else None, + # live-источники go2rtc: raw (как есть) и h264 (перекодирование по опции) + "live": { + "go2rtc_url": cfg.live.go2rtc_url, + "src": cam.id, + "src_h264": f"{cam.id}_h264", + }, + } + + +@router.get("") +async def list_cameras() -> dict: + cfg = runtime.config + return {"cameras": [_camera_payload(c.id) for c in cfg.cameras]} + + +@router.get("/{cam_id}") +async def get_camera(cam_id: str) -> dict: + if not runtime.config.camera(cam_id): + raise HTTPException(404, "camera not found") + return _camera_payload(cam_id) + + +@router.get("/{cam_id}/live.mp4") +async def camera_live_transcoded( + cam_id: str, + profile: str = Query(None), + bitrate: str = Query(None), + preset: str = Query(None), + keyint: str = Query(None), + codec: str = Query(None), + stream: str = Query("sub"), +): + """Live с перекодированием в H.264 (fragmented mp4) под профиль/битрейт клиента. + + stream=main — основной поток (высокое разрешение, для fullscreen), + иначе субпоток. Открывает отдельную RTSP-сессию на каждого зрителя.""" + cam = runtime.config.camera(cam_id) + if not cam: + raise HTTPException(404, "camera not found") + # источник — рестрим go2rtc (камера держит одну сессию: и запись, и live из go2rtc) + src = (go2rtc.restream_main_url(runtime.config, cam_id) if stream == "main" + else go2rtc.restream_sub_url(runtime.config, cam_id)) + args = [ + "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", + "-fflags", "nobuffer", "-flags", "low_delay", + "-rtsp_transport", "tcp", "-i", src, + *encode_args(norm_codec(codec), norm_profile(profile), norm_bitrate(bitrate), + low_latency=True, preset=norm_preset(preset), keyint_sec=norm_keyint(keyint)), + "-an", + "-movflags", "frag_keyframe+empty_moov+default_base_moof", + "-f", "mp4", "pipe:1", + ] + proc = await asyncio.create_subprocess_exec( + *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + ) + + async def stream(): + assert proc.stdout is not None + try: + while True: + chunk = await proc.stdout.read(64 * 1024) + if not chunk: + break + yield chunk + finally: + if proc.returncode is None: + try: + proc.kill() + except ProcessLookupError: + pass + await proc.wait() + + return StreamingResponse(stream(), media_type="video/mp4") + + +@router.get("/{cam_id}/live.flv") +async def camera_live_flv(cam_id: str, stream: str = Query("sub")): + """Live H.265 в FLV по HTTP (ffmpeg -c copy, БЕЗ перекодирования, 0% CPU). + + Для плееров с http-flv (h265web.js): URL оканчивается на .flv, плеер + сам определяет формат. Источник — рестрим go2rtc.""" + cam = runtime.config.camera(cam_id) + if not cam: + raise HTTPException(404, "camera not found") + src = (go2rtc.restream_main_url(runtime.config, cam_id) if stream == "main" + else go2rtc.restream_sub_url(runtime.config, cam_id)) + args = [ + "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", + "-fflags", "nobuffer", "-flags", "low_delay", + "-rtsp_transport", "tcp", "-i", src, + "-an", "-c:v", "copy", + "-f", "flv", "pipe:1", + ] + proc = await asyncio.create_subprocess_exec( + *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + ) + + async def gen(): + assert proc.stdout is not None + try: + while True: + chunk = await proc.stdout.read(32 * 1024) + if not chunk: + break + yield chunk + finally: + if proc.returncode is None: + try: + proc.kill() + except ProcessLookupError: + pass + await proc.wait() + + return StreamingResponse(gen(), media_type="video/x-flv") + + +@router.websocket("/{cam_id}/flv") +async def camera_flv(ws: WebSocket, cam_id: str, stream: str = Query("sub")): + """Поток H.265 в FLV (ffmpeg -c copy, БЕЗ перекодирования) по WebSocket. + + Источник — рестрим go2rtc; в браузере его декодирует свой плеер (jessibuca, + WASM). Так H.265 играет даже в браузерах без аппаратного HEVC.""" + if not auth_manager.validate(ws.cookies.get(COOKIE_NAME)): + await ws.close(code=1008) + return + cam = runtime.config.camera(cam_id) + if not cam: + await ws.close(code=1008) + return + await ws.accept() + src = (go2rtc.restream_main_url(runtime.config, cam_id) if stream == "main" + else go2rtc.restream_sub_url(runtime.config, cam_id)) + args = [ + "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", + "-fflags", "nobuffer", "-flags", "low_delay", + "-rtsp_transport", "tcp", "-i", src, + "-an", "-c:v", "copy", + "-f", "flv", "pipe:1", + ] + proc = await asyncio.create_subprocess_exec( + *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + ) + try: + assert proc.stdout is not None + while True: + chunk = await proc.stdout.read(32 * 1024) + if not chunk: + break + await ws.send_bytes(chunk) + except (WebSocketDisconnect, RuntimeError, ConnectionError): + pass + finally: + if proc.returncode is None: + try: + proc.kill() + except ProcessLookupError: + pass + await proc.wait() + try: + await ws.close() + except RuntimeError: + pass + + +@router.get("/{cam_id}/log") +async def camera_log(cam_id: str) -> dict: + if not runtime.config.camera(cam_id): + raise HTTPException(404, "camera not found") + return {"camera_id": cam_id, "log": runtime.supervisor.log_tail(cam_id)} + + +@router.post("/{cam_id}/restart") +async def restart_camera(cam_id: str, request: Request) -> dict: + require_role(request, "admin") + ok = await runtime.supervisor.restart(cam_id) + if not ok: + raise HTTPException(404, "camera not found") + return {"ok": True} + + +# ── CRUD ──────────────────────────────────────────────────────── +@router.post("") +async def create_camera(request: Request) -> dict: + require_role(request, "admin") + body = await request.json() + cam_id = (body.get("id") or "").strip() + if not ID_RE.match(cam_id): + raise HTTPException(400, "id: разрешены латиница, цифры, _ и - (до 32 символов)") + if runtime.config.camera(cam_id): + raise HTTPException(409, "камера с таким id уже существует") + + cam = _camera_from_body(body, cam_id) + runtime.config.cameras.append(cam) + save_config(runtime.config) + runtime.supervisor.add_camera(cam) + await go2rtc.add_camera_streams(runtime.config, cam) + return _camera_payload(cam_id) + + +@router.put("/{cam_id}") +async def update_camera(cam_id: str, request: Request) -> dict: + require_role(request, "admin") + if not runtime.config.camera(cam_id): + raise HTTPException(404, "camera not found") + body = await request.json() + cam = _camera_from_body(body, cam_id) # id неизменяем (привязан к архиву) + idx = next(i for i, c in enumerate(runtime.config.cameras) if c.id == cam_id) + runtime.config.cameras[idx] = cam + save_config(runtime.config) + await runtime.supervisor.replace_camera(cam) + await go2rtc.add_camera_streams(runtime.config, cam) + return _camera_payload(cam_id) + + +@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): + raise HTTPException(404, "camera not found") + runtime.config.cameras = [c for c in runtime.config.cameras 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) + return {"ok": True} diff --git a/backend/app/api/recordings.py b/backend/app/api/recordings.py new file mode 100644 index 0000000..97d339b --- /dev/null +++ b/backend/app/api/recordings.py @@ -0,0 +1,99 @@ +"""REST: архив — список сегментов, отдача mp4 (Range), перекодирование H.265→H.264.""" +from __future__ import annotations + +import asyncio +import os + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse, StreamingResponse + +from ..runtime import runtime +from ..transcode import (H264_PROFILES, BITRATES, encode_args, norm_profile, + norm_bitrate, norm_preset, norm_keyint, norm_codec) + +router = APIRouter(prefix="/api/recordings", tags=["recordings"]) + + +@router.get("") +async def list_recordings( + camera: str | None = Query(None), + ts_from: int | None = Query(None, alias="from"), + ts_to: int | None = Query(None, alias="to"), +) -> dict: + recs = await runtime.db.list_recordings(camera, ts_from, ts_to) + return { + "recordings": [ + { + "id": r.id, + "camera_id": r.camera_id, + "started_at": r.started_at, + "ended_at": r.ended_at, + "duration_s": r.duration_s, + "size_bytes": r.size_bytes, + } + for r in recs + ] + } + + +@router.get("/{rec_id}/file") +async def recording_file(rec_id: int) -> FileResponse: + """Отдаёт mp4 как есть (H.265). Range поддерживается FileResponse.""" + rec = await runtime.db.get_recording(rec_id) + if not rec or not os.path.exists(rec.path): + raise HTTPException(404, "recording not found") + return FileResponse(rec.path, media_type="video/mp4", filename=os.path.basename(rec.path)) + + +@router.get("/{rec_id}/play.mp4") +async def recording_transcoded( + rec_id: int, + profile: str = Query(None), + bitrate: str = Query(None), + preset: str = Query(None), + keyint: str = Query(None), + codec: str = Query(None), + start: float = Query(0.0), +): + """Перекодирование H.265→H.264 на лету (fragmented mp4) — для браузеров без HEVC. + + Профиль и битрейт задаёт клиент (настройка в браузере). + start — секунда, с которой начать перекодирование (перемотка): транскод + из пайпа не seekable, поэтому seek реализуется перезапуском с -ss.""" + rec = await runtime.db.get_recording(rec_id) + if not rec or not os.path.exists(rec.path): + raise HTTPException(404, "recording not found") + + args = [ + "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", + # -ss ДО -i — быстрый input-seek к ближайшему ключевому кадру ≤ start, + # выходные PTS обнуляются → новый поток идёт с 0 (плеер ведёт виртуальный таймлайн) + *(["-ss", f"{start:.3f}"] if start and start > 0 else []), + "-i", rec.path, + *encode_args(norm_codec(codec), norm_profile(profile), norm_bitrate(bitrate), + preset=norm_preset(preset), keyint_sec=norm_keyint(keyint)), + "-an", + "-movflags", "frag_keyframe+empty_moov+default_base_moof", + "-f", "mp4", "pipe:1", + ] + proc = await asyncio.create_subprocess_exec( + *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + ) + + async def stream(): + assert proc.stdout is not None + try: + while True: + chunk = await proc.stdout.read(64 * 1024) + if not chunk: + break + yield chunk + finally: + if proc.returncode is None: + try: + proc.kill() + except ProcessLookupError: + pass + await proc.wait() + + return StreamingResponse(stream(), media_type="video/mp4") diff --git a/backend/app/api/system.py b/backend/app/api/system.py new file mode 100644 index 0000000..8238bfa --- /dev/null +++ b/backend/app/api/system.py @@ -0,0 +1,60 @@ +"""REST: системная статистика и сводный статус.""" +from __future__ import annotations + +import os + +from fastapi import APIRouter, HTTPException, Request + +from ..auth import require_role +from ..config import Storage, save_config +from ..runtime import runtime +from ..services.system import system_stats + +router = APIRouter(prefix="/api", tags=["system"]) + +ALLOWED_SEGMENTS = (60, 120, 300, 600, 900) # 1 / 2 / 5 / 10 / 15 минут +VERSION_FILE = os.environ.get("NVR_VERSION_FILE", "/app/VERSION") + + +def app_version() -> str: + try: + with open(VERSION_FILE, "r", encoding="utf-8") as fh: + return fh.read().strip() + except OSError: + return "0.0.0" + + +@router.get("/system") +async def get_system() -> dict: + stats = system_stats(runtime.config) + stats["cameras"] = [s.to_dict() for s in runtime.supervisor.statuses()] + stats["archive_size_bytes"] = await runtime.db.total_size() + stats["version"] = app_version() + return stats + + +@router.get("/health") +async def health() -> dict: + return {"status": "ok"} + + +@router.post("/storage") +async def update_storage(request: Request) -> dict: + """Смена размера сегмента записи (1/5/10/15 мин). Перезапускает рекордеры.""" + require_role(request, "admin") + body = await request.json() + try: + seg = int(body.get("segment_seconds")) + except (TypeError, ValueError): + raise HTTPException(400, "segment_seconds должен быть числом") + if seg not in ALLOWED_SEGMENTS: + raise HTTPException(400, "допустимо 60 / 300 / 600 / 900 секунд") + + st = runtime.config.storage + runtime.config.storage = Storage( + path=st.path, quota_gb=st.quota_gb, + retention_days=st.retention_days, segment_seconds=seg, + ) + save_config(runtime.config) + await runtime.supervisor.restart_all() + return {"segment_seconds": seg} diff --git a/backend/app/api/ws.py b/backend/app/api/ws.py new file mode 100644 index 0000000..ad2f348 --- /dev/null +++ b/backend/app/api/ws.py @@ -0,0 +1,64 @@ +"""WebSocket: широковещательные live-обновления статуса камер.""" +from __future__ import annotations + +import asyncio +import json +import logging + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from ..runtime import runtime +from ..auth import COOKIE_NAME, auth_manager + +log = logging.getLogger("nvr.ws") +router = APIRouter() + + +class ConnectionManager: + def __init__(self) -> None: + self._clients: set[WebSocket] = set() + self._loop: asyncio.AbstractEventLoop | None = None + + def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None: + self._loop = loop + + async def connect(self, ws: WebSocket) -> None: + await ws.accept() + self._clients.add(ws) + + def disconnect(self, ws: WebSocket) -> None: + self._clients.discard(ws) + + async def broadcast(self, message: dict) -> None: + data = json.dumps(message, ensure_ascii=False) + dead: list[WebSocket] = [] + for ws in list(self._clients): + try: + await ws.send_text(data) + except Exception: # noqa: BLE001 + dead.append(ws) + for ws in dead: + self._clients.discard(ws) + + def broadcast_threadsafe(self, message: dict) -> None: + """Вызов из синхронного callback рекордера.""" + if self._loop and self._loop.is_running(): + asyncio.run_coroutine_threadsafe(self.broadcast(message), self._loop) + + +@router.websocket("/api/ws") +async def ws_endpoint(ws: WebSocket) -> None: + if not auth_manager.validate(ws.cookies.get(COOKIE_NAME)): + await ws.close(code=1008) # policy violation + return + await runtime.ws.connect(ws) + # начальный снапшот статусов + await ws.send_text(json.dumps({ + "type": "status", + "cameras": [s.to_dict() for s in runtime.supervisor.statuses()], + }, ensure_ascii=False)) + try: + while True: + await ws.receive_text() # клиент ничего не шлёт; держим соединение + except WebSocketDisconnect: + runtime.ws.disconnect(ws) diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..5b48d74 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,247 @@ +"""Авторизация по логину/паролю + роли (оператор / админ / суперадмин). + +Пользователи читаются из .env.json (раздел "users"). Если его нет — он +выводится из старого раздела "auth" (как один суперадмин) или admin/admin по +умолчанию. Сессии — в памяти (перезапуск сервиса разлогинивает всех). Пароли +хранятся в открытом виде, как и раньше; для нескольких операторов этого +достаточно, при необходимости позже заменим на хеши. +""" +from __future__ import annotations + +import json +import os +import secrets + +from fastapi import APIRouter, HTTPException, Request, Response +from fastapi.responses import JSONResponse + +COOKIE_NAME = "nvr_session" + +ROLES = ("operator", "admin", "superadmin") +_RANK = {"operator": 1, "admin": 2, "superadmin": 3} + + +def role_at_least(role: str | None, minimum: str) -> bool: + return bool(role) and _RANK.get(role, 0) >= _RANK[minimum] + + +class AuthManager: + def __init__(self, settings_path: str | None = None): + self.path = settings_path or os.environ.get("NVR_SETTINGS", ".env.json") + self.users: list[dict] = [] + self._sessions: dict[str, str] = {} # token -> username + self.reload() + + # ── загрузка/сохранение ───────────────────────────────────── + def _read(self) -> dict: + try: + with open(self.path, "r", encoding="utf-8") as fh: + return json.load(fh) + except (OSError, json.JSONDecodeError): + return {} + + def reload(self) -> None: + data = self._read() + users = data.get("users") + if not users: + auth = data.get("auth") or {} + users = [{ + "username": auth.get("username", "admin"), + "password": auth.get("password", "admin"), + "role": "superadmin", + }] + norm: list[dict] = [] + for u in users: + role = u.get("role", "operator") + if role not in ROLES: + role = "operator" + norm.append({ + "username": u.get("username", ""), + "password": u.get("password", ""), + "role": role, + }) + # гарантируем хотя бы одного суперадмина + if norm and not any(u["role"] == "superadmin" for u in norm): + norm[0]["role"] = "superadmin" + self.users = norm + + def _write_users(self) -> None: + data = self._read() + data["users"] = self.users + with open(self.path, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=2) + + def _find(self, username: str) -> dict | None: + return next((u for u in self.users if u["username"] == username), None) + + def _superadmin_count(self) -> int: + return sum(1 for u in self.users if u["role"] == "superadmin") + + # ── сессии ────────────────────────────────────────────────── + def login(self, username: str, password: str) -> str | None: + u = self._find(username) + if u and secrets.compare_digest(password, u["password"]): + token = secrets.token_urlsafe(32) + self._sessions[token] = username + return token + return None + + def validate(self, token: str | None) -> bool: + return bool(token) and token in self._sessions and \ + self._find(self._sessions[token]) is not None + + def user_for(self, token: str | None) -> dict | None: + if not token: + return None + username = self._sessions.get(token) + return self._find(username) if username else None + + def role_for(self, token: str | None) -> str | None: + u = self.user_for(token) + return u["role"] if u else None + + def logout(self, token: str | None) -> None: + if token: + self._sessions.pop(token, None) + + def verify_token_password(self, token: str | None, password: str) -> bool: + u = self.user_for(token) + return bool(u) and secrets.compare_digest(password or "", u["password"]) + + # ── управление пользователями ─────────────────────────────── + def list_users(self) -> list[dict]: + return [{"username": u["username"], "role": u["role"]} for u in self.users] + + def add_user(self, username: str, password: str, role: str) -> None: + username = (username or "").strip() + if not username: + raise ValueError("пустой логин") + if role not in ROLES: + raise ValueError("недопустимая роль") + if not password: + raise ValueError("пустой пароль") + if self._find(username): + raise ValueError("пользователь с таким логином уже существует") + self.users.append({"username": username, "password": password, "role": role}) + self._write_users() + + def update_user(self, username: str, password: str | None = None, + role: str | None = None) -> None: + u = self._find(username) + if not u: + raise ValueError("пользователь не найден") + if role is not None: + if role not in ROLES: + raise ValueError("недопустимая роль") + if u["role"] == "superadmin" and role != "superadmin" and \ + self._superadmin_count() == 1: + raise ValueError("нельзя снять роль с последнего суперадмина") + u["role"] = role + if password: + u["password"] = password + self._write_users() + + def delete_user(self, username: str) -> None: + u = self._find(username) + if not u: + raise ValueError("пользователь не найден") + if u["role"] == "superadmin" and self._superadmin_count() == 1: + raise ValueError("нельзя удалить последнего суперадмина") + self.users = [x for x in self.users if x["username"] != username] + self._sessions = {t: n for t, n in self._sessions.items() if n != username} + self._write_users() + + +auth_manager = AuthManager() + + +def require_role(request: Request, minimum: str) -> str: + """Проверяет, что текущая сессия имеет роль не ниже minimum, иначе 403.""" + role = auth_manager.role_for(request.cookies.get(COOKIE_NAME)) + if not role_at_least(role, minimum): + raise HTTPException(403, "недостаточно прав") + return role + + +router = APIRouter(prefix="/api", tags=["auth"]) + + +@router.post("/login") +async def login(request: Request) -> Response: + body = await request.json() + token = auth_manager.login(body.get("username", ""), body.get("password", "")) + if not token: + return JSONResponse({"detail": "неверный логин или пароль"}, status_code=401) + resp = JSONResponse({"ok": True}) + resp.set_cookie(COOKIE_NAME, token, httponly=True, samesite="lax", max_age=7 * 86400) + return resp + + +@router.get("/me") +async def me(request: Request) -> Response: + u = auth_manager.user_for(request.cookies.get(COOKIE_NAME)) + if not u: + return JSONResponse({"detail": "unauthorized"}, status_code=401) + return JSONResponse({"username": u["username"], "role": u["role"]}) + + +@router.post("/verify-password") +async def verify_password(request: Request) -> Response: + """Подтверждение пароля текущего оператора (для защищённых настроек).""" + body = await request.json() + token = request.cookies.get(COOKIE_NAME) + if not auth_manager.verify_token_password(token, body.get("password", "")): + return JSONResponse({"detail": "неверный пароль"}, status_code=403) + return JSONResponse({"ok": True}) + + +@router.post("/logout") +async def logout(request: Request) -> Response: + auth_manager.logout(request.cookies.get(COOKIE_NAME)) + resp = JSONResponse({"ok": True}) + resp.delete_cookie(COOKIE_NAME) + return resp + + +# ── управление пользователями (только суперадмин) ─────────────── +@router.get("/users") +async def users_list(request: Request) -> dict: + require_role(request, "superadmin") + return {"users": auth_manager.list_users()} + + +@router.post("/users") +async def users_create(request: Request) -> dict: + require_role(request, "superadmin") + body = await request.json() + try: + auth_manager.add_user(body.get("username", ""), body.get("password", ""), + body.get("role", "operator")) + except ValueError as e: + raise HTTPException(400, str(e)) + return {"ok": True} + + +@router.put("/users/{username}") +async def users_update(username: str, request: Request) -> dict: + require_role(request, "superadmin") + body = await request.json() + try: + auth_manager.update_user( + username, + password=(body.get("password") or None), + role=(body.get("role") or None), + ) + except ValueError as e: + raise HTTPException(400, str(e)) + return {"ok": True} + + +@router.delete("/users/{username}") +async def users_delete(username: str, request: Request) -> dict: + require_role(request, "superadmin") + try: + auth_manager.delete_user(username) + except ValueError as e: + raise HTTPException(400, str(e)) + return {"ok": True} diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..fd56265 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,115 @@ +"""Загрузка и валидация конфигурации NVR из cameras.yaml.""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from functools import lru_cache + +import yaml + + +@dataclass(frozen=True) +class Camera: + id: str + name: str + enabled: bool + ip: str + user: str + password: str + main_path: str + sub_path: str | None = None + + def rtsp_url(self, path: str) -> str: + return f"rtsp://{self.user}:{self.password}@{self.ip}:554{path}" + + @property + def record_url(self) -> str: + """Поток для записи (основной, H.265, копированием).""" + return self.rtsp_url(self.main_path) + + +@dataclass(frozen=True) +class Storage: + path: str = "/records" + quota_gb: int = 50 + retention_days: int = 7 + segment_seconds: int = 600 + + +@dataclass(frozen=True) +class Live: + go2rtc_url: str = "http://localhost:1984" + + +@dataclass +class Config: + storage: Storage + live: Live + cameras: list[Camera] = field(default_factory=list) + + def camera(self, cam_id: str) -> Camera | None: + return next((c for c in self.cameras if c.id == cam_id), None) + + @property + def enabled_cameras(self) -> list[Camera]: + return [c for c in self.cameras if c.enabled] + + +def load_config(path: str | None = None) -> Config: + path = path or os.environ.get("NVR_CONFIG", "cameras.yaml") + with open(path, "r", encoding="utf-8") as fh: + raw = yaml.safe_load(fh) or {} + + storage = Storage(**(raw.get("storage") or {})) + live = Live(**(raw.get("live") or {})) + cameras = [ + Camera( + id=c["id"], + name=c.get("name", c["id"]), + enabled=c.get("enabled", True), + ip=c["ip"], + user=c["user"], + password=c["password"], + main_path=c["main_path"], + sub_path=c.get("sub_path"), + ) + for c in (raw.get("cameras") or []) + ] + return Config(storage=storage, live=live, cameras=cameras) + + +def camera_to_yaml(c: Camera) -> dict: + return { + "id": c.id, + "name": c.name, + "enabled": c.enabled, + "ip": c.ip, + "user": c.user, + "password": c.password, + "main_path": c.main_path, + "sub_path": c.sub_path, + } + + +def save_config(config: Config, path: str | None = None) -> None: + """Сохраняет конфиг обратно в cameras.yaml (комментарии теряются).""" + path = path or os.environ.get("NVR_CONFIG", "cameras.yaml") + data = { + "storage": { + "path": config.storage.path, + "quota_gb": config.storage.quota_gb, + "retention_days": config.storage.retention_days, + "segment_seconds": config.storage.segment_seconds, + }, + "live": {"go2rtc_url": config.live.go2rtc_url}, + "cameras": [camera_to_yaml(c) for c in config.cameras], + } + # пишем на месте: cameras.yaml примонтирован как отдельный файл (bind mount), + # переименование поверх точки монтирования даёт EBUSY. + with open(path, "w", encoding="utf-8") as fh: + yaml.safe_dump(data, fh, allow_unicode=True, sort_keys=False) + + +@lru_cache(maxsize=1) +def get_config() -> Config: + return load_config() diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..7e8e86f --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,131 @@ +"""SQLite-индекс архива (aiosqlite).""" +from __future__ import annotations + +import os +import time + +import aiosqlite + +from .models import Recording + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + camera_id TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + started_at INTEGER NOT NULL, + ended_at INTEGER, + duration_s REAL, + size_bytes INTEGER, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_rec_cam_time ON recordings(camera_id, started_at); + +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + camera_id TEXT NOT NULL, + type TEXT NOT NULL, + ts INTEGER NOT NULL, + meta TEXT +); +""" + + +class Database: + def __init__(self, path: str | None = None): + self.path = path or os.environ.get("NVR_DB", "/data/nvr.db") + self._conn: aiosqlite.Connection | None = None + + async def connect(self) -> None: + os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True) + self._conn = await aiosqlite.connect(self.path) + self._conn.row_factory = aiosqlite.Row + await self._conn.executescript(SCHEMA) + await self._conn.commit() + + async def close(self) -> None: + if self._conn: + await self._conn.close() + self._conn = None + + @property + def conn(self) -> aiosqlite.Connection: + if self._conn is None: + raise RuntimeError("Database not connected") + return self._conn + + # ── recordings ───────────────────────────────────────────── + async def has_path(self, path: str) -> bool: + cur = await self.conn.execute("SELECT 1 FROM recordings WHERE path = ?", (path,)) + return await cur.fetchone() is not None + + async def add_recording( + self, camera_id: str, path: str, started_at: int, + duration_s: float | None, size_bytes: int | None, + ) -> None: + ended_at = int(started_at + duration_s) if duration_s else None + await self.conn.execute( + """INSERT OR IGNORE INTO recordings + (camera_id, path, started_at, ended_at, duration_s, size_bytes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (camera_id, path, started_at, ended_at, duration_s, size_bytes, int(time.time())), + ) + await self.conn.commit() + + async def list_recordings( + self, camera_id: str | None, ts_from: int | None, ts_to: int | None, + limit: int = 2000, + ) -> list[Recording]: + sql = "SELECT * FROM recordings WHERE 1=1" + args: list = [] + if camera_id: + sql += " AND camera_id = ?" + args.append(camera_id) + if ts_from is not None: + sql += " AND started_at >= ?" + args.append(ts_from) + if ts_to is not None: + sql += " AND started_at <= ?" + args.append(ts_to) + sql += " ORDER BY started_at ASC LIMIT ?" + args.append(limit) + cur = await self.conn.execute(sql, args) + rows = await cur.fetchall() + return [Recording(**dict(r)) for r in rows] + + async def get_recording(self, rec_id: int) -> Recording | None: + cur = await self.conn.execute("SELECT * FROM recordings WHERE id = ?", (rec_id,)) + row = await cur.fetchone() + return Recording(**dict(row)) if row else None + + async def oldest_recordings(self, limit: int = 50) -> list[Recording]: + cur = await self.conn.execute( + "SELECT * FROM recordings ORDER BY started_at ASC LIMIT ?", (limit,) + ) + rows = await cur.fetchall() + return [Recording(**dict(r)) for r in rows] + + async def recordings_before(self, ts: int) -> list[Recording]: + cur = await self.conn.execute( + "SELECT * FROM recordings WHERE started_at < ? ORDER BY started_at ASC", (ts,) + ) + rows = await cur.fetchall() + return [Recording(**dict(r)) for r in rows] + + async def delete_recording(self, rec_id: int) -> None: + await self.conn.execute("DELETE FROM recordings WHERE id = ?", (rec_id,)) + await self.conn.commit() + + async def delete_missing(self, path: str) -> None: + await self.conn.execute("DELETE FROM recordings WHERE path = ?", (path,)) + await self.conn.commit() + + async def total_size(self) -> int: + cur = await self.conn.execute("SELECT COALESCE(SUM(size_bytes), 0) FROM recordings") + row = await cur.fetchone() + return int(row[0]) if row else 0 + + async def known_paths(self) -> set[str]: + cur = await self.conn.execute("SELECT path FROM recordings") + rows = await cur.fetchall() + return {r[0] for r in rows} diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..af150e9 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,136 @@ +"""Точка входа NVR: FastAPI + супервизор записи + индексатор + retention.""" +from __future__ import annotations + +import asyncio +import logging +import os +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse + +from .config import load_config +from .db import Database +from .recorder.indexer import Indexer +from .recorder.supervisor import Supervisor +from .services.retention import Retention +from .runtime import runtime +from . import auth, prefs +from .api import cameras, recordings, system, ws +from .api.ws import ConnectionManager +from .services import go2rtc + +logging.basicConfig( + level=os.environ.get("NVR_LOG_LEVEL", "INFO"), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +log = logging.getLogger("nvr") + +FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + runtime.config = load_config() + runtime.db = Database() + await runtime.db.connect() + + runtime.ws = ConnectionManager() + runtime.ws.bind_loop(asyncio.get_running_loop()) + + def on_status(status) -> None: + runtime.ws.broadcast_threadsafe({"type": "camera", "camera": status.to_dict()}) + + runtime.supervisor = Supervisor(runtime.config, on_status=on_status) + runtime.indexer = Indexer(runtime.config, runtime.db) + runtime.retention = Retention(runtime.config, runtime.db) + + os.makedirs(runtime.config.storage.path, exist_ok=True) + await go2rtc.sync_all(runtime.config) # потоки (вкл. _main) в go2rtc для рестрима + runtime.supervisor.start_all() + runtime.indexer.start() + runtime.retention.start() + log.info("NVR запущен: камер=%d, хранилище=%s", + len(runtime.config.cameras), runtime.config.storage.path) + + try: + yield + finally: + log.info("остановка NVR…") + await runtime.indexer.stop() + await runtime.retention.stop() + await runtime.supervisor.stop_all() + await runtime.db.close() + + +app = FastAPI(title="CoRE.Vision", version="1.0", lifespan=lifespan) + +# публичные пути (без авторизации) +PUBLIC_PATHS = {"/login", "/api/login", "/api/health"} + + +@app.middleware("http") +async def cross_origin_isolation(request: Request, call_next): + # COOP/COEP включают crossOriginIsolated → доступен SharedArrayBuffer, + # который нужен многопоточному WASM-декодеру H.265 (свой плеер jessibuca). + response = await call_next(request) + response.headers["Cross-Origin-Opener-Policy"] = "same-origin" + response.headers["Cross-Origin-Embedder-Policy"] = "credentialless" + return response + + +@app.middleware("http") +async def require_auth(request: Request, call_next): + path = request.url.path + if path in PUBLIC_PATHS or path.startswith("/static/"): + return await call_next(request) + if auth.auth_manager.validate(request.cookies.get(auth.COOKIE_NAME)): + return await call_next(request) + if path.startswith("/api/"): + return JSONResponse({"detail": "unauthorized"}, status_code=401) + return RedirectResponse("/login") + + +app.include_router(auth.router) +app.include_router(prefs.router) +app.include_router(cameras.router) +app.include_router(recordings.router) +app.include_router(system.router) +app.include_router(ws.router) + + +# ── фронтенд ──────────────────────────────────────────────────── +@app.get("/") +async def index() -> FileResponse: + return FileResponse(os.path.join(FRONTEND_DIR, "index.html")) + + +@app.get("/archive") +async def archive() -> FileResponse: + return FileResponse(os.path.join(FRONTEND_DIR, "archive.html")) + + +@app.get("/settings") +async def settings() -> FileResponse: + return FileResponse(os.path.join(FRONTEND_DIR, "settings.html")) + + +@app.get("/cams") +async def cams() -> FileResponse: + return FileResponse(os.path.join(FRONTEND_DIR, "cams.html")) + + +@app.get("/layout") +async def layout() -> RedirectResponse: + # раскладка переехала во вкладку «Настройки → Раскладка» + return RedirectResponse("/settings") + + +@app.get("/login") +async def login_page() -> FileResponse: + return FileResponse(os.path.join(FRONTEND_DIR, "login.html")) + + +if os.path.isdir(os.path.join(FRONTEND_DIR, "static")): + app.mount("/static", StaticFiles(directory=os.path.join(FRONTEND_DIR, "static")), name="static") diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..2adf9de --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,44 @@ +"""Датаклассы строк БД и статусов рекордера.""" +from __future__ import annotations + +from dataclasses import asdict, dataclass +from enum import Enum + + +class CameraState(str, Enum): + ONLINE = "online" # ffmpeg пишет + RETRYING = "retrying" # упал, ждём перезапуск (backoff) + OFFLINE = "offline" # выключена/остановлена + STARTING = "starting" + + +@dataclass +class Recording: + id: int + camera_id: str + path: str + started_at: int + ended_at: int | None + duration_s: float | None + size_bytes: int | None + created_at: int + + def to_dict(self) -> dict: + return asdict(self) + + +@dataclass +class CameraStatus: + camera_id: str + name: str + enabled: bool + state: CameraState + last_error: str | None = None + restarts: int = 0 + pid: int | None = None + since: int | None = None # unix-время входа в текущее состояние + + def to_dict(self) -> dict: + d = asdict(self) + d["state"] = self.state.value + return d diff --git a/backend/app/prefs.py b/backend/app/prefs.py new file mode 100644 index 0000000..5f0cc81 --- /dev/null +++ b/backend/app/prefs.py @@ -0,0 +1,104 @@ +"""UI-настройки, сохраняемые в .env.json (раздел "ui"). + +Хранятся на сервере → значение общее для всех клиентов и переживает перезапуск. +""" +from __future__ import annotations + +import json +import os + +from fastapi import APIRouter, HTTPException, Request + +from .auth import COOKIE_NAME, auth_manager, require_role + +DEFAULT_UI = {"live_transcode": False, "archive_transcode": False} +DEFAULT_OPT = {"single_main_stream": True, "mode_16plus": False} # серверная политика Live view + + +def _path() -> str: + return os.environ.get("NVR_SETTINGS", ".env.json") + + +def load_ui() -> dict: + try: + with open(_path(), "r", encoding="utf-8") as fh: + data = json.load(fh) + ui = data.get("ui") or {} + except (OSError, json.JSONDecodeError): + ui = {} + return {**DEFAULT_UI, **{k: ui[k] for k in DEFAULT_UI if k in ui}} + + +def save_ui(patch: dict) -> dict: + path = _path() + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, json.JSONDecodeError): + data = {} + merged = load_ui() + for k in DEFAULT_UI: + if k in patch: + merged[k] = bool(patch[k]) + data["ui"] = merged + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=2) + return merged + + +def load_optimization() -> dict: + try: + with open(_path(), "r", encoding="utf-8") as fh: + data = json.load(fh) + opt = data.get("optimization") or {} + except (OSError, json.JSONDecodeError): + opt = {} + return {**DEFAULT_OPT, **{k: bool(opt[k]) for k in DEFAULT_OPT if k in opt}} + + +def save_optimization(patch: dict) -> dict: + path = _path() + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, json.JSONDecodeError): + data = {} + merged = load_optimization() + for k in DEFAULT_OPT: + if k in patch: + merged[k] = bool(patch[k]) + data["optimization"] = merged + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=2) + return merged + + +router = APIRouter(prefix="/api", tags=["settings"]) + + +@router.get("/settings") +async def get_settings() -> dict: + return load_ui() + + +@router.post("/settings") +async def update_settings(request: Request) -> dict: + body = await request.json() + return save_ui(body) + + +@router.get("/optimization") +async def get_optimization() -> dict: + """Политика Live view; читают все авторизованные клиенты.""" + return load_optimization() + + +@router.post("/optimization") +async def update_optimization(request: Request) -> dict: + """Менять может только суперадмин и только с подтверждением пароля.""" + require_role(request, "superadmin") + body = await request.json() + if not auth_manager.verify_token_password(request.cookies.get(COOKIE_NAME), + body.get("password", "")): + raise HTTPException(403, "неверный пароль") + return save_optimization(body) diff --git a/backend/app/recorder/__init__.py b/backend/app/recorder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/recorder/ffmpeg.py b/backend/app/recorder/ffmpeg.py new file mode 100644 index 0000000..328f9c2 --- /dev/null +++ b/backend/app/recorder/ffmpeg.py @@ -0,0 +1,61 @@ +"""Сборка команд ffmpeg/ffprobe. Запись — строго копированием (без перекодирования).""" +from __future__ import annotations + +import asyncio +import json +import os + +from ..config import Camera, Storage + + +def record_args(cam: Camera, storage: Storage, input_url: str | None = None) -> list[str]: + """ffmpeg для записи основного потока сегментами mp4 копированием. + + -c copy => CPU почти не используется. -strftime даёт имена сегментов по времени. + input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию). + """ + # Плоско в каталоге камеры: дата в имени файла. Каталог создаётся один раз + # (recorder_task), новых подкаталогов не нужно — корректно переживает полночь. + # Подкаталог даты не используем: этот ffmpeg-билд не знает -strftime_mkdir. + out_template = os.path.join(storage.path, cam.id, "%Y-%m-%d_%H-%M-%S.mp4") + return [ + "ffmpeg", + "-nostdin", + "-hide_banner", + "-loglevel", "warning", + "-rtsp_transport", "tcp", + "-timeout", "10000000", # 10s на установку соединения (микросекунды) + "-i", input_url or cam.record_url, + "-an", # звук не пишем (камеры обычно без него) + "-c:v", "copy", + "-f", "segment", + "-segment_time", str(storage.segment_seconds), + "-segment_atclocktime", "1", # резать по часам: кратно segment_time от 00:00 + "-segment_clocktime_offset", "0", + "-segment_format", "mp4", + "-reset_timestamps", "1", + "-strftime", "1", + out_template, + ] + + +async def probe_duration(path: str) -> tuple[float | None, int | None]: + """ffprobe: длительность (сек) и размер файла (байт).""" + try: + size = os.path.getsize(path) + except OSError: + size = None + + proc = await asyncio.create_subprocess_exec( + "ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", path, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + ) + out, _ = await proc.communicate() + duration = None + if proc.returncode == 0 and out: + try: + data = json.loads(out) + duration = float(data["format"]["duration"]) + except (json.JSONDecodeError, KeyError, ValueError): + duration = None + return duration, size diff --git a/backend/app/recorder/indexer.py b/backend/app/recorder/indexer.py new file mode 100644 index 0000000..47e8bf7 --- /dev/null +++ b/backend/app/recorder/indexer.py @@ -0,0 +1,109 @@ +"""Индексатор архива: периодически сканирует /records, заносит новые сегменты в БД. + +Логика: самый свежий сегмент каждой камеры ещё пишется ffmpeg-ом — его пропускаем. +Все остальные mp4, которых нет в БД, пробуем через ffprobe и добавляем. +Параллельно убираем из БД записи о пропавших файлах. +""" +from __future__ import annotations + +import asyncio +import logging +import os +import time +from datetime import datetime + +from ..config import Config +from ..db import Database +from .ffmpeg import probe_duration + +log = logging.getLogger("nvr.indexer") + +SCAN_INTERVAL = 30 # сек + + +def _parse_started_at(path: str) -> int: + """Время начала сегмента из имени /.mp4.""" + try: + stem = os.path.splitext(os.path.basename(path))[0] + dt = datetime.strptime(stem, "%Y-%m-%d_%H-%M-%S") + return int(dt.timestamp()) + except ValueError: + return int(os.path.getmtime(path)) + + +def _scan_segments(root: str) -> dict[str, list[str]]: + """Возвращает {camera_id: [пути mp4, отсортированы]}.""" + 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) + if not os.path.isdir(cam_dir): + continue + files = [ + os.path.join(cam_dir, fn) + for fn in os.listdir(cam_dir) + if fn.endswith(".mp4") + ] + files.sort() + if files: + result[cam_id] = files + return result + + +class Indexer: + def __init__(self, config: Config, db: Database): + self.config = config + self.db = db + self._task: asyncio.Task | None = None + self._stopping = False + + def start(self) -> None: + self._stopping = False + self._task = asyncio.create_task(self._loop(), name="indexer") + + async def stop(self) -> None: + self._stopping = True + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + + async def _loop(self) -> None: + while not self._stopping: + try: + await self.scan_once() + except Exception: # noqa: BLE001 + log.exception("indexer scan failed") + await asyncio.sleep(SCAN_INTERVAL) + + async def scan_once(self) -> int: + root = self.config.storage.path + by_cam = _scan_segments(root) + known = await self.db.known_paths() + added = 0 + + for cam_id, files in by_cam.items(): + # последний файл может писаться прямо сейчас — не индексируем + complete = files[:-1] if len(files) > 1 else [] + for path in complete: + if path in known: + continue + duration, size = await probe_duration(path) + if size is None or size == 0: + continue + await self.db.add_recording( + cam_id, path, _parse_started_at(path), duration, size + ) + added += 1 + + # уборка пропавших файлов + for path in known: + if not os.path.exists(path): + await self.db.delete_missing(path) + + if added: + log.info("indexer: добавлено сегментов %d", added) + return added diff --git a/backend/app/recorder/recorder_task.py b/backend/app/recorder/recorder_task.py new file mode 100644 index 0000000..3efe23a --- /dev/null +++ b/backend/app/recorder/recorder_task.py @@ -0,0 +1,139 @@ +"""Задача записи одной камеры: запуск ffmpeg + супервизия с backoff.""" +from __future__ import annotations + +import asyncio +import logging +import os +import time +from collections import deque + +from ..config import Camera, Storage +from ..models import CameraState, CameraStatus +from .ffmpeg import record_args + +log = logging.getLogger("nvr.recorder") + +BACKOFF_START = 1.0 +BACKOFF_MAX = 60.0 +MIN_HEALTHY_UPTIME = 15.0 # процесс прожил столько => считаем старт удачным, сбрасываем backoff + + +class RecorderTask: + """Держит один процесс ffmpeg на камеру, перезапускает при падении.""" + + def __init__(self, cam: Camera, storage: Storage, on_status=None, input_url: str | None = None): + self.cam = cam + self.storage = storage + self.input_url = input_url # источник записи (рестрим go2rtc) + self.on_status = on_status # callback(CameraStatus) для WS-уведомлений + self.status = CameraStatus( + camera_id=cam.id, name=cam.name, enabled=cam.enabled, + state=CameraState.OFFLINE, since=int(time.time()), + ) + self._proc: asyncio.subprocess.Process | None = None + self._task: asyncio.Task | None = None + self._stopping = False + self._log_tail: deque[str] = deque(maxlen=40) + + # ── управление ────────────────────────────────────────────── + def start(self) -> None: + if self._task and not self._task.done(): + return + self._stopping = False + self._task = asyncio.create_task(self._run(), name=f"rec-{self.cam.id}") + + async def stop(self) -> None: + self._stopping = True + await self._kill_proc() + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._set_state(CameraState.OFFLINE) + + async def _kill_proc(self) -> None: + proc = self._proc + if proc and proc.returncode is None: + try: + proc.terminate() + await asyncio.wait_for(proc.wait(), timeout=5) + except (asyncio.TimeoutError, ProcessLookupError): + try: + proc.kill() + except ProcessLookupError: + pass + self._proc = None + + # ── основной цикл ─────────────────────────────────────────── + async def _run(self) -> None: + backoff = BACKOFF_START + os.makedirs(os.path.join(self.storage.path, self.cam.id), exist_ok=True) + + while not self._stopping: + self._set_state(CameraState.STARTING) + args = record_args(self.cam, self.storage, self.input_url) + log.info("[%s] start ffmpeg: %s", self.cam.id, _redact(args, self.cam)) + try: + self._proc = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError: + self._set_state(CameraState.RETRYING, "ffmpeg not found") + return + + self.status.pid = self._proc.pid + self._set_state(CameraState.ONLINE) + started = time.monotonic() + + await self._drain_stderr(self._proc) + rc = await self._proc.wait() + uptime = time.monotonic() - started + + if self._stopping: + break + + # backoff сбрасываем только если поток реально шёл (а не упал сразу, как .33 с 453) + if uptime >= MIN_HEALTHY_UPTIME: + backoff = BACKOFF_START + else: + backoff = min(backoff * 2, BACKOFF_MAX) + + err = self._log_tail[-1] if self._log_tail else f"exit code {rc}" + self.status.restarts += 1 + self._set_state(CameraState.RETRYING, err) + log.warning("[%s] ffmpeg exited rc=%s (uptime %.1fs), retry in %.0fs", + self.cam.id, rc, uptime, backoff) + await asyncio.sleep(backoff) + + async def _drain_stderr(self, proc: asyncio.subprocess.Process) -> None: + assert proc.stderr is not None + async for raw in proc.stderr: + line = raw.decode("utf-8", "replace").rstrip() + if line: + self._log_tail.append(line) + log.debug("[%s] %s", self.cam.id, line) + + # ── статус ────────────────────────────────────────────────── + def _set_state(self, state: CameraState, error: str | None = None) -> None: + self.status.state = state + self.status.last_error = error + self.status.since = int(time.time()) + if state != CameraState.ONLINE: + self.status.pid = None + if self.on_status: + try: + self.on_status(self.status) + except Exception: # noqa: BLE001 — статус не должен ронять рекордер + log.exception("on_status callback failed") + + @property + def log_tail(self) -> list[str]: + return list(self._log_tail) + + +def _redact(args: list[str], cam: Camera) -> str: + return " ".join(a.replace(cam.password, "***") for a in args) diff --git a/backend/app/recorder/supervisor.py b/backend/app/recorder/supervisor.py new file mode 100644 index 0000000..6383dee --- /dev/null +++ b/backend/app/recorder/supervisor.py @@ -0,0 +1,80 @@ +"""Супервизор: владеет набором RecorderTask по всем включённым камерам.""" +from __future__ import annotations + +import logging + +from ..config import Camera, Config +from ..models import CameraStatus +from ..services import go2rtc +from .recorder_task import RecorderTask + +log = logging.getLogger("nvr.supervisor") + + +class Supervisor: + def __init__(self, config: Config, on_status=None): + self.config = config + self.on_status = on_status + self.tasks: dict[str, RecorderTask] = {} + + def _input_url(self, cam: Camera) -> str: + # запись основного потока идёт через рестрим go2rtc (одна сессия к камере) + return go2rtc.restream_main_url(self.config, cam.id) + + def start_all(self) -> None: + for cam in self.config.cameras: + task = RecorderTask(cam, self.config.storage, on_status=self.on_status, + input_url=self._input_url(cam)) + self.tasks[cam.id] = task + if cam.enabled: + task.start() + log.info("recorder started: %s", cam.id) + else: + log.info("recorder skipped (disabled): %s", cam.id) + + async def stop_all(self) -> None: + for task in self.tasks.values(): + await task.stop() + + def statuses(self) -> list[CameraStatus]: + return [t.status for t in self.tasks.values()] + + def status(self, cam_id: str) -> CameraStatus | None: + task = self.tasks.get(cam_id) + return task.status if task else None + + def log_tail(self, cam_id: str) -> list[str]: + task = self.tasks.get(cam_id) + return task.log_tail if task else [] + + async def restart(self, cam_id: str) -> bool: + task = self.tasks.get(cam_id) + if not task: + return False + await task.stop() + task.start() + return True + + def add_camera(self, cam: Camera) -> None: + """Создаёт задачу записи для новой камеры и запускает (если включена).""" + task = RecorderTask(cam, self.config.storage, on_status=self.on_status, + input_url=self._input_url(cam)) + self.tasks[cam.id] = task + if cam.enabled: + task.start() + log.info("recorder added: %s", cam.id) + + async def remove_camera(self, cam_id: str) -> None: + task = self.tasks.pop(cam_id, None) + if task: + await task.stop() + log.info("recorder removed: %s", cam_id) + + async def replace_camera(self, cam: Camera) -> None: + await self.remove_camera(cam.id) + self.add_camera(cam) + + async def restart_all(self) -> None: + """Пересоздаёт все задачи (например, после смены настроек хранилища).""" + for cam in list(self.config.cameras): + await self.replace_camera(cam) diff --git a/backend/app/runtime.py b/backend/app/runtime.py new file mode 100644 index 0000000..84681b2 --- /dev/null +++ b/backend/app/runtime.py @@ -0,0 +1,24 @@ +"""Глобальные ссылки на живые компоненты (заполняются в lifespan).""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .config import Config + from .db import Database + from .recorder.indexer import Indexer + from .recorder.supervisor import Supervisor + from .services.retention import Retention + from .api.ws import ConnectionManager + + +class Runtime: + config: "Config" + db: "Database" + supervisor: "Supervisor" + indexer: "Indexer" + retention: "Retention" + ws: "ConnectionManager" + + +runtime = Runtime() diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/go2rtc.py b/backend/app/services/go2rtc.py new file mode 100644 index 0000000..8fa0d95 --- /dev/null +++ b/backend/app/services/go2rtc.py @@ -0,0 +1,122 @@ +"""Синхронизация потоков с go2rtc + рестрим основного потока. + +Камеры Hikvision ограничивают основной поток (нельзя открыть его дважды: +запись + live дают RTSP 453). Решение: go2rtc держит ОДНО соединение с камерой +и раздаёт нескольким потребителям. И запись, и live основного потока читают +из go2rtc (rtsp-рестрим на :8554), поэтому камера видит одну сессию. + +go2rtc-потоки на камеру: + _main — основной поток (101), единственный источник записи и live-HD. + — субпоток (102) для живой сетки; если субпотока нет — ссылка на + _main (повторное использование, без второго коннекта к камере). + _h264 — субпоток, перекодированный в H.264 (для WebRTC/браузеров без HEVC). +""" +from __future__ import annotations + +import asyncio +import logging +import os +import urllib.parse +import urllib.request +from urllib.parse import urlparse + +import yaml + +from ..config import Camera, Config + +log = logging.getLogger("nvr.go2rtc") + +GO2RTC_YAML = os.environ.get("GO2RTC_CONFIG", "/app/go2rtc.yaml") + + +def _rtsp_host(config: Config) -> str: + return urlparse(config.live.go2rtc_url).hostname or "127.0.0.1" + + +def restream_main_url(config: Config, cam_id: str) -> str: + """RTSP-адрес основного потока камеры через go2rtc (для записи и live-HD).""" + return f"rtsp://{_rtsp_host(config)}:8554/{cam_id}_main" + + +def restream_sub_url(config: Config, cam_id: str) -> str: + """RTSP-адрес живого (суб)потока через go2rtc (для транскода сабпотока).""" + return f"rtsp://{_rtsp_host(config)}:8554/{cam_id}" + + +def _streams_for(cam: Camera) -> dict[str, str]: + """Имя go2rtc-потока -> источник, для одной камеры.""" + out = {f"{cam.id}_main": cam.rtsp_url(cam.main_path)} + if cam.sub_path: + out[cam.id] = cam.rtsp_url(cam.sub_path) + else: + # нет субпотока — берём основной из go2rtc, не открывая второй коннект к камере + out[cam.id] = f"rtsp://127.0.0.1:8554/{cam.id}_main" + out[f"{cam.id}_h264"] = f"ffmpeg:{cam.id}#video=h264#audio=copy" + return out + + +def build_streams(config: Config) -> dict: + streams: dict = {} + for c in config.cameras: + for name, src in _streams_for(c).items(): + streams[name] = [src] + return streams + + +def write_go2rtc_yaml(config: Config) -> None: + data = { + "api": {"listen": ":1984"}, + "rtsp": {"listen": ":8554"}, + "webrtc": {"listen": ":8555"}, + "log": {"level": "info"}, + "streams": build_streams(config), + } + try: + with open(GO2RTC_YAML, "w", encoding="utf-8") as fh: + yaml.safe_dump(data, fh, allow_unicode=True, sort_keys=False) + except OSError as exc: + log.warning("не удалось записать go2rtc.yaml: %s", exc) + + +def _http(method: str, url: str) -> int: + req = urllib.request.Request(url, method=method) + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.status + + +async def _call(method: str, url: str) -> None: + try: + await asyncio.to_thread(_http, method, url) + except Exception as exc: # noqa: BLE001 — go2rtc может быть недоступен/вернуть 4xx при ro-конфиге + log.debug("go2rtc API %s -> %s", method, exc) + + +def _put(base: str, name: str, src: str) -> str: + return f"{base}/api/streams?" + urllib.parse.urlencode({"name": name, "src": src}) + + +def _del(base: str, name: str) -> str: + return f"{base}/api/streams?" + urllib.parse.urlencode({"src": name}) + + +async def add_camera_streams(config: Config, cam: Camera) -> None: + base = config.live.go2rtc_url.rstrip("/") + for name, src in _streams_for(cam).items(): + await _call("PUT", _put(base, name, src)) + write_go2rtc_yaml(config) + + +async def remove_camera_streams(config: Config, cam_id: str) -> None: + base = config.live.go2rtc_url.rstrip("/") + for name in (f"{cam_id}_main", cam_id, f"{cam_id}_h264"): + await _call("DELETE", _del(base, name)) + write_go2rtc_yaml(config) + + +async def sync_all(config: Config) -> None: + """При старте: записать конфиг и добавить все потоки в go2rtc через API.""" + write_go2rtc_yaml(config) + base = config.live.go2rtc_url.rstrip("/") + for cam in config.cameras: + for name, src in _streams_for(cam).items(): + await _call("PUT", _put(base, name, src)) diff --git a/backend/app/services/retention.py b/backend/app/services/retention.py new file mode 100644 index 0000000..b78b2e5 --- /dev/null +++ b/backend/app/services/retention.py @@ -0,0 +1,101 @@ +"""Очистка архива по квоте диска и по сроку хранения.""" +from __future__ import annotations + +import asyncio +import logging +import os +import time + +from ..config import Config +from ..db import Database + +log = logging.getLogger("nvr.retention") + +CHECK_INTERVAL = 300 # сек + + +class Retention: + def __init__(self, config: Config, db: Database): + self.config = config + self.db = db + self._task: asyncio.Task | None = None + self._stopping = False + + def start(self) -> None: + self._stopping = False + self._task = asyncio.create_task(self._loop(), name="retention") + + async def stop(self) -> None: + self._stopping = True + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + + async def _loop(self) -> None: + while not self._stopping: + try: + await self.enforce() + except Exception: # noqa: BLE001 + log.exception("retention failed") + await asyncio.sleep(CHECK_INTERVAL) + + async def enforce(self) -> int: + removed = 0 + removed += await self._enforce_age() + removed += await self._enforce_quota() + if removed: + log.info("retention: удалено сегментов %d", removed) + return removed + + async def _enforce_age(self) -> int: + days = self.config.storage.retention_days + if not days: + return 0 + cutoff = int(time.time()) - days * 86400 + old = await self.db.recordings_before(cutoff) + for rec in old: + await self._remove(rec.id, rec.path) + return len(old) + + async def _enforce_quota(self) -> int: + quota = self.config.storage.quota_gb * 1024 ** 3 + if not quota: + return 0 + removed = 0 + # удаляем самые старые, пока суммарный размер не уложится в квоту + while await self.db.total_size() > quota: + batch = await self.db.oldest_recordings(limit=20) + if not batch: + break + for rec in batch: + await self._remove(rec.id, rec.path) + removed += 1 + if await self.db.total_size() <= quota: + break + return removed + + async def _remove(self, rec_id: int, path: str) -> None: + try: + os.remove(path) + except FileNotFoundError: + pass + except OSError as exc: + log.warning("не удалось удалить %s: %s", path, exc) + await self.db.delete_recording(rec_id) + _cleanup_empty_dirs(os.path.dirname(path)) + + +def _cleanup_empty_dirs(path: str) -> None: + """Удаляет пустой каталог дня (и камеры), оставшийся после очистки.""" + for _ in range(2): + try: + if path and os.path.isdir(path) and not os.listdir(path): + os.rmdir(path) + path = os.path.dirname(path) + else: + break + except OSError: + break diff --git a/backend/app/services/system.py b/backend/app/services/system.py new file mode 100644 index 0000000..5861e51 --- /dev/null +++ b/backend/app/services/system.py @@ -0,0 +1,39 @@ +"""Системная статистика: диск хранилища, CPU, RAM, аптайм.""" +from __future__ import annotations + +import shutil +import time + +import psutil + +from ..config import Config + +_START = time.time() + + +def system_stats(config: Config) -> dict: + path = config.storage.path + try: + usage = shutil.disk_usage(path) + disk = { + "total_gb": round(usage.total / 1024 ** 3, 1), + "used_gb": round(usage.used / 1024 ** 3, 1), + "free_gb": round(usage.free / 1024 ** 3, 1), + "percent": round(usage.used / usage.total * 100, 1) if usage.total else 0, + } + except OSError: + disk = {"total_gb": 0, "used_gb": 0, "free_gb": 0, "percent": 0} + + vm = psutil.virtual_memory() + return { + "uptime_s": int(time.time() - _START), + "cpu_percent": psutil.cpu_percent(interval=None), + "ram_percent": vm.percent, + "ram_used_gb": round(vm.used / 1024 ** 3, 1), + "ram_total_gb": round(vm.total / 1024 ** 3, 1), + "storage_path": path, + "disk": disk, + "quota_gb": config.storage.quota_gb, + "retention_days": config.storage.retention_days, + "segment_seconds": config.storage.segment_seconds, + } diff --git a/backend/app/transcode.py b/backend/app/transcode.py new file mode 100644 index 0000000..22ced97 --- /dev/null +++ b/backend/app/transcode.py @@ -0,0 +1,88 @@ +"""Общие параметры перекодирования H.264 (профиль, битрейт, пресет, GOP).""" +from __future__ import annotations + +H264_PROFILES = ("baseline", "main", "high") +BITRATES = ("500k", "1000k", "2000k", "4000k", "8000k", "10000k", "16000k", "20000k", "24000k") +PRESETS = ("ultrafast", "superfast") +KEYINTS = (1, 2, 5) # интервал ключевых кадров, секунды +CODECS = ("h264", "vp9") + +DEFAULT_PROFILE = "main" +DEFAULT_BITRATE = "2000k" +DEFAULT_PRESET = "superfast" +DEFAULT_KEYINT = 2 +DEFAULT_CODEC = "h264" + + +def norm_profile(p: str | None) -> str: + return p if p in H264_PROFILES else DEFAULT_PROFILE + + +def norm_codec(c: str | None) -> str: + return c if c in CODECS else DEFAULT_CODEC + + +def norm_bitrate(b: str | None) -> str: + return b if b in BITRATES else DEFAULT_BITRATE + + +def norm_preset(p: str | None) -> str: + return p if p in PRESETS else DEFAULT_PRESET + + +def norm_keyint(k) -> int: + try: + k = int(k) + except (TypeError, ValueError): + return DEFAULT_KEYINT + return k if k in KEYINTS else DEFAULT_KEYINT + + +def x264_args(profile: str, bitrate: str, low_latency: bool = False, + preset: str | None = None, keyint_sec=None) -> list[str]: + """Аргументы libx264: профиль, битрейт, пресет и интервал ключевых кадров. + + Ключевые кадры расставляются по времени (через -force_key_frames), поэтому + интервал в секундах соблюдается независимо от fps камеры.""" + bufsize = f"{int(bitrate[:-1]) * 2}k" + preset = norm_preset(preset) + sec = norm_keyint(keyint_sec) + args = [ + "-c:v", "libx264", + "-preset", preset, + "-profile:v", profile, + "-b:v", bitrate, + "-maxrate", bitrate, + "-bufsize", bufsize, + "-force_key_frames", f"expr:gte(t,n_forced*{sec})", + "-pix_fmt", "yuv420p", + ] + if low_latency: + args += ["-tune", "zerolatency"] + return args + + +def vp9_args(bitrate: str, low_latency: bool = False, + preset: str | None = None, keyint_sec=None) -> list[str]: + """Аргументы кодировщика libvpx-VP9 (профиль H.264 неприменим).""" + sec = norm_keyint(keyint_sec) + # быстрый пресет → выше cpu-used (быстрее, ниже качество); realtime для live + cpu = "8" if norm_preset(preset) == "ultrafast" else "6" + return [ + "-c:v", "libvpx-vp9", + "-b:v", bitrate, + "-maxrate", bitrate, + "-deadline", "realtime", + "-cpu-used", cpu, + "-row-mt", "1", + "-force_key_frames", f"expr:gte(t,n_forced*{sec})", + "-pix_fmt", "yuv420p", + ] + + +def encode_args(codec: str, profile: str, bitrate: str, low_latency: bool = False, + preset: str | None = None, keyint_sec=None) -> list[str]: + """Аргументы видеокодировщика под выбранный кодек (h264 | vp9).""" + if norm_codec(codec) == "vp9": + return vp9_args(bitrate, low_latency, preset, keyint_sec) + return x264_args(profile, bitrate, low_latency, preset, keyint_sec) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..c9d2d6e --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +aiosqlite==0.20.0 +PyYAML==6.0.2 +psutil==6.1.1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a04b0b3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + nvr-backend: + build: + context: . + dockerfile: backend/Dockerfile + container_name: nvr-backend + restart: unless-stopped + # HTTPS обязателен: SharedArrayBuffer (для WASM-декодера H.265) работает + # только в защищённом контексте (https/localhost). + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8090", + "--ssl-keyfile", "/app/certs/key.pem", "--ssl-certfile", "/app/certs/cert.pem"] + ports: + - "443:8090" # дашборд на https://<сервер> + volumes: + - ./certs:/app/certs:ro # self-signed TLS-сертификат + - ./VERSION:/app/VERSION:ro # номер версии (показывается в UI) + - ./cameras.yaml:/app/cameras.yaml # rw: редактирование камер из UI + - ./go2rtc.yaml:/app/go2rtc.yaml # rw: бэкенд регенерирует live-потоки + - ./.env.json:/app/.env.json # логин/пароль + ui-настройки (JSON; имя .env.json, чтобы не путать с .env docker compose) + - ./backend/app:/app/app # код бэкенда (правка без пересборки образа) + - ./frontend:/app/frontend # фронтенд (правка без пересборки) + - ./data:/data # SQLite-индекс архива + # Запись. Локальная папка ./records ИЛИ смонтированный на хост NAS (SMB/NFS). + # Для NAS: смонтируйте на хосте (например /mnt/nas/cctv) и замените левую часть: + # - /mnt/nas/cctv:/records + - ./records:/records + environment: + TZ: Europe/Moscow + NVR_CONFIG: /app/cameras.yaml + NVR_SETTINGS: /app/.env.json + GO2RTC_CONFIG: /app/go2rtc.yaml + NVR_DB: /data/nvr.db + + go2rtc: + image: alexxit/go2rtc:latest + container_name: nvr-go2rtc + restart: unless-stopped + network_mode: host # WebRTC проще всего в host-сети (порт 8555 udp/tcp) + volumes: + - ./go2rtc.yaml:/config/go2rtc.yaml:ro + environment: + TZ: Asia/Tashkent diff --git a/flvprobe.py b/flvprobe.py new file mode 100644 index 0000000..aa512f8 --- /dev/null +++ b/flvprobe.py @@ -0,0 +1,30 @@ +import sys +p = sys.argv[1] if len(sys.argv) > 1 else '/tmp/cap.flv' +d = open(p, 'rb').read() +print('size', len(d)) +if d[:3] != b'FLV': + print('NOT FLV, first bytes:', d[:16].hex()); sys.exit(0) +off = int.from_bytes(d[5:9], 'big') +print('sig', d[:3], 'ver', d[3], 'flags', hex(d[4]), 'dataoffset', off) +i = off + 4 # skip PrevTagSize0 +n = 0 +while i + 11 <= len(d) and n < 14: + ttype = d[i] & 0x1f + dsize = int.from_bytes(d[i+1:i+4], 'big') + body = d[i+11:i+11+dsize] + info = '' + if ttype == 9 and body: + b0 = body[0]; isEx = (b0 & 0x80) != 0 + if isEx: + ft = (b0 >> 4) & 7; pt = b0 & 0xf; fourcc = bytes(body[1:5]) + info = f'video EX frameType={ft} packetType={pt} fourcc={fourcc} head={body[:14].hex()}' + else: + cid = b0 & 0xf; ft = (b0 >> 4) & 0xf; avcpt = body[1] if len(body) > 1 else -1 + info = f'video LEGACY codecId={cid} frameType={ft} avcPacketType={avcpt} head={body[:14].hex()}' + elif ttype == 18: + info = 'script/meta' + elif ttype == 8: + info = 'audio' + print(f'tag#{n} type={ttype} dsize={dsize} {info}') + i = i + 11 + dsize + 4 + n += 1 diff --git a/frontend/archive.html b/frontend/archive.html new file mode 100644 index 0000000..d1a41b9 --- /dev/null +++ b/frontend/archive.html @@ -0,0 +1,50 @@ + + + + + + CoRE.Vision — Архив + + + +
+
CoRE.Vision
+
+
+
+
+ +
+ + +
+
+ + + + +
+
+
Кликните по таймлайну ниже, чтобы смотреть запись
+
+
+
+
+ + + + diff --git a/frontend/cams.html b/frontend/cams.html new file mode 100644 index 0000000..bec3d18 --- /dev/null +++ b/frontend/cams.html @@ -0,0 +1,46 @@ + + + + + + CoRE.Vision — Камеры + + + +
+
CoRE.Vision
+
+
+
+
+ +
+ + +
+
+
+
+

Камеры — 64 слота

+ +
+ + + + + +
IDКамераIPПотокиСтатус
+
+
+
+
+ + + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f93eae9 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,43 @@ + + + + + + CoRE.Vision — Live view + + + +
+
CoRE.Vision
+
+
+
+
+ + + + + + diff --git a/frontend/layout.html b/frontend/layout.html new file mode 100644 index 0000000..0cdb98e --- /dev/null +++ b/frontend/layout.html @@ -0,0 +1,66 @@ + + + + + + CoRE.Vision — Раскладка + + + +
+
CoRE.Vision
+
+
+
+
+ +
+ + +
+
+
+
+

Настройка раскладки

+
+ + + +
+
+
Назначьте камеру в каждую ячейку раскладки. Сохраняется в этом браузере.
+
+
+
+
+
+
+ + + + diff --git a/frontend/login.html b/frontend/login.html new file mode 100644 index 0000000..66b7ccf --- /dev/null +++ b/frontend/login.html @@ -0,0 +1,41 @@ + + + + + + CoRE.Vision — Вход + + + + + + + + diff --git a/frontend/settings.html b/frontend/settings.html new file mode 100644 index 0000000..383ca37 --- /dev/null +++ b/frontend/settings.html @@ -0,0 +1,225 @@ + + + + + + CoRE.Vision — Настройки + + + +
+
CoRE.Vision
+
+
+
+
+ +
+ + +
+
+ + + + + +
+ + +
+
+
+

Камеры — 64 слота

+
+ + + +
+
+ + + + + +
IDКамераIPПотокиСтатус
+
+
+ + + + + + + + + + + + +
+
+ + + + diff --git a/frontend/static/app.js b/frontend/static/app.js new file mode 100644 index 0000000..b77aa1c --- /dev/null +++ b/frontend/static/app.js @@ -0,0 +1,1722 @@ +"use strict"; + +// ── общие хелперы ─────────────────────────────────────────────── +async function api(path) { + const r = await fetch(path); + if (r.status === 401) { location.href = "/login"; throw new Error("unauthorized"); } + if (!r.ok) throw new Error(path + " -> " + r.status); + return r.json(); +} +function post(path) { return fetch(path, { method: "POST" }); } + +// ── клиентские настройки (этот браузер), localStorage ─────────── +const PREFS_KEY = "corevision.prefs"; +// mode: "player" — свой плеер (клиентский WASM-декод H.265), "transcode" — серверный транскод +const PROFILE_DEFAULT = { mode: "player", codec: "h264", profile: "main", bitrate: "2000k", preset: "superfast", keyint: 2 }; +const PROF_KEY = { "1": "prof1", "2": "prof2", "a": "profA" }; // 1 поток / 2 поток / архив +// общие пресеты перекодирования — выставляют все параметры профиля разом +const TR_PRESETS = { + SOFT: { codec: "vp9", profile: "baseline", bitrate: "500k", keyint: 2, preset: "ultrafast" }, + MEDIUM: { codec: "h264", profile: "baseline", bitrate: "8000k", keyint: 2, preset: "ultrafast" }, + HARD: { codec: "h264", profile: "main", bitrate: "16000k", keyint: 1, preset: "superfast" }, +}; +function matchPreset(pr) { // имя пресета, совпадающего с параметрами + return Object.keys(TR_PRESETS).find((k) => { + const p = TR_PRESETS[k]; + return p.codec === pr.codec && p.bitrate === pr.bitrate && +p.keyint === +pr.keyint && + p.preset === pr.preset && (pr.codec === "vp9" || p.profile === pr.profile); + }) || ""; +} +const PREFS_DEFAULT = { + prof1: { ...PROFILE_DEFAULT, mode: "player" }, // 1 поток (основной) + prof2: { ...PROFILE_DEFAULT, mode: "player" }, // 2 поток (суб) + profA: { ...PROFILE_DEFAULT, mode: "transcode" }, // архив +}; + +// ── текущий пользователь / роли ───────────────────────────────── +const ROLE_RANK = { operator: 1, admin: 2, superadmin: 3 }; +const ROLE_RU = { operator: "оператор", admin: "админ", superadmin: "суперадмин" }; +let CURRENT_USER = null; +async function fetchMe() { + try { CURRENT_USER = await api("/api/me"); } catch (e) { CURRENT_USER = null; } + return CURRENT_USER; +} +function roleAtLeast(min) { + const r = CURRENT_USER && CURRENT_USER.role; + return !!r && (ROLE_RANK[r] || 0) >= (ROLE_RANK[min] || 99); +} +function clientPrefs() { + try { return { ...PREFS_DEFAULT, ...(JSON.parse(localStorage.getItem(PREFS_KEY)) || {}) }; } + catch (e) { return { ...PREFS_DEFAULT }; } +} +function setClientPref(key, val) { + const p = clientPrefs(); p[key] = val; + localStorage.setItem(PREFS_KEY, JSON.stringify(p)); +} +function transcodeProfile(which) { // which: "1" | "2" | "a" + const p = clientPrefs(); + return { ...PROFILE_DEFAULT, ...(p[PROF_KEY[which]] || {}) }; +} +function setProfilePref(which, patch) { + const p = clientPrefs(); + p[PROF_KEY[which]] = { ...PROFILE_DEFAULT, ...(p[PROF_KEY[which]] || {}), ...patch }; + localStorage.setItem(PREFS_KEY, JSON.stringify(p)); +} +function transcodeQueryFor(which) { // строка запроса под профиль + const e = encodeURIComponent, pr = transcodeProfile(which); + return `profile=${e(pr.profile)}&bitrate=${e(pr.bitrate)}&preset=${e(pr.preset)}` + + `&keyint=${e(pr.keyint)}&codec=${e(pr.codec)}`; +} +function profileMode(which) { // "native" | "player" | "webcodecs" | "transcode" + const m = transcodeProfile(which).mode; + return (m === "transcode" || m === "native" || m === "webcodecs") ? m : "player"; +} +// раскладки: маппинг камер по ячейкам, на размерность (этот браузер) +function getLayout(dim) { + try { return (JSON.parse(localStorage.getItem("corevision.layout")) || {})[dim] || null; } + catch (e) { return null; } +} +function setLayout(dim, arr) { + let m; try { m = JSON.parse(localStorage.getItem("corevision.layout")) || {}; } catch (e) { m = {}; } + m[dim] = arr; + localStorage.setItem("corevision.layout", JSON.stringify(m)); +} +const pad = (n) => String(n).padStart(2, "0"); +function fmtTime(ts) { + const d = new Date(ts * 1000); + return pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()); +} +function fmtDur(s) { + if (!s) return "—"; + const m = Math.floor(s / 60), sec = Math.round(s % 60); + return m + ":" + pad(sec); +} +function fmtSize(b) { + if (!b) return "—"; + const mb = b / 1048576; + return mb >= 1024 ? (mb / 1024).toFixed(1) + " GB" : mb.toFixed(0) + " MB"; +} +function fmtUptime(s) { + const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); + if (d) return `${d}д ${h}ч ${m}м`; + if (h) return `${h}ч ${m}м`; + return `${m}м`; +} +const STATE_RU = { online: "запись", retrying: "переподключение", offline: "выключена", starting: "запуск" }; +function dotClass(state) { return "dot " + (state || "offline"); } + +// ── шапка: часы + статистика + выход ──────────────────────────── +function startClock() { + const el = document.getElementById("clock"); + if (!el) return; + const tick = () => { + const d = new Date(); + el.textContent = pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()); + }; + tick(); setInterval(tick, 1000); +} +function ensureVersionEl() { + const aside = document.querySelector("aside"); + if (aside && !document.getElementById("app-version")) { + const d = document.createElement("div"); + d.id = "app-version"; d.className = "app-version"; + aside.appendChild(d); + } +} +async function refreshStats() { + const el = document.getElementById("sys-stats"); + try { + const s = await api("/api/system"); + if (el) el.innerHTML = + `CPU ${s.cpu_percent}%  RAM ${s.ram_percent}%` + + `  Диск ${s.disk.used_gb}/${s.disk.total_gb} GB`; + const v = document.getElementById("app-version"); + if (v && s.version) v.textContent = "v" + s.version; + } catch (e) { /* нет связи / 401 — молчим */ } +} +// ограничение подменю раскладок (режим 16+) — на любой странице с .subnav +async function applyGridMenuLimit() { + const items = document.querySelectorAll(".subnav .subitem[data-grid]"); + if (!items.length) return; + let maxDim = 4; + try { const opt = await api("/api/optimization"); maxDim = opt.mode_16plus ? 8 : 4; } + catch (e) { /* нет связи — лимит 16 */ } + items.forEach((a) => { a.hidden = +a.dataset.grid > maxDim; }); +} + +function bindLogout() { + const btn = document.getElementById("logout"); + if (!btn) return; + btn.onclick = async () => { await post("/api/logout"); location.href = "/login"; }; +} + +// ── статусный WebSocket (общий) ───────────────────────────────── +function connectStatusWS(onCameras) { + const proto = location.protocol === "https:" ? "wss" : "ws"; + const ws = new WebSocket(`${proto}://${location.host}/api/ws`); + ws.onmessage = (ev) => { + const msg = JSON.parse(ev.data); + if (msg.type === "status") onCameras(msg.cameras); + else if (msg.type === "camera") onCameras([msg.camera]); + }; + ws.onclose = () => setTimeout(() => connectStatusWS(onCameras), 3000); +} + +// ── go2rtc плеер ──────────────────────────────────────────────── +let _rtcLoaded = null; +function loadRtcComponent(go2rtcUrl) { + if (_rtcLoaded) return _rtcLoaded; + _rtcLoaded = new Promise((resolve, reject) => { + const s = document.createElement("script"); + s.src = go2rtcUrl.replace(/\/$/, "") + "/video-rtc.js"; + s.onload = resolve; + s.onerror = () => reject(new Error("go2rtc video-rtc.js не загрузился")); + document.head.appendChild(s); + }); + return _rtcLoaded; +} +// ── встроенный плеер: hevc.js (open-source WASM HEVC) в Web Worker, рендер в OffscreenCanvas ── +// Декод H.265 (включая тайлы) + рендер целиком в отдельном потоке: главный поток свободен, +// сетка из нескольких камер не тормозит. Сервер отдаёт поток как есть (-c:v copy → 0% CPU). +const HEVC_WORKER_URL = "/static/vendor/hevcjs/hevc-player-worker.js"; +const HEVC_WASM_JS = "/static/vendor/hevcjs/hevc-decode.esm.js"; +const HEVC_WASM_BIN = "/static/vendor/hevcjs/dist/wasm/hevc-decode.wasm"; + +class HevcWasmPlayer { + constructor(box, wsUrl, useMain, onFail) { + this.box = box; this.wsUrl = wsUrl; this.onFail = onFail || null; + this.closed = false; this.worker = null; this.ws = null; + const cv = document.createElement("canvas"); + cv.className = "wc-canvas"; + // размер буфера рендера: main крупнее (для разворота на весь экран), sub мелкий + cv.width = useMain ? 1600 : 640; + cv.height = useMain ? 900 : 360; + box.appendChild(cv); + this.canvas = cv; + let off; + try { off = cv.transferControlToOffscreen(); } catch (e) { this._fail("offscreen"); return; } + try { this.worker = new Worker(HEVC_WORKER_URL, { type: "module" }); } catch (e) { this._fail("worker"); return; } + this.worker.onmessage = (e) => { + const m = e.data; + if (m.type === "ready") this._openWS(); + else if (m.type === "err") console.warn("HEVC worker:", m.msg); + }; + this.worker.onerror = (e) => { console.warn("HEVC worker.onerror:", e && e.message); this._fail("worker-error"); }; + this.worker.postMessage( + { type: "init", canvas: off, wasmUrl: HEVC_WASM_JS, wasmBinaryUrl: HEVC_WASM_BIN }, [off]); + } + _openWS() { + if (this.closed) return; + try { + this.ws = new WebSocket(this.wsUrl); + this.ws.binaryType = "arraybuffer"; + this.ws.onmessage = (ev) => { if (!this.closed && this.worker) this.worker.postMessage({ type: "data", buf: ev.data }, [ev.data]); }; + this.ws.onerror = () => { /**/ }; + this.ws.onclose = () => { /**/ }; + } catch (e) { /**/ } + } + _fail(reason) { + if (this.closed) return; + const f = this.onFail; this.onFail = null; + this.destroy(); + if (f) f(reason); + } + resize() { /* canvas масштабируется CSS (object-fit), буфер рендера фиксирован */ } + destroy() { + this.closed = true; + try { if (this.ws) { this.ws.onmessage = null; this.ws.onclose = null; this.ws.close(); } } catch (e) { /**/ } + this.ws = null; + if (this.worker) { + try { this.worker.postMessage({ type: "stop" }); } catch (e) { /**/ } + try { this.worker.terminate(); } catch (e) { /**/ } + this.worker = null; + } + } +} + +function toggleFullscreen(el) { + const fsEl = document.fullscreenElement || document.webkitFullscreenElement; + if (fsEl) { + (document.exitFullscreen || document.webkitExitFullscreen).call(document); + } else if (el.requestFullscreen || el.webkitRequestFullscreen) { + (el.requestFullscreen || el.webkitRequestFullscreen).call(el); + } +} + +// умеет ли браузер декодировать HEVC (H.265) сам — для нативного просмотра +let _hevc = null; +function hevcSupported() { + if (_hevc !== null) return _hevc; + let ok = false; + try { + const v = document.createElement("video"); + const t = v.canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); + if (t === "probably" || t === "maybe") ok = true; + if (!ok && window.MediaSource && MediaSource.isTypeSupported) { + ok = MediaSource.isTypeSupported('video/mp4; codecs="hvc1.1.6.L93.B0"') || + MediaSource.isTypeSupported('video/mp4; codecs="hev1.1.6.L93.B0"'); + } + } catch (e) { /* ignore */ } + _hevc = ok; + return ok; +} + +// нативный плеер go2rtc (open-source video-rtc.js): MSE/WebRTC, поддерживает H.265 +function makePlayer(go2rtcUrl, srcName, transcode) { + const el = document.createElement("video-stream"); + // mse раньше webrtc — в Chrome HEVC доступен только в MSE; webrtc — фолбэк (Safari) + el.setAttribute("mode", transcode ? "webrtc,mse" : "mse,webrtc"); + el.setAttribute("background", "false"); + const base = go2rtcUrl.replace(/\/$/, ""); + el.src = new URL(base + "/api/ws?src=" + encodeURIComponent(srcName)); + return el; +} + +// ── свой WebCodecs-плеер H.265 (аппаратный декод на GPU клиента, 0% CPU сервера) ── +// есть ли в браузере аппаратный HEVC-декодер через WebCodecs (кэш Promise) +let _hevcHw = null; +function hevcHwSupported() { + if (_hevcHw) return _hevcHw; + _hevcHw = (async () => { + if (!("VideoDecoder" in window) || !window.isSecureContext) return false; + const cands = ["hvc1.1.6.L153.B0", "hev1.1.6.L153.B0", "hvc1.2.4.L153.B0"]; + for (const codec of cands) { + try { + const r = await VideoDecoder.isConfigSupported( + { codec, codedWidth: 3200, codedHeight: 1800, hardwareAcceleration: "prefer-hardware" }); + if (r && r.supported) return true; + } catch (e) { /* пробуем следующий */ } + } + return false; + })(); + return _hevcHw; +} + +// строка кодека из HEVCDecoderConfigurationRecord (hvcC) +function hevcCodecFromHvcC(hvcC) { + try { + const profileIdc = hvcC[1] & 0x1f; + const tier = ((hvcC[1] >> 5) & 1) ? "H" : "L"; + const level = hvcC[12] || 153; + return `hvc1.${profileIdc || 1}.6.${tier}${level}.B0`; + } catch (e) { return "hvc1.1.6.L153.B0"; } +} + +// демуксер FLV (enhanced-RTMP) → hvcC + length-prefixed HEVC-кадры +class FlvHevcDemuxer { + constructor({ onConfig, onSample }) { + this.onConfig = onConfig; this.onSample = onSample; + this.buf = new Uint8Array(0); this.headerDone = false; this.gotConfig = false; + } + push(ab) { + const inc = new Uint8Array(ab); + if (this.buf.length === 0) { this.buf = inc; } + else { const m = new Uint8Array(this.buf.length + inc.length); m.set(this.buf, 0); m.set(inc, this.buf.length); this.buf = m; } + this._parse(); + } + _u32(o) { return ((this.buf[o] << 24) | (this.buf[o + 1] << 16) | (this.buf[o + 2] << 8) | this.buf[o + 3]) >>> 0; } + _parse() { + const b = this.buf, len = b.length; + if (!this.headerDone) { + if (len < 9) return; + const dataOffset = this._u32(5); // обычно 9 + this.buf = b.slice(dataOffset); this.headerDone = true; + return this._parse(); + } + let o = 0; + while (true) { + if (o + 4 + 11 > len) break; // 4 байта PrevTagSize + 11 байт TagHeader + const ts = o + 4; + const tagType = b[ts] & 0x1f; + const dataSize = (b[ts + 1] << 16) | (b[ts + 2] << 8) | b[ts + 3]; + const bodyStart = ts + 11, bodyEnd = bodyStart + dataSize; + if (bodyEnd > len) break; // тег ещё не дочитан + if (tagType === 9) { + const tsMs = ((b[ts + 7] << 24) >>> 0) | (b[ts + 4] << 16) | (b[ts + 5] << 8) | b[ts + 6]; + this._videoTag(b.subarray(bodyStart, bodyEnd), tsMs); + } + o = bodyEnd; + } + if (o > 0) this.buf = b.slice(o); + } + _videoTag(body, tsMs) { + if (body.length < 5) return; + const b0 = body[0], isEx = (b0 & 0x80) !== 0; + if (isEx) { + const frameType = (b0 >> 4) & 0x07, packetType = b0 & 0x0f; + const fourcc = String.fromCharCode(body[1], body[2], body[3], body[4]); + if (fourcc !== "hvc1" && fourcc !== "hev1") return; + let off = 5; + if (packetType === 1) off += 3; // CompositionTime + const payload = body.subarray(off); + if (packetType === 0) this._config(payload); + else if (packetType === 1 || packetType === 3) this._frames(payload, frameType === 1, tsMs); + } else { + const codecId = b0 & 0x0f, frameType = (b0 >> 4) & 0x0f; + if (codecId !== 12 && codecId !== 7) return; // 12=HEVC legacy, 7=AVC + const pkt = body[1], payload = body.subarray(5); + if (pkt === 0) this._config(payload); + else if (pkt === 1) this._frames(payload, frameType === 1, tsMs); + } + } + _config(hvcC) { + if (this.gotConfig || hvcC.length < 13) return; + this.gotConfig = true; + this.onConfig(hvcC.slice(), hevcCodecFromHvcC(hvcC)); + } + _frames(payload, isKey, tsMs) { + if (!this.gotConfig || payload.length === 0) return; + this.onSample({ data: payload.slice(), type: isKey ? "key" : "delta", timestampUs: tsMs * 1000 }); + } +} + +// плеер: FLV(ws) → FlvHevcDemuxer → VideoDecoder (HW) → +class WebCodecsHevcPlayer { + constructor(box, wsUrl, onFail) { + this.box = box; this.wsUrl = wsUrl; this.onFail = onFail || null; + this.closed = false; this.configured = false; this.pending = null; this.rafId = 0; + this.canvas = document.createElement("canvas"); + this.canvas.className = "wc-canvas"; + box.appendChild(this.canvas); + this.ctx = this.canvas.getContext("2d", { alpha: false, desynchronized: true }); + this._start(); + } + _fail(reason) { + if (this.closed) return; + const f = this.onFail; this.onFail = null; + this.destroy(); + if (f) f(reason); + } + _start() { + if (!("VideoDecoder" in window)) { this._fail("no-webcodecs"); return; } + try { + this.decoder = new VideoDecoder({ + output: (frame) => { + if (this.closed) { frame.close(); return; } + if (this.pending) this.pending.close(); // живое видео — держим только последний кадр + this.pending = frame; + if (!this.rafId) this.rafId = requestAnimationFrame(() => this._paint()); + }, + error: (e) => { console.warn("VideoDecoder", e && e.message); this._fail("decode-error"); }, + }); + } catch (e) { this._fail("ctor"); return; } + + this.demux = new FlvHevcDemuxer({ + onConfig: (hvcC, codec) => { + console.log("WC: config received codec=", codec, "hvcClen=", hvcC.length); + try { + this.decoder.configure({ codec, description: hvcC, optimizeForLatency: true, hardwareAcceleration: "prefer-hardware" }); + this.configured = true; + console.log("WC: configure() ok, state=", this.decoder.state); + } catch (e) { console.warn("WC: configure threw:", e && (e.message || e)); this._fail("configure:" + (e && e.name || "")); } + }, + onSample: ({ data, type, timestampUs }) => { + if (!this.configured || this.closed || this.decoder.state !== "configured") return; + // отстаём → ждём ближайший ключевой кадр (чистая граница GOP, без битых ссылок) + if (this.decoder.decodeQueueSize > 6) this.waitKey = true; + if (this.waitKey) { if (type !== "key") return; this.waitKey = false; } + try { + this.decoder.decode(new EncodedVideoChunk({ type, timestamp: timestampUs, data })); + if (!this._fed) { this._fed = true; console.log("WC: first decode() sent, type=", type, "bytes=", data.length); } + } catch (e) { console.warn("WC: decode threw:", e && (e.message || e)); } + }, + }); + + try { + this.ws = new WebSocket(this.wsUrl); + this.ws.binaryType = "arraybuffer"; + this.ws.onmessage = (ev) => { if (!this.closed) { try { this.demux.push(ev.data); } catch (e) { /**/ } } }; + this.ws.onerror = () => { /**/ }; + this.ws.onclose = () => { if (!this.closed && !this.configured) this._fail("ws-closed"); }; + } catch (e) { this._fail("ws"); } + } + _paint() { + this.rafId = 0; + const f = this.pending; this.pending = null; + if (!f) return; + if (this.canvas.width !== f.displayWidth) this.canvas.width = f.displayWidth; + if (this.canvas.height !== f.displayHeight) this.canvas.height = f.displayHeight; + try { this.ctx.drawImage(f, 0, 0); } catch (e) { /**/ } + f.close(); + } + resize() { /* canvas масштабируется CSS object-fit, явный ресайз не нужен */ } + destroy() { + this.closed = true; + if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = 0; } + if (this.pending) { try { this.pending.close(); } catch (e) { /**/ } this.pending = null; } + try { if (this.ws) { this.ws.onmessage = null; this.ws.onclose = null; this.ws.close(); } } catch (e) { /**/ } + try { if (this.decoder && this.decoder.state !== "closed") this.decoder.close(); } catch (e) { /**/ } + } +} + +// ════════════════════════════════════════════════════════════════ +// Страница LIVE VIEW +// ════════════════════════════════════════════════════════════════ +const Live = { + cameras: [], dim: 2, layout: 4, transcode: false, go2rtcUrl: "", selected: [], singleMain: false, maxDim: 8, playing: true, + + async init() { + const data = await api("/api/cameras"); + this.cameras = data.cameras; + // режим каждого потока (свой плеер / транскод) — настройка этого браузера + this.mode1 = profileMode("1"); // 1 поток (основной) + this.mode2 = profileMode("2"); // 2 поток (суб) + // серверная политика (общая для всех клиентов) + let opt = {}; + try { opt = await api("/api/optimization"); } catch (e) { /* дефолты */ } + this.singleMain = opt.single_main_stream !== false; // по умолчанию вкл + this.maxDim = opt.mode_16plus ? 8 : 4; // 16+ открывает 25..64 + this.go2rtcUrl = (this.cameras[0] && this.cameras[0].live.go2rtc_url) || ""; + // встроенный плеер (hevc.js WASM) грузит свой worker по требованию — предзагрузка не нужна + if (this.go2rtcUrl && (this.mode1 === "native" || this.mode2 === "native")) { // «как есть» + try { await loadRtcComponent(this.go2rtcUrl); } catch (e) { console.warn(e); } + } + + this.applyMaxDim(); + this.setDim(this.initialDim(), false); + this.bindSubnav(); + this.bindFullscreenResize(); + this.renderGrid(); + connectStatusWS((cams) => { + cams.forEach((u) => { + const c = this.cameras.find((x) => x.id === u.camera_id); + if (c) c.status = u; + }); + this.updateDots(); + }); + }, + + applyMaxDim() { + // скрыть пункты подменю с числом больше лимита (режим 16+) + document.querySelectorAll(".subnav .subitem[data-grid]").forEach((a) => { + a.hidden = +a.dataset.grid > this.maxDim; + }); + }, + + initialDim() { + const max = this.maxDim || 8; + const q = parseInt(new URLSearchParams(location.search).get("grid"), 10); + if (q >= 2 && q <= 8) return Math.min(q, max); + const saved = parseInt(localStorage.getItem("corevision.grid"), 10); + return saved >= 2 ? Math.min(saved, max) : 2; + }, + + setDim(dim, rerender = true) { + this.dim = Math.min(this.maxDim || 8, Math.max(2, dim)); + this.layout = this.dim * this.dim; + localStorage.setItem("corevision.grid", String(this.dim)); + // приоритет — сохранённая раскладка (страница "Раскладка"); иначе автозаполнение + const saved = getLayout(this.dim); + if (Array.isArray(saved) && saved.length === this.layout) { + this.selected = saved.slice(); + } else { + this.selected = this.selected.slice(0, this.layout); + while (this.selected.length < this.layout) { + const next = this.cameras.find((c) => !this.selected.includes(c.id)); + if (!next) break; + this.selected.push(next.id); + } + } + document.querySelectorAll("#live-subnav .subitem, .subnav .subitem").forEach((a) => + a.classList.toggle("active", +a.dataset.grid === this.dim)); + if (rerender) this.renderGrid(); + }, + + bindSubnav() { + document.querySelectorAll(".subnav .subitem[data-grid]").forEach((a) => { + a.onclick = (e) => { + e.preventDefault(); // на странице live меняем без перезагрузки + history.replaceState(null, "", "/?grid=" + a.dataset.grid); + this.setDim(+a.dataset.grid); + }; + }); + const fs = document.getElementById("fs-grid"); + if (fs) fs.onclick = (e) => { e.preventDefault(); toggleFullscreen(document.getElementById("grid")); }; + const pt = document.getElementById("play-toggle"); + if (pt) { this.updatePlayBtn(); pt.onclick = (e) => { e.preventDefault(); this.setPlaying(!this.playing); }; } + }, + + bindFullscreenResize() { + if (this._fsBound) return; + this._fsBound = true; + const onFs = () => { + // дать браузеру применить размеры, затем пересчитать canvas плееров + setTimeout(() => { + document.querySelectorAll(".hevc-player").forEach((b) => { + if (b._wc && typeof b._wc.resize === "function") { try { b._wc.resize(); } catch (e) { /**/ } } + if (b._hevc && typeof b._hevc.resize === "function") { try { b._hevc.resize(); } catch (e) { /**/ } } + }); + window.dispatchEvent(new Event("resize")); + }, 120); + }; + document.addEventListener("fullscreenchange", onFs); + document.addEventListener("webkitfullscreenchange", onFs); + }, + + updatePlayBtn() { + const pt = document.getElementById("play-toggle"); + if (pt) { + pt.textContent = this.playing ? "⏹ Стоп" : "▶ Просмотр"; + pt.classList.toggle("active", !this.playing); + } + }, + + setPlaying(on) { + this.playing = on; + this.updatePlayBtn(); + this.renderGrid(); // playing=false → плееры не создаются, сетка останавливается + }, + + transcodeVideo(cam, useMain) { // серверный транскод H.264/VP9 →