mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 10:56:52 +03:00
Cameras can now add lower quality live streams that go2rtc transcodes to H.264 on demand. `live.transcode` takes a source stream and a list of heights and bitrates, and each quality becomes a `{camera}_transcode_{height}p` stream. The config validator adds them to `live.streams` without moving any the user already placed, and drops them when transcoding is disabled or a height changes. `create_config.py` writes them into go2rtc's generated config at startup, and saving the config or deleting a camera syncs them through go2rtc's API, so no restart is needed. They use `#hardware`, so go2rtc picks a hardware encoder and falls back to the CPU when there isn't one.
The Live playback settings stream list can be reordered by drag, since its order is the auto ladder. Auto order sorts it by bitrate, measuring native streams through a new admin-only `/go2rtc/streams/{name}/bitrate` endpoint and using the configured bitrate for transcoded ones. A pure reorder wasn't saved before because RJSF, the settings form, and `update_yaml` all ignore map key order. Sections can now mark a map with `orderedMaps`, which sends the whole map with `replace_paths` so `config_set` rewrites it in order.
Transcoded streams aren't in `go2rtc.streams`, which only lists yaml streams, so the frontend treated them as not restreamed and fell back to jsmpeg. Every restream check now goes through `isRestreamedStream`.
Auto treated any stall with no bytes in the last 2 seconds as a dead camera and handed it to the error fallback, which went straight to jsmpeg. Heavy congestion can stop delivery completely, so congested viewers skipped every lower stream. Auto now declines only when stats show the camera offline, and a stall on a live camera steps down. The stream picker also has a Try highest quality button that sends auto back to the top stream.
957 lines
29 KiB
TypeScript
957 lines
29 KiB
TypeScript
// Shared config save utilities.
|
|
//
|
|
// Provides the core per-section save logic (buildOverrides, sanitize, restart
|
|
// detection, update-topic resolution) used by both the individual per-section
|
|
// Save button in BaseSection and the global "Save All" coordinator in Settings.
|
|
|
|
import get from "lodash/get";
|
|
import cloneDeep from "lodash/cloneDeep";
|
|
import unset from "lodash/unset";
|
|
import isEqual from "lodash/isEqual";
|
|
import mergeWith from "lodash/mergeWith";
|
|
import set from "lodash/set";
|
|
import { isJsonObject } from "@/lib/utils";
|
|
import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const";
|
|
import { applySchemaDefaults } from "@/lib/config-schema";
|
|
import { applyConfiguredToggles } from "@/utils/runtimeOverrides";
|
|
import { normalizeConfigValue } from "@/hooks/use-config-override";
|
|
import {
|
|
modifySchemaForSection,
|
|
getEffectiveDefaultsForSection,
|
|
sanitizeOverridesForSection,
|
|
} from "@/components/config-form/sections/section-special-cases";
|
|
import type { RJSFSchema } from "@rjsf/utils";
|
|
import type { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
|
import type {
|
|
ConfigSectionData,
|
|
HiddenFieldContext,
|
|
JsonObject,
|
|
JsonValue,
|
|
} from "@/types/configForm";
|
|
import type { SectionConfig } from "../components/config-form/sections/BaseSection";
|
|
import { sectionConfigs } from "../components/config-form/sectionConfigs";
|
|
|
|
/**
|
|
* Recursively strip any key whose value is the redaction sentinel from a
|
|
* config_data payload. Use just before sending to /config/set so untouched
|
|
* credential placeholder fields don't clobber the saved YAML value. Mutates
|
|
* and returns the input.
|
|
*/
|
|
export function stripRedactedCredentials<T>(value: T): T {
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
stripRedactedCredentials(item);
|
|
}
|
|
return value;
|
|
}
|
|
if (value && typeof value === "object") {
|
|
const obj = value as Record<string, unknown>;
|
|
for (const key of Object.keys(obj)) {
|
|
const v = obj[key];
|
|
if (v === REDACTED_CREDENTIAL_SENTINEL) {
|
|
delete obj[key];
|
|
} else if (v && typeof v === "object") {
|
|
stripRedactedCredentials(v);
|
|
}
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// cameraUpdateTopicMap — maps config section paths to MQTT/WS update topics
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const cameraUpdateTopicMap: Record<string, string> = {
|
|
detect: "detect",
|
|
record: "record",
|
|
snapshots: "snapshots",
|
|
motion: "motion",
|
|
objects: "objects",
|
|
review: "review",
|
|
audio: "audio",
|
|
notifications: "notifications",
|
|
live: "live",
|
|
timestamp_style: "timestamp_style",
|
|
audio_transcription: "audio_transcription",
|
|
birdseye: "birdseye",
|
|
face_recognition: "face_recognition",
|
|
ffmpeg: "ffmpeg",
|
|
lpr: "lpr",
|
|
semantic_search: "semantic_search",
|
|
mqtt: "mqtt",
|
|
onvif: "onvif",
|
|
ui: "ui",
|
|
zones: "zones",
|
|
};
|
|
|
|
// Sections where global config serves as the default for per-camera config.
|
|
// Global updates to these sections are fanned out to all cameras via wildcard.
|
|
export const globalCameraDefaultSections = new Set([
|
|
"detect",
|
|
"objects",
|
|
"motion",
|
|
"record",
|
|
"snapshots",
|
|
"review",
|
|
"audio",
|
|
"notifications",
|
|
"ffmpeg",
|
|
]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Profile helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get the saved-config value for a camera section, which is what the settings
|
|
* form edits.
|
|
*
|
|
* Two things move the top-level (effective) value away from yaml. A profile
|
|
* merges its overrides into it, and the API then populates `base_config` with
|
|
* the originals. Runtime toggles from the live view, MQTT, or Home Assistant
|
|
* change it in place, and `applyConfiguredToggles` puts those fields back.
|
|
*/
|
|
export function getBaseCameraSectionValue(
|
|
config: FrigateConfig | undefined,
|
|
cameraName: string | undefined,
|
|
sectionPath: string,
|
|
): unknown {
|
|
if (!config || !cameraName) return undefined;
|
|
const cam = config.cameras?.[cameraName];
|
|
if (!cam) return undefined;
|
|
const base = cam.base_config?.[sectionPath];
|
|
return applyConfiguredToggles(
|
|
cam,
|
|
sectionPath,
|
|
base !== undefined ? base : get(cam, sectionPath),
|
|
);
|
|
}
|
|
|
|
// mergeWith customizer that replaces arrays wholesale instead of merging them
|
|
// positionally by index. Used when the source value is meant to fully replace
|
|
// the destination (e.g. profile overrides, section config overrides), so an
|
|
// empty source array correctly clears the destination array.
|
|
const replaceArraysCustomizer = (objValue: unknown, srcValue: unknown) => {
|
|
if (Array.isArray(objValue) || Array.isArray(srcValue)) {
|
|
return srcValue !== undefined ? srcValue : objValue;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
// Merge profile overrides on top of base config values. Matches the backend's
|
|
// deep_merge(overrides, base_data) semantics: arrays are replaced wholesale by
|
|
// the profile's value rather than merged positionally, so an empty array in a
|
|
// profile clears the base array instead of leaving stale entries behind.
|
|
export function mergeProfileOverrides<T extends object>(
|
|
baseValue: T,
|
|
profileOverrides: object,
|
|
): T {
|
|
return mergeWith(
|
|
cloneDeep(baseValue),
|
|
cloneDeep(profileOverrides),
|
|
replaceArraysCustomizer,
|
|
) as T;
|
|
}
|
|
|
|
/** Sections that can appear inside a camera profile definition. */
|
|
export const PROFILE_ELIGIBLE_SECTIONS = new Set([
|
|
"audio",
|
|
"birdseye",
|
|
"detect",
|
|
"face_recognition",
|
|
"lpr",
|
|
"motion",
|
|
"notifications",
|
|
"objects",
|
|
"record",
|
|
"review",
|
|
"snapshots",
|
|
]);
|
|
|
|
/**
|
|
* Parse a section path that may encode a profile reference.
|
|
*
|
|
* Examples:
|
|
* "detect" → { isProfile: false, actualSection: "detect" }
|
|
* "profiles.armed.detect" → { isProfile: true, profileName: "armed", actualSection: "detect" }
|
|
*/
|
|
export function parseProfileFromSectionPath(sectionPath: string): {
|
|
isProfile: boolean;
|
|
profileName?: string;
|
|
actualSection: string;
|
|
} {
|
|
const match = sectionPath.match(/^profiles\.([^.]+)\.(.+)$/);
|
|
if (match) {
|
|
return { isProfile: true, profileName: match[1], actualSection: match[2] };
|
|
}
|
|
return { isProfile: false, actualSection: sectionPath };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// buildOverrides — pure recursive diff of current vs stored config & defaults
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Recursively compare `current` (pending form data) against `base` (persisted
|
|
// config) and `defaults` (schema defaults) to produce a minimal overrides
|
|
// payload.
|
|
//
|
|
// - Returns `undefined` when the value matches `base` (or `defaults` when
|
|
// `base` is absent), indicating no override is needed.
|
|
// - For objects, recurses per-key; deleted keys (present in `base` but absent
|
|
// in `current`) are represented as `""`.
|
|
// - For arrays, returns the full array when it differs.
|
|
|
|
export function buildOverrides(
|
|
current: unknown,
|
|
base: unknown,
|
|
defaults: unknown,
|
|
): unknown | undefined {
|
|
if (current === null || current === undefined || current === "") {
|
|
return undefined;
|
|
}
|
|
|
|
if (Array.isArray(current)) {
|
|
if (
|
|
current.length === 0 &&
|
|
(base === undefined || base === null) &&
|
|
(defaults === undefined || defaults === null)
|
|
) {
|
|
return undefined;
|
|
}
|
|
if (
|
|
(base === undefined &&
|
|
defaults !== undefined &&
|
|
isEqual(current, defaults)) ||
|
|
isEqual(current, base)
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
// Same-length arrays get compared element by element rather than by
|
|
// identity, so an item carrying an explicit null where the base simply
|
|
// omits the key does not read as a change. `/api/config` serializes with
|
|
// exclude_none, so every nullable field a form materializes would
|
|
// otherwise look edited the moment the page opens.
|
|
if (Array.isArray(base) && base.length === current.length) {
|
|
const baseItems = base;
|
|
const defaultItems = Array.isArray(defaults) ? defaults : undefined;
|
|
const unchanged = current.every(
|
|
(item, index) =>
|
|
buildOverrides(item, baseItems[index], defaultItems?.[index]) ===
|
|
undefined,
|
|
);
|
|
|
|
if (unchanged) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
if (isJsonObject(current)) {
|
|
const currentObj = current;
|
|
const baseObj = isJsonObject(base) ? base : undefined;
|
|
const defaultsObj = isJsonObject(defaults) ? defaults : undefined;
|
|
|
|
const result: JsonObject = {};
|
|
for (const [key, value] of Object.entries(currentObj)) {
|
|
if (
|
|
(value === undefined || value === null) &&
|
|
baseObj &&
|
|
baseObj[key] !== undefined &&
|
|
baseObj[key] !== null
|
|
) {
|
|
result[key] = "";
|
|
continue;
|
|
}
|
|
const overrideValue = buildOverrides(
|
|
value,
|
|
baseObj ? baseObj[key] : undefined,
|
|
defaultsObj ? defaultsObj[key] : undefined,
|
|
);
|
|
if (overrideValue !== undefined) {
|
|
result[key] = overrideValue as JsonValue;
|
|
}
|
|
}
|
|
|
|
if (baseObj) {
|
|
for (const [key, baseValue] of Object.entries(baseObj)) {
|
|
if (Object.prototype.hasOwnProperty.call(currentObj, key)) {
|
|
continue;
|
|
}
|
|
if (baseValue === undefined) {
|
|
continue;
|
|
}
|
|
result[key] = "";
|
|
}
|
|
}
|
|
|
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
}
|
|
|
|
if (
|
|
base === undefined &&
|
|
defaults !== undefined &&
|
|
isEqual(current, defaults)
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
if (isEqual(current, base)) {
|
|
return undefined;
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ordered maps: config maps whose key order is meaningful
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// lodash isEqual ignores key order, so ordered maps compare entry lists.
|
|
export function changedOrderedMapPaths(
|
|
current: unknown,
|
|
base: unknown,
|
|
paths: string[],
|
|
): string[] {
|
|
return paths.filter((path) => {
|
|
const value = get(current, path);
|
|
|
|
if (!isJsonObject(value)) {
|
|
return false;
|
|
}
|
|
|
|
const baseValue = get(base, path);
|
|
return !isEqual(
|
|
Object.entries(value),
|
|
isJsonObject(baseValue) ? Object.entries(baseValue) : undefined,
|
|
);
|
|
});
|
|
}
|
|
|
|
// Send a changed ordered map whole; per-key overrides cannot carry order.
|
|
export function applyOrderedMaps(
|
|
overrides: unknown,
|
|
current: unknown,
|
|
base: unknown,
|
|
paths: string[],
|
|
): unknown {
|
|
const changed = changedOrderedMapPaths(current, base, paths);
|
|
|
|
if (changed.length === 0) {
|
|
return overrides;
|
|
}
|
|
|
|
const result = isJsonObject(overrides) ? cloneDeep(overrides) : {};
|
|
changed.forEach((path) => set(result, path, cloneDeep(get(current, path))));
|
|
return result;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// flattenOverrides — turn an overrides object into a list of leaf paths
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Walks a nested overrides value and produces a flat list of `{ path, value }`
|
|
// entries, one per leaf. Used by save/preview UIs to enumerate the individual
|
|
// fields that will be changed.
|
|
export function flattenOverrides(
|
|
value: JsonValue | undefined,
|
|
path: string[] = [],
|
|
): Array<{ path: string; value: JsonValue }> {
|
|
if (value === undefined) return [];
|
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
return [{ path: path.join("."), value }];
|
|
}
|
|
|
|
const entries = Object.entries(value);
|
|
if (entries.length === 0) {
|
|
return [{ path: path.join("."), value: {} }];
|
|
}
|
|
|
|
return entries.flatMap(([key, entryValue]) =>
|
|
flattenOverrides(entryValue, [...path, key]),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// sanitizeSectionData — normalize config values and strip hidden fields
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Normalize raw config data (strip internal fields) and remove any paths
|
|
// listed in `hiddenFields` so they are not included in override computation.
|
|
|
|
// lodash `unset` treats `*` as a literal key. This helper expands wildcard
|
|
// segments so that e.g. `"filters.*.mask"` unsets `filters.<each key>.mask`.
|
|
export function unsetWithWildcard(
|
|
obj: Record<string, unknown>,
|
|
path: string,
|
|
): void {
|
|
if (!path.includes("*")) {
|
|
unset(obj, path);
|
|
return;
|
|
}
|
|
const segments = path.split(".");
|
|
const starIndex = segments.indexOf("*");
|
|
const prefix = segments.slice(0, starIndex).join(".");
|
|
const suffix = segments.slice(starIndex + 1).join(".");
|
|
const parent = prefix ? get(obj, prefix) : obj;
|
|
if (parent && typeof parent === "object") {
|
|
for (const key of Object.keys(parent as Record<string, unknown>)) {
|
|
const fullPath = suffix ? `${key}.${suffix}` : key;
|
|
unsetWithWildcard(parent as Record<string, unknown>, fullPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function sanitizeSectionData(
|
|
data: ConfigSectionData,
|
|
hiddenFields?: string[],
|
|
): ConfigSectionData {
|
|
const normalized = normalizeConfigValue(data) as ConfigSectionData;
|
|
if (!hiddenFields || hiddenFields.length === 0) {
|
|
return normalized;
|
|
}
|
|
const cleaned = cloneDeep(normalized) as ConfigSectionData;
|
|
hiddenFields.forEach((path) => {
|
|
if (!path) return;
|
|
unsetWithWildcard(cleaned as Record<string, unknown>, path);
|
|
});
|
|
return cleaned;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// buildConfigDataForPath — convert dotted path to nested config_data payload
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Converts a dotted path (e.g. "cameras.front_door.detect") and a value into
|
|
// a properly nested config_data object (e.g. { cameras: { front_door: { detect: value } } }).
|
|
// This ensures the backend's flatten_config_data function can correctly distinguish
|
|
// between path separators (dots in the path) and literal dots in keys
|
|
// (e.g. "frigate.foo.bar" in logger.logs).
|
|
export function buildConfigDataForPath(
|
|
path: string,
|
|
value: unknown,
|
|
): Record<string, unknown> {
|
|
const configData: Record<string, unknown> = {};
|
|
set(configData, path, value);
|
|
return configData;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// requiresRestartForOverrides — determine whether a restart is needed
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Check whether the given overrides include fields that require a Frigate
|
|
// restart. When `restartRequired` is `undefined` the caller's default is
|
|
// used; an empty array means "never restart"; otherwise the function checks
|
|
// if any of the listed field paths are present in the overrides object.
|
|
|
|
function hasMatchAtPath(value: unknown, pathSegments: string[]): boolean {
|
|
if (pathSegments.length === 0) {
|
|
return value !== undefined;
|
|
}
|
|
|
|
if (value === undefined || value === null) {
|
|
return false;
|
|
}
|
|
|
|
const [segment, ...rest] = pathSegments;
|
|
|
|
if (segment === "*") {
|
|
if (Array.isArray(value)) {
|
|
return value.some((item) => hasMatchAtPath(item, rest));
|
|
}
|
|
|
|
if (isJsonObject(value)) {
|
|
return Object.values(value).some((item) => hasMatchAtPath(item, rest));
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
const index = Number(segment);
|
|
if (!Number.isInteger(index)) {
|
|
return false;
|
|
}
|
|
return hasMatchAtPath(value[index], rest);
|
|
}
|
|
|
|
if (isJsonObject(value)) {
|
|
return hasMatchAtPath(value[segment], rest);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export function requiresRestartForOverrides(
|
|
overrides: unknown,
|
|
restartRequired: string[] | undefined,
|
|
defaultRequiresRestart: boolean = true,
|
|
): boolean {
|
|
if (restartRequired === undefined) {
|
|
return defaultRequiresRestart;
|
|
}
|
|
if (restartRequired.length === 0) {
|
|
return false;
|
|
}
|
|
if (!overrides || typeof overrides !== "object") {
|
|
return false;
|
|
}
|
|
return restartRequired.some((path) => {
|
|
if (!path) {
|
|
return false;
|
|
}
|
|
|
|
if (!path.includes("*")) {
|
|
return get(overrides as JsonObject, path) !== undefined;
|
|
}
|
|
|
|
return hasMatchAtPath(overrides, path.split("."));
|
|
});
|
|
}
|
|
|
|
export function requiresRestartForFieldPath(
|
|
fieldPath: Array<string | number>,
|
|
restartRequired: string[] | undefined,
|
|
defaultRequiresRestart: boolean = true,
|
|
): boolean {
|
|
if (restartRequired === undefined) {
|
|
return defaultRequiresRestart;
|
|
}
|
|
|
|
if (restartRequired.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
if (fieldPath.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const probe: Record<string, unknown> = {};
|
|
set(
|
|
probe,
|
|
fieldPath.map((segment) => String(segment)),
|
|
true,
|
|
);
|
|
|
|
return requiresRestartForOverrides(
|
|
probe,
|
|
restartRequired,
|
|
defaultRequiresRestart,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SectionSavePayload — data produced by prepareSectionSavePayload
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Ready-to-PUT payload for a single config section.
|
|
|
|
export interface SectionSavePayload {
|
|
basePath: string;
|
|
sanitizedOverrides: JsonObject;
|
|
updateTopic: string | undefined;
|
|
needsRestart: boolean;
|
|
pendingDataKey: string;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// extractSectionSchema — resolve a section schema from the full config schema
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { resolveAndCleanSchema } from "@/lib/config-schema";
|
|
import { getAllAttributes } from "@/utils/modelUtil";
|
|
|
|
type SchemaWithDefinitions = RJSFSchema & {
|
|
$defs?: Record<string, RJSFSchema>;
|
|
definitions?: Record<string, RJSFSchema>;
|
|
properties?: Record<string, RJSFSchema>;
|
|
};
|
|
|
|
function getSchemaDefinitions(schema: RJSFSchema): Record<string, RJSFSchema> {
|
|
return (
|
|
(schema as SchemaWithDefinitions).$defs ||
|
|
(schema as SchemaWithDefinitions).definitions ||
|
|
{}
|
|
);
|
|
}
|
|
|
|
function extractSectionSchema(
|
|
schema: RJSFSchema,
|
|
sectionPath: string,
|
|
level: "global" | "camera",
|
|
): RJSFSchema | null {
|
|
const defs = getSchemaDefinitions(schema);
|
|
const schemaObj = schema as SchemaWithDefinitions;
|
|
let sectionDef: RJSFSchema | null = null;
|
|
|
|
if (level === "camera") {
|
|
const cameraConfigDef = defs.CameraConfig;
|
|
if (cameraConfigDef?.properties) {
|
|
const sectionProp = cameraConfigDef.properties[sectionPath];
|
|
if (sectionProp && typeof sectionProp === "object") {
|
|
if ("$ref" in sectionProp && typeof sectionProp.$ref === "string") {
|
|
const refPath = sectionProp.$ref
|
|
.replace(/^#\/\$defs\//, "")
|
|
.replace(/^#\/definitions\//, "");
|
|
sectionDef = defs[refPath] || null;
|
|
} else {
|
|
sectionDef = sectionProp;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if (schemaObj.properties) {
|
|
const sectionProp = schemaObj.properties[sectionPath];
|
|
if (sectionProp && typeof sectionProp === "object") {
|
|
if ("$ref" in sectionProp && typeof sectionProp.$ref === "string") {
|
|
const refPath = sectionProp.$ref
|
|
.replace(/^#\/\$defs\//, "")
|
|
.replace(/^#\/definitions\//, "");
|
|
sectionDef = defs[refPath] || null;
|
|
} else {
|
|
sectionDef = sectionProp;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!sectionDef) return null;
|
|
|
|
const schemaWithDefs: RJSFSchema = { ...sectionDef, $defs: defs };
|
|
return resolveAndCleanSchema(schemaWithDefs);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// prepareSectionSavePayload — build the PUT payload for a single section
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Given a pending-data key (e.g. `"detect"` or `"front_door::detect"`), its
|
|
// dirty form data, the current stored config, and the full JSON Schema,
|
|
// produce a `SectionSavePayload` that can be sent directly to
|
|
// `PUT config/set`. Returns `null` when there are no effective overrides.
|
|
|
|
export function prepareSectionSavePayload(opts: {
|
|
pendingDataKey: string;
|
|
pendingData: unknown;
|
|
config: FrigateConfig;
|
|
fullSchema: RJSFSchema;
|
|
}): SectionSavePayload | null {
|
|
const { pendingDataKey, pendingData, config, fullSchema } = opts;
|
|
|
|
if (!pendingData) return null;
|
|
|
|
// Parse pendingDataKey → sectionPath, level, cameraName
|
|
let sectionPath: string;
|
|
let level: "global" | "camera";
|
|
let cameraName: string | undefined;
|
|
|
|
if (pendingDataKey.includes("::")) {
|
|
const idx = pendingDataKey.indexOf("::");
|
|
cameraName = pendingDataKey.slice(0, idx);
|
|
sectionPath = pendingDataKey.slice(idx + 2);
|
|
level = "camera";
|
|
} else {
|
|
sectionPath = pendingDataKey;
|
|
level = "global";
|
|
}
|
|
|
|
// Detect profile-encoded section paths (e.g., "profiles.armed.detect")
|
|
const profileInfo = parseProfileFromSectionPath(sectionPath);
|
|
const schemaSection = profileInfo.actualSection;
|
|
|
|
// Resolve section config using the actual section name (not the profile path)
|
|
const sectionConfig = getSectionConfig(schemaSection, level);
|
|
|
|
// Resolve section schema using the actual section name
|
|
const sectionSchema = extractSectionSchema(fullSchema, schemaSection, level);
|
|
if (!sectionSchema) return null;
|
|
|
|
const modifiedSchema = modifySchemaForSection(
|
|
schemaSection,
|
|
level,
|
|
sectionSchema,
|
|
config
|
|
? {
|
|
fullConfig: config,
|
|
fullCameraConfig:
|
|
level === "camera" && cameraName
|
|
? config.cameras?.[cameraName]
|
|
: undefined,
|
|
level,
|
|
cameraName,
|
|
}
|
|
: undefined,
|
|
);
|
|
|
|
// Compute rawFormData (the current stored value for this section)
|
|
// For profiles, merge base camera config with profile overrides (matching
|
|
// what BaseSection displays in the form) so the diff only contains actual
|
|
// user changes, not every field from the merged view.
|
|
let rawSectionValue: unknown;
|
|
if (level === "camera" && cameraName) {
|
|
if (profileInfo.isProfile) {
|
|
const baseValue = getBaseCameraSectionValue(
|
|
config,
|
|
cameraName,
|
|
profileInfo.actualSection,
|
|
);
|
|
const profileOverrides = get(config.cameras?.[cameraName], sectionPath);
|
|
if (
|
|
profileOverrides &&
|
|
typeof profileOverrides === "object" &&
|
|
baseValue &&
|
|
typeof baseValue === "object"
|
|
) {
|
|
rawSectionValue = mergeProfileOverrides(
|
|
baseValue as object,
|
|
profileOverrides as object,
|
|
);
|
|
} else {
|
|
rawSectionValue = baseValue;
|
|
}
|
|
} else {
|
|
// Use base (pre-profile) value so the diff matches what the form shows
|
|
rawSectionValue = getBaseCameraSectionValue(
|
|
config,
|
|
cameraName,
|
|
sectionPath,
|
|
);
|
|
}
|
|
} else {
|
|
rawSectionValue = get(config, sectionPath);
|
|
}
|
|
const rawFormData =
|
|
rawSectionValue === undefined || rawSectionValue === null
|
|
? {}
|
|
: rawSectionValue;
|
|
|
|
// For profile sections, also hide restart-required fields to match
|
|
// effectiveHiddenFields in BaseSection (prevents spurious deletion markers
|
|
// for fields that are hidden from the form during profile editing).
|
|
const resolvedHidden = resolveHiddenFieldEntries(sectionConfig.hiddenFields, {
|
|
fullConfig: config,
|
|
fullCameraConfig:
|
|
level === "camera" && cameraName
|
|
? config.cameras?.[cameraName]
|
|
: undefined,
|
|
level,
|
|
cameraName,
|
|
formData: rawFormData as ConfigSectionData,
|
|
});
|
|
const hiddenFieldsForSanitize =
|
|
profileInfo.isProfile && sectionConfig.restartRequired?.length
|
|
? [...new Set([...resolvedHidden, ...sectionConfig.restartRequired])]
|
|
: resolvedHidden;
|
|
|
|
// Sanitize raw form data
|
|
const rawData = sanitizeSectionData(
|
|
rawFormData as ConfigSectionData,
|
|
hiddenFieldsForSanitize,
|
|
);
|
|
|
|
// Compute schema defaults
|
|
const schemaDefaults = modifiedSchema
|
|
? applySchemaDefaults(modifiedSchema, {})
|
|
: {};
|
|
const effectiveDefaults = getEffectiveDefaultsForSection(
|
|
schemaSection,
|
|
level,
|
|
modifiedSchema ?? undefined,
|
|
schemaDefaults,
|
|
);
|
|
|
|
// Build overrides
|
|
const overrides = buildOverrides(pendingData, rawData, effectiveDefaults);
|
|
const sanitizedOverrides = sanitizeOverridesForSection(
|
|
schemaSection,
|
|
level,
|
|
overrides,
|
|
);
|
|
|
|
if (
|
|
!sanitizedOverrides ||
|
|
typeof sanitizedOverrides !== "object" ||
|
|
Object.keys(sanitizedOverrides as JsonObject).length === 0
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// Compute basePath
|
|
const basePath =
|
|
level === "camera" && cameraName
|
|
? `cameras.${cameraName}.${sectionPath}`
|
|
: sectionPath;
|
|
|
|
// Compute updateTopic — profile definitions don't trigger hot-reload
|
|
let updateTopic: string | undefined;
|
|
if (profileInfo.isProfile) {
|
|
updateTopic = undefined;
|
|
} else if (level === "camera" && cameraName) {
|
|
const topic = cameraUpdateTopicMap[sectionPath];
|
|
updateTopic = topic ? `config/cameras/${cameraName}/${topic}` : undefined;
|
|
} else if (globalCameraDefaultSections.has(sectionPath)) {
|
|
const topic = cameraUpdateTopicMap[sectionPath];
|
|
updateTopic = topic ? `config/cameras/*/${topic}` : `config/${sectionPath}`;
|
|
} else {
|
|
updateTopic = `config/${sectionPath}`;
|
|
}
|
|
|
|
// Restart detection — profile definitions never need restart
|
|
const needsRestart = profileInfo.isProfile
|
|
? false
|
|
: requiresRestartForOverrides(
|
|
sanitizedOverrides,
|
|
sectionConfig.restartRequired,
|
|
true,
|
|
);
|
|
|
|
return {
|
|
basePath,
|
|
sanitizedOverrides: sanitizedOverrides as JsonObject,
|
|
updateTopic,
|
|
needsRestart,
|
|
pendingDataKey,
|
|
};
|
|
}
|
|
|
|
const mergeSectionConfig = (
|
|
base: SectionConfig | undefined,
|
|
overrides: Partial<SectionConfig> | undefined,
|
|
): SectionConfig =>
|
|
mergeWith({}, base ?? {}, overrides ?? {}, (objValue, srcValue, key) => {
|
|
const arrayResult = replaceArraysCustomizer(objValue, srcValue);
|
|
if (arrayResult !== undefined) return arrayResult;
|
|
|
|
if (key === "uiSchema") {
|
|
if (objValue && srcValue) {
|
|
return mergeWith({}, objValue, srcValue, replaceArraysCustomizer);
|
|
}
|
|
return srcValue ?? objValue;
|
|
}
|
|
|
|
return undefined;
|
|
});
|
|
|
|
export function getSectionConfig(
|
|
sectionKey: string,
|
|
level: "global" | "camera" | "replay",
|
|
): SectionConfig {
|
|
const entry = sectionConfigs[sectionKey];
|
|
if (!entry) {
|
|
return {};
|
|
}
|
|
|
|
const overrides =
|
|
level === "global"
|
|
? entry.global
|
|
: level === "replay"
|
|
? entry.replay
|
|
: entry.camera;
|
|
return mergeSectionConfig(entry.base, overrides);
|
|
}
|
|
|
|
/**
|
|
* Resolve the effective attribute label set at a given scope. At camera
|
|
* (and replay) scope on a dedicated LPR camera (`camera.type === "lpr"`),
|
|
* `license_plate` is treated as a regular tracked object — not an
|
|
* attribute — to match the backend's per-camera carve-out in
|
|
* `frigate/video/detect.py`. Returns the full attribute list at global
|
|
* scope and for non-LPR cameras.
|
|
*/
|
|
export function getEffectiveAttributeLabels(
|
|
fullConfig: FrigateConfig | undefined,
|
|
fullCameraConfig: CameraConfig | undefined,
|
|
level: "global" | "camera" | "replay" | undefined,
|
|
): string[] {
|
|
const all = getAllAttributes(fullConfig);
|
|
if (level !== "global" && fullCameraConfig?.type === "lpr") {
|
|
return all.filter((attr) => attr !== "license_plate");
|
|
}
|
|
return all;
|
|
}
|
|
|
|
/**
|
|
* Build a `HiddenFieldContext` for the common case where a callsite has
|
|
* `config`, an optional `cameraName`, and a level, but no per-section
|
|
* saved form data to thread through. Resolvers that don't read `formData`
|
|
* (which is most of them) just fall through to `fullCameraConfig` /
|
|
* `fullConfig`.
|
|
*/
|
|
export function buildHiddenFieldContext(
|
|
config: FrigateConfig | undefined,
|
|
level: "global" | "camera" | "replay",
|
|
cameraName?: string,
|
|
): HiddenFieldContext | undefined {
|
|
if (!config) return undefined;
|
|
return {
|
|
fullConfig: config,
|
|
fullCameraConfig:
|
|
level !== "global" && cameraName
|
|
? config.cameras?.[cameraName]
|
|
: undefined,
|
|
level,
|
|
cameraName,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve the effective hidden-field patterns for a section. Each entry in
|
|
* `hiddenFields` is either a literal pattern or a function that produces
|
|
* patterns from the loaded config and scope (e.g. `filters.<attr>` for each
|
|
* `model.all_attributes` entry on the objects section, gated by the
|
|
* effective `objects.track` list at the current scope).
|
|
*/
|
|
export function getEffectiveHiddenFields(
|
|
sectionKey: string,
|
|
level: "global" | "camera" | "replay",
|
|
ctx: HiddenFieldContext | undefined,
|
|
): string[] {
|
|
return resolveHiddenFieldEntries(
|
|
getSectionConfig(sectionKey, level).hiddenFields,
|
|
ctx,
|
|
);
|
|
}
|
|
|
|
export function resolveHiddenFieldEntries(
|
|
entries: SectionConfig["hiddenFields"] | undefined,
|
|
ctx: HiddenFieldContext | undefined,
|
|
): string[] {
|
|
if (!entries || entries.length === 0) return [];
|
|
const result: string[] = [];
|
|
for (const entry of entries) {
|
|
if (typeof entry === "function") {
|
|
if (ctx) result.push(...entry(ctx));
|
|
} else {
|
|
result.push(entry);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Match a delta path against a hidden-field pattern. Supports literal prefixes
|
|
* (so a hidden field "streams" also hides "streams.foo.bar") and `*` wildcards
|
|
* matching exactly one path segment (e.g. "filters.*.mask").
|
|
*/
|
|
export function pathMatchesHiddenPattern(
|
|
path: string,
|
|
pattern: string,
|
|
): boolean {
|
|
if (!pattern) return false;
|
|
if (!pattern.includes("*")) {
|
|
return path === pattern || path.startsWith(`${pattern}.`);
|
|
}
|
|
const patternSegments = pattern.split(".");
|
|
const pathSegments = path.split(".");
|
|
if (pathSegments.length < patternSegments.length) return false;
|
|
for (let i = 0; i < patternSegments.length; i += 1) {
|
|
if (patternSegments[i] === "*") continue;
|
|
if (patternSegments[i] !== pathSegments[i]) return false;
|
|
}
|
|
return true;
|
|
}
|