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
This commit is contained in:
Nicolas Mowen 2025-10-13 16:47:26 -06:00 committed by GitHub
parent 3c3e11ecaf
commit 6a031eb9ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 586 additions and 294 deletions

View File

@ -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&microphone"
)
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()

443
frigate/api/camera.py Normal file
View File

@ -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&microphone"
)
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

View File

@ -3,6 +3,7 @@ from enum import Enum
class Tags(Enum):
app = "App"
camera = "Camera"
preview = "Preview"
logs = "Logs"
media = "Media"

View File

@ -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)

View File

@ -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": {

View File

@ -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<WizardFormData>;
@ -134,8 +135,44 @@ export default function Step1NameCamera({
? !!(watchedCustomUrl && watchedCustomUrl.trim())
: !!(watchedHost && watchedHost.trim());
const generateDynamicStreamUrl = useCallback(
async (data: z.infer<typeof step1FormData>): Promise<string | null> => {
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<string, string> = 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<typeof step1FormData>): string => {
async (data: z.infer<typeof step1FormData>): Promise<string> => {
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({
<h4 className="font-medium">
{selectedBrand.label}
</h4>
<p className="text-sm text-muted-foreground">
<p className="break-all text-sm text-muted-foreground">
{t("cameraWizard.step1.brandUrlFormat", {
exampleUrl: selectedBrand.exampleUrl,
})}

View File

@ -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();

View File

@ -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;

View File

@ -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;
}
}