diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 912d4f693..fe797ad28 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -143,6 +143,43 @@ "error": "Failed to save config changes: {{errorMessage}}" } }, + "cameraWizard": { + "title": "Add Camera", + "description": "Follow the steps below to add a new camera to your Frigate installation.", + "steps": { + "nameAndConnection": "Name & Connection", + "streamConfiguration": "Stream Configuration", + "validationAndTesting": "Validation & Testing" + }, + "step1": { + "description": "Enter your camera details and test the connection.", + "cameraName": "Camera Name", + "cameraNamePlaceholder": "e.g., front_door", + "host": "Host/IP Address", + "port": "Port", + "username": "Username", + "usernamePlaceholder": "Optional", + "password": "Password", + "passwordPlaceholder": "Optional", + "selectTransport": "Select transport protocol", + "cameraBrand": "Camera Brand", + "selectBrand": "Select camera brand for URL template", + "customUrl": "Custom Stream URL", + "customUrlPlaceholder": "rtsp://username:password@host:port/path", + "testConnection": "Test Connection", + "testSuccess": "Connection test successful!", + "testFailed": "Connection test failed. Please check your input and try again.", + "errors": { + "noUrl": "Please provide a valid stream URL", + "testFailed": "Connection test failed: {{error}}", + "brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL" + } + }, + "save": { + "successWithLive": "Camera {{cameraName}} saved successfully with live streaming configured.", + "successWithoutLive": "Camera {{cameraName}} saved successfully, but live streaming configuration failed." + } + }, "camera": { "title": "Camera Settings", "streams": { diff --git a/web/src/components/settings/Step1NameCamera.tsx b/web/src/components/settings/Step1NameCamera.tsx new file mode 100644 index 000000000..af0d6d8f0 --- /dev/null +++ b/web/src/components/settings/Step1NameCamera.tsx @@ -0,0 +1,462 @@ +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useTranslation } from "react-i18next"; +import { useState, useCallback, useMemo } from "react"; +import { LuEye, LuEyeOff } from "react-icons/lu"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import axios from "axios"; +import { toast } from "sonner"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { + WizardFormData, + CameraBrand, + CAMERA_BRANDS, + CAMERA_BRAND_VALUES, + TestResult, + FfprobeStream, +} from "@/types/cameraWizard"; + +type Step1NameCameraProps = { + wizardData: Partial; + onUpdate: (data: Partial) => void; + onCancel: () => void; +}; + +export default function Step1NameCamera({ + wizardData, + onUpdate, + onCancel, +}: Step1NameCameraProps) { + const { t } = useTranslation(["views/settings"]); + const { data: config } = useSWR("config"); + const [showPassword, setShowPassword] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [testResult, setTestResult] = useState(null); + + const existingCameraNames = useMemo(() => { + if (!config?.cameras) { + return []; + } + return Object.keys(config.cameras); + }, [config]); + + const step1FormData = z + .object({ + cameraName: z + .string() + .min(1, "Camera name is required") + .max(64, "Camera name must be 64 characters or less") + .regex(/^[a-zA-Z0-9\s_-]+$/, "Camera name contains invalid characters") + .refine( + (value) => !existingCameraNames.includes(value), + "Camera name already exists", + ), + host: z.string().optional(), + username: z.string().optional(), + password: z.string().optional(), + brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(), + customUrl: z.string().optional(), + }) + .refine( + (data) => { + // If brand is "other", customUrl is required + if (data.brandTemplate === "other") { + return data.customUrl && data.customUrl.trim().length > 0; + } + // If brand is not "other", host is required + return data.host && data.host.trim().length > 0; + }, + { + message: t("cameraWizard.step1.errors.brandOrCustomUrlRequired"), + path: ["customUrl"], + }, + ); + + const form = useForm>({ + resolver: zodResolver(step1FormData), + defaultValues: { + cameraName: wizardData.cameraName || "", + host: wizardData.host || "", + username: wizardData.username || "", + password: wizardData.password || "", + brandTemplate: + wizardData.brandTemplate && + CAMERA_BRAND_VALUES.includes(wizardData.brandTemplate as CameraBrand) + ? (wizardData.brandTemplate as CameraBrand) + : "hikvision", + customUrl: wizardData.customUrl || "", + }, + mode: "onChange", + }); + + const watchedBrand = form.watch("brandTemplate"); + const watchedHost = form.watch("host"); + const watchedCustomUrl = form.watch("customUrl"); + + const isTestButtonEnabled = + watchedBrand === "other" + ? !!(watchedCustomUrl && watchedCustomUrl.trim()) + : !!(watchedHost && watchedHost.trim()); + + const generateStreamUrl = useCallback( + (data: z.infer): string => { + if (data.brandTemplate === "other") { + return data.customUrl || ""; + } + + const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate); + if (!brand || !data.host) return ""; + + return brand.template + .replace("{username}", data.username || "") + .replace("{password}", data.password || "") + .replace("{host}", data.host); + }, + [], + ); + + const testConnection = useCallback(async () => { + const data = form.getValues(); + const streamUrl = generateStreamUrl(data); + + if (!streamUrl) { + toast.error(t("cameraWizard.step1.errors.noUrl")); + return; + } + + setIsTesting(true); + setTestResult(null); + + await axios + .get("ffprobe", { + params: { paths: streamUrl, detailed: true }, + timeout: 10000, + }) + .then((response) => { + if ( + response.data && + response.data.length > 0 && + response.data[0].return_code === 0 + ) { + const probeData = response.data[0]; + const ffprobeData = probeData.stdout; + const streams = ffprobeData.streams || []; + + // Extract video stream info + const videoStream = streams.find( + (s: FfprobeStream) => + s.codec_type === "video" || + s.codec_name?.includes("h264") || + s.codec_name?.includes("h265"), + ); + const audioStream = streams.find( + (s: FfprobeStream) => + s.codec_type === "audio" || + s.codec_name?.includes("aac") || + s.codec_name?.includes("mp3"), + ); + + // Calculate resolution + const resolution = videoStream + ? `${videoStream.width}x${videoStream.height}` + : undefined; + + // Extract FPS from rational (e.g., "15/1" -> 15) + const fps = videoStream?.r_frame_rate + ? parseFloat(videoStream.r_frame_rate.split("/")[0]) / + parseFloat(videoStream.r_frame_rate.split("/")[1]) + : undefined; + + const testResult: TestResult = { + success: true, + resolution, + videoCodec: videoStream?.codec_name, + audioCodec: audioStream?.codec_name, + fps: fps && !isNaN(fps) ? fps : undefined, + }; + + setTestResult(testResult); + toast.success(t("cameraWizard.step1.testSuccess")); + + // Auto-populate stream if successful + const streamId = `stream_${Date.now()}`; + onUpdate({ + ...data, + streams: [ + { + id: streamId, + url: streamUrl, + roles: ["detect"], + resolution: testResult.resolution, + testResult, + }, + ], + }); + } else { + const error = response.data?.[0]?.stderr || "Unknown error"; + setTestResult({ + success: false, + error: error, + }); + toast.error(t("cameraWizard.step1.testFailed", { error })); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Connection failed"; + setTestResult({ + success: false, + error: errorMessage, + }); + toast.error( + t("cameraWizard.step1.testFailed", { error: errorMessage }), + ); + }) + .finally(() => { + setIsTesting(false); + }); + }, [form, generateStreamUrl, onUpdate, t]); + + const onSubmit = (data: z.infer) => { + onUpdate(data); + }; + + return ( +
+
+ {t("cameraWizard.step1.description")} +
+ +
+ + ( + + {t("cameraWizard.step1.cameraName")} + + + + + + )} + /> + + ( + + {t("cameraWizard.step1.cameraBrand")} + + + {field.value && + (() => { + const selectedBrand = CAMERA_BRANDS.find( + (brand) => brand.value === field.value, + ); + return selectedBrand ? ( + +
+ {selectedBrand.exampleUrl} +
+
+ ) : null; + })()} +
+ )} + /> + + {watchedBrand !== "other" && ( + <> + ( + + {t("cameraWizard.step1.host")} + + + + + + )} + /> + + ( + + {t("cameraWizard.step1.username")} + + + + + + )} + /> + + ( + + {t("cameraWizard.step1.password")} + +
+ + +
+
+ +
+ )} + /> + + )} + + {watchedBrand == "other" && ( + ( + + {t("cameraWizard.step1.customUrl")} + + + + + + )} + /> + )} + + {testResult && ( +
+
+ {testResult.success + ? t("cameraWizard.step1.testSuccess") + : t("cameraWizard.step1.testFailed")} +
+ {testResult.success ? ( +
+ {testResult.resolution && ( +
Resolution: {testResult.resolution}
+ )} + {testResult.videoCodec && ( +
Video: {testResult.videoCodec}
+ )} + {testResult.audioCodec && ( +
Audio: {testResult.audioCodec}
+ )} + {testResult.fps &&
FPS: {testResult.fps}
} +
+ ) : ( +
+ {testResult.error} +
+ )} +
+ )} + +
+ + +
+ + +
+ ); +}