Compare commits

...
3 Commits
Author SHA1 Message Date
Martin WeineltandGitHub 97ca7a41f9 Merge b0588a02f9 into 6f80bcd19f 2026-07-19 14:36:44 +08:00
Josh HawkinsandGitHub 6f80bcd19f Miscellaneous fixes (0.18 beta) (#23755)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* resolve saved credential sentinel to the stored api_key in the GenAI probe

* add profile faq

* center the multi-camera export time range on the current playback position

* add faq about preview restart cache

* clarify exports bulk download
2026-07-18 11:19:37 -06:00
Martin Weinelt b0588a02f9 Replace blocking I/O in async functions
Replaces aiofiles with anyio, because anyio.Path is much more complete
and comparable to the Pathlib API.
2026-07-06 22:24:19 +02:00
17 changed files with 243 additions and 78 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
aiofiles == 24.1.*
anyio == 4.14.*
click == 8.1.*
# FastAPI
aiohttp == 3.12.*
+15
View File
@@ -232,6 +232,21 @@ No. Only one profile can be active at a time. Activating a new profile automatic
When you delete a base zone or mask in the Frigate UI, any profile overrides for that entry are deleted automatically as part of the same operation. If you remove a base entry by editing your config file directly and leave a profile override behind, the config will fail validation at startup until the orphaned override is removed as well.
### How do I make a YAML profile track no objects at all?
Set the tracked object list explicitly to an empty list in the profile:
```yaml
cameras:
front_door:
profiles:
home:
objects:
track: []
```
Leaving the `objects` section empty (or omitting `track`) does not clear the list. Empty sections set no fields, so the profile inherits the full tracked object list from the base config, including anything set at the global level. The same applies to other lists, such as `audio.listen`.
### Why are some settings missing when I configure a profile override?
Fields that require a Frigate restart to take effect cannot be overridden by profiles, since profiles are applied at runtime without restarting. Those fields are hidden when editing a profile override and can only be changed on the base configuration.
+16
View File
@@ -428,3 +428,19 @@ You'll want to:
- [Tune your motion detection settings](/configuration/motion_detection) either by editing your config file or by using the UI's Motion Tuner.
</FaqItem>
<FaqItem id="my-timeline-previews-are-black-after-restarting-frigate-or-recreating-the-container" question="My timeline previews are black after restarting Frigate or recreating the container. Why?">
The scrubbing previews (the timelapse clips shown when dragging the History timeline, the secondary-camera previews, and the preview that plays when hovering a review card) are not recorded continuously. Frigate caches low-resolution preview frames in `/tmp/cache` throughout each hour and only assembles them into a finished preview clip **at the top of the hour**.
In the recommended configuration, `/tmp/cache` is a small in-memory (`tmpfs`) area. When Frigate starts, it tries to restore the current hour's cached frames, so a **soft restart from the UI** preserves them. But if you recreate the Docker container or stop Frigate forcibly by any other means partway through an hour, the in-memory cache is discarded, so no preview clip is produced for that partial hour.
This is expected behavior, not a bug:
- Previews for hours that already completed and were written to disk are unaffected.
- The next full hour after a restart will generate previews normally.
- This is unrelated to `shm_size`; increasing shared memory does not change it.
To avoid the gap, use the **Restart Frigate** button in the UI's Settings menu rather than recreating the container when possible.
</FaqItem>
+1 -1
View File
@@ -34,7 +34,7 @@ All of your exports live on the **Exports** page, reachable from the main naviga
- **Rename** it, and
- **Delete** it: deleting is the only way an export is removed.
You can also select multiple exports at once to **delete** them in bulk, or to **add them to** (or **remove them from**) a [case](#cases).
You can also select multiple exports at once to **delete** them in bulk, or to **add them to** (or **remove them from**) a [case](#cases). To download multiple exports as a zip archive, add them to a **case** and use the Download button there.
## Cases
+5
View File
@@ -8244,6 +8244,11 @@ components:
properties:
provider:
$ref: '#/components/schemas/GenAIProviderEnum'
name:
anyOf:
- type: string
- type: 'null'
title: Name
api_key:
anyOf:
- type: string
+12 -5
View File
@@ -14,8 +14,8 @@ from io import StringIO
from pathlib import Path as FilePath
from typing import Any
import aiofiles
import ruamel.yaml
from anyio import open_file as aopen
from fastapi import APIRouter, Body, Path, Request, Response
from fastapi.encoders import jsonable_encoder
from fastapi.params import Depends
@@ -196,7 +196,7 @@ def genai_models(request: Request):
"before saving the configuration."
),
)
async def genai_probe(body: GenAIProbeBody):
async def genai_probe(request: Request, body: GenAIProbeBody):
load_providers()
provider_cls = PROVIDERS.get(body.provider)
@@ -206,6 +206,13 @@ async def genai_probe(body: GenAIProbeBody):
content={"success": False, "message": "Unknown provider"},
)
api_key = body.api_key
if api_key == REDACTED_CREDENTIAL_SENTINEL:
saved_cfg = (
request.app.frigate_config.genai.get(body.name) if body.name else None
)
api_key = saved_cfg.api_key if saved_cfg else None
# The OpenAI-compatible SDKs accept "timeout" as a constructor kwarg via
# provider_options; other plugins use GenAIClient.timeout passed below.
# Don't inject timeout for Gemini — its HttpOptions interprets the value
@@ -217,7 +224,7 @@ async def genai_probe(body: GenAIProbeBody):
try:
transient_cfg = GenAIConfig(
provider=body.provider,
api_key=body.api_key,
api_key=api_key,
base_url=body.base_url,
provider_options=probe_provider_options,
# model is required by the schema but irrelevant for listing.
@@ -1045,7 +1052,7 @@ async def logs(
"""Asynchronously stream log lines."""
buffer = ""
try:
async with aiofiles.open(file_path) as file:
async with await aopen(file_path) as file:
await file.seek(0, 2)
while True:
line = await file.readline()
@@ -1083,7 +1090,7 @@ async def logs(
# For full logs initially
try:
async with aiofiles.open(service_location) as file:
async with await aopen(service_location) as file:
contents = await file.read()
total_lines, log_lines = process_logs(contents, service, start, end)
+12 -9
View File
@@ -10,6 +10,7 @@ from urllib.parse import quote_plus
import httpx
import requests
from anyio import open_file as aopen
from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi.responses import JSONResponse
from filelock import FileLock, Timeout
@@ -1188,15 +1189,17 @@ async def delete_camera(
try:
with lock:
with open(config_file) as f:
old_raw_config = f.read()
async with await aopen(config_file) as f:
old_raw_config = await f.read()
try:
yaml = YAML()
yaml.indent(mapping=2, sequence=4, offset=2)
with open(config_file) as f:
data = yaml.load(f)
async with await aopen(config_file) as f:
text = await f.read()
data = yaml.load(text)
# Remove camera from config
if "cameras" in data and camera_name in data["cameras"]:
@@ -1221,17 +1224,17 @@ async def delete_camera(
for role_name in empty_roles:
del auth["roles"][role_name]
with open(config_file, "w") as f:
async with await aopen(config_file, "w") as f:
yaml.dump(data, f)
with open(config_file) as f:
new_raw_config = f.read()
async with await aopen(config_file) as f:
new_raw_config = await f.read()
try:
config = FrigateConfig.parse(new_raw_config)
except Exception:
with open(config_file, "w") as f:
f.write(old_raw_config)
async with await aopen(config_file, "w") as f:
await f.write(old_raw_config)
logger.exception(
"Config error after removing camera %s",
camera_name,
+1
View File
@@ -14,6 +14,7 @@ class AppConfigSetBody(BaseModel):
class GenAIProbeBody(BaseModel):
provider: GenAIProviderEnum
name: str | None = None
api_key: str | None = None
base_url: str | None = None
provider_options: dict[str, Any] = Field(default_factory=dict)
+3 -2
View File
@@ -13,6 +13,7 @@ from pathlib import Path
from urllib.parse import unquote
import numpy as np
from anyio import Path as AsyncPath
from fastapi import APIRouter, Request
from fastapi.params import Depends
from fastapi.responses import JSONResponse
@@ -1455,10 +1456,10 @@ async def set_attributes(
dataset_dir = os.path.join(CLIPS_DIR, sanitize_filename(model_key), "dataset")
available_labels = set()
if os.path.exists(dataset_dir):
if await AsyncPath(dataset_dir).exists():
for category_name in os.listdir(dataset_dir):
category_dir = os.path.join(dataset_dir, category_name)
if os.path.isdir(category_dir):
if await AsyncPath(category_dir).is_dir():
available_labels.add(category_name)
if not available_labels:
+13 -11
View File
@@ -15,6 +15,8 @@ from urllib.parse import unquote
import cv2
import numpy as np
import pytz
from anyio import Path as AsyncPath
from anyio import open_file as aopen
from fastapi import APIRouter, Depends, Path, Query, Request, Response
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pathvalidate import sanitize_filename
@@ -497,18 +499,18 @@ async def recording_clip(
file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt")
file_path = os.path.join(CACHE_DIR, file_name)
with open(file_path, "w") as file:
async with await aopen(file_path, "w") as file:
clip: Recordings
for clip in recordings:
file.write(f"file '{clip.path}'\n")
await file.write(f"file '{clip.path}'\n")
# if this is the starting clip, add an inpoint
if clip.start_time < start_ts:
file.write(f"inpoint {int(start_ts - clip.start_time)}\n")
await file.write(f"inpoint {int(start_ts - clip.start_time)}\n")
# if this is the ending clip, add an outpoint
if clip.end_time > end_ts:
file.write(f"outpoint {int(end_ts - clip.start_time)}\n")
await file.write(f"outpoint {int(end_ts - clip.start_time)}\n")
if len(file_name) > 1000:
return JSONResponse(
@@ -1149,8 +1151,8 @@ async def event_snapshot_clean(request: Request, event_id: str, download: bool =
)
if image_path.endswith(".webp"):
with open(image_path, "rb") as image_file:
webp_bytes = image_file.read()
async with await aopen(image_path, "rb") as image_file:
webp_bytes = await image_file.read()
else:
image = load_event_snapshot_image(event, clean_only=True)[0]
if image is None:
@@ -1366,7 +1368,7 @@ async def preview_gif(
# need to generate from existing images
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
if not os.path.isdir(preview_dir):
if not await AsyncPath(preview_dir).is_dir():
return JSONResponse(
content={"success": False, "message": "Preview not found"},
status_code=404,
@@ -1555,7 +1557,7 @@ async def preview_mp4(
# need to generate from existing images
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
if not os.path.isdir(preview_dir):
if not await AsyncPath(preview_dir).is_dir():
return JSONResponse(
content={"success": False, "message": "Preview not found"},
status_code=404,
@@ -1633,7 +1635,7 @@ async def preview_mp4(
"Content-Description": "File Transfer",
"Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}",
"Content-Type": "video/mp4",
"Content-Length": str(os.path.getsize(path)),
"Content-Length": str((await AsyncPath(path).stat()).st_size),
# nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
"X-Accel-Redirect": f"/cache/{file_name}",
}
@@ -1707,10 +1709,10 @@ async def preview_thumbnail(request: Request, file_name: str):
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
try:
with open(
async with await aopen(
os.path.join(preview_dir, safe_file_name_current), "rb"
) as image_file:
jpg_bytes = image_file.read()
jpg_bytes = await image_file.read()
except FileNotFoundError:
return JSONResponse(
content=({"success": False, "message": "Image file not found"}),
+2 -2
View File
@@ -4,9 +4,9 @@ import datetime as dt
import logging
from datetime import datetime, timedelta
from functools import reduce
from pathlib import Path
from urllib.parse import unquote
from anyio import Path as AsyncPath
from fastapi import APIRouter, Depends, Request
from fastapi import Path as PathParam
from fastapi.responses import JSONResponse
@@ -443,7 +443,7 @@ async def delete_recordings(
recording_ids.append(recording["id"])
try:
Path(recording["path"]).unlink(missing_ok=True)
await AsyncPath(recording["path"]).unlink(missing_ok=True)
deleted_count += 1
except Exception as e:
logger.error(f"Failed to delete recording file {recording['path']}: {e}")
+13 -10
View File
@@ -15,6 +15,7 @@ from typing import Any
import numpy as np
import psutil
from anyio import Path as AsyncPath
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
from frigate.comms.inter_process import InterProcessRequestor
@@ -105,11 +106,11 @@ class RecordingMaintainer(threading.Thread):
async def move_files(self) -> None:
cache_files = [
d
for d in os.listdir(CACHE_DIR)
if os.path.isfile(os.path.join(CACHE_DIR, d))
and d.endswith(".mp4")
and not d.startswith("preview_")
path.name
async for path in AsyncPath(CACHE_DIR).iterdir()
if await path.is_file()
and path.suffix == ".mp4"
and not path.name.startswith("preview_")
]
# publish newest cached segment per camera (including in use files)
@@ -229,7 +230,7 @@ class RecordingMaintainer(threading.Thread):
to_remove = grouped_recordings[camera][:-keep_count]
for rec in to_remove:
cache_path = rec["cache_path"]
Path(cache_path).unlink(missing_ok=True)
await AsyncPath(cache_path).unlink(missing_ok=True)
self.end_time_cache.pop(cache_path, None)
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
@@ -244,7 +245,7 @@ class RecordingMaintainer(threading.Thread):
to_remove = grouped_recordings[camera][:-keep_count]
for rec in to_remove:
cache_path = rec["cache_path"]
Path(cache_path).unlink(missing_ok=True)
await AsyncPath(cache_path).unlink(missing_ok=True)
self.end_time_cache.pop(cache_path, None)
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
@@ -634,7 +635,7 @@ class RecordingMaintainer(threading.Thread):
file_path = os.path.join(directory, file_name)
try:
if not os.path.exists(file_path):
if not await AsyncPath(file_path).exists():
start_frame = datetime.datetime.now().timestamp()
# add faststart to kept segments to improve metadata reading
@@ -670,7 +671,9 @@ class RecordingMaintainer(threading.Thread):
# get the segment size of the cache file
# file without faststart is same size
segment_size = round(
float(os.path.getsize(cache_path)) / pow(2, 20), 2
float((await AsyncPath(cache_path).stat()).st_size)
/ pow(2, 20),
2,
)
except OSError:
segment_size = 0
@@ -698,7 +701,7 @@ class RecordingMaintainer(threading.Thread):
}
except Exception as e:
logger.error(f"Unable to store recording segment {cache_path}")
Path(cache_path).unlink(missing_ok=True)
await AsyncPath(cache_path).unlink(missing_ok=True)
logger.error(e)
# clear end_time cache
+71
View File
@@ -132,6 +132,77 @@ class TestHttpApp(BaseTestHttp):
"models": ["fake-model-a", "fake-model-b"],
}
def test_genai_probe_resolves_sentinel_to_saved_api_key(self):
# After a save the UI's api_key field holds the redaction sentinel;
# the probe must substitute the saved key for the named entry instead
# of sending the literal sentinel to the provider (GH discussion 23754).
probed_keys: list[str | None] = []
class CapturingClient(GenAIClient):
def list_models(self):
probed_keys.append(self.genai_config.api_key)
return ["fake-model"]
self.minimal_config["genai"] = {
"llm": {
"provider": "openai",
"api_key": "sk-saved",
"base_url": "https://example.invalid",
"model": "fake-model",
}
}
app = super().create_app()
with (
AuthTestClient(app) as client,
patch.dict(
frigate.genai.PROVIDERS,
{GenAIProviderEnum.openai: CapturingClient},
),
):
response = client.post(
"/genai/probe",
json={
"provider": "openai",
"name": "llm",
"api_key": REDACTED_CREDENTIAL_SENTINEL,
"base_url": "https://example.invalid",
},
)
assert response.status_code == 200
assert response.json()["success"] is True
assert probed_keys == ["sk-saved"]
def test_genai_probe_sentinel_without_saved_entry_sends_no_key(self):
# If the sentinel arrives for an entry that has no saved config, the
# probe must drop the key entirely rather than leak the sentinel.
probed_keys: list[str | None] = []
class CapturingClient(GenAIClient):
def list_models(self):
probed_keys.append(self.genai_config.api_key)
return ["fake-model"]
app = super().create_app()
with (
AuthTestClient(app) as client,
patch.dict(
frigate.genai.PROVIDERS,
{GenAIProviderEnum.openai: CapturingClient},
),
):
response = client.post(
"/genai/probe",
json={
"provider": "openai",
"name": "llm",
"api_key": REDACTED_CREDENTIAL_SENTINEL,
},
)
assert response.status_code == 200
assert probed_keys == [None]
def test_genai_probe_empty_list_is_treated_as_failure(self):
# The plugin's list_models() returns [] on connection failure rather
# than raising. The endpoint should surface that as success=false so
+44 -31
View File
@@ -1,7 +1,7 @@
import datetime
import sys
import unittest
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
# Mock complex imports before importing maintainer, saving originals so we can
# restore them after import and avoid polluting sys.modules for other tests.
@@ -42,38 +42,51 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
# One bad file, one good file
files = ["bad_filename.mp4", "camera@20210101000000+0000.mp4"]
with patch("os.listdir", return_value=files):
with patch("os.path.isfile", return_value=True):
with patch(
"frigate.record.maintainer.psutil.process_iter", return_value=[]
):
with patch("frigate.record.maintainer.logger.warning") as warn:
# Mock validate_and_move_segment to avoid further logic
maintainer.validate_and_move_segment = MagicMock()
mock_paths = []
for filename in files:
path = MagicMock()
path.name = filename
path.suffix = ".mp4"
path.is_file = AsyncMock(return_value=True)
mock_paths.append(path)
try:
await maintainer.move_files()
except ValueError as e:
if "not enough values to unpack" in str(e):
self.fail("move_files() crashed on bad filename!")
raise e
except Exception:
# Ignore other errors (like DB connection) as we only care about the unpack crash
pass
async def mock_iterdir():
for path in mock_paths:
yield path
# The bad filename is encountered in multiple loops, but should only warn once.
matching = [
c
for c in warn.call_args_list
if c.args
and isinstance(c.args[0], str)
and "Skipping unexpected files in cache" in c.args[0]
]
self.assertEqual(
1,
len(matching),
f"Expected a single warning for unexpected files, got {len(matching)}",
)
with patch("frigate.record.maintainer.AsyncPath") as mock_async_path:
mock_async_path.return_value.iterdir = mock_iterdir
with patch(
"frigate.record.maintainer.psutil.process_iter", return_value=[]
):
with patch("frigate.record.maintainer.logger.warning") as warn:
# Mock validate_and_move_segment to avoid further logic
maintainer.validate_and_move_segment = MagicMock()
try:
await maintainer.move_files()
except ValueError as e:
if "not enough values to unpack" in str(e):
self.fail("move_files() crashed on bad filename!")
raise e
except Exception:
# Ignore other errors (like DB connection) as we only care about the unpack crash
pass
# The bad filename is encountered in multiple loops, but should only warn once.
matching = [
c
for c in warn.call_args_list
if c.args
and isinstance(c.args[0], str)
and "Skipping unexpected files in cache" in c.args[0]
]
self.assertEqual(
1,
len(matching),
f"Expected a single warning for unexpected files, got {len(matching)}",
)
async def test_drops_quiet_segment_when_only_motion_retention(self):
# Regression: when motion retention is enabled but a segment has no
+17 -2
View File
@@ -2,5 +2,20 @@
target-version = "py311"
[tool.ruff.lint]
ignore = ["E501","E711","E712","UP031","UP032","UP042","G004"]
extend-select = ["I", "UP", "G", "ASYNC210", "B904"]
ignore = [
"ASYNC109", # Async function definition with a timeout parameter
"E501", # line-too-long
"E711", # none-comparison
"E712", # true-false-comparison
"UP031", # printf-string-formatting
"UP032", # f-string
"UP042", # replace-str-enum
"G004", # logging-f-string
]
extend-select = [
"ASYNC", # https://docs.astral.sh/ruff/rules/#flake8-async-async
"B904", # https://docs.astral.sh/ruff/rules/raise-without-from-inside-except/
"G", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
"I", # https://docs.astral.sh/ruff/rules/#isort-i
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
]
@@ -160,6 +160,7 @@ export function GenAIModelWidget(props: WidgetProps) {
try {
const res = await axios.post<ProbeResponse>("genai/probe", {
provider: formProvider,
name: providerKey,
api_key:
typeof formEntry.api_key === "string" ? formEntry.api_key : null,
base_url:
+16 -4
View File
@@ -444,10 +444,10 @@ export function ExportContent({
}
setRange({
before: latestTime,
after: latestTime - 3600,
before: currentTime + 1800,
after: currentTime - 1800,
});
}, [activeTab, latestTime, range, setRange]);
}, [activeTab, currentTime, range, setRange]);
const { data: events, isLoading: isEventsLoading } = useSWR<Event[]>(
activeTab === "multi" && debouncedRange
@@ -817,7 +817,19 @@ export function ExportContent({
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as ExportTab)}
onValueChange={(value) => {
const tab = value as ExportTab;
if (tab === "multi") {
setRange({
before: currentTime + 1800,
after: currentTime - 1800,
});
} else {
onSelectTime(selectedOption);
}
setActiveTab(tab);
}}
className={cn("w-full", !isDesktop && "flex min-h-0 flex-1 flex-col")}
>
<TabsList className="grid w-full grid-cols-2">