diff --git a/web/public/locales/en/config/groups.json b/web/public/locales/en/config/groups.json index 581b77d0d..1663ad169 100644 --- a/web/public/locales/en/config/groups.json +++ b/web/public/locales/en/config/groups.json @@ -64,5 +64,10 @@ "retention": "Retention", "events": "Events" } + }, + "ffmpeg": { + "cameras": { + "cameraFfmpeg": "Camera-specific FFmpeg arguments" + } } } diff --git a/web/src/components/config-form/section-configs/ffmpeg.ts b/web/src/components/config-form/section-configs/ffmpeg.ts index cada4bd45..ccbca5609 100644 --- a/web/src/components/config-form/section-configs/ffmpeg.ts +++ b/web/src/components/config-form/section-configs/ffmpeg.ts @@ -159,6 +159,9 @@ const ffmpeg: SectionConfigOverrides = { }, }, camera: { + fieldGroups: { + cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"], + }, restartRequired: [ "inputs", "path", diff --git a/web/src/components/config-form/theme/fields/CameraInputsField.tsx b/web/src/components/config-form/theme/fields/CameraInputsField.tsx index 375aef3bd..ee19dbc95 100644 --- a/web/src/components/config-form/theme/fields/CameraInputsField.tsx +++ b/web/src/components/config-form/theme/fields/CameraInputsField.tsx @@ -24,6 +24,11 @@ import { LuTrash2, } from "react-icons/lu"; import type { ConfigFormContext } from "@/types/configForm"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; type FfmpegInput = { path?: string; @@ -351,6 +356,7 @@ export function CameraInputsField(props: FieldProps) {
{renderField(index, "path", { extraUiSchema: { + "ui:widget": "CameraPathWidget", "ui:options": { size: "full", splitLayout: false, @@ -377,16 +383,23 @@ export function CameraInputsField(props: FieldProps) { {renderField(index, "output_args")}
- + + + + + + {t("button.delete", { ns: "common" })} + +
diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index 1b40dd813..3baa2f3ad 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -25,6 +25,7 @@ import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget"; import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget"; import { InputRolesWidget } from "./widgets/InputRolesWidget"; import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget"; +import { CameraPathWidget } from "./widgets/CameraPathWidget"; import { FieldTemplate } from "./templates/FieldTemplate"; import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate"; @@ -57,6 +58,7 @@ export const frigateTheme: FrigateTheme = { CheckboxWidget: SwitchWidget, ArrayAsTextWidget: ArrayAsTextWidget, FfmpegArgsWidget: FfmpegArgsWidget, + CameraPathWidget: CameraPathWidget, inputRoles: InputRolesWidget, // Custom widgets switch: SwitchWidget, diff --git a/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx b/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx new file mode 100644 index 000000000..b2490d2ab --- /dev/null +++ b/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx @@ -0,0 +1,202 @@ +import type { WidgetProps } from "@rjsf/utils"; +import useSWR from "swr"; +import { useMemo, useState, type FocusEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { LuEye, LuEyeOff } from "react-icons/lu"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { ConfigFormContext } from "@/types/configForm"; +import { cn } from "@/lib/utils"; +import { getSizedFieldClassName } from "../utils"; + +type RawPathsResponse = { + cameras?: Record< + string, + { + ffmpeg?: { + inputs?: Array<{ + path?: string; + }>; + }; + } + >; +}; + +const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i; +const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i; + +const getInputIndexFromWidgetId = (id: string): number | undefined => { + const match = id.match(/_inputs_(\d+)_path$/); + if (!match) { + return undefined; + } + + const index = Number(match[1]); + return Number.isNaN(index) ? undefined : index; +}; + +const isMaskedPath = (value: string): boolean => + MASKED_AUTH_PATTERN.test(value) || MASKED_QUERY_PATTERN.test(value); + +const hasCredentials = (value: string): boolean => { + if (!value) { + return false; + } + + if (isMaskedPath(value)) { + return true; + } + + try { + const parsed = new URL(value); + if (parsed.username || parsed.password) { + return true; + } + + return ( + parsed.searchParams.has("user") && parsed.searchParams.has("password") + ); + } catch { + return /:\/\/[^:@/\s]+:[^@/\s]+@/.test(value); + } +}; + +const maskCredentials = (value: string): string => { + if (!value) { + return value; + } + + const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@"); + + return maskedAuth + .replace(/([?&]user=)[^&]*/gi, "$1*") + .replace(/([?&]password=)[^&]*/gi, "$1*"); +}; + +export function CameraPathWidget(props: WidgetProps) { + const { + id, + value, + disabled, + readonly, + onChange, + onBlur, + onFocus, + placeholder, + schema, + options, + } = props; + + const { t } = useTranslation(["common", "views/settings"]); + const [showCredentials, setShowCredentials] = useState(false); + + const formContext = props.registry?.formContext as + | ConfigFormContext + | undefined; + const isCameraLevel = formContext?.level === "camera"; + const cameraName = formContext?.cameraName; + const inputIndex = useMemo(() => getInputIndexFromWidgetId(id), [id]); + + const shouldFetchRawPaths = + isCameraLevel && !!cameraName && inputIndex !== undefined; + const { data: rawPaths } = useSWR( + shouldFetchRawPaths ? "config/raw_paths" : null, + ); + + const rawPath = useMemo(() => { + if (!cameraName || inputIndex === undefined) { + return undefined; + } + + const path = + rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs?.[inputIndex]?.path; + return typeof path === "string" ? path : undefined; + }, [cameraName, inputIndex, rawPaths]); + + const rawValue = typeof value === "string" ? value : ""; + const resolvedValue = + isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? ""); + const canReveal = + hasCredentials(resolvedValue) && !isMaskedPath(resolvedValue); + const canToggle = canReveal || isMaskedPath(rawValue); + + const isMaskedView = canToggle && !showCredentials; + const displayValue = isMaskedView + ? maskCredentials(resolvedValue) + : resolvedValue; + + const isNullable = Array.isArray(schema.type) + ? schema.type.includes("null") + : false; + + const fieldClassName = getSizedFieldClassName(options, "xs"); + const uriLabel = t("cameraWizard.step3.url", { + ns: "views/settings", + defaultValue: schema.title, + }); + const toggleLabel = showCredentials + ? t("label.hide", { ns: "common", item: uriLabel }) + : t("label.show", { ns: "common", item: uriLabel }); + + const handleFocus = (event: FocusEvent) => { + if (isMaskedView && canReveal) { + setShowCredentials(true); + onFocus(id, resolvedValue); + return; + } + + onFocus(id, event.target.value); + }; + + const handleBlur = (event: FocusEvent) => { + if (canToggle) { + setShowCredentials(false); + } + + onBlur(id, event.target.value); + }; + + return ( +
+ + onChange( + e.target.value === "" + ? isNullable + ? null + : undefined + : e.target.value, + ) + } + onBlur={handleBlur} + onFocus={handleFocus} + aria-label={schema.title} + /> + + {canToggle ? ( + + ) : null} +
+ ); +} diff --git a/web/src/components/indicators/RestartRequiredIndicator.tsx b/web/src/components/indicators/RestartRequiredIndicator.tsx index 3268599ea..f2f71c05c 100644 --- a/web/src/components/indicators/RestartRequiredIndicator.tsx +++ b/web/src/components/indicators/RestartRequiredIndicator.tsx @@ -1,6 +1,8 @@ import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; import { LuRefreshCcw } from "react-icons/lu"; +import { Tooltip, TooltipContent } from "../ui/tooltip"; +import { TooltipTrigger } from "@radix-ui/react-tooltip"; type RestartRequiredIndicatorProps = { className?: string; @@ -18,15 +20,19 @@ export default function RestartRequiredIndicator({ }); return ( - - - + + + + + + + {restartRequiredLabel} + ); }