diff --git a/docs/docs/configuration/camera_specific.md b/docs/docs/configuration/camera_specific.md
index c83942e495..7bc9b0b9c3 100644
--- a/docs/docs/configuration/camera_specific.md
+++ b/docs/docs/configuration/camera_specific.md
@@ -169,10 +169,10 @@ The [Add Camera Wizard](cameras.md#adding-a-camera-with-the-add-camera-wizard) i
1. Click **Add Camera** in .
2. Choose **Manual selection** as the stream detection method and select **Reolink** as the camera brand.
-3. The wizard queries the camera and automatically uses an http-flv stream for cameras 5MP and lower, or an RTSP stream for higher resolution cameras.
-4. In the validation step, enable **Use stream compatibility mode** for http-flv streams when the wizard recommends it.
+3. The wizard queries the camera and automatically uses an http-flv stream for cameras 5MP and lower. For higher resolution cameras, it tries the http-flv stream first and falls back to RTSP when the camera does not support it.
+4. The wizard turns on **Use stream compatibility mode** for the http-flv stream it selects. Enable it for any other http-flv stream you add, such as the sub stream.
-If you use the **Probe camera** method instead, the discovered stream URLs will be RTSP. For Reolink cameras where http-flv is recommended, the wizard will show a warning in the validation step.
+If you use the **Probe camera** method instead, the discovered stream URLs will be RTSP. For Reolink cameras 5MP and lower, where http-flv is recommended, the wizard will show a warning in the validation step.
The wizard covers standard single-camera setups. For two way talk, cameras connected through a Reolink NVR, or audio transcoding for WebRTC live view, configure the camera manually as shown below.
diff --git a/web/e2e/specs/settings/camera-wizard-reolink.spec.ts b/web/e2e/specs/settings/camera-wizard-reolink.spec.ts
new file mode 100644
index 0000000000..cacc0bfcc0
--- /dev/null
+++ b/web/e2e/specs/settings/camera-wizard-reolink.spec.ts
@@ -0,0 +1,155 @@
+/**
+ * Add-camera wizard - Reolink stream selection with the brand template.
+ *
+ * The wizard asks the camera for its resolution, then probes http-flv first
+ * above 5MP and falls back to RTSP. The Step 4 RTSP warning is only for
+ * cameras that should be on http-flv. An http-flv stream the wizard selects
+ * is registered with go2rtc through the ffmpeg module.
+ */
+
+import { test, expect } from "../../fixtures/frigate-test";
+import type { Page } from "@playwright/test";
+
+const FLV_PATH = "channel0_main.bcs";
+const RTSP_PATH = "Preview_01_main";
+const RTSP_WARNING = "Reolink RTSP is not recommended";
+const HTTP_WARNING = "Reolink HTTP streams should use FFmpeg";
+
+const FFPROBE_OK = [
+ {
+ return_code: 0,
+ stderr: [],
+ stdout: {
+ streams: [
+ {
+ codec_type: "video",
+ codec_name: "hevc",
+ width: 3840,
+ height: 2160,
+ avg_frame_rate: "15/1",
+ },
+ { codec_type: "audio", codec_name: "aac" },
+ ],
+ },
+ },
+];
+
+const FFPROBE_FAILED = [
+ { return_code: 1, stderr: ["probe failed"], stdout: "" },
+];
+
+/**
+ * Mock the camera's answers and drive the wizard to Step 3. Returns the
+ * dialog and the stream paths the wizard probed, in order.
+ */
+async function gotoStep3(
+ page: Page,
+ { protocol, flvProbes }: { protocol: string | null; flvProbes: boolean },
+) {
+ const probed: string[] = [];
+
+ await page.route("**/api/reolink/detect**", (route) =>
+ route.fulfill({ json: { success: protocol !== null, protocol } }),
+ );
+ await page.route("**/api/ffprobe**", (route) => {
+ const paths = new URL(route.request().url()).searchParams.get("paths");
+ const isFlv = !!paths?.includes(FLV_PATH);
+ probed.push(isFlv ? FLV_PATH : RTSP_PATH);
+ return route.fulfill({
+ json: isFlv && !flvProbes ? FFPROBE_FAILED : FFPROBE_OK,
+ });
+ });
+ await page.route("**/api/ffprobe/snapshot**", (route) =>
+ route.fulfill({ status: 500 }),
+ );
+
+ await page.getByRole("button", { name: /Add New Camera/i }).click();
+ const dialog = page.getByRole("dialog");
+ await expect(dialog).toBeVisible();
+
+ await dialog.getByPlaceholder(/front_door/i).fill("reolink_test_camera");
+ await dialog.getByPlaceholder("192.168.1.100").fill("192.168.1.100");
+ await dialog.getByPlaceholder("Optional").first().fill("admin");
+ await dialog.getByPlaceholder("Optional").last().fill("pw");
+ await dialog.getByText("Manual selection").click();
+ await dialog.getByRole("combobox").click();
+ await page.getByRole("option", { name: "Reolink" }).click();
+ await dialog.getByRole("button", { name: /^Continue$/i }).click();
+
+ // Step 2 tests the connection on its own, then offers Continue
+ const next = dialog.getByRole("button", { name: /^Continue$/i });
+ await expect(next).toBeEnabled({ timeout: 10_000 });
+ await next.click();
+
+ await expect(
+ dialog.getByRole("button", { name: /Add Another Stream/i }),
+ ).toBeVisible();
+ return { dialog, probed };
+}
+
+test.describe("Camera wizard Reolink stream selection @medium @mobile", () => {
+ test.beforeEach(async ({ frigateApp }) => {
+ // not in the default mock; unmocked it 500s and trips the error collector
+ await frigateApp.page.route("**/api/config/raw_paths", (route) =>
+ route.fulfill({ json: {} }),
+ );
+ await frigateApp.goto("/settings?page=cameraManagement");
+ await expect(
+ frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
+ ).toBeVisible();
+ });
+
+ test("above 5MP keeps http-flv when it probes", async ({ frigateApp }) => {
+ const { dialog, probed } = await gotoStep3(frigateApp.page, {
+ protocol: "rtsp",
+ flvProbes: true,
+ });
+
+ expect(probed).toEqual([FLV_PATH]);
+ await expect(dialog.locator(`input[value*="${FLV_PATH}"]`)).toBeVisible();
+
+ const registered: string[] = [];
+ await frigateApp.page.route("**/api/go2rtc/streams/**", (route) => {
+ const src = new URL(route.request().url()).searchParams.get("src");
+ if (src) registered.push(src);
+ return route.fulfill({ json: {} });
+ });
+
+ await dialog.getByRole("button", { name: /^Next$/i }).click();
+ await expect(
+ dialog.getByRole("button", { name: /Save New Camera/i }),
+ ).toBeVisible();
+ await expect.poll(() => registered[0]).toMatch(/^ffmpeg:http:\/\//);
+ await expect(dialog.getByText(HTTP_WARNING)).toHaveCount(0);
+ });
+
+ test("above 5MP falls back to RTSP without a warning", async ({
+ frigateApp,
+ }) => {
+ const { dialog, probed } = await gotoStep3(frigateApp.page, {
+ protocol: "rtsp",
+ flvProbes: false,
+ });
+
+ expect(probed).toEqual([FLV_PATH, RTSP_PATH]);
+ await expect(dialog.locator(`input[value*="${RTSP_PATH}"]`)).toBeVisible();
+
+ await dialog.getByRole("button", { name: /^Next$/i }).click();
+ await expect(
+ dialog.getByRole("button", { name: /Save New Camera/i }),
+ ).toBeVisible();
+ await expect(dialog.getByText(RTSP_WARNING)).toHaveCount(0);
+ });
+
+ test("failed detection uses RTSP and warns", async ({ frigateApp }) => {
+ const { dialog, probed } = await gotoStep3(frigateApp.page, {
+ protocol: null,
+ flvProbes: true,
+ });
+
+ expect(probed).toEqual([RTSP_PATH]);
+
+ await dialog.getByRole("button", { name: /^Next$/i }).click();
+ await expect(dialog.getByText(RTSP_WARNING)).toBeVisible();
+ });
+});
diff --git a/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx b/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx
index 8ddbac1860..f1cfdac4ea 100644
--- a/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx
+++ b/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx
@@ -222,6 +222,7 @@ export default function Step2ProbeOrSnapshot({
wizardData.username,
wizardData.password,
);
+ update.reolinkProtocol = protocol;
if (protocol === "http-flv") {
update.brandTemplate = "reolink";
}
@@ -294,73 +295,72 @@ export default function Step2ProbeOrSnapshot({
[probeUri],
);
- const generateDynamicStreamUrl = useCallback(
- async (data: Partial): Promise => {
+ const generateDynamicStreamUrls = useCallback(
+ async (data: Partial): Promise => {
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
- if (!brand || !data.host) return null;
+ const host = data.host;
+ if (!brand || !host) return [];
let protocol = undefined;
if (data.brandTemplate === "reolink" && data.username && data.password) {
try {
protocol = await detectReolinkCamera(
- data.host,
+ host,
data.username,
data.password,
);
} catch {
- return null;
+ return [];
}
+ onUpdate({ reolinkProtocol: protocol });
}
- const protocolKey = protocol || "rtsp";
+ // Only some Reolink cameras above 5MP serve http-flv, so RTSP is the
+ // fallback when the http-flv stream does not probe.
+ const protocolKeys =
+ protocol === "rtsp" ? ["http-flv", "rtsp"] : [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;
+ return protocolKeys
+ .filter((key) => key in templates)
+ .map((key) =>
+ templates[key]
+ .replace("{username}", data.username || "")
+ .replace("{password}", data.password || "")
+ .replace("{host}", host),
+ );
},
- [],
+ [onUpdate],
);
- const generateStreamUrl = useCallback(
- async (data: Partial): Promise => {
+ const generateStreamUrls = useCallback(
+ async (data: Partial): Promise => {
if (data.brandTemplate === "other") {
- return data.customUrl || "";
+ return data.customUrl ? [data.customUrl] : [];
}
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
- if (!brand || !data.host) return "";
+ if (!brand || !data.host) return [];
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
- const dynamicUrl = await generateDynamicStreamUrl(data);
-
- if (dynamicUrl) {
- return dynamicUrl;
- }
-
- return "";
+ return generateDynamicStreamUrls(data);
}
- return brand.template
- .replace("{username}", data.username || "")
- .replace("{password}", data.password || "")
- .replace("{host}", data.host);
+ return [
+ brand.template
+ .replace("{username}", data.username || "")
+ .replace("{password}", data.password || "")
+ .replace("{host}", data.host),
+ ];
},
- [generateDynamicStreamUrl],
+ [generateDynamicStreamUrls],
);
const testConnection = useCallback(
async (showToast = true) => {
- const streamUrl = await generateStreamUrl(wizardData);
+ const streamUrls = await generateStreamUrls(wizardData);
- if (!streamUrl) {
+ if (streamUrls.length === 0) {
toast.error(t("cameraWizard.commonErrors.noUrl"));
return;
}
@@ -370,8 +370,18 @@ export default function Step2ProbeOrSnapshot({
setTestResult(null);
try {
- setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
- const result = await probeUri(streamUrl, true, setTestStatus);
+ let streamUrl = streamUrls[0];
+ let result: TestResult | undefined;
+
+ for (const url of streamUrls) {
+ streamUrl = url;
+ setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
+ result = await probeUri(url, true, setTestStatus);
+
+ if (result.success && result.resolution) {
+ break;
+ }
+ }
if (result && result.success) {
setTestResult(result);
@@ -383,6 +393,9 @@ export default function Step2ProbeOrSnapshot({
url: streamUrl,
roles: ["detect"] as StreamRole[],
testResult: result,
+ useFfmpeg:
+ wizardData.brandTemplate === "reolink" &&
+ streamUrl.startsWith("http://"),
},
],
});
@@ -434,7 +447,7 @@ export default function Step2ProbeOrSnapshot({
setTestStatus("");
}
},
- [wizardData, generateStreamUrl, t, onUpdate, probeUri],
+ [wizardData, generateStreamUrls, t, onUpdate, probeUri],
);
const handleContinue = useCallback(() => {
diff --git a/web/src/components/settings/wizard/Step4Validation.tsx b/web/src/components/settings/wizard/Step4Validation.tsx
index 0a35e22f6c..a6d21aba16 100644
--- a/web/src/components/settings/wizard/Step4Validation.tsx
+++ b/web/src/components/settings/wizard/Step4Validation.tsx
@@ -499,6 +499,7 @@ function StreamIssues({
url: stream.url,
roles: stream.roles,
brand: wizardData.brandTemplate,
+ reolinkProtocol: wizardData.reolinkProtocol,
useFfmpeg: stream.useFfmpeg,
restream: stream.restream,
testResult: stream.testResult,
diff --git a/web/src/types/cameraWizard.ts b/web/src/types/cameraWizard.ts
index 06cf6d4a6a..7b481ddc1e 100644
--- a/web/src/types/cameraWizard.ts
+++ b/web/src/types/cameraWizard.ts
@@ -110,6 +110,7 @@ export type WizardFormData = {
username?: string;
password?: string;
brandTemplate?: CameraBrand;
+ reolinkProtocol?: "http-flv" | "rtsp" | null; // null when detection failed
customUrl?: string;
streams?: StreamConfig[];
probeMode?: boolean; // true for probe, false for manual
diff --git a/web/src/utils/streamIssues.ts b/web/src/utils/streamIssues.ts
index a5db8716c0..f763cc9c6d 100644
--- a/web/src/utils/streamIssues.ts
+++ b/web/src/utils/streamIssues.ts
@@ -13,6 +13,7 @@ export type StreamIssueInput = {
url: string;
roles: StreamRole[];
brand?: CameraBrand;
+ reolinkProtocol?: "http-flv" | "rtsp" | null;
useFfmpeg?: boolean;
restream?: boolean;
testResult?: TestResult;
@@ -96,7 +97,7 @@ export function getStreamIssues(
if (input.brand === "reolink") {
const streamUrl = input.url.toLowerCase();
- if (streamUrl.startsWith("rtsp://")) {
+ if (streamUrl.startsWith("rtsp://") && input.reolinkProtocol !== "rtsp") {
result.push({
type: "warning",
rule: "reolink-rtsp",