import { Button } from "@/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; 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, FfprobeData, FfprobeResponse, StreamRole, StreamConfig, OnvifProbeResponse, } from "@/types/cameraWizard"; import type { CandidateTestMap } from "@/types/cameraWizard"; import { FaCircleCheck } from "react-icons/fa6"; import { Card, CardContent, CardTitle } from "../../ui/card"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { LuInfo } from "react-icons/lu"; import { detectReolinkCamera } from "@/utils/cameraUtil"; import ProbeDialog from "./ProbeDialog"; type Step1NameCameraProps = { wizardData: Partial; onUpdate: (data: Partial) => void; onNext: (data?: Partial) => void; onCancel: () => void; canProceed?: boolean; }; export default function Step1NameCamera({ wizardData, onUpdate, onNext, onCancel, }: Step1NameCameraProps) { const { t } = useTranslation(["views/settings"]); 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 [probeMode, setProbeMode] = useState(true); const [onvifPort, setOnvifPort] = useState(80); const [isProbing, setIsProbing] = useState(false); const [probeError, setProbeError] = useState(null); const [probeResult, setProbeResult] = useState( null, ); const [probeDialogOpen, setProbeDialogOpen] = useState(false); const [selectedCandidateUris, setSelectedCandidateUris] = useState( [], ); const [candidateTests, setCandidateTests] = useState( {} as CandidateTestMap, ); const [testingCandidates, setTestingCandidates] = useState< Record >({} as Record); const existingCameraNames = useMemo(() => { if (!config?.cameras) { return []; } return Object.keys(config.cameras); }, [config]); const step1FormData = z .object({ cameraName: z .string() .min(1, t("cameraWizard.step1.errors.nameRequired")) .max(64, t("cameraWizard.step1.errors.nameLength")) .refine( (value) => !existingCameraNames.includes(value), t("cameraWizard.step1.errors.nameExists"), ), host: z.string().optional(), username: z.string().optional(), password: z.string().optional(), brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(), customUrl: z .string() .optional() .refine( (val) => !val || val.startsWith("rtsp://"), t("cameraWizard.step1.errors.customUrlRtspRequired"), ), }) .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) : "dahua", customUrl: wizardData.customUrl || "", }, mode: "onChange", }); const watchedBrand = form.watch("brandTemplate"); const watchedHost = form.watch("host"); const watchedCustomUrl = form.watch("customUrl"); const hostPresent = !!(watchedHost && watchedHost.trim()); const customPresent = !!(watchedCustomUrl && watchedCustomUrl.trim()); const cameraNamePresent = !!(form.getValues().cameraName || "").trim(); const isTestButtonEnabled = cameraNamePresent && (probeMode ? hostPresent : watchedBrand === "other" ? customPresent : hostPresent); const generateDynamicStreamUrl = useCallback( async (data: z.infer): Promise => { const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate); if (!brand || !data.host) return null; let protocol = undefined; if (data.brandTemplate === "reolink" && data.username && data.password) { try { protocol = await detectReolinkCamera( data.host, data.username, data.password, ); } catch (error) { return null; } } // Use detected protocol or fallback to rtsp const protocolKey = protocol || "rtsp"; const templates: Record = 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; }, [], ); const generateStreamUrl = useCallback( async (data: z.infer): Promise => { if (data.brandTemplate === "other") { return data.customUrl || ""; } const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate); if (!brand || !data.host) return ""; if (brand.template === "dynamic" && "dynamicTemplates" in brand) { const dynamicUrl = await generateDynamicStreamUrl(data); if (dynamicUrl) { return dynamicUrl; } return ""; } return brand.template .replace("{username}", data.username || "") .replace("{password}", data.password || "") .replace("{host}", data.host); }, [generateDynamicStreamUrl], ); const probeCamera = useCallback(async () => { const data = form.getValues(); if (!data.host) { toast.error(t("cameraWizard.step1.errors.hostRequired")); return; } setIsProbing(true); setProbeError(null); setProbeResult(null); try { const response = await axios.get("/onvif/probe", { params: { host: data.host, port: onvifPort, username: data.username || "", password: data.password || "", test: false, }, timeout: 30000, }); if (response.data && response.data.success) { setProbeResult(response.data); // open the probe dialog to show results setProbeDialogOpen(true); } else { setProbeError(response.data?.message || "Probe failed"); } } catch (error) { const axiosError = error as { response?: { data?: { message?: string; detail?: string } }; message?: string; }; const errorMessage = axiosError.response?.data?.message || axiosError.response?.data?.detail || axiosError.message || "Failed to probe camera"; setProbeError(errorMessage); toast.error(t("cameraWizard.step1.probeFailed", { error: errorMessage })); } finally { setIsProbing(false); } }, [form, onvifPort, t]); const handleSelectCandidate = useCallback((uri: string) => { // toggle selection: add or remove from selectedCandidateUris setSelectedCandidateUris((s) => { if (s.includes(uri)) { return s.filter((u) => u !== uri); } return [...s, uri]; }); }, []); // Probe a single URI and return a TestResult. If fetchSnapshot is true, // also attempt to fetch a snapshot (may be undefined on failure). const probeUri = useCallback( async ( uri: string, fetchSnapshot = false, setStatus?: (s: string) => void, ): Promise => { try { const probeResponse = await axios.get("ffprobe", { params: { paths: uri, detailed: true }, timeout: 10000, }); let probeData: FfprobeResponse | null = null; if ( probeResponse.data && probeResponse.data.length > 0 && probeResponse.data[0].return_code === 0 ) { probeData = probeResponse.data[0]; } if (!probeData) { const error = Array.isArray(probeResponse.data?.[0]?.stderr) && probeResponse.data[0].stderr.length > 0 ? probeResponse.data[0].stderr.join("\n") : "Unable to probe stream"; return { success: false, error }; } // stdout may be a string or structured object. Normalize to FfprobeData. let ffprobeData: FfprobeData; if (typeof probeData.stdout === "string") { try { ffprobeData = JSON.parse(probeData.stdout as string) as FfprobeData; } catch { ffprobeData = { streams: [] }; } } else { ffprobeData = probeData.stdout as FfprobeData; } const streams = ffprobeData.streams || []; const videoStream = streams.find( (s: FfprobeStream) => s.codec_type === "video" || s.codec_name?.includes("h264") || s.codec_name?.includes("hevc"), ); const audioStream = streams.find( (s: FfprobeStream) => s.codec_type === "audio" || s.codec_name?.includes("aac") || s.codec_name?.includes("mp3") || s.codec_name?.includes("pcm_mulaw") || s.codec_name?.includes("pcm_alaw"), ); const resolution = videoStream ? `${videoStream.width}x${videoStream.height}` : undefined; const fps = videoStream?.avg_frame_rate ? parseFloat(videoStream.avg_frame_rate.split("/")[0]) / parseFloat(videoStream.avg_frame_rate.split("/")[1]) : undefined; let snapshotBase64: string | undefined = undefined; if (fetchSnapshot) { if (setStatus) { setStatus(t("cameraWizard.step1.testing.fetchingSnapshot")); } try { const snapshotResponse = await axios.get("ffprobe/snapshot", { params: { url: uri }, responseType: "blob", timeout: 10000, }); const snapshotBlob = snapshotResponse.data; snapshotBase64 = await new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.readAsDataURL(snapshotBlob); }); } catch (snapshotError) { snapshotBase64 = undefined; } } const streamTestResult: TestResult = { success: true, snapshot: snapshotBase64, resolution, videoCodec: videoStream?.codec_name, audioCodec: audioStream?.codec_name, fps: fps && !isNaN(fps) ? fps : undefined, }; return streamTestResult; } catch (err) { const axiosError = err as { response?: { data?: { message?: string; detail?: string } }; message?: string; }; const errorMessage = axiosError.response?.data?.message || axiosError.response?.data?.detail || axiosError.message || "Connection failed"; return { success: false, error: errorMessage }; } }, [t], ); const testAllSelectedCandidates = useCallback(async () => { const uris = selectedCandidateUris; if (!uris || uris.length === 0) { toast.error(t("cameraWizard.commonErrors.noUrl")); return; } setIsTesting(true); setTestStatus(t("cameraWizard.step1.testing.probingMetadata")); const streamConfigs: StreamConfig[] = []; let firstSuccessfulTestResult: TestResult | null = null; let firstSuccessfulUri: string | undefined = undefined; try { for (let i = 0; i < uris.length; i++) { const uri = uris[i]; const streamTestResult = await probeUri(uri, false); if (streamTestResult && streamTestResult.success) { const streamId = `stream_${Date.now()}_${i}`; streamConfigs.push({ id: streamId, url: uri, // Only the first stream should have the detect role roles: streamConfigs.length === 0 ? (["detect"] as StreamRole[]) : ([] as StreamRole[]), testResult: streamTestResult, }); // keep first successful for main pane snapshot if (!firstSuccessfulTestResult) { firstSuccessfulTestResult = streamTestResult; firstSuccessfulUri = uri; } // also store candidate test summary setCandidateTests((s) => ({ ...s, [uri]: streamTestResult })); } else { setCandidateTests((s) => ({ ...s, [uri]: streamTestResult, })); } } if (streamConfigs.length > 0) { // Add all successful streams and navigate to Step 2 onNext({ streams: streamConfigs, customUrl: firstSuccessfulUri }); toast.success(t("cameraWizard.step1.testSuccess")); setProbeDialogOpen(false); setProbeResult(null); } else { toast.error( t("cameraWizard.commonErrors.testFailed", { error: "No streams succeeded", }), ); } } catch (error) { const axiosError = error as { response?: { data?: { message?: string; detail?: string } }; message?: string; }; const errorMessage = axiosError.response?.data?.message || axiosError.response?.data?.detail || axiosError.message || "Connection failed"; setTestResult({ success: false, error: errorMessage, }); toast.error( t("cameraWizard.commonErrors.testFailed", { error: errorMessage }), ); } finally { setIsTesting(false); setTestStatus(""); } }, [selectedCandidateUris, t, onNext, probeUri]); const testCandidate = useCallback( async (uri: string) => { if (!uri) return; setTestingCandidates((s) => ({ ...s, [uri]: true })); try { const result = await probeUri(uri, false); setCandidateTests((s) => ({ ...s, [uri]: result })); } finally { setTestingCandidates((s) => ({ ...s, [uri]: false })); } }, [probeUri], ); const testConnection = useCallback(async () => { const _data = form.getValues(); const streamUrl = await generateStreamUrl(_data); if (!streamUrl) { toast.error(t("cameraWizard.commonErrors.noUrl")); return; } setIsTesting(true); setTestStatus(""); setTestResult(null); try { setTestStatus(t("cameraWizard.step1.testing.probingMetadata")); const result = await probeUri(streamUrl, true, setTestStatus); if (result && result.success) { setTestResult(result); onUpdate({ streams: [{ id: "", url: streamUrl, roles: [], testResult: result }], }); toast.success(t("cameraWizard.step1.testSuccess")); } else { const errMsg = result?.error || "Unable to probe stream"; setTestResult({ success: false, error: errMsg, }); toast.error( t("cameraWizard.commonErrors.testFailed", { error: errMsg }), { duration: 6000, }, ); } } catch (error) { const axiosError = error as { response?: { data?: { message?: string; detail?: string } }; message?: string; }; const errorMessage = axiosError.response?.data?.message || axiosError.response?.data?.detail || axiosError.message || "Connection failed"; setTestResult({ success: false, error: errorMessage, }); toast.error( t("cameraWizard.commonErrors.testFailed", { error: errorMessage }), { duration: 10000, }, ); } finally { setIsTesting(false); setTestStatus(""); } }, [form, generateStreamUrl, t, onUpdate, probeUri]); const onSubmit = (data: z.infer) => { onUpdate(data); }; const handleContinue = useCallback(async () => { const data = form.getValues(); const streamUrl = await generateStreamUrl(data); const streamId = `stream_${Date.now()}`; const streamConfig: StreamConfig = { id: streamId, url: streamUrl, roles: ["detect" as StreamRole], resolution: testResult?.resolution, testResult: testResult || undefined, userTested: false, }; const updatedData = { ...data, streams: [streamConfig], }; onNext(updatedData); }, [form, generateStreamUrl, testResult, onNext]); return (
{!testResult?.success && ( <>
{t("cameraWizard.step1.description")}
( {t("cameraWizard.step1.cameraName")} )} />
( {t("cameraWizard.step1.host")} )} /> ( {t("cameraWizard.step1.username")} )} /> ( {t("cameraWizard.step1.password")}
)} />
{t("cameraWizard.step1.detectionMethod")} { setProbeMode(value === "probe"); setProbeResult(null); setProbeError(null); }} >
{probeMode && ( {t("cameraWizard.step1.onvifPort")} setOnvifPort(parseInt(e.target.value, 10) || 80) } placeholder="80" /> )} {!probeMode && (
(
{t("cameraWizard.step1.cameraBrand")} {field.value && (() => { const selectedBrand = CAMERA_BRANDS.find( (brand) => brand.value === field.value, ); return selectedBrand && selectedBrand.value != "other" ? (

{selectedBrand.label}

{t( "cameraWizard.step1.brandUrlFormat", { exampleUrl: selectedBrand.exampleUrl, }, )}

) : null; })()}
)} /> {watchedBrand == "other" && ( ( {t("cameraWizard.step1.customUrl")} )} /> )}
)} )} {probeMode && probeResult && (
)} {testResult?.success && (
{t("cameraWizard.step1.testSuccess")}
{testResult.snapshot ? (
Camera snapshot
) : ( {t("cameraWizard.step1.streamDetails")} )}
)} {isTesting && (
{testStatus}
)}
{testResult?.success ? ( ) : ( )}
); } function StreamDetails({ testResult }: { testResult: TestResult }) { const { t } = useTranslation(["views/settings"]); return ( <> {testResult.resolution && (
{t("cameraWizard.testResultLabels.resolution")}: {" "} {testResult.resolution}
)} {testResult.fps && (
{t("cameraWizard.testResultLabels.fps")}: {" "} {testResult.fps}
)} {testResult.videoCodec && (
{t("cameraWizard.testResultLabels.video")}: {" "} {testResult.videoCodec}
)} {testResult.audioCodec && (
{t("cameraWizard.testResultLabels.audio")}: {" "} {testResult.audioCodec}
)} ); }