Compare commits

..
Author SHA1 Message Date
Josh Hawkins ea268533e5 keep valid listen values and refuse to start nginx when the ports collide 2026-10-18 13:59:38 -05:00
Josh Hawkins 777b33f849 fall back to default listen ports when either is invalid or they collide 2026-10-18 13:49:43 -05:00
Josh Hawkins f08e4aac69 always create go2rtc.yaml after removing it and don't let opt_in_out stop frigate
The go2rtc else-branch was only reachable when a regular file was planted between rm and the test, and then loaded that file as go2rtc's config. create_config.py now runs unconditionally and its exclusive create fails closed. opt_in_out runs after s6-svc -O, so a failure there was terminal under errexit.
2026-10-18 12:35:45 -05:00
Josh Hawkins 2d632f4b70 accept hostnames, wildcards, and padded values in networking.listen
The stricter parser broke localhost:5000, *:5000, and values with surrounding whitespace that the old int() parsing accepted. parse_listen_address now strips the value and accepts RFC 1123 hostnames and *, still rebuilding the rendered value from the parsed parts. The ListenConfig validator runs in before mode, because pydantic coerced true to port 1 while get_nginx_settings.py rejected it and fell back to 5000, so the auth internal port and nginx disagreed.
2026-10-18 12:35:45 -05:00
Josh Hawkins 2f3338f950 tighten comments 2026-10-18 12:19:43 -05:00
Josh Hawkins f650ccb3b5 validate networking.listen values before rendering them into nginx config
ListenConfig and get_nginx_settings.py only checked that the text after the last colon parsed as an int, and tempio rendered the raw string into the listen directive, so a value like "5000; location /x/ { alias /; } #:5000" injected nginx directives. Both now go through parse_listen_address, which accepts only a port, IPv4:port, or [IPv6]:port with a 1 to 65535 port and rebuilds the value from the parsed parts. get_nginx_settings.py falls back to the default port on an invalid value so nginx still serves the safe-mode UI.
2026-10-18 12:15:30 -05:00
Josh Hawkins 19d3047fb9 run root python helpers in isolated mode and keep HOME=/config for non-root containers
The previous commit only set HOME=/config when dropping from root, so a container started with --user lost it. The other root-run helpers in the go2rtc, nginx, and certsync scripts now use python3 -I too, so they never load user site-packages or PYTHON* variables.
2026-10-18 12:11:59 -05:00
Josh Hawkins 5d81da8c00 don't follow a planted symlink when writing /dev/shm/go2rtc.yaml
After the first start the go2rtc user owned /dev/shm/go2rtc.yaml and could swap it for a dangling symlink. The run script's -f test was false for a dangling link, so the link survived, and root's create_config.py then created the link target and the chown handed it to go2rtc. The run script now removes any existing entry, create_config.py creates the file with O_EXCL and O_NOFOLLOW at mode 0640, and the chown uses -h.
2026-10-18 12:07:17 -05:00
Josh Hawkins 72183983b0 keep /config out of the root environment in the frigate run script
The script exported HOME=/config and prepended /config/.local/lib to LD_LIBRARY_PATH while still root, then ran python3, ffmpeg, grep, and opt_in_out as root. The runtime user owns /config, so it could plant a .pth file in the user site-packages or a library on the loader path and get code run as root on the next start. Root helpers now run with root's environment (and python3 -I), and HOME, LD_LIBRARY_PATH, and PATH reach only the dropped process through env on the s6-setuidgid line. opt_in_out runs as the runtime user, so the openvino marker no longer gets written into /config by root.
2026-10-18 12:06:35 -05:00
Josh HawkinsandGitHub 275e18403e Miscellaneous fixes (#24704)
* remove unreachable FileNotFoundError catches around frame_manager.get

* clear the end time cache entry when a segment is moved

The successful move returned before the end_time_cache pop at the bottom of move_segment, so every kept segment left its entry, keyframes list included, in the cache for the life of the process. Removing the cache file through drop_segment clears the entry with it.

* drop the cache file when the recording destination already exists

When the destination mp4 already existed, move_segment returned without removing the cache file, so the next pass probed and resolved the same segment again, forever. The leftover file is now dropped along with its end time cache entry, the same as the exception path.

* pace restarts of exited record and audio ffmpeg processes

The watchdog loop runs every second, and an exited record or audio ffmpeg was restarted on every pass with no backoff. A camera that refuses connections, such as one rebooting or rejecting credentials, got a reconnect attempt and a dump of ffmpeg's logs every second until it came back. The restart is now gated on can_restart like the stale segment and detect paths.

* don't send a stray newline in the certsync tls probe

The probe piped echo into openssl s_client, which sent the newline to nginx as a request after the handshake, so nginx logged an empty "" 400 line every 60 seconds on any install with TLS enabled. Reading stdin from /dev/null closes the connection right after the handshake. The fingerprint and the failure logging are unchanged.

* require a record expire_interval of at least 1

An expire_interval of 0 or less made the cleanup loop iterate over an empty range, so the cleanup thread exited on startup and expired recordings, temp clips and previews were never removed.

* remove the transcription temp wav when whisper raises

The temp wav was only removed after a successful transcribe call, so every failed transcription left a file in the cache directory.

* don't kill the ipc responder when a described event is gone

handle_update_event_description used Event.get, so a GenAI description that finished after its event was deleted raised DoesNotExist on the inter-process REP thread. That thread died with a reply still owed, and every process waiting on the requestor hung until restart. A missing event is now skipped.

* size the birdseye frame from the snapped canvas shape

The birdseye writer sizes its shared memory with get_canvas_shape, which snaps an unsupported aspect ratio such as 1920x1200 to a supported one, but the tracked object processor read it with the raw config width and height. The sizes never matched, so get returned None on every call and /api/birdseye/latest.jpg returned a 500, which broke the Home Assistant birdseye entity's still image. The reader now uses the same canvas shape, and a missing frame during startup returns an error response instead of raising in cvtColor.

* remove dead disconnect timer from the mse player

The effect that scheduled a delayed disconnect when playback was disabled never ran its body. The connect effect's cleanup already calls onDisconnect, which clears wsRef before the later effect checks it.

* remove partial output when a recording move fails

A remux that failed partway, such as on a full disk, left a partial mp4 at the destination. The next pass saw the destination already existed and dropped the cache file instead of retrying, so the segment was never saved. The failed output is now removed so the next pass retries the move. Tests cover the success, existing destination and failed move paths of move_segment.
2026-10-18 04:50:25 -10:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1a8644fed5 Bump ruff in /docker/main in the pip-main-minor-patch group (#24701)
Bumps the pip-main-minor-patch group in /docker/main with 1 update: [ruff](https://github.com/astral-sh/ruff).


Updates `ruff` from 0.16.10 to 0.17.0
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.16.10...0.17.0)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.17.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: pip-main-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-18 08:36:51 -05:00
[NUT]andGitHub add310d63d HAProxy and OPNSense HAProxy add-on added to the proxy guides. (#24671)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* Include HAProxy example in reverse proxy guide

Added HAProxy configuration example for reverse proxy setup.

* Revise HAProxy config and add OPNSense guide

Updated HAProxy configuration details and added a guide for OPNSense HAProxy add-on setup.

* Revise HAProxy configuration steps in guide

Updated the step-by-step guide for configuring HAProxy on OPNSense, ensuring clarity and consistency in instructions.

* Update note on port 443 request forwarding

Clarified note about forwarding requests on port 443 and provided guidance on creating rules and conditions for multiple websites.

* Fix proxy mode description and bind command typo

Corrected the proxy mode description from Layer 7 to Layer 4 and fixed a typo in the bind command.

* Clarify Layer 4 proxy configuration details

Added note about changing hdr(host) to req.ssl_sni for TCP traffic.
2026-10-16 16:31:31 -06:00
Josh HawkinsandGitHub c177849f57 Tweaks (#24684)
* bound recording snapshot lookups by segment length

Ordering the recording snapshot and Frigate+ lookups by stream_type ahead of start_time broke index order, so SQLite fetched and sorted every older recording for the camera on each request (about 100 ms on a 30 day camera, run on the event loop). A start_time lower bound of the frame time minus MAX_SEGMENT_DURATION keeps the lookup to a few rows.

* bound recording overlap queries by segment length

Clip, export, review and recording deletion, debug replay, motion search, and audio transcription found overlapping recordings with an OR clause that gave SQLite no start_time range, so every call read the camera's whole recording history (30 to 300 ms on a 30 day database). The classification and review description point lookups did the same whenever the timestamp had no recording. A shared recording_overlap helper now adds a start_time lower bound of MAX_SEGMENT_DURATION before the range, which drops no rows because segments are always shorter than that.

* probe webrtc before reporting it as not configured

The WebRTC gate read only the Frigate config, so an install with no go2rtc candidates showed WebRTC as not configured, even though go2rtc fills in default candidates at startup (including the internal one under the HA app) and WebRTC often worked. Unset candidates now run the connectivity probe first, and only a failed probe reports not configured. The not-configured reason shows as a neutral note that names the setting and says WebRTC is optional, rather than as a red error.

* validate trusted_proxies at config load

Malformed trusted_proxies entries were only parsed per request in get_remote_addr, so a typo logged a warning on every proxied request and the entry was skipped, collapsing all clients behind that proxy into one rate-limit bucket. The config now rejects unparseable entries at startup and get_remote_addr no longer needs the fallback. Host bits are accepted (192.168.1.1/24 reads as 192.168.1.0/24) so a common typo doesn't take down safe mode, which copies the auth section verbatim.

* remove path data from thumbnail payloads

The thumbnail data carried a full copy of the object's path, which went out as snapshot.path_data in every events message alongside the top level path_data. Nothing reads the snapshot copy, and the path has no length limit, so it only made events payloads larger.

* hide incompatible frigate+ models behind a toggle

* keep the probe failure detail in the not-configured console message

* use a unique id for each show incompatible models toggle

* pad the bottom of the desktop timeline so zoom buttons don't cover segments

* create the admin row when auth is disabled so notification registrations persist

Installs that never enabled auth had no admin row, so registering for notifications returned 200 but saved nothing. Frigate now creates an admin row with an empty password hash when auth is disabled, and treats that empty hash as first-time setup when auth is enabled later so a password is still generated and printed. Registration returns 404 when no user row was updated, and the success toast only shows after the request succeeds. The Users settings page hides Add User and Reset Password when auth is disabled and notes that accounts only apply once built-in auth is enabled.
2026-10-16 13:49:16 -06:00
El Mehdi TAFIKandGitHub c9f7cc8165 Compare mask coordinates numerically (#24666)
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
* Compare mask coordinates numerically

* Fix zone coordinate comparisons and normalize mask examples

* Fix validation errors for malformed zone coordinates
2026-10-16 07:53:31 -05:00
Alexander GroßandGitHub c42fb58391 Add OCI source label to container images (#24674) 2026-10-16 06:46:47 -06:00
Blake Blackshear 62ca5b25ea Merge remote-tracking branch 'origin/master' into dev
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-10-15 17:54:06 -05:00
Josh HawkinsandGitHub 698b1ce628 bump version in docs (#24673) 2026-10-15 18:52:01 -04:00
16a377233e Translated using Weblate (Slovak)
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
Currently translated at 86.4% (102 of 118 strings)

Translated using Weblate (Slovak)

Currently translated at 98.8% (262 of 265 strings)

Translated using Weblate (Slovak)

Currently translated at 8.6% (4 of 46 strings)

Translated using Weblate (Slovak)

Currently translated at 8.0% (5 of 62 strings)

Translated using Weblate (Slovak)

Currently translated at 9.0% (6 of 66 strings)

Translated using Weblate (Slovak)

Currently translated at 30.7% (8 of 26 strings)

Translated using Weblate (Slovak)

Currently translated at 32.0% (8 of 25 strings)

Translated using Weblate (Slovak)

Currently translated at 1.6% (13 of 771 strings)

Translated using Weblate (Slovak)

Currently translated at 3.6% (19 of 520 strings)

Translated using Weblate (Slovak)

Currently translated at 85.1% (120 of 141 strings)

Translated using Weblate (Slovak)

Currently translated at 17.2% (15 of 87 strings)

Translated using Weblate (Slovak)

Currently translated at 71.0% (91 of 128 strings)

Translated using Weblate (Slovak)

Currently translated at 91.6% (55 of 60 strings)

Translated using Weblate (Slovak)

Currently translated at 78.8% (93 of 118 strings)

Translated using Weblate (Slovak)

Currently translated at 98.4% (261 of 265 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Viliam Frtala <viliam.frtala@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-groups/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/sk/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Groups
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-replay
2026-10-15 08:45:48 -05:00
6ff8bbd014 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Korean)

Currently translated at 100.0% (66 of 66 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (281 of 281 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (1394 of 1394 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (771 of 771 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (520 of 520 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (280 of 280 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (1393 of 1393 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (68 of 68 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (37 of 37 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (265 of 265 strings)

Update translation files

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

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: sinfancy <yujsjs@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/ko/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-10-15 08:45:48 -05:00
b22ef39aad Added translation using Weblate (Georgian)
Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Added translation using Weblate (Georgian)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mirian Koridze <mirian@koridze.com>
2026-10-15 08:45:48 -05:00
7620ea61a8 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Finnish)

Currently translated at 100.0% (46 of 46 strings)

Translated using Weblate (Finnish)

Currently translated at 3.3% (26 of 771 strings)

Translated using Weblate (Finnish)

Currently translated at 1.9% (10 of 520 strings)

Translated using Weblate (Finnish)

Currently translated at 71.6% (53 of 74 strings)

Translated using Weblate (Finnish)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Finnish)

Currently translated at 100.0% (129 of 129 strings)

Translated using Weblate (Finnish)

Currently translated at 100.0% (500 of 500 strings)

Translated using Weblate (Finnish)

Currently translated at 17.0% (24 of 141 strings)

Translated using Weblate (Finnish)

Currently translated at 33.9% (95 of 280 strings)

Translated using Weblate (Finnish)

Currently translated at 16.4% (229 of 1393 strings)

Translated using Weblate (Finnish)

Currently translated at 100.0% (49 of 49 strings)

Translated using Weblate (Finnish)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Finnish)

Currently translated at 38.6% (56 of 145 strings)

Translated using Weblate (Finnish)

Currently translated at 89.7% (61 of 68 strings)

Translated using Weblate (Finnish)

Currently translated at 54.7% (145 of 265 strings)

Translated using Weblate (Finnish)

Currently translated at 90.6% (453 of 500 strings)

Translated using Weblate (Finnish)

Currently translated at 90.6% (453 of 500 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Tobbana <tobbana@gmail.com>
Co-authored-by: Vesa Takomo <vesatakomo@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/fi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/fi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/fi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-filter/fi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/fi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/fi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/fi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/fi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/fi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/fi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/fi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/fi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-search/fi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/fi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/fi/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/components-filter
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-replay
Translation: Frigate NVR/views-search
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-10-15 08:45:48 -05:00
754a4a9888 Translated using Weblate (French)
Currently translated at 100.0% (37 of 37 strings)

Translated using Weblate (French)

Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (French)

Currently translated at 99.2% (263 of 265 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: leo <boutin.leo2001@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/fr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/fr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/fr/
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
2026-10-15 08:45:48 -05:00
58d6454537 Translated using Weblate (Spanish)
Currently translated at 96.3% (743 of 771 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (520 of 520 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (141 of 141 strings)

Translated using Weblate (Spanish)

Currently translated at 98.8% (86 of 87 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Spanish)

Currently translated at 98.3% (116 of 118 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (500 of 500 strings)

Translated using Weblate (Spanish)

Currently translated at 98.3% (116 of 118 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (265 of 265 strings)

Co-authored-by: David Cambra <cambrafontan.david@gmail.com>
Co-authored-by: Drones Aerial <drones.bsas@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: glafe <tazefr@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/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-classificationmodel/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/es/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-exports
2026-10-15 08:45:48 -05:00
9c399fc519 Translated using Weblate (Indonesian)
Currently translated at 18.9% (146 of 771 strings)

Translated using Weblate (Indonesian)

Currently translated at 31.9% (166 of 520 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (265 of 265 strings)

Update translation files

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

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: My-Name-Is-Mac <adhari.mahendra@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/views-settings
2026-10-15 08:45:48 -05:00
48470c4537 Translated using Weblate (Italian)
Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (265 of 265 strings)

Co-authored-by: Angelo Rossi <angelo.rossi@smeup.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/it/
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
2026-10-15 08:45:48 -05:00
8fd57d81fc Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Polish)

Currently translated at 74.0% (208 of 281 strings)

Co-authored-by: Artur <wy66m6xm@anonaddy.me>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/pl/
Translation: Frigate NVR/views-system
2026-10-15 08:45:48 -05:00
99134ed4cd Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Czech)

Currently translated at 100.0% (46 of 46 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (62 of 62 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (66 of 66 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (25 of 25 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (771 of 771 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (520 of 520 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (141 of 141 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (281 of 281 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (1395 of 1395 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (49 of 49 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (6 of 6 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (68 of 68 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (37 of 37 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (74 of 74 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (129 of 129 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (265 of 265 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (500 of 500 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (74 of 74 strings)

Translated using Weblate (Czech)

Currently translated at 99.1% (117 of 118 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (10 of 10 strings)

Translated using Weblate (Czech)

Currently translated at 91.4% (118 of 129 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (265 of 265 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (500 of 500 strings)

Translated using Weblate (Czech)

Currently translated at 92.3% (109 of 118 strings)

Translated using Weblate (Czech)

Currently translated at 98.8% (262 of 265 strings)

Co-authored-by: Ashus <ashus@users.noreply.hosted.weblate.org>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Petr Doležal <pietr_czech@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-auth/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-filter/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-groups/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-recording/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-search/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/cs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/cs/
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-player
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-classificationmodel
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-10-15 08:45:48 -05:00
822c8e2bda Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Catalan)

Currently translated at 100.0% (281 of 281 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1394 of 1394 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (280 of 280 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (278 of 278 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1393 of 1393 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (771 of 771 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (520 of 520 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1391 of 1391 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (37 of 37 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1371 of 1371 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (265 of 265 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: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/ca/
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/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-10-15 08:45:48 -05:00
df92f07a79 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Belarusian)

Currently translated at 100.0% (281 of 281 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (1394 of 1394 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (771 of 771 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (520 of 520 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (37 of 37 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (265 of 265 strings)

Update translation files

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

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Uladz Maltsau <wldyslw@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/be/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-10-15 08:45:48 -05:00
7227929a9d Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Romanian)

Currently translated at 100.0% (1395 of 1395 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (1394 of 1394 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (281 of 281 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (280 of 280 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (771 of 771 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (520 of 520 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (278 of 278 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (1393 of 1393 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (37 of 37 strings)

Update translation files

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

Translated using Weblate (Romanian)

Currently translated at 100.0% (1374 of 1374 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (265 of 265 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: lukasig <lukasig@hotmail.com>
Co-authored-by: lukasig <lukasig@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/ro/
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/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-10-15 08:45:48 -05:00
03ff04311b Translated using Weblate (Estonian)
Currently translated at 100.0% (46 of 46 strings)

Translated using Weblate (Estonian)

Currently translated at 28.7% (400 of 1393 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Estonian)

Currently translated at 46.0% (65 of 141 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Estonian)

Currently translated at 82.8% (414 of 500 strings)

Translated using Weblate (Estonian)

Currently translated at 95.1% (138 of 145 strings)

Translated using Weblate (Estonian)

Currently translated at 28.7% (400 of 1393 strings)

Translated using Weblate (Estonian)

Currently translated at 94.4% (137 of 145 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (Estonian)

Currently translated at 93.7% (136 of 145 strings)

Translated using Weblate (Estonian)

Currently translated at 22.6% (32 of 141 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (37 of 37 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (265 of 265 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/audio/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/et/
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-replay
Translation: Frigate NVR/views-settings
2026-10-15 08:45:48 -05:00
5eb2b6f04d Translated using Weblate (Danish)
Currently translated at 90.0% (450 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-10-15 08:45:48 -05:00
ba5330f8cf Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.7% (761 of 771 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.4% (512 of 520 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.4% (759 of 771 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.0% (510 of 520 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (37 of 37 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (265 of 265 strings)

Update translation files

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

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 99.3% (1365 of 1374 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Paulo Fernando <paulofme01@gmail.com>
Co-authored-by: Ricardo RFPP <ricardo.inteli@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/pt_BR/
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-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/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-10-15 08:45:48 -05:00
7c6c529314 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Turkish)

Currently translated at 100.0% (1395 of 1395 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (1394 of 1394 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (281 of 281 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (280 of 280 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (1393 of 1393 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (771 of 771 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (520 of 520 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (141 of 141 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (1391 of 1391 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (1391 of 1391 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (37 of 37 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (265 of 265 strings)

Update translation files

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

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)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Murat Odabasi <modabas@users.noreply.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/common/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-validation/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/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 - Validation
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-10-15 08:45:48 -05:00
15da89e56c Avoid buffering raw frames during watchdog resets (#24663)
* Don't buffer detect ffmpeg stdout when resetting the capture thread

reset_capture_thread waited for the terminated detect process with
communicate() on the no-frames and stale sub stream paths. communicate()
reads the process's raw frames into memory until it exits, and a stalled
detect process can write hundreds of duplicate frames per second before
it does. Use wait() on every path, as the fps-exceeded path has since
#19110; the timeout and kill still bound the wait.

* Update frigate/video/ffmpeg.py

accurately explain why wait() is safe

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* test(watchdog): remove non-regression child process test

The Python child exits on SIGTERM, so this test also passes with the original communicate() reset. Keep the two mock tests that distinguish the fix and remove the unused imports.

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-10-15 07:19:13 -06:00
Josh HawkinsandGitHub 64b07889af Tweaks and fixes (#24659)
* add last time to active notices

* Stop retrying the review lookup in the explore detail menu when it 404s

* don't let a slow websocket client block publishers

A websocket client that stopped reading its socket (for example a suspended workstation with a Frigate tab open) blocked ws4py's sendall on whatever thread was publishing, including the single inter-process responder thread, so the recording maintainer and stats hung until nginx dropped the client after 5 minutes. Each client now gets its own bounded send queue and writer thread, and a client that overflows the queue is shut down. ws4py writes close and pong frames directly on its shared manager thread, bypassing the queue, so a 10s socket send timeout keeps a stalled client from pinning that thread. The jsmpeg and birdseye outputs share the class, so their unreachable send error handling is removed.

* don't let a lost object enter a zone

When the detector lost an object, the tracker kept it at its last box and every held frame still counted toward the zone's inertia. A car detected in a zone once or twice as it drove out of view could reach an inertia of 8 and be marked as entering, which sent it to GenAI and any other required zones check even though Review never showed the zone. Inertia and loitering still count on frames without a detection, but an object can only newly enter a zone on a frame where it was actually detected.

* add test

* limit retry stop to 404 only

* add docstrings
2026-10-15 06:54:59 -06:00
Josh HawkinsandGitHub cfa08c56b2 Normalize request paths in media auth (#24662)
CI / AMD64 Build (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
* merge repeated slashes in the media auth path check

X-Original-URL carries only the raw request path since #24602, and extract_path parsed it with urlparse, which reads a path starting with // as a host. A restricted role could request another camera's clips or recordings with a leading // and the media check saw a non-media path and allowed it, while nginx merged the slashes and served the file. extract_path now splits the raw URI the same way the go2rtc stream check does and merges repeated slashes as nginx does before matching locations. The split helper moves from auth.py into media_auth.py so both checks share it.

* check the nginx-normalized path in the media auth dot-segment fallback

The dot-segment fallback only checked the raw first segment, so a role restricted to one camera could request `/x/../clips/back_door-1.jpg` and nginx served back_door's clip. The fallback now resolves the path the way nginx does before checking for a media root, and the URI split ends at a literal `#` since nginx ignores everything after it.
2026-10-15 07:02:08 -05:00
Blake BlackshearandClaude Opus 5.5 2b0a69c028 Merge remote-tracking branch 'origin/master' into dev
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
Brings the go2rtc security fixes from #24658 to dev. Dev-specific changes
in the merge:
- frigate/config/env.py stays deleted; the environment_vars protection for
  GO2RTC_ALLOW_ARBITRARY_EXEC moves to apply_config_env_vars in
  frigate/util/env.py, so the value never reaches os.environ
- services.py imports substitute_frigate_vars from frigate.util.env and
  maps UnknownVariableError to the unknown variable message
- go2rtc stream tests patch dev's import-time exec override snapshot and
  its FRIGATE_* variable sources
- test_http_media.py keeps dev's _insert_recording helper; the one added
  on master replaced it and broke the dev tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:30:11 -05:00
a3ca8742b0 Security patches (#24658)
CI / AMD64 Build (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
* update version

* Restrict ffmpeg: go2rtc sources added through the API

go2rtc expands ffmpeg: sources into an exec: command line built from the
source's #key=value parameters, so the echo/expr/exec prefix check on
PUT /go2rtc/streams could be bypassed to run arbitrary ffmpeg arguments.

Sources submitted through the API now only accept the ffmpeg: parameters
the UI and docs use (video, audio, hardware with preset names, plus
numeric width/height/rotate/bitrate/timeout and async). Quotes,
whitespace, #raw, #input, #drawtext and unknown parameters are rejected
unless GO2RTC_ALLOW_ARBITRARY_EXEC is set. Sources defined in the config
file are unchanged.

Fixes GHSA-qjjw-3v66-279f

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Only allow existing streams on proxied go2rtc live paths

nginx proxies /live/mse/api/ws, /live/webrtc/api/ws and /api/go2rtc/webrtc
straight to go2rtc, which creates a new ad hoc stream from any src that is
not an existing stream name and starts the producer it describes. Any
authenticated user, including the built-in viewer role, could use this to
launch arbitrary go2rtc sources without going through the restricted-source
checks on PUT /go2rtc/streams.

The /auth subrequest now denies these paths unless every src names an
existing stream: a camera or its live stream, a go2rtc stream from the
config, or a stream go2rtc reports at runtime. This applies to every role,
and the runtime lookup fails closed when go2rtc cannot be reached.

Fixes GHSA-93xj-hg88-ffg7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ignore GO2RTC_ALLOW_ARBITRARY_EXEC in environment_vars

environment_vars installs every key into the Frigate process environment
at startup, so an admin could enable GO2RTC_ALLOW_ARBITRARY_EXEC from the
config and turn off the restricted go2rtc source checks on the API. The
override is meant to be set by the operator in the container environment
(or the Home Assistant add-on options), so it is now skipped with a
warning when it appears in environment_vars.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Apply go2rtc source restrictions to config/save and config/set

go2rtc.streams written through config/save or config/set were only
filtered at startup, which blocks echo/expr/exec and the map form but not
the ffmpeg: parameters that PUT /go2rtc/streams now rejects. Saving a
stream this way and restarting bypassed the API restriction.

Both endpoints now reject a change that adds a go2rtc stream source that
fails is_restricted_go2rtc_api_source, or a map-form source, unless
GO2RTC_ALLOW_ARBITRARY_EXEC is set. Sources already in the saved config
for the same stream are left alone, so streams configured on disk keep
working and resubmitting the whole file from the config editor succeeds.
config/set restores the previous file when it rejects a change.

Follow-up to GHSA-qjjw-3v66-279f

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Match go2rtc stream-proxy paths the way nginx does

/auth compared the raw $request_uri path against the three go2rtc
stream-proxy paths exactly, while nginx matches its prefix locations
against the normalized URI. Encoded characters, repeated slashes, dot
segments or a suffix on the path skipped the stream check even though
nginx still proxied the request to go2rtc.

The go2rtc stream locations now set a variable that the auth subrequest
forwards as X-Go2rtc-Stream-Proxy (client headers are not forwarded, so it
cannot be spoofed). As a fallback, /auth also normalizes the path like
nginx (percent-decoding, merging slashes, resolving dot segments) and
prefix matches it, and splits the raw URI by hand since urlparse reads a
leading // as a host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Parse config stream changes with Frigate's YAML loader

The go2rtc stream check on config/save and config/set parsed the config
with the safe loader and treated a parse error as an empty config, while
Frigate and the go2rtc startup script use the round-trip loader. Input
that only the round-trip loader accepts, such as a tagged root map, hid
new stream sources from the check.

The check now uses the same round-trip loader and rejects the request
when the new config cannot be parsed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Resolve go2rtc stream placeholders the same way everywhere

The go2rtc startup script expanded stream sources with str.format(),
which also supports attribute and index access, while the config API
check used substitute_frigate_vars and fell back to the raw string when a
placeholder could not be resolved. A placeholder the API check could not
resolve could therefore expand into a restricted source at startup.

The startup script now uses substitute_frigate_vars, like the rest of the
config, and the API check rejects a stream source whose placeholders
cannot be resolved. The startup script's own copy of the FRIGATE_* env
var map is removed; the shared one also honors CREDENTIALS_DIRECTORY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Accept codec sample rates in ffmpeg: go2rtc sources

The ffmpeg: parameter allowlist only accepted plain names for video,
audio and hardware, so the documented HomeKit source with
#audio=opus/16000 was rejected by PUT /go2rtc/streams, config/save and
config/set. Names may now carry a numeric /rate suffix. A rate without a
name is still rejected. All ffmpeg: sources in the docs pass the check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restore the environment after go2rtc exec override tests

Tests that removed or set GO2RTC_ALLOW_ARBITRARY_EXEC did not restore a
value that was already present, so later tests could run with a changed
security setting. They now use patch.dict on os.environ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Revert the go2rtc startup script to str.format substitution

Restores create_config.py to its state on master. The config API check
rejects any stream source whose {FRIGATE_*} placeholders it cannot
resolve with substitute_frigate_vars, and for every source it accepts,
str.format() at startup either produces the same string or fails and
stops go2rtc startup. Changing the startup script is therefore not
needed for the fix, and leaving it alone avoids a conflict with dev,
which already moved this script to the new env helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Explain why a go2rtc stream change was rejected

A stream source with an undefined or malformed {FRIGATE_*} placeholder
was reported as a restricted source, which reads like a security block
when the user made a typo. The config API now reports unknown variables
and invalid placeholders separately from restricted sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:10:05 -04:00
Josh HawkinsandGitHub d3b2af932c bump version in docs (#24654) 2026-10-13 17:07:09 -06:00
Josh HawkinsandGitHub b79e043976 Reduce idle CPU from thread pools (#24652)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* reduce idle cpu from onnxruntime and opencv thread pools

ONNX Runtime's intra-op threads kept spinning for about 1 s after every run, so a CPU enrichment called once a second or more kept most cores busy. On a 16 thread machine, arcface at one call per second used 9.4 s of CPU per call with ~360 ms latency. Enrichment sessions on the CPU execution provider now set `session.intra_op.allow_spinning` to 0, which drops that to 1.9 s with no latency change. Detector sessions and GPU providers are unchanged, since disabling spinning doubled yolov9-t latency on CPU (12 to 23 ms).

The output and embeddings processes now run OpenCV single threaded, like the camera tracking process. A 4 camera birdseye redraw at 1080p drops from 13.7 to 4.0 ms of CPU, and the full frame conversion in the enrichment processors from 23 to 2.7 ms, each adding about 1 ms of wall time.

* test that only cpu enrichment sessions disable ort spinning

* patch out the neural engine in the spinning test
2026-10-13 07:46:55 -06:00
Josh HawkinsandGitHub e61ad52b67 move env helpers out of the config package (#24648)
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
go2rtc's create_config.py imported frigate.config.env, which ran the frigate.config package init and loaded every detector plugin. Since the onnxruntime 1.30 bump, importing onnxruntime takes about 5s on some hosts, so go2rtc started after Frigate's cameras and every ffmpeg process failed to connect to the restream until it came up. The env helpers now live in frigate.util.env, so go2rtc's config script no longer imports onnxruntime.
2026-10-12 14:54:50 -05:00
Josh HawkinsandGitHub 41d9577d72 Fix docs shm calculator (#24649)
CI / AMD64 Build (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
* match the docs shm calculator to the backend's minimum

The calculator left out the room Frigate reserves for 2 cameras added at runtime, counted the per-frame overhead once per camera instead of per frame, and reserved 40mb instead of 50mb for logs and the nginx cache, so a setup sized with it got well under 20 frames and the "SHM size is too small" warning. It now computes the same minimum as calculate_shm_requirements() and also shows the size for the full 50 frame cache.

* size the docs shm calculator for 25 frames

Sizing to the backend's 20 frame minimum could land at 19 frames from float rounding and still show the warning.
2026-10-12 13:51:21 -06:00
Josh HawkinsandGitHub cf67f08913 fix mse buffer trim spinning in a remove loop (#24643)
Subtracting 15s from the buffered end left a floating point remainder, so when Firefox held exactly 15s of buffer the handler called remove() on a near-zero range that removed nothing, and each updateend called it again. The loop pegged a CPU core and kept running after the websocket closed. Trimming now requires more than 1s past the window.
2026-10-12 10:22:02 -06:00
Josh HawkinsandGitHub ff9eb8f184 Keep frame shared memory mapped (#24644)
* keep frame shm open and skip overwritten frames

Every reader closed and reopened a frame's shared memory segment on each frame, so each process paid a page fault on every page of a fresh mapping (about 760 for a 1080p frame) per frame. Readers now keep segments mapped. Capture stamps each slot's frame time in a per-camera `{camera}_frame_times` segment, and `get()` returns None when the slot no longer holds the requested frame, reopening once by name so a mapping left over from a same-size camera recreate heals itself. Capture also reuses a slot when the frame queue is full, since no reader was told about it.

* verify the frame for embeddings object updates

Tracked object events only carried the object's own frame_time, which stays at its last detection when the object isn't detected in a frame, so embeddings couldn't check the slot it read and could crop face or plate data from a frame that had already been overwritten. Events now carry the time of the frame they name, and embeddings passes it to `get()`.

* log when a frame slot was overwritten before it was read

A lapped read and a missing segment ended up as the same debug line at every caller, so there was no way to tell a slow reader from a missing frame.

* bring the FrameManager interface in line with its implementation

The abstract get() still had a timeout_ms signature from an older implementation and frame_times() wasn't declared, though capture and detect are typed against the interface. get() also didn't say how long the array it returns stays valid.

* drop a camera's cached frame mappings when it is removed or recreated

A reader that kept its mappings open stayed on a removed camera's segments, which pinned their shared memory until restart, and after a same-size recreate the ledger reopen only refreshed the slot being read while the other cached slots still returned the old camera's pixels. Readers now drop every cached slot for a camera on its remove update, and get() does the same when a ledger mismatch turns out to be a replaced ledger rather than a lapped slot.

* match a camera's frame names exactly in close_camera

The prefix match on `{camera}_frame` also caught another camera whose name starts with it, so removing `cam` dropped the cached ring of a camera named `cam_frame`.
2026-10-12 10:21:08 -06:00
Josh HawkinsandGitHub bc06b8c753 run opencv single threaded in camera tracking processes (#24640)
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
OpenCV's default thread pool is sized to every core, and its idle threads spin while it works on the small motion frames and detection regions in each camera tracking process. On a 16 thread machine replaying 720p clips at 5 fps, tracking CPU dropped from 27-50 ms to 5-8 ms per frame with identical output and no change in wall time. Frames from ffmpeg are now also read directly into shared memory with readinto(), which skips one copy per frame.
2026-10-11 21:01:58 -06:00
e7164882f5 Return to the requested URL after logging in (#24637)
When an unauthenticated user opens a deep link, carry the requested
path through the login page in a redirect query param and return to it
after a successful login instead of always going to the home page.
Only same-origin targets are honored.


Claude-Session: https://claude.ai/code/session_011CFGjgYURSRTpXBaxp1Dq7

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:59:13 -06:00
Martin WeineltandGitHub 135a95d8f2 Fix various warnings and deprecations (#23641)
* Migrate to fastapi lifespan for startup events

The `on_event` style decorator is deprecated

https://fastapi.tiangolo.com/advanced/events/

* Migrate to pydantic TypeAdapter

https://pydantic.dev/docs/validation/2.12/get-started/migration/#introduction-of-typeadapter

* Replace pytz with native tz-aware objects and zoneinfo library

Supported from Python 3.9, see https://peps.python.org/pep-0615/. Also
https://blog.ganssle.io/articles/2019/11/utcnow.html.

Also provide tzdata as new dependency to supplement systems without local
tzdata information.

* Provide lookup for normalized timezone names

This provides a helper to look up the correct timezone name independent
of the casing of the given timezone name.

* Test timezone naming normalization and error handling
2026-10-11 17:49:33 -05:00
Josh HawkinsandGitHub 7e402d70df Fix environment_vars not reaching ffmpeg processes (#24635)
CI / AMD64 Build (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
* start the forkserver after the config applies environment_vars

The forkserver started as a side effect of creating the multiprocessing Manager at the top of main(), before the config loaded, so every worker process and the ffmpeg it launched inherited the environment from before environment_vars were applied. Variables like LIBVA_DRIVER_NAME set in config.yml never reached ffmpeg. The Manager now uses the fork context, so the forkserver does not start until the first worker process.

* fix test
2026-10-11 13:21:45 -06:00
Josh HawkinsandGitHub 186a730790 use CLIPImageProcessorPil to silence transformers torchvision warning (#24636)
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-10-11 13:21:21 -06:00
Josh HawkinsandGitHub 5c32af3c7f Autotracking improvements and fixes (#24633)
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
* fix autotracking tracker selection and crop before histogram conversion

- Rebuild the norfair trackers when an onvif save changes autotracking enabled_in_config or the PTZ-tracked labels. The trackers were only built at startup, so disabling autotracking or removing person from its track list in the UI made get_tracker pick a tracker that didn't exist, and the KeyError killed the camera process until restart. Saves that leave the selection alone, and runtime MQTT toggles, don't touch tracking.
- get_tracker now returns the default tracker that match_and_update actually feeds. On autotracking cameras it returned the static default for labels without their own tracker, so register missed the norfair object and lost the pre-initialization score history, and deregister pruned the wrong tracker.
- get_histogram crops the box out of each I420 plane before converting to BGR instead of converting the whole frame. It runs per detection per frame on autotracking cameras and now costs about 0.2 ms at any resolution, down from 1 ms at 1080p and 5 ms at 4K.

* don't rebuild trackers for track list edits while autotracking is disabled
2026-10-11 07:02:42 -06:00
Josh HawkinsandGitHub acc84d7658 Fixes (#24631)
* fix classification wizard finding no sample images on large databases

The wizard grouped tracked objects by camera and 6 hour block, oldest first, then kept the first 100. With more than 100 groups that was always the oldest objects, which often have no snapshot or thumbnail left on disk, so no examples were generated. Shuffle the selection before truncating, and keep extracting from the remaining tracked objects until 100 usable images are found.

* add notice and system ui message when a camera isn't using go2rtc

* add docs note about privacy masks

* match index-less device strings to hardware units in the detection models picker

A config with `edgetpu:usb` was reported as hardware that wasn't found, because the picker compared device strings exactly and the probe reports the unit as `edgetpu:usb:0`. A device without an index now resolves to the first unit of that kind, so the hardware dropdown, the unit checkboxes, and the model summary all recognize it.

* don't treat unknown audio as an audio-bearing stream

A recording row with NULL has_audio (ffprobe failed and the cv2 fallback can't report audio) marked the whole stream as audio-bearing, so every confirmed video-only row on it was dropped as a glitch. A stream now counts as audio-bearing only when a row is known to carry audio.

* stage main+sub exports on disk instead of /tmp/cache

A main+sub export stages a full copy of itself before the final file is written. That copy went to /tmp/cache, so a long export outgrew the tmpfs and failed with no space left on device. Staged runs are now written to the exports directory, and startup removes any left behind by a killed export.

* address review feedback

- stage main+sub export runs in a staging subfolder of the exports directory, so media sync can't delete them mid-export and startup cleanup can't remove a finished export with a matching name or fail on an unremovable file
- run object classification example collection off the event loop
- prefer an exact hardware unit match over an index-less one, so an AMD GPU's "onnx" no longer selects the NVIDIA entry
- add tests for the classification fallback and index-less hardware matching

* serialize example collection and clean up exports when staging dir creation fails

- overlapping object example requests could delete each other's images in the shared temp and train directories, so collection now runs under a lock
- a failed makedirs for the staging directory raised past the failed-export cleanup and left the export spinning until restart, so it now fails the run through the normal cleanup path

* revert
2026-10-11 07:01:39 -06:00
Josh HawkinsandGitHub 8e569ff4f9 clarify intel gpu stats requirements for pre-broadwell gpus (#24632)
CI / AMD64 Build (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 i915 driver never publishes per-client usage counters on GPUs older than Broadwell, so Haswell and older users got a warning telling them to upgrade to kernel 6.5 even on 6.12. The warning and docs now state the Broadwell requirement alongside the kernel 6.5 requirement for 12th gen and newer.
2026-10-11 05:57:15 -06:00
Josh HawkinsandGitHub 2cb03f442c Group dependabot updates (#24624)
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
* group dependabot updates and hold known-bad versions

Dependabot opened a separate PR for every bump, refilled the list with security PRs right after each merge, and kept proposing versions known to break Frigate.

- Group minor and patch updates and security updates into one PR per ecosystem, with separate groups for packages that only resolve together
- Move every version hold into ignore rules, each with its reason
- Switch to weekly updates with a 3-day cooldown
- Turn off version updates for docker/tensorrt, since no pull request check builds those images
- Drop target-branch, which kept these options from applying to security updates, and the docker entry, which never opened a PR
- Scan the setup composite action, and bump change-string-case, setup-qemu, setup-buildx and login-action

* group github actions security updates
2026-10-10 15:08:01 -05:00
Josh HawkinsandGitHub 9d9de6263e only show camera group edit button on live grid to admins (#24622)
CI / AMD64 Build (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 pencil button on the desktop live grid opened the camera group edit dialog for any role, so viewers could change settings and then hit an admin-only error on save. The button now renders only for admins, matching the sidebar and mobile entry points.
2026-10-10 11:24:00 -06:00
Josh HawkinsandGitHub c75f7c3241 Reject dot-only usernames (#24611)
* reject usernames made only of dots

A username of "." or ".." passed validation, but browsers resolve those as path segments, so requests to /users/{username} never reached the API and the user could not be deleted or edited from the UI. User creation now rejects dot-only names in both the API and the create user dialog.

* show a specific error for dot-only usernames

Entering "." or ".." in the create user dialog showed the message listing periods as allowed characters, which didn't explain the rejection. The dialog now checks for dot-only names separately and shows its own message.
2026-10-10 08:12:50 -06:00
Josh HawkinsandGitHub 2e92251aa5 save genai debug thumbnails on early requests (#24615)
CI / AMD64 Build (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
debug_save_thumbnails was only honored when a description was sent at the end of a tracked object, so requests triggered by after_significant_updates never wrote their images to genai-requests. The save now lives in a helper that both paths call.
2026-10-10 06:59:52 -06:00
Josh HawkinsandGitHub cc86519603 Lock ZMQ sockets shared across threads (#24607)
CI / AMD64 Build (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
* lock zmq sockets shared across threads

Several zmq sockets were used from more than one thread at a time, which zmq does not allow. In the embeddings process, GenAI review and object description threads shared one requestor, so overlapping calls silently dropped the second write and could eventually leave a thread waiting forever on a reply that had already arrived, after which nothing was saved until restart. The API's embeddings requestor had the same problem, concurrent config publishes could mix the topic of one update with the payload of another, and the webpush config subscribers were drained from every publishing thread, which could abort the process. Each socket now has a lock. The embeddings requestor lock does not wait, so an overlapping call still returns empty right away instead of stalling the API behind a slow reply.

* add test
2026-10-09 13:28:00 -06:00
Josh HawkinsandGitHub 7786da465f Check export thumbnail image access (#24603)
* require export image_path to belong to the exported camera

image_path was only checked for containment under the clips directory, which every camera shares, so a user restricted to one camera could start an export of that camera and have the server copy another camera's image, or a file from an admin-only directory like faces, into the export thumbnail. The path now also has to resolve to the camera being exported, using the same classifier that gates media requests.

* check image_path against the caller's read access instead of the exported camera

Requiring the image to belong to the exported camera rejected custom images that a script saves under the clips directory for its own export pipeline. The path is now accepted whenever the caller's role could read it as media, so admins and unrestricted roles can use any image there and restricted roles are limited to their own cameras.
2026-10-09 07:34:55 -06:00
Josh HawkinsandGitHub e66da62db3 fix untrained classification models not saving images (#24597)
CI / AMD64 Build (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 mypy cleanup in https://github.com/blakeblackshear/frigate/pull/22658 added a check on the tensor details to the top of process_frame for both state and object models. Those are unset until a model is trained, so untrained models returned early and never saved images to Recent Classifications. Remove the check from process_frame, the classify methods already check the tensor details before using them.
2026-10-09 07:09:31 -06:00
Josh HawkinsandGitHub c06a97378c skip malformed webpush subscriptions instead of failing startup (#24604)
Registration only checked that the p256dh and auth keys were non-empty strings, so any authenticated user could store a subscription that WebPusher refuses to construct. WebPushClient built one WebPusher per stored subscription with no error handling during init_dispatcher, so a single bad row raised before the camera processes and API started, and it did so again on every restart because the row was reloaded from the database. Building the pushers now logs and skips a subscription that fails to construct, both at startup and when expired subscriptions are cleaned up.
2026-10-09 07:05:21 -06:00
Josh HawkinsandGitHub 33cb8d987d pass only the request path in X-Original-URL (#24602)
nginx built X-Original-URL from the client's Host header plus the request path, so a Host containing '#' or '?' pushed the real path into the URL fragment or query. The media and go2rtc auth checks then saw an empty path and allowed restricted-role users to read other cameras' clips, recordings, exports and live streams. The header now carries only $request_uri, which is all the auth checks need.
2026-10-09 07:44:53 -05:00
Josh HawkinsandGitHub 2273cf2d50 remove annke firmware workaround for autotracking (#24594)
see https://github.com/blakeblackshear/frigate/discussions/24593
2026-10-08 06:56:19 -06:00
Nicolas MowenandGitHub f929134a1b Revise embedding model details in config.md (#24586)
Updated model descriptions for embeddings in the configuration documentation.
2026-10-07 10:22:47 -05:00
Josh HawkinsandGitHub cb745d0a50 Miscellaneous fixes (0.18) (#24567)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / Assemble and push default build (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
* run recording snapshot ffmpeg off the event loop

The recording snapshot and Frigate+ recording submit endpoints called get_image_from_recording inline, which is a blocking ffmpeg subprocess with no timeout, so every other request stalled until the frame was decoded. Both calls now run in asyncio.to_thread.

* fix camera info dialog overflowing on mobile

* adjust test
2026-10-05 11:26:08 -06:00
Josh HawkinsandGitHub e912d0852b Fix deleting the only camera when the cameras key has a trailing comment (#24542)
CI / AMD64 Build (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-10-02 06:58:57 -06:00
324 changed files with 13150 additions and 1709 deletions
+4 -4
View File
@@ -34,15 +34,15 @@ runs:
run: sudo systemctl start docker
shell: bash
- id: lowercaseRepo
uses: ASzc/change-string-case-action@v5
uses: ASzc/change-string-case-action@v8
with:
string: ${{ github.repository }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to the Container registry
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
+139 -22
View File
@@ -1,40 +1,157 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
# Local composite actions are only scanned when their directory is listed
directories:
- "/"
- "/.github/actions/setup"
schedule:
interval: daily
open-pull-requests-limit: 10
target-branch: dev
- package-ecosystem: "docker"
directory: "/docker"
schedule:
interval: daily
open-pull-requests-limit: 10
target-branch: dev
interval: weekly
cooldown:
default-days: 3
open-pull-requests-limit: 5
groups:
actions:
patterns: ["*"]
actions-security:
applies-to: security-updates
patterns: ["*"]
- package-ecosystem: "pip"
directory: "/docker/main"
schedule:
interval: daily
open-pull-requests-limit: 10
target-branch: dev
interval: weekly
cooldown:
default-days: 3
open-pull-requests-limit: 5
groups:
# Sets that fail pip resolution unless they move together
peewee:
patterns: ["peewee", "peewee-migrate"]
crypto:
patterns: ["cryptography", "joserfc", "pywebpush", "py-vapid"]
pydantic-genai:
patterns: ["pydantic", "google-genai"]
pip-main-minor-patch:
patterns: ["*"]
update-types: ["minor", "patch"]
pip-main-security:
applies-to: security-updates
patterns: ["*"]
ignore:
# numpy 2.5 needs Python 3.12; norfair, tflite_runtime and rknn-toolkit2
# cap numpy below 2
- dependency-name: "numpy"
versions: [">=2"]
# Every OpenCV wheel from 4.12 requires numpy 2
- dependency-name: "opencv-python-headless"
versions: [">=4.12"]
- dependency-name: "opencv-contrib-python"
versions: [">=4.12"]
# aarch64 only. Rockchip rknn-toolkit2 caps protobuf at 4.25.4 and
# TensorFlow 2.21 needs protobuf 6.31 or newer.
- dependency-name: "tensorflow"
versions: [">=2.20"]
# Needs Python 3.12 and numpy 2
- dependency-name: "librosa"
versions: [">=1.0"]
# Needs Python 3.12
- dependency-name: "scipy"
versions: [">=1.18"]
# Newer stubs report false errors against peewee 4
- dependency-name: "types-peewee"
versions: [">=4.1"]
# sdist only and imported by HailoRT; needs a Hailo device to test
- dependency-name: "netifaces"
versions: [">=0.11"]
- package-ecosystem: "pip"
directory: "/docker/tensorrt"
schedule:
interval: daily
open-pull-requests-limit: 10
target-branch: dev
interval: weekly
# No pull request check builds these images, so version updates are off.
# Security updates still open.
open-pull-requests-limit: 0
groups:
pip-trt-security:
applies-to: security-updates
patterns: ["*"]
ignore:
# onnx 1.17 and newer write a newer IR and opset than TensorRT 8.6 on
# JetPack 6 is known to parse
- dependency-name: "onnx"
versions: [">=1.17"]
# 1.27 moved to CUDA 13, and the image ships the CUDA 12.8 wheel set
- dependency-name: "onnxruntime-gpu"
versions: [">=1.27"]
# Hand-matched CUDA 12.8 set (2c9a25e67); move as a block
- dependency-name: "nvidia-*"
# Must match the JetPack CUDA runtime
- dependency-name: "cuda-python"
versions: [">=12.9"]
- package-ecosystem: "npm"
directory: "/web"
schedule:
interval: daily
open-pull-requests-limit: 10
target-branch: dev
interval: weekly
cooldown:
default-days: 3
open-pull-requests-limit: 5
groups:
radix:
patterns: ["@radix-ui/*"]
rjsf:
patterns: ["@rjsf/*"]
web-minor-patch:
patterns: ["*"]
update-types: ["minor", "patch"]
web-security:
applies-to: security-updates
patterns: ["*"]
ignore:
# typescript-eslint peers typescript below 6.1
- dependency-name: "typescript"
versions: [">=6.1"]
# Tailwind 4 is a config rewrite; scrollbar 4 and merge 3 depend on it
- dependency-name: "tailwindcss"
update-types: ["version-update:semver-major"]
- dependency-name: "tailwind-scrollbar"
update-types: ["version-update:semver-major"]
- dependency-name: "tailwind-merge"
update-types: ["version-update:semver-major"]
# Intermittent failure in the UI settings import spec, cause not found
- dependency-name: "zod"
update-types: ["version-update:semver-major"]
# Needs an on-device pinch test before moving
- dependency-name: "react-zoom-pan-pinch"
update-types: ["version-update:semver-major"]
# Build fails until monaco-worker-manager releases its PR 4
- dependency-name: "monaco-editor"
versions: [">=0.55.0"]
# Later versions need Node 22 and extract false keys
- dependency-name: "i18next-cli"
versions: [">1.5.11"]
# 6.11.0 refills the saved model in the GenAI settings form
- dependency-name: "@rjsf/*"
versions: ["6.11.0"]
- package-ecosystem: "npm"
directory: "/docs"
schedule:
interval: daily
interval: weekly
cooldown:
default-days: 3
open-pull-requests-limit: 5
allow:
- dependency-name: "@docusaurus/*"
open-pull-requests-limit: 10
target-branch: dev
groups:
docusaurus:
patterns: ["@docusaurus/*"]
docs-security:
applies-to: security-updates
patterns: ["*"]
ignore:
# A Docusaurus major needs the openapi plugin and theme moved in the
# same commit
- dependency-name: "@docusaurus/*"
update-types: ["version-update:semver-major"]
+1 -1
View File
@@ -586,7 +586,7 @@ jobs:
- arm64_build
steps:
- id: lowercaseRepo
uses: ASzc/change-string-case-action@v6
uses: ASzc/change-string-case-action@v8
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
with:
persist-credentials: false
- id: lowercaseRepo
uses: ASzc/change-string-case-action@v6
uses: ASzc/change-string-case-action@v8
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
+2
View File
@@ -217,6 +217,8 @@ COPY docker/main/rootfs/ /
# Frigate deps (ffmpeg, python, nginx, go2rtc, s6-overlay, etc)
FROM slim-base AS deps
LABEL org.opencontainers.image.source="https://github.com/blakeblackshear/frigate"
ARG TARGETARCH
ARG BASE_IMAGE
+1 -1
View File
@@ -1,4 +1,4 @@
ruff == 0.16.10
ruff == 0.17.0
# types
types-peewee == 4.0.*
+1 -1
View File
@@ -25,7 +25,7 @@ peewee_migrate == 2.3.*
psutil == 7.2.*
pydantic == 2.13.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
pytz == 2026.*
tzdata == 2026.5
pyzmq == 27.2.*
ruamel.yaml == 0.19.*
tzlocal == 5.4.4
@@ -36,8 +36,8 @@ else
lefile="/config/tls/fullchain.pem"
fi
tls_enabled=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .tls.enabled`
listen_external_port=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .listen.external_port`
tls_enabled=`python3 -I /usr/local/nginx/get_nginx_settings.py | jq -r .tls.enabled`
listen_external_port=`python3 -I /usr/local/nginx/get_nginx_settings.py | jq -r .listen.external_port`
while true
do
@@ -61,7 +61,7 @@ do
;;
esac
liveprint=`echo | openssl s_client -showcerts -connect 127.0.0.1:$listen_external_port 2>&1 | openssl x509 -fingerprint 2>&1 | grep -i fingerprint || echo 'failed'`
liveprint=`openssl s_client -showcerts -connect 127.0.0.1:$listen_external_port </dev/null 2>&1 | openssl x509 -fingerprint 2>&1 | grep -i fingerprint || echo 'failed'`
case "$liveprint" in
*Fingerprint*)
@@ -11,30 +11,13 @@ if [[ "$(id -u)" -eq 0 ]]; then
fi
fi
# /root survives s6-setuidgid and breaks cache writes after the drop; set
# before opt_in_out so the opt-out marker lands where the service will look
if [[ "$runs_as_root" -eq 0 ]]; then
export HOME=/config
fi
# detector runtimes installed at first start (pip install --user) live under
# $HOME/.local; the dynamic loader only reads LD_LIBRARY_PATH at exec time
export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+${LD_LIBRARY_PATH}:}${HOME}/.local/lib"
export PATH="${PATH}:${HOME}/.local/bin"
# opt out of openvino telemetry
if [ -e /usr/local/bin/opt_in_out ]; then
/usr/local/bin/opt_in_out --opt_out > /dev/null 2>&1
fi
# Logs should be sent to stdout so that s6 can collect them
# Tell S6-Overlay not to restart this service
s6-svc -O .
# Runs as root with root's HOME and LD_LIBRARY_PATH; -I skips user site-packages
function set_libva_version() {
local ffmpeg_path
ffmpeg_path=$(python3 /usr/local/ffmpeg/get_ffmpeg_path.py)
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
export LIBAVFORMAT_VERSION_MAJOR
}
@@ -42,14 +25,32 @@ function set_libva_version() {
echo "[INFO] Preparing Frigate..."
set_libva_version
# HOME=/root survives s6-setuidgid, so any run that is not root gets
# HOME=/config explicitly. The runtime user owns /config, so these paths go
# only to the service process, never to root. Detector runtimes installed at
# first start (pip install --user) live under $HOME/.local.
run_as=()
runtime_home="${HOME}"
if [[ "$runs_as_root" -eq 0 ]]; then
runtime_home=/config
if [[ "$(id -u)" -eq 0 ]]; then
run_as=(s6-setuidgid frigate)
fi
fi
run_as+=(env
"HOME=${runtime_home}"
"LD_LIBRARY_PATH=${LD_LIBRARY_PATH:+${LD_LIBRARY_PATH}:}${runtime_home}/.local/lib"
"PATH=${PATH}:${runtime_home}/.local/bin")
# opt out of openvino telemetry; the marker lands under the service's HOME
if [ -e /usr/local/bin/opt_in_out ]; then
"${run_as[@]}" /usr/local/bin/opt_in_out --opt_out > /dev/null 2>&1 || true
fi
echo "[INFO] Starting Frigate..."
cd /opt/frigate || echo "[ERROR] Failed to change working directory to /opt/frigate"
# Replace the bash process with the Frigate process, redirecting stderr to stdout
exec 2>&1
if [[ "$(id -u)" -ne 0 || "$runs_as_root" -eq 1 ]]; then
exec python3 -u -m frigate
else
exec s6-setuidgid frigate python3 -u -m frigate
fi
exec "${run_as[@]}" python3 -u -m frigate
@@ -59,38 +59,33 @@ function get_ip_and_port_from_supervisor() {
function set_libva_version() {
local ffmpeg_path
ffmpeg_path=$(python3 /usr/local/ffmpeg/get_ffmpeg_path.py)
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
export LIBAVFORMAT_VERSION_MAJOR
}
set_libva_version
if [[ -f "/dev/shm/go2rtc.yaml" ]]; then
echo "[INFO] Removing stale config from last run..."
rm /dev/shm/go2rtc.yaml
# create_config.py creates the file exclusively and fails if anything
# reappears at this path
rm -f /dev/shm/go2rtc.yaml
echo "[INFO] Preparing new go2rtc config..."
if [[ -n "${SUPERVISOR_TOKEN:-}" ]]; then
# Running as a Home Assistant Add-on, infer the IP address and port
get_ip_and_port_from_supervisor
fi
if [[ ! -f "/dev/shm/go2rtc.yaml" ]]; then
echo "[INFO] Preparing new go2rtc config..."
if [[ -n "${SUPERVISOR_TOKEN:-}" ]]; then
# Running as a Home Assistant Add-on, infer the IP address and port
get_ip_and_port_from_supervisor
fi
python3 /usr/local/go2rtc/create_config.py
else
echo "[WARNING] Unable to remove existing go2rtc config. Changes made to your frigate config file may not be recognized. Please remove the /dev/shm/go2rtc.yaml from your docker host manually."
fi
python3 -I /usr/local/go2rtc/create_config.py
# HomeKit persistence. The helper is symlink-safe; hand off to go2rtc only when dropping.
readonly homekit_config_path="/config/go2rtc_homekit.yml"
if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then
python3 /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}" --chown
chown go2rtc:go2rtc /dev/shm/go2rtc.yaml 2>/dev/null || true
python3 -I /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}" --chown
chown -h go2rtc:go2rtc /dev/shm/go2rtc.yaml 2>/dev/null || true
else
python3 /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}"
python3 -I /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}"
fi
readonly config_path="/config"
@@ -124,7 +124,7 @@ fi
mkdir -p /etc/letsencrypt/www 2>/dev/null || true
# nginx settings are read once; both templates consume them
nginx_settings=$(python3 /usr/local/nginx/get_nginx_settings.py)
nginx_settings=$(python3 -I /usr/local/nginx/get_nginx_settings.py)
# build templates for optional FRIGATE_BASE_PATH environment variable
echo "$nginx_settings" | \
@@ -8,13 +8,13 @@ from typing import Any
from ruamel.yaml import YAML
sys.path.insert(0, "/opt/frigate")
from frigate.config.env import apply_config_env_vars, substitute_frigate_vars
from frigate.const import (
BIRDSEYE_PIPE,
LIBAVFORMAT_VERSION_MAJOR,
)
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
from frigate.util.config import find_config_file, resolve_ffmpeg_path
from frigate.util.env import apply_config_env_vars, substitute_frigate_vars
from frigate.util.live_streams import raw_transcode_streams
from frigate.util.services import (
is_go2rtc_arbitrary_exec_allowed,
@@ -198,9 +198,13 @@ if config.get("birdseye", {}).get("restream", False):
else:
go2rtc_config["streams"] = {"birdseye": ffmpeg_cmd}
# Write go2rtc_config to /dev/shm/go2rtc.yaml
with open("/dev/shm/go2rtc.yaml", "w") as f:
# The run script removes the old file first. Exclusive create never follows a
# link the go2rtc user planted in sticky /dev/shm. The config contains camera
# credentials, so it is not world-readable.
fd = os.open(
"/dev/shm/go2rtc.yaml",
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o640,
)
with os.fdopen(fd, "w") as f:
yaml.dump(go2rtc_config, f)
# config contains camera credentials; do not leave it world-readable
os.chmod("/dev/shm/go2rtc.yaml", 0o640)
@@ -14,7 +14,10 @@ location /auth {
proxy_pass_request_headers off;
# Pass info about the request
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Original-URL $request_uri;
# set by the go2rtc stream locations; client headers are not forwarded, so
# this cannot be spoofed
proxy_set_header X-Go2rtc-Stream-Proxy $go2rtc_stream_proxy;
proxy_set_header X-Server-Port $server_port;
proxy_set_header Content-Length "";
# Pass along auth related info
@@ -219,6 +219,8 @@ http {
# frigate lovelace card uses this path
location /live/mse/api/ws {
# tells /auth to apply the go2rtc stream checks
set $go2rtc_stream_proxy 1;
include auth_request.conf;
limit_except GET {
deny all;
@@ -228,6 +230,8 @@ http {
}
location /live/webrtc/api/ws {
# tells /auth to apply the go2rtc stream checks
set $go2rtc_stream_proxy 1;
include auth_request.conf;
limit_except GET {
deny all;
@@ -248,6 +252,8 @@ http {
# integration uses this to add webrtc candidate
location /api/go2rtc/webrtc {
# tells /auth to apply the go2rtc stream checks
set $go2rtc_stream_proxy 1;
include auth_request.conf;
limit_except POST {
deny all;
@@ -2,7 +2,7 @@
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Original-URL $request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-URI $request_uri;
@@ -8,7 +8,7 @@ from typing import Any
from ruamel.yaml import YAML
sys.path.insert(0, "/opt/frigate")
from frigate.util.config import find_config_file
from frigate.util.config import find_config_file, parse_listen_address
sys.path.remove("/opt/frigate")
@@ -35,20 +35,29 @@ ipv6_config: dict[str, Any] = networking_config.get("ipv6", {})
ipv6_config.setdefault("enabled", False)
listen_config: dict[str, Any] = networking_config.get("listen", {})
listen_config.setdefault("internal", 5000)
listen_config.setdefault("external", 8971)
# handle case where internal port is a string with ip:port
internal_port = listen_config["internal"]
if type(internal_port) is str:
internal_port = int(internal_port.split(":")[-1])
listen_config["internal_port"] = internal_port
# Frigate's own validation runs elsewhere, and a bad value here must not stop nginx
# from serving the safe-mode UI. Valid values are kept so a fallback never widens
# the unauthenticated internal listener beyond the configured address.
for key, default in (("internal", 5000), ("external", 8971)):
try:
value, port = parse_listen_address(listen_config.get(key, default))
except ValueError:
print(
f"[WARN] Invalid networking.listen.{key}, using {default}",
file=sys.stderr,
)
value, port = default, default
# handle case where external port is a string with ip:port
external_port = listen_config["external"]
if type(external_port) is str:
external_port = int(external_port.split(":")[-1])
listen_config["external_port"] = external_port
listen_config[key] = value
listen_config[f"{key}_port"] = port
if listen_config["internal_port"] == listen_config["external_port"]:
print(
"[ERROR] networking.listen.internal and external share a port",
file=sys.stderr,
)
sys.exit(1)
base_path = os.environ.get("FRIGATE_BASE_PATH", "")
@@ -556,7 +556,7 @@ motion:
# Optional: Whether this mask is active (default: true)
enabled: true
# Required: Coordinates polygon for the mask
coordinates: "0.000,0.469,1.000,0.469,1.000,1.000,0.000,1.000"
coordinates: "0.000,0.469,1.0,0.469,1.0,1.0,0.000,1.0"
# Optional: improve contrast (default: shown below)
# Enables dynamic contrast improvement. This should help improve night detections at the cost of making motion detection more sensitive
# for daytime.
+1 -1
View File
@@ -235,7 +235,7 @@ The FeatureList on the [ONVIF Conformant Products Database](https://www.onvif.or
| Amcrest ASH21 | ✅ | ❌ | ONVIF service port: 80 |
| Amcrest IP4M-S2112EW-AI | ✅ | ❌ | FOV relative movement not supported. |
| Amcrest IP5M-1190EW | ✅ | ❌ | ONVIF Port: 80. FOV relative movement not supported. |
| Annke CZ504 | ✅ | ✅ | Annke support provide specific firmware ([V5.7.1 build 250227](https://github.com/pierrepinon/annke_cz504/raw/refs/heads/main/digicap_V5-7-1_build_250227.dav)) to fix issue with ONVIF "TranslationSpaceFov" |
| Annke CZ504 | ✅ | ❌ | |
| Axis Q-6155E | ✅ | ❌ | ONVIF service port: 80; Camera does not support MoveStatus. |
| Ctronics PTZ | ✅ | ❌ | |
| Dahua | ✅ | ✅ | Some low-end Dahuas (lite series, picoo series (commonly), among others) have been reported to not support autotracking. These models usually don't have a four digit model number with chassis prefix and options postfix (e.g. DH-P5AE-PV vs DH-SD49825GB-HNR). |
@@ -136,7 +136,8 @@ ffmpeg:
Frigate reads Intel GPU utilization directly from the kernel's per-client DRM usage counters exposed at `/proc/<pid>/fdinfo/<fd>`. This requires:
- Linux kernel **5.19 or newer** for the `i915` driver, or any release of the `xe` driver.
- An Intel GPU from Broadwell (5th gen) or newer. Older GPUs do not expose these counters on any kernel.
- Linux kernel **5.19 or newer** for the `i915` driver (**6.5 or newer** on 12th gen and newer), or any release of the `xe` driver.
- Frigate running with permission to read other processes' fdinfo. Running as root inside the container (the default) satisfies this; non-root setups may need `CAP_SYS_PTRACE`.
No `intel_gpu_top` binary, `CAP_PERFMON`, privileged mode, or `perf_event_paranoid` tuning is required.
+1 -1
View File
@@ -38,7 +38,7 @@ Frigate [defaults to MSE](#why-does-frigate-prefer-mse-over-webrtc-for-live-view
- The choices are saved **per device, per camera** in your browser's local storage.
- **WebRTC is only selectable when it can actually work for that stream.** When it can't, the option is shown disabled with the reason inline, and a more detailed reason (the failing codecs, or why the connectivity check failed) is logged to your browser's console. Common reasons:
- **Not configured**: no `candidates` or `ice_servers` are set under `go2rtc.webrtc` (see [WebRTC extra configuration](#webrtc-extra-configuration)).
- **Not configured**: no `candidates` or `ice_servers` are set under `go2rtc.webrtc`, and the connectivity check failed with go2rtc's default candidates (see [WebRTC extra configuration](#webrtc-extra-configuration)). This is a setup note rather than an error, since live view works without WebRTC.
- **Could not connect**: e.g. port `8555` isn't reachable, or a STUN/TURN server is misconfigured. Frigate runs a one-time WebRTC connectivity check when the Live view opens; the option may briefly show as "checking" while it runs.
- **Unsupported video codec**: the stream's video codec can't be played over WebRTC in your browser, most commonly H.265/HEVC in Firefox or Edge.
- **Unsupported audio codec**: WebRTC needs opus or G.711 audio, so a stream whose playback audio is only AAC (without an added opus/G.711 track) can't carry audio over WebRTC. See [Audio Support](#audio-support) for how to add one.
+2 -2
View File
@@ -82,13 +82,13 @@ objects:
person_filter1:
friendly_name: "Roof area"
enabled: true
coordinates: "0.000,0.000,1.000,0.000,1.000,0.400,0.000,0.400"
coordinates: "0.000,0.000,1.0,0.000,1.0,0.400,0.000,0.400"
car:
mask:
car_filter1:
friendly_name: "Sidewalk area"
enabled: true
coordinates: "0.000,0.700,1.000,0.700,1.000,1.000,0.000,1.000"
coordinates: "0.000,0.700,1.0,0.700,1.0,1.0,0.000,1.0"
```
</TabItem>
+2 -2
View File
@@ -78,9 +78,9 @@ Users of the Snapcraft build of Docker cannot use storage locations outside your
Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**.
The default shm size of **128MB** is fine for setups with **2 cameras** detecting at **720p**. If Frigate is exiting with "Bus error" messages, it is likely because you have too many high resolution cameras and you need to specify a higher shm size, using [`--shm-size`](https://docs.docker.com/engine/reference/run/#runtime-constraints-on-resources) (or [`service.shm_size`](https://docs.docker.com/compose/compose-file/compose-file-v2/#shm_size) in Docker Compose). If raising the shm size does not help, check your [process and file limits](#process-and-file-limits) as well.
Frigate needs about **210MB** for **2 cameras** detecting at **720p**. If Frigate is exiting with "Bus error" messages, it is likely because you have too many high resolution cameras and you need to specify a higher shm size, using [`--shm-size`](https://docs.docker.com/engine/reference/run/#runtime-constraints-on-resources) (or [`service.shm_size`](https://docs.docker.com/compose/compose-file/compose-file-v2/#shm_size) in Docker Compose). If raising the shm size does not help, check your [process and file limits](#process-and-file-limits) as well.
The Frigate container also stores logs in shm, which can take up to **40MB**, so make sure to take this into account in your math as well.
The Frigate container also stores logs and an nginx cache in shm, which can take up to **50MB**. The calculator below includes this, along with room for 2 cameras added while Frigate is running. Its result holds about 25 frames per camera, a little above the 20 frame minimum Frigate warns below. Frigate keeps up to 50 when there is room, which gives slow processes more time to read each frame.
<ShmCalculator/>
+7 -7
View File
@@ -5,7 +5,7 @@ title: Updating
# Updating Frigate
The current stable version of Frigate is **0.18.0**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.18.0).
The current stable version of Frigate is **0.18.2**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.18.2).
Keeping Frigate up to date ensures you benefit from the latest features, performance improvements, and bug fixes. The update process varies slightly depending on your installation method (Docker, Home Assistant App, etc.). Below are instructions for the most common setups.
@@ -31,21 +31,21 @@ If you’re running Frigate via Docker (recommended method), follow these steps:
2. **Update and Pull the Latest Image**:
- If using Docker Compose:
- Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.18.0` instead of `0.17.1`). For example:
- Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.18.2` instead of `0.17.1`). For example:
```yaml
services:
frigate:
image: ghcr.io/blakeblackshear/frigate:0.18.0
image: ghcr.io/blakeblackshear/frigate:0.18.2
```
- Then pull the image:
```bash
docker pull ghcr.io/blakeblackshear/frigate:0.18.0
docker pull ghcr.io/blakeblackshear/frigate:0.18.2
```
- **Note for `stable` Tag Users**: If your `docker-compose.yml` uses the `stable` tag (e.g., `ghcr.io/blakeblackshear/frigate:stable`), you don’t need to update the tag manually. The `stable` tag always points to the latest stable release after pulling.
- If using `docker run`:
- Pull the image with the appropriate tag (e.g., `0.18.0`, `0.18.0-tensorrt`, or `stable`):
- Pull the image with the appropriate tag (e.g., `0.18.2`, `0.18.2-tensorrt`, or `stable`):
```bash
docker pull ghcr.io/blakeblackshear/frigate:0.18.0
docker pull ghcr.io/blakeblackshear/frigate:0.18.2
```
3. **Start the Container**:
@@ -100,7 +100,7 @@ If an update causes issues:
1. Stop Frigate.
2. Restore your backed-up config file and database.
3. Revert to the previous image version:
- For Docker: Specify an older tag (e.g., `ghcr.io/blakeblackshear/frigate:0.17.1`) in your `docker run` command.
- For Docker: Specify an older tag (e.g., `ghcr.io/blakeblackshear/frigate:0.18.1`) in your `docker run` command.
- For Docker Compose: Edit your `docker-compose.yml`, specify the older version tag (e.g., `ghcr.io/blakeblackshear/frigate:0.16.4`), and re-run `docker compose up -d`.
- For Home Assistant: Restore from the app/addon backup you took before you updated.
4. Verify the old version is running again.
+67
View File
@@ -45,6 +45,8 @@ There are many solutions available to implement reverse proxies and the communit
- [Nginx](#nginx-reverse-proxy)
- [Traefik](#traefik-reverse-proxy)
- [Caddy](#caddy-reverse-proxy)
- [HAProxy](#haproxy)
- [OPNSense HAProxy Add-on](#opnsense-haproxy-add-on)
## Apache2 Reverse Proxy
@@ -218,3 +220,68 @@ frigate.YOUR_DOMAIN.TLD {
}
```
## HAProxy
This example is for a pure haproxy installation on a dedicated host. It assumes you have already combined the certificate parts in de cert file.
The frontend config:
```# Public Service: SSLSecured (Frigate)
frontend SSLSecured
bind XXX.XXX.XXX.XXX:443 ssl crt /etc/haproxy/certs/frigate.cert
mode http
option http-keep-alive
default_backend NVR
# CONDITION: NVR
acl acl_frigate hdr(host) -i nvr.example.video
# RULE: NVR
use_backend NVR if acl_frigate
```
The backend config:
```# Backend Pool: NVR (Frigate)
backend NVR
mode http
balance source
# stickiness
stick-table type ipv4 size 50k expire 30m
stick on src
http-reuse safe
server Frigate XXX.XXX.XXX.XXX:8971
```
The above config is created for a Layer 7 proxy (application layer, in this case http), if streaming the camera feeds are a little spotty or somewhat unstable, you can try to change the *mode* from http to tcp in both the frontend and backend config, which sets up a Layer 4 proxy (proxy by protocol). Make sure you change hdr(host) to req.ssl_sni in the frontend Condition to make the host based condition work on tcp traffic.
## OPNSense HAProxy add-on
This guide is for the OPNSense firewall add-on *os-haproxy* and is configured and tested on version 5.1 of the add-on on OPNSense 26.7.
The guide starts where you are already done setting up the system, *have a certificate added to the certificate store* and *installed the plugin/add-on*.
Step by step plan:
1. Go to Services -> HAProxy, click on Settings;
2. Add a Real Server under *Real Servers*;
3. Fill in the fields *Name or Prefix* and *Description*;
4. set *Type* to **static**;
5. fill in the frigate IP in the *FQDN or IP* field;
6. under *Common Options* fill *Port* with the port number for your Frigate (e.g. 8971) and
7. Click **Save** to store this configuration.
8. Under *Virtual Services* open *Backend Pools*;
9. Add a *Backend Pool*;
10. Fill in the fields *Name or Prefix* and *Description* but use something else then what you used under *Real Servers* otherwise if and when rules and conditions are needed it gets really confusing really fast, the use of them is out of the scope of this guide for a basic functioning haproxy add-on;
11. set *Mode* to **HTTP (Layer 7)**;
12. set *Balancing Algorithm* to **Source-IP Hash**;
13. set *Servers* by choosing the *Real Servers* instance you added in step 2->6;
14. Click **Save** to store this configuration.
15. Under *Virtual Services* open *Public Services*;
16. Add a *Public Service*;
17. Fill in the fields *Name or Prefix* and *Description* but use something else then what you used under *Real Servers* otherwise if and when rules and conditions are needed it gets really confusing really fast, the use of them is out of the scope of this guide for a basic functioning haproxy add-on;
18. Fill in the external address the haproxy instance will be listening on in *Listen Addresses*;
19. set *Type* to **HTTP/HTTPS (SSL offloading)**
20. set *Default Backend Pool* to the option carrying *the name you used in step 9*;
21. check *Enable SSL Offloading*;
22. Under *SSL Offloading* you set *Default certificate* to the certificate you added before you started following this guide;
23. Click **Save** to store this configuration.
24. Click **Apply** to reload the haproxy config on the service running.
**NOTE: This will forward _ALL_ requests on port 443 to your frigate instance.** If that is not what you want or have more then one website alive behind the haproxy you will need to create a *Condition* to trigger when the right URL is used and a *Rules* rule to use the right backend under *Rules & Checks* when the condition is met. This is a useful hint for those who need it, though it is out of the scope of this guide.
+8
View File
@@ -53,6 +53,14 @@ go2rtc:
Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution.
### Can I add a privacy mask to hide part of my camera's view?
Frigate does not have privacy masks. [Motion masks and object filter masks](../configuration/masks.md) only affect detection, they don't hide anything in live view, recordings, or snapshots.
Privacy masks are best configured in the camera's firmware settings so the area is blacked out before the video ever leaves the camera and no extra processing is needed. Check there first.
If your camera does not support privacy masks, there is no efficient alternative. Frigate copies the camera's video into recordings and live view without decoding it, so part of the image can't be hidden without transcoding (re-encoding) the stream. This can be done with a custom ffmpeg filter in go2rtc, but it is not recommended. Every masked camera needs a continuous re-encode, which significantly increases CPU usage, especially for high resolution streams.
### My mjpeg stream or snapshots look green and crazy
This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards.
+49 -30
View File
@@ -2,42 +2,53 @@ import React, { useState, useEffect } from "react";
import Admonition from "@theme/Admonition";
import styles from "./styles.module.css";
// follows calculate_shm_requirements() in frigate/util/services.py, but sizes
// for 25 frames so float rounding can't land under its 20 frame warning
const MB = 1048576;
const RESERVED_MB = 50; // logs + nginx cache
const TARGET_FRAMES = 25;
const DEFAULT_FRAMES = 50;
const frameMb = (width, height) =>
Math.round(((width * height * 1.5 + 270480) / MB) * 10) / 10;
// room for 2 cameras added at runtime, sized as 720p
const addedCamerasMb = 2 * frameMb(1280, 720);
const ShmCalculator = () => {
const [width, setWidth] = useState(1280);
const [height, setHeight] = useState(720);
const [cameraCount, setCameraCount] = useState(1);
const [result, setResult] = useState("26.32MB");
const [singleCameraShm, setSingleCameraShm] = useState("26.32MB");
const [totalShm, setTotalShm] = useState("26.32MB");
const [result, setResult] = useState("-");
const [singleCameraShm, setSingleCameraShm] = useState("-");
const [addedCamerasShm, setAddedCamerasShm] = useState("-");
const [fullRingShm, setFullRingShm] = useState("-");
const calculate = () => {
if (!width || !height || !cameraCount) {
setResult("Please enter valid values");
setSingleCameraShm("-");
setTotalShm("-");
setAddedCamerasShm("-");
setFullRingShm("-");
return;
}
// Single camera base SHM calculation (excluding logs)
// Formula: (width * height * 1.5 * 20 + 270480) / 1048576
const singleCameraBase =
(width * height * 1.5 * 20 + 270480) / 1048576;
setSingleCameraShm(`${singleCameraBase.toFixed(2)}mb`);
const cameraFramesMb = cameraCount * frameMb(width, height) + addedCamerasMb;
// Total SHM calculation (multiple cameras, including logs)
const totalBase = singleCameraBase * cameraCount;
const finalResult = totalBase + 40; // Default includes logs +40mb
setSingleCameraShm(formatMb(frameMb(width, height) * TARGET_FRAMES));
setAddedCamerasShm(formatMb(addedCamerasMb * TARGET_FRAMES));
setResult(formatMb(Math.round(RESERVED_MB + cameraFramesMb * TARGET_FRAMES)));
setFullRingShm(
formatMb(Math.round(RESERVED_MB + cameraFramesMb * DEFAULT_FRAMES)),
);
};
setTotalShm(`${(totalBase + 40).toFixed(2)}mb`);
// Format result
if (finalResult < 1) {
setResult(`${(finalResult * 1024).toFixed(2)}kb`);
} else if (finalResult >= 1024) {
setResult(`${(finalResult / 1024).toFixed(2)}gb`);
} else {
setResult(`${finalResult.toFixed(2)}mb`);
const formatMb = (value) => {
if (value >= 1024) {
return `${(value / 1024).toFixed(2)}gb`;
}
return `${Number(value.toFixed(2))}mb`;
};
const formatWithUnit = (value) => {
@@ -147,19 +158,27 @@ const ShmCalculator = () => {
</div>
<div className={styles.formulaDisplay}>
<p>
<strong>Single Camera:</strong> {formatWithUnit(singleCameraShm)}
<strong>Per Camera:</strong> {formatWithUnit(singleCameraShm)}
</p>
<p>
<strong>Formula:</strong> (width × height × 1.5 × 20 + 270480) ÷
1048576
<strong>Formula:</strong> (width × height × 1.5 + 270480) ÷
1048576 × {TARGET_FRAMES} frames
</p>
{cameraCount > 1 && (
<p>
<strong>Total ({cameraCount} cameras):</strong> {formatWithUnit(totalShm)}
</p>
)}
<p>
<strong>With Logs:</strong> + 40<span className={styles.unit}>mb</span>
<strong>Room for 2 Added Cameras:</strong>{" "}
{formatWithUnit(addedCamerasShm)}
</p>
<p>
<strong>Logs and Cache:</strong> + {RESERVED_MB}
<span className={styles.unit}>mb</span>
</p>
<p>
<strong>Birdseye Restream:</strong> + 8
<span className={styles.unit}>mb</span> if enabled
</p>
<p>
<strong>For {DEFAULT_FRAMES} Frames:</strong>{" "}
{formatWithUnit(fullRingShm)}
</p>
</div>
</div>
+7 -7
View File
@@ -2131,7 +2131,7 @@ paths:
required: false
schema:
type: string
default: utc
default: UTC
title: Timezone
responses:
'200':
@@ -3266,7 +3266,7 @@ paths:
anyOf:
- type: string
- type: 'null'
default: utc
default: UTC
title: Timezone
responses:
'200':
@@ -4666,7 +4666,7 @@ paths:
anyOf:
- type: string
- type: 'null'
default: utc
default: UTC
title: Timezone
responses:
'200':
@@ -4928,7 +4928,7 @@ paths:
anyOf:
- type: string
- type: 'null'
default: utc
default: UTC
title: Timezone
- name: min_score
in: query
@@ -5008,7 +5008,7 @@ paths:
anyOf:
- type: string
- type: 'null'
default: utc
default: UTC
title: Timezone
- name: has_clip
in: query
@@ -7367,7 +7367,7 @@ paths:
required: false
schema:
type: string
default: utc
default: UTC
title: Timezone
- name: cameras
in: query
@@ -7417,7 +7417,7 @@ paths:
required: false
schema:
type: string
default: utc
default: UTC
title: Timezone
responses:
'200':
+3 -1
View File
@@ -15,7 +15,9 @@ from frigate.util.config import find_config_file
def main() -> None:
manager = mp.Manager()
# Fork this manager so the forkserver does not start until the first
# worker process, after the config has applied environment_vars.
manager = mp.get_context("fork").Manager()
faulthandler.enable()
# Setup the logging thread
+80 -2
View File
@@ -78,6 +78,8 @@ from frigate.util.live_streams import (
from frigate.util.object_names import get_categorized_object_names
from frigate.util.schema import get_config_schema
from frigate.util.services import (
Go2rtcStreamIssue,
find_restricted_go2rtc_stream_changes,
get_nvidia_driver_info,
process_logs,
restart_frigate,
@@ -546,6 +548,63 @@ def config_raw():
)
def _go2rtc_streams(raw_config: str) -> Any:
"""Load go2rtc.streams with the same loader Frigate and go2rtc startup use.
Raises ruamel.yaml.YAMLError when the config cannot be parsed.
"""
loaded = ruamel.yaml.YAML().load(raw_config)
go2rtc = loaded.get("go2rtc") if isinstance(loaded, dict) else None
return go2rtc.get("streams") if isinstance(go2rtc, dict) else None
def _restricted_go2rtc_streams_response(
old_raw_config: str, new_raw_config: str
) -> JSONResponse | None:
"""Reject config changes that add restricted go2rtc stream sources.
The same sources are rejected by PUT /go2rtc/streams, and go2rtc would
otherwise load them from the saved config on the next restart.
"""
try:
new_streams = _go2rtc_streams(new_raw_config)
except ruamel.yaml.YAMLError:
logger.warning("Rejected config change that could not be parsed")
return JSONResponse(
content={"success": False, "message": "Unable to parse config"},
status_code=400,
)
try:
old_streams = _go2rtc_streams(old_raw_config)
except ruamel.yaml.YAMLError:
# with no readable saved config, every source is treated as new
old_streams = None
issues = find_restricted_go2rtc_stream_changes(old_streams, new_streams)
if not issues:
return None
messages = []
for issue, label in (
(Go2rtcStreamIssue.restricted, "Restricted go2rtc stream source in"),
(Go2rtcStreamIssue.unknown_variable, "Unknown variable in go2rtc stream"),
(
Go2rtcStreamIssue.invalid_placeholder,
"Invalid variable placeholder in go2rtc stream",
),
):
names = [name for name, found in issues.items() if found == issue]
if names:
messages.append(f"{label}: {', '.join(names)}")
logger.warning("Rejected config change: %s", "; ".join(messages))
return JSONResponse(
content={"success": False, "message": "; ".join(messages)},
status_code=400,
)
@router.post("/config/save", dependencies=[Depends(require_role(["admin"]))])
def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
new_config = body.decode()
@@ -622,10 +681,22 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
status_code=400,
)
config_file = find_config_file()
try:
with open(config_file) as f:
old_raw_config = f.read()
except OSError:
old_raw_config = ""
restricted_response = _restricted_go2rtc_streams_response(
old_raw_config, new_config
)
if restricted_response is not None:
return restricted_response
# Save the config to file
try:
config_file = find_config_file()
with open(config_file, "w") as f:
f.write(new_config)
f.close()
@@ -960,6 +1031,13 @@ def config_set(request: Request, body: AppConfigSetBody):
),
status_code=400,
)
restricted_response = _restricted_go2rtc_streams_response(
old_raw_config, new_raw_config
)
if restricted_response is not None:
with open(config_file, "w") as f:
f.write(old_raw_config)
return restricted_response
except Exception as e:
logging.error(f"Error updating config: {e}")
return JSONResponse(
+67 -18
View File
@@ -12,8 +12,9 @@ import threading
import time
from datetime import datetime
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from urllib.parse import parse_qs
import requests
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
from joserfc import jwt
@@ -31,6 +32,8 @@ from frigate.api.media_auth import (
check_camera_access,
deny_response_for_media_uri,
is_role_restricted,
normalize_request_path,
split_request_uri,
)
from frigate.config import AuthConfig, ProxyConfig
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
@@ -313,14 +316,10 @@ def get_remote_addr(request: Request):
route = list(reversed(forwarded_for.split(",")))
logger.debug(f"IP Route: {[r for r in route]}")
trusted_proxies = []
for proxy in request.app.frigate_config.auth.trusted_proxies:
try:
network = ipaddress.ip_network(proxy)
except ValueError:
logger.warning(f"Unable to parse trusted network: {proxy}")
continue
trusted_proxies.append(network)
trusted_proxies = [
ipaddress.ip_network(proxy, strict=False)
for proxy in request.app.frigate_config.auth.trusted_proxies
]
# return the first remote address that is not trusted
for addr in route:
@@ -1003,7 +1002,7 @@ def create_user(
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
config_roles = list(request.app.frigate_config.auth.roles.keys())
if not re.match("^[A-Za-z0-9._]+$", body.username):
if not re.match(r"^(?!\.+$)[A-Za-z0-9._]+$", body.username):
return JSONResponse(content={"message": "Invalid username"}, status_code=400)
if body.role not in config_roles:
@@ -1229,11 +1228,44 @@ def _get_stream_owner_cameras(request: Request, stream_name: str) -> set[str]:
return owner_cameras
def _is_existing_go2rtc_stream(request: Request, stream_name: str) -> bool:
"""Check that `stream_name` names a stream go2rtc already knows about.
go2rtc turns any `src` that is not an existing stream name into a new ad hoc
stream and starts the producer it describes, so the proxied live-stream
paths must only ever reference existing streams.
"""
frigate_config = request.app.frigate_config
if _get_stream_owner_cameras(request, stream_name):
return True
config_streams = getattr(frigate_config.go2rtc, "streams", None) or {}
if stream_name in config_streams:
return True
# streams added at runtime through PUT /go2rtc/streams only exist in go2rtc
try:
r = requests.get("http://127.0.0.1:1984/api/streams", timeout=2)
except requests.RequestException:
logger.warning("Unable to fetch streams from go2rtc to authorize request")
return False
if not r.ok:
return False
try:
return stream_name in r.json()
except ValueError:
return False
# nginx proxies these paths straight to go2rtc with authentication-only checks
# (see auth_request.conf). Each names the desired stream via the `src` query
# param, so the camera-level check must happen here in the `/auth` subrequest —
# `require_go2rtc_stream_access` only guards the REST `/go2rtc/streams/{name}`
# endpoint, not these proxied live-stream paths.
# endpoint, not these proxied live-stream paths. The nginx locations are prefix
# matches, so anything under these paths is proxied too.
GO2RTC_STREAM_PROXY_PATHS = frozenset(
{
"/live/mse/api/ws",
@@ -1243,31 +1275,48 @@ GO2RTC_STREAM_PROXY_PATHS = frozenset(
)
def _is_go2rtc_stream_proxy_request(path: str, request: Request) -> bool:
# nginx marks requests routed through the go2rtc stream locations, and the
# normalized path catches them if that marker is ever missing
if request.headers.get("x-go2rtc-stream-proxy") == "1":
return True
normalized = normalize_request_path(path)
return any(normalized.startswith(p) for p in GO2RTC_STREAM_PROXY_PATHS)
def deny_response_for_go2rtc_stream(
original_url: str | None, role: str | None, request: Request
) -> int | None:
"""Block role-restricted users from go2rtc live streams they cannot access.
"""Block go2rtc live-stream requests the current role may not make.
Returns 403 when any `src` stream named in `original_url` resolves to a
camera outside the role's allow-list (or when no `src` is provided on a
stream-proxy path), otherwise None. Mirrors the resolution logic in
Returns 403 when any `src` named in `original_url` is not an existing go2rtc
stream (for every role), or when it resolves to a camera outside a
restricted role's allow-list (or no `src` is provided for such a role),
otherwise None. Mirrors the resolution logic in
`require_go2rtc_stream_access` so substream names map to their owning
camera correctly.
"""
if not original_url:
return None
parsed = urlparse(original_url)
if parsed.path not in GO2RTC_STREAM_PROXY_PATHS:
path, query = split_request_uri(original_url)
if not _is_go2rtc_stream_proxy_request(path, request):
return None
frigate_config = request.app.frigate_config
sources = parse_qs(query, keep_blank_values=True).get("src", [])
# an unknown src would make go2rtc create an ad hoc stream from it, which
# bypasses the restricted-source checks on PUT /go2rtc/streams
for src in sources:
if not _is_existing_go2rtc_stream(request, src):
return 403
# admin and full-access roles (no allow-list) bypass the camera check
if not role or not is_role_restricted(role, frigate_config):
return None
sources = parse_qs(parsed.query).get("src", [])
if not sources:
# a stream-proxy request naming no stream has nothing legitimate to
# show a restricted user
+12 -7
View File
@@ -33,11 +33,15 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
from frigate.config.env import UnknownVariableError, substitute_frigate_vars
from frigate.models import User
from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
from frigate.util.builtin import (
clean_camera_user_pass,
clear_orphaned_comments,
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.env import UnknownVariableError, substitute_frigate_vars
from frigate.util.image import run_ffmpeg_snapshot
from frigate.util.live_streams import (
generated_transcode_streams,
@@ -47,7 +51,7 @@ from frigate.util.live_streams import (
from frigate.util.services import (
analyze_record_keyframes,
ffprobe_stream,
is_restricted_go2rtc_source,
is_restricted_go2rtc_api_source,
)
logger = logging.getLogger(__name__)
@@ -153,9 +157,9 @@ def go2rtc_camera_stream(request: Request, stream_name: str):
)
def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
"""Add or update a go2rtc stream configuration."""
if src and is_restricted_go2rtc_source(src):
if src and is_restricted_go2rtc_api_source(src):
logger.warning(
"Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)",
"Rejected go2rtc stream '%s' with restricted source",
stream_name,
)
return JSONResponse(
@@ -174,9 +178,9 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
except UnknownVariableError:
resolved_src = src
if is_restricted_go2rtc_source(resolved_src):
if is_restricted_go2rtc_api_source(resolved_src):
logger.warning(
"Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)",
"Rejected go2rtc stream '%s' with restricted source",
stream_name,
)
return JSONResponse(
@@ -1273,6 +1277,7 @@ async def delete_camera(
# Remove camera from config
if "cameras" in data and camera_name in data["cameras"]:
del data["cameras"][camera_name]
clear_orphaned_comments(data["cameras"], data, "cameras")
# Remove camera from auth roles
auth = data.get("auth", {})
+8 -1
View File
@@ -1,5 +1,6 @@
"""Object classification APIs."""
import asyncio
import datetime
import logging
import os
@@ -48,6 +49,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.classification])
# concurrent collections for one model share its temp and train directories
example_collection_lock = asyncio.Lock()
def invalid_name_response(value: str) -> JSONResponse:
"""Response for a name that cannot be used as a path component."""
@@ -1290,7 +1294,10 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
if model_name is None:
return invalid_name_response(body.model_name)
collect_object_classification_examples(model_name, body.label)
async with example_collection_lock:
await asyncio.to_thread(
collect_object_classification_examples, model_name, body.label
)
return JSONResponse(
content={"success": True, "message": "Example generation completed"},
@@ -7,4 +7,4 @@ class AppTimelineHourlyQueryParameters(BaseModel):
after: float | None = None
before: float | None = None
limit: int | None = 200
timezone: str | None = "utc"
timezone: str | None = "UTC"
@@ -40,7 +40,7 @@ class EventsQueryParams(BaseModel):
max_length: float | None = None
event_id: str | None = None
sort: str | None = None
timezone: str | None = "utc"
timezone: str | None = "UTC"
class EventsSearchQueryParams(BaseModel):
@@ -68,7 +68,7 @@ class EventsSearchQueryParams(BaseModel):
has_clip: bool | None = None
has_snapshot: bool | None = None
is_submitted: bool | None = None
timezone: str | None = "utc"
timezone: str | None = "UTC"
min_score: float | None = None
max_score: float | None = None
min_speed: float | None = None
@@ -78,6 +78,6 @@ class EventsSearchQueryParams(BaseModel):
class EventsSummaryQueryParams(BaseModel):
timezone: str | None = "utc"
timezone: str | None = "UTC"
has_clip: int | None = None
has_snapshot: int | None = None
@@ -3,7 +3,7 @@ from pydantic.json_schema import SkipJsonSchema
class MediaRecordingsSummaryQueryParams(BaseModel):
timezone: str = "utc"
timezone: str = "UTC"
cameras: str | None = "all"
@@ -19,7 +19,7 @@ class ReviewSummaryQueryParams(BaseModel):
cameras: str = "all"
labels: str = "all"
zones: str = "all"
timezone: str = "utc"
timezone: str = "UTC"
class ReviewActivityMotionQueryParams(BaseModel):
+21 -15
View File
@@ -58,6 +58,7 @@ from frigate.api.defs.response.export_response import (
)
from frigate.api.defs.response.generic_response import GenericResponse
from frigate.api.defs.tags import Tags
from frigate.api.media_auth import deny_response_for_media_uri
from frigate.const import CLIPS_DIR, EXPORT_DIR
from frigate.jobs.export import (
ExportJob,
@@ -79,6 +80,7 @@ from frigate.record.export import (
validate_ffmpeg_args,
)
from frigate.util.path import sanitize_contained_path
from frigate.util.recording_coverage import recording_overlap
from frigate.util.time import is_current_hour
logger = logging.getLogger(__name__)
@@ -134,6 +136,7 @@ def _validate_export_case(export_case_id: str | None) -> JSONResponse | None:
def _sanitize_existing_image(
request: Request,
image_path: str | None,
) -> tuple[str | None, JSONResponse | None]:
if not image_path:
@@ -141,6 +144,15 @@ def _sanitize_existing_image(
existing_image = sanitize_contained_path(image_path, CLIPS_DIR)
# CLIPS_DIR is shared by every camera, so the caller must also be allowed
# to read the image.
if existing_image is not None and deny_response_for_media_uri(
f"/clips/{quote(os.path.relpath(existing_image, CLIPS_DIR))}",
request.headers.get("remote-role"),
request.app.frigate_config,
):
existing_image = None
if existing_image is None:
return None, JSONResponse(
content={"success": False, "message": "Invalid image path"},
@@ -166,15 +178,7 @@ def _validate_export_source(
) -> str | None:
if playback_source == PlaybackSourceEnum.recordings:
query = Recordings.select().where(
(
Recordings.start_time.between(start_time, end_time)
| Recordings.end_time.between(start_time, end_time)
| (
(start_time > Recordings.start_time)
& (end_time < Recordings.end_time)
)
)
& (Recordings.camera == camera_name)
recording_overlap(start_time, end_time) & (Recordings.camera == camera_name)
)
# a pinned export reads only that stream, so the other stream's
@@ -239,9 +243,7 @@ def _get_item_recording_export_errors(
query = Recordings.select(Recordings.start_time, Recordings.end_time).where(
Recordings.camera == camera_name,
Recordings.start_time.between(min_start, max_end)
| Recordings.end_time.between(min_start, max_end)
| ((min_start > Recordings.start_time) & (max_end < Recordings.end_time)),
recording_overlap(min_start, max_end),
)
# a pinned batch reads only that stream, so the other stream's
@@ -701,7 +703,7 @@ def export_recordings_batch(
sanitized_images: list[str | None] = []
for item in body.items:
existing_image, image_validation_error = _sanitize_existing_image(
item.image_path
request, item.image_path
)
if image_validation_error is not None:
return image_validation_error
@@ -849,7 +851,9 @@ def export_recording(
playback_source = body.source
friendly_name = body.name
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
existing_image, image_validation_error = _sanitize_existing_image(
request, body.image_path
)
if image_validation_error is not None:
return image_validation_error
@@ -1044,7 +1048,9 @@ def export_recording_custom(
playback_source = body.source
friendly_name = body.name
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
existing_image, image_validation_error = _sanitize_existing_image(
request, body.image_path
)
if image_validation_error is not None:
return image_validation_error
ffmpeg_input_args = body.ffmpeg_input_args
+12 -9
View File
@@ -1,6 +1,7 @@
import asyncio
import logging
import re
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse
@@ -81,9 +82,20 @@ def create_fastapi_app(
config_holder: ConfigHolder | None = None,
notice_registry: NoticeRegistry | None = None,
):
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("FastAPI started")
asyncio.create_task(
debug_replay_auto_stop_watchdog(
replay_manager, frigate_config, config_publisher
)
)
yield
logger.info("Starting FastAPI app")
app = FastAPI(
debug=False,
lifespan=lifespan,
swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"},
dependencies=[Depends(require_admin_by_default())]
if enforce_default_admin
@@ -119,15 +131,6 @@ def create_fastapi_app(
database.close()
return response
@app.on_event("startup")
async def startup():
logger.info("FastAPI started")
asyncio.create_task(
debug_replay_auto_stop_watchdog(
replay_manager, frigate_config, config_publisher
)
)
# Rate limiter (used for login endpoint)
if frigate_config.auth.failed_login_rate_limit is None:
limiter.enabled = False
+32 -26
View File
@@ -14,10 +14,10 @@ from enum import Enum
from pathlib import Path as FilePath
from typing import IO, Any
from urllib.parse import unquote
from zoneinfo import ZoneInfo
import cv2
import numpy as np
import pytz
from fastapi import APIRouter, Depends, Path, Query, Request, Response
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pathvalidate import sanitize_filename
@@ -62,8 +62,10 @@ from frigate.util.recording_coverage import (
build_spans,
null_audio_glitches,
plan_clip,
recording_overlap,
resolve_coverage,
)
from frigate.util.time import get_normalized_tz_name
logger = logging.getLogger(__name__)
@@ -295,10 +297,15 @@ async def latest_frame(
and request.app.frigate_config.birdseye.enabled
and request.app.frigate_config.birdseye.restream
):
frame = cv2.cvtColor(
frame_processor.get_current_frame(camera_name),
cv2.COLOR_YUV2BGR_I420,
)
frame = frame_processor.get_current_frame(camera_name)
if frame is None:
return JSONResponse(
content={"success": False, "message": "Unable to get valid frame"},
status_code=500,
)
frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
height = int(params.height or str(frame.shape[0]))
width = int(height * frame.shape[1] / frame.shape[0])
@@ -346,10 +353,7 @@ async def get_snapshot_from_recording(
Recordings.path,
Recordings.start_time,
)
.where(
(frame_time >= Recordings.start_time)
& (frame_time <= Recordings.end_time)
)
.where(recording_overlap(frame_time, frame_time))
.where(Recordings.camera == camera_name)
.order_by(Recordings.stream_type.asc(), Recordings.start_time.desc())
.limit(1)
@@ -365,10 +369,7 @@ async def get_snapshot_from_recording(
Recordings.path,
Recordings.start_time,
)
.where(
(frame_time >= Recordings.start_time)
& (frame_time <= Recordings.end_time)
)
.where(recording_overlap(frame_time, frame_time))
.where(Recordings.camera == camera_name)
.order_by(Recordings.stream_type.asc(), Recordings.start_time.desc())
.limit(1)
@@ -383,8 +384,13 @@ async def get_snapshot_from_recording(
mime_type = "png" if format == "png" else "jpeg"
config: FrigateConfig = request.app.frigate_config
image_data = get_image_from_recording(
config.ffmpeg, recording.path, time_in_segment, codec, height
image_data = await asyncio.to_thread(
get_image_from_recording,
config.ffmpeg,
recording.path,
time_in_segment,
codec,
height,
)
if not image_data:
@@ -426,9 +432,7 @@ async def submit_recording_snapshot_to_plus(
Recordings.path,
Recordings.start_time,
)
.where(
(frame_time >= Recordings.start_time) & (frame_time <= Recordings.end_time)
)
.where(recording_overlap(frame_time, frame_time))
.where(Recordings.camera == camera_name)
.order_by(Recordings.stream_type.asc(), Recordings.start_time.desc())
.limit(1)
@@ -438,8 +442,12 @@ async def submit_recording_snapshot_to_plus(
config: FrigateConfig = request.app.frigate_config
recording: Recordings = recording_query.get()
time_in_segment = frame_time - recording.start_time
image_data = get_image_from_recording(
config.ffmpeg, recording.path, time_in_segment, "png"
image_data = await asyncio.to_thread(
get_image_from_recording,
config.ffmpeg,
recording.path,
time_in_segment,
"png",
)
if not image_data:
@@ -538,11 +546,7 @@ async def recording_clip(
Recordings.start_time,
Recordings.end_time,
)
.where(
(Recordings.start_time.between(start_ts, end_ts))
| (Recordings.end_time.between(start_ts, end_ts))
| ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time))
)
.where(recording_overlap(start_ts, end_ts))
.where(Recordings.camera == camera_name)
.where(Recordings.stream_type == stream_type)
.order_by(Recordings.start_time.asc())
@@ -837,7 +841,9 @@ async def vod_hour(
parts = year_month.split("-")
start_date = (
datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=UTC)
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
- datetime.now(
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
).utcoffset()
)
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
start_ts = start_date.timestamp()
+42 -10
View File
@@ -13,8 +13,9 @@ from __future__ import annotations
import logging
import os
import re
from enum import Enum
from urllib.parse import unquote, urlparse
from urllib.parse import unquote
from peewee import DoesNotExist
@@ -24,6 +25,8 @@ from frigate.models import Export, User
logger = logging.getLogger(__name__)
_URI_SCHEME_AND_AUTHORITY = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://[^/?]*")
class MediaAuthResolution(str, Enum):
"""Classification of an `X-Original-URL` path for media-auth purposes."""
@@ -41,6 +44,36 @@ class MediaAuthResolution(str, Enum):
UNKNOWN = "unknown"
def split_request_uri(request_uri: str) -> tuple[str, str]:
"""Split a raw request URI into its path and query string.
urlparse is not used since it reads a path starting with `//` as a host.
nginx ends the URI at a literal `#`, so anything after it is dropped.
"""
uri = _URI_SCHEME_AND_AUTHORITY.sub("", request_uri, count=1)
path, _, query = uri.partition("#")[0].partition("?")
return path, query
def normalize_request_path(path: str) -> str:
"""Normalize a raw request path the way nginx does before matching locations.
nginx percent-decodes the path, merges repeated slashes and resolves dot
segments, while `X-Original-URL` carries the raw `$request_uri`.
"""
segments: list[str] = []
for segment in unquote(path).split("/"):
if segment in ("", "."):
continue
if segment == "..":
if segments:
segments.pop()
continue
segments.append(segment)
return "/" + "/".join(segments)
def extract_path(original_url: str | None) -> str | None:
"""Return the decoded path component of nginx's `X-Original-URL` header.
@@ -49,14 +82,14 @@ def extract_path(original_url: str | None) -> str | None:
request like `/recordings/.../allowed_cam/../forbidden_cam/file.mp4`
would (1) parse as the allowed camera in our auth check, (2) be served
as the forbidden camera by nginx. To close the bypass we reject any URI
whose path contains `.` or `..` segments outright.
whose path contains `.` or `..` segments outright. Repeated slashes are
merged, as nginx does before matching locations.
"""
if not original_url:
return None
parsed = urlparse(original_url)
raw_path = parsed.path or original_url
decoded = unquote(raw_path)
path, _ = split_request_uri(original_url)
decoded = re.sub(r"/{2,}", "/", unquote(path))
if not decoded:
return None
@@ -252,12 +285,11 @@ def deny_response_for_media_uri(
path = extract_path(original_url)
# `extract_path` returns None for URIs containing `.` or `..` segments.
# For media-root URIs that's a traversal attempt — deny outright. For
# non-media URIs, pass through (nginx / the backend handle them).
# Deny them when nginx would resolve them under a media root, and pass
# the rest through to nginx and the backend.
if path is None:
raw = urlparse(original_url).path or original_url
decoded = unquote(raw)
first = decoded.lstrip("/").split("/", 1)[0] if decoded else ""
normalized = normalize_request_path(split_request_uri(original_url)[0])
first = normalized.lstrip("/").split("/", 1)[0]
if first in ("clips", "recordings", "exports"):
return 403
return None
+14 -9
View File
@@ -9,7 +9,6 @@ from urllib.parse import urlparse
from cryptography.hazmat.primitives import serialization
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from peewee import DoesNotExist
from py_vapid import Vapid01, utils
from frigate.api.auth import allow_any_authenticated
@@ -173,16 +172,22 @@ def register_notifications(request: Request, body: dict = None):
status_code=400,
)
try:
User.update(notification_tokens=User.notification_tokens.append(sub)).where(
User.username == username
).execute()
return JSONResponse(
content=({"success": True, "message": "Successfully saved token."}),
status_code=200,
updated = (
User.update(notification_tokens=User.notification_tokens.append(sub))
.where(User.username == username)
.execute()
)
if not updated:
logger.warning(
"Rejected notification registration for unknown user %s", username
)
except DoesNotExist:
return JSONResponse(
content=({"success": False, "message": "Could not find user."}),
status_code=404,
)
return JSONResponse(
content=({"success": True, "message": "Successfully saved token."}),
status_code=200,
)
+5 -2
View File
@@ -5,8 +5,8 @@ import logging
import os
import threading
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo
import pytz
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
@@ -22,6 +22,7 @@ from frigate.api.defs.response.preview_response import (
from frigate.api.defs.tags import Tags
from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE
from frigate.models import Previews
from frigate.util.time import get_normalized_tz_name
logger = logging.getLogger(__name__)
@@ -126,7 +127,9 @@ def preview_hour(
parts = year_month.split("-")
start_date = (
datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=UTC)
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
- datetime.now(
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
).utcoffset()
)
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
start_ts = start_date.timestamp()
+3 -6
View File
@@ -36,6 +36,7 @@ from frigate.util.recording_coverage import (
coverage_spans,
known_video_codecs,
realized_timelines,
recording_overlap,
resolve_coverage,
stream_media_summary,
)
@@ -175,7 +176,7 @@ async def all_recordings_summary(
@router.get(
"/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)]
)
async def recordings_summary(camera_name: str, timezone: str = "utc"):
async def recordings_summary(camera_name: str, timezone: str = "UTC"):
"""Returns hourly summary for recordings of given camera"""
time_range_query = (
@@ -537,11 +538,7 @@ async def delete_recordings(
# Build query to find overlapping recordings
clauses = [
(
Recordings.start_time.between(start, end)
| Recordings.end_time.between(start, end)
| ((start > Recordings.start_time) & (end < Recordings.end_time))
),
recording_overlap(start, end),
(Recordings.camera << camera_list),
]
+2 -8
View File
@@ -37,6 +37,7 @@ from frigate.const import STREAM_TYPE_MAIN
from frigate.embeddings import EmbeddingsContext
from frigate.models import Recordings, ReviewSegment, UserReviewStatus
from frigate.review.types import SeverityEnum
from frigate.util.recording_coverage import recording_overlap
from frigate.util.time import get_dst_transitions
logger = logging.getLogger(__name__)
@@ -585,14 +586,7 @@ def delete_reviews(body: ReviewModifyMultipleBody):
camera_name = review["camera"]
recordings = (
Recordings.select(Recordings.id, Recordings.path)
.where(
Recordings.start_time.between(start_time, end_time)
| Recordings.end_time.between(start_time, end_time)
| (
(start_time > Recordings.start_time)
& (end_time < Recordings.end_time)
)
)
.where(recording_overlap(start_time, end_time))
.where(Recordings.camera == camera_name)
.dicts()
.iterator()
+22 -1
View File
@@ -581,7 +581,12 @@ class FrigateApp:
def init_auth(self) -> None:
if self.config.auth.enabled:
if User.select().count() == 0:
if (
User.select().count() == 0
or User.select()
.where((User.username == "admin") & (User.password_hash == ""))
.exists()
):
password = secrets.token_hex(16)
password_hash = hash_password(
password, iterations=self.config.auth.hash_iterations
@@ -593,6 +598,9 @@ class FrigateApp:
User.password_hash: password_hash,
User.notification_tokens: [],
}
).on_conflict(
conflict_target=[User.username],
update={User.password_hash: password_hash},
).execute()
self.config.auth.admin_first_time_login = True
@@ -623,6 +631,19 @@ class FrigateApp:
logger.info(f"*** Password: {password} ***")
logger.info("********************************************************")
logger.info("********************************************************")
else:
# Notification registrations store tokens on the admin row. Its
# empty password hash marks it for a generated password if auth
# is enabled later.
if User.select().count() == 0:
User.insert(
{
User.username: "admin",
User.role: "admin",
User.password_hash: "",
User.notification_tokens: [],
}
).execute()
def start(self) -> None:
logger.info(f"Starting Frigate ({VERSION})")
+4
View File
@@ -169,6 +169,10 @@ class CameraMaintainer(threading.Thread):
frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1]
self.frame_manager.create(f"{config.name}_frame{i}", frame_size)
# a leftover ledger from an unclean exit may be the wrong length
self.frame_manager.delete(f"{config.name}_frame_times")
self.frame_manager.create(f"{config.name}_frame_times", max(count, 1) * 8)
capture_process = CameraCapture(
config,
count,
+5 -9
View File
@@ -349,7 +349,7 @@ class CameraState:
return
current_frame = self.frame_manager.get(
frame_name, self.camera_config.frame_shape_yuv
frame_name, self.camera_config.frame_shape_yuv, frame_time
)
tracked_objects = self.tracked_objects.copy()
@@ -392,7 +392,6 @@ class CameraState:
"attributes": new_obj.obj_data["attributes"],
"current_estimated_speed": 0,
"velocity_angle": 0,
"path_data": [],
"recognized_license_plate": None,
"recognized_license_plate_score": None,
}
@@ -404,7 +403,7 @@ class CameraState:
self.send_mqtt_snapshot(new_obj, object_type)
for c in self.callbacks["start"]:
c(self.name, new_obj, frame_name)
c(self.name, new_obj, frame_name, frame_time)
for id in updated_ids:
updated_obj = tracked_objects[id]
@@ -416,7 +415,7 @@ class CameraState:
if autotracker_update or significant_update:
for c in self.callbacks["autotrack"]:
c(self.name, updated_obj, frame_name)
c(self.name, updated_obj, frame_name, frame_time)
if thumb_update and current_frame is not None:
# ensure this frame is stored in the cache
@@ -466,7 +465,7 @@ class CameraState:
):
# call event handlers
for c in self.callbacks["update"]:
c(self.name, updated_obj, frame_name)
c(self.name, updated_obj, frame_name, frame_time)
updated_obj.last_published = frame_time
# send MQTT snapshot when object first enters a required zone,
@@ -488,7 +487,7 @@ class CameraState:
removed_obj.obj_data["end_time"] = frame_time
logger.debug(f"{self.name}: end callback for object {id}")
for c in self.callbacks["end"]:
c(self.name, removed_obj, frame_name)
c(self.name, removed_obj, frame_name, frame_time)
# TODO: can i switch to looking this up and only changing when an event ends?
# maintain best objects
@@ -603,9 +602,6 @@ class CameraState:
self.current_frame_time = frame_time
self._current_frame = np.copy(current_frame)
if self.previous_frame_id is not None:
self.frame_manager.close(self.previous_frame_id)
self.previous_frame_id = frame_name
def send_mqtt_snapshot(self, new_obj: TrackedObject, object_type: str) -> None:
+5 -2
View File
@@ -1,6 +1,7 @@
"""Facilitates communication between processes."""
import multiprocessing as mp
import threading
from _pickle import UnpicklingError
from multiprocessing.synchronize import Event as MpEvent
from typing import Any
@@ -18,11 +19,13 @@ class ConfigPublisher:
self.socket = self.context.socket(zmq.PUB)
self.socket.bind(SOCKET_PUB_SUB)
self.stop_event: MpEvent = mp.Event()
self.lock = threading.Lock()
def publish(self, topic: str, payload: Any) -> None:
"""There is no communication back to the processes."""
self.socket.send_string(topic, flags=zmq.SNDMORE)
self.socket.send_pyobj(payload)
with self.lock:
self.socket.send_string(topic, flags=zmq.SNDMORE)
self.socket.send_pyobj(payload)
def stop(self) -> None:
self.stop_event.set()
+5 -1
View File
@@ -322,7 +322,11 @@ class Dispatcher:
self.audio_activity.expire_all(payload)
def handle_update_event_description() -> None:
event: Event = Event.get(Event.id == payload["id"])
event: Event | None = Event.get_or_none(Event.id == payload["id"])
if event is None:
return
cast(dict, event.data)["description"] = payload["description"]
event.save()
self.publish(
+8
View File
@@ -1,6 +1,7 @@
"""Facilitates communication between processes."""
import logging
import threading
from collections.abc import Callable
from enum import Enum
from typing import Any
@@ -79,14 +80,21 @@ class EmbeddingsRequestor:
self.context = zmq.Context()
self.socket = self.context.socket(zmq.REQ)
self.socket.connect(SOCKET_REP_REQ)
self.lock = threading.Lock()
def send_data(self, topic: str, data: Any) -> Any:
"""Sends data and then waits for reply."""
# an overlapping call fails fast so a slow reply can't stall the API
if not self.lock.acquire(blocking=False):
return ""
try:
self.socket.send_json((topic, data))
return self.socket.recv_json()
except zmq.ZMQError:
return ""
finally:
self.lock.release()
def stop(self) -> None:
self.socket.close()
+18 -2
View File
@@ -8,7 +8,16 @@ from .zmq_proxy import Publisher, Subscriber
class EventUpdatePublisher(
Publisher[tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]]]
Publisher[
tuple[
EventTypeEnum,
EventStateEnum,
str | None,
str,
dict[str, Any],
float | None,
]
]
):
"""Publishes events (objects, audio, manual)."""
@@ -19,7 +28,14 @@ class EventUpdatePublisher(
def publish(
self,
payload: tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]],
payload: tuple[
EventTypeEnum,
EventStateEnum,
str | None,
str,
dict[str, Any],
float | None,
],
sub_topic: str = "",
) -> None:
super().publish(payload, sub_topic)
+7 -5
View File
@@ -79,14 +79,16 @@ class InterProcessRequestor:
self.context = zmq.Context()
self.socket = self.context.socket(zmq.REQ)
self.socket.connect(SOCKET_REP_REQ)
self.lock = threading.Lock()
def send_data(self, topic: str, data: Any) -> Any:
"""Sends data and then waits for reply."""
try:
self.socket.send_json((topic, data))
return self.socket.recv_json()
except zmq.ZMQError:
return ""
with self.lock:
try:
self.socket.send_json((topic, data))
return self.socket.recv_json()
except zmq.ZMQError:
return ""
def stop(self) -> None:
self.socket.close(linger=0)
+41 -28
View File
@@ -41,6 +41,20 @@ class PushNotification:
ttl: int = 0
def _build_web_pushers(user: str, subs: list[dict[str, Any]]) -> list[WebPusher]:
"""Build pushers for a user's stored subscriptions, skipping unusable ones."""
pushers: list[WebPusher] = []
for sub in subs:
# WebPusher decodes the stored keys and raises on malformed ones
try:
pushers.append(WebPusher(sub))
except Exception:
logger.warning("Skipping invalid notification subscription for %s", user)
return pushers
class WebPushClient(Communicator):
"""Frigate wrapper for webpush client."""
@@ -56,6 +70,7 @@ class WebPushClient(Communicator):
for c in self.config.cameras.values()
}
self.suspension_broadcaster: Callable[[str, Any, bool], None] | None = None
self.config_lock = threading.Lock()
self.last_camera_notification_time: dict[str, float] = {
c.name: 0 # type: ignore[misc]
for c in self.config.cameras.values()
@@ -76,9 +91,9 @@ class WebPushClient(Communicator):
User.select(User.username, User.notification_tokens).dicts().iterator()
)
for user in users:
self.web_pushers[user["username"]] = []
for sub in user["notification_tokens"]:
self.web_pushers[user["username"]].append(WebPusher(sub))
self.web_pushers[user["username"]] = _build_web_pushers(
user["username"], user["notification_tokens"]
)
# notification and auth config updater
self.global_config_subscriber = ConfigSubscriber("config/")
@@ -146,10 +161,7 @@ class WebPushClient(Communicator):
User.username == user
).execute()
self.web_pushers[user] = []
for sub in user_subs:
self.web_pushers[user].append(WebPusher(sub))
self.web_pushers[user] = _build_web_pushers(user, user_subs)
logger.info(
f"Cleaned up {len(expired)} notification subscriptions for {user}"
@@ -198,29 +210,30 @@ class WebPushClient(Communicator):
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Wrapper for publishing when client is in valid state."""
# check for updated global config (notifications, auth)
while True:
config_topic, config_payload = (
self.global_config_subscriber.check_for_update()
)
if config_topic is None:
break
if config_topic == "config/notifications" and config_payload:
self.config.notifications = config_payload
elif config_topic == "config/auth":
if isinstance(config_payload, AuthConfig):
self.config.auth = config_payload
with self.config_lock:
# check for updated global config (notifications, auth)
while True:
config_topic, config_payload = (
self.global_config_subscriber.check_for_update()
)
if config_topic is None:
break
if config_topic == "config/notifications" and config_payload:
self.config.notifications = config_payload
elif config_topic == "config/auth":
if isinstance(config_payload, AuthConfig):
self.config.auth = config_payload
self._refresh_user_cameras()
updates = self.config_subscriber.check_for_updates()
if "add" in updates:
for camera in updates["add"]:
self.suspended_cameras[camera] = 0
self.last_camera_notification_time[camera] = 0
self._refresh_user_cameras()
updates = self.config_subscriber.check_for_updates()
if "add" in updates:
for camera in updates["add"]:
self.suspended_cameras[camera] = 0
self.last_camera_notification_time[camera] = 0
self._refresh_user_cameras()
if topic == "reviews":
decoded = json.loads(payload)
camera = decoded["before"]["camera"]
+82 -4
View File
@@ -3,6 +3,9 @@
import errno
import json
import logging
import queue
import socket
import struct
import threading
from collections.abc import Callable
from typing import Any
@@ -76,6 +79,12 @@ _WS_VIEWER_TOPICS = frozenset(
# Camera-scoped command topics a camera-authorized (non-admin) user may send.
_WS_CAMERA_COMMAND_TOPICS = frozenset({"ptz"})
# Max outbound messages waiting on a client's writer thread.
WS_MAX_PENDING_MESSAGES = 256
# Seconds a single send() may wait for a client to free socket buffer space.
WS_SEND_TIMEOUT = 10
def _check_ws_authorization(
topic: str,
@@ -464,6 +473,78 @@ def _materialize_for_ws(
class WebSocket(WebSocket_): # type: ignore[misc]
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._send_queue: queue.Queue[tuple[Any, bool] | None] = queue.Queue(
maxsize=WS_MAX_PENDING_MESSAGES
)
self._writer: threading.Thread | None = None
self._aborted = False
def opened(self) -> None:
"""Set a send timeout on the socket and start this client's writer thread."""
# ws4py writes close and pong frames on the shared manager thread, so a
# stalled client must not block sendall forever. SO_SNDTIMEO leaves recv
# (and idle clients) untouched, unlike settimeout().
self.sock.setsockopt(
socket.SOL_SOCKET, socket.SO_SNDTIMEO, struct.pack("ll", WS_SEND_TIMEOUT, 0)
)
# every client gets its own writer so a client that stops reading only
# blocks itself, never the thread that called publish()
self._writer = threading.Thread(
target=self._drain_send_queue, name="ws_writer", daemon=True
)
self._writer.start()
def send(self, payload: Any, binary: bool = False) -> None:
"""Queue a message for this client's writer thread.
Returns without waiting for delivery. Disconnects the client if its queue
is full.
"""
try:
self._send_queue.put_nowait((payload, binary))
except queue.Full:
self._abort("Websocket client is not keeping up, disconnecting it")
def _drain_send_queue(self) -> None:
while True:
item = self._send_queue.get()
if item is None or self.terminated or self.sock is None:
return
try:
super().send(*item)
except Exception:
self._abort()
return
def _abort(self, reason: str | None = None) -> None:
# publish() keeps hitting a full queue until the manager thread removes
# the connection, so only act (and log) the first time
if self._aborted:
return
self._aborted = True
if reason:
logger.warning(reason)
# shutdown rather than close so the ws4py manager thread sees EOF and
# runs its normal unregister/terminate; this also unblocks a stuck sendall
sock = self.sock
if sock is not None:
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
def closed(self, code: int, reason: str | None = None) -> None:
"""Signal the writer thread to exit."""
try:
self._send_queue.put_nowait(None)
except queue.Full:
pass
def unhandled_error(self, error: Any) -> None:
"""
Handles the unfriendly socket closures on the server side
@@ -597,10 +678,7 @@ class WebSocketClient(Communicator):
)
if message is None:
continue
try:
ws.send(message)
except (ConnectionResetError, BrokenPipeError, ValueError):
pass
ws.send(message)
def stop(self) -> None:
if self.websocket_server is not None:
+10
View File
@@ -1,3 +1,5 @@
import ipaddress
from pydantic import Field, field_validator, model_validator
from .base import FrigateBaseModel
@@ -68,6 +70,14 @@ class AuthConfig(FrigateBaseModel):
),
)
@field_validator("trusted_proxies")
@classmethod
def validate_trusted_proxies(cls, v: list[str]) -> list[str]:
for proxy in v:
ipaddress.ip_network(proxy, strict=False)
return v
@field_validator("roles")
@classmethod
def validate_roles(cls, v: dict[str, list[str]]) -> dict[str, list[str]]:
+1 -1
View File
@@ -3,9 +3,9 @@ from enum import Enum
from pydantic import Field, field_validator
from frigate.util.config import resolve_ffmpeg_path
from frigate.util.env import EnvString
from ..base import FrigateBaseModel
from ..env import EnvString
__all__ = [
"CameraFfmpegConfig",
+2 -1
View File
@@ -3,8 +3,9 @@ from typing import Any, Self
from pydantic import Field, model_validator
from frigate.util.env import EnvString
from ..base import FrigateBaseModel
from ..env import EnvString
__all__ = ["GenAIConfig", "GenAIProviderEnum", "GenAIRoleEnum"]
+2 -1
View File
@@ -1,7 +1,8 @@
from pydantic import Field
from frigate.util.env import EnvString
from ..base import FrigateBaseModel
from ..env import EnvString
__all__ = ["NotificationConfig"]
+2 -1
View File
@@ -2,8 +2,9 @@ from enum import Enum
from pydantic import Field, field_validator
from frigate.util.env import EnvString
from ..base import FrigateBaseModel
from ..env import EnvString
from .objects import DEFAULT_TRACKED_OBJECTS
__all__ = ["OnvifConfig", "PtzAutotrackConfig", "ZoomingModeEnum"]
+1
View File
@@ -147,6 +147,7 @@ class RecordConfig(FrigateBaseModel):
)
expire_interval: int = Field(
default=60,
ge=1,
title="Record cleanup interval",
description="Minutes between cleanup passes that remove expired recording segments.",
)
+2 -2
View File
@@ -128,8 +128,8 @@ class ZoneConfig(BaseModel):
# we know if any points are > 1 then it is using the
# old native resolution coordinates
if isinstance(coordinates, list):
explicit = any(p.split(",")[0] > "1.0" for p in coordinates)
try:
explicit = any(float(p.split(",")[0]) > 1.0 for p in coordinates)
self._contour = np.array(
[
(
@@ -157,8 +157,8 @@ class ZoneConfig(BaseModel):
)
elif isinstance(coordinates, str):
points = coordinates.split(",")
explicit = any(p > "1.0" for p in points)
try:
explicit = any(float(p) > 1.0 for p in points)
self._contour = np.array(
[
(
+1 -1
View File
@@ -34,6 +34,7 @@ from frigate.util.config import (
get_relative_coordinates,
migrate_frigate_config,
)
from frigate.util.env import EnvVars, reload_sources
from frigate.util.image import create_mask
from frigate.util.live_streams import (
default_transcode_source,
@@ -71,7 +72,6 @@ from .classification import (
SemanticSearchModelEnum,
)
from .database import DatabaseConfig
from .env import EnvVars, reload_sources
from .logger import LoggerConfig
from .mqtt import MqttConfig
from .network import NetworkingConfig
+1 -1
View File
@@ -3,9 +3,9 @@ from typing import Self
from pydantic import Field, ValidationInfo, model_validator
from frigate.const import FREQUENCY_STATS_POINTS
from frigate.util.env import EnvString
from .base import FrigateBaseModel
from .env import EnvString
__all__ = ["MqttConfig"]
+12 -11
View File
@@ -1,18 +1,12 @@
from pydantic import Field, model_validator
from pydantic import Field, field_validator, model_validator
from frigate.util.config import parse_listen_address
from .base import FrigateBaseModel
__all__ = ["IPv6Config", "ListenConfig", "NetworkingConfig"]
def parse_listen_port(value: int | str) -> int:
"""Return the port number from a bare port or an "address:port" value."""
if isinstance(value, str):
return int(value.split(":")[-1])
return value
class IPv6Config(FrigateBaseModel):
enabled: bool = Field(
default=False,
@@ -33,13 +27,20 @@ class ListenConfig(FrigateBaseModel):
description="External listening port for Frigate (default 8971).",
)
# Runs before pydantic coerces a bool to an int port, which
# get_nginx_settings.py rejects when it reads the raw YAML
@field_validator("internal", "external", mode="before")
@classmethod
def validate_listen(cls, value: int | str) -> int | str:
return parse_listen_address(value)[0]
@property
def internal_port(self) -> int:
return parse_listen_port(self.internal)
return parse_listen_address(self.internal)[1]
@property
def external_port(self) -> int:
return parse_listen_port(self.external)
return parse_listen_address(self.external)[1]
@model_validator(mode="after")
def validate_distinct_ports(self) -> "ListenConfig":
+2 -1
View File
@@ -1,7 +1,8 @@
from pydantic import Field, field_validator
from frigate.util.env import EnvString
from .base import FrigateBaseModel
from .env import EnvString
__all__ = ["ProxyConfig", "HeaderMappingConfig"]
+1
View File
@@ -8,6 +8,7 @@ MODEL_CACHE_DIR = f"{CONFIG_DIR}/model_cache"
BASE_DIR = "/media/frigate"
CLIPS_DIR = f"{BASE_DIR}/clips"
EXPORT_DIR = f"{BASE_DIR}/exports"
EXPORT_STAGING_DIR = f"{EXPORT_DIR}/staging"
FACE_DIR = f"{CLIPS_DIR}/faces"
THUMB_DIR = f"{CLIPS_DIR}/thumbs"
RECORD_DIR = f"{BASE_DIR}/recordings"
@@ -4,6 +4,7 @@ import logging
import os
import threading
import time
from pathlib import Path
from typing import Any
from peewee import DoesNotExist
@@ -193,9 +194,10 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
logger.debug("Recognizer not initialized")
return None
try: # type: ignore[unreachable]
# Save audio data to a temporary wav (faster-whisper expects a file)
temp_wav = os.path.join(CACHE_DIR, f"temp_audio_{int(time.time())}.wav")
# Save audio data to a temporary wav (faster-whisper expects a file)
temp_wav = os.path.join(CACHE_DIR, f"temp_audio_{int(time.time())}.wav") # type: ignore[unreachable]
try:
with open(temp_wav, "wb") as f:
f.write(audio_data)
@@ -205,8 +207,6 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
beam_size=5,
)
os.remove(temp_wav)
# Combine all segment texts
text = " ".join(segment.text.strip() for segment in segments)
if not text:
@@ -222,6 +222,8 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
except Exception as e:
logger.error(f"Error transcribing audio: {e}")
return None
finally:
Path(temp_wav).unlink(missing_ok=True)
def _transcription_wrapper(self, event: dict[str, Any]) -> None:
"""Wrapper to run transcription and reset running flag when done."""
@@ -105,6 +105,10 @@ class ObjectDescriptionProcessor(PostProcessorApi):
logger.debug(f"{camera} sending early request to GenAI")
self.early_request_sent[data["id"]] = True
if camera_config.objects.genai.debug_save_thumbnails:
self._save_debug_thumbnails(data["id"])
# Copy thumbnails to avoid holding references after cleanup
thumbnails_copy = [
data["thumbnail"][:] if data.get("thumbnail") else None
@@ -302,26 +306,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
)
if camera_config.objects.genai.debug_save_thumbnails and num_thumbnails > 0:
logger.debug(f"Saving {num_thumbnails} thumbnails for event {event_id}")
Path(os.path.join(CLIPS_DIR, f"genai-requests/{event_id}")).mkdir(
parents=True, exist_ok=True
)
for idx, data in enumerate(self.tracked_events[event_id], 1):
jpg_bytes: bytes | None = data["thumbnail"]
if jpg_bytes is None:
logger.warning(f"Unable to save thumbnail {idx} for {event_id}.")
else:
with open(
os.path.join(
CLIPS_DIR,
f"genai-requests/{event_id}/{idx}.jpg",
),
"wb",
) as j:
j.write(jpg_bytes)
self._save_debug_thumbnails(event_id)
# Generate the description. Call happens in a thread since it is network bound.
threading.Thread(
@@ -337,6 +322,30 @@ class ObjectDescriptionProcessor(PostProcessorApi):
# Clean up tracked events and early request state
self.cleanup_event(event_id)
def _save_debug_thumbnails(self, event_id: str) -> None:
"""Write the tracked thumbnails for an event to the genai-requests dir."""
tracked = self.tracked_events.get(event_id, [])
logger.debug(f"Saving {len(tracked)} thumbnails for event {event_id}")
Path(os.path.join(CLIPS_DIR, f"genai-requests/{event_id}")).mkdir(
parents=True, exist_ok=True
)
for idx, data in enumerate(tracked, 1):
jpg_bytes: bytes | None = data["thumbnail"]
if jpg_bytes is None:
logger.warning(f"Unable to save thumbnail {idx} for {event_id}.")
else:
with open(
os.path.join(
CLIPS_DIR,
f"genai-requests/{event_id}/{idx}.jpg",
),
"wb",
) as j:
j.write(jpg_bytes)
def _genai_embed_description(self, event: Event, thumbnails: list[bytes]) -> None:
"""Embed the description for an event."""
start = datetime.datetime.now().timestamp()
@@ -37,6 +37,7 @@ from frigate.genai.manager import GenAIClientManager
from frigate.models import Recordings, ReviewSegment
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
from frigate.util.image import get_image_from_recording
from frigate.util.recording_coverage import recording_overlap
from ..post.api import PostProcessorApi
from ..types import DataProcessorMetrics
@@ -592,7 +593,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
Recordings.path,
Recordings.start_time,
)
.where((ts >= Recordings.start_time) & (ts <= Recordings.end_time))
.where(recording_overlap(ts, ts))
.where(Recordings.camera == camera)
.where(Recordings.stream_type == STREAM_TYPE_MAIN)
.order_by(Recordings.start_time.desc())
@@ -193,12 +193,7 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
return None
def process_frame(self, frame_data: dict[str, Any], frame: np.ndarray) -> None:
if (
not self.model_config.name
or not self.model_config.state_config
or not self.tensor_input_details
or not self.tensor_output_details
):
if not self.model_config.name or not self.model_config.state_config:
return
if self.metrics and self.model_config.name in self.metrics.classification_cps:
@@ -541,12 +536,7 @@ class CustomObjectClassificationProcessor(DeferredRealtimeProcessorApi):
return best_label, avg_score
def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray) -> None:
if (
not self.model_config.name
or not self.model_config.object_config
or not self.tensor_input_details
or not self.tensor_output_details
):
if not self.model_config.name or not self.model_config.object_config:
return
if self.metrics and self.model_config.name in self.metrics.classification_cps:
+28 -10
View File
@@ -89,30 +89,46 @@ def is_arm64_platform() -> bool:
return machine in ("aarch64", "arm64", "armv8", "armv7l")
def get_ort_session_options(model_type: str | None = None) -> ort.SessionOptions | None:
def get_ort_session_options(
model_type: str | None = None, cpu: bool = False
) -> ort.SessionOptions | None:
"""Get ONNX Runtime session options with appropriate settings.
Args:
model_type: Model being loaded, used to pin its graph optimization level.
cpu: Whether the session runs on the CPU execution provider.
Returns:
SessionOptions with a pinned optimization level, or None for default settings.
SessionOptions for enrichment models, or None for default settings.
"""
# Import here to avoid circular imports
from frigate.embeddings.types import EnrichmentModelTypeEnum
from frigate.embeddings.types import (
ENRICHMENT_FOR_MODEL_TYPE,
EnrichmentModelTypeEnum,
)
if model_type not in ENRICHMENT_FOR_MODEL_TYPE:
return None
sess_options = ort.SessionOptions()
if cpu:
# idle intra-op threads otherwise spin for ~1s after every run, keeping
# most cores busy while an enrichment runs even once a second
sess_options.add_session_config_entry("session.intra_op.allow_spinning", "0")
if model_type == EnrichmentModelTypeEnum.jina_v2.value:
# below EXTENDED the CUDA EP returns an identical vector for every image,
# and ORT_ENABLE_ALL fails to build on CPU with a SimplifiedLayerNormFusion error
level = ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED
sess_options.graph_optimization_level = (
ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED
)
elif model_type == EnrichmentModelTypeEnum.jina_v1.value:
# aggressive optimizations create or expect nodes that don't exist
level = ort.GraphOptimizationLevel.ORT_ENABLE_BASIC
else:
return None
sess_options.graph_optimization_level = (
ort.GraphOptimizationLevel.ORT_ENABLE_BASIC
)
sess_options = ort.SessionOptions()
sess_options.graph_optimization_level = level
return sess_options
@@ -789,7 +805,9 @@ def get_optimized_runner(
ONNXModelRunner(
ort.InferenceSession(
model_path,
sess_options=get_ort_session_options(model_type),
sess_options=get_ort_session_options(
model_type, cpu=providers[0] == "CPUExecutionProvider"
),
providers=providers,
provider_options=options,
),
+6
View File
@@ -10,6 +10,7 @@ from json.decoder import JSONDecodeError
from multiprocessing.synchronize import Event as MpEvent
from typing import Any
import cv2
import regex
from pathvalidate import ValidationError, sanitize_filename
@@ -51,6 +52,11 @@ class EmbeddingProcess(FrigateProcess):
from .maintainer import EmbeddingMaintainer
self.pre_run_setup(self.config.logger)
# frame conversions and crops here are one-off calls, and OpenCV's
# per-core thread pool burns ~8x the CPU of running them inline
cv2.setNumThreads(1)
maintainer = EmbeddingMaintainer(
self.config,
self.metrics,
+11 -19
View File
@@ -332,6 +332,9 @@ class EmbeddingMaintainer(threading.Thread):
"""Apply camera config updates and register newly enabled processors."""
updated_topics = self.config_updater.check_for_updates()
for camera in updated_topics.get("remove", []):
self.frame_manager.close_camera(camera)
if updated_topics.keys() & GENAI_UPDATE_TOPICS:
self._sync_genai_processors()
@@ -517,7 +520,7 @@ class EmbeddingMaintainer(threading.Thread):
if update is None:
return
source_type, event_type, camera, frame_name, data = update
source_type, event_type, camera, frame_name, data, frame_time = update
logger.debug(
f"Received update - source_type: {source_type}, camera: {camera}, data label: {data.get('label') if data else 'None'}"
@@ -544,13 +547,9 @@ class EmbeddingMaintainer(threading.Thread):
return
# Create our own thumbnail based on the bounding box and the frame time
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv
)
except FileNotFoundError:
logger.debug(f"Frame {frame_name} not found for camera {camera}")
pass
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time
)
if yuv_frame is None:
logger.debug(
@@ -583,8 +582,6 @@ class EmbeddingMaintainer(threading.Thread):
PostProcessDataEnum.tracked_object,
)
self.frame_manager.close(frame_name)
def _process_finalized(self) -> None:
"""Process the end of an event."""
while True:
@@ -793,7 +790,7 @@ class EmbeddingMaintainer(threading.Thread):
if topic is None:
return
camera, frame_name, _, _, motion_boxes, _ = data
camera, frame_name, frame_time, _, motion_boxes, _ = data
if not camera or camera not in self.config.cameras:
return
@@ -815,12 +812,9 @@ class EmbeddingMaintainer(threading.Thread):
# no active features that use this data
return
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv
)
except FileNotFoundError:
pass
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time
)
if yuv_frame is None:
logger.debug(
@@ -841,8 +835,6 @@ class EmbeddingMaintainer(threading.Thread):
{"camera": camera, "motion": motion_boxes}, yuv_frame
)
self.frame_manager.close(frame_name)
def _process_deferred_results(self) -> None:
"""Drain results from deferred processors and perform IPC side-effects."""
for processor in self.realtime_processors:
+2 -2
View File
@@ -5,7 +5,7 @@ import os
import shutil
import threading
from transformers import AutoTokenizer, CLIPImageProcessor
from transformers import AutoTokenizer, CLIPImageProcessorPil
from transformers.utils.logging import disable_progress_bar
from frigate.comms.inter_process import InterProcessRequestor
@@ -209,7 +209,7 @@ class JinaV1ImageEmbedding(BaseEmbedding):
if self.downloader:
self.downloader.wait_for_download()
self.feature_extractor = CLIPImageProcessor.from_pretrained(
self.feature_extractor = CLIPImageProcessorPil.from_pretrained(
f"{MODEL_CACHE_DIR}/{self.model_name}",
)
+1 -1
View File
@@ -94,7 +94,7 @@ class EventProcessor(threading.Thread):
if update == None:
continue
source_type, event_type, camera, _, event_data = update
source_type, event_type, camera, _, event_data, _ = update
logger.debug(
f"Event received: {source_type} {event_type} {camera} {event_data['id']}"
+2 -5
View File
@@ -27,6 +27,7 @@ from frigate.jobs.manager import job_is_running, set_current_job
from frigate.models import Export, Recordings
from frigate.types import JobStatusTypesEnum
from frigate.util.ffmpeg import run_ffmpeg_with_progress
from frigate.util.recording_coverage import recording_overlap
if TYPE_CHECKING:
from frigate.debug_replay import DebugReplayManager
@@ -104,11 +105,7 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> Mode
Recordings.start_time,
Recordings.end_time,
)
.where(
Recordings.start_time.between(start_ts, end_ts)
| Recordings.end_time.between(start_ts, end_ts)
| ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time))
)
.where(recording_overlap(start_ts, end_ts))
.where(Recordings.camera == source_camera)
.order_by(Recordings.start_time.asc())
)
+5 -1
View File
@@ -2,6 +2,7 @@
import logging
import os
import shutil
import threading
import time
from collections.abc import Callable
@@ -15,7 +16,7 @@ from peewee import DoesNotExist
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig
from frigate.config.camera.record import ChaptersEnum
from frigate.const import UPDATE_JOB_STATE
from frigate.const import EXPORT_STAGING_DIR, UPDATE_JOB_STATE
from frigate.jobs.job import Job
from frigate.models import Export
from frigate.record.export import (
@@ -415,6 +416,9 @@ def reap_stale_exports() -> None:
this in a try/except. A failure on a single row will not stop the rest
of the sweep, and a failure in the top-level query will log and return.
"""
# staged stream runs live on disk, so a killed export leaves them behind
shutil.rmtree(EXPORT_STAGING_DIR, ignore_errors=True)
try:
stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712
except Exception:
+2 -14
View File
@@ -35,6 +35,7 @@ from frigate.jobs.motion_search_decode import (
)
from frigate.models import Recordings
from frigate.types import JobStatusTypesEnum
from frigate.util.recording_coverage import recording_overlap
logger = logging.getLogger(__name__)
@@ -469,20 +470,7 @@ class MotionSearchRunner(threading.Thread):
recordings = list(
Recordings.select()
.where(
(
Recordings.start_time.between(
self.job.start_time_range, self.job.end_time_range
)
)
| (
Recordings.end_time.between(
self.job.start_time_range, self.job.end_time_range
)
)
| (
(self.job.start_time_range > Recordings.start_time)
& (self.job.end_time_range < Recordings.end_time)
)
recording_overlap(self.job.start_time_range, self.job.end_time_range)
)
.where(Recordings.camera == camera_name)
.where(Recordings.stream_type == STREAM_TYPE_MAIN)
-5
View File
@@ -170,7 +170,6 @@ class DetectorRunner(FrigateProcess):
mono_start = time.monotonic()
detections = object_detector.detect_raw(input_frame)
duration = time.monotonic() - mono_start
frame_manager.close(connection_id)
if connection_id not in self.outputs:
self.create_output_shm(connection_id)
@@ -259,10 +258,6 @@ class AsyncDetectorRunner(FrigateProcess):
ts = self.send_times.popleft()
duration = time.perf_counter() - ts
# release input buffer
assert self._frame_manager is not None
self._frame_manager.close(connection_id)
if connection_id not in self.outputs:
self.create_output_shm(connection_id)
+1 -6
View File
@@ -277,12 +277,7 @@ class BroadcastThread(threading.Thread):
and ws.environ["PATH_INFO"] == f"/{self.camera}"
and ws_has_camera_access(ws, self.camera, self.config)
):
try:
ws.send(buf, binary=True)
except ValueError:
pass
except (BrokenPipeError, ConnectionResetError, OSError) as e:
logger.debug(f"Websocket unexpectedly closed {e}")
ws.send(buf, binary=True)
elif self.converter.process.poll() is not None:
break
+1 -6
View File
@@ -127,12 +127,7 @@ class BroadcastThread(threading.Thread):
and ws.environ["PATH_INFO"] == f"/{self.camera}"
and ws_has_camera_access(ws, self.camera, self.config)
):
try:
ws.send(buf, binary=True)
except ValueError:
pass
except (BrokenPipeError, ConnectionResetError) as e:
logger.debug(f"Websocket unexpectedly closed {e}")
ws.send(buf, binary=True)
elif self.converter.process.poll() is not None:
break
+11 -18
View File
@@ -8,6 +8,7 @@ import threading
from multiprocessing.synchronize import Event as MpEvent
from wsgiref.simple_server import make_server
import cv2
from ws4py.server.wsgirefserver import (
WebSocketWSGIHandler,
WebSocketWSGIRequestHandler,
@@ -122,6 +123,10 @@ class OutputProcess(FrigateProcess):
def run(self) -> None:
self.pre_run_setup(self.config.logger)
# birdseye resizes are small, and OpenCV's per-core thread pool burns
# ~3x the CPU of running them inline
cv2.setNumThreads(1)
frame_manager = SharedMemoryFrameManager()
# start a websocket server on 8082
@@ -145,6 +150,7 @@ class OutputProcess(FrigateProcess):
CameraConfigUpdateEnum.birdseye,
CameraConfigUpdateEnum.enabled,
CameraConfigUpdateEnum.record,
CameraConfigUpdateEnum.remove,
],
)
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
@@ -208,6 +214,10 @@ class OutputProcess(FrigateProcess):
birdseye,
)
if CameraConfigUpdateEnum.remove in updates:
for camera in updates["remove"]:
frame_manager.close_camera(camera)
_result = detection_subscriber.check_for_update(timeout=1)
if _result is None:
continue
@@ -241,7 +251,7 @@ class OutputProcess(FrigateProcess):
continue
frame = frame_manager.get(
frame_name, self.config.cameras[camera].frame_shape_yuv
frame_name, self.config.cameras[camera].frame_shape_yuv, frame_time
)
if frame is None:
@@ -296,8 +306,6 @@ class OutputProcess(FrigateProcess):
frame,
)
frame_manager.close(frame_name)
move_preview_frames("clips")
while True:
@@ -309,21 +317,6 @@ class OutputProcess(FrigateProcess):
if not topic or data is None:
break
(
camera,
frame_name,
frame_time,
current_tracked_objects,
motion_boxes,
regions,
) = data
camera_config = self.config.cameras.get(camera)
if camera_config is not None:
frame_manager.get(frame_name, camera_config.frame_shape_yuv)
frame_manager.close(frame_name)
detection_subscriber.stop()
for jsmpeg in jsmpeg_cameras.values():
+1 -3
View File
@@ -124,7 +124,7 @@ class PtzMotionEstimator:
)
yuv_frame = self.frame_manager.get(
frame_name, self.camera_config.frame_shape_yuv
frame_name, self.camera_config.frame_shape_yuv, frame_time
)
if yuv_frame is None:
@@ -181,8 +181,6 @@ class PtzMotionEstimator:
except Exception:
pass
self.frame_manager.close(frame_name)
return self.coord_transformations
+21 -15
View File
@@ -15,8 +15,8 @@ from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, cast
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import pytz
from pathvalidate import sanitize_filename
from peewee import DoesNotExist
@@ -26,6 +26,7 @@ from frigate.const import (
CACHE_DIR,
CLIPS_DIR,
EXPORT_DIR,
EXPORT_STAGING_DIR,
MAX_PLAYLIST_SECONDS,
PREVIEW_FRAME_TYPE,
STREAM_TYPE_MAIN,
@@ -44,11 +45,12 @@ from frigate.util.recording_coverage import (
known_video_codecs,
null_audio_glitches,
realized_timeline,
recording_overlap,
resolve_coverage,
stream_media_summary,
)
from frigate.util.services import get_video_properties
from frigate.util.time import is_current_hour
from frigate.util.time import get_normalized_tz_name, is_current_hour
logger = logging.getLogger(__name__)
@@ -559,7 +561,7 @@ class RecordingExporter(threading.Thread):
)
def _staged_run_path(self, index: int) -> str:
return os.path.join(CACHE_DIR, f"export_stage_{self.export_id}_{index}.mp4")
return os.path.join(EXPORT_STAGING_DIR, f"{self.export_id}_{index}.mp4")
def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None:
"""Probe one recording from a run for its resolution.
@@ -714,6 +716,17 @@ class RecordingExporter(threading.Thread):
for index, run in enumerate(runs):
dest = self._staged_run_path(index)
try:
os.makedirs(os.path.dirname(dest), exist_ok=True)
except OSError:
logger.exception(
"Failed to create staging directory for export %s",
self.export_id,
)
self._cleanup_staged_runs()
return False
weight = 100.0 * run.duration / total_duration
base = 100.0 * completed / total_duration
@@ -761,14 +774,7 @@ class RecordingExporter(threading.Thread):
Recordings.start_time,
Recordings.end_time,
)
.where(
Recordings.start_time.between(self.start_time, self.end_time)
| Recordings.end_time.between(self.start_time, self.end_time)
| (
(self.start_time > Recordings.start_time)
& (self.end_time < Recordings.end_time)
)
)
.where(recording_overlap(self.start_time, self.end_time))
.where(
(Recordings.camera == self.camera)
& (Recordings.stream_type == stream_type)
@@ -862,8 +868,8 @@ class RecordingExporter(threading.Thread):
tz_name = self.config.ui.timezone
if tz_name:
try:
tz = pytz.timezone(tz_name)
except pytz.UnknownTimeZoneError:
tz = ZoneInfo(get_normalized_tz_name(tz_name))
except (ValueError, ZoneInfoNotFoundError):
tz = None
if tz is not None:
return datetime.datetime.fromtimestamp(timestamp, tz=tz).strftime(
@@ -1026,8 +1032,8 @@ class RecordingExporter(threading.Thread):
tz: datetime.tzinfo | None = None
if tz_name:
try:
tz = pytz.timezone(tz_name)
except pytz.UnknownTimeZoneError:
tz = ZoneInfo(get_normalized_tz_name(tz_name))
except (ValueError, ZoneInfoNotFoundError):
tz = None
if tz is None:
tz = datetime.UTC
+3 -4
View File
@@ -1000,6 +1000,7 @@ class RecordingMaintainer(threading.Thread):
logger.error(f"Unable to convert {cache_path} to {file_path}")
if p.stderr:
logger.error((await p.stderr.read()).decode("ascii"))
Path(file_path).unlink(missing_ok=True)
return None
else:
logger.debug(
@@ -1017,7 +1018,7 @@ class RecordingMaintainer(threading.Thread):
except OSError:
segment_size = 0
os.remove(cache_path)
self.drop_segment(cache_path)
rand_id = "".join(
random.choices(string.ascii_lowercase + string.digits, k=6)
@@ -1046,10 +1047,8 @@ class RecordingMaintainer(threading.Thread):
}
except Exception:
logger.exception(f"Unable to store recording segment {cache_path}")
Path(cache_path).unlink(missing_ok=True)
# clear end_time cache
self.end_time_cache.pop(cache_path, None)
self.drop_segment(cache_path)
return None
def run(self) -> None:
+7 -13
View File
@@ -504,6 +504,7 @@ class ReviewSegmentMaintainer(threading.Thread):
def _handle_camera_removed(self, camera: str) -> None:
"""Close out a deleted camera's segment so a reused name cannot inherit it."""
self.forcibly_end_segment(camera)
self.frame_manager.close_camera(camera)
self.indefinite_events.pop(camera, None)
self.recent_classification_state_changes.pop(camera, None)
@@ -542,19 +543,15 @@ class ReviewSegmentMaintainer(threading.Thread):
if len(objects) <= pending.frame_active_count:
return
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv
)
except FileNotFoundError:
return
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time
)
if yuv_frame is None:
logger.debug(f"Failed to get frame {frame_name} from SHM")
return
pending.update_frame(camera_config, yuv_frame, objects)
self.frame_manager.close(frame_name)
def update_existing_segment(
self,
@@ -653,7 +650,7 @@ class ReviewSegmentMaintainer(threading.Thread):
try:
if should_update_image:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv
frame_name, camera_config.frame_shape_yuv, frame_time
)
if yuv_frame is None:
@@ -669,7 +666,6 @@ class ReviewSegmentMaintainer(threading.Thread):
activity.get_all_objects(),
prev_data,
)
self.frame_manager.close(frame_name)
except FileNotFoundError:
return
@@ -677,7 +673,7 @@ class ReviewSegmentMaintainer(threading.Thread):
if not segment.has_frame:
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv
frame_name, camera_config.frame_shape_yuv, frame_time
)
if yuv_frame is None:
@@ -685,7 +681,6 @@ class ReviewSegmentMaintainer(threading.Thread):
return
segment.save_full_frame(camera_config, yuv_frame)
self.frame_manager.close(frame_name)
self._publish_segment_update(
segment, camera_config, None, [], prev_data
)
@@ -770,7 +765,7 @@ class ReviewSegmentMaintainer(threading.Thread):
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv
frame_name, camera_config.frame_shape_yuv, frame_time
)
if yuv_frame is None:
@@ -780,7 +775,6 @@ class ReviewSegmentMaintainer(threading.Thread):
new_segment.update_frame(
camera_config, yuv_frame, activity.get_all_objects()
)
self.frame_manager.close(frame_name)
self._publish_segment_start(new_segment)
except FileNotFoundError:
return
+1 -1
View File
@@ -4,12 +4,12 @@ from unittest.mock import Mock, patch
import frigate.genai
from frigate.config import GenAIProviderEnum
from frigate.config.env import FRIGATE_ENV_VARS
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
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
from frigate.util.env import FRIGATE_ENV_VARS
class TestHttpApp(BaseTestHttp):
@@ -417,6 +417,77 @@ class TestGo2rtcStreamAccess(BaseTestHttp):
assert resp.status_code == 400
assert resp.json().get("success") is False
def test_add_stream_rejects_ffmpeg_argument_injection(self):
"""PUT /go2rtc/streams must reject ffmpeg: sources whose parameters can
inject arbitrary ffmpeg arguments, since go2rtc expands them to exec:"""
app = self._make_app(_MULTI_CAMERA_CONFIG)
with patch("frigate.api.camera.requests.put") as mock_put:
with AuthTestClient(app) as client:
for src in (
# shape of the source in GHSA-qjjw-3v66-279f
"ffmpeg:#input='-f''h264''-i''data:placeholder''-c''copy'",
"ffmpeg:#input=-re",
"ffmpeg:front_door#raw=-vn",
"ffmpeg:front_door#video=-vn",
"ffmpeg:front_door#video=out.mp4",
"ffmpeg:front_door#drawtext=x",
"ffmpeg:front_door#width=1x",
"ffmpeg:front_door#audio=/16000",
"ffmpeg:front_door#audio=opus/",
"ffmpeg:front_door#audio=opus/16000/1",
"ffmpeg:'front_door'",
" ffmpeg:#input=-re",
):
resp = client.put("/go2rtc/streams/injected", params={"src": src})
assert resp.status_code == 400, (
f"Expected 400 for ffmpeg src {src!r}; got {resp.status_code}"
)
assert resp.json().get("success") is False
mock_put.assert_not_called()
def test_add_stream_allows_standard_ffmpeg_sources(self):
"""ffmpeg: sources built by the UI and shown in the docs must still be
forwarded to go2rtc"""
app = self._make_app(_MULTI_CAMERA_CONFIG)
mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})()
with patch(
"frigate.api.camera.requests.put", return_value=mock_response
) as mock_put:
with AuthTestClient(app) as client:
for src in (
"ffmpeg:rtsp://10.0.0.1:554/video",
"ffmpeg:rtsp://10.0.0.1:554/video#video=copy#audio=copy",
"ffmpeg:rtsp://10.0.0.1:554/video#video=h264#hardware",
"ffmpeg:front_door#audio=opus",
"ffmpeg:front_door#video=h264#hardware=vaapi",
"ffmpeg:front_door#video=h264#width=1280#rotate=-90",
# documented HomeKit source
"ffmpeg:front_door#video=h264#width=1280#height=720#audio=opus/16000",
"ffmpeg:http://10.0.0.1/flv?port=1935&app=bcs#video=copy",
):
resp = client.put("/go2rtc/streams/legit", params={"src": src})
assert resp.status_code == 200, (
f"Expected 200 for ffmpeg src {src!r}; got {resp.status_code}"
)
forwarded_src = mock_put.call_args.kwargs["params"]["src"]
assert forwarded_src == src
def test_add_stream_allows_ffmpeg_injection_when_override_set(self):
"""GO2RTC_ALLOW_ARBITRARY_EXEC also lifts the ffmpeg: parameter check"""
app = self._make_app(_MULTI_CAMERA_CONFIG)
mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})()
with patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "true"):
with patch(
"frigate.api.camera.requests.put", return_value=mock_response
) as mock_put:
with AuthTestClient(app) as client:
resp = client.put(
"/go2rtc/streams/legit",
params={"src": "ffmpeg:front_door#raw=-vn"},
)
assert resp.status_code == 200
mock_put.assert_called_once()
def test_stream_alias_blocked_when_owning_camera_disallowed(self):
"""limited_user cannot access a stream alias that belongs to a camera they
are not allowed to see."""
@@ -0,0 +1,226 @@
"""Tests for go2rtc stream source restrictions on config/save and config/set."""
import contextlib
import copy
import os
import tempfile
from unittest.mock import patch
import ruamel.yaml
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
from frigate.util.env import _CONTAINER_ENV, FRIGATE_ENV_VARS, reload_sources
# shape of the source in GHSA-qjjw-3v66-279f, with the payload replaced
_INJECTED_SRC = "ffmpeg:#input='-f''h264''-i''data:placeholder''-c''copy'"
# an unrestricted ffmpeg: source the operator already has in the file on disk
_EXISTING_SRC = "ffmpeg:rtsp://10.0.0.1:554/video#raw=-vn"
_CONFIG = {
"mqtt": {"host": "mqtt"},
"go2rtc": {"streams": {"front_door": [_EXISTING_SRC]}},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
},
}
@contextlib.contextmanager
def _frigate_vars(values: dict[str, str]):
# config parsing rebuilds FRIGATE_ENV_VARS from its sources
with patch.dict(_CONTAINER_ENV, values), patch.dict(FRIGATE_ENV_VARS, values):
yield
reload_sources(warn=False)
def _dump(config: dict) -> str:
yaml = ruamel.yaml.YAML()
stream = tempfile.SpooledTemporaryFile(mode="w+")
yaml.dump(config, stream)
stream.seek(0)
return stream.read()
class TestConfigGo2rtcStreamRestrictions(BaseTestHttp):
def setUp(self):
super().setUp([Event, Recordings, ReviewSegment])
self.minimal_config = _CONFIG
self.app = super().create_app()
fd, self.config_path = tempfile.mkstemp(suffix=".yml")
with os.fdopen(fd, "w") as f:
f.write(_dump(_CONFIG))
self.addCleanup(os.unlink, self.config_path)
find_patch = patch(
"frigate.api.app.find_config_file", return_value=self.config_path
)
find_patch.start()
self.addCleanup(find_patch.stop)
# the override is read once at import; make sure it is off here
exec_patch = patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", None)
exec_patch.start()
self.addCleanup(exec_patch.stop)
def _read_config(self) -> str:
with open(self.config_path) as f:
return f.read()
def _set_streams(self, streams: dict):
with AuthTestClient(self.app) as client:
return client.put(
"/config/set",
json={
"config_data": {"go2rtc": {"streams": streams}},
"requires_restart": 1,
},
)
def _save_streams(self, streams: dict, prefix: str = ""):
config = copy.deepcopy(_CONFIG)
config["go2rtc"]["streams"].update(streams)
with AuthTestClient(self.app) as client:
return client.post(
"/config/save?save_option=saveonly",
content=prefix + _dump(config),
headers={"Content-Type": "text/plain"},
)
# --- config/set ---
def test_set_rejects_injected_ffmpeg_source(self):
before = self._read_config()
resp = self._set_streams({"new_stream": [_INJECTED_SRC]})
assert resp.status_code == 400
assert resp.json()["success"] is False
assert self._read_config() == before
def test_set_rejects_injected_string_source(self):
before = self._read_config()
resp = self._set_streams({"new_stream": "ffmpeg:front_door#raw=-vn"})
assert resp.status_code == 400
assert self._read_config() == before
def test_set_rejects_injected_source_added_to_existing_stream(self):
before = self._read_config()
resp = self._set_streams({"front_door": [_EXISTING_SRC, _INJECTED_SRC]})
assert resp.status_code == 400
assert self._read_config() == before
def test_set_allows_standard_sources(self):
resp = self._set_streams(
{
"new_stream": [
"rtsp://10.0.0.2:554/video",
"ffmpeg:new_stream#audio=opus",
]
}
)
assert resp.status_code == 200, resp.text
assert "ffmpeg:new_stream#audio=opus" in self._read_config()
def test_set_keeps_existing_source_on_disk(self):
resp = self._set_streams(
{"front_door": [_EXISTING_SRC, "ffmpeg:front_door#audio=opus"]}
)
assert resp.status_code == 200, resp.text
assert _EXISTING_SRC in self._read_config()
def test_set_allows_injected_source_when_override_set(self):
with patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "true"):
resp = self._set_streams({"new_stream": [_INJECTED_SRC]})
assert resp.status_code == 200, resp.text
# --- config/save ---
def test_save_rejects_injected_ffmpeg_source(self):
before = self._read_config()
resp = self._save_streams({"new_stream": [_INJECTED_SRC]})
assert resp.status_code == 400
assert self._read_config() == before
def test_save_rejects_exec_source(self):
before = self._read_config()
resp = self._save_streams({"new_stream": "exec:/tmp/something"})
assert resp.status_code == 400
assert self._read_config() == before
def test_save_rejects_map_source(self):
before = self._read_config()
resp = self._save_streams({"new_stream": {"url": "rtsp://10.0.0.2/video"}})
assert resp.status_code == 400
assert self._read_config() == before
def test_save_resubmitting_existing_config_allowed(self):
# the UI config editor always sends back the whole file
resp = self._save_streams({"new_stream": "rtsp://10.0.0.2:554/video"})
assert resp.status_code == 200, resp.text
config = self._read_config()
assert _EXISTING_SRC in config
assert "rtsp://10.0.0.2:554/video" in config
def test_save_tagged_config_with_standard_source_allowed(self):
# a tag on the root map still passes validation and startup parsing
resp = self._save_streams(
{"new_stream": "rtsp://10.0.0.2:554/video"}, prefix="!config\n"
)
assert resp.status_code == 200, resp.text
def test_save_tagged_config_with_injected_source_rejected(self):
before = self._read_config()
resp = self._save_streams({"new_stream": [_INJECTED_SRC]}, prefix="!config\n")
assert resp.status_code == 400
assert "Restricted go2rtc stream source" in resp.json()["message"]
assert self._read_config() == before
# --- {FRIGATE_*} placeholders resolve the same way as at go2rtc startup ---
def test_set_rejects_unresolvable_placeholder(self):
before = self._read_config()
with _frigate_vars({"FRIGATE_RTSP_USER": "user"}):
for src, message in (
(
"{FRIGATE_RTSP_USER!r}fmpeg:front_door#raw=-vn",
"Invalid variable placeholder in go2rtc stream: new_stream",
),
(
"{FRIGATE_RTSP_USER:>5}",
"Invalid variable placeholder in go2rtc stream: new_stream",
),
(
"rtsp://{FRIGATE_UNDEFINED}@10.0.0.2:554/video",
"Unknown variable in go2rtc stream: new_stream",
),
):
resp = self._set_streams({"new_stream": [src]})
assert resp.status_code == 400, src
assert resp.json()["message"] == message, src
assert self._read_config() == before
def test_set_reports_each_issue(self):
resp = self._set_streams(
{
"typo_stream": ["rtsp://{FRIGATE_UNDEFINED}@10.0.0.2:554/video"],
"injected_stream": [_INJECTED_SRC],
}
)
assert resp.status_code == 400
assert resp.json()["message"] == (
"Restricted go2rtc stream source in: injected_stream; "
"Unknown variable in go2rtc stream: typo_stream"
)
def test_set_allows_resolvable_placeholder(self):
src = "rtsp://{FRIGATE_RTSP_USER}@10.0.0.2:554/video"
with _frigate_vars({"FRIGATE_RTSP_USER": "user"}):
resp = self._set_streams({"new_stream": [src]})
assert resp.status_code == 200, resp.text
assert src in self._read_config()
+77
View File
@@ -4,6 +4,7 @@ import tempfile
import zipfile
from unittest.mock import patch
from frigate.const import CLIPS_DIR
from frigate.jobs.export import (
ExportJob,
get_export_job_manager,
@@ -453,6 +454,23 @@ class TestHttpExport(BaseTestHttp):
assert unchanged.name == "front door export"
assert unchanged.video_path == video
def test_reap_stale_exports_removes_staged_runs(self):
with tempfile.TemporaryDirectory() as tmpdir:
staging_dir = os.path.join(tmpdir, "staging")
os.makedirs(staging_dir)
staged = os.path.join(staging_dir, "front_door_abc_0.mp4")
finished = os.path.join(tmpdir, "export_stage_saved_abc.mp4")
for path in (staged, finished):
with open(path, "w") as handle:
handle.write("video")
with patch("frigate.jobs.export.EXPORT_STAGING_DIR", staging_dir):
reap_stale_exports()
assert not os.path.exists(staging_dir)
assert os.path.exists(finished)
def test_reap_stale_exports_deletes_rows_with_no_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
stale_video = os.path.join(tmpdir, "stale.mp4")
@@ -1034,6 +1052,65 @@ class TestHttpExport(BaseTestHttp):
assert response.status_code == 400
assert ExportCase.select().count() == 0
def _batch_export_with_image(self, image_path: str, role: str):
with patch(
"frigate.api.export.start_export_job",
side_effect=lambda _config, job: job.id,
) as start_export_job:
with AuthTestClient(self.app) as client:
response = client.post(
"/exports/batch",
headers={"remote-user": role, "remote-role": role},
json={
"items": [
{
"camera": "front_door",
"start_time": 110,
"end_time": 150,
"image_path": image_path,
}
],
},
)
return response, start_export_job
def test_batch_export_restricted_role_rejects_unreadable_image_path(self):
self._insert_recording("rec-front", "front_door", 100, 400)
self.app.frigate_config.auth.roles["limited_user"] = ["front_door"]
for image_path in (
f"{CLIPS_DIR}/review/thumb-backyard-123.456-abc.webp",
f"{CLIPS_DIR}/thumbs/backyard/123.webp",
f"{CLIPS_DIR}/faces/someone/1.webp",
f"{CLIPS_DIR}/custom/thumb.jpg",
):
response, _ = self._batch_export_with_image(image_path, "limited_user")
assert response.status_code == 400, image_path
def test_batch_export_restricted_role_accepts_own_camera_image_path(self):
self._insert_recording("rec-front", "front_door", 100, 400)
self.app.frigate_config.auth.roles["limited_user"] = ["front_door"]
image_path = f"{CLIPS_DIR}/review/thumb-front_door-123.456-abc.webp"
response, start_export_job = self._batch_export_with_image(
image_path, "limited_user"
)
assert response.status_code == 202
assert start_export_job.call_args.args[1].image_path == image_path
def test_batch_export_unrestricted_roles_accept_custom_image_path(self):
self._insert_recording("rec-front", "front_door", 100, 400)
image_path = f"{CLIPS_DIR}/custom/thumb.jpg"
for role in ("admin", "viewer"):
response, start_export_job = self._batch_export_with_image(image_path, role)
assert response.status_code == 202, role
assert start_export_job.call_args.args[1].image_path == image_path
def test_batch_export_non_admin_can_queue(self):
self._insert_recording("rec-front", "front_door", 100, 400)
@@ -0,0 +1,73 @@
"""Tests for the /auth subrequest on nginx-proxied go2rtc live-stream paths."""
import time
from unittest.mock import patch
import requests
from fastapi.testclient import TestClient
from joserfc.jwk import OctKey
from frigate.api.auth import create_encoded_jwt
from frigate.models import Event, Recordings, ReviewSegment, User
from frigate.test.http_api.base_http_test import BaseTestHttp
_CONFIG = {
"mqtt": {"host": "mqtt"},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
},
}
# shape of the source in GHSA-93xj-hg88-ffg7, with the payload replaced
_AD_HOC_SRC = "ffmpeg:%23input%3D'-f''h264''-i''data:placeholder''-c''copy'"
class TestGo2rtcProxyAuth(BaseTestHttp):
def setUp(self):
super().setUp([Event, ReviewSegment, Recordings, User])
self.minimal_config = _CONFIG
self.app = super().create_app()
self.app.jwt_token = OctKey.import_key(b"0" * 32)
get_patch = patch(
"frigate.api.auth.requests.get",
side_effect=requests.ConnectionError("go2rtc unavailable"),
)
get_patch.start()
self.addCleanup(get_patch.stop)
def _auth(self, role: str, original_url: str):
token = create_encoded_jwt(
role, role, int(time.time()) + 3600, self.app.jwt_token
)
with TestClient(self.app) as client:
return client.get(
"/auth",
headers={
"authorization": f"Bearer {token}",
"x-original-url": original_url,
},
)
def test_viewer_ad_hoc_mse_source_denied(self):
resp = self._auth("viewer", f"/live/mse/api/ws?src={_AD_HOC_SRC}")
assert resp.status_code == 403
def test_viewer_ad_hoc_webrtc_sources_denied(self):
for path in ("/live/webrtc/api/ws", "/api/go2rtc/webrtc"):
resp = self._auth("viewer", f"{path}?src={_AD_HOC_SRC}")
assert resp.status_code == 403, path
def test_admin_ad_hoc_mse_source_denied(self):
resp = self._auth("admin", f"/live/mse/api/ws?src={_AD_HOC_SRC}")
assert resp.status_code == 403
def test_viewer_camera_stream_allowed(self):
resp = self._auth("viewer", "/live/mse/api/ws?src=front_door")
assert resp.status_code == 202
assert resp.headers["remote-role"] == "viewer"
+96 -21
View File
@@ -1,10 +1,12 @@
"""Unit tests for recordings/media API endpoints."""
import threading
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from unittest.mock import patch
from zoneinfo import ZoneInfo
import pytz
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
@@ -128,16 +130,16 @@ class TestHttpMedia(BaseTestHttp):
In 2024, DST in America/New_York transitions on March 10, 2024 at 2:00 AM
Clocks spring forward from 2:00 AM to 3:00 AM (EST to EDT)
"""
tz = pytz.timezone("America/New_York")
tz = ZoneInfo("America/New_York")
# March 9, 2024 at 12:00 PM EST (before DST)
march_9_noon = tz.localize(datetime(2024, 3, 9, 12, 0, 0)).timestamp()
march_9_noon = datetime(2024, 3, 9, 12, 0, 0, tzinfo=tz).timestamp()
# March 10, 2024 at 12:00 PM EDT (after DST transition)
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp()
# March 11, 2024 at 12:00 PM EDT (after DST)
march_11_noon = tz.localize(datetime(2024, 3, 11, 12, 0, 0)).timestamp()
march_11_noon = datetime(2024, 3, 11, 12, 0, 0, tzinfo=tz).timestamp()
with AuthTestClient(self.app) as client:
# Insert recordings for each day
@@ -201,19 +203,16 @@ class TestHttpMedia(BaseTestHttp):
In 2024, DST in America/New_York transitions on November 3, 2024 at 2:00 AM
Clocks fall back from 2:00 AM to 1:00 AM (EDT to EST)
"""
tz = pytz.timezone("America/New_York")
tz = ZoneInfo("America/New_York")
# November 2, 2024 at 12:00 PM EDT (before DST transition)
nov_2_noon = tz.localize(datetime(2024, 11, 2, 12, 0, 0)).timestamp()
nov_2_noon = datetime(2024, 11, 2, 12, 0, 0, tzinfo=tz).timestamp()
# November 3, 2024 at 12:00 PM EST (after DST transition)
# Need to specify is_dst=False to get the time after fall back
nov_3_noon = tz.localize(
datetime(2024, 11, 3, 12, 0, 0), is_dst=False
).timestamp()
nov_3_noon = datetime(2024, 11, 3, 12, 0, 0, tzinfo=tz).timestamp()
# November 4, 2024 at 12:00 PM EST (after DST)
nov_4_noon = tz.localize(datetime(2024, 11, 4, 12, 0, 0)).timestamp()
nov_4_noon = datetime(2024, 11, 4, 12, 0, 0, tzinfo=tz).timestamp()
with AuthTestClient(self.app) as client:
# Insert recordings for each day
@@ -274,13 +273,13 @@ class TestHttpMedia(BaseTestHttp):
"""
Test recordings summary with multiple cameras across DST boundary.
"""
tz = pytz.timezone("America/New_York")
tz = ZoneInfo("America/New_York")
# March 9, 2024 at 10:00 AM EST (before DST)
march_9_morning = tz.localize(datetime(2024, 3, 9, 10, 0, 0)).timestamp()
march_9_morning = datetime(2024, 3, 9, 10, 0, 0, tzinfo=tz).timestamp()
# March 10, 2024 at 3:00 PM EDT (after DST transition)
march_10_afternoon = tz.localize(datetime(2024, 3, 10, 15, 0, 0)).timestamp()
march_10_afternoon = datetime(2024, 3, 10, 15, 0, 0, tzinfo=tz).timestamp()
with AuthTestClient(self.app) as client:
# Override allowed cameras for this test to include both
@@ -343,15 +342,15 @@ class TestHttpMedia(BaseTestHttp):
"""
Test recordings that span the exact DST transition time.
"""
tz = pytz.timezone("America/New_York")
tz = ZoneInfo("America/New_York")
# March 10, 2024 at 1:00 AM EST (1 hour before DST transition)
# At 2:00 AM, clocks jump to 3:00 AM
before_transition = tz.localize(datetime(2024, 3, 10, 1, 0, 0)).timestamp()
before_transition = datetime(2024, 3, 10, 1, 0, 0, tzinfo=tz).timestamp()
# Recording that spans the transition (1:00 AM to 3:30 AM EDT)
# This is 1.5 hours of actual time but spans the "missing" hour
after_transition = tz.localize(datetime(2024, 3, 10, 3, 30, 0)).timestamp()
after_transition = datetime(2024, 3, 10, 3, 30, 0, tzinfo=tz).timestamp()
with AuthTestClient(self.app) as client:
Recordings.insert(
@@ -411,7 +410,7 @@ class TestHttpMedia(BaseTestHttp):
# Test with UTC timezone
response = client.get(
"/recordings/summary", params={"timezone": "utc", "cameras": "all"}
"/recordings/summary", params={"timezone": "UTC", "cameras": "all"}
)
assert response.status_code == 200
@@ -442,8 +441,8 @@ class TestHttpMedia(BaseTestHttp):
"""
Test recordings summary filtered to a single camera.
"""
tz = pytz.timezone("America/New_York")
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
tz = ZoneInfo("America/New_York")
march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp()
with AuthTestClient(self.app) as client:
# Insert recordings for both cameras
@@ -2022,3 +2021,79 @@ class TestHttpMedia(BaseTestHttp):
assert response.status_code == 200
assert response.json() == [{"start_time": 1010, "end_time": 1030}]
def test_recording_snapshot_does_not_block_event_loop(self):
self._insert_recording("snapshot", 1000, 1010)
started = threading.Event()
release = threading.Event()
finished = threading.Event()
def slow_image(*args):
started.set()
release.wait(timeout=5)
finished.set()
return b"jpeg"
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording", slow_image),
ThreadPoolExecutor(max_workers=1) as executor,
):
snapshot = executor.submit(
client.get, "/front_door/recordings/1005/snapshot.jpg"
)
self.assertTrue(started.wait(timeout=2))
version = client.get("/version")
blocked = finished.is_set()
release.set()
self.assertEqual(version.status_code, 200)
self.assertFalse(blocked)
self.assertEqual(snapshot.result().status_code, 200)
def test_recording_snapshot_hit_returns_image(self):
self._insert_recording("snapshot", 1000, 1010)
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording", return_value=b"jpeg"),
):
response = client.get("/front_door/recordings/1005/snapshot.jpg")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], "image/jpeg")
self.assertEqual(response.content, b"jpeg")
def test_recording_snapshot_misses_return_404(self):
self._insert_recording("snapshot", 1000, 1010)
with AuthTestClient(self.app) as client:
absent = client.get("/front_door/recordings/2000/snapshot.jpg")
rounded_miss = client.get("/front_door/recordings/1010.5/snapshot.jpg")
self.assertEqual(absent.status_code, 404)
self.assertEqual(rounded_miss.status_code, 404)
def test_plus_snapshot_miss_returns_404_without_image_lookup(self):
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording") as image,
):
response = client.post("/front_door/plus/1005")
self.assertEqual(response.status_code, 404)
image.assert_not_called()
def test_plus_snapshot_hit_uploads_image(self):
self._insert_recording("snapshot", 1000, 1010)
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording", return_value=b"png"),
patch("frigate.api.media.cv2.imdecode", return_value="frame"),
patch.object(self.app.frigate_config.plus_api, "upload_image") as upload,
):
response = client.post("/front_door/plus/1005")
self.assertEqual(response.status_code, 200)
upload.assert_called_once_with("frame", "front_door")
@@ -0,0 +1,59 @@
"""Tests for notification registration and the admin row it relies on."""
from types import SimpleNamespace
from frigate.app import FrigateApp
from frigate.config import FrigateConfig
from frigate.models import User
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
SUBSCRIPTION = {
"endpoint": "https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLXRva2Vu",
"keys": {"p256dh": "cHVibGljLWtleQ", "auth": "YXV0aC1zZWNyZXQ"},
}
class TestNotificationRegistration(BaseTestHttp):
def setUp(self):
super().setUp([User])
def _init_auth(self, enabled: bool) -> FrigateConfig:
config = FrigateConfig(**{**self.minimal_config, "auth": {"enabled": enabled}})
FrigateApp.init_auth(SimpleNamespace(config=config))
return config
def _register(self):
self.minimal_config["auth"] = {"enabled": False}
with AuthTestClient(self.create_app()) as client:
return client.post("/notifications/register", json={"sub": SUBSCRIPTION})
def test_auth_disabled_fresh_install_persists_token(self):
self._init_auth(enabled=False)
assert self._register().status_code == 200
admin = User.get(User.username == "admin")
assert admin.password_hash == ""
assert admin.notification_tokens == [SUBSCRIPTION]
def test_auth_disabled_does_not_recreate_deleted_admin(self):
User.insert(
username="viewer1",
role="viewer",
password_hash="x$1$y$z",
notification_tokens=[],
).execute()
self._init_auth(enabled=False)
assert User.get_or_none(User.username == "admin") is None
assert self._register().status_code == 404
def test_enabling_auth_sets_password_and_keeps_tokens(self):
self._init_auth(enabled=False)
assert self._register().status_code == 200
config = self._init_auth(enabled=True)
admin = User.get(User.username == "admin")
assert admin.password_hash.count("$") == 3
assert admin.notification_tokens == [SUBSCRIPTION]
assert config.auth.admin_first_time_login is True
+1 -1
View File
@@ -342,7 +342,7 @@ class TestHttpReview(BaseTestHttp):
"cameras": "front_door",
"labels": "all",
"zones": "all",
"timezone": "utc",
"timezone": "UTC",
}
response = client.get("/review/summary", params=params)
assert response.status_code == 200
+33
View File
@@ -0,0 +1,33 @@
"""Tests for user creation."""
from unittest.mock import MagicMock
from frigate.models import User
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
PASSWORD = "a-valid-password-123"
class TestCreateUser(BaseTestHttp):
def setUp(self):
super().setUp([User])
self.app = super().create_app()
self.app.config_publisher = MagicMock()
def _create(self, username: str):
with AuthTestClient(self.app) as client:
return client.post(
"/users",
json={"username": username, "password": PASSWORD, "role": "viewer"},
)
def test_rejects_dot_only_usernames(self):
# browsers resolve these as URL path segments, so the per-user
# endpoints can never be reached for them
for username in (".", "..", "..."):
assert self._create(username).status_code == 400
assert User.get_or_none(User.username == username) is None
def test_accepts_username_containing_dots(self):
assert self._create("john.doe_1").status_code == 200
assert User.get_or_none(User.username == "john.doe_1") is not None
+2
View File
@@ -183,10 +183,12 @@ class TestReviewMaintainerRemoval(unittest.TestCase):
"deleted_cam": [{"model": "gate", "from": "a", "to": "b", "timestamp": 1.0}]
}
maintainer.forcibly_end_segment = MagicMock()
maintainer.frame_manager = MagicMock()
maintainer._handle_camera_removed("deleted_cam")
maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam")
maintainer.frame_manager.close_camera.assert_called_once_with("deleted_cam")
self.assertNotIn("deleted_cam", maintainer.indefinite_events)
self.assertNotIn("deleted_cam", maintainer.recent_classification_state_changes)
+35 -1
View File
@@ -1,8 +1,9 @@
"""Tests for per stream recording health tracking in the camera watchdog."""
import subprocess as sp
import unittest
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, call, patch
from frigate.config import FrigateConfig
from frigate.const import STREAM_TYPE_MAIN, STREAM_TYPE_SUB
@@ -62,6 +63,39 @@ def build_watchdog(
return watchdog
class TestCameraWatchdogReset(unittest.TestCase):
def _build_resettable_watchdog(self) -> CameraWatchdog:
watchdog = build_watchdog()
watchdog.ffmpeg_detect_process = MagicMock()
watchdog.capture_thread = MagicMock()
watchdog.capture_thread.is_alive.return_value = False
watchdog.start_ffmpeg_detect = MagicMock()
return watchdog
def test_reset_does_not_buffer_raw_frames(self):
watchdog = self._build_resettable_watchdog()
process = watchdog.ffmpeg_detect_process
watchdog.reset_capture_thread()
process.terminate.assert_called_once()
process.wait.assert_called_once_with(timeout=30)
process.communicate.assert_not_called()
watchdog.start_ffmpeg_detect.assert_called_once()
def test_timed_out_reset_kills_and_reaps_without_reading_stdout(self):
watchdog = self._build_resettable_watchdog()
process = watchdog.ffmpeg_detect_process
process.wait.side_effect = [sp.TimeoutExpired("ffmpeg", 30), 0]
watchdog.reset_capture_thread()
process.kill.assert_called_once()
self.assertEqual(process.wait.call_args_list, [call(timeout=30), call()])
process.communicate.assert_not_called()
watchdog.start_ffmpeg_detect.assert_called_once()
class TestCameraWatchdogStreamHealth(unittest.TestCase):
def _build_watchdog(
self, sub_enabled: bool = True, output_args: dict | None = None
@@ -0,0 +1,33 @@
"""Tests for object classification example extraction."""
import tempfile
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
from frigate.util.classification import _extract_event_thumbnails
class TestExtractEventThumbnails(unittest.TestCase):
def test_falls_back_past_missing_images_and_stops_at_target(self):
events = [SimpleNamespace(id=f"event_{i}") for i in range(10)]
missing = {"event_0", "event_1", "event_2"}
image = np.zeros((32, 32, 3), dtype=np.uint8)
with (
tempfile.TemporaryDirectory() as tmpdir,
patch(
"frigate.util.classification._load_event_classification_crop",
side_effect=lambda event: None if event.id in missing else image,
) as load,
):
paths = _extract_event_thumbnails(events, tmpdir, target_count=4)
self.assertEqual(len(paths), 4)
self.assertEqual(load.call_count, 7)
if __name__ == "__main__":
unittest.main()
+193
View File
@@ -0,0 +1,193 @@
"""Tests for sharing one zmq socket wrapper across threads."""
import os
import random
import tempfile
import threading
import time
import unittest
from unittest.mock import patch
import zmq
from frigate.comms import config_updater, embeddings_updater, inter_process
from frigate.comms.config_updater import ConfigPublisher, ConfigSubscriber
from frigate.comms.embeddings_updater import EmbeddingsRequestor
from frigate.comms.inter_process import InterProcessRequestor
from frigate.comms.webpush import WebPushClient
THREADS = 8
CALLS = 100
# nothing reads until the end, so the total stays under the default zmq HWM
PUBLISH_CALLS = 100
class TestSharedRequestor(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.address = f"ipc://{os.path.join(self.tmp.name, 'comms')}"
self.stop = threading.Event()
self.context = zmq.Context()
self.responder = self.context.socket(zmq.REP)
self.responder.bind(self.address)
self.responder_thread = threading.Thread(target=self._respond)
self.responder_thread.start()
def tearDown(self) -> None:
self.stop.set()
self.responder_thread.join()
self.responder.close(linger=0)
self.context.destroy(linger=0)
self.tmp.cleanup()
def _respond(self) -> None:
while not self.stop.is_set():
ready, _, _ = zmq.select([self.responder], [], [], 0.1)
if ready:
self.responder.recv_json()
time.sleep(random.uniform(0, 0.002))
self.responder.send_json(["ok"])
def _call_from_threads(self, requestor) -> list:
results: list = []
def call() -> None:
for _ in range(CALLS):
# jitter lands some calls on the instant another reply arrives
time.sleep(random.uniform(0, 0.002))
results.append(requestor.send_data("topic", {"key": "value"}))
threads = [threading.Thread(target=call, daemon=True) for _ in range(THREADS)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(30)
self.assertFalse(any(thread.is_alive() for thread in threads))
return results
def test_inter_process_requestor_waits_for_overlapping_calls(self) -> None:
with patch.object(inter_process, "SOCKET_REP_REQ", self.address):
requestor = InterProcessRequestor()
results = self._call_from_threads(requestor)
self.assertEqual(results, [["ok"]] * THREADS * CALLS)
requestor.stop()
def test_embeddings_requestor_survives_overlapping_calls(self) -> None:
with patch.object(embeddings_updater, "SOCKET_REP_REQ", self.address):
requestor = EmbeddingsRequestor()
# an overlapping call may fail fast, but the socket has to stay usable
results = self._call_from_threads(requestor)
self.assertEqual(len(results), THREADS * CALLS)
self.assertEqual(requestor.send_data("topic", {}), ["ok"])
requestor.stop()
class TestSharedConfigPublisher(unittest.TestCase):
def test_frames_stay_paired_across_threads(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
address = f"ipc://{os.path.join(tmp, 'config')}"
with patch.object(config_updater, "SOCKET_PUB_SUB", address):
publisher = ConfigPublisher()
subscriber = ConfigSubscriber("config/")
# a subscription that is still connecting drops messages
subscriber.socket.setsockopt(zmq.RCVTIMEO, 5000)
while True:
publisher.publish("config/ready", None)
if zmq.select([subscriber.socket], [], [], 0.05)[0]:
break
def publish(index: int) -> None:
topic = f"config/cameras/camera_{index}"
for _ in range(PUBLISH_CALLS):
publisher.publish(topic, topic)
threads = [
threading.Thread(target=publish, args=(i,)) for i in range(THREADS)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
publisher.publish("config/done", None)
received = 0
while True:
topic = subscriber.socket.recv_string()
payload = subscriber.socket.recv_pyobj()
if topic == "config/done":
break
if topic != "config/ready":
self.assertEqual(topic, payload)
received += 1
self.assertEqual(received, THREADS * PUBLISH_CALLS)
subscriber.stop()
publisher.stop()
class FakeConfigSubscriber:
"""Records whether two threads read at the same time."""
def __init__(self) -> None:
self.reading = threading.Lock()
self.overlapped = False
def _read(self) -> None:
if not self.reading.acquire(blocking=False):
self.overlapped = True
return
time.sleep(0.001)
self.reading.release()
def check_for_update(self) -> tuple[None, None]:
self._read()
return (None, None)
def check_for_updates(self) -> dict:
self._read()
return {}
class TestSharedWebPushClient(unittest.TestCase):
def test_config_subscribers_read_by_one_thread_at_a_time(self) -> None:
client = WebPushClient.__new__(WebPushClient)
client.config_lock = threading.Lock()
client.global_config_subscriber = FakeConfigSubscriber()
client.config_subscriber = FakeConfigSubscriber()
def publish() -> None:
for _ in range(20):
client.publish("topic", "payload")
threads = [threading.Thread(target=publish) for _ in range(THREADS)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertFalse(client.global_config_subscriber.overlapped)
self.assertFalse(client.config_subscriber.overlapped)
if __name__ == "__main__":
unittest.main()
+10
View File
@@ -75,6 +75,16 @@ class TestConfig(unittest.TestCase):
DetectorTypeEnum.cpu
)
def test_trusted_proxies_reject_invalid_networks(self):
config = {
"auth": {"trusted_proxies": ["172.18.0.0/16", "192.168.1.1/24", "fd00::/8"]}
}
FrigateConfig(**deep_merge(config, self.minimal))
config = {"auth": {"trusted_proxies": ["172.18.0.0/33"]}}
with self.assertRaises(ValidationError):
FrigateConfig(**deep_merge(config, self.minimal))
@patch("frigate.detectors.detector_config.load_labels")
def test_model_custom_path(self, mock_labels):
mock_labels.return_value = {}

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