Compare commits

...
3 Commits
Author SHA1 Message Date
Josh Hawkins f8935ec1b2 keep using ffmpeg module 2026-10-06 08:18:06 -05:00
Josh Hawkins 3180d63df1 add reolink tests 2026-10-05 14:56:31 -05:00
Josh Hawkins fa1c41974f try http-flv first for reolink cameras above 5MP in the camera wizard
The wizard picked RTSP for every Reolink above 5MP and then warned that RTSP isn't recommended, telling users to enable HTTP and restart, which just produced the same result. It now tries the http-flv stream first for those cameras and falls back to RTSP when the stream doesn't probe, since only the newer models serve H.265 over enhanced FLV. The RTSP warning is skipped when the camera's resolution made RTSP a valid choice.

http-flv no longer needs go2rtc's ffmpeg module, so the compatibility mode warning for Reolink HTTP streams is removed and the docs drop the `ffmpeg:` prefix from http-flv sources that have no transcoding modifiers.
2026-10-05 14:44:40 -05:00
6 changed files with 213 additions and 42 deletions
+3 -3
View File
@@ -169,10 +169,10 @@ The [Add Camera Wizard](cameras.md#adding-a-camera-with-the-add-camera-wizard) i
1. Click **Add Camera** in <NavPath path="Settings > Global configuration > Camera management" />.
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.
@@ -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();
});
});
@@ -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<WizardFormData>): Promise<string | null> => {
const generateDynamicStreamUrls = useCallback(
async (data: Partial<WizardFormData>): Promise<string[]> => {
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<string, string> = brand.dynamicTemplates || {};
if (Object.keys(templates).includes(protocolKey)) {
const template =
templates[protocolKey as keyof typeof brand.dynamicTemplates];
return template
return protocolKeys
.filter((key) => key in templates)
.map((key) =>
templates[key]
.replace("{username}", data.username || "")
.replace("{password}", data.password || "")
.replace("{host}", data.host);
}
return null;
.replace("{host}", host),
);
},
[],
[onUpdate],
);
const generateStreamUrl = useCallback(
async (data: Partial<WizardFormData>): Promise<string> => {
const generateStreamUrls = useCallback(
async (data: Partial<WizardFormData>): Promise<string[]> => {
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 generateDynamicStreamUrls(data);
}
return "";
}
return brand.template
return [
brand.template
.replace("{username}", data.username || "")
.replace("{password}", data.password || "")
.replace("{host}", data.host);
.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 {
let streamUrl = streamUrls[0];
let result: TestResult | undefined;
for (const url of streamUrls) {
streamUrl = url;
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
const result = await probeUri(streamUrl, true, setTestStatus);
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(() => {
@@ -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,
+1
View File
@@ -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
+2 -1
View File
@@ -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",