This commit is contained in:
2026-05-30 00:08:59 +05:00
commit d3afd698b6
52 changed files with 8150 additions and 0 deletions
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)