mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +03:00
misc tweaks to ffmpeg section
- add raw paths endpoint to ensure credentials get saved - restart required tooltip
This commit is contained in:
parent
ef665a8c3d
commit
1ec2cd4468
@ -64,5 +64,10 @@
|
||||
"retention": "Retention",
|
||||
"events": "Events"
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"cameras": {
|
||||
"cameraFfmpeg": "Camera-specific FFmpeg arguments"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -159,6 +159,9 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
camera: {
|
||||
fieldGroups: {
|
||||
cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"],
|
||||
},
|
||||
restartRequired: [
|
||||
"inputs",
|
||||
"path",
|
||||
|
||||
@ -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) {
|
||||
<div className="w-full">
|
||||
{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")}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveInput(index)}
|
||||
disabled={disabled || readonly}
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveInput(index)}
|
||||
disabled={disabled || readonly}
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<RawPathsResponse>(
|
||||
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<HTMLInputElement>) => {
|
||||
if (isMaskedView && canReveal) {
|
||||
setShowCredentials(true);
|
||||
onFocus(id, resolvedValue);
|
||||
return;
|
||||
}
|
||||
|
||||
onFocus(id, event.target.value);
|
||||
};
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
|
||||
if (canToggle) {
|
||||
setShowCredentials(false);
|
||||
}
|
||||
|
||||
onBlur(id, event.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("relative", fieldClassName)}>
|
||||
<Input
|
||||
id={id}
|
||||
className={cn("text-md", canToggle ? "pr-10" : undefined)}
|
||||
type="text"
|
||||
value={displayValue}
|
||||
disabled={disabled || readonly}
|
||||
placeholder={placeholder || (options.placeholder as string) || ""}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
e.target.value === ""
|
||||
? isNullable
|
||||
? null
|
||||
: undefined
|
||||
: e.target.value,
|
||||
)
|
||||
}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
aria-label={schema.title}
|
||||
/>
|
||||
|
||||
{canToggle ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => setShowCredentials((previous) => !previous)}
|
||||
disabled={disabled || (!canReveal && !showCredentials)}
|
||||
aria-label={toggleLabel}
|
||||
title={toggleLabel}
|
||||
>
|
||||
{showCredentials ? (
|
||||
<LuEyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<LuEye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
title={restartRequiredLabel}
|
||||
aria-label={restartRequiredLabel}
|
||||
>
|
||||
<LuRefreshCcw className={cn("size-3", iconClassName)} />
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex cursor-default items-center text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
aria-label={restartRequiredLabel}
|
||||
>
|
||||
<LuRefreshCcw className={cn("size-3", iconClassName)} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{restartRequiredLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user