sync filter entries with track and listen labels

- Auto-populate `audio.filters` from `audio.listen` instead of the full audio labelmap, matching how `objects.filters` is keyed by `track` (no longer need to populate the full audio labelmap, which was added in #22630)
- Synthesize the matching filter entries in the settings form on load so each track/listen label shows its collapsible after a profile is selected, since the backend's auto-populate only runs at config init
This commit is contained in:
Josh Hawkins 2026-05-14 07:18:41 -05:00
parent 78fc472026
commit 25dcd25751
4 changed files with 32 additions and 31 deletions

View File

@ -26,7 +26,6 @@ from frigate.plus import PlusApi
from frigate.util.builtin import ( from frigate.util.builtin import (
deep_merge, deep_merge,
get_ffmpeg_arg_list, get_ffmpeg_arg_list,
load_labels,
) )
from frigate.util.config import ( from frigate.util.config import (
CURRENT_CONFIG_VERSION, CURRENT_CONFIG_VERSION,
@ -638,17 +637,12 @@ class FrigateConfig(FrigateBaseModel):
if self.ffmpeg.hwaccel_args == "auto": if self.ffmpeg.hwaccel_args == "auto":
self.ffmpeg.hwaccel_args = auto_detect_hwaccel() self.ffmpeg.hwaccel_args = auto_detect_hwaccel()
# Populate global audio filters for all audio labels # Populate global audio filters from listen. Existing user-defined
all_audio_labels = { # entries for labels not in listen are preserved but unused at runtime.
label
for label in load_labels("/audio-labelmap.txt", prefill=521).values()
if label
}
if self.audio.filters is None: if self.audio.filters is None:
self.audio.filters = {} self.audio.filters = {}
for key in sorted(all_audio_labels - self.audio.filters.keys()): for key in sorted(set(self.audio.listen) - self.audio.filters.keys()):
self.audio.filters[key] = AudioFilterConfig() self.audio.filters[key] = AudioFilterConfig()
self.audio.filters = dict(sorted(self.audio.filters.items())) self.audio.filters = dict(sorted(self.audio.filters.items()))
@ -840,7 +834,9 @@ class FrigateConfig(FrigateBaseModel):
if camera_config.audio.filters is None: if camera_config.audio.filters is None:
camera_config.audio.filters = {} camera_config.audio.filters = {}
for key in sorted(all_audio_labels - camera_config.audio.filters.keys()): for key in sorted(
set(camera_config.audio.listen) - camera_config.audio.filters.keys()
):
camera_config.audio.filters[key] = AudioFilterConfig() camera_config.audio.filters[key] = AudioFilterConfig()
camera_config.audio.filters = dict( camera_config.audio.filters = dict(

View File

@ -10,7 +10,7 @@ from ruamel.yaml.constructor import DuplicateKeyError
from frigate.config import BirdseyeModeEnum, FrigateConfig from frigate.config import BirdseyeModeEnum, FrigateConfig
from frigate.const import MODEL_CACHE_DIR from frigate.const import MODEL_CACHE_DIR
from frigate.detectors import DetectorTypeEnum from frigate.detectors import DetectorTypeEnum
from frigate.util.builtin import deep_merge, load_labels from frigate.util.builtin import deep_merge
class TestConfig(unittest.TestCase): class TestConfig(unittest.TestCase):
@ -309,16 +309,11 @@ class TestConfig(unittest.TestCase):
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
all_audio_labels = { assert set(frigate_config.cameras["back"].audio.filters.keys()) == {
label "speech",
for label in load_labels("/audio-labelmap.txt", prefill=521).values() "yell",
if label
} }
assert all_audio_labels.issubset(
set(frigate_config.cameras["back"].audio.filters.keys())
)
def test_override_audio_filters(self): def test_override_audio_filters(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
@ -345,7 +340,8 @@ class TestConfig(unittest.TestCase):
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert "speech" in frigate_config.cameras["back"].audio.filters assert "speech" in frigate_config.cameras["back"].audio.filters
assert frigate_config.cameras["back"].audio.filters["speech"].threshold == 0.9 assert frigate_config.cameras["back"].audio.filters["speech"].threshold == 0.9
assert "babbling" in frigate_config.cameras["back"].audio.filters assert "yell" in frigate_config.cameras["back"].audio.filters
assert "babbling" not in frigate_config.cameras["back"].audio.filters
def test_inherit_object_filters(self): def test_inherit_object_filters(self):
config = { config = {

View File

@ -22,7 +22,7 @@ import {
modifySchemaForSection, modifySchemaForSection,
getEffectiveDefaultsForSection, getEffectiveDefaultsForSection,
sanitizeOverridesForSection, sanitizeOverridesForSection,
synthesizeMissingObjectFilters, synthesizeMissingFilters,
} from "./section-special-cases"; } from "./section-special-cases";
import { getSectionValidation } from "../section-validations"; import { getSectionValidation } from "../section-validations";
import { useConfigOverride } from "@/hooks/use-config-override"; import { useConfigOverride } from "@/hooks/use-config-override";
@ -370,7 +370,7 @@ export function ConfigSection({
return {}; return {};
} }
return synthesizeMissingObjectFilters( return synthesizeMissingFilters(
sectionPath, sectionPath,
rawSectionValue, rawSectionValue,
modifiedSchema ?? undefined, modifiedSchema ?? undefined,

View File

@ -128,22 +128,31 @@ export function getEffectiveDefaultsForSection(
return schemaDefaults; return schemaDefaults;
} }
// Sections whose `filters` dict is keyed by a sibling list field. The backend
// auto-populates these filters at config init but doesn't re-run after profile
// merges, so we synthesize the missing entries on the frontend.
const FILTER_SECTIONS: Record<string, { listField: string }> = {
objects: { listField: "track" },
audio: { listField: "listen" },
};
/** /**
* Add default filter entries for any label in `objects.track` that isn't * Add default filter entries for any label in the section's list field
* already in `objects.filters`, so each tracked label gets a collapsible. * (e.g. `objects.track`, `audio.listen`) that isn't already in `filters`, so
* The backend only auto-populates filters at config init, not after profile * each label gets a collapsible. The backend only auto-populates filters at
* merges. * config init, not after profile merges.
*/ */
export function synthesizeMissingObjectFilters( export function synthesizeMissingFilters(
sectionPath: string, sectionPath: string,
data: unknown, data: unknown,
sectionSchema: RJSFSchema | undefined, sectionSchema: RJSFSchema | undefined,
): unknown { ): unknown {
if (sectionPath !== "objects") return data; const sectionConfig = FILTER_SECTIONS[sectionPath];
if (!sectionConfig) return data;
if (!isJsonObject(data)) return data; if (!isJsonObject(data)) return data;
const trackValue = (data as JsonObject).track; const listValue = (data as JsonObject)[sectionConfig.listField];
if (!Array.isArray(trackValue) || trackValue.length === 0) return data; if (!Array.isArray(listValue) || listValue.length === 0) return data;
const properties = (sectionSchema as { properties?: Record<string, unknown> }) const properties = (sectionSchema as { properties?: Record<string, unknown> })
?.properties; ?.properties;
@ -160,7 +169,7 @@ export function synthesizeMissingObjectFilters(
const newFilters: JsonObject = { ...existingFilters }; const newFilters: JsonObject = { ...existingFilters };
let added = false; let added = false;
for (const label of trackValue) { for (const label of listValue) {
if (typeof label !== "string") continue; if (typeof label !== "string") continue;
if (Object.prototype.hasOwnProperty.call(newFilters, label)) continue; if (Object.prototype.hasOwnProperty.call(newFilters, label)) continue;
newFilters[label] = ( newFilters[label] = (