"""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}