init
This commit is contained in:
@@ -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"]
|
||||
@@ -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}
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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) # потоки (вкл. <cam>_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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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:
|
||||
"""Время начала сегмента из имени <cam>/<YYYY-MM-DD_HH-MM-SS>.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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Синхронизация потоков с go2rtc + рестрим основного потока.
|
||||
|
||||
Камеры Hikvision ограничивают основной поток (нельзя открыть его дважды:
|
||||
запись + live дают RTSP 453). Решение: go2rtc держит ОДНО соединение с камерой
|
||||
и раздаёт нескольким потребителям. И запись, и live основного потока читают
|
||||
из go2rtc (rtsp-рестрим на :8554), поэтому камера видит одну сессию.
|
||||
|
||||
go2rtc-потоки на камеру:
|
||||
<cam>_main — основной поток (101), единственный источник записи и live-HD.
|
||||
<cam> — субпоток (102) для живой сетки; если субпотока нет — ссылка на
|
||||
<cam>_main (повторное использование, без второго коннекта к камере).
|
||||
<cam>_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))
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user