mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-19 22:58:22 +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",
|
"retention": "Retention",
|
||||||
"events": "Events"
|
"events": "Events"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ffmpeg": {
|
||||||
|
"cameras": {
|
||||||
|
"cameraFfmpeg": "Camera-specific FFmpeg arguments"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -159,6 +159,9 @@ const ffmpeg: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
|
fieldGroups: {
|
||||||
|
cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"],
|
||||||
|
},
|
||||||
restartRequired: [
|
restartRequired: [
|
||||||
"inputs",
|
"inputs",
|
||||||
"path",
|
"path",
|
||||||
|
|||||||
@ -24,6 +24,11 @@ import {
|
|||||||
LuTrash2,
|
LuTrash2,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
import type { ConfigFormContext } from "@/types/configForm";
|
import type { ConfigFormContext } from "@/types/configForm";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
type FfmpegInput = {
|
type FfmpegInput = {
|
||||||
path?: string;
|
path?: string;
|
||||||
@ -351,6 +356,7 @@ export function CameraInputsField(props: FieldProps) {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{renderField(index, "path", {
|
{renderField(index, "path", {
|
||||||
extraUiSchema: {
|
extraUiSchema: {
|
||||||
|
"ui:widget": "CameraPathWidget",
|
||||||
"ui:options": {
|
"ui:options": {
|
||||||
size: "full",
|
size: "full",
|
||||||
splitLayout: false,
|
splitLayout: false,
|
||||||
@ -377,16 +383,23 @@ export function CameraInputsField(props: FieldProps) {
|
|||||||
{renderField(index, "output_args")}
|
{renderField(index, "output_args")}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Tooltip>
|
||||||
type="button"
|
<TooltipTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
type="button"
|
||||||
onClick={() => handleRemoveInput(index)}
|
variant="ghost"
|
||||||
disabled={disabled || readonly}
|
size="icon"
|
||||||
aria-label={t("button.delete", { ns: "common" })}
|
onClick={() => handleRemoveInput(index)}
|
||||||
>
|
disabled={disabled || readonly}
|
||||||
<LuTrash2 className="h-4 w-4" />
|
aria-label={t("button.delete", { ns: "common" })}
|
||||||
</Button>
|
>
|
||||||
|
<LuTrash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{t("button.delete", { ns: "common" })}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget";
|
|||||||
import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
|
import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
|
||||||
import { InputRolesWidget } from "./widgets/InputRolesWidget";
|
import { InputRolesWidget } from "./widgets/InputRolesWidget";
|
||||||
import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
|
import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
|
||||||
|
import { CameraPathWidget } from "./widgets/CameraPathWidget";
|
||||||
|
|
||||||
import { FieldTemplate } from "./templates/FieldTemplate";
|
import { FieldTemplate } from "./templates/FieldTemplate";
|
||||||
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
||||||
@ -57,6 +58,7 @@ export const frigateTheme: FrigateTheme = {
|
|||||||
CheckboxWidget: SwitchWidget,
|
CheckboxWidget: SwitchWidget,
|
||||||
ArrayAsTextWidget: ArrayAsTextWidget,
|
ArrayAsTextWidget: ArrayAsTextWidget,
|
||||||
FfmpegArgsWidget: FfmpegArgsWidget,
|
FfmpegArgsWidget: FfmpegArgsWidget,
|
||||||
|
CameraPathWidget: CameraPathWidget,
|
||||||
inputRoles: InputRolesWidget,
|
inputRoles: InputRolesWidget,
|
||||||
// Custom widgets
|
// Custom widgets
|
||||||
switch: SwitchWidget,
|
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 { cn } from "@/lib/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LuRefreshCcw } from "react-icons/lu";
|
import { LuRefreshCcw } from "react-icons/lu";
|
||||||
|
import { Tooltip, TooltipContent } from "../ui/tooltip";
|
||||||
|
import { TooltipTrigger } from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
type RestartRequiredIndicatorProps = {
|
type RestartRequiredIndicatorProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -18,15 +20,19 @@ export default function RestartRequiredIndicator({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<Tooltip>
|
||||||
className={cn(
|
<TooltipTrigger>
|
||||||
"inline-flex items-center text-muted-foreground",
|
<span
|
||||||
className,
|
className={cn(
|
||||||
)}
|
"inline-flex cursor-default items-center text-muted-foreground",
|
||||||
title={restartRequiredLabel}
|
className,
|
||||||
aria-label={restartRequiredLabel}
|
)}
|
||||||
>
|
aria-label={restartRequiredLabel}
|
||||||
<LuRefreshCcw className={cn("size-3", iconClassName)} />
|
>
|
||||||
</span>
|
<LuRefreshCcw className={cn("size-3", iconClassName)} />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{restartRequiredLabel}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user