Files
CoRE.Vision/backend/app/api/cameras.py
T
git_admin 7060f26e86 cameras: surface per-camera REC (record-to-disk) toggle
`enabled` already gated recording only (go2rtc/live work regardless), but
it was a buried "Вкл" checkbox. Make it an explicit REC control:
- New "Запись" column with an inline REC switch in each camera row.
- Relabel the add/edit form toggle "Вкл" -> "REC" + tooltip.
- New POST /api/cameras/{id}/record {enabled} that flips recording through
  the supervisor only (no go2rtc re-sync, so live view doesn't blip).

Off = view-only (live via go2rtc); on = record segments to disk.
VERSION -> 0.0.124.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:21:49 +05:00

322 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""REST: список камер, статус, live-источники, перезапуск, логи + CRUD."""
from __future__ import annotations
import asyncio
import re
from dataclasses import replace
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, norm_rc, norm_qp)
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),
rc: str = Query(None),
qp: 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),
rc=norm_rc(rc), qp=norm_qp(qp)),
"-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}
@router.post("/clear")
async def clear_cameras(request: Request) -> dict:
"""Удаляет ВСЕ камеры: чистит cameras.yaml, останавливает запись и потоки go2rtc.
Архив (nvr.db + файлы .mp4) НЕ трогает — очищается только список камер."""
require_role(request, "admin")
ids = [c.id for c in runtime.config.cameras]
runtime.config.cameras = []
save_config(runtime.config)
for cam_id in ids:
await runtime.supervisor.remove_camera(cam_id)
await go2rtc.remove_camera_streams(runtime.config, cam_id)
return {"ok": True, "removed": len(ids)}
@router.post("/{cam_id}/record")
async def set_record(cam_id: str, request: Request) -> dict:
"""Включает/выключает запись камеры на диск (тумблер REC).
enabled=False — запись не идёт, но go2rtc/live не трогаем → просмотр работает.
Меняет только рекордер (без пересоздания go2rtc-потоков, чтобы live не мигал)."""
require_role(request, "admin")
cam = runtime.config.camera(cam_id)
if not cam:
raise HTTPException(404, "camera not found")
body = await request.json()
on = bool(body.get("enabled", True))
idx = next(i for i, c in enumerate(runtime.config.cameras) if c.id == cam_id)
new = replace(cam, enabled=on)
runtime.config.cameras[idx] = new
save_config(runtime.config)
await runtime.supervisor.replace_camera(new) # старт/стоп записи; go2rtc не трогаем
return {"ok": True, "enabled": on}