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 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"; type ConfigSetBody = { requires_restart: number; // TODO: type this better // eslint-disable-next-line @typescript-eslint/no-explicit-any config_data: any; update_topic?: string; }; const generateFixedHash = (name: string): string => { const encoded = encodeURIComponent(name); const base64 = btoa(encoded); const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8); return `cam_${cleanHash.toLowerCase()}`; }; 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("camera.cameraConfig.nameRequired") }), enabled: z.boolean(), ffmpeg: z.object({ inputs: z .array( z.object({ path: z.string().min(1, { message: t("camera.cameraConfig.ffmpeg.pathRequired"), }), roles: z.array(RoleEnum).min(1, { message: t("camera.cameraConfig.ffmpeg.rolesRequired"), }), }), ) .min(1, { message: t("camera.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("camera.cameraConfig.ffmpeg.rolesUnique"), path: ["inputs"], }, ), }), }), [t], ); type FormValues = z.infer; const cameraInfo = useMemo(() => { if (!cameraName || !config?.cameras[cameraName]) { return { friendly_name: undefined, name: cameraName || "", roles: new Set(), }; } const camera = config.cameras[cameraName]; const roles = new Set(); camera.ffmpeg?.inputs?.forEach((input) => { input.roles.forEach((role) => roles.add(role as Role)); }); return { friendly_name: camera?.friendly_name || cameraName, name: cameraName, roles, }; }, [cameraName, config]); const defaultValues: FormValues = { cameraName: cameraInfo?.friendly_name || cameraName || "", enabled: true, ffmpeg: { inputs: [ { path: "", roles: cameraInfo.roles.has("detect") ? [] : ["detect"], }, ], }, }; // 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 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"); const saveCameraConfig = (values: FormValues) => { setIsLoading(true); let finalCameraName = values.cameraName; let friendly_name: string | undefined = undefined; const isValidName = /^[a-zA-Z0-9_-]+$/.test(values.cameraName); if (!isValidName) { finalCameraName = generateFixedHash(finalCameraName); friendly_name = values.cameraName; } const configData: ConfigSetBody["config_data"] = { cameras: { [finalCameraName]: { enabled: values.enabled, ...(friendly_name && { friendly_name }), ffmpeg: { inputs: values.ffmpeg.inputs.map((input) => ({ path: input.path, roles: input.roles, })), }, }, }, }; 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) { toast.success( t("camera.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: ConfigSetBody = { requires_restart: 1, config_data: { cameras: { [cameraName]: "", }, }, 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("camera.cameraConfig.edit") : t("camera.cameraConfig.add")}
{t("camera.cameraConfig.description")}
( {t("camera.cameraConfig.name")} )} /> ( {t("camera.cameraConfig.enabled")} )} />
{t("camera.cameraConfig.ffmpeg.inputs")} {fields.map((field, index) => (
( {t("camera.cameraConfig.ffmpeg.path")} )} /> ( {t("camera.cameraConfig.ffmpeg.roles")}
{(["audio", "detect", "record"] as const).map( (role) => ( ), )}
)} />
))} {form.formState.errors.ffmpeg?.inputs?.root && form.formState.errors.ffmpeg.inputs.root.message}
); }