Files
CoRE.Vision/backend/app/api/cameras.py
T
git_admin 8e4844111b storage: per-camera target + date subfolders; move active-storage widget
- Camera.storage_id: each camera chooses its storage ("" = active/default).
  supervisor resolves per-camera (storage_by_id → active fallback); cameras
  API (body/payload) + /toggle accept storage_id and repoint the recorder.
- Recordings now go to <root>/<cam>/<YYYY-MM-DD>/<file>.mp4. This ffmpeg build
  lacks -strftime_mkdir, so recorder_task pre-creates today+tomorrow dirs at
  start and the indexer re-ensures them each tick (survives midnight).
- indexer: scan date subdirs + legacy flat files; sort by basename so the
  in-progress segment is detected correctly when both layouts coexist.
- frontend: per-camera "Хранилище" dropdown in the camera table (inline) and
  in the add/edit + bulk forms. Move the active-storage summary widget from
  the Система tab into the Хранилища tab.

VERSION 0.0.129.

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

345 lines
14 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)),
record=bool(body.get("record", True)),
ip=ip,
user=user,
password=password,
main_path=(body.get("main_path") or DEFAULT_MAIN).strip(),
sub_path=sub,
storage_id=(body.get("storage_id") or "").strip(),
)
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,
"record": cam.record,
"ip": cam.ip,
"user": cam.user,
"password": cam.password,
"main_path": cam.main_path,
"sub_path": cam.sub_path,
"storage_id": cam.storage_id,
"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)
if cam.enabled:
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)
if cam.enabled:
await go2rtc.add_camera_streams(runtime.config, cam)
else:
await go2rtc.remove_camera_streams(runtime.config, cam_id)
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}/toggle")
async def toggle_camera(cam_id: str, request: Request) -> dict:
"""Тумблеры камеры: enabled (вкл/выкл камеру) и/или record (REC — запись на диск).
enabled=False — камера выключена полностью: убираем go2rtc-потоки → нет live и записи
(камера остаётся зарегистрированной). record управляет только записью. go2rtc трогаем
лишь при смене enabled, поэтому переключение REC не мигает live."""
require_role(request, "admin")
cam = runtime.config.camera(cam_id)
if not cam:
raise HTTPException(404, "camera not found")
body = await request.json()
kw = {}
if "enabled" in body:
kw["enabled"] = bool(body["enabled"])
if "record" in body:
kw["record"] = bool(body["record"])
if "storage_id" in body: # смена хранилища записи камеры
kw["storage_id"] = (body["storage_id"] or "").strip()
if not kw:
raise HTTPException(400, "ожидается enabled, record и/или storage_id")
enabled_changed = "enabled" in kw and kw["enabled"] != cam.enabled
new = replace(cam, **kw)
idx = next(i for i, c in enumerate(runtime.config.cameras) if c.id == cam_id)
runtime.config.cameras[idx] = new
save_config(runtime.config)
if enabled_changed: # потоки go2rtc только при смене вкл/выкл камеры
if new.enabled:
await go2rtc.add_camera_streams(runtime.config, new)
else:
await go2rtc.remove_camera_streams(runtime.config, cam_id)
await runtime.supervisor.replace_camera(new) # рекордер: репойнт на новое хранилище / enabled AND record
return {"ok": True, "enabled": new.enabled, "record": new.record, "storage_id": new.storage_id}