From 68de18f10dd2455f340b5f8cd8a44593679fd334 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Fri, 20 Mar 2026 08:24:34 -0500
Subject: [PATCH 1/8] Settings UI tweaks (#22547)
* fix genai settings ui
- add roles widget to select roles for genai providers
- add dropdown in semantic search to allow selection of embeddings genai provider
* tweak grouping to prioritize fieldOrder before groups
previously, groups were always rendered first. now fieldOrder is respected, and any fields in a group will cause the group and all the fields in that group to be rendered in order. this allows moving the enabled switches to the top of the section
* mobile tweaks
stack buttons, add more space on profiles pane, and move the overridden badge beneath the description
* language consistency
* prevent camera config sections from being regenerated for profiles
* conditionally import axengine module
to match other detectors
* i18n
* update vscode launch.json for new integrated browser
* formatting
---
.vscode/launch.json | 17 ++
frigate/config/camera/detect.py | 4 +-
frigate/config/camera/snapshots.py | 2 +-
frigate/config/config.py | 2 +-
frigate/detectors/plugins/axengine.py | 7 +-
generate_config_translations.py | 9 +
web/public/locales/en/config/cameras.json | 10 +-
web/public/locales/en/config/global.json | 20 ++-
web/public/locales/en/views/settings.json | 12 ++
.../config-form/section-configs/audio.ts | 2 +-
.../config-form/section-configs/detect.ts | 2 +-
.../section-configs/face_recognition.ts | 2 +-
.../config-form/section-configs/genai.ts | 55 +++---
.../config-form/section-configs/lpr.ts | 4 +-
.../config-form/section-configs/motion.ts | 2 +-
.../config-form/section-configs/record.ts | 2 +-
.../section-configs/semantic_search.ts | 5 +
.../config-form/section-configs/snapshots.ts | 2 +-
.../config-form/sections/BaseSection.tsx | 2 +-
.../config-form/theme/frigateTheme.ts | 4 +
.../theme/templates/ObjectFieldTemplate.tsx | 109 ++++++------
.../theme/widgets/GenAIRolesWidget.tsx | 109 ++++++++++++
.../widgets/SemanticSearchModelWidget.tsx | 159 ++++++++++++++++++
web/src/lib/config-schema/transformer.ts | 37 +++-
web/src/views/settings/ProfilesView.tsx | 20 +--
web/src/views/settings/SingleSectionPage.tsx | 91 +++++++---
26 files changed, 552 insertions(+), 138 deletions(-)
create mode 100644 web/src/components/config-form/theme/widgets/GenAIRolesWidget.tsx
create mode 100644 web/src/components/config-form/theme/widgets/SemanticSearchModelWidget.tsx
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 5c858267d..2d7b6c8fb 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -6,6 +6,23 @@
"type": "debugpy",
"request": "launch",
"module": "frigate"
+ },
+ {
+ "type": "editor-browser",
+ "request": "launch",
+ "name": "Vite: Launch in integrated browser",
+ "url": "http://localhost:5173"
+ },
+ {
+ "type": "editor-browser",
+ "request": "launch",
+ "name": "Nginx: Launch in integrated browser",
+ "url": "http://localhost:5000"
+ },
+ {
+ "type": "editor-browser",
+ "request": "attach",
+ "name": "Attach to integrated browser"
}
]
}
diff --git a/frigate/config/camera/detect.py b/frigate/config/camera/detect.py
index 19ba670a6..c0a2e7036 100644
--- a/frigate/config/camera/detect.py
+++ b/frigate/config/camera/detect.py
@@ -49,8 +49,8 @@ class StationaryConfig(FrigateBaseModel):
class DetectConfig(FrigateBaseModel):
enabled: bool = Field(
default=False,
- title="Detection enabled",
- description="Enable or disable object detection for all cameras; can be overridden per-camera. Detection must be enabled for object tracking to run.",
+ title="Enable object detection",
+ description="Enable or disable object detection for all cameras; can be overridden per-camera.",
)
height: Optional[int] = Field(
default=None,
diff --git a/frigate/config/camera/snapshots.py b/frigate/config/camera/snapshots.py
index c367aad8e..5a7f8480c 100644
--- a/frigate/config/camera/snapshots.py
+++ b/frigate/config/camera/snapshots.py
@@ -29,7 +29,7 @@ class RetainConfig(FrigateBaseModel):
class SnapshotsConfig(FrigateBaseModel):
enabled: bool = Field(
default=False,
- title="Snapshots enabled",
+ title="Enable snapshots",
description="Enable or disable saving snapshots for all cameras; can be overridden per-camera.",
)
clean_copy: bool = Field(
diff --git a/frigate/config/config.py b/frigate/config/config.py
index ea21fa831..699092d7d 100644
--- a/frigate/config/config.py
+++ b/frigate/config/config.py
@@ -444,7 +444,7 @@ class FrigateConfig(FrigateBaseModel):
# GenAI config (named provider configs: name -> GenAIConfig)
genai: Dict[str, GenAIConfig] = Field(
default_factory=dict,
- title="Generative AI configuration (named providers).",
+ title="Generative AI configuration",
description="Settings for integrated generative AI providers used to generate object descriptions and review summaries.",
)
diff --git a/frigate/detectors/plugins/axengine.py b/frigate/detectors/plugins/axengine.py
index 1752188d9..383fcd0bf 100644
--- a/frigate/detectors/plugins/axengine.py
+++ b/frigate/detectors/plugins/axengine.py
@@ -4,7 +4,6 @@ import re
import urllib.request
from typing import Literal
-import axengine as axe
from pydantic import ConfigDict
from frigate.const import MODEL_CACHE_DIR
@@ -37,6 +36,12 @@ class Axengine(DetectionApi):
type_key = DETECTOR_KEY
def __init__(self, config: AxengineDetectorConfig):
+ try:
+ import axengine as axe
+ except ModuleNotFoundError:
+ raise ImportError("AXEngine is not installed.")
+ return
+
logger.info("__init__ axengine")
super().__init__(config)
self.height = config.model.height
diff --git a/generate_config_translations.py b/generate_config_translations.py
index f41957561..df6c18f99 100644
--- a/generate_config_translations.py
+++ b/generate_config_translations.py
@@ -518,6 +518,15 @@ def main():
sanitize_camera_descriptions(camera_translations)
+ # Profiles contain the same sections as the camera itself; only keep
+ # label and description to avoid duplicating every camera section.
+ if "profiles" in camera_translations:
+ camera_translations["profiles"] = {
+ k: v
+ for k, v in camera_translations["profiles"].items()
+ if k in ("label", "description")
+ }
+
with open(cameras_file, "w", encoding="utf-8") as f:
json.dump(camera_translations, f, indent=2, ensure_ascii=False)
f.write("\n")
diff --git a/web/public/locales/en/config/cameras.json b/web/public/locales/en/config/cameras.json
index 0ae231c37..f14599e14 100644
--- a/web/public/locales/en/config/cameras.json
+++ b/web/public/locales/en/config/cameras.json
@@ -79,8 +79,8 @@
"label": "Object Detection",
"description": "Settings for the detection/detect role used to run object detection and initialize trackers.",
"enabled": {
- "label": "Detection enabled",
- "description": "Enable or disable object detection for this camera. Detection must be enabled for object tracking to run."
+ "label": "Enable object detection",
+ "description": "Enable or disable object detection for this camera."
},
"height": {
"label": "Detect height",
@@ -628,7 +628,7 @@
"label": "Snapshots",
"description": "Settings for saved JPEG snapshots of tracked objects for this camera.",
"enabled": {
- "label": "Snapshots enabled",
+ "label": "Enable snapshots",
"description": "Enable or disable saving snapshots for this camera."
},
"clean_copy": {
@@ -860,6 +860,10 @@
"label": "Camera URL",
"description": "URL to visit the camera directly from system page"
},
+ "profiles": {
+ "label": "Profiles",
+ "description": "Named config profiles with partial overrides that can be activated at runtime."
+ },
"zones": {
"label": "Zones",
"description": "Zones allow you to define a specific area of the frame so you can determine whether or not an object is within a particular area.",
diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json
index fdfc4b389..8e3439528 100644
--- a/web/public/locales/en/config/global.json
+++ b/web/public/locales/en/config/global.json
@@ -1174,7 +1174,7 @@
}
},
"genai": {
- "label": "Generative AI configuration (named providers).",
+ "label": "Generative AI configuration",
"description": "Settings for integrated generative AI providers used to generate object descriptions and review summaries.",
"api_key": {
"label": "API key",
@@ -1293,8 +1293,8 @@
"label": "Object Detection",
"description": "Settings for the detection/detect role used to run object detection and initialize trackers.",
"enabled": {
- "label": "Detection enabled",
- "description": "Enable or disable object detection for all cameras; can be overridden per-camera. Detection must be enabled for object tracking to run."
+ "label": "Enable object detection",
+ "description": "Enable or disable object detection for all cameras; can be overridden per-camera."
},
"height": {
"label": "Detect height",
@@ -1778,7 +1778,7 @@
"label": "Snapshots",
"description": "Settings for saved JPEG snapshots of tracked objects for all cameras; can be overridden per-camera.",
"enabled": {
- "label": "Snapshots enabled",
+ "label": "Enable snapshots",
"description": "Enable or disable saving snapshots for all cameras; can be overridden per-camera."
},
"clean_copy": {
@@ -2128,6 +2128,18 @@
"description": "Numeric order used to sort camera groups in the UI; larger numbers appear later."
}
},
+ "profiles": {
+ "label": "Profiles",
+ "description": "Named profile definitions with friendly names. Camera profiles must reference names defined here.",
+ "friendly_name": {
+ "label": "Friendly name",
+ "description": "Display name for this profile shown in the UI."
+ }
+ },
+ "active_profile": {
+ "label": "Active profile",
+ "description": "Currently active profile name. Runtime-only, not persisted in YAML."
+ },
"camera_mqtt": {
"label": "MQTT",
"description": "MQTT image publishing settings.",
diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json
index f93439244..0dd96acbb 100644
--- a/web/public/locales/en/views/settings.json
+++ b/web/public/locales/en/views/settings.json
@@ -1402,6 +1402,18 @@
"audio": "Audio"
}
},
+ "genaiRoles": {
+ "options": {
+ "embeddings": "Embedding",
+ "vision": "Vision",
+ "tools": "Tools"
+ }
+ },
+ "semanticSearchModel": {
+ "placeholder": "Select model…",
+ "builtIn": "Built-in Models",
+ "genaiProviders": "GenAI Providers"
+ },
"review": {
"title": "Review Settings"
},
diff --git a/web/src/components/config-form/section-configs/audio.ts b/web/src/components/config-form/section-configs/audio.ts
index 09fe4e974..740d76f78 100644
--- a/web/src/components/config-form/section-configs/audio.ts
+++ b/web/src/components/config-form/section-configs/audio.ts
@@ -13,7 +13,7 @@ const audio: SectionConfigOverrides = {
"num_threads",
],
fieldGroups: {
- detection: ["enabled", "listen", "filters"],
+ detection: ["listen", "filters"],
sensitivity: ["min_volume", "max_not_heard"],
},
hiddenFields: ["enabled_in_config"],
diff --git a/web/src/components/config-form/section-configs/detect.ts b/web/src/components/config-form/section-configs/detect.ts
index ecf5102b4..778620f1c 100644
--- a/web/src/components/config-form/section-configs/detect.ts
+++ b/web/src/components/config-form/section-configs/detect.ts
@@ -18,7 +18,7 @@ const detect: SectionConfigOverrides = {
],
restartRequired: [],
fieldGroups: {
- resolution: ["enabled", "width", "height", "fps"],
+ resolution: ["width", "height", "fps"],
tracking: ["min_initialized", "max_disappeared"],
},
hiddenFields: ["enabled_in_config"],
diff --git a/web/src/components/config-form/section-configs/face_recognition.ts b/web/src/components/config-form/section-configs/face_recognition.ts
index 18e963940..ef9e43506 100644
--- a/web/src/components/config-form/section-configs/face_recognition.ts
+++ b/web/src/components/config-form/section-configs/face_recognition.ts
@@ -6,7 +6,7 @@ const faceRecognition: SectionConfigOverrides = {
restartRequired: [],
fieldOrder: ["enabled", "min_area"],
hiddenFields: [],
- advancedFields: ["min_area"],
+ advancedFields: [],
overrideFields: ["enabled", "min_area"],
},
global: {
diff --git a/web/src/components/config-form/section-configs/genai.ts b/web/src/components/config-form/section-configs/genai.ts
index 739659496..e37478f11 100644
--- a/web/src/components/config-form/section-configs/genai.ts
+++ b/web/src/components/config-form/section-configs/genai.ts
@@ -4,39 +4,50 @@ const genai: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/genai/config",
restartRequired: [
- "provider",
- "api_key",
- "base_url",
- "model",
- "provider_options",
- "runtime_options",
+ "*.provider",
+ "*.api_key",
+ "*.base_url",
+ "*.model",
+ "*.provider_options",
+ "*.runtime_options",
],
- fieldOrder: [
- "provider",
- "api_key",
- "base_url",
- "model",
- "provider_options",
- "runtime_options",
- ],
- advancedFields: ["base_url", "provider_options", "runtime_options"],
+ advancedFields: ["*.base_url", "*.provider_options", "*.runtime_options"],
hiddenFields: ["genai.enabled_in_config"],
uiSchema: {
- api_key: {
- "ui:options": { size: "md" },
+ "ui:options": { disableNestedCard: true },
+ "*": {
+ "ui:options": { disableNestedCard: true },
+ "ui:order": [
+ "provider",
+ "api_key",
+ "base_url",
+ "model",
+ "provider_options",
+ "runtime_options",
+ "*",
+ ],
},
- base_url: {
+ "*.roles": {
+ "ui:widget": "genaiRoles",
+ },
+ "*.api_key": {
"ui:options": { size: "lg" },
},
- model: {
- "ui:options": { size: "md" },
+ "*.base_url": {
+ "ui:options": { size: "lg" },
},
- provider_options: {
+ "*.model": {
+ "ui:options": { size: "xs" },
+ },
+ "*.provider": {
+ "ui:options": { size: "xs" },
+ },
+ "*.provider_options": {
additionalProperties: {
"ui:options": { size: "lg" },
},
},
- runtime_options: {
+ "*.runtime_options": {
additionalProperties: {
"ui:options": { size: "lg" },
},
diff --git a/web/src/components/config-form/section-configs/lpr.ts b/web/src/components/config-form/section-configs/lpr.ts
index c5e16eb23..514dba9be 100644
--- a/web/src/components/config-form/section-configs/lpr.ts
+++ b/web/src/components/config-form/section-configs/lpr.ts
@@ -7,9 +7,9 @@ const lpr: SectionConfigOverrides = {
enhancement: "/configuration/license_plate_recognition#enhancement",
},
restartRequired: [],
- fieldOrder: ["enabled", "expire_time", "min_area", "enhancement"],
+ fieldOrder: ["enabled", "min_area", "enhancement", "expire_time"],
hiddenFields: [],
- advancedFields: ["expire_time", "min_area", "enhancement"],
+ advancedFields: ["expire_time", "enhancement"],
overrideFields: ["enabled", "min_area", "enhancement"],
},
global: {
diff --git a/web/src/components/config-form/section-configs/motion.ts b/web/src/components/config-form/section-configs/motion.ts
index 38755ee20..4e9676bb2 100644
--- a/web/src/components/config-form/section-configs/motion.ts
+++ b/web/src/components/config-form/section-configs/motion.ts
@@ -23,7 +23,7 @@ const motion: SectionConfigOverrides = {
"mqtt_off_delay",
],
fieldGroups: {
- sensitivity: ["enabled", "threshold", "contour_area"],
+ sensitivity: ["threshold", "contour_area"],
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
},
uiSchema: {
diff --git a/web/src/components/config-form/section-configs/record.ts b/web/src/components/config-form/section-configs/record.ts
index 53803eed9..9cfc92127 100644
--- a/web/src/components/config-form/section-configs/record.ts
+++ b/web/src/components/config-form/section-configs/record.ts
@@ -15,7 +15,7 @@ const record: SectionConfigOverrides = {
"export",
],
fieldGroups: {
- retention: ["enabled", "continuous", "motion"],
+ retention: ["continuous", "motion"],
events: ["alerts", "detections"],
},
hiddenFields: ["enabled_in_config", "sync_recordings"],
diff --git a/web/src/components/config-form/section-configs/semantic_search.ts b/web/src/components/config-form/section-configs/semantic_search.ts
index 2fea46782..34c1e149f 100644
--- a/web/src/components/config-form/section-configs/semantic_search.ts
+++ b/web/src/components/config-form/section-configs/semantic_search.ts
@@ -18,6 +18,11 @@ const semanticSearch: SectionConfigOverrides = {
advancedFields: ["reindex", "device"],
restartRequired: ["enabled", "model", "model_size", "device"],
hiddenFields: ["reindex"],
+ uiSchema: {
+ model: {
+ "ui:widget": "semanticSearchModel",
+ },
+ },
},
};
diff --git a/web/src/components/config-form/section-configs/snapshots.ts b/web/src/components/config-form/section-configs/snapshots.ts
index 8f08fa843..126ecd496 100644
--- a/web/src/components/config-form/section-configs/snapshots.ts
+++ b/web/src/components/config-form/section-configs/snapshots.ts
@@ -13,7 +13,7 @@ const snapshots: SectionConfigOverrides = {
"retain",
],
fieldGroups: {
- display: ["enabled", "bounding_box", "crop", "quality", "timestamp"],
+ display: ["bounding_box", "crop", "quality", "timestamp"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["height", "quality", "retain"],
diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx
index d2be6ded4..f171d9fe1 100644
--- a/web/src/components/config-form/sections/BaseSection.tsx
+++ b/web/src/components/config-form/sections/BaseSection.tsx
@@ -936,7 +936,7 @@ export function ConfigSection({
)}
-
+
{((effectiveLevel === "camera" && isOverridden) ||
effectiveLevel === "global") &&
!hasChanges &&
diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts
index 79bc14b84..5df8564f2 100644
--- a/web/src/components/config-form/theme/frigateTheme.ts
+++ b/web/src/components/config-form/theme/frigateTheme.ts
@@ -23,10 +23,12 @@ import { AudioLabelSwitchesWidget } from "./widgets/AudioLabelSwitchesWidget";
import { ZoneSwitchesWidget } from "./widgets/ZoneSwitchesWidget";
import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget";
import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
+import { GenAIRolesWidget } from "./widgets/GenAIRolesWidget";
import { InputRolesWidget } from "./widgets/InputRolesWidget";
import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
import { CameraPathWidget } from "./widgets/CameraPathWidget";
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
+import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget";
import { FieldTemplate } from "./templates/FieldTemplate";
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
@@ -60,6 +62,7 @@ export const frigateTheme: FrigateTheme = {
ArrayAsTextWidget: ArrayAsTextWidget,
FfmpegArgsWidget: FfmpegArgsWidget,
CameraPathWidget: CameraPathWidget,
+ genaiRoles: GenAIRolesWidget,
inputRoles: InputRolesWidget,
// Custom widgets
switch: SwitchWidget,
@@ -75,6 +78,7 @@ export const frigateTheme: FrigateTheme = {
zoneNames: ZoneSwitchesWidget,
timezoneSelect: TimezoneSelectWidget,
optionalField: OptionalFieldWidget,
+ semanticSearchModel: SemanticSearchModelWidget,
},
templates: {
FieldTemplate: FieldTemplate as React.ComponentType
,
diff --git a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx
index 3ba4cb0bc..f028a566f 100644
--- a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx
+++ b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx
@@ -311,51 +311,54 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
return null;
}
- const grouped = new Set();
- const groups = Object.entries(groupDefinitions)
- .map(([groupKey, fields]) => {
- const ordered = fields
- .map((field) => items.find((item) => item.name === field))
- .filter(Boolean) as (typeof properties)[number][];
+ // Build a lookup: field name → group info
+ const fieldToGroup = new Map<
+ string,
+ { groupKey: string; label: string; items: (typeof properties)[number][] }
+ >();
+ const hasGroups = Object.keys(groupDefinitions).length > 0;
- if (ordered.length === 0) {
- return null;
- }
+ for (const [groupKey, fields] of Object.entries(groupDefinitions)) {
+ const ordered = fields
+ .map((field) => items.find((item) => item.name === field))
+ .filter(Boolean) as (typeof properties)[number][];
- ordered.forEach((item) => grouped.add(item.name));
+ if (ordered.length === 0) continue;
- const label = domain
- ? t(`${sectionI18nPrefix}.${domain}.${groupKey}`, {
- ns: "config/groups",
- defaultValue: humanizeKey(groupKey),
- })
- : t(`groups.${groupKey}`, {
- defaultValue: humanizeKey(groupKey),
- });
+ const label = domain
+ ? t(`${sectionI18nPrefix}.${domain}.${groupKey}`, {
+ ns: "config/groups",
+ defaultValue: humanizeKey(groupKey),
+ })
+ : t(`groups.${groupKey}`, {
+ defaultValue: humanizeKey(groupKey),
+ });
- return {
- key: groupKey,
- label,
- items: ordered,
- };
- })
- .filter(Boolean) as Array<{
- key: string;
- label: string;
- items: (typeof properties)[number][];
- }>;
+ const groupInfo = { groupKey, label, items: ordered };
+ for (const item of ordered) {
+ fieldToGroup.set(item.name, groupInfo);
+ }
+ }
- const ungrouped = items.filter((item) => !grouped.has(item.name));
const isObjectLikeField = (item: (typeof properties)[number]) => {
const fieldSchema = (item.content.props as RjsfElementProps)?.schema;
return fieldSchema?.type === "object";
};
- return (
-
- {groups.map((group) => (
+ // Walk items in order (respects fieldOrder / ui:order).
+ // When we hit the first field of a group, render the whole group block.
+ // Skip subsequent fields that belong to an already-rendered group.
+ const renderedGroups = new Set
();
+ const elements: React.ReactNode[] = [];
+
+ for (const item of items) {
+ const group = fieldToGroup.get(item.name);
+ if (group) {
+ if (renderedGroups.has(group.groupKey)) continue;
+ renderedGroups.add(group.groupKey);
+ elements.push(
@@ -366,25 +369,21 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
{element.content}
))}
-
- ))}
+ ,
+ );
+ } else {
+ elements.push(
+
+ {item.content}
+
,
+ );
+ }
+ }
- {ungrouped.length > 0 && (
- 0 && "pt-2")}>
- {ungrouped.map((element) => (
-
0 && !isObjectLikeField(element) && "px-4",
- )}
- >
- {element.content}
-
- ))}
-
- )}
-
- );
+ return
{elements}
;
};
// Root level renders children directly
@@ -456,7 +455,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
-
+
{isOpen ? (
-
+
) : (
-
+
)}
diff --git a/web/src/components/config-form/theme/widgets/GenAIRolesWidget.tsx b/web/src/components/config-form/theme/widgets/GenAIRolesWidget.tsx
new file mode 100644
index 000000000..92b265b7d
--- /dev/null
+++ b/web/src/components/config-form/theme/widgets/GenAIRolesWidget.tsx
@@ -0,0 +1,109 @@
+import type { WidgetProps } from "@rjsf/utils";
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { Switch } from "@/components/ui/switch";
+import type { ConfigFormContext } from "@/types/configForm";
+
+const GENAI_ROLES = ["embeddings", "vision", "tools"] as const;
+
+function normalizeValue(value: unknown): string[] {
+ if (Array.isArray(value)) {
+ return value.filter((item): item is string => typeof item === "string");
+ }
+
+ if (typeof value === "string" && value.trim()) {
+ return [value.trim()];
+ }
+
+ return [];
+}
+
+function getProviderKey(widgetId: string): string | undefined {
+ const prefix = "root_";
+ const suffix = "_roles";
+
+ if (!widgetId.startsWith(prefix) || !widgetId.endsWith(suffix)) {
+ return undefined;
+ }
+
+ return widgetId.slice(prefix.length, -suffix.length) || undefined;
+}
+
+export function GenAIRolesWidget(props: WidgetProps) {
+ const { id, value, disabled, readonly, onChange, registry } = props;
+ const { t } = useTranslation(["views/settings"]);
+
+ const formContext = registry?.formContext as ConfigFormContext | undefined;
+ const selectedRoles = useMemo(() => normalizeValue(value), [value]);
+ const providerKey = useMemo(() => getProviderKey(id), [id]);
+
+ // Compute occupied roles directly from formData. The computation is
+ // trivially cheap (iterate providers × 3 roles max) so we skip an
+ // intermediate memoization layer whose formData dependency would
+ // never produce a cache hit (new object reference on every change).
+ const occupiedRoles = useMemo(() => {
+ const occupied = new Set
();
+ const fd = formContext?.formData;
+
+ if (!fd || typeof fd !== "object") return occupied;
+
+ for (const [provider, config] of Object.entries(
+ fd as Record,
+ )) {
+ if (provider === providerKey) continue;
+ if (!config || typeof config !== "object" || Array.isArray(config))
+ continue;
+
+ for (const role of normalizeValue(
+ (config as Record).roles,
+ )) {
+ occupied.add(role);
+ }
+ }
+
+ return occupied;
+ }, [formContext?.formData, providerKey]);
+
+ const toggleRole = (role: string, enabled: boolean) => {
+ if (enabled) {
+ if (!selectedRoles.includes(role)) {
+ onChange([...selectedRoles, role]);
+ }
+ return;
+ }
+
+ onChange(selectedRoles.filter((item) => item !== role));
+ };
+
+ return (
+
+
+ {GENAI_ROLES.map((role) => {
+ const checked = selectedRoles.includes(role);
+ const roleDisabled = !checked && occupiedRoles.has(role);
+ const label = t(`configForm.genaiRoles.options.${role}`, {
+ ns: "views/settings",
+ defaultValue: role,
+ });
+
+ return (
+
+
+ {label}
+
+ toggleRole(role, !!enabled)}
+ />
+
+ );
+ })}
+
+
+ );
+}
diff --git a/web/src/components/config-form/theme/widgets/SemanticSearchModelWidget.tsx b/web/src/components/config-form/theme/widgets/SemanticSearchModelWidget.tsx
new file mode 100644
index 000000000..a2f35ec9f
--- /dev/null
+++ b/web/src/components/config-form/theme/widgets/SemanticSearchModelWidget.tsx
@@ -0,0 +1,159 @@
+// Combobox widget for semantic_search.model field.
+// Shows built-in model enum values and GenAI providers with the embeddings role.
+import { useState, useMemo } from "react";
+import type { WidgetProps } from "@rjsf/utils";
+import { useTranslation } from "react-i18next";
+import { Check, ChevronsUpDown } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandGroup,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import type { ConfigFormContext } from "@/types/configForm";
+import { getSizedFieldClassName } from "../utils";
+
+interface ProviderOption {
+ value: string;
+ label: string;
+}
+
+export function SemanticSearchModelWidget(props: WidgetProps) {
+ const { id, value, disabled, readonly, onChange, schema, registry, options } =
+ props;
+ const { t } = useTranslation(["views/settings"]);
+ const [open, setOpen] = useState(false);
+
+ const formContext = registry?.formContext as ConfigFormContext | undefined;
+ const fieldClassName = getSizedFieldClassName(options, "sm");
+
+ // Built-in model options from schema.examples (populated by transformer
+ // collapsing the anyOf enum+string union)
+ const builtInModels: ProviderOption[] = useMemo(() => {
+ const examples = (schema as Record).examples;
+ if (!Array.isArray(examples)) return [];
+ return examples
+ .filter((v): v is string => typeof v === "string")
+ .map((v) => ({ value: v, label: v }));
+ }, [schema]);
+
+ // GenAI providers that have the "embeddings" role
+ const embeddingsProviders: ProviderOption[] = useMemo(() => {
+ const genai = (
+ formContext?.fullConfig as Record | undefined
+ )?.genai;
+ if (!genai || typeof genai !== "object" || Array.isArray(genai)) return [];
+
+ const providers: ProviderOption[] = [];
+ for (const [key, config] of Object.entries(
+ genai as Record,
+ )) {
+ if (!config || typeof config !== "object" || Array.isArray(config))
+ continue;
+ const roles = (config as Record).roles;
+ if (Array.isArray(roles) && roles.includes("embeddings")) {
+ providers.push({ value: key, label: key });
+ }
+ }
+ return providers;
+ }, [formContext?.fullConfig]);
+
+ const currentLabel =
+ builtInModels.find((m) => m.value === value)?.label ??
+ embeddingsProviders.find((p) => p.value === value)?.label ??
+ (typeof value === "string" && value ? value : undefined);
+
+ return (
+
+
+
+ {currentLabel ??
+ t("configForm.semanticSearchModel.placeholder", {
+ ns: "views/settings",
+ defaultValue: "Select model…",
+ })}
+
+
+
+
+
+
+ {builtInModels.length > 0 && (
+
+ {builtInModels.map((model) => (
+ {
+ onChange(model.value);
+ setOpen(false);
+ }}
+ >
+
+ {model.label}
+
+ ))}
+
+ )}
+ {embeddingsProviders.length > 0 && (
+
+ {embeddingsProviders.map((provider) => (
+ {
+ onChange(provider.value);
+ setOpen(false);
+ }}
+ >
+
+ {provider.label}
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/web/src/lib/config-schema/transformer.ts b/web/src/lib/config-schema/transformer.ts
index b7c0e8c35..eb57d4c94 100644
--- a/web/src/lib/config-schema/transformer.ts
+++ b/web/src/lib/config-schema/transformer.ts
@@ -98,8 +98,8 @@ function normalizeNullableSchema(schema: RJSFSchema): RJSFSchema {
: ["null"];
const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj;
const merged: Record = {
- ...rest,
...normalizedNonNullObj,
+ ...rest,
type: mergedType,
};
// When unwrapping a nullable enum, add null to the enum list so
@@ -110,6 +110,39 @@ function normalizeNullableSchema(schema: RJSFSchema): RJSFSchema {
return merged as RJSFSchema;
}
+ // Handle anyOf where a plain string branch subsumes a string-enum branch
+ // (e.g. Union[StrEnum, str] or Union[StrEnum, str, None]).
+ // Collapse to a single string type with enum values preserved as `examples`.
+ const stringBranches = anyOf.filter(
+ (item) =>
+ isSchemaObject(item) &&
+ (item as Record).type === "string",
+ );
+ const enumBranch = stringBranches.find((item) =>
+ Array.isArray((item as Record).enum),
+ );
+ const plainStringBranch = stringBranches.find(
+ (item) => !Array.isArray((item as Record).enum),
+ );
+
+ if (
+ enumBranch &&
+ plainStringBranch &&
+ anyOf.length === stringBranches.length + (hasNull ? 1 : 0)
+ ) {
+ const enumValues = (enumBranch as Record).enum as
+ | unknown[]
+ | undefined;
+ const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj;
+ return {
+ ...rest,
+ type: hasNull ? ["string", "null"] : "string",
+ ...(enumValues && enumValues.length > 0
+ ? { examples: enumValues }
+ : {}),
+ } as RJSFSchema;
+ }
+
return {
...schemaObj,
anyOf: anyOf
@@ -142,8 +175,8 @@ function normalizeNullableSchema(schema: RJSFSchema): RJSFSchema {
: ["null"];
const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj;
const merged: Record = {
- ...rest,
...normalizedNonNullObj,
+ ...rest,
type: mergedType,
};
// When unwrapping a nullable oneOf enum, add null to the enum list.
diff --git a/web/src/views/settings/ProfilesView.tsx b/web/src/views/settings/ProfilesView.tsx
index 3c4436015..d40972816 100644
--- a/web/src/views/settings/ProfilesView.tsx
+++ b/web/src/views/settings/ProfilesView.tsx
@@ -385,7 +385,7 @@ export default function ProfilesView({
{/* Active Profile + Add Profile bar */}
{(hasProfiles || profilesUIEnabled) && (
-
+
{hasProfiles && (
@@ -470,12 +470,12 @@ export default function ProfilesView({
)}
>
-
-
+
+
{isExpanded ? (
-
+
) : (
-
+
)}
-
+
{profileFriendlyNames?.get(profile) ?? profile}
{
e.stopPropagation();
setRenameProfile(profile);
@@ -500,6 +500,8 @@ export default function ProfilesView({
>
+
+
{isActive && (
)}
-
-
{cameras.length > 0
? t("profiles.cameraCount", {
@@ -523,7 +523,7 @@ export default function ProfilesView({
{
e.stopPropagation();
diff --git a/web/src/views/settings/SingleSectionPage.tsx b/web/src/views/settings/SingleSectionPage.tsx
index ee87a9e76..d9c07917b 100644
--- a/web/src/views/settings/SingleSectionPage.tsx
+++ b/web/src/views/settings/SingleSectionPage.tsx
@@ -131,34 +131,35 @@ export function SingleSectionPage({
return (
-
-
-
- {t(`${sectionKey}.label`, { ns: sectionNamespace })}
-
- {i18n.exists(`${sectionKey}.description`, {
- ns: sectionNamespace,
- }) && (
-
- {t(`${sectionKey}.description`, { ns: sectionNamespace })}
-
- )}
- {sectionDocsUrl && (
-
-
- {t("readTheDocumentation", { ns: "common" })}
-
-
-
- )}
-
-
-
+
+
+
+
+ {t(`${sectionKey}.label`, { ns: sectionNamespace })}
+
+ {i18n.exists(`${sectionKey}.description`, {
+ ns: sectionNamespace,
+ }) && (
+
+ {t(`${sectionKey}.description`, { ns: sectionNamespace })}
+
+ )}
+ {sectionDocsUrl && (
+
+
+ {t("readTheDocumentation", { ns: "common" })}
+
+
+
+ )}
+
+ {/* Desktop: badge inline next to title */}
+
{level === "camera" &&
showOverrideIndicator &&
sectionStatus.isOverridden && (
@@ -211,6 +212,40 @@ export function SingleSectionPage({
)}
+ {/* Mobile: badge below title/description */}
+
+ {level === "camera" &&
+ showOverrideIndicator &&
+ sectionStatus.isOverridden && (
+
+ {sectionStatus.overrideSource === "profile"
+ ? t("button.overriddenBaseConfig", {
+ ns: "views/settings",
+ defaultValue: "Overridden (Base Config)",
+ })
+ : t("button.overriddenGlobal", {
+ ns: "views/settings",
+ defaultValue: "Overridden (Global)",
+ })}
+
+ )}
+ {sectionStatus.hasChanges && (
+
+ {t("modified", { ns: "common", defaultValue: "Modified" })}
+
+ )}
+
Date: Fri, 20 Mar 2026 07:50:58 -0600
Subject: [PATCH 2/8] Update ffmpeg (#22548)
* Update ffmpeg builds to 7.1
* Remove unused
* Cleanup
---
docker/main/install_deps.sh | 4 ++--
frigate/ffmpeg_presets.py | 17 ++++-------------
frigate/test/test_ffmpeg_presets.py | 5 ++---
3 files changed, 8 insertions(+), 18 deletions(-)
diff --git a/docker/main/install_deps.sh b/docker/main/install_deps.sh
index f3e00d0da..2dfe07d35 100755
--- a/docker/main/install_deps.sh
+++ b/docker/main/install_deps.sh
@@ -52,7 +52,7 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/7.0
- wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linux64-gpl-7.0.tar.xz"
+ wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-03-19-13-03/ffmpeg-n7.1.3-43-g5a1f107b4c-linux64-gpl-7.1.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz
fi
@@ -64,7 +64,7 @@ if [[ "${TARGETARCH}" == "arm64" ]]; then
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/7.0
- wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linuxarm64-gpl-7.0.tar.xz"
+ wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-03-19-13-03/ffmpeg-n7.1.3-43-g5a1f107b4c-linuxarm64-gpl-7.1.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz
fi
diff --git a/frigate/ffmpeg_presets.py b/frigate/ffmpeg_presets.py
index ec40bf0c5..0652ec645 100644
--- a/frigate/ffmpeg_presets.py
+++ b/frigate/ffmpeg_presets.py
@@ -120,10 +120,10 @@ PRESETS_HW_ACCEL_DECODE["preset-rk-h265"] = PRESETS_HW_ACCEL_DECODE[
PRESETS_HW_ACCEL_SCALE = {
"preset-rpi-64-h264": "-r {0} -vf fps={0},scale={1}:{2}",
"preset-rpi-64-h265": "-r {0} -vf fps={0},scale={1}:{2}",
- FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12,eq=gamma=1.4:gamma_weight=0.5",
- "preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=framerate={0}:w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
- "preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=framerate={0}:w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
- FFMPEG_HWACCEL_NVIDIA: "-r {0} -vf fps={0},scale_cuda=w={1}:h={2},hwdownload,format=nv12,eq=gamma=1.4:gamma_weight=0.5",
+ FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12",
+ "preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
+ "preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
+ FFMPEG_HWACCEL_NVIDIA: "-r {0} -vf fps={0},scale_cuda=w={1}:h={2},hwdownload,format=nv12",
"preset-jetson-h264": "-r {0}", # scaled in decoder
"preset-jetson-h265": "-r {0}", # scaled in decoder
FFMPEG_HWACCEL_RKMPP: "-r {0} -vf scale_rkrga=w={1}:h={2}:format=yuv420p:force_original_aspect_ratio=0,hwmap=mode=read,format=yuv420p",
@@ -242,15 +242,6 @@ def parse_preset_hardware_acceleration_scale(
else:
scale = PRESETS_HW_ACCEL_SCALE.get(arg, PRESETS_HW_ACCEL_SCALE["default"])
- if (
- ",hwdownload,format=nv12,eq=gamma=1.4:gamma_weight=0.5" in scale
- and os.environ.get("FFMPEG_DISABLE_GAMMA_EQUALIZER") is not None
- ):
- scale = scale.replace(
- ",hwdownload,format=nv12,eq=gamma=1.4:gamma_weight=0.5",
- ":format=nv12,hwdownload,format=nv12,format=yuv420p",
- )
-
scale = scale.format(fps, width, height).split(" ")
scale.extend(detect_args)
return scale
diff --git a/frigate/test/test_ffmpeg_presets.py b/frigate/test/test_ffmpeg_presets.py
index 92df0571b..86fdd5f3a 100644
--- a/frigate/test/test_ffmpeg_presets.py
+++ b/frigate/test/test_ffmpeg_presets.py
@@ -73,9 +73,8 @@ class TestFfmpegPresets(unittest.TestCase):
assert "preset-nvidia-h264" not in (
" ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"])
)
- assert (
- "fps=10,scale_cuda=w=2560:h=1920,hwdownload,format=nv12,eq=gamma=1.4:gamma_weight=0.5"
- in (" ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]))
+ assert "fps=10,scale_cuda=w=2560:h=1920,hwdownload,format=nv12" in (
+ " ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"])
)
def test_default_ffmpeg_input_arg_preset(self):
From 23820718ee9c6d202af5c1bf1e170ea54d65d364 Mon Sep 17 00:00:00 2001
From: Nicolas Mowen
Date: Fri, 20 Mar 2026 09:02:34 -0600
Subject: [PATCH 3/8] Fix splintered language keys (#22550)
---
web/public/locales/{nb_NO => nb-NO}/config/cameras.json | 0
web/public/locales/{nb_NO => nb-NO}/config/global.json | 0
web/public/locales/{nb_NO => nb-NO}/config/groups.json | 0
web/public/locales/{nb_NO => nb-NO}/config/validation.json | 0
web/public/locales/{pt_BR => pt-BR}/config/cameras.json | 0
web/public/locales/{pt_BR => pt-BR}/config/global.json | 0
web/public/locales/{pt_BR => pt-BR}/config/groups.json | 0
web/public/locales/{pt_BR => pt-BR}/config/validation.json | 0
web/public/locales/{yue_Hant => yue-Hant}/config/cameras.json | 0
web/public/locales/{yue_Hant => yue-Hant}/config/global.json | 0
web/public/locales/{yue_Hant => yue-Hant}/config/groups.json | 0
web/public/locales/{yue_Hant => yue-Hant}/config/validation.json | 0
web/public/locales/{zh_Hans => zh-CN}/config/cameras.json | 0
web/public/locales/{zh_Hans => zh-CN}/config/global.json | 0
web/public/locales/{zh_Hans => zh-CN}/config/groups.json | 0
web/public/locales/{zh-Hans => zh-CN}/config/validation.json | 0
web/public/locales/{zh_Hant => zh-Hant}/config/cameras.json | 0
web/public/locales/{zh_Hant => zh-Hant}/config/global.json | 0
web/public/locales/{zh_Hant => zh-Hant}/config/groups.json | 0
web/public/locales/{zh_Hant => zh-Hant}/config/validation.json | 0
20 files changed, 0 insertions(+), 0 deletions(-)
rename web/public/locales/{nb_NO => nb-NO}/config/cameras.json (100%)
rename web/public/locales/{nb_NO => nb-NO}/config/global.json (100%)
rename web/public/locales/{nb_NO => nb-NO}/config/groups.json (100%)
rename web/public/locales/{nb_NO => nb-NO}/config/validation.json (100%)
rename web/public/locales/{pt_BR => pt-BR}/config/cameras.json (100%)
rename web/public/locales/{pt_BR => pt-BR}/config/global.json (100%)
rename web/public/locales/{pt_BR => pt-BR}/config/groups.json (100%)
rename web/public/locales/{pt_BR => pt-BR}/config/validation.json (100%)
rename web/public/locales/{yue_Hant => yue-Hant}/config/cameras.json (100%)
rename web/public/locales/{yue_Hant => yue-Hant}/config/global.json (100%)
rename web/public/locales/{yue_Hant => yue-Hant}/config/groups.json (100%)
rename web/public/locales/{yue_Hant => yue-Hant}/config/validation.json (100%)
rename web/public/locales/{zh_Hans => zh-CN}/config/cameras.json (100%)
rename web/public/locales/{zh_Hans => zh-CN}/config/global.json (100%)
rename web/public/locales/{zh_Hans => zh-CN}/config/groups.json (100%)
rename web/public/locales/{zh-Hans => zh-CN}/config/validation.json (100%)
rename web/public/locales/{zh_Hant => zh-Hant}/config/cameras.json (100%)
rename web/public/locales/{zh_Hant => zh-Hant}/config/global.json (100%)
rename web/public/locales/{zh_Hant => zh-Hant}/config/groups.json (100%)
rename web/public/locales/{zh_Hant => zh-Hant}/config/validation.json (100%)
diff --git a/web/public/locales/nb_NO/config/cameras.json b/web/public/locales/nb-NO/config/cameras.json
similarity index 100%
rename from web/public/locales/nb_NO/config/cameras.json
rename to web/public/locales/nb-NO/config/cameras.json
diff --git a/web/public/locales/nb_NO/config/global.json b/web/public/locales/nb-NO/config/global.json
similarity index 100%
rename from web/public/locales/nb_NO/config/global.json
rename to web/public/locales/nb-NO/config/global.json
diff --git a/web/public/locales/nb_NO/config/groups.json b/web/public/locales/nb-NO/config/groups.json
similarity index 100%
rename from web/public/locales/nb_NO/config/groups.json
rename to web/public/locales/nb-NO/config/groups.json
diff --git a/web/public/locales/nb_NO/config/validation.json b/web/public/locales/nb-NO/config/validation.json
similarity index 100%
rename from web/public/locales/nb_NO/config/validation.json
rename to web/public/locales/nb-NO/config/validation.json
diff --git a/web/public/locales/pt_BR/config/cameras.json b/web/public/locales/pt-BR/config/cameras.json
similarity index 100%
rename from web/public/locales/pt_BR/config/cameras.json
rename to web/public/locales/pt-BR/config/cameras.json
diff --git a/web/public/locales/pt_BR/config/global.json b/web/public/locales/pt-BR/config/global.json
similarity index 100%
rename from web/public/locales/pt_BR/config/global.json
rename to web/public/locales/pt-BR/config/global.json
diff --git a/web/public/locales/pt_BR/config/groups.json b/web/public/locales/pt-BR/config/groups.json
similarity index 100%
rename from web/public/locales/pt_BR/config/groups.json
rename to web/public/locales/pt-BR/config/groups.json
diff --git a/web/public/locales/pt_BR/config/validation.json b/web/public/locales/pt-BR/config/validation.json
similarity index 100%
rename from web/public/locales/pt_BR/config/validation.json
rename to web/public/locales/pt-BR/config/validation.json
diff --git a/web/public/locales/yue_Hant/config/cameras.json b/web/public/locales/yue-Hant/config/cameras.json
similarity index 100%
rename from web/public/locales/yue_Hant/config/cameras.json
rename to web/public/locales/yue-Hant/config/cameras.json
diff --git a/web/public/locales/yue_Hant/config/global.json b/web/public/locales/yue-Hant/config/global.json
similarity index 100%
rename from web/public/locales/yue_Hant/config/global.json
rename to web/public/locales/yue-Hant/config/global.json
diff --git a/web/public/locales/yue_Hant/config/groups.json b/web/public/locales/yue-Hant/config/groups.json
similarity index 100%
rename from web/public/locales/yue_Hant/config/groups.json
rename to web/public/locales/yue-Hant/config/groups.json
diff --git a/web/public/locales/yue_Hant/config/validation.json b/web/public/locales/yue-Hant/config/validation.json
similarity index 100%
rename from web/public/locales/yue_Hant/config/validation.json
rename to web/public/locales/yue-Hant/config/validation.json
diff --git a/web/public/locales/zh_Hans/config/cameras.json b/web/public/locales/zh-CN/config/cameras.json
similarity index 100%
rename from web/public/locales/zh_Hans/config/cameras.json
rename to web/public/locales/zh-CN/config/cameras.json
diff --git a/web/public/locales/zh_Hans/config/global.json b/web/public/locales/zh-CN/config/global.json
similarity index 100%
rename from web/public/locales/zh_Hans/config/global.json
rename to web/public/locales/zh-CN/config/global.json
diff --git a/web/public/locales/zh_Hans/config/groups.json b/web/public/locales/zh-CN/config/groups.json
similarity index 100%
rename from web/public/locales/zh_Hans/config/groups.json
rename to web/public/locales/zh-CN/config/groups.json
diff --git a/web/public/locales/zh-Hans/config/validation.json b/web/public/locales/zh-CN/config/validation.json
similarity index 100%
rename from web/public/locales/zh-Hans/config/validation.json
rename to web/public/locales/zh-CN/config/validation.json
diff --git a/web/public/locales/zh_Hant/config/cameras.json b/web/public/locales/zh-Hant/config/cameras.json
similarity index 100%
rename from web/public/locales/zh_Hant/config/cameras.json
rename to web/public/locales/zh-Hant/config/cameras.json
diff --git a/web/public/locales/zh_Hant/config/global.json b/web/public/locales/zh-Hant/config/global.json
similarity index 100%
rename from web/public/locales/zh_Hant/config/global.json
rename to web/public/locales/zh-Hant/config/global.json
diff --git a/web/public/locales/zh_Hant/config/groups.json b/web/public/locales/zh-Hant/config/groups.json
similarity index 100%
rename from web/public/locales/zh_Hant/config/groups.json
rename to web/public/locales/zh-Hant/config/groups.json
diff --git a/web/public/locales/zh_Hant/config/validation.json b/web/public/locales/zh-Hant/config/validation.json
similarity index 100%
rename from web/public/locales/zh_Hant/config/validation.json
rename to web/public/locales/zh-Hant/config/validation.json
From 34a06ac77b0d36ad63da9fa28ebc0978a9f6a528 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Fri, 20 Mar 2026 12:00:28 -0500
Subject: [PATCH 4/8] Tweaks (#22552)
* add weblate to contributing docs
* show vertex points in dialog only in motion search
---
CONTRIBUTING.md | 11 ++++++
.../motion-search/MotionSearchROICanvas.tsx | 37 ++++++++++---------
2 files changed, 30 insertions(+), 18 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 150b85bcb..9cb575d37 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -122,6 +122,17 @@ docs/ # Documentation site
migrations/ # Database migrations
```
+## Translations
+
+Frigate uses [Weblate](https://hosted.weblate.org/projects/frigate-nvr/) for managing language translations. If you'd like to help translate Frigate into your language:
+
+1. Visit the [Frigate project on Weblate](https://hosted.weblate.org/projects/frigate-nvr/).
+2. Create an account or log in.
+3. Browse the available languages and select the one you'd like to contribute to, or request a new language.
+4. Translate strings directly in the Weblate interface — no code changes or pull requests needed.
+
+Translation contributions through Weblate are automatically synced to the repository. Please do not submit pull requests for translation changes — use Weblate instead so that translations are properly tracked and coordinated.
+
## Resources
- [Documentation](https://docs.frigate.video)
diff --git a/web/src/views/motion-search/MotionSearchROICanvas.tsx b/web/src/views/motion-search/MotionSearchROICanvas.tsx
index f393a9cfb..43fac7a48 100644
--- a/web/src/views/motion-search/MotionSearchROICanvas.tsx
+++ b/web/src/views/motion-search/MotionSearchROICanvas.tsx
@@ -372,24 +372,25 @@ export default function MotionSearchROICanvas({
/>
)}
- {/* Vertex points */}
- {scaledPoints.map((point, index) => (
- handlePointDragMove(e, index)}
- onMouseOver={(e) => handleMouseOverPoint(e, index)}
- onMouseOut={(e) => handleMouseOutPoint(e, index)}
- onContextMenu={(e) => handleContextMenu(e, index)}
- />
- ))}
+ {/* Vertex points (only shown in interactive/dialog mode) */}
+ {isInteractive &&
+ scaledPoints.map((point, index) => (
+ handlePointDragMove(e, index)}
+ onMouseOver={(e) => handleMouseOverPoint(e, index)}
+ onMouseOut={(e) => handleMouseOutPoint(e, index)}
+ onContextMenu={(e) => handleContextMenu(e, index)}
+ />
+ ))}
)}
From 373bcadef885c14ddb958fd7b0e0cf5c90100a28 Mon Sep 17 00:00:00 2001
From: Nicolas Mowen
Date: Fri, 20 Mar 2026 11:01:46 -0600
Subject: [PATCH 5/8] Dynamic vision chat (#22551)
* Improve live context image handling
* Improve chat handling
* Cleanup
---
frigate/api/chat.py | 149 +++++++++++++++++++-------
frigate/api/defs/request/chat_body.py | 7 --
2 files changed, 108 insertions(+), 48 deletions(-)
diff --git a/frigate/api/chat.py b/frigate/api/chat.py
index b4eee6a9d..900fa86cc 100644
--- a/frigate/api/chat.py
+++ b/frigate/api/chat.py
@@ -201,10 +201,9 @@ def get_tool_definitions() -> List[Dict[str, Any]]:
"function": {
"name": "get_live_context",
"description": (
- "Get the current detection information for a camera: objects being tracked, "
+ "Get the current live image and detection information for a camera: objects being tracked, "
"zones, timestamps. Use this to understand what is visible in the live view. "
- "Call this when the user has included a live image (via include_live_image) or "
- "when answering questions about what is happening right now on a specific camera."
+ "Call this when answering questions about what is happening right now on a specific camera."
),
"parameters": {
"type": "object",
@@ -384,12 +383,54 @@ async def _execute_get_live_context(
"stationary": obj_dict.get("stationary", False),
}
- return {
+ result: Dict[str, Any] = {
"camera": camera,
"timestamp": frame_time,
"detections": list(tracked_objects_dict.values()),
}
+ # Grab live frame and handle based on provider configuration
+ image_url = await _get_live_frame_image_url(request, camera, allowed_cameras)
+ if image_url:
+ genai_manager = request.app.genai_manager
+ if genai_manager.tool_client is genai_manager.vision_client:
+ # Same provider handles both roles — pass image URL so it can
+ # be injected as a user message (images can't be in tool results)
+ result["_image_url"] = image_url
+ elif genai_manager.vision_client is not None:
+ # Separate vision provider — have it describe the image,
+ # providing detection context so it knows what to focus on
+ frame_bytes = _decode_data_url(image_url)
+ if frame_bytes:
+ detections = result.get("detections", [])
+ if detections:
+ detection_lines = []
+ for d in detections:
+ parts = [d.get("label", "unknown")]
+ if d.get("sub_label"):
+ parts.append(f"({d['sub_label']})")
+ if d.get("zones"):
+ parts.append(f"in {', '.join(d['zones'])}")
+ detection_lines.append(" ".join(parts))
+ context = (
+ "The following objects are currently being tracked: "
+ + "; ".join(detection_lines)
+ + "."
+ )
+ else:
+ context = "No objects are currently being tracked."
+
+ description = genai_manager.vision_client._send(
+ f"Describe what you see in this security camera image. "
+ f"{context} Focus on the scene, any visible activity, "
+ f"and details about the tracked objects.",
+ [frame_bytes],
+ )
+ if description:
+ result["image_description"] = description
+
+ return result
+
except Exception as e:
logger.error(f"Error executing get_live_context: {e}", exc_info=True)
return {
@@ -405,8 +446,8 @@ async def _get_live_frame_image_url(
"""
Fetch the current live frame for a camera as a base64 data URL.
- Returns None if the frame cannot be retrieved. Used when include_live_image
- is set to attach the image to the first user message.
+ Returns None if the frame cannot be retrieved. Used by get_live_context
+ to attach the live image to the conversation.
"""
if (
camera not in allowed_cameras
@@ -421,12 +462,12 @@ async def _get_live_frame_image_url(
if frame is None:
return None
height, width = frame.shape[:2]
- max_dimension = 1024
- if height > max_dimension or width > max_dimension:
- scale = max_dimension / max(height, width)
+ target_height = 480
+ if height > target_height:
+ scale = target_height / height
frame = cv2.resize(
frame,
- (int(width * scale), int(height * scale)),
+ (int(width * scale), target_height),
interpolation=cv2.INTER_AREA,
)
_, img_encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
@@ -437,6 +478,17 @@ async def _get_live_frame_image_url(
return None
+def _decode_data_url(data_url: str) -> Optional[bytes]:
+ """Decode a base64 data URL to raw bytes."""
+ try:
+ # Format: data:image/jpeg;base64,
+ _, encoded = data_url.split(",", 1)
+ return base64.b64decode(encoded)
+ except (ValueError, Exception) as e:
+ logger.debug("Failed to decode data URL: %s", e)
+ return None
+
+
async def _execute_set_camera_state(
request: Request,
arguments: Dict[str, Any],
@@ -527,12 +579,18 @@ async def _execute_pending_tools(
pending_tool_calls: List[Dict[str, Any]],
request: Request,
allowed_cameras: List[str],
-) -> tuple[List[ToolCall], List[Dict[str, Any]]]:
+) -> tuple[List[ToolCall], List[Dict[str, Any]], List[Dict[str, Any]]]:
"""
- Execute a list of tool calls; return (ToolCall list for API response, tool result dicts for conversation).
+ Execute a list of tool calls.
+
+ Returns:
+ (ToolCall list for API response,
+ tool result dicts for conversation,
+ extra messages to inject after tool results — e.g. user messages with images)
"""
tool_calls_out: List[ToolCall] = []
tool_results: List[Dict[str, Any]] = []
+ extra_messages: List[Dict[str, Any]] = []
for tool_call in pending_tool_calls:
tool_name = tool_call["name"]
tool_args = tool_call.get("arguments") or {}
@@ -569,6 +627,27 @@ async def _execute_pending_tools(
for evt in tool_result
if isinstance(evt, dict)
]
+
+ # Extract _image_url from get_live_context results — images can
+ # only be sent in user messages, not tool results
+ if isinstance(tool_result, dict) and "_image_url" in tool_result:
+ image_url = tool_result.pop("_image_url")
+ extra_messages.append(
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": f"Here is the current live image from camera '{tool_result.get('camera', 'unknown')}'.",
+ },
+ {
+ "type": "image_url",
+ "image_url": {"url": image_url},
+ },
+ ],
+ }
+ )
+
result_content = (
json.dumps(tool_result)
if isinstance(tool_result, (dict, list))
@@ -604,7 +683,7 @@ async def _execute_pending_tools(
"content": error_content,
}
)
- return (tool_calls_out, tool_results)
+ return (tool_calls_out, tool_results, extra_messages)
@router.post(
@@ -660,7 +739,13 @@ async def chat_completion(
if camera_config.friendly_name
else camera_id.replace("_", " ").title()
)
- cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
+ zone_names = list(camera_config.zones.keys())
+ if zone_names:
+ cameras_info.append(
+ f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})"
+ )
+ else:
+ cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
cameras_section = ""
if cameras_info:
@@ -670,14 +755,6 @@ async def chat_completion(
+ "\n\nWhen users refer to cameras by their friendly name (e.g., 'Back Deck Camera'), use the corresponding camera ID (e.g., 'back_deck_cam') in tool calls."
)
- live_image_note = ""
- if body.include_live_image:
- live_image_note = (
- f"\n\nThe first user message includes a live image from camera "
- f"'{body.include_live_image}'. Use get_live_context for that camera to get "
- "current detection details (objects, zones) to aid in understanding the image."
- )
-
system_prompt = f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events.
Current server local date and time: {current_date_str} at {current_time_str}
@@ -687,7 +764,7 @@ Do not start your response with phrases like "I will check...", "Let me see...",
Always present times to the user in the server's local timezone. When tool results include start_time_local and end_time_local, use those exact strings when listing or describing detection times—do not convert or invent timestamps. Do not use UTC or ISO format with Z for the user-facing answer unless the tool result only provides Unix timestamps without local time fields.
When users ask about "today", "yesterday", "this week", etc., use the current date above as reference.
When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today).
-Always be accurate with time calculations based on the current date provided.{cameras_section}{live_image_note}"""
+Always be accurate with time calculations based on the current date provided.{cameras_section}"""
conversation.append(
{
@@ -696,7 +773,6 @@ Always be accurate with time calculations based on the current date provided.{ca
}
)
- first_user_message_seen = False
for msg in body.messages:
msg_dict = {
"role": msg.role,
@@ -707,21 +783,6 @@ Always be accurate with time calculations based on the current date provided.{ca
if msg.name:
msg_dict["name"] = msg.name
- if (
- msg.role == "user"
- and not first_user_message_seen
- and body.include_live_image
- ):
- first_user_message_seen = True
- image_url = await _get_live_frame_image_url(
- request, body.include_live_image, allowed_cameras
- )
- if image_url:
- msg_dict["content"] = [
- {"type": "text", "text": msg.content},
- {"type": "image_url", "image_url": {"url": image_url}},
- ]
-
conversation.append(msg_dict)
tool_iterations = 0
@@ -779,11 +840,16 @@ Always be accurate with time calculations based on the current date provided.{ca
msg.get("content"), pending
)
)
- executed_calls, tool_results = await _execute_pending_tools(
+ (
+ executed_calls,
+ tool_results,
+ extra_msgs,
+ ) = await _execute_pending_tools(
pending, request, allowed_cameras
)
stream_tool_calls.extend(executed_calls)
conversation.extend(tool_results)
+ conversation.extend(extra_msgs)
yield (
json.dumps(
{
@@ -890,11 +956,12 @@ Always be accurate with time calculations based on the current date provided.{ca
f"Tool calls detected (iteration {tool_iterations}/{max_iterations}): "
f"{len(pending_tool_calls)} tool(s) to execute"
)
- executed_calls, tool_results = await _execute_pending_tools(
+ executed_calls, tool_results, extra_msgs = await _execute_pending_tools(
pending_tool_calls, request, allowed_cameras
)
tool_calls.extend(executed_calls)
conversation.extend(tool_results)
+ conversation.extend(extra_msgs)
logger.debug(
f"Added {len(tool_results)} tool result(s) to conversation. "
f"Continuing with next LLM call..."
diff --git a/frigate/api/defs/request/chat_body.py b/frigate/api/defs/request/chat_body.py
index 3a67cd038..79ca3a6fe 100644
--- a/frigate/api/defs/request/chat_body.py
+++ b/frigate/api/defs/request/chat_body.py
@@ -32,13 +32,6 @@ class ChatCompletionRequest(BaseModel):
le=10,
description="Maximum number of tool call iterations (default: 5)",
)
- include_live_image: Optional[str] = Field(
- default=None,
- description=(
- "If set, the current live frame from this camera is attached to the first "
- "user message as multimodal content. Use with get_live_context for detection info."
- ),
- )
stream: bool = Field(
default=False,
description="If true, stream the final assistant response in the body as newline-delimited JSON.",
From acd10d0e08267d2af227defcf92320d36e96829e Mon Sep 17 00:00:00 2001
From: Nicolas Mowen
Date: Fri, 20 Mar 2026 11:38:22 -0600
Subject: [PATCH 6/8] Various Tweaks (#22554)
* Change review GenAI metric to seconds
* Update API docs
---
docs/static/frigate-api.yaml | 2803 ++++++++++++++++----
web/src/types/graph.ts | 4 +-
web/src/views/system/EnrichmentMetrics.tsx | 9 +-
3 files changed, 2319 insertions(+), 497 deletions(-)
diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml
index 2063514ac..caef92eac 100644
--- a/docs/static/frigate-api.yaml
+++ b/docs/static/frigate-api.yaml
@@ -10,30 +10,51 @@ servers:
- url: http://localhost:5001/api
paths:
+ /auth/first_time_login:
+ get:
+ tags:
+ - Auth
+ summary: First Time Login
+ description: |-
+ Return whether the admin first-time login help flag is set in config.
+
+ This endpoint is intentionally unauthenticated so the login page can
+ query it before a user is authenticated.
+ operationId: first_time_login_auth_first_time_login_get
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
/auth:
get:
tags:
- Auth
summary: Authenticate request
- description: |-
+ description: >-
Authenticates the current request based on proxy headers or JWT token.
- This endpoint verifies authentication credentials and manages JWT token refresh.
- On success, no JSON body is returned; authentication state is communicated via response headers and cookies.
+ This endpoint verifies authentication credentials and manages JWT token
+ refresh. On success, no JSON body is returned; authentication state is
+ communicated via response headers and cookies.
operationId: auth_auth_get
responses:
"202":
- description: Authentication Accepted (no response body, different headers depending on auth method)
+ description: Authentication Accepted (no response body)
+ content:
+ application/json:
+ schema: {}
headers:
remote-user:
description: Authenticated username or "viewer" in proxy-only mode
schema:
type: string
remote-role:
- description: Resolved role (e.g., admin, viewer, or custom)
+ description: "Resolved role (e.g., admin, viewer, or custom)"
schema:
type: string
Set-Cookie:
- description: May include refreshed JWT cookie ("frigate-token") when applicable
+ description: May include refreshed JWT cookie when applicable
schema:
type: string
"401":
@@ -43,9 +64,10 @@ paths:
tags:
- Auth
summary: Get user profile
- description: |-
- Returns the current authenticated user's profile including username, role, and allowed cameras.
- This endpoint requires authentication and returns information about the user's permissions.
+ description: >-
+ Returns the current authenticated user's profile including username,
+ role, and allowed cameras. This endpoint requires authentication and
+ returns information about the user's permissions.
operationId: profile_profile_get
responses:
"200":
@@ -53,16 +75,14 @@ paths:
content:
application/json:
schema: {}
- "401":
- description: Unauthorized
/logout:
get:
tags:
- Auth
summary: Logout user
- description: |-
- Logs out the current user by clearing the session cookie.
- After logout, subsequent requests will require re-authentication.
+ description: >-
+ Logs out the current user by clearing the session cookie. After logout,
+ subsequent requests will require re-authentication.
operationId: logout_logout_get
responses:
"200":
@@ -70,21 +90,25 @@ paths:
content:
application/json:
schema: {}
- "303":
- description: See Other (redirects to login page)
/login:
post:
tags:
- Auth
summary: Login with credentials
- description: |-
- Authenticates a user with username and password.
- Returns a JWT token as a secure HTTP-only cookie that can be used for subsequent API requests.
- The JWT token can also be retrieved from the response and used as a Bearer token in the Authorization header.
+ description: >-
+ Authenticates a user with username and password. Returns a JWT token as
+ a secure HTTP-only cookie that can be used for subsequent API requests.
+ The JWT token can also be retrieved from the response and used as a
+ Bearer token in the Authorization header.
+
Example using Bearer token:
+
```
- curl -H "Authorization: Bearer " https://frigate_ip:8971/api/profile
+
+ curl -H "Authorization: Bearer "
+ https://frigate_ip:8971/api/profile
+
```
operationId: login_login_post
requestBody:
@@ -99,11 +123,6 @@ paths:
content:
application/json:
schema: {}
- "401":
- description: Login Failed - Invalid credentials
- content:
- application/json:
- schema: {}
"422":
description: Validation Error
content:
@@ -115,9 +134,9 @@ paths:
tags:
- Auth
summary: Get all users
- description: |-
- Returns a list of all users with their usernames and roles.
- Requires admin role. Each user object contains the username and assigned role.
+ description: >-
+ Returns a list of all users with their usernames and roles. Requires
+ admin role. Each user object contains the username and assigned role.
operationId: get_users_users_get
responses:
"200":
@@ -125,19 +144,13 @@ paths:
content:
application/json:
schema: {}
- "403":
- description: Forbidden - Admin role required
post:
tags:
- Auth
summary: Create new user
- description: |-
+ description: >-
Creates a new user with the specified username, password, and role.
- Requires admin role. Password must meet strength requirements:
- - Minimum 8 characters
- - At least one uppercase letter
- - At least one digit
- - At least one special character (!@#$%^&*(),.?":{}\|<>)
+ Requires admin role. Password must be at least 12 characters long.
operationId: create_user_users_post
requestBody:
required: true
@@ -151,25 +164,18 @@ paths:
content:
application/json:
schema: {}
- "400":
- description: Bad Request - Invalid username or role
- content:
- application/json:
- schema: {}
- "403":
- description: Forbidden - Admin role required
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /users/{username}:
+ "/users/{username}":
delete:
tags:
- Auth
summary: Delete user
- description: |-
+ description: >-
Deletes a user by username. The built-in admin user cannot be deleted.
Requires admin role. Returns success message or error if user not found.
operationId: delete_user_users__username__delete
@@ -180,36 +186,29 @@ paths:
schema:
type: string
title: Username
- description: The username of the user to delete
responses:
"200":
description: Successful Response
content:
application/json:
schema: {}
- "403":
- description: Forbidden - Cannot delete admin user or admin role required
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /users/{username}/password:
+ "/users/{username}/password":
put:
tags:
- Auth
summary: Update user password
- description: |-
- Updates a user's password. Users can only change their own password unless they have admin role.
- Requires the current password to verify identity for non-admin users.
- Password must meet strength requirements:
- - Minimum 8 characters
- - At least one uppercase letter
- - At least one digit
- - At least one special character (!@#$%^&*(),.?":{}\|<>)
-
- If user changes their own password, a new JWT cookie is automatically issued.
+ description: >-
+ Updates a user's password. Users can only change their own password
+ unless they have admin role. Requires the current password to verify
+ identity for non-admin users. Password must be at least 12 characters
+ long. If user changes their own password, a new JWT cookie is
+ automatically issued.
operationId: update_password_users__username__password_put
parameters:
- name: username
@@ -218,7 +217,6 @@ paths:
schema:
type: string
title: Username
- description: The username of the user whose password to update
requestBody:
required: true
content:
@@ -231,28 +229,21 @@ paths:
content:
application/json:
schema: {}
- "400":
- description: Bad Request - Current password required or password doesn't meet requirements
- "401":
- description: Unauthorized - Current password is incorrect
- "403":
- description: Forbidden - Viewers can only update their own password
- "404":
- description: Not Found - User not found
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /users/{username}/role:
+ "/users/{username}/role":
put:
tags:
- Auth
summary: Update user role
- description: |-
- Updates a user's role. The built-in admin user's role cannot be modified.
- Requires admin role. Valid roles are defined in the configuration.
+ description: >-
+ Updates a user's role. The built-in admin user's role cannot be
+ modified. Requires admin role. Valid roles are defined in the
+ configuration.
operationId: update_role_users__username__role_put
parameters:
- name: username
@@ -261,7 +252,6 @@ paths:
schema:
type: string
title: Username
- description: The username of the user whose role to update
requestBody:
required: true
content:
@@ -274,10 +264,482 @@ paths:
content:
application/json:
schema: {}
- "400":
- description: Bad Request - Invalid role
- "403":
- description: Forbidden - Cannot modify admin user's role or admin role required
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ /go2rtc/streams:
+ get:
+ tags:
+ - Camera
+ summary: Go2Rtc Streams
+ operationId: go2rtc_streams_go2rtc_streams_get
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "/go2rtc/streams/{camera_name}":
+ get:
+ tags:
+ - Camera
+ summary: Go2Rtc Camera Stream
+ operationId: go2rtc_camera_stream_go2rtc_streams__camera_name__get
+ parameters:
+ - name: camera_name
+ in: path
+ required: true
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Camera Name
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/go2rtc/streams/{stream_name}":
+ put:
+ tags:
+ - Camera
+ summary: Go2Rtc Add Stream
+ description: Add or update a go2rtc stream configuration.
+ operationId: go2rtc_add_stream_go2rtc_streams__stream_name__put
+ parameters:
+ - name: stream_name
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Stream Name
+ - name: src
+ in: query
+ required: false
+ schema:
+ type: string
+ default: ""
+ title: Src
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ delete:
+ tags:
+ - Camera
+ summary: Go2Rtc Delete Stream
+ description: Delete a go2rtc stream.
+ operationId: go2rtc_delete_stream_go2rtc_streams__stream_name__delete
+ parameters:
+ - name: stream_name
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Stream Name
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ /ffprobe:
+ get:
+ tags:
+ - Camera
+ summary: Ffprobe
+ operationId: ffprobe_ffprobe_get
+ parameters:
+ - name: paths
+ in: query
+ required: false
+ schema:
+ type: string
+ default: ""
+ title: Paths
+ - name: detailed
+ in: query
+ required: false
+ schema:
+ type: boolean
+ default: false
+ title: Detailed
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ /ffprobe/snapshot:
+ get:
+ tags:
+ - Camera
+ summary: Ffprobe Snapshot
+ description: Get a snapshot from a stream URL using ffmpeg.
+ operationId: ffprobe_snapshot_ffprobe_snapshot_get
+ parameters:
+ - name: url
+ in: query
+ required: false
+ schema:
+ type: string
+ default: ""
+ title: Url
+ - name: timeout
+ in: query
+ required: false
+ schema:
+ type: integer
+ default: 10
+ title: Timeout
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ /reolink/detect:
+ get:
+ tags:
+ - Camera
+ summary: Reolink Detect
+ description: >-
+ Detect Reolink camera capabilities and recommend optimal protocol.
+
+
+ Queries the Reolink camera API to determine the camera's resolution
+
+ and recommends either http-flv (for 5MP and below) or rtsp (for higher
+ resolutions).
+ operationId: reolink_detect_reolink_detect_get
+ parameters:
+ - name: host
+ in: query
+ required: false
+ schema:
+ type: string
+ default: ""
+ title: Host
+ - name: username
+ in: query
+ required: false
+ schema:
+ type: string
+ default: ""
+ title: Username
+ - name: password
+ in: query
+ required: false
+ schema:
+ type: string
+ default: ""
+ title: Password
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ /onvif/probe:
+ get:
+ tags:
+ - Camera
+ summary: Probe ONVIF device
+ description: >-
+ Probe an ONVIF device to determine capabilities and optionally test
+ available stream URIs. Query params: host (required), port (default 80),
+ username, password, test (boolean), auth_type (basic or digest, default
+ basic).
+ operationId: onvif_probe_onvif_probe_get
+ parameters:
+ - name: host
+ in: query
+ required: false
+ schema:
+ type: string
+ title: Host
+ - name: port
+ in: query
+ required: false
+ schema:
+ type: integer
+ default: 80
+ title: Port
+ - name: username
+ in: query
+ required: false
+ schema:
+ type: string
+ default: ""
+ title: Username
+ - name: password
+ in: query
+ required: false
+ schema:
+ type: string
+ default: ""
+ title: Password
+ - name: test
+ in: query
+ required: false
+ schema:
+ type: boolean
+ default: false
+ title: Test
+ - name: auth_type
+ in: query
+ required: false
+ schema:
+ type: string
+ default: basic
+ title: Auth Type
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/cameras/{camera_name}":
+ delete:
+ tags:
+ - Camera
+ summary: Delete Camera
+ description: |-
+ Delete a camera and all its associated data.
+
+ Removes the camera from config, stops processes, and cleans up
+ all database entries and media files.
+
+ Args:
+ camera_name: Name of the camera to delete
+ delete_exports: Whether to also delete exports for this camera
+ operationId: delete_camera_cameras__camera_name__delete
+ parameters:
+ - name: camera_name
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Camera Name
+ - name: delete_exports
+ in: query
+ required: false
+ schema:
+ type: boolean
+ default: false
+ title: Delete Exports
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/camera/{camera_name}/set/{feature}/{sub_command}":
+ put:
+ tags:
+ - Camera
+ summary: Camera Set
+ description: Set a camera feature state. Use camera_name='*' to target all cameras.
+ operationId: camera_set_camera__camera_name__set__feature___sub_command__put
+ parameters:
+ - name: camera_name
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Camera Name
+ - name: feature
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Feature
+ - name: sub_command
+ in: path
+ required: true
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Sub Command
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CameraSetBody"
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/camera/{camera_name}/set/{feature}":
+ put:
+ tags:
+ - Camera
+ summary: Camera Set
+ description: Set a camera feature state. Use camera_name='*' to target all cameras.
+ operationId: camera_set_camera__camera_name__set__feature__put
+ parameters:
+ - name: camera_name
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Camera Name
+ - name: feature
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Feature
+ - name: sub_command
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Sub Command
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CameraSetBody"
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ /chat/tools:
+ get:
+ tags:
+ - Chat
+ summary: Get available tools
+ description: Returns OpenAI-compatible tool definitions for function calling.
+ operationId: get_tools_chat_tools_get
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ /chat/execute:
+ post:
+ tags:
+ - Chat
+ summary: Execute a tool
+ description: Execute a tool function call from an LLM.
+ operationId: execute_tool_chat_execute_post
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ToolExecuteRequest"
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ /chat/completion:
+ post:
+ tags:
+ - Chat
+ summary: Chat completion with tool calling
+ description: >-
+ Send a chat message to the configured GenAI provider with tool calling
+ support. The LLM can call Frigate tools to answer questions about your
+ cameras and events.
+ operationId: chat_completion_chat_completion_post
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ChatCompletionRequest"
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
"422":
description: Validation Error
content:
@@ -331,60 +793,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /media/sync:
- post:
- tags:
- - App
- summary: Start media sync job
- description: |-
- Start an asynchronous media sync job to find and (optionally) remove orphaned media files.
- Returns 202 with job details when queued, or 409 if a job is already running.
- operationId: sync_media_media_sync_post
- requestBody:
- required: true
- content:
- application/json:
- responses:
- "202":
- description: Accepted - Job queued
- "409":
- description: Conflict - Job already running
- "422":
- description: Validation Error
-
- /media/sync/current:
- get:
- tags:
- - App
- summary: Get current media sync job
- description: |-
- Retrieve the current running media sync job, if any. Returns the job details or null when no job is active.
- operationId: get_media_sync_current_media_sync_current_get
- responses:
- "200":
- description: Successful Response
- "422":
- description: Validation Error
-
- /media/sync/status/{job_id}:
- get:
- tags:
- - App
- summary: Get media sync job status
- description: |-
- Get status and results for the specified media sync job id. Returns 200 with job details including results, or 404 if the job is not found.
- operationId: get_media_sync_status_media_sync_status__job_id__get
- parameters:
- - name: job_id
- in: path
- responses:
- "200":
- description: Successful Response
- "404":
- description: Not Found - Job not found
- "422":
- description: Validation Error
- /faces/train/{name}/classify:
+ "/faces/train/{name}/classify":
post:
tags:
- Classification
@@ -423,7 +832,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /faces/{name}/create:
+ "/faces/{name}/create":
post:
tags:
- Classification
@@ -454,7 +863,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /faces/{name}/register:
+ "/faces/{name}/register":
post:
tags:
- Classification
@@ -522,7 +931,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /faces/{name}/delete:
+ "/faces/{name}/delete":
post:
tags:
- Classification
@@ -559,7 +968,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /faces/{old_name}/rename:
+ "/faces/{old_name}/rename":
put:
tags:
- Classification
@@ -669,33 +1078,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /classification/attributes:
- get:
- tags:
- - Classification
- summary: Get custom classification attributes
- description: |-
- Returns custom classification attributes for a given object type.
- Only includes models with classification_type set to 'attribute'.
- By default returns a flat sorted list of all attribute labels.
- If group_by_model is true, returns attributes grouped by model name.
- operationId: get_custom_attributes_classification_attributes_get
- parameters:
- - name: object_type
- in: query
- schema:
- type: string
- - name: group_by_model
- in: query
- schema:
- type: boolean
- default: false
- responses:
- "200":
- description: Successful Response
- "422":
- description: Validation Error
- /classification/{name}/dataset:
+ "/classification/{name}/dataset":
get:
tags:
- Classification
@@ -723,7 +1106,44 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /classification/{name}/train:
+ /classification/attributes:
+ get:
+ tags:
+ - Classification
+ summary: Get custom classification attributes
+ description: |-
+ Returns custom classification attributes for a given object type.
+ Only includes models with classification_type set to 'attribute'.
+ By default returns a flat sorted list of all attribute labels.
+ If group_by_model is true, returns attributes grouped by model name.
+ operationId: get_custom_attributes_classification_attributes_get
+ parameters:
+ - name: object_type
+ in: query
+ required: false
+ schema:
+ type: string
+ title: Object Type
+ - name: group_by_model
+ in: query
+ required: false
+ schema:
+ type: boolean
+ default: false
+ title: Group By Model
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/classification/{name}/train":
get:
tags:
- Classification
@@ -779,7 +1199,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /classification/{name}/dataset/{category}/delete:
+ "/classification/{name}/dataset/{category}/delete":
post:
tags:
- Classification
@@ -822,7 +1242,49 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /classification/{name}/dataset/categorize:
+ "/classification/{name}/dataset/{old_category}/rename":
+ put:
+ tags:
+ - Classification
+ summary: Rename a classification category
+ description: |-
+ Renames a classification category for a given classification model.
+ The old category must exist and the new name must be valid. Returns a success message or an error if the name is invalid.
+ operationId: >-
+ rename_classification_category_classification__name__dataset__old_category__rename_put
+ parameters:
+ - name: name
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Name
+ - name: old_category
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Old Category
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ title: Body
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GenericResponse"
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/classification/{name}/dataset/categorize":
post:
tags:
- Classification
@@ -859,7 +1321,44 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /classification/{name}/train/delete:
+ "/classification/{name}/dataset/{category}/create":
+ post:
+ tags:
+ - Classification
+ summary: Create an empty classification category folder
+ description: |-
+ Creates an empty folder for a classification category.
+ This is used to create folders for categories that don't have images yet.
+ Returns a success message or an error if the name is invalid.
+ operationId: >-
+ create_classification_category_classification__name__dataset__category__create_post
+ parameters:
+ - name: name
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Name
+ - name: category
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Category
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GenericResponse"
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/classification/{name}/train/delete":
post:
tags:
- Classification
@@ -895,6 +1394,88 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
+ /classification/generate_examples/state:
+ post:
+ tags:
+ - Classification
+ summary: Generate state classification examples
+ description: Generate examples for state classification.
+ operationId: generate_state_examples_classification_generate_examples_state_post
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GenerateStateExamplesBody"
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GenericResponse"
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ /classification/generate_examples/object:
+ post:
+ tags:
+ - Classification
+ summary: Generate object classification examples
+ description: Generate examples for object classification.
+ operationId: generate_object_examples_classification_generate_examples_object_post
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GenerateObjectExamplesBody"
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GenericResponse"
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/classification/{name}":
+ delete:
+ tags:
+ - Classification
+ summary: Delete a classification model
+ description: |-
+ Deletes a specific classification model and all its associated data.
+ Works even if the model is not in the config (e.g., partially created during wizard).
+ Returns a success message.
+ operationId: delete_classification_model_classification__name__delete
+ parameters:
+ - name: name
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Name
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GenericResponse"
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
/review:
get:
tags:
@@ -928,7 +1509,6 @@ paths:
required: false
schema:
type: integer
- default: 0
title: Reviewed
- name: limit
in: query
@@ -1146,7 +1726,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /review/event/{event_id}:
+ "/review/event/{event_id}":
get:
tags:
- Review
@@ -1172,7 +1752,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /review/{review_id}:
+ "/review/{review_id}":
get:
tags:
- Review
@@ -1198,7 +1778,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /review/{review_id}/viewed:
+ "/review/{review_id}/viewed":
delete:
tags:
- Review
@@ -1224,7 +1804,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /review/summarize/start/{start_ts}/end/{end_ts}:
+ "/review/summarize/start/{start_ts}/end/{end_ts}":
post:
tags:
- Review
@@ -1282,43 +1862,6 @@ paths:
content:
application/json:
schema: {}
- /go2rtc/streams:
- get:
- tags:
- - App
- summary: Go2Rtc Streams
- operationId: go2rtc_streams_go2rtc_streams_get
- responses:
- "200":
- description: Successful Response
- content:
- application/json:
- schema: {}
- /go2rtc/streams/{camera_name}:
- get:
- tags:
- - App
- summary: Go2Rtc Camera Stream
- operationId: go2rtc_camera_stream_go2rtc_streams__camera_name__get
- parameters:
- - name: camera_name
- in: path
- required: true
- schema:
- type: string
- title: Camera Name
- responses:
- "200":
- description: Successful Response
- content:
- application/json:
- schema: {}
- "422":
- description: Validation Error
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/HTTPValidationError"
/version:
get:
tags:
@@ -1394,6 +1937,60 @@ paths:
content:
application/json:
schema: {}
+ /profiles:
+ get:
+ tags:
+ - App
+ summary: Get Profiles
+ description: List all available profiles and the currently active profile.
+ operationId: get_profiles_profiles_get
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ /profile/active:
+ get:
+ tags:
+ - App
+ summary: Get Active Profile
+ description: Get the currently active profile.
+ operationId: get_active_profile_profile_active_get
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ /ffmpeg/presets:
+ get:
+ tags:
+ - App
+ summary: Ffmpeg Presets
+ description: Return available ffmpeg preset keys for config UI usage.
+ operationId: ffmpeg_presets_ffmpeg_presets_get
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ /config/raw_paths:
+ get:
+ tags:
+ - App
+ summary: Config Raw Paths
+ description: >-
+ Admin-only endpoint that returns camera paths and go2rtc streams without
+ credential masking.
+ operationId: config_raw_paths_config_raw_paths_get
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
/config/raw:
get:
tags:
@@ -1461,32 +2058,6 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /ffprobe:
- get:
- tags:
- - App
- summary: Ffprobe
- operationId: ffprobe_ffprobe_get
- parameters:
- - name: paths
- in: query
- required: false
- schema:
- type: string
- default: ""
- title: Paths
- responses:
- "200":
- description: Successful Response
- content:
- application/json:
- schema: {}
- "422":
- description: Validation Error
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/HTTPValidationError"
/vainfo:
get:
tags:
@@ -1511,7 +2082,7 @@ paths:
content:
application/json:
schema: {}
- /logs/{service}:
+ "/logs/{service}":
get:
tags:
- App
@@ -1588,6 +2159,79 @@ paths:
content:
application/json:
schema: {}
+ /media/sync:
+ post:
+ tags:
+ - App
+ summary: Start media sync job
+ description: >-
+ Start an asynchronous media sync job to find and (optionally) remove
+ orphaned media files.
+ Returns 202 with job details when queued, or 409 if a job is already running.
+ operationId: sync_media_media_sync_post
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MediaSyncBody"
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ /media/sync/current:
+ get:
+ tags:
+ - App
+ summary: Get current media sync job
+ description: >-
+ Retrieve the current running media sync job, if any. Returns the job
+ details
+ or null when no job is active.
+ operationId: get_media_sync_current_media_sync_current_get
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "/media/sync/status/{job_id}":
+ get:
+ tags:
+ - App
+ summary: Get media sync job status
+ description: >-
+ Get status and results for the specified media sync job id. Returns 200
+ with
+ job details including results, or 404 if the job is not found.
+ operationId: get_media_sync_status_media_sync_status__job_id__get
+ parameters:
+ - name: job_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Job Id
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
/labels:
get:
tags:
@@ -1641,6 +2285,18 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
+ /audio_labels:
+ get:
+ tags:
+ - App
+ summary: Get Audio Labels
+ operationId: get_audio_labels_audio_labels_get
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
/plus/models:
get:
tags:
@@ -1807,7 +2463,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /preview/{camera_name}/start/{start_ts}/end/{end_ts}:
+ "/preview/{camera_name}/start/{start_ts}/end/{end_ts}":
get:
tags:
- Preview
@@ -1823,9 +2479,7 @@ paths:
in: path
required: true
schema:
- anyOf:
- - type: string
- - type: "null"
+ type: string
title: Camera Name
- name: start_ts
in: path
@@ -1857,7 +2511,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}:
+ "/preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}":
get:
tags:
- Preview
@@ -1892,9 +2546,7 @@ paths:
in: path
required: true
schema:
- anyOf:
- - type: string
- - type: "null"
+ type: string
title: Camera Name
- name: tz_name
in: path
@@ -1920,7 +2572,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames:
+ "/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames":
get:
tags:
- Preview
@@ -2022,6 +2674,40 @@ paths:
Gets all exports from the database for cameras the user has access to.
Returns a list of exports ordered by date (most recent first).
operationId: get_exports_exports_get
+ parameters:
+ - name: export_case_id
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Export Case Id
+ - name: cameras
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ default: all
+ title: Cameras
+ - name: start_date
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: number
+ - type: "null"
+ title: Start Date
+ - name: end_date
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: number
+ - type: "null"
+ title: End Date
responses:
"200":
description: Successful Response
@@ -2032,7 +2718,175 @@ paths:
items:
$ref: "#/components/schemas/ExportModel"
title: Response Get Exports Exports Get
- /export/{camera_name}/start/{start_time}/end/{end_time}:
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ /cases:
+ get:
+ tags:
+ - Export
+ summary: Get export cases
+ description: Gets all export cases from the database.
+ operationId: get_export_cases_cases_get
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "#/components/schemas/ExportCaseModel"
+ title: Response Get Export Cases Cases Get
+ post:
+ tags:
+ - Export
+ summary: Create export case
+ description: Creates a new export case.
+ operationId: create_export_case_cases_post
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ExportCaseCreateBody"
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ExportCaseModel"
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/cases/{case_id}":
+ get:
+ tags:
+ - Export
+ summary: Get a single export case
+ description: Gets a specific export case by ID.
+ operationId: get_export_case_cases__case_id__get
+ parameters:
+ - name: case_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Case Id
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ExportCaseModel"
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ patch:
+ tags:
+ - Export
+ summary: Update export case
+ description: Updates an existing export case.
+ operationId: update_export_case_cases__case_id__patch
+ parameters:
+ - name: case_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Case Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ExportCaseUpdateBody"
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GenericResponse"
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ delete:
+ tags:
+ - Export
+ summary: Delete export case
+ description: |-
+ Deletes an export case.
+ Exports that reference this case will have their export_case set to null.
+ operationId: delete_export_case_cases__case_id__delete
+ parameters:
+ - name: case_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Case Id
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GenericResponse"
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/export/{export_id}/case":
+ patch:
+ tags:
+ - Export
+ summary: Assign export to case
+ description: "Assigns an export to a case, or unassigns it if export_case_id is null."
+ operationId: assign_export_case_export__export_id__case_patch
+ parameters:
+ - name: export_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Export Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ExportCaseAssignBody"
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GenericResponse"
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/export/{camera_name}/start/{start_time}/end/{end_time}":
post:
tags:
- Export
@@ -2084,7 +2938,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /export/{event_id}/rename:
+ "/export/{event_id}/rename":
patch:
tags:
- Export
@@ -2119,7 +2973,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /export/{event_id}:
+ "/export/{event_id}":
delete:
tags:
- Export
@@ -2145,7 +2999,61 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /exports/{export_id}:
+ "/export/custom/{camera_name}/start/{start_time}/end/{end_time}":
+ post:
+ tags:
+ - Export
+ summary: Start custom recording export
+ description: >-
+ Starts an export of a recording for the specified time range using
+ custom FFmpeg arguments.
+ The export can be from recordings or preview footage. Returns the export ID if
+ successful, or an error message if the camera is invalid or no recordings/previews
+ are found for the time range. If ffmpeg_input_args and ffmpeg_output_args are not provided,
+ defaults to timelapse export settings.
+ operationId: >-
+ export_recording_custom_export_custom__camera_name__start__start_time__end__end_time__post
+ parameters:
+ - name: camera_name
+ in: path
+ required: true
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Camera Name
+ - name: start_time
+ in: path
+ required: true
+ schema:
+ type: number
+ title: Start Time
+ - name: end_time
+ in: path
+ required: true
+ schema:
+ type: number
+ title: End Time
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ExportRecordingsCustomBody"
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/StartExportResponse"
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/exports/{export_id}":
get:
tags:
- Export
@@ -2236,6 +3144,15 @@ paths:
- type: "null"
default: all
title: Sub Labels
+ - name: attributes
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ default: all
+ title: Attributes
- name: zone
in: query
required: false
@@ -2286,7 +3203,7 @@ paths:
anyOf:
- type: string
- type: "null"
- default: 00:00,24:00
+ default: "00:00,24:00"
title: Time Range
- name: has_clip
in: query
@@ -2571,6 +3488,24 @@ paths:
- type: "null"
default: all
title: Labels
+ - name: sub_labels
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ default: all
+ title: Sub Labels
+ - name: attributes
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ default: all
+ title: Attributes
- name: zones
in: query
required: false
@@ -2603,7 +3538,7 @@ paths:
anyOf:
- type: string
- type: "null"
- default: 00:00,24:00
+ default: "00:00,24:00"
title: Time Range
- name: has_clip
in: query
@@ -2743,7 +3678,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /events/{event_id}:
+ "/events/{event_id}":
get:
tags:
- Events
@@ -2798,11 +3733,11 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /events/{event_id}/retain:
+ "/events/{event_id}/retain":
post:
tags:
- Events
- summary: Set event retain indefinitely
+ summary: Set event retain indefinitely.
description: |-
Sets an event to retain indefinitely.
Returns a success message or an error if the event is not found.
@@ -2857,7 +3792,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /events/{event_id}/plus:
+ "/events/{event_id}/plus":
post:
tags:
- Events
@@ -2891,7 +3826,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /events/{event_id}/false_positive:
+ "/events/{event_id}/false_positive":
put:
tags:
- Events
@@ -2921,7 +3856,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /events/{event_id}/sub_label:
+ "/events/{event_id}/sub_label":
post:
tags:
- Events
@@ -2956,7 +3891,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /events/{event_id}/recognized_license_plate:
+ "/events/{event_id}/recognized_license_plate":
post:
tags:
- Events
@@ -2991,15 +3926,14 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /events/{event_id}/attributes:
+ "/events/{event_id}/attributes":
post:
tags:
- Events
summary: Set custom classification attributes
- description: |-
+ description: >-
Sets an event's custom classification attributes for all attribute-type
models that apply to the event's object type.
- Returns a success message or an error if the event is not found.
operationId: set_attributes_events__event_id__attributes_post
parameters:
- name: event_id
@@ -3027,7 +3961,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /events/{event_id}/description:
+ "/events/{event_id}/description":
post:
tags:
- Events
@@ -3062,7 +3996,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /events/{event_id}/description/regenerate:
+ "/events/{event_id}/description/regenerate":
put:
tags:
- Events
@@ -3165,7 +4099,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /events/{camera_name}/{label}/create:
+ "/events/{camera_name}/{label}/create":
post:
tags:
- Events
@@ -3200,7 +4134,6 @@ paths:
duration: 30
include_recording: true
draw: {}
- pre_capture: null
responses:
"200":
description: Successful Response
@@ -3214,7 +4147,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /events/{event_id}/end:
+ "/events/{event_id}/end":
put:
tags:
- Events
@@ -3292,7 +4225,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /trigger/embedding/{camera_name}/{name}:
+ "/trigger/embedding/{camera_name}/{name}":
put:
tags:
- Events
@@ -3373,7 +4306,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /triggers/status/{camera_name}:
+ "/triggers/status/{camera_name}":
get:
tags:
- Events
@@ -3403,7 +4336,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /{camera_name}:
+ "/{camera_name}":
get:
tags:
- Media
@@ -3492,7 +4425,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /{camera_name}/ptz/info:
+ "/{camera_name}/ptz/info":
get:
tags:
- Media
@@ -3519,11 +4452,15 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /{camera_name}/latest.{extension}:
+ "/{camera_name}/latest.{extension}":
get:
tags:
- Media
summary: Latest Frame
+ description: >-
+ Returns the latest frame from the specified camera in the requested
+ format (jpg, png, webp). Falls back to preview frames if the camera is
+ offline.
operationId: latest_frame__camera_name__latest__extension__get
parameters:
- name: camera_name
@@ -3632,7 +4569,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /{camera_name}/recordings/{frame_time}/snapshot.{format}:
+ "/{camera_name}/recordings/{frame_time}/snapshot.{format}":
get:
tags:
- Media
@@ -3681,7 +4618,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /{camera_name}/plus/{frame_time}:
+ "/{camera_name}/plus/{frame_time}":
post:
tags:
- Media
@@ -3714,184 +4651,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /recordings/storage:
- get:
- tags:
- - Media
- summary: Get Recordings Storage Usage
- operationId: get_recordings_storage_usage_recordings_storage_get
- responses:
- "200":
- description: Successful Response
- content:
- application/json:
- schema: {}
- /recordings/summary:
- get:
- tags:
- - Media
- summary: All Recordings Summary
- description: Returns true/false by day indicating if recordings exist
- operationId: all_recordings_summary_recordings_summary_get
- parameters:
- - name: timezone
- in: query
- required: false
- schema:
- type: string
- default: utc
- title: Timezone
- - name: cameras
- in: query
- required: false
- schema:
- anyOf:
- - type: string
- - type: "null"
- default: all
- title: Cameras
- responses:
- "200":
- description: Successful Response
- content:
- application/json:
- schema: {}
- "422":
- description: Validation Error
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/HTTPValidationError"
- /{camera_name}/recordings/summary:
- get:
- tags:
- - Media
- summary: Recordings Summary
- description: Returns hourly summary for recordings of given camera
- operationId: recordings_summary__camera_name__recordings_summary_get
- parameters:
- - name: camera_name
- in: path
- required: true
- schema:
- anyOf:
- - type: string
- - type: "null"
- title: Camera Name
- - name: timezone
- in: query
- required: false
- schema:
- type: string
- default: utc
- title: Timezone
- responses:
- "200":
- description: Successful Response
- content:
- application/json:
- schema: {}
- "422":
- description: Validation Error
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/HTTPValidationError"
- /{camera_name}/recordings:
- get:
- tags:
- - Media
- summary: Recordings
- description: >-
- Return specific camera recordings between the given 'after'/'end' times.
- If not provided the last hour will be used
- operationId: recordings__camera_name__recordings_get
- parameters:
- - name: camera_name
- in: path
- required: true
- schema:
- anyOf:
- - type: string
- - type: "null"
- title: Camera Name
- - name: after
- in: query
- required: false
- schema:
- type: number
- default: 1759932070.40171
- title: After
- - name: before
- in: query
- required: false
- schema:
- type: number
- default: 1759935670.40172
- title: Before
- responses:
- "200":
- description: Successful Response
- content:
- application/json:
- schema: {}
- "422":
- description: Validation Error
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/HTTPValidationError"
- /recordings/unavailable:
- get:
- tags:
- - Media
- summary: No Recordings
- description: Get time ranges with no recordings.
- operationId: no_recordings_recordings_unavailable_get
- parameters:
- - name: cameras
- in: query
- required: false
- schema:
- type: string
- default: all
- title: Cameras
- - name: before
- in: query
- required: false
- schema:
- type: number
- title: Before
- - name: after
- in: query
- required: false
- schema:
- type: number
- title: After
- - name: scale
- in: query
- required: false
- schema:
- type: integer
- default: 30
- title: Scale
- responses:
- "200":
- description: Successful Response
- content:
- application/json:
- schema:
- type: array
- items:
- type: object
- title: Response No Recordings Recordings Unavailable Get
- "422":
- description: Validation Error
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/HTTPValidationError"
- /{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4:
+ "/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4":
get:
tags:
- Media
@@ -3933,7 +4693,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /vod/{camera_name}/start/{start_ts}/end/{end_ts}:
+ "/vod/{camera_name}/start/{start_ts}/end/{end_ts}":
get:
tags:
- Media
@@ -3963,6 +4723,13 @@ paths:
schema:
type: number
title: End Ts
+ - name: force_discontinuity
+ in: query
+ required: false
+ schema:
+ type: boolean
+ default: false
+ title: Force Discontinuity
responses:
"200":
description: Successful Response
@@ -3975,7 +4742,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /vod/{year_month}/{day}/{hour}/{camera_name}:
+ "/vod/{year_month}/{day}/{hour}/{camera_name}":
get:
tags:
- Media
@@ -4023,7 +4790,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /vod/{year_month}/{day}/{hour}/{camera_name}/{tz_name}:
+ "/vod/{year_month}/{day}/{hour}/{camera_name}/{tz_name}":
get:
tags:
- Media
@@ -4078,7 +4845,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /vod/event/{event_id}:
+ "/vod/event/{event_id}":
get:
tags:
- Media
@@ -4115,7 +4882,49 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /events/{event_id}/snapshot.jpg:
+ "/vod/clip/{camera_name}/start/{start_ts}/end/{end_ts}":
+ get:
+ tags:
+ - Media
+ summary: Vod Clip
+ description: >-
+ Returns an HLS playlist for a timestamp range with HLS discontinuity
+ enabled. Append /master.m3u8 or /index.m3u8 for HLS playback.
+ operationId: vod_clip_vod_clip__camera_name__start__start_ts__end__end_ts__get
+ parameters:
+ - name: camera_name
+ in: path
+ required: true
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Camera Name
+ - name: start_ts
+ in: path
+ required: true
+ schema:
+ type: number
+ title: Start Ts
+ - name: end_ts
+ in: path
+ required: true
+ schema:
+ type: number
+ title: End Ts
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/events/{event_id}/snapshot.jpg":
get:
tags:
- Media
@@ -4194,7 +5003,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /events/{event_id}/thumbnail.{extension}:
+ "/events/{event_id}/thumbnail.{extension}":
get:
tags:
- Media
@@ -4231,6 +5040,14 @@ paths:
- android
default: ios
title: Format
+ - name: camera_name
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Camera Name
responses:
"200":
description: Successful Response
@@ -4243,7 +5060,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /{camera_name}/grid.jpg:
+ "/{camera_name}/grid.jpg":
get:
tags:
- Media
@@ -4284,12 +5101,38 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /events/{event_id}/snapshot-clean.webp:
+ "/{camera_name}/region_grid":
+ delete:
+ tags:
+ - Media
+ summary: Clear Region Grid
+ description: Clear the region grid for a camera.
+ operationId: clear_region_grid__camera_name__region_grid_delete
+ parameters:
+ - name: camera_name
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Camera Name
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/events/{event_id}/snapshot-clean.webp":
get:
tags:
- Media
summary: Event Snapshot Clean
- operationId: event_snapshot_clean_events__event_id__snapshot_clean_png_get
+ operationId: event_snapshot_clean_events__event_id__snapshot_clean_webp_get
parameters:
- name: event_id
in: path
@@ -4304,6 +5147,14 @@ paths:
type: boolean
default: false
title: Download
+ - name: camera_name
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Camera Name
responses:
"200":
description: Successful Response
@@ -4316,7 +5167,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /events/{event_id}/clip.mp4:
+ "/events/{event_id}/clip.mp4":
get:
tags:
- Media
@@ -4338,6 +5189,14 @@ paths:
default: 0
title: Padding
description: Padding to apply to clip.
+ - name: camera_name
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Camera Name
responses:
"200":
description: Successful Response
@@ -4350,7 +5209,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /events/{event_id}/preview.gif:
+ "/events/{event_id}/preview.gif":
get:
tags:
- Media
@@ -4363,6 +5222,14 @@ paths:
schema:
type: string
title: Event Id
+ - name: camera_name
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Camera Name
responses:
"200":
description: Successful Response
@@ -4375,7 +5242,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif:
+ "/{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif":
get:
tags:
- Media
@@ -4423,7 +5290,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4:
+ "/{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4":
get:
tags:
- Media
@@ -4471,7 +5338,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /review/{event_id}/preview:
+ "/review/{event_id}/preview":
get:
tags:
- Media
@@ -4494,6 +5361,14 @@ paths:
- mp4
default: gif
title: Format
+ - name: camera_name
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Camera Name
responses:
"200":
description: Successful Response
@@ -4506,7 +5381,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /preview/{file_name}/thumbnail.webp:
+ "/preview/{file_name}/thumbnail.webp":
get:
tags:
- Media
@@ -4520,6 +5395,14 @@ paths:
schema:
type: string
title: File Name
+ - name: camera_name
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Camera Name
responses:
"200":
description: Successful Response
@@ -4532,7 +5415,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /preview/{file_name}/thumbnail.jpg:
+ "/preview/{file_name}/thumbnail.jpg":
get:
tags:
- Media
@@ -4546,6 +5429,14 @@ paths:
schema:
type: string
title: File Name
+ - name: camera_name
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Camera Name
responses:
"200":
description: Successful Response
@@ -4558,7 +5449,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /{camera_name}/{label}/thumbnail.jpg:
+ "/{camera_name}/{label}/thumbnail.jpg":
get:
tags:
- Media
@@ -4591,7 +5482,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /{camera_name}/{label}/best.jpg:
+ "/{camera_name}/{label}/best.jpg":
get:
tags:
- Media
@@ -4624,7 +5515,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /{camera_name}/{label}/clip.mp4:
+ "/{camera_name}/{label}/clip.mp4":
get:
tags:
- Media
@@ -4657,7 +5548,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
- /{camera_name}/{label}/snapshot.jpg:
+ "/{camera_name}/{label}/snapshot.jpg":
get:
tags:
- Media
@@ -4693,6 +5584,402 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
+ "/{camera_name}/search/motion":
+ post:
+ tags:
+ - Motion Search
+ summary: Start motion search job
+ description: |-
+ Starts an asynchronous search for significant motion changes within
+ a user-defined Region of Interest (ROI) over a specified time range. Returns a job_id
+ that can be used to poll for results.
+ operationId: start_motion_search__camera_name__search_motion_post
+ parameters:
+ - name: camera_name
+ in: path
+ required: true
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Camera Name
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MotionSearchRequest"
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MotionSearchStartResponse"
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/{camera_name}/search/motion/{job_id}":
+ get:
+ tags:
+ - Motion Search
+ summary: Get motion search job status
+ description: Returns the status and results (if complete) of a motion search job.
+ operationId: >-
+ get_motion_search_status_endpoint__camera_name__search_motion__job_id__get
+ parameters:
+ - name: camera_name
+ in: path
+ required: true
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Camera Name
+ - name: job_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Job Id
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MotionSearchStatusResponse"
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/{camera_name}/search/motion/{job_id}/cancel":
+ post:
+ tags:
+ - Motion Search
+ summary: Cancel motion search job
+ description: Cancels an active motion search job if it is still processing.
+ operationId: >-
+ cancel_motion_search_endpoint__camera_name__search_motion__job_id__cancel_post
+ parameters:
+ - name: camera_name
+ in: path
+ required: true
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Camera Name
+ - name: job_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Job Id
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ /recordings/storage:
+ get:
+ tags:
+ - Recordings
+ summary: Get Recordings Storage Usage
+ operationId: get_recordings_storage_usage_recordings_storage_get
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ /recordings/summary:
+ get:
+ tags:
+ - Recordings
+ summary: All Recordings Summary
+ description: Returns true/false by day indicating if recordings exist
+ operationId: all_recordings_summary_recordings_summary_get
+ parameters:
+ - name: timezone
+ in: query
+ required: false
+ schema:
+ type: string
+ default: utc
+ title: Timezone
+ - name: cameras
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ default: all
+ title: Cameras
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/{camera_name}/recordings/summary":
+ get:
+ tags:
+ - Recordings
+ summary: Recordings Summary
+ description: Returns hourly summary for recordings of given camera
+ operationId: recordings_summary__camera_name__recordings_summary_get
+ parameters:
+ - name: camera_name
+ in: path
+ required: true
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Camera Name
+ - name: timezone
+ in: query
+ required: false
+ schema:
+ type: string
+ default: utc
+ title: Timezone
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/{camera_name}/recordings":
+ get:
+ tags:
+ - Recordings
+ summary: Recordings
+ description: >-
+ Return specific camera recordings between the given 'after'/'end' times.
+ If not provided the last hour will be used
+ operationId: recordings__camera_name__recordings_get
+ parameters:
+ - name: camera_name
+ in: path
+ required: true
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Camera Name
+ - name: after
+ in: query
+ required: false
+ schema:
+ type: number
+ default: 1774023877.74743
+ title: After
+ - name: before
+ in: query
+ required: false
+ schema:
+ type: number
+ default: 1774027477.74744
+ title: Before
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ /recordings/unavailable:
+ get:
+ tags:
+ - Recordings
+ summary: No Recordings
+ description: Get time ranges with no recordings.
+ operationId: no_recordings_recordings_unavailable_get
+ parameters:
+ - name: cameras
+ in: query
+ required: false
+ schema:
+ type: string
+ default: all
+ title: Cameras
+ - name: before
+ in: query
+ required: false
+ schema:
+ type: number
+ title: Before
+ - name: after
+ in: query
+ required: false
+ schema:
+ type: number
+ title: After
+ - name: scale
+ in: query
+ required: false
+ schema:
+ type: integer
+ default: 30
+ title: Scale
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: object
+ title: Response No Recordings Recordings Unavailable Get
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ "/recordings/start/{start}/end/{end}":
+ delete:
+ tags:
+ - Recordings
+ summary: Delete recordings
+ description: |-
+ Deletes recordings within the specified time range.
+ Recordings can be filtered by cameras and kept based on motion, objects, or audio attributes.
+ operationId: delete_recordings_recordings_start__start__end__end__delete
+ parameters:
+ - name: start
+ in: path
+ required: true
+ schema:
+ type: number
+ description: Start timestamp (unix)
+ title: Start
+ description: Start timestamp (unix)
+ - name: end
+ in: path
+ required: true
+ schema:
+ type: number
+ description: End timestamp (unix)
+ title: End
+ description: End timestamp (unix)
+ - name: keep
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Keep
+ - name: cameras
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: "null"
+ default: all
+ title: Cameras
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GenericResponse"
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ /debug_replay/start:
+ post:
+ tags:
+ - App
+ summary: Start debug replay
+ description: Start a debug replay session from camera recordings.
+ operationId: start_debug_replay_debug_replay_start_post
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DebugReplayStartBody"
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DebugReplayStartResponse"
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
+ /debug_replay/status:
+ get:
+ tags:
+ - App
+ summary: Get debug replay status
+ description: Get the status of the current debug replay session.
+ operationId: get_debug_replay_status_debug_replay_status_get
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DebugReplayStatusResponse"
+ /debug_replay/stop:
+ post:
+ tags:
+ - App
+ summary: Stop debug replay
+ description: Stop the active debug replay session and clean up all artifacts.
+ operationId: stop_debug_replay_debug_replay_stop_post
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DebugReplayStopResponse"
components:
schemas:
AppConfigSetBody:
@@ -4711,6 +5998,10 @@ components:
- type: object
- type: "null"
title: Config Data
+ skip_save:
+ type: boolean
+ title: Skip Save
+ default: false
type: object
title: AppConfigSetBody
AppPostLoginBody:
@@ -4750,6 +6041,11 @@ components:
password:
type: string
title: Password
+ old_password:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Old Password
type: object
required:
- password
@@ -4768,6 +6064,7 @@ components:
event_id:
type: string
title: Event Id
+ description: ID of the event to transcribe audio for
type: object
required:
- event_id
@@ -4792,6 +6089,71 @@ components:
required:
- file
title: Body_register_face_faces__name__register_post
+ CameraSetBody:
+ properties:
+ value:
+ type: string
+ title: Value
+ description: The value to set for the feature
+ type: object
+ required:
+ - value
+ title: CameraSetBody
+ ChatCompletionRequest:
+ properties:
+ messages:
+ items:
+ $ref: "#/components/schemas/ChatMessage"
+ type: array
+ title: Messages
+ description: List of messages in the conversation
+ max_tool_iterations:
+ type: integer
+ maximum: 10
+ minimum: 1
+ title: Max Tool Iterations
+ description: "Maximum number of tool call iterations (default: 5)"
+ default: 5
+ stream:
+ type: boolean
+ title: Stream
+ description: >-
+ If true, stream the final assistant response in the body as
+ newline-delimited JSON.
+ default: false
+ type: object
+ required:
+ - messages
+ title: ChatCompletionRequest
+ description: Request for chat completion with tool calling.
+ ChatMessage:
+ properties:
+ role:
+ type: string
+ title: Role
+ description: "Message role: 'user', 'assistant', 'system', or 'tool'"
+ content:
+ type: string
+ title: Content
+ description: Message content
+ tool_call_id:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Tool Call Id
+ description: "For tool messages, the ID of the tool call"
+ name:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Name
+ description: "For tool messages, the tool name"
+ type: object
+ required:
+ - role
+ - content
+ title: ChatMessage
+ description: A single message in a chat conversation.
DayReview:
properties:
day:
@@ -4818,6 +6180,82 @@ components:
- total_alert
- total_detection
title: DayReview
+ DebugReplayStartBody:
+ properties:
+ camera:
+ type: string
+ title: Source camera name
+ start_time:
+ type: number
+ title: Start timestamp
+ end_time:
+ type: number
+ title: End timestamp
+ type: object
+ required:
+ - camera
+ - start_time
+ - end_time
+ title: DebugReplayStartBody
+ description: Request body for starting a debug replay session.
+ DebugReplayStartResponse:
+ properties:
+ success:
+ type: boolean
+ title: Success
+ replay_camera:
+ type: string
+ title: Replay Camera
+ type: object
+ required:
+ - success
+ - replay_camera
+ title: DebugReplayStartResponse
+ description: Response for starting a debug replay session.
+ DebugReplayStatusResponse:
+ properties:
+ active:
+ type: boolean
+ title: Active
+ replay_camera:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Replay Camera
+ source_camera:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Source Camera
+ start_time:
+ anyOf:
+ - type: number
+ - type: "null"
+ title: Start Time
+ end_time:
+ anyOf:
+ - type: number
+ - type: "null"
+ title: End Time
+ live_ready:
+ type: boolean
+ title: Live Ready
+ default: false
+ type: object
+ required:
+ - active
+ title: DebugReplayStatusResponse
+ description: Response for debug replay status.
+ DebugReplayStopResponse:
+ properties:
+ success:
+ type: boolean
+ title: Success
+ type: object
+ required:
+ - success
+ title: DebugReplayStopResponse
+ description: Response for stopping a debug replay session.
DeleteFaceImagesBody:
properties:
ids:
@@ -4972,6 +6410,15 @@ components:
- success
- plus_id
title: EventUploadPlusResponse
+ EventsAttributesBody:
+ properties:
+ attributes:
+ items:
+ type: string
+ type: array
+ title: Selected classification attributes for the event
+ type: object
+ title: EventsAttributesBody
EventsCreateBody:
properties:
sub_label:
@@ -5007,8 +6454,7 @@ components:
anyOf:
- type: integer
- type: "null"
- title: Pre Capture Seconds
- default: null
+ title: Pre Capture
type: object
title: EventsCreateBody
EventsDeleteBody:
@@ -5081,18 +6527,86 @@ components:
required:
- subLabel
title: EventsSubLabelBody
- EventsAttributesBody:
+ ExportCaseAssignBody:
properties:
- attributes:
- type: object
- title: Attributes
- description: Object with model names as keys and attribute values
- additionalProperties:
- type: string
+ export_case_id:
+ anyOf:
+ - type: string
+ maxLength: 30
+ - type: "null"
+ title: Export Case Id
+ description: "Case ID to assign to the export, or null to unassign"
+ type: object
+ title: ExportCaseAssignBody
+ description: Request body for assigning or unassigning an export to a case.
+ ExportCaseCreateBody:
+ properties:
+ name:
+ type: string
+ maxLength: 100
+ title: Name
+ description: Friendly name of the export case
+ description:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Description
+ description: Optional description of the export case
type: object
required:
- - attributes
- title: EventsAttributesBody
+ - name
+ title: ExportCaseCreateBody
+ description: Request body for creating a new export case.
+ ExportCaseModel:
+ properties:
+ id:
+ type: string
+ title: Id
+ description: Unique identifier for the export case
+ name:
+ type: string
+ title: Name
+ description: Friendly name of the export case
+ description:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Description
+ description: Optional description of the export case
+ created_at:
+ type: number
+ title: Created At
+ description: Unix timestamp when the export case was created
+ updated_at:
+ type: number
+ title: Updated At
+ description: Unix timestamp when the export case was last updated
+ type: object
+ required:
+ - id
+ - name
+ - created_at
+ - updated_at
+ title: ExportCaseModel
+ description: Model representing a single export case.
+ ExportCaseUpdateBody:
+ properties:
+ name:
+ anyOf:
+ - type: string
+ maxLength: 100
+ - type: "null"
+ title: Name
+ description: Updated friendly name of the export case
+ description:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Description
+ description: Updated description of the export case
+ type: object
+ title: ExportCaseUpdateBody
+ description: Request body for updating an existing export case.
ExportModel:
properties:
id:
@@ -5123,6 +6637,12 @@ components:
type: boolean
title: In Progress
description: Whether the export is currently being processed
+ export_case_id:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Export Case Id
+ description: ID of the export case this export belongs to
type: object
required:
- id
@@ -5136,10 +6656,30 @@ components:
description: Model representing a single export.
ExportRecordingsBody:
properties:
- playback:
- $ref: "#/components/schemas/PlaybackFactorEnum"
- title: Playback factor
- default: realtime
+ source:
+ $ref: "#/components/schemas/PlaybackSourceEnum"
+ title: Playback source
+ default: recordings
+ name:
+ anyOf:
+ - type: string
+ maxLength: 256
+ - type: "null"
+ title: Friendly name
+ image_path:
+ type: string
+ title: Image Path
+ export_case_id:
+ anyOf:
+ - type: string
+ maxLength: 30
+ - type: "null"
+ title: Export case ID
+ description: ID of the export case to assign this export to
+ type: object
+ title: ExportRecordingsBody
+ ExportRecordingsCustomBody:
+ properties:
source:
$ref: "#/components/schemas/PlaybackSourceEnum"
title: Playback source
@@ -5151,8 +6691,38 @@ components:
image_path:
type: string
title: Image Path
+ export_case_id:
+ anyOf:
+ - type: string
+ maxLength: 30
+ - type: "null"
+ title: Export case ID
+ description: ID of the export case to assign this export to
+ ffmpeg_input_args:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: FFmpeg input arguments
+ description: >-
+ Custom FFmpeg input arguments. If not provided, defaults to
+ timelapse input args.
+ ffmpeg_output_args:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: FFmpeg output arguments
+ description: >-
+ Custom FFmpeg output arguments. If not provided, defaults to
+ timelapse output args.
+ cpu_fallback:
+ type: boolean
+ title: CPU Fallback
+ description: >-
+ If true, retry export without hardware acceleration if the initial
+ export fails.
+ default: false
type: object
- title: ExportRecordingsBody
+ title: ExportRecordingsCustomBody
ExportRenameBody:
properties:
name:
@@ -5218,6 +6788,47 @@ components:
"john_doe": ["face1.webp", "face2.jpg"],
"jane_smith": ["face3.png"]
}
+ GenerateObjectExamplesBody:
+ properties:
+ model_name:
+ type: string
+ title: Model Name
+ description: Name of the classification model
+ label:
+ type: string
+ title: Label
+ description: "Object label to collect examples for (e.g., 'person', 'car')"
+ type: object
+ required:
+ - model_name
+ - label
+ title: GenerateObjectExamplesBody
+ GenerateStateExamplesBody:
+ properties:
+ model_name:
+ type: string
+ title: Model Name
+ description: Name of the classification model
+ cameras:
+ additionalProperties:
+ prefixItems:
+ - type: number
+ - type: number
+ - type: number
+ - type: number
+ type: array
+ maxItems: 4
+ minItems: 4
+ type: object
+ title: Cameras
+ description: >-
+ Dictionary mapping camera names to normalized crop coordinates in
+ [x1, y1, x2, y2] format (values 0-1)
+ type: object
+ required:
+ - model_name
+ - cameras
+ title: GenerateStateExamplesBody
GenericResponse:
properties:
success:
@@ -5261,12 +6872,199 @@ components:
- total_alert
- total_detection
title: Last24HoursReview
- PlaybackFactorEnum:
- type: string
- enum:
- - realtime
- - timelapse_25x
- title: PlaybackFactorEnum
+ MediaSyncBody:
+ properties:
+ dry_run:
+ type: boolean
+ title: Dry Run
+ description: "If True, only report orphans without deleting them"
+ default: true
+ media_types:
+ items:
+ type: string
+ type: array
+ title: Media Types
+ description: >-
+ Types of media to sync: 'all', 'event_snapshots',
+ 'event_thumbnails', 'review_thumbnails', 'previews', 'exports',
+ 'recordings'
+ default:
+ - all
+ force:
+ type: boolean
+ title: Force
+ description: "If True, bypass safety threshold checks"
+ default: false
+ type: object
+ title: MediaSyncBody
+ MotionSearchMetricsResponse:
+ properties:
+ segments_scanned:
+ type: integer
+ title: Segments Scanned
+ default: 0
+ segments_processed:
+ type: integer
+ title: Segments Processed
+ default: 0
+ metadata_inactive_segments:
+ type: integer
+ title: Metadata Inactive Segments
+ default: 0
+ heatmap_roi_skip_segments:
+ type: integer
+ title: Heatmap Roi Skip Segments
+ default: 0
+ fallback_full_range_segments:
+ type: integer
+ title: Fallback Full Range Segments
+ default: 0
+ frames_decoded:
+ type: integer
+ title: Frames Decoded
+ default: 0
+ wall_time_seconds:
+ type: number
+ title: Wall Time Seconds
+ default: 0
+ segments_with_errors:
+ type: integer
+ title: Segments With Errors
+ default: 0
+ type: object
+ title: MotionSearchMetricsResponse
+ description: Metrics collected during motion search execution.
+ MotionSearchRequest:
+ properties:
+ start_time:
+ type: number
+ title: Start Time
+ description: Start timestamp for the search range
+ end_time:
+ type: number
+ title: End Time
+ description: End timestamp for the search range
+ polygon_points:
+ items:
+ items:
+ type: number
+ type: array
+ type: array
+ title: Polygon Points
+ description: "List of [x, y] normalized coordinates (0-1) defining the ROI polygon"
+ threshold:
+ type: integer
+ maximum: 255
+ minimum: 1
+ title: Threshold
+ description: Pixel difference threshold (1-255)
+ default: 30
+ min_area:
+ type: number
+ maximum: 100
+ minimum: 0.1
+ title: Min Area
+ description: Minimum change area as a percentage of the ROI
+ default: 5
+ frame_skip:
+ type: integer
+ maximum: 30
+ minimum: 1
+ title: Frame Skip
+ description: "Process every Nth frame (1=all frames, 5=every 5th frame)"
+ default: 5
+ parallel:
+ type: boolean
+ title: Parallel
+ description: Enable parallel scanning across segments
+ default: false
+ max_results:
+ type: integer
+ maximum: 200
+ minimum: 1
+ title: Max Results
+ description: Maximum number of search results to return
+ default: 25
+ type: object
+ required:
+ - start_time
+ - end_time
+ - polygon_points
+ title: MotionSearchRequest
+ description: Request body for motion search.
+ MotionSearchResult:
+ properties:
+ timestamp:
+ type: number
+ title: Timestamp
+ description: Timestamp where change was detected
+ change_percentage:
+ type: number
+ title: Change Percentage
+ description: Percentage of ROI area that changed
+ type: object
+ required:
+ - timestamp
+ - change_percentage
+ title: MotionSearchResult
+ description: A single search result with timestamp and change info.
+ MotionSearchStartResponse:
+ properties:
+ success:
+ type: boolean
+ title: Success
+ message:
+ type: string
+ title: Message
+ job_id:
+ type: string
+ title: Job Id
+ type: object
+ required:
+ - success
+ - message
+ - job_id
+ title: MotionSearchStartResponse
+ description: Response when motion search job starts.
+ MotionSearchStatusResponse:
+ properties:
+ success:
+ type: boolean
+ title: Success
+ message:
+ type: string
+ title: Message
+ status:
+ type: string
+ title: Status
+ results:
+ anyOf:
+ - items:
+ $ref: "#/components/schemas/MotionSearchResult"
+ type: array
+ - type: "null"
+ title: Results
+ total_frames_processed:
+ anyOf:
+ - type: integer
+ - type: "null"
+ title: Total Frames Processed
+ error_message:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Error Message
+ metrics:
+ anyOf:
+ - $ref: "#/components/schemas/MotionSearchMetricsResponse"
+ - type: "null"
+ type: object
+ required:
+ - success
+ - message
+ - status
+ title: MotionSearchStatusResponse
+ description: Response containing job status and results.
PlaybackSourceEnum:
type: string
enum:
@@ -5315,6 +7113,7 @@ components:
new_name:
type: string
title: New Name
+ description: New name for the face
type: object
required:
- new_name
@@ -5345,6 +7144,10 @@ components:
type: array
minItems: 1
title: Ids
+ reviewed:
+ type: boolean
+ title: Reviewed
+ default: true
type: object
required:
- ids
@@ -5436,6 +7239,20 @@ components:
default: 1
type: object
title: SubmitPlusBody
+ ToolExecuteRequest:
+ properties:
+ tool_name:
+ type: string
+ title: Tool Name
+ arguments:
+ type: object
+ title: Arguments
+ type: object
+ required:
+ - tool_name
+ - arguments
+ title: ToolExecuteRequest
+ description: Request model for tool execution.
TriggerEmbeddingBody:
properties:
type:
diff --git a/web/src/types/graph.ts b/web/src/types/graph.ts
index 894a2feec..901ad14ad 100644
--- a/web/src/types/graph.ts
+++ b/web/src/types/graph.ts
@@ -24,8 +24,8 @@ export const EmbeddingThreshold = {
} as Threshold;
export const GenAIThreshold = {
- warning: 30000,
- error: 60000,
+ warning: 60,
+ error: 120,
} as Threshold;
export const DetectorTempThreshold = {
diff --git a/web/src/views/system/EnrichmentMetrics.tsx b/web/src/views/system/EnrichmentMetrics.tsx
index 29eda8fa2..252d63ebc 100644
--- a/web/src/views/system/EnrichmentMetrics.tsx
+++ b/web/src/views/system/EnrichmentMetrics.tsx
@@ -107,7 +107,10 @@ export default function EnrichmentMetrics({
};
}
- series[key].data.push({ x: statsIdx + 1, y: stat });
+ series[key].data.push({
+ x: statsIdx + 1,
+ y: rawKey.includes("description_speed") ? stat / 1000 : stat,
+ });
});
});
@@ -115,6 +118,7 @@ export default function EnrichmentMetrics({
const grouped: {
[category: string]: {
categoryName: string;
+ unit: string;
speedSeries?: {
name: string;
metrics: Threshold;
@@ -154,6 +158,7 @@ export default function EnrichmentMetrics({
if (!(categoryKey in grouped)) {
grouped[categoryKey] = {
categoryName,
+ unit: categoryKey.includes("description") ? "s" : "ms",
speedSeries: undefined,
eventsSeries: undefined,
};
@@ -196,7 +201,7 @@ export default function EnrichmentMetrics({
key={`${group.categoryName}-speed`}
graphId={`${group.categoryName}-inference`}
name={t("enrichments.averageInf")}
- unit="ms"
+ unit={group.unit}
threshold={group.speedSeries.metrics}
updateTimes={updateTimes}
data={[group.speedSeries]}
From a8da4c45216b12672f18a6cdee188d6f60eba7d1 Mon Sep 17 00:00:00 2001
From: Nicolas Mowen
Date: Fri, 20 Mar 2026 17:44:02 -0600
Subject: [PATCH 7/8] Add support a looping GenAI process to monitor a camera
(#22556)
* Add support for VLM monitoring a camera
* Cleanup
* Cleanup
---
frigate/api/chat.py | 237 +++++++++++++++++++++-
frigate/comms/webpush.py | 33 ++++
frigate/jobs/vlm_watch.py | 405 ++++++++++++++++++++++++++++++++++++++
3 files changed, 670 insertions(+), 5 deletions(-)
create mode 100644 frigate/jobs/vlm_watch.py
diff --git a/frigate/api/chat.py b/frigate/api/chat.py
index 900fa86cc..136c425f2 100644
--- a/frigate/api/chat.py
+++ b/frigate/api/chat.py
@@ -26,6 +26,11 @@ from frigate.api.defs.response.chat_response import (
from frigate.api.defs.tags import Tags
from frigate.api.event import events
from frigate.genai.utils import build_assistant_message_for_conversation
+from frigate.jobs.vlm_watch import (
+ get_vlm_watch_job,
+ start_vlm_watch_job,
+ stop_vlm_watch_job,
+)
logger = logging.getLogger(__name__)
@@ -82,6 +87,16 @@ class ToolExecuteRequest(BaseModel):
arguments: Dict[str, Any]
+class VLMMonitorRequest(BaseModel):
+ """Request model for starting a VLM watch job."""
+
+ camera: str
+ condition: str
+ max_duration_minutes: int = 60
+ labels: List[str] = []
+ zones: List[str] = []
+
+
def get_tool_definitions() -> List[Dict[str, Any]]:
"""
Get OpenAI-compatible tool definitions for Frigate.
@@ -95,9 +110,11 @@ def get_tool_definitions() -> List[Dict[str, Any]]:
"function": {
"name": "search_objects",
"description": (
- "Search for detected objects in Frigate by camera, object label, time range, "
- "zones, and other filters. Use this to answer questions about when "
- "objects were detected, what objects appeared, or to find specific object detections. "
+ "Search the historical record of detected objects in Frigate. "
+ "Use this ONLY for questions about the PAST — e.g. 'did anyone come by today?', "
+ "'when was the last car?', 'show me detections from yesterday'. "
+ "Do NOT use this for monitoring or alerting requests about future events — "
+ "use start_camera_watch instead for those. "
"An 'object' in Frigate represents a tracked detection (e.g., a person, package, car). "
"When the user asks about a specific name (person, delivery company, animal, etc.), "
"filter by sub_label only and do not set label."
@@ -217,6 +234,65 @@ def get_tool_definitions() -> List[Dict[str, Any]]:
},
},
},
+ {
+ "type": "function",
+ "function": {
+ "name": "start_camera_watch",
+ "description": (
+ "Start a continuous VLM watch job that monitors a camera and sends a notification "
+ "when a specified condition is met. Use this when the user wants to be alerted about "
+ "a future event, e.g. 'tell me when guests arrive' or 'notify me when the package is picked up'. "
+ "Only one watch job can run at a time. Returns a job ID."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "camera": {
+ "type": "string",
+ "description": "Camera ID to monitor.",
+ },
+ "condition": {
+ "type": "string",
+ "description": (
+ "Natural-language description of the condition to watch for, "
+ "e.g. 'a person arrives at the front door'."
+ ),
+ },
+ "max_duration_minutes": {
+ "type": "integer",
+ "description": "Maximum time to watch before giving up (minutes, default 60).",
+ "default": 60,
+ },
+ "labels": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Object labels that should trigger a VLM check (e.g. ['person', 'car']). If omitted, any detection on the camera triggers a check.",
+ },
+ "zones": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Zone names to filter by. If specified, only detections in these zones trigger a VLM check.",
+ },
+ },
+ "required": ["camera", "condition"],
+ },
+ },
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "stop_camera_watch",
+ "description": (
+ "Cancel the currently running VLM watch job. Use this when the user wants to "
+ "stop a previously started watch, e.g. 'stop watching the front door'."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {},
+ "required": [],
+ },
+ },
+ },
]
@@ -565,16 +641,75 @@ async def _execute_tool_internal(
)
return {"error": "Camera parameter is required"}
return await _execute_get_live_context(request, camera, allowed_cameras)
+ elif tool_name == "start_camera_watch":
+ return await _execute_start_camera_watch(request, arguments)
+ elif tool_name == "stop_camera_watch":
+ return _execute_stop_camera_watch()
else:
logger.error(
- "Tool call failed: unknown tool %r. Expected one of: search_objects, get_live_context. "
- "Arguments received: %s",
+ "Tool call failed: unknown tool %r. Expected one of: search_objects, get_live_context, "
+ "start_camera_watch, stop_camera_watch. Arguments received: %s",
tool_name,
json.dumps(arguments),
)
return {"error": f"Unknown tool: {tool_name}"}
+async def _execute_start_camera_watch(
+ request: Request,
+ arguments: Dict[str, Any],
+) -> Dict[str, Any]:
+ camera = arguments.get("camera", "").strip()
+ condition = arguments.get("condition", "").strip()
+ max_duration_minutes = int(arguments.get("max_duration_minutes", 60))
+ labels = arguments.get("labels") or []
+ zones = arguments.get("zones") or []
+
+ if not camera or not condition:
+ return {"error": "camera and condition are required."}
+
+ config = request.app.frigate_config
+ if camera not in config.cameras:
+ return {"error": f"Camera '{camera}' not found."}
+
+ genai_manager = request.app.genai_manager
+ vision_client = genai_manager.vision_client or genai_manager.tool_client
+ if vision_client is None:
+ return {"error": "No vision/GenAI provider configured."}
+
+ try:
+ job_id = start_vlm_watch_job(
+ camera=camera,
+ condition=condition,
+ max_duration_minutes=max_duration_minutes,
+ config=config,
+ frame_processor=request.app.detected_frames_processor,
+ genai_manager=genai_manager,
+ dispatcher=request.app.dispatcher,
+ labels=labels,
+ zones=zones,
+ )
+ except RuntimeError as e:
+ logger.error("Failed to start VLM watch job: %s", e, exc_info=True)
+ return {"error": "Failed to start VLM watch job."}
+
+ return {
+ "success": True,
+ "job_id": job_id,
+ "message": (
+ f"Now watching '{camera}' for: {condition}. "
+ f"You'll receive a notification when the condition is met (timeout: {max_duration_minutes} min)."
+ ),
+ }
+
+
+def _execute_stop_camera_watch() -> Dict[str, Any]:
+ cancelled = stop_vlm_watch_job()
+ if cancelled:
+ return {"success": True, "message": "Watch job cancelled."}
+ return {"success": False, "message": "No active watch job to cancel."}
+
+
async def _execute_pending_tools(
pending_tool_calls: List[Dict[str, Any]],
request: Request,
@@ -991,3 +1126,95 @@ Always be accurate with time calculations based on the current date provided.{ca
},
status_code=500,
)
+
+
+# ---------------------------------------------------------------------------
+# VLM Monitor endpoints
+# ---------------------------------------------------------------------------
+
+
+@router.post(
+ "/vlm/monitor",
+ dependencies=[Depends(allow_any_authenticated())],
+ summary="Start a VLM watch job",
+ description=(
+ "Start monitoring a camera with the vision provider. "
+ "The VLM analyzes live frames until the specified condition is met, "
+ "then sends a notification. Only one watch job can run at a time."
+ ),
+)
+async def start_vlm_monitor(
+ request: Request,
+ body: VLMMonitorRequest,
+) -> JSONResponse:
+ config = request.app.frigate_config
+ genai_manager = request.app.genai_manager
+
+ if body.camera not in config.cameras:
+ return JSONResponse(
+ content={"success": False, "message": f"Camera '{body.camera}' not found."},
+ status_code=404,
+ )
+
+ vision_client = genai_manager.vision_client or genai_manager.tool_client
+ if vision_client is None:
+ return JSONResponse(
+ content={
+ "success": False,
+ "message": "No vision/GenAI provider configured.",
+ },
+ status_code=400,
+ )
+
+ try:
+ job_id = start_vlm_watch_job(
+ camera=body.camera,
+ condition=body.condition,
+ max_duration_minutes=body.max_duration_minutes,
+ config=config,
+ frame_processor=request.app.detected_frames_processor,
+ genai_manager=genai_manager,
+ dispatcher=request.app.dispatcher,
+ labels=body.labels,
+ zones=body.zones,
+ )
+ except RuntimeError as e:
+ logger.error("Failed to start VLM watch job: %s", e, exc_info=True)
+ return JSONResponse(
+ content={"success": False, "message": "Failed to start VLM watch job."},
+ status_code=409,
+ )
+
+ return JSONResponse(
+ content={"success": True, "job_id": job_id},
+ status_code=201,
+ )
+
+
+@router.get(
+ "/vlm/monitor",
+ dependencies=[Depends(allow_any_authenticated())],
+ summary="Get current VLM watch job",
+ description="Returns the current (or most recently completed) VLM watch job.",
+)
+async def get_vlm_monitor() -> JSONResponse:
+ job = get_vlm_watch_job()
+ if job is None:
+ return JSONResponse(content={"active": False}, status_code=200)
+ return JSONResponse(content={"active": True, **job.to_dict()}, status_code=200)
+
+
+@router.delete(
+ "/vlm/monitor",
+ dependencies=[Depends(allow_any_authenticated())],
+ summary="Cancel the current VLM watch job",
+ description="Cancels the running watch job if one exists.",
+)
+async def cancel_vlm_monitor() -> JSONResponse:
+ cancelled = stop_vlm_watch_job()
+ if not cancelled:
+ return JSONResponse(
+ content={"success": False, "message": "No active watch job to cancel."},
+ status_code=404,
+ )
+ return JSONResponse(content={"success": True}, status_code=200)
diff --git a/frigate/comms/webpush.py b/frigate/comms/webpush.py
index 62cc12c9a..ad8142f6f 100644
--- a/frigate/comms/webpush.py
+++ b/frigate/comms/webpush.py
@@ -210,6 +210,15 @@ class WebPushClient(Communicator):
logger.debug(f"Notifications for {camera} are currently suspended.")
return
self.send_trigger(decoded)
+ elif topic == "camera_monitoring":
+ decoded = json.loads(payload)
+ camera = decoded["camera"]
+ if not self.config.cameras[camera].notifications.enabled:
+ return
+ if self.is_camera_suspended(camera):
+ logger.debug(f"Notifications for {camera} are currently suspended.")
+ return
+ self.send_camera_monitoring(decoded)
elif topic == "notification_test":
if not self.config.notifications.enabled and not any(
cam.notifications.enabled for cam in self.config.cameras.values()
@@ -477,6 +486,30 @@ class WebPushClient(Communicator):
self.cleanup_registrations()
+ def send_camera_monitoring(self, payload: dict[str, Any]) -> None:
+ camera: str = payload["camera"]
+ camera_name: str = getattr(
+ self.config.cameras[camera], "friendly_name", None
+ ) or titlecase(camera.replace("_", " "))
+
+ self.check_registrations()
+
+ reasoning: str = payload.get("reasoning", "")
+ title = f"{camera_name}: Monitoring Alert"
+ message = (reasoning[:197] + "...") if len(reasoning) > 200 else reasoning
+
+ logger.debug(f"Sending camera monitoring push notification for {camera_name}")
+
+ for user in self.web_pushers:
+ self.send_push_notification(
+ user=user,
+ payload=payload,
+ title=title,
+ message=message,
+ )
+
+ self.cleanup_registrations()
+
def stop(self) -> None:
logger.info("Closing notification queue")
self.notification_thread.join()
diff --git a/frigate/jobs/vlm_watch.py b/frigate/jobs/vlm_watch.py
new file mode 100644
index 000000000..dae5e5f41
--- /dev/null
+++ b/frigate/jobs/vlm_watch.py
@@ -0,0 +1,405 @@
+"""VLM watch job: continuously monitors a camera and notifies when a condition is met."""
+
+import base64
+import json
+import logging
+import re
+import threading
+import time
+from dataclasses import asdict, dataclass, field
+from datetime import datetime
+from typing import Any, Optional
+
+import cv2
+
+from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
+from frigate.comms.inter_process import InterProcessRequestor
+from frigate.config import FrigateConfig
+from frigate.const import UPDATE_JOB_STATE
+from frigate.jobs.job import Job
+from frigate.types import JobStatusTypesEnum
+
+logger = logging.getLogger(__name__)
+
+# Polling interval bounds (seconds)
+_MIN_INTERVAL = 1
+_MAX_INTERVAL = 300
+
+# Max user/assistant turn pairs to keep in conversation history
+_MAX_HISTORY = 10
+
+
+@dataclass
+class VLMWatchJob(Job):
+ """Job state for a VLM watch monitor."""
+
+ job_type: str = "vlm_watch"
+ camera: str = ""
+ condition: str = ""
+ max_duration_minutes: int = 60
+ labels: list = field(default_factory=list)
+ zones: list = field(default_factory=list)
+ last_reasoning: str = ""
+ iteration_count: int = 0
+
+ def to_dict(self) -> dict[str, Any]:
+ return asdict(self)
+
+
+class VLMWatchRunner(threading.Thread):
+ """Background thread that polls a camera with the vision client until a condition is met."""
+
+ def __init__(
+ self,
+ job: VLMWatchJob,
+ config: FrigateConfig,
+ cancel_event: threading.Event,
+ frame_processor,
+ genai_manager,
+ dispatcher,
+ ) -> None:
+ super().__init__(daemon=True, name=f"vlm_watch_{job.id}")
+ self.job = job
+ self.config = config
+ self.cancel_event = cancel_event
+ self.frame_processor = frame_processor
+ self.genai_manager = genai_manager
+ self.dispatcher = dispatcher
+ self.requestor = InterProcessRequestor()
+ self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video.value)
+ self.conversation: list[dict[str, Any]] = []
+
+ def run(self) -> None:
+ self.job.status = JobStatusTypesEnum.running
+ self.job.start_time = time.time()
+ self._broadcast_status()
+ self.conversation = [{"role": "system", "content": self._build_system_prompt()}]
+
+ max_end_time = self.job.start_time + self.job.max_duration_minutes * 60
+
+ try:
+ while not self.cancel_event.is_set():
+ if time.time() > max_end_time:
+ logger.debug(
+ "VLM watch job %s timed out after %d minutes",
+ self.job.id,
+ self.job.max_duration_minutes,
+ )
+ self.job.status = JobStatusTypesEnum.failed
+ self.job.error_message = f"Monitor timed out after {self.job.max_duration_minutes} minutes"
+ break
+
+ next_run_in = self._run_iteration()
+
+ if self.job.status == JobStatusTypesEnum.success:
+ break
+
+ self._wait_for_trigger(next_run_in)
+
+ except Exception as e:
+ logger.exception("VLM watch job %s failed: %s", self.job.id, e)
+ self.job.status = JobStatusTypesEnum.failed
+ self.job.error_message = str(e)
+
+ finally:
+ if self.job.status == JobStatusTypesEnum.running:
+ self.job.status = JobStatusTypesEnum.cancelled
+ self.job.end_time = time.time()
+ self._broadcast_status()
+ try:
+ self.detection_subscriber.stop()
+ except Exception:
+ pass
+ try:
+ self.requestor.stop()
+ except Exception:
+ pass
+
+ def _run_iteration(self) -> float:
+ """Run one VLM analysis iteration. Returns seconds until next run."""
+ vision_client = (
+ self.genai_manager.vision_client or self.genai_manager.tool_client
+ )
+ if vision_client is None:
+ logger.warning("VLM watch job %s: no vision client available", self.job.id)
+ return 30
+
+ frame = self.frame_processor.get_current_frame(self.job.camera, {})
+ if frame is None:
+ logger.debug(
+ "VLM watch job %s: frame unavailable for camera %s",
+ self.job.id,
+ self.job.camera,
+ )
+ self.job.last_reasoning = "Camera frame unavailable"
+ return 10
+
+ # Downscale frame to 480p max height
+ h, w = frame.shape[:2]
+ if h > 480:
+ scale = 480.0 / h
+ frame = cv2.resize(
+ frame, (int(w * scale), 480), interpolation=cv2.INTER_AREA
+ )
+
+ _, enc = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
+ b64 = base64.b64encode(enc.tobytes()).decode()
+
+ timestamp = datetime.now().strftime("%H:%M:%S")
+ self.conversation.append(
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": f"Frame captured at {timestamp}."},
+ {
+ "type": "image_url",
+ "image_url": {"url": f"data:image/jpeg;base64,{b64}"},
+ },
+ ],
+ }
+ )
+
+ response = vision_client.chat_with_tools(
+ messages=self.conversation,
+ tools=None,
+ tool_choice=None,
+ )
+ response_str = response.get("content") or ""
+
+ if not response_str:
+ logger.warning(
+ "VLM watch job %s: empty response from vision client", self.job.id
+ )
+ # Remove the user message we just added so we don't leave a dangling turn
+ self.conversation.pop()
+ return 30
+
+ logger.debug("VLM watch job %s response: %s", self.job.id, response_str)
+
+ self.conversation.append({"role": "assistant", "content": response_str})
+
+ # Keep system prompt + last _MAX_HISTORY user/assistant pairs
+ max_msgs = 1 + _MAX_HISTORY * 2
+ if len(self.conversation) > max_msgs:
+ self.conversation = [self.conversation[0]] + self.conversation[
+ -(max_msgs - 1) :
+ ]
+
+ try:
+ clean = re.sub(
+ r"\n?```$", "", re.sub(r"^```[a-zA-Z0-9]*\n?", "", response_str)
+ )
+ parsed = json.loads(clean)
+ condition_met = bool(parsed.get("condition_met", False))
+ next_run_in = max(
+ _MIN_INTERVAL,
+ min(_MAX_INTERVAL, int(parsed.get("next_run_in", 30))),
+ )
+ reasoning = str(parsed.get("reasoning", ""))
+ except (json.JSONDecodeError, ValueError, TypeError) as e:
+ logger.warning(
+ "VLM watch job %s: failed to parse VLM response: %s", self.job.id, e
+ )
+ return 30
+
+ self.job.last_reasoning = reasoning
+ self.job.iteration_count += 1
+ self._broadcast_status()
+
+ if condition_met:
+ logger.debug(
+ "VLM watch job %s: condition met on camera %s — %s",
+ self.job.id,
+ self.job.camera,
+ reasoning,
+ )
+ self._send_notification(reasoning)
+ self.job.status = JobStatusTypesEnum.success
+ return 0
+
+ return next_run_in
+
+ def _wait_for_trigger(self, max_wait: float) -> None:
+ """Wait up to max_wait seconds, returning early if a relevant detection fires on the target camera."""
+ deadline = time.time() + max_wait
+ while not self.cancel_event.is_set():
+ remaining = deadline - time.time()
+ if remaining <= 0:
+ break
+ topic, payload = self.detection_subscriber.check_for_update(
+ timeout=min(1.0, remaining)
+ )
+ if topic is None or payload is None:
+ continue
+ # payload = (camera, frame_name, frame_time, tracked_objects, motion_boxes, regions)
+ cam = payload[0]
+ tracked_objects = payload[3]
+ logger.debug(
+ "VLM watch job %s: detection event cam=%s (want %s), objects=%s",
+ self.job.id,
+ cam,
+ self.job.camera,
+ [
+ {"label": o.get("label"), "zones": o.get("current_zones")}
+ for o in (tracked_objects or [])
+ ],
+ )
+ if cam != self.job.camera or not tracked_objects:
+ continue
+ if self._detection_matches_filters(tracked_objects):
+ logger.debug(
+ "VLM watch job %s: woken early by detection event on %s",
+ self.job.id,
+ self.job.camera,
+ )
+ break
+
+ def _detection_matches_filters(self, tracked_objects: list) -> bool:
+ """Return True if any tracked object passes the label and zone filters."""
+ labels = self.job.labels
+ zones = self.job.zones
+ for obj in tracked_objects:
+ label_ok = not labels or obj.get("label") in labels
+ zone_ok = not zones or bool(set(obj.get("current_zones", [])) & set(zones))
+ if label_ok and zone_ok:
+ return True
+ return False
+
+ def _build_system_prompt(self) -> str:
+ focus_text = ""
+ if self.job.labels or self.job.zones:
+ parts = []
+ if self.job.labels:
+ parts.append(f"object types: {', '.join(self.job.labels)}")
+ if self.job.zones:
+ parts.append(f"zones: {', '.join(self.job.zones)}")
+ focus_text = f"\nFocus on {' and '.join(parts)}.\n"
+
+ return (
+ f'You are monitoring a security camera. Your task: determine when "{self.job.condition}" occurs.\n'
+ f"{focus_text}\n"
+ f"You will receive a sequence of frames over time. Use the conversation history to understand "
+ f"what is stationary vs. actively changing.\n\n"
+ f"For each frame respond with JSON only:\n"
+ f'{{"condition_met": , "next_run_in": , "reasoning": ""}}\n\n'
+ f"Guidelines for next_run_in:\n"
+ f"- Scene is empty / nothing of interest visible: 60-300.\n"
+ f"- Relevant object(s) visible anywhere in frame (even outside the target zone): 3-10. "
+ f"They may be moving toward the zone.\n"
+ f"- Condition is actively forming (object approaching zone or threshold): 1-5.\n"
+ f"- Set condition_met to true only when you are confident the condition is currently met.\n"
+ f"- Keep reasoning to 1-2 sentences."
+ )
+
+ def _send_notification(self, reasoning: str) -> None:
+ """Publish a camera_monitoring event so downstream handlers (web push, MQTT) can notify users."""
+ payload = {
+ "camera": self.job.camera,
+ "condition": self.job.condition,
+ "reasoning": reasoning,
+ "job_id": self.job.id,
+ }
+
+ if self.dispatcher:
+ try:
+ self.dispatcher.publish("camera_monitoring", json.dumps(payload))
+ except Exception as e:
+ logger.warning(
+ "VLM watch job %s: failed to publish alert: %s", self.job.id, e
+ )
+
+ def _broadcast_status(self) -> None:
+ try:
+ self.requestor.send_data(UPDATE_JOB_STATE, self.job.to_dict())
+ except Exception as e:
+ logger.warning(
+ "VLM watch job %s: failed to broadcast status: %s", self.job.id, e
+ )
+
+
+# Module-level singleton (only one watch job at a time)
+_current_job: Optional[VLMWatchJob] = None
+_cancel_event: Optional[threading.Event] = None
+_job_lock = threading.Lock()
+
+
+def start_vlm_watch_job(
+ camera: str,
+ condition: str,
+ max_duration_minutes: int,
+ config: FrigateConfig,
+ frame_processor,
+ genai_manager,
+ dispatcher,
+ labels: list[str] | None = None,
+ zones: list[str] | None = None,
+) -> str:
+ """Start a new VLM watch job. Returns the job ID.
+
+ Raises RuntimeError if a job is already running.
+ """
+ global _current_job, _cancel_event
+
+ with _job_lock:
+ if _current_job is not None and _current_job.status in (
+ JobStatusTypesEnum.queued,
+ JobStatusTypesEnum.running,
+ ):
+ raise RuntimeError(
+ f"A VLM watch job is already running (id={_current_job.id}). "
+ "Cancel it before starting a new one."
+ )
+
+ job = VLMWatchJob(
+ camera=camera,
+ condition=condition,
+ max_duration_minutes=max_duration_minutes,
+ labels=labels or [],
+ zones=zones or [],
+ )
+ cancel_ev = threading.Event()
+ _current_job = job
+ _cancel_event = cancel_ev
+
+ runner = VLMWatchRunner(
+ job=job,
+ config=config,
+ cancel_event=cancel_ev,
+ frame_processor=frame_processor,
+ genai_manager=genai_manager,
+ dispatcher=dispatcher,
+ )
+ runner.start()
+
+ logger.debug(
+ "Started VLM watch job %s: camera=%s, condition=%r, max_duration=%dm",
+ job.id,
+ camera,
+ condition,
+ max_duration_minutes,
+ )
+ return job.id
+
+
+def stop_vlm_watch_job() -> bool:
+ """Cancel the current VLM watch job. Returns True if a job was cancelled."""
+ global _current_job, _cancel_event
+
+ with _job_lock:
+ if _current_job is None or _current_job.status not in (
+ JobStatusTypesEnum.queued,
+ JobStatusTypesEnum.running,
+ ):
+ return False
+
+ if _cancel_event:
+ _cancel_event.set()
+
+ _current_job.status = JobStatusTypesEnum.cancelled
+ logger.debug("Cancelled VLM watch job %s", _current_job.id)
+ return True
+
+
+def get_vlm_watch_job() -> Optional[VLMWatchJob]:
+ """Return the current (or most recent) VLM watch job."""
+ return _current_job
From 6d2b84e20233ad784ce524b08f1a702ec6cc1554 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Fri, 20 Mar 2026 19:02:47 -0500
Subject: [PATCH 8/8] Improve process watchdog (#22557)
* monitor subprocesses and auto-restart with watchdog
* fix typing
* formatting
---
frigate/app.py | 44 +++++++++++++++++++-
frigate/watchdog.py | 97 ++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 139 insertions(+), 2 deletions(-)
diff --git a/frigate/app.py b/frigate/app.py
index fef37813a..750f1ad23 100644
--- a/frigate/app.py
+++ b/frigate/app.py
@@ -8,7 +8,7 @@ from multiprocessing import Queue
from multiprocessing.managers import DictProxy, SyncManager
from multiprocessing.synchronize import Event as MpEvent
from pathlib import Path
-from typing import Optional
+from typing import Callable, Optional
import psutil
import uvicorn
@@ -81,6 +81,7 @@ from frigate.timeline import TimelineProcessor
from frigate.track.object_processing import TrackedObjectProcessor
from frigate.util.builtin import empty_and_close_queue
from frigate.util.image import UntrackedSharedMemory
+from frigate.util.process import FrigateProcess
from frigate.util.services import set_file_limit
from frigate.version import VERSION
from frigate.watchdog import FrigateWatchdog
@@ -497,6 +498,47 @@ class FrigateApp:
def start_watchdog(self) -> None:
self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event)
+
+ # (attribute on self, key in self.processes, factory)
+ specs: list[tuple[str, str, Callable[[], FrigateProcess]]] = [
+ (
+ "embedding_process",
+ "embeddings",
+ lambda: EmbeddingProcess(
+ self.config, self.embeddings_metrics, self.stop_event
+ ),
+ ),
+ (
+ "recording_process",
+ "recording",
+ lambda: RecordProcess(self.config, self.stop_event),
+ ),
+ (
+ "review_segment_process",
+ "review_segment",
+ lambda: ReviewProcess(self.config, self.stop_event),
+ ),
+ (
+ "output_processor",
+ "output",
+ lambda: OutputProcess(self.config, self.stop_event),
+ ),
+ ]
+
+ for attr, key, factory in specs:
+ if not hasattr(self, attr):
+ continue
+
+ def on_restart(
+ proc: FrigateProcess, _attr: str = attr, _key: str = key
+ ) -> None:
+ setattr(self, _attr, proc)
+ self.processes[_key] = proc.pid or 0
+
+ self.frigate_watchdog.register(
+ key, getattr(self, attr), factory, on_restart
+ )
+
self.frigate_watchdog.start()
def init_auth(self) -> None:
diff --git a/frigate/watchdog.py b/frigate/watchdog.py
index 4c49de1a0..63fd16629 100644
--- a/frigate/watchdog.py
+++ b/frigate/watchdog.py
@@ -2,19 +2,111 @@ import datetime
import logging
import threading
import time
+from collections import deque
+from dataclasses import dataclass, field
from multiprocessing.synchronize import Event as MpEvent
+from typing import Callable
from frigate.object_detection.base import ObjectDetectProcess
+from frigate.util.process import FrigateProcess
from frigate.util.services import restart_frigate
logger = logging.getLogger(__name__)
+MAX_RESTARTS = 5
+RESTART_WINDOW_S = 60
+
+
+@dataclass
+class MonitoredProcess:
+ """A process monitored by the watchdog for automatic restart."""
+
+ name: str
+ process: FrigateProcess
+ factory: Callable[[], FrigateProcess]
+ on_restart: Callable[[FrigateProcess], None] | None = None
+ restart_timestamps: deque[float] = field(
+ default_factory=lambda: deque(maxlen=MAX_RESTARTS)
+ )
+
+ def is_restarting_too_fast(self, now: float) -> bool:
+ while (
+ self.restart_timestamps
+ and now - self.restart_timestamps[0] > RESTART_WINDOW_S
+ ):
+ self.restart_timestamps.popleft()
+ return len(self.restart_timestamps) >= MAX_RESTARTS
+
class FrigateWatchdog(threading.Thread):
- def __init__(self, detectors: dict[str, ObjectDetectProcess], stop_event: MpEvent):
+ def __init__(
+ self,
+ detectors: dict[str, ObjectDetectProcess],
+ stop_event: MpEvent,
+ ):
super().__init__(name="frigate_watchdog")
self.detectors = detectors
self.stop_event = stop_event
+ self._monitored: list[MonitoredProcess] = []
+
+ def register(
+ self,
+ name: str,
+ process: FrigateProcess,
+ factory: Callable[[], FrigateProcess],
+ on_restart: Callable[[FrigateProcess], None] | None = None,
+ ) -> None:
+ """Register a FrigateProcess for monitoring and automatic restart."""
+ self._monitored.append(
+ MonitoredProcess(
+ name=name,
+ process=process,
+ factory=factory,
+ on_restart=on_restart,
+ )
+ )
+
+ def _check_process(self, entry: MonitoredProcess) -> None:
+ if entry.process.is_alive():
+ return
+
+ exitcode = entry.process.exitcode
+ if exitcode == 0:
+ logger.info("Process %s exited cleanly, not restarting", entry.name)
+ return
+
+ logger.warning(
+ "Process %s (PID %s) exited with code %s",
+ entry.name,
+ entry.process.pid,
+ exitcode,
+ )
+
+ now = datetime.datetime.now().timestamp()
+
+ if entry.is_restarting_too_fast(now):
+ logger.error(
+ "Process %s restarting too frequently (%d times in %ds), backing off",
+ entry.name,
+ MAX_RESTARTS,
+ RESTART_WINDOW_S,
+ )
+ return
+
+ try:
+ entry.process.close()
+ new_process = entry.factory()
+ new_process.start()
+
+ entry.process = new_process
+ entry.restart_timestamps.append(now)
+
+ if entry.on_restart:
+ entry.on_restart(new_process)
+
+ logger.info("Restarted %s (PID %s)", entry.name, new_process.pid)
+ except Exception:
+ logger.exception("Failed to restart %s", entry.name)
def run(self) -> None:
time.sleep(10)
@@ -38,4 +130,7 @@ class FrigateWatchdog(threading.Thread):
logger.info("Detection appears to have stopped. Exiting Frigate...")
restart_frigate()
+ for entry in self._monitored:
+ self._check_process(entry)
+
logger.info("Exiting watchdog...")