Camera wizard improvements (#20636)

* use avg_frame_rate

* probe metadata and snapshot separately

* improve ffprobe error reporting

* show error messages in toaster
This commit is contained in:
Josh Hawkins 2025-10-23 08:34:52 -05:00 committed by GitHub
parent 0d5cfa2e38
commit 81df534784
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 80 additions and 43 deletions

View File

@ -199,18 +199,29 @@ def ffprobe(request: Request, paths: str = "", detailed: bool = False):
request.app.frigate_config.ffmpeg, path.strip(), detailed=detailed request.app.frigate_config.ffmpeg, path.strip(), detailed=detailed
) )
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 = { result = {
"return_code": ffprobe.returncode, "return_code": ffprobe.returncode,
"stderr": ( "stderr": stderr_lines,
ffprobe.stderr.decode("unicode_escape").strip() "stdout": "",
if ffprobe.returncode != 0 }
else "" else:
), result = {
"stdout": ( "return_code": ffprobe.returncode,
json.loads(ffprobe.stdout.decode("unicode_escape").strip()) "stderr": [],
if ffprobe.returncode == 0 "stdout": json.loads(ffprobe.stdout.decode("unicode_escape").strip()),
else ""
),
} }
# Add detailed metadata if requested and probe was successful # Add detailed metadata if requested and probe was successful

View File

