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 17:47:26 -05:00
committed by GitHub
parent 3c3e11ecaf
commit 6a031eb9ee
9 changed files with 586 additions and 294 deletions
@@ -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,
})}
@@ -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();
+15 -2
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;
+48
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;
}
}