Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 45fadcdd2b Bump ruff in /docker/main in the pip-main-minor-patch group
Bumps the pip-main-minor-patch group in /docker/main with 1 update: [ruff](https://github.com/astral-sh/ruff).


Updates `ruff` from 0.17.3 to 0.17.4
- [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.17.3...0.17.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 13:46:58 +00:00
28 changed files with 160 additions and 544 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ onnxruntime == 1.31.*
# Embeddings
transformers == 5.22.*
# Generative AI
google-genai == 3.0.*
google-genai == 2.34.*
ollama == 0.6.*
openai == 3.46.*
# push notifications
@@ -80,11 +80,6 @@ cp -r /usr/local/nginx/conf/. /tmp/nginx/conf/
set_worker_processes
# A non-root master ignores `user` and warns on every start
if [[ "$runs_as_root" -eq 0 ]]; then
sed -i '/^user root;$/d' /tmp/nginx/conf/nginx.conf
fi
# TLS certs: user-mounted certs at /etc/letsencrypt/live/frigate (documented
# contract) always win; otherwise fall back to a self-signed cert persisted in
# /config/tls, which stays writable under a read-only root filesystem.
+1 -14
View File
@@ -105,8 +105,6 @@ if [[ -n "$sentinel" ]]; then
mismatch_expr+=(-a ! -path "$sentinel")
fi
exec 3>&1
for path in "$@"; do
# An absent root is an incomplete sweep, not a finished one: /media/frigate
# is not in the image, so a boot before the volume is mounted would
@@ -123,18 +121,7 @@ for path in "$@"; do
# stale mount. Tolerate it rather than aborting under errexit, but never
# read a failed scan as "nothing to do": that would record the sweep as
# complete without having looked.
# The walk prints nothing until it ends, so report every 30s through fd 3
# (stdout) while the count itself comes back through the substitution.
if ! count=$(find "$path" -name lost+found -prune -o "(" "${mismatch_expr[@]}" ")" -printf 'm\n' -o -printf '.\n' 2>/dev/null \
| awk -v path="$path" '
BEGIN { next_report = systime() + 30 }
$0 == "m" { mismatched++ }
systime() >= next_report {
printf "[INFO] fix-ownership: still scanning %s, %d entries checked, %d need changes\n", path, NR, mismatched > "/dev/fd/3"
fflush("/dev/fd/3")
next_report = systime() + 30
}
END { print mismatched + 0 }'); then
if ! count=$(find "$path" -name lost+found -prune -o "${mismatch_expr[@]}" -printf '.' 2>/dev/null | wc -c); then
swept_clean=0
echo "[WARN] fix-ownership: could not scan ${path}; will retry on next boot"
continue
@@ -2,7 +2,7 @@
# file, all other path directives follow --prefix and must stay absolute.
daemon off;
# keeps workers root under FRIGATE_RUN_AS_ROOT; the run script strips it otherwise
# ignored by a non-root master; keeps workers root under FRIGATE_RUN_AS_ROOT
user root;
worker_processes auto;
+1 -2
View File
@@ -71,14 +71,13 @@ Both the script and the boot sweep report progress, so you can tell a slow sweep
```
[INFO] fix-ownership: scanning /media/frigate for ownership mismatches; this may take a while on large filesystems
[INFO] fix-ownership: still scanning /media/frigate, 2481000 entries checked, 2390112 need changes
[WARN] fix-ownership: adjusting ownership of 4823941 entries under /media/frigate
[INFO] fix-ownership: /media/frigate 5% (241197/4823941 entries)
[INFO] fix-ownership: /media/frigate 10% (482394/4823941 entries)
[INFO] fix-ownership: finished /media/frigate in 12m 4s
```
The scan has no percentage because the total isn't known until it finishes, so it reports a running count every 30 seconds instead. Watch the boot sweep with `docker logs -f frigate`.
The scan has no percentage because the total isn't known until it finishes. Watch the boot sweep with `docker logs -f frigate`.
Once the volumes are aligned, start Frigate normally. A file at `/config/.permissions_version` records what was done, so later boots skip the sweep unless you change `PUID`/`PGID`.
+1 -23
View File
@@ -683,15 +683,6 @@
"title": "FeaturesSection",
"type": "object"
},
"FfmpegSource": {
"enum": [
"default",
"bundled",
"custom"
],
"title": "FfmpegSource",
"type": "string"
},
"FpsBucket": {
"enum": [
"le_5",
@@ -1026,17 +1017,6 @@
"description": "CPU architecture",
"x-public": true
},
"custom_go2rtc": {
"description": "Whether an executable /config/go2rtc build exists",
"title": "Custom Go2Rtc",
"type": "boolean",
"x-public": true
},
"ffmpeg": {
"$ref": "#/$defs/FfmpegSource",
"description": "Configured global ffmpeg.path: the default build, another bundled version, or a custom path",
"x-public": true
},
"image_variant": {
"$ref": "#/$defs/ImageVariant",
"description": "Published image the install runs",
@@ -1074,9 +1054,7 @@
"install_type",
"arch",
"kernel",
"run_as_root",
"custom_go2rtc",
"ffmpeg"
"run_as_root"
],
"title": "InstallSection",
"type": "object"
+1 -22
View File
@@ -6,15 +6,7 @@ import re
from frigate.analytics.collectors.common import closed
from frigate.analytics.context import ReportContext
from frigate.analytics.schema import (
Arch,
FfmpegSource,
ImageVariant,
InstallSection,
InstallType,
)
from frigate.const import CONFIG_DIR, DEFAULT_FFMPEG_VERSION, INCLUDED_FFMPEG_VERSIONS
from frigate.util.config import is_valid_ffmpeg_path
from frigate.analytics.schema import Arch, ImageVariant, InstallSection, InstallType
from frigate.version import VERSION
KERNEL_PATTERN = re.compile(r"^(\d{1,3})\.(\d{1,3})")
@@ -62,17 +54,6 @@ def kernel(release: str) -> str:
return f"{match.group(1)}.{match.group(2)}" if match else "unknown"
def ffmpeg_source(path: str) -> FfmpegSource:
"""Classify the configured ffmpeg.path, applying resolve_ffmpeg_path's alias fallbacks."""
if path.startswith("/") and is_valid_ffmpeg_path(path):
return FfmpegSource.custom
if path != DEFAULT_FFMPEG_VERSION and path in INCLUDED_FFMPEG_VERSIONS:
return FfmpegSource.bundled
return FfmpegSource.default
def collect(ctx: ReportContext) -> InstallSection:
"""Describe the Frigate version, image, install method, and platform."""
return InstallSection(
@@ -82,6 +63,4 @@ def collect(ctx: ReportContext) -> InstallSection:
arch=arch(platform.machine()),
kernel=kernel(platform.release()),
run_as_root=os.geteuid() == 0,
custom_go2rtc=os.access(os.path.join(CONFIG_DIR, "go2rtc"), os.X_OK),
ffmpeg=ffmpeg_source(ctx.config.ffmpeg.path),
)
-10
View File
@@ -47,12 +47,6 @@ class Arch(StrEnum):
other = "other"
class FfmpegSource(StrEnum):
default = "default"
bundled = "bundled"
custom = "custom"
class GpuVendor(StrEnum):
intel = "intel"
amd = "amd"
@@ -207,10 +201,6 @@ class InstallSection(AnalyticsModel):
"Host kernel as major.minor", pattern=r"^(\d{1,3}\.\d{1,3}|unknown)$"
)
run_as_root: bool = metric("Whether the main process runs as root")
custom_go2rtc: bool = metric("Whether an executable /config/go2rtc build exists")
ffmpeg: FfmpegSource = metric(
"Configured global ffmpeg.path: the default build, another bundled version, or a custom path"
)
class GpuInfo(AnalyticsModel):
+1 -17
View File
@@ -45,7 +45,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
from frigate.const import REDACTED_CREDENTIAL_SENTINEL, REPLAY_CAMERA_PREFIX
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
from frigate.genai import PROVIDERS, load_providers
from frigate.jobs.media_sync import (
@@ -979,17 +979,6 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
status_code=400,
)
# an unresolvable scene would crash the threads that look up the model
detect_update = sections.get("detect")
if isinstance(detect_update, dict) and "scene" in detect_update:
try:
config._resolve_camera_model(cam_name, detect_update["scene"])
except (TypeError, ValueError):
return JSONResponse(
content={"success": False, "message": "Invalid detect scene"},
status_code=400,
)
for section_name, update in sections.items():
err = apply_section_update(camera_config, section_name, update)
if err is not None:
@@ -998,11 +987,6 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
status_code=400,
)
# the model is resolved again from the scene when the replay
# camera is recycled by the refresh below
if "detect" in sections and cam_name.startswith(REPLAY_CAMERA_PREFIX):
config.drop_camera_model(cam_name)
# Publish ZMQ updates so processing threads pick up changes
if body.update_topic and body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/")
+9 -2
View File
@@ -76,7 +76,7 @@ from frigate.models import (
from frigate.notices import install_registry
from frigate.notices.registry import NoticeRegistry
from frigate.object_detection.base import ObjectDetectProcess
from frigate.object_detection.util import create_detection_input_shm
from frigate.object_detection.util import detection_frame_size
from frigate.output.output import OutputProcess
from frigate.ptz.autotrack import PtzAutoTracker
from frigate.ptz.onvif import OnvifController
@@ -399,7 +399,14 @@ class FrigateApp:
model = self.config.model_for_camera(name)
model_cameras[model.scene].append(name)
shm_in = create_detection_input_shm(name, self.config.models)
try:
shm_in = UntrackedSharedMemory(
name=name,
create=True,
size=detection_frame_size(model),
)
except FileExistsError:
shm_in = UntrackedSharedMemory(name=name)
try:
shm_out = UntrackedSharedMemory(
+6 -3
View File
@@ -16,7 +16,7 @@ from frigate.config.camera.updater import (
)
from frigate.const import REPLAY_CAMERA_PREFIX
from frigate.models import Regions
from frigate.object_detection.util import create_detection_input_shm
from frigate.object_detection.util import detection_frame_size
from frigate.util.builtin import empty_and_close_queue
from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory
from frigate.util.object import get_camera_regions_grid
@@ -127,10 +127,13 @@ class CameraMaintainer(threading.Thread):
max(model.width, model.height),
)
create_detection_input_shm(name, self.config.models)
try:
UntrackedSharedMemory(name=f"out-{name}", create=True, size=20 * 6 * 4)
UntrackedSharedMemory(
name=name,
create=True,
size=detection_frame_size(model),
)
except FileExistsError:
pass
+1 -2
View File
@@ -296,8 +296,7 @@ class CameraState:
return frame_copy
def finished(self, obj_id: str) -> None:
# state rebuilt after a resolution or scene change no longer has the object
self.tracked_objects.pop(obj_id, None)
del self.tracked_objects[obj_id]
def on(self, event_type: str, callback: Callable[..., Any]) -> None:
self.callbacks[event_type].append(callback)
+14 -23
View File
@@ -113,23 +113,6 @@ class AsyncLocalObjectDetector(BaseLocalDetector):
return self.detect_api.receive_output()
def map_input_frame(
frame_manager: SharedMemoryFrameManager, camera: str, model: ModelConfig
) -> np.ndarray | None:
"""Map a camera's detection input segment as this model's input tensor.
The segment is sized for the largest configured model, so it can be larger
than this model's input.
"""
buf = frame_manager.write(camera)
shape = (1, model.height, model.width, 3)
if buf is None or len(buf) < model.height * model.width * 3:
return None
return np.ndarray(shape, dtype=np.uint8, buffer=buf)
class DetectorRunner(FrigateProcess):
def __init__(
self,
@@ -171,10 +154,14 @@ class DetectorRunner(FrigateProcess):
connection_id = self.detection_queue.get(timeout=1)
except queue.Empty:
continue
input_frame = map_input_frame(
frame_manager,
input_frame = frame_manager.get(
connection_id,
self.detector_config.model, # type: ignore[arg-type]
(
1,
self.detector_config.model.height, # type: ignore[union-attr]
self.detector_config.model.width, # type: ignore[union-attr]
3,
),
)
if input_frame is None:
@@ -240,10 +227,14 @@ class AsyncDetectorRunner(FrigateProcess):
continue
assert self._frame_manager is not None
input_frame = map_input_frame(
self._frame_manager,
input_frame = self._frame_manager.get(
connection_id,
self.detector_config.model, # type: ignore[arg-type]
(
1,
self.detector_config.model.height, # type: ignore[union-attr]
self.detector_config.model.width, # type: ignore[union-attr]
3,
),
)
if input_frame is None:
+5 -26
View File
@@ -6,39 +6,18 @@ import threading
from numpy import ndarray
from frigate.detectors.detector_config import InputTensorEnum, ModelConfig
from frigate.util.image import UntrackedSharedMemory
def create_detection_input_shm(
camera: str, models: list[ModelConfig]
) -> UntrackedSharedMemory:
"""Create the shared memory a camera hands detection frames through.
It is sized for the largest model so a camera re-added under the same name
with another scene can reuse it. Detectors keep mappings of it, so it is
never unlinked while they run.
def detection_frame_size(model: ModelConfig) -> int:
"""Get the shared memory size a camera needs to hand frames to a model.
Args:
camera: Name of the camera
models: Every configured model
model: The model the camera runs on
Returns:
The camera's detection input segment
Size in bytes of one model input frame
"""
size = max(model.height * model.width * 3 for model in models)
try:
return UntrackedSharedMemory(name=camera, create=True, size=size)
except FileExistsError:
shm = UntrackedSharedMemory(name=camera)
if shm.size >= size:
return shm
# left by an earlier run with smaller models, so no detector maps it yet
shm.close()
shm.unlink()
return UntrackedSharedMemory(name=camera, create=True, size=size)
return model.height * model.width * 3
class RequestStore:
+51 -40
View File
@@ -585,36 +585,26 @@ class PtzAutoTracker(threading.Thread):
def _predict_movement_time(self, camera, pan, tilt):
combined_movement = abs(pan) + abs(tilt)
input_data = np.array([1, combined_movement])
input_data = np.array([self.intercept[camera], combined_movement])
return np.dot(self.move_coefficients[camera], input_data)
def _predict_area_after_time(self, camera, time):
return np.dot(
self.tracked_object_metrics[camera]["area_coefficients"],
[self.tracked_object_history[camera][-1]["frame_time"] + time],
)
def _predict_target_box(self, camera, predicted_time):
tom = self.tracked_object_metrics[camera]
target_box = tom["target_box"]
target_box = self.tracked_object_metrics[camera]["target_box"]
if not predicted_time:
return target_box
camera_config = self.config.cameras[camera]
left, top, right, bottom = self.tracked_object_history[camera][-1]["box"]
size = max(right - left, bottom - top)
frame_shape = self.config.cameras[camera].frame_shape
if size <= 0:
return target_box
# the corner velocities diverge as the object approaches the camera
frames = camera_config.detect.fps * predicted_time
velocity = tom["velocity"]
predicted_size = max(
right - left + frames * (velocity[2] - velocity[0]),
bottom - top + frames * (velocity[3] - velocity[1]),
1,
)
# target_box is the squared size raised to the zoom factor
return target_box * (predicted_size / size) ** (
2 * camera_config.onvif.autotracking.zoom_factor
return target_box + self._predict_area_after_time(camera, predicted_time) / (
frame_shape[0] * frame_shape[1]
)
def _calculate_tracked_object_metrics(self, camera, obj):
@@ -670,6 +660,26 @@ class PtzAutoTracker(threading.Thread):
filtered_areas = remove_outliers(areas) if len(areas) > 3 else areas
# Filter entries that are not touching the frame edge
filtered_areas_not_touching_edge = [
entry
for entry in filtered_areas
if self._touching_frame_edges(camera, entry["box"]) == 0
]
# Calculate regression for area change predictions
if len(filtered_areas_not_touching_edge):
X = np.array(
[item["frame_time"] for item in filtered_areas_not_touching_edge]
)
y = np.array([item["area"] for item in filtered_areas_not_touching_edge])
tom["area_coefficients"] = np.linalg.lstsq(X.reshape(-1, 1), y, rcond=None)[
0
]
else:
tom["area_coefficients"] = np.array([0])
weights = np.arange(1, len(filtered_areas) + 1)
weighted_area = np.average(
[item["area"] for item in filtered_areas], weights=weights
@@ -734,8 +744,6 @@ class PtzAutoTracker(threading.Thread):
continue
else:
pan_tilt_times = None
if (
camera_config.onvif.autotracking.zooming
== ZoomingModeEnum.relative
@@ -747,28 +755,17 @@ class PtzAutoTracker(threading.Thread):
await self._wait_until_stopped(camera, metrics)
# _zoom_absolute resets the metrics timestamps
pan_tilt_times = (
metrics.start_time.value,
metrics.stop_time.value,
)
if zoom > 0 and metrics.zoom_level.value != zoom:
await self.onvif._zoom_absolute(camera, zoom, 1)
await self._wait_until_stopped(camera, metrics)
start_time, stop_time = pan_tilt_times or (
metrics.start_time.value,
metrics.stop_time.value,
)
if camera_config.onvif.autotracking.movement_weights:
logger.debug(
f"{camera}: Predicted movement time: {self._predict_movement_time(camera, pan, tilt)}"
)
logger.debug(
f"{camera}: Actual movement time: {stop_time - start_time}"
f"{camera}: Actual movement time: {metrics.stop_time.value - metrics.start_time.value}"
)
# save metrics for better estimate calculations
@@ -784,8 +781,8 @@ class PtzAutoTracker(threading.Thread):
{
"pan": pan,
"tilt": tilt,
"start_timestamp": start_time,
"end_timestamp": stop_time,
"start_timestamp": metrics.start_time.value,
"end_timestamp": metrics.stop_time.value,
}
)
@@ -848,6 +845,7 @@ class PtzAutoTracker(threading.Thread):
# Thresholds
x_mags_thresh = camera_width / camera_fps / 2
y_mags_thresh = camera_height / camera_fps / 2
dir_thresh = 0.93
delta_thresh = 20
var_thresh = 10
@@ -865,12 +863,24 @@ class PtzAutoTracker(threading.Thread):
stdev_list = np.std(velocities, axis=0)
high_variances = np.any(stdev_list > var_thresh)
# the corners of an object moving toward or away from the camera move
# in different directions, so the delta bounds how far they may differ
# Check direction difference
velocities = np.round(velocities)
invalid_dirs = False
if np.all(np.linalg.norm(velocities, axis=1)):
cosine_sim = np.dot(velocities[0], velocities[1]) / (
np.linalg.norm(velocities[0]) * np.linalg.norm(velocities[1])
)
dir_thresh = 0.6 if np.all(delta < delta_thresh / 2) else dir_thresh
invalid_dirs = cosine_sim < dir_thresh
# Combine
invalid = invalid_x_mags or invalid_y_mags or invalid_delta or high_variances
invalid = (
invalid_x_mags
or invalid_y_mags
or invalid_dirs
or invalid_delta
or high_variances
)
if invalid:
logger.debug(
@@ -881,6 +891,7 @@ class PtzAutoTracker(threading.Thread):
for var_name, is_invalid in [
("invalid_x_mags", invalid_x_mags),
("invalid_y_mags", invalid_y_mags),
("invalid_dirs", invalid_dirs),
("invalid_delta", invalid_delta),
("high_variances", high_variances),
]
-40
View File
@@ -98,46 +98,6 @@ class TestHttpApp(BaseTestHttp):
assert response.status_code == 200
assert app.frigate_config.cameras["front_door"].objects.track == ["person"]
@patch("frigate.detectors.detector_config.load_labels")
def test_config_set_in_memory_rebinds_replay_model_on_scene_change(
self, mock_labels
):
mock_labels.return_value = {}
replay = "_replay_front_door"
self.minimal_config["models"] = [
{"devices": ["cpu"]},
{"scene": "thermal", "devices": ["openvino:CPU"], "width": 300},
]
self.minimal_config["cameras"][replay] = self.minimal_config["cameras"].pop(
"front_door"
)
app = super().create_app()
app.config_publisher = Mock()
assert app.frigate_config.model_for_camera(replay).scene == "default"
with AuthTestClient(app) as client:
response = client.put(
"/config/set",
json={
"requires_restart": 0,
"skip_save": True,
"update_topic": f"config/cameras/{replay}/detect",
"config_data": {
"cameras": {replay: {"detect": {"scene": "thermal"}}}
},
},
)
assert response.status_code == 200
assert app.frigate_config.cameras[replay].detect.scene == "thermal"
assert app.frigate_config.model_for_camera(replay).scene == "thermal"
topics = [
call.args[0].update_type.name
for call in app.config_publisher.publish_update.call_args_list
]
assert topics[0] == "detect"
assert topics[-1] == "refresh"
####################################################################################################################
################################### Credential redaction sentinel ################################################
####################################################################################################################
@@ -12,7 +12,6 @@ from frigate.analytics.collectors.common import closed, histogram, rate
from frigate.analytics.schema import (
Arch,
DecodeFamily,
FfmpegSource,
GpuVendor,
HardwareKey,
ImageVariant,
@@ -57,36 +56,6 @@ class TestInstallCollector(unittest.TestCase):
self.assertEqual(section.arch, Arch.aarch64)
self.assertEqual(section.kernel, "6.8")
self.assertFalse(section.run_as_root)
self.assertEqual(section.ffmpeg, FfmpegSource.default)
def test_ffmpeg_source_follows_path_resolution(self):
with (
patch.object(install, "DEFAULT_FFMPEG_VERSION", "8.0"),
patch.object(install, "INCLUDED_FFMPEG_VERSIONS", ["8.0", "7.0"]),
):
self.assertEqual(install.ffmpeg_source("default"), FfmpegSource.default)
self.assertEqual(install.ffmpeg_source("8.0"), FfmpegSource.default)
self.assertEqual(install.ffmpeg_source("4.4"), FfmpegSource.default)
self.assertEqual(install.ffmpeg_source("7.0"), FfmpegSource.bundled)
self.assertEqual(
install.ffmpeg_source("/config/custom-ffmpeg"), FfmpegSource.custom
)
self.assertEqual(
install.ffmpeg_source("/config/a b; rm"), FfmpegSource.default
)
def test_custom_go2rtc_needs_an_executable_binary(self):
with tempfile.TemporaryDirectory() as config_dir:
binary = os.path.join(config_dir, "go2rtc")
with patch.object(install, "CONFIG_DIR", config_dir):
self.assertFalse(install.collect(make_context()).custom_go2rtc)
open(binary, "w").close()
self.assertFalse(install.collect(make_context()).custom_go2rtc)
os.chmod(binary, 0o755)
self.assertTrue(install.collect(make_context()).custom_go2rtc)
def test_image_variant_is_dev_when_unset_and_other_when_unknown(self):
self.assertEqual(install.image_variant(None), ImageVariant.dev)
-2
View File
@@ -25,8 +25,6 @@ INSTALL = InstallSection(
arch="x86_64",
kernel="6.8",
run_as_root=False,
custom_go2rtc=False,
ffmpeg="default",
)
-37
View File
@@ -1,6 +1,5 @@
"""Tests for migrating detectors and model into the models list."""
import errno
import logging
import os
import tempfile
@@ -241,42 +240,6 @@ class TestMigrateConfigFile(unittest.TestCase):
with open(self.config_file) as f:
return YAML().load(f)
def test_a_failed_write_leaves_the_original_config(self):
original = "mqtt:\n enabled: false\ncameras: {}\n"
with open(self.config_file, "w") as f:
f.write(original)
with (
patch.object(YAML, "dump", side_effect=RuntimeError),
self.assertRaises(RuntimeError),
):
migrate_frigate_config(self.config_file)
with open(self.config_file) as f:
self.assertEqual(f.read(), original)
def test_a_file_bind_mount_is_written_in_place(self):
with patch(
"frigate.util.config.os.replace",
side_effect=OSError(errno.EBUSY, "Device or resource busy"),
):
migrated = self._migrate("mqtt:\n enabled: false\ncameras: {}\n")
self.assertEqual(migrated["version"], CURRENT_CONFIG_VERSION)
self.assertFalse(os.path.exists(f"{self.config_file}.tmp"))
def test_a_symlinked_config_migrates_its_target(self):
target = os.path.join(self.temp_dir.name, "frigate.yml")
with open(target, "w") as f:
f.write("mqtt:\n enabled: false\ncameras: {}\n")
os.symlink(target, self.config_file)
migrate_frigate_config(self.config_file)
self.assertTrue(os.path.islink(self.config_file))
with open(target) as f:
self.assertEqual(YAML().load(f)["version"], CURRENT_CONFIG_VERSION)
def test_migrates_a_config_already_stamped_with_the_current_version(self):
# 0.19 is unreleased, so a dev config can be current and still use
# the pre-models keys
+2 -117
View File
@@ -11,14 +11,11 @@ KeyError on the autotracker thread or silently keep the wrong state:
running its motion estimator for a camera that could not autotrack.
"""
import asyncio
import unittest
from unittest.mock import AsyncMock, MagicMock
import numpy as np
from unittest.mock import MagicMock
from frigate.camera import PTZMetrics
from frigate.config import FrigateConfig, ZoomingModeEnum
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdateEnum
from frigate.ptz.autotrack import PtzAutoTracker, calculate_max_target_box
@@ -126,118 +123,6 @@ class TestAutotrackerDisable(unittest.TestCase):
self.assertIs(payload, autotracking)
class TestAutotrackerPrediction(unittest.TestCase):
def test_movement_time_uses_the_fitted_intercept_unchanged(self) -> None:
tracker = _make_tracker()
tracker.intercept = {CAMERA: 0.5}
tracker.move_coefficients = {CAMERA: [0.3, 0.8]}
self.assertAlmostEqual(tracker._predict_movement_time(CAMERA, 0.25, 0.25), 0.7)
def test_shrinking_box_keeps_a_positive_target_box(self) -> None:
# 60 px shrinking 6 px/frame reaches zero after 2 s at 5 fps
target_box = self._target_box_for((0, 0, 60, 60), [3, 3, -3, -3], 2.0)
self.assertGreater(target_box, 0)
self.assertTrue(np.isfinite(target_box))
def test_approaching_object_keeps_its_velocity(self) -> None:
# the box grows, so the corners move in opposite directions
tracker = _make_tracker()
obj = MagicMock()
obj.obj_data = {"estimate_velocity": np.array([[-3.0, -4.0], [3.0, 1.0]])}
valid, velocity = tracker._get_valid_velocity(CAMERA, obj)
self.assertTrue(valid)
self.assertEqual(list(velocity), [-3, -4, 3, 1])
def test_corners_that_disagree_too_much_are_rejected(self) -> None:
tracker = _make_tracker()
obj = MagicMock()
obj.obj_data = {"estimate_velocity": np.array([[-15.0, 0.0], [15.0, 0.0]])}
valid, velocity = tracker._get_valid_velocity(CAMERA, obj)
self.assertFalse(valid)
self.assertFalse(np.any(velocity))
def _target_box(self, velocity: list[float], predicted_time: float) -> float:
return self._target_box_for((900, 400, 1000, 600), velocity, predicted_time)
def _target_box_for(
self,
box: tuple[int, int, int, int],
velocity: list[float],
predicted_time: float,
) -> float:
tracker = _make_tracker()
tracker.tracked_object_history = {CAMERA: [{"box": box}]}
tracker.tracked_object_metrics = {
CAMERA: {"target_box": 0.2, "velocity": np.array(velocity)}
}
return tracker._predict_target_box(CAMERA, predicted_time)
def test_target_box_grows_with_an_approaching_object(self) -> None:
# the 200px height gains 4px per frame at 5 fps for 1 second
zoom_factor = 0.3
self.assertAlmostEqual(
self._target_box([-1, -2, 1, 2], 1.0),
0.2 * (220 / 200) ** (2 * zoom_factor),
)
def test_target_box_is_unchanged_without_growth_or_time(self) -> None:
self.assertAlmostEqual(self._target_box([5, 0, 5, 0], 1.0), 0.2)
self.assertEqual(self._target_box([-1, -2, 1, 2], 0), 0.2)
class TestAutotrackerMoveQueue(unittest.IsolatedAsyncioTestCase):
async def test_absolute_zoom_does_not_overwrite_the_pan_tilt_sample(self) -> None:
tracker = _make_tracker()
autotracking = tracker.config.cameras[CAMERA].onvif.autotracking
autotracking.zooming = ZoomingModeEnum.absolute
autotracking.calibrate_on_startup = True
metrics = tracker.ptz_metrics[CAMERA]
tracker.stop_event = MagicMock()
tracker.stop_event.is_set.side_effect = [False, True]
tracker.move_queues = {CAMERA: asyncio.Queue()}
tracker.move_queue_locks = {CAMERA: asyncio.Lock()}
tracker.intercept = {CAMERA: 0.5}
tracker.move_metrics = {CAMERA: []}
tracker._wait_until_stopped = AsyncMock()
tracker._calculate_move_coefficients = MagicMock()
async def move_relative(*_args) -> None:
metrics.start_time.value = 100.0
metrics.stop_time.value = 102.0
async def zoom_absolute(*_args) -> None:
metrics.start_time.value = 103.0
metrics.stop_time.value = 0
tracker.onvif._move_relative = AsyncMock(side_effect=move_relative)
tracker.onvif._zoom_absolute = AsyncMock(side_effect=zoom_absolute)
tracker.move_queues[CAMERA].put_nowait((50.0, 0.5, 0.5, 0.5))
await tracker._process_move_queue(CAMERA)
tracker.onvif._zoom_absolute.assert_awaited_once()
self.assertEqual(
tracker.move_metrics[CAMERA],
[
{
"pan": 0.5,
"tilt": 0.5,
"start_timestamp": 100.0,
"end_timestamp": 102.0,
}
],
)
class TestMaxTargetBox(unittest.TestCase):
def test_follows_zoom_factor(self) -> None:
self.assertAlmostEqual(calculate_max_target_box(0.5), 0.6**2)
-30
View File
@@ -321,36 +321,6 @@ class TestLighterANE(unittest.TestCase):
ane.assert_not_called()
class TestTensorRTProvider(unittest.TestCase):
def _trt_options(self, env):
with (
patch.object(
model_util.ort,
"get_available_providers",
return_value=["TensorrtExecutionProvider", "CPUExecutionProvider"],
),
patch.object(model_util.os, "makedirs"),
patch.dict(os.environ, env, clear=False),
):
if "USE_FP16" not in env:
os.environ.pop("USE_FP16", None)
providers, options = model_util.get_ort_providers(device="Tensorrt")
return options[providers.index("TensorrtExecutionProvider")]
def test_fp16_is_disabled_by_default(self):
self.assertFalse(self._trt_options({})["trt_fp16_enable"])
def test_use_fp16_enables_fp16(self):
for value in ("true", "True", "TRUE"):
with self.subTest(value=value):
options = self._trt_options({"USE_FP16": value})
self.assertTrue(options["trt_fp16_enable"])
def test_use_fp16_false_keeps_fp16_disabled(self):
self.assertFalse(self._trt_options({"USE_FP16": "False"})["trt_fp16_enable"])
class TestMIGraphX(unittest.TestCase):
"""AMD's MIGraphX plugin provider, selected through its devices in the ROCm image."""
-16
View File
@@ -91,7 +91,6 @@ class TrackedObjectProcessor(threading.Thread):
CameraConfigUpdateEnum.enabled,
CameraConfigUpdateEnum.motion,
CameraConfigUpdateEnum.objects,
CameraConfigUpdateEnum.refresh,
CameraConfigUpdateEnum.remove,
CameraConfigUpdateEnum.timestamp_style,
CameraConfigUpdateEnum.zones,
@@ -785,21 +784,6 @@ class TrackedObjectProcessor(threading.Thread):
self.requestor.send_data(UPDATE_CAMERA_ACTIVITY, self.camera_activity)
# a replay camera recycled onto another scene's model needs new state
for camera in updated_topics.get("refresh", []):
camera_state = self.camera_states.get(camera)
if (
camera_state is None
or camera_state.model is self.config.model_for_camera(camera)
):
continue
self.force_end_all_events(camera, camera_state)
camera_state.shutdown()
self.frame_manager.close_camera(camera)
self.create_camera_state(camera)
# manage camera disabled state
for camera, config in self.config.cameras.items():
if not config.enabled_in_config:
+18 -33
View File
@@ -1,8 +1,6 @@
"""configuration utils."""
import asyncio
import contextlib
import errno
import ipaddress
import logging
import os
@@ -214,28 +212,6 @@ def find_config_file() -> str:
return config_path
def _write_config(config_file: str, yaml: YAML, config: dict[str, Any]) -> None:
config_file = os.path.realpath(config_file)
st = os.stat(config_file)
tmp_file = f"{config_file}.tmp"
with open(tmp_file, "w") as f:
yaml.dump(config, f)
os.chmod(tmp_file, st.st_mode)
with contextlib.suppress(PermissionError):
os.chown(tmp_file, st.st_uid, st.st_gid)
try:
os.replace(tmp_file, config_file)
except OSError as e:
if e.errno != errno.EBUSY:
raise
# a file bind mount can't be renamed over, so write it in place
shutil.copyfile(tmp_file, config_file)
os.remove(tmp_file)
def migrate_frigate_config(config_file: str):
"""handle migrating the frigate config."""
logger.info("Checking if frigate config needs migration...")
@@ -283,7 +259,8 @@ def migrate_frigate_config(config_file: str):
if previous_version < "0.14":
logger.info(f"Migrating frigate config from {previous_version} to 0.14...")
new_config = migrate_014(new_config)
_write_config(config_file, yaml, new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.14"
logger.info("Migrating export file names...")
@@ -300,48 +277,56 @@ def migrate_frigate_config(config_file: str):
if previous_version < "0.15-0":
logger.info(f"Migrating frigate config from {previous_version} to 0.15-0...")
new_config = migrate_015_0(new_config)
_write_config(config_file, yaml, new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.15-0"
if previous_version < "0.15-1":
logger.info(f"Migrating frigate config from {previous_version} to 0.15-1...")
new_config = migrate_015_1(new_config)
_write_config(config_file, yaml, new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.15-1"
if previous_version < "0.16-0":
logger.info(f"Migrating frigate config from {previous_version} to 0.16-0...")
new_config = migrate_016_0(new_config)
_write_config(config_file, yaml, new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.16-0"
if previous_version < "0.17-0":
logger.info(f"Migrating frigate config from {previous_version} to 0.17-0...")
new_config = migrate_017_0(new_config)
_write_config(config_file, yaml, new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.17-0"
if previous_version < "0.18-0":
logger.info(f"Migrating frigate config from {previous_version} to 0.18-0...")
new_config = migrate_018_0(new_config)
_write_config(config_file, yaml, new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.18-0"
if previous_version < "0.19-0":
logger.info(f"Migrating frigate config from {previous_version} to 0.19-0...")
new_config = migrate_019_0(new_config)
_write_config(config_file, yaml, new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.19-0"
if needs_models:
logger.info("Migrating frigate detectors and model to models...")
new_config = migrate_models(new_config)
_write_config(config_file, yaml, new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
if needs_detector_rename:
logger.info("Migrating renamed frigate detectors...")
new_config = rename_hailo_detector(new_config)
_write_config(config_file, yaml, new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
logger.info("Finished frigate config migration...")
+3 -2
View File
@@ -318,6 +318,7 @@ def post_process_yolox(
def get_ort_providers(
force_cpu: bool = False,
device: str | None = "AUTO",
requires_fp16: bool = False,
) -> tuple[list[str], list[dict[str, Any]]]:
if force_cpu:
return (
@@ -356,8 +357,8 @@ def get_ort_providers(
options.append(
{
"device_id": device_id,
"trt_fp16_enable": os.environ.get("USE_FP16", "False").lower()
== "true",
"trt_fp16_enable": requires_fp16
and os.environ.get("USE_FP16", "True") != "False",
"trt_timing_cache_enable": True,
"trt_engine_cache_enable": True,
"trt_timing_cache_path": os.path.join(
+32 -32
View File
@@ -45,15 +45,15 @@
"copy-to-clipboard": "^4.0.2",
"date-fns": "^4.4.0",
"date-fns-tz": "^3.2.0",
"framer-motion": "^14.10.0",
"framer-motion": "^14.9.1",
"hls.js": "^1.7.4",
"i18next": "^26.7.2",
"i18next": "^26.7.1",
"i18next-http-backend": "^4.0.10",
"idb-keyval": "^6.3.0",
"js-yaml": "^5.5.0",
"konva": "^10.9.1",
"lodash": "^4.18.1",
"lucide-react": "^1.70.0",
"lucide-react": "^1.69.0",
"monaco-yaml": "^5.5.1",
"next-themes": "^0.4.6",
"nosleep.js": "^0.12.0",
@@ -65,7 +65,7 @@
"react-dropzone": "^20.1.4",
"react-grid-layout": "^2.3.0",
"react-hook-form": "^7.91.0",
"react-i18next": "^17.1.13",
"react-i18next": "^17.1.12",
"react-icons": "^5.7.0",
"react-konva": "^19.3.2",
"react-markdown": "^10.1.0",
@@ -87,7 +87,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.65.0",
"@playwright/test": "^1.64.1",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.27",
"@types/node": "^26.6.6",
@@ -1714,13 +1714,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz",
"integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.65.0"
"playwright": "1.64.1"
},
"bin": {
"playwright": "cli.js"
@@ -5596,12 +5596,12 @@
}
},
"node_modules/framer-motion": {
"version": "14.10.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.10.0.tgz",
"integrity": "sha512-e8MjIet+IJKohYxBj8TAk9Mg5cD0HiOX87qde1YTPL67gI2ao074O6yBtQnIcWCk+bTX7XX4litepufLiYPrLg==",
"version": "14.9.1",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.9.1.tgz",
"integrity": "sha512-WDWNT2ixHdmpvGg02BZWI/SWwRYzPiMUf4aIkTBgrxxD8RYFdZO3fCM4pnLMo6j8mUpj0+BuJRHQBChI4v3Anw==",
"license": "MIT",
"dependencies": {
"motion-dom": "14.10.0",
"motion-dom": "14.9.1",
"motion-utils": "14.7.0",
"tslib": "^2.4.0"
},
@@ -6009,9 +6009,9 @@
}
},
"node_modules/i18next": {
"version": "26.7.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.7.2.tgz",
"integrity": "sha512-RsOY3BeaCDdkk52NeJSFAXELQ2rQXBO/tOub6L8ue2ICPfR/FRDWDOfEDg4xMPd1SqO5goVYQ6i72qWEUB/PPw==",
"version": "26.7.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.7.1.tgz",
"integrity": "sha512-cX+3RRVczel/V2kCMTACckSGSpqgtWYEA6beXNYVms7dOQiHojdPYHD26lT4IKimNeoGWrLvtPgjftbRvmZJVw==",
"funding": [
{
"type": "individual",
@@ -6996,9 +6996,9 @@
}
},
"node_modules/lucide-react": {
"version": "1.70.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.70.0.tgz",
"integrity": "sha512-gCTPPN1MlQUBtUgdsUv+0CAUj+X8hifRUiRBw4E5v7lvDIOIpx0EZezdGQMSimS8pKA6esvullJub4GYr0mCHA==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.69.0.tgz",
"integrity": "sha512-UTFECm4yEgkgMN0kfLuxWcmlk+4u02Jrqehgk3ZXRx/Ud68OpP7hjkp67bvkBHmIpMaZCIveF1QDYeupO7XmYQ==",
"license": "ISC",
"peerDependencies": {
"@types/react": "*",
@@ -8073,9 +8073,9 @@
}
},
"node_modules/motion-dom": {
"version": "14.10.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.10.0.tgz",
"integrity": "sha512-k0LwJZ1R5xCvqrScszO4ywuZP52lRcT3QO0IUovgczom9ZHb0BaRLOX6M2O40q7TrSLvzX/fIHHmDm8TUMt3fg==",
"version": "14.9.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.9.1.tgz",
"integrity": "sha512-2eCCGXH2Yv09lM8GHK3TdfChrcvHxzVY19LD9VraoHEaQl5+PuGnTNnmJaBAR+5MdxUVdsLFXygFJjR9RwRSxw==",
"license": "MIT",
"dependencies": {
"motion-utils": "14.7.0"
@@ -8537,13 +8537,13 @@
}
},
"node_modules/playwright": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz",
"integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.65.0"
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
@@ -8553,9 +8553,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz",
"integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -9077,9 +9077,9 @@
}
},
"node_modules/react-i18next": {
"version": "17.1.13",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.13.tgz",
"integrity": "sha512-ZxhOSlyKSKPtbUNPLwYMmE3w3bhoc0jaZJZ6bc7wGOT6cLbrbs//6m01HxCLtFdSjHvFpwFwoPhxxzIlHSs9cw==",
"version": "17.1.12",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.12.tgz",
"integrity": "sha512-Gf8riCKF/EmqQ8I1g0N5pJOLvJCkAXGAEkzOuRNHc/sBGL3dfE3WZ7+sDLrUo/kw/xdug3g48jo7M+s/SKf0HQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
+5 -5
View File
@@ -57,15 +57,15 @@
"copy-to-clipboard": "^4.0.2",
"date-fns": "^4.4.0",
"date-fns-tz": "^3.2.0",
"framer-motion": "^14.10.0",
"framer-motion": "^14.9.1",
"hls.js": "^1.7.4",
"i18next": "^26.7.2",
"i18next": "^26.7.1",
"i18next-http-backend": "^4.0.10",
"idb-keyval": "^6.3.0",
"js-yaml": "^5.5.0",
"konva": "^10.9.1",
"lodash": "^4.18.1",
"lucide-react": "^1.70.0",
"lucide-react": "^1.69.0",
"monaco-yaml": "^5.5.1",
"next-themes": "^0.4.6",
"nosleep.js": "^0.12.0",
@@ -77,7 +77,7 @@
"react-dropzone": "^20.1.4",
"react-grid-layout": "^2.3.0",
"react-hook-form": "^7.91.0",
"react-i18next": "^17.1.13",
"react-i18next": "^17.1.12",
"react-icons": "^5.7.0",
"react-konva": "^19.3.2",
"react-markdown": "^10.1.0",
@@ -99,7 +99,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.65.0",
"@playwright/test": "^1.64.1",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.27",
"@types/node": "^26.6.6",
@@ -240,14 +240,14 @@ const detect: SectionConfigOverrides = {
},
replay: {
restartRequired: [],
fieldOrder: ["scene", "width", "height", "fps"],
fieldOrder: ["width", "height", "fps"],
fieldGroups: {
resolution: ["width", "height", "fps"],
},
hiddenFields: [
"enabled",
"enabled_in_config",
hideSceneWithoutChoice,
"scene",
"min_initialized",
"max_disappeared",
"annotation_offset",
@@ -150,18 +150,17 @@ export function TrackingDetails({
// Fetch the VOD manifest JSON to get the actual clipFrom after keyframe
// snapping. The backend may snap clipFrom backwards to a keyframe, making
// the video start earlier than the requested time. Uses sourceOffsetRef so
// the manifest describes the same clip as videoSource.
// the video start earlier than the requested time.
const vodManifestUrl = useMemo(() => {
if (!event.camera) return null;
const sourceOffset = sourceOffsetRef.current;
const startTime = event.start_time + sourceOffset / 1000 - REVIEW_PADDING;
const startTime =
event.start_time + annotationOffset / 1000 - REVIEW_PADDING;
const endTime =
(event.end_time ?? Date.now() / 1000) +
sourceOffset / 1000 +
annotationOffset / 1000 +
REVIEW_PADDING;
return `vod/clip/${event.camera}/start/${startTime}/end/${endTime}`;
}, [event]);
}, [event, annotationOffset]);
const { data: vodManifest } = useSWR<VodManifest>(vodManifestUrl, null, {
revalidateOnFocus: false,