Compare commits

...
Author SHA1 Message Date
Nicolas Mowen c6d8f067be Improve report context management 2026-09-29 10:18:47 -06:00
Nicolas Mowen 7767f67c67 Cleanup and fixes 2026-09-29 10:13:04 -06:00
Nicolas Mowen cdda89a11e Integrate state changes with review items 2026-09-29 09:47:36 -06:00
Hosted WeblateandJosh Hawkins d092189b8e Update translation files
CI / AMD64 Build (push) Waiting to run
CI / AMD64 Smoke Test (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 6d377e56e4 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
b13164cd3d Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 93.5% (247 of 264 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 93.5% (247 of 264 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: GuoQing Liu <842607283@qq.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/common
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 2cdb10a96a Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
6051374852 Translated using Weblate (Urdu)
Currently translated at 10.0% (13 of 129 strings)

Translated using Weblate (Urdu)

Currently translated at 27.6% (138 of 500 strings)

Translated using Weblate (Urdu)

Currently translated at 6.2% (8 of 129 strings)

Translated using Weblate (Urdu)

Currently translated at 16.0% (80 of 500 strings)

Co-authored-by: Abdullah Kaleem <abdullahkaleemlive@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mohsin <mohsinrafiq@ymail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/ur/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/ur/
Translation: Frigate NVR/audio
Translation: Frigate NVR/objects
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins abe99e5838 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 698f3c1904 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 22a21c761d Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translation: Frigate NVR/views-settings
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins b5613ca06c Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins dfd425655e Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translation: Frigate NVR/views-settings
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 19a7f35c00 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 8266a3d9dd Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 2755f7c8c3 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
6259872314 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Spanish)

Currently translated at 91.8% (697 of 759 strings)

Translated using Weblate (Spanish)

Currently translated at 93.8% (477 of 508 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (35 of 35 strings)

Translated using Weblate (Spanish)

Currently translated at 97.4% (115 of 118 strings)

Translated using Weblate (Spanish)

Currently translated at 95.7% (113 of 118 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Gerard Ricart Castells <gerard.ricart@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jaime Martin <jycmarting@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
bd4f7a3656 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Dutch)

Currently translated at 93.2% (708 of 759 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Dutch)

Currently translated at 93.2% (708 of 759 strings)

Translated using Weblate (Dutch)

Currently translated at 95.4% (485 of 508 strings)

Translated using Weblate (Dutch)

Currently translated at 97.1% (34 of 35 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Herik <wvdh2002@hotmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
0ea571abfe Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Indonesian)

Currently translated at 100.0% (1364 of 1364 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (119 of 119 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Arif Budiman <arifpedia@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins bf032bc92a Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 8b035e36d3 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins b7fae3bafd Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins ae88705b88 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins c5f979d522 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
0ea9c96f89 Translated using Weblate (Vietnamese)
Currently translated at 0.9% (5 of 508 strings)

Translated using Weblate (Vietnamese)

Currently translated at 80.7% (214 of 265 strings)

Translated using Weblate (Vietnamese)

Currently translated at 86.4% (432 of 500 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: voquangminh <voquangminh90@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/vi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/vi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/vi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins ca6a85be29 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 6637103b58 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
19a378af43 Translated using Weblate (Catalan)
Currently translated at 99.4% (1366 of 1374 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (277 of 277 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1366 of 1366 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Catalan)

Currently translated at 100.0% (1364 of 1364 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Eduardo Pastor Fernández <123eduardoneko123@gmail.com>
Co-authored-by: Gerard Ricart Castells <gerard.ricart@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: anton garcias <isaga.percompartir@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/ca/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 3e075885f7 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 8858f06624 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
f6e119c89e Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Ukrainian)

Currently translated at 53.6% (407 of 759 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (35 of 35 strings)

Translated using Weblate (Ukrainian)

Currently translated at 98.3% (116 of 118 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (264 of 264 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: IvanKovalenko32 <koval.vana123123@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins 32d15f2cbd Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
94146e2051 Translated using Weblate (Romanian)
Currently translated at 100.0% (277 of 277 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (1366 of 1366 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Romanian)

Currently translated at 100.0% (1364 of 1364 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: lukasig <lukasig@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/ro/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
f08e880099 Translated using Weblate (Russian)
Currently translated at 85.7% (30 of 35 strings)

Translated using Weblate (Russian)

Currently translated at 94.9% (112 of 118 strings)

Translated using Weblate (Russian)

Currently translated at 98.4% (261 of 265 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Russian)

Currently translated at 94.3% (249 of 264 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: internetson <sockmancore@gmail.com>
Co-authored-by: Алексей Лисевский <yokainfromabyss@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/ru/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/ru/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/ru/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
e155f67f22 Translated using Weblate (Estonian)
Currently translated at 100.0% (119 of 119 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/et/
Translation: Frigate NVR/views-live
2026-09-29 08:53:42 -05:00
c26eb023fc Translated using Weblate (Danish)
Currently translated at 69.6% (348 of 500 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jan Eenholt <jan@eenholt.dk>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/da/
Translation: Frigate NVR/audio
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins bd75d4bba0 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
c95d7f1808 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (46 of 46 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (62 of 62 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (141 of 141 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (277 of 277 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (1366 of 1366 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (68 of 68 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Ítalo Peixoto <italoopeixoto@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/pt_BR/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-replay
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Hosted WeblateandJosh Hawkins e36c7ae118 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
008a02eca8 Translated using Weblate (Turkish)
Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (1374 of 1374 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (1374 of 1374 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (141 of 141 strings)

Translated using Weblate (Turkish)

Currently translated at 99.7% (1370 of 1374 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (10 of 10 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (265 of 265 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (277 of 277 strings)

Translated using Weblate (Turkish)

Currently translated at 99.4% (1366 of 1374 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (10 of 10 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (265 of 265 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (46 of 46 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (62 of 62 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (66 of 66 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (141 of 141 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (277 of 277 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (1366 of 1366 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (49 of 49 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (6 of 6 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (68 of 68 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (10 of 10 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (264 of 264 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (46 of 46 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (62 of 62 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (24 of 24 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (25 of 25 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (141 of 141 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (277 of 277 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (1366 of 1366 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (129 of 129 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (35 of 35 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (2 of 2 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (74 of 74 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (10 of 10 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (500 of 500 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (264 of 264 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (500 of 500 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (62 of 62 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (24 of 24 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (1364 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Turkish)

Currently translated at 96.8% (1321 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Turkish)

Currently translated at 95.3% (1300 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 95.2% (1299 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 86.1% (1175 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 85.2% (1163 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 58.3% (157 of 269 strings)

Translated using Weblate (Turkish)

Currently translated at 84.6% (1155 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 84.6% (1155 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 82.7% (1129 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 58.3% (157 of 269 strings)

Translated using Weblate (Turkish)

Currently translated at 74.7% (1019 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (49 of 49 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (35 of 35 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (74 of 74 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (46 of 46 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (62 of 62 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (66 of 66 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (24 of 24 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (25 of 25 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (141 of 141 strings)

Translated using Weblate (Turkish)

Currently translated at 58.3% (157 of 269 strings)

Translated using Weblate (Turkish)

Currently translated at 69.5% (949 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (49 of 49 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (68 of 68 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (129 of 129 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (35 of 35 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (74 of 74 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (264 of 264 strings)

Translated using Weblate (Turkish)

Currently translated at 91.5% (695 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 86.5% (122 of 141 strings)

Translated using Weblate (Turkish)

Currently translated at 48.6% (663 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (49 of 49 strings)

Translated using Weblate (Turkish)

Currently translated at 94.4% (137 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 91.4% (118 of 129 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (74 of 74 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (500 of 500 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (264 of 264 strings)

Translated using Weblate (Turkish)

Currently translated at 31.2% (237 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 31.2% (237 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 31.2% (237 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 56.1% (285 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 56.1% (285 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 56.1% (285 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 48.6% (663 of 1364 strings)

Translated using Weblate (Turkish)

Currently translated at 94.4% (137 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (74 of 74 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Turkish)

Currently translated at 1.5% (1 of 66 strings)

Translated using Weblate (Turkish)

Currently translated at 54.1% (13 of 24 strings)

Translated using Weblate (Turkish)

Currently translated at 16.0% (122 of 759 strings)

Translated using Weblate (Turkish)

Currently translated at 25.3% (129 of 508 strings)

Translated using Weblate (Turkish)

Currently translated at 86.5% (122 of 141 strings)

Translated using Weblate (Turkish)

Currently translated at 57.9% (156 of 269 strings)

Translated using Weblate (Turkish)

Currently translated at 48.6% (665 of 1366 strings)

Translated using Weblate (Turkish)

Currently translated at 80.6% (96 of 119 strings)

Translated using Weblate (Turkish)

Currently translated at 94.4% (137 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (68 of 68 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (35 of 35 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (74 of 74 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (500 of 500 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (264 of 264 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Murat Odabasi <murat12@gmail.com>
Co-authored-by: pcislocked <git@pcislocked.net>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-auth/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-filter/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-icons/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-groups/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-configeditor/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-recording/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-search/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/tr/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Groups
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
Translation: Frigate NVR/components-auth
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-filter
Translation: Frigate NVR/components-icons
Translation: Frigate NVR/components-player
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-configeditor
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-recording
Translation: Frigate NVR/views-replay
Translation: Frigate NVR/views-search
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-09-29 08:53:42 -05:00
Nicolas MowenandGitHub dc97f294a0 Support newer and cleaner llama.cpp embeddings api (#24499)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-09-28 15:22:59 -05:00
Josh HawkinsandGitHub a1c8bf99a7 Miscellaneous fixes (#24498)
* fix auto quality recovery after a downswitch

The downswitch callback armed the upswitch probe, but `triggerDownswitch` reset the stall history right after the callback returned, which disarmed it again. Auto quality stayed on the sub stream until the next chunk boundary no matter how much the connection recovered. The governor now arms the probe itself after the reset.

The chunk boundary effect also ran on mount, so when coverage was already cached it immediately undid the low quality cold start the seed effect had just picked. It now only runs when the chunk actually changes.

* fix recording playback quality switches and silent codec failures

A quality switch changes `bufferLength` a commit before the new source arrives, and since it was a dependency of the hls.js setup effect, the player rebuilt once on the outgoing playlist (jumping back to its original `startPosition`) and again on the new one. The buffer length now updates the running instance's config instead.

A fatal codec error with no lower quality stream to fall back to did nothing at all, so playback just sat there with no message. It now shows the playback failure toast, which is also limited to once per source since hls.js and the video element can both report the same failure.

* retry failed WebRTC probes and skip offline streams

The connectivity probe only ever tried the first go2rtc stream and cached its result for the whole page session, so a single offline camera at the top of the go2rtc config marked WebRTC unreachable for every camera until a reload, as did any brief network hiccup. The probe now starts with the stream being viewed and moves on to the next one when go2rtc reports that it can't open the stream's source. A failed result is only reused for 30 seconds, and it's retried on the next mount or when the page becomes visible again.

* fix two-way talk on cameras with AAC audio

The mic button was enabled whenever WebRTC was globally available, but the live view only switched to the WebRTC player when the stream itself qualified for WebRTC, and AAC playback audio disqualifies it. On most cameras the mic showed as on while nothing was sent. Two-way talk only needs the stream's video to connect since the backchannel is sent, not received, so AAC playback audio no longer blocks it. The mic is also turned off when a stream switch makes talk unavailable.

* keep Frigate+ model references when saving the models section

`/api/config` served a Frigate+ model's path as the resolved `/config/model_cache/<id>` file, and since the models list is saved whole, editing any model in the settings UI wrote that cache path back to the config in place of `plus://<id>`. After a restart the model loaded as a custom model with the default labelmap. The config API now reports the `plus://<id>` reference the model was configured with, and the models section drops the fields the Frigate+ model info supplies (size, tensor, pixel format, dtype, and type) instead of pinning them in the config.

* allow models to share shareable detection hardware

The hardware picker treated every device another model listed as taken, so a second model couldn't pick an Intel GPU or the CPU that the first one already used. The backend only rejects reuse of devices that can't be shared (Coral, MemryX), so the picker now matches that and only marks exclusive units as claimed.

* fix model card state and camera counts in the models editor

Model cards were keyed by index, so deleting one handed its state (such as the selected model source tab) to the card after it. Cards are keyed by scene now, which is unique per model.

The camera count on each card also ignored the backend's fallback to the `all` model, so a camera whose detect scene had no model of its own wasn't counted anywhere. It's counted under `all` now, which also feeds the recommended detector count.

* share a unit's temperature across repeated detector devices

Detector temperatures were matched to units by counting detectors of each type, so a device listed twice to run a second inference process (`hailo:PCIe` and `hailo:PCIe#2`) showed the next unit's temperature, or none at all. Distinct devices are numbered now and repeats share their unit's reading.

* update monitored hardware after a runtime config swap

`swap_runtime_config` rebound the stats emitter to the new config but not its `HardwareStats`, which kept polling hardware for the old config and applied camera updates to the discarded object. It now follows the swap along with its camera update subscriber.

* time out model downloads that never respond

`download_from_url` had no timeout, so a proxy or server that accepted the connection and never answered hung the download forever, including runtime downloads during startup. Connect and read timeouts now fail it like any other download error. The read timeout applies per socket read, so large models still finish.

* resolve segment start times in segment order

A camera stream's cached segments are probed concurrently, and each one chained its start off `last_segment_end` as soon as its own probe finished. When segments backed up in the cache and a later probe finished first, it chained off the wrong segment and the earlier one then moved `last_segment_end` backwards, so rows lost their exact adjacency. Probes still run concurrently, but each segment now waits for the one before it to settle its start before resolving its own.

* plan exports from the same coverage the vod route serves

The vod manifest nulls video-only glitch rows on audio-bearing streams, but exports planned their stream runs from the raw coverage, so a glitch row could produce a mixed-stream file or a 404 that failed the export. `null_audio_glitches` now works out each stream's audio composition itself, and exports go through it like the manifest and its realized timelines do.

An unstaged auto export also paged its playlist and chapters over main whenever main had any rows in range, even when the manifest served the range from sub and main only contributed glitches or slivers at the edges. It now reads the rows of the stream its single run actually uses.

* keep staged export chapters aligned across stream hand-offs

Each staged run of a mixed-stream export is rendered from its own pinned vod playlist, and that playlist's first clip snaps back to the preceding keyframe, so every staged file runs up to a GOP longer than its slice of the merged timeline. Chapters were placed on the merged timeline, so they drifted further from the video at every hand-off. Chapter windows for staged exports are now planned the same way each run's playlist is, carrying that keyframe lead-in into the offsets.

* clarify which hardware units only one model can use

* add e2e tests for shareable hardware and Frigate+ model saves

* only show the path field for a Frigate+ model without an API key

Without `PLUS_API_KEY` the models editor has no Frigate+ tab, so a `plus://` model showed every custom model field. The size, format, type, and labelmap fields are all supplied by the Frigate+ model info and ignored for a Frigate+ model, so editing them did nothing. Only the path is shown now, which still lets the model be switched to a custom one.

* keep a configured input_dtype when saving a Frigate+ model

The backend only overwrites `input_dtype` when the Frigate+ model info supplies `inputDataType`, which older models don't, so a configured dtype still matters for them. Saving the models section was dropping it along with the fields the backend always overwrites.

* probe every go2rtc stream until one isn't refused

The probe stopped after three streams, so with three offline cameras ahead of a working one, WebRTC was marked unreachable everywhere. It only moves past a stream when go2rtc refuses it, which is quick, and a stream that hangs still ends the probe at its timeout, so the cap bought nothing.

* only reuse a failed WebRTC probe for the stream it started from

A failed probe was reused for 30 seconds by every caller, so a camera whose stream timed out kept WebRTC unavailable for the next camera viewed, including one opened while that probe was still running. A pass still counts for every stream since it proves the connection, but a failure is only reused by probes that start from the same stream.

* strip input_dtype from Frigate+ models again

A Frigate+ model's config comes entirely from its model info, and a missing `inputDataType` means the `int` default. Keeping `input_dtype` meant switching from a custom model with `input_dtype: float` to a Frigate+ model carried the stale dtype over, with the field hidden so it couldn't be corrected.
2026-09-28 13:45:05 -06:00
Josh HawkinsandGitHub f9a37d4f4f remember last selected history sidebar tab (#24488)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
The History view always opened on Timeline unless the link carried a tab. The selected tab is now saved with useUserPersistence and used as the default when no tab is passed. Notification and shared review links no longer default to "timeline", so they pick up the saved tab too, and `?tab=` still overrides it.
2026-09-27 17:21:46 -06:00
Josh HawkinsandGitHub 0977fa5e15 Improve camera group live view grids (#24461)
* rework live dashboard grid layout and add natural mode

* hide natural aspect and layout import on phones

* fix merge conflict

* add confirmation dialog for clearing groups and streaming settings

* add confirmation dialog for natural aspect switch

* clean up

* remove note

* wording tweak

* remove unused

* fixes
2026-09-27 17:09:31 -06:00
Nick RogersandGitHub 6791df7971 Fix ZMQ detector ignoring the endpoint in its device string (#24464)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-09-24 15:44:07 -06:00
Josh HawkinsandGitHub 9664d9ceae Notices and status bar improvements (#24459)
* Notice and status bar improvements

Status bar problems added in the same pass got the same `Date.now()` id and overwrote each other, so usually only one showed. Messages now fall back to their text as the id. The desktop status bar shows the most severe message with a count of the rest that opens a popover listing all of them, and the mobile drawer stacks them vertically instead of placing them side by side.

Dismissing a notice hid it for good, so a detector that restarted again after a dismissal was never shown. Dismiss is replaced by acknowledge, which hides a notice until it happens again, and mute, which hides it permanently. Kinds that never repeat (config and stream checks, the update notice) can only be muted. `reopen_at_count` is removed since acknowledge covers the failed login case.

* move camera CPU warnings to notices

High ffmpeg and detect CPU warnings sat in the status bar with no way to dismiss them. They're now `ffmpeg_high_cpu` and `detect_high_cpu` notices, raised per episode by the same tracker as skipped detections. Also stop failed login attempts held from before an acknowledgement from reopening the notice.

* fix mypy and handle missing cpu stats in notices
2026-09-24 15:39:18 -06:00
Nick RogersandGitHub c959df32c9 Apple Silicon Macs via lighter: ONNX detector on the Neural Engine and media engine decode (#24453)
* Run ONNX models on a Mac's Neural Engine through lighter's plugin provider

lighter's lighter.sh/ane device places an ONNX Runtime plugin execution
provider in the container. When it is present, the ONNX session setup
registers it once and opens sessions on its Neural Engine device, the same
place CUDA, ROCm and OpenVINO are chosen, so the onnx detector (and any
model that is not pinned to the CPU) runs there with no configuration. The
hardware probe reports it as an onnx unit.

* docs: hardware decode on an Apple Silicon Mac under lighter

A community section on the video decoding page: lighter's lighter.sh/video
device, hwaccel_args -c:v h264_v4l2m2m, and why the Raspberry Pi presets
decode a single-stream camera in software. The detector docs link to it.

* docs: set the lighter decoder per camera when codecs are mixed

* docs: the ONNX detector on a Mac's Neural Engine under lighter

* Format the Neural Engine provider setup

* Fall back to the default providers when the Neural Engine cannot load a model

* Apple Silicon ffmpeg presets for lighter's media engine, recommended when it is present
2026-09-24 15:37:32 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c5889ef35f Bump image-size from 2.0.2 to 2.0.4 in /docs (#24462)
Bumps [image-size](https://github.com/image-size/image-size) from 2.0.2 to 2.0.4.
- [Release notes](https://github.com/image-size/image-size/releases)
- [Commits](https://github.com/image-size/image-size/commits)

---
updated-dependencies:
- dependency-name: image-size
  dependency-version: 2.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 13:48:50 -05:00
Josh HawkinsandGitHub 9443289112 bump onnxruntime, ruamel, and argcomplete (#24460) 2026-09-24 13:42:58 -05:00
237 changed files with 15418 additions and 2933 deletions
+3 -3
View File
@@ -27,7 +27,7 @@ pydantic == 2.10.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml git+https://github.com/fbcotter/py3nvml#egg=py3nvml
pytz == 2025.* pytz == 2025.*
pyzmq == 27.1.* pyzmq == 27.1.*
ruamel.yaml == 0.18.* ruamel.yaml == 0.19.*
tzlocal == 5.2 tzlocal == 5.2
requests == 2.33.* requests == 2.33.*
types-requests == 2.32.* types-requests == 2.32.*
@@ -43,7 +43,7 @@ opencv-contrib-python == 4.11.0.*
scipy == 1.16.* scipy == 1.16.*
# OpenVino & ONNX # OpenVino & ONNX
openvino == 2025.4.* openvino == 2025.4.*
onnxruntime == 1.22.* onnxruntime == 1.30.*
# Embeddings # Embeddings
transformers == 4.45.* transformers == 4.45.*
# Generative AI # Generative AI
@@ -58,7 +58,7 @@ pyclipper == 1.4.*
shapely == 2.0.* shapely == 2.0.*
rapidfuzz==3.12.* rapidfuzz==3.12.*
# HailoRT # HailoRT
argcomplete==2.0.* argcomplete==3.7.*
contextlib2==0.6.* contextlib2==0.6.*
future==0.18.* future==0.18.*
netaddr==1.3.* netaddr==1.3.*
@@ -85,6 +85,14 @@ An optional config, `save_attempts`, can be set as a key under the model name. T
</TabItem> </TabItem>
</ConfigTabs> </ConfigTabs>
## Review items
When a model's state changes while its camera has an active review item, the change is recorded on that review item. This includes changes in the few seconds before the item starts, such as a garage door opening just before the car is detected. State changes never create or extend review items on their own, and the first state reported after Frigate starts is not recorded as a change.
Recorded changes appear in the review item's data as `classification_state_changes` (see the [`frigate/reviews`](/integrations/mqtt#frigatereviews) MQTT topic) and are passed to [GenAI review summaries](/configuration/genai/genai_review) as facts, so a description can note that a gate was opened during the activity.
Change times are most accurate with `motion: true`. A model that only runs on an `interval` notices a change at its next run, so the change may be recorded late or attached to a later review item.
## Training the model ## Training the model
Creating and training the model is done within the Frigate UI using the `Classification` page. The process consists of three steps: Creating and training the model is done within the Frigate UI using the `Classification` page. The process consists of three steps:
+13 -11
View File
@@ -17,17 +17,19 @@ Hardware acceleration arguments tell FFmpeg to decode your camera's video stream
See [the hardware acceleration docs](/configuration/hardware_acceleration_video.md) for details on setting up hardware acceleration for your GPU / iGPU, then select the preset that matches your hardware. See [the hardware acceleration docs](/configuration/hardware_acceleration_video.md) for details on setting up hardware acceleration for your GPU / iGPU, then select the preset that matches your hardware.
| Preset (YAML config) | UI Label | Usage | Notes | | Preset (YAML config) | UI Label | Usage | Notes |
| --------------------- | ----------------------- | --------------------------------- | --------------------------------------------------------------- | | ------------------------- | ----------------------- | --------------------------------------------- | --------------------------------------------------------------- |
| preset-rpi-64-h264 | Raspberry Pi (H.264) | 64-bit Raspberry Pi, H.264 stream | | | preset-rpi-64-h264 | Raspberry Pi (H.264) | 64-bit Raspberry Pi, H.264 stream | |
| preset-rpi-64-h265 | Raspberry Pi (H.265) | 64-bit Raspberry Pi, H.265 stream | | | preset-rpi-64-h265 | Raspberry Pi (H.265) | 64-bit Raspberry Pi, H.265 stream | |
| preset-vaapi | VAAPI (Intel/AMD GPU) | Intel or AMD GPU via VAAPI | Check the hwaccel docs to ensure the correct driver is selected | | preset-apple-silicon-h264 | Apple Silicon (H.264) | Apple Silicon Mac under lighter, H.264 stream | Needs the `lighter.sh/video` device |
| preset-intel-qsv-h264 | Intel QuickSync (H.264) | Intel QuickSync, H.264 stream | If you have issues, use the VAAPI preset instead | | preset-apple-silicon-h265 | Apple Silicon (H.265) | Apple Silicon Mac under lighter, H.265 stream | Needs the `lighter.sh/video` device |
| preset-intel-qsv-h265 | Intel QuickSync (H.265) | Intel QuickSync, H.265 stream | If you have issues, use the VAAPI preset instead | | preset-vaapi | VAAPI (Intel/AMD GPU) | Intel or AMD GPU via VAAPI | Check the hwaccel docs to ensure the correct driver is selected |
| preset-nvidia | NVIDIA GPU | NVIDIA GPU | | | preset-intel-qsv-h264 | Intel QuickSync (H.264) | Intel QuickSync, H.264 stream | If you have issues, use the VAAPI preset instead |
| preset-jetson-h264 | NVIDIA Jetson (H.264) | NVIDIA Jetson, H.264 stream | | | preset-intel-qsv-h265 | Intel QuickSync (H.265) | Intel QuickSync, H.265 stream | If you have issues, use the VAAPI preset instead |
| preset-jetson-h265 | NVIDIA Jetson (H.265) | NVIDIA Jetson, H.265 stream | | | preset-nvidia | NVIDIA GPU | NVIDIA GPU | |
| preset-rkmpp | Rockchip RKMPP | Rockchip MPP | Use an image with the `-rk` suffix and run in privileged mode | | preset-jetson-h264 | NVIDIA Jetson (H.264) | NVIDIA Jetson, H.264 stream | |
| preset-jetson-h265 | NVIDIA Jetson (H.265) | NVIDIA Jetson, H.265 stream | |
| preset-rkmpp | Rockchip RKMPP | Rockchip MPP | Use an image with the `-rk` suffix and run in privileged mode |
<ConfigTabs> <ConfigTabs>
<TabItem value="ui"> <TabItem value="ui">
@@ -201,6 +201,8 @@ Review items are sent to the model as a sequence of still frames. Some models fo
The notes come from tracking data rather than from the images, so they describe activity the model may not have picked up on its own. In testing with a person carrying three waste bins to the curb one at a time, `gemma4` described a single trip on every attempt with `frames`, and consistently described multiple trips with `annotated_frames`. Models that already handle these sequences well, such as the `qwen3-vl` family, gain little and should stay on `frames`. The notes come from tracking data rather than from the images, so they describe activity the model may not have picked up on its own. In testing with a person carrying three waste bins to the curb one at a time, `gemma4` described a single trip on every attempt with `frames`, and consistently described multiple trips with `annotated_frames`. Models that already handle these sequences well, such as the `qwen3-vl` family, gain little and should stay on `frames`.
Changes reported by [state classification](/configuration/custom_classification/state_classification#review-items) models during the review item are listed in the prompt in both modes. `annotated_frames` also notes each change before the frame where it happened.
Annotated mode also caps the number of frames, since the notes already establish the order of events and extra near-duplicate frames tend to crowd out the middle of a clip. Longer review items are sampled more sparsely as a result, and typically use fewer tokens than `frames` mode for the same item. Annotated mode also caps the number of frames, since the notes already establish the order of events and extra near-duplicate frames tend to crowd out the middle of a clip. Longer review items are sampled more sparsely as a result, and typically use fewer tokens than `frames` mode for the same item.
:::note :::note
@@ -43,6 +43,10 @@ Frigate supports presets for optimal hardware accelerated video decoding:
- [RKNN](#rockchip-platform): Frigate can utilize the media engine in RockChip SOCs to accelerate video decoding. - [RKNN](#rockchip-platform): Frigate can utilize the media engine in RockChip SOCs to accelerate video decoding.
**Apple Silicon Mac** <CommunityBadge />
- [lighter](#apple-silicon-mac-lighter): Frigate can utilize the media engine in Apple Silicon Macs to accelerate video decoding, when running under the lighter container runtime.
**Other Hardware** **Other Hardware**
Depending on your system, these presets may not be compatible, and you may need to use manual hwaccel args to take advantage of your hardware. More information on hardware accelerated decoding for ffmpeg can be found here: https://trac.ffmpeg.org/wiki/HWAccelIntro Depending on your system, these presets may not be compatible, and you may need to use manual hwaccel args to take advantage of your hardware. More information on hardware accelerated decoding for ffmpeg can be found here: https://trac.ffmpeg.org/wiki/HWAccelIntro
@@ -533,3 +537,35 @@ output_args:
Make sure that your SoC supports hardware acceleration for your input stream and your input stream is h264 encoding. For example, if your camera streams with h264 encoding, your SoC must be able to de- and encode with it. If you are unsure whether your SoC meets the requirements, take a look at the datasheet. Make sure that your SoC supports hardware acceleration for your input stream and your input stream is h264 encoding. For example, if your camera streams with h264 encoding, your SoC must be able to de- and encode with it. If you are unsure whether your SoC meets the requirements, take a look at the datasheet.
::: :::
## Apple Silicon Mac (lighter)
[lighter](https://github.com/fieldwork-ai/lighter) is an open-source container runtime for macOS. It gives a container the Mac's media engine as a standard V4L2 decoder, backed by VideoToolbox, so Frigate decodes H.264 and H.265 streams in hardware with the ffmpeg it already ships. It works on M1 and newer Macs with lighter 0.9.2 or newer.
Give the container the video device. With Docker Compose:
```yaml {4-5}
services:
frigate:
...
devices:
- lighter.sh/video=all
```
Or with `docker run`, add `--device lighter.sh/video=all`.
Then set the preset for the codec your cameras stream. The decoder is specific to the codec, so if your cameras mix H.264 and H.265, set the preset for the most common codec globally and override it on the other cameras:
```yaml
ffmpeg:
hwaccel_args: preset-apple-silicon-h264
cameras:
garage: # an H.265 camera
ffmpeg:
hwaccel_args: preset-apple-silicon-h265
```
The presets decode on the media engine and encode the Birdseye restream and timelapses there too. Scaling to the detect resolution runs on the CPU, as ffmpeg's V4L2 decoders cannot scale.
lighter can also run object detection on the Mac's Neural Engine; see [Apple Neural Engine (lighter)](object_detectors.md#apple-neural-engine-lighter).
+21 -1
View File
@@ -34,6 +34,7 @@ Frigate supports multiple different detectors that work on different types of ha
**Apple Silicon** **Apple Silicon**
- [Apple Silicon](#apple-silicon-detector): Apple Silicon can run on M1 and newer Apple Silicon devices. - [Apple Silicon](#apple-silicon-detector): Apple Silicon can run on M1 and newer Apple Silicon devices.
- <CommunityBadge /> [ONNX](#apple-neural-engine-lighter): the ONNX detector runs on the Neural Engine of M1 and newer Macs when Frigate runs under the lighter container runtime.
**Intel** **Intel**
@@ -484,7 +485,7 @@ See [ONNX supported models](#onnx) for supported models, there are some caveats:
## ONNX ## ONNX
ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, ROCm, and TensorRT. On startup Frigate will automatically try to use a GPU if one is available. ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, ROCm, TensorRT, and a Mac's Neural Engine. On startup Frigate will automatically try to use a GPU if one is available.
:::info :::info
@@ -500,6 +501,9 @@ If the correct build is used for your GPU then the GPU will be detected and used
- Nvidia GPUs will automatically be detected and used with the ONNX detector in the `-tensorrt` Frigate image. - Nvidia GPUs will automatically be detected and used with the ONNX detector in the `-tensorrt` Frigate image.
- Jetson devices will automatically be detected and used with the ONNX detector in the `-tensorrt-jp6` Frigate image. - Jetson devices will automatically be detected and used with the ONNX detector in the `-tensorrt-jp6` Frigate image.
- **Apple Silicon Mac** <CommunityBadge />
- The Neural Engine will automatically be detected and used with the ONNX detector when Frigate runs under lighter with its Neural Engine device. See [Apple Neural Engine (lighter)](#apple-neural-engine-lighter).
::: :::
:::tip :::tip
@@ -515,6 +519,22 @@ models:
::: :::
### Apple Neural Engine (lighter) {#apple-neural-engine-lighter}
[lighter](https://github.com/fieldwork-ai/lighter) is an open-source container runtime for macOS. A container started with its `lighter.sh/ane` device gets an ONNX Runtime execution provider that runs models on the Mac's Neural Engine, and the ONNX detector uses it automatically, with the same models and configuration as on any other hardware. It works on M1 and newer Macs with lighter 0.9.2 or newer.
Give the Frigate container the Neural Engine device. With Docker Compose:
```yaml
services:
frigate:
image: ghcr.io/blakeblackshear/frigate:stable-standard-arm64
devices:
- lighter.sh/ane=all
```
Or with `docker run`, add `--device lighter.sh/ane=all`. Frigate then reports the Neural Engine under **Settings > System > Detection models**, and the ONNX detector's model loads on it. lighter can also decode camera streams on the Mac's media engine; see [Video Decoding](hardware_acceleration_video.md#apple-silicon-mac-lighter).
### Configuration {#configuration-onnx} ### Configuration {#configuration-onnx}
<ModelConfigDropdown detectorTitle="ONNX" models={objectDetectorsModels.onnx.models} /> <ModelConfigDropdown detectorTitle="ONNX" models={objectDetectorsModels.onnx.models} />
+11 -1
View File
@@ -78,6 +78,10 @@ Frigate supports multiple different detectors that work on different types of ha
**Apple Silicon** **Apple Silicon**
- [ONNX via lighter](#apple-silicon): The ONNX detector runs on the Neural Engine of M1 and newer Macs when Frigate runs in the lighter container runtime
- [Supports the same model architectures as the ONNX detector](../../configuration/object_detectors#apple-neural-engine-lighter)
- Runs inside the Frigate container, with no separate detector process to set up
- The recommended way to run Frigate on a Mac
- [Apple Silicon](#apple-silicon): Apple Silicon is usable on all M1 and newer Apple Silicon devices to provide efficient and fast object detection - [Apple Silicon](#apple-silicon): Apple Silicon is usable on all M1 and newer Apple Silicon devices to provide efficient and fast object detection
- [Supports primarily ssdlite and mobilenet model architectures](../../configuration/object_detectors#apple-silicon-detector) - [Supports primarily ssdlite and mobilenet model architectures](../../configuration/object_detectors#apple-silicon-detector)
- Runs well with any size models including large - Runs well with any size models including large
@@ -211,7 +215,13 @@ Inference is done with the `onnx` detector type. Speeds will vary greatly depend
### Apple Silicon ### Apple Silicon
With the [Apple Silicon](../configuration/object_detectors.md#apple-silicon-detector) detector Frigate can take advantage of the NPU in M1 and newer Apple Silicon. Frigate on a Mac is best run in the [lighter](https://github.com/fieldwork-ai/lighter) container runtime, where the [ONNX detector](../configuration/object_detectors.md#apple-neural-engine-lighter) runs on the Neural Engine of M1 and newer Macs from inside the Frigate container. There is no separate detector process to install or keep running, and the same container can decode video on the Mac's media engine.
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time |
| ---- | -------------------------------------- | ----------------------- | ---------------------- |
| M1 | t-320: 3.3 ms s-320: 7 ms s-640: 13 ms | 320: 6.6 ms | Nano-320: 38 ms |
Alternatively, with the [Apple Silicon](../configuration/object_detectors.md#apple-silicon-detector) detector Frigate can take advantage of the NPU in M1 and newer Apple Silicon.
:::warning :::warning
+13 -2
View File
@@ -212,6 +212,7 @@ An `update` with the same ID will be published when:
- The severity changes from `detection` to `alert` - The severity changes from `detection` to `alert`
- Additional objects are detected - Additional objects are detected
- An object is recognized via face, lpr, etc. - An object is recognized via face, lpr, etc.
- A [state classification](/configuration/custom_classification/state_classification#review-items) model changes state
When the review activity has ended a final `end` message is published. When the review activity has ended a final `end` message is published.
@@ -235,7 +236,8 @@ When the review activity has ended a final `end` message is published.
"objects": ["person", "car"], "objects": ["person", "car"],
"sub_labels": [], "sub_labels": [],
"zones": [], "zones": [],
"audio": [] "audio": [],
"classification_state_changes": []
} }
}, },
"after": { "after": {
@@ -254,7 +256,16 @@ When the review activity has ended a final `end` message is published.
"objects": ["person", "car"], "objects": ["person", "car"],
"sub_labels": ["Bob"], "sub_labels": ["Bob"],
"zones": ["front_yard"], "zones": ["front_yard"],
"audio": [] "audio": [],
"classification_state_changes": [
// verified changes of state classification models on this camera
{
"model": "front_gate",
"from": "closed",
"to": "open",
"timestamp": 1718987131.52
}
]
} }
} }
} }
+1 -1
View File
@@ -184,6 +184,6 @@ Filters and masks only hide the incorrect result - they don't teach Frigate what
### Where do I see problems Frigate has detected? ### Where do I see problems Frigate has detected?
Open System > Health. The Notices list keeps a record of problems Frigate has found, and you can dismiss any entry to acknowledge it. Ongoing conditions, such as an offline camera or recordings deleted before their retention period, appear in the status bar for admins until they clear, and the status bar links to the Notices list while it has undismissed entries. On mobile, tap the warning icon in the bottom navigation bar to see them. Open System > Health. The Notices list keeps a record of problems Frigate has found. Acknowledge an entry to hide it until the problem happens again, or mute it to hide it for good. Hidden entries stay listed under Show hidden in the filter. Ongoing conditions, such as an offline camera or recordings deleted before their retention period, appear in the status bar for admins until they clear, and the status bar links to the Notices list while it has entries showing. On mobile, tap the warning icon in the bottom navigation bar to see them.
The Hardware section below the notices shows whether the detection hardware, hardware acceleration, and enrichment devices in your config were found and are being used, so a GPU that silently fell back to the CPU shows up as a warning. Run stream checks to probe every camera's streams for the same problems the camera wizard reports. The Hardware section below the notices shows whether the detection hardware, hardware acceleration, and enrichment devices in your config were found and are being used, so a GPU that silently fell back to the CPU shows up as a warning. Run stream checks to probe every camera's streams for the same problems the camera wizard reports.
+4 -4
View File
@@ -11375,15 +11375,15 @@
} }
}, },
"node_modules/image-size": { "node_modules/image-size": {
"version": "2.0.2", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.4.tgz",
"integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", "integrity": "sha512-QRUkFFsRV/6fuESxb9Vkq+a0LkSrgKXuc2NEqfikiXxxN/G3tjWt5EVUlMaImRBZRZK/jRBEbYvpPYZL8t08Zw==",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"image-size": "bin/image-size.js" "image-size": "bin/image-size.js"
}, },
"engines": { "engines": {
"node": ">=16.x" "node": ">=18"
} }
}, },
"node_modules/immer": { "node_modules/immer": {
+82 -16
View File
@@ -4174,19 +4174,20 @@ paths:
Get notices, most severe first. Get notices, most severe first.
Args: Args:
include_dismissed: Also return dismissed notices, for the history view include_hidden: Also return acknowledged and muted notices, for the
hidden list
Returns: Returns:
The notices The notices
operationId: get_notices_notices_get operationId: get_notices_notices_get
parameters: parameters:
- name: include_dismissed - name: include_hidden
in: query in: query
required: false required: false
schema: schema:
type: boolean type: boolean
default: false default: false
title: Include Dismissed title: Include Hidden
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
@@ -4221,16 +4222,16 @@ paths:
security: security:
- frigateAdminAuth: [] - frigateAdminAuth: []
x-required-role: admin x-required-role: admin
/notices/dismissed_checks: /notices/muted_checks:
get: get:
tags: tags:
- Notices - Notices
summary: Get Dismissed Checks summary: Get Muted Checks
description: |- description: |-
**Access:** Admin role required. **Access:** Admin role required.
Get the dismissed config and stream check rows, newest first. Get the muted config and stream check rows, newest first.
operationId: get_dismissed_checks_notices_dismissed_checks_get operationId: get_muted_checks_notices_muted_checks_get
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
@@ -4240,16 +4241,16 @@ paths:
security: security:
- frigateAdminAuth: [] - frigateAdminAuth: []
x-required-role: admin x-required-role: admin
/notices/dismissed: /notices/hidden:
delete: delete:
tags: tags:
- Notices - Notices
summary: Purge Dismissed summary: Unhide All Notices
description: |- description: |-
**Access:** Admin role required. **Access:** Admin role required.
Delete every dismissed notice and check row so each can show again. Show every acknowledged and muted notice and check row again.
operationId: purge_dismissed_notices_dismissed_delete operationId: unhide_all_notices_notices_hidden_delete
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
@@ -4259,18 +4260,83 @@ paths:
security: security:
- frigateAdminAuth: [] - frigateAdminAuth: []
x-required-role: admin x-required-role: admin
/notices/{notice_id}/dismiss: /notices/{notice_id}/acknowledge:
post: post:
tags: tags:
- Notices - Notices
summary: Dismiss Notice summary: Acknowledge Notice
description: |- description: |-
**Access:** Admin role required. **Access:** Admin role required.
Hide a notice or a config or stream check row. Hide a notice until it happens again.
It stays hidden if the same problem happens again. Config and stream check rows and the update notice never repeat, so they
operationId: dismiss_notice_notices__notice_id__dismiss_post can only be muted.
operationId: acknowledge_notice_notices__notice_id__acknowledge_post
parameters:
- name: notice_id
in: path
required: true
schema:
type: string
title: Notice Id
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateAdminAuth: []
x-required-role: admin
/notices/{notice_id}/mute:
post:
tags:
- Notices
summary: Mute Notice
description: |-
**Access:** Admin role required.
Hide a notice or a config or stream check row for good.
operationId: mute_notice_notices__notice_id__mute_post
parameters:
- name: notice_id
in: path
required: true
schema:
type: string
title: Notice Id
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateAdminAuth: []
x-required-role: admin
/notices/{notice_id}/hidden:
delete:
tags:
- Notices
summary: Unhide Notice
description: |-
**Access:** Admin role required.
Show an acknowledged or muted notice or check row again.
operationId: unhide_notice_notices__notice_id__hidden_delete
parameters: parameters:
- name: notice_id - name: notice_id
in: path in: path
+7
View File
@@ -393,6 +393,11 @@ def config(request: Request):
model_dict["non_logo_attributes"] = model.non_logo_attributes model_dict["non_logo_attributes"] = model.non_logo_attributes
model_dict["labelmap"] = model.merged_labelmap model_dict["labelmap"] = model.merged_labelmap
# report the configured reference rather than the resolved cache path,
# so saving the config back doesn't lose the Frigate+ model
if model.plus_id:
model_dict["path"] = f"plus://{model.plus_id}"
if not config["plus"]["enabled"]: if not config["plus"]["enabled"]:
continue continue
@@ -429,6 +434,8 @@ def ffmpeg_presets():
hwaccel_presets = [ hwaccel_presets = [
"preset-rpi-64-h264", "preset-rpi-64-h264",
"preset-rpi-64-h265", "preset-rpi-64-h265",
"preset-apple-silicon-h264",
"preset-apple-silicon-h265",
"preset-jetson-h264", "preset-jetson-h264",
"preset-jetson-h265", "preset-jetson-h265",
"preset-rkmpp", "preset-rkmpp",
+1
View File
@@ -51,6 +51,7 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
if app.stats_emitter is not None: if app.stats_emitter is not None:
app.stats_emitter.config = config app.stats_emitter.config = config
app.stats_emitter.hardware_stats.set_config(config)
if app.dispatcher is not None: if app.dispatcher is not None:
app.dispatcher.config = config app.dispatcher.config = config
+1 -7
View File
@@ -63,7 +63,6 @@ from frigate.util.recording_coverage import (
null_audio_glitches, null_audio_glitches,
plan_clip, plan_clip,
resolve_coverage, resolve_coverage,
stream_has_audio,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -681,15 +680,10 @@ async def _vod_response(
end_ts, end_ts,
force_discontinuity, force_discontinuity,
) )
intervals = resolve_coverage(camera_name, start_ts, end_ts)
# rows contradicting their stream's audio composition are # rows contradicting their stream's audio composition are
# truncated-shutdown glitches # truncated-shutdown glitches
main_audio = stream_has_audio(intervals, main=True)
sub_audio = stream_has_audio(intervals, main=False)
spans = build_spans( spans = build_spans(
null_audio_glitches(intervals, main_audio, sub_audio), null_audio_glitches(resolve_coverage(camera_name, start_ts, end_ts)),
stream_preference, stream_preference,
) )
+50 -22
View File
@@ -14,17 +14,18 @@ router = APIRouter(tags=[Tags.notices])
@router.get("/notices", dependencies=[Depends(require_role(["admin"]))]) @router.get("/notices", dependencies=[Depends(require_role(["admin"]))])
def get_notices(request: Request, include_dismissed: bool = False) -> JSONResponse: def get_notices(request: Request, include_hidden: bool = False) -> JSONResponse:
"""Get notices, most severe first. """Get notices, most severe first.
Args: Args:
include_dismissed: Also return dismissed notices, for the history view include_hidden: Also return acknowledged and muted notices, for the
hidden list
Returns: Returns:
The notices The notices
""" """
return JSONResponse( return JSONResponse(
content=request.app.notice_registry.active(include_dismissed=include_dismissed) content=request.app.notice_registry.active(include_hidden=include_hidden)
) )
@@ -34,37 +35,64 @@ def get_notice_stats(request: Request) -> JSONResponse:
return JSONResponse(content=request.app.notice_registry.stats()) return JSONResponse(content=request.app.notice_registry.stats())
@router.get( @router.get("/notices/muted_checks", dependencies=[Depends(require_role(["admin"]))])
"/notices/dismissed_checks", dependencies=[Depends(require_role(["admin"]))] def get_muted_checks(request: Request) -> JSONResponse:
) """Get the muted config and stream check rows, newest first."""
def get_dismissed_checks(request: Request) -> JSONResponse: return JSONResponse(content=request.app.notice_registry.muted_checks())
"""Get the dismissed config and stream check rows, newest first."""
return JSONResponse(content=request.app.notice_registry.dismissed_checks())
@router.delete("/notices/dismissed", dependencies=[Depends(require_role(["admin"]))]) @router.delete("/notices/hidden", dependencies=[Depends(require_role(["admin"]))])
def purge_dismissed(request: Request) -> JSONResponse: def unhide_all_notices(request: Request) -> JSONResponse:
"""Delete every dismissed notice and check row so each can show again.""" """Show every acknowledged and muted notice and check row again."""
request.app.notice_registry.purge_dismissed() request.app.notice_registry.unhide_all()
return JSONResponse( return JSONResponse(content={"success": True, "message": "Notices shown again"})
content={"success": True, "message": "Dismissed notices cleared"}
)
# model notice ids contain a slash, so the id is a path parameter # model notice ids contain a slash, so the id is a path parameter
@router.post( @router.post(
"/notices/{notice_id:path}/dismiss", "/notices/{notice_id:path}/acknowledge",
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
) )
def dismiss_notice(request: Request, notice_id: str) -> JSONResponse: def acknowledge_notice(request: Request, notice_id: str) -> JSONResponse:
"""Hide a notice or a config or stream check row. """Hide a notice until it happens again.
It stays hidden if the same problem happens again. Config and stream check rows and the update notice never repeat, so they
can only be muted.
""" """
if not request.app.notice_registry.dismiss(notice_id): if not request.app.notice_registry.acknowledge(notice_id):
return JSONResponse( return JSONResponse(
content={"success": False, "message": "Notice not found"}, content={"success": False, "message": "Notice not found"},
status_code=404, status_code=404,
) )
return JSONResponse(content={"success": True, "message": "Notice dismissed"}) return JSONResponse(content={"success": True, "message": "Notice acknowledged"})
@router.post(
"/notices/{notice_id:path}/mute",
dependencies=[Depends(require_role(["admin"]))],
)
def mute_notice(request: Request, notice_id: str) -> JSONResponse:
"""Hide a notice or a config or stream check row for good."""
if not request.app.notice_registry.mute(notice_id):
return JSONResponse(
content={"success": False, "message": "Notice not found"},
status_code=404,
)
return JSONResponse(content={"success": True, "message": "Notice muted"})
@router.delete(
"/notices/{notice_id:path}/hidden",
dependencies=[Depends(require_role(["admin"]))],
)
def unhide_notice(request: Request, notice_id: str) -> JSONResponse:
"""Show an acknowledged or muted notice or check row again."""
if not request.app.notice_registry.unhide(notice_id):
return JSONResponse(
content={"success": False, "message": "Notice not found"},
status_code=404,
)
return JSONResponse(content={"success": True, "message": "Notice shown again"})
+1
View File
@@ -12,6 +12,7 @@ class DetectionTypeEnum(str, Enum):
video = "video" video = "video"
audio = "audio" audio = "audio"
lpr = "lpr" lpr = "lpr"
classification_state = "classification_state"
class DetectionPublisher(Publisher): class DetectionPublisher(Publisher):
@@ -1,10 +1,10 @@
"""Frame annotations derived from object tracking data. """Frame annotations derived from object tracking data.
Builds short notes describing what changed during a review item, keyed to the Builds short notes describing what changed during a review item, keyed to the
frames sampled from it. Everything here comes from tracked object data already frames sampled from it. Everything here comes from data already recorded
in the database (each event's `path_data` trajectory and the timeline's (each event's `path_data` trajectory, the timeline's stationary/active
stationary/active changes), so the notes can be stated to the model as fact changes, and the review item's state classification changes), so the notes
rather than as something it must perceive. can be stated to the model as fact rather than as something it must perceive.
""" """
import logging import logging
@@ -95,6 +95,15 @@ def event_name(event: dict[str, Any]) -> str:
return f"{article} {label}" return f"{article} {label}"
def describe_classification_change(change: dict[str, Any]) -> str:
"""Phrase a state classification change, e.g. 'front gate changed from
closed to open'."""
model = str(change["model"]).replace("_", " ")
before = str(change["from"]).replace("_", " ")
after = str(change["to"]).replace("_", " ")
return f"{model} changed from {before} to {after}"
def path_legs(points: list[Point]) -> list[Leg]: def path_legs(points: list[Point]) -> list[Leg]:
"""Split a trajectory into runs of travel in a consistent direction. """Split a trajectory into runs of travel in a consistent direction.
@@ -359,25 +368,38 @@ def get_state_changes(detection_ids: list[str]) -> list[dict[str, Any]]:
def build_frame_captions( def build_frame_captions(
detection_ids: list[str], detection_ids: list[str],
frame_times: list[float], frame_times: list[float],
classification_changes: Sequence[dict[str, Any]] = (),
) -> list[str]: ) -> list[str]:
"""A caption for each sampled frame, in frame order. """A caption for each sampled frame, in frame order.
Every frame gets its index and elapsed time so the model can tell them Every frame gets its index and elapsed time so the model can tell them
apart; frames where something changed also carry the tracker notes for apart; frames where something changed also carry the tracker and state
that moment. Returns an empty list when there is nothing to say, which classification notes for that moment. Returns an empty list when there is
callers treat as a reason to fall back to sending plain frames. nothing to say, which callers treat as a reason to fall back to sending
plain frames.
""" """
if not frame_times: if not frame_times:
return [] return []
span_end = frame_times[-1]
timeline: list[tuple[float, str]] = []
events = get_tracked_events(detection_ids) events = get_tracked_events(detection_ids)
if not events: # audio and manual review items can have state changes but no tracked objects
logger.debug("No tracked events found for review item, skipping annotations") if events:
return [] timeline.extend(
(timestamp, f"[tracker] {note}")
for timestamp, note in build_timeline(
events, span_end, get_state_changes(detection_ids)
)
)
timeline = build_timeline(events, frame_times[-1], get_state_changes(detection_ids)) timeline.extend(
buckets = annotations_by_frame(timeline, frame_times) (change["timestamp"], f"[state] {describe_classification_change(change)}")
for change in classification_changes
if change["timestamp"] <= span_end
)
buckets = annotations_by_frame(sorted(timeline, key=lambda m: m[0]), frame_times)
if not buckets: if not buckets:
return [] return []
@@ -388,7 +410,7 @@ def build_frame_captions(
for index, timestamp in enumerate(frame_times): for index, timestamp in enumerate(frame_times):
lines = [f"Frame {index + 1} of {total} (+{timestamp - origin:.1f}s):"] lines = [f"Frame {index + 1} of {total} (+{timestamp - origin:.1f}s):"]
lines.extend(f"[tracker] {note}" for note in buckets.get(index, [])) lines.extend(buckets.get(index, []))
captions.append("\n".join(lines)) captions.append("\n".join(lines))
return captions return captions
@@ -40,7 +40,7 @@ from frigate.util.image import get_image_from_recording
from ..post.api import PostProcessorApi from ..post.api import PostProcessorApi
from ..types import DataProcessorMetrics from ..types import DataProcessorMetrics
from .review_annotations import build_frame_captions from .review_annotations import build_frame_captions, describe_classification_change
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -254,6 +254,10 @@ class ReviewDescriptionProcessor(PostProcessorApi):
"start_time": r["start_time"], "start_time": r["start_time"],
"end_time": r["end_time"], "end_time": r["end_time"],
"metadata": r["data"]["metadata"], "metadata": r["data"]["metadata"],
"state_changes": [
describe_classification_change(change)
for change in sorted_classification_state_changes(r["data"])
],
} }
for r in ( for r in (
ReviewSegment.select( ReviewSegment.select(
@@ -298,6 +302,9 @@ class ReviewDescriptionProcessor(PostProcessorApi):
primary_item["start_time"] = primary_seg["start_time"] primary_item["start_time"] = primary_seg["start_time"]
primary_item["end_time"] = primary_seg["end_time"] primary_item["end_time"] = primary_seg["end_time"]
if primary_seg["state_changes"]:
primary_item["state_changes"] = primary_seg["state_changes"]
# Find overlapping contextual items from other cameras # Find overlapping contextual items from other cameras
primary_start = primary_seg["start_time"] primary_start = primary_seg["start_time"]
primary_end = primary_seg["end_time"] primary_end = primary_seg["end_time"]
@@ -318,14 +325,25 @@ class ReviewDescriptionProcessor(PostProcessorApi):
seg_end = seg["end_time"] seg_end = seg["end_time"]
if seg_start < primary_end and primary_start < seg_end: if seg_start < primary_end and primary_start < seg_end:
# Avoid duplicates if same camera has multiple overlapping segments # Avoid duplicates if same camera has multiple overlapping
if seg_camera not in seen_contextual_cameras: # segments. One with state changes is kept as its own item
contextual_item = copy.deepcopy(seg["metadata"]) # so each change stays within its item's time range.
contextual_item["camera"] = seg_camera if (
contextual_item["start_time"] = seg_start seg_camera in seen_contextual_cameras
contextual_item["end_time"] = seg_end and not seg["state_changes"]
contextual_items.append(contextual_item) ):
seen_contextual_cameras.add(seg_camera) continue
contextual_item = copy.deepcopy(seg["metadata"])
contextual_item["camera"] = seg_camera
contextual_item["start_time"] = seg_start
contextual_item["end_time"] = seg_end
if seg["state_changes"]:
contextual_item["state_changes"] = seg["state_changes"]
contextual_items.append(contextual_item)
seen_contextual_cameras.add(seg_camera)
# Add context array to primary item # Add context array to primary item
primary_item["context"] = contextual_items primary_item["context"] = contextual_items
@@ -439,6 +457,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
captions = build_frame_captions( captions = build_frame_captions(
final_data["data"].get("detections") or [], final_data["data"].get("detections") or [],
[timestamp for _, timestamp in frames], [timestamp for _, timestamp in frames],
sorted_classification_state_changes(final_data["data"]),
) )
if not captions: if not captions:
@@ -688,6 +707,39 @@ def get_recording_buffer_extension(duration: float) -> float:
return buffer_extension return buffer_extension
def sorted_classification_state_changes(
review_data: dict[str, Any],
) -> list[dict[str, Any]]:
"""A review item's state classification changes in time order."""
return sorted(
review_data.get("classification_state_changes") or [],
key=lambda change: change["timestamp"],
)
def format_classification_state_changes(
changes: list[dict[str, Any]], start_time: float, end_time: float
) -> list[str]:
"""Phrase state classification changes with their timing in the activity.
Changes are attached while the review item is active, which runs past its
end_time by the review cutoff, and a few seconds before its start.
"""
lines = []
for change in changes:
if change["timestamp"] < start_time:
when = "just before the activity started"
elif change["timestamp"] > end_time:
when = "after the activity ended"
else:
when = f"{round(change['timestamp'] - start_time)}s into the activity"
lines.append(f"{describe_classification_change(change)}, {when}")
return lines
def run_analysis( def run_analysis(
requestor: InterProcessRequestor, requestor: InterProcessRequestor,
genai_client: GenAIClient, genai_client: GenAIClient,
@@ -743,6 +795,13 @@ def run_analysis(
unified_objects.append(object_type) unified_objects.append(object_type)
analytics_data["unified_objects"] = unified_objects analytics_data["unified_objects"] = unified_objects
analytics_data["classification_state_changes"] = (
format_classification_state_changes(
sorted_classification_state_changes(final_data["data"]),
final_data["start_time"],
final_data["end_time"],
)
)
metadata = genai_client.generate_review_description( metadata = genai_client.generate_review_description(
analytics_data, analytics_data,
@@ -91,8 +91,23 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
self.tensor_input_details = self.interpreter.get_input_details() self.tensor_input_details = self.interpreter.get_input_details()
self.tensor_output_details = self.interpreter.get_output_details() self.tensor_output_details = self.interpreter.get_output_details()
self.labelmap = load_labels(labelmap_path, prefill=0, indexed=False) self.labelmap = load_labels(labelmap_path, prefill=0, indexed=False)
self._forget_unknown_states()
self.classifications_per_second.start() self.classifications_per_second.start()
def _forget_unknown_states(self) -> None:
"""Drop verified states that are not labels of the loaded model.
A retrained model can rename or remove labels. Keeping a state it can
no longer produce would report its first verified state as a change
from that obsolete label.
"""
labels = set(self.labelmap.values())
self.state_history = {
camera: history
for camera, history in self.state_history.items()
if history["current_state"] in labels
}
def __update_metrics(self, duration: float) -> None: def __update_metrics(self, duration: float) -> None:
self.classifications_per_second.update() self.classifications_per_second.update()
if self.inference_speed: if self.inference_speed:
@@ -134,15 +149,20 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
# Don't save if state is stable (detected_state == current_state) AND score is 100% # Don't save if state is stable (detected_state == current_state) AND score is 100%
return False return False
def verify_state_change(self, camera: str, detected_state: str) -> str | None: def verify_state_change(
self, camera: str, detected_state: str, timestamp: float
) -> tuple[str | None, float] | None:
""" """
Verify state change requires 3 consecutive identical states before publishing. Verify state change requires 3 consecutive identical states before publishing.
Returns state to publish or None if verification not complete. Returns (previous state, time the new state was first seen) once verified,
or None if verification not complete. The previous state is None for the
first state verified on a camera.
""" """
if camera not in self.state_history: if camera not in self.state_history:
self.state_history[camera] = { self.state_history[camera] = {
"current_state": None, "current_state": None,
"pending_state": None, "pending_state": None,
"pending_since": 0.0,
"consecutive_count": 0, "consecutive_count": 0,
} }
@@ -157,12 +177,14 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
verification["consecutive_count"] += 1 verification["consecutive_count"] += 1
if verification["consecutive_count"] >= 3: if verification["consecutive_count"] >= 3:
previous_state = verification["current_state"]
verification["current_state"] = detected_state verification["current_state"] = detected_state
verification["pending_state"] = None verification["pending_state"] = None
verification["consecutive_count"] = 0 verification["consecutive_count"] = 0
return detected_state return previous_state, verification["pending_since"]
else: else:
verification["pending_state"] = detected_state verification["pending_state"] = detected_state
verification["pending_since"] = timestamp
verification["consecutive_count"] = 1 verification["consecutive_count"] = 1
logger.debug( logger.debug(
f"New state '{detected_state}' detected for {camera}, need {3 - verification['consecutive_count']} more consecutive detections" f"New state '{detected_state}' detected for {camera}, need {3 - verification['consecutive_count']} more consecutive detections"
@@ -340,16 +362,19 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
) )
return return
verified_state = self.verify_state_change(camera, detected_state) verified = self.verify_state_change(camera, detected_state, timestamp)
if verified_state is not None: if verified is not None:
previous_state, changed_at = verified
self._emit_result( self._emit_result(
{ {
"type": "classification", "type": "classification",
"processor": "state", "processor": "state",
"model_name": self.model_config.name, "model_name": self.model_config.name,
"camera": camera, "camera": camera,
"state": verified_state, "state": detected_state,
"previous_state": previous_state,
"timestamp": changed_at,
} }
) )
+51
View File
@@ -46,8 +46,42 @@ _PROVIDER_LABELS = {
"MIGraphXExecutionProvider": "MIGraphX", "MIGraphXExecutionProvider": "MIGraphX",
"OpenVINOExecutionProvider": "OpenVINO", "OpenVINOExecutionProvider": "OpenVINO",
"CPUExecutionProvider": "CPU", "CPUExecutionProvider": "CPU",
"LighterANE": "Neural Engine",
} }
# lighter (https://github.com/fieldwork-ai/lighter) places an ONNX Runtime plugin
# execution provider in a container started with --device lighter.sh/ane=all,
# which runs models on a Mac's Neural Engine; LIGHTER_ANE_EP names where it is
LIGHTER_ANE_EP_NAME = "LighterANE"
LIGHTER_ANE_LIBRARY = "/usr/lib/lighter/liblighter_ane_ep.so"
def get_lighter_ane_devices() -> list[Any]:
"""Get the Neural Engine devices lighter's provider offers, registering it once.
Returns:
The provider's ONNX Runtime devices, or an empty list without lighter's device
"""
library = os.environ.get("LIGHTER_ANE_EP", LIGHTER_ANE_LIBRARY)
if not os.path.exists(library):
return []
devices = [d for d in ort.get_ep_devices() if d.ep_name == LIGHTER_ANE_EP_NAME]
if not devices:
try:
ort.register_execution_provider_library(LIGHTER_ANE_EP_NAME, library)
except Exception as e:
logger.warning(
f"Failed to load the Neural Engine provider from {library}: {e}"
)
return []
devices = [d for d in ort.get_ep_devices() if d.ep_name == LIGHTER_ANE_EP_NAME]
return devices
def is_arm64_platform() -> bool: def is_arm64_platform() -> bool:
"""Check if we're running on an ARM platform.""" """Check if we're running on an ARM platform."""
@@ -689,6 +723,23 @@ def get_optimized_runner(
if rknn_path: if rknn_path:
return _record_runner(model_path, model_type, RKNNModelRunner(rknn_path)) return _record_runner(model_path, model_type, RKNNModelRunner(rknn_path))
if device != "CPU" and (ane_devices := get_lighter_ane_devices()):
sess_options = get_ort_session_options(model_type) or ort.SessionOptions()
sess_options.add_provider_for_devices(ane_devices, {})
try:
session = ort.InferenceSession(model_path, sess_options=sess_options)
except Exception as e:
logger.warning(
f"Failed to load {model_path} on the Neural Engine, using the default providers: {e}"
)
else:
return _record_runner(
model_path,
model_type,
ONNXModelRunner(session, model_type=model_type),
)
providers, options = get_ort_providers(device == "CPU", device, **kwargs) providers, options = get_ort_providers(device == "CPU", device, **kwargs)
if providers[0] == "CPUExecutionProvider": if providers[0] == "CPUExecutionProvider":
+7
View File
@@ -123,6 +123,7 @@ class ModelConfig(BaseModel):
_all_attributes: list[str] = PrivateAttr() _all_attributes: list[str] = PrivateAttr()
_all_attribute_logos: list[str] = PrivateAttr() _all_attribute_logos: list[str] = PrivateAttr()
_model_hash: str = PrivateAttr() _model_hash: str = PrivateAttr()
_plus_id: str | None = PrivateAttr(default=None)
@property @property
def merged_labelmap(self) -> dict[int, str]: def merged_labelmap(self) -> dict[int, str]:
@@ -148,6 +149,11 @@ class ModelConfig(BaseModel):
def model_hash(self) -> str: def model_hash(self) -> str:
return self._model_hash return self._model_hash
@property
def plus_id(self) -> str | None:
"""The Frigate+ model id, once a plus:// path has been resolved."""
return self._plus_id
def __init__(self, **config): def __init__(self, **config):
super().__init__(**config) super().__init__(**config)
@@ -178,6 +184,7 @@ class ModelConfig(BaseModel):
os.makedirs(MODEL_CACHE_DIR, exist_ok=True) os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
model_id = self.path[7:] model_id = self.path[7:]
self._plus_id = model_id
self.path = os.path.join(MODEL_CACHE_DIR, model_id) self.path = os.path.join(MODEL_CACHE_DIR, model_id)
model_info_path = f"{self.path}.json" model_info_path = f"{self.path}.json"
+15
View File
@@ -25,6 +25,7 @@ SYS_ROOT = "/sys"
DEV_ROOT = "/dev" DEV_ROOT = "/dev"
PROC_ROOT = "/proc" PROC_ROOT = "/proc"
ETC_ROOT = "/etc" ETC_ROOT = "/etc"
LIB_ROOT = "/usr/lib"
# a Coral reports as Global Unichip until its firmware is loaded, then as Google # a Coral reports as Global Unichip until its firmware is loaded, then as Google
CORAL_USB_IDS = {("1a6e", "089a"), ("18d1", "9302")} CORAL_USB_IDS = {("1a6e", "089a"), ("18d1", "9302")}
@@ -317,6 +318,19 @@ def detect_synaptics() -> DetectionHardware | None:
return _hardware("synaptics", "synaptics", "Synaptics NPU", units) return _hardware("synaptics", "synaptics", "Synaptics NPU", units)
def detect_lighter_ane() -> DetectionHardware | None:
"""Find a Mac's Neural Engine by the provider library lighter's device places."""
library = os.environ.get(
"LIGHTER_ANE_EP", f"{LIB_ROOT}/lighter/liblighter_ane_ep.so"
)
if not os.path.exists(library):
return None
# runs through onnx, whose session picks lighter's provider when it is present
units = [HardwareUnit(device="onnx", label="Neural Engine")]
return _hardware("onnx:lighter", "onnx", "Apple Neural Engine", units)
def detect_cpu() -> DetectionHardware: def detect_cpu() -> DetectionHardware:
"""The CPU, which is always available.""" """The CPU, which is always available."""
units = [HardwareUnit(device="cpu", label="CPU")] units = [HardwareUnit(device="cpu", label="CPU")]
@@ -338,6 +352,7 @@ PROBES = (
detect_rockchip, detect_rockchip,
detect_axengine, detect_axengine,
detect_synaptics, detect_synaptics,
detect_lighter_ane,
detect_cpu, detect_cpu,
) )
+2 -1
View File
@@ -1,7 +1,7 @@
import json import json
import logging import logging
import os import os
from typing import Any, Literal from typing import Any, ClassVar, Literal
import numpy as np import numpy as np
import zmq import zmq
@@ -21,6 +21,7 @@ class ZmqDetectorConfig(BaseDetectorConfig):
model_config = ConfigDict( model_config = ConfigDict(
title="ZMQ IPC", title="ZMQ IPC",
) )
device_spec_field: ClassVar[str] = "endpoint"
type: Literal[DETECTOR_KEY] type: Literal[DETECTOR_KEY]
endpoint: str = Field( endpoint: str = Field(
+22 -1
View File
@@ -11,7 +11,11 @@ from typing import Any
from peewee import DoesNotExist from peewee import DoesNotExist
from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.config_updater import ConfigSubscriber
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum from frigate.comms.detections_updater import (
DetectionPublisher,
DetectionSubscriber,
DetectionTypeEnum,
)
from frigate.comms.embeddings_updater import ( from frigate.comms.embeddings_updater import (
EmbeddingsRequestEnum, EmbeddingsRequestEnum,
EmbeddingsResponder, EmbeddingsResponder,
@@ -168,6 +172,7 @@ class EmbeddingMaintainer(threading.Thread):
) )
self.review_subscriber = ReviewDataSubscriber("") self.review_subscriber = ReviewDataSubscriber("")
self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video.value) self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video.value)
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.all.value)
self.embeddings_responder = EmbeddingsResponder() self.embeddings_responder = EmbeddingsResponder()
self.frame_manager = SharedMemoryFrameManager() self.frame_manager = SharedMemoryFrameManager()
@@ -356,6 +361,7 @@ class EmbeddingMaintainer(threading.Thread):
self.event_end_subscriber.stop() self.event_end_subscriber.stop()
self.recordings_subscriber.stop() self.recordings_subscriber.stop()
self.detection_subscriber.stop() self.detection_subscriber.stop()
self.detection_publisher.stop()
self.event_metadata_publisher.stop() self.event_metadata_publisher.stop()
self.event_metadata_subscriber.stop() self.event_metadata_subscriber.stop()
self.embeddings_responder.stop() self.embeddings_responder.stop()
@@ -851,6 +857,21 @@ class EmbeddingMaintainer(threading.Thread):
f"{result['camera']}/classification/{result['model_name']}", f"{result['camera']}/classification/{result['model_name']}",
result["state"], result["state"],
) )
# the first state verified after startup is not a change
if result["previous_state"] is not None:
self.detection_publisher.publish(
(
result["camera"],
{
"model": result["model_name"],
"from": result["previous_state"],
"to": result["state"],
"timestamp": result["timestamp"],
},
),
DetectionTypeEnum.classification_state.value,
)
elif result["processor"] == "object": elif result["processor"] == "object":
object_id = result["object_id"] object_id = result["object_id"]
camera = result["camera"] camera = result["camera"]
+9
View File
@@ -84,6 +84,8 @@ _user_agent_args = [
PRESETS_HW_ACCEL_DECODE = { PRESETS_HW_ACCEL_DECODE = {
"preset-rpi-64-h264": "-c:v:1 h264_v4l2m2m", "preset-rpi-64-h264": "-c:v:1 h264_v4l2m2m",
"preset-rpi-64-h265": "-c:v:1 hevc_v4l2m2m", "preset-rpi-64-h265": "-c:v:1 hevc_v4l2m2m",
"preset-apple-silicon-h264": "-c:v h264_v4l2m2m",
"preset-apple-silicon-h265": "-c:v hevc_v4l2m2m",
FFMPEG_HWACCEL_VAAPI: "-hwaccel_flags allow_profile_mismatch -hwaccel vaapi -hwaccel_device {3} -hwaccel_output_format vaapi", FFMPEG_HWACCEL_VAAPI: "-hwaccel_flags allow_profile_mismatch -hwaccel vaapi -hwaccel_device {3} -hwaccel_output_format vaapi",
"preset-intel-qsv-h264": f"-hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv -c:v h264_qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17 "preset-intel-qsv-h264": f"-hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv -c:v h264_qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17
"preset-intel-qsv-h265": f"-load_plugin hevc_hw -hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17 "preset-intel-qsv-h265": f"-load_plugin hevc_hw -hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17
@@ -120,6 +122,9 @@ PRESETS_HW_ACCEL_DECODE["preset-rk-h265"] = PRESETS_HW_ACCEL_DECODE[
PRESETS_HW_ACCEL_SCALE = { PRESETS_HW_ACCEL_SCALE = {
"preset-rpi-64-h264": "-r {0} -vf fps={0},scale={1}:{2}", "preset-rpi-64-h264": "-r {0} -vf fps={0},scale={1}:{2}",
"preset-rpi-64-h265": "-r {0} -vf fps={0},scale={1}:{2}", "preset-rpi-64-h265": "-r {0} -vf fps={0},scale={1}:{2}",
# ffmpeg's v4l2m2m decoders cannot scale, so frames are scaled on the CPU
"preset-apple-silicon-h264": "-r {0} -vf fps={0},scale={1}:{2}",
"preset-apple-silicon-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", 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-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-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
@@ -150,6 +155,8 @@ PRESETS_HW_ACCEL_SCALE["preset-rk-h265"] = PRESETS_HW_ACCEL_SCALE[FFMPEG_HWACCEL
PRESETS_HW_ACCEL_ENCODE_BIRDSEYE = { PRESETS_HW_ACCEL_ENCODE_BIRDSEYE = {
"preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}", "preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
"preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m {2}", "preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m {2}",
"preset-apple-silicon-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
"preset-apple-silicon-h265": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
# -vaapi_device is required in addition to -hwaccel_device: this is the only # -vaapi_device is required in addition to -hwaccel_device: this is the only
# birdseye preset that uses hwupload, and ffmpeg 8 initializes filters before # birdseye preset that uses hwupload, and ffmpeg 8 initializes filters before
# the decoder creates a device, so hwupload cannot see an -hwaccel_device one. # the decoder creates a device, so hwupload cannot see an -hwaccel_device one.
@@ -184,6 +191,8 @@ PRESETS_HW_ACCEL_ENCODE_BIRDSEYE["preset-rk-h264"] = PRESETS_HW_ACCEL_ENCODE_BIR
PRESETS_HW_ACCEL_ENCODE_TIMELAPSE = { PRESETS_HW_ACCEL_ENCODE_TIMELAPSE = {
"preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m -pix_fmt yuv420p {2}", "preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m -pix_fmt yuv420p {2}",
"preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m -pix_fmt yuv420p {2}", "preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m -pix_fmt yuv420p {2}",
"preset-apple-silicon-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m -pix_fmt yuv420p {2}",
"preset-apple-silicon-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m -pix_fmt yuv420p {2}",
FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi {2}", FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi {2}",
"preset-intel-qsv-h264": "{0} -hide_banner {1} -c:v h264_qsv -profile:v high -level:v 4.1 -async_depth:v 1 {2}", "preset-intel-qsv-h264": "{0} -hide_banner {1} -c:v h264_qsv -profile:v high -level:v 4.1 -async_depth:v 1 {2}",
"preset-intel-qsv-h265": "{0} -hide_banner {1} -c:v hevc_qsv -profile:v main -level:v 4.1 -async_depth:v 1 {2}", "preset-intel-qsv-h265": "{0} -hide_banner {1} -c:v hevc_qsv -profile:v main -level:v 4.1 -async_depth:v 1 {2}",
+36 -89
View File
@@ -103,11 +103,10 @@ class LlamaCppClient(GenAIClient):
_supports_reasoning: bool _supports_reasoning: bool
_image_token_cache: dict[tuple[int, int], int] _image_token_cache: dict[tuple[int, int], int]
_text_baseline_tokens: int | None _text_baseline_tokens: int | None
_media_marker: str
@property @property
def supports_embeddings(self) -> bool: def supports_embeddings(self) -> bool:
"""llama.cpp exposes an /embeddings endpoint for any loaded model.""" """llama.cpp exposes a /v1/embeddings endpoint for any loaded model."""
return True return True
def _auth_headers(self) -> dict | None: def _auth_headers(self) -> dict | None:
@@ -159,7 +158,6 @@ class LlamaCppClient(GenAIClient):
self._supports_reasoning = False self._supports_reasoning = False
self._image_token_cache = {} self._image_token_cache = {}
self._text_baseline_tokens = None self._text_baseline_tokens = None
self._media_marker = "<__media__>"
base_url = ( base_url = (
self.genai_config.base_url.rstrip("/") self.genai_config.base_url.rstrip("/")
@@ -187,7 +185,6 @@ class LlamaCppClient(GenAIClient):
self._supports_audio = info["supports_audio"] self._supports_audio = info["supports_audio"]
self._supports_tools = info["supports_tools"] self._supports_tools = info["supports_tools"]
self._supports_reasoning = info["supports_reasoning"] self._supports_reasoning = info["supports_reasoning"]
self._media_marker = info["media_marker"]
logger.info( logger.info(
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s", "llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s",
@@ -215,9 +212,7 @@ class LlamaCppClient(GenAIClient):
`architecture.input_modalities` (text/image/audio) — the primary `architecture.input_modalities` (text/image/audio) — the primary
source. When proxied through llama-swap, the same entry carries source. When proxied through llama-swap, the same entry carries
`status.args` (server launch argv) and, for the loaded model, `status.args` (server launch argv) and, for the loaded model,
`meta.n_ctx`. /props remains the only source for `media_marker`, `meta.n_ctx`.
which the server randomizes per startup unless LLAMA_MEDIA_MARKER
is set.
""" """
info: dict[str, Any] = { info: dict[str, Any] = {
"context_size": None, "context_size": None,
@@ -225,7 +220,6 @@ class LlamaCppClient(GenAIClient):
"supports_audio": False, "supports_audio": False,
"supports_tools": False, "supports_tools": False,
"supports_reasoning": False, "supports_reasoning": False,
"media_marker": "<__media__>",
} }
model_entry: dict[str, Any] | None = None model_entry: dict[str, Any] | None = None
@@ -314,16 +308,8 @@ class LlamaCppClient(GenAIClient):
# in the Jinja chat template itself. # in the Jinja chat template itself.
chat_template = props.get("chat_template") or "" chat_template = props.get("chat_template") or ""
info["supports_reasoning"] = "enable_thinking" in chat_template info["supports_reasoning"] = "enable_thinking" in chat_template
media_marker = props.get("media_marker")
if isinstance(media_marker, str) and media_marker:
info["media_marker"] = media_marker
except Exception as e: except Exception as e:
logger.warning( logger.warning("Failed to query llama.cpp /props endpoint: %s", e)
"Failed to query llama.cpp /props endpoint: %s. "
"Image embeddings may fail if the server randomized its media marker.",
e,
)
return info return info
@@ -474,9 +460,6 @@ class LlamaCppClient(GenAIClient):
def _transcribe_via_chat(self, audio: bytes, language: str | None) -> str | None: def _transcribe_via_chat(self, audio: bytes, language: str | None) -> str | None:
"""Transcribe through /v1/chat/completions, for servers without the """Transcribe through /v1/chat/completions, for servers without the
transcriptions route. transcriptions route.
The _media_marker / multimodal_data convention is an /embeddings-only
protocol, so no marker-refresh retry is needed here.
""" """
prompt = "Transcribe the speech in this audio verbatim. Respond with the transcript only, and with nothing at all if there is no speech." prompt = "Transcribe the speech in this audio verbatim. Respond with the transcript only, and with nothing at all if there is no speech."
@@ -794,41 +777,16 @@ class LlamaCppClient(GenAIClient):
) )
return result if result else None return result if result else None
def _refresh_media_marker(self) -> bool:
"""Re-fetch /props and update the cached media marker if it changed.
The server randomizes the marker per startup (unless LLAMA_MEDIA_MARKER
is set), so a stale marker indicates a restart. Returns True iff the
marker was updated to a new value — used to gate a one-shot retry of
a failed embeddings request.
"""
if self.provider is None:
return False
try:
props = self._fetch_llama_props(self.provider, self.genai_config.model)
except Exception as e:
logger.warning("Failed to refresh llama.cpp media marker: %s", e)
return False
marker = props.get("media_marker")
if not isinstance(marker, str) or not marker or marker == self._media_marker:
return False
logger.info("llama.cpp media marker changed (server restart); refreshed")
self._media_marker = marker
return True
def embed( def embed(
self, self,
texts: list[str] | None = None, texts: list[str] | None = None,
images: list[bytes] | None = None, images: list[bytes] | None = None,
) -> list[np.ndarray]: ) -> list[np.ndarray]:
"""Generate embeddings via llama.cpp /embeddings endpoint. """Generate embeddings via llama.cpp /v1/embeddings endpoint.
Supports batch requests. Uses content format with prompt_string and Each text or image is one entry in `input`, using the chat-style
multimodal_data for images (PR #15108). Server must be started with content array from ggml-org/llama.cpp#29556. Server must be started
--embeddings and --mmproj for multimodal support. with --embeddings, and --mmproj for image support.
""" """
if self.provider is None: if self.provider is None:
logger.warning( logger.warning(
@@ -843,49 +801,42 @@ class LlamaCppClient(GenAIClient):
EMBEDDING_DIM = 768 EMBEDDING_DIM = 768
encoded_images: list[str] = [] inputs: list[dict[str, Any]] = [
{"content": [{"type": "text", "text": text}]} for text in texts
]
for img in images: for img in images:
# llama.cpp uses STB which does not support WebP; convert to JPEG # llama.cpp uses STB which does not support WebP; convert to JPEG
jpeg_bytes = _to_jpeg(img) jpeg_bytes = _to_jpeg(img)
to_encode = jpeg_bytes if jpeg_bytes is not None else img to_encode = jpeg_bytes if jpeg_bytes is not None else img
encoded_images.append(base64.b64encode(to_encode).decode("utf-8")) encoded = base64.b64encode(to_encode).decode("utf-8")
# The trailing newline keeps tokenization identical to the older
def build_content() -> list[dict[str, Any]]: # "<__media__>\n" prompt_string format, so indexed vectors stay valid
# prompt_string must contain the server's media marker placeholder inputs.append(
# for each image. The marker is randomized per server startup. {
content: list[dict[str, Any]] = [] "content": [
for text in texts: {
content.append({"prompt_string": text}) "type": "image_url",
for encoded in encoded_images: "image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
content.append( },
{ {"type": "text", "text": "\n"},
"prompt_string": f"{self._media_marker}\n", ]
"multimodal_data": [encoded], }
}
)
return content
def post_embeddings() -> requests.Response:
return self._post(
f"{self.provider}/embeddings",
json={"model": self.genai_config.model, "content": build_content()},
timeout=self.timeout,
) )
try: try:
try: response = self._post(
response = post_embeddings() f"{self.provider}/v1/embeddings",
response.raise_for_status() json={
except requests.exceptions.RequestException: "model": self.genai_config.model,
# The server may have restarted with a new media marker. "input": inputs,
# Refresh from /props; only retry if the marker actually changed. "encoding_format": "float",
if not encoded_images or not self._refresh_media_marker(): },
raise timeout=self.timeout,
response = post_embeddings() )
response.raise_for_status() response.raise_for_status()
result = response.json() items = response.json().get("data")
items = result.get("data", result) if isinstance(result, dict) else result
if not isinstance(items, list): if not isinstance(items, list):
logger.warning("llama.cpp embeddings returned unexpected format") logger.warning("llama.cpp embeddings returned unexpected format")
return [] return []
@@ -896,11 +847,7 @@ class LlamaCppClient(GenAIClient):
if emb is None: if emb is None:
logger.warning("llama.cpp embeddings item missing embedding field") logger.warning("llama.cpp embeddings item missing embedding field")
continue continue
arr = np.array(emb, dtype=np.float32) arr = np.array(emb, dtype=np.float32).flatten()
if arr.ndim > 1:
# llama.cpp can return token-level embeddings; pool per item
arr = arr.mean(axis=0)
arr = arr.flatten()
orig_dim = arr.size orig_dim = arr.size
if orig_dim != EMBEDDING_DIM: if orig_dim != EMBEDDING_DIM:
if orig_dim > EMBEDDING_DIM: if orig_dim > EMBEDDING_DIM:
+28 -2
View File
@@ -104,6 +104,21 @@ def build_review_description_prompt(
else: else:
return "\n- (No objects detected)" return "\n- (No objects detected)"
def get_state_changes_section() -> str:
# empty when nothing changed so the prompt is otherwise unaffected
changes = review_data.get("classification_state_changes")
if not changes:
return ""
return (
"\n\n## State Changes\n\n"
"The camera's state classifiers watch fixed areas of the scene and "
"reported these changes. They come from the classifiers rather than "
"from the images, and they are reliable. Describe each one where it "
"fits in the sequence of events.\n- " + "\n- ".join(changes)
)
fields = get_review_field_guidelines(response_style) fields = get_review_field_guidelines(response_style)
frame_guidance = f"\n{FRAME_ANNOTATION_GUIDANCE}" if frame_captions else "" frame_guidance = f"\n{FRAME_ANNOTATION_GUIDANCE}" if frame_captions else ""
@@ -145,7 +160,7 @@ Respond with a JSON object matching the provided schema. Field-specific guidance
- Camera: {review_data["camera"]} - Camera: {review_data["camera"]}
- Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest){frame_guidance} - Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest){frame_guidance}
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds - Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"} - Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}{get_state_changes_section()}
## Objects in Scene ## Objects in Scene
@@ -196,6 +211,17 @@ def build_review_summary_prompt(
f" to " f" to "
f"{datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}" f"{datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}"
) )
has_state_changes = any(
"state_changes" in item
for event in events
for item in [event, *event.get("context", [])]
)
state_changes_format = (
'\n- "state_changes" (only on some events): changes to monitored areas '
"reported by the camera's state classifiers, which are reliable"
if has_state_changes
else ""
)
prompt = f""" prompt = f"""
You are a security officer writing a concise security report. You are a security officer writing a concise security report.
@@ -203,7 +229,7 @@ Time range: {time_range}
Input format: Each event is a JSON object with: Input format: Each event is a JSON object with:
- "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "camera", "time", "start_time", "end_time" - "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "camera", "time", "start_time", "end_time"
- "context": array of related events from other cameras that occurred during overlapping time periods - "context": array of related events from other cameras that occurred during overlapping time periods{state_changes_format}
**Note: Use the "scene" field for event descriptions in the report. Ignore any "shortSummary" field if present.** **Note: Use the "scene" field for event descriptions in the report. Ignore any "shortSummary" field if present.**
+8 -3
View File
@@ -195,15 +195,20 @@ class Notice(Model):
first_seen = DateTimeField() first_seen = DateTimeField()
last_seen = DateTimeField() last_seen = DateTimeField()
count = IntegerField(default=1) count = IntegerField(default=1)
dismissed_at = DateTimeField(null=True) # hidden until the next occurrence
acknowledged_at = DateTimeField(null=True)
# hidden for good
muted_at = DateTimeField(null=True)
class NoticeStats(Model): class NoticeStats(Model):
kind = CharField(null=False, primary_key=True, max_length=50) kind = CharField(null=False, primary_key=True, max_length=50)
occurrences = IntegerField(default=0) occurrences = IntegerField(default=0)
dismissals = IntegerField(default=0) acknowledgements = IntegerField(default=0)
mutes = IntegerField(default=0)
first_seen = DateTimeField() first_seen = DateTimeField()
last_seen = DateTimeField() last_seen = DateTimeField()
# watermarks for a future analytics reporter; unused until then # watermarks for a future analytics reporter; unused until then
reported_occurrences = IntegerField(default=0) reported_occurrences = IntegerField(default=0)
reported_dismissals = IntegerField(default=0) reported_acknowledgements = IntegerField(default=0)
reported_mutes = IntegerField(default=0)
+110 -43
View File
@@ -5,7 +5,7 @@ import threading
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any, cast
from frigate.const import REPLAY_CAMERA_PREFIX from frigate.const import REPLAY_CAMERA_PREFIX
from frigate.models import Notice, NoticeStats from frigate.models import Notice, NoticeStats
@@ -77,9 +77,8 @@ class NoticeRegistry:
) -> None: ) -> None:
"""Insert a notice or count another occurrence of it. """Insert a notice or count another occurrence of it.
A dismissed notice stays dismissed when it is raised again, unless its Another occurrence shows an acknowledged notice again. A muted notice
kind sets reopen_at_count. A kind that should come back after a stays hidden.
dismissal gives each episode its own scope.
""" """
definition = NOTICE_KINDS.get(kind) definition = NOTICE_KINDS.get(kind)
@@ -112,7 +111,6 @@ class NoticeRegistry:
first_seen=now, first_seen=now,
last_seen=now, last_seen=now,
count=1, count=1,
dismissed_at=None,
) )
self._bump_occurrences(kind, 1, now) self._bump_occurrences(kind, 1, now)
@@ -175,7 +173,7 @@ class NoticeRegistry:
self._notify() self._notify()
def resolve_camera(self, camera: str) -> None: def resolve_camera(self, camera: str) -> None:
"""Drop the notices and check dismissals of a camera being deleted.""" """Drop the notices and check mutes of a camera being deleted."""
camera_kinds = [ camera_kinds = [
key key
for key, definition in NOTICE_KINDS.items() for key, definition in NOTICE_KINDS.items()
@@ -193,7 +191,7 @@ class NoticeRegistry:
) )
# a stream id names its camera first; a config id ends with camera.<name> # a stream id names its camera first; a config id ends with camera.<name>
for check in self.dismissed_checks(): for check in self.muted_checks():
check_id = check["id"] check_id = check["id"]
if check_id.startswith(f"stream:{camera}:") or ( if check_id.startswith(f"stream:{camera}:") or (
@@ -205,26 +203,50 @@ class NoticeRegistry:
if deleted: if deleted:
self._notify() self._notify()
def purge_dismissed(self) -> int: def acknowledge(self, row_id: str) -> bool:
"""Delete every dismissed row so each can show again. Returns how many.""" """Hide a notice until it happens again.
with self._lock:
return int(
Notice.delete().where(Notice.dismissed_at.is_null(False)).execute()
)
def dismiss(self, row_id: str) -> bool: Returns False for an unknown id or a kind that never repeats, such as
"""Hide a notice or check row for good. Returns False for an unknown id.""" a check row or the update notice.
"""
with self._lock: with self._lock:
existing = Notice.get_or_none(Notice.id == row_id) existing = Notice.get_or_none(Notice.id == row_id)
if existing is None: if existing is None:
# a check row gets a notice row only once it is dismissed return False
definition = NOTICE_KINDS.get(existing.kind)
if definition is None or not definition.counts_repeats:
return False
if existing.acknowledged_at is not None or existing.muted_at is not None:
return True
Notice.update(acknowledged_at=datetime.now().timestamp()).where(
Notice.id == row_id
).execute()
NoticeStats.update(acknowledgements=NoticeStats.acknowledgements + 1).where(
NoticeStats.kind == existing.kind
).execute()
self._notify()
return True
def mute(self, row_id: str) -> bool:
"""Hide a notice or check row for good. Returns False for an unknown id."""
now = datetime.now().timestamp()
with self._lock:
existing = Notice.get_or_none(Notice.id == row_id)
if existing is None:
# a check row gets a notice row only once it is muted
kind, _, scope = row_id.partition(":") kind, _, scope = row_id.partition(":")
if kind not in CHECK_KINDS or not scope: if kind not in CHECK_KINDS or not scope:
return False return False
now = datetime.now().timestamp()
Notice.create( Notice.create(
id=row_id, id=row_id,
kind=kind, kind=kind,
@@ -233,40 +255,78 @@ class NoticeRegistry:
first_seen=now, first_seen=now,
last_seen=now, last_seen=now,
count=1, count=1,
dismissed_at=now, muted_at=now,
) )
return True return True
if existing.dismissed_at is not None: if existing.muted_at is not None:
return True return True
if existing.kind not in NOTICE_KINDS: if existing.kind not in NOTICE_KINDS:
return False return False
Notice.update(dismissed_at=datetime.now().timestamp()).where( Notice.update(acknowledged_at=None, muted_at=now).where(
Notice.id == row_id Notice.id == row_id
).execute() ).execute()
NoticeStats.update(dismissals=NoticeStats.dismissals + 1).where( NoticeStats.update(mutes=NoticeStats.mutes + 1).where(
NoticeStats.kind == existing.kind NoticeStats.kind == existing.kind
).execute() ).execute()
self._notify() self._notify()
return True return True
def dismissed_checks(self) -> list[dict[str, Any]]: def unhide(self, row_id: str) -> bool:
"""Dismissed config and stream check rows, newest first.""" """Show an acknowledged or muted row again. Returns False for an unknown id."""
with self._lock:
existing = Notice.get_or_none(Notice.id == row_id)
if existing is None:
return False
if existing.kind in CHECK_KINDS:
Notice.delete_by_id(row_id)
return True
Notice.update(acknowledged_at=None, muted_at=None).where(
Notice.id == row_id
).execute()
self._notify()
return True
def unhide_all(self) -> None:
"""Show every acknowledged and muted row again."""
with self._lock:
for check in self.muted_checks():
Notice.delete_by_id(check["id"])
shown = (
Notice.update(acknowledged_at=None, muted_at=None)
.where(
Notice.acknowledged_at.is_null(False)
| Notice.muted_at.is_null(False)
)
.execute()
)
if shown:
self._notify()
def muted_checks(self) -> list[dict[str, Any]]:
"""Muted config and stream check rows, newest first."""
rows = ( rows = (
Notice.select() Notice.select()
.where(Notice.kind.in_(list(CHECK_KINDS))) .where(Notice.kind.in_(list(CHECK_KINDS)))
.order_by(Notice.dismissed_at.desc()) .order_by(Notice.muted_at.desc())
) )
return [{"id": row.id, "dismissed_at": row.dismissed_at} for row in rows] return [{"id": row.id, "muted_at": row.muted_at} for row in rows]
def active(self, include_dismissed: bool = False) -> list[dict[str, Any]]: def active(self, include_hidden: bool = False) -> list[dict[str, Any]]:
"""Notices most severe first, then most recent first. """Notices most severe first, then most recent first.
Args: Args:
include_dismissed: Also return dismissed notices, for the history view include_hidden: Also return acknowledged and muted notices, for the
hidden list
""" """
rows = [] rows = []
@@ -276,7 +336,9 @@ class NoticeRegistry:
if definition is None: if definition is None:
continue continue
if row.dismissed_at is not None and not include_dismissed: hidden = row.acknowledged_at is not None or row.muted_at is not None
if hidden and not include_hidden:
continue continue
rows.append( rows.append(
@@ -291,7 +353,9 @@ class NoticeRegistry:
"first_seen": row.first_seen, "first_seen": row.first_seen,
"last_seen": row.last_seen, "last_seen": row.last_seen,
"count": row.count, "count": row.count,
"dismissed_at": row.dismissed_at, "acknowledgeable": definition.counts_repeats,
"acknowledged_at": row.acknowledged_at,
"muted_at": row.muted_at,
} }
) )
@@ -309,11 +373,13 @@ class NoticeRegistry:
{ {
"kind": row.kind, "kind": row.kind,
"occurrences": row.occurrences, "occurrences": row.occurrences,
"dismissals": row.dismissals, "acknowledgements": row.acknowledgements,
"mutes": row.mutes,
"first_seen": row.first_seen, "first_seen": row.first_seen,
"last_seen": row.last_seen, "last_seen": row.last_seen,
"reported_occurrences": row.reported_occurrences, "reported_occurrences": row.reported_occurrences,
"reported_dismissals": row.reported_dismissals, "reported_acknowledgements": row.reported_acknowledgements,
"reported_mutes": row.reported_mutes,
} }
for row in NoticeStats.select() for row in NoticeStats.select()
if row.kind in NOTICE_KINDS if row.kind in NOTICE_KINDS
@@ -327,18 +393,18 @@ class NoticeRegistry:
last_seen: float, last_seen: float,
params: dict[str, Any], params: dict[str, Any],
) -> None: ) -> None:
# called with the lock held # called with the lock held; held repeats from before an acknowledgement
fields: dict[str, Any] = { # still count but leave the notice hidden
"count": row.count + count, still_acknowledged = row.acknowledged_at is not None and (
"last_seen": last_seen, last_seen <= cast(float, row.acknowledged_at)
"params": params, )
}
reopen_at = NOTICE_KINDS[kind].reopen_at_count
if reopen_at is not None and row.count < reopen_at <= row.count + count: Notice.update(
fields["dismissed_at"] = None count=row.count + count,
last_seen=last_seen,
Notice.update(**fields).where(Notice.id == row.id).execute() params=params,
acknowledged_at=row.acknowledged_at if still_acknowledged else None,
).where(Notice.id == row.id).execute()
self._bump_occurrences(kind, count, last_seen) self._bump_occurrences(kind, count, last_seen)
def _prune(self, kind: str, keep: int) -> None: def _prune(self, kind: str, keep: int) -> None:
@@ -362,7 +428,8 @@ class NoticeRegistry:
NoticeStats.create( NoticeStats.create(
kind=kind, kind=kind,
occurrences=count, occurrences=count,
dismissals=0, acknowledgements=0,
mutes=0,
first_seen=now, first_seen=now,
last_seen=now, last_seen=now,
) )
+10 -6
View File
@@ -28,9 +28,9 @@ class NoticeKind:
category: camera, detector, model, or system; a camera scope is a category: camera, detector, model, or system; a camera scope is a
camera name, and the UI shows it camera name, and the UI shows it
link: app route or absolute URL for the row, filled in from params link: app route or absolute URL for the row, filled in from params
counts_repeats: whether raising an existing notice counts another occurrence counts_repeats: whether raising an existing notice counts another
occurrence, which also shows an acknowledged notice again
batch_repeats: whether repeats wait in memory for the next flush batch_repeats: whether repeats wait in memory for the next flush
reopen_at_count: count at which a dismissed notice shows again
keep_latest: rows of this kind to keep; a new row drops the oldest keep_latest: rows of this kind to keep; a new row drops the oldest
reportable: whether a future analytics reporter may send this kind's counts reportable: whether a future analytics reporter may send this kind's counts
""" """
@@ -41,7 +41,6 @@ class NoticeKind:
link: str | None = None link: str | None = None
counts_repeats: bool = True counts_repeats: bool = True
batch_repeats: bool = False batch_repeats: bool = False
reopen_at_count: int | None = None
keep_latest: int | None = None keep_latest: int | None = None
reportable: bool = True reportable: bool = True
@@ -67,6 +66,12 @@ _KINDS = (
"camera", "camera",
link="/system#cameras", link="/system#cameras",
), ),
NoticeKind(
"ffmpeg_high_cpu", NoticeSeverity.warning, "camera", link="/system#cameras"
),
NoticeKind(
"detect_high_cpu", NoticeSeverity.warning, "camera", link="/system#cameras"
),
NoticeKind("shm_too_low", NoticeSeverity.warning, "system", link="/system#storage"), NoticeKind("shm_too_low", NoticeSeverity.warning, "system", link="/system#storage"),
# one row per user per burst; the login log lines carry the address # one row per user per burst; the login log lines carry the address
NoticeKind( NoticeKind(
@@ -75,10 +80,9 @@ _KINDS = (
"system", "system",
link="/logs", link="/logs",
batch_repeats=True, batch_repeats=True,
reopen_at_count=5,
keep_latest=100, keep_latest=100,
), ),
# one row per release, so a dismissal lasts until the next release # one row per release, so muting it lasts until the next release
NoticeKind( NoticeKind(
"update_available", "update_available",
NoticeSeverity.info, NoticeSeverity.info,
@@ -92,7 +96,7 @@ _KINDS = (
NOTICE_KINDS: dict[str, NoticeKind] = {kind.key: kind for kind in _KINDS} NOTICE_KINDS: dict[str, NoticeKind] = {kind.key: kind for kind in _KINDS}
# the Health tab builds config and stream check rows in the browser, so a notice # the Health tab builds config and stream check rows in the browser, so a notice
# row of these kinds only records a dismissal; its other fields are placeholders # row of these kinds only records a mute; its other fields are placeholders
CHECK_KINDS = frozenset({"config", "stream"}) CHECK_KINDS = frozenset({"config", "stream"})
+90 -22
View File
@@ -42,6 +42,8 @@ from frigate.util.ownership import chown_to_runtime
from frigate.util.recording_coverage import ( from frigate.util.recording_coverage import (
build_spans, build_spans,
known_video_codecs, known_video_codecs,
null_audio_glitches,
realized_timeline,
resolve_coverage, resolve_coverage,
stream_media_summary, stream_media_summary,
) )
@@ -84,10 +86,20 @@ class StreamRun:
@dataclass @dataclass
class _ChapterWindow: class _ChapterWindow:
"""A merged-timeline slice, shaped like the recording rows chapters read.""" """A merged-timeline slice, shaped like the recording rows chapters read.
lead_in is the output time the slice's vod clip plays before start_time,
from snapping its first frame back to a keyframe.
"""
start_time: float start_time: float
end_time: float end_time: float
lead_in: float = 0.0
def _lead_in(recording: Any) -> float:
"""Output seconds a chapter source plays before its first wall second."""
return recording.lead_in if isinstance(recording, _ChapterWindow) else 0.0
# Matches the setpts factor used in timelapse exports (e.g. setpts=0.04*PTS). # Matches the setpts factor used in timelapse exports (e.g. setpts=0.04*PTS).
@@ -376,15 +388,18 @@ class RecordingExporter(threading.Thread):
def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]: def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]:
"""Resolve the export range into the spans the VOD manifest will serve. """Resolve the export range into the spans the VOD manifest will serve.
Delegates to the same coverage resolution the manifest builder Delegates to the same coverage resolution and glitch nulling the
uses, so what we plan around and what nginx-vod emits agree by manifest builder uses, so what we plan around and what nginx-vod
construction. Returns the spans (each [row, start, end, is_main]), emits agree by construction. Returns the spans (each [row, start,
the known video codecs, and whether audio survives the range. end, is_main]), the known video codecs, and whether audio survives
the range.
Memoized: several stages of the export ask the same question, and Memoized: several stages of the export ask the same question, and
the recordings backing a finished range do not change under us. the recordings backing a finished range do not change under us.
""" """
if self._coverage is None: if self._coverage is None:
intervals = resolve_coverage(self.camera, self.start_time, self.end_time) intervals = null_audio_glitches(
resolve_coverage(self.camera, self.start_time, self.end_time)
)
self._coverage = ( self._coverage = (
build_spans(intervals, self.pinned_stream), build_spans(intervals, self.pinned_stream),
known_video_codecs(intervals), known_video_codecs(intervals),
@@ -433,17 +448,57 @@ class RecordingExporter(threading.Thread):
# hand-off to stage around # hand-off to stage around
return True return True
spans, codecs, keep_audio = self._resolve_coverage() _spans, codecs, keep_audio = self._resolve_coverage()
runs = self._stream_runs(spans) runs = self._planned_stream_runs()
# a range one stream covers end to end has nothing to hand off, # a range one stream covers end to end has nothing to hand off,
# so it stays on the existing path however long it is # so it stays on the existing path however long it is
if len(runs) < 2: if len(runs) < 2:
return True return True
runs = [piece for run in runs for piece in self._split_long_run(run)]
return self._stage_stream_runs(runs, codecs, keep_audio) return self._stage_stream_runs(runs, codecs, keep_audio)
def _planned_stream_runs(self) -> list[StreamRun]:
"""The runs a mixed range is staged as, one pinned vod playlist each."""
runs = self._stream_runs(self._merged_spans())
if len(runs) < 2:
return runs
return [piece for run in runs for piece in self._split_long_run(run)]
def _staged_chapter_windows(self) -> list[_ChapterWindow]:
"""Chapter windows for the staged files as they were rendered.
Each staged run comes from its own pinned vod playlist, whose first
clip snaps back to the preceding keyframe, so a staged file runs up
to a GOP longer than its slice of the merged timeline. Planning each
run the way its playlist does carries that lead-in into the chapter
offsets instead of letting it accumulate at every hand-off.
"""
windows: list[_ChapterWindow] = []
for run in self._planned_stream_runs():
intervals = null_audio_glitches(
resolve_coverage(self.camera, run.start_time, run.end_time)
)
for clip in realized_timeline(intervals, run.stream_type):
# a skipped clip is absent from the playlist and the file
if clip["duration"] <= 0:
continue
span = clip["end_time"] - clip["start_time"]
windows.append(
_ChapterWindow(
clip["start_time"],
clip["end_time"],
max(0.0, clip["duration"] / 1000 - span),
)
)
return windows
def _stream_runs(self, spans: list[list[Any]]) -> list[StreamRun]: def _stream_runs(self, spans: list[list[Any]]) -> list[StreamRun]:
"""Collapse the merged spans into contiguous runs of one stream type. """Collapse the merged spans into contiguous runs of one stream type.
@@ -840,6 +895,8 @@ class RecordingExporter(threading.Thread):
clipped_end = min(float(rec.end_time), float(self.end_time)) clipped_end = min(float(rec.end_time), float(self.end_time))
if clipped_end <= clipped_start: if clipped_end <= clipped_start:
continue continue
# a staged window's keyframe lead-in plays before it
output_offset += _lead_in(rec)
windows.append((clipped_start, clipped_end, output_offset)) windows.append((clipped_start, clipped_end, output_offset))
output_offset += clipped_end - clipped_start output_offset += clipped_end - clipped_start
@@ -987,9 +1044,13 @@ class RecordingExporter(threading.Thread):
if duration_ms <= 0: if duration_ms <= 0:
continue continue
title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat( # a staged window's keyframe lead-in opens its chapter, with
timespec="seconds" # frames captured that long before the window
) lead_in = _lead_in(rec)
duration_ms += int(round(lead_in * 1000))
title = datetime.datetime.fromtimestamp(
clipped_start - lead_in, tz=tz
).isoformat(timespec="seconds")
chapter_blocks.append( chapter_blocks.append(
"[CHAPTER]\n" "[CHAPTER]\n"
"TIMEBASE=1/1000\n" "TIMEBASE=1/1000\n"
@@ -1128,12 +1189,12 @@ class RecordingExporter(threading.Thread):
if self.staged_runs: if self.staged_runs:
# each run was already rendered to a temp file with a common # each run was already rendered to a temp file with a common
# track timescale, so the concat demuxer has nothing left to # track timescale, so the concat demuxer has nothing left to
# reconcile and every chapter offset lines up with the merged # reconcile
# timeline the staged files reproduce recordings = (
recordings = [ self._staged_chapter_windows()
_ChapterWindow(span_start, span_end) if self.chapters not in (None, ChaptersEnum.none)
for _row, span_start, span_end, _is_main in self._merged_spans() else []
] )
playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs] playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs]
ffmpeg_input = ( ffmpeg_input = (
"-y -protocol_whitelist pipe,file -f concat -safe 0 -i /dev/stdin" "-y -protocol_whitelist pipe,file -f concat -safe 0 -i /dev/stdin"
@@ -1149,11 +1210,18 @@ class RecordingExporter(threading.Thread):
# its own rows are the ones the chapters describe # its own rows are the ones the chapters describe
recordings = self._get_recordings_for_range(pin) recordings = self._get_recordings_for_range(pin)
else: else:
# never mix streams in one playlist; use main when available # an unstaged auto range resolves to at most one stream run, and
# and fall back to sub for expired-main history # its rows are the ones the chapters describe. Main rows the
recordings = self._get_recordings_for_range(STREAM_TYPE_MAIN) # manifest drops (glitches, slivers at the edges of a sub range)
# must not stand in for it.
runs = self._stream_runs(self._merged_spans())
recordings = self._get_recordings_for_range(
runs[0].stream_type if runs else STREAM_TYPE_MAIN
)
if not recordings: # never mix streams in one playlist; fall back to sub for
# expired-main history
if not recordings and not runs:
recordings = self._get_recordings_for_range(STREAM_TYPE_SUB) recordings = self._get_recordings_for_range(STREAM_TYPE_SUB)
playlist_lines = [] playlist_lines = []
+42 -4
View File
@@ -490,9 +490,17 @@ class RecordingMaintainer(threading.Thread):
) )
reviews = reviews_by_camera[camera] reviews = reviews_by_camera[camera]
tasks.extend( # probes run concurrently, but each segment's start chains off the
[self.validate_and_move_segment(camera, reviews, r) for r in recordings] # previous segment's end, so starts resolve in segment order
) previous: asyncio.Event | None = None
for recording in recordings:
resolved = asyncio.Event()
tasks.append(
self._validate_in_order(
camera, reviews, recording, previous, resolved
)
)
previous = resolved
# publish most recently available recording time and None if disabled # publish most recently available recording time and None if disabled
if stream_type == STREAM_TYPE_MAIN: if stream_type == STREAM_TYPE_MAIN:
@@ -550,12 +558,33 @@ class RecordingMaintainer(threading.Thread):
while info and info[0][0] < expire_before: while info and info[0][0] < expire_before:
info.pop(0) info.pop(0)
async def _validate_in_order(
self,
camera: str,
reviews: Any,
recording: dict[str, Any],
previous_start: asyncio.Event | None,
start_resolved: asyncio.Event,
) -> dict[str, Any] | None:
"""Validate a segment, always releasing the next one in its stream."""
try:
return await self.validate_and_move_segment(
camera, reviews, recording, previous_start, start_resolved
)
finally:
start_resolved.set()
def drop_segment(self, cache_path: str) -> None: def drop_segment(self, cache_path: str) -> None:
Path(cache_path).unlink(missing_ok=True) Path(cache_path).unlink(missing_ok=True)
self.end_time_cache.pop(cache_path, None) self.end_time_cache.pop(cache_path, None)
async def validate_and_move_segment( async def validate_and_move_segment(
self, camera: str, reviews: Any, recording: dict[str, Any] self,
camera: str,
reviews: Any,
recording: dict[str, Any],
previous_start: asyncio.Event | None = None,
start_resolved: asyncio.Event | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
cache_path: str = recording["cache_path"] cache_path: str = recording["cache_path"]
start_time: datetime.datetime = recording["start_time"] start_time: datetime.datetime = recording["start_time"]
@@ -617,6 +646,9 @@ class RecordingMaintainer(threading.Thread):
async with self.probe_semaphore: async with self.probe_semaphore:
keyframes = await get_keyframe_offsets(cache_path) keyframes = await get_keyframe_offsets(cache_path)
if previous_start is not None:
await previous_start.wait()
start_time = self._resolve_segment_start( start_time = self._resolve_segment_start(
camera, stream_type, start_time, duration, cache_path camera, stream_type, start_time, duration, cache_path
) )
@@ -654,6 +686,11 @@ class RecordingMaintainer(threading.Thread):
RecordingsDataTypeEnum.valid.value, RecordingsDataTypeEnum.valid.value,
) )
# the start is settled, so the next segment of the stream can chain
# off it while this one waits on retention and the move
if start_resolved is not None:
start_resolved.set()
record_config = self.config.cameras[camera].record record_config = self.config.cameras[camera].record
# sub's alerts/detections carry the retain mode directly, unlike # sub's alerts/detections carry the retain mode directly, unlike
@@ -1090,6 +1127,7 @@ class RecordingMaintainer(threading.Thread):
elif ( elif (
topic == DetectionTypeEnum.api.value topic == DetectionTypeEnum.api.value
or topic == DetectionTypeEnum.lpr.value or topic == DetectionTypeEnum.lpr.value
or topic == DetectionTypeEnum.classification_state.value
): ):
continue continue
+69 -12
View File
@@ -40,6 +40,10 @@ logger = logging.getLogger(__name__)
THUMB_HEIGHT = 180 THUMB_HEIGHT = 180
THUMB_WIDTH = 320 THUMB_WIDTH = 320
# seconds before a review item starts that a state classification change is
# still attached to it, e.g. a garage door opening before the car is visible
CLASSIFICATION_STATE_PRE_ROLL = 5
class PendingReviewSegment: class PendingReviewSegment:
def __init__( def __init__(
@@ -61,6 +65,7 @@ class PendingReviewSegment:
self.sub_labels = sub_labels self.sub_labels = sub_labels
self.zones = zones self.zones = zones
self.audio = audio self.audio = audio
self.classification_state_changes: list[dict[str, Any]] = []
self.thumb_time: float | None = None self.thumb_time: float | None = None
self.last_alert_time: float | None = None self.last_alert_time: float | None = None
self.last_detection_time: float = frame_time self.last_detection_time: float = frame_time
@@ -162,6 +167,7 @@ class PendingReviewSegment:
"sub_labels": list(self.sub_labels.values()), "sub_labels": list(self.sub_labels.values()),
"zones": self.zones, "zones": self.zones,
"audio": list(self.audio), "audio": list(self.audio),
"classification_state_changes": self.classification_state_changes,
"thumb_time": self.thumb_time, "thumb_time": self.thumb_time,
"metadata": None, "metadata": None,
}, },
@@ -293,6 +299,9 @@ class ReviewSegmentMaintainer(threading.Thread):
# manual events # manual events
self.indefinite_events: dict[str, dict[str, Any]] = {} self.indefinite_events: dict[str, dict[str, Any]] = {}
# state classification changes seen while a camera had no review item
self.recent_classification_state_changes: dict[str, list[dict[str, Any]]] = {}
# ensure dirs # ensure dirs
Path(os.path.join(CLIPS_DIR, "review")).mkdir(exist_ok=True) Path(os.path.join(CLIPS_DIR, "review")).mkdir(exist_ok=True)
@@ -374,6 +383,43 @@ class ReviewSegmentMaintainer(threading.Thread):
self.active_review_segments[segment.camera] = None self.active_review_segments[segment.camera] = None
return end_time return end_time
def _activate_segment(self, segment: PendingReviewSegment) -> None:
"""Make a segment the camera's active one, attaching any state
classification changes seen just before it started."""
self.active_review_segments[segment.camera] = segment
recent = self.recent_classification_state_changes.pop(segment.camera, [])
segment.classification_state_changes.extend(
c
for c in recent
if c["timestamp"] >= segment.start_time - CLASSIFICATION_STATE_PRE_ROLL
)
def handle_classification_state_change(
self, camera: str, change: dict[str, Any]
) -> None:
"""Attach a verified state classification change to the active segment.
State changes never start, extend, or upgrade a segment. A change seen
with no active segment is held briefly for a segment starting right
after it.
"""
segment = self.active_review_segments.get(camera)
if segment is None:
cutoff = change["timestamp"] - CLASSIFICATION_STATE_PRE_ROLL
self.recent_classification_state_changes[camera] = [
c
for c in self.recent_classification_state_changes.get(camera, [])
if c["timestamp"] >= cutoff
] + [change]
return
prev_data = segment.get_data(False)
segment.classification_state_changes.append(change)
self._publish_segment_update(
segment, self.config.cameras[camera], None, [], prev_data
)
def forcibly_end_segment(self, camera: str) -> Any: def forcibly_end_segment(self, camera: str) -> Any:
"""Forcibly end the pending segment for a camera.""" """Forcibly end the pending segment for a camera."""
segment = self.active_review_segments.get(camera) segment = self.active_review_segments.get(camera)
@@ -422,6 +468,7 @@ class ReviewSegmentMaintainer(threading.Thread):
"""Close out a deleted camera's segment so a reused name cannot inherit it.""" """Close out a deleted camera's segment so a reused name cannot inherit it."""
self.forcibly_end_segment(camera) self.forcibly_end_segment(camera)
self.indefinite_events.pop(camera, None) self.indefinite_events.pop(camera, None)
self.recent_classification_state_changes.pop(camera, None)
def update_existing_segment( def update_existing_segment(
self, self,
@@ -579,7 +626,7 @@ class ReviewSegmentMaintainer(threading.Thread):
audio=set(), audio=set(),
zones=list(new_zones), zones=list(new_zones),
) )
self.active_review_segments[segment.camera] = new_segment self._activate_segment(new_segment)
self._publish_segment_start(new_segment) self._publish_segment_start(new_segment)
new_segment.last_detection_time = last_detection_time new_segment.last_detection_time = last_detection_time
elif segment.severity == SeverityEnum.detection and frame_time > ( elif segment.severity == SeverityEnum.detection and frame_time > (
@@ -639,7 +686,7 @@ class ReviewSegmentMaintainer(threading.Thread):
audio=set(), audio=set(),
zones=zones, zones=zones,
) )
self.active_review_segments[camera] = new_segment self._activate_segment(new_segment)
try: try:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
@@ -713,6 +760,10 @@ class ReviewSegmentMaintainer(threading.Thread):
if camera not in self.indefinite_events: if camera not in self.indefinite_events:
self.indefinite_events[camera] = {} self.indefinite_events[camera] = {}
elif topic == DetectionTypeEnum.classification_state.value:
(camera, classification_change) = data
else:
continue
if camera not in self.config.cameras: if camera not in self.config.cameras:
continue continue
@@ -723,6 +774,10 @@ class ReviewSegmentMaintainer(threading.Thread):
): ):
continue continue
if topic == DetectionTypeEnum.classification_state:
self.handle_classification_state_change(camera, classification_change)
continue
current_segment = self.active_review_segments.get(camera) current_segment = self.active_review_segments.get(camera)
# Check if the current segment should be processed based on enabled settings # Check if the current segment should be processed based on enabled settings
@@ -864,14 +919,16 @@ class ReviewSegmentMaintainer(threading.Thread):
severity = SeverityEnum.detection severity = SeverityEnum.detection
if severity: if severity:
self.active_review_segments[camera] = PendingReviewSegment( self._activate_segment(
camera, PendingReviewSegment(
frame_time, camera,
severity, frame_time,
{}, severity,
{}, {},
[], {},
detections, [],
detections,
)
) )
elif topic == DetectionTypeEnum.api: elif topic == DetectionTypeEnum.api:
severity = self.get_manual_event_severity( severity = self.get_manual_event_severity(
@@ -888,7 +945,7 @@ class ReviewSegmentMaintainer(threading.Thread):
[], [],
set(), set(),
) )
self.active_review_segments[camera] = api_segment self._activate_segment(api_segment)
if manual_info["state"] == ManualEventState.start: if manual_info["state"] == ManualEventState.start:
self.indefinite_events[camera][manual_info["event_id"]] = ( self.indefinite_events[camera][manual_info["event_id"]] = (
@@ -915,7 +972,7 @@ class ReviewSegmentMaintainer(threading.Thread):
[], [],
set(), set(),
) )
self.active_review_segments[camera] = lpr_segment self._activate_segment(lpr_segment)
if manual_info["state"] == ManualEventState.start: if manual_info["state"] == ManualEventState.start:
self.indefinite_events[camera][manual_info["event_id"]] = ( self.indefinite_events[camera][manual_info["event_id"]] = (
+61 -15
View File
@@ -29,38 +29,59 @@ VERSION_REFRESH_S = 24 * 60 * 60
# a camera skipping at least this percent of its frames is falling behind # a camera skipping at least this percent of its frames is falling behind
SKIPPED_DETECTIONS_PCT = 5 SKIPPED_DETECTIONS_PCT = 5
# for at least this long before it becomes a notice # lifetime CPU averages at or above these are high; they match
SKIPPED_DETECTIONS_HOLD_S = 60 # CameraFfmpegThreshold.error and CameraDetectThreshold.error in the UI
FFMPEG_HIGH_CPU_PCT = 20
DETECT_HIGH_CPU_PCT = 40
# a camera stays over a threshold this long before it becomes a notice
EPISODE_HOLD_S = 60
# detectors warm up after a start; the status bar waits this long too # detectors warm up after a start; the status bar waits this long too
STARTUP_GRACE_S = 120 STARTUP_GRACE_S = 120
class SkippedDetectionsTracker: def cpu_average(cpu_usages: dict[str, Any], pid: int | None) -> float | None:
"""Finds cameras whose skipped share stays high long enough for a notice.""" """A process's CPU use averaged over its lifetime, or None if unknown."""
usage = cpu_usages.get(str(pid)) if pid else None
def __init__(self) -> None: try:
return float(usage["cpu_average"]) if usage else None
except (KeyError, TypeError, ValueError):
return None
class EpisodeTracker:
"""Finds cameras whose value stays over a threshold long enough for a notice."""
def __init__(self, threshold: float) -> None:
self._threshold = threshold
self._since: dict[str, float] = {} self._since: dict[str, float] = {}
self._raised: set[str] = set() self._raised: set[str] = set()
def update(self, cameras: dict[str, dict[str, Any]], now: float) -> list[str]: def update(self, values: dict[str, float | None], now: float) -> list[str]:
"""Return the cameras whose episode qualified on this sample.""" """Return the cameras whose episode qualified on this sample.
Args:
values: Each camera's current value, or None when it has none
now: Sample time in seconds
"""
qualified: list[str] = [] qualified: list[str] = []
# a removed camera that comes back starts a new episode # a removed camera that comes back starts a new episode
for camera in self._since.keys() - cameras.keys(): for camera in self._since.keys() - values.keys():
self._since.pop(camera) self._since.pop(camera)
self._raised.discard(camera) self._raised.discard(camera)
for camera, camera_stats in cameras.items(): for camera, value in values.items():
if camera_stats["skipped_pct"] < SKIPPED_DETECTIONS_PCT: if value is None or value < self._threshold:
self._since.pop(camera, None) self._since.pop(camera, None)
self._raised.discard(camera) self._raised.discard(camera)
continue continue
since = self._since.setdefault(camera, now) since = self._since.setdefault(camera, now)
if camera not in self._raised and now - since >= SKIPPED_DETECTIONS_HOLD_S: if camera not in self._raised and now - since >= EPISODE_HOLD_S:
self._raised.add(camera) self._raised.add(camera)
qualified.append(camera) qualified.append(camera)
@@ -80,7 +101,9 @@ class StatsEmitter(threading.Thread):
self.stop_event = stop_event self.stop_event = stop_event
self.hardware_stats = HardwareStats(config) self.hardware_stats = HardwareStats(config)
self.stats_history: list[dict[str, Any]] = [] self.stats_history: list[dict[str, Any]] = []
self.skipped_detections = SkippedDetectionsTracker() self.skipped_detections = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
self.ffmpeg_cpu = EpisodeTracker(FFMPEG_HIGH_CPU_PCT)
self.detect_cpu = EpisodeTracker(DETECT_HIGH_CPU_PCT)
# the shm notice's params as last sent, so only a change is written # the shm notice's params as last sent, so only a change is written
self._shm_checked = False self._shm_checked = False
@@ -227,15 +250,38 @@ class StatsEmitter(threading.Thread):
def _update_notices(self, stats: dict[str, Any], now: float) -> None: def _update_notices(self, stats: dict[str, Any], now: float) -> None:
"""Update notices based on current stats or time.""" """Update notices based on current stats or time."""
# skipped detections cameras = stats["cameras"]
# absent when CPU collection timed out or failed on this tick
cpu_usages = stats.get("cpu_usages", {})
if stats["service"]["uptime"] >= STARTUP_GRACE_S: if stats["service"]["uptime"] >= STARTUP_GRACE_S:
for camera in self.skipped_detections.update(stats["cameras"], now): # skipped detections
skipped = {
camera: camera_stats["skipped_pct"]
for camera, camera_stats in cameras.items()
}
for camera in self.skipped_detections.update(skipped, now):
raise_notice( raise_notice(
"skipped_detections", "skipped_detections",
scope=camera, scope=camera,
params={"pct": stats["cameras"][camera]["skipped_pct"]}, params={"pct": skipped[camera]},
) )
# high ffmpeg and detect CPU
for tracker, kind, pid_key in (
(self.ffmpeg_cpu, "ffmpeg_high_cpu", "ffmpeg_pid"),
(self.detect_cpu, "detect_high_cpu", "pid"),
):
averages = {
camera: cpu_average(cpu_usages, camera_stats.get(pid_key))
for camera, camera_stats in cameras.items()
}
for camera in tracker.update(averages, now):
raise_notice(kind, scope=camera, params={"cpu": averages[camera]})
# shm too small for the cameras # shm too small for the cameras
self._update_shm_notice(stats["service"]["storage"]["/dev/shm"]) self._update_shm_notice(stats["service"]["storage"]["/dev/shm"])
+11
View File
@@ -256,6 +256,17 @@ class HardwareStats:
) )
self.update_config() self.update_config()
def set_config(self, config: FrigateConfig) -> None:
"""Follow a runtime config swap and recalculate the monitored hardware.
The camera update subscriber has to follow too, or later camera updates
would land on the discarded config.
"""
self.config = config
self._config_subscriber.config = config
self._config_subscriber.camera_configs = config.cameras
self.update_config()
def update_config(self) -> None: def update_config(self) -> None:
"""Recalculate all hardware that needs to be monitored from the config.""" """Recalculate all hardware that needs to be monitored from the config."""
names = self._scan_ffmpeg() | self._scan_detectors() | self._scan_enrichments() names = self._scan_ffmpeg() | self._scan_detectors() | self._scan_enrichments()
+7 -4
View File
@@ -111,15 +111,18 @@ def get_detector_stats(
) -> dict[str, dict[str, Any]]: ) -> dict[str, dict[str, Any]]:
"""Get stats for all detectors, including temperatures based on detector type.""" """Get stats for all detectors, including temperatures based on detector type."""
detector_stats: dict[str, dict[str, Any]] = {} detector_stats: dict[str, dict[str, Any]] = {}
detector_type_indices: dict[str, int] = {} # detector type -> device -> index into that type's temperatures
device_indices: dict[str, dict[str, int]] = {}
for name, detector in stats_tracking["detectors"].items(): for name, detector in stats_tracking["detectors"].items():
pid = detector.detect_process.pid if detector.detect_process else None pid = detector.detect_process.pid if detector.detect_process else None
detector_type = detector.detector_config.type detector_type = detector.detector_config.type
# Keep track of the index for each detector type to match temperatures correctly # temperatures are per physical unit, so a repeated device
current_index = detector_type_indices.get(detector_type, 0) # ("hailo:PCIe#2", see runner_names) shares its unit's reading
detector_type_indices[detector_type] = current_index + 1 device = name.partition("#")[0]
type_devices = device_indices.setdefault(detector_type, {})
current_index = type_devices.setdefault(device, len(type_devices))
detector_stat = { detector_stat = {
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined] "inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
+41 -1
View File
@@ -1,8 +1,10 @@
import json
import os
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import frigate.genai import frigate.genai
from frigate.config import GenAIProviderEnum from frigate.config import GenAIProviderEnum
from frigate.const import REDACTED_CREDENTIAL_SENTINEL from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL
from frigate.genai import GenAIClient from frigate.genai import GenAIClient
from frigate.models import Event, Recordings, ReviewSegment from frigate.models import Event, Recordings, ReviewSegment
from frigate.stats.emitter import StatsEmitter from frigate.stats.emitter import StatsEmitter
@@ -90,6 +92,44 @@ class TestHttpApp(BaseTestHttp):
mqtt = response.json()["mqtt"] mqtt = response.json()["mqtt"]
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
def test_config_response_keeps_plus_model_reference(self):
model_id = "test_plus_reference"
model_path = os.path.join(MODEL_CACHE_DIR, model_id)
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
with open(model_path, "w") as f:
f.write("model")
with open(f"{model_path}.json", "w") as f:
json.dump(
{
"id": model_id,
"type": "ssd",
"supportedDetectors": ["cpu"],
"width": 320,
"height": 320,
"inputShape": "nhwc",
"pixelFormat": "rgb",
"labelMap": {"0": "person"},
},
f,
)
self.addCleanup(os.remove, model_path)
self.addCleanup(os.remove, f"{model_path}.json")
self.minimal_config["models"] = [
{"path": f"plus://{model_id}", "devices": ["cpu"]}
]
app = super().create_app()
with AuthTestClient(app) as client:
response = client.get("/config")
assert response.status_code == 200
assert response.json()["models"][0]["path"] == f"plus://{model_id}"
# detection still loads the resolved cache file
assert app.frigate_config.models[0].path == model_path
#################################################################################################################### ####################################################################################################################
################################### POST /genai/probe Endpoint ################################################## ################################### POST /genai/probe Endpoint ##################################################
#################################################################################################################### ####################################################################################################################
+61 -45
View File
@@ -53,92 +53,108 @@ class TestHttpNotices(BaseTestHttp):
self.assertEqual(response.json()[0]["kind"], "detector_stuck") self.assertEqual(response.json()[0]["kind"], "detector_stuck")
self.assertEqual(response.json()[0]["occurrences"], 1) self.assertEqual(response.json()[0]["occurrences"], 1)
def test_dismiss_hides_and_history_keeps_it(self): def test_acknowledge_hides_and_the_hidden_list_keeps_it(self):
self.registry.raise_notice( self.registry.raise_notice(
"detector_stuck", scope="ov", params={"detector": "ov"} "detector_stuck", scope="ov", params={"detector": "ov"}
) )
with self.client() as client: with self.client() as client:
response = client.post("/notices/detector_stuck:ov/dismiss") response = client.post("/notices/detector_stuck:ov/acknowledge")
listed = client.get("/notices").json() listed = client.get("/notices").json()
history = client.get("/notices", params={"include_dismissed": True}).json() hidden = client.get("/notices", params={"include_hidden": True}).json()
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(listed, []) self.assertEqual(listed, [])
self.assertEqual([n["id"] for n in history], ["detector_stuck:ov"]) self.assertEqual([n["id"] for n in hidden], ["detector_stuck:ov"])
self.assertIsNotNone(history[0]["dismissed_at"]) self.assertIsNotNone(hidden[0]["acknowledged_at"])
def test_dismiss_an_id_with_a_slash(self): def test_acknowledging_a_check_is_404(self):
with self.client() as client:
response = client.post(f"/notices/{CONFIG_CHECK}/acknowledge")
self.assertEqual(response.status_code, 404)
def test_mute_an_id_with_a_slash(self):
self.registry.raise_notice( self.registry.raise_notice(
"model_download_failed", scope="yolo/model.onnx", params={} "model_download_failed", scope="yolo/model.onnx", params={}
) )
with self.client() as client: with self.client() as client:
response = client.post( response = client.post(
"/notices/model_download_failed:yolo/model.onnx/dismiss" "/notices/model_download_failed:yolo/model.onnx/mute"
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(self.registry.active(), []) self.assertEqual(self.registry.active(), [])
def test_dismiss_unknown_is_404(self): def test_unknown_ids_are_404(self):
with self.client() as client: with self.client() as client:
response = client.post("/notices/nope/dismiss") responses = [
client.post("/notices/nope/acknowledge"),
client.post("/notices/nope/mute"),
client.delete("/notices/nope/hidden"),
]
self.assertEqual(response.status_code, 404) self.assertEqual([r.status_code for r in responses], [404, 404, 404])
def test_requires_admin(self): def test_requires_admin(self):
viewer = {"remote-user": "viewer", "remote-role": "viewer"}
with TestClient(self.app) as client: with TestClient(self.app) as client:
response = client.get( responses = [
"/notices", headers={"remote-user": "viewer", "remote-role": "viewer"} client.get("/notices", headers=viewer),
) client.get("/notices/muted_checks", headers=viewer),
client.post("/notices/detector_stuck:ov/acknowledge", headers=viewer),
client.post("/notices/detector_stuck:ov/mute", headers=viewer),
client.delete("/notices/detector_stuck:ov/hidden", headers=viewer),
client.delete("/notices/hidden", headers=viewer),
]
self.assertEqual(response.status_code, 403) self.assertEqual([r.status_code for r in responses], [403] * 6)
def test_dismissed_checks_are_listed_once(self): def test_muted_checks_are_listed_once(self):
with self.client() as client: with self.client() as client:
first = client.post(f"/notices/{CONFIG_CHECK}/dismiss") first = client.post(f"/notices/{CONFIG_CHECK}/mute")
again = client.post(f"/notices/{CONFIG_CHECK}/dismiss") again = client.post(f"/notices/{CONFIG_CHECK}/mute")
listed = client.get("/notices/dismissed_checks").json() listed = client.get("/notices/muted_checks").json()
history = client.get("/notices", params={"include_dismissed": True}).json() hidden = client.get("/notices", params={"include_hidden": True}).json()
self.assertEqual(first.status_code, 200) self.assertEqual(first.status_code, 200)
self.assertEqual(again.status_code, 200) self.assertEqual(again.status_code, 200)
self.assertEqual([check["id"] for check in listed], [CONFIG_CHECK]) self.assertEqual([check["id"] for check in listed], [CONFIG_CHECK])
self.assertIsNotNone(listed[0]["dismissed_at"]) self.assertIsNotNone(listed[0]["muted_at"])
# the Health tab builds the row itself, so it never comes back as a notice # the Health tab builds the row itself, so it never comes back as a notice
self.assertEqual(history, []) self.assertEqual(hidden, [])
def test_dismissed_checks_require_admin(self): def test_unhide_one_row(self):
with TestClient(self.app) as client:
response = client.get(
"/notices/dismissed_checks",
headers={"remote-user": "viewer", "remote-role": "viewer"},
)
self.assertEqual(response.status_code, 403)
def test_purge_dismissed_clears_notices_and_checks(self):
self.registry.raise_notice( self.registry.raise_notice(
"detector_stuck", scope="ov", params={"detector": "ov"} "detector_stuck", scope="ov", params={"detector": "ov"}
) )
self.registry.dismiss("detector_stuck:ov") self.registry.mute("detector_stuck:ov")
self.registry.dismiss(CONFIG_CHECK) self.registry.mute(CONFIG_CHECK)
with self.client() as client: with self.client() as client:
response = client.delete("/notices/dismissed") notice = client.delete("/notices/detector_stuck:ov/hidden")
history = client.get("/notices", params={"include_dismissed": True}).json() check = client.delete(f"/notices/{CONFIG_CHECK}/hidden")
checks = client.get("/notices/dismissed_checks").json() listed = client.get("/notices").json()
checks = client.get("/notices/muted_checks").json()
self.assertEqual(response.status_code, 200) self.assertEqual([notice.status_code, check.status_code], [200, 200])
self.assertEqual(history, []) self.assertEqual([n["id"] for n in listed], ["detector_stuck:ov"])
self.assertEqual(checks, []) self.assertEqual(checks, [])
def test_purge_dismissed_requires_admin(self): def test_unhide_all_shows_notices_and_drops_check_mutes(self):
with TestClient(self.app) as client: self.registry.raise_notice(
response = client.delete( "detector_stuck", scope="ov", params={"detector": "ov"}
"/notices/dismissed", )
headers={"remote-user": "viewer", "remote-role": "viewer"}, self.registry.acknowledge("detector_stuck:ov")
) self.registry.mute(CONFIG_CHECK)
self.assertEqual(response.status_code, 403) with self.client() as client:
response = client.delete("/notices/hidden")
listed = client.get("/notices").json()
checks = client.get("/notices/muted_checks").json()
self.assertEqual(response.status_code, 200)
self.assertEqual([n["id"] for n in listed], ["detector_stuck:ov"])
self.assertEqual(checks, [])
+4
View File
@@ -179,12 +179,16 @@ class TestReviewMaintainerRemoval(unittest.TestCase):
maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer) maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer)
maintainer.active_review_segments = {"deleted_cam": MagicMock()} maintainer.active_review_segments = {"deleted_cam": MagicMock()}
maintainer.indefinite_events = {"deleted_cam": {"1234.5-abcdef": 1.0}} maintainer.indefinite_events = {"deleted_cam": {"1234.5-abcdef": 1.0}}
maintainer.recent_classification_state_changes = {
"deleted_cam": [{"model": "gate", "from": "a", "to": "b", "timestamp": 1.0}]
}
maintainer.forcibly_end_segment = MagicMock() maintainer.forcibly_end_segment = MagicMock()
maintainer._handle_camera_removed("deleted_cam") maintainer._handle_camera_removed("deleted_cam")
maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam") maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam")
self.assertNotIn("deleted_cam", maintainer.indefinite_events) self.assertNotIn("deleted_cam", maintainer.indefinite_events)
self.assertNotIn("deleted_cam", maintainer.recent_classification_state_changes)
class TestAutotrackerMoveQueue(unittest.TestCase): class TestAutotrackerMoveQueue(unittest.TestCase):
+1
View File
@@ -26,6 +26,7 @@ class TestSwapRuntimeConfig(unittest.TestCase):
app.genai_manager.update_config.assert_called_once_with(config) app.genai_manager.update_config.assert_called_once_with(config)
app.profile_manager.update_config.assert_called_once_with(config) app.profile_manager.update_config.assert_called_once_with(config)
self.assertIs(app.stats_emitter.config, config) self.assertIs(app.stats_emitter.config, config)
app.stats_emitter.hardware_stats.set_config.assert_called_once_with(config)
self.assertIs(app.dispatcher.config, config) self.assertIs(app.dispatcher.config, config)
for comm in app.dispatcher.comms: for comm in app.dispatcher.comms:
self.assertIs(comm.config, config) self.assertIs(comm.config, config)
+5
View File
@@ -59,6 +59,10 @@ class TestBuildDetectorConfig(unittest.TestCase):
def test_detectors_that_name_the_field_something_else(self): def test_detectors_that_name_the_field_something_else(self):
self.assertEqual(self._build("cpu:4").num_threads, 4) self.assertEqual(self._build("cpu:4").num_threads, 4)
self.assertEqual(self._build("rknn:2").num_cores, 2) self.assertEqual(self._build("rknn:2").num_cores, 2)
self.assertEqual(
self._build("zmq:tcp://host.docker.internal:5555").endpoint,
"tcp://host.docker.internal:5555",
)
def test_device_is_coerced_to_the_detector_field_type(self): def test_device_is_coerced_to_the_detector_field_type(self):
self.assertEqual(self._build("tensorrt:1").device, 1) self.assertEqual(self._build("tensorrt:1").device, 1)
@@ -66,6 +70,7 @@ class TestBuildDetectorConfig(unittest.TestCase):
def test_omitted_device_falls_back_to_the_detector_default(self): def test_omitted_device_falls_back_to_the_detector_default(self):
self.assertEqual(self._build("cpu").num_threads, 3) self.assertEqual(self._build("cpu").num_threads, 3)
self.assertEqual(self._build("rknn").num_cores, 0) self.assertEqual(self._build("rknn").num_cores, 0)
self.assertEqual(self._build("zmq").endpoint, "ipc:///tmp/cache/zmq_detector")
self.assertEqual(self._build("openvino").device, "AUTO") self.assertEqual(self._build("openvino").device, "AUTO")
self.assertIsNone(self._build("edgetpu").device) self.assertIsNone(self._build("edgetpu").device)
+24 -1
View File
@@ -25,7 +25,7 @@ class HardwareProbeTestCase(unittest.TestCase):
self.root = tempfile.TemporaryDirectory() self.root = tempfile.TemporaryDirectory()
self.addCleanup(self.root.cleanup) self.addCleanup(self.root.cleanup)
for name in ("SYS_ROOT", "DEV_ROOT", "PROC_ROOT", "ETC_ROOT"): for name in ("SYS_ROOT", "DEV_ROOT", "PROC_ROOT", "ETC_ROOT", "LIB_ROOT"):
sub = os.path.join(self.root.name, name.split("_")[0].lower()) sub = os.path.join(self.root.name, name.split("_")[0].lower())
os.makedirs(sub, exist_ok=True) os.makedirs(sub, exist_ok=True)
patcher = patch.object(hardware, name, sub) patcher = patch.object(hardware, name, sub)
@@ -167,6 +167,29 @@ class TestNvidia(HardwareProbeTestCase):
class TestAccelerators(HardwareProbeTestCase): class TestAccelerators(HardwareProbeTestCase):
def test_the_neural_engine_is_found_by_lighters_provider_library(self):
write(os.path.join(self.lib_root, "lighter", "liblighter_ane_ep.so"))
with patch.dict(os.environ, clear=False) as env:
env.pop("LIGHTER_ANE_EP", None)
ane = self.probe()["onnx:lighter"]
self.assertEqual(ane.detector, "onnx")
self.assertEqual(ane.units[0].device, "onnx")
self.assertEqual(ane.units[0].label, "Neural Engine")
def test_the_neural_engine_is_found_where_lighter_ane_ep_points(self):
library = os.path.join(self.root.name, "elsewhere", "liblighter_ane_ep.so")
write(library)
with patch.dict(os.environ, {"LIGHTER_ANE_EP": library}):
self.assertIn("onnx:lighter", self.probe())
def test_no_neural_engine_is_reported_without_the_library(self):
with patch.dict(os.environ, clear=False) as env:
env.pop("LIGHTER_ANE_EP", None)
self.assertNotIn("onnx:lighter", self.probe())
def test_hailo_is_found_by_its_device_node(self): def test_hailo_is_found_by_its_device_node(self):
write(os.path.join(self.dev_root, "hailo0")) write(os.path.join(self.dev_root, "hailo0"))
+39
View File
@@ -0,0 +1,39 @@
"""Tests for per-detector stats."""
import unittest
from unittest.mock import MagicMock, patch
from frigate.stats.util import get_detector_stats
def _detector(detector_type: str) -> MagicMock:
detector = MagicMock()
detector.detector_config.type = detector_type
detector.avg_inference_speed.value = 0.01
detector.detection_start.value = 0.0
detector.detect_process.pid = 1
return detector
class TestDetectorTemperatures(unittest.TestCase):
def test_repeated_device_shares_its_unit_temperature(self):
stats_tracking = {
"detectors": {
"hailo:PCIe": _detector("hailo8l"),
"hailo:PCIe#2": _detector("hailo8l"),
"hailo:PCIe:1": _detector("hailo8l"),
}
}
with patch(
"frigate.stats.util.get_hardware_temperatures", return_value=[50.0, 60.0]
):
stats = get_detector_stats(stats_tracking)
self.assertEqual(stats["hailo:PCIe"]["temperature"], 50.0)
self.assertEqual(stats["hailo:PCIe#2"]["temperature"], 50.0)
self.assertEqual(stats["hailo:PCIe:1"]["temperature"], 60.0)
if __name__ == "__main__":
unittest.main()
+50
View File
@@ -543,6 +543,56 @@ class TestPinnedStream(unittest.TestCase):
self.assertFalse(any("/vod/front/main/" in token for token in cmd)) self.assertFalse(any("/vod/front/main/" in token for token in cmd))
class TestExportTimelineAlignment(unittest.TestCase):
def test_unstaged_auto_reads_the_stream_it_serves(self) -> None:
# a sub-only range whose main rows are glitches the manifest drops
exporter = _make_exporter([_span("/s1.mp4", 1_000, 1_040, False)], {"h264"})
streams: list[str] = []
def rows(stream: str) -> list:
streams.append(stream)
return [_FakeRow(f"/{stream}.mp4")]
exporter._get_recordings_for_range = rows # type: ignore[method-assign]
exporter.get_record_export_command("/exports/out.mp4")
self.assertEqual(streams, ["sub"])
def test_staged_chapters_carry_keyframe_lead_in(self) -> None:
exporter = _make_exporter(
[
_span("/m1.mp4", 1_000, 1_020, True),
_span("/s1.mp4", 1_020, 1_040, False),
],
{"h264"},
)
exporter.config.ui.timezone = None
# each run's vod clip snaps 1.5s back to a keyframe
def timeline(_intervals: list, stream: str) -> list[dict]:
start, end = (1_000, 1_020) if stream == "main" else (1_020, 1_040)
return [
{
"start_time": start,
"end_time": end,
"duration": (end - start + 1.5) * 1000,
}
]
with (
patch("frigate.record.export.resolve_coverage", return_value=[]),
patch("frigate.record.export.realized_timeline", side_effect=timeline),
):
windows = exporter._staged_chapter_windows()
path = exporter._build_recording_segment_chapter_metadata_file(windows)
self.addCleanup(os.remove, path)
content = Path(path).read_text()
self.assertIn("START=0\nEND=21500", content)
self.assertIn("START=21500\nEND=43000", content)
class TestStagedFileCleanup(unittest.TestCase): class TestStagedFileCleanup(unittest.TestCase):
"""A staged path must be tracked before ffmpeg can write to it.""" """A staged path must be tracked before ffmpeg can write to it."""
+10
View File
@@ -51,6 +51,16 @@ class TestFfmpegPresets(unittest.TestCase):
" ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]) " ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"])
) )
def test_ffmpeg_hwaccel_apple_preset_decodes_every_stream(self):
self.default_ffmpeg["cameras"]["back"]["ffmpeg"]["hwaccel_args"] = (
"preset-apple-silicon-h265"
)
frigate_config = FrigateConfig(**self.default_ffmpeg)
cmd = " ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"])
assert "-c:v hevc_v4l2m2m" in cmd
assert "-c:v:1" not in cmd
assert "scale=1920:1080" in cmd
def test_ffmpeg_hwaccel_not_preset(self): def test_ffmpeg_hwaccel_not_preset(self):
self.default_ffmpeg["cameras"]["back"]["ffmpeg"]["hwaccel_args"] = ( self.default_ffmpeg["cameras"]["back"]["ffmpeg"]["hwaccel_args"] = (
"-other-hwaccel args" "-other-hwaccel args"
+45
View File
@@ -7,6 +7,7 @@ from frigate.genai.prompts import (
REVIEW_DESCRIPTION_FIELD_GUIDELINES, REVIEW_DESCRIPTION_FIELD_GUIDELINES,
REVIEW_RESPONSE_STYLES, REVIEW_RESPONSE_STYLES,
build_review_description_prompt, build_review_description_prompt,
build_review_summary_prompt,
get_review_field_guidelines, get_review_field_guidelines,
) )
@@ -83,5 +84,49 @@ class TestReviewResponseStyle(unittest.TestCase):
) )
class TestClassificationStateChanges(unittest.TestCase):
def _build_prompt(self, **extra) -> str:
review_data = {
"camera": "Front Door",
"start": "Monday, 09:30 AM",
"duration": 25,
"zones": [],
"unified_objects": ["person"],
**extra,
}
return build_review_description_prompt(
review_data, [b"fake-image"], [], None, "activity context"
)
def test_no_changes_leaves_prompt_unchanged(self):
self.assertEqual(
self._build_prompt(classification_state_changes=[]),
self._build_prompt(),
)
self.assertNotIn("## State Changes", self._build_prompt())
def test_changes_are_listed_before_objects(self):
change = "front gate changed from closed to open, 12s into the activity"
prompt = self._build_prompt(classification_state_changes=[change])
self.assertIn(f"\n- {change}\n\n## Objects in Scene", prompt)
self.assertLess(
prompt.index("## Sequence Details"), prompt.index("## State Changes")
)
def test_summary_describes_state_changes_only_when_present(self):
event = {"title": "Person at door", "camera": "Front Door", "context": []}
without = build_review_summary_prompt(0, 3600, [event], None)
self.assertNotIn('"state_changes"', without)
context_event = {
**event,
"context": [
{"camera": "Driveway", "state_changes": ["gate changed from a to b"]}
],
}
with_changes = build_review_summary_prompt(0, 3600, [context_event], None)
self.assertIn('- "state_changes"', with_changes)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+58 -2
View File
@@ -19,6 +19,8 @@ import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import requests
from frigate.config import GenAIConfig, GenAIProviderEnum from frigate.config import GenAIConfig, GenAIProviderEnum
from frigate.genai import PROVIDERS, load_providers from frigate.genai import PROVIDERS, load_providers
@@ -566,7 +568,6 @@ class TestLlamaCppProvider(unittest.TestCase):
"supports_audio": False, "supports_audio": False,
"supports_tools": False, "supports_tools": False,
"supports_reasoning": False, "supports_reasoning": False,
"media_marker": "<__media__>",
} }
cls = PROVIDERS[GenAIProviderEnum.llamacpp] cls = PROVIDERS[GenAIProviderEnum.llamacpp]
with patch.object(cls, "_get_model_info", return_value=info): with patch.object(cls, "_get_model_info", return_value=info):
@@ -589,6 +590,62 @@ class TestLlamaCppProvider(unittest.TestCase):
with patch.object(client, "_fetch_models_data", return_value=models_data): with patch.object(client, "_fetch_models_data", return_value=models_data):
self.assertEqual(client.list_models(), ["g4", "gemma", "qwen3-asr"]) self.assertEqual(client.list_models(), ["g4", "gemma", "qwen3-asr"])
@staticmethod
def _embeddings_response(vectors):
response = MagicMock()
response.status_code = 200
response.json.return_value = {
"object": "list",
"data": [
{"object": "embedding", "index": i, "embedding": v}
for i, v in enumerate(vectors)
],
}
return response
def test_embed_posts_content_arrays_to_v1_embeddings(self):
client = self._client()
response = self._embeddings_response([[0.1] * 768, [0.2] * 768])
with patch.object(client, "_post", return_value=response) as post:
result = client.embed(texts=["a person"], images=[b"not an image"])
url = post.call_args.args[0]
payload = post.call_args.kwargs["json"]
self.assertEqual(url, "http://localhost:9999/v1/embeddings")
self.assertEqual(payload["model"], "m")
self.assertEqual(payload["encoding_format"], "float")
self.assertEqual(
payload["input"][0], {"content": [{"type": "text", "text": "a person"}]}
)
image_parts = payload["input"][1]["content"]
self.assertEqual(image_parts[0]["type"], "image_url")
self.assertEqual(
image_parts[0]["image_url"]["url"],
"data:image/jpeg;base64," + base64.b64encode(b"not an image").decode(),
)
self.assertEqual(image_parts[1], {"type": "text", "text": "\n"})
self.assertEqual(len(result), 2)
self.assertAlmostEqual(float(result[1][0]), 0.2, places=5)
def test_embed_normalizes_dimension(self):
client = self._client()
response = self._embeddings_response([[1.0] * 1024, [1.0] * 512])
with patch.object(client, "_post", return_value=response):
result = client.embed(texts=["long", "short"])
self.assertEqual([r.shape for r in result], [(768,), (768,)])
self.assertEqual(float(result[1][-1]), 0.0)
def test_embed_request_error_returns_empty(self):
client = self._client()
response = MagicMock()
response.raise_for_status.side_effect = requests.exceptions.HTTPError("400")
with patch.object(client, "_post", return_value=response):
self.assertEqual(client.embed(texts=["a"]), [])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# transcribe role # transcribe role
@@ -762,7 +819,6 @@ class TestLlamaCppTranscribe(unittest.TestCase):
"supports_audio": supports_audio, "supports_audio": supports_audio,
"supports_tools": False, "supports_tools": False,
"supports_reasoning": False, "supports_reasoning": False,
"media_marker": "<__media__>",
} }
cls = PROVIDERS[GenAIProviderEnum.llamacpp] cls = PROVIDERS[GenAIProviderEnum.llamacpp]
with patch.object(cls, "_get_model_info", return_value=info): with patch.object(cls, "_get_model_info", return_value=info):
+11
View File
@@ -288,6 +288,17 @@ class TestUpdateConfig(HardwareStatsTestCase):
self.assertEqual(set(stats._monitored), {"rockchip"}) self.assertEqual(set(stats._monitored), {"rockchip"})
def test_follows_a_runtime_config_swap(self):
stats = self.make_stats(self.make_config())
self.assertEqual(set(stats._monitored), set())
swapped = self.make_config("preset-rk-h264")
stats.set_config(swapped)
self.assertEqual(set(stats._monitored), {"rockchip"})
self.assertIs(self.subscriber.return_value.config, swapped)
self.assertIs(self.subscriber.return_value.camera_configs, swapped.cameras)
class TestUpdateStats(HardwareStatsTestCase): class TestUpdateStats(HardwareStatsTestCase):
def run_stats(self, stats: HardwareStats) -> dict: def run_stats(self, stats: HardwareStats) -> dict:
@@ -34,6 +34,12 @@ class HwaccelRecommendationTestCase(unittest.TestCase):
patcher.start() patcher.start()
self.addCleanup(patcher.stop) self.addCleanup(patcher.stop)
self.sys_root = os.path.join(self.root.name, "sys")
os.makedirs(self.sys_root)
patcher = patch.object(hwaccel, "SYS_ROOT", self.sys_root)
patcher.start()
self.addCleanup(patcher.stop)
drm = patch.object(hwaccel, "enumerate_drm_devices", return_value={}) drm = patch.object(hwaccel, "enumerate_drm_devices", return_value={})
self.drm = drm.start() self.drm = drm.start()
self.addCleanup(drm.stop) self.addCleanup(drm.stop)
@@ -66,6 +72,12 @@ class HwaccelRecommendationTestCase(unittest.TestCase):
with open(os.path.join(self.proc_root, "cpuinfo"), "w") as f: with open(os.path.join(self.proc_root, "cpuinfo"), "w") as f:
f.write(f"processor\t: 0\nmodel name\t: {model_name}\n") f.write(f"processor\t: 0\nmodel name\t: {model_name}\n")
def write_video_device(self, vendor: str) -> None:
device = os.path.join(self.sys_root, "class", "video4linux", "video0", "device")
os.makedirs(device)
with open(os.path.join(device, "vendor"), "w") as f:
f.write(f"{vendor}\n")
def write_device_tree(self) -> None: def write_device_tree(self) -> None:
os.makedirs(os.path.join(self.proc_root, "device-tree"), exist_ok=True) os.makedirs(os.path.join(self.proc_root, "device-tree"), exist_ok=True)
with open(os.path.join(self.proc_root, "device-tree", "compatible"), "w") as f: with open(os.path.join(self.proc_root, "device-tree", "compatible"), "w") as f:
@@ -191,6 +203,22 @@ class TestAvailableFamilies(HwaccelRecommendationTestCase):
self.assertIn(recommended, [family.key for family in families]) self.assertIn(recommended, [family.key for family in families])
class TestLighter(HwaccelRecommendationTestCase):
def test_lighters_media_engine_is_recommended(self):
self.write_video_device(hwaccel.LIGHTER_VIRTIO_VENDOR)
self.assertEqual(self.recommend(codecs={"h264"}), "apple-silicon")
self.assertEqual(
self.presets()["apple-silicon"],
{"h264": "preset-apple-silicon-h264", "h265": "preset-apple-silicon-h265"},
)
def test_another_virtio_media_device_is_not_lighters(self):
self.write_video_device("0x554d4551")
self.assertEqual(self.available(), [])
class TestCodecCoverage(HwaccelRecommendationTestCase): class TestCodecCoverage(HwaccelRecommendationTestCase):
def test_a_family_carries_a_preset_per_codec(self): def test_a_family_carries_a_preset_per_codec(self):
self.assertEqual( self.assertEqual(
+6 -3
View File
@@ -1,7 +1,7 @@
import datetime import datetime
import sys import sys
import unittest 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 # Mock complex imports before importing maintainer, saving originals so we can
# restore them after import and avoid polluting sys.modules for other tests. # restore them after import and avoid polluting sys.modules for other tests.
@@ -48,8 +48,11 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
"frigate.record.maintainer.psutil.process_iter", return_value=[] "frigate.record.maintainer.psutil.process_iter", return_value=[]
): ):
with patch("frigate.record.maintainer.logger.warning") as warn: with patch("frigate.record.maintainer.logger.warning") as warn:
# Mock validate_and_move_segment to avoid further logic # Mock validate_and_move_segment to avoid further logic.
maintainer.validate_and_move_segment = MagicMock() # The requestor is real when another test imported the
# maintainer first, and it would block on a reply.
maintainer.validate_and_move_segment = AsyncMock()
maintainer.requestor = MagicMock()
try: try:
await maintainer.move_files() await maintainer.move_files()
+120 -58
View File
@@ -16,9 +16,7 @@ from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
# kinds that switch on the lifecycle knobs, so the tests do not depend on the # kinds that switch on the lifecycle knobs, so the tests do not depend on the
# values the real catalog picks # values the real catalog picks
BATCHED = NoticeKind( BATCHED = NoticeKind("batched", NoticeSeverity.warning, "system", batch_repeats=True)
"batched", NoticeSeverity.warning, "system", batch_repeats=True, reopen_at_count=3
)
PRUNED = NoticeKind("pruned", NoticeSeverity.info, "system", keep_latest=2) PRUNED = NoticeKind("pruned", NoticeSeverity.info, "system", keep_latest=2)
TEST_KINDS = {kind.key: kind for kind in (BATCHED, PRUNED)} TEST_KINDS = {kind.key: kind for kind in (BATCHED, PRUNED)}
@@ -77,6 +75,11 @@ class RegistryTestCase(unittest.TestCase):
mock_datetime.now.return_value.timestamp.return_value = timestamp mock_datetime.now.return_value.timestamp.return_value = timestamp
self.registry.raise_notice(kind, **kwargs) self.registry.raise_notice(kind, **kwargs)
def _acknowledge_at(self, timestamp: float, row_id: str) -> None:
with patch("frigate.notices.registry.datetime") as mock_datetime:
mock_datetime.now.return_value.timestamp.return_value = timestamp
self.registry.acknowledge(row_id)
class TestNoticeRegistry(RegistryTestCase): class TestNoticeRegistry(RegistryTestCase):
def test_raise_inserts_and_counts_one_occurrence(self): def test_raise_inserts_and_counts_one_occurrence(self):
@@ -122,34 +125,98 @@ class TestNoticeRegistry(RegistryTestCase):
self.assertEqual(self.registry.stats()[0]["occurrences"], 1) self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
self.listener.assert_not_called() self.listener.assert_not_called()
def test_dismissal_survives_a_re_raise(self): def test_acknowledged_notice_returns_on_a_re_raise(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
self.assertEqual(self.registry.active(), [])
self.registry.raise_notice("detector_stuck", scope="ov", params={})
notice = self.registry.active()[0]
self.assertEqual(notice["count"], 2)
self.assertIsNone(notice["acknowledged_at"])
def test_muted_notice_stays_hidden_on_a_re_raise(self):
self.registry.raise_notice("skipped_detections", scope="front_door", params={}) self.registry.raise_notice("skipped_detections", scope="front_door", params={})
self.assertTrue(self.registry.dismiss("skipped_detections:front_door")) self.assertTrue(self.registry.mute("skipped_detections:front_door"))
self.registry.raise_notice("skipped_detections", scope="front_door", params={}) self.registry.raise_notice("skipped_detections", scope="front_door", params={})
self.assertEqual(self.registry.active(), []) self.assertEqual(self.registry.active(), [])
history = self.registry.active(include_dismissed=True) hidden = self.registry.active(include_hidden=True)
self.assertEqual(history[0]["count"], 2) self.assertEqual(hidden[0]["count"], 2)
self.assertIsNotNone(history[0]["dismissed_at"]) self.assertIsNotNone(hidden[0]["muted_at"])
def test_dismiss_counts_once(self): def test_muting_an_acknowledged_notice_replaces_the_acknowledgement(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={}) self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.acknowledge("detector_stuck:ov")
self.assertTrue(self.registry.dismiss("detector_stuck:ov")) self.assertTrue(self.registry.mute("detector_stuck:ov"))
self.assertTrue(self.registry.dismiss("detector_stuck:ov"))
self.assertEqual(self.registry.stats()[0]["dismissals"], 1) notice = self.registry.active(include_hidden=True)[0]
self.assertIsNone(notice["acknowledged_at"])
self.assertIsNotNone(notice["muted_at"])
def test_dismiss_unknown_id(self): def test_acknowledging_a_muted_notice_keeps_it_muted(self):
self.assertFalse(self.registry.dismiss("nope"))
def test_dismissed_hidden_unless_requested(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={}) self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.dismiss("detector_stuck:ov") self.registry.mute("detector_stuck:ov")
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
notice = self.registry.active(include_hidden=True)[0]
self.assertIsNone(notice["acknowledged_at"])
self.assertIsNotNone(notice["muted_at"])
def test_acknowledge_and_mute_each_count_once(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.raise_notice("shm_too_low", params={})
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
self.assertTrue(self.registry.mute("shm_too_low"))
self.assertTrue(self.registry.mute("shm_too_low"))
stats = {s["kind"]: s for s in self.registry.stats()}
self.assertEqual(stats["detector_stuck"]["acknowledgements"], 1)
self.assertEqual(stats["detector_stuck"]["mutes"], 0)
self.assertEqual(stats["shm_too_low"]["mutes"], 1)
def test_unknown_ids_are_rejected(self):
self.assertFalse(self.registry.acknowledge("nope"))
self.assertFalse(self.registry.mute("nope"))
self.assertFalse(self.registry.unhide("nope"))
def test_kinds_that_never_repeat_cannot_be_acknowledged(self):
self.registry.raise_notice(
"update_available", scope="0.19.1", params={"version": "0.19.1"}
)
self.assertFalse(self.registry.acknowledge("update_available:0.19.1"))
self.assertFalse(
self.registry.acknowledge("config:detect:fps-greater-than-five:global")
)
self.assertEqual(self.registry.active()[0]["acknowledgeable"], False)
def test_hidden_notices_listed_only_when_requested(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.acknowledge("detector_stuck:ov")
self.assertEqual(self.registry.active(), []) self.assertEqual(self.registry.active(), [])
self.assertEqual(len(self.registry.active(include_dismissed=True)), 1) self.assertEqual(len(self.registry.active(include_hidden=True)), 1)
def test_unhide_shows_a_notice_and_drops_a_check_mute(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.mute("detector_stuck:ov")
self.registry.mute("config:detect:fps-greater-than-five:global")
self.assertTrue(self.registry.unhide("detector_stuck:ov"))
self.assertTrue(
self.registry.unhide("config:detect:fps-greater-than-five:global")
)
notice = self.registry.active()[0]
self.assertIsNone(notice["muted_at"])
self.assertEqual(self.registry.muted_checks(), [])
def test_resolve_deletes_and_keeps_stats(self): def test_resolve_deletes_and_keeps_stats(self):
self.registry.raise_notice( self.registry.raise_notice(
@@ -160,7 +227,7 @@ class TestNoticeRegistry(RegistryTestCase):
self.registry.resolve("model_download_failed", "yolo/model.onnx") self.registry.resolve("model_download_failed", "yolo/model.onnx")
self.registry.resolve("model_download_failed", "yolo/model.onnx") self.registry.resolve("model_download_failed", "yolo/model.onnx")
self.assertEqual(self.registry.active(include_dismissed=True), []) self.assertEqual(self.registry.active(include_hidden=True), [])
self.assertEqual(self.registry.stats()[0]["occurrences"], 1) self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
self.listener.assert_called_once() self.listener.assert_called_once()
@@ -237,45 +304,47 @@ class TestNoticeRegistry(RegistryTestCase):
ids, ["model_download_failed:front_door", "skipped_detections:garage"] ids, ["model_download_failed:front_door", "skipped_detections:garage"]
) )
def test_resolve_camera_drops_that_cameras_check_dismissals(self): def test_resolve_camera_drops_that_cameras_check_mutes(self):
for check_id in ( for check_id in (
"stream:front_door:0:probe", "stream:front_door:0:probe",
"config:detect:fps-greater-than-five:camera.front_door", "config:detect:fps-greater-than-five:camera.front_door",
"config:detect:fps-greater-than-five:global", "config:detect:fps-greater-than-five:global",
"stream:garage:0:probe", "stream:garage:0:probe",
): ):
self.assertTrue(self.registry.dismiss(check_id)) self.assertTrue(self.registry.mute(check_id))
self.registry.resolve_camera("front_door") self.registry.resolve_camera("front_door")
ids = sorted(check["id"] for check in self.registry.dismissed_checks()) ids = sorted(check["id"] for check in self.registry.muted_checks())
self.assertEqual( self.assertEqual(
ids, ids,
["config:detect:fps-greater-than-five:global", "stream:garage:0:probe"], ["config:detect:fps-greater-than-five:global", "stream:garage:0:probe"],
) )
def test_camera_named_global_keeps_global_check_dismissals(self): def test_camera_named_global_keeps_global_check_mutes(self):
self.registry.dismiss("config:detect:fps-greater-than-five:global") self.registry.mute("config:detect:fps-greater-than-five:global")
self.registry.dismiss("config:detect:fps-greater-than-five:camera.global") self.registry.mute("config:detect:fps-greater-than-five:camera.global")
self.registry.resolve_camera("global") self.registry.resolve_camera("global")
ids = [check["id"] for check in self.registry.dismissed_checks()] ids = [check["id"] for check in self.registry.muted_checks()]
self.assertEqual(ids, ["config:detect:fps-greater-than-five:global"]) self.assertEqual(ids, ["config:detect:fps-greater-than-five:global"])
def test_purge_dismissed_keeps_active_notices_and_counts(self): def test_unhide_all_shows_every_notice_and_keeps_counts(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={}) self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.raise_notice("skipped_detections", scope="garage", params={}) self.registry.raise_notice("skipped_detections", scope="garage", params={})
self.registry.dismiss("detector_stuck:ov") self.registry.acknowledge("detector_stuck:ov")
self.registry.dismiss("config:detect:fps-greater-than-five:camera.garage") self.registry.mute("skipped_detections:garage")
self.registry.mute("config:detect:fps-greater-than-five:camera.garage")
self.assertEqual(self.registry.purge_dismissed(), 2) self.registry.unhide_all()
ids = [n["id"] for n in self.registry.active(include_dismissed=True)] ids = sorted(n["id"] for n in self.registry.active())
self.assertEqual(ids, ["skipped_detections:garage"]) self.assertEqual(ids, ["detector_stuck:ov", "skipped_detections:garage"])
self.assertEqual(self.registry.dismissed_checks(), []) self.assertEqual(self.registry.muted_checks(), [])
dismissals = {s["kind"]: s["dismissals"] for s in self.registry.stats()} stats = {s["kind"]: s for s in self.registry.stats()}
self.assertEqual(dismissals["detector_stuck"], 1) self.assertEqual(stats["detector_stuck"]["acknowledgements"], 1)
self.assertEqual(stats["skipped_detections"]["mutes"], 1)
class TestApply(RegistryTestCase): class TestApply(RegistryTestCase):
@@ -349,7 +418,7 @@ class TestLifecycleKnobs(RegistryTestCase):
self.registry.flush() self.registry.flush()
self.assertEqual(self.registry.active(include_dismissed=True), []) self.assertEqual(self.registry.active(include_hidden=True), [])
self.listener.assert_not_called() self.listener.assert_not_called()
def test_a_new_row_starts_without_stale_repeats(self): def test_a_new_row_starts_without_stale_repeats(self):
@@ -362,32 +431,26 @@ class TestLifecycleKnobs(RegistryTestCase):
self.assertEqual(self.registry.active()[0]["count"], 1) self.assertEqual(self.registry.active()[0]["count"], 1)
def test_a_dismissed_notice_reopens_at_the_count(self): def test_an_acknowledged_notice_returns_when_a_later_repeat_flushes(self):
self.registry.raise_notice("batched") self._raise_at(1000.0, "batched")
self.registry.dismiss("batched") self._acknowledge_at(1010.0, "batched")
self.registry.raise_notice("batched") self._raise_at(1020.0, "batched")
self.registry.flush()
self.assertEqual(self.registry.active(), []) self.assertEqual(self.registry.active(), [])
self.registry.raise_notice("batched")
self.registry.flush() self.registry.flush()
notice = self.registry.active()[0] self.assertEqual(self.registry.active()[0]["count"], 2)
self.assertEqual(notice["count"], BATCHED.reopen_at_count)
self.assertIsNone(notice["dismissed_at"])
def test_a_notice_dismissed_past_the_count_stays_dismissed(self): def test_repeats_held_from_before_an_acknowledgement_stay_hidden(self):
for _ in range(3): self._raise_at(1000.0, "batched")
self.registry.raise_notice("batched") self._raise_at(1005.0, "batched")
self.registry.flush() self._acknowledge_at(1010.0, "batched")
self.registry.dismiss("batched")
self.registry.raise_notice("batched")
self.registry.flush() self.registry.flush()
self.assertEqual(self.registry.active(), []) self.assertEqual(self.registry.active(), [])
self.assertEqual(self.registry.active(include_dismissed=True)[0]["count"], 4) self.assertEqual(self.registry.active(include_hidden=True)[0]["count"], 2)
def test_keep_latest_drops_the_oldest_rows(self): def test_keep_latest_drops_the_oldest_rows(self):
for index, scope in enumerate(("a", "b", "c")): for index, scope in enumerate(("a", "b", "c")):
@@ -410,14 +473,13 @@ class TestCatalogLifecycles(RegistryTestCase):
ids = [n["id"] for n in self.registry.active()] ids = [n["id"] for n in self.registry.active()]
self.assertEqual(ids, ["update_available:0.19.1"]) self.assertEqual(ids, ["update_available:0.19.1"])
def test_a_dismissed_failed_login_burst_reopens_at_five_attempts(self): def test_an_acknowledged_failed_login_burst_returns_on_the_next_attempt(self):
self.registry.raise_notice("failed_login", scope="1000") self.registry.raise_notice("failed_login", scope="1000")
self.registry.dismiss("failed_login:1000") self.registry.acknowledge("failed_login:1000")
for _ in range(4): self.registry.raise_notice("failed_login", scope="1000")
self.registry.raise_notice("failed_login", scope="1000")
self.registry.flush() self.registry.flush()
burst = self.registry.active()[0] burst = self.registry.active()[0]
self.assertEqual(burst["count"], 5) self.assertEqual(burst["count"], 2)
self.assertIsNone(burst["dismissed_at"]) self.assertIsNone(burst["acknowledged_at"])
@@ -1,5 +1,6 @@
"""Tests for sub stream cache segment handling in the recording maintainer.""" """Tests for sub stream cache segment handling in the recording maintainer."""
import asyncio
import datetime import datetime
import os import os
import tempfile import tempfile
@@ -532,6 +533,59 @@ class TestSegmentStartChaining(unittest.IsolatedAsyncioTestCase):
self.assertAlmostEqual(calls[1].args[2].timestamp(), self.T0 + 10.4, places=3) self.assertAlmostEqual(calls[1].args[2].timestamp(), self.T0 + 10.4, places=3)
self.assertAlmostEqual(calls[1].args[3].timestamp(), self.T0 + 20.8, places=3) self.assertAlmostEqual(calls[1].args[3].timestamp(), self.T0 + 20.8, places=3)
async def test_out_of_order_probes_chain_in_segment_order(self):
maintainer = _build_chaining_maintainer(self.T0)
async def probe(_ffmpeg, cache_path, get_duration=False):
# the earlier segment's probe finishes last
if "chain0" in cache_path:
await asyncio.sleep(0.05)
return {"has_valid_video": True, "duration": 10.4}
recordings = [
{
"start_time": datetime.datetime.fromtimestamp(
self.T0 + offset, tz=datetime.UTC
),
"cache_path": f"/tmp/cache/test_cam@chain{offset}.mp4",
"stream_type": "main",
}
for offset in (0, 10)
]
first_resolved = asyncio.Event()
with (
patch("frigate.record.maintainer.get_video_properties", probe),
patch(
"frigate.record.maintainer.get_keyframe_offsets",
AsyncMock(return_value=[0]),
),
patch(
"frigate.record.maintainer.os.path.getmtime",
MagicMock(side_effect=OSError("missing")),
),
):
await asyncio.gather(
maintainer._validate_in_order(
"test_cam", [], recordings[0], None, first_resolved
),
maintainer._validate_in_order(
"test_cam", [], recordings[1], first_resolved, asyncio.Event()
),
)
starts = sorted(
call.args[2].timestamp() for call in maintainer.move_segment.await_args_list
)
self.assertEqual(starts[0], self.T0)
self.assertAlmostEqual(starts[1], self.T0 + 10.4, places=3)
self.assertAlmostEqual(
maintainer.last_segment_end[("test_cam", "main")],
self.T0 + 20.8,
places=3,
)
async def test_genuine_gap_is_not_snapped(self): async def test_genuine_gap_is_not_snapped(self):
maintainer = _build_chaining_maintainer(self.T0) maintainer = _build_chaining_maintainer(self.T0)
+73
View File
@@ -1,10 +1,13 @@
"""Tests for tracker-derived review frame annotations.""" """Tests for tracker-derived review frame annotations."""
import unittest import unittest
from unittest.mock import patch
from frigate.data_processing.post.review_annotations import ( from frigate.data_processing.post.review_annotations import (
annotations_by_frame, annotations_by_frame,
build_frame_captions,
build_timeline, build_timeline,
describe_classification_change,
describe_heading, describe_heading,
describe_position, describe_position,
event_name, event_name,
@@ -312,5 +315,75 @@ class TestFrameBucketing(unittest.TestCase):
self.assertEqual(annotations_by_frame([(1.0, "x")], []), {}) self.assertEqual(annotations_by_frame([(1.0, "x")], []), {})
class TestClassificationChangeCaptions(unittest.TestCase):
def setUp(self):
person = track(
"1789481994.684479-lpyc2z",
"person",
0.0,
straight_path((0.9, 0.6), (0.4, 0.3), 10, 0.0),
)
patcher = patch(
"frigate.data_processing.post.review_annotations.get_tracked_events",
return_value=[person],
)
patcher.start()
self.addCleanup(patcher.stop)
patcher = patch(
"frigate.data_processing.post.review_annotations.get_state_changes",
return_value=[],
)
patcher.start()
self.addCleanup(patcher.stop)
def test_change_is_phrased_without_underscores(self):
self.assertEqual(
describe_classification_change(
{"model": "trash_day", "from": "no_bins", "to": "bins_at_curb"}
),
"trash day changed from no bins to bins at curb",
)
def test_change_is_noted_before_the_frame_it_precedes(self):
change = {"model": "front_gate", "from": "closed", "to": "open"}
captions = build_frame_captions(
["1789481994.684479-lpyc2z"],
[0.0, 10.0, 20.0],
[{**change, "timestamp": 12.0}],
)
self.assertEqual(
captions[2],
"Frame 3 of 3 (+20.0s):\n[state] front gate changed from closed to open",
)
self.assertTrue(captions[0].splitlines()[1].startswith("[tracker] "))
def test_change_is_noted_without_tracked_objects(self):
with patch(
"frigate.data_processing.post.review_annotations.get_tracked_events",
return_value=[],
):
captions = build_frame_captions(
[],
[0.0, 10.0],
[{"model": "gate", "from": "a", "to": "b", "timestamp": 5.0}],
)
self.assertEqual(
captions,
[
"Frame 1 of 2 (+0.0s):",
"Frame 2 of 2 (+10.0s):\n[state] gate changed from a to b",
],
)
def test_change_after_the_last_frame_is_dropped(self):
captions = build_frame_captions(
["1789481994.684479-lpyc2z"],
[0.0, 10.0, 20.0],
[{"model": "gate", "from": "a", "to": "b", "timestamp": 25.0}],
)
self.assertFalse(any("[state]" in caption for caption in captions))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -0,0 +1,258 @@
"""Tests for attaching state classification changes to review items."""
import unittest
from unittest.mock import MagicMock, patch
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
from frigate.config import FrigateConfig
from frigate.data_processing.post.review_descriptions import (
ReviewDescriptionProcessor,
format_classification_state_changes,
)
from frigate.data_processing.real_time.custom_classification import (
CustomStateClassificationProcessor,
)
from frigate.models import ReviewSegment
from frigate.review.maintainer import (
CLASSIFICATION_STATE_PRE_ROLL,
PendingReviewSegment,
ReviewSegmentMaintainer,
)
from frigate.review.types import SeverityEnum
CONFIG = """
mqtt:
enabled: False
cameras:
front_door:
ffmpeg:
inputs:
- path: rtsp://10.0.0.1:554/video
roles:
- detect
detect:
width: 1920
height: 1080
fps: 5
"""
def gate_change(timestamp: float, before: str = "closed", after: str = "open"):
return {"model": "front_gate", "from": before, "to": after, "timestamp": timestamp}
class TestVerifyStateChange(unittest.TestCase):
def setUp(self):
self.processor = CustomStateClassificationProcessor.__new__(
CustomStateClassificationProcessor
)
self.processor.state_history = {}
def verify(self, state: str, timestamp: float):
return self.processor.verify_state_change("front_door", state, timestamp)
def test_first_verified_state_has_no_previous_state(self):
self.assertIsNone(self.verify("closed", 1.0))
self.assertIsNone(self.verify("closed", 2.0))
self.assertEqual(self.verify("closed", 3.0), (None, 1.0))
def test_change_reports_previous_state_and_first_sighting(self):
for timestamp in (1.0, 2.0, 3.0):
self.verify("closed", timestamp)
self.assertIsNone(self.verify("open", 10.0))
self.assertIsNone(self.verify("open", 11.0))
self.assertEqual(self.verify("open", 12.0), ("closed", 10.0))
def test_interrupted_verification_restarts_the_first_sighting(self):
for timestamp in (1.0, 2.0, 3.0):
self.verify("closed", timestamp)
self.verify("open", 10.0)
self.verify("closed", 11.0)
self.verify("open", 20.0)
self.verify("open", 21.0)
self.assertEqual(self.verify("open", 22.0), ("closed", 20.0))
def test_reload_forgets_states_the_model_no_longer_has(self):
for timestamp in (1.0, 2.0, 3.0):
self.verify("closed", timestamp)
self.processor.state_history["back_door"] = {"current_state": "open"}
self.processor.labelmap = {0: "open", 1: "shut"}
self.processor._forget_unknown_states()
self.assertEqual(list(self.processor.state_history), ["back_door"])
self.assertIsNone(self.verify("shut", 10.0))
self.assertIsNone(self.verify("shut", 11.0))
self.assertEqual(self.verify("shut", 12.0), (None, 10.0))
class TestReviewSegmentAttachment(unittest.TestCase):
def setUp(self):
self.maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer)
self.maintainer.config = FrigateConfig.parse_yaml(CONFIG)
self.maintainer.active_review_segments = {}
self.maintainer.recent_classification_state_changes = {}
self.maintainer._publish_segment_update = MagicMock()
def segment(self, start_time: float) -> PendingReviewSegment:
return PendingReviewSegment(
"front_door",
start_time,
SeverityEnum.alert,
{"1.0-abcdef": "person"},
{},
[],
set(),
)
def test_change_during_segment_is_attached_and_published(self):
segment = self.segment(100.0)
self.maintainer.active_review_segments["front_door"] = segment
self.maintainer.handle_classification_state_change(
"front_door", gate_change(110.0)
)
self.assertEqual(segment.classification_state_changes, [gate_change(110.0)])
self.maintainer._publish_segment_update.assert_called_once()
prev_data = self.maintainer._publish_segment_update.call_args.args[4]
self.assertEqual(prev_data["data"]["classification_state_changes"], [])
self.assertEqual(
segment.get_data(False)["data"]["classification_state_changes"],
[gate_change(110.0)],
)
def test_change_never_starts_a_segment(self):
self.maintainer.handle_classification_state_change(
"front_door", gate_change(110.0)
)
self.assertIsNone(self.maintainer.active_review_segments.get("front_door"))
self.maintainer._publish_segment_update.assert_not_called()
def test_change_just_before_a_segment_is_attached_when_it_starts(self):
self.maintainer.handle_classification_state_change(
"front_door", gate_change(98.0)
)
segment = self.segment(100.0)
self.maintainer._activate_segment(segment)
self.assertIs(self.maintainer.active_review_segments["front_door"], segment)
self.assertEqual(segment.classification_state_changes, [gate_change(98.0)])
self.assertNotIn(
"front_door", self.maintainer.recent_classification_state_changes
)
def test_change_long_before_a_segment_is_not_attached(self):
self.maintainer.handle_classification_state_change(
"front_door", gate_change(100.0 - CLASSIFICATION_STATE_PRE_ROLL - 1)
)
segment = self.segment(100.0)
self.maintainer._activate_segment(segment)
self.assertEqual(segment.classification_state_changes, [])
def test_held_changes_are_pruned(self):
self.maintainer.handle_classification_state_change(
"front_door", gate_change(10.0)
)
self.maintainer.handle_classification_state_change(
"front_door", gate_change(50.0, "open", "closed")
)
self.assertEqual(
self.maintainer.recent_classification_state_changes["front_door"],
[gate_change(50.0, "open", "closed")],
)
class TestChangeTiming(unittest.TestCase):
def test_changes_are_placed_relative_to_the_activity(self):
lines = format_classification_state_changes(
[
gate_change(98.0),
gate_change(112.4, "open", "closed"),
gate_change(140.0),
],
start_time=100.0,
end_time=130.0,
)
self.assertEqual(
lines,
[
"front gate changed from closed to open, "
"just before the activity started",
"front gate changed from open to closed, 12s into the activity",
"front gate changed from closed to open, after the activity ended",
],
)
class TestSummaryContext(unittest.TestCase):
def row(self, camera, start, end, threat, changes=()):
return {
"camera": camera,
"start_time": start,
"end_time": end,
"data": {
"metadata": {"title": camera, "potential_threat_level": threat},
"classification_state_changes": list(changes),
},
}
def summarize(self, rows):
processor = ReviewDescriptionProcessor.__new__(ReviewDescriptionProcessor)
processor.config = FrigateConfig.parse_yaml(CONFIG)
processor.genai_manager = MagicMock()
client = processor.genai_manager.description_client
with patch.object(ReviewSegment, "select") as select:
query = select.return_value.where.return_value.order_by.return_value
query.dicts.return_value.iterator.return_value = iter(rows)
processor.handle_request(
EmbeddingsRequestEnum.summarize_review.value,
{"start_ts": 0, "end_ts": 100},
)
return client.generate_review_summary.call_args.args[2]
def test_context_state_changes_stay_with_their_review(self):
events = self.summarize(
[
self.row("front_door", 10, 60, 1),
self.row("driveway", 15, 25, 0, [gate_change(20.0)]),
self.row("driveway", 30, 40, 0, [gate_change(35.0, "open", "closed")]),
]
)
self.assertEqual(
[
(item["start_time"], item["end_time"], item["state_changes"])
for item in events[0]["context"]
],
[
(15, 25, ["front gate changed from closed to open"]),
(30, 40, ["front gate changed from open to closed"]),
],
)
def test_later_context_review_without_changes_is_deduplicated(self):
events = self.summarize(
[
self.row("front_door", 10, 60, 1),
self.row("driveway", 15, 25, 0, [gate_change(20.0)]),
self.row("driveway", 30, 40, 0),
]
)
self.assertEqual(len(events[0]["context"]), 1)
self.assertEqual(events[0]["context"][0]["start_time"], 15)
if __name__ == "__main__":
unittest.main()
+147
View File
@@ -1,5 +1,7 @@
"""Tests for the device each model runner reports after loading.""" """Tests for the device each model runner reports after loading."""
import os
import tempfile
import threading import threading
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -38,6 +40,7 @@ class TestRunnerDeviceName(unittest.TestCase):
self._onnx(["OpenVINOExecutionProvider"]).device_name, "OpenVINO" self._onnx(["OpenVINOExecutionProvider"]).device_name, "OpenVINO"
) )
self.assertEqual(self._onnx(["CPUExecutionProvider"]).device_name, "CPU") self.assertEqual(self._onnx(["CPUExecutionProvider"]).device_name, "CPU")
self.assertEqual(self._onnx(["LighterANE"]).device_name, "Neural Engine")
self.assertEqual(self._onnx(["ROCMExecutionProvider"]).device_name, "ROCM") self.assertEqual(self._onnx(["ROCMExecutionProvider"]).device_name, "ROCM")
self.assertEqual(self._onnx([]).device_name, "CPU") self.assertEqual(self._onnx([]).device_name, "CPU")
@@ -129,3 +132,147 @@ class TestLoadedDeviceSnapshot(unittest.TestCase):
finally: finally:
stop.set() stop.set()
thread.join(timeout=5) thread.join(timeout=5)
class TestLighterANE(unittest.TestCase):
"""lighter's plugin provider, picked up by the ONNX session setup."""
def setUp(self):
loaded_devices.clear()
self.root = tempfile.TemporaryDirectory()
self.addCleanup(self.root.cleanup)
self.library = os.path.join(self.root.name, "liblighter_ane_ep.so")
env = patch.dict(os.environ, {"LIGHTER_ANE_EP": self.library})
env.start()
self.addCleanup(env.stop)
def _device(self) -> MagicMock:
device = MagicMock()
device.ep_name = "LighterANE"
return device
def test_no_devices_without_the_library(self):
with patch.object(detection_runners.ort, "get_ep_devices") as get_ep_devices:
self.assertEqual(detection_runners.get_lighter_ane_devices(), [])
get_ep_devices.assert_not_called()
def test_the_provider_is_registered_once(self):
open(self.library, "w").close()
device = self._device()
other = MagicMock()
other.ep_name = "CPUExecutionProvider"
with (
patch.object(
detection_runners.ort,
"get_ep_devices",
side_effect=[[other], [other, device], [other, device]],
),
patch.object(
detection_runners.ort, "register_execution_provider_library"
) as register,
):
self.assertEqual(detection_runners.get_lighter_ane_devices(), [device])
self.assertEqual(detection_runners.get_lighter_ane_devices(), [device])
register.assert_called_once_with("LighterANE", self.library)
def test_a_provider_that_will_not_load_is_skipped(self):
open(self.library, "w").close()
with (
patch.object(detection_runners.ort, "get_ep_devices", return_value=[]),
patch.object(
detection_runners.ort,
"register_execution_provider_library",
side_effect=RuntimeError("not a provider"),
),
):
self.assertEqual(detection_runners.get_lighter_ane_devices(), [])
def test_the_session_runs_on_the_neural_engine(self):
device = self._device()
session = MagicMock()
session.get_providers.return_value = ["LighterANE", "CPUExecutionProvider"]
options = MagicMock()
with (
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
patch.object(
detection_runners, "get_lighter_ane_devices", return_value=[device]
),
patch.object(
detection_runners, "get_ort_session_options", return_value=options
),
patch.object(
detection_runners.ort, "InferenceSession", return_value=session
) as inference_session,
):
runner = get_optimized_runner("/models/yolo.onnx", "AUTO", "yolo-generic")
options.add_provider_for_devices.assert_called_once_with([device], {})
inference_session.assert_called_once_with(
"/models/yolo.onnx", sess_options=options
)
self.assertIsInstance(runner, ONNXModelRunner)
self.assertEqual(
loaded_devices["/models/yolo.onnx"], ("yolo-generic", "Neural Engine")
)
def test_a_model_the_neural_engine_cannot_load_uses_the_default_providers(self):
session = MagicMock()
session.get_providers.return_value = ["CPUExecutionProvider"]
with (
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
patch.object(
detection_runners, "get_lighter_ane_devices", return_value=[MagicMock()]
),
patch.object(
detection_runners,
"get_ort_providers",
return_value=(["CPUExecutionProvider"], [{}]),
),
patch.object(
detection_runners, "is_openvino_gpu_npu_available", return_value=False
),
patch.object(
detection_runners.ort,
"InferenceSession",
side_effect=[RuntimeError("unsupported"), session],
) as inference_session,
patch.object(
detection_runners, "get_ort_session_options", return_value=MagicMock()
),
self.assertLogs(detection_runners.logger, level="WARNING"),
):
runner = get_optimized_runner("/models/jina.onnx", "AUTO", "jina-v2")
self.assertEqual(inference_session.call_count, 2)
self.assertIsInstance(runner, ONNXModelRunner)
self.assertEqual(loaded_devices["/models/jina.onnx"], ("jina-v2", "CPU"))
def test_a_cpu_model_stays_on_the_cpu(self):
session = MagicMock()
session.get_providers.return_value = ["CPUExecutionProvider"]
with (
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
patch.object(
detection_runners, "get_lighter_ane_devices", return_value=[MagicMock()]
) as ane,
patch.object(
detection_runners,
"get_ort_providers",
return_value=(["CPUExecutionProvider"], [{}]),
),
patch.object(
detection_runners.ort, "InferenceSession", return_value=session
),
patch.object(
detection_runners, "get_ort_session_options", return_value=None
),
):
get_optimized_runner("/models/arcface.onnx", "CPU", "arcface")
ane.assert_not_called()
+86 -18
View File
@@ -1,10 +1,14 @@
"""Tests for the skipped detection rate and the notice it can raise.""" """Tests for the per-camera episode notices: skipped detections and high CPU."""
import unittest import unittest
from unittest.mock import patch from unittest.mock import call, patch
from frigate.stats import emitter from frigate.stats import emitter
from frigate.stats.emitter import SkippedDetectionsTracker from frigate.stats.emitter import (
SKIPPED_DETECTIONS_PCT,
EpisodeTracker,
cpu_average,
)
from frigate.stats.util import skipped_percent from frigate.stats.util import skipped_percent
@@ -19,18 +23,18 @@ class TestSkippedPercent(unittest.TestCase):
self.assertEqual(skipped_percent(2.0, 5.0, False), 0.0) self.assertEqual(skipped_percent(2.0, 5.0, False), 0.0)
class TestSkippedDetectionsTracker(unittest.TestCase): class TestEpisodeTracker(unittest.TestCase):
def _cameras(self, pct: float) -> dict: def _cameras(self, pct: float) -> dict:
return {"front_door": {"skipped_pct": pct}} return {"front_door": pct}
def test_below_threshold_never_qualifies(self): def test_below_threshold_never_qualifies(self):
tracker = SkippedDetectionsTracker() tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
for tick in range(10): for tick in range(10):
self.assertEqual(tracker.update(self._cameras(4.9), tick * 15.0), []) self.assertEqual(tracker.update(self._cameras(4.9), tick * 15.0), [])
def test_qualifies_once_after_the_hold(self): def test_qualifies_once_after_the_hold(self):
tracker = SkippedDetectionsTracker() tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
results = [ results = [
tracker.update(self._cameras(10.0), tick * 15.0) for tick in range(8) tracker.update(self._cameras(10.0), tick * 15.0) for tick in range(8)
@@ -42,7 +46,7 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
self.assertEqual(results[5:], [[], [], []]) self.assertEqual(results[5:], [[], [], []])
def test_a_dip_restarts_the_hold(self): def test_a_dip_restarts_the_hold(self):
tracker = SkippedDetectionsTracker() tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
for now, pct in ((0.0, 10.0), (15.0, 10.0), (30.0, 10.0), (45.0, 2.0)): for now, pct in ((0.0, 10.0), (15.0, 10.0), (30.0, 10.0), (45.0, 2.0)):
self.assertEqual(tracker.update(self._cameras(pct), now), []) self.assertEqual(tracker.update(self._cameras(pct), now), [])
@@ -51,7 +55,7 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
self.assertEqual(tracker.update(self._cameras(10.0), 120.0), ["front_door"]) self.assertEqual(tracker.update(self._cameras(10.0), 120.0), ["front_door"])
def test_recovery_then_relapse_is_a_new_episode(self): def test_recovery_then_relapse_is_a_new_episode(self):
tracker = SkippedDetectionsTracker() tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
tracker.update(self._cameras(10.0), 0.0) tracker.update(self._cameras(10.0), 0.0)
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), ["front_door"]) self.assertEqual(tracker.update(self._cameras(10.0), 60.0), ["front_door"])
@@ -61,17 +65,15 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
self.assertEqual(tracker.update(self._cameras(10.0), 150.0), ["front_door"]) self.assertEqual(tracker.update(self._cameras(10.0), 150.0), ["front_door"])
def test_cameras_are_tracked_separately(self): def test_cameras_are_tracked_separately(self):
tracker = SkippedDetectionsTracker() tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
tracker.update({"a": {"skipped_pct": 10.0}, "b": {"skipped_pct": 0.0}}, 0.0) tracker.update({"a": 10.0, "b": 0.0}, 0.0)
qualified = tracker.update( qualified = tracker.update({"a": 10.0, "b": 10.0}, 60.0)
{"a": {"skipped_pct": 10.0}, "b": {"skipped_pct": 10.0}}, 60.0
)
self.assertEqual(qualified, ["a"]) self.assertEqual(qualified, ["a"])
def test_a_removed_camera_starts_a_new_episode(self): def test_a_removed_camera_starts_a_new_episode(self):
tracker = SkippedDetectionsTracker() tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
tracker.update(self._cameras(10.0), 0.0) tracker.update(self._cameras(10.0), 0.0)
tracker.update({}, 15.0) tracker.update({}, 15.0)
tracker.update(self._cameras(10.0), 30.0) tracker.update(self._cameras(10.0), 30.0)
@@ -79,6 +81,25 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), []) self.assertEqual(tracker.update(self._cameras(10.0), 60.0), [])
self.assertEqual(tracker.update(self._cameras(10.0), 90.0), ["front_door"]) self.assertEqual(tracker.update(self._cameras(10.0), 90.0), ["front_door"])
def test_a_missing_value_ends_the_episode(self):
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
tracker.update(self._cameras(10.0), 0.0)
tracker.update({"front_door": None}, 30.0)
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), [])
class TestCpuAverage(unittest.TestCase):
def test_reads_the_lifetime_average(self):
usages = {"42": {"cpu": "80.0", "cpu_average": "25.0"}}
self.assertEqual(cpu_average(usages, 42), 25.0)
def test_unknown_process_has_no_average(self):
self.assertIsNone(cpu_average({}, 42))
self.assertIsNone(cpu_average({"42": {"cpu_average": "25.0"}}, None))
self.assertIsNone(cpu_average({"42": {"cpu_average": ""}}, 42))
class TestEmitterNotices(unittest.TestCase): class TestEmitterNotices(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -94,15 +115,25 @@ class TestEmitterNotices(unittest.TestCase):
def _emitter(self) -> emitter.StatsEmitter: def _emitter(self) -> emitter.StatsEmitter:
stats_emitter = emitter.StatsEmitter.__new__(emitter.StatsEmitter) stats_emitter = emitter.StatsEmitter.__new__(emitter.StatsEmitter)
stats_emitter.skipped_detections = SkippedDetectionsTracker() stats_emitter.skipped_detections = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
stats_emitter.ffmpeg_cpu = EpisodeTracker(emitter.FFMPEG_HIGH_CPU_PCT)
stats_emitter.detect_cpu = EpisodeTracker(emitter.DETECT_HIGH_CPU_PCT)
stats_emitter._shm_checked = False stats_emitter._shm_checked = False
stats_emitter._shm_params = None stats_emitter._shm_params = None
return stats_emitter return stats_emitter
def _stats(self, uptime: int, pct: float) -> dict: def _stats(
self, uptime: int, pct: float, ffmpeg_cpu: str = "5.0", detect_cpu: str = "5.0"
) -> dict:
return { return {
"service": {"uptime": uptime, "storage": {"/dev/shm": {}}}, "service": {"uptime": uptime, "storage": {"/dev/shm": {}}},
"cameras": {"front_door": {"skipped_pct": pct}}, "cameras": {
"front_door": {"skipped_pct": pct, "ffmpeg_pid": 10, "pid": 11}
},
"cpu_usages": {
"10": {"cpu_average": ffmpeg_cpu},
"11": {"cpu_average": detect_cpu},
},
} }
def test_qualified_camera_raises_a_notice(self): def test_qualified_camera_raises_a_notice(self):
@@ -115,6 +146,43 @@ class TestEmitterNotices(unittest.TestCase):
"skipped_detections", scope="front_door", params={"pct": 12.5} "skipped_detections", scope="front_door", params={"pct": 12.5}
) )
def test_high_cpu_raises_a_notice_per_process(self):
stats_emitter = self._emitter()
for uptime, now in ((300, 0.0), (360, 60.0)):
stats_emitter._update_notices(
self._stats(uptime, 0.0, ffmpeg_cpu="25.0", detect_cpu="45.0"), now
)
self.assertEqual(
self.raise_notice.call_args_list,
[
call("ffmpeg_high_cpu", scope="front_door", params={"cpu": 25.0}),
call("detect_high_cpu", scope="front_door", params={"cpu": 45.0}),
],
)
def test_cpu_below_each_threshold_raises_nothing(self):
stats_emitter = self._emitter()
for uptime, now in ((300, 0.0), (360, 60.0)):
stats_emitter._update_notices(
self._stats(uptime, 0.0, ffmpeg_cpu="19.0", detect_cpu="39.0"), now
)
self.raise_notice.assert_not_called()
def test_missing_cpu_stats_raise_nothing(self):
stats_emitter = self._emitter()
for uptime, now in ((300, 0.0), (360, 60.0)):
stats = self._stats(uptime, 0.0)
del stats["cpu_usages"]
stats_emitter._update_notices(stats, now)
self.raise_notice.assert_not_called()
self.assertEqual(self.flush_notices.call_count, 2)
def test_startup_window_is_ignored(self): def test_startup_window_is_ignored(self):
stats_emitter = self._emitter() stats_emitter = self._emitter()
+7 -1
View File
@@ -14,6 +14,10 @@ from frigate.util.file import FileLock
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# (connect, read) seconds. The read timeout bounds each socket read rather than
# the whole download, so large models still finish.
DOWNLOAD_TIMEOUT = (15, 60)
# target path -> first line of the last download error for it; every existing # target path -> first line of the last download error for it; every existing
# download function swallows its exceptions, so this is how the downloader # download function swallows its exceptions, so this is how the downloader
# thread learns why a file is still missing # thread learns why a file is still missing
@@ -124,7 +128,9 @@ class ModelDownloader:
logger.info(f"Downloading model file from: {url}") logger.info(f"Downloading model file from: {url}")
try: try:
with requests.get(url, stream=True, allow_redirects=True) as r: with requests.get(
url, stream=True, allow_redirects=True, timeout=DOWNLOAD_TIMEOUT
) as r:
r.raise_for_status() r.raise_for_status()
with open(temporary_filename, "wb") as f: with open(temporary_filename, "wb") as f:
for chunk in r.iter_content(chunk_size=8192): for chunk in r.iter_content(chunk_size=8192):
+33 -1
View File
@@ -9,6 +9,7 @@ and resolve it per camera against that camera's detect stream.
""" """
import logging import logging
import os
import re import re
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -23,14 +24,20 @@ from frigate.util.services import enumerate_drm_devices
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# root the /proc reads use, so tests can point them at a fixture tree # roots the /proc and /sys reads use, so tests can point them at a fixture tree
PROC_ROOT = "/proc" PROC_ROOT = "/proc"
SYS_ROOT = "/sys"
ANY_CODEC = "any" ANY_CODEC = "any"
# a Raspberry Pi has no detection hardware of its own, so it gets a key here # a Raspberry Pi has no detection hardware of its own, so it gets a key here
RASPBERRY_PI = "raspberrypi" RASPBERRY_PI = "raspberrypi"
# lighter (https://github.com/fieldwork-ai/lighter) decodes on a Mac's media
# engine through virtio-media devices, which carry its virtio vendor id "LGHT"
LIGHTER_MEDIA = "lighter"
LIGHTER_VIRTIO_VENDOR = "0x4c474854"
# ffprobe names h265 streams hevc # ffprobe names h265 streams hevc
CODEC_ALIASES = {"hevc": "h265"} CODEC_ALIASES = {"hevc": "h265"}
@@ -52,6 +59,7 @@ DECODE_HARDWARE = (
"rknn", "rknn",
"openvino:GPU", "openvino:GPU",
"onnx:amd", "onnx:amd",
LIGHTER_MEDIA,
RASPBERRY_PI, RASPBERRY_PI,
) )
@@ -98,6 +106,10 @@ FAMILY_RPI = HwaccelFamily(
key="rpi", key="rpi",
presets={"h264": "preset-rpi-64-h264", "h265": "preset-rpi-64-h265"}, presets={"h264": "preset-rpi-64-h264", "h265": "preset-rpi-64-h265"},
) )
FAMILY_APPLE = HwaccelFamily(
key="apple-silicon",
presets={"h264": "preset-apple-silicon-h264", "h265": "preset-apple-silicon-h265"},
)
def _read(path: str) -> str | None: def _read(path: str) -> str | None:
@@ -139,6 +151,20 @@ def _is_raspberry_pi() -> bool:
return "raspberrypi" in compatible return "raspberrypi" in compatible
def _has_lighter_media() -> bool:
video = f"{SYS_ROOT}/class/video4linux"
try:
devices = os.listdir(video)
except OSError:
return False
return any(
_read(f"{video}/{device}/device/vendor") == LIGHTER_VIRTIO_VENDOR
for device in devices
)
def _intel_families(generation: int | None) -> list[HwaccelFamily]: def _intel_families(generation: int | None) -> list[HwaccelFamily]:
"""vaapi drives every Intel GPU, qsv only those from gen8 on.""" """vaapi drives every Intel GPU, qsv only those from gen8 on."""
if generation is not None and generation < INTEL_QSV_SUPPORTED_GEN: if generation is not None and generation < INTEL_QSV_SUPPORTED_GEN:
@@ -164,6 +190,9 @@ def _families(key: str, generation: int | None) -> list[HwaccelFamily]:
if key == "onnx:amd": if key == "onnx:amd":
return [FAMILY_VAAPI] return [FAMILY_VAAPI]
if key == LIGHTER_MEDIA:
return [FAMILY_APPLE]
if key == RASPBERRY_PI: if key == RASPBERRY_PI:
return [FAMILY_RPI] return [FAMILY_RPI]
@@ -193,6 +222,9 @@ def _decode_hardware(detector_key: str | None) -> list[str]:
""" """
present = {found.key for found in hardware_prober.probe()} present = {found.key for found in hardware_prober.probe()}
if _has_lighter_media():
present.add(LIGHTER_MEDIA)
if _is_raspberry_pi(): if _is_raspberry_pi():
present.add(RASPBERRY_PI) present.add(RASPBERRY_PI)
+7 -7
View File
@@ -213,17 +213,19 @@ def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
) )
def null_audio_glitches( def null_audio_glitches(intervals: list[CoverageInterval]) -> list[CoverageInterval]:
intervals: list[CoverageInterval], main_audio: bool, sub_audio: bool
) -> list[CoverageInterval]:
"""Treat video-only glitch rows on audio-bearing streams as no recording. """Treat video-only glitch rows on audio-bearing streams as no recording.
nginx-vod requires every clip in a sequence to carry the same track nginx-vod requires every clip in a sequence to carry the same track
count, so a truncated video-only segment (a backend restart can flush count, so a truncated video-only segment (a backend restart can flush
a sub-second file before any audio packet landed) poisons every a sub-second file before any audio packet landed) poisons every
manifest that includes it. Nulling the row turns the glitch into a manifest that includes it. Nulling the row turns the glitch into a
hole the span builder skips like any recording gap. hole the span builder skips like any recording gap. Every consumer of
a window's coverage (the vod manifest, its realized timelines, and
exports) goes through here, so they all agree on which rows exist.
""" """
main_audio = stream_has_audio(intervals, main=True)
sub_audio = stream_has_audio(intervals, main=False)
result: list[CoverageInterval] = [] result: list[CoverageInterval] = []
for interval in intervals: for interval in intervals:
main = interval.main main = interval.main
@@ -449,9 +451,7 @@ def realized_timelines(
assembles each variant's realized spans. Keyframe snapping reads the assembles each variant's realized spans. Keyframe snapping reads the
per-row index stored at record time, so no file is touched. per-row index stored at record time, so no file is touched.
""" """
main_audio = stream_has_audio(intervals, main=True) nulled = null_audio_glitches(intervals)
sub_audio = stream_has_audio(intervals, main=False)
nulled = null_audio_glitches(intervals, main_audio, sub_audio)
return { return {
"auto": realized_timeline(nulled, None), "auto": realized_timeline(nulled, None),
+6 -3
View File
@@ -18,18 +18,21 @@ def migrate(migrator, database, fake=False, **kwargs):
'"first_seen" DATETIME NOT NULL, ' '"first_seen" DATETIME NOT NULL, '
'"last_seen" DATETIME NOT NULL, ' '"last_seen" DATETIME NOT NULL, '
'"count" INTEGER NOT NULL, ' '"count" INTEGER NOT NULL, '
'"dismissed_at" DATETIME)' '"acknowledged_at" DATETIME, '
'"muted_at" DATETIME)'
) )
migrator.sql('CREATE INDEX IF NOT EXISTS "notice_kind" ON "notice" ("kind")') migrator.sql('CREATE INDEX IF NOT EXISTS "notice_kind" ON "notice" ("kind")')
migrator.sql( migrator.sql(
'CREATE TABLE IF NOT EXISTS "noticestats" (' 'CREATE TABLE IF NOT EXISTS "noticestats" ('
'"kind" VARCHAR(50) NOT NULL PRIMARY KEY, ' '"kind" VARCHAR(50) NOT NULL PRIMARY KEY, '
'"occurrences" INTEGER NOT NULL, ' '"occurrences" INTEGER NOT NULL, '
'"dismissals" INTEGER NOT NULL, ' '"acknowledgements" INTEGER NOT NULL, '
'"mutes" INTEGER NOT NULL, '
'"first_seen" DATETIME NOT NULL, ' '"first_seen" DATETIME NOT NULL, '
'"last_seen" DATETIME NOT NULL, ' '"last_seen" DATETIME NOT NULL, '
'"reported_occurrences" INTEGER NOT NULL DEFAULT 0, ' '"reported_occurrences" INTEGER NOT NULL DEFAULT 0, '
'"reported_dismissals" INTEGER NOT NULL DEFAULT 0)' '"reported_acknowledgements" INTEGER NOT NULL DEFAULT 0, '
'"reported_mutes" INTEGER NOT NULL DEFAULT 0)'
) )
+3 -3
View File
@@ -49,7 +49,7 @@ export interface ApiMockOverrides {
}; };
users?: { username: string; role: string }[]; users?: { username: string; role: string }[];
notices?: unknown[]; notices?: unknown[];
dismissedChecks?: unknown[]; mutedChecks?: unknown[];
/** camera name to the ffprobe entries returned for `paths=camera:<name>` */ /** camera name to the ffprobe entries returned for `paths=camera:<name>` */
ffprobe?: Record<string, unknown[]>; ffprobe?: Record<string, unknown[]>;
} }
@@ -238,8 +238,8 @@ export class ApiMocker {
await this.page.route("**/api/notices", (route) => await this.page.route("**/api/notices", (route) =>
route.fulfill({ json: overrides?.notices ?? [] }), route.fulfill({ json: overrides?.notices ?? [] }),
); );
await this.page.route("**/api/notices/dismissed_checks", (route) => await this.page.route("**/api/notices/muted_checks", (route) =>
route.fulfill({ json: overrides?.dismissedChecks ?? [] }), route.fulfill({ json: overrides?.mutedChecks ?? [] }),
); );
// Users. GET lists them; POST/PUT (create, password) just succeed, so // Users. GET lists them; POST/PUT (create, password) just succeed, so
+204
View File
@@ -0,0 +1,204 @@
/**
* Helpers for the live dashboard's draggable grid layout: reading and seeding
* the persisted layout, and measuring rendered tiles.
*
* DraggableGridLayout persists through useUserPersistence, which namespaces
* keys by username, and every write is an async idb put. A test that seeds the
* bare key, or seeds before the app's own first write has landed, silently
* asserts against a key the app never reads. persistedLayoutKey() closes both
* holes, so prefer it over building the key by hand.
*
* Geometry has its own trap: the grid first lays out against window.innerWidth,
* then reflows narrower once useResizeObserver reports the real container.
* Tiles measured in separate round-trips can straddle that reflow and disagree
* on scale, so cameraBoxes() takes every measurement in one evaluate.
*
* Used by live-grid-aspect-modes.spec.ts and masonry-live-grid.spec.ts.
*/
import { expect, type Page } from "@playwright/test";
export type LayoutItem = {
i: string;
x: number;
y: number;
w: number;
h: number;
};
export type PersistedLayout = {
version: number;
naturalAspect: boolean;
layout: LayoutItem[];
};
function layoutKeySuffix(group: string): string {
return `${group}-draggable-layout`;
}
/**
* The key the app has actually written an envelope to, or undefined while its
* first write is still in flight.
*/
function findWrittenKey(
page: Page,
group: string,
): Promise<string | undefined> {
return page.evaluate(
(suffix) =>
new Promise<string | undefined>((resolve) => {
const open = indexedDB.open("keyval-store");
open.onsuccess = () => {
const store = open.result
.transaction("keyval", "readonly")
.objectStore("keyval");
// getAllKeys and getAll both return in key order, so the indexes align
const keys = store.getAllKeys();
const values = store.getAll();
keys.transaction.oncomplete = () => {
open.result.close();
const names = keys.result as string[];
const stored = values.result as { version?: number }[];
const match = names.findIndex(
(name, index) =>
(name === suffix || name.startsWith(`${suffix}:`)) &&
typeof stored[index]?.version === "number",
);
resolve(match === -1 ? undefined : names[match]);
};
};
open.onerror = () => resolve(undefined);
}),
layoutKeySuffix(group),
);
}
/**
* Wait for the grid to persist its own layout, then return the key it used.
* Waiting for that write is what makes a later seed meaningful: it proves the
* key is live, and it rules out the app overwriting the seed a moment later.
*/
export async function persistedLayoutKey(
page: Page,
group: string,
): Promise<string> {
let key: string | undefined;
await expect
.poll(async () => (key = await findWrittenKey(page, group)), {
timeout: 10_000,
message: `grid never persisted a layout for group "${group}"`,
})
.not.toBeUndefined();
return key!;
}
/** Overwrite the stored layout, resolving only once the put has committed. */
export function seedLayout(
page: Page,
key: string,
value: unknown,
): Promise<void> {
return page.evaluate(
([key, value]) =>
new Promise<void>((resolve, reject) => {
const open = indexedDB.open("keyval-store");
open.onupgradeneeded = () => open.result.createObjectStore("keyval");
open.onsuccess = () => {
const tx = open.result.transaction("keyval", "readwrite");
tx.objectStore("keyval").put(value, key as string);
tx.oncomplete = () => {
open.result.close();
resolve();
};
tx.onerror = () => reject(tx.error);
};
open.onerror = () => reject(open.error);
}),
[key, value] as const,
);
}
/** Read the stored layout back. Undefined until the app writes it. */
export function readLayout(
page: Page,
key: string,
): Promise<PersistedLayout | undefined> {
return page.evaluate(
(target) =>
new Promise((resolve) => {
const open = indexedDB.open("keyval-store");
open.onsuccess = () => {
const tx = open.result.transaction("keyval", "readonly");
const request = tx.objectStore("keyval").get(target);
tx.oncomplete = () => {
open.result.close();
resolve(request.result);
};
};
open.onerror = () => resolve(undefined);
}),
key,
) as Promise<PersistedLayout | undefined>;
}
export type Box = { w: number; h: number; x: number; y: number };
/** The card is the player root; the cell is the grid slot it sits in. */
export type BoxTarget = "card" | "cell";
/** One atomic snapshot, or null while any tile is missing or unlaid out. */
function snapshotBoxes(
page: Page,
cameras: readonly string[],
target: BoxTarget,
): Promise<Record<string, Box> | null> {
return page.evaluate(
({ cams, target }) => {
const boxes: Record<string, Box> = {};
for (const cam of cams) {
const card = document.querySelector(`[data-camera='${cam}']`);
const el = target === "cell" ? card?.closest(".p-1") : card;
if (!el) {
return null;
}
const r = el.getBoundingClientRect();
// a re-rendering tile can briefly report no box at all
if (!r.width || !r.height) {
return null;
}
boxes[cam] = { w: r.width, h: r.height, x: r.x, y: r.y };
}
return boxes;
},
{ cams: cameras as readonly string[], target },
);
}
/**
* Measure the given cameras' tiles together, once they have all rendered.
* Measuring in one evaluate is what keeps the numbers mutually comparable.
*/
export async function cameraBoxes<T extends string>(
page: Page,
cameras: readonly T[],
target: BoxTarget = "cell",
): Promise<Record<T, Box>> {
let boxes: Record<string, Box> | null = null;
await expect
.poll(async () => (boxes = await snapshotBoxes(page, cameras, target)), {
timeout: 10_000,
message: `${target}s never rendered for ${cameras.join(", ")}`,
})
.not.toBeNull();
return boxes as unknown as Record<T, Box>;
}
+5
View File
@@ -45,6 +45,11 @@ export class LivePage extends BasePage {
); );
} }
/** Edit-layout toggle on the draggable grid (desktop, custom groups). */
get editLayoutButton(): Locator {
return this.page.getByTestId("toggle-edit-layout");
}
/** Open the right-click context menu on a camera card (desktop only). */ /** Open the right-click context menu on a camera card (desktop only). */
async openContextMenuOn(cameraName: string): Promise<Locator> { async openContextMenuOn(cameraName: string): Promise<Locator> {
await this.cameraCard(cameraName).first().click({ button: "right" }); await this.cameraCard(cameraName).first().click({ button: "right" });
@@ -0,0 +1,185 @@
/**
* Live grid aspect modes.
*
* Bucketed mode (the default) snaps every camera to a wide, landscape or tall
* tile, and converts layouts saved by pre-masonry versions instead of
* discarding them. Natural mode sizes each tile to its own camera.
*/
import { test, expect } from "../fixtures/frigate-test";
import { LivePage } from "../pages/live.page";
import {
cameraBoxes,
persistedLayoutKey,
readLayout,
seedLayout,
type LayoutItem,
} from "../helpers/grid-layout";
const GROUP = "outdoor";
const GRID_COLS = 96;
test.describe("Live grid aspect modes @critical", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Draggable grid is desktop-only",
);
test("an ultra-wide camera gets a 32:9 tile in bucketed mode @mobile", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: {
cameras: { backyard: { detect: { width: 2560, height: 720 } } },
},
});
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("backyard").first()).toBeVisible({
timeout: 10_000,
});
const { backyard: wide, front_door: normal } = await cameraBoxes(
frigateApp.page,
["backyard", "front_door"] as const,
);
expect(wide.w / wide.h).toBeCloseTo(32 / 9, 1);
expect(wide.w / normal.w).toBeCloseTo(2, 1);
expect(wide.h).toBeCloseTo(normal.h, 0);
});
test("a letterboxed still image rounds its own corners", async ({
frigateApp,
}) => {
// A portrait camera pillarboxes inside its 8:9 bucket, so the card's
// overflow-hidden clip never reaches the picture's corners. The image has
// to carry the radius itself or it renders with square edges on the tile.
await frigateApp.installDefaults({
config: {
cameras: { backyard: { detect: { width: 720, height: 1280 } } },
},
});
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("backyard").first()).toBeVisible({
timeout: 10_000,
});
const radii = await frigateApp.page.evaluate(() => {
const card = document.querySelector("[data-camera='backyard']");
const img = card?.querySelector("img");
return {
card: card ? getComputedStyle(card).borderTopLeftRadius : null,
img: img ? getComputedStyle(img).borderTopLeftRadius : null,
};
});
expect(radii.card).not.toBe("0px");
expect(radii.img).toBe(radii.card);
});
test("a pre-masonry layout is converted, keeping resized tiles", async ({
frigateApp,
}) => {
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// 0.17/0.18 shape: bare array on a 12-column grid, 4x4 standard tiles.
// backyard was manually resized to 8x8 and sits beside front_door's column,
// front_door is a standard tile on the row below.
const key = await persistedLayoutKey(frigateApp.page, GROUP);
await seedLayout(frigateApp.page, key, [
{ i: "backyard", x: 4, y: 0, w: 8, h: 8, moved: false, static: false },
{ i: "front_door", x: 0, y: 8, w: 4, h: 4, moved: false, static: false },
]);
await frigateApp.page.reload();
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// The conversion is written back on first load, replacing the legacy array
// with an envelope. Poll for it: that write is an async idb put.
await expect
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
timeout: 10_000,
})
.toBe(2);
const stored = (await readLayout(frigateApp.page, key))!;
expect(stored).toMatchObject({ version: 2, naturalAspect: false });
// x and w scale 8x (12 -> 96 columns), y and h scale 18x (4 -> 72 rows per
// standard tile), so the manual resize survives instead of snapping back.
expect(
stored.layout.find((i: LayoutItem) => i.i === "backyard"),
).toMatchObject({
x: 32,
y: 0,
w: 64,
h: 144,
});
expect(
stored.layout.find((i: LayoutItem) => i.i === "front_door"),
).toMatchObject({
x: 0,
y: 144,
w: 32,
h: 72,
});
// arrangement on screen: backyard indented, front_door below it
const { backyard, front_door: frontDoor } = await cameraBoxes(
frigateApp.page,
["backyard", "front_door"] as const,
);
expect(backyard.x).toBeGreaterThan(frontDoor.x + frontDoor.w / 2);
expect(frontDoor.y).toBeGreaterThan(backyard.y + backyard.h / 2);
});
test("conversion is a pure scale, so odd sizes and positions survive", async ({
frigateApp,
}) => {
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// The old grid exposed all four resize corners with no aspect constraint,
// so a stored tile can be any size. These two are adjacent and non-standard.
const key = await persistedLayoutKey(frigateApp.page, GROUP);
await seedLayout(frigateApp.page, key, [
{ i: "front_door", x: 0, y: 3, w: 5, h: 5 },
{ i: "backyard", x: 5, y: 3, w: 7, h: 5 },
]);
await frigateApp.page.reload();
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(live.cameraCard("backyard").first()).toBeVisible({
timeout: 10_000,
});
await expect
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
timeout: 10_000,
})
.toBe(2);
const stored = (await readLayout(frigateApp.page, key))!;
const frontDoor = stored.layout.find(
(i: LayoutItem) => i.i === "front_door",
)!;
const backyard = stored.layout.find((i: LayoutItem) => i.i === "backyard")!;
expect(frontDoor).toMatchObject({ x: 0, y: 54, w: 40, h: 90 });
expect(backyard).toMatchObject({ x: 40, y: 54, w: 56, h: 90 });
// still adjacent, still inside the grid, still not overlapping
expect(frontDoor.x + frontDoor.w).toBe(backyard.x);
expect(backyard.x + backyard.w).toBe(GRID_COLS);
});
});
+298
View File
@@ -0,0 +1,298 @@
/**
* Masonry live grid -- custom-group draggable layout.
*
* Verifies natural-aspect tile sizing and that a saved layout the current
* version cannot read is regenerated cleanly. The grid renders only for a
* custom camera group (here: "outdoor") on desktop; mobile keeps the static
* grid, which the @mobile block below guards.
*/
import { test, expect } from "../fixtures/frigate-test";
import { LivePage } from "../pages/live.page";
import {
cameraBoxes,
persistedLayoutKey,
readLayout,
seedLayout,
} from "../helpers/grid-layout";
const GROUP = "outdoor"; // custom group: front_door + backyard
test.describe("Masonry live grid @critical", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Draggable masonry grid is desktop-only",
);
test("custom group renders its cameras in the draggable grid", async ({
frigateApp,
}) => {
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
await expect(live.cameraCard("backyard").first()).toBeVisible();
});
test("tiles render at their camera's natural aspect ratio", async ({
frigateApp,
}) => {
// backyard is 9:16, which bucketed mode would snap to an 8:9 tile
await frigateApp.installDefaults({
config: {
cameras: { backyard: { detect: { width: 720, height: 1280 } } },
},
});
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
await seedLayout(frigateApp.page, "naturalAspectLayout:admin", true);
await frigateApp.page.reload();
await expect(live.cameraCard("backyard").first()).toBeVisible({
timeout: 10_000,
});
const { front_door: landscape, backyard: portrait } = await cameraBoxes(
frigateApp.page,
["front_door", "backyard"] as const,
"card",
);
expect(landscape.w / landscape.h).toBeCloseTo(16 / 9, 1);
expect(portrait.w / portrait.h).toBeCloseTo(9 / 16, 1);
});
test("dragging a tile does not shove other tiles far away", async ({
frigateApp,
}) => {
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
await live.editLayoutButton.click();
const cameras = ["front_door", "backyard"] as const;
const { front_door: fixedBefore, backyard: draggedBox } = await cameraBoxes(
frigateApp.page,
cameras,
"card",
);
// Drag backyard onto front_door's position (a deliberate collision). With
// free-placement + prevent-collision, front_door must NOT be shoved down.
const from = {
x: draggedBox.x + draggedBox.w / 2,
y: draggedBox.y + draggedBox.h / 2,
};
const to = {
x: fixedBefore.x + fixedBefore.w / 2,
y: fixedBefore.y + fixedBefore.h / 2,
};
await frigateApp.page.mouse.move(from.x, from.y);
await frigateApp.page.mouse.down();
await frigateApp.page.mouse.move(to.x, to.y, { steps: 15 });
await frigateApp.page.mouse.up();
const { front_door: fixedAfter } = await cameraBoxes(
frigateApp.page,
cameras,
"card",
);
// Allow a few px of snap; a collision-push would move it a whole tile down.
expect(Math.abs(fixedAfter.y - fixedBefore.y)).toBeLessThan(40);
});
test("resizing a top-row tile preserves its aspect ratio (no pillarboxing)", async ({
frigateApp,
}) => {
// A lone top tile has room to grow sideways, which is what exposed the bug:
// a top-edge handle let width grow while height stayed clamped at y=0.
await frigateApp.installDefaults({
config: { camera_groups: { outdoor: { cameras: ["front_door"] } } },
});
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
await live.editLayoutButton.click();
const tile = frigateApp.page.locator(".react-grid-item", {
has: frigateApp.page.locator("[data-camera='front_door']"),
});
const only = ["front_door"] as const;
const { front_door: before } = await cameraBoxes(
frigateApp.page,
only,
"card",
);
const aspect = before.w / before.h;
// Regression: if a top-edge handle is exposed, dragging it up/out must NOT
// distort the aspect (the old bug grew width while height stayed clamped).
const ne = tile.locator(".react-resizable-handle-ne");
if (await ne.count()) {
await ne.dragTo(tile, {
force: true,
targetPosition: { x: 1000, y: -160 },
});
const { front_door: afterNe } = await cameraBoxes(
frigateApp.page,
only,
"card",
);
// It must actually resize (not a silent no-op) AND keep its aspect.
expect(afterNe.w).toBeGreaterThan(before.w);
expect(Math.abs(afterNe.w / afterNe.h - aspect)).toBeLessThan(0.2);
}
// Positive: growing from the bottom-right corner resizes and keeps aspect.
const se = tile.locator(".react-resizable-handle-se");
await se.dragTo(tile, { force: true, targetPosition: { x: 1000, y: 520 } });
const { front_door: grown } = await cameraBoxes(
frigateApp.page,
only,
"card",
);
expect(grown.w).toBeGreaterThan(before.w);
expect(Math.abs(grown.w / grown.h - aspect)).toBeLessThan(0.2);
});
test("the grid keeps its measured width after a back navigation", async ({
frigateApp,
}) => {
// The grid sizes itself from window.innerWidth until its container is
// measured. On a warm back navigation nothing re-renders after that
// container mounts, so an observer that never attaches leaves every tile
// sized against the full window: the layout widens by the sidebar's width
// and the rightmost column clips on a full row.
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// total width the tiles span; tracks the width the grid laid out against
const span = () =>
frigateApp.page.evaluate(() => {
const tiles = [...document.querySelectorAll(".react-grid-item")];
if (!tiles.length) {
return null;
}
const rects = tiles.map((tile) => tile.getBoundingClientRect());
return +(
Math.max(...rects.map((r) => r.right)) -
Math.min(...rects.map((r) => r.left))
).toFixed(1);
});
let fresh: number | null = null;
await expect
.poll(async () => (fresh = await span()), { timeout: 10_000 })
.not.toBeNull();
await live.cameraCard("front_door").first().click();
await expect(frigateApp.page).toHaveURL(/#front_door/);
await frigateApp.page.goBack();
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// the layout must settle back to the measured width, not window.innerWidth
await expect
.poll(span, { timeout: 10_000 })
.toBeLessThanOrEqual(fresh! + 2);
});
test("a camera added to a saved layout fills an open column", async ({
frigateApp,
}) => {
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// one tall tile in the first column; backyard is missing from the layout
const key = await persistedLayoutKey(frigateApp.page, GROUP);
await seedLayout(frigateApp.page, key, {
version: 2,
naturalAspect: false,
layout: [{ i: "front_door", x: 0, y: 0, w: 32, h: 400 }],
});
await frigateApp.page.reload();
await expect
.poll(async () => {
const stored = await readLayout(frigateApp.page, key);
return stored?.layout.find((item) => item.i === "backyard");
})
.toMatchObject({ x: 32, y: 0 });
});
test("saved layout from an unreadable version regenerates without error", async ({
frigateApp,
}) => {
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// A bare array is converted rather than discarded (covered in
// live-grid-aspect-modes), so use a version the current grid cannot read.
const key = await persistedLayoutKey(frigateApp.page, GROUP);
await seedLayout(frigateApp.page, key, {
version: 1,
naturalAspect: false,
layout: [{ i: "front_door", x: 0, y: 0, w: 4, h: 3 }],
});
await frigateApp.page.reload();
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
// Grid regenerated; both cameras still render and the error collector
// (frigate-test fixture) catches any crash.
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
await expect(live.cameraCard("backyard").first()).toBeVisible();
// The app must have replaced the value it could not read. Without this the
// test would still pass against a key the app never touches.
await expect
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
timeout: 10_000,
})
.toBeGreaterThan(1);
});
});
test.describe("Masonry live grid on mobile @critical @mobile", () => {
test("custom group keeps the static grid, with no draggable layout", async ({
frigateApp,
}) => {
test.skip(!frigateApp.isMobile, "Mobile-only");
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, false);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
await expect(live.cameraCard("backyard").first()).toBeVisible();
// isMobileOnly routes around DraggableGridLayout entirely, so neither the
// grid items nor the edit-layout toggle may appear.
await expect(frigateApp.page.locator(".react-grid-item")).toHaveCount(0);
await expect(live.editLayoutButton).toHaveCount(0);
});
});
@@ -217,6 +217,23 @@ test.describe("Detection models settings @high", () => {
).toBeDisabled(); ).toBeDisabled();
}); });
test("shareable hardware another model uses can still be picked", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0"] },
{ scene: "outdoor", devices: ["openvino:GPU.1"] },
]);
await openPage(frigateApp);
await expect(
frigateApp.page.locator("#models-0-openvino\\:GPU\\.1").first(),
).toBeEnabled();
await expect(frigateApp.page.locator("#pageRoot")).not.toContainText(
"used by outdoor",
);
});
test("adding a model appends a card with an unused scene", async ({ test("adding a model appends a card with an unused scene", async ({
frigateApp, frigateApp,
}) => { }) => {
@@ -248,15 +265,13 @@ test.describe("Detection models settings @high", () => {
test("a saved Frigate+ model opens on the Frigate+ tab", async ({ test("a saved Frigate+ model opens on the Frigate+ tab", async ({
frigateApp, frigateApp,
}) => { }) => {
// the backend resolves plus:// to a cache path before serving the config
// back, so the plus metadata is the only signal the model is a Plus one
await installRoutes( await installRoutes(
frigateApp.page, frigateApp.page,
[ [
{ {
scene: "all", scene: "all",
devices: ["openvino:GPU.0"], devices: ["openvino:GPU.0"],
path: "/config/model_cache/abc123", path: "plus://abc123",
plus: PLUS_MODEL, plus: PLUS_MODEL,
}, },
], ],
@@ -302,6 +317,65 @@ test.describe("Detection models settings @high", () => {
expect(saves.at(-1)?.config_data?.models?.[0].path).toBe("plus://abc123"); expect(saves.at(-1)?.config_data?.models?.[0].path).toBe("plus://abc123");
}); });
test("saving a Frigate+ model keeps its reference without the Frigate+ fields", async ({
frigateApp,
}) => {
// the backend fills these in from the Frigate+ model info when it loads
const saves = await installRoutes(
frigateApp.page,
[
{
scene: "all",
devices: ["openvino:GPU.0"],
path: "plus://abc123",
plus: PLUS_MODEL,
width: 320,
height: 320,
input_tensor: "nchw",
input_dtype: "float",
model_type: "yolo-generic",
},
],
true,
);
await openPage(frigateApp);
await frigateApp.page.locator("#models-0-openvino\\:GPU\\.1").click();
await frigateApp.page.getByRole("button", { name: /^Save$/ }).click();
await expect.poll(() => saves.length).toBeGreaterThan(0);
const model = saves.at(-1)?.config_data?.models?.[0];
expect(model?.path).toBe("plus://abc123");
expect(model?.devices).toEqual(["openvino:GPU.0", "openvino:GPU.1"]);
expect(model).not.toHaveProperty("width");
expect(model).not.toHaveProperty("input_tensor");
expect(model).not.toHaveProperty("model_type");
// a leftover dtype from a custom model must not override the int default
expect(model).not.toHaveProperty("input_dtype");
});
test("a Frigate+ model only shows its path without a Frigate+ API key", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{
scene: "all",
devices: ["openvino:GPU.0"],
path: "plus://abc123",
width: 320,
height: 320,
},
]);
await openPage(frigateApp);
const root = frigateApp.page.locator("#pageRoot");
await expect(root).toContainText("Custom object detector model path");
await expect(root).not.toContainText("Object detection model input width");
await expect(root).not.toContainText(
"Label map for custom object detector",
);
});
test("a Frigate+ Hailo model is listed by the device it was built for", async ({ test("a Frigate+ Hailo model is listed by the device it was built for", async ({
frigateApp, frigateApp,
}) => { }) => {
@@ -13,7 +13,26 @@ import type { Page } from "@playwright/test";
const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout:admin"; const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout:admin";
const STREAMING_KEY = "streaming-settings:admin"; const STREAMING_KEY = "streaming-settings:admin";
const OUTDOOR_LAYOUT = [ // the shape DraggableGridLayout writes
const OUTDOOR_LAYOUT = {
version: 2,
naturalAspect: false,
layout: [
{ i: "front_door", x: 0, y: 0, w: 32, h: 72 },
{ i: "backyard", x: 32, y: 0, w: 32, h: 72 },
],
};
const NATURAL_OUTDOOR_LAYOUT = {
version: 2,
naturalAspect: true,
layout: [
{ i: "front_door", x: 0, y: 0, w: 32, h: 72 },
{ i: "backyard", x: 32, y: 0, w: 24, h: 96 },
],
};
const LEGACY_OUTDOOR_LAYOUT = [
{ i: "front_door", x: 0, y: 0, w: 6, h: 4 }, { i: "front_door", x: 0, y: 0, w: 6, h: 4 },
{ i: "backyard", x: 6, y: 0, w: 6, h: 4 }, { i: "backyard", x: 6, y: 0, w: 6, h: 4 },
]; ];
@@ -159,6 +178,160 @@ test.describe("UI settings import/export @medium", () => {
expect(payload.sections.preferences.playbackRate).toBe(2); expect(payload.sections.preferences.playbackRate).toBe(2);
}); });
test("exports only layouts built for the current tile sizing mode", async ({
frigateApp,
}) => {
// a group not opened since the mode changed still holds a layout from
// the other mode, which would import into a mode that cannot show it
await frigateApp.goto("/settings?page=uiSettings");
await writeIdb(frigateApp.page, {
[OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT,
"default-draggable-layout:admin": NATURAL_OUTDOOR_LAYOUT,
"naturalAspectLayout:admin": true,
});
const downloadPromise = frigateApp.page.waitForEvent("download");
await frigateApp.page
.getByRole("button", { name: "Export Settings" })
.click();
const download = await downloadPromise;
const payload = JSON.parse(readFileSync((await download.path())!, "utf-8"));
expect(payload.sections.layouts).toEqual({
default: NATURAL_OUTDOOR_LAYOUT,
});
});
test("toggling tile sizing mode clears stored layouts", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "The setting is hidden on phones");
await frigateApp.goto("/settings?page=uiSettings");
await writeIdb(frigateApp.page, { [OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT });
await frigateApp.page.locator("#natural-aspect-desktop").click();
await frigateApp.page
.getByRole("alertdialog")
.getByRole("button", { name: "Enable" })
.click();
await expect
.poll(() => readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY))
.toBeNull();
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
true,
);
});
test("round-trips a layout left unconverted by an upgrade", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
// DraggableGridLayout rewrites a pre-0.19 layout only when that group's
// dashboard is opened, so exporting first carries the bare array into the
// file. Import must accept it back rather than rejecting the whole file.
await frigateApp.goto("/settings?page=uiSettings");
await writeIdb(frigateApp.page, {
[OUTDOOR_LAYOUT_KEY]: LEGACY_OUTDOOR_LAYOUT,
"playbackRate:admin": 2,
});
const downloadPromise = frigateApp.page.waitForEvent("download");
await frigateApp.page
.getByRole("button", { name: "Export Settings" })
.click();
const download = await downloadPromise;
const contents = readFileSync((await download.path())!, "utf-8");
expect(JSON.parse(contents).sections.layouts.outdoor).toEqual(
LEGACY_OUTDOOR_LAYOUT,
);
await clearIdb(frigateApp.page);
await chooseImportText(frigateApp.page, contents);
await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
LEGACY_OUTDOOR_LAYOUT,
);
// the rest of the file must survive alongside it
expect(await readIdb(frigateApp.page, "playbackRate:admin")).toBe(2);
});
test("legacy layouts import turns natural aspect off so they display", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
// Bare-array layouts only render in bucketed mode; with natural aspect on
// they would be discarded and regenerated on the next dashboard visit. The
// import applies the mode the layouts were built for, and the file's own
// naturalAspectLayout preference must not override that.
await frigateApp.goto("/settings?page=uiSettings");
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": true });
await chooseImportFile(
frigateApp.page,
importPayload({
sections: {
layouts: { outdoor: LEGACY_OUTDOOR_LAYOUT },
streaming: {},
preferences: { naturalAspectLayout: true },
},
}),
);
const note = frigateApp.page.getByText(/standard tile sizing/);
await expect(note).toBeVisible();
// the note is about the layouts section, so it follows its switch
await frigateApp.page.getByText("Camera group layouts (1 group)").click();
await expect(note).toBeHidden();
await frigateApp.page.getByText("Camera group layouts (1 group)").click();
await expect(note).toBeVisible();
await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
LEGACY_OUTDOOR_LAYOUT,
);
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
false,
);
});
test("natural aspect layouts import turns the setting on", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
await frigateApp.goto("/settings?page=uiSettings");
await chooseImportFile(
frigateApp.page,
importPayload({
sections: {
layouts: { outdoor: NATURAL_OUTDOOR_LAYOUT },
streaming: {},
preferences: {},
},
}),
);
await expect(
frigateApp.page.getByText(/camera aspect ratio tile sizing/),
).toBeVisible();
await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
NATURAL_OUTDOOR_LAYOUT,
);
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
true,
);
});
test("omits settings that were never stored", async ({ frigateApp }) => { test("omits settings that were never stored", async ({ frigateApp }) => {
await frigateApp.goto("/settings?page=uiSettings"); await frigateApp.goto("/settings?page=uiSettings");
@@ -190,12 +363,15 @@ test.describe("UI settings import/export @medium", () => {
await expect( await expect(
frigateApp.page.getByText("UI preferences (2 settings)"), frigateApp.page.getByText("UI preferences (2 settings)"),
).toBeVisible(); ).toBeVisible();
await expect(frigateApp.page.getByText(/patio/)).toBeVisible(); // patio is layout-only, so its warning follows the layouts section
await expect(frigateApp.page.getByText(/patio/)).toBeVisible({
visible: !frigateApp.isMobile,
});
await confirmImport(frigateApp.page); await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
OUTDOOR_LAYOUT, frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
); );
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual( expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
STREAMING_SETTINGS, STREAMING_SETTINGS,
@@ -206,6 +382,7 @@ test.describe("UI settings import/export @medium", () => {
test("hides the unknown-group warning when layouts are switched off", async ({ test("hides the unknown-group warning when layouts are switched off", async ({
frigateApp, frigateApp,
}) => { }) => {
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
await frigateApp.goto("/settings?page=uiSettings"); await frigateApp.goto("/settings?page=uiSettings");
// patio is a layout-only group absent from this server, so the warning // patio is a layout-only group absent from this server, so the warning
@@ -235,7 +412,39 @@ test.describe("UI settings import/export @medium", () => {
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({}); expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({});
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
OUTDOOR_LAYOUT, frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
);
});
test("phones refuse layouts and say why @mobile", async ({ frigateApp }) => {
test.skip(!frigateApp.isMobile, "Phone-only");
await frigateApp.goto("/settings?page=uiSettings");
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": false });
await chooseImportFile(frigateApp.page, importPayload());
await expect(
frigateApp.page.getByText(/aren't imported on phones/),
).toBeVisible();
// the section is still listed, but cannot be switched on
await expect(
frigateApp.page.getByText("Camera group layouts (2 groups)"),
).toBeVisible();
await expect(
frigateApp.page.locator('[id="Camera group layouts (2 groups)"]'),
).toBeDisabled();
await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull();
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
STREAMING_SETTINGS,
);
// a layouts import is what flips this, so it must stay put
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
false,
); );
}); });
@@ -325,7 +534,7 @@ test.describe("UI settings import/export @medium", () => {
await confirmImport(frigateApp.page); await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
OUTDOOR_LAYOUT, frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
); );
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual( expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
STREAMING_SETTINGS, STREAMING_SETTINGS,
+258 -121
View File
@@ -1,7 +1,8 @@
/** /**
* Health tab tests -- MEDIUM tier. * Health tab tests -- MEDIUM tier.
* *
* Default tab, notice list rendering, dismiss, empty state, update notice. * Default tab, notice list rendering, acknowledge and mute, empty state,
* update notice.
*/ */
import { test, expect } from "../fixtures/frigate-test"; import { test, expect } from "../fixtures/frigate-test";
@@ -23,7 +24,9 @@ const ERROR_NOTICE = {
first_seen: NOW - 600, first_seen: NOW - 600,
last_seen: NOW, last_seen: NOW,
count: 2, count: 2,
dismissed_at: null, acknowledgeable: true,
acknowledged_at: null,
muted_at: null,
}; };
const EVENT_NOTICE = { const EVENT_NOTICE = {
@@ -37,7 +40,9 @@ const EVENT_NOTICE = {
first_seen: NOW - 7200, first_seen: NOW - 7200,
last_seen: NOW - 60, last_seen: NOW - 60,
count: 3, count: 3,
dismissed_at: null, acknowledgeable: true,
acknowledged_at: null,
muted_at: null,
}; };
test.describe("System — Health tab @medium", () => { test.describe("System — Health tab @medium", () => {
@@ -63,54 +68,61 @@ test.describe("System — Health tab @medium", () => {
"Downloading model.onnx for yolo failed: timeout", "Downloading model.onnx for yolo failed: timeout",
); );
await expect(rows.nth(0)).toContainText("2 times"); await expect(rows.nth(0)).toContainText("2 times");
await expect(
rows.nth(0).getByRole("button", { name: "Dismiss" }),
).toBeVisible();
await expect(rows.nth(1)).toContainText("Detector ov was restarted"); await expect(rows.nth(1)).toContainText("Detector ov was restarted");
await expect(rows.nth(1)).toContainText("3 times"); await expect(rows.nth(1)).toContainText("3 times");
await expect( for (const index of [0, 1]) {
rows.nth(1).getByRole("button", { name: "Dismiss" }), await expect(
).toBeVisible(); rows.nth(index).getByRole("button", { name: "Acknowledge" }),
).toBeVisible();
await expect(
rows.nth(index).getByRole("button", { name: "Mute", exact: true }),
).toBeVisible();
}
}); });
test("dismiss posts and removes the row", async ({ frigateApp }) => { for (const action of ["acknowledge", "mute"] as const) {
await frigateApp.installDefaults({ test(`${action} posts and removes the row`, async ({ frigateApp }) => {
stats: QUIET_STATS, await frigateApp.installDefaults({
notices: [EVENT_NOTICE], stats: QUIET_STATS,
notices: [EVENT_NOTICE],
});
// the list shrinks after the POST so the refetch shows the row gone
let hidden = false;
await frigateApp.page.route("**/api/notices", (route) =>
route.fulfill({ json: hidden ? [] : [EVENT_NOTICE] }),
);
await frigateApp.page.route(
`**/api/notices/detector_stuck/${action}`,
(route) => {
hidden = true;
return route.fulfill({ json: { success: true } });
},
);
await frigateApp.goto("/system#health");
const request = frigateApp.page.waitForRequest(
(req) =>
req.url().includes(`/api/notices/detector_stuck/${action}`) &&
req.method() === "POST",
);
await frigateApp.page
.getByTestId("health-problem-notice:detector_stuck")
.getByRole("button", {
name: action === "mute" ? "Mute" : "Acknowledge",
exact: true,
})
.click();
await request;
await expect(
frigateApp.page.locator("[data-testid^='health-problem-']"),
).toHaveCount(0, { timeout: 5_000 });
await expect(
frigateApp.page.getByText("Your Frigate installation is healthy"),
).toBeVisible();
}); });
}
// the list shrinks after the dismiss so the refetch shows the row gone
let dismissed = false;
await frigateApp.page.route("**/api/notices", (route) =>
route.fulfill({ json: dismissed ? [] : [EVENT_NOTICE] }),
);
await frigateApp.page.route(
"**/api/notices/detector_stuck/dismiss",
(route) => {
dismissed = true;
return route.fulfill({ json: { success: true } });
},
);
await frigateApp.goto("/system#health");
const request = frigateApp.page.waitForRequest(
(req) =>
req.url().includes("/api/notices/detector_stuck/dismiss") &&
req.method() === "POST",
);
await frigateApp.page
.getByTestId("health-problem-notice:detector_stuck")
.getByRole("button", { name: "Dismiss" })
.click();
await request;
await expect(
frigateApp.page.locator("[data-testid^='health-problem-']"),
).toHaveCount(0, { timeout: 5_000 });
await expect(
frigateApp.page.getByText("Your Frigate installation is healthy"),
).toBeVisible();
});
test("empty state with no notices", async ({ frigateApp }) => { test("empty state with no notices", async ({ frigateApp }) => {
await frigateApp.installDefaults({ stats: QUIET_STATS }); await frigateApp.installDefaults({ stats: QUIET_STATS });
@@ -140,7 +152,9 @@ test.describe("System — Health tab @medium", () => {
first_seen: NOW - 3600, first_seen: NOW - 3600,
last_seen: NOW, last_seen: NOW,
count: 1, count: 1,
dismissed_at: null, acknowledgeable: false,
acknowledged_at: null,
muted_at: null,
}, },
], ],
}); });
@@ -156,10 +170,23 @@ test.describe("System — Health tab @medium", () => {
"href", "href",
"https://github.com/blakeblackshear/frigate/releases/tag/v0.19.0", "https://github.com/blakeblackshear/frigate/releases/tag/v0.19.0",
); );
await expect(row.getByRole("button", { name: "Dismiss" })).toBeVisible(); await expect(
row.getByRole("button", { name: "Mute", exact: true }),
).toBeVisible();
await expect(row.getByRole("button", { name: "Acknowledge" })).toHaveCount(
0,
);
}); });
test("the filter shows dismissed notices without a Dismiss button", async ({ const ACKNOWLEDGED_NOTICE = {
...EVENT_NOTICE,
id: "detector_stuck:coral",
params: { detector: "coral" },
acknowledged_at: NOW - 60,
};
const MUTED_NOTICE = { ...ERROR_NOTICE, muted_at: NOW - 120 };
test("the filter shows hidden notices marked by how they were hidden", async ({
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
@@ -169,33 +196,85 @@ test.describe("System — Health tab @medium", () => {
await frigateApp.page.route( await frigateApp.page.route(
(url) => (url) =>
url.pathname.endsWith("/api/notices") && url.pathname.endsWith("/api/notices") &&
url.searchParams.get("include_dismissed") === "true", url.searchParams.get("include_hidden") === "true",
(route) => (route) =>
route.fulfill({ route.fulfill({
json: [EVENT_NOTICE, { ...ERROR_NOTICE, dismissed_at: NOW - 120 }], json: [EVENT_NOTICE, ACKNOWLEDGED_NOTICE, MUTED_NOTICE],
}), }),
); );
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
await frigateApp.page.getByRole("button", { name: "Filter" }).click(); await frigateApp.page.getByRole("button", { name: "Filter" }).click();
const showDismissed = frigateApp.page.getByRole("switch", { const showHidden = frigateApp.page.getByRole("switch", {
name: "Show dismissed", name: "Show hidden",
}); });
await expect(showDismissed).toHaveAttribute("aria-checked", "false"); await expect(showHidden).toHaveAttribute("aria-checked", "false");
await showDismissed.click(); await showHidden.click();
// mobile opens the filter as a modal drawer, which hides the rows' roles
await frigateApp.page.keyboard.press("Escape");
const row = frigateApp.page.getByTestId( const acknowledged = frigateApp.page.getByTestId(
"health-problem-notice:detector_stuck:coral",
);
await expect(acknowledged).toBeVisible({ timeout: 15_000 });
await expect(acknowledged).toContainText("Acknowledged");
await expect(
acknowledged.getByRole("button", { name: "Show again" }),
).toBeVisible();
await expect(
acknowledged.getByRole("button", { name: "Acknowledge" }),
).toHaveCount(0);
const muted = frigateApp.page.getByTestId(
"health-problem-notice:model_download_failed:yolo/model.onnx", "health-problem-notice:model_download_failed:yolo/model.onnx",
); );
await expect(row).toBeVisible({ timeout: 15_000 }); await expect(muted).toContainText("Muted");
await expect(row).toContainText("Dismissed"); await expect(muted.getByRole("button", { name: "Unmute" })).toBeVisible();
await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0); await expect(
muted.getByRole("button", { name: "Mute", exact: true }),
).toHaveCount(0);
await showDismissed.click(); await frigateApp.page.getByRole("button", { name: "Filter" }).click();
await expect(row).toHaveCount(0); await showHidden.click();
await expect(muted).toHaveCount(0);
}); });
test("clear dismissed deletes the dismissed rows after confirming", async ({ test("unmute deletes the row's hidden state", async ({ frigateApp }) => {
await frigateApp.installDefaults({ stats: QUIET_STATS, notices: [] });
await frigateApp.page.route(
(url) =>
url.pathname.endsWith("/api/notices") &&
url.searchParams.get("include_hidden") === "true",
(route) => route.fulfill({ json: [MUTED_NOTICE] }),
);
await frigateApp.page.route(
"**/api/notices/model_download_failed:yolo/model.onnx/hidden",
(route) => route.fulfill({ json: { success: true } }),
);
await frigateApp.goto("/system#health");
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click();
await frigateApp.page.keyboard.press("Escape");
const request = frigateApp.page.waitForRequest(
(req) =>
req
.url()
.endsWith(
"/api/notices/model_download_failed:yolo/model.onnx/hidden",
) && req.method() === "DELETE",
);
await frigateApp.page
.getByTestId(
"health-problem-notice:model_download_failed:yolo/model.onnx",
)
.getByRole("button", { name: "Unmute" })
.click({ timeout: 15_000 });
await request;
});
test("show all again unhides every row after confirming", async ({
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
@@ -203,29 +282,25 @@ test.describe("System — Health tab @medium", () => {
notices: [EVENT_NOTICE], notices: [EVENT_NOTICE],
}); });
// the history loses its dismissed row once the DELETE lands // the hidden list loses its muted row once the DELETE lands
let cleared = false; let cleared = false;
await frigateApp.page.route( await frigateApp.page.route(
(url) => (url) =>
url.pathname.endsWith("/api/notices") && url.pathname.endsWith("/api/notices") &&
url.searchParams.get("include_dismissed") === "true", url.searchParams.get("include_hidden") === "true",
(route) => (route) =>
route.fulfill({ route.fulfill({
json: cleared json: cleared ? [EVENT_NOTICE] : [EVENT_NOTICE, MUTED_NOTICE],
? [EVENT_NOTICE]
: [EVENT_NOTICE, { ...ERROR_NOTICE, dismissed_at: NOW - 120 }],
}), }),
); );
await frigateApp.page.route("**/api/notices/dismissed", (route) => { await frigateApp.page.route("**/api/notices/hidden", (route) => {
cleared = true; cleared = true;
return route.fulfill({ json: { success: true } }); return route.fulfill({ json: { success: true } });
}); });
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
await frigateApp.page.getByRole("button", { name: "Filter" }).click(); await frigateApp.page.getByRole("button", { name: "Filter" }).click();
await frigateApp.page await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click();
.getByRole("switch", { name: "Show dismissed" })
.click();
const row = frigateApp.page.getByTestId( const row = frigateApp.page.getByTestId(
"health-problem-notice:model_download_failed:yolo/model.onnx", "health-problem-notice:model_download_failed:yolo/model.onnx",
); );
@@ -233,23 +308,20 @@ test.describe("System — Health tab @medium", () => {
await frigateApp.page.keyboard.press("Escape"); await frigateApp.page.keyboard.press("Escape");
await frigateApp.page await frigateApp.page
.getByRole("button", { name: "Clear dismissed" }) .getByRole("button", { name: "Show all again" })
.click(); .click();
const request = frigateApp.page.waitForRequest( const request = frigateApp.page.waitForRequest(
(req) => (req) =>
req.url().endsWith("/api/notices/dismissed") && req.url().endsWith("/api/notices/hidden") && req.method() === "DELETE",
req.method() === "DELETE",
); );
await frigateApp.page await frigateApp.page
.getByRole("alertdialog") .getByRole("alertdialog")
.getByRole("button", { name: "Clear dismissed" }) .getByRole("button", { name: "Show all again" })
.click(); .click();
await request; await request;
await expect(row).toHaveCount(0); await expect(row).toHaveCount(0);
await expect( await expect(frigateApp.page.getByText("No hidden notices")).toBeVisible();
frigateApp.page.getByText("No dismissed notices"),
).toBeVisible();
}); });
test("severity switches hide notices of that severity", async ({ test("severity switches hide notices of that severity", async ({
@@ -290,7 +362,9 @@ test.describe("System — Health tab @medium", () => {
first_seen: start, first_seen: start,
last_seen: start + 60, last_seen: start + 60,
count, count,
dismissed_at: null, acknowledgeable: true,
acknowledged_at: null,
muted_at: null,
}); });
await frigateApp.installDefaults({ await frigateApp.installDefaults({
stats: QUIET_STATS, stats: QUIET_STATS,
@@ -332,7 +406,9 @@ test.describe("System — Health tab @medium", () => {
first_seen: NOW - 600, first_seen: NOW - 600,
last_seen: NOW - 600, last_seen: NOW - 600,
count: 1, count: 1,
dismissed_at: null, acknowledgeable: true,
acknowledged_at: null,
muted_at: null,
}, },
], ],
}); });
@@ -363,7 +439,9 @@ test.describe("System — Health tab @medium", () => {
first_seen: NOW - 600, first_seen: NOW - 600,
last_seen: NOW - 600, last_seen: NOW - 600,
count: 1, count: 1,
dismissed_at: null, acknowledgeable: true,
acknowledged_at: null,
muted_at: null,
}, },
], ],
}); });
@@ -695,7 +773,7 @@ test.describe("System — Health notices sources @medium", () => {
// the status bar shows a problem, so stats have loaded // the status bar shows a problem, so stats have loaded
await expect( await expect(
frigateApp.page.getByText("Front Door is offline"), frigateApp.page.getByText("Recordings are being deleted"),
).toBeVisible({ timeout: 15_000 }); ).toBeVisible({ timeout: 15_000 });
await expect( await expect(
frigateApp.page.locator("[data-testid^='health-problem-']"), frigateApp.page.locator("[data-testid^='health-problem-']"),
@@ -1046,7 +1124,7 @@ test.describe("System — Health notices sources @medium", () => {
await expect(frigateApp.page).toHaveURL(/\/system#health/); await expect(frigateApp.page).toHaveURL(/\/system#health/);
}); });
test("status bar counts undismissed notices next to the health text", async ({ test("status bar counts shown notices next to the health text", async ({
frigateApp, frigateApp,
}) => { }) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only"); test.skip(frigateApp.isMobile, "Status bar is desktop-only");
@@ -1089,41 +1167,61 @@ test.describe("System — Health notices sources @medium", () => {
await expect(frigateApp.page.getByText("System is healthy")).toHaveCount(0); await expect(frigateApp.page.getByText("System is healthy")).toHaveCount(0);
}); });
test("a config row can be dismissed", async ({ frigateApp }) => { // the slow detector warning is added before the offline error
const id = "config:detect:fps-greater-than-five:camera.garage"; const TWO_PROBLEM_STATS = {
await frigateApp.installDefaults({ detectors: { cpu: { inference_speed: 60 } },
config: { cameras: { front_door: { camera_fps: 0 } },
cameras: { };
garage: { detect: { width: 2560, height: 1440, fps: 10 } },
},
},
stats: QUIET_STATS,
});
// the list gains the dismissal after the POST so the refetch hides the row test("status bar collapses several problems behind the most severe", async ({
let dismissed = false; frigateApp,
await frigateApp.page.route(`**/api/notices/${id}/dismiss`, (route) => { }) => {
dismissed = true; test.skip(frigateApp.isMobile, "Status bar is desktop-only");
return route.fulfill({ json: { success: true } }); await frigateApp.installDefaults({ stats: TWO_PROBLEM_STATS });
}); await frigateApp.goto("/");
await frigateApp.page.route("**/api/notices/dismissed_checks", (route) =>
route.fulfill({ json: dismissed ? [{ id, dismissed_at: NOW }] : [] }),
);
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(`health-problem-${id}`); const summary = frigateApp.page.getByRole("button", {
await expect(row).toBeVisible({ timeout: 15_000 }); name: "Front Door is offline +1",
const request = frigateApp.page.waitForRequest( });
(req) => await expect(summary).toBeVisible({ timeout: 15_000 });
req.url().includes(`/api/notices/${id}/dismiss`) && await expect(frigateApp.page.getByText("Cpu is slow")).toHaveCount(0);
req.method() === "POST",
); await summary.click();
await row.getByRole("button", { name: "Dismiss" }).click(); const list = frigateApp.page.getByTestId("status-message-list");
await request; await expect(list.getByRole("link")).toHaveText([
await expect(row).toHaveCount(0); "Front Door is offline",
"Cpu is slow (60 ms)",
]);
await list.getByRole("link", { name: "Cpu is slow (60 ms)" }).click();
await expect(frigateApp.page).toHaveURL(/\/system#general/);
await expect(list).toHaveCount(0);
}); });
test("dismissed config rows move to the dismissed list", async ({ test("mobile status drawer stacks every problem", async ({ frigateApp }) => {
test.skip(!frigateApp.isMobile, "Mobile-only");
await frigateApp.installDefaults({ stats: TWO_PROBLEM_STATS });
await frigateApp.goto("/");
await frigateApp.page
.getByTestId("status-alert-trigger")
.click({ timeout: 15_000 });
const items = frigateApp.page
.getByTestId("status-message-list")
.getByRole("link");
await expect(items).toHaveText([
"Front Door is offline",
"Cpu is slow (60 ms)",
]);
const [first, second] = await Promise.all([
items.nth(0).boundingBox(),
items.nth(1).boundingBox(),
]);
expect(second!.y).toBeGreaterThan(first!.y);
});
test("a config row can be muted but not acknowledged", async ({
frigateApp, frigateApp,
}) => { }) => {
const id = "config:detect:fps-greater-than-five:camera.garage"; const id = "config:detect:fps-greater-than-five:camera.garage";
@@ -1134,12 +1232,49 @@ test.describe("System — Health notices sources @medium", () => {
}, },
}, },
stats: QUIET_STATS, stats: QUIET_STATS,
dismissedChecks: [{ id, dismissed_at: NOW - 120 }], });
// the list gains the mute after the POST so the refetch hides the row
let muted = false;
await frigateApp.page.route(`**/api/notices/${id}/mute`, (route) => {
muted = true;
return route.fulfill({ json: { success: true } });
});
await frigateApp.page.route("**/api/notices/muted_checks", (route) =>
route.fulfill({ json: muted ? [{ id, muted_at: NOW }] : [] }),
);
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(`health-problem-${id}`);
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(row.getByRole("button", { name: "Acknowledge" })).toHaveCount(
0,
);
const request = frigateApp.page.waitForRequest(
(req) =>
req.url().includes(`/api/notices/${id}/mute`) &&
req.method() === "POST",
);
await row.getByRole("button", { name: "Mute", exact: true }).click();
await request;
await expect(row).toHaveCount(0);
});
test("muted config rows move to the hidden list", async ({ frigateApp }) => {
const id = "config:detect:fps-greater-than-five:camera.garage";
await frigateApp.installDefaults({
config: {
cameras: {
garage: { detect: { width: 2560, height: 1440, fps: 10 } },
},
},
stats: QUIET_STATS,
mutedChecks: [{ id, muted_at: NOW - 120 }],
}); });
await frigateApp.page.route( await frigateApp.page.route(
(url) => (url) =>
url.pathname.endsWith("/api/notices") && url.pathname.endsWith("/api/notices") &&
url.searchParams.get("include_dismissed") === "true", url.searchParams.get("include_hidden") === "true",
(route) => route.fulfill({ json: [] }), (route) => route.fulfill({ json: [] }),
); );
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
@@ -1153,12 +1288,14 @@ test.describe("System — Health notices sources @medium", () => {
await expect(row).toHaveCount(0); await expect(row).toHaveCount(0);
await frigateApp.page.getByRole("button", { name: "Filter" }).click(); await frigateApp.page.getByRole("button", { name: "Filter" }).click();
await frigateApp.page await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click();
.getByRole("switch", { name: "Show dismissed" }) await frigateApp.page.keyboard.press("Escape");
.click();
await expect(row).toBeVisible(); await expect(row).toBeVisible();
await expect(row).toContainText("Dismissed"); await expect(row).toContainText("Muted");
await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0); await expect(row.getByRole("button", { name: "Unmute" })).toBeVisible();
await expect(
row.getByRole("button", { name: "Mute", exact: true }),
).toHaveCount(0);
}); });
}); });
@@ -1223,7 +1223,6 @@
"desc": "Frigate можа сам адпраўляць push-апавяшчэнні на вашу прыладу, калі працуе ў браўзеры або ўсталяваны як PWA." "desc": "Frigate можа сам адпраўляць push-апавяшчэнні на вашу прыладу, калі працуе ў браўзеры або ўсталяваны як PWA."
}, },
"notificationUnavailable": { "notificationUnavailable": {
"title": "Апавяшчэнні недаступныя",
"desc": "Вэб-push-апавяшчэнні патрабуюць бяспечнага кантэксту (<code>https://…</code>). Гэта абмежаванне браўзера. Каб карыстацца апавяшчэннямі, адкрывайце Frigate па бяспечным пратаколе.", "desc": "Вэб-push-апавяшчэнні патрабуюць бяспечнага кантэксту (<code>https://…</code>). Гэта абмежаванне браўзера. Каб карыстацца апавяшчэннямі, адкрывайце Frigate па бяспечным пратаколе.",
"descPwa": "У iOS вэб-push-апавяшчэнні даступныя, толькі калі Frigate дададзены на хатні экран. Адкрыйце меню <strong>Абагуліць</strong>, выберыце <strong>На хатні экран</strong>, а потым адкрыйце Frigate праз новы значок, каб зарэгістраваць гэту прыладу для апавяшчэнняў." "descPwa": "У iOS вэб-push-апавяшчэнні даступныя, толькі калі Frigate дададзены на хатні экран. Адкрыйце меню <strong>Абагуліць</strong>, выберыце <strong>На хатні экран</strong>, а потым адкрыйце Frigate праз новы значок, каб зарэгістраваць гэту прыладу для апавяшчэнняў."
}, },
@@ -1331,7 +1330,6 @@
"triggers": { "triggers": {
"documentTitle": "Трыгеры", "documentTitle": "Трыгеры",
"semanticSearch": { "semanticSearch": {
"title": "Семантычны пошук адключаны",
"desc": "Каб карыстацца трыгерамі, трэба ўключыць семантычны пошук." "desc": "Каб карыстацца трыгерамі, трэба ўключыць семантычны пошук."
}, },
"management": { "management": {
-10
View File
@@ -236,8 +236,6 @@
}, },
"lastRefreshed": "Апошняе абнаўленне: ", "lastRefreshed": "Апошняе абнаўленне: ",
"stats": { "stats": {
"ffmpegHighCpuUsage": "{{camera}} моцна нагружае CPU праз FFmpeg ({{ffmpegAvg}}%)",
"detectHighCpuUsage": "{{camera}} моцна нагружае CPU дэтэктаваннем ({{detectAvg}}%)",
"healthy": "Сістэма спраўная", "healthy": "Сістэма спраўная",
"reindexingEmbeddings": "Пераіндэксацыя ўбудаванняў (гатова {{processed}}%)", "reindexingEmbeddings": "Пераіндэксацыя ўбудаванняў (гатова {{processed}}%)",
"cameraIsOffline": "{{camera}} па-за сеткай", "cameraIsOffline": "{{camera}} па-за сеткай",
@@ -281,20 +279,12 @@
"notices": { "notices": {
"title": "Заўвагі", "title": "Заўвагі",
"empty": "Ваша ўсталёўка Frigate у парадку", "empty": "Ваша ўсталёўка Frigate у парадку",
"dismiss": "Схаваць",
"openSettings": "Адкрыць налады", "openSettings": "Адкрыць налады",
"openLink": "Адкрыць спасылку", "openLink": "Адкрыць спасылку",
"noMatches": "Няма заўваг, якія адпавядаюць фільтру", "noMatches": "Няма заўваг, якія адпавядаюць фільтру",
"dismissedTitle": "Схаваныя",
"noneDismissed": "Няма схаваных заўваг",
"clearDismissed": "Ачысціць схаваныя",
"clearDismissedTitle": "Ачысціць схаваныя заўвагі?",
"clearDismissedDesc": "Усе схаваныя заўвагі будуць выдалены. Праверкі канфігурацыі і плыняў пакажуцца адразу, а іншыя заўвагі вернуцца пры наступным узнікненні.",
"firstSeen_one": "Упершыню {{time}}", "firstSeen_one": "Упершыню {{time}}",
"firstSeen_other": "Упершыню {{time}} · {{count}} разу", "firstSeen_other": "Упершыню {{time}} · {{count}} разу",
"dismissedAt": "Схавана {{time}}",
"filter": { "filter": {
"showDismissed": "Паказаць схаваныя",
"severity": "Узровень важнасці", "severity": "Узровень важнасці",
"error": "Памылка", "error": "Памылка",
"warning": "Папярэджанне", "warning": "Папярэджанне",
@@ -1037,7 +1037,6 @@
"desc": "Frigate može nativno slati obavijesti na vaš uređaj kada radi u pregledaču ili je instalirana kao PWA." "desc": "Frigate može nativno slati obavijesti na vaš uređaj kada radi u pregledaču ili je instalirana kao PWA."
}, },
"notificationUnavailable": { "notificationUnavailable": {
"title": "Obavijesti nedostupne",
"desc": "Web obavijesti zahtijevaju sigurni kontekst (<code>https://…</code>). Ovo je ograničenje pregledača. Pristupite Frigate sigurno da biste koristili obavijesti." "desc": "Web obavijesti zahtijevaju sigurni kontekst (<code>https://…</code>). Ovo je ograničenje pregledača. Pristupite Frigate sigurno da biste koristili obavijesti."
}, },
"globalSettings": { "globalSettings": {
@@ -1135,7 +1134,6 @@
"triggers": { "triggers": {
"documentTitle": "Pokretači", "documentTitle": "Pokretači",
"semanticSearch": { "semanticSearch": {
"title": "Semantička pretraga je onemogućena",
"desc": "Semantička pretraga mora biti omogućena da biste koristili izazivače." "desc": "Semantička pretraga mora biti omogućena da biste koristili izazivače."
}, },
"management": { "management": {
-2
View File
@@ -211,8 +211,6 @@
}, },
"lastRefreshed": "Posljednje ažuriranje: ", "lastRefreshed": "Posljednje ažuriranje: ",
"stats": { "stats": {
"ffmpegHighCpuUsage": "{{camera}} ima visoku upotrebu CPU za FFmpeg ({{ffmpegAvg}}%)",
"detectHighCpuUsage": "{{camera}} ima visoku upotrebu CPU za detekciju ({{detectAvg}}%)",
"healthy": "Sistem je zdrav", "healthy": "Sistem je zdrav",
"reindexingEmbeddings": "Ponovno indeksiranje ugrađenih vjerodajnica ({{processed}}% završeno)", "reindexingEmbeddings": "Ponovno indeksiranje ugrađenih vjerodajnica ({{processed}}% završeno)",
"cameraIsOffline": "{{camera}} je offline", "cameraIsOffline": "{{camera}} je offline",
+9 -6
View File
@@ -119,6 +119,9 @@
"liveFallbackTimeout": { "liveFallbackTimeout": {
"label": "Temps d'espera per a la reserva del jugador en directe", "label": "Temps d'espera per a la reserva del jugador en directe",
"desc": "Quan el flux en viu d'alta qualitat d'una càmera no està disponible, torneu al mode d'amplada de banda baixa després d'aquests molts segons. Per defecte: 3." "desc": "Quan el flux en viu d'alta qualitat d'una càmera no està disponible, torneu al mode d'amplada de banda baixa després d'aquests molts segons. Per defecte: 3."
},
"naturalAspectLayout": {
"label": "Utilitza relació natural d'aspecte"
} }
}, },
"storedLayouts": { "storedLayouts": {
@@ -455,9 +458,8 @@
}, },
"title": "Notificacions", "title": "Notificacions",
"notificationUnavailable": { "notificationUnavailable": {
"title": "Notificacions no disponibles", "desc": "Les notificacions push web requereixen un context segur (https://…). Aquesta és una limitació del navegador. Accedeix a Frigate de manera segura per utilitzar les notificacions.",
"desc": "Les notificacions push web requereixen un context segur (<code>https://…</code>). Aquesta és una limitació del navegador. Accedeix a Frigate de manera segura per utilitzar les notificacions.", "descPwa": "A iOS, les notificacions push web només estàn disponibles quan Frigate està instalat a la pantalla principal. Obre el menú Compartir, selecciona Afegir a la pantalla, i obre Frigate des del nou icona per registrar les notificacions en aquest dispositiu."
"descPwa": "A iOS, les notificacions push web només estàn disponibles quan Frigate està instalat a la pantalla principal. Obre el menú <strong>Compartir</strong> , selecciona <strong>Afegir a la pantalla</strong>, i obre Frigate des del nou icona per registrar les notificacions en aquest dispositiu."
}, },
"unsavedChanges": "Canvis de notificació no desats", "unsavedChanges": "Canvis de notificació no desats",
"globalSettings": { "globalSettings": {
@@ -881,8 +883,7 @@
}, },
"addTrigger": "Afegir disaprador", "addTrigger": "Afegir disaprador",
"semanticSearch": { "semanticSearch": {
"desc": "La cerca semàntica ha d'estar activada per a utilitzar els activadors.", "desc": "La cerca semàntica ha d'estar activada per a utilitzar els activadors."
"title": "La cerca semàntica està desactivada"
}, },
"wizard": { "wizard": {
"title": "Crea un activador", "title": "Crea un activador",
@@ -1544,7 +1545,9 @@
"preset-record-generic-audio-aac": "Enregistra (Genèric + Àudio a AAC)", "preset-record-generic-audio-aac": "Enregistra (Genèric + Àudio a AAC)",
"preset-record-mjpeg": "Registre - Càmeres MJPEG", "preset-record-mjpeg": "Registre - Càmeres MJPEG",
"preset-record-jpeg": "Registre - Càmeres JPEG", "preset-record-jpeg": "Registre - Càmeres JPEG",
"preset-record-ubiquiti": "Registre - Càmeres Ubiquiti" "preset-record-ubiquiti": "Registre - Càmeres Ubiquiti",
"preset-apple-silicon-h265": "Apple Silicon (H.265)",
"preset-apple-silicon-h264": "Apple Silicon (H.264)"
}, },
"sameAsRecord": "Igual que els arguments de sortida del registre" "sameAsRecord": "Igual que els arguments de sortida del registre"
}, },
+22 -14
View File
@@ -239,15 +239,15 @@
"reindexingEmbeddings": "Reindexant vectors ({{processed}}% completat)", "reindexingEmbeddings": "Reindexant vectors ({{processed}}% completat)",
"healthy": "El sistema és saludable", "healthy": "El sistema és saludable",
"cameraIsOffline": "{{camera}} està fora de línia", "cameraIsOffline": "{{camera}} està fora de línia",
"ffmpegHighCpuUsage": "{{camera}} te un ús elevat de CPU per FFmpeg ({{ffmpegAvg}}%)",
"detectHighCpuUsage": "{{camera}} te un ús elevat de CPU per la detecció ({{detectAvg}}%)",
"detectIsVerySlow": "{{detect}} és molt lent ({{speed}} ms)", "detectIsVerySlow": "{{detect}} és molt lent ({{speed}} ms)",
"detectIsSlow": "{{detect}} és lent ({{speed}} ms)", "detectIsSlow": "{{detect}} és lent ({{speed}} ms)",
"debugReplayActive": "La sessió de repetició de depuració està activa", "debugReplayActive": "La sessió de repetició de depuració està activa",
"cameraSkippedDetections": "{{camera}} està ometent la detecció en {{pct}}% de fotogrames", "cameraSkippedDetections": "{{camera}} està ometent la detecció en {{pct}}% de fotogrames",
"systemNotices_one": "{{count}} avís del sistema", "systemNotices_one": "{{count}} avís del sistema",
"systemNotices_other": "{{count}} avisos del sistema", "systemNotices_other": "{{count}} avisos del sistema",
"retentionUnmet": "Els enregistraments s'estan suprimint abans que acabi el seu període de retenció a l'espai lliure" "retentionUnmet": "Els enregistraments s'estan suprimint abans que acabi el seu període de retenció a l'espai lliure",
"moreMessages_other": "+{{count}}",
"moreMessages_one": "+{{count}}"
}, },
"enrichments": { "enrichments": {
"title": "Anàlisi avançada", "title": "Anàlisi avançada",
@@ -281,24 +281,17 @@
"notices": { "notices": {
"title": "Avís", "title": "Avís",
"empty": "La instal·lació de Frigate és saludable", "empty": "La instal·lació de Frigate és saludable",
"dismiss": "Descarta",
"openSettings": "Obre la configuració", "openSettings": "Obre la configuració",
"openLink": "Obre l'enllaç", "openLink": "Obre l'enllaç",
"noMatches": "No hi ha avisos que coincideixin amb el filtre", "noMatches": "No hi ha avisos que coincideixin amb el filtre",
"dismissedTitle": "Descartat",
"noneDismissed": "No hi ha avisos descartats",
"clearDismissed": "Neteja descartada",
"clearDismissedTitle": "Voleu netejar els avisos descartats?",
"clearDismissedDesc": "S'elimina cada avís acomiadat. Les comprovacions de configuració i flux es mostren de nou immediatament, i altres avisos tornen la propera vegada que succeeixin.",
"firstSeen_one": "{{time}} vist per primera vegada", "firstSeen_one": "{{time}} vist per primera vegada",
"firstSeen_other": "{{time}} · {{count}} vegades", "firstSeen_other": "{{time}} · {{count}} vegades",
"dismissedAt": "S'ha suprimit {{time}}",
"filter": { "filter": {
"showDismissed": "Mostra descartat",
"severity": "Severitat", "severity": "Severitat",
"warning": "Avís", "warning": "Avís",
"error": "Error", "error": "Error",
"info": "Info" "info": "Info",
"showHidden": "Mostra els ocults"
}, },
"kinds": { "kinds": {
"detector_stuck": "S'ha reiniciat el detector {{detector}} després que deixés de respondre", "detector_stuck": "S'ha reiniciat el detector {{detector}} després que deixés de respondre",
@@ -307,12 +300,27 @@
"shm_too_low": "L'assignació /dev/shm ({{total}} MB) s'hauria d'augmentar a com a mínim {{min}} MB", "shm_too_low": "L'assignació /dev/shm ({{total}} MB) s'hauria d'augmentar a com a mínim {{min}} MB",
"failed_login_one": "Error en l'intent d'inici de sessió de {{user}}", "failed_login_one": "Error en l'intent d'inici de sessió de {{user}}",
"failed_login_other": "Intents d'inici de sessió fallits per {{user}}", "failed_login_other": "Intents d'inici de sessió fallits per {{user}}",
"update_available": "Frigate {{version}} està disponible" "update_available": "Frigate {{version}} està disponible",
"ffmpeg_high_cpu": "L'ús de la CPU FFmpeg és alt ({{cpu}}% mitjana)",
"detect_high_cpu": "L'ús de la CPU de detecció és alt ({{cpu}}% mitjana)"
}, },
"streamPrefix": "Flux {{index}}: {{message}}", "streamPrefix": "Flux {{index}}: {{message}}",
"streamPrefixRestream": "Flux {{index}} (via go2rtc): {{message}}", "streamPrefixRestream": "Flux {{index}} (via go2rtc): {{message}}",
"streamProbeFailed": "No s'ha pogut provar el flux {{index}}: {{error}}", "streamProbeFailed": "No s'ha pogut provar el flux {{index}}: {{error}}",
"cameraProbeFailed": "No s'han pogut explorar els fluxos: {{error}}" "cameraProbeFailed": "No s'han pogut explorar els fluxos: {{error}}",
"acknowledge": "Agraïment",
"acknowledgeHint": "Amaga fins que això torni a passar",
"mute": "Silenciar",
"muteHint": "No ho mostris mai més",
"unmute": "No silenciis",
"showAgain": "Torna a mostrar",
"hiddenTitle": "Ocultar",
"noneHidden": "No hi ha avisos ocults",
"showAll": "Mostra-ho tot de nou",
"showAllTitle": "Voleu mostrar tots els avisos ocults?",
"showAllDesc": "Cada avís reconegut i silenciat torna a la llista d'avisos, incloent-hi les comprovacions de configuració i flux.",
"acknowledgedAt": "Reconegut {{time}}",
"mutedAt": "Mutat {{time}}"
}, },
"hardware": { "hardware": {
"title": "Maquinari", "title": "Maquinari",
@@ -374,7 +374,6 @@
"desc": "Frigate může nativně odesílat push notifikace do vašeho zařízení, pokud běží v prohlížeči nebo je nainstalován jako PWA (progresivní webová aplikace)." "desc": "Frigate může nativně odesílat push notifikace do vašeho zařízení, pokud běží v prohlížeči nebo je nainstalován jako PWA (progresivní webová aplikace)."
}, },
"notificationUnavailable": { "notificationUnavailable": {
"title": "Notifikace Nedostupné",
"desc": "Webové push notifikace vyžadují zabezpečený kontext (<code>https://…</code>). Jedná se o omezení prohlížeče. Pro použití notifikací přistupujte k Frigate přes zabezpečené připojení." "desc": "Webové push notifikace vyžadují zabezpečený kontext (<code>https://…</code>). Jedná se o omezení prohlížeče. Pro použití notifikací přistupujte k Frigate přes zabezpečené připojení."
}, },
"cameras": { "cameras": {
+1 -3
View File
@@ -50,9 +50,7 @@
"healthy": "Systém je zdravý", "healthy": "Systém je zdravý",
"reindexingEmbeddings": "Přeindexování vektorů ({{processed}} % dokončeno)", "reindexingEmbeddings": "Přeindexování vektorů ({{processed}} % dokončeno)",
"detectIsSlow": "{{detect}} je pomalé ({{speed}} ms)", "detectIsSlow": "{{detect}} je pomalé ({{speed}} ms)",
"detectIsVerySlow": "{{detect}} je velmi pomalé ({{speed}} ms)", "detectIsVerySlow": "{{detect}} je velmi pomalé ({{speed}} ms)"
"detectHighCpuUsage": "{{camera}} má vysoké využití CPU detekcemi ({{detectAvg}} %)",
"ffmpegHighCpuUsage": "{{camera}} má vyské využití CPU FFmpegem ({{ffmpegAvg}}%)"
}, },
"enrichments": { "enrichments": {
"embeddings": { "embeddings": {
+67 -1
View File
@@ -346,5 +346,71 @@
"bluegrass": "Bluegrass", "bluegrass": "Bluegrass",
"folk_music": "Folkemusik", "folk_music": "Folkemusik",
"echo": "Ekko", "echo": "Ekko",
"pulse": "Puls" "pulse": "Puls",
"funk": "Funk",
"middle_eastern_music": "Mellemøstlig musik",
"carnatic_music": "Karnatisk musik",
"music_of_bollywood": "Bollywood musik",
"dental_drill's_drill": "Tandlægebor",
"medium_engine": "Mellemstor motor",
"heavy_engine": "Kraftig motor",
"engine_knocking": "Motorbanken",
"engine_starting": "Motor, der starter",
"idling": "Tomgang",
"accelerating": "Acceleration",
"ding-dong": "Ding-dong",
"sliding_door": "Skydedør",
"tap": "let bank",
"cupboard_open_or_close": "Skab åbnes eller lukkes",
"drawer_open_or_close": "Skuffe åbnes eller lukkes",
"chopping": "Hugning",
"frying": "Stegning",
"microwave_oven": "Mikrobølgeovn",
"water_tap": "Vandhane",
"toilet_flush": "Toiletskylning",
"electric_toothbrush": "Elektrisk tandbørste",
"vacuum_cleaner": "Støvsuger",
"keys_jangling": "Nøgleraslen",
"electric_shaver": "Elektrisk barbermaskine",
"shuffling_cards": "Kortblanding",
"typing": "Skrivning (på tastatur)",
"computer_keyboard": "Computertastatur",
"writing": "Skrivning",
"telephone_bell_ringing": "Telefonringen",
"telephone_dialing": "Telefonopkald",
"dial_tone": "Summetone",
"busy_signal": "Optaget-signal",
"alarm_clock": "Vækkeur",
"civil_defense_siren": "Luftsirene",
"buzzer": "Summer",
"smoke_detector": "Røgalarm",
"fire_alarm": "Brandalarm",
"steam_whistle": "Dampfløjte",
"mechanisms": "Mekanismer",
"ratchet": "Skralde",
"tick": "Tik",
"tick-tock": "Tik-tak",
"gears": "Tandhjul",
"pulleys": "Trisser",
"sewing_machine": "Symaskine",
"mechanical_fan": "Mekanisk blæser",
"air_conditioning": "Air Condition",
"cash_register": "Kasseapparat",
"single-lens_reflex_camera": "Spejlreflekskamera",
"jackhammer": "Trykkluftbor",
"sawing": "Savning",
"filing": "Filning",
"sanding": "Slibning",
"power_tool": "Elværktøj",
"gunshot": "Skud",
"machine_gun": "Maskingevær",
"fusillade": "Salve",
"artillery_fire": "Artilleriild",
"cap_gun": "Knaldpistol",
"firecracker": "Kanonslag",
"burst": "Sprang",
"eruption": "Udbrud",
"boom": "Bum",
"wood": "Træ",
"chop": "Hugge"
} }
@@ -580,7 +580,6 @@
}, },
"title": "Benachrichtigungen", "title": "Benachrichtigungen",
"notificationUnavailable": { "notificationUnavailable": {
"title": "Benachrichtigungen nicht verfügbar",
"desc": "Web Push Benachrichtigungen erfordern einen sicheren Kontext (<code>https://…</code>). Das ist eine Vorgabe des Browsers. Greife auf Frigate gesichert zu um Benachrichtigungen zu nutzen.", "desc": "Web Push Benachrichtigungen erfordern einen sicheren Kontext (<code>https://…</code>). Das ist eine Vorgabe des Browsers. Greife auf Frigate gesichert zu um Benachrichtigungen zu nutzen.",
"descPwa": "Unter iOS sind Web-Push-Benachrichtigungen nur verfügbar, wenn Frigate auf Ihrem Startbildschirm installiert ist. Öffnen Sie das Menü <strong>Teilen</strong>, wählen Sie <strong>Zum Startbildschirm hinzufügen</strong> und öffnen Sie Frigate über das neue Symbol, um dieses Gerät für Benachrichtigungen zu registrieren." "descPwa": "Unter iOS sind Web-Push-Benachrichtigungen nur verfügbar, wenn Frigate auf Ihrem Startbildschirm installiert ist. Öffnen Sie das Menü <strong>Teilen</strong>, wählen Sie <strong>Zum Startbildschirm hinzufügen</strong> und öffnen Sie Frigate über das neue Symbol, um dieses Gerät für Benachrichtigungen zu registrieren."
}, },
@@ -839,7 +838,6 @@
} }
}, },
"semanticSearch": { "semanticSearch": {
"title": "Semantische Suche ist deaktiviert",
"desc": "Semantische Suche muss aktiviert sein um Auslöser nutzen zu können." "desc": "Semantische Suche muss aktiviert sein um Auslöser nutzen zu können."
}, },
"wizard": { "wizard": {
-2
View File
@@ -254,8 +254,6 @@
}, },
"stats": { "stats": {
"healthy": "Das System läuft problemlos", "healthy": "Das System läuft problemlos",
"ffmpegHighCpuUsage": "{{camera}} hat eine hohe FFmpeg CPU Auslastung ({{ffmpegAvg}}%)",
"detectHighCpuUsage": "{{camera}} hat eine hohe CPU Auslastung bei der Erkennung ({{detectAvg}}%)",
"reindexingEmbeddings": "Neuindizierung von Einbettungen ({{processed}}% erledigt)", "reindexingEmbeddings": "Neuindizierung von Einbettungen ({{processed}}% erledigt)",
"detectIsSlow": "{{detect}} ist langsam ({{speed}} ms)", "detectIsSlow": "{{detect}} ist langsam ({{speed}} ms)",
"detectIsVerySlow": "{{detect}} ist sehr langsam ({{speed}} ms)", "detectIsVerySlow": "{{detect}} ist sehr langsam ({{speed}} ms)",
+1
View File
@@ -131,6 +131,7 @@
"close": "Close", "close": "Close",
"expand": "Expand", "expand": "Expand",
"collapse": "Collapse", "collapse": "Collapse",
"clear": "Clear",
"copy": "Copy", "copy": "Copy",
"copiedToClipboard": "Copied to clipboard", "copiedToClipboard": "Copied to clipboard",
"back": "Back", "back": "Back",
+15 -3
View File
@@ -167,6 +167,11 @@
"label": "Always Show Camera Names", "label": "Always Show Camera Names",
"desc": "Always show the camera names in a chip in the multi-camera live view dashboard." "desc": "Always show the camera names in a chip in the multi-camera live view dashboard."
}, },
"naturalAspectLayout": {
"label": "Use Natural Aspect Ratios",
"desc": "On camera group live view dashboards, size each tile to its camera's own natural aspect ratio. When disabled, cameras are snapped to a standard wide, landscape, or tall tile shape.",
"descNote": "Toggling this setting on or off will clear the stored layout for all camera group live dashboards. Manual reconfiguration will be required."
},
"liveFallbackTimeout": { "liveFallbackTimeout": {
"label": "Live Player Fallback Timeout", "label": "Live Player Fallback Timeout",
"desc": "When a camera's high quality live stream is unavailable, fall back to low bandwidth mode after this many seconds. Default: 3." "desc": "When a camera's high quality live stream is unavailable, fall back to low bandwidth mode after this many seconds. Default: 3."
@@ -175,12 +180,14 @@
"storedLayouts": { "storedLayouts": {
"title": "Stored Layouts", "title": "Stored Layouts",
"desc": "The layout of cameras in a camera group can be dragged/resized. The positions are stored in your browser's local storage.", "desc": "The layout of cameras in a camera group can be dragged/resized. The positions are stored in your browser's local storage.",
"clearAll": "Clear All Layouts" "clearAll": "Clear All Layouts",
"clearConfirm": "This will clear the stored layout for every camera group in this browser. This cannot be undone."
}, },
"cameraGroupStreaming": { "cameraGroupStreaming": {
"title": "Camera Group Streaming Settings", "title": "Camera Group Streaming Settings",
"desc": "Streaming settings for each camera group are stored in your browser's local storage.", "desc": "Streaming settings for each camera group are stored in your browser's local storage.",
"clearAll": "Clear All Streaming Settings" "clearAll": "Clear All Streaming Settings",
"clearConfirm": "This will clear the streaming settings for every camera group in this browser. This cannot be undone."
}, },
"backupRestore": { "backupRestore": {
"title": "Backup & Restore", "title": "Backup & Restore",
@@ -195,6 +202,9 @@
"desc": "Choose what to apply from this file. Frigate will reload when the import finishes.", "desc": "Choose what to apply from this file. Frigate will reload when the import finishes.",
"exportedFrom": "Exported {{date}} from Frigate config version {{version}}", "exportedFrom": "Exported {{date}} from Frigate config version {{version}}",
"layouts_one": "Camera group layouts ({{count}} group)", "layouts_one": "Camera group layouts ({{count}} group)",
"layoutsPhone": "Camera group layouts aren't imported on phones, which always use the standard grid.",
"layoutsModeOn": "These layouts use camera aspect ratio tile sizing, so importing them will also turn on \"Use Natural Aspect Ratios\".",
"layoutsModeOff": "These layouts use standard tile sizing, so importing them will also turn off \"Use Natural Aspect Ratios\".",
"layouts_other": "Camera group layouts ({{count}} groups)", "layouts_other": "Camera group layouts ({{count}} groups)",
"streaming_one": "Streaming settings ({{count}} camera)", "streaming_one": "Streaming settings ({{count}} camera)",
"streaming_other": "Streaming settings ({{count}} cameras)", "streaming_other": "Streaming settings ({{count}} cameras)",
@@ -1567,6 +1577,8 @@
"presetLabels": { "presetLabels": {
"preset-rpi-64-h264": "Raspberry Pi (H.264)", "preset-rpi-64-h264": "Raspberry Pi (H.264)",
"preset-rpi-64-h265": "Raspberry Pi (H.265)", "preset-rpi-64-h265": "Raspberry Pi (H.265)",
"preset-apple-silicon-h264": "Apple Silicon (H.264)",
"preset-apple-silicon-h265": "Apple Silicon (H.265)",
"preset-vaapi": "VAAPI (Intel/AMD GPU)", "preset-vaapi": "VAAPI (Intel/AMD GPU)",
"preset-intel-qsv-h264": "Intel QuickSync (H.264)", "preset-intel-qsv-h264": "Intel QuickSync (H.264)",
"preset-intel-qsv-h265": "Intel QuickSync (H.265)", "preset-intel-qsv-h265": "Intel QuickSync (H.265)",
@@ -2051,7 +2063,7 @@
"unrecognized": "This model is configured for hardware that was not found on this system: {{devices}}", "unrecognized": "This model is configured for hardware that was not found on this system: {{devices}}",
"description": "The hardware this model runs its detection on.", "description": "The hardware this model runs its detection on.",
"detectorCountDescription": "How many detection processes to run on this hardware. More detectors keep up with more cameras, at the cost of extra device memory.", "detectorCountDescription": "How many detection processes to run on this hardware. More detectors keep up with more cameras, at the cost of extra device memory.",
"unitsDescription": "Each unit runs its own detection process. A unit already used by another model can not be selected." "unitsDescription": "Each unit runs its own detection process. A unit that can't be shared, such as a Coral, can only be used by one model."
}, },
"tabs": { "tabs": {
"plus": "Frigate+", "plus": "Frigate+",
+1
View File
@@ -48,6 +48,7 @@
"rkmpp": "RKMPP (Rockchip)", "rkmpp": "RKMPP (Rockchip)",
"jetson": "NVIDIA Jetson", "jetson": "NVIDIA Jetson",
"rpi": "V4L2 (Raspberry Pi)", "rpi": "V4L2 (Raspberry Pi)",
"apple-silicon": "Media engine (Apple Silicon)",
"none": "None (software decoding)" "none": "None (software decoding)"
} }
}, },
+18 -10
View File
@@ -19,20 +19,26 @@
"notices": { "notices": {
"title": "Notices", "title": "Notices",
"empty": "Your Frigate installation is healthy", "empty": "Your Frigate installation is healthy",
"dismiss": "Dismiss", "acknowledge": "Acknowledge",
"acknowledgeHint": "Hide until this happens again",
"mute": "Mute",
"muteHint": "Never show this again",
"unmute": "Unmute",
"showAgain": "Show again",
"openSettings": "Open settings", "openSettings": "Open settings",
"openLink": "Open link", "openLink": "Open link",
"noMatches": "No notices match the filter", "noMatches": "No notices match the filter",
"dismissedTitle": "Dismissed", "hiddenTitle": "Hidden",
"noneDismissed": "No dismissed notices", "noneHidden": "No hidden notices",
"clearDismissed": "Clear dismissed", "showAll": "Show all again",
"clearDismissedTitle": "Clear dismissed notices?", "showAllTitle": "Show all hidden notices?",
"clearDismissedDesc": "Every dismissed notice is deleted. Config and stream checks show again right away, and other notices return the next time they happen.", "showAllDesc": "Every acknowledged and muted notice returns to the Notices list, including config and stream checks.",
"firstSeen_one": "First seen {{time}}", "firstSeen_one": "First seen {{time}}",
"firstSeen_other": "First seen {{time}} · {{count}} times", "firstSeen_other": "First seen {{time}} · {{count}} times",
"dismissedAt": "Dismissed {{time}}", "acknowledgedAt": "Acknowledged {{time}}",
"mutedAt": "Muted {{time}}",
"filter": { "filter": {
"showDismissed": "Show dismissed", "showHidden": "Show hidden",
"severity": "Severity", "severity": "Severity",
"error": "Error", "error": "Error",
"warning": "Warning", "warning": "Warning",
@@ -42,6 +48,8 @@
"detector_stuck": "Detector {{detector}} was restarted after it stopped responding", "detector_stuck": "Detector {{detector}} was restarted after it stopped responding",
"model_download_failed": "Downloading {{file}} for {{model}} failed: {{error}}", "model_download_failed": "Downloading {{file}} for {{model}} failed: {{error}}",
"skipped_detections": "Detection could not keep up and skipped {{pct}}% of frames for over a minute", "skipped_detections": "Detection could not keep up and skipped {{pct}}% of frames for over a minute",
"ffmpeg_high_cpu": "FFmpeg CPU usage is high ({{cpu}}% average)",
"detect_high_cpu": "Detection CPU usage is high ({{cpu}}% average)",
"shm_too_low": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB", "shm_too_low": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB",
"failed_login_one": "Failed login attempt for {{user}}", "failed_login_one": "Failed login attempt for {{user}}",
"failed_login_other": "Failed login attempts for {{user}}", "failed_login_other": "Failed login attempts for {{user}}",
@@ -324,12 +332,12 @@
}, },
"lastRefreshed": "Last refreshed: ", "lastRefreshed": "Last refreshed: ",
"stats": { "stats": {
"ffmpegHighCpuUsage": "{{camera}} has high FFmpeg CPU usage ({{ffmpegAvg}}%)",
"cameraSkippedDetections": "{{camera}} is skipping detection on {{pct}}% of frames", "cameraSkippedDetections": "{{camera}} is skipping detection on {{pct}}% of frames",
"detectHighCpuUsage": "{{camera}} has high detect CPU usage ({{detectAvg}}%)",
"healthy": "System is healthy", "healthy": "System is healthy",
"systemNotices_one": "{{count}} system notice", "systemNotices_one": "{{count}} system notice",
"systemNotices_other": "{{count}} system notices", "systemNotices_other": "{{count}} system notices",
"moreMessages_one": "+{{count}}",
"moreMessages_other": "+{{count}}",
"reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)", "reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)",
"cameraIsOffline": "{{camera}} is offline", "cameraIsOffline": "{{camera}} is offline",
"detectIsSlow": "{{detect}} is slow ({{speed}} ms)", "detectIsSlow": "{{detect}} is slow ({{speed}} ms)",
+16 -1
View File
@@ -137,6 +137,15 @@
"partial": "Se iniciaron {{successful}} de {{total}} exportaciones. Fallidas: {{failedItems}}", "partial": "Se iniciaron {{successful}} de {{total}} exportaciones. Fallidas: {{failedItems}}",
"failed": "No se pudieron iniciar {{total}} exportaciones. Fallidas: {{failedItems}}" "failed": "No se pudieron iniciar {{total}} exportaciones. Fallidas: {{failedItems}}"
} }
},
"stream": {
"label": "Calidad",
"auto": "Auto",
"main": "Original",
"sub": "Bajo",
"autoDesc": "Usa el stream princial y vuelve a la calidad baja del sub-stream si la principal no está disponible.",
"mainDesc": "Exporta sólo la calidad original del stream principal. Los tiempos dónde el stream principal no esté disponible estarán faltantes.",
"subDesc": "Exporta sólo la calidad inferior del sub-stream. Los tiempos dónde el sub-steam no está disponible faltarán."
} }
}, },
"streaming": { "streaming": {
@@ -182,7 +191,8 @@
"export": "Exportar", "export": "Exportar",
"markAsReviewed": "Marcar como revisado", "markAsReviewed": "Marcar como revisado",
"deleteNow": "Eliminar ahora", "deleteNow": "Eliminar ahora",
"markAsUnreviewed": "Marcar como no revisado" "markAsUnreviewed": "Marcar como no revisado",
"generateDescription": "Generar descripción"
}, },
"shareTimestamp": { "shareTimestamp": {
"description": "Comparta una URL con marca de tiempo de la posición actual del reproductor o elija una marca de tiempo personalizada. Tenga en cuenta que esta no es una URL pública para compartir y solo es accesible para los usuarios que tienen acceso a Frigate y a esta cámara.", "description": "Comparta una URL con marca de tiempo de la posición actual del reproductor o elija una marca de tiempo personalizada. Tenga en cuenta que esta no es una URL pública para compartir y solo es accesible para los usuarios que tienen acceso a Frigate y a esta cámara.",
@@ -191,6 +201,11 @@
"custom": "Marca de tiempo personalizada", "custom": "Marca de tiempo personalizada",
"button": "Compartir URL de la marca de tiempo", "button": "Compartir URL de la marca de tiempo",
"shareTitle": "Marca de tiempo de revisión de Frigate: {{camera}}" "shareTitle": "Marca de tiempo de revisión de Frigate: {{camera}}"
},
"genaiDescription": {
"toast": {
"error": "Falló al pedir descripción: {{error}}"
}
} }
}, },
"imagePicker": { "imagePicker": {
+17 -2
View File
@@ -35,10 +35,25 @@
"submittedFrigatePlus": "Fotograma enviado correctamente a Frigate+" "submittedFrigatePlus": "Fotograma enviado correctamente a Frigate+"
}, },
"error": { "error": {
"submitFrigatePlusFailed": "Error al enviar el fotograma a Frigate+" "submitFrigatePlusFailed": "Error al enviar el fotograma a Frigate+",
"playRecordingsFailed": "Falló al reproducir grabaciones(error{{code}}:{{message}})"
} }
}, },
"livePlayerRequiredIOSVersion": "Se requiere iOS 17.1 o superior para este tipo de transmisión en vivo.", "livePlayerRequiredIOSVersion": "Se requiere iOS 17.1 o superior para este tipo de transmisión en vivo.",
"noRecordingsFoundForThisTime": "No se encontraron grabaciones para este momento", "noRecordingsFoundForThisTime": "No se encontraron grabaciones para este momento",
"cameraOff": "La cámara está apagada" "cameraOff": "La cámara está apagada",
"quality": {
"auto": "Auto",
"autoLow": "Reproduciendo con baja calidad (ancho de banda limitado)",
"autoLowCodec": "Reproduciendo con baja calidad (Este navegador no soporta la calidad original)",
"autoLowSaveData": "Reproduciendo con baja calidad (ahorro de datos)",
"main": "Original",
"sub": "Baja",
"notSupportedBrowser": "No soportado por este navegador",
"label": "Calidad",
"noAudio": "Sin audio",
"audioRate": "{{rate}}kHz audio",
"audioCodecRate": "{{codec}}{{rate}}kHz",
"noRecordings": "Sin grabaciones en este rango de tiempo"
}
} }
+19
View File
@@ -496,6 +496,9 @@
"annotation_offset": { "annotation_offset": {
"label": "Desplazamiento de anotaciones", "label": "Desplazamiento de anotaciones",
"description": "Milisegundos para desplazar las anotaciones de detección y alinear mejor los cuadros delimitadores de la línea de tiempo con las grabaciones; puede ser positivo o negativo." "description": "Milisegundos para desplazar las anotaciones de detección y alinear mejor los cuadros delimitadores de la línea de tiempo con las grabaciones; puede ser positivo o negativo."
},
"scene": {
"label": "Detectar escena"
} }
}, },
"record": { "record": {
@@ -599,6 +602,19 @@
"enabled_in_config": { "enabled_in_config": {
"label": "Estado de grabación original", "label": "Estado de grabación original",
"description": "Indica si la grabación estaba habilitada en la configuración estática original." "description": "Indica si la grabación estaba habilitada en la configuración estática original."
},
"sub": {
"continuous": {
"days": {
"label": "Días de retención",
"description": "Días a retener las grabaciones."
}
},
"motion": {
"days": {
"label": "Días de retención"
}
}
} }
}, },
"ui": { "ui": {
@@ -735,6 +751,9 @@
"order": { "order": {
"label": "Posición", "label": "Posición",
"description": "Posición numérica que controla el orden de la cámara en el diseño de Birdseye." "description": "Posición numérica que controla el orden de la cámara en el diseño de Birdseye."
},
"modes": {
"label": "Tipos de actividad"
} }
}, },
"ffmpeg": { "ffmpeg": {
+20 -1
View File
@@ -711,6 +711,9 @@
"annotation_offset": { "annotation_offset": {
"label": "Desplazamiento de anotaciones", "label": "Desplazamiento de anotaciones",
"description": "Milisegundos para desplazar las anotaciones de detección y alinear mejor los cuadros delimitadores de la línea de tiempo con las grabaciones; puede ser positivo o negativo." "description": "Milisegundos para desplazar las anotaciones de detección y alinear mejor los cuadros delimitadores de la línea de tiempo con las grabaciones; puede ser positivo o negativo."
},
"scene": {
"label": "Detectar escena"
} }
}, },
"record": { "record": {
@@ -814,7 +817,20 @@
"label": "Estado de grabación original", "label": "Estado de grabación original",
"description": "Indica si la grabación estaba habilitada en la configuración estática original." "description": "Indica si la grabación estaba habilitada en la configuración estática original."
}, },
"description": "Ajustes de grabación y retención aplicados a las cámaras salvo que se sobrescriban por cámara." "description": "Ajustes de grabación y retención aplicados a las cámaras salvo que se sobrescriban por cámara.",
"sub": {
"continuous": {
"days": {
"label": "Días de retención",
"description": "Días a retener las grabaciones."
}
},
"motion": {
"days": {
"label": "Días de retención"
}
}
}
}, },
"camera_ui": { "camera_ui": {
"dashboard": { "dashboard": {
@@ -1038,6 +1054,9 @@
"idle_heartbeat_fps": { "idle_heartbeat_fps": {
"label": "FPS de latido en reposo", "label": "FPS de latido en reposo",
"description": "Fotogramas por segundo para reenviar el último fotograma compuesto de Birdseye en reposo; establécelo en 0 para deshabilitarlo." "description": "Fotogramas por segundo para reenviar el último fotograma compuesto de Birdseye en reposo; establécelo en 0 para deshabilitarlo."
},
"modes": {
"label": "Tipos de actividad"
} }
}, },
"ffmpeg": { "ffmpeg": {
@@ -578,7 +578,6 @@
"desc": "Frigate puede enviar notificaciones push a tu dispositivo de forma nativa cuando se ejecuta en el navegador o está instalado como una PWA." "desc": "Frigate puede enviar notificaciones push a tu dispositivo de forma nativa cuando se ejecuta en el navegador o está instalado como una PWA."
}, },
"notificationUnavailable": { "notificationUnavailable": {
"title": "Notificaciones no disponibles",
"desc": "Las notificaciones push web requieren un contexto seguro (<code>https://…</code>). Esto es una limitación del navegador. Accede a Frigate de forma segura para usar las notificaciones.", "desc": "Las notificaciones push web requieren un contexto seguro (<code>https://…</code>). Esto es una limitación del navegador. Accede a Frigate de forma segura para usar las notificaciones.",
"descPwa": "En iOS, las notificaciones push web solo están disponibles cuando Frigate está instalado en la pantalla de inicio. Abre el menú <strong>Compartir</strong>, selecciona <strong>Añadir a la pantalla de inicio</strong> y, a continuación, abre Frigate desde el nuevo icono para registrar este dispositivo para las notificaciones." "descPwa": "En iOS, las notificaciones push web solo están disponibles cuando Frigate está instalado en la pantalla de inicio. Abre el menú <strong>Compartir</strong>, selecciona <strong>Añadir a la pantalla de inicio</strong> y, a continuación, abre Frigate desde el nuevo icono para registrar este dispositivo para las notificaciones."
}, },
@@ -830,7 +829,6 @@
} }
}, },
"semanticSearch": { "semanticSearch": {
"title": "Búsqueda semántica desactivada",
"desc": "Búsqueda semántica debe estar activada para usar Disparadores." "desc": "Búsqueda semántica debe estar activada para usar Disparadores."
}, },
"toast": { "toast": {
-2
View File
@@ -254,8 +254,6 @@
"averageInf": "Tiempo promedio de inferencia" "averageInf": "Tiempo promedio de inferencia"
}, },
"stats": { "stats": {
"ffmpegHighCpuUsage": "{{camera}} tiene un uso elevado de CPU por FFmpeg ({{ffmpegAvg}}%)",
"detectHighCpuUsage": "{{camera}} tiene un uso elevado de CPU por detección ({{detectAvg}}%)",
"healthy": "El sistema está saludable", "healthy": "El sistema está saludable",
"reindexingEmbeddings": "Reindexando empotrados ({{processed}}% completado)", "reindexingEmbeddings": "Reindexando empotrados ({{processed}}% completado)",
"detectIsSlow": "{{detect}} es lento ({{speed}} ms)", "detectIsSlow": "{{detect}} es lento ({{speed}} ms)",
+34 -3
View File
@@ -21,7 +21,12 @@
"lowBandwidthMode": "Väikese ribalaiusega režiim", "lowBandwidthMode": "Väikese ribalaiusega režiim",
"twoWayTalk": { "twoWayTalk": {
"enable": "Lülita kahepoolne kõneside sisse", "enable": "Lülita kahepoolne kõneside sisse",
"disable": "Lülita kahepoolne kõneside välja" "disable": "Lülita kahepoolne kõneside välja",
"requiresWebRTC": "Kahepoolne suhtlus eeldab WebRTC kasutamist, aga see poel saadaval",
"error": {
"microphone": "Kahepoolsel suhtlusel puudub ligipääs sinu mikrofonile",
"refused": "Kahepoolse suhtluse käivitamine ei õnnestunud. Lisateavet leiad veebibrauseri konsoolist."
}
}, },
"cameraAudio": { "cameraAudio": {
"enable": "Lülita kaamera heli sisse", "enable": "Lülita kaamera heli sisse",
@@ -112,10 +117,36 @@
"title": "Voogedastus", "title": "Voogedastus",
"lowBandwidth": { "lowBandwidth": {
"resetStream": "Lähtesta voogedastus", "resetStream": "Lähtesta voogedastus",
"tips": "Reaalaja pilt on puhverdamise või voogedastuse vigade tõttu madala ribalaiusega režiimis." "tips": "Reaalaja pilt on puhverdamise või voogedastuse vigade tõttu madala ribalaiusega režiimis.",
"force": {
"label": "Kasuta sundkorras väikese ribalaiusega režiimi",
"desc": "Valitud voogedastuse asemel esita sisu alati Frigate'i sisseehitatud väikese ribalaiusega voogedastusena. Toimib igasuguste ühendustega, aga pildikvaliteet on kehvem ning heliriba pole kasutusel."
}
}, },
"debug": { "debug": {
"picker": "Voogedastuse osa valik pole silumisrežiimis saadaval. Silumisvaade kasutab alati voogedastust, millele on määratud tuvastamisroll." "picker": "Voogedastuse osa valik pole silumisrežiimis saadaval. Silumisvaade kasutab alati voogedastust, millele on määratud tuvastamisroll.",
"technology": "Voogedastuse tehnoloogia valik pole silumisrežiimis saadaval."
},
"mode": "Voogedastuse tehnoloogia",
"technology": {
"description": "Vali eelistatud voogedastuse tehnoloogia. Taasesituse ja võrguvigade puhul võib Frigate tagavaravariandina kasutada väikese ribalaiusega režiimi.",
"name": {
"mse": "MSE",
"webrtc": "WebRTC",
"jsmpeg": "JSMpeg"
},
"tips": {
"mse": "Soovitatud vaikimisi väärtus, millel on lai ühilduvus ja sujuv taasesitus",
"webrtc": "Vajab täiendavat seadistust ja pole igas seadmes toetatud"
},
"unavailable": {
"browser": "Sinu veebibrauseris puudub WebRTC tugi.",
"not-configured": "WebRTC pole seadistatud. Seadista go2rtc webrtc kandidaadid või ice_server teenused.",
"unreachable": "WebRTC ühendamine ei õnnestunud. Kontrolli, et port 8555 on kättesaadav ja STUN/TURN-serveri seadistus on õige.",
"video-codec": "Selles veebibrauseris puudub WebRTC jaoks antud voogedastuse videokoodeki tugi.",
"audio-codec": "WebRTC ei toeta selle voogedastuse helikoodekit. Kodeeri go2rtc abil opus või G.711 vormingusse.",
"checking": "Kontrollin WebRTC saadavust…"
}
} }
}, },
"notifications": "Teavitused", "notifications": "Teavitused",
@@ -1102,7 +1102,6 @@
"desc": "Frigate می‌تواند به‌صورت بومی وقتی در مرورگر اجرا می‌شود یا به‌عنوان PWA نصب شده است، اعلان‌های پوش را به دستگاه شما ارسال کند." "desc": "Frigate می‌تواند به‌صورت بومی وقتی در مرورگر اجرا می‌شود یا به‌عنوان PWA نصب شده است، اعلان‌های پوش را به دستگاه شما ارسال کند."
}, },
"notificationUnavailable": { "notificationUnavailable": {
"title": "اعلان‌ها در دسترس نیستند",
"desc": "اعلان‌های پوش وب نیاز به یک بستر امن دارند ( <code>https://… </code>). این محدودیت مرورگر است. برای استفاده از اعلان‌ها، به‌صورت امن به Frigate دسترسی پیدا کنید.", "desc": "اعلان‌های پوش وب نیاز به یک بستر امن دارند ( <code>https://… </code>). این محدودیت مرورگر است. برای استفاده از اعلان‌ها، به‌صورت امن به Frigate دسترسی پیدا کنید.",
"descPwa": "در iOS، اعلان‌های وب پوش فقط زمانی در دسترس هستند که فریگیت روی صفحه اصلی شما نصب شده باشد. منوی <strong>اشتراک‌گذاری</strong> را باز کنید، <strong>افزودن به صفحه اصلی</strong> را انتخاب کنید، سپس فریگیت را از آیکون جدید باز کنید تا این دستگاه برای اعلان‌ها ثبت شود." "descPwa": "در iOS، اعلان‌های وب پوش فقط زمانی در دسترس هستند که فریگیت روی صفحه اصلی شما نصب شده باشد. منوی <strong>اشتراک‌گذاری</strong> را باز کنید، <strong>افزودن به صفحه اصلی</strong> را انتخاب کنید، سپس فریگیت را از آیکون جدید باز کنید تا این دستگاه برای اعلان‌ها ثبت شود."
}, },
@@ -1210,7 +1209,6 @@
"triggers": { "triggers": {
"documentTitle": "تریگرها", "documentTitle": "تریگرها",
"semanticSearch": { "semanticSearch": {
"title": "جستجوی معنایی غیرفعال است",
"desc": "برای استفاده از تریگرها باید جستجوی معنایی فعال باشد." "desc": "برای استفاده از تریگرها باید جستجوی معنایی فعال باشد."
}, },
"management": { "management": {
-2
View File
@@ -226,8 +226,6 @@
} }
}, },
"stats": { "stats": {
"ffmpegHighCpuUsage": "{{camera}} استفادهٔ CPU بالایی برای FFmpeg دارد ({{ffmpegAvg}}%)",
"detectHighCpuUsage": "{{camera}} استفادهٔ CPU بالایی برای تشخیص دارد ({{detectAvg}}%)",
"reindexingEmbeddings": "بازتولید نمایهٔ embeddingها ({{processed}}% تکمیل شده)", "reindexingEmbeddings": "بازتولید نمایهٔ embeddingها ({{processed}}% تکمیل شده)",
"cameraIsOffline": "{{camera}} آفلاین است", "cameraIsOffline": "{{camera}} آفلاین است",
"detectIsVerySlow": "{{detect}} بسیار کند است ({{speed}} ms)", "detectIsVerySlow": "{{detect}} بسیار کند است ({{speed}} ms)",
@@ -181,7 +181,6 @@
"desc": "Frigate peut envoyer nativement des notifications push à votre appareil lorsqu'il est exécuté dans le navigateur ou installé en tant que PWA." "desc": "Frigate peut envoyer nativement des notifications push à votre appareil lorsqu'il est exécuté dans le navigateur ou installé en tant que PWA."
}, },
"notificationUnavailable": { "notificationUnavailable": {
"title": "Notifications indisponibles",
"desc": "Les notifications push Web nécessitent un contexte sécurisé (<code>https://…</code>). Il s'agit d'une limitation du navigateur. Accédez à Frigate en toute sécurité pour utiliser les notifications." "desc": "Les notifications push Web nécessitent un contexte sécurisé (<code>https://…</code>). Il s'agit d'une limitation du navigateur. Accédez à Frigate en toute sécurité pour utiliser les notifications."
}, },
"globalSettings": { "globalSettings": {
@@ -797,7 +796,6 @@
} }
}, },
"semanticSearch": { "semanticSearch": {
"title": "La recherche sémantique est désactivée",
"desc": "La recherche sémantique doit être activée pour utiliser les déclencheurs." "desc": "La recherche sémantique doit être activée pour utiliser les déclencheurs."
}, },
"wizard": { "wizard": {
-2
View File
@@ -227,8 +227,6 @@
}, },
"lastRefreshed": "Dernier rafraichissement : ", "lastRefreshed": "Dernier rafraichissement : ",
"stats": { "stats": {
"ffmpegHighCpuUsage": "{{camera}} a un taux élevé d'utilisation processeur par FFmpeg ({{ffmpegAvg}}%)",
"detectHighCpuUsage": "{{camera}} : charge CPU détection élevée ({{detectAvg}}%)",
"healthy": "Le système est sain", "healthy": "Le système est sain",
"reindexingEmbeddings": "Réindexation des embeddings ({{processed}} % terminée)", "reindexingEmbeddings": "Réindexation des embeddings ({{processed}} % terminée)",
"cameraIsOffline": "{{camera}} est hors ligne", "cameraIsOffline": "{{camera}} est hors ligne",
@@ -531,7 +531,6 @@
"desc": "Frigate יכולה לשלוח התראות דחיפה באופן טבעי למכשיר שלך כאשר הוא פועל בדפדפן או מותקן כ-PWA." "desc": "Frigate יכולה לשלוח התראות דחיפה באופן טבעי למכשיר שלך כאשר הוא פועל בדפדפן או מותקן כ-PWA."
}, },
"notificationUnavailable": { "notificationUnavailable": {
"title": "התראות לא זמינות",
"desc": "התראות דחיפה באינטרנט דורשות קישור מאובטח (<code>https://…</code>). זוהי מגבלה של הדפדפן. יש לגשת ל-Frigate בצורה מאובטחת כדי להשתמש בהתראות." "desc": "התראות דחיפה באינטרנט דורשות קישור מאובטח (<code>https://…</code>). זוהי מגבלה של הדפדפן. יש לגשת ל-Frigate בצורה מאובטחת כדי להשתמש בהתראות."
}, },
"globalSettings": { "globalSettings": {
@@ -949,7 +948,6 @@
"triggers": { "triggers": {
"documentTitle": "טריגרים", "documentTitle": "טריגרים",
"semanticSearch": { "semanticSearch": {
"title": "חיפוש סמנטי מושבת",
"desc": "כדי להשתמש בטריגרים, יש להפעיל חיפוש סמנטי." "desc": "כדי להשתמש בטריגרים, יש להפעיל חיפוש סמנטי."
}, },
"management": { "management": {
-2
View File
@@ -1,8 +1,6 @@
{ {
"lastRefreshed": "רענון אחרון: ", "lastRefreshed": "רענון אחרון: ",
"stats": { "stats": {
"ffmpegHighCpuUsage": "ל-{{camera}} יש צריכת מעבד גבוהה של FFmpeg ({{ffmpegAvg}}%)",
"detectHighCpuUsage": "ל-{{camera}} יש צריכת CPU גבוהה ({{detectAvg}}%)",
"healthy": "המערכת פועלת בצורה תקינה", "healthy": "המערכת פועלת בצורה תקינה",
"reindexingEmbeddings": "אינדקס מחדש של ההטמעות ({{processed}}% הושלם)", "reindexingEmbeddings": "אינדקס מחדש של ההטמעות ({{processed}}% הושלם)",
"cameraIsOffline": "{{camera}} לא זמינה", "cameraIsOffline": "{{camera}} לא זמינה",
@@ -855,7 +855,6 @@
"desc": "Frigate može nativno slati push obavijesti na vaš uređaj kada je pokrenut u pregledniku ili instaliran kao PWA." "desc": "Frigate može nativno slati push obavijesti na vaš uređaj kada je pokrenut u pregledniku ili instaliran kao PWA."
}, },
"notificationUnavailable": { "notificationUnavailable": {
"title": "Obavijesti nisu dostupne",
"desc": "Web push obavijesti zahtijevaju siguran kontekst (<code>https://...</code>). Ovo je ograničenje preglednika. Pristupite Frigateu sigurno kako biste koristili obavijesti." "desc": "Web push obavijesti zahtijevaju siguran kontekst (<code>https://...</code>). Ovo je ograničenje preglednika. Pristupite Frigateu sigurno kako biste koristili obavijesti."
}, },
"globalSettings": { "globalSettings": {
@@ -946,7 +945,6 @@
"triggers": { "triggers": {
"documentTitle": "Okidači", "documentTitle": "Okidači",
"semanticSearch": { "semanticSearch": {
"title": "Semantičko pretraživanje je onemogućeno",
"desc": "Semantičko pretraživanje mora biti omogućeno za korištenje okidača." "desc": "Semantičko pretraživanje mora biti omogućeno za korištenje okidača."
}, },
"management": { "management": {
-2
View File
@@ -164,8 +164,6 @@
}, },
"lastRefreshed": "Zadnje osvježavanje: ", "lastRefreshed": "Zadnje osvježavanje: ",
"stats": { "stats": {
"ffmpegHighCpuUsage": "{{camera}} ima visoku upotrebu CPU-a za FFmpeg ({{ffmpegAvg}}%)",
"detectHighCpuUsage": "{{camera}} ima visoku upotrebu CPU-a za detekciju ({{detectAvg}}%)",
"healthy": "Sustav je zdrav", "healthy": "Sustav je zdrav",
"reindexingEmbeddings": "Reindeksiranje embeddings ({{processed}}% završeno)", "reindexingEmbeddings": "Reindeksiranje embeddings ({{processed}}% završeno)",
"cameraIsOffline": "{{camera}} je offline", "cameraIsOffline": "{{camera}} je offline",
@@ -210,7 +210,6 @@
} }
}, },
"notificationUnavailable": { "notificationUnavailable": {
"title": "Értesítés elérhetetlen",
"desc": "A webes push értesítésekhez biztonságos környezet (<code>https://…</code>) szükséges. Ez egy böngészői korlátozás. A értesítések használatához férjen hozzá biztonságosan a Frigate-hez." "desc": "A webes push értesítésekhez biztonságos környezet (<code>https://…</code>) szükséges. Ez egy böngészői korlátozás. A értesítések használatához férjen hozzá biztonságosan a Frigate-hez."
}, },
"sendTestNotification": "Teszt értesítés küldése", "sendTestNotification": "Teszt értesítés küldése",
@@ -711,7 +710,6 @@
} }
}, },
"semanticSearch": { "semanticSearch": {
"title": "Szemantikus keresés le van tiltva",
"desc": "A Triggerek használatához engedélyezni kell a szemantikus keresést." "desc": "A Triggerek használatához engedélyezni kell a szemantikus keresést."
}, },
"wizard": { "wizard": {
+1 -3
View File
@@ -201,9 +201,7 @@
"healthy": "A rendszer egészséges", "healthy": "A rendszer egészséges",
"cameraIsOffline": "{{camera}} nem elérhető", "cameraIsOffline": "{{camera}} nem elérhető",
"detectIsSlow": "{{detect}} lassú ({{speed}} ms)", "detectIsSlow": "{{detect}} lassú ({{speed}} ms)",
"reindexingEmbeddings": "Beágyazások újra indexelése ({{processed}}% kész)", "reindexingEmbeddings": "Beágyazások újra indexelése ({{processed}}% kész)"
"ffmpegHighCpuUsage": "{{camera}}-nak/-nek magas FFmpeg CPU felhasználása ({{ffmpegAvg}}%)",
"detectHighCpuUsage": "A(z) {{camera}} kameránál magas az észlelési CPU-használat ({{detectAvg}}%)"
}, },
"lastRefreshed": "Utoljára frissítve: " "lastRefreshed": "Utoljára frissítve: "
} }

Some files were not shown because too many files have changed in this diff Show More