add custom validation and use it for ffmpeg input roles

This commit is contained in:
Josh Hawkins 2026-02-01 12:45:16 -06:00
parent 718757fc60
commit 09ceff9bd8
5 changed files with 685 additions and 574 deletions

View File

@ -16,5 +16,6 @@
"format": "Invalid format", "format": "Invalid format",
"additionalProperties": "Unknown property is not allowed", "additionalProperties": "Unknown property is not allowed",
"oneOf": "Must match exactly one of the allowed schemas", "oneOf": "Must match exactly one of the allowed schemas",
"anyOf": "Must match at least one of the allowed schemas" "anyOf": "Must match at least one of the allowed schemas",
"ffmpeg.inputs.rolesUnique": "Each role can only be assigned to one input stream."
} }

View File

@ -1,7 +1,7 @@
// ConfigForm - Main RJSF form wrapper component // ConfigForm - Main RJSF form wrapper component
import Form from "@rjsf/shadcn"; import Form from "@rjsf/shadcn";
import validator from "@rjsf/validator-ajv8"; import validator from "@rjsf/validator-ajv8";
import type { RJSFSchema, UiSchema } from "@rjsf/utils"; import type { FormValidation, RJSFSchema, UiSchema } from "@rjsf/utils";
import type { IChangeEvent } from "@rjsf/core"; import type { IChangeEvent } from "@rjsf/core";
import { frigateTheme } from "./theme"; import { frigateTheme } from "./theme";
import { transformSchema } from "@/lib/config-schema"; import { transformSchema } from "@/lib/config-schema";
@ -182,6 +182,11 @@ export interface ConfigFormProps {
formContext?: ConfigFormContext; formContext?: ConfigFormContext;
/** i18n namespace for field labels */ /** i18n namespace for field labels */
i18nNamespace?: string; i18nNamespace?: string;
/** Optional custom validation */
customValidate?: (
formData: unknown,
errors: FormValidation,
) => FormValidation;
} }
export function ConfigForm({ export function ConfigForm({
@ -202,6 +207,7 @@ export function ConfigForm({
liveValidate = true, liveValidate = true,
formContext, formContext,
i18nNamespace, i18nNamespace,
customValidate,
}: ConfigFormProps) { }: ConfigFormProps) {
const { t, i18n } = useTranslation([ const { t, i18n } = useTranslation([
i18nNamespace || "common", i18nNamespace || "common",
@ -319,6 +325,7 @@ export function ConfigForm({
liveValidate={liveValidate} liveValidate={liveValidate}
formContext={extendedFormContext} formContext={extendedFormContext}
transformErrors={errorTransformer} transformErrors={errorTransformer}
customValidate={customValidate}
{...frigateTheme} {...frigateTheme}
/> />
</div> </div>

View File

@ -0,0 +1,47 @@
import type { FormValidation } from "@rjsf/utils";
import type { TFunction } from "i18next";
import { isJsonObject } from "@/lib/utils";
import type { JsonObject } from "@/types/configForm";
export function validateFfmpegInputRoles(
formData: unknown,
errors: FormValidation,
t: TFunction,
): FormValidation {
if (!isJsonObject(formData as JsonObject)) {
return errors;
}
const inputs = (formData as JsonObject).inputs;
if (!Array.isArray(inputs)) {
return errors;
}
const roleCounts = new Map<string, number>();
inputs.forEach((input) => {
if (!isJsonObject(input) || !Array.isArray(input.roles)) {
return;
}
input.roles.forEach((role) => {
if (typeof role !== "string") {
return;
}
roleCounts.set(role, (roleCounts.get(role) || 0) + 1);
});
});
const hasDuplicates = Array.from(roleCounts.values()).some(
(count) => count > 1,
);
if (hasDuplicates) {
const inputsErrors = errors.inputs as {
addError?: (message: string) => void;
};
inputsErrors?.addError?.(
t("ffmpeg.inputs.rolesUnique", { ns: "config/validation" }),
);
}
return errors;
}

View File

@ -0,0 +1,26 @@
import type { FormValidation } from "@rjsf/utils";
import type { TFunction } from "i18next";
import { validateFfmpegInputRoles } from "./ffmpeg";
export type SectionValidation = (
formData: unknown,
errors: FormValidation,
) => FormValidation;
type SectionValidationOptions = {
sectionPath: string;
level: "global" | "camera";
t: TFunction;
};
export function getSectionValidation({
sectionPath,
level,
t,
}: SectionValidationOptions): SectionValidation | undefined {
if (sectionPath === "ffmpeg" && level === "camera") {
return (formData, errors) => validateFfmpegInputRoles(formData, errors, t);
}
return undefined;
}

View File

@ -10,7 +10,8 @@ import sectionRenderers, {
RendererComponent, RendererComponent,
} from "@/components/config-form/sectionExtras/registry"; } from "@/components/config-form/sectionExtras/registry";
import { ConfigForm } from "../ConfigForm"; import { ConfigForm } from "../ConfigForm";
import type { UiSchema } from "@rjsf/utils"; import type { FormValidation, UiSchema } from "@rjsf/utils";
import { getSectionValidation } from "../section-validations";
import { import {
useConfigOverride, useConfigOverride,
normalizeConfigValue, normalizeConfigValue,
@ -56,6 +57,11 @@ export interface SectionConfig {
uiSchema?: UiSchema; uiSchema?: UiSchema;
/** Optional per-section renderers usable by FieldTemplate `ui:before`/`ui:after` */ /** Optional per-section renderers usable by FieldTemplate `ui:before`/`ui:after` */
renderers?: Record<string, RendererComponent>; renderers?: Record<string, RendererComponent>;
/** Optional custom validation for section data */
customValidate?: (
formData: unknown,
errors: FormValidation,
) => FormValidation;
} }
export interface BaseSectionProps { export interface BaseSectionProps {
@ -90,13 +96,6 @@ export interface CreateSectionOptions {
defaultConfig: SectionConfig; defaultConfig: SectionConfig;
} }
/**
* Factory function to create reusable config section components
*/
export function createConfigSection({
sectionPath,
defaultConfig,
}: CreateSectionOptions) {
const cameraUpdateTopicMap: Record<string, string> = { const cameraUpdateTopicMap: Record<string, string> = {
detect: "detect", detect: "detect",
record: "record", record: "record",
@ -119,7 +118,11 @@ export function createConfigSection({
ui: "ui", ui: "ui",
}; };
const ConfigSection = function ConfigSection({ export type ConfigSectionProps = BaseSectionProps & CreateSectionOptions;
export function ConfigSection({
sectionPath,
defaultConfig,
level, level,
cameraName, cameraName,
showOverrideIndicator = true, showOverrideIndicator = true,
@ -131,7 +134,7 @@ export function createConfigSection({
collapsible = false, collapsible = false,
defaultCollapsed = false, defaultCollapsed = false,
showTitle, showTitle,
}: BaseSectionProps) { }: ConfigSectionProps) {
const { t, i18n } = useTranslation([ const { t, i18n } = useTranslation([
level === "camera" ? "config/cameras" : "config/global", level === "camera" ? "config/cameras" : "config/global",
"config/cameras", "config/cameras",
@ -152,7 +155,6 @@ export function createConfigSection({
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}` ? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
: undefined : undefined
: `config/${sectionPath}`; : `config/${sectionPath}`;
// Default: show title for camera level (since it might be collapsible), hide for global // Default: show title for camera level (since it might be collapsible), hide for global
const shouldShowTitle = showTitle ?? level === "camera"; const shouldShowTitle = showTitle ?? level === "camera";
@ -180,7 +182,7 @@ export function createConfigSection({
} }
return get(config, sectionPath) || {}; return get(config, sectionPath) || {};
}, [config, level, cameraName]); }, [config, level, cameraName, sectionPath]);
const sanitizeSectionData = useCallback( const sanitizeSectionData = useCallback(
(data: ConfigSectionData) => { (data: ConfigSectionData) => {
@ -336,7 +338,6 @@ export function createConfigSection({
level === "camera" && cameraName level === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}` ? `cameras.${cameraName}.${sectionPath}`
: sectionPath; : sectionPath;
const rawData = sanitizeSectionData(rawFormData); const rawData = sanitizeSectionData(rawFormData);
const overrides = buildOverrides(pendingData, rawData, schemaDefaults); const overrides = buildOverrides(pendingData, rawData, schemaDefaults);
@ -352,7 +353,6 @@ export function createConfigSection({
// [basePath]: overrides, // [basePath]: overrides,
// }, // },
// }); // });
// log save to console for debugging // log save to console for debugging
console.log("Saved config data:", { console.log("Saved config data:", {
[basePath]: overrides, [basePath]: overrides,
@ -409,6 +409,7 @@ export function createConfigSection({
setIsSaving(false); setIsSaving(false);
} }
}, [ }, [
sectionPath,
pendingData, pendingData,
level, level,
cameraName, cameraName,
@ -431,9 +432,7 @@ export function createConfigSection({
const basePath = const basePath =
level === "camera" && cameraName level === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}` ? `cameras.${cameraName}.${sectionPath}`
: sectionPath; : sectionPath; // const configData = level === "global" ? schemaDefaults : "";
// const configData = level === "global" ? schemaDefaults : "";
// await axios.put("config/set", { // await axios.put("config/set", {
// requires_restart: requiresRestart ? 0 : 1, // requires_restart: requiresRestart ? 0 : 1,
@ -454,7 +453,6 @@ export function createConfigSection({
requires_restart: requiresRestart ? 0 : 1, requires_restart: requiresRestart ? 0 : 1,
}, },
); );
toast.success( toast.success(
t("toast.resetSuccess", { t("toast.resetSuccess", {
ns: "views/settings", ns: "views/settings",
@ -475,7 +473,44 @@ export function createConfigSection({
}), }),
); );
} }
}, [level, cameraName, requiresRestart, t, refreshConfig, updateTopic]); }, [
sectionPath,
level,
cameraName,
requiresRestart,
t,
refreshConfig,
updateTopic,
]);
const sectionValidation = useMemo(
() => getSectionValidation({ sectionPath, level, t }),
[sectionPath, level, t],
);
const customValidate = useMemo(() => {
const validators: Array<
(formData: unknown, errors: FormValidation) => FormValidation
> = [];
if (sectionConfig.customValidate) {
validators.push(sectionConfig.customValidate);
}
if (sectionValidation) {
validators.push(sectionValidation);
}
if (validators.length === 0) {
return undefined;
}
return (formData: unknown, errors: FormValidation) =>
validators.reduce(
(currentErrors, validatorFn) => validatorFn(formData, currentErrors),
errors,
);
}, [sectionConfig.customValidate, sectionValidation]);
if (!sectionSchema) { if (!sectionSchema) {
return null; return null;
@ -519,6 +554,7 @@ export function createConfigSection({
readonly={readonly} readonly={readonly}
showSubmit={false} showSubmit={false}
i18nNamespace={configNamespace} i18nNamespace={configNamespace}
customValidate={customValidate}
formContext={{ formContext={{
level, level,
cameraName, cameraName,
@ -613,8 +649,7 @@ export function createConfigSection({
</Badge> </Badge>
)} )}
</div> </div>
{((level === "camera" && isOverridden) || {((level === "camera" && isOverridden) || level === "global") && (
level === "global") && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -654,9 +689,7 @@ export function createConfigSection({
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Heading as="h4">{title}</Heading> <Heading as="h4">{title}</Heading>
{showOverrideIndicator && {showOverrideIndicator && level === "camera" && isOverridden && (
level === "camera" &&
isOverridden && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{t("overridden", { {t("overridden", {
ns: "common", ns: "common",
@ -725,7 +758,4 @@ export function createConfigSection({
{sectionContent} {sectionContent}
</div> </div>
); );
};
return ConfigSection;
} }