From 50a2b6729eb152d9512b100c78c55fa84dffa430 Mon Sep 17 00:00:00 2001 From: Blake Blackshear Date: Sat, 18 Jul 2026 12:19:12 -0500 Subject: [PATCH 01/21] update labels/faq (#23759) --- docs/docs/plus/faq.md | 8 +++++++- docs/docs/plus/index.md | 15 +++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/docs/plus/faq.md b/docs/docs/plus/faq.md index 151eb3f60e..2bcebf1d4b 100644 --- a/docs/docs/plus/faq.md +++ b/docs/docs/plus/faq.md @@ -21,7 +21,13 @@ Yes. Models and metadata are stored in the `model_cache` directory within the co ### Can I keep using my Frigate+ models even if I do not renew my subscription? -Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models trained with your subscription are yours to keep and use forever. However, do note that the terms and conditions prohibit you from sharing, reselling, or creating derivative products from the models. +Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models you train during an active subscription remain licensed for your continued use even after your subscription ends — models already in your model cache will keep working indefinitely. An active subscription is required to train new models and download new versions. + +### Can I use Frigate+ models commercially? + +A standard subscription covers use on camera systems you own or operate, including for your business. A shop, restaurant, warehouse, or office running Frigate+ at its own locations (including multiple locations) is exactly the kind of use the subscription is for. +What the standard subscription does not cover is using Frigate+ models to provide a product or service to others. If you're deploying models at your customers' sites, bundling them with hardware you sell, or running them as part of a hosted or managed service, even if your customers never receive the model files themselves, you'll need a commercial license. +Note that professional installers are fine under standard subscriptions when each customer holds their own Frigate+ subscription. The commercial license is for cases where your license powers your customers' sites. ### Why can't I submit images to Frigate+? diff --git a/docs/docs/plus/index.md b/docs/docs/plus/index.md index 76792f96f0..a550802a78 100644 --- a/docs/docs/plus/index.md +++ b/docs/docs/plus/index.md @@ -65,11 +65,11 @@ Some users may find that Frigate+ models result in more false positives initiall Frigate+ models support a more relevant set of objects for security cameras. The labels for annotation in Frigate+ are configurable by editing the camera in the Cameras section of Frigate+. Currently, the following objects are supported: -- **People**: `person`, `face` -- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `license_plate` +- **People**: `person`, `face`, `baby` +- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `garbage truck`, `license_plate` - **Delivery Logos**: `amazon`, `usps`, `ups`, `fedex`, `dhl`, `an_post`, `purolator`, `postnl`, `nzpost`, `postnord`, `gls`, `dpd`, `canada_post`, `royal_mail` -- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo` -- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella` +- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`, `possum`, `rodent` +- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`, `baby_stroller` Other object types available in the default Frigate model are not available. Additional object types will be added in future releases. @@ -77,9 +77,12 @@ Other object types available in the default Frigate model are not available. Add Candidate labels are also available for annotation. These labels don't have enough data to be included in the model yet, but using them will help add support sooner. You can enable these labels by editing the camera settings. -Where possible, these labels are mapped to existing labels during training. For example, any `baby` labels are mapped to `person` until support for new labels is added. +Where possible, these labels are mapped to existing labels during training. For example, any `duck` labels are mapped to `bird` until support for new labels is added. -The candidate labels are: `baby`, `bpost`, `badger`, `possum`, `rodent`, `chicken`, `groundhog`, `boar`, `hedgehog`, `tractor`, `golf cart`, `garbage truck`, `bus`, `sports ball`, `la_poste`, `lawnmower`, `heron`, `rickshaw`, `wombat`, `auspost`, `aramex`, `bobcat`, `mustelid`, `transoflex`, `airplane`, `drone`, `mountain_lion`, `crocodile`, `turkey`, `baby_stroller`, `monkey`, `coyote`, `porcupine`, `parcelforce`, `sheep`, `snake`, `helicopter`, `lizard`, `duck`, `hermes`, `cargus`, `fan_courier`, `sameday` +- **Vehicles**: `tractor`, `golf_cart`, `bus`, `airplane`, `helicopter`, `rickshaw`, `scooter` +- **Delivery Logos**: `bpost`, `auspost`, `aramex`, `transoflex`, `parcelforce`, `hermes`, `cargus`, `fan_courier`, `sameday`, `la_poste` +- **Animals**: `badger`, `chicken`, `duck`, `turkey`, `groundhog`, `boar`, `hedgehog`, `wombat`, `bobcat`, `mustelid`, `mountain_lion`, `crocodile`, `monkey`, `coyote`, `porcupine`, `sheep`, `snake`, `lizard`, `heron`, `elk`, `moose`, `pig`, `donkey`, `civet` +- **Other**: `sports_ball`, `drone`, `lawnmower` Candidate labels are not available for automatic suggestions. From 51171319a42a24c1c1cbe6854a87ac1aec33d016 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 13 Sep 2026 07:41:33 -0500 Subject: [PATCH 02/21] Clarify profile docs (#24267) * Recording must always be enabled in the config to be toggled later by a profile * add faq --- docs/docs/configuration/profiles.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/docs/configuration/profiles.md b/docs/docs/configuration/profiles.md index 87475d74b3..a47c06ba30 100644 --- a/docs/docs/configuration/profiles.md +++ b/docs/docs/configuration/profiles.md @@ -191,14 +191,12 @@ cameras: detect: enabled: false record: - enabled: false + enabled: true profiles: away: enabled: true detect: enabled: true - record: - enabled: true home: enabled: false ``` @@ -251,6 +249,12 @@ Leaving the `objects` section empty (or omitting `track`) does not clear the lis Fields that require a Frigate restart to take effect cannot be overridden by profiles, since profiles are applied at runtime without restarting. Those fields are hidden when editing a profile override and can only be changed on the base configuration. +### Why can't a profile enable recording when it's disabled in the base config? + +Frigate only sets up a camera's recording stream at startup when recording is enabled in the base config, so enabling it later from a profile has no effect. The same applies to turning recording on from the UI or MQTT. + +To keep recording off by default, leave `record.enabled: true` in the base config and create a profile that sets `record.enabled: false`. Activate that profile and it will be restored automatically when Frigate starts. + ### Can I schedule profiles to be enabled or disabled at certain times? Not within Frigate itself. Frigate is an NVR, not an automation platform, so it intentionally does not include a scheduler for activating profiles. Instead, activate profiles from an automation platform that already handles time- and event-based triggers well, such as [Home Assistant](https://www.home-assistant.io/) or [Node-RED](https://nodered.org/). These integrate with Frigate and give you far more robust and flexible scheduling than a built-in scheduler could. From b02aea03cd14c21002b8afb62310cad10feb1498 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:53:44 -0500 Subject: [PATCH 03/21] add field messages for recording and notifications (#24272) recording and notifications require enabled_in_config to be true at startup to build the correct ffmpeg commands and start the notifications worker --- web/public/locales/en/views/settings.json | 6 +++++- .../section-configs/notifications.ts | 17 +++++++++++++++++ .../config-form/section-configs/record.ts | 15 +++++++++++++++ .../config-form/section-configs/types.ts | 1 + .../config-form/sections/BaseSection.tsx | 3 ++- 5 files changed, 40 insertions(+), 2 deletions(-) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 91d3e06c81..1f50863e46 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1952,7 +1952,11 @@ "modelSizeLarge": "The 'large' model is optimized for multi-line license plates. The 'small' model provides better performance over 'large' and should be used unless your region uses multi-line plate formats." }, "record": { - "noRecordRole": "No streams have the record role defined. Recording will not function." + "noRecordRole": "No streams have the record role defined. Recording will not function.", + "profileBaseDisabled": "Recording is disabled in this camera's base config, so enabling it in a profile has no effect. Enable recording in the base config and use a profile to disable it instead." + }, + "notifications": { + "profileBaseDisabled": "No cameras have notifications enabled in their base config, so enabling them in a profile has no effect. Enable notifications in the base config of at least one camera." }, "birdseye": { "objectsModeDetectDisabled": "Birdseye is set to 'objects' mode, but object detection is disabled for this camera. The camera will not appear in Birdseye." diff --git a/web/src/components/config-form/section-configs/notifications.ts b/web/src/components/config-form/section-configs/notifications.ts index 68fd78f788..62adcab520 100644 --- a/web/src/components/config-form/section-configs/notifications.ts +++ b/web/src/components/config-form/section-configs/notifications.ts @@ -8,6 +8,23 @@ const notifications: SectionConfigOverrides = { fieldGroups: {}, hiddenFields: ["enabled_in_config"], advancedFields: [], + fieldMessages: [ + { + key: "profile-base-notifications-disabled", + field: "enabled", + messageKey: "configMessages.notifications.profileBaseDisabled", + severity: "warning", + position: "after", + condition: (ctx) => + !!ctx.profileName && + ctx.formData?.enabled === true && + !Object.values(ctx.fullConfig.cameras).some( + (camera) => + camera.enabled_in_config && + camera.notifications.enabled_in_config, + ), + }, + ], }, global: { uiSchema: { diff --git a/web/src/components/config-form/section-configs/record.ts b/web/src/components/config-form/section-configs/record.ts index 9271e025fb..b267385124 100644 --- a/web/src/components/config-form/section-configs/record.ts +++ b/web/src/components/config-form/section-configs/record.ts @@ -16,6 +16,21 @@ const record: SectionConfigOverrides = { }, }, ], + fieldMessages: [ + { + key: "profile-base-record-disabled", + field: "enabled", + messageKey: "configMessages.record.profileBaseDisabled", + severity: "warning", + position: "after", + docLink: + "/configuration/profiles#why-cant-a-profile-enable-recording-when-its-disabled-in-the-base-config", + condition: (ctx) => + !!ctx.profileName && + ctx.formData?.enabled === true && + ctx.fullCameraConfig?.record.enabled_in_config === false, + }, + ], fieldDocs: { "alerts.pre_capture": "/configuration/record#pre-capture-and-post-capture", diff --git a/web/src/components/config-form/section-configs/types.ts b/web/src/components/config-form/section-configs/types.ts index 510d3bfa4f..30f6e6be93 100644 --- a/web/src/components/config-form/section-configs/types.ts +++ b/web/src/components/config-form/section-configs/types.ts @@ -8,6 +8,7 @@ export type MessageConditionContext = { fullCameraConfig?: CameraConfig; level: "global" | "camera"; cameraName?: string; + profileName?: string; formData: ConfigSectionData; }; diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 685b06ebe3..f68a39d1d0 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -619,9 +619,10 @@ export function ConfigSection({ : undefined, level: effectiveLevel, cameraName, + profileName, formData: currentFormData as ConfigSectionData, }; - }, [config, currentFormData, effectiveLevel, cameraName]); + }, [config, currentFormData, effectiveLevel, cameraName, profileName]); const { activeMessages, activeFieldMessages } = useConfigMessages( sectionConfig.messages, From 04480a18b6bca8bf5a124300fbe79926784afded Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 17 Sep 2026 10:17:45 -0600 Subject: [PATCH 04/21] Revert QSV ffmpeg framerate filter (#24384) * Update version * Move back to QSV framerate filter * Use standard fps filter instead --- Makefile | 2 +- frigate/ffmpeg_presets.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 3800399ea1..f30bf8680b 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ default_target: local COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1) -VERSION = 0.18.0 +VERSION = 0.18.1 IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) BOARDS= #Initialized empty diff --git a/frigate/ffmpeg_presets.py b/frigate/ffmpeg_presets.py index 445cfd4df3..b482664f89 100644 --- a/frigate/ffmpeg_presets.py +++ b/frigate/ffmpeg_presets.py @@ -121,8 +121,8 @@ PRESETS_HW_ACCEL_SCALE = { "preset-rpi-64-h264": "-r {0} -vf fps={0},scale={1}:{2}", "preset-rpi-64-h265": "-r {0} -vf fps={0},scale={1}:{2}", FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12", - "preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p", - "preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p", + "preset-intel-qsv-h264": "-r {0} -vf fps={0},vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p", + "preset-intel-qsv-h265": "-r {0} -vf fps={0},vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p", FFMPEG_HWACCEL_NVIDIA: "-r {0} -vf fps={0},scale_cuda=w={1}:h={2},hwdownload,format=nv12", "preset-jetson-h264": "-r {0}", # scaled in decoder "preset-jetson-h265": "-r {0}", # scaled in decoder From d69107de335f3d0552e7486361dbe31950ef88e4 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 17 Sep 2026 10:54:54 -0600 Subject: [PATCH 05/21] Handle sub labeled objects to still show up in review filter (#24391) --- frigate/api/review.py | 31 +++++--- frigate/test/http_api/test_http_review.py | 92 +++++++++++++++++++++++ 2 files changed, 112 insertions(+), 11 deletions(-) diff --git a/frigate/api/review.py b/frigate/api/review.py index 2194c7c2fb..bb9dd24f34 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -43,6 +43,22 @@ logger = logging.getLogger(__name__) router = APIRouter(tags=[Tags.review]) +def get_label_clause(label: str, include_audio: bool = True): + """Build a clause matching a label within a review segment's data. + + Verified objects are stored with a `-verified` suffix (eg. `person-verified`) + so that variant is matched as well. + """ + clause = (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') | ( + ReviewSegment.data["objects"].cast("text") % f'*"{label}-verified"*' + ) + + if include_audio: + clause |= ReviewSegment.data["audio"].cast("text") % f'*"{label}"*' + + return clause + + @router.get( "/review", response_model=list[ReviewSegmentResponse], @@ -92,10 +108,7 @@ async def review( filtered_labels = labels.split(",") for label in filtered_labels: - label_clauses.append( - (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') - | (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*') - ) + label_clauses.append(get_label_clause(label)) clauses.append(reduce(operator.or_, label_clauses)) if zones != "all": @@ -236,10 +249,7 @@ async def review_summary( filtered_labels = labels.split(",") for label in filtered_labels: - label_clauses.append( - (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') - | (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*') - ) + label_clauses.append(get_label_clause(label)) clauses.append(reduce(operator.or_, label_clauses)) if zones != "all": # use matching so segments with multiple zones @@ -337,9 +347,8 @@ async def review_summary( filtered_labels = labels.split(",") for label in filtered_labels: - label_clauses.append( - ReviewSegment.data["objects"].cast("text") % f'*"{label}"*' - ) + label_clauses.append(get_label_clause(label, include_audio=False)) + clauses.append(reduce(operator.or_, label_clauses)) # Find the time range of available data diff --git a/frigate/test/http_api/test_http_review.py b/frigate/test/http_api/test_http_review.py index d13a7bd273..e2312e771f 100644 --- a/frigate/test/http_api/test_http_review.py +++ b/frigate/test/http_api/test_http_review.py @@ -240,9 +240,101 @@ class TestHttpReview(BaseTestHttp): assert len(response_json) == 1 assert response_json[0]["id"] == id_reviewed + def test_get_review_with_label_filter_matches_verified(self): + """Test that a label filter also matches the `-verified` variant.""" + now = datetime.now().timestamp() + + with AuthTestClient(self.app) as client: + super().insert_mock_review_segment( + "123456.person", now, now + 2, data={"objects": ["person"]} + ) + super().insert_mock_review_segment( + "123456.verified", now, now + 2, data={"objects": ["person-verified"]} + ) + super().insert_mock_review_segment( + "123456.car", now, now + 2, data={"objects": ["car"]} + ) + + params = { + "labels": "person", + "after": now - 1, + "before": now + 3, + } + response = client.get("/review", params=params) + assert response.status_code == 200 + response_json = response.json() + assert {r["id"] for r in response_json} == { + "123456.person", + "123456.verified", + } + + def test_get_review_with_label_filter_does_not_match_prefix(self): + """Test that a label filter does not match labels that only share a prefix.""" + now = datetime.now().timestamp() + + with AuthTestClient(self.app) as client: + super().insert_mock_review_segment( + "123456.carrot", now, now + 2, data={"objects": ["carrot"]} + ) + + params = { + "labels": "car", + "after": now - 1, + "before": now + 3, + } + response = client.get("/review", params=params) + assert response.status_code == 200 + assert len(response.json()) == 0 + + def test_get_review_with_audio_label_filter(self): + """Test that a label filter still matches audio labels.""" + now = datetime.now().timestamp() + + with AuthTestClient(self.app) as client: + super().insert_mock_review_segment( + "123456.audio", now, now + 2, data={"audio": ["speech"]} + ) + + params = { + "labels": "speech", + "after": now - 1, + "before": now + 3, + } + response = client.get("/review", params=params) + assert response.status_code == 200 + response_json = response.json() + assert len(response_json) == 1 + assert response_json[0]["id"] == "123456.audio" + #################################################################################################################### ################################### GET /review/summary Endpoint ################################################# #################################################################################################################### + def test_get_review_summary_label_filter_matches_verified(self): + """Test that the summary label filter also matches the `-verified` variant.""" + with AuthTestClient(self.app) as client: + super().insert_mock_review_segment( + "123456.verified", data={"objects": ["person-verified"]} + ) + super().insert_mock_review_segment( + "123456.car", data={"objects": ["car"]}, severity=SeverityEnum.detection + ) + + params = { + "cameras": "front_door", + "labels": "person", + "zones": "all", + "timezone": "utc", + } + response = client.get("/review/summary", params=params) + assert response.status_code == 200 + response_json = response.json() + assert response_json["last24Hours"]["total_alert"] == 1 + assert response_json["last24Hours"]["total_detection"] == 0 + + today_formatted = datetime.today().strftime("%Y-%m-%d") + assert response_json[today_formatted]["total_alert"] == 1 + assert response_json[today_formatted]["total_detection"] == 0 + def test_get_review_summary_all_filters(self): with AuthTestClient(self.app) as client: super().insert_mock_review_segment("123456.random") From 06967fec9171c9d4d766dcdcdd60da065b992589 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:14:20 -0500 Subject: [PATCH 06/21] Fix explore paging for non-date sorts (#24392) * fix explore paging for non-date sorts Explore paged every sort by passing the last row's `start_time` as a `before` or `after` cursor, which only works when rows are ordered by `start_time`. For score, speed, and relevance sorts, each page dropped every match newer than that row and repeated older rows from earlier pages, so infinite scroll stopped after a few pages. `/events` and `/events/search` now accept `offset`, and Explore pages non-date sorts by offset. Date sorts keep the cursor because `useSWRInfinite` only revalidates the first page, and cursor keys for later pages follow it while offset keys don't. Score and speed sorts on `/events` break ties on `id` so offset pages stay stable. * order search ties by id and reject negative offsets `/events/search` sorted in Python over a query with no `ORDER BY`, so tied scores, speeds, or distances kept whatever order SQLite returned, which isn't guaranteed to match across page requests. The query is now ordered by id and the stable sorts keep that order for ties. `offset` also accepted negative values, which sliced from the end of the search results. --- docs/static/frigate-api.yaml | 20 ++++ .../api/defs/query/events_query_parameters.py | 2 + frigate/api/event.py | 13 ++- frigate/test/http_api/test_http_event.py | 102 ++++++++++++++++++ web/src/pages/Explore.tsx | 32 ++++-- web/src/types/search.ts | 1 + 6 files changed, 162 insertions(+), 8 deletions(-) diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 18a75df5da..7753ed7af3 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -4073,6 +4073,16 @@ paths: - type: 'null' default: 100 title: Limit + - name: offset + in: query + required: false + schema: + anyOf: + - type: integer + minimum: 0 + - type: 'null' + default: 0 + title: Offset - name: after in: query required: false @@ -4378,6 +4388,16 @@ paths: - type: 'null' default: 50 title: Limit + - name: offset + in: query + required: false + schema: + anyOf: + - type: integer + minimum: 0 + - type: 'null' + default: 0 + title: Offset - name: cameras in: query required: false diff --git a/frigate/api/defs/query/events_query_parameters.py b/frigate/api/defs/query/events_query_parameters.py index 06d0dfc3af..80cc77c721 100644 --- a/frigate/api/defs/query/events_query_parameters.py +++ b/frigate/api/defs/query/events_query_parameters.py @@ -14,6 +14,7 @@ class EventsQueryParams(BaseModel): zone: str | None = "all" zones: str | None = "all" limit: int | None = 100 + offset: int | None = Field(0, ge=0) after: float | None = None before: float | None = None time_range: str | None = DEFAULT_TIME_RANGE @@ -55,6 +56,7 @@ class EventsSearchQueryParams(BaseModel): deprecated=True, ) limit: int | None = 50 + offset: int | None = Field(0, ge=0) cameras: str | None = "all" labels: str | None = "all" sub_labels: str | None = "all" diff --git a/frigate/api/event.py b/frigate/api/event.py index 1597c18856..a0313902b1 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -129,6 +129,7 @@ def events( zones = zone limit = params.limit + offset = params.offset after = params.after before = params.before time_range = params.time_range @@ -361,11 +362,15 @@ def events( else: order_by = Event.start_time.desc() + # offset paging needs a stable order when scores or speeds tie + tiebreaker = [Event.id] if sort and sort.startswith(("score", "speed")) else [] + events = ( Event.select(*selected_columns) .where(reduce(operator.and_, clauses)) - .order_by(order_by) + .order_by(order_by, *tiebreaker) .limit(limit) + .offset(offset) .dicts() .iterator() ) @@ -518,6 +523,7 @@ def events_search( search_type = params.search_type include_thumbnails = params.include_thumbnails limit = params.limit + offset = params.offset sort = params.sort # Filters @@ -824,6 +830,9 @@ def events_search( if search_results: events_query = events_query.where(Event.id << list(search_results.keys())) + # sorts below are stable, so this orders ties for offset paging + events_query = events_query.order_by(Event.id) + # Fetch events and process them in a single pass processed_events = [] for event in events_query.dicts(): @@ -881,7 +890,7 @@ def events_search( processed_events.sort(key=lambda x: x["start_time"], reverse=True) # Limit the number of events returned - processed_events = processed_events[:limit] + processed_events = processed_events[offset:][:limit] return JSONResponse(content=processed_events) diff --git a/frigate/test/http_api/test_http_event.py b/frigate/test/http_api/test_http_event.py index 8aca6577d9..959fe6dbc2 100644 --- a/frigate/test/http_api/test_http_event.py +++ b/frigate/test/http_api/test_http_event.py @@ -168,6 +168,29 @@ class TestHttpApp(BaseTestHttp): assert events[0]["id"] == id assert events[1]["id"] == id2 + def test_get_event_list_offset_pages_score_sort(self): + now = datetime.now().timestamp() + scores = [0.6, 0.9, 0.7, 0.95, 0.8] + + with AuthTestClient(self.app) as client: + for i, score in enumerate(scores): + super().insert_mock_event( + f"event-{i}", start_time=now + i, data={"score": score} + ) + + params = {"sort": "score_desc"} + full = [e["id"] for e in client.get("/events", params=params).json()] + paged = [ + e["id"] + for offset in (0, 2, 4) + for e in client.get( + "/events", params={**params, "limit": 2, "offset": offset} + ).json() + ] + + assert full == ["event-3", "event-1", "event-4", "event-2", "event-0"] + assert paged == full + def test_get_event_list_match_multilingual_attribute(self): event_id = "123456.zh" attribute = "中文标签" @@ -219,6 +242,85 @@ class TestHttpApp(BaseTestHttp): assert len(events) == 1 assert events[0]["id"] == event_id + def test_events_search_offset_pages_score_sort(self): + now = datetime.now().timestamp() + scores = [0.6, 0.9, 0.7, 0.95, 0.8] + ids = [f"event-{i}" for i in range(len(scores))] + mock_embeddings = Mock() + mock_embeddings.search_thumbnail.return_value = [ + (event_id, 0.1 * i) for i, event_id in enumerate(ids) + ] + + self.app.frigate_config.semantic_search.enabled = True + self.app.embeddings = mock_embeddings + + with AuthTestClient(self.app) as client: + for i, score in enumerate(scores): + super().insert_mock_event( + ids[i], start_time=now + i, data={"score": score} + ) + + params = { + "search_type": "similarity", + "event_id": ids[0], + "sort": "score_desc", + } + paged = [ + e["id"] + for offset in (0, 2, 4) + for e in client.get( + "/events/search", + params={**params, "limit": 2, "offset": offset}, + ).json() + ] + + assert paged == ["event-3", "event-1", "event-4", "event-2", "event-0"] + + def test_events_search_offset_pages_orders_ties_by_id(self): + now = datetime.now().timestamp() + ids = ["event-c", "event-a", "event-b"] + mock_embeddings = Mock() + mock_embeddings.search_thumbnail.return_value = [ + (event_id, 0.1) for event_id in ids + ] + + self.app.frigate_config.semantic_search.enabled = True + self.app.embeddings = mock_embeddings + + with AuthTestClient(self.app) as client: + for i, event_id in enumerate(ids): + super().insert_mock_event( + event_id, start_time=now + i, data={"score": 0.8} + ) + + for sort in ("score_desc", "relevance"): + params = { + "search_type": "similarity", + "event_id": ids[0], + "sort": sort, + } + paged = [ + e["id"] + for offset in (0, 1, 2) + for e in client.get( + "/events/search", + params={**params, "limit": 1, "offset": offset}, + ).json() + ] + + assert paged == ["event-a", "event-b", "event-c"] + + def test_event_list_rejects_negative_offset(self): + with AuthTestClient(self.app) as client: + response = client.get("/events", params={"offset": -5}) + assert response.status_code == 422 + + response = client.get( + "/events/search", + params={"query": "car", "offset": -5}, + ) + assert response.status_code == 422 + def test_similarity_search_hides_unauthorized_anchor_event(self): mock_embeddings = Mock() self.app.frigate_config.semantic_search.enabled = True diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index b5b19c8afb..4b2ca38e5d 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -198,7 +198,19 @@ export default function Explore() { const [url, params] = searchQuery; - const isAscending = params.sort?.includes("date_asc"); + // a start_time cursor only works when rows are ordered by start_time, + // so every other sort pages by offset + const isDateSort = + params.sort === "date_asc" || + params.sort === "date_desc" || + (!params.sort && url === "events"); + + if (pageIndex > 0 && !isDateSort) { + return [ + url, + { ...params, offset: pageIndex * API_LIMIT, limit: API_LIMIT }, + ]; + } if (pageIndex > 0 && previousPageData) { const lastDate = previousPageData[previousPageData.length - 1].start_time; @@ -206,7 +218,8 @@ export default function Explore() { url, { ...params, - [isAscending ? "after" : "before"]: lastDate.toString(), + [params.sort === "date_asc" ? "after" : "before"]: + lastDate.toString(), limit: API_LIMIT, }, ]; @@ -238,10 +251,17 @@ export default function Explore() { }, }); - const searchResults = useMemo( - () => (data ? ([] as SearchResult[]).concat(...data) : []), - [data], - ); + // offset pages can overlap when results shift between page fetches + const searchResults = useMemo(() => { + if (!data) return []; + + const seen = new Set(); + return data.flat().filter((result) => { + if (seen.has(result.id)) return false; + seen.add(result.id); + return true; + }); + }, [data]); const isLoadingInitialData = !data && !isValidating; const isLoadingMore = isLoadingInitialData || diff --git a/web/src/types/search.ts b/web/src/types/search.ts index d47e955847..8740314bfc 100644 --- a/web/src/types/search.ts +++ b/web/src/types/search.ts @@ -109,6 +109,7 @@ export type SearchQueryParams = { max_speed?: number; search_type?: string; limit?: number; + offset?: number; in_progress?: number; include_thumbnails?: number; query?: string; From de416b7ae751b22ec96a78d294e4f7c9577d2f7b Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 17 Sep 2026 12:28:51 -0600 Subject: [PATCH 07/21] Remove invalid hardware acceleration step in recording troubleshooting (#24395) Remove the invalid suggestion in docs --- docs/docs/troubleshooting/recordings.md | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/docs/docs/troubleshooting/recordings.md b/docs/docs/troubleshooting/recordings.md index 04b0240557..6bb2cc70ad 100644 --- a/docs/docs/troubleshooting/recordings.md +++ b/docs/docs/troubleshooting/recordings.md @@ -397,19 +397,11 @@ dmesg | grep -i -E "gpu|drm|reset|hang" Messages like `trying reset from guc_exec_queue_timedout_job` or similar GPU reset/hang messages indicate a driver or hardware issue. Ensure your kernel and GPU drivers (especially Intel) are up to date. -#### Step 6: Verify hardware acceleration configuration - -An incorrect `hwaccel_args` preset can cause ffmpeg to fail silently or consume excessive CPU, starving the detector of resources. - -- After upgrading Frigate, verify your preset matches your hardware (e.g., `preset-intel-qsv-h264` instead of the deprecated `preset-vaapi`). -- For h265 cameras, use the corresponding h265 preset (e.g., `preset-intel-qsv-h265`). -- Note that `hwaccel_args` are only relevant for the detect stream. Frigate does not decode the record stream. - -#### Step 7: Verify go2rtc stream configuration +#### Step 6: Verify go2rtc stream configuration Ensure that the ffmpeg source names in your go2rtc configuration match the correct camera stream. A misconfigured stream name (e.g., copying a config from one camera to another without updating the stream reference) will cause the wrong stream to be used or the stream to fail entirely. -#### Step 8: Check system resources +#### Step 7: Check system resources If none of the above apply, the issue may be a general resource constraint. Monitor the following on your host: From 26e6adee88a0f1d04c63d938d1f98e0e5636c58a Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:10:36 -0500 Subject: [PATCH 08/21] Fix semantic search reindex (#24407) * fix semantic search reindex sqlite-vec added the `_info` shadow table in 0.1.6 and drops it unconditionally when a vec0 table is destroyed, so `DROP TABLE` on a table written by 0.17 failed with "SQL logic error" once 0.18 moved to 0.1.9. `SqliteQueueDatabase` queues non-SELECT statements and stores the exception on the cursor it returns, and nothing read those cursors, so the failed drop and every write after it went unreported while reindex still logged "Embedded N thumbnails". `drop_embeddings_tables()` now recreates the missing `_info` stub before dropping, and writes go through `execute_write()`, which waits on the cursor so failures raise. `INSERT OR REPLACE` is gone too, since vec0 implements neither REPLACE nor UPSERT and it always failed on an id already in the table, including under the 0.1.3 build 0.17 shipped. * use lock * show reindex failure in status bar --- frigate/db/sqlitevecq.py | 91 +++++++++++--- frigate/embeddings/embeddings.py | 80 ++++++------- frigate/test/test_sqlitevecq_embeddings.py | 126 +++++++++++++++++++- web/public/locales/en/views/system.json | 1 + web/src/components/Statusbar.tsx | 10 +- web/src/components/navigation/Bottombar.tsx | 10 +- 6 files changed, 252 insertions(+), 66 deletions(-) diff --git a/frigate/db/sqlitevecq.py b/frigate/db/sqlitevecq.py index 2d740f3736..eefd0988d1 100644 --- a/frigate/db/sqlitevecq.py +++ b/frigate/db/sqlitevecq.py @@ -1,8 +1,10 @@ import logging import sqlite3 +import threading from typing import Any import regex +from peewee import DatabaseError from playhouse.sqliteq import SqliteQueueDatabase logger = logging.getLogger(__name__) @@ -17,6 +19,7 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase): self.load_vec_extension: bool = load_vec_extension # no extension necessary, sqlite will load correctly for each platform self.sqlite_vec_path = "/usr/local/lib/vec0" + self.upsert_lock = threading.Lock() super().__init__(*args, **kwargs) def _connect(self, *args: Any, **kwargs: Any) -> sqlite3.Connection: @@ -53,6 +56,22 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase): conn.create_function("REGEXP", 2, regexp) + def execute_write(self, sql: str, params: Any = None) -> None: + """Run a write and wait for it, so that failures are raised here. + + SqliteQueueDatabase hands non-SELECT statements to a writer thread and + stores any exception on the cursor it returns, so callers that ignore + that cursor never learn the write failed. + """ + self.execute_sql(sql, params).fetchall() + + def _table_exists(self, table: str) -> bool: + cursor = self.execute_sql( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + (table,), + ) + return cursor.fetchone() is not None + def _delete_embeddings(self, table: str, event_ids: list[str]) -> None: """Delete embeddings for the given events, if the table exists. @@ -63,17 +82,17 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase): return # the embeddings tables are only created once semantic search has run - cursor = self.execute_sql( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", - (table,), - ) - - if cursor.fetchone() is None: + if not self._table_exists(table): logger.debug("Skipping %s cleanup, table does not exist", table) return ids = ",".join(["?" for _ in event_ids]) - self.execute_sql(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids) + + # callers treat cleanup as best effort, so log rather than propagate + try: + self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids) + except DatabaseError: + logger.exception("Failed to delete embeddings from %s", table) def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None: self._delete_embeddings("vec_thumbnails", event_ids) @@ -81,25 +100,67 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase): def delete_embeddings_description(self, event_ids: list[str]) -> None: self._delete_embeddings("vec_descriptions", event_ids) + def _restore_vec_info_table(self, table: str) -> None: + """Recreate the _info shadow table a legacy vec0 table is missing. + + sqlite-vec added _info in 0.1.6 and drops it unconditionally when a + table is destroyed, so tables written by Frigate 0.17 and earlier fail + to drop. An empty stub is enough, and leaving it unseeded keeps the + table reading as pre-0.1.10 if the drop does not follow. + """ + if not self._table_exists(table) or self._table_exists(f"{table}_info"): + return + + logger.debug("Restoring the %s_info shadow table before dropping", table) + self.execute_write( + f'CREATE TABLE "{table}_info" (key TEXT PRIMARY KEY, value ANY)' + ) + def drop_embeddings_tables(self) -> None: - self.execute_sql(""" - DROP TABLE vec_descriptions; - """) - self.execute_sql(""" - DROP TABLE vec_thumbnails; - """) + for table in ("vec_descriptions", "vec_thumbnails"): + self._restore_vec_info_table(table) + self.execute_write(f"DROP TABLE IF EXISTS {table}") def create_embeddings_tables(self) -> None: """Create vec0 virtual table for embeddings""" - self.execute_sql(""" + self.execute_write(""" CREATE VIRTUAL TABLE IF NOT EXISTS vec_thumbnails USING vec0( id TEXT PRIMARY KEY, thumbnail_embedding FLOAT[768] distance_metric=cosine ); """) - self.execute_sql(""" + self.execute_write(""" CREATE VIRTUAL TABLE IF NOT EXISTS vec_descriptions USING vec0( id TEXT PRIMARY KEY, description_embedding FLOAT[768] distance_metric=cosine ); """) + + def upsert_embeddings( + self, table: str, column: str, embeddings: dict[str, bytes] + ) -> None: + """Write embeddings for the given event ids, replacing any that exist. + + vec0 implements neither REPLACE nor UPSERT, so rows that are already + there have to be deleted first. + """ + if not embeddings: + return + + event_ids = list(embeddings.keys()) + ids = ",".join(["?" for _ in event_ids]) + params: list[Any] = [] + + for event_id in event_ids: + params.extend((event_id, embeddings[event_id])) + + values = ", ".join(["(?, ?)"] * len(event_ids)) + + # reindexing and live embedding run on separate threads, and each write + # is queued separately, so the delete and the insert have to be held + # together or an interleaved pair fails on the vec0 primary key + with self.upsert_lock: + self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids) + self.execute_write( + f"INSERT INTO {table}(id, {column}) VALUES {values}", params + ) diff --git a/frigate/embeddings/embeddings.py b/frigate/embeddings/embeddings.py index 91144c3fa5..52c43a6c30 100644 --- a/frigate/embeddings/embeddings.py +++ b/frigate/embeddings/embeddings.py @@ -6,9 +6,10 @@ import logging import os import threading import time +from typing import Any import numpy as np -from peewee import DoesNotExist, IntegrityError +from peewee import DatabaseError, DoesNotExist, IntegrityError from PIL import Image from playhouse.shortcuts import model_to_dict @@ -207,12 +208,10 @@ class Embeddings: embedding = self.vision_embedding([thumbnail])[0] if upsert: - self.db.execute_sql( - """ - INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding) - VALUES(?, ?) - """, - (event_id, serialize(embedding)), + self.db.upsert_embeddings( + "vec_thumbnails", + "thumbnail_embedding", + {event_id: serialize(embedding)}, ) self.image_inference_speed.update(datetime.datetime.now().timestamp() - start) @@ -251,19 +250,12 @@ class Embeddings: embeddings = self.vision_embedding(valid_thumbs) if upsert: - items = [] + items = {} for i in range(len(valid_ids)): - items.append(valid_ids[i]) - items.append(serialize(embeddings[i])) + items[valid_ids[i]] = serialize(embeddings[i]) self.image_eps.update() - self.db.execute_sql( - """ - INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding) - VALUES {} - """.format(", ".join(["(?, ?)"] * len(valid_ids))), - items, - ) + self.db.upsert_embeddings("vec_thumbnails", "thumbnail_embedding", items) duration = datetime.datetime.now().timestamp() - start self.image_inference_speed.update(duration / len(valid_ids)) @@ -277,12 +269,10 @@ class Embeddings: embedding = self.text_embedding([description])[0] if upsert: - self.db.execute_sql( - """ - INSERT OR REPLACE INTO vec_descriptions(id, description_embedding) - VALUES(?, ?) - """, - (event_id, serialize(embedding)), + self.db.upsert_embeddings( + "vec_descriptions", + "description_embedding", + {event_id: serialize(embedding)}, ) self.text_inference_speed.update(datetime.datetime.now().timestamp() - start) @@ -302,19 +292,14 @@ class Embeddings: if upsert: ids = list(event_descriptions.keys()) - items = [] + items = {} for i in range(len(ids)): - items.append(ids[i]) - items.append(serialize(embeddings[i])) + items[ids[i]] = serialize(embeddings[i]) self.text_eps.update() - self.db.execute_sql( - """ - INSERT OR REPLACE INTO vec_descriptions(id, description_embedding) - VALUES {} - """.format(", ".join(["(?, ?)"] * len(ids))), - items, + self.db.upsert_embeddings( + "vec_descriptions", "description_embedding", items ) self.text_inference_speed.update(datetime.datetime.now().timestamp() - start) @@ -322,6 +307,17 @@ class Embeddings: return embeddings def reindex(self) -> None: + """Rebuild every tracked object embedding from scratch.""" + totals: dict[str, Any] = {"status": "indexing"} + + try: + self._reindex(totals) + except DatabaseError: + logger.exception("Unable to reindex tracked object embeddings") + totals["status"] = "failed" + self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals) + + def _reindex(self, totals: dict[str, Any]) -> None: logger.info("Indexing tracked object embeddings...") self.db.drop_embeddings_tables() @@ -346,14 +342,18 @@ class Embeddings: batch_size = 32 current_page = 1 - totals = { - "thumbnails": 0, - "descriptions": 0, - "processed_objects": total_events - 1 if total_events < batch_size else 0, - "total_objects": total_events, - "time_remaining": 0 if total_events < batch_size else -1, - "status": "indexing", - } + totals.update( + { + "thumbnails": 0, + "descriptions": 0, + "processed_objects": total_events - 1 + if total_events < batch_size + else 0, + "total_objects": total_events, + "time_remaining": 0 if total_events < batch_size else -1, + "status": "indexing", + } + ) self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals) diff --git a/frigate/test/test_sqlitevecq_embeddings.py b/frigate/test/test_sqlitevecq_embeddings.py index dc80cd96f8..cce5937b53 100644 --- a/frigate/test/test_sqlitevecq_embeddings.py +++ b/frigate/test/test_sqlitevecq_embeddings.py @@ -1,16 +1,24 @@ -"""Tests for embedding cleanup on the main Frigate database. +"""Tests for embedding storage and cleanup on the main Frigate database. Embeddings are deleted whether or not semantic search is currently enabled, so the delete path has to tolerate databases where the vec0 tables were never created and installs where the sqlite-vec extension is unavailable. + +The write paths need the real extension, since the behavior under test belongs +to vec0 itself, so those tests are skipped when it is not installed. """ import os +import struct import tempfile import unittest +from peewee import OperationalError + from frigate.db.sqlitevecq import SqliteVecQueueDatabase +VEC_EXTENSION_PATH = "/usr/local/lib/vec0.so" + class TestDeleteEmbeddings(unittest.TestCase): def setUp(self) -> None: @@ -52,6 +60,21 @@ class TestDeleteEmbeddings(unittest.TestCase): self.assertEqual(self._thumbnail_ids(), ["b"]) + def test_delete_failure_is_logged_not_raised(self) -> None: + self._create_thumbnails_table() + self.db.execute_sql( + """ + CREATE TRIGGER vec_thumbnails_no_delete BEFORE DELETE ON vec_thumbnails + BEGIN SELECT RAISE(ABORT, 'delete blocked'); END + """ + ).fetchall() + + with self.assertLogs("frigate.db.sqlitevecq", level="ERROR") as logs: + self.db.delete_embeddings_thumbnail(event_ids=["a"]) + + self.assertIn("Failed to delete embeddings", logs.output[0]) + self.assertEqual(self._thumbnail_ids(), ["a", "b"]) + def test_delete_skipped_without_extension(self) -> None: self._create_thumbnails_table() self.db.load_vec_extension = False @@ -61,3 +84,104 @@ class TestDeleteEmbeddings(unittest.TestCase): # the vec0 tables cannot be written without the extension self.assertEqual(self._thumbnail_ids(), ["a", "b"]) + + +def _vector(value: float) -> bytes: + return struct.pack("768f", *([value] * 768)) + + +@unittest.skipUnless( + os.path.exists(VEC_EXTENSION_PATH), "sqlite-vec extension is not installed" +) +class TestEmbeddingsTableWrites(unittest.TestCase): + """Covers the vec0 writes behind semantic search reindexing.""" + + def setUp(self) -> None: + self.tmp_dir = tempfile.TemporaryDirectory() + self.db = SqliteVecQueueDatabase( + os.path.join(self.tmp_dir.name, "test.db"), load_vec_extension=True + ) + self.db.start() + self.db.create_embeddings_tables() + + def tearDown(self) -> None: + self.db.stop() + self.db.close() + self.tmp_dir.cleanup() + + def _vec_tables(self) -> list[str]: + return [ + row[0] + for row in self.db.execute_sql( + "SELECT name FROM sqlite_master WHERE name LIKE 'vec_%' ORDER BY name" + ) + ] + + def _make_legacy(self, table: str) -> None: + # sqlite-vec added the _info shadow table in 0.1.6, so tables written by + # Frigate 0.17 and earlier do not have one + self.db.execute_sql(f"DROP TABLE {table}_info").fetchall() + + def _stored(self, table: str, column: str, event_id: str) -> str | None: + row = self.db.execute_sql( + f"SELECT vec_to_json({column}) FROM {table} WHERE id = ?", (event_id,) + ).fetchone() + return row[0] if row else None + + def test_write_error_is_raised(self) -> None: + # queued writes hide their exception in the returned cursor + with self.assertRaises(OperationalError): + self.db.execute_write("INSERT INTO vec_missing(id) VALUES ('a')") + + def test_drop_tables_removes_legacy_tables(self) -> None: + self._make_legacy("vec_thumbnails") + self._make_legacy("vec_descriptions") + + self.db.drop_embeddings_tables() + + self.assertEqual(self._vec_tables(), []) + + def test_drop_tables_without_any_tables_does_not_raise(self) -> None: + self.db.drop_embeddings_tables() + + self.db.drop_embeddings_tables() + + def test_upsert_replaces_existing_embedding(self) -> None: + self.db.upsert_embeddings( + "vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.01)} + ) + + self.db.upsert_embeddings( + "vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.99)} + ) + + stored = self._stored("vec_thumbnails", "thumbnail_embedding", "evt1") + self.assertTrue(stored.startswith("[0.990000"), stored) + + def test_upsert_keeps_one_row_per_event(self) -> None: + for _ in range(3): + self.db.upsert_embeddings( + "vec_descriptions", "description_embedding", {"evt1": _vector(0.5)} + ) + + count = self.db.execute_sql( + "SELECT count(*) FROM vec_descriptions WHERE id = 'evt1'" + ).fetchone()[0] + self.assertEqual(count, 1) + + def test_reindex_cycle_rewrites_legacy_tables(self) -> None: + """The 0.18 upgrade path: old vectors in, new vectors out.""" + self.db.upsert_embeddings( + "vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.01)} + ) + self._make_legacy("vec_thumbnails") + self._make_legacy("vec_descriptions") + + self.db.drop_embeddings_tables() + self.db.create_embeddings_tables() + self.db.upsert_embeddings( + "vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.99)} + ) + + stored = self._stored("vec_thumbnails", "thumbnail_embedding", "evt1") + self.assertTrue(stored.startswith("[0.990000"), stored) diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json index 84014aaffe..e32126505d 100644 --- a/web/public/locales/en/views/system.json +++ b/web/public/locales/en/views/system.json @@ -233,6 +233,7 @@ "detectHighCpuUsage": "{{camera}} has high detect CPU usage ({{detectAvg}}%)", "healthy": "System is healthy", "reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)", + "reindexEmbeddingsFailed": "Reindexing embeddings failed, check the logs", "cameraIsOffline": "{{camera}} is offline", "detectIsSlow": "{{detect}} is slow ({{speed}} ms)", "detectIsVerySlow": "{{detect}} is very slow ({{speed}} ms)", diff --git a/web/src/components/Statusbar.tsx b/web/src/components/Statusbar.tsx index 4e7c4053c3..7bd6f9ab15 100644 --- a/web/src/components/Statusbar.tsx +++ b/web/src/components/Statusbar.tsx @@ -71,8 +71,9 @@ export default function Statusbar() { useEffect(() => { if (reindexState) { - if (reindexState.status == "indexing") { - clearMessages("embeddings-reindex"); + clearMessages("embeddings-reindex"); + + if (reindexState.status === "indexing") { addMessage( "embeddings-reindex", t("stats.reindexingEmbeddings", { @@ -82,9 +83,8 @@ export default function Statusbar() { ), }), ); - } - if (reindexState.status === "completed") { - clearMessages("embeddings-reindex"); + } else if (reindexState.status === "failed") { + addMessage("embeddings-reindex", t("stats.reindexEmbeddingsFailed")); } } }, [reindexState, addMessage, clearMessages, t]); diff --git a/web/src/components/navigation/Bottombar.tsx b/web/src/components/navigation/Bottombar.tsx index a46cef2806..468debc5f5 100644 --- a/web/src/components/navigation/Bottombar.tsx +++ b/web/src/components/navigation/Bottombar.tsx @@ -133,8 +133,9 @@ function StatusAlertNav({ className, large }: StatusAlertNavProps) { useEffect(() => { if (reindexState) { - if (reindexState.status == "indexing") { - clearMessages("embeddings-reindex"); + clearMessages("embeddings-reindex"); + + if (reindexState.status === "indexing") { addMessage( "embeddings-reindex", t("stats.reindexingEmbeddings", { @@ -144,9 +145,8 @@ function StatusAlertNav({ className, large }: StatusAlertNavProps) { ), }), ); - } - if (reindexState.status === "completed") { - clearMessages("embeddings-reindex"); + } else if (reindexState.status === "failed") { + addMessage("embeddings-reindex", t("stats.reindexEmbeddingsFailed")); } } }, [reindexState, addMessage, clearMessages, t]); From 93aa6c41740e8bc002b847665e3d80de6b900a86 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:40:09 -0500 Subject: [PATCH 09/21] Add version/release link to docs site (#24410) * add version/release link to docs * link to full releases page --- docs/docusaurus.config.ts | 52 ++++++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index 69ab7c3f70..862db4518f 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -3,6 +3,9 @@ import * as path from "node:path"; import type { Config, PluginConfig } from "@docusaurus/types"; import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs"; +// Bump when a new stable release ships +const STABLE_VERSION = "0.18"; + const config: Config = { title: "Frigate", tagline: "NVR With Realtime Object Detection for IP Cameras", @@ -23,17 +26,17 @@ const config: Config = { mermaid: true, }, i18n: { - defaultLocale: 'en', - locales: ['en'], + defaultLocale: "en", + locales: ["en"], localeConfigs: { en: { - label: 'English', - } + label: "English", + }, }, }, themeConfig: { announcementBar: { - id: 'frigate_plus', + id: "frigate_plus", content: ` 🚀 Get more relevant and accurate detections with Frigate+ models. @@ -45,8 +48,8 @@ const config: Config = { 50% { transform: scale(1.1); } } `, - backgroundColor: '#005f73', - textColor: '#e0fbfc', + backgroundColor: "#005f73", + textColor: "#e0fbfc", isCloseable: false, }, docs: { @@ -83,15 +86,15 @@ const config: Config = { }, }, prism: { - magicComments:[ + magicComments: [ { - className: 'theme-code-block-highlighted-line', - line: 'highlight-next-line', - block: {start: 'highlight-start', end: 'highlight-end'}, + className: "theme-code-block-highlighted-line", + line: "highlight-next-line", + block: { start: "highlight-start", end: "highlight-end" }, }, { - className: 'code-block-error-line', - line: 'highlight-error-line', + className: "code-block-error-line", + line: "highlight-error-line", }, ], additionalLanguages: ["bash", "json"], @@ -131,6 +134,11 @@ const config: Config = { srcDark: "img/branding/logo-dark.svg", }, items: [ + { + href: "https://github.com/blakeblackshear/frigate/releases", + label: `${STABLE_VERSION}`, + position: "left", + }, { to: "/", activeBasePath: "docs", @@ -148,19 +156,19 @@ const config: Config = { position: "right", }, { - type: 'localeDropdown', - position: 'right', + type: "localeDropdown", + position: "right", dropdownItemsAfter: [ { - label: '简体中文(社区翻译)', - href: 'https://docs.frigate-cn.video', - } - ] + label: "简体中文(社区翻译)", + href: "https://docs.frigate-cn.video", + }, + ], }, { - href: 'https://github.com/blakeblackshear/frigate', - label: 'GitHub', - position: 'right', + href: "https://github.com/blakeblackshear/frigate", + label: "GitHub", + position: "right", }, ], }, From ac9ac50df55f66346baa011a630932e8d0cc5ae8 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:44:47 -0500 Subject: [PATCH 10/21] back off restarts when a recording stream goes stale (#24420) The watchdog loop runs every second and the record staleness check restarted ffmpeg on every pass, so once a camera's segments went stale it got one restart per second and never had time to finish a 10 second segment. The restart is now gated on `can_restart` like the detect paths and grants 90 seconds of grace afterward. Backport of https://github.com/blakeblackshear/frigate/pull/24072, already in 0.19. --- frigate/video/ffmpeg.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/frigate/video/ffmpeg.py b/frigate/video/ffmpeg.py index 24b7805333..d2f60c0aea 100644 --- a/frigate/video/ffmpeg.py +++ b/frigate/video/ffmpeg.py @@ -34,6 +34,8 @@ from frigate.util.process import FrigateProcess logger = logging.getLogger(__name__) +RECORD_GRACE_SECONDS = 90 + def capture_frames( ffmpeg_process: sp.Popen[Any], @@ -164,6 +166,7 @@ class CameraWatchdog(threading.Thread): self.latest_invalid_segment_time: float = 0 self.latest_cache_segment_time: float = 0 self.record_enable_time: datetime | None = None + self.record_grace_until: datetime | None = None # `valid` segments are published with the segment's start time, so the # gap between consecutive publishes can reach 2 * segment_time. Pad the @@ -280,6 +283,7 @@ class CameraWatchdog(threading.Thread): self.latest_valid_segment_time = 0 self.latest_invalid_segment_time = 0 self.latest_cache_segment_time = 0 + self.record_grace_until = None self.record_enable_time = datetime.now().astimezone(UTC) last_restart_time = datetime.now().timestamp() continue @@ -294,6 +298,7 @@ class CameraWatchdog(threading.Thread): self.latest_valid_segment_time = 0 self.latest_invalid_segment_time = 0 self.latest_cache_segment_time = 0 + self.record_grace_until = None self.record_enable_time = datetime.now().astimezone(UTC) else: self.logger.debug(f"Disabling camera {self.config.name}") @@ -318,6 +323,7 @@ class CameraWatchdog(threading.Thread): self.latest_valid_segment_time = 0 self.latest_invalid_segment_time = 0 self.latest_cache_segment_time = 0 + self.record_grace_until = None self.record_enable_time = datetime.now().astimezone(UTC) last_restart_time = datetime.now().timestamp() self.was_record_enabled_in_config = record_enabled_in_config @@ -404,11 +410,16 @@ class CameraWatchdog(threading.Thread): if self.config.record.enabled and "record" in p["roles"]: now_utc = datetime.now().astimezone(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) + # ffmpeg needs time to create a first segment after + # recording is enabled and after a restart + in_grace_period = ( + self.record_enable_time is not None + and (now_utc - self.record_enable_time) + < timedelta(seconds=RECORD_GRACE_SECONDS) + ) or ( + self.record_grace_until is not None + and now_utc < self.record_grace_until + ) latest_cache_dt = ( datetime.fromtimestamp(self.latest_cache_segment_time, tz=UTC) @@ -445,8 +456,9 @@ class CameraWatchdog(threading.Thread): <= self.latest_invalid_segment_time ) invalid_stale = invalid_stale_condition + stale = cache_stale or valid_stale or invalid_stale - if cache_stale or valid_stale or invalid_stale: + if stale and can_restart: if cache_stale: reason = "No new recording segments were created" elif valid_stale: @@ -471,8 +483,13 @@ class CameraWatchdog(threading.Thread): f"{self.config.name}/status/{role.value}", "offline" ) + self.record_grace_until = now_utc + timedelta( + seconds=RECORD_GRACE_SECONDS + ) + last_restart_time = now + continue - else: + elif not stale: self._send_record_status("online", now) p["latest_segment_time"] = self.latest_cache_segment_time From bbc412763d959b0cd0a1e8cb815f729c6a2b7919 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 21 Sep 2026 17:45:08 -0600 Subject: [PATCH 11/21] Update keywords used in docs to match UI (#24436) --- .../license_plate_recognition.md | 8 +++--- docs/docs/configuration/object_filters.md | 20 +++++++------- docs/docs/configuration/objects.md | 16 ++++++------ docs/docs/configuration/semantic_search.md | 4 +-- docs/docs/configuration/zones.md | 8 +++--- docs/docs/frigate/glossary.md | 4 +-- docs/docs/plus/first_model.md | 26 +++++++++---------- 7 files changed, 43 insertions(+), 43 deletions(-) diff --git a/docs/docs/configuration/license_plate_recognition.md b/docs/docs/configuration/license_plate_recognition.md index 2c7c8e4cbb..1df2f08db3 100644 --- a/docs/docs/configuration/license_plate_recognition.md +++ b/docs/docs/configuration/license_plate_recognition.md @@ -378,10 +378,10 @@ Navigate to . -| Field | Description | -| ---------------------------------------------- | ------------------- | -| **Objects to track** | Add `license_plate` | -| **Object filters > License Plate > Threshold** | Set to `0.7` | +| Field | Description | +| --------------------------------------------------------- | ------------------- | +| **Objects to track** | Add `license_plate` | +| **Object filters > License Plate > Confidence threshold** | Set to `0.7` | Navigate to . diff --git a/docs/docs/configuration/object_filters.md b/docs/docs/configuration/object_filters.md index b2e7fca319..53464152cb 100644 --- a/docs/docs/configuration/object_filters.md +++ b/docs/docs/configuration/object_filters.md @@ -45,10 +45,10 @@ Any detection below `min_score` will be immediately thrown out and never tracked Navigate to to set score filters globally. -| Field | Description | -| --------------------------------------- | ---------------------------------------------------------------- | -| **Object filters > Person > Min Score** | Minimum score for a single detection to initiate tracking | -| **Object filters > Person > Threshold** | Minimum computed (median) score to be considered a true positive | +| Field | Description | +| -------------------------------------------------- | ---------------------------------------------------------------- | +| **Object filters > Person > Minimum confidence** | Minimum score for a single detection to initiate tracking | +| **Object filters > Person > Confidence threshold** | Minimum computed (median) score to be considered a true positive | To override score filters for a specific camera, navigate to and select the camera. @@ -103,12 +103,12 @@ Conceptually, a ratio of 1 is a square, 0.5 is a "tall skinny" box, and 2 is a " Navigate to to set shape filters globally. -| Field | Description | -| --------------------------------------- | ------------------------------------------------------------------------ | -| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) | -| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) | -| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box | -| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box | +| Field | Description | +| -------------------------------------------------- | ------------------------------------------------------------------------ | +| **Object filters > Person > Minimum object area** | Minimum bounding box area in pixels (or decimal for percentage of frame) | +| **Object filters > Person > Maximum object area** | Maximum bounding box area in pixels (or decimal for percentage of frame) | +| **Object filters > Person > Minimum aspect ratio** | Minimum width/height ratio of the bounding box | +| **Object filters > Person > Maximum aspect ratio** | Maximum width/height ratio of the bounding box | To override shape filters for a specific camera, navigate to and select the camera. diff --git a/docs/docs/configuration/objects.md b/docs/docs/configuration/objects.md index e5a24ea081..cded360977 100644 --- a/docs/docs/configuration/objects.md +++ b/docs/docs/configuration/objects.md @@ -70,14 +70,14 @@ Object filters help reduce false positives by constraining the size, shape, and Navigate to . -| Field | Description | -| --------------------------------------- | ------------------------------------------------------------------------ | -| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) | -| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) | -| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box | -| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box | -| **Object filters > Person > Min Score** | Minimum score for the object to initiate tracking | -| **Object filters > Person > Threshold** | Minimum computed score to be considered a true positive | +| Field | Description | +| -------------------------------------------------- | ------------------------------------------------------------------------ | +| **Object filters > Person > Minimum object area** | Minimum bounding box area in pixels (or decimal for percentage of frame) | +| **Object filters > Person > Maximum object area** | Maximum bounding box area in pixels (or decimal for percentage of frame) | +| **Object filters > Person > Minimum aspect ratio** | Minimum width/height ratio of the bounding box | +| **Object filters > Person > Maximum aspect ratio** | Maximum width/height ratio of the bounding box | +| **Object filters > Person > Minimum confidence** | Minimum score for the object to initiate tracking | +| **Object filters > Person > Confidence threshold** | Minimum computed score to be considered a true positive | To override filters for a specific camera, navigate to . diff --git a/docs/docs/configuration/semantic_search.md b/docs/docs/configuration/semantic_search.md index 30a60d505f..1c115cb576 100644 --- a/docs/docs/configuration/semantic_search.md +++ b/docs/docs/configuration/semantic_search.md @@ -245,8 +245,8 @@ Triggers are best configured through the Frigate UI. 1. Navigate to and select a camera from the dropdown menu. 2. Click **Add Trigger** to create a new trigger or use the pencil icon to edit an existing one. 3. In the **Create Trigger** wizard: - - Enter a **Name** for the trigger (e.g., "Red Car Alert"). - - Enter a descriptive **Friendly Name** for the trigger (e.g., "Red car on the driveway camera"). + - Enter a **Name** for the trigger (e.g., "Red Car Alert"). Frigate derives the trigger's + internal **ID** from this name, which can be revealed and edited with the show/hide toggle. - Select the **Type** (`Thumbnail` or `Description`). - For `Thumbnail`, select an image to trigger this action when a similar thumbnail image is detected, based on the threshold. - For `Description`, enter text to trigger this action when a similar tracked object description is detected. diff --git a/docs/docs/configuration/zones.md b/docs/docs/configuration/zones.md index 2a26453203..bd00198f1b 100644 --- a/docs/docs/configuration/zones.md +++ b/docs/docs/configuration/zones.md @@ -28,7 +28,7 @@ During testing, enable the Zones option for the [Debug view](/usage/live#the-sin 1. Navigate to and select the desired camera. 2. Under the **Zones** section, click the plus icon to add a new zone. 3. Click on the camera's latest image to create the points for the zone boundary. Click the first point again to close the polygon. -4. Configure zone options such as **Friendly name**, **Objects**, **Loitering time**, and **Inertia** in the zone editor. +4. Configure zone options such as **Name**, **Objects**, **Loitering Time**, and **Inertia** in the zone editor. 5. Press **Save** when finished. @@ -200,7 +200,7 @@ When using loitering zones, a review item will behave in the following way: 1. Navigate to and select the desired camera. 2. Edit or create the zone (e.g., `sidewalk`). - - Set **Loitering time** to the desired number of seconds (e.g., `4`) + - Set **Loitering Time** to the desired number of seconds (e.g., `4`) - Under **Objects**, add the relevant object types (e.g., `person`) @@ -291,7 +291,7 @@ Accurate real-world distance measurements are required to estimate speeds. These 1. Navigate to and select the desired camera. 2. Create or edit a zone with exactly 4 points aligned to the ground plane. -3. In the zone editor, enter the real-world **Distances** between each pair of consecutive points. +3. In the zone editor, enable **Speed Estimation** and enter the real-world **Line A distance**, **Line B distance**, **Line C distance**, and **Line D distance** between each pair of consecutive points. - For example, if the distance between the first and second points is 10 meters, between the second and third is 12 meters, etc. 4. Distances are measured in meters (metric) or feet (imperial), depending on the **Unit system** setting. @@ -358,7 +358,7 @@ Zones can be configured with a minimum speed requirement, meaning an object must 1. Navigate to and select the desired camera. 2. Edit or create the zone with distances configured. - - Set **Speed threshold** to the desired minimum speed (e.g., `20`) + - Set **Speed Threshold** to the desired minimum speed (e.g., `20`) - The unit is kph or mph, depending on the **Unit system** setting diff --git a/docs/docs/frigate/glossary.md b/docs/docs/frigate/glossary.md index d550015670..3c53903e83 100644 --- a/docs/docs/frigate/glossary.md +++ b/docs/docs/frigate/glossary.md @@ -54,7 +54,7 @@ An object filter mask drops any [bounding box](#bounding-box) whose bottom cente ## Min Score -The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded. +The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded. Set with `min_score` in the config, shown as **Minimum confidence** in the settings UI. ## Model @@ -86,7 +86,7 @@ A more specific identity assigned to a [tracked object](#tracked-object-event-in ## Threshold -The median score an object must reach to be considered a true positive. +The median score an object must reach to be considered a true positive. Set with `threshold` in the config, shown as **Confidence threshold** in the settings UI. ## Top Score diff --git a/docs/docs/plus/first_model.md b/docs/docs/plus/first_model.md index 98095554b5..626da519a9 100644 --- a/docs/docs/plus/first_model.md +++ b/docs/docs/plus/first_model.md @@ -64,20 +64,20 @@ Frigate+ models generally have much higher scores than the default model provide -Navigate to . Under **Object filters**, set **Min Score** and **Threshold** for each object type, then click **Save**. +Navigate to . Under **Object filters**, set **Minimum confidence** and **Confidence threshold** for each object type, then click **Save**. -| Object | Min Score | Threshold | -| ----------------- | --------- | --------- | -| **dog** | .7 | .9 | -| **cat** | .65 | .8 | -| **face** | .7 | | -| **package** | .65 | .9 | -| **license_plate** | .6 | | -| **amazon** | .75 | | -| **ups** | .75 | | -| **fedex** | .75 | | -| **person** | .65 | .85 | -| **car** | .65 | .85 | +| Object | Minimum confidence | Confidence threshold | +| ----------------- | ------------------ | -------------------- | +| **dog** | .7 | .9 | +| **cat** | .65 | .8 | +| **face** | .7 | | +| **package** | .65 | .9 | +| **license_plate** | .6 | | +| **amazon** | .75 | | +| **ups** | .75 | | +| **fedex** | .75 | | +| **person** | .65 | .85 | +| **car** | .65 | .85 | From 9d0d8a99bb0d51c66366bac54a67538b172abd70 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:23:20 -0500 Subject: [PATCH 12/21] use resolved camera config in object processor to avoid race on replay stop (#24450) --- frigate/camera/activity_manager.py | 10 ++++--- frigate/stats/util.py | 9 +++--- frigate/track/object_processing.py | 45 ++++++++++++++++++++---------- 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/frigate/camera/activity_manager.py b/frigate/camera/activity_manager.py index bd3474b1ab..556e2619e4 100644 --- a/frigate/camera/activity_manager.py +++ b/frigate/camera/activity_manager.py @@ -103,12 +103,13 @@ class CameraActivityManager: all_objects: list[dict[str, Any]] = [] for camera in new_activity.keys(): - if camera not in self.config.cameras: + camera_config = self.config.cameras.get(camera) + if camera_config is None: continue # handle cameras that were added dynamically if camera not in self.camera_all_object_counts: - self.__init_camera(self.config.cameras[camera]) + self.__init_camera(camera_config) new_objects = new_activity[camera].get("objects", []) all_objects.extend(new_objects) @@ -233,12 +234,13 @@ class AudioActivityManager: now = datetime.datetime.now().timestamp() for camera in new_activity.keys(): - if camera not in self.config.cameras: + camera_config = self.config.cameras.get(camera) + if camera_config is None: continue # handle cameras that were added dynamically if camera not in self.current_audio_detections: - self.__init_camera(self.config.cameras[camera]) + self.__init_camera(camera_config) new_detections = new_activity[camera].get("detections", []) if self.compare_audio_activity(camera, new_detections, now): diff --git a/frigate/stats/util.py b/frigate/stats/util.py index 6e20197391..ca9d000425 100644 --- a/frigate/stats/util.py +++ b/frigate/stats/util.py @@ -352,8 +352,9 @@ def stats_snapshot( total_camera_fps = total_process_fps = total_skipped_fps = total_detection_fps = 0 stats["cameras"] = {} - for name, camera_stats in camera_metrics.items(): - if name not in config.cameras: + for name, camera_stats in list(camera_metrics.items()): + camera_config = config.cameras.get(name) + if camera_config is None: continue total_camera_fps += camera_stats.camera_fps.value @@ -370,7 +371,7 @@ def stats_snapshot( # Calculate connection quality based on current state # This is computed at stats-collection time so offline cameras # correctly show as unusable rather than excellent - expected_fps = config.cameras[name].detect.fps + expected_fps = camera_config.detect.fps current_fps = camera_stats.camera_fps.value reconnects = camera_stats.reconnects_last_hour.value stalls = camera_stats.stalls_last_hour.value @@ -398,7 +399,7 @@ def stats_snapshot( "process_fps": round(camera_stats.process_fps.value, 2), "skipped_fps": round(camera_stats.skipped_fps.value, 2), "detection_fps": round(camera_stats.detection_fps.value, 2), - "detection_enabled": config.cameras[name].detect.enabled, + "detection_enabled": camera_config.detect.enabled, "pid": pid, "capture_pid": capture_pid, "ffmpeg_pid": ffmpeg_pid, diff --git a/frigate/track/object_processing.py b/frigate/track/object_processing.py index 999ec5c04b..201a41b9dc 100644 --- a/frigate/track/object_processing.py +++ b/frigate/track/object_processing.py @@ -24,6 +24,7 @@ from frigate.comms.event_metadata_updater import ( from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher from frigate.comms.inter_process import InterProcessRequestor from frigate.config import ( + CameraConfig, CameraMqttConfig, FrigateConfig, RecordConfig, @@ -128,8 +129,10 @@ class TrackedObjectProcessor(threading.Thread): ) def update(camera: str, obj: TrackedObject, frame_name: str) -> None: - obj.has_snapshot = self.should_save_snapshot(camera, obj) - obj.has_clip = self.should_retain_recording(camera, obj) + obj.has_snapshot = self.should_save_snapshot( + camera_state.camera_config, obj + ) + obj.has_clip = self.should_retain_recording(camera_state.camera_config, obj) after = obj.to_dict() message = { "before": obj.previous, @@ -153,8 +156,10 @@ class TrackedObjectProcessor(threading.Thread): def end(camera: str, obj: TrackedObject, frame_name: str) -> None: # populate has_snapshot - obj.has_snapshot = self.should_save_snapshot(camera, obj) - obj.has_clip = self.should_retain_recording(camera, obj) + obj.has_snapshot = self.should_save_snapshot( + camera_state.camera_config, obj + ) + obj.has_clip = self.should_retain_recording(camera_state.camera_config, obj) # write thumbnail to disk if it will be saved as an event if obj.has_snapshot or obj.has_clip: @@ -184,8 +189,8 @@ class TrackedObjectProcessor(threading.Thread): ) def snapshot(camera: str, obj: TrackedObject) -> bool: - mqtt_config: CameraMqttConfig = self.config.cameras[camera].mqtt - if mqtt_config.enabled and self.should_mqtt_snapshot(camera, obj): + mqtt_config: CameraMqttConfig = camera_state.camera_config.mqtt + if mqtt_config.enabled and self.should_mqtt_snapshot(mqtt_config, obj): jpg_bytes, _ = obj.get_img_bytes( ext="jpg", timestamp=mqtt_config.timestamp, @@ -238,11 +243,13 @@ class TrackedObjectProcessor(threading.Thread): camera_state.on("camera_activity", camera_activity) self.camera_states[camera] = camera_state - def should_save_snapshot(self, camera: str, obj: TrackedObject) -> bool: + def should_save_snapshot( + self, camera_config: CameraConfig, obj: TrackedObject + ) -> bool: if obj.false_positive: return False - snapshot_config: SnapshotsConfig = self.config.cameras[camera].snapshots + snapshot_config: SnapshotsConfig = camera_config.snapshots if not snapshot_config.enabled: return False @@ -261,11 +268,13 @@ class TrackedObjectProcessor(threading.Thread): return True - def should_retain_recording(self, camera: str, obj: TrackedObject) -> bool: + def should_retain_recording( + self, camera_config: CameraConfig, obj: TrackedObject + ) -> bool: if obj.false_positive: return False - record_config: RecordConfig = self.config.cameras[camera].record + record_config: RecordConfig = camera_config.record # Recording is disabled if not record_config.enabled: @@ -281,13 +290,15 @@ class TrackedObjectProcessor(threading.Thread): return True - def should_mqtt_snapshot(self, camera: str, obj: TrackedObject) -> bool: + def should_mqtt_snapshot( + self, mqtt_config: CameraMqttConfig, obj: TrackedObject + ) -> bool: # object never changed position if obj.is_stationary(): return False # if there are required zones and there is no overlap - required_zones = self.config.cameras[camera].mqtt.required_zones + required_zones = mqtt_config.required_zones if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones): logger.debug( f"Not sending mqtt for {obj.obj_data['id']} because it did not enter required zones" @@ -297,7 +308,11 @@ class TrackedObjectProcessor(threading.Thread): return True def update_mqtt_motion( - self, camera: str, frame_time: float, motion_boxes: list + self, + camera: str, + camera_config: CameraConfig, + frame_time: float, + motion_boxes: list, ) -> None: # publish if motion is currently being detected if motion_boxes: @@ -312,7 +327,7 @@ class TrackedObjectProcessor(threading.Thread): # always updated latest motion self.last_motion_detected[camera] = frame_time elif self.last_motion_detected.get(camera, 0) > 0: - mqtt_delay = self.config.cameras[camera].motion.mqtt_off_delay + mqtt_delay = camera_config.motion.mqtt_off_delay # If no motion, make sure the off_delay has passed if frame_time - self.last_motion_detected.get(camera, 0) >= mqtt_delay: @@ -783,7 +798,7 @@ class TrackedObjectProcessor(threading.Thread): frame_name, frame_time, current_tracked_objects, motion_boxes, regions ) - self.update_mqtt_motion(camera, frame_time, motion_boxes) + self.update_mqtt_motion(camera, camera_config, frame_time, motion_boxes) tracked_objects = [ o.to_dict() for o in camera_state.tracked_objects.values() From 1a278630da0577fe6354008ae30239ac431ef47c Mon Sep 17 00:00:00 2001 From: lin-xianming <108489988+lin-xianming@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:33:53 -0400 Subject: [PATCH 13/21] Fix ffmpeg default record preset in reference config (#24451) Default was changed in b733355 --- docs/docs/configuration/advanced/reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/configuration/advanced/reference.md b/docs/docs/configuration/advanced/reference.md index 802f3d7856..b383673248 100644 --- a/docs/docs/configuration/advanced/reference.md +++ b/docs/docs/configuration/advanced/reference.md @@ -286,7 +286,7 @@ ffmpeg: # Optional: output args for detect streams (default: shown below) detect: -threads 2 -f rawvideo -pix_fmt yuv420p # Optional: output args for record streams (default: shown below) - record: preset-record-generic + record: preset-record-generic-audio-aac # Optional: Time in seconds to wait before ffmpeg retries connecting to the camera. (default: shown below) # If set too low, frigate will retry a connection to the camera's stream too frequently, using up the limited streams some cameras can allow at once # If set too high, then if a ffmpeg crash or camera stream timeout occurs, you could potentially lose up to a maximum of retry_interval second(s) of footage From 3941355051575097b87d4cc9e60f50e87b62ec7a Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:31:40 -0500 Subject: [PATCH 14/21] add note to mqtt docs to use ID rather than friendly_name (#24441) --- docs/docs/integrations/mqtt.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index a7a8740bd5..f40bb0784f 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -11,6 +11,12 @@ MQTT requires a network connection to your broker. This is typically local, but ::: +:::note + +Wherever a topic below includes a camera, mask, or zone name, use its `ID` from the config, not its `friendly_name`. For example, a camera with `friendly_name: "Back Yard"` and ID `back_yard` publishes to `frigate/back_yard/...`, not `frigate/Back Yard/...`. + +::: + ## General Frigate Topics ### `frigate/available` From 4e196516dd755b6b82e5d7732f22a86ee8bc4866 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:45:32 -0500 Subject: [PATCH 15/21] Tweaks (#24484) * fix long press on mobile in face and classification the listener was on the image only, so long pressing on any overlaid div/text area would cause iOS to select the text instead of adding the blue outline * improve navigation to and from explore when viewing a tracked object in explore from a classification card, triggers, or the detail stream, explore would open and show a single tracked object. on mobile (noted especially on iOS with frigate in HA), there is no obvious way to navigate back, so add a back button in its usual spot. also, when going back to the classification view from explore, it may not be obvious which thumbnail you were last viewing, so add a temporary blue outline around the card like review and explore already does * test tweaks --- web/e2e/specs/explore.spec.ts | 12 +++ web/e2e/specs/face-library.spec.ts | 101 ++++++++++++------ .../components/card/ClassificationCard.tsx | 86 +++++++++++---- .../overlay/detail/TrackingDetails.tsx | 1 + web/src/components/timeline/DetailStream.tsx | 1 + web/src/components/timeline/EventMenu.tsx | 5 +- web/src/views/search/SearchView.tsx | 54 +++++++--- web/src/views/settings/TriggerView.tsx | 2 + 8 files changed, 196 insertions(+), 66 deletions(-) diff --git a/web/e2e/specs/explore.spec.ts b/web/e2e/specs/explore.spec.ts index 442a6d8d88..4886a719fa 100644 --- a/web/e2e/specs/explore.spec.ts +++ b/web/e2e/specs/explore.spec.ts @@ -216,6 +216,18 @@ test.describe("Explore — content @high", () => { // Similarity search URL param // --------------------------------------------------------------------------- +test.describe("Explore: back button @high", () => { + test("direct visits do not show a back button", async ({ frigateApp }) => { + await frigateApp.goto("/explore?labels=person"); + await expect(frigateApp.page.getByLabel("Labels").first()).toBeVisible({ + timeout: 10_000, + }); + await expect( + frigateApp.page.getByRole("button", { name: "Go back" }), + ).toHaveCount(0); + }); +}); + test.describe("Explore — similarity search (desktop) @high", () => { test.skip( ({ frigateApp }) => frigateApp.isMobile, diff --git a/web/e2e/specs/face-library.spec.ts b/web/e2e/specs/face-library.spec.ts index e499178b7b..db3742a1cd 100644 --- a/web/e2e/specs/face-library.spec.ts +++ b/web/e2e/specs/face-library.spec.ts @@ -30,40 +30,51 @@ function groupedFacesMock() { }); } -async function installGroupedFaces(app: FrigateApp) { +const GROUPED_EVENT = { + id: GROUPED_EVENT_ID, + label: "person", + sub_label: null, + camera: "front_door", + start_time: 1775487131.3863528, + end_time: 1775487161.3863528, + false_positive: false, + zones: ["front_yard"], + thumbnail: null, + has_clip: true, + has_snapshot: true, + retain_indefinitely: false, + plus_id: null, + model_hash: "abc123", + detector_type: "cpu", + model_type: "ssd", + data: { + top_score: 0.92, + score: 0.92, + region: [0.1, 0.1, 0.5, 0.8], + box: [0.2, 0.15, 0.45, 0.75], + area: 0.18, + ratio: 0.6, + type: "object", + path_data: [], + }, +}; + +async function installGroupedFaces( + app: FrigateApp, + opts: { withEventIds?: boolean } = {}, +) { await app.api.install({ - events: [ - { - id: GROUPED_EVENT_ID, - label: "person", - sub_label: null, - camera: "front_door", - start_time: 1775487131.3863528, - end_time: 1775487161.3863528, - false_positive: false, - zones: ["front_yard"], - thumbnail: null, - has_clip: true, - has_snapshot: true, - retain_indefinitely: false, - plus_id: null, - model_hash: "abc123", - detector_type: "cpu", - model_type: "ssd", - data: { - top_score: 0.92, - score: 0.92, - region: [0.1, 0.1, 0.5, 0.8], - box: [0.2, 0.15, 0.45, 0.75], - area: 0.18, - ratio: 0.6, - type: "object", - path_data: [], - }, - }, - ], + events: [GROUPED_EVENT], faces: groupedFacesMock(), }); + + // api-mocker does not cover /api/event_ids, which the card needs to link to + // Explore. Registered after install so it takes precedence. + if (opts.withEventIds) { + await app.page.route("**/api/event_ids**", (route) => + route.fulfill({ json: [GROUPED_EVENT] }), + ); + } } async function openGroupedFaceDialog(app: FrigateApp): Promise { @@ -512,6 +523,34 @@ test.describe("FaceSelectionDialog @high", () => { }); }); +test.describe("Face Library: return from Explore @high", () => { + test("Explore back button returns to an outlined collection", async ({ + frigateApp, + }) => { + await installGroupedFaces(frigateApp, { withEventIds: true }); + await frigateApp.goto("/faces"); + + // Mobile opens the collection as a MobilePage, which has no dialog role + const card = frigateApp.page + .locator('img[src*="clips/faces/train/"]') + .first() + .locator("xpath=.."); + await card.click(); + await frigateApp.page.getByLabel("View in Explore").click(); + await expect(frigateApp.page).toHaveURL( + new RegExp(`/explore\\?event_id=${GROUPED_EVENT_ID}`), + ); + + const back = frigateApp.page.getByRole("button", { name: "Go back" }); + await expect(back).toBeVisible({ timeout: 5_000 }); + await back.click(); + await expect(frigateApp.page).toHaveURL(/\/faces/); + + await expect(card).toHaveClass(/outline-selected/, { timeout: 5_000 }); + await expect(card).not.toHaveClass(/outline-selected/, { timeout: 5_000 }); + }); +}); + test.describe("Face Library — mobile @high @mobile", () => { test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only"); diff --git a/web/src/components/card/ClassificationCard.tsx b/web/src/components/card/ClassificationCard.tsx index 4fed7e2385..b1c7b9e0a8 100644 --- a/web/src/components/card/ClassificationCard.tsx +++ b/web/src/components/card/ClassificationCard.tsx @@ -1,12 +1,20 @@ import { baseUrl } from "@/api/baseUrl"; import useContextMenu from "@/hooks/use-contextmenu"; +import { useOverlayState } from "@/hooks/use-overlay-state"; import { cn } from "@/lib/utils"; import { ClassificationItemData, ClassificationThreshold, ClassifiedEvent, } from "@/types/classification"; -import { forwardRef, useEffect, useMemo, useRef, useState } from "react"; +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; import { isDesktop, isIOS, isMobile, isMobileOnly } from "react-device-detect"; import { useTranslation } from "react-i18next"; import TimeAgo from "../dynamic/TimeAgo"; @@ -16,6 +24,7 @@ import { LuSearch, LuInfo } from "react-icons/lu"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { useNavigate } from "react-router-dom"; import { HiSquare2Stack } from "react-icons/hi2"; +import scrollIntoView from "scroll-into-view-if-needed"; import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay"; import { Dialog, @@ -85,9 +94,14 @@ export const ClassificationCard = forwardRef< // interaction + const cardRef = useRef(null); const imgRef = useRef(null); - useContextMenu(imgRef, () => { + useImperativeHandle(ref, () => cardRef.current!); + + // Listen on the whole card, since overlays cover most of the image + + useContextMenu(cardRef, () => { onClick(data, true); }); @@ -101,9 +115,9 @@ export const ClassificationCard = forwardRef< return (
{ - e.preventDefault(); - e.stopPropagation(); - onClick(data, true); - }} + style={isIOS ? { WebkitTouchCallout: "none" } : undefined} > setImageLoaded(true)} @@ -156,7 +158,7 @@ export const ClassificationCard = forwardRef<
)}
-
+
(null); + const [returnEventId, setReturnEventId] = useOverlayState( + "returnEventId", + ); + const [highlighted, setHighlighted] = useState(false); + + useEffect(() => { + if (!returnEventId || classifiedEvent?.id !== returnEventId) { + return; + } + + setReturnEventId(undefined, true); + setHighlighted(true); + }, [classifiedEvent?.id, returnEventId, setReturnEventId]); + + useEffect(() => { + if (!highlighted) { + return; + } + + if (cardRef.current) { + scrollIntoView(cardRef.current, { + block: "center", + behavior: "smooth", + scrollMode: "if-needed", + }); + } + + const timeout = setTimeout(() => setHighlighted(false), 3000); + return () => clearTimeout(timeout); + }, [highlighted]); + // If the component unmounts while the detail overlay is open, we need to // pop the history state that was pushed by useHistoryBack, otherwise it // leaves a stale entry that breaks back navigation. @@ -308,9 +345,10 @@ export function GroupedClassificationCard({ return ( <> - +
{ - navigate(`/explore?event_id=${classifiedEvent.id}`); + setReturnEventId(classifiedEvent.id, true); + navigate(`/explore?event_id=${classifiedEvent.id}`, { + state: { canGoBack: true }, + }); }} > diff --git a/web/src/components/overlay/detail/TrackingDetails.tsx b/web/src/components/overlay/detail/TrackingDetails.tsx index 1e6d3e6306..7e95cec656 100644 --- a/web/src/components/overlay/detail/TrackingDetails.tsx +++ b/web/src/components/overlay/detail/TrackingDetails.tsx @@ -845,6 +845,7 @@ export function TrackingDetails({
{event.data.recognized_license_plate} diff --git a/web/src/components/timeline/DetailStream.tsx b/web/src/components/timeline/DetailStream.tsx index 2bed0ab356..221b85c0e9 100644 --- a/web/src/components/timeline/DetailStream.tsx +++ b/web/src/components/timeline/DetailStream.tsx @@ -727,6 +727,7 @@ function EventList({
{event.data.recognized_license_plate} diff --git a/web/src/components/timeline/EventMenu.tsx b/web/src/components/timeline/EventMenu.tsx index 7696424c62..343cfe5913 100644 --- a/web/src/components/timeline/EventMenu.tsx +++ b/web/src/components/timeline/EventMenu.tsx @@ -135,7 +135,9 @@ export default function EventMenu({ { - navigate(`/explore?event_id=${event.id}`); + navigate(`/explore?event_id=${event.id}`, { + state: { canGoBack: true }, + }); }} > {t("details.item.button.viewInExplore")} @@ -177,6 +179,7 @@ export default function EventMenu({ else navigate( `/explore?search_type=similarity&event_id=${event.id}`, + { state: { canGoBack: true } }, ); }} > diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index 0becce1cab..279907a8b8 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -9,7 +9,7 @@ import { cn } from "@/lib/utils"; import { FrigateConfig } from "@/types/frigateConfig"; import { SearchFilter, SearchResult, SearchSource } from "@/types/search"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { isMobileOnly } from "react-device-detect"; +import { isDesktop, isMobileOnly } from "react-device-detect"; import { LuImage, LuSearchX, LuText } from "react-icons/lu"; import useSWR from "swr"; import ExploreView from "../explore/ExploreView"; @@ -32,7 +32,9 @@ import { TooltipPortal } from "@radix-ui/react-tooltip"; import SearchActionGroup from "@/components/filter/SearchActionGroup"; import { Trans, useTranslation } from "react-i18next"; import { use24HourTime } from "@/hooks/use-date-utils"; -import { useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { IoMdArrowRoundBack } from "react-icons/io"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; type SearchViewProps = { @@ -80,6 +82,10 @@ export default function SearchView({ }); const is24Hour = use24HourTime(config); const navigate = useNavigate(); + const location = useLocation(); + + // set by in-app links, since an iOS home screen app has no back gesture + const canGoBack = location.state?.canGoBack === true; const { data: exploreEvents } = useSWR( (!searchFilter || Object.keys(searchFilter).length === 0) && @@ -535,17 +541,39 @@ export default function SearchView({ isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0", )} > - {config?.semantic_search?.enabled && ( -
- + {(canGoBack || config?.semantic_search?.enabled) && ( +
+ {canGoBack && ( + + )} + {config?.semantic_search?.enabled && ( +
+ +
+ )}
)} diff --git a/web/src/views/settings/TriggerView.tsx b/web/src/views/settings/TriggerView.tsx index 57d76704ed..f5845b7337 100644 --- a/web/src/views/settings/TriggerView.tsx +++ b/web/src/views/settings/TriggerView.tsx @@ -574,6 +574,7 @@ export default function TriggerView({ Date: Tue, 29 Sep 2026 14:49:37 +1000 Subject: [PATCH 16/21] Detail XDNA2 community detector (#24480) * Update hardware.md Fix stray qualification * docs: add XDNA2 detector configuration * docs: add XDNA2 ZMQ model config --- docs/data/object_detectors_models.yaml | 35 +++++++++++++++++++++ docs/docs/configuration/object_detectors.md | 23 ++++++++++++++ docs/docs/frigate/hardware.md | 29 +++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/docs/data/object_detectors_models.yaml b/docs/data/object_detectors_models.yaml index 9d9aa40a1d..44d7471389 100644 --- a/docs/data/object_detectors_models.yaml +++ b/docs/data/object_detectors_models.yaml @@ -894,6 +894,41 @@ deepstack: api_url: http://:/v1/vision/detection type: deepstack api_timeout: 0.1 # seconds +xdna2: + title: AMD XDNA2 + models: + - key: yolov9 + label: YOLOv9 + recommended: true + download: |- + Prepare the model using the frigate-xdna setup instructions linked above. For local YOLO models, Frigate must have access to the same ONNX file bytes as the sidecar. The example below uses YOLOv9-C at 320x320. Frigate+ models may instead use the same `plus://MODEL_ID` in Frigate and the sidecar. + ui: |- + Navigate to **Settings > System > Detectors and model** and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://xdna:5555`. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | ------------------------------------------ | + | **Custom object detector model path** | `/config/models/yolov9-c-320.onnx` | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `320` | + | **Object detection model input height** | `320` | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `float` | + | **Object Detection Model Type** | `yolo-generic` | + yaml: |- + detectors: + xdna: + type: zmq + endpoint: tcp://xdna:5555 + + model: + model_type: yolo-generic + width: 320 + height: 320 + input_tensor: nchw + input_dtype: float + path: /config/models/yolov9-c-320.onnx + labelmap_path: /labelmap/coco-80.txt memryx: title: MemryX models: diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 602ebfcb85..c200488aba 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -29,6 +29,7 @@ Frigate supports multiple different detectors that work on different types of ha - [ROCm](#amdrocm-gpu-detector): ROCm can run on AMD Discrete GPUs to provide efficient object detection. - [ONNX](#onnx): ROCm will automatically be detected and used as a detector in the `-rocm` Frigate image when a supported ONNX model is configured. +- [XDNA2](#amd-xdna2): AMD Ryzen AI / XDNA2 NPUs can run object detection through the community-maintained `frigate-xdna` ZMQ sidecar. **Apple Silicon** @@ -508,6 +509,28 @@ To verify that the integration is working correctly, start Frigate and observe t # Community Supported Detectors +## AMD XDNA2 + +AMD Ryzen AI / XDNA2 NPUs can be used through the community-maintained +[frigate-xdna](https://github.com/mitchins/frigate-xdna) detector sidecar. +The sidecar runs separately from Frigate and connects using Frigate's ZMQ +detector interface. + +Currently qualified on **Ryzen AI Max 300 / Strix Halo**. Other XDNA2 devices +are not yet qualified; XDNA1 is unsupported. + +Follow the frigate-xdna setup instructions to prepare and start the sidecar +before starting Frigate. + +### Configuration {#configuration-xdna2} + +Using the detector config below will connect Frigate to the sidecar: + + + +The example assumes Frigate and the sidecar share a Docker network where the +sidecar is named `xdna`. + ## MemryX MX3 This detector is available for use with the MemryX MX3 accelerator M.2 module. Frigate supports the MX3 on compatible hardware platforms, providing efficient and high-performance object detection. diff --git a/docs/docs/frigate/hardware.md b/docs/docs/frigate/hardware.md index 1df3bb8368..d38d065443 100644 --- a/docs/docs/frigate/hardware.md +++ b/docs/docs/frigate/hardware.md @@ -70,6 +70,9 @@ Frigate supports multiple different detectors that work on different types of ha - [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete GPUs to provide efficient object detection - [Supports limited model architectures](../../configuration/object_detectors#amdrocm-gpu-detector) - Runs best on discrete AMD GPUs +- [XDNA2 (Ryzen AI)](#amd-xdna2): AMD XDNA2 NPU (sub-watt power AI/ML processor separate to the GPU) inside Strix and other "AI" branded AMD platforms + - Has only been tested with YOLOv9, in theory other graphs may be compiled too. + - Runs via ZMQ proxy which adds some latency, only recommended for local connection **Apple Silicon** @@ -296,6 +299,32 @@ The inference time of a rk3588 with all 3 cores enabled is typically 25-30 ms fo | ---------------- | ----------------------------------- | | yolov9-tiny | ~ 4 ms | +### AMD Ryzen AI / XDNA2 + +Frigate supports AMD XDNA2 NPUs through the community-maintained +frigate-xdna ZMQ sidecar. It works with stock Frigate and supports +Frigate+ models or compatible local YOLO ONNX models. Models are compiled +once on the target system and cached for subsequent use. + +Currently qualified on **Ryzen AI Max 300 / Strix Halo**. Other XDNA2 +devices are not yet qualified; XDNA1 is unsupported. + +Measured YOLOv9 detector latency on Strix Halo: + +| Model | 320 | 640 | +| ----- | ---: | ---: | +| YOLOv9-T | ~7.4 ms | unsupported | +| YOLOv9-S | ~9.0 ms | ~20.0 ms | +| YOLOv9-M | ~13.1 ms | ~34.4 ms | +| YOLOv9-C | ~14.1 ms | ~35.2 ms | +| YOLOv9-E | ~69.4 ms | ~224.8 ms | + +**YOLOv9-C at 320 is the recommended quality/performance balance.** +C at 640 is also usable where the lower throughput is acceptable. + +Setup, model preparation, and compatibility details are available +[in the frigate-xdna documentation](https://github.com/mitchins/frigate-xdna). + ## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version) This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity. From e37041c879e150012cf8892ec0ee2f137998a6b1 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:02:45 -0500 Subject: [PATCH 17/21] delete timeline entries when expiring events without clips (#24510) Events without a clip were deleted once their snapshot expired, but their timeline rows were only removed when clip retention expired, so they were orphaned indefinitely. The timeline cache also held entries forever for events that ended without ever being saved. --- frigate/events/cleanup.py | 1 + frigate/test/test_timeline.py | 66 +++++++++++++++++++++++++++++++++++ frigate/timeline.py | 3 ++ 3 files changed, 70 insertions(+) create mode 100644 frigate/test/test_timeline.py diff --git a/frigate/events/cleanup.py b/frigate/events/cleanup.py index 88b6a9eda5..1369ab463d 100644 --- a/frigate/events/cleanup.py +++ b/frigate/events/cleanup.py @@ -365,6 +365,7 @@ class EventCleanup(threading.Thread): chunk = ids_to_delete[i : i + CHUNK_SIZE] logger.debug(f"Deleting {len(chunk)} events from the database") Event.delete().where(Event.id << chunk).execute() + Timeline.delete().where(Timeline.source_id << chunk).execute() # embeddings are always cleaned up, even when semantic search # is disabled, so that they don't outlive their events diff --git a/frigate/test/test_timeline.py b/frigate/test/test_timeline.py new file mode 100644 index 0000000000..a438528ab1 --- /dev/null +++ b/frigate/test/test_timeline.py @@ -0,0 +1,66 @@ +import unittest +from unittest.mock import MagicMock, patch + +from frigate.events.types import EventStateEnum +from frigate.models import Timeline +from frigate.timeline import TimelineProcessor + + +def make_event(has_clip: bool, has_snapshot: bool) -> dict: + return { + "id": "event-1", + "frame_time": 1000.0, + "box": [0, 0, 10, 10], + "region": [0, 0, 100, 100], + "label": "car", + "sub_label": None, + "score": 0.8, + "has_clip": has_clip, + "has_snapshot": has_snapshot, + "current_zones": [], + "stationary": False, + "attributes": {}, + "current_attributes": [], + } + + +class TestTimelineProcessor(unittest.TestCase): + def setUp(self): + camera_config = MagicMock() + camera_config.detect.width = 1280 + camera_config.detect.height = 720 + config = MagicMock() + config.cameras.get.return_value = camera_config + self.processor = TimelineProcessor(config, MagicMock(), MagicMock()) + + @patch.object(Timeline, "insert") + def test_unsaved_event_writes_no_timeline_rows(self, insert): + event = make_event(has_clip=False, has_snapshot=False) + self.processor.handle_object_detection( + "front", EventStateEnum.start, None, event + ) + self.processor.handle_object_detection( + "front", EventStateEnum.end, event, event + ) + + insert.assert_not_called() + self.assertEqual(self.processor.pre_event_cache, {}) + + @patch.object(Timeline, "insert") + def test_cached_entries_flush_when_event_is_saved(self, insert): + start = make_event(has_clip=False, has_snapshot=False) + self.processor.handle_object_detection( + "front", EventStateEnum.start, None, start + ) + insert.assert_not_called() + + end = make_event(has_clip=True, has_snapshot=False) + self.processor.handle_object_detection("front", EventStateEnum.end, start, end) + + class_types = [c.args[0][Timeline.class_type] for c in insert.call_args_list] + self.assertEqual(class_types, ["visible", "gone"]) + self.assertEqual(self.processor.pre_event_cache, {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/timeline.py b/frigate/timeline.py index d82f17cb7d..cc864c7bc3 100644 --- a/frigate/timeline.py +++ b/frigate/timeline.py @@ -191,6 +191,9 @@ class TimelineProcessor(threading.Thread): timeline_entry[Timeline.class_type] = "gone" self.insert_or_save(timeline_entry, prev_event_data, event_data) + # drop entries for events that ended without being saved + self.pre_event_cache.pop(event_id, None) + def handle_api_entry( self, camera: str, From b749b0bbc5498f00ba5293d01be1d458d7acb9bd Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 30 Sep 2026 07:29:45 -0600 Subject: [PATCH 18/21] Apply data check to all cases when validating a segment (#24515) --- frigate/record/maintainer.py | 61 ++++++++++++++--------------- frigate/test/test_maintainer.py | 68 ++++++++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 34 deletions(-) diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index 7f9dbc19da..9ce504e34f 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -388,6 +388,22 @@ class RecordingMaintainer(threading.Thread): RecordingsDataTypeEnum.valid.value, ) + # assume that empty means the relevant recording info has not been received yet + camera_info = self.object_recordings_info[camera] + most_recently_processed_frame_time = ( + camera_info[-1][0] if len(camera_info) > 0 else 0 + ) + + # ensure delayed segment info does not lead to lost segments, every + # retention decision below depends on complete stats for the segment + if ( + datetime.datetime.fromtimestamp( + most_recently_processed_frame_time + ).astimezone(datetime.UTC) + < end_time + ): + return None + record_config = self.config.cameras[camera].record segment_stats: SegmentInfo | None = None highest = None @@ -401,37 +417,22 @@ class RecordingMaintainer(threading.Thread): # we should first just check if this segment matches that # and avoid any DB calls if highest is not None: - # assume that empty means the relevant recording info has not been received yet - camera_info = self.object_recordings_info[camera] - most_recently_processed_frame_time = ( - camera_info[-1][0] if len(camera_info) > 0 else 0 + record_mode = ( + RetainModeEnum.all if highest == "continuous" else RetainModeEnum.motion ) + segment_stats = self.segment_stats(camera, start_time, end_time) - # ensure delayed segment info does not lead to lost segments - if ( - datetime.datetime.fromtimestamp( - most_recently_processed_frame_time - ).astimezone(datetime.UTC) - >= end_time - ): - record_mode = ( - RetainModeEnum.all - if highest == "continuous" - else RetainModeEnum.motion + # Here we only check if we should move the segment based on non-object recording retention + # we will always want to check for overlapping review items below before dropping the segment + if not segment_stats.should_discard_segment(record_mode): + return await self.move_segment( + camera, + start_time, + end_time, + duration, + cache_path, + segment_stats, ) - segment_stats = self.segment_stats(camera, start_time, end_time) - - # Here we only check if we should move the segment based on non-object recording retention - # we will always want to check for overlapping review items below before dropping the segment - if not segment_stats.should_discard_segment(record_mode): - return await self.move_segment( - camera, - start_time, - end_time, - duration, - cache_path, - segment_stats, - ) # we fell through the continuous / motion check, so we need to check the review items # if the cached segment overlaps with the review items: @@ -487,10 +488,6 @@ class RecordingMaintainer(threading.Thread): # continuous/motion retention (either disabled or segment_stats said # discard), so waiting longer just fills the cache. else: - camera_info = self.object_recordings_info[camera] - most_recently_processed_frame_time = ( - camera_info[-1][0] if len(camera_info) > 0 else 0 - ) retain_cutoff = datetime.datetime.fromtimestamp( most_recently_processed_frame_time - record_config.event_pre_capture ).astimezone(datetime.UTC) diff --git a/frigate/test/test_maintainer.py b/frigate/test/test_maintainer.py index 715cd5a1a1..5d7067ba7e 100644 --- a/frigate/test/test_maintainer.py +++ b/frigate/test/test_maintainer.py @@ -1,7 +1,7 @@ import datetime import sys import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch # Mock complex imports before importing maintainer, saving originals so we can # restore them after import and avoid polluting sys.modules for other tests. @@ -16,7 +16,7 @@ for name in _MOCKED_MODULES: sys.modules[name] = MagicMock() # Now import the class under test -from frigate.config import FrigateConfig # noqa: E402 +from frigate.config import FrigateConfig, RetainModeEnum # noqa: E402 from frigate.record.maintainer import RecordingMaintainer # noqa: E402 # Restore original modules (or remove mock if there was no original) @@ -115,6 +115,70 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase): self.assertIsNone(result) maintainer.drop_segment.assert_called_once_with(cache_path) + async def test_defers_review_overlap_segment_until_metadata_catches_up(self): + # Regression: a segment overlapping an active_objects review must not + # be dropped while detection metadata lags behind the segment end, + # the missing frames may hold the active objects (or continuous + # retention would keep it anyway). + config = MagicMock(spec=FrigateConfig) + + camera_config = MagicMock() + camera_config.record.enabled = True + camera_config.record.continuous.days = 7 + camera_config.record.motion.days = 0 + camera_config.record.alerts.retain.mode = RetainModeEnum.active_objects + camera_config.record.get_review_pre_capture.return_value = 5 + camera_config.record.get_review_post_capture.return_value = 5 + config.cameras = {"test_cam": camera_config} + + stop_event = MagicMock() + maintainer = RecordingMaintainer(config, stop_event) + + now = datetime.datetime.now(datetime.UTC) + start_time = now - datetime.timedelta(seconds=20) + end_time = now - datetime.timedelta(seconds=10) + cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4" + + maintainer.end_time_cache = {cache_path: (end_time, 10.0)} + # Metadata has only reached partway into the segment. + maintainer.object_recordings_info["test_cam"] = [ + (end_time.timestamp() - 8, [], [], []) + ] + maintainer.audio_recordings_info["test_cam"] = [] + + maintainer.drop_segment = MagicMock() + maintainer.move_segment = AsyncMock(return_value=None) + maintainer.recordings_publisher = MagicMock() + + review = MagicMock() + review.severity = "alert" + review.start_time = start_time.timestamp() - 30 + review.end_time = None + + result = await maintainer.validate_and_move_segment( + "test_cam", + reviews=[review], + recording={"start_time": start_time, "cache_path": cache_path}, + ) + + self.assertIsNone(result) + maintainer.drop_segment.assert_not_called() + maintainer.move_segment.assert_not_awaited() + + # Once metadata passes the segment end, continuous retention keeps it. + maintainer.object_recordings_info["test_cam"].append( + (now.timestamp(), [], [], []) + ) + + await maintainer.validate_and_move_segment( + "test_cam", + reviews=[review], + recording={"start_time": start_time, "cache_path": cache_path}, + ) + + maintainer.drop_segment.assert_not_called() + maintainer.move_segment.assert_awaited_once() + async def test_expire_stale_recordings_info_drops_only_absent_cameras(self): config = MagicMock(spec=FrigateConfig) config.cameras = {} From 160d2130255494ac75fce1a15222f9815c7d5f36 Mon Sep 17 00:00:00 2001 From: 007hacky007 <007hacky007@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:36:39 +0200 Subject: [PATCH 19/21] Add frigate-abr to third party extensions (#24526) --- docs/docs/integrations/third_party_extensions.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/docs/integrations/third_party_extensions.md b/docs/docs/integrations/third_party_extensions.md index 64b174a042..69a803f5c2 100644 --- a/docs/docs/integrations/third_party_extensions.md +++ b/docs/docs/integrations/third_party_extensions.md @@ -27,6 +27,10 @@ The [Advanced Camera Card](https://card.camera/#/README) is a Home Assistant das It supports automatically setting the sub labels in Frigate for person objects that are detected and recognized. This is a fork (with fixed errors and new features) of [original Double Take](https://github.com/jakowenko/double-take) project which, unfortunately, isn't being maintained by author. +## [frigate-abr](https://github.com/007hacky007/frigate-abr) + +[frigate-abr](https://github.com/007hacky007/frigate-abr) is a drop-in Docker image of Frigate that adds adaptive bitrate (ABR) playback for recordings: a sidecar transcodes footage to lower quality tiers on demand, for reviewing over slow remote connections. Segments are transcoded when played and cached, so no additional stream is recorded. Frigate itself is not modified. + ## [Frigate Notify](https://github.com/0x2142/frigate-notify) [Frigate Notify](https://github.com/0x2142/frigate-notify) is a simple app designed to send notifications from Frigate to your favorite platforms. Intended to be used with standalone Frigate installations - Home Assistant not required, MQTT is optional but recommended. From 17a8efa09cc8f481f595917df58a8043b83e9306 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:56:04 -0500 Subject: [PATCH 20/21] Fix camera name collision bypassing admin check (#24516) * don't let camera names waive the admin check on non-camera routes The global admin guard skipped the admin check for any request whose first path segment matched a configured camera name, without looking at which route actually handled it. A camera named `faces`, `lpr`, `audio`, or `classification` let viewers reach the face, LPR, audio transcription, and classification endpoints that rely only on the global guard. The exemption now also requires the matched route to be a `/{camera_name}` route, so camera routes behave exactly as before. * add test --- frigate/api/auth.py | 29 ++++++++++--------------- frigate/test/http_api/base_http_test.py | 6 +++-- frigate/test/http_api/test_http_app.py | 19 ++++++++++++++++ 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/frigate/api/auth.py b/frigate/api/auth.py index fa51d6f328..e0090e32cd 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -127,23 +127,18 @@ def require_admin_by_default(): if path.startswith(EXEMPT_PREFIXES): return - # Dynamic camera path exemption: - # Any path whose first segment matches a configured camera name should - # bypass the global admin requirement. These endpoints enforce access - # via route-level dependencies (e.g. require_camera_access) to ensure - # per-camera authorization. This allows non-admin authenticated users - # (e.g. viewer role) to access camera-specific resources without - # needing admin privileges. - try: - if path.startswith("/"): - first_segment = path.split("/", 2)[1] - if ( - first_segment - and first_segment in request.app.frigate_config.cameras - ): - return - except Exception: - pass + # Camera routes enforce per-camera access via route-level dependencies + # (e.g. require_camera_access). Match on the route template, not the raw + # path, so a camera named like another namespace (e.g. "faces") can't + # waive the admin check for that namespace's routes. + route = request.scope.get("route") + if ( + route is not None + and route.path.startswith("/{camera_name}") + and request.path_params.get("camera_name") + in request.app.frigate_config.cameras + ): + return # For all other paths, require admin role # Internal port requests have admin role set automatically diff --git a/frigate/test/http_api/base_http_test.py b/frigate/test/http_api/base_http_test.py index 32d110962e..e382d373df 100644 --- a/frigate/test/http_api/base_http_test.py +++ b/frigate/test/http_api/base_http_test.py @@ -144,7 +144,9 @@ class BaseTestHttp(unittest.TestCase): except OSError: pass - def create_app(self, stats=None, event_metadata_publisher=None): + def create_app( + self, stats=None, event_metadata_publisher=None, enforce_default_admin=False + ): from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user app = create_fastapi_app( @@ -158,7 +160,7 @@ class BaseTestHttp(unittest.TestCase): event_metadata_publisher, None, DebugReplayManager(), - enforce_default_admin=False, + enforce_default_admin=enforce_default_admin, ) # Default test mocks for authentication diff --git a/frigate/test/http_api/test_http_app.py b/frigate/test/http_api/test_http_app.py index ef5b99ad08..66618b26ac 100644 --- a/frigate/test/http_api/test_http_app.py +++ b/frigate/test/http_api/test_http_app.py @@ -47,6 +47,25 @@ class TestHttpApp(BaseTestHttp): assert response.status_code == 200 assert response.json()["front_door"]["usage_percent"] == 25.0 + def test_camera_name_collision_keeps_admin_default(self): + self.minimal_config["cameras"]["faces"] = self.minimal_config["cameras"].pop( + "front_door" + ) + app = super().create_app(enforce_default_admin=True) + viewer = {"remote-user": "viewer", "remote-role": "viewer"} + + with AuthTestClient(app) as client: + assert client.get("/faces", headers=viewer).status_code == 403 + assert client.get("/faces").status_code == 200 + assert ( + client.post("/faces/train/person/classify", headers=viewer).status_code + == 403 + ) + + # Camera routes for the same name stay reachable by viewers + response = client.get("/faces/recordings/summary", headers=viewer) + assert response.status_code == 200 + def test_config_set_in_memory_replaces_objects_track_list(self): self.minimal_config["cameras"]["front_door"]["objects"] = { "track": ["person", "car"], From 321e78e65b95af4dafb5828983e1e406ebcc2fd5 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:56:27 -0500 Subject: [PATCH 21/21] Fix record status stuck offline after ffmpeg restart (#24522) * fix record status never returning online after a record ffmpeg restart The record ffmpeg restart paths sent `offline` directly, so the cached record status still read `online` and the recovery was never published. Detect and record also shared one resend timestamp, and detect's resend always ran first, so record's periodic resend never fired either. Record's offline now goes through the cache and each status has its own timestamp. * don't publish record online when the record process has exited --- frigate/video/ffmpeg.py | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/frigate/video/ffmpeg.py b/frigate/video/ffmpeg.py index d2f60c0aea..67c0ac0e5d 100644 --- a/frigate/video/ffmpeg.py +++ b/frigate/video/ffmpeg.py @@ -18,6 +18,7 @@ from frigate.comms.recordings_updater import ( RecordingsDataTypeEnum, ) from frigate.config import CameraConfig, LoggerConfig +from frigate.config.camera.ffmpeg import CameraRoleEnum from frigate.config.camera.updater import ( CameraConfigUpdateEnum, CameraConfigUpdateSubscriber, @@ -181,27 +182,38 @@ class CameraWatchdog(threading.Thread): # Status caching to reduce message volume self._last_detect_status: str | None = None self._last_record_status: str | None = None - self._last_status_update_time: float = 0.0 + self._last_detect_status_update_time: float = 0.0 + self._last_record_status_update_time: float = 0.0 def _send_detect_status(self, status: str, now: float) -> None: """Send detect status only if changed or retry_interval has elapsed.""" if ( status != self._last_detect_status - or (now - self._last_status_update_time) >= self.sleeptime + or (now - self._last_detect_status_update_time) >= self.sleeptime ): self.requestor.send_data(f"{self.config.name}/status/detect", status) self._last_detect_status = status - self._last_status_update_time = now + self._last_detect_status_update_time = now def _send_record_status(self, status: str, now: float) -> None: """Send record status only if changed or retry_interval has elapsed.""" if ( status != self._last_record_status - or (now - self._last_status_update_time) >= self.sleeptime + or (now - self._last_record_status_update_time) >= self.sleeptime ): self.requestor.send_data(f"{self.config.name}/status/record", status) self._last_record_status = status - self._last_status_update_time = now + self._last_record_status_update_time = now + + def _send_roles_offline(self, roles: list[CameraRoleEnum], now: float) -> None: + """Send offline status for each role of a restarted ffmpeg process.""" + for role in roles: + if role == CameraRoleEnum.record: + self._send_record_status("offline", now) + else: + self.requestor.send_data( + f"{self.config.name}/status/{role.value}", "offline" + ) def _check_config_updates(self) -> dict[str, list[str]]: """Check for config updates and return the update dict.""" @@ -478,10 +490,7 @@ class CameraWatchdog(threading.Thread): ffmpeg_process=p["process"], ) - for role in p["roles"]: - self.requestor.send_data( - f"{self.config.name}/status/{role.value}", "offline" - ) + self._send_roles_offline(p["roles"], now) self.record_grace_until = now_utc + timedelta( seconds=RECORD_GRACE_SECONDS @@ -490,16 +499,14 @@ class CameraWatchdog(threading.Thread): continue elif not stale: - self._send_record_status("online", now) + if poll is None: + self._send_record_status("online", now) p["latest_segment_time"] = self.latest_cache_segment_time if poll is None: continue - for role in p["roles"]: - self.requestor.send_data( - f"{self.config.name}/status/{role.value}", "offline" - ) + self._send_roles_offline(p["roles"], now) p["logpipe"].dump() p["process"] = start_or_restart_ffmpeg(