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
228 changed files with 13969 additions and 5786 deletions
@@ -15,7 +15,6 @@ from frigate.const import (
)
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
from frigate.util.config import find_config_file, resolve_ffmpeg_path
from frigate.util.live_streams import raw_transcode_streams
from frigate.util.services import (
is_go2rtc_arbitrary_exec_allowed,
is_restricted_go2rtc_source,
@@ -175,17 +174,6 @@ for name in list(go2rtc_config.get("streams", {})):
del go2rtc_config["streams"][name]
continue
# add transcoded live streams; a user stream with the same name wins here and
# fails Frigate's config validation
transcoded_streams = raw_transcode_streams(config)
if transcoded_streams:
if go2rtc_config.get("streams") is None:
go2rtc_config["streams"] = {}
for name, source in transcoded_streams.items():
go2rtc_config["streams"].setdefault(name, source)
# add birdseye restream stream if enabled
if config.get("birdseye", {}).get("restream", False):
birdseye: dict[str, Any] = config.get("birdseye")
@@ -900,21 +900,6 @@ live:
streams:
main_stream: main_stream_name
sub_stream: sub_stream_name
# Optional: Lower-quality live streams transcoded by go2rtc while someone is watching.
# NOTE: Set at the camera level only.
transcode:
# Optional: Enable transcoded streams (default: shown below)
enabled: False
# Optional: go2rtc stream to transcode (default: the first live stream)
source: main_stream_name
# Optional: One transcoded stream per quality (default: shown below)
qualities:
- height: 720
bitrate: 1200
- height: 480
bitrate: 500
- height: 360
bitrate: 250
# Optional: Set the height of the jsmpeg stream. (default: 720)
# This must be less than or equal to the height of the detect stream. Lower resolutions
# reduce bandwidth required for viewing the jsmpeg stream. Width is computed to match known aspect ratio.
@@ -85,6 +85,14 @@ An optional config, `save_attempts`, can be set as a key under the model name. T
</TabItem>
</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
Creating and training the model is done within the Frigate UI using the `Classification` page. The process consists of three steps:
@@ -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`.
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.
:::note
+1 -28
View File
@@ -92,7 +92,7 @@ go2rtc:
### Setting Streams For Live UI
You can configure Frigate to allow manual selection of the stream you want to view in the Live UI. For example, you may want to view your camera's substream on mobile devices, but the full resolution stream on desktop devices. Setting the streams list will populate a dropdown in the UI's Live view that allows you to choose between the streams. This stream setting is _per device_ and is saved in your browser's local storage. When a camera has more than one stream, the dropdown also offers **Auto**, which is used until you pick a specific stream. Auto starts on the first stream, steps down the list when your connection can't keep up, and steps back up when it recovers. To retry the top stream right away, select **Try highest quality** under the stream picker. List streams from highest to lowest quality, and avoid names that are plain numbers (such as `720`), which the browser sorts ahead of the others. In the UI, drag streams to reorder them, or use **Auto order** to sort them by measured bitrate.
You can configure Frigate to allow manual selection of the stream you want to view in the Live UI. For example, you may want to view your camera's substream on mobile devices, but the full resolution stream on desktop devices. Setting the streams list will populate a dropdown in the UI's Live view that allows you to choose between the streams. This stream setting is _per device_ and is saved in your browser's local storage.
Additionally, when creating and editing camera groups in the UI, you can choose the stream you want to use for your camera group's Live dashboard.
@@ -158,26 +158,6 @@ cameras:
</TabItem>
</ConfigTabs>
### Transcoded streams
When a camera has no suitable sub stream, Frigate can add lower-quality streams that go2rtc transcodes to H.264 while someone is watching. They appear in the stream list like any other stream, so Auto mode can step down to them. Enable them under <NavPath path="Settings > Camera configuration > Live playback" />, or in YAML:
```yaml
cameras:
test_cam:
live:
transcode:
enabled: true
source: test_cam # optional, defaults to the first live stream
qualities:
- height: 720
bitrate: 1200 # kbps
- height: 480
bitrate: 500
```
Each quality becomes a go2rtc stream named `<camera>_transcode_<height>p`. go2rtc picks a hardware encoder automatically and falls back to the CPU, which costs CPU for each transcode while it is being watched. Check go2rtc's `api/ffmpeg/hardware` page to see which encoder it found. Using a sub stream as the `source` lowers the cost.
### WebRTC extra configuration:
WebRTC works by creating a TCP or UDP connection on port `8555`. However, it requires additional configuration:
@@ -383,13 +363,6 @@ When your browser runs into problems playing back your camera streams, it will l
- `Safari reported InvalidStateError.`
- `Safari reported decoding errors.`
- **mse-codec**
- What it means: go2rtc has no codec for this stream that the browser can play.
- What to try: Pick a stream with a codec the browser supports (H.264 is the most compatible), or use a browser that supports the stream's codec. In Auto, Frigate skips this stream for the rest of the session.
- Possible console messages from the player code:
- `mse: streams: codecs not matched: ...`
- **stalled**
- What it means: Playback has stalled because the player has fallen too far behind live (extended buffering or no data arriving).
- What to try: This is usually indicative of the browser struggling to decode too many high-resolution streams at once. Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution. Also check your camera's keyframe (I-frame) interval: shorter intervals make playback start and recover faster. You can also try increasing the timeout value in <NavPath path="Settings > UI" /> .
+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`
- Additional objects are detected
- 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.
@@ -235,7 +236,8 @@ When the review activity has ended a final `end` message is published.
"objects": ["person", "car"],
"sub_labels": [],
"zones": [],
"audio": []
"audio": [],
"classification_state_changes": []
}
},
"after": {
@@ -254,7 +256,16 @@ When the review activity has ended a final `end` message is published.
"objects": ["person", "car"],
"sub_labels": ["Bob"],
"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
}
]
}
}
}
-38
View File
@@ -412,39 +412,6 @@ paths:
security:
- frigateAdminAuth: []
x-required-role: admin
/go2rtc/streams/{stream_name}/bitrate:
get:
tags:
- Camera
summary: Go2Rtc Stream Bitrate
description: |-
**Access:** Admin role required.
Measure a go2rtc stream's bitrate over a few seconds.
operationId:
go2rtc_stream_bitrate_go2rtc_streams__stream_name__bitrate_get
parameters:
- name: stream_name
in: path
required: true
schema:
type: string
title: Stream Name
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateAdminAuth: []
x-required-role: admin
/ffprobe:
get:
tags:
@@ -7764,11 +7731,6 @@ components:
type: boolean
title: Skip Save
default: false
replace_paths:
items:
type: string
type: array
title: Replace Paths
type: object
title: AppConfigSetBody
AppPostLoginBody:
+5 -36
View File
@@ -63,7 +63,6 @@ from frigate.util.builtin import (
flatten_config_data,
load_labels,
process_config_query_string,
split_config_key_path,
update_yaml_file_bulk,
)
from frigate.util.config import (
@@ -71,10 +70,6 @@ from frigate.util.config import (
find_config_file,
redact_credential,
)
from frigate.util.live_streams import (
generated_transcode_streams,
sync_transcode_streams,
)
from frigate.util.object_names import get_categorized_object_names
from frigate.util.schema import get_config_schema
from frigate.util.services import (
@@ -398,6 +393,11 @@ def config(request: Request):
model_dict["non_logo_attributes"] = model.non_logo_attributes
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"]:
continue
@@ -815,17 +815,6 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
)
def _config_path_exists(data: Any, key_path: str) -> bool:
"""Return whether a dotted config path is present in parsed yaml."""
for key in split_config_key_path(key_path):
if not isinstance(data, dict) or key not in data:
return False
data = data[key]
return True
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
def config_set(request: Request, body: AppConfigSetBody):
config_file = find_config_file()
@@ -880,19 +869,6 @@ def config_set(request: Request, body: AppConfigSetBody):
status_code=400,
)
# delete replaced paths first so their maps are rewritten in
# the order sent; update_yaml would otherwise keep old order
if body.replace_paths:
old_yaml = ruamel.yaml.YAML(typ="safe").load(old_raw_config) or {}
updates = {
**{
path: ""
for path in body.replace_paths
if _config_path_exists(old_yaml, path)
},
**updates,
}
# apply all updates in a single operation
update_yaml_file_bulk(config_file, updates)
@@ -956,15 +932,9 @@ def config_set(request: Request, body: AppConfigSetBody):
if request.app.dispatcher is not None:
request.app.dispatcher.clear_runtime_state_for_yaml_keys(updates.keys())
go2rtc_synced = True
if body.requires_restart == 0 or body.update_topic:
old_config: FrigateConfig = request.app.frigate_config
swap_runtime_config(request.app, config)
go2rtc_synced = sync_transcode_streams(
generated_transcode_streams(old_config),
generated_transcode_streams(config),
)
if body.update_topic:
if body.update_topic.startswith("config/cameras/"):
@@ -1024,7 +994,6 @@ def config_set(request: Request, body: AppConfigSetBody):
if body.requires_restart == 0
else "Config successfully updated, restart to apply"
),
"go2rtc_synced": go2rtc_synced,
}
),
status_code=200,
-39
View File
@@ -39,11 +39,6 @@ from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
from frigate.util.config import find_config_file
from frigate.util.image import run_ffmpeg_snapshot
from frigate.util.live_streams import (
generated_transcode_streams,
measure_stream_bitrate,
sync_transcode_streams,
)
from frigate.util.services import (
analyze_record_keyframes,
ffprobe_stream,
@@ -253,34 +248,6 @@ def go2rtc_delete_stream(stream_name: str):
)
@router.get(
"/go2rtc/streams/{stream_name}/bitrate",
dependencies=[Depends(require_role(["admin"]))],
)
async def go2rtc_stream_bitrate(request: Request, stream_name: str):
"""Measure a go2rtc stream's bitrate over a few seconds."""
config: FrigateConfig = request.app.frigate_config
known = set(config.go2rtc.model_dump().get("streams") or {}) | set(
generated_transcode_streams(config)
)
if stream_name not in known:
return JSONResponse(
content={"success": False, "message": "Unknown stream"},
status_code=404,
)
kbps = await asyncio.to_thread(measure_stream_bitrate, stream_name)
if kbps is None:
return JSONResponse(
content={"success": False, "message": "Stream sent no data"},
status_code=502,
)
return JSONResponse(content={"success": True, "kbps": round(kbps)})
@router.get("/ffprobe", dependencies=[Depends(require_role(["admin"]))])
def ffprobe(request: Request, paths: str = "", detailed: bool = False):
path_param = paths
@@ -1375,12 +1342,6 @@ async def delete_camera(
except Exception:
logger.debug("Failed to remove go2rtc stream for %s", camera_name)
await asyncio.to_thread(
sync_transcode_streams,
generated_transcode_streams(frigate_config),
generated_transcode_streams(request.app.frigate_config),
)
return JSONResponse(
content={
"success": True,
+1
View File
@@ -51,6 +51,7 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
if app.stats_emitter is not None:
app.stats_emitter.config = config
app.stats_emitter.hardware_stats.set_config(config)
if app.dispatcher is not None:
app.dispatcher.config = config
-2
View File
@@ -10,8 +10,6 @@ class AppConfigSetBody(BaseModel):
update_topic: str | None = None
config_data: dict[str, Any] | None = None
skip_save: bool = False
# paths rewritten whole, so a map saves in the order sent
replace_paths: list[str] = Field(default_factory=list)
class GenAIProbeBody(BaseModel):
+1 -7
View File
@@ -63,7 +63,6 @@ from frigate.util.recording_coverage import (
null_audio_glitches,
plan_clip,
resolve_coverage,
stream_has_audio,
)
logger = logging.getLogger(__name__)
@@ -681,15 +680,10 @@ async def _vod_response(
end_ts,
force_discontinuity,
)
intervals = resolve_coverage(camera_name, start_ts, end_ts)
# rows contradicting their stream's audio composition are
# truncated-shutdown glitches
main_audio = stream_has_audio(intervals, main=True)
sub_audio = stream_has_audio(intervals, main=False)
spans = build_spans(
null_audio_glitches(intervals, main_audio, sub_audio),
null_audio_glitches(resolve_coverage(camera_name, start_ts, end_ts)),
stream_preference,
)
+1
View File
@@ -12,6 +12,7 @@ class DetectionTypeEnum(str, Enum):
video = "video"
audio = "audio"
lpr = "lpr"
classification_state = "classification_state"
class DetectionPublisher(Publisher):
+2 -56
View File
@@ -1,57 +1,8 @@
from pydantic import Field, field_validator
from frigate.util.live_streams import DEFAULT_TRANSCODE_QUALITIES
from pydantic import Field
from ..base import FrigateBaseModel
__all__ = ["CameraLiveConfig", "LiveTranscodeConfig", "LiveTranscodeQualityConfig"]
class LiveTranscodeQualityConfig(FrigateBaseModel):
height: int = Field(
ge=144,
le=2160,
title="Height",
description="Output height in pixels; width follows the source aspect ratio.",
)
bitrate: int = Field(
ge=64,
title="Bitrate",
description="Target and maximum video bitrate in kbps.",
)
class LiveTranscodeConfig(FrigateBaseModel):
enabled: bool = Field(
default=False,
title="Enable transcoded streams",
description="Add lower-quality live streams that go2rtc transcodes in real time while someone is watching.",
)
source: str | None = Field(
default=None,
title="Source stream",
description="go2rtc stream to transcode. Defaults to the first live stream.",
)
qualities: list[LiveTranscodeQualityConfig] = Field(
default_factory=lambda: [
LiveTranscodeQualityConfig(**quality)
for quality in DEFAULT_TRANSCODE_QUALITIES
],
title="Qualities",
description="One transcoded stream is added per quality.",
)
@field_validator("qualities")
@classmethod
def validate_unique_heights(
cls, qualities: list[LiveTranscodeQualityConfig]
) -> list[LiveTranscodeQualityConfig]:
heights = [quality.height for quality in qualities]
if len(heights) != len(set(heights)):
raise ValueError("Transcoded stream heights must be unique.")
return qualities
__all__ = ["CameraLiveConfig"]
class CameraLiveConfig(FrigateBaseModel):
@@ -60,11 +11,6 @@ class CameraLiveConfig(FrigateBaseModel):
title="Live stream names",
description="Mapping of configured stream names to restream/go2rtc names used for live playback.",
)
transcode: LiveTranscodeConfig = Field(
default_factory=LiveTranscodeConfig,
title="Transcoded streams",
description="Lower-quality live streams transcoded on demand by go2rtc.",
)
height: int = Field(
default=720,
title="Live height",
-77
View File
@@ -35,12 +35,6 @@ from frigate.util.config import (
migrate_frigate_config,
)
from frigate.util.image import create_mask
from frigate.util.live_streams import (
default_transcode_source,
is_transcode_stream_name,
transcode_stream_name,
transcode_streams,
)
from frigate.util.services import auto_detect_hwaccel
from .auth import AuthConfig
@@ -288,78 +282,11 @@ def verify_config_roles(camera_config: CameraConfig) -> None:
)
def apply_live_transcode_streams(
frigate_config: FrigateConfig, camera_config: CameraConfig
) -> None:
"""Fold a camera's transcoded streams into its live stream list.
Enabled qualities missing from live.streams are appended, and entries the
user placed keep their position. Transcoded names that are no longer
generated are dropped unless they name a real go2rtc stream.
"""
live = camera_config.live
transcode = live.transcode
go2rtc_streams = frigate_config.go2rtc.model_dump().get("streams") or {}
generated: dict[str, str] = {}
if transcode.enabled:
if transcode.source is None:
transcode.source = default_transcode_source(
camera_config.name, live.streams
)
if transcode.source not in go2rtc_streams:
raise ValueError(
f"Camera {camera_config.name} has transcoded streams enabled, but its source {transcode.source} is not a go2rtc stream."
)
generated = transcode_streams(
camera_config.name,
transcode.source,
[quality.model_dump() for quality in transcode.qualities],
)
for name in generated:
if name in go2rtc_streams:
raise ValueError(
f"Camera {camera_config.name} generates transcoded stream {name}, which collides with a go2rtc stream of the same name."
)
streams = {
label: name
for label, name in live.streams.items()
if name in generated
or name in go2rtc_streams
or not is_transcode_stream_name(camera_config.name, name)
}
placed = set(streams.values())
for quality in transcode.qualities if transcode.enabled else []:
name = transcode_stream_name(camera_config.name, quality.height)
if name in placed:
continue
label = f"{quality.height}p"
if label in streams:
raise ValueError(
f"Camera {camera_config.name} already has a live stream named {label}; rename it or place the transcoded stream under another name."
)
streams[label] = name
live.streams = streams
def verify_valid_live_stream_names(
frigate_config: FrigateConfig, camera_config: CameraConfig
) -> ValueError | None:
"""Verify that a restream exists to use for live view."""
for _, stream_name in camera_config.live.streams.items():
if is_transcode_stream_name(camera_config.name, stream_name):
continue
if (
stream_name
not in frigate_config.go2rtc.model_dump().get("streams", {}).keys()
@@ -1051,8 +978,6 @@ class FrigateConfig(FrigateBaseModel):
"face_recognition": ["enabled", "min_area"],
"lpr": ["enabled", "expire_time", "min_area", "enhancement"],
"audio_transcription": ["enabled", "live_enabled"],
# transcode is camera-level only
"live": ["streams", "height", "quality"],
}
for section in allowed_fields_map:
@@ -1281,8 +1206,6 @@ class FrigateConfig(FrigateBaseModel):
if not camera_config.live.streams:
camera_config.live.streams = {name: name}
apply_live_transcode_streams(self, camera_config)
# generate the ffmpeg commands
camera_config.create_ffmpeg_cmds()
self.cameras[name] = camera_config
@@ -1,10 +1,10 @@
"""Frame annotations derived from object tracking data.
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
in the database (each event's `path_data` trajectory and the timeline's
stationary/active changes), so the notes can be stated to the model as fact
rather than as something it must perceive.
frames sampled from it. Everything here comes from data already recorded
(each event's `path_data` trajectory, the timeline's stationary/active
changes, and the review item's state classification changes), so the notes
can be stated to the model as fact rather than as something it must perceive.
"""
import logging
@@ -95,6 +95,15 @@ def event_name(event: dict[str, Any]) -> str:
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]:
"""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(
detection_ids: list[str],
frame_times: list[float],
classification_changes: Sequence[dict[str, Any]] = (),
) -> list[str]:
"""A caption for each sampled frame, in frame order.
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
that moment. Returns an empty list when there is nothing to say, which
callers treat as a reason to fall back to sending plain frames.
apart; frames where something changed also carry the tracker and state
classification notes for that moment. Returns an empty list when there is
nothing to say, which callers treat as a reason to fall back to sending
plain frames.
"""
if not frame_times:
return []
span_end = frame_times[-1]
timeline: list[tuple[float, str]] = []
events = get_tracked_events(detection_ids)
if not events:
logger.debug("No tracked events found for review item, skipping annotations")
return []
# audio and manual review items can have state changes but no tracked objects
if events:
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))
buckets = annotations_by_frame(timeline, frame_times)
timeline.extend(
(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:
return []
@@ -388,7 +410,7 @@ def build_frame_captions(
for index, timestamp in enumerate(frame_times):
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))
return captions
@@ -40,7 +40,7 @@ from frigate.util.image import get_image_from_recording
from ..post.api import PostProcessorApi
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__)
@@ -254,6 +254,10 @@ class ReviewDescriptionProcessor(PostProcessorApi):
"start_time": r["start_time"],
"end_time": r["end_time"],
"metadata": r["data"]["metadata"],
"state_changes": [
describe_classification_change(change)
for change in sorted_classification_state_changes(r["data"])
],
}
for r in (
ReviewSegment.select(
@@ -298,6 +302,9 @@ class ReviewDescriptionProcessor(PostProcessorApi):
primary_item["start_time"] = primary_seg["start_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
primary_start = primary_seg["start_time"]
primary_end = primary_seg["end_time"]
@@ -318,14 +325,25 @@ class ReviewDescriptionProcessor(PostProcessorApi):
seg_end = seg["end_time"]
if seg_start < primary_end and primary_start < seg_end:
# Avoid duplicates if same camera has multiple overlapping segments
if seg_camera not in seen_contextual_cameras:
contextual_item = copy.deepcopy(seg["metadata"])
contextual_item["camera"] = seg_camera
contextual_item["start_time"] = seg_start
contextual_item["end_time"] = seg_end
contextual_items.append(contextual_item)
seen_contextual_cameras.add(seg_camera)
# Avoid duplicates if same camera has multiple overlapping
# segments. One with state changes is kept as its own item
# so each change stays within its item's time range.
if (
seg_camera in seen_contextual_cameras
and not seg["state_changes"]
):
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
primary_item["context"] = contextual_items
@@ -439,6 +457,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
captions = build_frame_captions(
final_data["data"].get("detections") or [],
[timestamp for _, timestamp in frames],
sorted_classification_state_changes(final_data["data"]),
)
if not captions:
@@ -688,6 +707,39 @@ def get_recording_buffer_extension(duration: float) -> float:
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(
requestor: InterProcessRequestor,
genai_client: GenAIClient,
@@ -743,6 +795,13 @@ def run_analysis(
unified_objects.append(object_type)
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(
analytics_data,
@@ -91,8 +91,23 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
self.tensor_input_details = self.interpreter.get_input_details()
self.tensor_output_details = self.interpreter.get_output_details()
self.labelmap = load_labels(labelmap_path, prefill=0, indexed=False)
self._forget_unknown_states()
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:
self.classifications_per_second.update()
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%
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.
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:
self.state_history[camera] = {
"current_state": None,
"pending_state": None,
"pending_since": 0.0,
"consecutive_count": 0,
}
@@ -157,12 +177,14 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
verification["consecutive_count"] += 1
if verification["consecutive_count"] >= 3:
previous_state = verification["current_state"]
verification["current_state"] = detected_state
verification["pending_state"] = None
verification["consecutive_count"] = 0
return detected_state
return previous_state, verification["pending_since"]
else:
verification["pending_state"] = detected_state
verification["pending_since"] = timestamp
verification["consecutive_count"] = 1
logger.debug(
f"New state '{detected_state}' detected for {camera}, need {3 - verification['consecutive_count']} more consecutive detections"
@@ -340,16 +362,19 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
)
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(
{
"type": "classification",
"processor": "state",
"model_name": self.model_config.name,
"camera": camera,
"state": verified_state,
"state": detected_state,
"previous_state": previous_state,
"timestamp": changed_at,
}
)
+7
View File
@@ -123,6 +123,7 @@ class ModelConfig(BaseModel):
_all_attributes: list[str] = PrivateAttr()
_all_attribute_logos: list[str] = PrivateAttr()
_model_hash: str = PrivateAttr()
_plus_id: str | None = PrivateAttr(default=None)
@property
def merged_labelmap(self) -> dict[int, str]:
@@ -148,6 +149,11 @@ class ModelConfig(BaseModel):
def model_hash(self) -> str:
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):
super().__init__(**config)
@@ -178,6 +184,7 @@ class ModelConfig(BaseModel):
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
model_id = self.path[7:]
self._plus_id = model_id
self.path = os.path.join(MODEL_CACHE_DIR, model_id)
model_info_path = f"{self.path}.json"
+22 -1
View File
@@ -11,7 +11,11 @@ from typing import Any
from peewee import DoesNotExist
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 (
EmbeddingsRequestEnum,
EmbeddingsResponder,
@@ -168,6 +172,7 @@ class EmbeddingMaintainer(threading.Thread):
)
self.review_subscriber = ReviewDataSubscriber("")
self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video.value)
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.all.value)
self.embeddings_responder = EmbeddingsResponder()
self.frame_manager = SharedMemoryFrameManager()
@@ -356,6 +361,7 @@ class EmbeddingMaintainer(threading.Thread):
self.event_end_subscriber.stop()
self.recordings_subscriber.stop()
self.detection_subscriber.stop()
self.detection_publisher.stop()
self.event_metadata_publisher.stop()
self.event_metadata_subscriber.stop()
self.embeddings_responder.stop()
@@ -851,6 +857,21 @@ class EmbeddingMaintainer(threading.Thread):
f"{result['camera']}/classification/{result['model_name']}",
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":
object_id = result["object_id"]
camera = result["camera"]
+36 -89
View File
@@ -103,11 +103,10 @@ class LlamaCppClient(GenAIClient):
_supports_reasoning: bool
_image_token_cache: dict[tuple[int, int], int]
_text_baseline_tokens: int | None
_media_marker: str
@property
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
def _auth_headers(self) -> dict | None:
@@ -159,7 +158,6 @@ class LlamaCppClient(GenAIClient):
self._supports_reasoning = False
self._image_token_cache = {}
self._text_baseline_tokens = None
self._media_marker = "<__media__>"
base_url = (
self.genai_config.base_url.rstrip("/")
@@ -187,7 +185,6 @@ class LlamaCppClient(GenAIClient):
self._supports_audio = info["supports_audio"]
self._supports_tools = info["supports_tools"]
self._supports_reasoning = info["supports_reasoning"]
self._media_marker = info["media_marker"]
logger.info(
"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
source. When proxied through llama-swap, the same entry carries
`status.args` (server launch argv) and, for the loaded model,
`meta.n_ctx`. /props remains the only source for `media_marker`,
which the server randomizes per startup unless LLAMA_MEDIA_MARKER
is set.
`meta.n_ctx`.
"""
info: dict[str, Any] = {
"context_size": None,
@@ -225,7 +220,6 @@ class LlamaCppClient(GenAIClient):
"supports_audio": False,
"supports_tools": False,
"supports_reasoning": False,
"media_marker": "<__media__>",
}
model_entry: dict[str, Any] | None = None
@@ -314,16 +308,8 @@ class LlamaCppClient(GenAIClient):
# in the Jinja chat template itself.
chat_template = props.get("chat_template") or ""
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:
logger.warning(
"Failed to query llama.cpp /props endpoint: %s. "
"Image embeddings may fail if the server randomized its media marker.",
e,
)
logger.warning("Failed to query llama.cpp /props endpoint: %s", e)
return info
@@ -474,9 +460,6 @@ class LlamaCppClient(GenAIClient):
def _transcribe_via_chat(self, audio: bytes, language: str | None) -> str | None:
"""Transcribe through /v1/chat/completions, for servers without the
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."
@@ -794,41 +777,16 @@ class LlamaCppClient(GenAIClient):
)
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(
self,
texts: list[str] | None = None,
images: list[bytes] | None = None,
) -> 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
multimodal_data for images (PR #15108). Server must be started with
--embeddings and --mmproj for multimodal support.
Each text or image is one entry in `input`, using the chat-style
content array from ggml-org/llama.cpp#29556. Server must be started
with --embeddings, and --mmproj for image support.
"""
if self.provider is None:
logger.warning(
@@ -843,49 +801,42 @@ class LlamaCppClient(GenAIClient):
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:
# llama.cpp uses STB which does not support WebP; convert to JPEG
jpeg_bytes = _to_jpeg(img)
to_encode = jpeg_bytes if jpeg_bytes is not None else img
encoded_images.append(base64.b64encode(to_encode).decode("utf-8"))
def build_content() -> list[dict[str, Any]]:
# prompt_string must contain the server's media marker placeholder
# for each image. The marker is randomized per server startup.
content: list[dict[str, Any]] = []
for text in texts:
content.append({"prompt_string": text})
for encoded in encoded_images:
content.append(
{
"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,
encoded = base64.b64encode(to_encode).decode("utf-8")
# The trailing newline keeps tokenization identical to the older
# "<__media__>\n" prompt_string format, so indexed vectors stay valid
inputs.append(
{
"content": [
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
},
{"type": "text", "text": "\n"},
]
}
)
try:
try:
response = post_embeddings()
response.raise_for_status()
except requests.exceptions.RequestException:
# The server may have restarted with a new media marker.
# Refresh from /props; only retry if the marker actually changed.
if not encoded_images or not self._refresh_media_marker():
raise
response = post_embeddings()
response.raise_for_status()
result = response.json()
response = self._post(
f"{self.provider}/v1/embeddings",
json={
"model": self.genai_config.model,
"input": inputs,
"encoding_format": "float",
},
timeout=self.timeout,
)
response.raise_for_status()
items = response.json().get("data")
items = result.get("data", result) if isinstance(result, dict) else result
if not isinstance(items, list):
logger.warning("llama.cpp embeddings returned unexpected format")
return []
@@ -896,11 +847,7 @@ class LlamaCppClient(GenAIClient):
if emb is None:
logger.warning("llama.cpp embeddings item missing embedding field")
continue
arr = np.array(emb, dtype=np.float32)
if arr.ndim > 1:
# llama.cpp can return token-level embeddings; pool per item
arr = arr.mean(axis=0)
arr = arr.flatten()
arr = np.array(emb, dtype=np.float32).flatten()
orig_dim = arr.size
if orig_dim != EMBEDDING_DIM:
if orig_dim > EMBEDDING_DIM:
+28 -2
View File
@@ -104,6 +104,21 @@ def build_review_description_prompt(
else:
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)
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"]}
- 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
- 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
@@ -196,6 +211,17 @@ def build_review_summary_prompt(
f" to "
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"""
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:
- "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.**
+90 -22
View File
@@ -42,6 +42,8 @@ from frigate.util.ownership import chown_to_runtime
from frigate.util.recording_coverage import (
build_spans,
known_video_codecs,
null_audio_glitches,
realized_timeline,
resolve_coverage,
stream_media_summary,
)
@@ -84,10 +86,20 @@ class StreamRun:
@dataclass
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
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).
@@ -376,15 +388,18 @@ class RecordingExporter(threading.Thread):
def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]:
"""Resolve the export range into the spans the VOD manifest will serve.
Delegates to the same coverage resolution the manifest builder
uses, so what we plan around and what nginx-vod emits agree by
construction. Returns the spans (each [row, start, end, is_main]),
the known video codecs, and whether audio survives the range.
Delegates to the same coverage resolution and glitch nulling the
manifest builder uses, so what we plan around and what nginx-vod
emits agree by construction. Returns the spans (each [row, start,
end, is_main]), the known video codecs, and whether audio survives
the range.
Memoized: several stages of the export ask the same question, and
the recordings backing a finished range do not change under us.
"""
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 = (
build_spans(intervals, self.pinned_stream),
known_video_codecs(intervals),
@@ -433,17 +448,57 @@ class RecordingExporter(threading.Thread):
# hand-off to stage around
return True
spans, codecs, keep_audio = self._resolve_coverage()
runs = self._stream_runs(spans)
_spans, codecs, keep_audio = self._resolve_coverage()
runs = self._planned_stream_runs()
# a range one stream covers end to end has nothing to hand off,
# so it stays on the existing path however long it is
if len(runs) < 2:
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)
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]:
"""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))
if clipped_end <= clipped_start:
continue
# a staged window's keyframe lead-in plays before it
output_offset += _lead_in(rec)
windows.append((clipped_start, clipped_end, output_offset))
output_offset += clipped_end - clipped_start
@@ -987,9 +1044,13 @@ class RecordingExporter(threading.Thread):
if duration_ms <= 0:
continue
title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat(
timespec="seconds"
)
# a staged window's keyframe lead-in opens its chapter, with
# 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]\n"
"TIMEBASE=1/1000\n"
@@ -1128,12 +1189,12 @@ class RecordingExporter(threading.Thread):
if self.staged_runs:
# each run was already rendered to a temp file with a common
# track timescale, so the concat demuxer has nothing left to
# reconcile and every chapter offset lines up with the merged
# timeline the staged files reproduce
recordings = [
_ChapterWindow(span_start, span_end)
for _row, span_start, span_end, _is_main in self._merged_spans()
]
# reconcile
recordings = (
self._staged_chapter_windows()
if self.chapters not in (None, ChaptersEnum.none)
else []
)
playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs]
ffmpeg_input = (
"-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
recordings = self._get_recordings_for_range(pin)
else:
# never mix streams in one playlist; use main when available
# and fall back to sub for expired-main history
recordings = self._get_recordings_for_range(STREAM_TYPE_MAIN)
# an unstaged auto range resolves to at most one stream run, and
# its rows are the ones the chapters describe. Main rows the
# 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)
playlist_lines = []
+42 -4
View File
@@ -490,9 +490,17 @@ class RecordingMaintainer(threading.Thread):
)
reviews = reviews_by_camera[camera]
tasks.extend(
[self.validate_and_move_segment(camera, reviews, r) for r in recordings]
)
# probes run concurrently, but each segment's start chains off the
# 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
if stream_type == STREAM_TYPE_MAIN:
@@ -550,12 +558,33 @@ class RecordingMaintainer(threading.Thread):
while info and info[0][0] < expire_before:
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:
Path(cache_path).unlink(missing_ok=True)
self.end_time_cache.pop(cache_path, None)
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:
cache_path: str = recording["cache_path"]
start_time: datetime.datetime = recording["start_time"]
@@ -617,6 +646,9 @@ class RecordingMaintainer(threading.Thread):
async with self.probe_semaphore:
keyframes = await get_keyframe_offsets(cache_path)
if previous_start is not None:
await previous_start.wait()
start_time = self._resolve_segment_start(
camera, stream_type, start_time, duration, cache_path
)
@@ -654,6 +686,11 @@ class RecordingMaintainer(threading.Thread):
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
# sub's alerts/detections carry the retain mode directly, unlike
@@ -1090,6 +1127,7 @@ class RecordingMaintainer(threading.Thread):
elif (
topic == DetectionTypeEnum.api.value
or topic == DetectionTypeEnum.lpr.value
or topic == DetectionTypeEnum.classification_state.value
):
continue
+69 -12
View File
@@ -40,6 +40,10 @@ logger = logging.getLogger(__name__)
THUMB_HEIGHT = 180
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:
def __init__(
@@ -61,6 +65,7 @@ class PendingReviewSegment:
self.sub_labels = sub_labels
self.zones = zones
self.audio = audio
self.classification_state_changes: list[dict[str, Any]] = []
self.thumb_time: float | None = None
self.last_alert_time: float | None = None
self.last_detection_time: float = frame_time
@@ -162,6 +167,7 @@ class PendingReviewSegment:
"sub_labels": list(self.sub_labels.values()),
"zones": self.zones,
"audio": list(self.audio),
"classification_state_changes": self.classification_state_changes,
"thumb_time": self.thumb_time,
"metadata": None,
},
@@ -293,6 +299,9 @@ class ReviewSegmentMaintainer(threading.Thread):
# manual events
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
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
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:
"""Forcibly end the pending segment for a 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."""
self.forcibly_end_segment(camera)
self.indefinite_events.pop(camera, None)
self.recent_classification_state_changes.pop(camera, None)
def update_existing_segment(
self,
@@ -579,7 +626,7 @@ class ReviewSegmentMaintainer(threading.Thread):
audio=set(),
zones=list(new_zones),
)
self.active_review_segments[segment.camera] = new_segment
self._activate_segment(new_segment)
self._publish_segment_start(new_segment)
new_segment.last_detection_time = last_detection_time
elif segment.severity == SeverityEnum.detection and frame_time > (
@@ -639,7 +686,7 @@ class ReviewSegmentMaintainer(threading.Thread):
audio=set(),
zones=zones,
)
self.active_review_segments[camera] = new_segment
self._activate_segment(new_segment)
try:
yuv_frame = self.frame_manager.get(
@@ -713,6 +760,10 @@ class ReviewSegmentMaintainer(threading.Thread):
if camera not in self.indefinite_events:
self.indefinite_events[camera] = {}
elif topic == DetectionTypeEnum.classification_state.value:
(camera, classification_change) = data
else:
continue
if camera not in self.config.cameras:
continue
@@ -723,6 +774,10 @@ class ReviewSegmentMaintainer(threading.Thread):
):
continue
if topic == DetectionTypeEnum.classification_state:
self.handle_classification_state_change(camera, classification_change)
continue
current_segment = self.active_review_segments.get(camera)
# Check if the current segment should be processed based on enabled settings
@@ -864,14 +919,16 @@ class ReviewSegmentMaintainer(threading.Thread):
severity = SeverityEnum.detection
if severity:
self.active_review_segments[camera] = PendingReviewSegment(
camera,
frame_time,
severity,
{},
{},
[],
detections,
self._activate_segment(
PendingReviewSegment(
camera,
frame_time,
severity,
{},
{},
[],
detections,
)
)
elif topic == DetectionTypeEnum.api:
severity = self.get_manual_event_severity(
@@ -888,7 +945,7 @@ class ReviewSegmentMaintainer(threading.Thread):
[],
set(),
)
self.active_review_segments[camera] = api_segment
self._activate_segment(api_segment)
if manual_info["state"] == ManualEventState.start:
self.indefinite_events[camera][manual_info["event_id"]] = (
@@ -915,7 +972,7 @@ class ReviewSegmentMaintainer(threading.Thread):
[],
set(),
)
self.active_review_segments[camera] = lpr_segment
self._activate_segment(lpr_segment)
if manual_info["state"] == ManualEventState.start:
self.indefinite_events[camera][manual_info["event_id"]] = (
+11
View File
@@ -256,6 +256,17 @@ class HardwareStats:
)
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:
"""Recalculate all hardware that needs to be monitored from the config."""
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]]:
"""Get stats for all detectors, including temperatures based on detector type."""
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():
pid = detector.detect_process.pid if detector.detect_process else None
detector_type = detector.detector_config.type
# Keep track of the index for each detector type to match temperatures correctly
current_index = detector_type_indices.get(detector_type, 0)
detector_type_indices[detector_type] = current_index + 1
# temperatures are per physical unit, so a repeated device
# ("hailo:PCIe#2", see runner_names) shares its unit's reading
device = name.partition("#")[0]
type_devices = device_indices.setdefault(detector_type, {})
current_index = type_devices.setdefault(device, len(type_devices))
detector_stat = {
"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
import frigate.genai
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.models import Event, Recordings, ReviewSegment
from frigate.stats.emitter import StatsEmitter
@@ -90,6 +92,44 @@ class TestHttpApp(BaseTestHttp):
mqtt = response.json()["mqtt"]
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 ##################################################
####################################################################################################################
@@ -1,173 +0,0 @@
"""Tests for config_set live stream ordering and go2rtc transcode sync."""
import os
import tempfile
from unittest.mock import MagicMock, Mock, patch
import ruamel.yaml
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.api.fastapi_app import create_fastapi_app
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
from frigate.util.live_streams import transcode_stream_source
STREAMS_PATH = "cameras.front_door.live.streams"
class TestConfigSetLiveStreams(BaseTestHttp):
def setUp(self):
super().setUp(models=[Event, Recordings, ReviewSegment])
self.minimal_config["go2rtc"] = {
"streams": {
"front_main": ["rtsp://10.0.0.1:554/main"],
"front_sub": ["rtsp://10.0.0.1:554/sub"],
}
}
self.minimal_config["cameras"]["front_door"]["live"] = {
"streams": {"Main": "front_main", "Sub": "front_sub"}
}
def _write_config_file(self) -> str:
yaml = ruamel.yaml.YAML()
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
yaml.dump(self.minimal_config, f)
f.close()
self.addCleanup(os.unlink, f.name)
return f.name
def _read_config_file(self, path: str) -> dict:
with open(path) as f:
return ruamel.yaml.YAML(typ="safe").load(f)
def _app(self):
publisher = Mock(spec=CameraConfigUpdatePublisher)
publisher.publisher = MagicMock()
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
publisher,
None,
enforce_default_admin=False,
)
async def mock_get_current_user(request: Request):
return {
"username": request.headers.get("remote-user"),
"role": request.headers.get("remote-role"),
}
async def mock_get_allowed_cameras_for_filter(request: Request):
return ["front_door"]
app.dependency_overrides[get_current_user] = mock_get_current_user
app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
return app
def _save(self, app, live: dict, replace_paths: list[str] | None = None):
body = {
"config_data": {"cameras": {"front_door": {"live": live}}},
"requires_restart": 0,
"update_topic": "config/cameras/front_door/live",
}
if replace_paths is not None:
body["replace_paths"] = replace_paths
with AuthTestClient(app) as client:
return client.put("/config/set", json=body)
@patch("frigate.api.app.find_config_file")
def test_replace_paths_saves_map_in_sent_order(self, mock_find_config):
path = self._write_config_file()
mock_find_config.return_value = path
resp = self._save(
self._app(),
{"streams": {"Sub": "front_sub", "Main": "front_main"}},
[STREAMS_PATH],
)
self.assertEqual(resp.status_code, 200)
streams = self._read_config_file(path)["cameras"]["front_door"]["live"][
"streams"
]
self.assertEqual(list(streams), ["Sub", "Main"])
@patch("frigate.api.app.find_config_file")
def test_without_replace_paths_order_is_kept(self, mock_find_config):
path = self._write_config_file()
mock_find_config.return_value = path
self._save(self._app(), {"streams": {"Sub": "front_sub", "Main": "front_main"}})
streams = self._read_config_file(path)["cameras"]["front_door"]["live"][
"streams"
]
self.assertEqual(list(streams), ["Main", "Sub"])
@patch("frigate.api.app.find_config_file")
def test_replace_path_missing_from_yaml_is_skipped(self, mock_find_config):
del self.minimal_config["cameras"]["front_door"]["live"]
path = self._write_config_file()
mock_find_config.return_value = path
resp = self._save(
self._app(), {"streams": {"Main": "front_main"}}, [STREAMS_PATH]
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(
self._read_config_file(path)["cameras"]["front_door"]["live"]["streams"],
{"Main": "front_main"},
)
@patch("frigate.api.app.sync_transcode_streams", return_value=True)
@patch("frigate.api.app.find_config_file")
def test_enabling_transcode_syncs_go2rtc(self, mock_find_config, mock_sync):
path = self._write_config_file()
mock_find_config.return_value = path
resp = self._save(
self._app(),
{
"transcode": {
"enabled": True,
"qualities": [{"height": 480, "bitrate": 500}],
}
},
)
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.json()["go2rtc_synced"])
mock_sync.assert_called_once_with(
{},
{
"front_door_transcode_480p": transcode_stream_source(
"front_main", 480, 500
)
},
)
@patch("frigate.api.app.sync_transcode_streams", return_value=False)
@patch("frigate.api.app.find_config_file")
def test_sync_failure_is_reported(self, mock_find_config, mock_sync):
path = self._write_config_file()
mock_find_config.return_value = path
resp = self._save(self._app(), {"transcode": {"enabled": True}})
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.json()["success"])
self.assertFalse(resp.json()["go2rtc_synced"])
@@ -1,61 +0,0 @@
"""Tests for the go2rtc stream bitrate endpoint."""
from unittest.mock import patch
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestHttpGo2rtcBitrate(BaseTestHttp):
def setUp(self):
super().setUp([])
self.minimal_config["go2rtc"] = {
"streams": {"front_main": ["rtsp://10.0.0.1:554/main"]}
}
self.minimal_config["cameras"]["front_door"]["live"] = {
"streams": {"Main": "front_main"},
"transcode": {
"enabled": True,
"qualities": [{"height": 480, "bitrate": 500}],
},
}
self.app = self.create_app()
@patch("frigate.api.camera.measure_stream_bitrate", return_value=812.4)
def test_returns_rounded_kbps(self, mock_measure):
with AuthTestClient(self.app) as client:
resp = client.get("/go2rtc/streams/front_main/bitrate")
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()["kbps"], 812)
mock_measure.assert_called_once_with("front_main")
@patch("frigate.api.camera.measure_stream_bitrate", return_value=480.0)
def test_generated_streams_are_known(self, _):
with AuthTestClient(self.app) as client:
resp = client.get("/go2rtc/streams/front_door_transcode_480p/bitrate")
self.assertEqual(resp.status_code, 200)
@patch("frigate.api.camera.measure_stream_bitrate")
def test_unknown_stream_is_404(self, mock_measure):
with AuthTestClient(self.app) as client:
resp = client.get("/go2rtc/streams/rtsp_somewhere/bitrate")
self.assertEqual(resp.status_code, 404)
mock_measure.assert_not_called()
@patch("frigate.api.camera.measure_stream_bitrate", return_value=None)
def test_no_data_is_502(self, _):
with AuthTestClient(self.app) as client:
resp = client.get("/go2rtc/streams/front_main/bitrate")
self.assertEqual(resp.status_code, 502)
def test_viewer_is_forbidden(self):
with AuthTestClient(self.app) as client:
resp = client.get(
"/go2rtc/streams/front_main/bitrate",
headers={"remote-user": "viewer", "remote-role": "viewer"},
)
self.assertEqual(resp.status_code, 403)
+4
View File
@@ -179,12 +179,16 @@ class TestReviewMaintainerRemoval(unittest.TestCase):
maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer)
maintainer.active_review_segments = {"deleted_cam": MagicMock()}
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._handle_camera_removed("deleted_cam")
maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam")
self.assertNotIn("deleted_cam", maintainer.indefinite_events)
self.assertNotIn("deleted_cam", maintainer.recent_classification_state_changes)
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.profile_manager.update_config.assert_called_once_with(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)
for comm in app.dispatcher.comms:
self.assertIs(comm.config, config)
+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))
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):
"""A staged path must be tracked before ffmpeg can write to it."""
+45
View File
@@ -7,6 +7,7 @@ from frigate.genai.prompts import (
REVIEW_DESCRIPTION_FIELD_GUIDELINES,
REVIEW_RESPONSE_STYLES,
build_review_description_prompt,
build_review_summary_prompt,
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__":
unittest.main()
+58 -2
View File
@@ -19,6 +19,8 @@ import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import requests
from frigate.config import GenAIConfig, GenAIProviderEnum
from frigate.genai import PROVIDERS, load_providers
@@ -566,7 +568,6 @@ class TestLlamaCppProvider(unittest.TestCase):
"supports_audio": False,
"supports_tools": False,
"supports_reasoning": False,
"media_marker": "<__media__>",
}
cls = PROVIDERS[GenAIProviderEnum.llamacpp]
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):
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
@@ -762,7 +819,6 @@ class TestLlamaCppTranscribe(unittest.TestCase):
"supports_audio": supports_audio,
"supports_tools": False,
"supports_reasoning": False,
"media_marker": "<__media__>",
}
cls = PROVIDERS[GenAIProviderEnum.llamacpp]
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"})
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):
def run_stats(self, stats: HardwareStats) -> dict:
-397
View File
@@ -1,397 +0,0 @@
"""Tests for Frigate-managed go2rtc live streams."""
import unittest
from unittest.mock import MagicMock, call, patch
import requests
from pydantic import ValidationError
from frigate.config import FrigateConfig
from frigate.util.live_streams import (
DEFAULT_TRANSCODE_QUALITIES,
default_transcode_source,
generated_transcode_streams,
is_transcode_stream_name,
measure_stream_bitrate,
raw_transcode_streams,
sync_transcode_streams,
transcode_stream_name,
transcode_stream_source,
)
class TestTranscodeNaming(unittest.TestCase):
def test_stream_name(self):
self.assertEqual(transcode_stream_name("front", 720), "front_transcode_720p")
def test_is_transcode_stream_name(self):
self.assertTrue(is_transcode_stream_name("front", "front_transcode_480p"))
self.assertFalse(is_transcode_stream_name("front", "front_sub"))
self.assertFalse(is_transcode_stream_name("front", "back_transcode_480p"))
self.assertFalse(is_transcode_stream_name("front", "front_transcode_480"))
def test_source_gives_each_ffmpeg_token_its_own_raw_param(self):
self.assertEqual(
transcode_stream_source("front", 480, 500),
"ffmpeg:front#video=h264#height=480#hardware#audio=copy"
"#raw=-b:v#raw=500k#raw=-maxrate#raw=500k#raw=-bufsize#raw=1000k",
)
def test_default_source_skips_transcoded_streams(self):
streams = {"Low": "front_transcode_360p", "Main": "front_main"}
self.assertEqual(default_transcode_source("front", streams), "front_main")
self.assertIsNone(default_transcode_source("front", {}))
class TestRawTranscodeStreams(unittest.TestCase):
def test_defaults_to_camera_stream_and_default_qualities(self):
config = {"cameras": {"front": {"live": {"transcode": {"enabled": True}}}}}
streams = raw_transcode_streams(config)
self.assertEqual(
list(streams),
[
transcode_stream_name("front", q["height"])
for q in DEFAULT_TRANSCODE_QUALITIES
],
)
self.assertTrue(streams["front_transcode_720p"].startswith("ffmpeg:front#"))
def test_uses_first_live_stream_and_explicit_qualities(self):
config = {
"cameras": {
"front": {
"live": {
"streams": {"Main": "front_main", "Sub": "front_sub"},
"transcode": {
"enabled": True,
"qualities": [{"height": 540, "bitrate": 800}],
},
}
}
}
}
self.assertEqual(
raw_transcode_streams(config),
{"front_transcode_540p": transcode_stream_source("front_main", 540, 800)},
)
def test_explicit_source_wins(self):
config = {
"cameras": {
"front": {
"live": {
"streams": {"Main": "front_main"},
"transcode": {
"enabled": True,
"source": "front_sub",
"qualities": [{"height": 360, "bitrate": 250}],
},
}
}
}
}
self.assertEqual(
raw_transcode_streams(config)["front_transcode_360p"],
transcode_stream_source("front_sub", 360, 250),
)
def test_malformed_qualities_are_skipped(self):
config = {
"cameras": {
"front": {
"live": {
"transcode": {
"enabled": True,
"qualities": [
{"height": 720},
"480",
{"height": 360, "bitrate": 250},
],
}
}
},
"back": {
"live": {"transcode": {"enabled": True, "qualities": {"a": 1}}}
},
}
}
self.assertEqual(list(raw_transcode_streams(config)), ["front_transcode_360p"])
def test_disabled_or_missing_generates_nothing(self):
self.assertEqual(raw_transcode_streams({}), {})
self.assertEqual(
raw_transcode_streams({"cameras": {"front": {"live": {}}}}), {}
)
self.assertEqual(
raw_transcode_streams(
{"cameras": {"front": {"live": {"transcode": {"enabled": False}}}}}
),
{},
)
def camera_config(live: dict | None = None, go2rtc: dict | None = None) -> dict:
config = {
"mqtt": {"host": "mqtt"},
"go2rtc": {
"streams": go2rtc
or {
"front": ["rtsp://10.0.0.1:554/main"],
"front_sub": ["rtsp://10.0.0.1:554/sub"],
}
},
"cameras": {
"front": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/main", "roles": ["detect"]}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
}
},
}
if live is not None:
config["cameras"]["front"]["live"] = live
return config
class TestLiveTranscodeConfig(unittest.TestCase):
def streams(self, live: dict, go2rtc: dict | None = None) -> dict[str, str]:
config = FrigateConfig(**camera_config(live, go2rtc))
return config.cameras["front"].live.streams
def test_global_transcode_is_ignored(self):
config = camera_config()
config["live"] = {"transcode": {"enabled": True}}
streams = FrigateConfig(**config).cameras["front"].live.streams
self.assertEqual(streams, {"front": "front"})
def test_disabled_leaves_streams_alone(self):
self.assertEqual(self.streams({}), {"front": "front"})
def test_enabled_appends_default_qualities(self):
streams = self.streams({"transcode": {"enabled": True}})
self.assertEqual(
list(streams.items()),
[
("front", "front"),
("720p", "front_transcode_720p"),
("480p", "front_transcode_480p"),
("360p", "front_transcode_360p"),
],
)
def test_source_resolves_to_first_live_stream(self):
config = FrigateConfig(
**camera_config(
{
"streams": {"Sub": "front_sub", "Main": "front"},
"transcode": {"enabled": True},
}
)
)
self.assertEqual(config.cameras["front"].live.transcode.source, "front_sub")
def test_placed_entries_keep_position_and_label(self):
streams = self.streams(
{
"streams": {
"Main": "front",
"Low": "front_transcode_480p",
"Sub": "front_sub",
},
"transcode": {"enabled": True},
}
)
self.assertEqual(
list(streams.items()),
[
("Main", "front"),
("Low", "front_transcode_480p"),
("Sub", "front_sub"),
("720p", "front_transcode_720p"),
("360p", "front_transcode_360p"),
],
)
def test_disabling_drops_transcoded_entries(self):
streams = self.streams(
{
"streams": {"Main": "front", "720p": "front_transcode_720p"},
"transcode": {"enabled": False},
}
)
self.assertEqual(streams, {"Main": "front"})
def test_changed_height_drops_the_old_entry(self):
streams = self.streams(
{
"streams": {"Main": "front", "720p": "front_transcode_720p"},
"transcode": {
"enabled": True,
"qualities": [{"height": 540, "bitrate": 800}],
},
}
)
self.assertEqual(streams, {"Main": "front", "540p": "front_transcode_540p"})
def test_real_go2rtc_stream_with_transcode_name_is_kept(self):
streams = self.streams(
{"streams": {"Main": "front", "Odd": "front_transcode_720p"}},
go2rtc={
"front": ["rtsp://10.0.0.1:554/main"],
"front_transcode_720p": ["rtsp://10.0.0.1:554/odd"],
},
)
self.assertEqual(streams, {"Main": "front", "Odd": "front_transcode_720p"})
def test_generated_name_colliding_with_go2rtc_stream_fails(self):
with self.assertRaises(ValidationError):
self.streams(
{"transcode": {"enabled": True}},
go2rtc={
"front": ["rtsp://10.0.0.1:554/main"],
"front_transcode_720p": ["rtsp://10.0.0.1:554/odd"],
},
)
def test_source_must_be_a_go2rtc_stream(self):
with self.assertRaises(ValidationError):
self.streams({"transcode": {"enabled": True, "source": "missing"}})
def test_duplicate_heights_fail(self):
with self.assertRaises(ValidationError):
self.streams(
{
"transcode": {
"enabled": True,
"qualities": [
{"height": 480, "bitrate": 500},
{"height": 480, "bitrate": 600},
],
}
}
)
def test_label_collision_fails(self):
with self.assertRaises(ValidationError):
self.streams(
{
"streams": {"Main": "front", "720p": "front_sub"},
"transcode": {"enabled": True},
}
)
class TestGeneratedTranscodeStreams(unittest.TestCase):
def test_collects_enabled_cameras(self):
config = FrigateConfig(
**camera_config(
{
"transcode": {
"enabled": True,
"qualities": [{"height": 480, "bitrate": 500}],
}
}
)
)
self.assertEqual(
generated_transcode_streams(config),
{"front_transcode_480p": transcode_stream_source("front", 480, 500)},
)
def test_disabled_is_empty(self):
self.assertEqual(
generated_transcode_streams(FrigateConfig(**camera_config())), {}
)
class TestSyncTranscodeStreams(unittest.TestCase):
@patch("frigate.util.live_streams.requests.request")
def test_puts_added_and_changed_and_deletes_removed(self, mock_request):
mock_request.return_value = MagicMock(ok=True)
ok = sync_transcode_streams(
{"a": "src-a", "b": "src-b", "c": "src-c"},
{"a": "src-a", "b": "src-b2", "d": "src-d"},
)
self.assertTrue(ok)
url = "http://127.0.0.1:1984/api/streams"
self.assertCountEqual(
mock_request.call_args_list,
[
call("put", url, params={"name": "b", "src": "src-b2"}, timeout=5),
call("put", url, params={"name": "d", "src": "src-d"}, timeout=5),
call("delete", url, params={"src": "c"}, timeout=5),
],
)
@patch("frigate.util.live_streams.requests.request")
def test_unchanged_makes_no_calls(self, mock_request):
self.assertTrue(sync_transcode_streams({"a": "x"}, {"a": "x"}))
mock_request.assert_not_called()
@patch("frigate.util.live_streams.requests.request")
def test_failure_returns_false_and_keeps_going(self, mock_request):
mock_request.side_effect = [
requests.ConnectionError("down"),
MagicMock(ok=True),
]
self.assertFalse(sync_transcode_streams({}, {"a": "x", "b": "y"}))
self.assertEqual(mock_request.call_count, 2)
class TestMeasureStreamBitrate(unittest.TestCase):
@patch("frigate.util.live_streams.time.monotonic")
@patch("frigate.util.live_streams.requests.get")
def test_skips_warmup_and_averages(self, mock_get, mock_monotonic):
response = MagicMock(ok=True)
response.iter_content.return_value = [b"x" * 1000] * 6
mock_get.return_value.__enter__.return_value = response
# first byte, warmup, window start, then counted chunks up to 6 s
mock_monotonic.side_effect = [0.0, 0.5, 1.0, 2.0, 4.0, 7.0]
kbps = measure_stream_bitrate("front", duration=6.0, warmup=1.0)
self.assertAlmostEqual(kbps, 3000 * 8 / 1000 / 6.0)
mock_get.assert_called_once_with(
"http://127.0.0.1:1984/api/stream.mp4",
params={"src": "front"},
stream=True,
timeout=5,
)
@patch("frigate.util.live_streams.requests.get")
def test_no_data_returns_none(self, mock_get):
response = MagicMock(ok=True)
response.iter_content.return_value = []
mock_get.return_value.__enter__.return_value = response
self.assertIsNone(measure_stream_bitrate("front"))
@patch("frigate.util.live_streams.requests.get")
def test_request_error_returns_none(self, mock_get):
mock_get.side_effect = requests.ReadTimeout("slow")
self.assertIsNone(measure_stream_bitrate("front"))
if __name__ == "__main__":
unittest.main()
+6 -3
View File
@@ -1,7 +1,7 @@
import datetime
import sys
import unittest
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
# Mock complex imports before importing maintainer, saving originals so we can
# restore them after import and avoid polluting sys.modules for other tests.
@@ -48,8 +48,11 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
"frigate.record.maintainer.psutil.process_iter", return_value=[]
):
with patch("frigate.record.maintainer.logger.warning") as warn:
# Mock validate_and_move_segment to avoid further logic
maintainer.validate_and_move_segment = MagicMock()
# Mock validate_and_move_segment to avoid further logic.
# 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:
await maintainer.move_files()
@@ -1,5 +1,6 @@
"""Tests for sub stream cache segment handling in the recording maintainer."""
import asyncio
import datetime
import os
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[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):
maintainer = _build_chaining_maintainer(self.T0)
+73
View File
@@ -1,10 +1,13 @@
"""Tests for tracker-derived review frame annotations."""
import unittest
from unittest.mock import patch
from frigate.data_processing.post.review_annotations import (
annotations_by_frame,
build_frame_captions,
build_timeline,
describe_classification_change,
describe_heading,
describe_position,
event_name,
@@ -312,5 +315,75 @@ class TestFrameBucketing(unittest.TestCase):
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__":
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()
+7 -1
View File
@@ -14,6 +14,10 @@ from frigate.util.file import FileLock
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
# download function swallows its exceptions, so this is how the downloader
# thread learns why a file is still missing
@@ -124,7 +128,9 @@ class ModelDownloader:
logger.info(f"Downloading model file from: {url}")
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()
with open(temporary_filename, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
-216
View File
@@ -1,216 +0,0 @@
"""Frigate-managed go2rtc live streams: transcoded rungs and bitrate probes."""
import logging
import re
import time
from typing import TYPE_CHECKING, Any
import requests
if TYPE_CHECKING:
from frigate.config import FrigateConfig
logger = logging.getLogger(__name__)
GO2RTC_API = "http://127.0.0.1:1984/api"
DEFAULT_TRANSCODE_QUALITIES: list[dict[str, int]] = [
{"height": 720, "bitrate": 1200},
{"height": 480, "bitrate": 500},
{"height": 360, "bitrate": 250},
]
def transcode_stream_name(camera: str, height: int) -> str:
"""Return the go2rtc name of a camera's transcoded stream at one height."""
return f"{camera}_transcode_{height}p"
def is_transcode_stream_name(camera: str, name: str) -> bool:
"""Return whether a stream name follows this camera's transcoded naming."""
return re.fullmatch(rf"{re.escape(camera)}_transcode_\d+p", name) is not None
def transcode_stream_source(source: str, height: int, bitrate: int) -> str:
"""Build the go2rtc ffmpeg source that transcodes a stream to H.264.
go2rtc rejects API-registered sources containing spaces, so each ffmpeg
token gets its own #raw= parameter. #audio=copy keeps audio, which any
#video= option would otherwise drop.
"""
tokens = (
"-b:v",
f"{bitrate}k",
"-maxrate",
f"{bitrate}k",
"-bufsize",
f"{2 * bitrate}k",
)
raw = "".join(f"#raw={token}" for token in tokens)
return f"ffmpeg:{source}#video=h264#height={height}#hardware#audio=copy{raw}"
def transcode_streams(
camera: str, source: str, qualities: list[dict[str, int]]
) -> dict[str, str]:
"""Map each quality's go2rtc stream name to its source."""
return {
transcode_stream_name(camera, quality["height"]): transcode_stream_source(
source, quality["height"], quality["bitrate"]
)
for quality in qualities
}
def default_transcode_source(camera: str, streams: dict[str, str]) -> str | None:
"""Return the first live stream that is not itself transcoded."""
return next(
(
name
for name in streams.values()
if not is_transcode_stream_name(camera, name)
),
None,
)
def raw_transcode_streams(config: dict[str, Any]) -> dict[str, str]:
"""Build transcoded go2rtc streams for every enabled camera in a raw config.
go2rtc's config is written before FrigateConfig validation runs, so this
resolves defaults the same way the validator does.
"""
streams: dict[str, str] = {}
for camera, camera_config in (config.get("cameras") or {}).items():
live = (camera_config or {}).get("live") or {}
transcode = live.get("transcode") or {}
if not transcode.get("enabled"):
continue
source = transcode.get("source") or default_transcode_source(
camera, live.get("streams") or {camera: camera}
)
if source is None:
continue
qualities = transcode.get("qualities")
if qualities is None:
qualities = DEFAULT_TRANSCODE_QUALITIES
# malformed entries fail Frigate's validation; skipping them here keeps
# go2rtc from crash-looping alongside it
valid = [
quality
for quality in (qualities if isinstance(qualities, list) else [])
if isinstance(quality, dict)
and "height" in quality
and "bitrate" in quality
]
streams.update(transcode_streams(camera, source, valid))
return streams
def generated_transcode_streams(config: "FrigateConfig") -> dict[str, str]:
"""Build transcoded go2rtc streams for every enabled camera."""
streams: dict[str, str] = {}
for camera in config.cameras.values():
transcode = camera.live.transcode
if transcode.enabled and transcode.source:
streams.update(
transcode_streams(
camera.name,
transcode.source,
[quality.model_dump() for quality in transcode.qualities],
)
)
return streams
def _go2rtc_streams_call(method: str, name: str, params: dict[str, str]) -> bool:
try:
response = requests.request(
method, f"{GO2RTC_API}/streams", params=params, timeout=5
)
except requests.RequestException as e:
logger.error("Failed to sync go2rtc stream %s: %s", name, e)
return False
if not response.ok:
logger.error("Failed to sync go2rtc stream %s: %s", name, response.text)
return False
return True
def sync_transcode_streams(old: dict[str, str], new: dict[str, str]) -> bool:
"""Register added or changed streams with go2rtc and remove dropped ones.
Returns False when any go2rtc call failed.
"""
ok = True
for name, source in new.items():
if old.get(name) != source:
ok = _go2rtc_streams_call("put", name, {"name": name, "src": source}) and ok
for name in old.keys() - new.keys():
ok = _go2rtc_streams_call("delete", name, {"src": name}) and ok
return ok
def measure_stream_bitrate(
name: str, duration: float = 6.0, warmup: float = 1.0
) -> float | None:
"""Measure a go2rtc stream's average kbps from its local MP4 output.
The first warmup seconds after the first byte are skipped so the startup
keyframe burst does not inflate the result. Returns None when the stream
sends nothing.
"""
first = start = now = None
counted = 0
try:
with requests.get(
f"{GO2RTC_API}/stream.mp4",
params={"src": name},
stream=True,
timeout=5,
) as response:
if not response.ok:
return None
for chunk in response.iter_content(chunk_size=16384):
now = time.monotonic()
if first is None:
first = now
if now - first < warmup:
continue
if start is None:
start = now
continue
counted += len(chunk)
if now - start >= duration:
break
except requests.RequestException as e:
logger.warning("Failed to measure go2rtc stream %s: %s", name, e)
return None
if start is None or now is None or now <= start or counted == 0:
return None
return counted * 8 / 1000 / (now - start)
+7 -7
View File
@@ -213,17 +213,19 @@ def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
)
def null_audio_glitches(
intervals: list[CoverageInterval], main_audio: bool, sub_audio: bool
) -> list[CoverageInterval]:
def null_audio_glitches(intervals: list[CoverageInterval]) -> list[CoverageInterval]:
"""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
count, so a truncated video-only segment (a backend restart can flush
a sub-second file before any audio packet landed) poisons every
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] = []
for interval in intervals:
main = interval.main
@@ -449,9 +451,7 @@ def realized_timelines(
assembles each variant's realized spans. Keyframe snapping reads the
per-row index stored at record time, so no file is touched.
"""
main_audio = stream_has_audio(intervals, main=True)
sub_audio = stream_has_audio(intervals, main=False)
nulled = null_audio_glitches(intervals, main_audio, sub_audio)
nulled = null_audio_glitches(intervals)
return {
"auto": realized_timeline(nulled, None),
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+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). */
async openContextMenuOn(cameraName: string): Promise<Locator> {
await this.cameraCard(cameraName).first().click({ button: "right" });
-320
View File
@@ -1,320 +0,0 @@
/**
* Auto live stream selection in the single-camera view.
*
* go2rtc is mocked per stream. A failing stream answers MSE codec
* negotiation with go2rtc's "codecs not matched" error, the one ladder
* trigger Chromium reproduces without real media. A healthy stream
* answers negotiation and then sends nothing, so it waits quietly.
*/
import type { Locator, Page } from "@playwright/test";
import { test, expect } from "../fixtures/frigate-test";
import { LivePage } from "../pages/live.page";
// the mocked profile is admin, so useUserPersistence keys are namespaced
const STREAM_KEY = "front_door-stream:admin";
const CODEC_ERROR = "mse: streams: codecs not matched: H265 => H264";
const CONFIG = {
go2rtc: {
streams: {
front_door: ["rtsp://127.0.0.1:8554/front_door"],
front_door_sub: ["rtsp://127.0.0.1:8554/front_door_sub"],
backyard: ["rtsp://127.0.0.1:8554/backyard"],
},
webrtc: { candidates: [], ice_servers: [] },
},
cameras: {
front_door: {
live: { streams: { Sub: "front_door_sub" } },
},
},
};
test.use({ expectedErrors: [/MSE error 'mse-codec'/] });
async function writeIdb(page: Page, entries: Record<string, unknown>) {
await page.evaluate(async (data) => {
await new Promise<void>((resolve, reject) => {
const request = indexedDB.open("keyval-store", 1);
request.onupgradeneeded = () =>
request.result.createObjectStore("keyval");
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const tx = request.result.transaction("keyval", "readwrite");
const store = tx.objectStore("keyval");
Object.entries(data).forEach(([key, value]) => store.put(value, key));
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
};
});
}, entries);
}
async function readIdb(page: Page, key: string) {
return page.evaluate(async (target) => {
return new Promise((resolve, reject) => {
const request = indexedDB.open("keyval-store", 1);
request.onupgradeneeded = () =>
request.result.createObjectStore("keyval");
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const tx = request.result.transaction("keyval", "readonly");
const get = tx.objectStore("keyval").get(target);
get.onsuccess = () => resolve(get.result ?? null);
get.onerror = () => reject(get.error);
};
});
}, key);
}
// records the src of every MSE socket in open order
async function mockGo2rtc(page: Page, failing: string[] = []) {
const opened: string[] = [];
await page.routeWebSocket("**/live/mse/api/ws**", (ws) => {
const src = new URL(ws.url()).searchParams.get("src") ?? "";
opened.push(src);
ws.onMessage((message) => {
const msg = JSON.parse(message.toString());
if (msg.type !== "mse") {
return;
}
ws.send(
JSON.stringify(
failing.includes(src)
? { type: "error", value: CODEC_ERROR }
: { type: "mse", value: 'video/mp4; codecs="avc1.640029"' },
),
);
});
});
await page.route("**/api/go2rtc/streams/**", (route) =>
route.fulfill({
json: {
producers: [{ medias: ["video, recvonly, H264"] }],
consumers: [],
},
}),
);
return opened;
}
async function openDesktopSettings(page: Page) {
const live = new LivePage(page, true);
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
await page.locator("button:has(svg)").last().click();
const menu = page.locator('[role="menu"], [data-radix-menu-content]').first();
await expect(menu).toBeVisible({ timeout: 3_000 });
return menu;
}
function streamTrigger(menu: Locator) {
return menu
.locator('div:has(> label[for="streaming-method"]) [role="combobox"]')
.first();
}
test.describe("Auto live stream @mobile", () => {
test("a codec error on the main stream moves Auto to the sub stream", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ config: CONFIG });
const opened = await mockGo2rtc(frigateApp.page, ["front_door"]);
await frigateApp.goto("/#front_door");
await expect
.poll(() => opened, { timeout: 10_000 })
.toContain("front_door_sub");
expect(opened[0]).toBe("front_door");
});
});
test.describe("Transcoded live stream @mobile", () => {
test("a pinned transcoded stream plays over MSE", async ({ frigateApp }) => {
// go2rtc.streams lists only yaml streams; transcoded ones are generated
await frigateApp.installDefaults({
config: {
...CONFIG,
cameras: {
front_door: {
live: {
streams: {
Sub: "front_door_sub",
"720p": "front_door_transcode_720p",
},
transcode: {
enabled: true,
source: "front_door",
qualities: [{ height: 720, bitrate: 1200 }],
},
},
},
},
},
});
const opened = await mockGo2rtc(frigateApp.page);
await frigateApp.goto("/");
await writeIdb(frigateApp.page, {
[STREAM_KEY]: "front_door_transcode_720p",
});
await frigateApp.goto("/#front_door");
await expect
.poll(() => opened, { timeout: 10_000 })
.toContain("front_door_transcode_720p");
});
});
test.describe("Auto live stream selector", () => {
test.beforeEach(({ frigateApp }) => {
test.skip(frigateApp.isMobile, "Desktop dropdown only");
});
test("no saved stream opens Auto on the first stream", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ config: CONFIG });
const opened = await mockGo2rtc(frigateApp.page);
await frigateApp.goto("/#front_door");
await expect.poll(() => opened[0], { timeout: 10_000 }).toBe("front_door");
const menu = await openDesktopSettings(frigateApp.page);
await expect(streamTrigger(menu)).toHaveText("Auto · front_door");
});
test("a codec error shows the codec reason", async ({ frigateApp }) => {
await frigateApp.installDefaults({ config: CONFIG });
const opened = await mockGo2rtc(frigateApp.page, ["front_door"]);
await frigateApp.goto("/#front_door");
await expect
.poll(() => opened, { timeout: 10_000 })
.toContain("front_door_sub");
const menu = await openDesktopSettings(frigateApp.page);
await expect(streamTrigger(menu)).toHaveText("Auto · Sub");
await expect(
menu.getByText(/because this browser can't play a higher-quality stream/),
).toBeVisible();
});
test("trying the highest quality retries the top stream", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ config: CONFIG });
const opened = await mockGo2rtc(frigateApp.page, ["front_door"]);
await frigateApp.goto("/#front_door");
await expect
.poll(() => opened, { timeout: 10_000 })
.toContain("front_door_sub");
const menu = await openDesktopSettings(frigateApp.page);
const beforeRetry = opened.length;
await menu.getByRole("button", { name: "Try highest quality" }).click();
await expect
.poll(() => opened.slice(beforeRetry), { timeout: 10_000 })
.toContain("front_door");
});
test("a pinned main takes the legacy fallback instead of stepping down", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ config: CONFIG });
const opened = await mockGo2rtc(frigateApp.page, ["front_door"]);
await frigateApp.goto("/");
await writeIdb(frigateApp.page, { [STREAM_KEY]: "front_door" });
await frigateApp.goto("/#front_door");
const menu = await openDesktopSettings(frigateApp.page);
await expect(
menu.getByText(/Live view is in low-bandwidth mode/),
).toBeVisible({ timeout: 10_000 });
expect(opened).not.toContain("front_door_sub");
});
test("selecting Auto clears the saved stream", async ({ frigateApp }) => {
await frigateApp.installDefaults({ config: CONFIG });
await mockGo2rtc(frigateApp.page);
await frigateApp.goto("/");
await writeIdb(frigateApp.page, { [STREAM_KEY]: "front_door_sub" });
await frigateApp.goto("/#front_door");
const menu = await openDesktopSettings(frigateApp.page);
await expect(streamTrigger(menu)).toHaveText("Sub");
await streamTrigger(menu).click();
await frigateApp.page.getByRole("option", { name: /^Auto/ }).click();
await expect.poll(() => readIdb(frigateApp.page, STREAM_KEY)).toBeNull();
});
test("a stale pin falls back to Auto", async ({ frigateApp }) => {
await frigateApp.installDefaults({ config: CONFIG });
const opened = await mockGo2rtc(frigateApp.page);
await frigateApp.goto("/");
await writeIdb(frigateApp.page, { [STREAM_KEY]: "removed_stream" });
await frigateApp.goto("/#front_door");
await expect.poll(() => readIdb(frigateApp.page, STREAM_KEY)).toBeNull();
await expect.poll(() => opened[0], { timeout: 10_000 }).toBe("front_door");
});
test("a single-stream camera offers no Auto", async ({ frigateApp }) => {
await frigateApp.installDefaults({ config: CONFIG });
await mockGo2rtc(frigateApp.page);
await frigateApp.goto("/#backyard");
const menu = await openDesktopSettings(frigateApp.page);
await streamTrigger(menu).click();
await expect(
frigateApp.page.getByRole("option", { name: "backyard" }),
).toBeVisible();
await expect(
frigateApp.page.getByRole("option", { name: /^Auto/ }),
).toHaveCount(0);
});
test("Reset stream at the floor returns to the top stream", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ config: CONFIG });
const opened = await mockGo2rtc(frigateApp.page, [
"front_door",
"front_door_sub",
]);
await frigateApp.goto("/#front_door");
await expect
.poll(() => opened, { timeout: 10_000 })
.toContain("front_door_sub");
expect(opened[0]).toBe("front_door");
const menu = await openDesktopSettings(frigateApp.page);
await expect(streamTrigger(menu)).toHaveText("Auto · Low bandwidth");
await expect(
menu.getByText(/Playing the low-bandwidth feed/),
).toBeVisible();
const beforeReset = opened.length;
await menu.getByRole("button", { name: "Reset stream" }).click();
// leaving the floor changes the stream and the mode in one render,
// which must still open each stream exactly once
await expect
.poll(() => opened.slice(beforeReset), { timeout: 10_000 })
.toEqual(["front_door", "front_door_sub"]);
});
});
@@ -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);
});
});
@@ -1,254 +0,0 @@
/**
* Live playback stream order and transcoded streams -- MEDIUM tier.
*
* The stream list order is the Auto ladder, so the form saves it with
* replace_paths. Transcoded streams are generated from live.transcode and
* appear in the list as soon as the switch flips.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
import { configFactory } from "../../fixtures/mock-data/config";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_SCHEMA = JSON.parse(
readFileSync(
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
"utf-8",
),
);
const GO2RTC_STREAMS = {
front_door_main: ["rtsp://user:pass@192.168.0.20:554/main"],
front_door_sub: ["rtsp://user:pass@192.168.0.20:554/sub"],
};
const TRANSCODE = {
enabled: false,
qualities: [
{ height: 720, bitrate: 1200 },
{ height: 480, bitrate: 500 },
],
};
const SETTINGS_URL = "/settings?page=cameraLivePlayback&camera=front_door";
type RouteOptions = {
go2rtcStreams?: Record<string, string[]>;
streams?: Record<string, string>;
bitrates?: Record<string, number>;
};
async function installRoutes(page: Page, options: RouteOptions = {}) {
const go2rtcStreams = options.go2rtcStreams ?? GO2RTC_STREAMS;
const streams = options.streams ?? {
Sub: "front_door_sub",
Main: "front_door_main",
};
const live = { streams, transcode: TRANSCODE };
const config = configFactory({
go2rtc: { streams: go2rtcStreams },
cameras: { front_door: { live } },
});
// configFactory's deepMerge adds override keys onto the base fixture's
// streams map instead of replacing it (they share no key names here), so
// pin the streams map to exactly what this test wants.
config.cameras.front_door.live.streams = streams;
let lastSaved: Record<string, unknown> | null = null;
await page.route("**/api/config/schema.json", (route) =>
route.fulfill({ json: CONFIG_SCHEMA }),
);
await page.route("**/api/config", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({ json: config });
}
return route.fulfill({ json: { success: true } });
});
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({
json: {
go2rtc: { streams: go2rtcStreams },
cameras: { front_door: { live } },
},
}),
);
await page.route("**/api/config/set", async (route) => {
lastSaved = route.request().postDataJSON();
await route.fulfill({ json: { success: true, go2rtc_synced: true } });
});
await page.route("**/api/go2rtc/streams/*/bitrate", (route) => {
const name = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").slice(-2)[0],
);
const kbps = options.bitrates?.[name];
return kbps === undefined
? route.fulfill({ status: 502, json: { success: false } })
: route.fulfill({ json: { success: true, kbps } });
});
return { saved: () => lastSaved };
}
function streamNames(page: Page) {
return page
.getByRole("textbox", { name: "Stream name" })
.evaluateAll((inputs) =>
inputs.map((input) => (input as HTMLInputElement).value),
);
}
async function dragRow(page: Page, from: number, to: number) {
const handles = page.getByRole("button", { name: "Drag to reorder" });
const start = await handles.nth(from).boundingBox();
const end = await handles.nth(to).boundingBox();
if (!start || !end) throw new Error("drag handle not visible");
await page.mouse.move(start.x + start.width / 2, start.y + start.height / 2);
await page.mouse.down();
await page.mouse.move(
end.x + end.width / 2,
end.y + end.height / 2 + (to > from ? 12 : -12),
{ steps: 15 },
);
await page.mouse.up();
}
test.describe("live stream order and transcoded streams @medium", () => {
test.skip(({ frigateApp }) => frigateApp.isMobile, "desktop drag");
test("dragging a stream saves the new order", async ({ frigateApp }) => {
const capture = await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
await expect
.poll(() => streamNames(frigateApp.page))
.toEqual(["Sub", "Main"]);
await dragRow(frigateApp.page, 1, 0);
await expect
.poll(() => streamNames(frigateApp.page))
.toEqual(["Main", "Sub"]);
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect.poll(() => capture.saved()).not.toBeNull();
const saved = capture.saved() as {
replace_paths: string[];
config_data: {
cameras: { front_door: { live: { streams: Record<string, string> } } };
};
};
expect(saved.replace_paths).toEqual(["cameras.front_door.live.streams"]);
expect(
Object.keys(saved.config_data.cameras.front_door.live.streams),
).toEqual(["Main", "Sub"]);
});
test("enabling transcoding saves the shown source", async ({
frigateApp,
}) => {
const capture = await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
await frigateApp.page
.getByRole("switch", { name: "Enable transcoded streams" })
.click();
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect.poll(() => capture.saved()).not.toBeNull();
const saved = capture.saved() as {
config_data: {
cameras: {
front_door: { live: { transcode: Record<string, unknown> } };
};
};
};
// pinned, so dragging another stream to the top can't change it
expect(saved.config_data.cameras.front_door.live.transcode).toMatchObject({
enabled: true,
source: "front_door_sub",
});
});
test("the transcode switch adds and removes transcoded rows", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
const toggle = frigateApp.page.getByRole("switch", {
name: "Enable transcoded streams",
});
const save = frigateApp.page.getByRole("button", { name: "Save" });
await toggle.click();
await expect
.poll(() => streamNames(frigateApp.page))
.toEqual(["Sub", "Main", "720p", "480p"]);
await expect(
frigateApp.page.getByText("Transcoded", { exact: true }),
).toHaveCount(2);
await expect(save).toBeEnabled();
await toggle.click();
await expect
.poll(() => streamNames(frigateApp.page))
.toEqual(["Sub", "Main"]);
await expect(save).toBeDisabled();
});
test("auto order sorts measured and set bitrates", async ({ frigateApp }) => {
await installRoutes(frigateApp.page, {
bitrates: { front_door_main: 4000, front_door_sub: 800 },
});
await frigateApp.goto(SETTINGS_URL);
await frigateApp.page
.getByRole("switch", { name: "Enable transcoded streams" })
.click();
await frigateApp.page.getByRole("button", { name: "Auto order" }).click();
await expect
.poll(() => streamNames(frigateApp.page))
.toEqual(["Main", "720p", "Sub", "480p"]);
await expect(frigateApp.page.getByText("4000 kbps")).toBeVisible();
await expect(frigateApp.page.getByText("1200 kbps (set)")).toBeVisible();
});
test("an unmeasurable stream sorts last", async ({ frigateApp }) => {
await installRoutes(frigateApp.page, {
bitrates: { front_door_main: 4000 },
});
await frigateApp.goto(SETTINGS_URL);
await frigateApp.page.getByRole("button", { name: "Auto order" }).click();
await expect
.poll(() => streamNames(frigateApp.page))
.toEqual(["Main", "Sub"]);
await expect(frigateApp.page.getByText("Couldn't measure")).toBeVisible();
});
test("transcoding needs a go2rtc stream", async ({ frigateApp }) => {
await installRoutes(frigateApp.page, {
go2rtcStreams: {},
streams: { front_door: "front_door" },
});
await frigateApp.goto(SETTINGS_URL);
await expect(
frigateApp.page.getByRole("switch", {
name: "Enable transcoded streams",
}),
).toBeDisabled();
await expect(
frigateApp.page.getByText(/need a go2rtc stream to read from/),
).toBeVisible();
});
});
@@ -217,6 +217,23 @@ test.describe("Detection models settings @high", () => {
).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 ({
frigateApp,
}) => {
@@ -248,15 +265,13 @@ test.describe("Detection models settings @high", () => {
test("a saved Frigate+ model opens on the Frigate+ tab", async ({
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(
frigateApp.page,
[
{
scene: "all",
devices: ["openvino:GPU.0"],
path: "/config/model_cache/abc123",
path: "plus://abc123",
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");
});
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 ({
frigateApp,
}) => {
@@ -13,7 +13,26 @@ import type { Page } from "@playwright/test";
const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout: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: "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);
});
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 }) => {
await frigateApp.goto("/settings?page=uiSettings");
@@ -190,12 +363,15 @@ test.describe("UI settings import/export @medium", () => {
await expect(
frigateApp.page.getByText("UI preferences (2 settings)"),
).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);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
OUTDOOR_LAYOUT,
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
);
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
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 ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
await frigateApp.goto("/settings?page=uiSettings");
// 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, 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);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
OUTDOOR_LAYOUT,
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
);
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
STREAMING_SETTINGS,
@@ -1223,7 +1223,6 @@
"desc": "Frigate можа сам адпраўляць push-апавяшчэнні на вашу прыладу, калі працуе ў браўзеры або ўсталяваны як PWA."
},
"notificationUnavailable": {
"title": "Апавяшчэнні недаступныя",
"desc": "Вэб-push-апавяшчэнні патрабуюць бяспечнага кантэксту (<code>https://…</code>). Гэта абмежаванне браўзера. Каб карыстацца апавяшчэннямі, адкрывайце Frigate па бяспечным пратаколе.",
"descPwa": "У iOS вэб-push-апавяшчэнні даступныя, толькі калі Frigate дададзены на хатні экран. Адкрыйце меню <strong>Абагуліць</strong>, выберыце <strong>На хатні экран</strong>, а потым адкрыйце Frigate праз новы значок, каб зарэгістраваць гэту прыладу для апавяшчэнняў."
},
@@ -1331,7 +1330,6 @@
"triggers": {
"documentTitle": "Трыгеры",
"semanticSearch": {
"title": "Семантычны пошук адключаны",
"desc": "Каб карыстацца трыгерамі, трэба ўключыць семантычны пошук."
},
"management": {
-10
View File
@@ -236,8 +236,6 @@
},
"lastRefreshed": "Апошняе абнаўленне: ",
"stats": {
"ffmpegHighCpuUsage": "{{camera}} моцна нагружае CPU праз FFmpeg ({{ffmpegAvg}}%)",
"detectHighCpuUsage": "{{camera}} моцна нагружае CPU дэтэктаваннем ({{detectAvg}}%)",
"healthy": "Сістэма спраўная",
"reindexingEmbeddings": "Пераіндэксацыя ўбудаванняў (гатова {{processed}}%)",
"cameraIsOffline": "{{camera}} па-за сеткай",
@@ -281,20 +279,12 @@
"notices": {
"title": "Заўвагі",
"empty": "Ваша ўсталёўка Frigate у парадку",
"dismiss": "Схаваць",
"openSettings": "Адкрыць налады",
"openLink": "Адкрыць спасылку",
"noMatches": "Няма заўваг, якія адпавядаюць фільтру",
"dismissedTitle": "Схаваныя",
"noneDismissed": "Няма схаваных заўваг",
"clearDismissed": "Ачысціць схаваныя",
"clearDismissedTitle": "Ачысціць схаваныя заўвагі?",
"clearDismissedDesc": "Усе схаваныя заўвагі будуць выдалены. Праверкі канфігурацыі і плыняў пакажуцца адразу, а іншыя заўвагі вернуцца пры наступным узнікненні.",
"firstSeen_one": "Упершыню {{time}}",
"firstSeen_other": "Упершыню {{time}} · {{count}} разу",
"dismissedAt": "Схавана {{time}}",
"filter": {
"showDismissed": "Паказаць схаваныя",
"severity": "Узровень важнасці",
"error": "Памылка",
"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."
},
"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."
},
"globalSettings": {
@@ -1135,7 +1134,6 @@
"triggers": {
"documentTitle": "Pokretači",
"semanticSearch": {
"title": "Semantička pretraga je onemogućena",
"desc": "Semantička pretraga mora biti omogućena da biste koristili izazivače."
},
"management": {
-2
View File
@@ -211,8 +211,6 @@
},
"lastRefreshed": "Posljednje ažuriranje: ",
"stats": {
"ffmpegHighCpuUsage": "{{camera}} ima visoku upotrebu CPU za FFmpeg ({{ffmpegAvg}}%)",
"detectHighCpuUsage": "{{camera}} ima visoku upotrebu CPU za detekciju ({{detectAvg}}%)",
"healthy": "Sistem je zdrav",
"reindexingEmbeddings": "Ponovno indeksiranje ugrađenih vjerodajnica ({{processed}}% završeno)",
"cameraIsOffline": "{{camera}} je offline",
+9 -6
View File
@@ -119,6 +119,9 @@
"liveFallbackTimeout": {
"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."
},
"naturalAspectLayout": {
"label": "Utilitza relació natural d'aspecte"
}
},
"storedLayouts": {
@@ -455,9 +458,8 @@
},
"title": "Notificacions",
"notificationUnavailable": {
"title": "Notificacions no disponibles",
"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ú <strong>Compartir</strong> , selecciona <strong>Afegir a la pantalla</strong>, i obre Frigate des del nou icona per registrar les notificacions en aquest dispositiu."
"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.",
"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."
},
"unsavedChanges": "Canvis de notificació no desats",
"globalSettings": {
@@ -881,8 +883,7 @@
},
"addTrigger": "Afegir disaprador",
"semanticSearch": {
"desc": "La cerca semàntica ha d'estar activada per a utilitzar els activadors.",
"title": "La cerca semàntica està desactivada"
"desc": "La cerca semàntica ha d'estar activada per a utilitzar els activadors."
},
"wizard": {
"title": "Crea un activador",
@@ -1544,7 +1545,9 @@
"preset-record-generic-audio-aac": "Enregistra (Genèric + Àudio a AAC)",
"preset-record-mjpeg": "Registre - Càmeres MJPEG",
"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"
},
+22 -14
View File
@@ -239,15 +239,15 @@
"reindexingEmbeddings": "Reindexant vectors ({{processed}}% completat)",
"healthy": "El sistema és saludable",
"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)",
"detectIsSlow": "{{detect}} és lent ({{speed}} ms)",
"debugReplayActive": "La sessió de repetició de depuració està activa",
"cameraSkippedDetections": "{{camera}} està ometent la detecció en {{pct}}% de fotogrames",
"systemNotices_one": "{{count}} avís 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": {
"title": "Anàlisi avançada",
@@ -281,24 +281,17 @@
"notices": {
"title": "Avís",
"empty": "La instal·lació de Frigate és saludable",
"dismiss": "Descarta",
"openSettings": "Obre la configuració",
"openLink": "Obre l'enllaç",
"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_other": "{{time}} · {{count}} vegades",
"dismissedAt": "S'ha suprimit {{time}}",
"filter": {
"showDismissed": "Mostra descartat",
"severity": "Severitat",
"warning": "Avís",
"error": "Error",
"info": "Info"
"info": "Info",
"showHidden": "Mostra els ocults"
},
"kinds": {
"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",
"failed_login_one": "Error en l'intent d'inici de sessió de {{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}}",
"streamPrefixRestream": "Flux {{index}} (via go2rtc): {{message}}",
"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": {
"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)."
},
"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í."
},
"cameras": {
+1 -3
View File
@@ -50,9 +50,7 @@
"healthy": "Systém je zdravý",
"reindexingEmbeddings": "Přeindexování vektorů ({{processed}} % dokončeno)",
"detectIsSlow": "{{detect}} je 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}}%)"
"detectIsVerySlow": "{{detect}} je velmi pomalé ({{speed}} ms)"
},
"enrichments": {
"embeddings": {
+67 -1
View File
@@ -346,5 +346,71 @@
"bluegrass": "Bluegrass",
"folk_music": "Folkemusik",
"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",
"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.",
"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": {
"title": "Semantische Suche ist deaktiviert",
"desc": "Semantische Suche muss aktiviert sein um Auslöser nutzen zu können."
},
"wizard": {
-2
View File
@@ -254,8 +254,6 @@
},
"stats": {
"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)",
"detectIsSlow": "{{detect}} ist langsam ({{speed}} ms)",
"detectIsVerySlow": "{{detect}} ist sehr langsam ({{speed}} ms)",
+1
View File
@@ -131,6 +131,7 @@
"close": "Close",
"expand": "Expand",
"collapse": "Collapse",
"clear": "Clear",
"copy": "Copy",
"copiedToClipboard": "Copied to clipboard",
"back": "Back",
+1 -3
View File
@@ -45,9 +45,7 @@
}
},
"decodedFrames": "Decoded Frames:",
"droppedFrameRate": "Dropped Frame Rate:",
"stream": "Stream:",
"streamAuto": "{{stream}} (auto)"
"droppedFrameRate": "Dropped Frame Rate:"
},
"toast": {
"success": {
-24
View File
@@ -238,30 +238,6 @@
"label": "Live stream names",
"description": "Mapping of configured stream names to restream/go2rtc names used for live playback."
},
"transcode": {
"label": "Transcoded streams",
"description": "Lower-quality live streams transcoded on demand by go2rtc.",
"enabled": {
"label": "Enable transcoded streams",
"description": "Add lower-quality live streams that go2rtc transcodes in real time while someone is watching."
},
"source": {
"label": "Source stream",
"description": "go2rtc stream to transcode. Defaults to the first live stream."
},
"qualities": {
"label": "Qualities",
"description": "One transcoded stream is added per quality.",
"height": {
"label": "Height",
"description": "Output height in pixels; width follows the source aspect ratio."
},
"bitrate": {
"label": "Bitrate",
"description": "Target and maximum video bitrate in kbps."
}
}
},
"height": {
"label": "Live height",
"description": "Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height."
-24
View File
@@ -594,30 +594,6 @@
"label": "Live stream names",
"description": "Mapping of configured stream names to restream/go2rtc names used for live playback."
},
"transcode": {
"label": "Transcoded streams",
"description": "Lower-quality live streams transcoded on demand by go2rtc.",
"enabled": {
"label": "Enable transcoded streams",
"description": "Add lower-quality live streams that go2rtc transcodes in real time while someone is watching."
},
"source": {
"label": "Source stream",
"description": "go2rtc stream to transcode. Defaults to the first live stream."
},
"qualities": {
"label": "Qualities",
"description": "One transcoded stream is added per quality.",
"height": {
"label": "Height",
"description": "Output height in pixels; width follows the source aspect ratio."
},
"bitrate": {
"label": "Bitrate",
"description": "Target and maximum video bitrate in kbps."
}
}
},
"height": {
"label": "Live height",
"description": "Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height."
-13
View File
@@ -132,19 +132,6 @@
},
"stream": {
"title": "Stream",
"auto": {
"label": "Auto",
"tips": "Switches streams to match your connection",
"selected": "Auto · {{stream}}",
"floorLabel": "Low bandwidth",
"retry": "Try highest quality",
"reason": {
"bandwidth": "Playing {{stream}} because the connection couldn't keep up with a higher-quality stream",
"codec": "Playing {{stream}} because this browser can't play a higher-quality stream",
"saveData": "Playing {{stream}} because Data Saver is on",
"floor": "Playing the low-bandwidth feed because no stream could keep up"
}
},
"audio": {
"tips": {
"title": "Audio must be output from your camera and configured in go2rtc for this stream."
+14 -25
View File
@@ -167,6 +167,11 @@
"label": "Always Show Camera Names",
"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": {
"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."
@@ -175,12 +180,14 @@
"storedLayouts": {
"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.",
"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": {
"title": "Camera Group Streaming Settings",
"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": {
"title": "Backup & Restore",
@@ -195,6 +202,9 @@
"desc": "Choose what to apply from this file. Frigate will reload when the import finishes.",
"exportedFrom": "Exported {{date}} from Frigate config version {{version}}",
"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)",
"streaming_one": "Streaming settings ({{count}} camera)",
"streaming_other": "Streaming settings ({{count}} cameras)",
@@ -1543,27 +1553,7 @@
"noGo2rtcStreams": "No go2rtc streams configured",
"availableStreams": "Available streams",
"useCustom": "Use \"{{value}}\"",
"addStream": "Add stream",
"reorderHandle": "Drag to reorder",
"transcodedBadge": "Transcoded",
"autoOrder": "Auto order",
"autoOrderTips": "Measure each stream's bitrate and sort highest first",
"measuring": "Measuring…",
"rate": "{{kbps}} kbps",
"rateSet": "{{kbps}} kbps (set)",
"rateFailed": "Couldn't measure"
},
"liveTranscode": {
"title": "Transcoded streams",
"description": "Adds lower-quality streams that go2rtc transcodes in real time while someone is watching.",
"unavailable": "Transcoded streams need a go2rtc stream to read from. Add one under go2rtc streams first.",
"enable": "Enable transcoded streams",
"source": "Source stream",
"height": "Height",
"heightOption": "{{height}}p",
"bitrate": "Bitrate (kbps)",
"addQuality": "Add quality",
"removeQuality": "Remove quality"
"addStream": "Add stream"
},
"timezone": {
"defaultOption": "Use browser timezone"
@@ -1798,7 +1788,6 @@
"toast": {
"success": "Settings saved successfully",
"applied": "Settings applied successfully",
"go2rtcSyncFailed": "Saved, but go2rtc couldn't be updated. Restart Frigate to apply.",
"successRestartRequired": "Settings saved successfully. Restart Frigate to apply your changes.",
"error": "Failed to save settings",
"validationError": "Validation failed: {{message}}",
@@ -2074,7 +2063,7 @@
"unrecognized": "This model is configured for hardware that was not found on this system: {{devices}}",
"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.",
"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": {
"plus": "Frigate+",
+16 -1
View File
@@ -137,6 +137,15 @@
"partial": "Se iniciaron {{successful}} de {{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": {
@@ -182,7 +191,8 @@
"export": "Exportar",
"markAsReviewed": "Marcar como revisado",
"deleteNow": "Eliminar ahora",
"markAsUnreviewed": "Marcar como no revisado"
"markAsUnreviewed": "Marcar como no revisado",
"generateDescription": "Generar descripción"
},
"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.",
@@ -191,6 +201,11 @@
"custom": "Marca de tiempo personalizada",
"button": "Compartir URL de la marca de tiempo",
"shareTitle": "Marca de tiempo de revisión de Frigate: {{camera}}"
},
"genaiDescription": {
"toast": {
"error": "Falló al pedir descripción: {{error}}"
}
}
},
"imagePicker": {
+17 -2
View File
@@ -35,10 +35,25 @@
"submittedFrigatePlus": "Fotograma enviado correctamente a Frigate+"
},
"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.",
"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": {
"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."
},
"scene": {
"label": "Detectar escena"
}
},
"record": {
@@ -599,6 +602,19 @@
"enabled_in_config": {
"label": "Estado de grabación 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": {
@@ -735,6 +751,9 @@
"order": {
"label": "Posición",
"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": {
+20 -1
View File
@@ -711,6 +711,9 @@
"annotation_offset": {
"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."
},
"scene": {
"label": "Detectar escena"
}
},
"record": {
@@ -814,7 +817,20 @@
"label": "Estado de grabación 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": {
"dashboard": {
@@ -1038,6 +1054,9 @@
"idle_heartbeat_fps": {
"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."
},
"modes": {
"label": "Tipos de actividad"
}
},
"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."
},
"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.",
"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": {
"title": "Búsqueda semántica desactivada",
"desc": "Búsqueda semántica debe estar activada para usar Disparadores."
},
"toast": {
-2
View File
@@ -254,8 +254,6 @@
"averageInf": "Tiempo promedio de inferencia"
},
"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",
"reindexingEmbeddings": "Reindexando empotrados ({{processed}}% completado)",
"detectIsSlow": "{{detect}} es lento ({{speed}} ms)",
+34 -3
View File
@@ -21,7 +21,12 @@
"lowBandwidthMode": "Väikese ribalaiusega režiim",
"twoWayTalk": {
"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": {
"enable": "Lülita kaamera heli sisse",
@@ -112,10 +117,36 @@
"title": "Voogedastus",
"lowBandwidth": {
"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": {
"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",
@@ -1102,7 +1102,6 @@
"desc": "Frigate می‌تواند به‌صورت بومی وقتی در مرورگر اجرا می‌شود یا به‌عنوان PWA نصب شده است، اعلان‌های پوش را به دستگاه شما ارسال کند."
},
"notificationUnavailable": {
"title": "اعلان‌ها در دسترس نیستند",
"desc": "اعلان‌های پوش وب نیاز به یک بستر امن دارند ( <code>https://… </code>). این محدودیت مرورگر است. برای استفاده از اعلان‌ها، به‌صورت امن به Frigate دسترسی پیدا کنید.",
"descPwa": "در iOS، اعلان‌های وب پوش فقط زمانی در دسترس هستند که فریگیت روی صفحه اصلی شما نصب شده باشد. منوی <strong>اشتراک‌گذاری</strong> را باز کنید، <strong>افزودن به صفحه اصلی</strong> را انتخاب کنید، سپس فریگیت را از آیکون جدید باز کنید تا این دستگاه برای اعلان‌ها ثبت شود."
},
@@ -1210,7 +1209,6 @@
"triggers": {
"documentTitle": "تریگرها",
"semanticSearch": {
"title": "جستجوی معنایی غیرفعال است",
"desc": "برای استفاده از تریگرها باید جستجوی معنایی فعال باشد."
},
"management": {
-2
View File
@@ -226,8 +226,6 @@
}
},
"stats": {
"ffmpegHighCpuUsage": "{{camera}} استفادهٔ CPU بالایی برای FFmpeg دارد ({{ffmpegAvg}}%)",
"detectHighCpuUsage": "{{camera}} استفادهٔ CPU بالایی برای تشخیص دارد ({{detectAvg}}%)",
"reindexingEmbeddings": "بازتولید نمایهٔ embeddingها ({{processed}}% تکمیل شده)",
"cameraIsOffline": "{{camera}} آفلاین است",
"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."
},
"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."
},
"globalSettings": {
@@ -797,7 +796,6 @@
}
},
"semanticSearch": {
"title": "La recherche sémantique est désactivée",
"desc": "La recherche sémantique doit être activée pour utiliser les déclencheurs."
},
"wizard": {
-2
View File
@@ -227,8 +227,6 @@
},
"lastRefreshed": "Dernier rafraichissement : ",
"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",
"reindexingEmbeddings": "Réindexation des embeddings ({{processed}} % terminée)",
"cameraIsOffline": "{{camera}} est hors ligne",
@@ -531,7 +531,6 @@
"desc": "Frigate יכולה לשלוח התראות דחיפה באופן טבעי למכשיר שלך כאשר הוא פועל בדפדפן או מותקן כ-PWA."
},
"notificationUnavailable": {
"title": "התראות לא זמינות",
"desc": "התראות דחיפה באינטרנט דורשות קישור מאובטח (<code>https://…</code>). זוהי מגבלה של הדפדפן. יש לגשת ל-Frigate בצורה מאובטחת כדי להשתמש בהתראות."
},
"globalSettings": {
@@ -949,7 +948,6 @@
"triggers": {
"documentTitle": "טריגרים",
"semanticSearch": {
"title": "חיפוש סמנטי מושבת",
"desc": "כדי להשתמש בטריגרים, יש להפעיל חיפוש סמנטי."
},
"management": {
-2
View File
@@ -1,8 +1,6 @@
{
"lastRefreshed": "רענון אחרון: ",
"stats": {
"ffmpegHighCpuUsage": "ל-{{camera}} יש צריכת מעבד גבוהה של FFmpeg ({{ffmpegAvg}}%)",
"detectHighCpuUsage": "ל-{{camera}} יש צריכת CPU גבוהה ({{detectAvg}}%)",
"healthy": "המערכת פועלת בצורה תקינה",
"reindexingEmbeddings": "אינדקס מחדש של ההטמעות ({{processed}}% הושלם)",
"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."
},
"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."
},
"globalSettings": {
@@ -946,7 +945,6 @@
"triggers": {
"documentTitle": "Okidači",
"semanticSearch": {
"title": "Semantičko pretraživanje je onemogućeno",
"desc": "Semantičko pretraživanje mora biti omogućeno za korištenje okidača."
},
"management": {
-2
View File
@@ -164,8 +164,6 @@
},
"lastRefreshed": "Zadnje osvježavanje: ",
"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",
"reindexingEmbeddings": "Reindeksiranje embeddings ({{processed}}% završeno)",
"cameraIsOffline": "{{camera}} je offline",
@@ -210,7 +210,6 @@
}
},
"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."
},
"sendTestNotification": "Teszt értesítés küldése",
@@ -711,7 +710,6 @@
}
},
"semanticSearch": {
"title": "Szemantikus keresés le van tiltva",
"desc": "A Triggerek használatához engedélyezni kell a szemantikus keresést."
},
"wizard": {
+1 -3
View File
@@ -201,9 +201,7 @@
"healthy": "A rendszer egészséges",
"cameraIsOffline": "{{camera}} nem elérhető",
"detectIsSlow": "{{detect}} lassú ({{speed}} ms)",
"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}}%)"
"reindexingEmbeddings": "Beágyazások újra indexelése ({{processed}}% kész)"
},
"lastRefreshed": "Utoljára frissítve: "
}
+40 -4
View File
@@ -6,7 +6,12 @@
"lowBandwidthMode": "Mode bandwidth rendah",
"twoWayTalk": {
"enable": "Aktifkan Audio Dua Arah",
"disable": "Nonaktifkan Audio Dua Arah"
"disable": "Nonaktifkan Audio Dua Arah",
"requiresWebRTC": "Percakapan dua arah memerlukan WebRTC, yang tidak tersedia",
"error": {
"microphone": "Percakapan dua arah tidak dapat mengakses mikrofon Anda",
"refused": "Percakapan dua arah gagal dimulai. Lihat konsol browser untuk detail."
}
},
"cameraAudio": {
"enable": "Aktifkan Audio Kamera",
@@ -131,7 +136,8 @@
"unavailable": "Audio tidak tersedia untuk stream ini"
},
"debug": {
"picker": "Pemilihan stream tidak tersedia dalam mode debug. Tampilan debug selalu menggunakan stream yang ditetapkan ke peran detect."
"picker": "Pemilihan stream tidak tersedia dalam mode debug. Tampilan debug selalu menggunakan stream yang ditetapkan ke peran detect.",
"technology": "Pemilihan teknologi streaming tidak tersedia dalam mode debug."
},
"twoWayTalk": {
"tips": "Perangkat Anda harus mendukung fitur ini dan WebRTC harus dikonfigurasi untuk audio dua arah.",
@@ -140,11 +146,36 @@
},
"lowBandwidth": {
"tips": "Tampilan live berada dalam mode bandwidth rendah karena buffering atau kesalahan stream.",
"resetStream": "Atur ulang stream"
"resetStream": "Atur ulang stream",
"force": {
"label": "Paksa mode bandwidth rendah",
"desc": "Selalu putar feed bandwidth rendah bawaan Frigate, bukan stream yang dipilih. Berfungsi pada semua koneksi, tetapi kualitasnya lebih rendah dan tanpa audio."
}
},
"playInBackground": {
"label": "Putar di latar belakang",
"tips": "Aktifkan opsi ini untuk melanjutkan streaming saat pemutar disembunyikan."
},
"mode": "Teknologi Streaming",
"technology": {
"description": "Pilih teknologi streaming yang Anda inginkan. Frigate mungkin tetap beralih ke mode bandwidth rendah jika terjadi kesalahan pemutaran atau jaringan.",
"name": {
"mse": "MSE",
"webrtc": "WebRTC",
"jsmpeg": "JSMpeg"
},
"tips": {
"mse": "Default yang direkomendasikan, kompatibilitas luas dan pemutaran lancar",
"webrtc": "Memerlukan pengaturan tambahan dan tidak didukung pada setiap perangkat"
},
"unavailable": {
"browser": "Browser Anda tidak mendukung WebRTC.",
"not-configured": "WebRTC belum dikonfigurasi. Atur candidates WebRTC go2rtc atau ice_servers.",
"unreachable": "WebRTC tidak dapat terhubung. Pastikan port 8555 dapat dijangkau dan server STUN/TURN telah dikonfigurasi dengan benar.",
"video-codec": "Codec video stream ini tidak didukung oleh WebRTC pada browser ini.",
"audio-codec": "Codec audio stream ini tidak didukung oleh WebRTC. Lakukan transcoding ke Opus atau G.711 menggunakan go2rtc.",
"checking": "Memeriksa ketersediaan WebRTC…"
}
}
},
"cameraSettings": {
@@ -154,7 +185,8 @@
"snapshots": "Snapshot",
"audioDetection": "Deteksi Audio",
"transcription": "Transkripsi Audio",
"autotracking": "Pelacakan Otomatis"
"autotracking": "Pelacakan Otomatis",
"camera": "Kamera"
},
"history": {
"label": "Tampilkan rekaman historis"
@@ -191,5 +223,9 @@
"description": "Grup kamera ini tidak memiliki kamera yang ditetapkan atau diaktifkan.",
"buttonText": "Kelola Grup"
}
},
"camera": {
"turnOn": "Nyalakan Kamera",
"turnOff": "Matikan Kamera"
}
}
+184 -29
View File
@@ -58,7 +58,7 @@
"systemTelemetry": "Telemetri",
"systemBirdseye": "Birdseye",
"systemFfmpeg": "FFmpeg",
"systemDetectorsAndModel": "Detektor dan model",
"systemDetectorsAndModel": "Model deteksi",
"systemMqtt": "MQTT",
"systemGo2rtcStreams": "Stream go2rtc",
"integrationSemanticSearch": "Pencarian semantik",
@@ -68,7 +68,7 @@
"integrationObjectClassification": "Klasifikasi objek",
"integrationAudioTranscription": "Transkripsi audio",
"cameraDetect": "Deteksi objek",
"cameraFfmpeg": "FFmpeg",
"cameraFfmpeg": "Stream (FFmpeg)",
"cameraRecording": "Perekaman",
"cameraSnapshots": "Cuplikan",
"cameraMotion": "Deteksi gerakan",
@@ -150,17 +150,48 @@
"toast": {
"success": {
"clearStoredLayout": "Tata letak tersimpan untuk {{cameraName}} telah dihapus",
"clearStreamingSettings": "Pengaturan streaming untuk semua grup kamera telah dihapus."
"clearStreamingSettings": "Pengaturan streaming untuk semua grup kamera telah dihapus.",
"exportUiSettings": "Pengaturan diekspor",
"importUiSettings": "Pengaturan diimpor"
},
"error": {
"clearStoredLayoutFailed": "Gagal menghapus tata letak tersimpan: {{errorMessage}}",
"clearStreamingSettingsFailed": "Gagal menghapus pengaturan streaming: {{errorMessage}}"
"clearStreamingSettingsFailed": "Gagal menghapus pengaturan streaming: {{errorMessage}}",
"exportUiSettingsFailed": "Gagal mengekspor pengaturan",
"importNothingToApply": "Gagal mengimpor pengaturan: file tidak berisi pengaturan",
"importInvalidJson": "Gagal mengimpor pengaturan: file bukan JSON yang valid",
"importWrongType": "Gagal mengimpor pengaturan: file bukan ekspor pengaturan Frigate",
"importUnsupportedVersion": "Gagal mengimpor pengaturan: file memerlukan versi Frigate yang lebih baru",
"importInvalidSchema": "Gagal mengimpor pengaturan: file tidak valid",
"importUiSettingsFailed": "Gagal mengimpor pengaturan"
}
},
"backupRestore": {
"title": "Pencadangan & Pemulihan",
"transfer": {
"label": "File Pengaturan Perangkat",
"desc": "Tata letak grup kamera, pengaturan streaming, dan preferensi UI disimpan di browser Anda. Ekspor ke file untuk mencadangkannya atau memindahkannya ke perangkat lain.",
"export": "Ekspor Pengaturan",
"import": "Impor Pengaturan"
},
"importDialog": {
"title": "Impor Pengaturan",
"desc": "Pilih yang akan diterapkan dari file ini. Frigate akan dimuat ulang setelah impor selesai.",
"exportedFrom": "Diekspor pada {{date}} dari konfigurasi Frigate versi {{version}}",
"layouts_other": "Tata letak grup kamera ({{count}} grup)",
"streaming_other": "Pengaturan streaming ({{count}} kamera)",
"preferences_other": "Preferensi UI ({{count}} pengaturan)",
"unknownGroups_other": "Grup kamera {{groups}} tidak ada di server ini. Pengaturannya akan disimpan tetapi tidak digunakan.",
"unknownCameras_other": "Kamera {{cameras}} tidak ada di server ini. Pengaturan streamingnya akan diabaikan.",
"confirm": "Impor"
}
}
},
"configMessages": {
"audioTranscription": {
"audioDetectionDisabled": "Deteksi audio tidak diaktifkan untuk kamera ini. Transkripsi audio memerlukan deteksi audio yang aktif."
"audioDetectionDisabled": "Deteksi audio tidak diaktifkan untuk kamera ini. Transkripsi audio memerlukan deteksi audio yang aktif.",
"audioDetectionRuntimeDisabled": "Deteksi audio diaktifkan dalam konfigurasi Anda, tetapi saat ini dinonaktifkan untuk kamera ini, sehingga transkripsi audio tidak akan berjalan. Aktifkan kembali dari tampilan langsung kamera, atau periksa apakah profil aktif menonaktifkannya.",
"genaiProviderSelected": "Penyedia GenAI dipilih, sehingga pengaturan perangkat dan ukuran model diabaikan."
},
"detect": {
"fpsGreaterThanFive": "Mengatur FPS deteksi lebih dari 5 tidak direkomendasikan. Nilai yang lebih tinggi dapat menyebabkan masalah performa dan tidak akan memberikan manfaat apa pun.",
@@ -170,7 +201,9 @@
"maxFramesSet": "Menetapkan frame maksimum akan menimpa perilaku default dan menonaktifkan pelacakan objek diam. Hanya sedikit situasi yang memerlukan ini, gunakan dengan hati-hati.",
"squareResolution": "Resolusi deteksi berbentuk persegi tidak lazim. Lebar dan tinggi deteksi harus sesuai dengan rasio aspek kamera Anda, misalnya 16:9, bukan dimensi model deteksi objek. Rasio aspek yang tidak sesuai dapat meregangkan gambar dan mengurangi akurasi deteksi.",
"resolutionHigh": "Resolusi deteksi ini lebih tinggi daripada yang direkomendasikan dan dapat meningkatkan penggunaan sumber daya tanpa meningkatkan akurasi deteksi. Resolusi deteksi 1080p atau lebih rendah direkomendasikan untuk sebagian besar kamera.",
"globalResolutionMultipleCameras": "Resolusi deteksi global ditetapkan saat beberapa kamera dikonfigurasi. Kecuali semua kamera memiliki resolusi dan rasio aspek yang sama, lebar dan tinggi deteksi sebaiknya ditentukan per kamera agar sesuai dengan rasio aspek asli masing-masing kamera."
"globalResolutionMultipleCameras": "Resolusi deteksi global ditetapkan saat beberapa kamera dikonfigurasi. Kecuali semua kamera memiliki resolusi dan rasio aspek yang sama, lebar dan tinggi deteksi sebaiknya ditentukan per kamera agar sesuai dengan rasio aspek asli masing-masing kamera.",
"runtimeDisabled": "Deteksi objek diaktifkan dalam konfigurasi Anda, tetapi saat ini dinonaktifkan untuk kamera ini. Snapshot, item tinjauan, dan pengayaan seperti pengenalan wajah, pengenalan pelat nomor, serta Generative AI tidak akan berfungsi hingga fitur tersebut diaktifkan kembali dari tampilan langsung kamera, atau hingga profil aktif berhenti menonaktifkannya.",
"sceneWithoutModel": "Tidak ada model deteksi yang dikonfigurasi untuk scene ini, sehingga kamera ini menggunakan model dengan scene “All cameras” sebagai cadangan. Tambahkan model untuk scene ini agar kamera memiliki modelnya sendiri."
},
"faceRecognition": {
"globalDisabled": "Pengayaan pengenalan wajah harus diaktifkan agar fitur pengenalan wajah berfungsi pada kamera ini.",
@@ -179,14 +212,17 @@
},
"lpr": {
"globalDisabled": "Pengayaan pengenalan pelat nomor harus diaktifkan agar fitur LPR berfungsi pada kamera ini.",
"vehicleNotTracked": "Pengenalan pelat nomor memerlukan 'car' atau 'motorcycle' untuk dilacak. Aktifkan 'car' atau 'motorcycle' di Objek untuk kamera ini.",
"vehicleNotTracked": "Pengenalan pelat nomor memerlukan kendaraan untuk dilacak. Aktifkan “car” atau jenis kendaraan lain pada Objects untuk kamera ini.",
"modelSizeLarge": "Model 'large' dioptimalkan untuk pelat nomor multi-baris. Model 'small' memberikan performa lebih baik daripada 'large' dan sebaiknya digunakan kecuali wilayah Anda menggunakan format pelat multi-baris."
},
"review": {
"recordDisabled": "Perekaman dinonaktifkan, item tinjauan tidak akan dibuat.",
"detectDisabled": "Deteksi objek dinonaktifkan. Item tinjauan memerlukan objek yang terdeteksi untuk mengategorikan alert dan deteksi.",
"allNonAlertDetections": "Semua aktivitas non-alert akan disertakan sebagai deteksi.",
"genaiImageSourceRecordingsRecordDisabled": "Sumber gambar disetel ke 'rekaman', tetapi perekaman dinonaktifkan. Frigate akan kembali ke gambar pratinjau."
"genaiImageSourceRecordingsRecordDisabled": "Sumber gambar disetel ke 'rekaman', tetapi perekaman dinonaktifkan. Frigate akan kembali ke gambar pratinjau.",
"recordRuntimeDisabled": "Perekaman diaktifkan dalam konfigurasi Anda, tetapi saat ini dinonaktifkan untuk kamera ini, sehingga item tinjauan tidak akan dibuat. Aktifkan kembali dari tampilan langsung kamera, atau periksa apakah profil aktif menonaktifkannya.",
"detectRuntimeDisabled": "Deteksi objek diaktifkan dalam konfigurasi Anda, tetapi saat ini dinonaktifkan untuk kamera ini. Item tinjauan memerlukan objek yang terdeteksi untuk mengategorikan peringatan dan deteksi. Aktifkan kembali dari tampilan langsung kamera, atau periksa apakah profil aktif menonaktifkannya.",
"genaiImageSourceRecordingsRecordRuntimeDisabled": "Sumber gambar diatur ke “recordings”, tetapi perekaman saat ini dinonaktifkan untuk kamera ini meskipun konfigurasi Anda mengaktifkannya. Frigate akan menggunakan gambar pratinjau sebagai pengganti."
},
"audio": {
"noAudioRole": "Tidak ada stream yang memiliki peran audio yang didefinisikan. Anda harus mengaktifkan peran audio agar deteksi audio berfungsi."
@@ -195,16 +231,30 @@
"genaiNoDescriptionsProvider": "Anda harus mengonfigurasi penyedia GenAI dengan peran 'deskripsi' agar deskripsi dapat dibuat."
},
"record": {
"noRecordRole": "Tidak ada stream yang memiliki peran record yang didefinisikan. Perekaman tidak akan berfungsi."
"noRecordRole": "Tidak ada stream yang memiliki peran record yang didefinisikan. Perekaman tidak akan berfungsi.",
"noRecordSubRole": "Tidak ada stream yang memiliki peran record_sub. Perekaman sub stream tidak akan berfungsi."
},
"snapshots": {
"detectDisabled": "Deteksi objek dinonaktifkan. Cuplikan dihasilkan dari objek yang terlacak dan tidak akan dibuat."
"detectDisabled": "Deteksi objek dinonaktifkan. Cuplikan dihasilkan dari objek yang terlacak dan tidak akan dibuat.",
"detectRuntimeDisabled": "Deteksi objek diaktifkan dalam konfigurasi Anda, tetapi saat ini dinonaktifkan untuk kamera ini, sehingga snapshot tidak akan dibuat. Aktifkan kembali dari tampilan langsung kamera, atau periksa apakah profil aktif menonaktifkannya."
},
"semanticSearch": {
"jinav2SmallModelSize": "Ukuran 'small' dengan model Jina V2 memiliki RAM tinggi dan biaya inferensi. Model 'large' dengan GPU diskrit direkomendasikan."
},
"onvif": {
"autotrackingNoZones": "Pelacakan otomatis memerlukan setidaknya satu zona. Tentukan zona untuk kamera ini di Masker / Zona, lalu tetapkan sebagai zona wajib di bawah."
},
"model": {
"optimizedFor320": "Frigate dioptimalkan untuk model 320x320, yang merupakan pilihan terbaik untuk sebagian besar konfigurasi. Model 640x640 lebih lambat dan hanya membantu dalam skenario tertentu.",
"inputDimensionsNotDetectResolution": "Lebar dan tinggi input model adalah dimensi input model deteksi objek, bukan resolusi deteksi kamera Anda. Nilainya harus sesuai dengan dimensi model yang digunakan—biasanya ukuran persegi seperti 320x320 atau 640x640."
},
"ffmpeg": {
"hwaccelManualNotRecommended": "Argumen akselerasi perangkat keras manual tidak direkomendasikan. Kecuali terdapat kebutuhan khusus, pilih preset yang sesuai dengan perangkat keras Anda.",
"inputsMissingGo2rtcStream": "Input di bawah ini mengarah ke restream go2rtc yang sudah tidak ada. Pilih restream yang tersedia atau masukkan URL kamera secara manual; jika tidak, kamera ini akan gagal terhubung."
},
"birdseye": {
"objectTrackingDetectDisabled": "Birdseye mencakup objek yang dilacak, tetapi deteksi objek dinonaktifkan untuk kamera ini. Kamera tidak akan muncul di Birdseye.",
"objectTrackingDetectRuntimeDisabled": "Birdseye mencakup objek yang dilacak, tetapi deteksi objek saat ini dinonaktifkan untuk kamera ini meskipun konfigurasi Anda mengaktifkannya. Kamera tidak akan muncul di Birdseye hingga deteksi objek diaktifkan kembali dari tampilan langsung kamera, atau hingga profil aktif berhenti menonaktifkannya."
}
},
"button": {
@@ -222,7 +272,10 @@
"heading_other": "Bagian global ini memiliki bidang yang ditimpa di {{count}} kamera.",
"othersField_other": "{{count}} lainnya",
"profilePrefix": "Profil {{profile}}: {{fields}}"
}
},
"overriddenLive": "Ditimpa (Langsung)",
"overriddenLiveTooltip": "Kamera ini berjalan dengan nilai yang berbeda dari nilai yang tersimpan dalam konfigurasi Anda, yang ditampilkan di sini. Tampilan langsung, MQTT, atau profil aktif dapat mengubahnya saat Frigate berjalan.",
"overriddenLiveValue": "Nilai yang berjalan: {{value}}"
},
"menuDot": {
"overrideGlobal": "Bagian ini menimpa konfigurasi global",
@@ -407,7 +460,7 @@
}
},
"step3": {
"description": "Konfigurasikan peran stream dan tambahkan stream tambahan untuk kamera Anda.",
"description": "Konfigurasikan fitur, peran stream, dan tambahkan stream tambahan untuk kamera Anda.",
"streamsTitle": "Stream Kamera",
"addStream": "Tambah Stream",
"addAnotherStream": "Tambah Stream Lain",
@@ -441,11 +494,26 @@
"title": "Peran Stream",
"detect": "Umpan utama untuk deteksi objek.",
"record": "Menyimpan segmen umpan video berdasarkan pengaturan konfigurasi.",
"audio": "Umpan untuk deteksi berbasis audio."
"audio": "Umpan untuk deteksi berbasis audio.",
"record_sub": "Rekaman berkualitas lebih rendah untuk pemutaran adaptif dan retensi lebih lama (biasanya sub stream kamera)."
},
"featuresPopover": {
"title": "Fitur Stream",
"description": "Gunakan restreaming go2rtc untuk mengurangi koneksi ke kamera Anda."
},
"appleCompatibility": {
"title": "Tingkatkan pemutaran pada perangkat Apple",
"description": "Aktifkan jika Anda menonton rekaman melalui Safari atau perangkat iPhone, iPad, atau Mac."
},
"ptz": {
"title": "Aktifkan Kontrol PTZ",
"detectedNote": "Dukungan PTZ telah terdeteksi melalui ONVIF. Jika ini adalah kamera PTZ, mengaktifkan opsi ini memungkinkan Anda mengendalikan fungsi pan/tilt/zoom kamera dari UI.",
"connectionDetails": "Detail koneksi ONVIF",
"host": "Host ONVIF",
"port": "Port ONVIF",
"username": "Nama Pengguna ONVIF",
"password": "Kata Sandi ONVIF",
"hostRequiredWarning": "Host dan port ONVIF diperlukan saat kontrol PTZ diaktifkan."
}
},
"step4": {
@@ -506,7 +574,7 @@
"deleteCameraDialog": {
"title": "Hapus Kamera",
"description": "Menghapus kamera akan menghapus secara permanen semua rekaman, objek terlacak, dan konfigurasi untuk kamera tersebut. Semua stream go2rtc yang terkait dengan kamera ini mungkin masih perlu dihapus secara manual.",
"selectPlaceholder": "Pilih kamera...",
"selectPlaceholder": "Pilih kamera…",
"confirmTitle": "Apakah Anda yakin?",
"confirmWarning": "Menghapus <strong>{{cameraName}}</strong> tidak dapat dibatalkan.",
"deleteExports": "Juga hapus ekspor untuk kamera ini",
@@ -542,7 +610,7 @@
"webuiUrlHelp": "URL untuk membuka web UI kamera langsung dari tampilan Debug. Biarkan kosong untuk menonaktifkan tautan.",
"webuiUrlInvalid": "Harus berupa URL yang valid (misalnya, https://example.com).",
"dashboardLabel": "Tampilkan di dasbor Live",
"dashboardHelp": "Tampilkan kamera ini di dasbor Live.",
"dashboardHelp": "Tampilkan kamera ini pada dasbor tampilan langsung Semua Kamera secara default. Kamera tetap tersedia di semua tempat lain, termasuk grup kamera.",
"reviewLabel": "Tampilkan di Review",
"reviewHelp": "Tampilkan kamera ini di Review, termasuk filter kamera, review gerakan, dan tampilan riwayat."
}
@@ -1089,7 +1157,7 @@
},
"createUser": {
"title": "Buat Pengguna Baru",
"desc": "Tambahkan akun pengguna baru dan tentukan perannya untuk akses ke area UI Frigate.",
"desc": "Tambahkan akun pengguna baru dan tentukan peran untuk akses ke area UI Frigate.",
"usernameOnlyInclude": "Nama pengguna hanya boleh berisi huruf, angka, . atau _",
"confirmPassword": "Harap konfirmasi kata sandi Anda"
},
@@ -1192,9 +1260,8 @@
"desc": "Frigate dapat secara native mengirim notifikasi push ke perangkat Anda saat berjalan di browser atau diinstal sebagai PWA."
},
"notificationUnavailable": {
"title": "Notifikasi Tidak Tersedia",
"desc": "Notifikasi push web memerlukan konteks aman (<code>https://…</code>). Ini adalah batasan browser. Akses Frigate secara aman untuk menggunakan notifikasi.",
"descPwa": "Di iOS, notifikasi push web hanya tersedia jika Frigate dipasang ke Layar Utama Anda. Buka menu <strong>Bagikan</strong>, pilih <strong>Tambahkan ke Layar Utama</strong>, lalu buka Frigate dari ikon baru tersebut untuk mendaftarkan perangkat ini agar menerima notifikasi."
"desc": "Notifikasi push web memerlukan konteks aman ([https://…](https://…)). Ini merupakan batasan browser. Akses Frigate secara aman untuk menggunakan notifikasi.",
"descPwa": "Di iOS, notifikasi push web hanya tersedia jika Frigate dipasang ke Layar Utama. Buka menu Bagikan, pilih Tambahkan ke Layar Utama, lalu buka Frigate dari ikon baru untuk mendaftarkan perangkat ini untuk notifikasi."
},
"globalSettings": {
"title": "Pengaturan Global",
@@ -1300,7 +1367,6 @@
"triggers": {
"documentTitle": "Pemicu",
"semanticSearch": {
"title": "Pencarian Semantik dinonaktifkan",
"desc": "Pencarian Semantik harus diaktifkan untuk menggunakan Pemicu."
},
"management": {
@@ -1458,7 +1524,9 @@
"orphansDeleted": "File Yatim Piatu Dihapus",
"aborted": "Dibatalkan. Penghapusan akan melebihi ambang keamanan.",
"error": "Kesalahan",
"totals": "Total"
"totals": "Total",
"spaceToReclaim": "Ruang yang Dapat Diklaim Kembali",
"spaceReclaimed": "Ruang Berhasil Diklaim Kembali"
},
"event_snapshots": "Cuplikan Objek Terlacak",
"event_thumbnails": "Thumbnail Objek Terlacak",
@@ -1500,11 +1568,23 @@
"keyLabel": "Kunci",
"valueLabel": "Nilai",
"keyPlaceholder": "Kunci baru",
"remove": "Hapus"
"remove": "Hapus",
"providerNameLabel": "Nama penyedia",
"providerNamePlaceholder": "mis., openai",
"variableNameLabel": "Nama variabel",
"variableNamePlaceholder": "mis., MY_VARIABLE",
"loggerNameLabel": "Nama logger",
"loggerNamePlaceholder": "mis., frigate.record",
"keyPatternError": "Gunakan hanya huruf, angka, tanda hubung, dan garis bawah (tanpa spasi)"
},
"knownPlates": {
"namePlaceholder": "mis., Mobil Istri",
"platePlaceholder": "Nomor pelat atau regex"
"platePlaceholder": "Nomor pelat atau regex",
"assignedTo": "Ditugaskan kepada {{name}}",
"detected": "Pelat nomor terdeteksi",
"noneDetected": "Belum ada pelat nomor yang terdeteksi",
"search": "Cari atau masukkan pelat nomor",
"useCustom": "Gunakan \"{{value}}\""
},
"timezone": {
"defaultOption": "Gunakan zona waktu browser"
@@ -1549,10 +1629,21 @@
"preset-record-mjpeg": "Rekam - Kamera MJPEG",
"preset-record-jpeg": "Rekam - Kamera JPEG",
"preset-record-ubiquiti": "Rekam - Kamera Ubiquiti"
}
},
"sameAsRecord": "Sama seperti argumen keluaran rekaman"
},
"cameraInputs": {
"itemTitle": "Stream {{index}}"
"itemTitle": "Stream {{index}}",
"sourceMode": {
"restream": "Restream (go2rtc)",
"manual": "Jalur input manual",
"go2rtcStreamLabel": "Stream go2rtc",
"go2rtcStreamPlaceholder": "Pilih stream go2rtc",
"noGo2rtcStreams": "Tidak ada stream go2rtc yang dikonfigurasi",
"go2rtcStreamSearch": "Cari stream...",
"availableStreams": "Stream yang tersedia",
"noMatchingStreams": "Tidak ada stream yang sesuai"
}
},
"restartRequiredField": "Perlu mulai ulang",
"restartRequiredFooter": "Konfigurasi berubah - Perlu mulai ulang",
@@ -1624,14 +1715,18 @@
"options": {
"detect": "Deteksi",
"record": "Rekam",
"audio": "Audio"
}
"audio": "Audio",
"record_sub": "Rekam (Sub Stream)"
},
"roleInUse": "Sudah ditetapkan ke stream lain",
"recordSubConflict": "Sebuah stream tidak dapat memiliki peran record dan record_sub sekaligus"
},
"genaiRoles": {
"options": {
"embeddings": "Embedding",
"descriptions": "Deskripsi",
"chat": "Chat"
"chat": "Chat",
"transcribe": "Transkripsi"
}
},
"semanticSearchModel": {
@@ -1689,7 +1784,26 @@
},
"defaultRole": {
"admin": "Admin",
"viewer": "Penampil"
"viewer": "Penampil",
"none": "Tidak ada (tolak akses)"
},
"birdseyeModes": {
"summary": "{{count}} dipilih",
"options": {
"continuous": "Kontinu",
"motion": "Gerakan",
"all_objects": "Semua objek",
"alerts": "Peringatan",
"detections": "Deteksi"
}
},
"audioTranscriptionModel": {
"placeholder": "Pilih model…",
"builtIn": "Model Bawaan",
"genaiProviders": "Penyedia GenAI"
},
"audioTranscriptionModelSize": {
"notApplicable": "Tidak berlaku untuk penyedia GenAI"
}
},
"globalConfig": {
@@ -1856,6 +1970,10 @@
"imageSource": {
"recordings": "Rekaman",
"previews": "Pratinjau"
},
"frameMode": {
"frames": "Frame",
"annotated_frames": "Frame beranotasi"
}
},
"logger": {
@@ -1881,5 +1999,42 @@
"modelSize": {
"small": "Kecil",
"large": "Besar"
},
"detectionModels": {
"title": "Model deteksi",
"description": "Konfigurasikan model deteksi objek dan perangkat keras yang digunakan oleh setiap model. Kamera memilih model berdasarkan scene dalam pengaturan deteksi kamera.",
"addModel": "Tambahkan model",
"cameras_other": "{{count}} kamera",
"scene": {
"label": "Scene",
"description": "Lingkungan yang menjadi tujuan model ini. Kamera memilih model dengan menetapkan scene yang sama dalam pengaturan deteksinya, sedangkan model dengan scene “all” digunakan oleh setiap kamera yang tidak menetapkan scene."
},
"scenes": {
"all": "Semua kamera",
"indoor": "Dalam ruangan",
"outdoor": "Luar ruangan",
"indoor_thermal": "Termal dalam ruangan",
"outdoor_thermal": "Termal luar ruangan"
},
"hardware": {
"label": "Perangkat keras",
"placeholder": "Pilih perangkat keras",
"loading": "Mencari perangkat keras deteksi...",
"none": "Tidak ada perangkat keras yang dipilih",
"claimedBy": "digunakan oleh {{scene}}",
"detectorCount": "Detektor",
"countRecommended_other": "Direkomendasikan untuk {{count}} kamera",
"unrecognized": "Model ini dikonfigurasi untuk perangkat keras yang tidak ditemukan pada sistem ini: {{devices}}",
"description": "Perangkat keras tempat model ini menjalankan proses deteksi.",
"detectorCountDescription": "Jumlah proses deteksi yang dijalankan pada perangkat keras ini. Lebih banyak detektor dapat menangani lebih banyak kamera, dengan konsekuensi penggunaan memori perangkat tambahan.",
"unitsDescription": "Setiap unit menjalankan proses deteksinya sendiri. Unit yang sudah digunakan oleh model lain tidak dapat dipilih."
},
"tabs": {
"plus": "Frigate+",
"custom": "Model Kustom"
},
"plusModel": {
"noModelSelected": "Pilih model Frigate+"
}
}
}
-2
View File
@@ -214,8 +214,6 @@
},
"lastRefreshed": "Terakhir diperbarui: ",
"stats": {
"ffmpegHighCpuUsage": "{{camera}} memiliki penggunaan CPU FFmpeg yang tinggi ({{ffmpegAvg}}%)",
"detectHighCpuUsage": "{{camera}} memiliki penggunaan CPU deteksi yang tinggi ({{detectAvg}}%)",
"healthy": "Sistem sehat",
"reindexingEmbeddings": "Mengindeks ulang embedding ({{processed}}% selesai)",
"cameraIsOffline": "{{camera}} sedang offline",
@@ -661,7 +661,6 @@
"registerDevice": "Registra questo dispositivo",
"notificationUnavailable": {
"desc": "Le notifiche push web richiedono un contesto sicuro (<code>https://...</code>). Questa è una limitazione del browser. Accedi a Frigate in modo sicuro per utilizzare le notifiche.",
"title": "Notifiche non disponibili",
"descPwa": "Su iOS, le notifiche push web sono disponibili solo se Frigate è installato sulla schermata Home. Apri il menu <strong>Condividi</strong>, scegli <strong>Aggiungi alla schermata Home</strong>, quindi apri Frigate dalla nuova icona per registrare questo dispositivo per le notifiche."
},
"deviceSpecific": "Impostazioni specifiche del dispositivo",
@@ -842,7 +841,6 @@
}
},
"semanticSearch": {
"title": "La ricerca semantica è disabilitata",
"desc": "Per utilizzare gli attivatori, è necessario abilitare la ricerca semantica."
},
"wizard": {
-2
View File
@@ -222,8 +222,6 @@
}
},
"stats": {
"detectHighCpuUsage": "{{camera}} ha un utilizzo elevato della CPU con il rilevamento ({{detectAvg}}%)",
"ffmpegHighCpuUsage": "{{camera}} ha un elevato utilizzo della CPU con FFmpeg ({{ffmpegAvg}}%)",
"healthy": "Il sistema è integro",
"reindexingEmbeddings": "Reindicizzazione degli incorporamenti (completata al {{processed}}%)",
"cameraIsOffline": "{{camera}} è disconnessa",
+1 -3
View File
@@ -691,7 +691,6 @@
"desc": "Frigate はブラウザで実行中、または PWA としてインストールされている場合に、端末へネイティブのプッシュ通知を送信できます。"
},
"notificationUnavailable": {
"title": "通知は利用できません",
"desc": "Web プッシュ通知にはセキュアコンテキスト(<code>https://…</code>)が必要です。これはブラウザの制限です。通知を利用するには、セキュアに Frigate へアクセスしてください。",
"descPwa": "iOSでは、Frigateをホーム画面に追加した場合にのみ、Webプッシュ通知を利用できます。<strong>共有</strong>メニューを開き、<strong>ホーム画面に追加</strong>を選択してから、新しいアイコンからFrigateを起動し、このデバイスを通知対象として登録してください。"
},
@@ -896,8 +895,7 @@
}
},
"semanticSearch": {
"desc": "トリガーを使用するにはセマンティック検索を有効にする必要があります。",
"title": "セマンティック検索が無効です"
"desc": "トリガーを使用するにはセマンティック検索を有効にする必要があります。"
},
"wizard": {
"title": "トリガーを作成",
-2
View File
@@ -227,8 +227,6 @@
},
"lastRefreshed": "最終更新: ",
"stats": {
"ffmpegHighCpuUsage": "{{camera}} の FFmpeg の CPU 使用率が高い({{ffmpegAvg}}%)",
"detectHighCpuUsage": "{{camera}} の検知の CPU 使用率が高い({{detectAvg}}%)",
"healthy": "システムは正常です",
"reindexingEmbeddings": "埋め込みを再インデックス中({{processed}}% 完了)",
"cameraIsOffline": "{{camera}} はオフラインです",
@@ -1143,7 +1143,6 @@
"desc": "Frigate អាចផ្ញើការជូនដំណឹង Push ទៅឧបករណ៍របស់អ្នកដោយផ្ទាល់ នៅពេលដែលវាកំពុងដំណើរការក្នុងកម្មវិធីរុករក ឬបានដំឡើងជា PWA។"
},
"notificationUnavailable": {
"title": "ការជូនដំណឹងមិនអាចប្រើបាន",
"desc": "ការជូនដំណឹង Push លើវេប ត្រូវការបរិយាកាសសុវត្ថិភាព (<code>https://…</code>)។ នេះគឺជាការកំណត់របស់កម្មវិធីរុករក។ ចូលប្រើ Frigate ដោយសុវត្ថិភាព ដើម្បីប្រើការជូនដំណឹង។",
"descPwa": "នៅលើ iOS ការជូនដំណឹង Push លើវេប អាចប្រើបានតែនៅពេលដែល Frigate ត្រូវបានដំឡើងទៅកាន់អេក្រង់ដើមរបស់អ្នក។ បើកម៉ឺនុយ <strong>ចែករំលែក</strong> ជ្រើសរើស <strong>បន្ថែមទៅអេក្រង់ដើម</strong> បន្ទាប់មកបើក Frigate ពីរូបតំណាងថ្មី ដើម្បីចុះឈ្មោះឧបករណ៍នេះសម្រាប់ការជូនដំណឹង។"
},
@@ -1251,7 +1250,6 @@
"triggers": {
"documentTitle": "កេះ",
"semanticSearch": {
"title": "ការស្វែងរកតាមអត្ថន័យត្រូវបានបិទ",
"desc": "ការស្វែងរកតាមអត្ថន័យត្រូវតែបើក ដើម្បីប្រើកេះ។"
},
"management": {
@@ -67,7 +67,6 @@
},
"documentTitle": "트리거",
"semanticSearch": {
"title": "시맨틱 검색이 비활성화됨",
"desc": "트리거 기능을 사용하려면 시맨틱 검색이 활성화되어 있어야 합니다."
},
"management": {
@@ -1308,7 +1307,6 @@
"desc": "Frigate이 브라우저에서 실행 중이거나 PWA로 설치된 경우 기기로 푸시 알림을 기본 전송할 수 있습니다."
},
"notificationUnavailable": {
"title": "알림을 사용할 수 없음",
"desc": "웹 푸시 알림을 사용하려면 보안 컨텍스트(<code>https://…</code>)가 필요합니다. 이는 브라우저 자체의 제한 사항입니다. 알림을 사용하려면 Frigate에 보안 연결로 접속하세요.",
"descPwa": "iOS의 경우 Frigate이 홈 화면에 추가되어 있어야만 웹 푸시 알림을 사용할 수 있습니다. <strong>공유</strong> 메뉴를 열고 <strong>홈 화면에 추가</strong>를 선택한 다음, 새로 생성된 아이콘으로 Frigate을 실행하여 기기를 알림에 등록하세요."
},
-10
View File
@@ -236,8 +236,6 @@
},
"lastRefreshed": "마지막 갱신: ",
"stats": {
"ffmpegHighCpuUsage": "{{camera}} 카메라의 FFmpeg CPU 사용량이 높습니다 ({{ffmpegAvg}}%)",
"detectHighCpuUsage": "{{camera}} 카메라의 감지(detect) CPU 사용량이 높습니다 ({{detectAvg}}%)",
"healthy": "시스템 상태가 양호합니다",
"reindexingEmbeddings": "임베딩 재색인 중 ({{processed}}% 완료)",
"cameraIsOffline": "{{camera}} 카메라가 오프라인 상태입니다",
@@ -281,20 +279,12 @@
"notices": {
"title": "알림",
"empty": "Frigate 이 잘 설치되어있습니다",
"dismiss": "무시",
"openSettings": "설정 열기",
"openLink": "링크 열기",
"noMatches": "필터에 맞는 알림이 없습니다",
"dismissedTitle": "무시됨",
"noneDismissed": "무시된 알림 없음",
"clearDismissed": "무시됨 지우기",
"clearDismissedTitle": "무시된 알림을 지울까요?",
"clearDismissedDesc": "무시된 모든 알림이 삭제됩니다. 설정 및 스트림 점검 항목은 즉시 다시 표시되며, 기타 알림은 다음에 다시 발생할 때 표시됩니다.",
"firstSeen_one": "최초 감지: {{time}}",
"firstSeen_other": "최초 감지: {{time}} · {{count}}회",
"dismissedAt": "무시됨: {{time}}",
"filter": {
"showDismissed": "무시된 항목 표시",
"severity": "심각도",
"error": "오류",
"warning": "경고",
@@ -605,7 +605,6 @@
}
},
"semanticSearch": {
"title": "Semantic Paieška išjungta",
"desc": "Norint naudoti Trigerius Semantic Paieška privalo būti įjungta."
}
},
@@ -616,7 +615,6 @@
"desc": "Frigate praneįimai sukurti veikti su push pranešimais į įrenginį kai naršoma per naršyklę arba įdiegta kaip PWA."
},
"notificationUnavailable": {
"title": "Pranešimai Negalimi",
"desc": "Web push pranešimai reikalauja saugios aplinkos (<code>https://...</code>). Tai yra naršyklės apribojimai. Atsidarykit Frigate saugiu kanalu kad galėtumėte naudotis pranešimais."
},
"globalSettings": {

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