Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e1005ac
fix: fix object classification model not reload (#21982)
ZhaiSoul Feb 12, 2026
67e3f8e
Miscellaneous fixes (0.17 beta) (#21934)
hawkeye217 Feb 12, 2026
5f93cee
fix tooltips (#21989)
hawkeye217 Feb 13, 2026
73c1e12
Fix saving attributes for object to DB (#22000)
NickM-27 Feb 14, 2026
4dcd296
consolidate attribute filtering to match non-english and url encoded …
hawkeye217 Feb 14, 2026
3101d5f
Update hardware.md (#22018)
dirk150 Feb 16, 2026
ef5608a
Imporove attributes handling (#22035)
NickM-27 Feb 18, 2026
5f2536d
Added section for macOS installation including port conflict warning,…
iShaymus Feb 19, 2026
7c11747
Translated using Weblate (Latvian)
weblate Feb 21, 2026
a2d6e04
Translated using Weblate (Thai)
weblate Feb 21, 2026
8c98b4c
Translated using Weblate (German)
weblate Feb 21, 2026
3aeeb09
Translated using Weblate (Danish)
weblate Feb 21, 2026
8a95cd2
Translated using Weblate (Estonian)
weblate Feb 21, 2026
ad076ae
Translated using Weblate (Romanian)
weblate Feb 21, 2026
252f1a6
Translated using Weblate (Japanese)
weblate Feb 21, 2026
1856e62
Translated using Weblate (Catalan)
weblate Feb 21, 2026
5cc81bc
Translated using Weblate (Hungarian)
weblate Feb 21, 2026
d4d4164
Translated using Weblate (Polish)
weblate Feb 21, 2026
29a4076
Translated using Weblate (Italian)
weblate Feb 21, 2026
f4f32a3
Translated using Weblate (Indonesian)
weblate Feb 21, 2026
71139ef
Translated using Weblate (Dutch)
weblate Feb 21, 2026
3cc8311
Translated using Weblate (Spanish)
weblate Feb 21, 2026
8bc8206
Translated using Weblate (French)
weblate Feb 21, 2026
806c589
Translated using Weblate (Swedish)
weblate Feb 21, 2026
1f14f1c
Added translation using Weblate (Georgian)
weblate Feb 21, 2026
d940ff3
Translated using Weblate (Slovak)
weblate Feb 21, 2026
e1a6f69
Translated using Weblate (Slovenian)
weblate Feb 21, 2026
b6142e3
Translated using Weblate (Chinese (Simplified Han script))
weblate Feb 21, 2026
5b16978
Translated using Weblate (Norwegian Bokmål)
weblate Feb 21, 2026
f0d69f7
Translated using Weblate (Cantonese (Traditional Han script))
weblate Feb 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/docs/configuration/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ In this example:
- If no mapping matches, Frigate falls back to `default_role` if configured.
- If `role_map` is not defined, Frigate assumes the role header directly contains `admin`, `viewer`, or a custom role name.

**Note on matching semantics:**

- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups).

#### Port Considerations

**Authenticated Port (8971)**
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/frigate/hardware.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ There are improved capabilities in newer GPU architectures that TensorRT can ben

#### Compatibility References:

[NVIDIA TensorRT Support Matrix](https://docs.nvidia.com/deeplearning/tensorrt/archives/tensorrt-841/support-matrix/index.html)
[NVIDIA TensorRT Support Matrix](https://docs.nvidia.com/deeplearning/tensorrt-rtx/latest/getting-started/support-matrix.html)

[NVIDIA CUDA Compatibility](https://docs.nvidia.com/deploy/cuda-compatibility/index.html)

Expand Down
39 changes: 39 additions & 0 deletions docs/docs/frigate/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -689,3 +689,42 @@ docker run \
```

Log into QNAP, open Container Station. Frigate docker container should be listed under 'Overview' and running. Visit Frigate Web UI by clicking Frigate docker, and then clicking the URL shown at the top of the detail page.

## macOS - Apple Silicon

:::warning

macOS uses port 5000 for its Airplay Receiver service. If you want to expose port 5000 in Frigate for local app and API access the port will need to be mapped to another port on the host e.g. 5001

Failure to remap port 5000 on the host will result in the WebUI and all API endpoints on port 5000 being unreachable, even if port 5000 is exposed correctly in Docker.

:::

Docker containers on macOS can be orchestrated by either [Docker Desktop](https://docs.docker.com/desktop/setup/install/mac-install/) or [OrbStack](https://orbstack.dev) (native swift app). The difference in inference speeds is negligable, however CPU, power consumption and container start times will be lower on OrbStack because it is a native Swift application.

To allow Frigate to use the Apple Silicon Neural Engine / Processing Unit (NPU) the host must be running [Apple Silicon Detector](../configuration/object_detectors.md#apple-silicon-detector) on the host (outside Docker)

#### Docker Compose example
```yaml
services:
frigate:
container_name: frigate
image: ghcr.io/blakeblackshear/frigate:stable-arm64
restart: unless-stopped
shm_size: "512mb" # update for your cameras based on calculation above
volumes:
- /etc/localtime:/etc/localtime:ro
- /path/to/your/config:/config
- /path/to/your/recordings:/recordings
ports:
- "8971:8971"
# If exposing on macOS map to a diffent host port like 5001 or any orher port with no conflicts
# - "5001:5000" # Internal unauthenticated access. Expose carefully.
- "8554:8554" # RTSP feeds
extra_hosts:
# This is very important
# It allows frigate access to the NPU on Apple Silicon via Apple Silicon Detector
- "host.docker.internal:host-gateway" # Required to talk to the NPU detector
environment:
- FRIGATE_RTSP_PASSWORD: "password"
```
15 changes: 11 additions & 4 deletions frigate/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,10 +439,11 @@ def resolve_role(
Determine the effective role for a request based on proxy headers and configuration.

Order of resolution:
1. If a role header is defined in proxy_config.header_map.role:
- If a role_map is configured, treat the header as group claims
(split by proxy_config.separator) and map to roles.
- If no role_map is configured, treat the header as role names directly.
1. If a role header is defined in proxy_config.header_map.role:
- If a role_map is configured, treat the header as group claims
(split by proxy_config.separator) and map to roles.
Admin matches short-circuit to admin.
- If no role_map is configured, treat the header as role names directly.
2. If no valid role is found, return proxy_config.default_role if it's valid in config_roles, else 'viewer'.

Args:
Expand Down Expand Up @@ -492,6 +493,12 @@ def resolve_role(
}
logger.debug("Matched roles from role_map: %s", matched_roles)

# If admin matches, prioritize it to avoid accidental downgrade when
# users belong to both admin and lower-privilege groups.
if "admin" in matched_roles and "admin" in config_roles:
logger.debug("Resolved role (with role_map) to 'admin'.")
return "admin"

if matched_roles:
resolved = next(
(r for r in config_roles if r in matched_roles), validated_default
Expand Down
42 changes: 26 additions & 16 deletions frigate/api/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,25 @@
router = APIRouter(tags=[Tags.events])


def _build_attribute_filter_clause(attributes: str):
filtered_attributes = [
attr.strip() for attr in attributes.split(",") if attr.strip()
]
attribute_clauses = []

for attr in filtered_attributes:
attribute_clauses.append(Event.data.cast("text") % f'*:"{attr}"*')

escaped_attr = json.dumps(attr, ensure_ascii=True)[1:-1]
if escaped_attr != attr:
attribute_clauses.append(Event.data.cast("text") % f'*:"{escaped_attr}"*')

if not attribute_clauses:
return None

return reduce(operator.or_, attribute_clauses)


@router.get(
"/events",
response_model=list[EventResponse],
Expand Down Expand Up @@ -193,14 +212,9 @@ def events(

if attributes != "all":
# Custom classification results are stored as data[model_name] = result_value
filtered_attributes = attributes.split(",")
attribute_clauses = []

for attr in filtered_attributes:
attribute_clauses.append(Event.data.cast("text") % f'*:"{attr}"*')

attribute_clause = reduce(operator.or_, attribute_clauses)
clauses.append(attribute_clause)
attribute_clause = _build_attribute_filter_clause(attributes)
if attribute_clause is not None:
clauses.append(attribute_clause)

if recognized_license_plate != "all":
filtered_recognized_license_plates = recognized_license_plate.split(",")
Expand Down Expand Up @@ -508,7 +522,7 @@ def events_search(
cameras = params.cameras
labels = params.labels
sub_labels = params.sub_labels
attributes = params.attributes
attributes = unquote(params.attributes)
zones = params.zones
after = params.after
before = params.before
Expand Down Expand Up @@ -607,13 +621,9 @@ def events_search(

if attributes != "all":
# Custom classification results are stored as data[model_name] = result_value
filtered_attributes = attributes.split(",")
attribute_clauses = []

for attr in filtered_attributes:
attribute_clauses.append(Event.data.cast("text") % f'*:"{attr}"*')

event_filters.append(reduce(operator.or_, attribute_clauses))
attribute_clause = _build_attribute_filter_clause(attributes)
if attribute_clause is not None:
event_filters.append(attribute_clause)

if zones != "all":
zone_clauses = []
Expand Down
1 change: 1 addition & 0 deletions frigate/data_processing/real_time/custom_classification.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,7 @@ def process_frame(self, obj_data, frame):
def handle_request(self, topic, request_data):
if topic == EmbeddingsRequestEnum.reload_classification_model.value:
if request_data.get("model_name") == self.model_config.name:
self.__build_detector()
logger.info(
f"Successfully loaded updated model for {self.model_config.name}"
)
Expand Down
13 changes: 13 additions & 0 deletions frigate/events/maintainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from frigate.comms.events_updater import EventEndPublisher, EventUpdateSubscriber
from frigate.config import FrigateConfig
from frigate.config.classification import ObjectClassificationType
from frigate.events.types import EventStateEnum, EventTypeEnum
from frigate.models import Event
from frigate.util.builtin import to_relative_box
Expand Down Expand Up @@ -247,6 +248,18 @@ def handle_object_detection(
"recognized_license_plate"
][1]

# only overwrite attribute-type custom model fields in the database if they're set
for name, model_config in self.config.classification.custom.items():
if (
model_config.object_config
and model_config.object_config.classification_type
== ObjectClassificationType.attribute
):
value = event_data.get(name)
if value is not None:
event[Event.data][name] = value[0]
event[Event.data][f"{name}_score"] = value[1]

(
Event.insert(event)
.on_conflict(
Expand Down
51 changes: 51 additions & 0 deletions frigate/test/http_api/test_http_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,57 @@ def test_get_event_list_sort_start_time(self):
assert events[0]["id"] == id
assert events[1]["id"] == id2

def test_get_event_list_match_multilingual_attribute(self):
event_id = "123456.zh"
attribute = "中文标签"

with AuthTestClient(self.app) as client:
super().insert_mock_event(event_id, data={"custom_attr": attribute})

events = client.get("/events", params={"attributes": attribute}).json()
assert len(events) == 1
assert events[0]["id"] == event_id

events = client.get(
"/events", params={"attributes": "%E4%B8%AD%E6%96%87%E6%A0%87%E7%AD%BE"}
).json()
assert len(events) == 1
assert events[0]["id"] == event_id

def test_events_search_match_multilingual_attribute(self):
event_id = "123456.zh.search"
attribute = "中文标签"
mock_embeddings = Mock()
mock_embeddings.search_thumbnail.return_value = [(event_id, 0.05)]

self.app.frigate_config.semantic_search.enabled = True
self.app.embeddings = mock_embeddings

with AuthTestClient(self.app) as client:
super().insert_mock_event(event_id, data={"custom_attr": attribute})

events = client.get(
"/events/search",
params={
"search_type": "similarity",
"event_id": event_id,
"attributes": attribute,
},
).json()
assert len(events) == 1
assert events[0]["id"] == event_id

events = client.get(
"/events/search",
params={
"search_type": "similarity",
"event_id": event_id,
"attributes": "%E4%B8%AD%E6%96%87%E6%A0%87%E7%AD%BE",
},
).json()
assert len(events) == 1
assert events[0]["id"] == event_id

def test_get_good_event(self):
id = "123456.random"

Expand Down
15 changes: 15 additions & 0 deletions frigate/test/test_proxy_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ def test_role_map_multiple_groups(self):
role = resolve_role(headers, self.proxy_config, self.config_roles)
self.assertEqual(role, "admin")

def test_role_map_or_matching(self):
config = self.proxy_config
config.header_map.role_map = {
"admin": ["group_admin", "group_privileged"],
}

# OR semantics: a single matching group should map to the role
headers = {"x-remote-role": "group_admin"}
role = resolve_role(headers, config, self.config_roles)
self.assertEqual(role, "admin")

headers = {"x-remote-role": "group_admin|group_privileged"}
role = resolve_role(headers, config, self.config_roles)
self.assertEqual(role, "admin")

def test_direct_role_header_with_separator(self):
config = self.proxy_config
config.header_map.role_map = None # disable role_map
Expand Down
14 changes: 13 additions & 1 deletion frigate/track/tracked_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,14 @@ def update(
return (thumb_update, significant_change, path_update, autotracker_update)

def to_dict(self) -> dict[str, Any]:
event = {
# Tracking internals excluded from output (centroid, estimate, estimate_velocity)
_EXCLUDED_OBJ_DATA_KEYS = {
"centroid",
"estimate",
"estimate_velocity",
}

event: dict[str, Any] = {
"id": self.obj_data["id"],
"camera": self.camera_config.name,
"frame_time": self.obj_data["frame_time"],
Expand Down Expand Up @@ -412,6 +419,11 @@ def to_dict(self) -> dict[str, Any]:
"recognized_license_plate": self.obj_data.get("recognized_license_plate"),
}

# Add any other obj_data keys (e.g. custom attribute fields) not yet included
for key, value in self.obj_data.items():
if key not in _EXCLUDED_OBJ_DATA_KEYS and key not in event:
event[key] = value

return event

def is_active(self) -> bool:
Expand Down
24 changes: 21 additions & 3 deletions frigate/video.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ def __init__(
self.latest_valid_segment_time: float = 0
self.latest_invalid_segment_time: float = 0
self.latest_cache_segment_time: float = 0
self.record_enable_time: datetime | None = None

def _update_enabled_state(self) -> bool:
"""Fetch the latest config and update enabled state."""
Expand Down Expand Up @@ -261,6 +262,9 @@ def reset_capture_thread(
def run(self) -> None:
if self._update_enabled_state():
self.start_all_ffmpeg()
# If recording is enabled at startup, set the grace period timer
if self.config.record.enabled:
self.record_enable_time = datetime.now().astimezone(timezone.utc)

time.sleep(self.sleeptime)
while not self.stop_event.wait(self.sleeptime):
Expand All @@ -270,13 +274,15 @@ def run(self) -> None:
self.logger.debug(f"Enabling camera {self.config.name}")
self.start_all_ffmpeg()

# reset all timestamps
# reset all timestamps and record the enable time for grace period
self.latest_valid_segment_time = 0
self.latest_invalid_segment_time = 0
self.latest_cache_segment_time = 0
self.record_enable_time = datetime.now().astimezone(timezone.utc)
else:
self.logger.debug(f"Disabling camera {self.config.name}")
self.stop_all_ffmpeg()
self.record_enable_time = None

# update camera status
self.requestor.send_data(
Expand Down Expand Up @@ -361,6 +367,12 @@ def run(self) -> None:
if self.config.record.enabled and "record" in p["roles"]:
now_utc = datetime.now().astimezone(timezone.utc)

# Check if we're within the grace period after enabling recording
# Grace period: 90 seconds allows time for ffmpeg to start and create first segment
in_grace_period = self.record_enable_time is not None and (
now_utc - self.record_enable_time
) < timedelta(seconds=90)

latest_cache_dt = (
datetime.fromtimestamp(
self.latest_cache_segment_time, tz=timezone.utc
Expand All @@ -386,10 +398,16 @@ def run(self) -> None:
)

# ensure segments are still being created and that they have valid video data
cache_stale = now_utc > (latest_cache_dt + timedelta(seconds=120))
valid_stale = now_utc > (latest_valid_dt + timedelta(seconds=120))
# Skip checks during grace period to allow segments to start being created
cache_stale = not in_grace_period and now_utc > (
latest_cache_dt + timedelta(seconds=120)
)
valid_stale = not in_grace_period and now_utc > (
latest_valid_dt + timedelta(seconds=120)
)
invalid_stale_condition = (
self.latest_invalid_segment_time > 0
and not in_grace_period
and now_utc > (latest_invalid_dt + timedelta(seconds=120))
and self.latest_valid_segment_time
<= self.latest_invalid_segment_time
Expand Down
3 changes: 2 additions & 1 deletion web/public/locales/ca/components/dialog.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"title": "Frigate s'està reiniciant",
"content": "Aquesta pàgina es tornarà a carregar d'aquí a {{countdown}} segons.",
"button": "Forçar la recàrrega ara"
}
},
"description": "Això aturarà breument Frigate mentre es reinicia."
},
"explore": {
"plus": {
Expand Down
5 changes: 5 additions & 0 deletions web/public/locales/ca/views/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@
},
"error": {
"mustBeFinished": "El dibuix del polígon s'ha d'acabar abans de desar."
},
"type": {
"zone": "zona",
"motion_mask": "màscara de moviment",
"object_mask": "màscara d'objecte"
}
},
"zoneName": {
Expand Down
Loading
Loading