@ -577,7 +577,7 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro
if detailed and format_entries: if detailed and format_entries:
ffprobe_cmd.extend(["-show_entries", f"format={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) return sp.run(ffprobe_cmd, capture_output=True)

View File

@ -16,7 +16,6 @@
"ui": "UI", "ui": "UI",
"enrichments": "Enrichments", "enrichments": "Enrichments",
"cameraManagement": "Management", "cameraManagement": "Management",
"cameraReview": "Review",
"masksAndZones": "Masks / Zones", "masksAndZones": "Masks / Zones",
"motionTuner": "Motion Tuner", "motionTuner": "Motion Tuner",
"triggers": "Triggers", "triggers": "Triggers",
@ -188,6 +187,10 @@
"testSuccess": "Connection test successful!", "testSuccess": "Connection test successful!",
"testFailed": "Connection test failed. Please check your input and try again.", "testFailed": "Connection test failed. Please check your input and try again.",
"streamDetails": "Stream Details", "streamDetails": "Stream Details",
"testing": {
"probingMetadata": "Probing camera metadata...",
"fetchingSnapshot": "Fetching camera snapshot..."
},
"warnings": { "warnings": {
"noSnapshot": "Unable to fetch a snapshot from the configured stream." "noSnapshot": "Unable to fetch a snapshot from the configured stream."
}, },
@ -197,8 +200,9 @@
"nameLength": "Camera name must be 64 characters or less", "nameLength": "Camera name must be 64 characters or less",
"invalidCharacters": "Camera name contains invalid characters", "invalidCharacters": "Camera name contains invalid characters",
"nameExists": "Camera name already exists", "nameExists": "Camera name already exists",
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\". Manual configuration is required for non-RTSP camera streams.",
"brands": { "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": { "docs": {

View File

@ -65,6 +65,7 @@ export default function Step1NameCamera({
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isTesting, setIsTesting] = useState(false); const [isTesting, setIsTesting] = useState(false);
const [testStatus, setTestStatus] = useState<string>("");
const [testResult, setTestResult] = useState<TestResult | null>(null); const [testResult, setTestResult] = useState<TestResult | null>(null);
const existingCameraNames = useMemo(() => { const existingCameraNames = useMemo(() => {
@ -88,7 +89,13 @@ export default function Step1NameCamera({
username: z.string().optional(), username: z.string().optional(),
password: z.string().optional(), password: z.string().optional(),
brandTemplate: z.enum(CAMERA_BRAND_VALUES).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( .refine(
(data) => { (data) => {
@ -204,24 +211,17 @@ export default function Step1NameCamera({
} }
setIsTesting(true); setIsTesting(true);
setTestStatus("");
setTestResult(null); setTestResult(null);
try {
// First get probe data for metadata // First get probe data for metadata
const probePromise = axios.get("ffprobe", { setTestStatus(t("cameraWizard.step1.testing.probingMetadata"));
const probeResponse = await axios.get("ffprobe", {
params: { paths: streamUrl, detailed: true }, params: { paths: streamUrl, detailed: true },
timeout: 10000, 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;
let probeData = null; let probeData = null;
if ( if (
probeResponse.data && probeResponse.data &&
@ -234,8 +234,13 @@ export default function Step1NameCamera({
// Then get snapshot for preview (only if probe succeeded) // Then get snapshot for preview (only if probe succeeded)
let snapshotBlob = null; let snapshotBlob = null;
if (probeData) { if (probeData) {
setTestStatus(t("cameraWizard.step1.testing.fetchingSnapshot"));
try { try {
const snapshotResponse = await snapshotPromise; const snapshotResponse = await axios.get("ffprobe/snapshot", {
params: { url: streamUrl },
responseType: "blob",
timeout: 10000,
});
snapshotBlob = snapshotResponse.data; snapshotBlob = snapshotResponse.data;
} catch (snapshotError) { } catch (snapshotError) {
// Snapshot is optional, don't fail if it doesn't work // Snapshot is optional, don't fail if it doesn't work
@ -295,12 +300,18 @@ export default function Step1NameCamera({
setTestResult(testResult); setTestResult(testResult);
toast.success(t("cameraWizard.step1.testSuccess")); toast.success(t("cameraWizard.step1.testSuccess"));
} else { } 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({ setTestResult({
success: false, success: false,
error: error, error: error,
}); });
toast.error(t("cameraWizard.commonErrors.testFailed", { error })); toast.error(t("cameraWizard.commonErrors.testFailed", { error }), {
duration: 6000,
});
} }
} catch (error) { } catch (error) {
const axiosError = error as { const axiosError = error as {
@ -318,9 +329,13 @@ export default function Step1NameCamera({
}); });
toast.error( toast.error(
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }), t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
{
duration: 10000,
},
); );
} finally { } finally {
setIsTesting(false); setIsTesting(false);
setTestStatus("");
} }
}, [form, generateStreamUrl, t]); }, [form, generateStreamUrl, t]);
@ -610,7 +625,9 @@ export default function Step1NameCamera({
className="flex items-center justify-center gap-2 sm:flex-1" className="flex items-center justify-center gap-2 sm:flex-1"
> >
{isTesting && <ActivityIndicator className="size-4" />} {isTesting && <ActivityIndicator className="size-4" />}
{t("cameraWizard.step1.testConnection")} {isTesting && testStatus
? testStatus
: t("cameraWizard.step1.testConnection")}
</Button> </Button>
)} )}
</div> </div>

View File

@ -151,9 +151,9 @@ export default function Step2StreamConfig({
? `${videoStream.width}x${videoStream.height}` ? `${videoStream.width}x${videoStream.height}`
: undefined; : undefined;
const fps = videoStream?.r_frame_rate const fps = videoStream?.avg_frame_rate
? parseFloat(videoStream.r_frame_rate.split("/")[0]) / ? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
parseFloat(videoStream.r_frame_rate.split("/")[1]) parseFloat(videoStream.avg_frame_rate.split("/")[1])
: undefined; : undefined;
const testResult: TestResult = { const testResult: TestResult = {

View File

@ -85,9 +85,9 @@ export default function Step3Validation({
? `${videoStream.width}x${videoStream.height}` ? `${videoStream.width}x${videoStream.height}`
: undefined; : undefined;
const fps = videoStream?.r_frame_rate const fps = videoStream?.avg_frame_rate
? parseFloat(videoStream.r_frame_rate.split("/")[0]) / ? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
parseFloat(videoStream.r_frame_rate.split("/")[1]) parseFloat(videoStream.avg_frame_rate.split("/")[1])
: undefined; : undefined;
return { return {
@ -323,7 +323,7 @@ export default function Step3Validation({
)} )}
<div className="mb-2 flex flex-col justify-between gap-1 md:flex-row md:items-center"> <div className="mb-2 flex flex-col justify-between gap-1 md:flex-row md:items-center">
<span className="text-sm text-muted-foreground"> <span className="break-all text-sm text-muted-foreground">
{stream.url} {stream.url}
</span> </span>
<Button <Button

View File

@ -66,8 +66,13 @@ export default function CameraManagementView({
return ( return (
<> <>
<Toaster
richColors
className="z-[1000]"
position="top-center"
closeButton
/>
<div className="flex size-full flex-col md:flex-row"> <div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none"> <div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
{viewMode === "settings" ? ( {viewMode === "settings" ? (
<> <>