Commit Graph
2649 Commits
Author SHA1 Message Date
Josh HawkinsandGitHub bb04e6d49d Move genai review frame extraction off the embeddings loop (#24959)
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
* move genai review frame extraction off the embeddings loop

- Run frame gathering for finished review items on a thread so per-frame ffmpeg calls don't stall the embeddings maintainer
- Share one path between live and regenerated review descriptions
- Stop padding review start and end times in place, which widened the stored review item
- Lock timestamp expiry in EventsPerSecond since update() and eps() now run on different threads

* serialize recording frame extraction for review descriptions
2026-11-13 06:45:29 -07:00
b2f5315e09 Validate MQTT TLS options before startup (#24951)
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
* Validate MQTT TLS verification options before client startup

* Test MQTT configuration recovery with authentication available

* Cover both MQTT TLS verification values in safe-mode recovery

* Use a documentation address in MQTT regression tests

* Skip MQTT TLS validation when MQTT is disabled

* Simplify MQTT TLS validation regression coverage

---------

Co-authored-by: Peter Kay <dougle.peter.kay@googlemail.com>
2026-11-12 10:04:09 -06:00
Josh HawkinsandGitHub a655ddcb2e Pin debug replay to main recording stream (#24952)
* pin debug replay to one recording stream

Debug replay stream-copied the merged VOD playlist into one mp4. When main had gaps that sub filled, the clip switched streams mid-file, and a camera with an H.265 main and an H.264 sub produced an mp4 labeled H.264 that also held HEVC packets. The replay camera's decoder then failed and the watchdog killed ffmpeg. Replay now follows the clip download rule: it pins the playlist to main and only falls back to sub when the range has no main recordings.

* choose the replay stream with the vod coverage resolver

Stream selection counted a main row that only touched the range, while the pinned playlist served nothing for it, so replay failed instead of using the sub footage. Selection now uses resolve_coverage, the same resolver the pinned vod and export use.
2026-11-12 08:33:33 -07:00
Josh HawkinsandGitHub 806ee66562 Fix sub label filter for non-ascii names (#24953)
* fix sub label filter for non-ascii names

The sub_labels filter lowercased the requested name in Python but the stored value with SQLite's LOWER(), which only folds ASCII. Cyrillic and other non-ASCII names never matched, so filtering by a face name returned no events. Both sides are now lowered in SQLite, which keeps ASCII matching case-insensitive and non-ASCII matching exact.

* cover events/search in sub label filter test
2026-11-12 08:33:04 -07:00
Josh HawkinsandGitHub 8a88cefc9a Autotracking tweaks (#24935)
* fix autotracking movement time prediction and recorded move times

Movement time prediction multiplied the fitted intercept by the saved zero-step calibration time instead of 1, so predictions didn't match the regression. In absolute zoom mode, a pan/tilt followed by a zoom recorded the zoom's timestamps against the pan/tilt distance, because _zoom_absolute resets the PTZ start and stop times. The pan/tilt times are now captured before the zoom runs.

* predict velocity and zoom size for objects approaching the autotracked camera

The velocity check required both box corners to move in the same direction, but the corners of an approaching object diverge, so its velocity was rejected and zeroed and the camera aimed where the object had been. That check is removed, and the existing magnitude, delta, and variance checks still reject noise. The zoom size prediction fit area against epoch time with no intercept, which returned roughly the mean area instead of a forecast. It now scales the target box by the growth the corner velocities predict over the movement time.

* keep predicted target box positive and add regression tests

A shrinking object's predicted size could reach zero, so the relative zoom ratio divided by zero and sent a NaN zoom to the camera. The predicted size is now floored at 1 px. Tests also cover the movement time intercept and the pan/tilt timestamps that absolute zoom overwrites.
2026-11-11 08:38:21 -07:00
Josh HawkinsandGitHub 33c55649a4 report custom go2rtc and ffmpeg builds in analytics (#24928)
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
Users can swap in their own go2rtc binary at /config/go2rtc or point ffmpeg.path at a custom build, and either can explain stream behavior that looks like a Frigate regression. The install section now reports a custom_go2rtc boolean and an ffmpeg enum (default, bundled, custom) for the configured global path, so no version strings or path text leave the install.
2026-11-09 14:05:16 -07:00
Josh HawkinsandGitHub c88434dc29 Write migrated config atomically (#24926)
* write migrated config atomically

Config migration rewrote config.yml in place with open("w") after each step, so nginx's get_nginx_settings.py, which starts alongside frigate, could read an empty file, get None from yaml.load, and crash on config.get. Each step now writes to a temp file with the original mode and os.replace's it into place.

* keep config owner and handle file bind mounts in migration writes

os.replace failed with EBUSY when config.yml was a file bind mount, as in the documented --validate-config command, and the replaced file lost its original owner when Frigate ran as root. The write now copies in place on EBUSY and restores the original owner when permitted. Tests cover a failed dump leaving the original config and the bind-mount fallback.

* follow config symlinks in migration writes
2026-11-09 13:56:34 -07:00
b2a7e992c9 Request FP16 TensorRT engine for ONNX detector with device Tensorrt (#24923)
* Request FP16 TensorRT engine for ONNX detector with device Tensorrt

get_ort_providers only enables trt_fp16_enable when requires_fp16 is
passed, and the ONNX detector never passed it, so device: Tensorrt
always built an FP32 engine. USE_FP16=False still disables FP16.

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

* Make TensorRT FP16 opt-in via USE_FP16 only

Drop the unused requires_fp16 parameter from get_ort_providers and decide
trt_fp16_enable from the USE_FP16 environment variable alone, defaulting
to false. Revert the ONNX detector change and document USE_FP16=true for
device: Tensorrt.

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

* Drop the TensorRT docs line

The stock image ships only the TensorRT runtime, not the builder
libraries needed to create engines, so the instructions were misleading.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:46:25 -07:00
81cf7be89a Add on-demand Frigate+ API key validation (#24918)
* Add on-demand Frigate+ API key validation

The Frigate+ settings page reported the API key as validated whenever it
matched the expected format, without ever checking it with the server.

- Clarify the status text to say the key matches the expected format
- Add PlusApi.validate_key(), which requests a token from Frigate+ and
  reports the key as valid, invalid, unreachable, or not set; server
  errors and timeouts are not reported as an invalid key
- Add an admin-only POST /plus/validate endpoint and regenerate the API spec
- Add a "Validate with Frigate+" button that shows the result inline

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde

* Validate Frigate+ key asynchronously and keep cached token on bad replies

- Use aiohttp for the validation request so the endpoint awaits it
  instead of blocking a worker thread
- Only replace the cached token when the reply has a string accessToken
  and numeric expires; otherwise report unreachable so an unexpected
  response can't break later Frigate+ requests
- Test against a local aiohttp server, covering timeouts, connection
  failures, non-JSON replies and unusable token shapes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde

* Honor proxy settings when validating the Frigate+ key

aiohttp ignores HTTP(S)_PROXY unless trust_env is set, so validation
reported Frigate+ as unreachable on installs that need a proxy while
other Frigate+ requests still worked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 06:30:35 -07:00
Josh HawkinsandGitHub d4ebe64fde Close config paths to command execution (#24890)
* check go2rtc stream sources on the internal port

auth() returned admin for internal port requests before the go2rtc src check ran, so anyone who could reach port 5000 could send an unknown src to /api/ws, and go2rtc created a stream from it and started its producer, including ffmpeg sources with #raw= arguments. The internal port now rejects any src that isn't an existing stream, the same as the external port, and still skips the per-camera role checks.

* reject go2rtc stream names that can inject transcode parameters

live.transcode.source names a go2rtc stream that is spliced into an ffmpeg:{name}#video=h264 source, and stream names were never validated, so a stream named with #raw= parameters, whitespace, quotes or a ${VAR} reference added its own ffmpeg arguments. New stream names with these characters are rejected through the API, transcode.source fails validation when its stream name has them, and startup skips the generated transcode stream.

* only export allowlisted environment_vars

apply_config_env_vars wrote every unprefixed environment_vars key into os.environ and exported every FRIGATE_ key, so a config could set LD_PRELOAD or LIGHTER_ANE_EP to load a library into Frigate or its children, or set FRIGATE_RUN_AS_ROOT to turn off the ffmpeg.path writable-tree guard. Only the documented names are now exported, each with a value check, and a name the container already sets is never overwritten. FRIGATE_ keys still feed substitution, but only FRIGATE_JWT_SECRET is exported. Anything else logs a warning naming the key.

* don't trust live stream names as existing go2rtc streams
2026-11-08 07:16:48 -07:00
Josh HawkinsandGitHub b55723146d Don't run config ffmpeg as root (#24891)
* don't run a config ffmpeg.path as root at boot

The frigate and go2rtc run scripts probed "$ffmpeg_path" -version as root before dropping privileges, and resolve_ffmpeg_path only refused runtime-user-writable paths when frigate itself was a root service. In the default mode uid 1000 could set ffmpeg.path to a /config directory, plant bin/ffmpeg there, and get root on the next restart, and under FRIGATE_ROOT_SERVICES=go2rtc a root go2rtc used that path as its ffmpeg bin. The probe now runs as the service user whenever the service drops privileges, and resolve_ffmpeg_path refuses writable paths for whichever service will run the binary, returning the resolved path so a symlink can't be retargeted after the check. go2rtc falls back to the bundled build's libavformat version if its user can't run the custom build.

* resolve the full ffmpeg path under granular root
2026-11-08 07:15:02 -07:00
Blake BlackshearandClaude Opus 5.5 b10c8b7498 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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4iDiYSpWJD13YQUdNKC5o
2026-11-07 07:49:43 -06:00
Josh HawkinsandGitHub 5d3b955bcb don't race the capture thread when stopping ffmpeg (#24884)
stop_all_ffmpeg called communicate() on the detect ffmpeg even when the capture thread was still reading stdout, which happened on every ffmpeg, record, and record_sub config restart and when a camera stalled during a stop. The two readers could buffer duplicate frames without bound or hang the stop. When the capture thread is still alive, it now terminates and waits, then kills, so the live thread drains the pipe and ffmpeg can finish its segment. Disable and shutdown still use stop_ffmpeg.
2026-11-07 06:32:25 -07:00
Martin WeineltandGitHub 0e90e8ee99 Reuse constants in media auth tests (#24625)
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-11-06 08:48:36 -06:00
463942e01d Measure Gemini image-token cost instead of using the pixel heuristic (#24469)
* Measure Gemini image-token cost instead of using the pixel heuristic

Review descriptions size their frame budget from the provider's
estimate_image_tokens(). Gemini did not override it, so the base
~1-token-per-1250-pixels heuristic was used: about 46 tokens for a 320x180
preview frame. Gemini 3 models actually bill about 1100 tokens per image
regardless of size, so the 24000-token image budget let Frigate send one
frame per second of the review item (e.g. 90 frames for a 90 s alert)
instead of ~21, and review descriptions cost roughly 4x more than intended.

Probe the model with the free count_tokens endpoint, the same way the
llama.cpp provider measures its mmproj, and cache the result per image
size. Fall back to the base heuristic if the request fails.

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

* Bound Gemini image-token probes and back off after failures

estimate_image_tokens() runs on the enrichment thread, and count_tokens was
using the client's defaults: a 120 s timeout and 3 attempts on 429/5xx. A
hung endpoint could stall review processing for minutes per review, and a
rate-limited key retried on every review of an unmeasured size.

Give the probe a 5 s timeout with no retries, and after a failure use the
heuristic without probing again for 5 minutes.

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

* Update docs to note that Gemini charges roughly-flat token cost regardless of video resolution

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 07:47:44 -07:00
Josh HawkinsandGitHub 4eb60af5a7 Implement opt-in analytics (#24440)
* Add analytics opt-in setting, prompt notice kind, and notice watermarks

* Add the analytics report schema

* Add the analytics state file

* Add the install and hardware analytics collectors

* Add the detection analytics collector

* Add the cameras analytics collector

* Add the features analytics collector

* Add the health analytics collector

* Build analytics reports from the section collectors

* Add the analytics report transport

* Send the daily analytics report from the main process

* Add the analytics preview API

* Generate the analytics schema and check it in CI

* Record the image variant in each Dockerfile

* Show the analytics preview in telemetry settings

* Document anonymous analytics

* change wording

* fixes

* update network requirements docs

* adapt analytics to free-form model scenes, split notice counts, and peewee 4

* fixes

* change log levels

* docstrings

* docstrings

* honor file-only analytics opt-outs and skip pre-consent notices

An opt-out saved through the YAML editor's "Save only" or a hand edit never reached the live config, so reports kept going out under the old install ID until a restart. The reporter now reads telemetry.analytics from the config file when a report is due, validated with TelemetryConfig so yes, on, and 1 still count as consent, and an opt-out there deletes the state file and skips the send. The first report under a new install ID also carried every notice since the counters started, including ones from before the opt-in, so a fresh state file now resets the notice watermarks before the report's snapshot.

* take the notice baseline at opt-in instead of the first send

The watermark reset ran at the first send attempt, so notices raised between the opt-in and that send, up to 45 minutes after a startup, were absorbed into the reset and never reported. The reporter now writes the new install ID and resets the watermarks as soon as it sees consent without a state file.

* report notice counts cumulatively from a database baseline

Moving watermarks after each accepted send kept the install ID and the notice baseline out of step: pre-consent notices went out, notices raised before the first send were dropped, and a send that finished after an off/on toggle overwrote the new identity's baseline. health.notices now carries cumulative counts since the install ID was created. Creating an ID copies the lifetime counts into the existing reported_* columns before the state file names it, and nothing is written after a send, so the backend diffs consecutive reports per install ID.

* write held notice repeats before the analytics baseline reset

Repeats of batched kinds like failed_login wait in memory until the stats emitter flushes them, so a baseline reset taken while some were held left them out, and the next flush counted those pre-opt-in repeats as post-consent. The reset now writes held repeats to the notice history under the same lock before copying the counts.

* seed detector inference speed from the first inference

The average started at 0 so idle detectors read 0, but that made every detector's speed climb from 0 for its first couple dozen inferences after a start. Detectors now use the same InferenceSpeed helper as enrichments, so the first inference seeds the average and idle detectors still read 0.
2026-11-06 07:43:38 -07:00
Josh HawkinsandGitHub 1cb5ec4107 Miscellaneous fixes (#24878)
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
* don't calibrate or skip capability checks when autotracking is enabled at runtime

Enabling autotracking from the UI on a camera that had been disabled for missing FOV relative movement or MoveStatus skipped both checks because ONVIF was already initialized, then ran calibration on the object processing thread and hung waiting for a motor stop that never came. Setup now runs the capability checks every time, only calibrates at startup, stops tracking the triggering object when setup disables the camera, and publishes the OFF state so the toggle reflects it.

* only show settings reset buttons when the reset can succeed

Reset to Global and Reset to Default delete the section from config.yml, and the delete raised a KeyError (HTTP 500) when the key wasn't in the file. Camera live sections always read as overridden because the backend fills in live.streams per camera, and the global Reset to Default button showed for every section, including ones the user never wrote. Global reset now only shows when the section differs from its schema defaults, ignoring fields the backend resolves at runtime (ffmpeg and record export hwaccel_args, audio filters). Camera live overrides skip streams, ffmpeg overrides skip inputs, and reset is hidden for camera ffmpeg and detection models, where deleting the section can't produce a valid config. Birdseye modes uses a plain default so the schema carries it.

* Added an e2e spec covering reset visibility for default and customized global sections, the runtime-resolved fields, camera live overrides, and camera ffmpeg
2026-11-04 16:23:27 -07:00
Nicolas MowenandGitHub 18a2eccab1 Refactor to use async openai (#24874) 2026-11-04 15:38:11 -06:00
Nicolas MowenandGitHub be73c2bbc9 Fix reading labels when there are multiple sub labels that match (#24873)
* Fix reading labels when there are multiple that match

* Pull from event DB directly
2026-11-04 15:37:44 -06:00
GeoandGitHub 169c5e1303 fix(genai): run blocking OpenAI chat calls in worker threads (#24868)
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
* Don't block the event loop in OpenAI chat streaming

* Close OpenAI stream opened after the caller disconnected

* Show GenAI worker thread usage in system metrics

* Remove GenAI thread stats per review

* Use Starlette iterate_in_threadpool for OpenAI stream
2026-11-04 10:32:16 -07:00
Nicolas MowenandGitHub 2b4397bcba Convert sensitive wall clock usages to monotonic (#24864)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-11-02 16:33:55 -06:00
Nicolas MowenandGitHub 47ea942590 Optimize review summaries for consistency (#24854)
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-11-02 07:43:14 -07:00
Josh HawkinsandGitHub 96ce75cf98 Miscellaneous fixes (#24846)
* miscellaneous fixes

- keep base path in recording share links
- reject non-positive scale on recordings/unavailable, which looped forever on the event loop
- reject negative vod event padding, which cleared has_clip
- clamp negative preview gif/mp4 offsets
- don't delete exports that are still in progress
- hash on unknown login usernames so timing doesn't reveal valid users
- include the first minute of the events time-of-day filter
- show an error when the push subscription is refused
- don't wipe an unnamed known plates entry when Add is pressed again
- use elapsed hours for export last N hours so DST doesn't shift the range

* fix live audio settings and trigger highlights

- start each camera group's audio from that group's saved settings instead of merging every group
- don't clear the previous trigger's highlight timeout when another trigger fires

* address review feedback

- Discard the export row when an export fails before or during ffmpeg, so it doesn't stay in progress until a restart
- Skip the empty thumbnail path when discarding a failed export
- Measure preview clip duration from the clamped start
- Default live player volume to 1 when the group has no saved setting
- Clear the unsaved registration message when notification registration fails
- Keep a highlight timer per trigger
- Remove the severity order_by in /review, which the start_time order_by replaced

* Hold pandas 3.1 until numpy 2

* fix two-way talk shortcut and detail stream active item after camera switch

* show already running message when a media sync is in progress

* pick the earliest overlapping preview for preview gif and mp4

* unmute camera audio when starting two-way talk from the shortcut

* remove password strength meter and fix confirm password feedback

The strength meter still scored against the old 8 character and character class rules, so any password with a digit, like test1234, showed a full green "Very Strong" bar next to the failing 12 character requirement. The meter is gone and the length checklist is the only feedback. The password field's form message duplicated the checklist, and editing the password never revalidated the confirm field, so a fixed mismatch kept showing "Passwords do not match". Editing the password now revalidates the confirm field once it has a value, and both dialogs show a live match/mismatch indicator in place of the confirm form message.
2026-11-02 08:11:27 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Josh Hawkins
e82dc7ad13 Update google-genai requirement from ==2.32.* to ==2.34.* in /docker/main in the pydantic-genai group (#24839)
* Update google-genai requirement

Updates the requirements on [google-genai](https://github.com/googleapis/python-genai) to permit the latest version.

Updates `google-genai` to 2.34.0
- [Release notes](https://github.com/googleapis/python-genai/releases)
- [Changelog](https://github.com/googleapis/python-genai/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/python-genai/compare/v2.32.0...v2.34.0)

---
updated-dependencies:
- dependency-name: google-genai
  dependency-version: 2.34.0
  dependency-type: direct:production
  dependency-group: pydantic-genai
...

Signed-off-by: dependabot[bot] <support@github.com>

* Remove type ignores google-genai 2.34 no longer needs

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-11-01 07:55:33 -06:00
Josh HawkinsandGitHub a0b8d40a66 guard relativemove failures (#24835)
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-31 14:15:24 -06:00
Josh HawkinsandGitHub 9b0aa75312 Add configurable notification TTL (#24829)
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
* add configurable ttl for active alert notifications

Notifications sent while an alert was active always used a TTL of 0, so the push service dropped them if the device wasn't reachable at that moment and users only received the notification sent when the alert ended. A new `ttl` notifications option, settable globally or per camera, lets the push service retry these notifications for that many seconds. It defaults to 0, and the settings UI warns that a non-zero value can deliver stale notifications.

* make notification ttl a global advanced setting

- Read `ttl` from the global notifications config and hide it on the camera form
- Show it under Advanced Settings on the Notifications page
- Remove extra padding that misaligned the Notifications cards with the rest of the form
- Shorten the ttl warning

* tweaks

* use enum instead of raw seconds value
2026-10-30 12:52:22 -06:00
Nicolas MowenandGitHub 3dd59d9a44 Optimize Python Tests (#24823)
* Optimize tests

* Address comments

* Fix test
2026-10-29 12:39:21 -05:00
Nicolas MowenandGitHub bbf23ccce7 Refactor TFL so code is shared among all detectors (#24822)
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-29 08:49:18 -06:00
Josh HawkinsandGitHub 4953c19a71 Miscellaneous fixes (#24820)
* keep base path in logout redirect

* store manual event sub labels as verified objects

Manual events created with a sub label were stored in the review item as "label: sub_label", so review previews and collapsed History cards showed a generic icon, the review label filter missed them, and the sub label never reached sub_labels for chips, notifications, or GenAI prompts. The object processor now publishes the label and sub label separately, and the review maintainer stores them as label-verified plus a sub_labels entry, the same as tracked objects.

* only expire snapshots for events that still have one

Snapshot expiry selected every event older than snapshot retention, including events whose snapshot was already deleted, so with snapshot retention shorter than alert/detection retention the same missing files were unlinked again every cleanup cycle until the event row expired. That was nearly free on local disk but caused constant metadata load on network shares. The expiry queries now filter on has_snapshot so each snapshot is deleted once.
2026-10-29 09:04:16 -05:00
Gleb NikiforovandGitHub 4f03480afd Fix audio worker startup after enabling a disabled camera (#24806)
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
* Fix audio worker startup after enabling a disabled camera

* Document audio lifecycle test helpers

* Focus audio startup coverage on the enabled update regression
2026-10-26 17:44:48 -05:00
Josh HawkinsandGitHub 9769741713 Set modified time on case zip entries (#24803)
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
* set modified time on case download zip entries

Case download zip entries were written with the zip format's 1980 placeholder date, so extracted files showed a blank or 1980 modified date. Each entry now uses the export's recording start time, converted with the UI timezone since zip times have no zone and extractors read them as local time.

* clamp case zip entry dates to 1980
2026-10-26 13:17:48 -05:00
Josh HawkinsandGitHub 0dc05305ae Improve History playback after camera audio dropouts (#24793)
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
* skip vod clips that start after their audio ends

When a camera's audio dropped out, a recording segment could end its audio early. If a playback range started past that point in the segment, nginx-vod saw no audio track in the first clip, failed the whole range with a track count mismatch, and left the player spinning for up to an hour of footage. Recording now stores each segment's audio length alongside its keyframe index, from the same ffprobe pass, and plan_clip skips a clip whose window starts at or after its audio ends when the range has other clips. A range made of only that clip still plays video-only, and legacy rows without the new column are unchanged.

* keep audio skip consistent with playback

Clips were skipped even when the range would be served video-only, and seeking into a skipped span landed on later footage. The skip now only applies when every row in the range carries audio, and seeks inside a zero-duration span land on the next clip.
2026-10-26 06:25:21 -06:00
Josh HawkinsandGitHub 94e570017c Check ownership before stopping a camera watch from chat (#24776)
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
* check ownership before stopping a camera watch from chat

The stop_camera_watch chat tool cancelled the active VLM watch job without checking who started it, so any authenticated user could cancel another user's job by calling /chat/completion directly. The tool now uses the same admin-or-creator check as DELETE /vlm/monitor, and chat-started jobs record their creator so non-admins can still stop their own.

* Added a test that calls `_execute_start_camera_watch` and checks `start_vlm_watch_job` receives the caller's username, so removing that argument now fails it.
2026-10-26 06:23:53 -06:00
Josh HawkinsandGitHub 7d4b87946e Miscellaneous fixes (#24790)
* stagger the daily region grid rebuild across cameras

Every camera requested its region grid at exactly 02:00, so the requests queued up in the dispatcher and held up recording segment inserts behind them. Each camera now picks a random offset of up to an hour.

* only log ffmpeg crashes when restarting

* round preview skeleton corners to match the player
2026-10-25 18:13:00 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Josh HawkinsClaude Opus 5.5
113d499a61 Update fastapi requirement from ==0.153.* to ==0.159.* in /docker/main (#24783)
* Update fastapi requirement from ==0.153.* to ==0.159.* in /docker/main

Updates the requirements on [fastapi](https://github.com/fastapi/fastapi) to permit the latest version.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.153.0...0.159.1)

---
updated-dependencies:
- dependency-name: fastapi
  dependency-version: 0.159.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Move enum param hints to json_schema_extra for fastapi 0.159

FastAPI 0.159 no longer accepts extra keyword arguments on Path() and
Query(), so passing enum= raised a TypeError on import.

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 08:09:44 -05:00
61fd753aed Add accelerated input transform support to zmq detector protocol (#24731)
* Let the zmq detector skip the input transform when the detector applies it

_transform_input converts the frame to the model's layout and dtype before
the detector sees it. For a remote detector that can apply the transform
itself, often on a GPU, that work is done twice over and the tensor is sent
in its most expensive form.

The detector now reports input_transform_offload in the model handshake. When
it does, Frigate sends the frame as captured (uint8 NHWC) and adds the model's
expected input_tensor and input_dtype to the request header, so the detector
can apply them. The model config is unchanged either way: the expected format
is relayed, not overridden.

The choice is read per frame rather than cached, because the detector redoes
its handshake on a timeout or socket error, so restarting or upgrading it
while Frigate runs already works and keeps working here.

Negotiated rather than configured, for two reasons. The detector is what knows
whether it can apply the transform, and a user who merely believed it could
would send unconverted frames to a model that cannot take them. It also needs
no config surface, which the device string format has no room for.

On a 640x640 region, measured on a Ryzen 9 5900XT, the host-side cost of
handing the detector a frame drops from about 4 ms to under 0.1 ms per
detection, and the payload from 4.9 MB to 1.2 MB. Nearly all of it is
tobytes(): np.transpose returns a view and astype keeps its strides by
default, so the transpose is deferred to serialization, where it becomes a
strided gather over the whole float32 tensor. The YUV to RGB conversion is
unaffected and stays on the host.

* Test that the handshake decides whether the input transform is applied

Parameterized over the three handshake values against the header the detector
ends up sending: reported, the frame goes over as captured uint8 NHWC with the
model's input_tensor and input_dtype declared; not reported or absent, the
existing transform runs and those fields are omitted.

A second case drives a real reconnect -- a timed out request, then a handshake
from an upgraded detector -- and checks the next frame follows the new answer
rather than the one from startup.

---------

Co-authored-by: Eric <eric@localhost>
2026-10-23 06:26:47 -06:00
Josh HawkinsandGitHub 6e185ed36e Keep post capture after review items end (#24726)
* keep post capture segments after a review item ends

The recording maintainer only loaded review items whose end_time was after the oldest cached segment, so once an item closed in the database the segments in its post_capture window no longer matched it. With continuous recording off and no motion after the item, everything past roughly the review cutoff_time was deleted from the cache instead of being kept for the full post_capture. The query bound now subtracts the camera's largest post_capture, and the per-item check still decides the exact cutoff.

* fix mypy and add docstring
2026-10-23 06:25:21 -06:00
Josh HawkinsandGitHub a85526187e check every overlapping review item before dropping a recording (#24728)
Recording retention around review items applied only the first review item, in start order, whose pre and post capture window covered a segment. When an alert and a detection were close together, a quiet segment covered by both was dropped by the detection's motion mode without checking the alert's all mode. The loop also stopped at a review item that started too late to reach the segment, even when a later alert with a longer pre_capture did reach it. Both the recording maintainer and cleanup now check every review item whose window covers the segment and keep it if any of their retain modes would.
2026-10-23 06:22:47 -06:00
Josh HawkinsandGitHub f385065981 Miscellaneous fixes (#24753)
* materialize event counts in metrics endpoint

The metrics endpoint stored a lazy peewee query on the shared Prometheus collector, and collect() ran it later. Overlapping scrapes run in separate threadpool threads, so two requests could execute and step the same query's sqlite cursor at once. The rows are now read with list() before they reach the collector.

* fix fps overflow restarting ffmpeg after 3 seconds instead of 3 retry intervals

This is a regression from 0.18. The watchdog has restarted ffmpeg after 3 consecutive fps overflow checks since #13651, which was 3 retry intervals (30s by default) when the loop ran every retry_interval. #21297 moved the loop to 1s ticks without converting the count, so the window shrank to 3s. camera_fps is a 10s average, so one burst of buffered frames after a stream hiccup could keep it over the limit long enough to restart a healthy camera, and the next reconnect burst could repeat it. The threshold is now 3 * retry_interval ticks. When the count filled while restarts were still rate limited, the watchdog logged "Exiting ffmpeg...", marked the camera offline and reset the count without restarting. The check now waits for can_restart, so the log, status and restart always happen together.

* keep recording cleanup running after a failed pass

An exception anywhere in a cleanup pass, such as an OSError from a read-only or unmounted storage path, ended the recording cleanup thread with nothing logged. Retention stopped until restart, and recordings, previews and database rows kept growing. Each pass now logs the exception and the loop continues.

* expose frigate_camera_events as a gauge

The metric reports the current count of events in the database, which drops when retention prunes old events. As a counter, each prune read as a counter reset, so increase() and rate() inflated event counts. The series name changes from frigate_camera_events_total to frigate_camera_events. The test's porch/inside assertion was missing its `in event.text` and always passed.

* keep uvicorn idle connections open longer than nginx's upstream keepalive

uvicorn closed idle connections after its 5s default while nginx kept them pooled for 60s, so a request nginx sent on a connection uvicorn was closing got a 502. nginx retries that on a fresh connection for most methods, but not for POST or PATCH.

* snapshot websocket clients under the manager lock in the output loop

The output loop iterated ws4py's live websocket dict without its lock, so a client connecting or disconnecting mid-iteration raised "dictionary changed size during iteration" and killed the output loop. It now copies the clients under manager.lock once per frame and skips terminated ones, matching the jsmpeg and birdseye broadcasters.

* only cache preview frames when recording is enabled

The segment-end check tested the record config object instead of record.enabled, and the first frame after startup was written unconditionally. With recording disabled, the first segment still cached frames and encoded a preview.

* fix untranslated unknown resolution text in camera info dialog

* detect all AMD GPUs from vainfo

The vainfo check only matched "AMD Radeon Graphics", so drivers reporting names like "AMD Radeon Vega 8 Graphics", "AMD Radeon RX 6600", "AMD Custom GPU 0405", or older codename strings fell through and their GPU stats were collected as Intel. It now matches Mesa's "for AMD" driver string.

* return a 500 instead of raising when go2rtc is unreachable

The two go2rtc stream GETs had no exception handling or timeout, so a go2rtc outage raised a connection error with a traceback, and a hung go2rtc held the request open indefinitely. Both now catch request errors, log the cause, and use a timeout. The per-stream probe gets a 35s read timeout because go2rtc probes each source in turn, and one unresponsive source takes about 10s.

* retry camera snapshots after a failed load

AutoUpdatingCameraImage only scheduled the next frame from onLoad, so a single failed latest.webp request froze the image until the component remounted. It now retries on error, dropping the periodic cache key so the retry requests a fresh URL.

* reuse pooled upstream connections for nginx auth subrequests

The /auth subrequest proxied to a variable address, which nginx can't pool, and its location-level headers dropped the inherited Connection header so nginx sent Connection: close. Every authenticated request opened and closed its own loopback connection to the API. It now proxies to the frigate_api upstream with an empty Connection header so the keepalive pool is reused.
2026-10-23 06:20:01 -06:00
Josh HawkinsandGitHub ed0f7c34c7 restrict go2rtc ${VAR} expansion in stream sources (#24734)
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
go2rtc expands ${NAME:default} over the raw config text before parsing it, and falls back to the inline default when NAME is unset, so a stream source like ${NOPE:exec:<cmd>} passed the exec gate and go2rtc then ran the command. On master the API accepted the plain form, and startup accepted the ${{...}} form because .format collapsed it to ${...} after the check would have caught it. Any stream source containing ${ is now restricted unless GO2RTC_ALLOW_ARBITRARY_EXEC is set, the same as exec sources.
2026-10-20 17:09:22 -10:00
Patryk OrwatGitHubPatryk Watkowskigreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
91e91c68ef Add Axelera Metis NPU detector plugin v.0.19 (#24270)
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
* Add Axelera Metis NPU object detector plugin

Runs Frigate inference on the Axelera Metis NPU through the vendor's
low-level axelera.runtime API with pip-installed, sha256-pinned wheels
from Axelera's public artifactory (self-contained: auditwheel-repaired
libs + bundled firmware).

- async send_input/receive_output contract (FIFO pairing, overload emits
  zero rows instead of raising, frames are copied out of camera shm)
- generic YOLO-family decoder driven by the compiled model's manifest.json
  (padded input geometry, dequant params, baked postprocess graph via onnx)
- labels come from the compiled model's model_info.json; empty labelmaps
  fail loudly at init

* object_detection: async runner path for the axelera detector

* detectors: detect_axelera probe (/dev/metis*) + device node init

* stats: Axelera Metis board temperature poller

The card exposes no utilization counter usable next to a live detector, so
the poller reports the board-controller temperature read via axcmd (cached
5s; the read must not race detector init).

* docs: Axelera Metis detector (install, config, model catalog, downloads)

* axelera: expose Metis colon-name device node and image deps for runtime enumeration

The Axelera runtime enumerates Metis cards via the colon-separated node
name created by the vendor udev rule on the host (e.g. /dev/metis-0:1:0)
and probes kernel state with lsmod/lspci. Containers only receive the
raw /dev/metis-0-1-0 node, so the detector never found the card.

- init-devices: create /dev/metis-BB:DD:F aliases for each Metis node
- install_deps: add kmod and pciutils so the runtime's driver check works

* axelera: review round 2 (downloader, stop release, pixel format, docs size, per-class NMS)

- download model zips through ModelDownloader (atomic .part rename; a
  truncated download no longer poisons the cache)
- stop(): release the Context (releases connection/model/instance); the
  previous instance.free() call never existed in the runtime API and was
  silently swallowed
- honor input_pixel_format: flip to RGB planes only when Frigate delivers
  BGR (input_pixel_format: bgr); with rgb the frame passes through as-is
- docs/catalog: width/height = compiled 640x640 input (square regions no
  longer get squashed twice), document input_pixel_format: bgr
- NMS via xyxy_to_xywh_for_nms + per-class cv2.dnn.NMSBoxes, matching the
  other detector plugins instead of a hand-rolled class-agnostic pass

* axelera: report Metis temperature only on the NPU card

The same board readout was surfaced twice on the System page: once as
the detector temperature (get_hardware_temperatures) and once on the
NPU card (npu_usages). Keep the NPU card, where the value belongs.

* axelera: report AIPU utilization as the detector run duty cycle

The runtime exposes no device-side usage counter (mvm_utilisation is a
configuration limit, not a readout), so the NPU card rendered empty.
ModelInstance.run blocks until the AIPU completes: the detector process
now times each run and publishes busy/wall over a 2s window through a
small file under the tmp cache dir; the stats poller reads it and sets
the npu key. A stale file (detector idle/restarting) reports nothing
rather than a stale number.

* axelera: cut hot-path allocations in preprocess and dequantise

Preprocess reused three fresh arrays per frame (resize output, quantised
copy, strided channel flip at write time) and refilled the whole padded
input buffer. Now resize writes into a scratch with cv2 dst=, the BGR->RGB
swap is a cvtColor into scratch (no strided copy), the int8 quantise runs
with out=, and only the border strips the content rect does not cover get
memset. Measured 39% faster on the 704x576 -> 640x640 path.

Dequantise allocated two fp32 temporaries per head (astype then the
dequant expression) before the transpose copy. copyto now casts int8->fp32
while transposing straight into a reused fp32 feed buffer, and the zp/scale
math runs in place. Measured 78% faster for the YOLOX-S head set, outputs
bit-identical (int8->fp32 cast is lossless).

Argmax+gather replaces max+argmax (one class-axis reduction instead of
two), and the postprocess session pins intra_op_num_threads=1: one frame
is in flight through the decoder, extra ORT threads only add context
switches.

* axelera: publish the run duty cycle through a shared Value

Replace the tmp-file duty-cycle handoff (and the FRIGATE_TMP env var
nothing else reads) with a third multiprocessing.Value alongside
avg_inference_speed and detection_start: ObjectDetectProcess creates it,
AsyncDetectorRunner passes it to the detector, and the plugin publishes
busy/wall of the blocking ModelInstance.run over a 2s window, mirroring
how memryx receives its shared state. No filesystem path, no staleness
window, no atomic rename, and stats/hardware.py no longer imports the
plugin module to find the file.

The producer also ticks the window while the queue is empty, so the duty
cycle falls to 0 when the card idles instead of sticking at its last
value.

Name it what it is: host-side occupancy of the run call, including DMA
and driver time, not an AIPU core counter (the runtime exposes no
device-side one). The NPU entry now reads temperature from the board
controller plus the detector-reported run duty cycle folded in where
the detector processes are in reach.

* axelera: enumerate devices explicitly and fail with an actionable error

Context.list_devices() keys off the lspci BDF and needs the colon-form
node to be present, so surface an empty enumeration at init with the
fix-it hint instead of a bare device_connect(None) ConnectionError
several frames deeper. Uses the DeviceInfo the runtime enumerated
rather than relying on the pick-first implicit path.

* axelera: deliver every accepted result under backpressure, reset duty cycle on restart

Greptile P1: _push_result dropped a completion when the result queue was
full, stranding the runner's send_times entry and the input SHM mapping
(1 submission : 1 result contract). Replace the drop with a stop-aware
blocking put so an accepted submission always produces exactly one
delivered completion.

Greptile P2: start_or_restart did not clear run_duty_cycle when starting
a replacement process, so stats could show the previous process's duty
cycle while the new detector was still initializing. Reset it to the
-1.0 sentinel alongside detection_start, inside the async branch.

Greptile P2 (offline docs): list axelera among the detector types whose
pre-seeded runtime files live under /config/model_cache/runtimes/<type>.

* axelera: reviewer round 3 (plugin-owned enumeration tools, generic DEVICE_ALIASES, generic duty-cycle fold)

install_deps.sh: drop kmod/pciutils from the base image. The runtime's
list_devices() only greps popen'd lspci/lsmod output over /sys and /proc,
so the plugin now installs shell stand-ins in its own
/config/model_cache/axelera/bin and prepends it to PATH when the real
tools are missing (hardware-verified: the stand-in lspci prints the
Device 1f9d:<id> line the runtime matches, the lsmod shape matches
/proc/modules).

init-devices: replace the Metis-keyed alias block with a generic
DEVICE_ALIASES env var (comma separated alias=target pairs, next to
DEVICE_ACL_PATHS). Docker rejects colon-named nodes in --device AND in
bind mounts (probed: 'too many colons' on both the long volume form and
the mount form), so the in-container symlink stays necessary; docs carry
the pair users must set.

stats/util.py: fold any detector's published run_duty_cycle into the
matching npu_usages entry generically; _axelera_run_duty_cycle and the
per-vendor branch are gone.

plugin: the no-device RuntimeError now names the raw dash-form node
(--device rejects colons, users can only pass the raw node) and points
at DEVICE_ALIASES for the colon alias.

* axelera: reviewer round 4 (aggregate shared-type duty cycles, american spellings)

stats/util.py: several detector processes can share one hardware type (e.g.
two axelera cards on one host). Iterating and assigning made the displayed
NPU usage depend on dict order, last writer wins. Collect the published
windows per type and write the busiest one, matching what the removed
per-vendor helper did with max().

plugin: the remaining British spellings (dequantise family) corrected to
the American forms the repo requires.

plugin: model path resolution follows hailo8l's single-string pattern (URL
or local path or preset name in one value), the download uses urllib
directly like memryx, and _find_model_json now raises on an ambiguous
multi-model directory instead of returning whichever sorts first.

plugin: AxeleraDetector exposes shutdown() (the async contract hook base.py
calls) instead of close(); __del__ guards a partially initialised instance.

* axelera: download URL models through the atomic ModelDownloader

* axelera: bring the detector unit tests in tree

* init-devices: create DEVICE_ALIASES before the ACL-mode exits

An alias is not an ACL grant: users running with FRIGATE_RUN_AS_ROOT or
FRIGATE_DEVICE_ACLS=false still need the colon-form node the runtime
enumerates, so create the aliases right after the euid check. Document
DEVICE_ALIASES next to DEVICE_ACL_PATHS and note it cannot work under
docker's user: mode (the container cannot write to /dev).

* detectors: cover the Axelera Metis probe in test_detector_hardware

* Update docs/docs/configuration/non_root.md

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* axelera: address the 2026-10-12 review round

- hash directory models deterministically in compute_model_hash instead of
  raising IsADirectoryError at startup
- keep canonical model labels addressable after user renames in the
  labelmap lookup (two classes can share one renamed name)
- key the URL model cache by source and archive content, extract replaced
  archives into fresh directories, prune superseded variants conservatively
- keep publishing the run duty cycle when the hardware poll has no entry
  (duty-only npu_usages entry instead of a hidden detector)
- document the user: mode device reach instead of host-only alias creation

* axelera: prune only generated cache entries

A fresh download matched user-provided layouts (the docs-recommended
<name>/ directory, <name>.zip archives) and look-alike model names
(yolox.zip vs yolox-s-*) by prefix. Pruning is now gated on the exact
generated identity pattern <name>-<source>-<32 hex digits>, so downloads
never touch user-provided directories, other models, or other sources.

* axelera: narrow the model path design to memryx-style zips

- path is always a .zip of the compiled output (local or http(s) URL),
  extracted fresh on every start under a lock, like the memryx
  custom-model flow
- _decode returns the raw class index and Frigate's labelmap names it;
  _build_label_lookup and _read_model_labels are gone, and the YOLOX
  objectness check keys off model_type
- scheme-less URL support and the quantized/manifest.json fallback are
  dropped
- width/height must equal the compiled input size; init raises otherwise
  instead of letterboxing
- compute_model_hash is back to the unchanged file-only hashing; the
  .extracted marker, source/content cache-name hashes and
  _prune_model_cache are removed

* Update frigate/test/test_axelera_detector.py

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* axelera: drop the run duty cycle, match the hailo temperature flow

- no other accelerator reports NPU usage and the published value was time
  blocked in run(), not device utilization from the vendor API; removing
  it leaves the runner selection as the only core change
- the board temperature moves to a get_hardware_temperatures branch like
  hailo, so it renders on the detector card; _poll_axelera, its POLLERS
  entry and the _scan_detectors line are gone
- axelera_runtime2 is not installed: axelera.runtime imports it only
  inside a try for its separate new-runtime API, and the low-level API
  the plugin uses (Context, load_model, device_connect,
  load_model_instance) lives in axelera.runtime.objects

* axelera: follow-ups from the 2026-10-13 review round

- the offline-setup table no longer lists the axelera_runtime2 wheels,
  which are not installed
- a failed board temperature read starts a 60s cooldown so an
  unresponsive axcmd does not delay every stats update

* axelera: drop the model-hash test, fix the model.json error messages

* axelera: drop DEVICE_ALIASES, document the CDI device spec

* axelera: use ln -sf in the CDI hook example

---------

Co-authored-by: Patryk Watkowski <patrykorwat@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-10-20 09:53:28 -05:00
Josh HawkinsandGitHub 5bb2cde8b3 Reject cross-origin websocket connections (#24714)
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
* reject cross-origin websocket handshakes

Neither /ws nor the jsmpeg websockets checked Origin, and the internal port grants admin to everyone, so any page a LAN user visited could open ws://frigate:5000/ws and send commands or read streams. Both servers now reject a browser handshake whose Origin host matches neither Host nor X-Forwarded-Host, and allow handshakes without an Origin for non-browser clients. nginx now forwards an outer proxy's X-Forwarded-Host instead of overwriting it, so proxies that rewrite Host and the Home Assistant integration's proxy still match.

* apply runtime role changes to jsmpeg streams

The output process never subscribed to config/auth, so role edits from the Roles UI (requires_restart 0) did not reach it and jsmpeg kept streaming cameras that had been removed from a role until restart. The output process now replaces its auth config on config/auth updates, which the camera and birdseye broadcast threads read through the shared config.

* tidy origin check docstring and auth update comment

* reject cross-origin go2rtc stream requests in /auth

The go2rtc websockets and the webrtc offer POST still accepted any Origin, since go2rtc runs with origin "*" and /auth grants admin on the internal port, so a page on another site could pull live video through a LAN user's browser. nginx now passes Origin, Host, X-Forwarded-Host and Upgrade to /auth, which rejects a websocket upgrade or go2rtc stream request with a foreign Origin before the internal-port early return. The hostname rule moves to frigate/util/origin.py so /auth and the ws4py servers share it.

* set Host in the nginx proxy doc examples

* drain all queued auth updates in the output process

* note that cards must stream through the integration proxy

* strip the query before matching go2rtc paths in the origin check
2026-10-19 20:19:40 -10:00
Josh HawkinsandGitHub 9a0672a64d Harden container startup (#24718)
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
* 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.

* 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.

* 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.

* 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.

* tighten comments

* 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.

* 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.

* fall back to default listen ports when either is invalid or they collide

* keep valid listen values and refuse to start nginx when the ports collide
2026-10-19 04:57:35 -10:00
Josh HawkinsandGitHub 3d3f6a0678 Harden web push notifications (#24715)
* reject push endpoints that crash the vapid audience parse

The validator accepted hostnames like "." so https://./x passed, and check_registrations then raised ValueError slicing the endpoint, killing the dispatcher's IPC thread. The validator now rejects empty hostname labels and lives in webpush.py, the audience comes from urlparse, and _build_web_pushers skips stored rows whose endpoint fails validation.

* keep the ipc thread alive when a handler raises

An exception from any dispatcher handler escaped InterProcessCommunicator.read, which only caught ZMQError. The REP thread died owing a reply, so every InterProcessRequestor blocked forever. read now logs the error and replies with an empty response, and Dispatcher.publish and publish_local catch per communicator so one failing communicator can't take down the rest.

* stop push sends from reaching internal addresses

pywebpush posts through requests with redirects enabled, so an endpoint could answer 307 to http://127.0.0.1:5000/api/restart and the sender would follow it into the internal admin port on every alert. Pushers now use a session that never follows redirects, and each send first resolves the endpoint host and skips it unless every address is global.

* keep the previous region grid when the refresh request fails

The IPC reader now replies [] when a handler raises, and process_frames assigned that reply to region_grid unconditionally. The next region lookup then indexed into an empty list and killed the camera's detect process. An empty reply now keeps the current grid until the next daily refresh.

* drop the send-time public address check

Refusing non-global resolved addresses broke Tailscale (100.64.0.0/10), split-horizon DNS and HTTPS_PROXY-only setups, and added little once redirects are no longer followed and TLS verification stays on.

* only skip stored push rows that break the vapid audience

Running the full registration validator over stored rows dropped self-hosted endpoints registered before 0.18 with a non-443 port or a bare / path. _build_web_pushers now skips only non-dict rows, rows with no endpoint, unparsable endpoints and hosts with empty labels, and the warning names the host so the row can be found. Registration still uses the full validator.
2026-10-19 04:55:59 -10:00
Josh HawkinsandGitHub 53ca9c929b Mask all camera credentials in config (#24719)
* mask every credential form in camera and go2rtc urls

The credential mask only matched a userinfo with a [A-Za-z0-9_-] username or the exact user=..&password=.. query order, so dotted or email usernames, password-only userinfo (tapo://pw@host), empty usernames, reversed or renamed query credentials, and token query strings (ring refresh_token, nest client_secret, homekit client_private) reached every authenticated user through /config, /go2rtc/streams, /stats and /metrics. clean_camera_user_pass now masks any userinfo and any credential-named query parameter, and the config save restore and the settings UI detect masked paths with the same patterns so a save never writes the mask back over the real credential.

* redact go2rtc rtsp, api and webrtc credentials in /config

/config cleaned only go2rtc.streams, so go2rtc.rtsp and go2rtc.api usernames and passwords and the WebRTC ICE server credentials went back verbatim to any authenticated user. They now use the redaction sentinel like the MQTT and ONVIF passwords. Config save already dropped a field equal to the sentinel, but lists are leaf values after flattening, so an ice_servers list echoed back from /config would have written the sentinel to disk. Save now drops any value that contains it.

* bound the credential mask to the url authority

The userinfo pattern ran from any host:port to the last "@" in the token, so rtsp://host:554/live?x=a@b lost its host and a comma between two URLs was swallowed, which merged the paths /ffprobe splits on. It also backtracked quadratically on long lines with many "://", and every subprocess log line goes through it. A "user:" userinfo now runs to the last "@" before the next URL unless a port follows the colon, and any other userinfo stops at the end of the authority. Query masking now covers only real secret names, so auth, private and autoken are left alone.

* restore masked credentials by value on config save

Masked camera paths were restored by index, so deleting the first input wrote its credentials onto the next input. Each masked path now matches the saved input that masks to the same value, with roles breaking a tie, and an unmatched or ambiguous path is rejected instead of written. A redacted ICE server is filled from the saved server with the same urls, or rejected when its urls changed, instead of dropping the whole list. /config also redacts the go2rtc mqtt credentials, the ngrok command, homekit pins and pairings, and publish destinations.

* restore masked camera paths from the saved yaml

Masked paths were restored from the in-memory config, where {FRIGATE_*} placeholders are already resolved, so saving an untouched camera replaced the placeholder in config.yml with the real secret. Masked paths now restore from the YAML on disk, matched against each saved path with its placeholders resolved, so the path is written back as it was. In-memory saves still restore from the live config.

* mask passwords that start with digits

The port check in the userinfo pattern also matched passwords, so rtsp://admin:2024/pw@host and rtsp://admin:12#abc@host went out unmasked where the old regex masked them. The check is gone. Stopping at the next "://" already keeps the comma between two ffprobe paths, and a host:port/path@x is over-masked for display only, since saves restore it by value.

* reject config saves that still hold masked credentials

Index-keyed input maps, query-string saves and go2rtc stream lists echoed from /config bypassed the credential restore and could write a masked path or the redaction sentinel over the real value. Every save, body or query string, now rejects any value that still holds one after the restores run, naming the field.

* fix partial password masking and ambiguous ice server restore

- Mask userinfo before query params so a credential key inside a password can't leave the rest of it visible
- Reject restoring redacted ice servers when saved servers share urls but hold different accounts

* mask the whole url when userinfo and a query credential overlap

Masking userinfo first leaked the tail of a query password holding "@" on URLs with a port, like rtsp://10.0.0.2:554/live?password=pre@suf. When the userinfo span contains a credential query key, the mask now covers the rest of the URL in both clean_camera_user_pass and maskCredentials.

* resolve placeholders with dev's env module

* reveal camera paths by masked value instead of position

After deleting or reordering inputs in the camera ffmpeg form, the path widget looked up raw_paths by index and revealed another input's real path, which a save then wrote with the wrong roles. The widget now matches the saved path whose mask equals the field's value, the same rule the backend uses on save, and doesn't reveal when there is no single match.

* stop redacting ice servers and break camera path ties by role

The WebRTC player passes /config's ice_servers to RTCPeerConnection, so redacting them sent the placeholder as the TURN account. ICE servers are no longer redacted and the restore code for them is removed. The camera path widget now uses the input's roles to pick between saved paths that mask to the same value, as the backend does on save.
2026-10-19 04:54:28 -10:00
Josh HawkinsandGitHub 796aec7a4a Limit concurrent motion searches (#24717)
* limit concurrent motion search jobs and search range

Any viewer could start unlimited motion search jobs over an unbounded time range, and finished jobs were never dropped from memory. Each user now gets one active job (409), at most 3 run at once (429), the range is capped at 48 hours, and finished jobs are evicted 10 minutes after they end. Jobs are no longer filed in the shared job manager, which kept every finished job forever.

* show translated errors for motion search limits

* release the motion search slot if the runner fails to start

* keep wedged or shared-identity motion searches from locking out users

A stalled VOD read blocked the runner forever, so a wedged job held its user and global slot until restart, and on installs without per-user identity (internal port, or auth disabled without a proxy user header) everyone shared one per-user slot. VOD ffmpeg and ffprobe inputs now use a 30 s rw_timeout, a cancel also drops queued parallel runs, a cancelled job stops counting after 60 s, and the per-user limit only applies when the request carries a real username. A failed runner start also stops its requestor.

* treat timed-out VOD segments as motion search errors

With rw_timeout, the HLS demuxer skipped a timed-out segment and exited cleanly, so a run reported success with a hole in it, and when the keyframe probe and the decode lost different segments the equal frame counts paired frames with the wrong timestamps. The keyframe path now requires the probe to cover the whole run without a gap over 5 s, a decode whose stderr shows a skipped segment fails the run, and a cadence decode more than 5 s short of the run's duration fails it too, so these runs land in segments_with_errors. The keyframe probe polls should_stop and is killed on cancel, probe_video_dimensions gets the read timeout, and a user's own cancelled job no longer blocks their next search while it still counts toward the global cap.

* drop motion search stall detection and range cap

* remove motion search range check from the UI
2026-10-19 04:52:15 -10:00
Josh HawkinsandGitHub 6ba3198232 Revoke stale sessions in /auth (#24713)
* take the jwt role from the user and revoke stale sessions

/auth trusted the role claim in the token, and only checked password_changed_at when refreshing a cookie. Demoting or deleting a user, changing a password, or resetting the admin password left existing cookies and bearer tokens working with their old role. The API now keeps each user's role and password change time in memory, loads it at startup, and updates it from the user endpoints. /auth rejects unknown users and tokens issued before the last password change, and authorizes with the stored role without touching the database. Resetting the admin password now sets password_changed_at.

* set the secure cookie flag when the request arrived over tls

The session cookie only got the Secure flag from auth.cookie_secure, which defaults to false even though TLS is on by default, so browsers would send it over plain HTTP. The cookie is now Secure whenever nginx reports an https scheme, and nginx passes that scheme to the /auth subrequest so refreshed cookies match. cookie_secure still forces the flag for a reverse proxy that terminates TLS in front of plain-HTTP Frigate.

* regenerate config schema mock data

* revoke tokens issued in the same second as a password change

/auth compared the token's whole-second iat against the truncated change time, so a token issued earlier in the same second as a password change stayed valid. The change time is now rounded up, and the cookie reissued to a user who changes their own password carries that rounded-up iat so it still passes.

* note that a proxy on the tls port must serve https to browsers

* reject users whose role was removed from the config

A user whose role was removed from the config fell back to viewer, which has no camera restrictions, so a restricted role removed from yaml gained access to every camera. /auth now rejects that user's tokens, and login refuses them until an admin assigns a valid role.

* floor password change time to match iat

The password change time was rounded up while the token iat was rounded down, so a login in the same second as a password change got a token that /auth rejected as revoked. Both are floored now, which leaves a sub-second window where a token issued just before the change stays valid.

* set password_changed_at on user creation

A recreated username had no password change cutoff, so an unexpired token from the deleted account passed /auth with the new account's role. Creating a user now sets the cutoff to the creation time.
2026-10-19 04:44:37 -10:00
Nicolas MowenandGitHub d7bb780ff8 Upgrade to ROCm 10.1 (#24708)
* Update to ROCm 10.1

* Update docs for new setup
2026-10-18 12:13:32 -05:00
Josh HawkinsandGitHub cf418dd6b6 Restrict go2rtc ffmpeg and ngrok options without arbitrary exec (#24709)
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
* restrict go2rtc ffmpeg and ngrok options without arbitrary exec

The GO2RTC_ALLOW_ARBITRARY_EXEC checks only looked at go2rtc.streams, so an admin could save a config with a custom ffmpeg.bin or ngrok.command and run any command as the go2rtc user after a restart. go2rtc builds every ffmpeg: source into a command that starts with ffmpeg.bin, and it runs ngrok.command at startup. A custom template also brought back arbitrary ffmpeg arguments, since a stream can reference any template key with #video=<name>.

config/save and config/set now reject any added or changed key under go2rtc.ffmpeg or go2rtc.ngrok unless arbitrary exec is enabled. Keys already in the saved config are left alone, the same as stream sources. At startup, ffmpeg.bin is always Frigate's ffmpeg and ngrok is dropped unless arbitrary exec is enabled. Templates already in the config file, such as the documented vaapi global, keep working.

* reject ffmpeg paths with spaces, quotes or shell characters

ffmpeg.path was used as-is when absolute, and several ffmpeg commands are built as strings and split on spaces before running. A path like `/bin/sh -c <cmd>;#` ran a shell command instead of ffmpeg in the preview and export processes, and in go2rtc through the birdseye restream exec source. The path is now validated as a version alias or an absolute path of plain characters, so config/save and config/set reject an unsafe value. resolve_ffmpeg_path falls back to the default ffmpeg for one, and the go2rtc startup script prints an error.
2026-10-18 06:41:38 -10: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