Compare commits

..

No commits in common. "e7a5f76f26447bb62f252ccd476230f244900420" and "27017d35f430cd814be64d7ece077445134304b9" have entirely different histories.

12 changed files with 24 additions and 179 deletions

View File

@ -499,40 +499,6 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
)
def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> None:
"""Substitute incoming `*:*` masked credentials with the in-memory ones.
The /config response masks ffmpeg input credentials, so the settings UI
sends the masked path back when sibling fields (e.g. hwaccel_args) are
edited. Without this we'd write `rtsp://*:*@host` into YAML and lose
the real credentials. Mutates `config_data` in place.
"""
cameras = config_data.get("cameras")
if not isinstance(cameras, dict):
return
for camera_name, camera_data in cameras.items():
if not isinstance(camera_data, dict):
continue
inputs = camera_data.get("ffmpeg", {}).get("inputs")
if not isinstance(inputs, list):
continue
existing = config.cameras.get(camera_name)
if existing is None:
continue
existing_paths = [inp.path for inp in existing.ffmpeg.inputs]
for index, input_obj in enumerate(inputs):
if not isinstance(input_obj, dict):
continue
path = input_obj.get("path")
if not isinstance(path, str):
continue
if ("://*:*@" in path or "user=*&password=*" in path) and index < len(
existing_paths
):
input_obj["path"] = existing_paths[index]
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
"""Apply config changes in-memory only, without writing to YAML.
@ -543,7 +509,6 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
try:
updates = {}
if body.config_data:
_restore_masked_camera_paths(body.config_data, request.app.frigate_config)
updates = flatten_config_data(body.config_data)
updates = {k: ("" if v is None else v) for k, v in updates.items()}
@ -650,9 +615,6 @@ def config_set(request: Request, body: AppConfigSetBody):
if query_string:
updates = process_config_query_string(query_string)
elif body.config_data:
_restore_masked_camera_paths(
body.config_data, request.app.frigate_config
)
updates = flatten_config_data(body.config_data)
# Convert None values to empty strings for deletion (e.g., when deleting masks)
updates = {k: ("" if v is None else v) for k, v in updates.items()}

View File

@ -121,10 +121,7 @@ class CameraConfigUpdateSubscriber:
elif update_type == CameraConfigUpdateEnum.objects:
config.objects = updated_config
elif update_type == CameraConfigUpdateEnum.record:
old_enabled_in_config = config.record.enabled_in_config
config.record = updated_config
if old_enabled_in_config != updated_config.enabled_in_config:
config.recreate_ffmpeg_cmds()
elif update_type == CameraConfigUpdateEnum.review:
config.review = updated_config
elif update_type == CameraConfigUpdateEnum.review_genai:

View File

@ -26,11 +26,6 @@ class EnrichmentsDeviceEnum(str, Enum):
CPU = "CPU"
class ModelSizeEnum(str, Enum):
small = "small"
large = "large"
class TriggerType(str, Enum):
THUMBNAIL = "thumbnail"
DESCRIPTION = "description"
@ -58,13 +53,13 @@ class AudioTranscriptionConfig(FrigateBaseModel):
title="Transcription language",
description="Language code used for transcription/translation (for example 'en' for English). See https://whisper-api.com/docs/languages/ for supported language codes.",
)
device: EnrichmentsDeviceEnum = Field(
device: Optional[EnrichmentsDeviceEnum] = Field(
default=EnrichmentsDeviceEnum.CPU,
title="Transcription device",
description="Device key (CPU/GPU) to run the transcription model on. Only NVIDIA CUDA GPUs are currently supported for transcription.",
)
model_size: ModelSizeEnum = Field(
default=ModelSizeEnum.small,
model_size: str = Field(
default="small",
title="Model size",
description="Model size to use for offline audio event transcription.",
)
@ -194,8 +189,8 @@ class SemanticSearchConfig(FrigateBaseModel):
return v
return v
model_size: ModelSizeEnum = Field(
default=ModelSizeEnum.small,
model_size: str = Field(
default="small",
title="Model size",
description="Select model size; 'small' runs on CPU and 'large' typically requires GPU.",
)
@ -258,8 +253,8 @@ class FaceRecognitionConfig(FrigateBaseModel):
title="Enable face recognition",
description="Enable or disable face recognition for all cameras; can be overridden per-camera.",
)
model_size: ModelSizeEnum = Field(
default=ModelSizeEnum.small,
model_size: str = Field(
default="small",
title="Model size",
description="Model size to use for face embeddings (small/large); larger may require GPU.",
)
@ -340,8 +335,8 @@ class LicensePlateRecognitionConfig(FrigateBaseModel):
title="Enable LPR",
description="Enable or disable license plate recognition for all cameras; can be overridden per-camera.",
)
model_size: ModelSizeEnum = Field(
default=ModelSizeEnum.small,
model_size: str = Field(
default="small",
title="Model size",
description="Model size used for text detection/recognition. Most users should use 'small'.",
)

View File

@ -1,6 +1,5 @@
from __future__ import annotations
import io
import json
import logging
import os
@ -81,40 +80,17 @@ logger = logging.getLogger(__name__)
yaml = YAML()
DEFAULT_DETECTORS = {
"ov": {
"type": "openvino",
"device": "CPU",
}
}
DEFAULT_MODEL = {
"width": 300,
"height": 300,
"input_tensor": "nhwc",
"input_pixel_format": "bgr",
"path": "/openvino-model/ssdlite_mobilenet_v2.xml",
"labelmap_path": "/openvino-model/coco_91cl_bkgr.txt",
}
DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720}
def _render_default_yaml(data: dict) -> str:
buf = io.StringIO()
_yaml_writer = YAML()
_yaml_writer.indent(mapping=2, sequence=4, offset=2)
_yaml_writer.dump(data, buf)
return buf.getvalue()
DEFAULT_CONFIG = f"""
mqtt:
enabled: False
{_render_default_yaml({"detectors": DEFAULT_DETECTORS, "model": DEFAULT_MODEL})}
cameras: {{}} # No cameras defined, UI wizard should be used
version: {CURRENT_CONFIG_VERSION}
"""
DEFAULT_DETECTORS = {"cpu": {"type": "cpu"}}
DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720}
# stream info handler
stream_info_retriever = StreamInfoRetriever()
@ -703,9 +679,6 @@ class FrigateConfig(FrigateBaseModel):
model_config["path"] = "/cpu_model.tflite"
elif detector_config.type == "edgetpu":
model_config["path"] = "/edgetpu_model.tflite"
elif detector_config.type == "openvino":
for default_key, default_value in DEFAULT_MODEL.items():
model_config.setdefault(default_key, default_value)
model = ModelConfig.model_validate(model_config)
model.check_and_load_plus_model(self.plus_api, detector_config.type)

View File

@ -64,9 +64,9 @@ class TestConfig(unittest.TestCase):
def test_config_class(self):
frigate_config = FrigateConfig(**self.minimal)
assert "ov" in frigate_config.detectors.keys()
assert frigate_config.detectors["ov"].type == DetectorTypeEnum.openvino
assert frigate_config.detectors["ov"].model.width == 300
assert "cpu" in frigate_config.detectors.keys()
assert frigate_config.detectors["cpu"].type == DetectorTypeEnum.cpu
assert frigate_config.detectors["cpu"].model.width == 320
@patch("frigate.detectors.detector_config.load_labels")
def test_detector_custom_model_path(self, mock_labels):
@ -1005,7 +1005,6 @@ class TestConfig(unittest.TestCase):
config = {
"mqtt": {"host": "mqtt"},
"detectors": {"cpu": {"type": "cpu"}},
"model": {"path": "plus://test"},
"cameras": {
"back": {

View File

@ -174,7 +174,6 @@ class CameraWatchdog(threading.Thread):
)
self.requestor = InterProcessRequestor()
self.was_enabled = self.config.enabled
self.was_record_enabled_in_config = self.config.record.enabled_in_config
self.segment_subscriber = RecordingsDataSubscriber(RecordingsDataTypeEnum.all)
self.latest_valid_segment_time: float = 0
@ -324,22 +323,6 @@ class CameraWatchdog(threading.Thread):
self.was_enabled = enabled
continue
record_enabled_in_config = self.config.record.enabled_in_config
if record_enabled_in_config != self.was_record_enabled_in_config:
if record_enabled_in_config and enabled:
self.logger.debug(
f"Record enabled in config for {self.config.name}, restarting ffmpeg"
)
self.stop_all_ffmpeg()
self.start_all_ffmpeg()
self.latest_valid_segment_time = 0
self.latest_invalid_segment_time = 0
self.latest_cache_segment_time = 0
self.record_enable_time = datetime.now().astimezone(timezone.utc)
last_restart_time = datetime.now().timestamp()
self.was_record_enabled_in_config = record_enabled_in_config
continue
if not enabled:
continue

View File

@ -70,8 +70,7 @@
},
"recording": {
"enable": "Enable Recording",
"disable": "Disable Recording",
"disabledInConfig": "Recording must first be enabled in Settings for this camera."
"disable": "Disable Recording"
},
"snapshots": {
"enable": "Enable Snapshots",

View File

@ -177,9 +177,6 @@
}
},
"framesAndDetections": "Frames / Detections",
"noCameras": {
"title": "No Cameras Found"
},
"label": {
"camera": "camera",
"detect": "detect",

View File

@ -9,7 +9,7 @@ import {
useRef,
useContext,
} from "react";
import useSWR, { mutate as swrMutate } from "swr";
import useSWR from "swr";
import axios from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
@ -22,7 +22,6 @@ import {
modifySchemaForSection,
getEffectiveDefaultsForSection,
sanitizeOverridesForSection,
synthesizeMissingObjectFilters,
} from "./section-special-cases";
import { getSectionValidation } from "../section-validations";
import { useConfigOverride } from "@/hooks/use-config-override";
@ -358,19 +357,15 @@ export function ConfigSection({
return get(config, sectionPath);
}, [config, cameraName, sectionPath, effectiveLevel, profileName]);
const rawFormData = useMemo<ConfigSectionData>(() => {
const rawFormData = useMemo(() => {
if (!config) return {};
if (rawSectionValue === undefined || rawSectionValue === null) {
return {};
}
return synthesizeMissingObjectFilters(
sectionPath,
rawSectionValue,
modifiedSchema ?? undefined,
) as ConfigSectionData;
}, [config, rawSectionValue, sectionPath, modifiedSchema]);
return rawSectionValue;
}, [config, rawSectionValue]);
// When editing a profile, hide fields that require a restart since they
// cannot take effect via profile switching alone.
@ -392,7 +387,7 @@ export function ConfigSection({
const baseData = modifiedSchema
? applySchemaDefaults(modifiedSchema, rawFormData)
: rawFormData;
return sanitizeSectionData(baseData as ConfigSectionData);
return sanitizeSectionData(baseData);
}, [rawFormData, modifiedSchema, sanitizeSectionData]);
const baselineSnapshot = useMemo(() => {
@ -511,11 +506,7 @@ export function ConfigSection({
setPendingOverrides(undefined);
return;
}
const sanitizedData = synthesizeMissingObjectFilters(
sectionPath,
sanitizeSectionData(data as ConfigSectionData),
modifiedSchema ?? undefined,
) as ConfigSectionData;
const sanitizedData = sanitizeSectionData(data as ConfigSectionData);
const nextBaselineFormData = baselineSnapshot;
const overrides = buildOverrides(
sanitizedData,
@ -555,8 +546,6 @@ export function ConfigSection({
setPendingOverrides,
setDirtyOverrides,
baselineSnapshot,
sectionPath,
modifiedSchema,
],
);
@ -754,7 +743,6 @@ export function ConfigSection({
}
await refreshConfig();
swrMutate("config/raw_paths");
setPendingData(null);
onSave?.();
} catch (error) {

View File

@ -105,51 +105,6 @@ export function getEffectiveDefaultsForSection(
return schemaDefaults;
}
/**
* Add default filter entries for any label in `objects.track` that isn't
* already in `objects.filters`, so each tracked label gets a collapsible.
* The backend only auto-populates filters at config init, not after profile
* merges or live track edits.
*/
export function synthesizeMissingObjectFilters(
sectionPath: string,
data: unknown,
sectionSchema: RJSFSchema | undefined,
): unknown {
if (sectionPath !== "objects") return data;
if (!isJsonObject(data)) return data;
const trackValue = (data as JsonObject).track;
if (!Array.isArray(trackValue) || trackValue.length === 0) return data;
const properties = (sectionSchema as { properties?: Record<string, unknown> })
?.properties;
const filtersSchema = isJsonObject(properties)
? (properties.filters as { additionalProperties?: unknown } | undefined)
: undefined;
const filterEntrySchema = isJsonObject(filtersSchema?.additionalProperties)
? (filtersSchema.additionalProperties as RJSFSchema)
: undefined;
const existingFilters = isJsonObject((data as JsonObject).filters)
? ((data as JsonObject).filters as JsonObject)
: {};
const newFilters: JsonObject = { ...existingFilters };
let added = false;
for (const label of trackValue) {
if (typeof label !== "string") continue;
if (Object.prototype.hasOwnProperty.call(newFilters, label)) continue;
newFilters[label] = (
filterEntrySchema ? applySchemaDefaults(filterEntrySchema, {}) : {}
) as JsonValue;
added = true;
}
if (!added) return data;
return { ...(data as JsonObject), filters: newFilters };
}
/**
* Sanitize overrides payloads for section-specific quirks.
*/

View File

@ -885,7 +885,6 @@ export default function Settings() {
// Refresh config from server once
await mutate("config");
mutate("config/raw_paths");
// Clear hasChanges in sidebar for all successfully saved sections
if (savedKeys.length > 0) {

View File

@ -1072,12 +1072,10 @@ function FrigateCameraFeatures({
title={
recordState == "ON"
? t("recording.disable")
: camera.record.enabled_in_config
? t("recording.enable")
: t("recording.disabledInConfig")
: t("recording.enable")
}
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled || !camera.record.enabled_in_config}
disabled={!cameraEnabled}
/>
<CameraFeatureToggle
className="p-2 md:p-0"