This commit is contained in:
2026-05-30 00:08:59 +05:00
commit d3afd698b6
52 changed files with 8150 additions and 0 deletions
+17
View File
@@ -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"]
View File
View File
+282
View File
@@ -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}
+99
View File
@@ -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")
+60
View File
@@ -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}
+64
View File
@@ -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)
+247
View File
@@ -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}
+115
View File
@@ -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()
+131
View File
@@ -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}
+136
View File
@@ -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")
+44
View File
@@ -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
+104
View File
@@ -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)
View File
+61
View File
@@ -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
+109
View File
@@ -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
+139
View File
@@ -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)
+80
View File
@@ -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)
+24
View File
@@ -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()
View File
+122
View File
@@ -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))
+101
View File
@@ -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
+39
View File
@@ -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,
}
+88
View File
@@ -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)
+5
View File
@@ -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