8e4844111b
- 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>
345 lines
14 KiB
Python
345 lines
14 KiB
Python
"""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}
|