import { Button } from "@/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Card, CardContent } from "@/components/ui/card"; import Heading from "@/components/ui/heading"; import { Separator } from "@/components/ui/separator"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm, useFieldArray } from "react-hook-form"; import { z } from "zod"; import axios from "axios"; import { toast, Toaster } from "sonner"; import { useTranslation } from "react-i18next"; import { useState, useMemo } from "react"; import { LuTrash2, LuPlus } from "react-icons/lu"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import { processCameraName } from "@/utils/cameraUtil"; import { Label } from "@/components/ui/label"; import { ConfigSetBody } from "@/types/cameraWizard"; const RoleEnum = z.enum(["audio", "detect", "record"]); type Role = z.infer; type CameraEditFormProps = { cameraName?: string; onSave?: () => void; onCancel?: () => void; }; export default function CameraEditForm({ cameraName, onSave, onCancel, }: CameraEditFormProps) { const { t } = useTranslation(["views/settings"]); const { data: config } = useSWR("config"); const [isLoading, setIsLoading] = useState(false); const formSchema = useMemo( () => z.object({ cameraName: z .string() .min(1, { message: t("cameraManagement.cameraConfig.nameRequired") }), enabled: z.boolean(), ffmpeg: z.object({ inputs: z .array( z.object({ path: z.string().min(1, { message: t( "cameraManagement.cameraConfig.ffmpeg.pathRequired", ), }), roles: z.array(RoleEnum).min(1, { message: t( "cameraManagement.cameraConfig.ffmpeg.rolesRequired", ), }), }), ) .min(1, { message: t("cameraManagement.cameraConfig.ffmpeg.inputsRequired"), }) .refine( (inputs) => { const roleOccurrences = new Map(); inputs.forEach((input) => { input.roles.forEach((role) => { roleOccurrences.set( role, (roleOccurrences.get(role) || 0) + 1, ); }); }); return Array.from(roleOccurrences.values()).every( (count) => count <= 1, ); }, { message: t("cameraManagement.cameraConfig.ffmpeg.rolesUnique"), path: ["inputs"], }, ), }), go2rtcStreams: z.record(z.string(), z.array(z.string())).optional(), }), [t], ); type FormValues = z.infer; const cameraInfo = useMemo(() => { if (!cameraName || !config?.cameras[cameraName]) { return { friendly_name: undefined, name: cameraName || "", roles: new Set(), go2rtcStreams: {}, }; } const camera = config.cameras[cameraName]; const roles = new Set(); camera.ffmpeg?.inputs?.forEach((input) => { input.roles.forEach((role) => roles.add(role as Role)); }); // Load existing go2rtc streams const go2rtcStreams = config.go2rtc?.streams || {}; return { friendly_name: camera?.friendly_name || cameraName, name: cameraName, roles, go2rtcStreams, }; }, [cameraName, config]); const defaultValues: FormValues = { cameraName: cameraInfo?.friendly_name || cameraName || "", enabled: true, ffmpeg: { inputs: [ { path: "", roles: cameraInfo.roles.has("detect") ? [] : ["detect"], }, ], }, go2rtcStreams: {}, }; // Load existing camera config if editing if (cameraName && config?.cameras[cameraName]) { const camera = config.cameras[cameraName]; defaultValues.enabled = camera.enabled ?? true; defaultValues.ffmpeg.inputs = camera.ffmpeg?.inputs?.length ? camera.ffmpeg.inputs.map((input) => ({ path: input.path, roles: input.roles as Role[], })) : defaultValues.ffmpeg.inputs; const go2rtcStreams = config.go2rtc?.streams || {}; const cameraStreams: Record = {}; // get candidate stream names for this camera. could be the camera's own name, // any restream names referenced by this camera, or any keys under live --> streams const validNames = new Set(); validNames.add(cameraName); // deduce go2rtc stream names from rtsp restream inputs camera.ffmpeg?.inputs?.forEach((input) => { // exclude any query strings or trailing slashes from the stream name const restreamMatch = input.path.match( /^rtsp:\/\/127\.0\.0\.1:8554\/([^?#/]+)(?:[?#].*)?$/, ); if (restreamMatch) { const streamName = restreamMatch[1]; validNames.add(streamName); } }); // Include live --> streams keys const liveStreams = camera?.live?.streams; if (liveStreams) { Object.keys(liveStreams).forEach((key) => { validNames.add(key); }); } // Map only go2rtc entries that match the collected names Object.entries(go2rtcStreams).forEach(([name, urls]) => { if (validNames.has(name)) { cameraStreams[name] = Array.isArray(urls) ? urls : [urls]; } }); defaultValues.go2rtcStreams = cameraStreams; } const form = useForm({ resolver: zodResolver(formSchema), defaultValues, mode: "onChange", }); const { fields, append, remove } = useFieldArray({ control: form.control, name: "ffmpeg.inputs", }); // Watch ffmpeg.inputs to track used roles const watchedInputs = form.watch("ffmpeg.inputs"); // Watch go2rtc streams const watchedGo2rtcStreams = form.watch("go2rtcStreams") || {}; const saveCameraConfig = (values: FormValues) => { setIsLoading(true); const { finalCameraName, friendlyName } = processCameraName( values.cameraName, ); const configData: ConfigSetBody["config_data"] = { cameras: { [finalCameraName]: { enabled: values.enabled, ...(friendlyName && { friendly_name: friendlyName }), ffmpeg: { inputs: values.ffmpeg.inputs.map((input) => ({ path: input.path, roles: input.roles, })), }, }, }, }; // Add go2rtc streams if provided if (values.go2rtcStreams && Object.keys(values.go2rtcStreams).length > 0) { configData.go2rtc = { streams: values.go2rtcStreams, }; } const requestBody: ConfigSetBody = { requires_restart: 1, config_data: configData, }; // Add update_topic for new cameras if (!cameraName) { requestBody.update_topic = `config/cameras/${finalCameraName}/add`; } axios .put("config/set", requestBody) .then((res) => { if (res.status === 200) { // Update running go2rtc instance if streams were configured if ( values.go2rtcStreams && Object.keys(values.go2rtcStreams).length > 0 ) { const updatePromises = Object.entries(values.go2rtcStreams).map( ([streamName, urls]) => axios.put( `go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`, ), ); Promise.allSettled(updatePromises).then(() => { toast.success( t("cameraManagement.cameraConfig.toast.success", { cameraName: values.cameraName, }), { position: "top-center" }, ); if (onSave) onSave(); }); } else { toast.success( t("cameraManagement.cameraConfig.toast.success", { cameraName: values.cameraName, }), { position: "top-center" }, ); if (onSave) onSave(); } } else { throw new Error(res.statusText); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("toast.save.error.title", { errorMessage, ns: "common" }), { position: "top-center" }, ); }) .finally(() => { setIsLoading(false); }); }; const onSubmit = (values: FormValues) => { if ( cameraName && values.cameraName !== cameraName && values.cameraName !== cameraInfo?.friendly_name ) { // If camera name changed, delete old camera config const deleteRequestBody = { requires_restart: 1, config_data: { cameras: { [cameraName]: null, }, }, update_topic: `config/cameras/${cameraName}/remove`, }; axios .put("config/set", deleteRequestBody) .then(() => saveCameraConfig(values)) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("toast.save.error.title", { errorMessage, ns: "common" }), { position: "top-center" }, ); }) .finally(() => { setIsLoading(false); }); } else { saveCameraConfig(values); } }; // Determine available roles for new streams const getAvailableRoles = (): Role[] => { const used = new Set(); watchedInputs.forEach((input) => { input.roles.forEach((role) => used.add(role)); }); return used.has("detect") ? [] : ["detect"]; }; const getUsedRolesExcludingIndex = (excludeIndex: number) => { const roles = new Set(); watchedInputs.forEach((input, idx) => { if (idx !== excludeIndex) { input.roles.forEach((role) => roles.add(role)); } }); return roles; }; return (
{cameraName ? t("cameraManagement.cameraConfig.edit") : t("cameraManagement.cameraConfig.add")}
{t("cameraManagement.cameraConfig.description")}
( {t("cameraManagement.cameraConfig.name")} )} /> ( {t("cameraManagement.cameraConfig.enabled")} )} />
{fields.map((field, index) => (

{t("cameraWizard.step2.streamTitle", { number: index + 1, })}

( {t("cameraManagement.cameraConfig.ffmpeg.path")} )} />
{(["detect", "record", "audio"] as const).map( (role) => { const isUsedElsewhere = getUsedRolesExcludingIndex(index).has(role); const isChecked = watchedInputs[index]?.roles?.includes(role) || false; return (
{role} { const currentRoles = watchedInputs[index]?.roles || []; const updatedRoles = checked ? [...currentRoles, role] : currentRoles.filter((r) => r !== role); form.setValue( `ffmpeg.inputs.${index}.roles`, updatedRoles, ); }} disabled={!isChecked && isUsedElsewhere} />
); }, )}
))} {form.formState.errors.ffmpeg?.inputs?.root && form.formState.errors.ffmpeg.inputs.root.message}
{/* go2rtc Streams Section */} {Object.keys(watchedGo2rtcStreams).length > 0 && (
{Object.entries(watchedGo2rtcStreams).map( ([streamName, urls]) => (

{streamName}

{(Array.isArray(urls) ? urls : [urls]).map( (url, urlIndex) => (
{ const updatedStreams = { ...watchedGo2rtcStreams, }; const currentUrls = Array.isArray( updatedStreams[streamName], ) ? updatedStreams[streamName] : [updatedStreams[streamName]]; currentUrls[urlIndex] = e.target.value; updatedStreams[streamName] = currentUrls; form.setValue( "go2rtcStreams", updatedStreams, ); }} placeholder="rtsp://username:password@host:port/path" /> {(Array.isArray(urls) ? urls : [urls]).length > 1 && ( )}
), )}
), )}
)}
); }