2024-03-03 01:10:37 +03:00
|
|
|
"""Main api runner."""
|
|
|
|
|
|
2025-02-10 18:38:56 +03:00
|
|
|
import asyncio
|
2024-03-03 01:10:37 +03:00
|
|
|
import copy
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
2026-03-23 17:37:40 +03:00
|
|
|
import platform
|
2024-03-03 01:10:37 +03:00
|
|
|
import traceback
|
2025-06-11 20:25:30 +03:00
|
|
|
import urllib
|
2024-03-03 01:10:37 +03:00
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
from functools import reduce
|
2024-12-18 23:10:32 +03:00
|
|
|
from io import StringIO
|
2025-03-17 21:44:57 +03:00
|
|
|
from pathlib import Path as FilePath
|
2025-09-19 15:27:20 +03:00
|
|
|
from typing import Any, Dict, List, Optional
|
2024-03-03 01:10:37 +03:00
|
|
|
|
2025-02-10 18:38:56 +03:00
|
|
|
import aiofiles
|
2024-12-18 23:10:32 +03:00
|
|
|
import ruamel.yaml
|
2024-09-26 01:22:11 +03:00
|
|
|
from fastapi import APIRouter, Body, Path, Request, Response
|
2024-09-24 16:05:30 +03:00
|
|
|
from fastapi.encoders import jsonable_encoder
|
|
|
|
|
from fastapi.params import Depends
|
2025-02-10 18:38:56 +03:00
|
|
|
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
|
2026-02-28 17:04:43 +03:00
|
|
|
from filelock import FileLock, Timeout
|
2024-03-03 01:10:37 +03:00
|
|
|
from markupsafe import escape
|
2025-09-19 15:27:20 +03:00
|
|
|
from peewee import SQL, fn, operator
|
2024-12-18 23:10:32 +03:00
|
|
|
from pydantic import ValidationError
|
2024-09-24 16:05:30 +03:00
|
|
|
|
2026-01-18 16:36:27 +03:00
|
|
|
from frigate.api.auth import (
|
|
|
|
|
allow_any_authenticated,
|
|
|
|
|
allow_public,
|
|
|
|
|
get_allowed_cameras_for_filter,
|
|
|
|
|
require_role,
|
|
|
|
|
)
|
2024-12-06 17:04:02 +03:00
|
|
|
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
|
Camera profile support (#22482)
* add CameraProfileConfig model for named config overrides
* add profiles field to CameraConfig
* add active_profile field to FrigateConfig
Runtime-only field excluded from YAML serialization, tracks which
profile is currently active.
* add ProfileManager for profile activation and persistence
Handles snapshotting base configs, applying profile overrides via
deep_merge + apply_section_update, publishing ZMQ updates, and
persisting active profile to /config/.active_profile.
* add profile API endpoints (GET /profiles, GET/PUT /profile)
* add MQTT and dispatcher integration for profiles
- Subscribe to frigate/profile/set MQTT topic
- Publish profile/state and profiles/available on connect
- Add _on_profile_command handler to dispatcher
- Broadcast active profile state on WebSocket connect
* wire ProfileManager into app startup and FastAPI
- Create ProfileManager after dispatcher init
- Restore persisted profile on startup
- Pass dispatcher and profile_manager to FastAPI app
* add tests for invalid profile values and keys
Tests that Pydantic rejects: invalid field values (fps: "not_a_number"),
unknown section keys (ffmpeg in profile), invalid nested values, and
invalid profiles in full config parsing.
* formatting
* fix CameraLiveConfig JSON serialization error on profile activation
refactor _publish_updates to only publish ZMQ updates for
sections that actually changed, not all sections on affected cameras.
* consolidate
* add enabled field to camera profiles for enabling/disabling cameras
* add zones support to camera profiles
* add frontend profile types, color utility, and config save support
* add profile state management and save preview support
* add profileName prop to BaseSection for profile-aware config editing
* add profile section dropdown and wire into camera settings pages
* add per-profile camera enable/disable to Camera Management view
* add profiles summary page with card-based layout and fix backend zone comparison bug
* add active profile badge to settings toolbar
* i18n
* add red dot for any pending changes including profiles
* profile support for mask and zone editor
* fix hidden field validation errors caused by lodash wildcard and schema gaps
lodash unset does not support wildcard (*) segments, so hidden fields like
filters.*.mask were never stripped from form data, leaving null raw_coordinates
that fail RJSF anyOf validation. Add unsetWithWildcard helper and also strip
hidden fields from the JSON schema itself as defense-in-depth.
* add face_recognition and lpr to profile-eligible sections
* move profile dropdown from section panes to settings header
* add profiles enable toggle and improve empty state
* formatting
* tweaks
* tweak colors and switch
* fix profile save diff, masksAndZones delete, and config sync
* ui tweaks
* ensure profile manager gets updated config
* rename profile settings to ui settings
* refactor profilesview and add dots/border colors when overridden
* implement an update_config method for profile manager
* fix mask deletion
* more unique colors
* add top-level profiles config section with friendly names
* implement profile friendly names and improve profile UI
- Add ProfileDefinitionConfig type and profiles field to FrigateConfig
- Use ProfilesApiResponse type with friendly_name support throughout
- Replace Record<string, unknown> with proper JsonObject/JsonValue types
- Add profile creation form matching zone pattern (Zod + NameAndIdFields)
- Add pencil icon for renaming profile friendly names in ProfilesView
- Move Profiles menu item to first under Camera Configuration
- Add activity indicators on save/rename/delete buttons
- Display friendly names in CameraManagementView profile selector
- Fix duplicate colored dots in management profile dropdown
- Fix i18n namespace for overridden base config tooltips
- Move profile override deletion from dropdown trash icon to footer
button with confirmation dialog, matching Reset to Global pattern
- Remove Add Profile from section header dropdown to prevent saving
camera overrides before top-level profile definition exists
- Clean up newProfiles state after API profile deletion
- Refresh profiles SWR cache after saving profile definitions
* remove profile badge in settings and add profiles to main menu
* use icon only on mobile
* change color order
* docs
* show activity indicator on trash icon while deleting a profile
* tweak language
* immediately create profiles on backend instead of deferring to Save All
* hide restart-required fields when editing a profile section
fields that require a restart cannot take effect via profile switching,
so they are merged into hiddenFields when profileName is set
* show active profile indicator in desktop status bar
* fix profile config inheritance bug where Pydantic defaults override base values
The /config API was dumping profile overrides with model_dump() which included
all Pydantic defaults. When the frontend merged these over
the camera's base config, explicitly-set base values were
lost. Now profile overrides are re-dumped with exclude_unset=True so only
user-specified fields are returned.
Also fixes the Save All path generating spurious deletion markers for
restart-required fields that are hidden during profile
editing but not excluded from the raw data sanitization in
prepareSectionSavePayload.
* docs tweaks
* docs tweak
* formatting
* formatting
* fix typing
* fix test pollution
test_maintainer was injecting MagicMock() into sys.modules["frigate.config.camera.updater"] at module load time and never restoring it. When the profile tests later imported CameraConfigUpdateEnum and CameraConfigUpdateTopic from that module, they got mock objects instead of the real dataclass/enum, so equality comparisons always failed
* remove
* fix settings showing profile-merged values when editing base config
When a profile is active, the in-memory config contains effective
(profile-merged) values. The settings UI was displaying these merged
values even when the "Base Config" view was selected.
Backend: snapshot pre-profile base configs in ProfileManager and expose
them via a `base_config` key in the /api/config camera response when a
profile is active. The top-level sections continue to reflect the
effective running config.
Frontend: read from `base_config` when available in BaseSection,
useConfigOverride, useAllCameraOverrides, and prepareSectionSavePayload.
Include formData labels in Object/Audio switches widgets so that labels
added only by a profile override remain visible when editing that profile.
* use rasterized_mask as field
makes it easier to exclude from the schema with exclude=True
prevents leaking of the field when using model_dump for profiles
* fix zones
- Fix zone colors not matching across profiles by falling back to base zone color when profile zone data lacks a color field
- Use base_config for base-layer values in masks/zones view so profile-merged values don't pollute the base config editing view
- Handle zones separately in profile manager snapshot/restore since ZoneConfig requires special serialization (color as private attr, contour generation)
- Inherit base zone color and generate contours for profile zone overrides in profile manager
* formatting
* don't require restart for camera enabled change for profiles
* publish camera state when changing profiles
* formatting
* remove available profiles from mqtt
* improve typing
2026-03-19 17:47:57 +03:00
|
|
|
from frigate.api.defs.request.app_body import (
|
|
|
|
|
AppConfigSetBody,
|
|
|
|
|
MediaSyncBody,
|
|
|
|
|
)
|
2024-09-24 16:05:30 +03:00
|
|
|
from frigate.api.defs.tags import Tags
|
2024-06-15 02:02:13 +03:00
|
|
|
from frigate.config import FrigateConfig
|
2025-05-23 05:51:23 +03:00
|
|
|
from frigate.config.camera.updater import (
|
|
|
|
|
CameraConfigUpdateEnum,
|
|
|
|
|
CameraConfigUpdateTopic,
|
|
|
|
|
)
|
2025-12-15 18:58:50 +03:00
|
|
|
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
|
2026-01-06 18:20:19 +03:00
|
|
|
from frigate.jobs.media_sync import (
|
|
|
|
|
get_current_media_sync_job,
|
|
|
|
|
get_media_sync_job_by_id,
|
|
|
|
|
start_media_sync_job,
|
|
|
|
|
)
|
2024-03-05 22:55:44 +03:00
|
|
|
from frigate.models import Event, Timeline
|
2025-02-17 16:17:15 +03:00
|
|
|
from frigate.stats.prometheus import get_metrics, update_metrics
|
2026-01-06 18:20:19 +03:00
|
|
|
from frigate.types import JobStatusTypesEnum
|
2024-03-03 01:10:37 +03:00
|
|
|
from frigate.util.builtin import (
|
|
|
|
|
clean_camera_user_pass,
|
2026-03-04 19:07:34 +03:00
|
|
|
deep_merge,
|
2025-06-11 20:25:30 +03:00
|
|
|
flatten_config_data,
|
2026-02-27 18:55:36 +03:00
|
|
|
load_labels,
|
2025-06-11 20:25:30 +03:00
|
|
|
process_config_query_string,
|
|
|
|
|
update_yaml_file_bulk,
|
2024-03-03 01:10:37 +03:00
|
|
|
)
|
2026-03-04 19:07:34 +03:00
|
|
|
from frigate.util.config import apply_section_update, find_config_file
|
2026-02-27 18:55:36 +03:00
|
|
|
from frigate.util.schema import get_config_schema
|
2024-10-08 05:15:31 +03:00
|
|
|
from frigate.util.services import (
|
|
|
|
|
get_nvidia_driver_info,
|
2025-02-10 18:38:56 +03:00
|
|
|
process_logs,
|
2024-10-08 05:15:31 +03:00
|
|
|
restart_frigate,
|
|
|
|
|
vainfo_hwaccel,
|
|
|
|
|
)
|
2025-11-03 16:34:47 +03:00
|
|
|
from frigate.util.time import get_tz_modifiers
|
2024-03-03 01:10:37 +03:00
|
|
|
from frigate.version import VERSION
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
2024-09-24 16:05:30 +03:00
|
|
|
router = APIRouter(tags=[Tags.app])
|
|
|
|
|
|
|
|
|
|
|
2025-11-27 00:07:28 +03:00
|
|
|
@router.get(
|
|
|
|
|
"/", response_class=PlainTextResponse, dependencies=[Depends(allow_public())]
|
|
|
|
|
)
|
2024-03-03 01:10:37 +03:00
|
|
|
def is_healthy():
|
|
|
|
|
return "Frigate is running. Alive and healthy!"
|
|
|
|
|
|
|
|
|
|
|
2025-11-27 00:07:28 +03:00
|
|
|
@router.get("/config/schema.json", dependencies=[Depends(allow_public())])
|
2024-09-24 16:05:30 +03:00
|
|
|
def config_schema(request: Request):
|
2026-02-27 18:55:36 +03:00
|
|
|
return JSONResponse(content=get_config_schema(FrigateConfig))
|
2024-03-03 01:10:37 +03:00
|
|
|
|
|
|
|
|
|
2025-11-27 00:07:28 +03:00
|
|
|
@router.get(
|
|
|
|
|
"/version", response_class=PlainTextResponse, dependencies=[Depends(allow_public())]
|
|
|
|
|
)
|
2024-03-03 01:10:37 +03:00
|
|
|
def version():
|
2024-09-25 21:35:30 +03:00
|
|
|
return VERSION
|
2024-03-03 01:10:37 +03:00
|
|
|
|
|
|
|
|
|
2025-11-27 00:07:28 +03:00
|
|
|
@router.get("/stats", dependencies=[Depends(allow_any_authenticated())])
|
2026-05-13 18:40:29 +03:00
|
|
|
def stats(
|
|
|
|
|
request: Request,
|
|
|
|
|
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
|
|
|
|
):
|
|
|
|
|
stats_data = request.app.stats_emitter.get_latest_stats()
|
|
|
|
|
|
|
|
|
|
# Admins see the full snapshot
|
|
|
|
|
if request.headers.get("remote-role") == "admin":
|
|
|
|
|
return JSONResponse(content=stats_data)
|
|
|
|
|
|
|
|
|
|
allowed_set = set(allowed_cameras)
|
|
|
|
|
|
|
|
|
|
# Shallow-copy so we don't mutate the cached stats history entry.
|
|
|
|
|
filtered = {**stats_data}
|
|
|
|
|
|
|
|
|
|
cameras = stats_data.get("cameras")
|
|
|
|
|
if cameras is not None:
|
|
|
|
|
filtered["cameras"] = {
|
|
|
|
|
name: data for name, data in cameras.items() if name in allowed_set
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bandwidth = stats_data.get("bandwidth_usages")
|
|
|
|
|
if bandwidth is not None:
|
|
|
|
|
filtered["bandwidth_usages"] = {
|
|
|
|
|
name: data for name, data in bandwidth.items() if name in allowed_set
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# cmdline can leak camera URLs/paths; strip but keep cpu/mem so
|
|
|
|
|
# client-side problem heuristics still work.
|
|
|
|
|
cpu_usages = stats_data.get("cpu_usages")
|
|
|
|
|
if cpu_usages is not None:
|
|
|
|
|
filtered["cpu_usages"] = {
|
|
|
|
|
pid: {k: v for k, v in usage.items() if k != "cmdline"}
|
|
|
|
|
for pid, usage in cpu_usages.items()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return JSONResponse(content=filtered)
|
2024-03-03 01:10:37 +03:00
|
|
|
|
|
|
|
|
|
2026-05-13 18:40:29 +03:00
|
|
|
@router.get("/stats/history", dependencies=[Depends(require_role(["admin"]))])
|
2024-09-24 16:05:30 +03:00
|
|
|
def stats_history(request: Request, keys: str = None):
|
2024-04-04 06:22:11 +03:00
|
|
|
if keys:
|
|
|
|
|
keys = keys.split(",")
|
|
|
|
|
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(content=request.app.stats_emitter.get_stats_history(keys))
|
2024-03-03 01:10:37 +03:00
|
|
|
|
|
|
|
|
|
2025-11-27 00:07:28 +03:00
|
|
|
@router.get("/metrics", dependencies=[Depends(allow_any_authenticated())])
|
2025-02-17 16:17:15 +03:00
|
|
|
def metrics(request: Request):
|
|
|
|
|
"""Expose Prometheus metrics endpoint and update metrics with latest stats"""
|
|
|
|
|
# Retrieve the latest statistics and update the Prometheus metrics
|
|
|
|
|
stats = request.app.stats_emitter.get_latest_stats()
|
2025-09-19 15:27:20 +03:00
|
|
|
# query DB for count of events by camera, label
|
|
|
|
|
event_counts: List[Dict[str, Any]] = (
|
|
|
|
|
Event.select(Event.camera, Event.label, fn.Count())
|
|
|
|
|
.group_by(Event.camera, Event.label)
|
|
|
|
|
.dicts()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
update_metrics(stats=stats, event_counts=event_counts)
|
2025-02-17 16:17:15 +03:00
|
|
|
content, content_type = get_metrics()
|
|
|
|
|
return Response(content=content, media_type=content_type)
|
2025-02-09 20:04:39 +03:00
|
|
|
|
|
|
|
|
|
2026-04-04 02:13:52 +03:00
|
|
|
@router.get(
|
|
|
|
|
"/genai/models",
|
|
|
|
|
dependencies=[Depends(allow_any_authenticated())],
|
|
|
|
|
summary="List available GenAI models",
|
|
|
|
|
description="Returns available models for each configured GenAI provider.",
|
|
|
|
|
)
|
|
|
|
|
def genai_models(request: Request):
|
|
|
|
|
return JSONResponse(content=request.app.genai_manager.list_models())
|
|
|
|
|
|
|
|
|
|
|
2025-11-27 00:07:28 +03:00
|
|
|
@router.get("/config", dependencies=[Depends(allow_any_authenticated())])
|
2024-09-24 16:05:30 +03:00
|
|
|
def config(request: Request):
|
|
|
|
|
config_obj: FrigateConfig = request.app.frigate_config
|
2025-05-13 17:27:20 +03:00
|
|
|
config: dict[str, dict[str, Any]] = config_obj.model_dump(
|
2024-04-16 23:55:24 +03:00
|
|
|
mode="json", warnings="none", exclude_none=True
|
2024-04-10 01:51:38 +03:00
|
|
|
)
|
2026-02-27 18:55:36 +03:00
|
|
|
config["detectors"] = {
|
|
|
|
|
name: detector.model_dump(mode="json", warnings="none", exclude_none=True)
|
|
|
|
|
for name, detector in config_obj.detectors.items()
|
|
|
|
|
}
|
2024-03-03 01:10:37 +03:00
|
|
|
|
2026-05-01 20:25:26 +03:00
|
|
|
# remove environment_vars for non-admin users
|
|
|
|
|
if request.headers.get("remote-role") != "admin":
|
|
|
|
|
config.pop("environment_vars", None)
|
|
|
|
|
|
|
|
|
|
# remove mqtt credentials
|
2024-03-03 01:10:37 +03:00
|
|
|
config["mqtt"].pop("password", None)
|
2026-05-01 20:25:26 +03:00
|
|
|
config["mqtt"].pop("user", None)
|
2024-03-03 01:10:37 +03:00
|
|
|
|
2024-06-15 02:02:13 +03:00
|
|
|
# remove the proxy secret
|
|
|
|
|
config["proxy"].pop("auth_secret", None)
|
|
|
|
|
|
2026-03-31 21:45:04 +03:00
|
|
|
# remove genai api keys
|
|
|
|
|
for genai_name, genai_cfg in config.get("genai", {}).items():
|
|
|
|
|
if isinstance(genai_cfg, dict):
|
|
|
|
|
genai_cfg.pop("api_key", None)
|
|
|
|
|
|
2024-09-24 16:05:30 +03:00
|
|
|
for camera_name, camera in request.app.frigate_config.cameras.items():
|
2024-03-03 01:10:37 +03:00
|
|
|
camera_dict = config["cameras"][camera_name]
|
|
|
|
|
|
2026-03-31 21:45:04 +03:00
|
|
|
# remove onvif credentials
|
|
|
|
|
onvif_dict = camera_dict.get("onvif", {})
|
|
|
|
|
if onvif_dict:
|
|
|
|
|
onvif_dict.pop("user", None)
|
|
|
|
|
onvif_dict.pop("password", None)
|
|
|
|
|
|
2024-03-03 01:10:37 +03:00
|
|
|
# clean paths
|
|
|
|
|
for input in camera_dict.get("ffmpeg", {}).get("inputs", []):
|
|
|
|
|
input["path"] = clean_camera_user_pass(input["path"])
|
|
|
|
|
|
|
|
|
|
# add clean ffmpeg_cmds
|
|
|
|
|
camera_dict["ffmpeg_cmds"] = copy.deepcopy(camera.ffmpeg_cmds)
|
|
|
|
|
for cmd in camera_dict["ffmpeg_cmds"]:
|
|
|
|
|
cmd["cmd"] = clean_camera_user_pass(" ".join(cmd["cmd"]))
|
|
|
|
|
|
2024-04-10 01:51:38 +03:00
|
|
|
# ensure that zones are relative
|
|
|
|
|
for zone_name, zone in config_obj.cameras[camera_name].zones.items():
|
|
|
|
|
camera_dict["zones"][zone_name]["color"] = zone.color
|
|
|
|
|
|
Camera profile support (#22482)
* add CameraProfileConfig model for named config overrides
* add profiles field to CameraConfig
* add active_profile field to FrigateConfig
Runtime-only field excluded from YAML serialization, tracks which
profile is currently active.
* add ProfileManager for profile activation and persistence
Handles snapshotting base configs, applying profile overrides via
deep_merge + apply_section_update, publishing ZMQ updates, and
persisting active profile to /config/.active_profile.
* add profile API endpoints (GET /profiles, GET/PUT /profile)
* add MQTT and dispatcher integration for profiles
- Subscribe to frigate/profile/set MQTT topic
- Publish profile/state and profiles/available on connect
- Add _on_profile_command handler to dispatcher
- Broadcast active profile state on WebSocket connect
* wire ProfileManager into app startup and FastAPI
- Create ProfileManager after dispatcher init
- Restore persisted profile on startup
- Pass dispatcher and profile_manager to FastAPI app
* add tests for invalid profile values and keys
Tests that Pydantic rejects: invalid field values (fps: "not_a_number"),
unknown section keys (ffmpeg in profile), invalid nested values, and
invalid profiles in full config parsing.
* formatting
* fix CameraLiveConfig JSON serialization error on profile activation
refactor _publish_updates to only publish ZMQ updates for
sections that actually changed, not all sections on affected cameras.
* consolidate
* add enabled field to camera profiles for enabling/disabling cameras
* add zones support to camera profiles
* add frontend profile types, color utility, and config save support
* add profile state management and save preview support
* add profileName prop to BaseSection for profile-aware config editing
* add profile section dropdown and wire into camera settings pages
* add per-profile camera enable/disable to Camera Management view
* add profiles summary page with card-based layout and fix backend zone comparison bug
* add active profile badge to settings toolbar
* i18n
* add red dot for any pending changes including profiles
* profile support for mask and zone editor
* fix hidden field validation errors caused by lodash wildcard and schema gaps
lodash unset does not support wildcard (*) segments, so hidden fields like
filters.*.mask were never stripped from form data, leaving null raw_coordinates
that fail RJSF anyOf validation. Add unsetWithWildcard helper and also strip
hidden fields from the JSON schema itself as defense-in-depth.
* add face_recognition and lpr to profile-eligible sections
* move profile dropdown from section panes to settings header
* add profiles enable toggle and improve empty state
* formatting
* tweaks
* tweak colors and switch
* fix profile save diff, masksAndZones delete, and config sync
* ui tweaks
* ensure profile manager gets updated config
* rename profile settings to ui settings
* refactor profilesview and add dots/border colors when overridden
* implement an update_config method for profile manager
* fix mask deletion
* more unique colors
* add top-level profiles config section with friendly names
* implement profile friendly names and improve profile UI
- Add ProfileDefinitionConfig type and profiles field to FrigateConfig
- Use ProfilesApiResponse type with friendly_name support throughout
- Replace Record<string, unknown> with proper JsonObject/JsonValue types
- Add profile creation form matching zone pattern (Zod + NameAndIdFields)
- Add pencil icon for renaming profile friendly names in ProfilesView
- Move Profiles menu item to first under Camera Configuration
- Add activity indicators on save/rename/delete buttons
- Display friendly names in CameraManagementView profile selector
- Fix duplicate colored dots in management profile dropdown
- Fix i18n namespace for overridden base config tooltips
- Move profile override deletion from dropdown trash icon to footer
button with confirmation dialog, matching Reset to Global pattern
- Remove Add Profile from section header dropdown to prevent saving
camera overrides before top-level profile definition exists
- Clean up newProfiles state after API profile deletion
- Refresh profiles SWR cache after saving profile definitions
* remove profile badge in settings and add profiles to main menu
* use icon only on mobile
* change color order
* docs
* show activity indicator on trash icon while deleting a profile
* tweak language
* immediately create profiles on backend instead of deferring to Save All
* hide restart-required fields when editing a profile section
fields that require a restart cannot take effect via profile switching,
so they are merged into hiddenFields when profileName is set
* show active profile indicator in desktop status bar
* fix profile config inheritance bug where Pydantic defaults override base values
The /config API was dumping profile overrides with model_dump() which included
all Pydantic defaults. When the frontend merged these over
the camera's base config, explicitly-set base values were
lost. Now profile overrides are re-dumped with exclude_unset=True so only
user-specified fields are returned.
Also fixes the Save All path generating spurious deletion markers for
restart-required fields that are hidden during profile
editing but not excluded from the raw data sanitization in
prepareSectionSavePayload.
* docs tweaks
* docs tweak
* formatting
* formatting
* fix typing
* fix test pollution
test_maintainer was injecting MagicMock() into sys.modules["frigate.config.camera.updater"] at module load time and never restoring it. When the profile tests later imported CameraConfigUpdateEnum and CameraConfigUpdateTopic from that module, they got mock objects instead of the real dataclass/enum, so equality comparisons always failed
* remove
* fix settings showing profile-merged values when editing base config
When a profile is active, the in-memory config contains effective
(profile-merged) values. The settings UI was displaying these merged
values even when the "Base Config" view was selected.
Backend: snapshot pre-profile base configs in ProfileManager and expose
them via a `base_config` key in the /api/config camera response when a
profile is active. The top-level sections continue to reflect the
effective running config.
Frontend: read from `base_config` when available in BaseSection,
useConfigOverride, useAllCameraOverrides, and prepareSectionSavePayload.
Include formData labels in Object/Audio switches widgets so that labels
added only by a profile override remain visible when editing that profile.
* use rasterized_mask as field
makes it easier to exclude from the schema with exclude=True
prevents leaking of the field when using model_dump for profiles
* fix zones
- Fix zone colors not matching across profiles by falling back to base zone color when profile zone data lacks a color field
- Use base_config for base-layer values in masks/zones view so profile-merged values don't pollute the base config editing view
- Handle zones separately in profile manager snapshot/restore since ZoneConfig requires special serialization (color as private attr, contour generation)
- Inherit base zone color and generate contours for profile zone overrides in profile manager
* formatting
* don't require restart for camera enabled change for profiles
* publish camera state when changing profiles
* formatting
* remove available profiles from mqtt
* improve typing
2026-03-19 17:47:57 +03:00
|
|
|
# Re-dump profile overrides with exclude_unset so that only
|
|
|
|
|
# explicitly-set fields are returned (not Pydantic defaults).
|
|
|
|
|
# Without this, the frontend merges defaults (e.g. threshold=30)
|
|
|
|
|
# over the camera's actual base values (e.g. threshold=20).
|
|
|
|
|
if camera.profiles:
|
|
|
|
|
for profile_name, profile_config in camera.profiles.items():
|
|
|
|
|
camera_dict.setdefault("profiles", {})[profile_name] = (
|
|
|
|
|
profile_config.model_dump(
|
|
|
|
|
mode="json", warnings="none", exclude_unset=True
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# When a profile is active, the top-level camera sections contain
|
|
|
|
|
# profile-merged (effective) values. Include the original base
|
|
|
|
|
# configs so the frontend settings can display them separately.
|
|
|
|
|
if (
|
|
|
|
|
config_obj.active_profile is not None
|
|
|
|
|
and request.app.profile_manager is not None
|
|
|
|
|
):
|
|
|
|
|
base_sections = request.app.profile_manager.get_base_configs_for_api(
|
|
|
|
|
camera_name
|
|
|
|
|
)
|
|
|
|
|
if base_sections:
|
|
|
|
|
camera_dict["base_config"] = base_sections
|
|
|
|
|
|
2024-12-16 01:56:24 +03:00
|
|
|
# remove go2rtc stream passwords
|
2025-05-13 17:27:20 +03:00
|
|
|
go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump(
|
2024-12-16 01:56:24 +03:00
|
|
|
mode="json", warnings="none", exclude_none=True
|
|
|
|
|
)
|
|
|
|
|
for stream_name, stream in go2rtc.get("streams", {}).items():
|
2024-12-27 17:36:21 +03:00
|
|
|
if stream is None:
|
|
|
|
|
continue
|
2024-12-16 01:56:24 +03:00
|
|
|
if isinstance(stream, str):
|
|
|
|
|
cleaned = clean_camera_user_pass(stream)
|
|
|
|
|
else:
|
|
|
|
|
cleaned = []
|
|
|
|
|
|
|
|
|
|
for item in stream:
|
|
|
|
|
cleaned.append(clean_camera_user_pass(item))
|
|
|
|
|
|
|
|
|
|
config["go2rtc"]["streams"][stream_name] = cleaned
|
|
|
|
|
|
2024-09-24 16:05:30 +03:00
|
|
|
config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()}
|
2024-05-01 17:07:56 +03:00
|
|
|
config["model"]["colormap"] = config_obj.model.colormap
|
2025-01-27 17:07:49 +03:00
|
|
|
config["model"]["all_attributes"] = config_obj.model.all_attributes
|
2025-01-29 20:52:21 +03:00
|
|
|
config["model"]["non_logo_attributes"] = config_obj.model.non_logo_attributes
|
2024-03-03 01:10:37 +03:00
|
|
|
|
2025-03-17 21:44:57 +03:00
|
|
|
# Add model plus data if plus is enabled
|
|
|
|
|
if config["plus"]["enabled"]:
|
2025-03-18 06:01:40 +03:00
|
|
|
model_path = config.get("model", {}).get("path")
|
|
|
|
|
if model_path:
|
|
|
|
|
model_json_path = FilePath(model_path).with_suffix(".json")
|
|
|
|
|
try:
|
|
|
|
|
with open(model_json_path, "r") as f:
|
|
|
|
|
model_plus_data = json.load(f)
|
|
|
|
|
config["model"]["plus"] = model_plus_data
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
config["model"]["plus"] = None
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
config["model"]["plus"] = None
|
|
|
|
|
else:
|
2025-03-17 21:44:57 +03:00
|
|
|
config["model"]["plus"] = None
|
|
|
|
|
|
2024-12-16 01:56:24 +03:00
|
|
|
# use merged labelamp
|
2024-04-10 01:51:38 +03:00
|
|
|
for detector_config in config["detectors"].values():
|
2024-03-03 01:10:37 +03:00
|
|
|
detector_config["model"]["labelmap"] = (
|
2024-09-24 16:05:30 +03:00
|
|
|
request.app.frigate_config.model.merged_labelmap
|
2024-03-03 01:10:37 +03:00
|
|
|
)
|
|
|
|
|
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(content=config)
|
2024-03-03 01:10:37 +03:00
|
|
|
|
|
|
|
|
|
Camera profile support (#22482)
* add CameraProfileConfig model for named config overrides
* add profiles field to CameraConfig
* add active_profile field to FrigateConfig
Runtime-only field excluded from YAML serialization, tracks which
profile is currently active.
* add ProfileManager for profile activation and persistence
Handles snapshotting base configs, applying profile overrides via
deep_merge + apply_section_update, publishing ZMQ updates, and
persisting active profile to /config/.active_profile.
* add profile API endpoints (GET /profiles, GET/PUT /profile)
* add MQTT and dispatcher integration for profiles
- Subscribe to frigate/profile/set MQTT topic
- Publish profile/state and profiles/available on connect
- Add _on_profile_command handler to dispatcher
- Broadcast active profile state on WebSocket connect
* wire ProfileManager into app startup and FastAPI
- Create ProfileManager after dispatcher init
- Restore persisted profile on startup
- Pass dispatcher and profile_manager to FastAPI app
* add tests for invalid profile values and keys
Tests that Pydantic rejects: invalid field values (fps: "not_a_number"),
unknown section keys (ffmpeg in profile), invalid nested values, and
invalid profiles in full config parsing.
* formatting
* fix CameraLiveConfig JSON serialization error on profile activation
refactor _publish_updates to only publish ZMQ updates for
sections that actually changed, not all sections on affected cameras.
* consolidate
* add enabled field to camera profiles for enabling/disabling cameras
* add zones support to camera profiles
* add frontend profile types, color utility, and config save support
* add profile state management and save preview support
* add profileName prop to BaseSection for profile-aware config editing
* add profile section dropdown and wire into camera settings pages
* add per-profile camera enable/disable to Camera Management view
* add profiles summary page with card-based layout and fix backend zone comparison bug
* add active profile badge to settings toolbar
* i18n
* add red dot for any pending changes including profiles
* profile support for mask and zone editor
* fix hidden field validation errors caused by lodash wildcard and schema gaps
lodash unset does not support wildcard (*) segments, so hidden fields like
filters.*.mask were never stripped from form data, leaving null raw_coordinates
that fail RJSF anyOf validation. Add unsetWithWildcard helper and also strip
hidden fields from the JSON schema itself as defense-in-depth.
* add face_recognition and lpr to profile-eligible sections
* move profile dropdown from section panes to settings header
* add profiles enable toggle and improve empty state
* formatting
* tweaks
* tweak colors and switch
* fix profile save diff, masksAndZones delete, and config sync
* ui tweaks
* ensure profile manager gets updated config
* rename profile settings to ui settings
* refactor profilesview and add dots/border colors when overridden
* implement an update_config method for profile manager
* fix mask deletion
* more unique colors
* add top-level profiles config section with friendly names
* implement profile friendly names and improve profile UI
- Add ProfileDefinitionConfig type and profiles field to FrigateConfig
- Use ProfilesApiResponse type with friendly_name support throughout
- Replace Record<string, unknown> with proper JsonObject/JsonValue types
- Add profile creation form matching zone pattern (Zod + NameAndIdFields)
- Add pencil icon for renaming profile friendly names in ProfilesView
- Move Profiles menu item to first under Camera Configuration
- Add activity indicators on save/rename/delete buttons
- Display friendly names in CameraManagementView profile selector
- Fix duplicate colored dots in management profile dropdown
- Fix i18n namespace for overridden base config tooltips
- Move profile override deletion from dropdown trash icon to footer
button with confirmation dialog, matching Reset to Global pattern
- Remove Add Profile from section header dropdown to prevent saving
camera overrides before top-level profile definition exists
- Clean up newProfiles state after API profile deletion
- Refresh profiles SWR cache after saving profile definitions
* remove profile badge in settings and add profiles to main menu
* use icon only on mobile
* change color order
* docs
* show activity indicator on trash icon while deleting a profile
* tweak language
* immediately create profiles on backend instead of deferring to Save All
* hide restart-required fields when editing a profile section
fields that require a restart cannot take effect via profile switching,
so they are merged into hiddenFields when profileName is set
* show active profile indicator in desktop status bar
* fix profile config inheritance bug where Pydantic defaults override base values
The /config API was dumping profile overrides with model_dump() which included
all Pydantic defaults. When the frontend merged these over
the camera's base config, explicitly-set base values were
lost. Now profile overrides are re-dumped with exclude_unset=True so only
user-specified fields are returned.
Also fixes the Save All path generating spurious deletion markers for
restart-required fields that are hidden during profile
editing but not excluded from the raw data sanitization in
prepareSectionSavePayload.
* docs tweaks
* docs tweak
* formatting
* formatting
* fix typing
* fix test pollution
test_maintainer was injecting MagicMock() into sys.modules["frigate.config.camera.updater"] at module load time and never restoring it. When the profile tests later imported CameraConfigUpdateEnum and CameraConfigUpdateTopic from that module, they got mock objects instead of the real dataclass/enum, so equality comparisons always failed
* remove
* fix settings showing profile-merged values when editing base config
When a profile is active, the in-memory config contains effective
(profile-merged) values. The settings UI was displaying these merged
values even when the "Base Config" view was selected.
Backend: snapshot pre-profile base configs in ProfileManager and expose
them via a `base_config` key in the /api/config camera response when a
profile is active. The top-level sections continue to reflect the
effective running config.
Frontend: read from `base_config` when available in BaseSection,
useConfigOverride, useAllCameraOverrides, and prepareSectionSavePayload.
Include formData labels in Object/Audio switches widgets so that labels
added only by a profile override remain visible when editing that profile.
* use rasterized_mask as field
makes it easier to exclude from the schema with exclude=True
prevents leaking of the field when using model_dump for profiles
* fix zones
- Fix zone colors not matching across profiles by falling back to base zone color when profile zone data lacks a color field
- Use base_config for base-layer values in masks/zones view so profile-merged values don't pollute the base config editing view
- Handle zones separately in profile manager snapshot/restore since ZoneConfig requires special serialization (color as private attr, contour generation)
- Inherit base zone color and generate contours for profile zone overrides in profile manager
* formatting
* don't require restart for camera enabled change for profiles
* publish camera state when changing profiles
* formatting
* remove available profiles from mqtt
* improve typing
2026-03-19 17:47:57 +03:00
|
|
|
@router.get("/profiles", dependencies=[Depends(allow_any_authenticated())])
|
|
|
|
|
def get_profiles(request: Request):
|
|
|
|
|
"""List all available profiles and the currently active profile."""
|
|
|
|
|
profile_manager = request.app.profile_manager
|
|
|
|
|
return JSONResponse(content=profile_manager.get_profile_info())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/profile/active", dependencies=[Depends(allow_any_authenticated())])
|
|
|
|
|
def get_active_profile(request: Request):
|
|
|
|
|
"""Get the currently active profile."""
|
|
|
|
|
config_obj: FrigateConfig = request.app.frigate_config
|
|
|
|
|
return JSONResponse(content={"active_profile": config_obj.active_profile})
|
|
|
|
|
|
|
|
|
|
|
2026-02-27 18:55:36 +03:00
|
|
|
@router.get("/ffmpeg/presets", dependencies=[Depends(allow_any_authenticated())])
|
|
|
|
|
def ffmpeg_presets():
|
|
|
|
|
"""Return available ffmpeg preset keys for config UI usage."""
|
2026-03-23 17:37:40 +03:00
|
|
|
machine = platform.machine().lower()
|
|
|
|
|
is_arm64 = machine in ("aarch64", "arm64", "armv8", "armv7l")
|
|
|
|
|
|
|
|
|
|
if is_arm64:
|
|
|
|
|
hwaccel_presets = [
|
|
|
|
|
"preset-rpi-64-h264",
|
|
|
|
|
"preset-rpi-64-h265",
|
|
|
|
|
"preset-jetson-h264",
|
|
|
|
|
"preset-jetson-h265",
|
|
|
|
|
"preset-rkmpp",
|
|
|
|
|
"preset-vaapi",
|
|
|
|
|
]
|
|
|
|
|
else:
|
|
|
|
|
hwaccel_presets = [
|
|
|
|
|
"preset-vaapi",
|
|
|
|
|
"preset-intel-qsv-h264",
|
|
|
|
|
"preset-intel-qsv-h265",
|
|
|
|
|
"preset-nvidia",
|
|
|
|
|
]
|
2026-02-27 18:55:36 +03:00
|
|
|
|
|
|
|
|
input_presets = [
|
|
|
|
|
"preset-http-jpeg-generic",
|
|
|
|
|
"preset-http-mjpeg-generic",
|
|
|
|
|
"preset-http-reolink",
|
|
|
|
|
"preset-rtmp-generic",
|
|
|
|
|
"preset-rtsp-generic",
|
|
|
|
|
"preset-rtsp-restream",
|
|
|
|
|
"preset-rtsp-restream-low-latency",
|
|
|
|
|
"preset-rtsp-udp",
|
|
|
|
|
"preset-rtsp-blue-iris",
|
|
|
|
|
]
|
|
|
|
|
record_output_presets = [
|
|
|
|
|
"preset-record-generic",
|
|
|
|
|
"preset-record-generic-audio-copy",
|
|
|
|
|
"preset-record-generic-audio-aac",
|
|
|
|
|
"preset-record-mjpeg",
|
|
|
|
|
"preset-record-jpeg",
|
|
|
|
|
"preset-record-ubiquiti",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content={
|
|
|
|
|
"hwaccel_args": hwaccel_presets,
|
|
|
|
|
"input_args": input_presets,
|
|
|
|
|
"output_args": {
|
|
|
|
|
"record": record_output_presets,
|
|
|
|
|
"detect": [],
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-11-19 01:33:42 +03:00
|
|
|
@router.get("/config/raw_paths", dependencies=[Depends(require_role(["admin"]))])
|
|
|
|
|
def config_raw_paths(request: Request):
|
|
|
|
|
"""Admin-only endpoint that returns camera paths and go2rtc streams without credential masking."""
|
|
|
|
|
config_obj: FrigateConfig = request.app.frigate_config
|
|
|
|
|
|
|
|
|
|
raw_paths = {"cameras": {}, "go2rtc": {"streams": {}}}
|
|
|
|
|
|
|
|
|
|
# Extract raw camera ffmpeg input paths
|
|
|
|
|
for camera_name, camera in config_obj.cameras.items():
|
|
|
|
|
raw_paths["cameras"][camera_name] = {
|
|
|
|
|
"ffmpeg": {
|
|
|
|
|
"inputs": [
|
|
|
|
|
{"path": input.path, "roles": input.roles}
|
|
|
|
|
for input in camera.ffmpeg.inputs
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Extract raw go2rtc stream URLs
|
|
|
|
|
go2rtc_config = config_obj.go2rtc.model_dump(
|
|
|
|
|
mode="json", warnings="none", exclude_none=True
|
|
|
|
|
)
|
|
|
|
|
for stream_name, stream in go2rtc_config.get("streams", {}).items():
|
|
|
|
|
if stream is None:
|
|
|
|
|
continue
|
|
|
|
|
raw_paths["go2rtc"]["streams"][stream_name] = stream
|
|
|
|
|
|
|
|
|
|
return JSONResponse(content=raw_paths)
|
|
|
|
|
|
|
|
|
|
|
2026-03-19 01:14:59 +03:00
|
|
|
@router.get("/config/raw", dependencies=[Depends(require_role(["admin"]))])
|
2024-03-03 01:10:37 +03:00
|
|
|
def config_raw():
|
2024-12-12 03:46:42 +03:00
|
|
|
config_file = find_config_file()
|
2024-03-03 01:10:37 +03:00
|
|
|
|
|
|
|
|
if not os.path.isfile(config_file):
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content=({"success": False, "message": "Could not find file"}),
|
|
|
|
|
status_code=404,
|
2024-03-03 01:10:37 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with open(config_file, "r") as f:
|
|
|
|
|
raw_config = f.read()
|
|
|
|
|
f.close()
|
|
|
|
|
|
2024-09-24 18:54:03 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content=raw_config, media_type="text/plain", status_code=200
|
|
|
|
|
)
|
2024-03-03 01:10:37 +03:00
|
|
|
|
|
|
|
|
|
2025-03-08 19:01:08 +03:00
|
|
|
@router.post("/config/save", dependencies=[Depends(require_role(["admin"]))])
|
2024-09-26 19:19:37 +03:00
|
|
|
def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
|
|
|
|
new_config = body.decode()
|
2024-03-03 01:10:37 +03:00
|
|
|
if not new_config:
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content=(
|
2024-03-03 01:10:37 +03:00
|
|
|
{"success": False, "message": "Config with body param is required"}
|
|
|
|
|
),
|
2024-09-24 16:05:30 +03:00
|
|
|
status_code=400,
|
2024-03-03 01:10:37 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Validate the config schema
|
|
|
|
|
try:
|
2024-12-18 23:10:32 +03:00
|
|
|
# Use ruamel to parse and preserve line numbers
|
|
|
|
|
yaml_config = ruamel.yaml.YAML()
|
|
|
|
|
yaml_config.preserve_quotes = True
|
|
|
|
|
full_config = yaml_config.load(StringIO(new_config))
|
|
|
|
|
|
2024-09-22 18:56:57 +03:00
|
|
|
FrigateConfig.parse_yaml(new_config)
|
2024-12-18 23:10:32 +03:00
|
|
|
|
|
|
|
|
except ValidationError as e:
|
|
|
|
|
error_message = []
|
|
|
|
|
|
|
|
|
|
for error in e.errors():
|
|
|
|
|
error_path = error["loc"]
|
|
|
|
|
current = full_config
|
|
|
|
|
line_number = "Unknown"
|
|
|
|
|
last_line_number = "Unknown"
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
for i, part in enumerate(error_path):
|
|
|
|
|
key = int(part) if part.isdigit() else part
|
|
|
|
|
|
|
|
|
|
if isinstance(current, ruamel.yaml.comments.CommentedMap):
|
|
|
|
|
current = current[key]
|
|
|
|
|
elif isinstance(current, list):
|
|
|
|
|
current = current[key]
|
|
|
|
|
|
|
|
|
|
if hasattr(current, "lc"):
|
|
|
|
|
last_line_number = current.lc.line
|
|
|
|
|
|
|
|
|
|
if i == len(error_path) - 1:
|
|
|
|
|
if hasattr(current, "lc"):
|
|
|
|
|
line_number = current.lc.line
|
|
|
|
|
else:
|
|
|
|
|
line_number = last_line_number
|
|
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
|
line_number = "Unable to determine"
|
|
|
|
|
|
|
|
|
|
error_message.append(
|
|
|
|
|
f"Line {line_number}: {' -> '.join(map(str, error_path))} - {error.get('msg', error.get('type', 'Unknown'))}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content=(
|
|
|
|
|
{
|
|
|
|
|
"success": False,
|
|
|
|
|
"message": "Your configuration is invalid.\nSee the official documentation at docs.frigate.video.\n\n"
|
|
|
|
|
+ "\n".join(error_message),
|
|
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
status_code=400,
|
|
|
|
|
)
|
|
|
|
|
|
2024-03-03 01:10:37 +03:00
|
|
|
except Exception:
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content=(
|
2024-03-03 01:10:37 +03:00
|
|
|
{
|
|
|
|
|
"success": False,
|
2024-12-18 23:10:32 +03:00
|
|
|
"message": f"\nYour configuration is invalid.\nSee the official documentation at docs.frigate.video.\n\n{escape(str(traceback.format_exc()))}",
|
2024-03-03 01:10:37 +03:00
|
|
|
}
|
|
|
|
|
),
|
2024-09-24 16:05:30 +03:00
|
|
|
status_code=400,
|
2024-03-03 01:10:37 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Save the config to file
|
|
|
|
|
try:
|
2024-12-12 03:46:42 +03:00
|
|
|
config_file = find_config_file()
|
2024-03-03 01:10:37 +03:00
|
|
|
|
|
|
|
|
with open(config_file, "w") as f:
|
|
|
|
|
f.write(new_config)
|
|
|
|
|
f.close()
|
|
|
|
|
except Exception:
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content=(
|
2024-03-03 01:10:37 +03:00
|
|
|
{
|
|
|
|
|
"success": False,
|
|
|
|
|
"message": "Could not write config file, be sure that Frigate has write permission on the config file.",
|
|
|
|
|
}
|
|
|
|
|
),
|
2024-09-24 16:05:30 +03:00
|
|
|
status_code=400,
|
2024-03-03 01:10:37 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if save_option == "restart":
|
|
|
|
|
try:
|
|
|
|
|
restart_frigate()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logging.error(f"Error restarting Frigate: {e}")
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content=(
|
2024-03-03 01:10:37 +03:00
|
|
|
{
|
|
|
|
|
"success": True,
|
|
|
|
|
"message": "Config successfully saved, unable to restart Frigate",
|
|
|
|
|
}
|
|
|
|
|
),
|
2024-09-24 16:05:30 +03:00
|
|
|
status_code=200,
|
2024-03-03 01:10:37 +03:00
|
|
|
)
|
|
|
|
|
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content=(
|
2024-03-03 01:10:37 +03:00
|
|
|
{
|
|
|
|
|
"success": True,
|
|
|
|
|
"message": "Config successfully saved, restarting (this can take up to one minute)...",
|
|
|
|
|
}
|
|
|
|
|
),
|
2024-09-24 16:05:30 +03:00
|
|
|
status_code=200,
|
2024-03-03 01:10:37 +03:00
|
|
|
)
|
|
|
|
|
else:
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content=({"success": True, "message": "Config successfully saved."}),
|
|
|
|
|
status_code=200,
|
2024-03-03 01:10:37 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
UI fixes (#23127)
* hide camera overrides badge from system sections
* show empty card on camera metrics page when no cameras are defined
* fix enabled camera state switch after adding via wizard
Cameras added mid-session have no WS state until the dispatcher publishes camera_activity (which only happens on a fresh onConnect). Fall back to the config's enabled value so the switch reflects reality immediately after the wizard closes.
* guard camera enabled access
console would throw errors after adding via camera wizard
* fix useOptimisticState dropping debounced setState under StrictMode
* use openvino on cpu as default model
- faster than tflite on cpu
- add to default generated config
* use an enum for model_size
the frontend will then render this as a select dropdown because of the changes in the json schema
* i18n
* sync object filter entries with tracked labels in camera config form
Filter sub-collapsibles in the camera Objects section are driven by `filters` dict keys, but profile merges and live track-switch edits don't add matching entries, so newly tracked labels (like from a profile override) had no collapsible. Synthesize default filter entries from `track` in the form data so every tracked label renders a collapsible; baseline data also gets the synthesized entries, so save payloads are unchanged.
* revalidate raw paths cache after config save so CameraPathWidget shows fresh credentials
* fix test
* restore masked ffmpeg credentials when persisting camera config
* formatting
* rebuild ffmpeg commands when enabling recording for the first time
Toggling record.enabled from the config UI updated the in-memory config but left ffmpeg running with its original command, so the record output args were never wired in and nothing landed in the cache for the maintainer to move. The record config update now rebuilds ffmpeg_cmds when enabled_in_config transitions, and the camera watchdog restarts ffmpeg on a false to true transition so the record output gets wired in. MQTT toggles, which only flip record.enabled at runtime, are unaffected and continue to work via the maintainer's drop/keep gate.
* keep record toggle switch in single camera view disabled until enabled in config
* fix override detection for sections unset in the global config
Override badges and the blue dot now compare against schema defaults for sections like motion that the API serializes as null when omitted from the global YAML, instead of treating any populated camera config as an override
* add support for config-aware patterns in section hiddenFields
Section configs can now declare dynamic hidden-field entries as functions of the loaded config; objects.ts uses this to hide auto-populated attribute filters (DHL, face, license_plate, etc.) from the form, save flow, and override popover when those labels aren't user-settable
* siimplify object filters handling
live updating was getting very messy. users will just need to save once they enable a new object in order to see filters for that object
* tweaks
* update docs for new detector default
* make genai provider required and add special case for UI
prevent validation errors from appearing on initial creation of genai provider by setting the first option in the select dropdown as default
2026-05-07 16:53:07 +03:00
|
|
|
def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> None:
|
|
|
|
|
"""Substitute incoming `*:*` masked credentials with the in-memory ones.
|
|
|
|
|
|
|
|
|
|
The /config response masks ffmpeg input credentials, so the settings UI
|
|
|
|
|
sends the masked path back when sibling fields (e.g. hwaccel_args) are
|
|
|
|
|
edited. Without this we'd write `rtsp://*:*@host` into YAML and lose
|
|
|
|
|
the real credentials. Mutates `config_data` in place.
|
|
|
|
|
"""
|
|
|
|
|
cameras = config_data.get("cameras")
|
|
|
|
|
if not isinstance(cameras, dict):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
for camera_name, camera_data in cameras.items():
|
|
|
|
|
if not isinstance(camera_data, dict):
|
|
|
|
|
continue
|
|
|
|
|
inputs = camera_data.get("ffmpeg", {}).get("inputs")
|
|
|
|
|
if not isinstance(inputs, list):
|
|
|
|
|
continue
|
|
|
|
|
existing = config.cameras.get(camera_name)
|
|
|
|
|
if existing is None:
|
|
|
|
|
continue
|
|
|
|
|
existing_paths = [inp.path for inp in existing.ffmpeg.inputs]
|
|
|
|
|
for index, input_obj in enumerate(inputs):
|
|
|
|
|
if not isinstance(input_obj, dict):
|
|
|
|
|
continue
|
|
|
|
|
path = input_obj.get("path")
|
|
|
|
|
if not isinstance(path, str):
|
|
|
|
|
continue
|
|
|
|
|
if ("://*:*@" in path or "user=*&password=*" in path) and index < len(
|
|
|
|
|
existing_paths
|
|
|
|
|
):
|
|
|
|
|
input_obj["path"] = existing_paths[index]
|
|
|
|
|
|
|
|
|
|
|
2026-03-04 19:07:34 +03:00
|
|
|
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
|
|
|
|
|
"""Apply config changes in-memory only, without writing to YAML.
|
|
|
|
|
|
|
|
|
|
Used for temporary config changes like debug replay camera tuning.
|
|
|
|
|
Updates the in-memory Pydantic config and publishes ZMQ updates,
|
|
|
|
|
bypassing YAML parsing entirely.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
updates = {}
|
|
|
|
|
if body.config_data:
|
UI fixes (#23127)
* hide camera overrides badge from system sections
* show empty card on camera metrics page when no cameras are defined
* fix enabled camera state switch after adding via wizard
Cameras added mid-session have no WS state until the dispatcher publishes camera_activity (which only happens on a fresh onConnect). Fall back to the config's enabled value so the switch reflects reality immediately after the wizard closes.
* guard camera enabled access
console would throw errors after adding via camera wizard
* fix useOptimisticState dropping debounced setState under StrictMode
* use openvino on cpu as default model
- faster than tflite on cpu
- add to default generated config
* use an enum for model_size
the frontend will then render this as a select dropdown because of the changes in the json schema
* i18n
* sync object filter entries with tracked labels in camera config form
Filter sub-collapsibles in the camera Objects section are driven by `filters` dict keys, but profile merges and live track-switch edits don't add matching entries, so newly tracked labels (like from a profile override) had no collapsible. Synthesize default filter entries from `track` in the form data so every tracked label renders a collapsible; baseline data also gets the synthesized entries, so save payloads are unchanged.
* revalidate raw paths cache after config save so CameraPathWidget shows fresh credentials
* fix test
* restore masked ffmpeg credentials when persisting camera config
* formatting
* rebuild ffmpeg commands when enabling recording for the first time
Toggling record.enabled from the config UI updated the in-memory config but left ffmpeg running with its original command, so the record output args were never wired in and nothing landed in the cache for the maintainer to move. The record config update now rebuilds ffmpeg_cmds when enabled_in_config transitions, and the camera watchdog restarts ffmpeg on a false to true transition so the record output gets wired in. MQTT toggles, which only flip record.enabled at runtime, are unaffected and continue to work via the maintainer's drop/keep gate.
* keep record toggle switch in single camera view disabled until enabled in config
* fix override detection for sections unset in the global config
Override badges and the blue dot now compare against schema defaults for sections like motion that the API serializes as null when omitted from the global YAML, instead of treating any populated camera config as an override
* add support for config-aware patterns in section hiddenFields
Section configs can now declare dynamic hidden-field entries as functions of the loaded config; objects.ts uses this to hide auto-populated attribute filters (DHL, face, license_plate, etc.) from the form, save flow, and override popover when those labels aren't user-settable
* siimplify object filters handling
live updating was getting very messy. users will just need to save once they enable a new object in order to see filters for that object
* tweaks
* update docs for new detector default
* make genai provider required and add special case for UI
prevent validation errors from appearing on initial creation of genai provider by setting the first option in the select dropdown as default
2026-05-07 16:53:07 +03:00
|
|
|
_restore_masked_camera_paths(body.config_data, request.app.frigate_config)
|
2026-03-04 19:07:34 +03:00
|
|
|
updates = flatten_config_data(body.config_data)
|
|
|
|
|
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
|
|
|
|
|
|
|
|
|
if not updates:
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content={"success": False, "message": "No configuration data provided"},
|
|
|
|
|
status_code=400,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
config: FrigateConfig = request.app.frigate_config
|
|
|
|
|
|
|
|
|
|
# Group flat key paths into nested per-camera, per-section dicts
|
|
|
|
|
grouped: dict[str, dict[str, dict]] = {}
|
|
|
|
|
for key_path, value in updates.items():
|
|
|
|
|
parts = key_path.split(".")
|
|
|
|
|
if len(parts) < 3 or parts[0] != "cameras":
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
cam, section = parts[1], parts[2]
|
|
|
|
|
grouped.setdefault(cam, {}).setdefault(section, {})
|
|
|
|
|
|
|
|
|
|
# Build nested dict from remaining path (e.g. "filters.person.threshold")
|
|
|
|
|
target = grouped[cam][section]
|
|
|
|
|
for part in parts[3:-1]:
|
|
|
|
|
target = target.setdefault(part, {})
|
|
|
|
|
if len(parts) > 3:
|
|
|
|
|
target[parts[-1]] = value
|
|
|
|
|
elif isinstance(value, dict):
|
|
|
|
|
grouped[cam][section] = deep_merge(
|
|
|
|
|
grouped[cam][section], value, override=True
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
grouped[cam][section] = value
|
|
|
|
|
|
|
|
|
|
# Apply each section update
|
|
|
|
|
for cam_name, sections in grouped.items():
|
|
|
|
|
camera_config = config.cameras.get(cam_name)
|
|
|
|
|
if not camera_config:
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content={
|
|
|
|
|
"success": False,
|
|
|
|
|
"message": f"Camera '{cam_name}' not found",
|
|
|
|
|
},
|
|
|
|
|
status_code=400,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
for section_name, update in sections.items():
|
|
|
|
|
err = apply_section_update(camera_config, section_name, update)
|
|
|
|
|
if err is not None:
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content={"success": False, "message": err},
|
|
|
|
|
status_code=400,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Publish ZMQ updates so processing threads pick up changes
|
|
|
|
|
if body.update_topic and body.update_topic.startswith("config/cameras/"):
|
|
|
|
|
_, _, camera, field = body.update_topic.split("/")
|
|
|
|
|
settings = getattr(config.cameras.get(camera, None), field, None)
|
|
|
|
|
|
|
|
|
|
if settings is not None:
|
|
|
|
|
request.app.config_publisher.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera),
|
|
|
|
|
settings,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content={"success": True, "message": "Config applied in-memory"},
|
|
|
|
|
status_code=200,
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error applying config in-memory: {e}")
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content={"success": False, "message": "Error applying config"},
|
|
|
|
|
status_code=500,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-03-08 19:01:08 +03:00
|
|
|
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
|
2024-09-24 16:05:30 +03:00
|
|
|
def config_set(request: Request, body: AppConfigSetBody):
|
2024-12-12 03:46:42 +03:00
|
|
|
config_file = find_config_file()
|
2026-03-04 19:07:34 +03:00
|
|
|
|
|
|
|
|
if body.skip_save:
|
|
|
|
|
return _config_set_in_memory(request, body)
|
|
|
|
|
|
2026-02-28 17:04:43 +03:00
|
|
|
lock = FileLock(f"{config_file}.lock", timeout=5)
|
2024-03-03 01:10:37 +03:00
|
|
|
|
|
|
|
|
try:
|
2026-02-28 17:04:43 +03:00
|
|
|
with lock:
|
|
|
|
|
with open(config_file, "r") as f:
|
|
|
|
|
old_raw_config = f.read()
|
2025-06-11 20:25:30 +03:00
|
|
|
|
2026-02-28 17:04:43 +03:00
|
|
|
try:
|
|
|
|
|
updates = {}
|
2025-06-11 20:25:30 +03:00
|
|
|
|
2026-02-28 17:04:43 +03:00
|
|
|
# process query string parameters (takes precedence over body.config_data)
|
|
|
|
|
parsed_url = urllib.parse.urlparse(str(request.url))
|
|
|
|
|
query_string = urllib.parse.parse_qs(
|
|
|
|
|
parsed_url.query, keep_blank_values=True
|
|
|
|
|
)
|
2025-06-11 20:25:30 +03:00
|
|
|
|
2026-02-28 17:04:43 +03:00
|
|
|
# Filter out empty keys but keep blank values for non-empty keys
|
|
|
|
|
query_string = {k: v for k, v in query_string.items() if k}
|
|
|
|
|
|
|
|
|
|
if query_string:
|
|
|
|
|
updates = process_config_query_string(query_string)
|
|
|
|
|
elif body.config_data:
|
UI fixes (#23127)
* hide camera overrides badge from system sections
* show empty card on camera metrics page when no cameras are defined
* fix enabled camera state switch after adding via wizard
Cameras added mid-session have no WS state until the dispatcher publishes camera_activity (which only happens on a fresh onConnect). Fall back to the config's enabled value so the switch reflects reality immediately after the wizard closes.
* guard camera enabled access
console would throw errors after adding via camera wizard
* fix useOptimisticState dropping debounced setState under StrictMode
* use openvino on cpu as default model
- faster than tflite on cpu
- add to default generated config
* use an enum for model_size
the frontend will then render this as a select dropdown because of the changes in the json schema
* i18n
* sync object filter entries with tracked labels in camera config form
Filter sub-collapsibles in the camera Objects section are driven by `filters` dict keys, but profile merges and live track-switch edits don't add matching entries, so newly tracked labels (like from a profile override) had no collapsible. Synthesize default filter entries from `track` in the form data so every tracked label renders a collapsible; baseline data also gets the synthesized entries, so save payloads are unchanged.
* revalidate raw paths cache after config save so CameraPathWidget shows fresh credentials
* fix test
* restore masked ffmpeg credentials when persisting camera config
* formatting
* rebuild ffmpeg commands when enabling recording for the first time
Toggling record.enabled from the config UI updated the in-memory config but left ffmpeg running with its original command, so the record output args were never wired in and nothing landed in the cache for the maintainer to move. The record config update now rebuilds ffmpeg_cmds when enabled_in_config transitions, and the camera watchdog restarts ffmpeg on a false to true transition so the record output gets wired in. MQTT toggles, which only flip record.enabled at runtime, are unaffected and continue to work via the maintainer's drop/keep gate.
* keep record toggle switch in single camera view disabled until enabled in config
* fix override detection for sections unset in the global config
Override badges and the blue dot now compare against schema defaults for sections like motion that the API serializes as null when omitted from the global YAML, instead of treating any populated camera config as an override
* add support for config-aware patterns in section hiddenFields
Section configs can now declare dynamic hidden-field entries as functions of the loaded config; objects.ts uses this to hide auto-populated attribute filters (DHL, face, license_plate, etc.) from the form, save flow, and override popover when those labels aren't user-settable
* siimplify object filters handling
live updating was getting very messy. users will just need to save once they enable a new object in order to see filters for that object
* tweaks
* update docs for new detector default
* make genai provider required and add special case for UI
prevent validation errors from appearing on initial creation of genai provider by setting the first option in the select dropdown as default
2026-05-07 16:53:07 +03:00
|
|
|
_restore_masked_camera_paths(
|
|
|
|
|
body.config_data, request.app.frigate_config
|
|
|
|
|
)
|
2026-02-28 17:04:43 +03:00
|
|
|
updates = flatten_config_data(body.config_data)
|
|
|
|
|
# Convert None values to empty strings for deletion (e.g., when deleting masks)
|
|
|
|
|
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
|
|
|
|
|
|
|
|
|
if not updates:
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content=(
|
|
|
|
|
{
|
|
|
|
|
"success": False,
|
|
|
|
|
"message": "No configuration data provided",
|
|
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
status_code=400,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# apply all updates in a single operation
|
|
|
|
|
update_yaml_file_bulk(config_file, updates)
|
|
|
|
|
|
|
|
|
|
# validate the updated config
|
|
|
|
|
with open(config_file, "r") as f:
|
|
|
|
|
new_raw_config = f.read()
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
config = FrigateConfig.parse(new_raw_config)
|
2026-03-26 21:47:24 +03:00
|
|
|
except ValidationError as e:
|
|
|
|
|
with open(config_file, "w") as f:
|
|
|
|
|
f.write(old_raw_config)
|
|
|
|
|
f.close()
|
|
|
|
|
logger.error(
|
|
|
|
|
f"Config Validation Error:\n\n{str(traceback.format_exc())}"
|
|
|
|
|
)
|
|
|
|
|
error_messages = []
|
|
|
|
|
for err in e.errors():
|
|
|
|
|
msg = err.get("msg", "")
|
|
|
|
|
# Strip pydantic "Value error, " prefix for cleaner display
|
|
|
|
|
if msg.startswith("Value error, "):
|
|
|
|
|
msg = msg[len("Value error, ") :]
|
|
|
|
|
error_messages.append(msg)
|
|
|
|
|
message = (
|
|
|
|
|
"; ".join(error_messages)
|
|
|
|
|
if error_messages
|
|
|
|
|
else "Check logs for error message."
|
|
|
|
|
)
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content=(
|
|
|
|
|
{
|
|
|
|
|
"success": False,
|
|
|
|
|
"message": f"Error saving config: {message}",
|
|
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
status_code=400,
|
|
|
|
|
)
|
2026-02-28 17:04:43 +03:00
|
|
|
except Exception:
|
|
|
|
|
with open(config_file, "w") as f:
|
|
|
|
|
f.write(old_raw_config)
|
|
|
|
|
f.close()
|
|
|
|
|
logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}")
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content=(
|
|
|
|
|
{
|
|
|
|
|
"success": False,
|
|
|
|
|
"message": "Error parsing config. Check logs for error message.",
|
|
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
status_code=400,
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logging.error(f"Error updating config: {e}")
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content=({"success": False, "message": "Error updating config"}),
|
|
|
|
|
status_code=500,
|
|
|
|
|
)
|
2025-06-11 20:25:30 +03:00
|
|
|
|
2026-02-28 17:04:43 +03:00
|
|
|
if body.requires_restart == 0 or body.update_topic:
|
|
|
|
|
old_config: FrigateConfig = request.app.frigate_config
|
|
|
|
|
request.app.frigate_config = config
|
|
|
|
|
request.app.genai_manager.update_config(config)
|
2025-06-11 20:25:30 +03:00
|
|
|
|
Camera profile support (#22482)
* add CameraProfileConfig model for named config overrides
* add profiles field to CameraConfig
* add active_profile field to FrigateConfig
Runtime-only field excluded from YAML serialization, tracks which
profile is currently active.
* add ProfileManager for profile activation and persistence
Handles snapshotting base configs, applying profile overrides via
deep_merge + apply_section_update, publishing ZMQ updates, and
persisting active profile to /config/.active_profile.
* add profile API endpoints (GET /profiles, GET/PUT /profile)
* add MQTT and dispatcher integration for profiles
- Subscribe to frigate/profile/set MQTT topic
- Publish profile/state and profiles/available on connect
- Add _on_profile_command handler to dispatcher
- Broadcast active profile state on WebSocket connect
* wire ProfileManager into app startup and FastAPI
- Create ProfileManager after dispatcher init
- Restore persisted profile on startup
- Pass dispatcher and profile_manager to FastAPI app
* add tests for invalid profile values and keys
Tests that Pydantic rejects: invalid field values (fps: "not_a_number"),
unknown section keys (ffmpeg in profile), invalid nested values, and
invalid profiles in full config parsing.
* formatting
* fix CameraLiveConfig JSON serialization error on profile activation
refactor _publish_updates to only publish ZMQ updates for
sections that actually changed, not all sections on affected cameras.
* consolidate
* add enabled field to camera profiles for enabling/disabling cameras
* add zones support to camera profiles
* add frontend profile types, color utility, and config save support
* add profile state management and save preview support
* add profileName prop to BaseSection for profile-aware config editing
* add profile section dropdown and wire into camera settings pages
* add per-profile camera enable/disable to Camera Management view
* add profiles summary page with card-based layout and fix backend zone comparison bug
* add active profile badge to settings toolbar
* i18n
* add red dot for any pending changes including profiles
* profile support for mask and zone editor
* fix hidden field validation errors caused by lodash wildcard and schema gaps
lodash unset does not support wildcard (*) segments, so hidden fields like
filters.*.mask were never stripped from form data, leaving null raw_coordinates
that fail RJSF anyOf validation. Add unsetWithWildcard helper and also strip
hidden fields from the JSON schema itself as defense-in-depth.
* add face_recognition and lpr to profile-eligible sections
* move profile dropdown from section panes to settings header
* add profiles enable toggle and improve empty state
* formatting
* tweaks
* tweak colors and switch
* fix profile save diff, masksAndZones delete, and config sync
* ui tweaks
* ensure profile manager gets updated config
* rename profile settings to ui settings
* refactor profilesview and add dots/border colors when overridden
* implement an update_config method for profile manager
* fix mask deletion
* more unique colors
* add top-level profiles config section with friendly names
* implement profile friendly names and improve profile UI
- Add ProfileDefinitionConfig type and profiles field to FrigateConfig
- Use ProfilesApiResponse type with friendly_name support throughout
- Replace Record<string, unknown> with proper JsonObject/JsonValue types
- Add profile creation form matching zone pattern (Zod + NameAndIdFields)
- Add pencil icon for renaming profile friendly names in ProfilesView
- Move Profiles menu item to first under Camera Configuration
- Add activity indicators on save/rename/delete buttons
- Display friendly names in CameraManagementView profile selector
- Fix duplicate colored dots in management profile dropdown
- Fix i18n namespace for overridden base config tooltips
- Move profile override deletion from dropdown trash icon to footer
button with confirmation dialog, matching Reset to Global pattern
- Remove Add Profile from section header dropdown to prevent saving
camera overrides before top-level profile definition exists
- Clean up newProfiles state after API profile deletion
- Refresh profiles SWR cache after saving profile definitions
* remove profile badge in settings and add profiles to main menu
* use icon only on mobile
* change color order
* docs
* show activity indicator on trash icon while deleting a profile
* tweak language
* immediately create profiles on backend instead of deferring to Save All
* hide restart-required fields when editing a profile section
fields that require a restart cannot take effect via profile switching,
so they are merged into hiddenFields when profileName is set
* show active profile indicator in desktop status bar
* fix profile config inheritance bug where Pydantic defaults override base values
The /config API was dumping profile overrides with model_dump() which included
all Pydantic defaults. When the frontend merged these over
the camera's base config, explicitly-set base values were
lost. Now profile overrides are re-dumped with exclude_unset=True so only
user-specified fields are returned.
Also fixes the Save All path generating spurious deletion markers for
restart-required fields that are hidden during profile
editing but not excluded from the raw data sanitization in
prepareSectionSavePayload.
* docs tweaks
* docs tweak
* formatting
* formatting
* fix typing
* fix test pollution
test_maintainer was injecting MagicMock() into sys.modules["frigate.config.camera.updater"] at module load time and never restoring it. When the profile tests later imported CameraConfigUpdateEnum and CameraConfigUpdateTopic from that module, they got mock objects instead of the real dataclass/enum, so equality comparisons always failed
* remove
* fix settings showing profile-merged values when editing base config
When a profile is active, the in-memory config contains effective
(profile-merged) values. The settings UI was displaying these merged
values even when the "Base Config" view was selected.
Backend: snapshot pre-profile base configs in ProfileManager and expose
them via a `base_config` key in the /api/config camera response when a
profile is active. The top-level sections continue to reflect the
effective running config.
Frontend: read from `base_config` when available in BaseSection,
useConfigOverride, useAllCameraOverrides, and prepareSectionSavePayload.
Include formData labels in Object/Audio switches widgets so that labels
added only by a profile override remain visible when editing that profile.
* use rasterized_mask as field
makes it easier to exclude from the schema with exclude=True
prevents leaking of the field when using model_dump for profiles
* fix zones
- Fix zone colors not matching across profiles by falling back to base zone color when profile zone data lacks a color field
- Use base_config for base-layer values in masks/zones view so profile-merged values don't pollute the base config editing view
- Handle zones separately in profile manager snapshot/restore since ZoneConfig requires special serialization (color as private attr, contour generation)
- Inherit base zone color and generate contours for profile zone overrides in profile manager
* formatting
* don't require restart for camera enabled change for profiles
* publish camera state when changing profiles
* formatting
* remove available profiles from mqtt
* improve typing
2026-03-19 17:47:57 +03:00
|
|
|
if request.app.profile_manager is not None:
|
|
|
|
|
request.app.profile_manager.update_config(config)
|
|
|
|
|
|
2026-03-06 23:45:39 +03:00
|
|
|
if request.app.stats_emitter is not None:
|
|
|
|
|
request.app.stats_emitter.config = config
|
|
|
|
|
|
2026-04-07 16:16:19 +03:00
|
|
|
if request.app.dispatcher is not None:
|
|
|
|
|
request.app.dispatcher.config = config
|
2026-05-19 22:51:16 +03:00
|
|
|
for comm in request.app.dispatcher.comms:
|
|
|
|
|
comm.config = config
|
2026-04-07 16:16:19 +03:00
|
|
|
|
2026-02-28 17:04:43 +03:00
|
|
|
if body.update_topic:
|
|
|
|
|
if body.update_topic.startswith("config/cameras/"):
|
|
|
|
|
_, _, camera, field = body.update_topic.split("/")
|
2025-06-11 20:25:30 +03:00
|
|
|
|
2026-03-06 23:45:39 +03:00
|
|
|
if camera == "*":
|
|
|
|
|
# Wildcard: fan out update to all cameras
|
|
|
|
|
enum_value = CameraConfigUpdateEnum[field]
|
|
|
|
|
for camera_name in config.cameras:
|
|
|
|
|
settings = config.get_nested_object(
|
|
|
|
|
f"config/cameras/{camera_name}/{field}"
|
|
|
|
|
)
|
|
|
|
|
request.app.config_publisher.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(enum_value, camera_name),
|
|
|
|
|
settings,
|
|
|
|
|
)
|
2026-02-28 17:04:43 +03:00
|
|
|
else:
|
2026-03-06 23:45:39 +03:00
|
|
|
if field == "add":
|
|
|
|
|
settings = config.cameras[camera]
|
|
|
|
|
elif field == "remove":
|
|
|
|
|
settings = old_config.cameras[camera]
|
|
|
|
|
else:
|
|
|
|
|
settings = config.get_nested_object(body.update_topic)
|
|
|
|
|
|
|
|
|
|
request.app.config_publisher.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(
|
|
|
|
|
CameraConfigUpdateEnum[field], camera
|
|
|
|
|
),
|
|
|
|
|
settings,
|
|
|
|
|
)
|
2026-02-28 17:04:43 +03:00
|
|
|
else:
|
|
|
|
|
# Generic handling for global config updates
|
|
|
|
|
settings = config.get_nested_object(body.update_topic)
|
|
|
|
|
|
|
|
|
|
# Publish None for removal, actual config for add/update
|
|
|
|
|
request.app.config_publisher.publisher.publish(
|
|
|
|
|
body.update_topic, settings
|
|
|
|
|
)
|
2025-06-11 20:25:30 +03:00
|
|
|
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content=(
|
2024-03-03 01:10:37 +03:00
|
|
|
{
|
2026-02-28 17:04:43 +03:00
|
|
|
"success": True,
|
|
|
|
|
"message": "Config successfully updated, restart to apply",
|
2024-03-03 01:10:37 +03:00
|
|
|
}
|
|
|
|
|
),
|
2026-02-28 17:04:43 +03:00
|
|
|
status_code=200,
|
2024-03-03 01:10:37 +03:00
|
|
|
)
|
2026-02-28 17:04:43 +03:00
|
|
|
except Timeout:
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
2026-02-28 17:04:43 +03:00
|
|
|
content=(
|
|
|
|
|
{
|
|
|
|
|
"success": False,
|
|
|
|
|
"message": "Another process is currently updating the config. Please try again in a few seconds.",
|
|
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
status_code=503,
|
2024-03-03 01:10:37 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-11-27 00:07:28 +03:00
|
|
|
@router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())])
|
2024-03-03 01:10:37 +03:00
|
|
|
def vainfo():
|
2025-12-15 18:58:50 +03:00
|
|
|
# Use LibvaGpuSelector to pick an appropriate libva device (if available)
|
|
|
|
|
selected_gpu = ""
|
|
|
|
|
try:
|
|
|
|
|
selected_gpu = _gpu_selector.get_gpu_arg(FFMPEG_HWACCEL_VAAPI, 0) or ""
|
|
|
|
|
except Exception:
|
|
|
|
|
selected_gpu = ""
|
|
|
|
|
|
|
|
|
|
# If selected_gpu is empty, pass None to vainfo_hwaccel to run plain `vainfo`.
|
|
|
|
|
vainfo = vainfo_hwaccel(device_name=selected_gpu or None)
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content={
|
2024-03-03 01:10:37 +03:00
|
|
|
"return_code": vainfo.returncode,
|
|
|
|
|
"stderr": (
|
|
|
|
|
vainfo.stderr.decode("unicode_escape").strip()
|
|
|
|
|
if vainfo.returncode != 0
|
|
|
|
|
else ""
|
|
|
|
|
),
|
|
|
|
|
"stdout": (
|
|
|
|
|
vainfo.stdout.decode("unicode_escape").strip()
|
|
|
|
|
if vainfo.returncode == 0
|
|
|
|
|
else ""
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-11-27 00:07:28 +03:00
|
|
|
@router.get("/nvinfo", dependencies=[Depends(allow_any_authenticated())])
|
2024-10-08 05:15:31 +03:00
|
|
|
def nvinfo():
|
|
|
|
|
return JSONResponse(content=get_nvidia_driver_info())
|
|
|
|
|
|
|
|
|
|
|
2025-11-27 00:07:28 +03:00
|
|
|
@router.get(
|
|
|
|
|
"/logs/{service}",
|
|
|
|
|
tags=[Tags.logs],
|
2026-05-13 18:40:29 +03:00
|
|
|
dependencies=[Depends(require_role(["admin"]))],
|
2025-11-27 00:07:28 +03:00
|
|
|
)
|
2025-02-10 18:38:56 +03:00
|
|
|
async def logs(
|
2024-10-07 23:30:45 +03:00
|
|
|
service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
|
2024-09-24 16:05:30 +03:00
|
|
|
download: Optional[str] = None,
|
2025-02-10 18:38:56 +03:00
|
|
|
stream: Optional[bool] = False,
|
2024-09-24 16:05:30 +03:00
|
|
|
start: Optional[int] = 0,
|
|
|
|
|
end: Optional[int] = None,
|
|
|
|
|
):
|
2024-10-07 23:30:45 +03:00
|
|
|
"""Get logs for the requested service (frigate/nginx/go2rtc)"""
|
2024-09-24 16:05:30 +03:00
|
|
|
|
2024-08-19 17:53:33 +03:00
|
|
|
def download_logs(service_location: str):
|
|
|
|
|
try:
|
|
|
|
|
file = open(service_location, "r")
|
|
|
|
|
contents = file.read()
|
|
|
|
|
file.close()
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(jsonable_encoder(contents))
|
2024-08-19 17:53:33 +03:00
|
|
|
except FileNotFoundError as e:
|
|
|
|
|
logger.error(e)
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content={"success": False, "message": "Could not find log file"},
|
|
|
|
|
status_code=500,
|
2024-08-19 17:53:33 +03:00
|
|
|
)
|
|
|
|
|
|
2025-02-10 18:38:56 +03:00
|
|
|
async def stream_logs(file_path: str):
|
|
|
|
|
"""Asynchronously stream log lines."""
|
|
|
|
|
buffer = ""
|
|
|
|
|
try:
|
|
|
|
|
async with aiofiles.open(file_path, "r") as file:
|
|
|
|
|
await file.seek(0, 2)
|
|
|
|
|
while True:
|
|
|
|
|
line = await file.readline()
|
|
|
|
|
if line:
|
|
|
|
|
buffer += line
|
|
|
|
|
# Process logs only when there are enough lines in the buffer
|
|
|
|
|
if "\n" in buffer:
|
|
|
|
|
_, processed_lines = process_logs(buffer, service)
|
|
|
|
|
buffer = ""
|
|
|
|
|
for processed_line in processed_lines:
|
|
|
|
|
yield f"{processed_line}\n"
|
|
|
|
|
else:
|
|
|
|
|
await asyncio.sleep(0.1)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
yield "Log file not found.\n"
|
|
|
|
|
|
2024-03-03 01:10:37 +03:00
|
|
|
log_locations = {
|
|
|
|
|
"frigate": "/dev/shm/logs/frigate/current",
|
|
|
|
|
"go2rtc": "/dev/shm/logs/go2rtc/current",
|
|
|
|
|
"nginx": "/dev/shm/logs/nginx/current",
|
|
|
|
|
}
|
|
|
|
|
service_location = log_locations.get(service)
|
|
|
|
|
|
|
|
|
|
if not service_location:
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content={"success": False, "message": "Not a valid service"},
|
|
|
|
|
status_code=404,
|
2024-03-03 01:10:37 +03:00
|
|
|
)
|
|
|
|
|
|
2024-09-24 16:05:30 +03:00
|
|
|
if download:
|
2024-08-19 17:53:33 +03:00
|
|
|
return download_logs(service_location)
|
|
|
|
|
|
2025-02-10 18:38:56 +03:00
|
|
|
if stream:
|
|
|
|
|
return StreamingResponse(stream_logs(service_location), media_type="text/plain")
|
2024-04-03 19:55:13 +03:00
|
|
|
|
2025-02-10 18:38:56 +03:00
|
|
|
# For full logs initially
|
|
|
|
|
try:
|
|
|
|
|
async with aiofiles.open(service_location, "r") as file:
|
|
|
|
|
contents = await file.read()
|
2024-04-03 19:55:13 +03:00
|
|
|
|
2025-02-10 18:38:56 +03:00
|
|
|
total_lines, log_lines = process_logs(contents, service, start, end)
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
2025-02-10 18:38:56 +03:00
|
|
|
content={"totalLines": total_lines, "lines": log_lines},
|
2024-09-24 16:05:30 +03:00
|
|
|
status_code=200,
|
2024-04-03 19:55:13 +03:00
|
|
|
)
|
2024-03-03 01:10:37 +03:00
|
|
|
except FileNotFoundError as e:
|
|
|
|
|
logger.error(e)
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content={"success": False, "message": "Could not find log file"},
|
|
|
|
|
status_code=500,
|
2024-03-03 01:10:37 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-03-08 19:01:08 +03:00
|
|
|
@router.post("/restart", dependencies=[Depends(require_role(["admin"]))])
|
2024-03-03 01:10:37 +03:00
|
|
|
def restart():
|
|
|
|
|
try:
|
|
|
|
|
restart_frigate()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logging.error(f"Error restarting Frigate: {e}")
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content=(
|
2024-03-03 01:10:37 +03:00
|
|
|
{
|
|
|
|
|
"success": False,
|
|
|
|
|
"message": "Unable to restart Frigate.",
|
|
|
|
|
}
|
|
|
|
|
),
|
2024-09-24 16:05:30 +03:00
|
|
|
status_code=500,
|
2024-03-03 01:10:37 +03:00
|
|
|
)
|
|
|
|
|
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content=(
|
2024-03-03 01:10:37 +03:00
|
|
|
{
|
|
|
|
|
"success": True,
|
|
|
|
|
"message": "Restarting (this can take up to one minute)...",
|
|
|
|
|
}
|
|
|
|
|
),
|
2024-09-24 16:05:30 +03:00
|
|
|
status_code=200,
|
2024-03-03 01:10:37 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-01-06 18:20:19 +03:00
|
|
|
@router.post(
|
|
|
|
|
"/media/sync",
|
|
|
|
|
dependencies=[Depends(require_role(["admin"]))],
|
|
|
|
|
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.""",
|
|
|
|
|
)
|
2026-01-04 21:21:55 +03:00
|
|
|
def sync_media(body: MediaSyncBody = Body(...)):
|
2026-01-06 18:20:19 +03:00
|
|
|
"""Start async media sync job - remove orphaned files.
|
2026-01-04 21:21:55 +03:00
|
|
|
|
|
|
|
|
Syncs specified media types: event snapshots, event thumbnails, review thumbnails,
|
2026-01-06 18:20:19 +03:00
|
|
|
previews, exports, and/or recordings. Job runs in background; use /media/sync/current
|
|
|
|
|
or /media/sync/status/{job_id} to check status.
|
2026-01-04 21:21:55 +03:00
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
body: MediaSyncBody with dry_run flag and media_types list.
|
|
|
|
|
media_types can include: 'all', 'event_snapshots', 'event_thumbnails',
|
|
|
|
|
'review_thumbnails', 'previews', 'exports', 'recordings'
|
|
|
|
|
|
|
|
|
|
Returns:
|
2026-01-06 18:20:19 +03:00
|
|
|
202 Accepted with job_id, or 409 Conflict if job already running.
|
2026-01-04 21:21:55 +03:00
|
|
|
"""
|
2026-01-06 18:20:19 +03:00
|
|
|
job_id = start_media_sync_job(
|
2026-03-23 19:05:38 +03:00
|
|
|
dry_run=body.dry_run,
|
|
|
|
|
media_types=body.media_types,
|
|
|
|
|
force=body.force,
|
|
|
|
|
verbose=body.verbose,
|
2026-01-06 18:20:19 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if job_id is None:
|
|
|
|
|
# A job is already running
|
|
|
|
|
current = get_current_media_sync_job()
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content={
|
|
|
|
|
"error": "A media sync job is already running",
|
|
|
|
|
"current_job_id": current.id if current else None,
|
|
|
|
|
},
|
|
|
|
|
status_code=409,
|
2026-01-04 21:21:55 +03:00
|
|
|
)
|
|
|
|
|
|
2026-01-06 18:20:19 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content={
|
|
|
|
|
"job": {
|
|
|
|
|
"job_type": "media_sync",
|
|
|
|
|
"status": JobStatusTypesEnum.queued,
|
|
|
|
|
"id": job_id,
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
status_code=202,
|
|
|
|
|
)
|
2026-01-04 21:21:55 +03:00
|
|
|
|
|
|
|
|
|
2026-01-06 18:20:19 +03:00
|
|
|
@router.get(
|
|
|
|
|
"/media/sync/current",
|
|
|
|
|
dependencies=[Depends(require_role(["admin"]))],
|
|
|
|
|
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.""",
|
|
|
|
|
)
|
|
|
|
|
def get_media_sync_current():
|
|
|
|
|
"""Get the current running media sync job, if any."""
|
|
|
|
|
job = get_current_media_sync_job()
|
|
|
|
|
|
|
|
|
|
if job is None:
|
|
|
|
|
return JSONResponse(content={"job": None}, status_code=200)
|
|
|
|
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content={"job": job.to_dict()},
|
|
|
|
|
status_code=200,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/media/sync/status/{job_id}",
|
|
|
|
|
dependencies=[Depends(require_role(["admin"]))],
|
|
|
|
|
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.""",
|
|
|
|
|
)
|
|
|
|
|
def get_media_sync_status(job_id: str):
|
|
|
|
|
"""Get the status of a specific media sync job."""
|
|
|
|
|
job = get_media_sync_job_by_id(job_id)
|
|
|
|
|
|
|
|
|
|
if job is None:
|
2026-01-04 21:21:55 +03:00
|
|
|
return JSONResponse(
|
2026-01-06 18:20:19 +03:00
|
|
|
content={"error": "Job not found"},
|
|
|
|
|
status_code=404,
|
2026-01-04 21:21:55 +03:00
|
|
|
)
|
|
|
|
|
|
2026-01-06 18:20:19 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content={"job": job.to_dict()},
|
|
|
|
|
status_code=200,
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-04 21:21:55 +03:00
|
|
|
|
2025-11-27 00:07:28 +03:00
|
|
|
@router.get("/labels", dependencies=[Depends(allow_any_authenticated())])
|
2026-05-13 18:40:29 +03:00
|
|
|
def get_labels(
|
|
|
|
|
camera: str = "",
|
|
|
|
|
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
|
|
|
|
):
|
2024-03-03 01:10:37 +03:00
|
|
|
try:
|
|
|
|
|
if camera:
|
2026-05-13 18:40:29 +03:00
|
|
|
if camera not in allowed_cameras:
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content={
|
|
|
|
|
"success": False,
|
|
|
|
|
"message": f"Access denied to camera '{camera}'",
|
|
|
|
|
},
|
|
|
|
|
status_code=403,
|
|
|
|
|
)
|
2024-03-03 01:10:37 +03:00
|
|
|
events = Event.select(Event.label).where(Event.camera == camera).distinct()
|
|
|
|
|
else:
|
2026-05-13 18:40:29 +03:00
|
|
|
events = (
|
|
|
|
|
Event.select(Event.label)
|
|
|
|
|
.where(Event.camera << allowed_cameras)
|
|
|
|
|
.distinct()
|
|
|
|
|
)
|
2024-03-03 01:10:37 +03:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(e)
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content=({"success": False, "message": "Failed to get labels"}),
|
|
|
|
|
status_code=404,
|
2024-03-03 01:10:37 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
labels = sorted([e.label for e in events])
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(content=labels)
|
2024-03-03 01:10:37 +03:00
|
|
|
|
|
|
|
|
|
2025-11-27 00:07:28 +03:00
|
|
|
@router.get("/sub_labels", dependencies=[Depends(allow_any_authenticated())])
|
2026-05-13 18:40:29 +03:00
|
|
|
def get_sub_labels(
|
|
|
|
|
split_joined: Optional[int] = None,
|
|
|
|
|
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
|
|
|
|
):
|
2024-03-03 01:10:37 +03:00
|
|
|
try:
|
2026-05-13 18:40:29 +03:00
|
|
|
events = (
|
|
|
|
|
Event.select(Event.sub_label)
|
|
|
|
|
.where(Event.camera << allowed_cameras)
|
|
|
|
|
.distinct()
|
|
|
|
|
)
|
2024-03-03 01:10:37 +03:00
|
|
|
except Exception:
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content=({"success": False, "message": "Failed to get sub_labels"}),
|
|
|
|
|
status_code=404,
|
2024-03-03 01:10:37 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
sub_labels = [e.sub_label for e in events]
|
|
|
|
|
|
|
|
|
|
if None in sub_labels:
|
|
|
|
|
sub_labels.remove(None)
|
|
|
|
|
|
|
|
|
|
if split_joined:
|
|
|
|
|
original_labels = sub_labels.copy()
|
|
|
|
|
|
|
|
|
|
for label in original_labels:
|
|
|
|
|
if "," in label:
|
|
|
|
|
sub_labels.remove(label)
|
|
|
|
|
parts = label.split(",")
|
|
|
|
|
|
|
|
|
|
for part in parts:
|
|
|
|
|
if part.strip() not in sub_labels:
|
|
|
|
|
sub_labels.append(part.strip())
|
|
|
|
|
|
|
|
|
|
sub_labels.sort()
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(content=sub_labels)
|
2024-03-03 01:10:37 +03:00
|
|
|
|
|
|
|
|
|
2026-02-27 18:55:36 +03:00
|
|
|
@router.get("/audio_labels", dependencies=[Depends(allow_any_authenticated())])
|
|
|
|
|
def get_audio_labels():
|
|
|
|
|
labels = load_labels("/audio-labelmap.txt", prefill=521)
|
|
|
|
|
return JSONResponse(content=labels)
|
|
|
|
|
|
|
|
|
|
|
2025-11-27 00:07:28 +03:00
|
|
|
@router.get("/plus/models", dependencies=[Depends(allow_any_authenticated())])
|
2025-03-24 18:19:58 +03:00
|
|
|
def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
|
|
|
|
|
if not request.app.frigate_config.plus_api.is_active():
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content=({"success": False, "message": "Frigate+ is not enabled"}),
|
|
|
|
|
status_code=400,
|
|
|
|
|
)
|
|
|
|
|
|
2025-05-13 17:27:20 +03:00
|
|
|
models: dict[Any, Any] = request.app.frigate_config.plus_api.get_models()
|
2025-03-24 18:19:58 +03:00
|
|
|
|
|
|
|
|
if not models["list"]:
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content=({"success": False, "message": "No models found"}),
|
|
|
|
|
status_code=400,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
modelList = models["list"]
|
|
|
|
|
|
|
|
|
|
# current model type
|
|
|
|
|
modelType = request.app.frigate_config.model.model_type
|
|
|
|
|
|
|
|
|
|
# current detectorType for comparing to supportedDetectors
|
|
|
|
|
detectorType = list(request.app.frigate_config.detectors.values())[0].type
|
|
|
|
|
|
|
|
|
|
validModels = []
|
|
|
|
|
|
|
|
|
|
for model in sorted(
|
|
|
|
|
filter(
|
|
|
|
|
lambda m: (
|
|
|
|
|
not filterByCurrentModelDetector
|
|
|
|
|
or (detectorType in m["supportedDetectors"] and modelType in m["type"])
|
|
|
|
|
),
|
|
|
|
|
modelList,
|
|
|
|
|
),
|
|
|
|
|
key=(lambda m: m["trainDate"]),
|
|
|
|
|
reverse=True,
|
|
|
|
|
):
|
|
|
|
|
validModels.append(model)
|
|
|
|
|
|
|
|
|
|
return JSONResponse(content=validModels)
|
|
|
|
|
|
|
|
|
|
|
2025-11-27 00:07:28 +03:00
|
|
|
@router.get(
|
|
|
|
|
"/recognized_license_plates", dependencies=[Depends(allow_any_authenticated())]
|
|
|
|
|
)
|
2026-01-18 16:36:27 +03:00
|
|
|
def get_recognized_license_plates(
|
|
|
|
|
split_joined: Optional[int] = None,
|
|
|
|
|
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
|
|
|
|
):
|
2025-03-12 23:38:28 +03:00
|
|
|
try:
|
2025-08-16 16:05:50 +03:00
|
|
|
query = (
|
|
|
|
|
Event.select(
|
|
|
|
|
SQL("json_extract(data, '$.recognized_license_plate') AS plate")
|
|
|
|
|
)
|
2026-01-18 16:36:27 +03:00
|
|
|
.where(
|
|
|
|
|
(SQL("json_extract(data, '$.recognized_license_plate') IS NOT NULL"))
|
|
|
|
|
& (Event.camera << allowed_cameras)
|
|
|
|
|
)
|
2025-08-16 16:05:50 +03:00
|
|
|
.distinct()
|
|
|
|
|
)
|
|
|
|
|
recognized_license_plates = [row[0] for row in query.tuples()]
|
2025-03-12 23:38:28 +03:00
|
|
|
except Exception:
|
|
|
|
|
return JSONResponse(
|
2025-03-13 02:45:16 +03:00
|
|
|
content=(
|
|
|
|
|
{"success": False, "message": "Failed to get recognized license plates"}
|
|
|
|
|
),
|
2025-03-12 23:38:28 +03:00
|
|
|
status_code=404,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if split_joined:
|
2025-03-13 02:45:16 +03:00
|
|
|
original_recognized_license_plates = recognized_license_plates.copy()
|
|
|
|
|
for recognized_license_plate in original_recognized_license_plates:
|
|
|
|
|
if recognized_license_plate and "," in recognized_license_plate:
|
|
|
|
|
recognized_license_plates.remove(recognized_license_plate)
|
|
|
|
|
parts = recognized_license_plate.split(",")
|
2025-03-12 23:38:28 +03:00
|
|
|
for part in parts:
|
2025-03-13 02:45:16 +03:00
|
|
|
if part.strip() not in recognized_license_plates:
|
|
|
|
|
recognized_license_plates.append(part.strip())
|
2025-03-12 23:38:28 +03:00
|
|
|
|
2025-03-13 02:45:16 +03:00
|
|
|
recognized_license_plates = list(set(recognized_license_plates))
|
|
|
|
|
recognized_license_plates.sort()
|
|
|
|
|
return JSONResponse(content=recognized_license_plates)
|
2025-03-12 23:38:28 +03:00
|
|
|
|
|
|
|
|
|
2025-11-27 00:07:28 +03:00
|
|
|
@router.get("/timeline", dependencies=[Depends(allow_any_authenticated())])
|
2026-03-19 03:54:31 +03:00
|
|
|
def timeline(
|
|
|
|
|
camera: str = "all",
|
|
|
|
|
limit: int = 100,
|
|
|
|
|
source_id: Optional[str] = None,
|
|
|
|
|
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
|
|
|
|
):
|
2024-03-03 01:10:37 +03:00
|
|
|
clauses = []
|
|
|
|
|
|
|
|
|
|
selected_columns = [
|
|
|
|
|
Timeline.timestamp,
|
|
|
|
|
Timeline.camera,
|
|
|
|
|
Timeline.source,
|
|
|
|
|
Timeline.source_id,
|
|
|
|
|
Timeline.class_type,
|
|
|
|
|
Timeline.data,
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
if camera != "all":
|
|
|
|
|
clauses.append((Timeline.camera == camera))
|
|
|
|
|
|
|
|
|
|
if source_id:
|
2025-10-26 01:15:36 +03:00
|
|
|
source_ids = [sid.strip() for sid in source_id.split(",")]
|
|
|
|
|
if len(source_ids) == 1:
|
|
|
|
|
clauses.append((Timeline.source_id == source_ids[0]))
|
|
|
|
|
else:
|
|
|
|
|
clauses.append((Timeline.source_id.in_(source_ids)))
|
2024-03-03 01:10:37 +03:00
|
|
|
|
2026-03-19 03:54:31 +03:00
|
|
|
# Enforce per-camera access control
|
|
|
|
|
clauses.append((Timeline.camera << allowed_cameras))
|
|
|
|
|
|
2024-03-03 01:10:37 +03:00
|
|
|
if len(clauses) == 0:
|
|
|
|
|
clauses.append((True))
|
|
|
|
|
|
|
|
|
|
timeline = (
|
|
|
|
|
Timeline.select(*selected_columns)
|
|
|
|
|
.where(reduce(operator.and_, clauses))
|
|
|
|
|
.order_by(Timeline.timestamp.asc())
|
|
|
|
|
.limit(limit)
|
|
|
|
|
.dicts()
|
|
|
|
|
)
|
|
|
|
|
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(content=[t for t in timeline])
|
2024-03-03 01:10:37 +03:00
|
|
|
|
|
|
|
|
|
2025-11-27 00:07:28 +03:00
|
|
|
@router.get("/timeline/hourly", dependencies=[Depends(allow_any_authenticated())])
|
2026-03-19 03:54:31 +03:00
|
|
|
def hourly_timeline(
|
|
|
|
|
params: AppTimelineHourlyQueryParameters = Depends(),
|
|
|
|
|
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
|
|
|
|
):
|
2024-03-03 01:10:37 +03:00
|
|
|
"""Get hourly summary for timeline."""
|
2024-09-24 16:05:30 +03:00
|
|
|
cameras = params.cameras
|
|
|
|
|
labels = params.labels
|
|
|
|
|
before = params.before
|
|
|
|
|
after = params.after
|
|
|
|
|
limit = params.limit
|
|
|
|
|
tz_name = params.timezone
|
2024-03-03 01:10:37 +03:00
|
|
|
|
|
|
|
|
_, minute_modifier, _ = get_tz_modifiers(tz_name)
|
|
|
|
|
minute_offset = int(minute_modifier.split(" ")[0])
|
|
|
|
|
|
|
|
|
|
clauses = []
|
|
|
|
|
|
|
|
|
|
if cameras != "all":
|
|
|
|
|
camera_list = cameras.split(",")
|
|
|
|
|
clauses.append((Timeline.camera << camera_list))
|
|
|
|
|
|
2026-03-19 03:54:31 +03:00
|
|
|
# Enforce per-camera access control
|
|
|
|
|
clauses.append((Timeline.camera << allowed_cameras))
|
|
|
|
|
|
2024-03-03 01:10:37 +03:00
|
|
|
if labels != "all":
|
|
|
|
|
label_list = labels.split(",")
|
|
|
|
|
clauses.append((Timeline.data["label"] << label_list))
|
|
|
|
|
|
|
|
|
|
if before:
|
|
|
|
|
clauses.append((Timeline.timestamp < before))
|
|
|
|
|
|
|
|
|
|
if after:
|
|
|
|
|
clauses.append((Timeline.timestamp > after))
|
|
|
|
|
|
|
|
|
|
if len(clauses) == 0:
|
|
|
|
|
clauses.append((True))
|
|
|
|
|
|
|
|
|
|
timeline = (
|
|
|
|
|
Timeline.select(
|
|
|
|
|
Timeline.camera,
|
|
|
|
|
Timeline.timestamp,
|
|
|
|
|
Timeline.data,
|
|
|
|
|
Timeline.class_type,
|
|
|
|
|
Timeline.source_id,
|
|
|
|
|
Timeline.source,
|
|
|
|
|
)
|
|
|
|
|
.where(reduce(operator.and_, clauses))
|
|
|
|
|
.order_by(Timeline.timestamp.desc())
|
|
|
|
|
.limit(limit)
|
|
|
|
|
.dicts()
|
|
|
|
|
.iterator()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
count = 0
|
|
|
|
|
start = 0
|
|
|
|
|
end = 0
|
2025-05-13 17:27:20 +03:00
|
|
|
hours: dict[str, list[dict[str, Any]]] = {}
|
2024-03-03 01:10:37 +03:00
|
|
|
|
|
|
|
|
for t in timeline:
|
|
|
|
|
if count == 0:
|
|
|
|
|
start = t["timestamp"]
|
|
|
|
|
else:
|
|
|
|
|
end = t["timestamp"]
|
|
|
|
|
|
|
|
|
|
count += 1
|
|
|
|
|
|
|
|
|
|
hour = (
|
|
|
|
|
datetime.fromtimestamp(t["timestamp"]).replace(
|
|
|
|
|
minute=0, second=0, microsecond=0
|
|
|
|
|
)
|
|
|
|
|
+ timedelta(
|
|
|
|
|
minutes=minute_offset,
|
|
|
|
|
)
|
|
|
|
|
).timestamp()
|
|
|
|
|
if hour not in hours:
|
|
|
|
|
hours[hour] = [t]
|
|
|
|
|
else:
|
|
|
|
|
hours[hour].insert(0, t)
|
|
|
|
|
|
2024-09-24 16:05:30 +03:00
|
|
|
return JSONResponse(
|
|
|
|
|
content={
|
2024-03-03 01:10:37 +03:00
|
|
|
"start": start,
|
|
|
|
|
"end": end,
|
|
|
|
|
"count": count,
|
|
|
|
|
"hours": hours,
|
|
|
|
|
}
|
|
|
|
|
)
|