Compare commits

...
2 Commits
Author SHA1 Message Date
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
9 changed files with 215 additions and 80 deletions
+6 -7
View File
@@ -169,10 +169,9 @@ 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.
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.
@@ -203,20 +202,20 @@ go2rtc:
your_reolink_camera:
- "ffmpeg:http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password#video=copy#audio=copy#audio=opus"
your_reolink_camera_sub:
- "ffmpeg:http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_ext.bcs&user=username&password=password"
- "http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_ext.bcs&user=username&password=password"
# example for connecting to a Reolink camera that supports two way talk
your_reolink_camera_twt:
- "ffmpeg:http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password#video=copy#audio=copy#audio=opus"
- "rtsp://username:password@reolink_ip/Preview_01_sub"
your_reolink_camera_twt_sub:
- "ffmpeg:http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_ext.bcs&user=username&password=password"
- "http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_ext.bcs&user=username&password=password"
- "rtsp://username:password@reolink_ip/Preview_01_sub"
# example for connecting to a Reolink NVR
your_reolink_camera_via_nvr:
- "ffmpeg:http://reolink_nvr_ip/flv?port=1935&app=bcs&stream=channel3_main.bcs&user=username&password=password" # channel numbers are 0-15
- "http://reolink_nvr_ip/flv?port=1935&app=bcs&stream=channel3_main.bcs&user=username&password=password" # channel numbers are 0-15
- "ffmpeg:your_reolink_camera_via_nvr#audio=aac"
your_reolink_camera_via_nvr_sub:
- "ffmpeg:http://reolink_nvr_ip/flv?port=1935&app=bcs&stream=channel3_ext.bcs&user=username&password=password"
- "http://reolink_nvr_ip/flv?port=1935&app=bcs&stream=channel3_ext.bcs&user=username&password=password"
cameras:
your_reolink_camera:
+2 -2
View File
@@ -190,14 +190,14 @@ If you rename or remove a go2rtc stream while experimenting and the live stream
Several camera brands have well-known quirks with go2rtc. Rather than repeat them here, see the [camera-specific configuration](/configuration/camera_specific) page, which covers them in detail. The highlights:
- **Reolink**: RTSP is unreliable on many models; the **http-flv** stream through the FFmpeg module is recommended, and you must enable HTTP/RTMP in the camera and **reboot** it. 6MP+ models stream H.265 over http-flv-enhanced, which requires FFmpeg 8.0. See [Reolink Cameras](/configuration/camera_specific#reolink-cameras).
- **Reolink**: RTSP is unreliable on many models; the **http-flv** stream is recommended, and you must enable HTTP/RTMP in the camera and **reboot** it. 6MP+ models stream H.265 over http-flv-enhanced, which requires FFmpeg 8.0. See [Reolink Cameras](/configuration/camera_specific#reolink-cameras).
- **TP-Link Tapo**: use go2rtc's native `tapo://` source for stability and two-way audio; a stale RTSP credential can often be revived by clicking play once in the go2rtc web UI.
- **Ubiquiti/UniFi Protect**: use the `rtspx://` scheme (not `rtsps://…?enableSrtp`).
- **Amcrest/Dahua**: use the `/cam/realmonitor?channel=1&subtype=N` scheme, where `subtype=0` is the main stream. See [Amcrest & Dahua](/configuration/camera_specific#amcrest--dahua).
## Non-RTSP sources and the FFmpeg module
go2rtc's native zero-copy handling only supports well-formed RTSP H.264/H.265. Anything else (MJPEG, HTTP/HTTP-FLV, RTMP, or unusual codecs) must be handed to the FFmpeg module by prefixing the source with `ffmpeg:`. This is also necessary for some camera streams to be parsed at all, at the cost of slightly slower startup. MJPEG and other non-H.264 sources additionally need `#video=h264` (with `#hardware`) before they can be used for the `record`, `detect`, or restream roles. See [MJPEG Cameras](/configuration/camera_specific#mjpeg-cameras) for a complete example.
go2rtc's native zero-copy handling only supports well-formed RTSP H.264/H.265. Anything else (MJPEG, RTMP, or unusual codecs) must be handed to the FFmpeg module by prefixing the source with `ffmpeg:`. This is also necessary for some camera streams to be parsed at all, at the cost of slightly slower startup. MJPEG and other non-H.264 sources additionally need `#video=h264` (with `#hardware`) before they can be used for the `record`, `detect`, or restream roles. See [MJPEG Cameras](/configuration/camera_specific#mjpeg-cameras) for a complete example.
## Hardware-accelerated transcoding with FFmpeg 8
@@ -0,0 +1,139 @@
/**
* 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.
*/
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 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();
});
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();
});
});
+1 -2
View File
@@ -505,8 +505,7 @@
"audioCodecRequired": "An audio stream is required to support audio detection.",
"restreamingWarning": "Reducing connections to the camera for the record stream may increase CPU usage slightly.",
"brands": {
"reolink-rtsp": "Reolink RTSP is not recommended. Enable HTTP in the camera's firmware settings and restart the wizard.",
"reolink-http": "Reolink HTTP streams should use FFmpeg for better compatibility. Enable 'Use stream compatibility mode' for this stream."
"reolink-rtsp": "Reolink RTSP is not recommended. Enable HTTP in the camera's firmware settings and restart the wizard."
},
"dahua": {
"substreamWarning": "Substream 1 is locked to a low resolution. Many Dahua / Amcrest / EmpireTech cameras support additional substreams that need to be enabled in the camera's settings. It is recommended to check and utilize those streams if available."
@@ -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
.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<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 "";
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);
@@ -434,7 +444,7 @@ export default function Step2ProbeOrSnapshot({
setTestStatus("");
}
},
[wizardData, generateStreamUrl, t, onUpdate, probeUri],
[wizardData, generateStreamUrls, t, onUpdate, probeUri],
);
const handleContinue = useCallback(() => {
@@ -499,7 +499,7 @@ function StreamIssues({
url: stream.url,
roles: stream.roles,
brand: wizardData.brandTemplate,
useFfmpeg: stream.useFfmpeg,
reolinkProtocol: wizardData.reolinkProtocol,
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
+1 -2
View File
@@ -12,7 +12,7 @@ import {
} from "@/utils/streamIssues";
// rules the add camera wizard shows that do not belong on the Health tab
const WIZARD_ONLY_RULES = new Set(["restream", "reolink-rtsp", "reolink-http"]);
const WIZARD_ONLY_RULES = new Set(["restream", "reolink-rtsp"]);
/**
* Whether the record output keeps the camera's audio codec. The default
@@ -115,7 +115,6 @@ export function streamHealth(
url,
roles: input.roles as StreamRole[],
brand: inferCameraBrand(url),
useFfmpeg: restream?.useFfmpeg,
restream: !!restream,
testResult: result,
},
+16 -28
View File
@@ -13,7 +13,7 @@ export type StreamIssueInput = {
url: string;
roles: StreamRole[];
brand?: CameraBrand;
useFfmpeg?: boolean;
reolinkProtocol?: "http-flv" | "rtsp" | null;
restream?: boolean;
testResult?: TestResult;
};
@@ -94,27 +94,18 @@ export function getStreamIssues(
const result: StreamIssue[] = [];
const { roles, testResult } = input;
if (input.brand === "reolink") {
const streamUrl = input.url.toLowerCase();
if (streamUrl.startsWith("rtsp://")) {
result.push({
type: "warning",
rule: "reolink-rtsp",
message: t("cameraWizard.step4.issues.brands.reolink-rtsp", {
ns: "views/settings",
}),
});
}
if (streamUrl.startsWith("http://") && !input.useFfmpeg) {
result.push({
type: "warning",
rule: "reolink-http",
message: t("cameraWizard.step4.issues.brands.reolink-http", {
ns: "views/settings",
}),
});
}
if (
input.brand === "reolink" &&
input.reolinkProtocol !== "rtsp" &&
input.url.toLowerCase().startsWith("rtsp://")
) {
result.push({
type: "warning",
rule: "reolink-rtsp",
message: t("cameraWizard.step4.issues.brands.reolink-rtsp", {
ns: "views/settings",
}),
});
}
if (testResult?.videoCodec) {
@@ -268,7 +259,7 @@ export function getStreamIssues(
export function resolveRestreamSource(
path: string,
streams: Record<string, string | string[]> | undefined,
): { url: string; useFfmpeg: boolean } | undefined {
): { url: string } | undefined {
const name = parseRestreamStreamName(path);
if (!name || !streams) {
@@ -288,11 +279,8 @@ export function resolveRestreamSource(
}
if (source.startsWith("ffmpeg:")) {
return {
url: source.slice("ffmpeg:".length).split("#")[0],
useFfmpeg: true,
};
return { url: source.slice("ffmpeg:".length).split("#")[0] };
}
return { url: source, useFfmpeg: false };
return { url: source };
}