From 84ac8d3654a3fd3addc65bbf8ab82160852665e4 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 12 Oct 2025 12:36:46 -0500 Subject: [PATCH] extract logic for friendly_name and use in wizard --- .../components/settings/CameraEditForm.tsx | 19 ++------ .../settings/CameraWizardDialog.tsx | 42 +++++++++-------- web/src/utils/cameraUtil.ts | 47 +++++++++++++++++++ 3 files changed, 74 insertions(+), 34 deletions(-) create mode 100644 web/src/utils/cameraUtil.ts diff --git a/web/src/components/settings/CameraEditForm.tsx b/web/src/components/settings/CameraEditForm.tsx index 983a8167d..d3d56112d 100644 --- a/web/src/components/settings/CameraEditForm.tsx +++ b/web/src/components/settings/CameraEditForm.tsx @@ -22,6 +22,7 @@ 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"; type ConfigSetBody = { requires_restart: number; @@ -30,12 +31,6 @@ type ConfigSetBody = { 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; @@ -168,19 +163,15 @@ export default function CameraEditForm({ 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 { finalCameraName, friendlyName } = processCameraName( + values.cameraName, + ); const configData: ConfigSetBody["config_data"] = { cameras: { [finalCameraName]: { enabled: values.enabled, - ...(friendly_name && { friendly_name }), + ...(friendlyName && { friendly_name: friendlyName }), ffmpeg: { inputs: values.ffmpeg.inputs.map((input) => ({ path: input.path, diff --git a/web/src/components/settings/CameraWizardDialog.tsx b/web/src/components/settings/CameraWizardDialog.tsx index 3085f911b..0838cd99a 100644 --- a/web/src/components/settings/CameraWizardDialog.tsx +++ b/web/src/components/settings/CameraWizardDialog.tsx @@ -19,6 +19,7 @@ import type { CameraConfigData, ConfigSetBody, } from "@/types/cameraWizard"; +import { processCameraName } from "@/utils/cameraUtil"; type WizardState = { wizardData: Partial; @@ -158,12 +159,17 @@ export default function CameraWizardDialog({ setIsLoading(true); + // Process camera name and friendly name + const { finalCameraName, friendlyName } = processCameraName( + wizardData.cameraName, + ); + // Convert wizard data to Frigate config format - const cameraName = wizardData.cameraName; const configData: CameraConfigData = { cameras: { - [cameraName]: { + [finalCameraName]: { enabled: true, + ...(friendlyName && { friendly_name: friendlyName }), ffmpeg: { inputs: wizardData.streams.map((stream, index) => { const isRestreamed = @@ -171,8 +177,8 @@ export default function CameraWizardDialog({ if (isRestreamed) { const go2rtcStreamName = wizardData.streams!.length === 1 - ? cameraName - : `${cameraName}_${index + 1}`; + ? finalCameraName + : `${finalCameraName}_${index + 1}`; return { path: `rtsp://127.0.0.1:8554/${go2rtcStreamName}`, input_args: "preset-rtsp-restream", @@ -190,30 +196,26 @@ export default function CameraWizardDialog({ }, }; - // Add friendly name if different from camera name - if (wizardData.cameraName !== cameraName) { - configData.cameras[cameraName].friendly_name = wizardData.cameraName; - } - // Add live.streams configuration for go2rtc streams if (wizardData.streams && wizardData.streams.length > 0) { - configData.cameras[cameraName].live = { + configData.cameras[finalCameraName].live = { streams: {}, }; wizardData.streams.forEach((_, index) => { const go2rtcStreamName = wizardData.streams!.length === 1 - ? cameraName - : `${cameraName}_${index + 1}`; - configData.cameras[cameraName].live!.streams[`Stream ${index + 1}`] = - go2rtcStreamName; + ? finalCameraName + : `${finalCameraName}_${index + 1}`; + configData.cameras[finalCameraName].live!.streams[ + `Stream ${index + 1}` + ] = go2rtcStreamName; }); } const requestBody: ConfigSetBody = { requires_restart: 1, config_data: configData, - update_topic: `config/cameras/${cameraName}/add`, + update_topic: `config/cameras/${finalCameraName}/add`, }; axios @@ -228,8 +230,8 @@ export default function CameraWizardDialog({ // Use camera name with index suffix for multiple streams const streamName = wizardData.streams!.length === 1 - ? cameraName - : `${cameraName}_${index + 1}`; + ? finalCameraName + : `${finalCameraName}_${index + 1}`; go2rtcStreams[streamName] = [stream.url]; }); @@ -260,7 +262,7 @@ export default function CameraWizardDialog({ Promise.allSettled(updatePromises).then(() => { toast.success( t("cameraWizard.save.successWithLive", { - cameraName: wizardData.cameraName, + cameraName: friendlyName || finalCameraName, }), { position: "top-center" }, ); @@ -272,7 +274,7 @@ export default function CameraWizardDialog({ // log the error but don't fail the entire save toast.warning( t("cameraWizard.save.successWithoutLive", { - cameraName: wizardData.cameraName, + cameraName: friendlyName || finalCameraName, }), { position: "top-center" }, ); @@ -283,7 +285,7 @@ export default function CameraWizardDialog({ // No valid streams found toast.success( t("cameraWizard.save.successWithoutLive", { - cameraName: wizardData.cameraName, + cameraName: friendlyName || finalCameraName, }), { position: "top-center" }, ); diff --git a/web/src/utils/cameraUtil.ts b/web/src/utils/cameraUtil.ts new file mode 100644 index 000000000..f4ad9563a --- /dev/null +++ b/web/src/utils/cameraUtil.ts @@ -0,0 +1,47 @@ +/** + * Generates a fixed-length hash from a camera name for use as a valid camera identifier. + * Used when user enters a display name with spaces or special characters. + * + * @param name - The original camera name/display name + * @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars) + */ +export 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()}`; +}; + +/** + * Checks if a string is a valid camera name identifier. + * Valid camera names contain only letters, numbers, underscores, and hyphens. + * + * @param name - The camera name to validate + * @returns True if the name is valid, false otherwise + */ +export const isValidCameraName = (name: string): boolean => { + return /^[a-zA-Z0-9_-]+$/.test(name); +}; + +/** + * Processes a user-entered camera name and returns both the final camera name + * and friendly name for Frigate configuration. + * + * @param userInput - The name entered by the user (could be display name) + * @returns Object with finalCameraName and friendlyName + */ +export const processCameraName = ( + userInput: string, +): { + finalCameraName: string; + friendlyName?: string; +} => { + if (isValidCameraName(userInput)) { + return { finalCameraName: userInput }; + } else { + return { + finalCameraName: generateFixedHash(userInput), + friendlyName: userInput, + }; + } +};