From 81df53478498afe57d5da3dfd6e3f876abcfb2e6 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 23 Oct 2025 08:34:52 -0500 Subject: [PATCH] Camera wizard improvements (#20636) * use avg_frame_rate * probe metadata and snapshot separately * improve ffprobe error reporting * show error messages in toaster --- frigate/api/camera.py | 37 ++++++++----- frigate/util/services.py | 2 +- web/public/locales/en/views/settings.json | 8 ++- .../settings/wizard/Step1NameCamera.tsx | 55 ++++++++++++------- .../settings/wizard/Step2StreamConfig.tsx | 6 +- .../settings/wizard/Step3Validation.tsx | 8 +-- .../views/settings/CameraManagementView.tsx | 7 ++- 7 files changed, 80 insertions(+), 43 deletions(-) diff --git a/frigate/api/camera.py b/frigate/api/camera.py index 01da847bc..9a91bd1a9 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -199,19 +199,30 @@ def ffprobe(request: Request, paths: str = "", detailed: bool = False): request.app.frigate_config.ffmpeg, path.strip(), detailed=detailed ) - result = { - "return_code": ffprobe.returncode, - "stderr": ( - ffprobe.stderr.decode("unicode_escape").strip() - if ffprobe.returncode != 0 - else "" - ), - "stdout": ( - json.loads(ffprobe.stdout.decode("unicode_escape").strip()) - if ffprobe.returncode == 0 - else "" - ), - } + if ffprobe.returncode != 0: + try: + stderr_decoded = ffprobe.stderr.decode("utf-8") + except UnicodeDecodeError: + try: + stderr_decoded = ffprobe.stderr.decode("unicode_escape") + except Exception: + stderr_decoded = str(ffprobe.stderr) + + stderr_lines = [ + line.strip() for line in stderr_decoded.split("\n") if line.strip() + ] + + result = { + "return_code": ffprobe.returncode, + "stderr": stderr_lines, + "stdout": "", + } + else: + result = { + "return_code": ffprobe.returncode, + "stderr": [], + "stdout": json.loads(ffprobe.stdout.decode("unicode_escape").strip()), + } # Add detailed metadata if requested and probe was successful if detailed and ffprobe.returncode == 0 and result["stdout"]: diff --git a/frigate/util/services.py b/frigate/util/services.py index 587794990..c51fe923a 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -577,7 +577,7 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro if detailed and format_entries: ffprobe_cmd.extend(["-show_entries", f"format={format_entries}"]) - ffprobe_cmd.extend(["-loglevel", "quiet", clean_path]) + ffprobe_cmd.extend(["-loglevel", "error", clean_path]) return sp.run(ffprobe_cmd, capture_output=True) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 6d4a32262..373f31ad0 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -16,7 +16,6 @@ "ui": "UI", "enrichments": "Enrichments", "cameraManagement": "Management", - "cameraReview": "Review", "masksAndZones": "Masks / Zones", "motionTuner": "Motion Tuner", "triggers": "Triggers", @@ -188,6 +187,10 @@ "testSuccess": "Connection test successful!", "testFailed": "Connection test failed. Please check your input and try again.", "streamDetails": "Stream Details", + "testing": { + "probingMetadata": "Probing camera metadata...", + "fetchingSnapshot": "Fetching camera snapshot..." + }, "warnings": { "noSnapshot": "Unable to fetch a snapshot from the configured stream." }, @@ -197,8 +200,9 @@ "nameLength": "Camera name must be 64 characters or less", "invalidCharacters": "Camera name contains invalid characters", "nameExists": "Camera name already exists", + "customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\". Manual configuration is required for non-RTSP camera streams.", "brands": { - "reolink-rtsp": "Reolink RTSP is not recommended. It is recommended to enable http in the camera settings and restart the camera wizard." + "reolink-rtsp": "Reolink RTSP is not recommended. Enable HTTP in the camera's firmware settings and restart the wizard." } }, "docs": { diff --git a/web/src/components/settings/wizard/Step1NameCamera.tsx b/web/src/components/settings/wizard/Step1NameCamera.tsx index 6ed2339bc..2065d1ae6 100644 --- a/web/src/components/settings/wizard/Step1NameCamera.tsx +++ b/web/src/components/settings/wizard/Step1NameCamera.tsx @@ -65,6 +65,7 @@ export default function Step1NameCamera({ const { data: config } = useSWR("config"); const [showPassword, setShowPassword] = useState(false); const [isTesting, setIsTesting] = useState(false); + const [testStatus, setTestStatus] = useState(""); const [testResult, setTestResult] = useState(null); const existingCameraNames = useMemo(() => { @@ -88,7 +89,13 @@ export default function Step1NameCamera({ username: z.string().optional(), password: z.string().optional(), brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(), - customUrl: z.string().optional(), + customUrl: z + .string() + .optional() + .refine( + (val) => !val || val.startsWith("rtsp://"), + t("cameraWizard.step1.errors.customUrlRtspRequired"), + ), }) .refine( (data) => { @@ -204,24 +211,17 @@ export default function Step1NameCamera({ } setIsTesting(true); + setTestStatus(""); setTestResult(null); - // First get probe data for metadata - const probePromise = axios.get("ffprobe", { - params: { paths: streamUrl, detailed: true }, - timeout: 10000, - }); - - // Then get snapshot for preview - const snapshotPromise = axios.get("ffprobe/snapshot", { - params: { url: streamUrl }, - responseType: "blob", - timeout: 10000, - }); - try { // First get probe data for metadata - const probeResponse = await probePromise; + setTestStatus(t("cameraWizard.step1.testing.probingMetadata")); + const probeResponse = await axios.get("ffprobe", { + params: { paths: streamUrl, detailed: true }, + timeout: 10000, + }); + let probeData = null; if ( probeResponse.data && @@ -234,8 +234,13 @@ export default function Step1NameCamera({ // Then get snapshot for preview (only if probe succeeded) let snapshotBlob = null; if (probeData) { + setTestStatus(t("cameraWizard.step1.testing.fetchingSnapshot")); try { - const snapshotResponse = await snapshotPromise; + const snapshotResponse = await axios.get("ffprobe/snapshot", { + params: { url: streamUrl }, + responseType: "blob", + timeout: 10000, + }); snapshotBlob = snapshotResponse.data; } catch (snapshotError) { // Snapshot is optional, don't fail if it doesn't work @@ -295,12 +300,18 @@ export default function Step1NameCamera({ setTestResult(testResult); toast.success(t("cameraWizard.step1.testSuccess")); } else { - const error = probeData?.stderr || "Unknown error"; + const error = + Array.isArray(probeResponse.data?.[0]?.stderr) && + probeResponse.data[0].stderr.length > 0 + ? probeResponse.data[0].stderr.join("\n") + : "Unable to probe stream"; setTestResult({ success: false, error: error, }); - toast.error(t("cameraWizard.commonErrors.testFailed", { error })); + toast.error(t("cameraWizard.commonErrors.testFailed", { error }), { + duration: 6000, + }); } } catch (error) { const axiosError = error as { @@ -318,9 +329,13 @@ export default function Step1NameCamera({ }); toast.error( t("cameraWizard.commonErrors.testFailed", { error: errorMessage }), + { + duration: 10000, + }, ); } finally { setIsTesting(false); + setTestStatus(""); } }, [form, generateStreamUrl, t]); @@ -610,7 +625,9 @@ export default function Step1NameCamera({ className="flex items-center justify-center gap-2 sm:flex-1" > {isTesting && } - {t("cameraWizard.step1.testConnection")} + {isTesting && testStatus + ? testStatus + : t("cameraWizard.step1.testConnection")} )} diff --git a/web/src/components/settings/wizard/Step2StreamConfig.tsx b/web/src/components/settings/wizard/Step2StreamConfig.tsx index 0a3419940..f9676f2b0 100644 --- a/web/src/components/settings/wizard/Step2StreamConfig.tsx +++ b/web/src/components/settings/wizard/Step2StreamConfig.tsx @@ -151,9 +151,9 @@ export default function Step2StreamConfig({ ? `${videoStream.width}x${videoStream.height}` : undefined; - 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; const testResult: TestResult = { diff --git a/web/src/components/settings/wizard/Step3Validation.tsx b/web/src/components/settings/wizard/Step3Validation.tsx index 355038b92..9f4b25330 100644 --- a/web/src/components/settings/wizard/Step3Validation.tsx +++ b/web/src/components/settings/wizard/Step3Validation.tsx @@ -85,9 +85,9 @@ export default function Step3Validation({ ? `${videoStream.width}x${videoStream.height}` : undefined; - 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; return { @@ -323,7 +323,7 @@ export default function Step3Validation({ )}
- + {stream.url}