From 6f80bcd19fb40afd280f1d5462d4f776304482e6 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Sat, 18 Jul 2026 12:19:37 -0500
Subject: [PATCH] Miscellaneous fixes (0.18 beta) (#23755)
* 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
---
docs/docs/configuration/profiles.md | 15 ++++
docs/docs/troubleshooting/recordings.md | 16 +++++
docs/docs/usage/exports.md | 2 +-
docs/static/frigate-api.yaml | 5 ++
frigate/api/app.py | 11 ++-
frigate/api/defs/request/app_body.py | 1 +
frigate/test/http_api/test_http_app.py | 71 +++++++++++++++++++
.../theme/widgets/GenAIModelWidget.tsx | 1 +
web/src/components/overlay/ExportDialog.tsx | 20 ++++--
9 files changed, 135 insertions(+), 7 deletions(-)
diff --git a/docs/docs/configuration/profiles.md b/docs/docs/configuration/profiles.md
index 3eab2bd13..13e08f76d 100644
--- a/docs/docs/configuration/profiles.md
+++ b/docs/docs/configuration/profiles.md
@@ -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.
diff --git a/docs/docs/troubleshooting/recordings.md b/docs/docs/troubleshooting/recordings.md
index c4e8e72b2..372c7b8dc 100644
--- a/docs/docs/troubleshooting/recordings.md
+++ b/docs/docs/troubleshooting/recordings.md
@@ -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.
+
+
+
+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.
+
+
diff --git a/docs/docs/usage/exports.md b/docs/docs/usage/exports.md
index a593374b3..885daa06f 100644
--- a/docs/docs/usage/exports.md
+++ b/docs/docs/usage/exports.md
@@ -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
diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml
index af3a019b4..f8519c4b4 100644
--- a/docs/static/frigate-api.yaml
+++ b/docs/static/frigate-api.yaml
@@ -8244,6 +8244,11 @@ components:
properties:
provider:
$ref: '#/components/schemas/GenAIProviderEnum'
+ name:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Name
api_key:
anyOf:
- type: string
diff --git a/frigate/api/app.py b/frigate/api/app.py
index 7f78f4b56..142d4ee0e 100644
--- a/frigate/api/app.py
+++ b/frigate/api/app.py
@@ -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.
diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py
index b0b85c7ab..a33148270 100644
--- a/frigate/api/defs/request/app_body.py
+++ b/frigate/api/defs/request/app_body.py
@@ -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)
diff --git a/frigate/test/http_api/test_http_app.py b/frigate/test/http_api/test_http_app.py
index 4c581dd42..ef5b99ad0 100644
--- a/frigate/test/http_api/test_http_app.py
+++ b/frigate/test/http_api/test_http_app.py
@@ -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
diff --git a/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx b/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx
index 58a30434b..52fd3aa57 100644
--- a/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx
+++ b/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx
@@ -160,6 +160,7 @@ export function GenAIModelWidget(props: WidgetProps) {
try {
const res = await axios.post("genai/probe", {
provider: formProvider,
+ name: providerKey,
api_key:
typeof formEntry.api_key === "string" ? formEntry.api_key : null,
base_url:
diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx
index 0b0cb84c1..1add0b09b 100644
--- a/web/src/components/overlay/ExportDialog.tsx
+++ b/web/src/components/overlay/ExportDialog.tsx
@@ -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(
activeTab === "multi" && debouncedRange
@@ -817,7 +817,19 @@ export function ExportContent({
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")}
>