From 6a031eb9eea9127da022c394f2d6caf81d195277 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 13 Oct 2025 16:47:26 -0600 Subject: [PATCH] Add dynamic Reolink stream configuration for stream URL (#20469) * Migrate camera APIs to separate tag * Implement reolink detection to handle dynamic URL assignment * Cleanup codec handling * Use average framerate not relative framerate * Add reolink rtsp warning * Don't return exception * Use avg_frame_rate in final info * Clenaup * Validate host * Fix overlap --- frigate/api/app.py | 280 ----------- frigate/api/camera.py | 443 ++++++++++++++++++ frigate/api/defs/tags.py | 1 + frigate/api/fastapi_app.py | 2 + web/public/locales/en/views/settings.json | 8 +- .../settings/wizard/Step1NameCamera.tsx | 71 ++- .../settings/wizard/Step3Validation.tsx | 10 + web/src/types/cameraWizard.ts | 17 +- web/src/utils/cameraUtil.ts | 48 ++ 9 files changed, 586 insertions(+), 294 deletions(-) create mode 100644 frigate/api/camera.py diff --git a/frigate/api/app.py b/frigate/api/app.py index 3d5d27e8b..f84190407 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -14,7 +14,6 @@ from pathlib import Path as FilePath from typing import Any, Dict, List, Optional import aiofiles -import requests import ruamel.yaml from fastapi import APIRouter, Body, Path, Request, Response from fastapi.encoders import jsonable_encoder @@ -43,9 +42,7 @@ from frigate.util.builtin import ( update_yaml_file_bulk, ) from frigate.util.config import find_config_file -from frigate.util.image import run_ffmpeg_snapshot from frigate.util.services import ( - ffprobe_stream, get_nvidia_driver_info, process_logs, restart_frigate, @@ -71,117 +68,6 @@ def config_schema(request: Request): ) -@router.get("/go2rtc/streams") -def go2rtc_streams(): - r = requests.get("http://127.0.0.1:1984/api/streams") - if not r.ok: - logger.error("Failed to fetch streams from go2rtc") - return JSONResponse( - content=({"success": False, "message": "Error fetching stream data"}), - status_code=500, - ) - stream_data = r.json() - for data in stream_data.values(): - for producer in data.get("producers") or []: - producer["url"] = clean_camera_user_pass(producer.get("url", "")) - return JSONResponse(content=stream_data) - - -@router.get("/go2rtc/streams/{camera_name}") -def go2rtc_camera_stream(request: Request, camera_name: str): - r = requests.get( - f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=allµphone" - ) - if not r.ok: - camera_config = request.app.frigate_config.cameras.get(camera_name) - - if camera_config and camera_config.enabled: - logger.error("Failed to fetch streams from go2rtc") - - return JSONResponse( - content=({"success": False, "message": "Error fetching stream data"}), - status_code=500, - ) - stream_data = r.json() - for producer in stream_data.get("producers", []): - producer["url"] = clean_camera_user_pass(producer.get("url", "")) - return JSONResponse(content=stream_data) - - -@router.put( - "/go2rtc/streams/{stream_name}", dependencies=[Depends(require_role(["admin"]))] -) -def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""): - """Add or update a go2rtc stream configuration.""" - try: - params = {"name": stream_name} - if src: - params["src"] = src - - r = requests.put( - "http://127.0.0.1:1984/api/streams", - params=params, - timeout=10, - ) - if not r.ok: - logger.error(f"Failed to add go2rtc stream {stream_name}: {r.text}") - return JSONResponse( - content=( - {"success": False, "message": f"Failed to add stream: {r.text}"} - ), - status_code=r.status_code, - ) - return JSONResponse( - content={"success": True, "message": "Stream added successfully"} - ) - except requests.RequestException as e: - logger.error(f"Error communicating with go2rtc: {e}") - return JSONResponse( - content=( - { - "success": False, - "message": "Error communicating with go2rtc", - } - ), - status_code=500, - ) - - -@router.delete( - "/go2rtc/streams/{stream_name}", dependencies=[Depends(require_role(["admin"]))] -) -def go2rtc_delete_stream(stream_name: str): - """Delete a go2rtc stream.""" - try: - r = requests.delete( - "http://127.0.0.1:1984/api/streams", - params={"src": stream_name}, - timeout=10, - ) - if not r.ok: - logger.error(f"Failed to delete go2rtc stream {stream_name}: {r.text}") - return JSONResponse( - content=( - {"success": False, "message": f"Failed to delete stream: {r.text}"} - ), - status_code=r.status_code, - ) - return JSONResponse( - content={"success": True, "message": "Stream deleted successfully"} - ) - except requests.RequestException as e: - logger.error(f"Error communicating with go2rtc: {e}") - return JSONResponse( - content=( - { - "success": False, - "message": "Error communicating with go2rtc", - } - ), - status_code=500, - ) - - @router.get("/version", response_class=PlainTextResponse) def version(): return VERSION @@ -527,172 +413,6 @@ def config_set(request: Request, body: AppConfigSetBody): ) -@router.get("/ffprobe") -def ffprobe(request: Request, paths: str = "", detailed: bool = False): - path_param = paths - - if not path_param: - return JSONResponse( - content=({"success": False, "message": "Path needs to be provided."}), - status_code=404, - ) - - if path_param.startswith("camera"): - camera = path_param[7:] - - if camera not in request.app.frigate_config.cameras.keys(): - return JSONResponse( - content=( - {"success": False, "message": f"{camera} is not a valid camera."} - ), - status_code=404, - ) - - if not request.app.frigate_config.cameras[camera].enabled: - return JSONResponse( - content=({"success": False, "message": f"{camera} is not enabled."}), - status_code=404, - ) - - paths = map( - lambda input: input.path, - request.app.frigate_config.cameras[camera].ffmpeg.inputs, - ) - elif "," in clean_camera_user_pass(path_param): - paths = path_param.split(",") - else: - paths = [path_param] - - # user has multiple streams - output = [] - - for path in paths: - ffprobe = ffprobe_stream( - request.app.frigate_config.ffmpeg, path.strip(), detailed=detailed - ) - - result = { - "return_code": ffprobe.returncode, - "stderr": ( - ffprobe.stderr.decode("unicode_escape").strip() - if ffprobe.returncode != 0 - else "" - ), - "stdout": ( - json.loads(ffprobe.stdout.decode("unicode_escape").strip()) - if ffprobe.returncode == 0 - else "" - ), - } - - # Add detailed metadata if requested and probe was successful - if detailed and ffprobe.returncode == 0 and result["stdout"]: - try: - probe_data = result["stdout"] - metadata = {} - - # Extract video stream information - video_stream = None - audio_stream = None - - for stream in probe_data.get("streams", []): - if stream.get("codec_type") == "video": - video_stream = stream - elif stream.get("codec_type") == "audio": - audio_stream = stream - - # Video metadata - if video_stream: - metadata["video"] = { - "codec": video_stream.get("codec_name"), - "width": video_stream.get("width"), - "height": video_stream.get("height"), - "fps": _extract_fps(video_stream.get("r_frame_rate")), - "pixel_format": video_stream.get("pix_fmt"), - "profile": video_stream.get("profile"), - "level": video_stream.get("level"), - } - - # Calculate resolution string - if video_stream.get("width") and video_stream.get("height"): - metadata["video"]["resolution"] = ( - f"{video_stream['width']}x{video_stream['height']}" - ) - - # Audio metadata - if audio_stream: - metadata["audio"] = { - "codec": audio_stream.get("codec_name"), - "channels": audio_stream.get("channels"), - "sample_rate": audio_stream.get("sample_rate"), - "channel_layout": audio_stream.get("channel_layout"), - } - - # Container/format metadata - if probe_data.get("format"): - format_info = probe_data["format"] - metadata["container"] = { - "format": format_info.get("format_name"), - "duration": format_info.get("duration"), - "size": format_info.get("size"), - } - - result["metadata"] = metadata - - except Exception as e: - logger.warning(f"Failed to extract detailed metadata: {e}") - # Continue without metadata if parsing fails - - output.append(result) - - return JSONResponse(content=output) - - -@router.get("/ffprobe/snapshot", dependencies=[Depends(require_role(["admin"]))]) -def ffprobe_snapshot(request: Request, url: str = "", timeout: int = 10): - """Get a snapshot from a stream URL using ffmpeg.""" - if not url: - return JSONResponse( - content={"success": False, "message": "URL parameter is required"}, - status_code=400, - ) - - config: FrigateConfig = request.app.frigate_config - - image_data, error = run_ffmpeg_snapshot( - config.ffmpeg, url, "mjpeg", timeout=timeout - ) - - if image_data: - return Response( - image_data, - media_type="image/jpeg", - headers={"Cache-Control": "no-store"}, - ) - elif error == "timeout": - return JSONResponse( - content={"success": False, "message": "Timeout capturing snapshot"}, - status_code=408, - ) - else: - logger.error(f"ffmpeg failed: {error}") - return JSONResponse( - content={"success": False, "message": "Failed to capture snapshot"}, - status_code=500, - ) - - -def _extract_fps(r_frame_rate: str) -> float | None: - """Extract FPS from ffprobe r_frame_rate string (e.g., '30/1' -> 30.0)""" - if not r_frame_rate: - return None - try: - num, den = r_frame_rate.split("/") - return round(float(num) / float(den), 2) - except (ValueError, ZeroDivisionError): - return None - - @router.get("/vainfo") def vainfo(): vainfo = vainfo_hwaccel() diff --git a/frigate/api/camera.py b/frigate/api/camera.py new file mode 100644 index 000000000..01da847bc --- /dev/null +++ b/frigate/api/camera.py @@ -0,0 +1,443 @@ +"""Camera apis.""" + +import json +import logging +import re +from urllib.parse import quote_plus + +import requests +from fastapi import APIRouter, Depends, Request, Response +from fastapi.responses import JSONResponse + +from frigate.api.auth import require_role +from frigate.api.defs.tags import Tags +from frigate.config.config import FrigateConfig +from frigate.util.builtin import clean_camera_user_pass +from frigate.util.image import run_ffmpeg_snapshot +from frigate.util.services import ffprobe_stream + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=[Tags.camera]) + + +def _is_valid_host(host: str) -> bool: + """ + Validate that the host is in a valid format. + Allows private IPs since cameras are typically on local networks. + Only blocks obviously malicious input to prevent injection attacks. + """ + try: + # Remove port if present + host_without_port = host.split(":")[0] if ":" in host else host + + # Block whitespace, newlines, and control characters + if not host_without_port or re.search(r"[\s\x00-\x1f]", host_without_port): + return False + + # Allow standard hostname/IP characters: alphanumeric, dots, hyphens + if not re.match(r"^[a-zA-Z0-9.-]+$", host_without_port): + return False + + return True + except Exception: + return False + + +@router.get("/go2rtc/streams") +def go2rtc_streams(): + r = requests.get("http://127.0.0.1:1984/api/streams") + if not r.ok: + logger.error("Failed to fetch streams from go2rtc") + return JSONResponse( + content=({"success": False, "message": "Error fetching stream data"}), + status_code=500, + ) + stream_data = r.json() + for data in stream_data.values(): + for producer in data.get("producers") or []: + producer["url"] = clean_camera_user_pass(producer.get("url", "")) + return JSONResponse(content=stream_data) + + +@router.get("/go2rtc/streams/{camera_name}") +def go2rtc_camera_stream(request: Request, camera_name: str): + r = requests.get( + f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=allµphone" + ) + if not r.ok: + camera_config = request.app.frigate_config.cameras.get(camera_name) + + if camera_config and camera_config.enabled: + logger.error("Failed to fetch streams from go2rtc") + + return JSONResponse( + content=({"success": False, "message": "Error fetching stream data"}), + status_code=500, + ) + stream_data = r.json() + for producer in stream_data.get("producers", []): + producer["url"] = clean_camera_user_pass(producer.get("url", "")) + return JSONResponse(content=stream_data) + + +@router.put( + "/go2rtc/streams/{stream_name}", dependencies=[Depends(require_role(["admin"]))] +) +def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""): + """Add or update a go2rtc stream configuration.""" + try: + params = {"name": stream_name} + if src: + params["src"] = src + + r = requests.put( + "http://127.0.0.1:1984/api/streams", + params=params, + timeout=10, + ) + if not r.ok: + logger.error(f"Failed to add go2rtc stream {stream_name}: {r.text}") + return JSONResponse( + content=( + {"success": False, "message": f"Failed to add stream: {r.text}"} + ), + status_code=r.status_code, + ) + return JSONResponse( + content={"success": True, "message": "Stream added successfully"} + ) + except requests.RequestException as e: + logger.error(f"Error communicating with go2rtc: {e}") + return JSONResponse( + content=( + { + "success": False, + "message": "Error communicating with go2rtc", + } + ), + status_code=500, + ) + + +@router.delete( + "/go2rtc/streams/{stream_name}", dependencies=[Depends(require_role(["admin"]))] +) +def go2rtc_delete_stream(stream_name: str): + """Delete a go2rtc stream.""" + try: + r = requests.delete( + "http://127.0.0.1:1984/api/streams", + params={"src": stream_name}, + timeout=10, + ) + if not r.ok: + logger.error(f"Failed to delete go2rtc stream {stream_name}: {r.text}") + return JSONResponse( + content=( + {"success": False, "message": f"Failed to delete stream: {r.text}"} + ), + status_code=r.status_code, + ) + return JSONResponse( + content={"success": True, "message": "Stream deleted successfully"} + ) + except requests.RequestException as e: + logger.error(f"Error communicating with go2rtc: {e}") + return JSONResponse( + content=( + { + "success": False, + "message": "Error communicating with go2rtc", + } + ), + status_code=500, + ) + + +@router.get("/ffprobe") +def ffprobe(request: Request, paths: str = "", detailed: bool = False): + path_param = paths + + if not path_param: + return JSONResponse( + content=({"success": False, "message": "Path needs to be provided."}), + status_code=404, + ) + + if path_param.startswith("camera"): + camera = path_param[7:] + + if camera not in request.app.frigate_config.cameras.keys(): + return JSONResponse( + content=( + {"success": False, "message": f"{camera} is not a valid camera."} + ), + status_code=404, + ) + + if not request.app.frigate_config.cameras[camera].enabled: + return JSONResponse( + content=({"success": False, "message": f"{camera} is not enabled."}), + status_code=404, + ) + + paths = map( + lambda input: input.path, + request.app.frigate_config.cameras[camera].ffmpeg.inputs, + ) + elif "," in clean_camera_user_pass(path_param): + paths = path_param.split(",") + else: + paths = [path_param] + + # user has multiple streams + output = [] + + for path in paths: + ffprobe = ffprobe_stream( + request.app.frigate_config.ffmpeg, path.strip(), detailed=detailed + ) + + result = { + "return_code": ffprobe.returncode, + "stderr": ( + ffprobe.stderr.decode("unicode_escape").strip() + if ffprobe.returncode != 0 + else "" + ), + "stdout": ( + json.loads(ffprobe.stdout.decode("unicode_escape").strip()) + if ffprobe.returncode == 0 + else "" + ), + } + + # Add detailed metadata if requested and probe was successful + if detailed and ffprobe.returncode == 0 and result["stdout"]: + try: + probe_data = result["stdout"] + metadata = {} + + # Extract video stream information + video_stream = None + audio_stream = None + + for stream in probe_data.get("streams", []): + if stream.get("codec_type") == "video": + video_stream = stream + elif stream.get("codec_type") == "audio": + audio_stream = stream + + # Video metadata + if video_stream: + metadata["video"] = { + "codec": video_stream.get("codec_name"), + "width": video_stream.get("width"), + "height": video_stream.get("height"), + "fps": _extract_fps(video_stream.get("avg_frame_rate")), + "pixel_format": video_stream.get("pix_fmt"), + "profile": video_stream.get("profile"), + "level": video_stream.get("level"), + } + + # Calculate resolution string + if video_stream.get("width") and video_stream.get("height"): + metadata["video"]["resolution"] = ( + f"{video_stream['width']}x{video_stream['height']}" + ) + + # Audio metadata + if audio_stream: + metadata["audio"] = { + "codec": audio_stream.get("codec_name"), + "channels": audio_stream.get("channels"), + "sample_rate": audio_stream.get("sample_rate"), + "channel_layout": audio_stream.get("channel_layout"), + } + + # Container/format metadata + if probe_data.get("format"): + format_info = probe_data["format"] + metadata["container"] = { + "format": format_info.get("format_name"), + "duration": format_info.get("duration"), + "size": format_info.get("size"), + } + + result["metadata"] = metadata + + except Exception as e: + logger.warning(f"Failed to extract detailed metadata: {e}") + # Continue without metadata if parsing fails + + output.append(result) + + return JSONResponse(content=output) + + +@router.get("/ffprobe/snapshot", dependencies=[Depends(require_role(["admin"]))]) +def ffprobe_snapshot(request: Request, url: str = "", timeout: int = 10): + """Get a snapshot from a stream URL using ffmpeg.""" + if not url: + return JSONResponse( + content={"success": False, "message": "URL parameter is required"}, + status_code=400, + ) + + config: FrigateConfig = request.app.frigate_config + + image_data, error = run_ffmpeg_snapshot( + config.ffmpeg, url, "mjpeg", timeout=timeout + ) + + if image_data: + return Response( + image_data, + media_type="image/jpeg", + headers={"Cache-Control": "no-store"}, + ) + elif error == "timeout": + return JSONResponse( + content={"success": False, "message": "Timeout capturing snapshot"}, + status_code=408, + ) + else: + logger.error(f"ffmpeg failed: {error}") + return JSONResponse( + content={"success": False, "message": "Failed to capture snapshot"}, + status_code=500, + ) + + +@router.get("/reolink/detect", dependencies=[Depends(require_role(["admin"]))]) +def reolink_detect(host: str = "", username: str = "", password: str = ""): + """ + Detect Reolink camera capabilities and recommend optimal protocol. + + Queries the Reolink camera API to determine the camera's resolution + and recommends either http-flv (for 5MP and below) or rtsp (for higher resolutions). + """ + if not host: + return JSONResponse( + content={"success": False, "message": "Host parameter is required"}, + status_code=400, + ) + + if not username: + return JSONResponse( + content={"success": False, "message": "Username parameter is required"}, + status_code=400, + ) + + if not password: + return JSONResponse( + content={"success": False, "message": "Password parameter is required"}, + status_code=400, + ) + + # Validate host format to prevent injection attacks + if not _is_valid_host(host): + return JSONResponse( + content={"success": False, "message": "Invalid host format"}, + status_code=400, + ) + + try: + # URL-encode credentials to prevent injection + encoded_user = quote_plus(username) + encoded_password = quote_plus(password) + api_url = f"http://{host}/api.cgi?cmd=GetEnc&user={encoded_user}&password={encoded_password}" + + response = requests.get(api_url, timeout=5) + + if not response.ok: + return JSONResponse( + content={ + "success": False, + "protocol": None, + "message": f"Failed to connect to camera API: HTTP {response.status_code}", + }, + status_code=200, + ) + + data = response.json() + enc_data = data[0] if isinstance(data, list) and len(data) > 0 else data + + stream_info = None + if isinstance(enc_data, dict): + if enc_data.get("value", {}).get("Enc"): + stream_info = enc_data["value"]["Enc"] + elif enc_data.get("Enc"): + stream_info = enc_data["Enc"] + + if not stream_info or not stream_info.get("mainStream"): + return JSONResponse( + content={ + "success": False, + "protocol": None, + "message": "Could not find stream information in API response", + } + ) + + main_stream = stream_info["mainStream"] + width = main_stream.get("width", 0) + height = main_stream.get("height", 0) + + if not width or not height: + return JSONResponse( + content={ + "success": False, + "protocol": None, + "message": "Could not determine camera resolution", + } + ) + + megapixels = (width * height) / 1_000_000 + protocol = "http-flv" if megapixels <= 5.0 else "rtsp" + + return JSONResponse( + content={ + "success": True, + "protocol": protocol, + "resolution": f"{width}x{height}", + "megapixels": round(megapixels, 2), + } + ) + + except requests.exceptions.Timeout: + return JSONResponse( + content={ + "success": False, + "protocol": None, + "message": "Connection timeout - camera did not respond", + } + ) + except requests.exceptions.RequestException: + return JSONResponse( + content={ + "success": False, + "protocol": None, + "message": "Failed to connect to camera", + } + ) + except Exception: + logger.exception(f"Error detecting Reolink camera at {host}") + return JSONResponse( + content={ + "success": False, + "protocol": None, + "message": "Unable to detect camera capabilities", + } + ) + + +def _extract_fps(r_frame_rate: str) -> float | None: + """Extract FPS from ffprobe avg_frame_rate / r_frame_rate string (e.g., '30/1' -> 30.0)""" + if not r_frame_rate: + return None + try: + num, den = r_frame_rate.split("/") + return round(float(num) / float(den), 2) + except (ValueError, ZeroDivisionError): + return None diff --git a/frigate/api/defs/tags.py b/frigate/api/defs/tags.py index a4e354b2a..f804385d1 100644 --- a/frigate/api/defs/tags.py +++ b/frigate/api/defs/tags.py @@ -3,6 +3,7 @@ from enum import Enum class Tags(Enum): app = "App" + camera = "Camera" preview = "Preview" logs = "Logs" media = "Media" diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index d56f6919d..afb7c9059 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -15,6 +15,7 @@ from starlette_context.plugins import Plugin from frigate.api import app as main_app from frigate.api import ( auth, + camera, classification, event, export, @@ -114,6 +115,7 @@ def create_fastapi_app( # Routes # Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters app.include_router(auth.router) + app.include_router(camera.router) app.include_router(classification.router) app.include_router(review.router) app.include_router(main_app.router) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index dead8e775..cc6f5b22b 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -196,7 +196,13 @@ "nameRequired": "Camera name is required", "nameLength": "Camera name must be 64 characters or less", "invalidCharacters": "Camera name contains invalid characters", - "nameExists": "Camera name already exists" + "nameExists": "Camera name already exists", + "brands": { + "reolink-rtsp": "Reolink RTSP is not recommended. It is recommended to enable http in the camera settings and restart the camera wizard." + } + }, + "docs": { + "reolink": "https://docs.frigate.video/configuration/camera_specific.html#reolink-cameras" } }, "step2": { diff --git a/web/src/components/settings/wizard/Step1NameCamera.tsx b/web/src/components/settings/wizard/Step1NameCamera.tsx index 87eb74f06..45c379972 100644 --- a/web/src/components/settings/wizard/Step1NameCamera.tsx +++ b/web/src/components/settings/wizard/Step1NameCamera.tsx @@ -45,6 +45,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { LuInfo } from "react-icons/lu"; +import { detectReolinkCamera } from "@/utils/cameraUtil"; type Step1NameCameraProps = { wizardData: Partial; @@ -134,8 +135,44 @@ export default function Step1NameCamera({ ? !!(watchedCustomUrl && watchedCustomUrl.trim()) : !!(watchedHost && watchedHost.trim()); + const generateDynamicStreamUrl = useCallback( + async (data: z.infer): Promise => { + const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate); + if (!brand || !data.host) return null; + + let protocol = undefined; + if (data.brandTemplate === "reolink" && data.username && data.password) { + try { + protocol = await detectReolinkCamera( + data.host, + data.username, + data.password, + ); + } catch (error) { + return null; + } + } + + // Use detected protocol or fallback to rtsp + const protocolKey = protocol || "rtsp"; + const templates: Record = brand.dynamicTemplates || {}; + + if (Object.keys(templates).includes(protocolKey)) { + const template = + templates[protocolKey as keyof typeof brand.dynamicTemplates]; + return template + .replace("{username}", data.username || "") + .replace("{password}", data.password || "") + .replace("{host}", data.host); + } + + return null; + }, + [], + ); + const generateStreamUrl = useCallback( - (data: z.infer): string => { + async (data: z.infer): Promise => { if (data.brandTemplate === "other") { return data.customUrl || ""; } @@ -143,17 +180,27 @@ export default function Step1NameCamera({ const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate); if (!brand || !data.host) return ""; + if (brand.template === "dynamic" && "dynamicTemplates" in brand) { + const dynamicUrl = await generateDynamicStreamUrl(data); + + if (dynamicUrl) { + return dynamicUrl; + } + + return ""; + } + return brand.template .replace("{username}", data.username || "") .replace("{password}", data.password || "") .replace("{host}", data.host); }, - [], + [generateDynamicStreamUrl], ); const testConnection = useCallback(async () => { const data = form.getValues(); - const streamUrl = generateStreamUrl(data); + const streamUrl = await generateStreamUrl(data); if (!streamUrl) { toast.error(t("cameraWizard.commonErrors.noUrl")); @@ -208,14 +255,16 @@ export default function Step1NameCamera({ (s: FfprobeStream) => s.codec_type === "video" || s.codec_name?.includes("h264") || - s.codec_name?.includes("h265"), + s.codec_name?.includes("hevc"), ); const audioStream = streams.find( (s: FfprobeStream) => s.codec_type === "audio" || s.codec_name?.includes("aac") || - s.codec_name?.includes("mp3"), + s.codec_name?.includes("mp3") || + s.codec_name?.includes("pcm_mulaw") || + s.codec_name?.includes("pcm_alaw"), ); const resolution = videoStream @@ -223,9 +272,9 @@ export default function Step1NameCamera({ : undefined; // Extract FPS from rational (e.g., "15/1" -> 15) - const fps = videoStream?.r_frame_rate - ? parseFloat(videoStream.r_frame_rate.split("/")[0]) / - parseFloat(videoStream.r_frame_rate.split("/")[1]) + const fps = videoStream?.avg_frame_rate + ? parseFloat(videoStream.avg_frame_rate.split("/")[0]) / + parseFloat(videoStream.avg_frame_rate.split("/")[1]) : undefined; // Convert snapshot blob to base64 if available @@ -283,9 +332,9 @@ export default function Step1NameCamera({ onUpdate(data); }; - const handleContinue = useCallback(() => { + const handleContinue = useCallback(async () => { const data = form.getValues(); - const streamUrl = generateStreamUrl(data); + const streamUrl = await generateStreamUrl(data); const streamId = `stream_${Date.now()}`; const streamConfig: StreamConfig = { @@ -381,7 +430,7 @@ export default function Step1NameCamera({

{selectedBrand.label}

-

+

{t("cameraWizard.step1.brandUrlFormat", { exampleUrl: selectedBrand.exampleUrl, })} diff --git a/web/src/components/settings/wizard/Step3Validation.tsx b/web/src/components/settings/wizard/Step3Validation.tsx index 195d4223a..130305342 100644 --- a/web/src/components/settings/wizard/Step3Validation.tsx +++ b/web/src/components/settings/wizard/Step3Validation.tsx @@ -431,6 +431,16 @@ function StreamIssues({ message: string; }> = []; + if (wizardData.brandTemplate === "reolink") { + const streamUrl = stream.url.toLowerCase(); + if (streamUrl.startsWith("rtsp://")) { + result.push({ + type: "warning", + message: t("cameraWizard.step1.errors.brands.reolink-rtsp"), + }); + } + } + // Video codec check if (stream.testResult?.videoCodec) { const videoCodec = stream.testResult.videoCodec.toLowerCase(); diff --git a/web/src/types/cameraWizard.ts b/web/src/types/cameraWizard.ts index 1fa4feaa9..f80dc60c2 100644 --- a/web/src/types/cameraWizard.ts +++ b/web/src/types/cameraWizard.ts @@ -7,6 +7,7 @@ export const CAMERA_BRANDS = [ "rtsp://{username}:{password}@{host}:554/cam/realmonitor?channel=1&subtype=0", exampleUrl: "rtsp://admin:password@192.168.1.100:554/cam/realmonitor?channel=1&subtype=0", + dynamicTemplates: undefined, }, { value: "hikvision" as const, @@ -14,42 +15,54 @@ export const CAMERA_BRANDS = [ template: "rtsp://{username}:{password}@{host}:554/Streaming/Channels/101", exampleUrl: "rtsp://admin:password@192.168.1.100:554/Streaming/Channels/101", + dynamicTemplates: undefined, }, { value: "ubiquiti" as const, label: "Ubiquiti", template: "rtsp://{username}:{password}@{host}:554/live/ch0", exampleUrl: "rtsp://ubnt:password@192.168.1.100:554/live/ch0", + dynamicTemplates: undefined, }, { value: "reolink" as const, label: "Reolink", - template: "rtsp://{username}:{password}@{host}:554/h264Preview_01_main", - exampleUrl: "rtsp://admin:password@192.168.1.100:554/h264Preview_01_main", + template: "dynamic", + dynamicTemplates: { + "http-flv": + "http://{host}/flv?port=1935&app=bcs&stream=channel0_main.bcs&user={username}&password={password}", + rtsp: "rtsp://{username}:{password}@{host}:554/Preview_01_main", + }, + exampleUrl: + "http://192.168.1.100/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=admin&password=password or rtsp://admin:password@192.168.1.100:554/Preview_01_main", }, { value: "axis" as const, label: "Axis", template: "rtsp://{username}:{password}@{host}:554/axis-media/media.amp", exampleUrl: "rtsp://root:password@192.168.1.100:554/axis-media/media.amp", + dynamicTemplates: undefined, }, { value: "tplink" as const, label: "TP-Link", template: "rtsp://{username}:{password}@{host}:554/stream1", exampleUrl: "rtsp://admin:password@192.168.1.100:554/stream1", + dynamicTemplates: undefined, }, { value: "foscam" as const, label: "Foscam", template: "rtsp://{username}:{password}@{host}:88/videoMain", exampleUrl: "rtsp://admin:password@192.168.1.100:88/videoMain", + dynamicTemplates: undefined, }, { value: "other" as const, label: "Other", template: "", exampleUrl: "rtsp://username:password@host:port/path", + dynamicTemplates: undefined, }, ] as const; diff --git a/web/src/utils/cameraUtil.ts b/web/src/utils/cameraUtil.ts index 6b5d9e584..ae7b8001a 100644 --- a/web/src/utils/cameraUtil.ts +++ b/web/src/utils/cameraUtil.ts @@ -1,3 +1,5 @@ +// ==================== Camera Name Processing ==================== + /** * Generates a fixed-length hash from a camera name for use as a valid camera identifier. * Works safely with Unicode input while outputting Latin-only identifiers. @@ -58,3 +60,49 @@ export function processCameraName(userInput: string): { friendlyName: userInput, }; } + +// ==================== Reolink Camera Detection ==================== + +/** + * Detect Reolink camera capabilities and recommend optimal protocol + * + * Calls the Frigate backend API which queries the Reolink camera to determine + * its resolution and recommends either http-flv (for 5MP and below) or rtsp + * (for higher resolutions). + * + * @param host - Camera IP address or hostname + * @param username - Camera username + * @param password - Camera password + * @returns The recommended protocol key ("http-flv" or "rtsp"), or null if detection failed + */ +export async function detectReolinkCamera( + host: string, + username: string, + password: string, +): Promise<"http-flv" | "rtsp" | null> { + try { + const params = new URLSearchParams({ + host, + username, + password, + }); + + const response = await fetch(`/api/reolink/detect?${params.toString()}`, { + method: "GET", + }); + + if (!response.ok) { + return null; + } + + const data = await response.json(); + + if (data.success && data.protocol) { + return data.protocol; + } + + return null; + } catch (error) { + return null; + } +}