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