Compare commits

..
Author SHA1 Message Date
thunderwolfandGitHub c21001efd7 Merge fa116949ce into 48aaafba3c 2026-07-16 22:42:22 +02:00
thunderwolf fa116949ce Publish segment end time for valid recording segment updates
The record watchdog compares latest_valid_segment_time against now() with
the record_stale_threshold. Publishing the segment start time means the
newest validated timestamp is already ~2x segment duration old in healthy
steady state (own duration + next segment recording + maintainer latency),
leaving little margin before a working ffmpeg record process is restarted.
Publish the end time so staleness measures the actual age of the newest
validated video data.
2026-07-07 22:27:14 -05:00
21 changed files with 51 additions and 239 deletions
+1 -1
View File
@@ -90,7 +90,7 @@ jobs:
with:
persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v7.0.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Install requirements
-13
View File
@@ -262,19 +262,6 @@ In this example:
- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups).
:::note
If a user isn't getting the role you expect, enable debug logging to see exactly what headers Frigate is receiving from your proxy:
```yaml
logger:
default: info
logs:
frigate.api.auth: debug
```
:::
#### Port Considerations
**Authenticated Port (8971)**
-15
View File
@@ -232,21 +232,6 @@ 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,19 +428,3 @@ 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). To download multiple exports as a zip archive, add them to a **case** and use the Download button there.
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).
## Cases
-5
View File
@@ -8244,11 +8244,6 @@ components:
properties:
provider:
$ref: '#/components/schemas/GenAIProviderEnum'
name:
anyOf:
- type: string
- type: 'null'
title: Name
api_key:
anyOf:
- type: string
+2 -9
View File
@@ -196,7 +196,7 @@ def genai_models(request: Request):
"before saving the configuration."
),
)
async def genai_probe(request: Request, body: GenAIProbeBody):
async def genai_probe(body: GenAIProbeBody):
load_providers()
provider_cls = PROVIDERS.get(body.provider)
@@ -206,13 +206,6 @@ async def genai_probe(request: Request, 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
@@ -224,7 +217,7 @@ async def genai_probe(request: Request, body: GenAIProbeBody):
try:
transient_cfg = GenAIConfig(
provider=body.provider,
api_key=api_key,
api_key=body.api_key,
base_url=body.base_url,
provider_options=probe_provider_options,
# model is required by the schema but irrelevant for listing.
-1
View File
@@ -14,7 +14,6 @@ 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)
+2
View File
@@ -23,6 +23,7 @@ from frigate.const import (
EXPIRE_AUDIO_ACTIVITY,
INSERT_MANY_RECORDINGS,
INSERT_PREVIEW,
NOTIFICATION_TEST,
REQUEST_REGION_GRID,
UPDATE_AUDIO_ACTIVITY,
UPDATE_AUDIO_TRANSCRIPTION_STATE,
@@ -56,6 +57,7 @@ _WS_BLOCKED_TOPICS = frozenset(
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
UPDATE_BIRDSEYE_LAYOUT,
UPDATE_AUDIO_TRANSCRIPTION_STATE,
NOTIFICATION_TEST,
}
)
@@ -288,10 +288,6 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
max(0, face_box[0]) : min(frame.shape[1], face_box[2]),
]
if face_frame.size == 0:
logger.debug(f"Empty face crop for {id}")
return
res = self.recognizer.classify(face_frame)
if not res:
+4 -1
View File
@@ -383,8 +383,11 @@ class RecordingMaintainer(threading.Thread):
return None
# this segment has a valid duration and has video data, so publish an update
# publish the segment end time so the watchdog measures the age of the
# newest validated video data; the start time is already ~2x segment
# duration old by the time the next segment finishes and is validated
self.recordings_publisher.publish(
(camera, start_time.timestamp(), cache_path),
(camera, end_time.timestamp(), cache_path),
RecordingsDataTypeEnum.valid.value,
)
-71
View File
@@ -132,77 +132,6 @@ 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
+3 -1
View File
@@ -8,7 +8,9 @@ from frigate.util.builtin import clean_camera_user_pass, escape_special_characte
class TestUserPassCleanup(unittest.TestCase):
def setUp(self) -> None:
self.rtsp_with_pass = "rtsp://user:password@192.168.0.2:554/live"
self.rtsp_with_special_pass = "rtsp://user:password`~!@#$%^&*()-_;',.<>:\"\\{\\}\\[\\]@@192.168.0.2:554/live"
self.rtsp_with_special_pass = (
"rtsp://user:password`~!@#$%^&*()-_;',.<>:\"\{\}\[\]@@192.168.0.2:554/live"
)
self.rtsp_no_pass = "rtsp://192.168.0.3:554/live"
def test_cleanup(self):
-14
View File
@@ -115,13 +115,6 @@ class TestCheckWsAuthorization(unittest.TestCase):
)
)
def test_viewer_blocked_from_notification_test(self):
self.assertFalse(
_check_ws_authorization(
"notification_test", "viewer", self.DEFAULT_SEPARATOR
)
)
# --- Admin access ---
def test_admin_can_send_restart(self):
@@ -141,13 +134,6 @@ class TestCheckWsAuthorization(unittest.TestCase):
_check_ws_authorization("front_door/ptz", "admin", self.DEFAULT_SEPARATOR)
)
def test_admin_can_send_notification_test(self):
self.assertTrue(
_check_ws_authorization(
"notification_test", "admin", self.DEFAULT_SEPARATOR
)
)
# --- Comma-separated roles ---
def test_comma_separated_admin_viewer_grants_admin(self):
+15 -14
View File
@@ -684,21 +684,22 @@ class TrackedObjectProcessor(threading.Thread):
# check for config updates
updated_topics = self.camera_config_subscriber.check_for_updates()
# a single drain can carry several topics at once, so add and
# remove are handled independently rather than as exclusive branches
for camera in updated_topics.get("add", []):
self.config.cameras[camera] = (
self.camera_config_subscriber.camera_configs[camera]
)
self.create_camera_state(camera)
if "remove" in updated_topics:
if "enabled" in updated_topics:
for camera in updated_topics["enabled"]:
if self.camera_states[camera].prev_enabled is None:
self.camera_states[camera].prev_enabled = self.config.cameras[
camera
].enabled
elif "add" in updated_topics:
for camera in updated_topics["add"]:
self.config.cameras[camera] = (
self.camera_config_subscriber.camera_configs[camera]
)
self.create_camera_state(camera)
elif "remove" in updated_topics:
for camera in updated_topics["remove"]:
camera_state = self.camera_states.get(camera)
if camera_state is None:
continue
camera_state.shutdown()
removed_camera_state = self.camera_states[camera]
removed_camera_state.shutdown()
self.camera_states.pop(camera)
self.camera_activity.pop(camera, None)
self.last_motion_detected.pop(camera, None)
@@ -160,7 +160,6 @@ 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:
@@ -228,18 +227,16 @@ export function GenAIModelWidget(props: WidgetProps) {
aria-expanded={open}
disabled={disabled || readonly}
className={cn(
"min-w-0 justify-between font-normal",
"justify-between font-normal",
!currentLabel && "text-muted-foreground",
fieldClassName,
)}
>
<span className="truncate">
{currentLabel ??
t("configForm.genaiModel.placeholder", {
ns: "views/settings",
defaultValue: "Select or enter a model…",
})}
</span>
{currentLabel ??
t("configForm.genaiModel.placeholder", {
ns: "views/settings",
defaultValue: "Select or enter a model…",
})}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@@ -266,14 +263,12 @@ export function GenAIModelWidget(props: WidgetProps) {
value={trimmedSearch}
onSelect={() => commit(trimmedSearch)}
>
<Plus className="mr-2 h-4 w-4 shrink-0" />
<span className="truncate">
{t("configForm.genaiModel.useCustom", {
ns: "views/settings",
value: trimmedSearch,
defaultValue: 'Use "{{value}}"',
})}
</span>
<Plus className="mr-2 h-4 w-4" />
{t("configForm.genaiModel.useCustom", {
ns: "views/settings",
value: trimmedSearch,
defaultValue: 'Use "{{value}}"',
})}
</CommandItem>
</CommandGroup>
)}
@@ -292,11 +287,11 @@ export function GenAIModelWidget(props: WidgetProps) {
>
<Check
className={cn(
"mr-2 h-4 w-4 shrink-0",
"mr-2 h-4 w-4",
value === model ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{model}</span>
{model}
</CommandItem>
))}
</CommandGroup>
@@ -129,7 +129,7 @@ export function GeneralFilterContent({
className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
htmlFor={item}
>
{t(`logger.logLevel.${item}`, { ns: "views/settings" })}
{item.replaceAll("_", " ")}
</Label>
<Switch
key={item}
+1 -3
View File
@@ -3,7 +3,6 @@ import { LogSeverity } from "@/types/log";
import { ReactNode, useMemo } from "react";
import { isIOS } from "react-device-detect";
import { AnimatePresence, motion } from "framer-motion";
import { useTranslation } from "react-i18next";
type ChipProps = {
className?: string;
@@ -51,7 +50,6 @@ type LogChipProps = {
onClickSeverity?: () => void;
};
export function LogChip({ severity, onClickSeverity }: LogChipProps) {
const { t } = useTranslation(["views/settings"]);
const severityClassName = useMemo(() => {
switch (severity) {
case "info":
@@ -75,7 +73,7 @@ export function LogChip({ severity, onClickSeverity }: LogChipProps) {
}
}}
>
{t(`logger.logLevel.${severity}`, { ns: "views/settings" })}
{severity}
</span>
</div>
);
+4 -16
View File
@@ -444,10 +444,10 @@ export function ExportContent({
}
setRange({
before: currentTime + 1800,
after: currentTime - 1800,
before: latestTime,
after: latestTime - 3600,
});
}, [activeTab, currentTime, range, setRange]);
}, [activeTab, latestTime, range, setRange]);
const { data: events, isLoading: isEventsLoading } = useSWR<Event[]>(
activeTab === "multi" && debouncedRange
@@ -817,19 +817,7 @@ export function ExportContent({
<Tabs
value={activeTab}
onValueChange={(value) => {
const tab = value as ExportTab;
if (tab === "multi") {
setRange({
before: currentTime + 1800,
after: currentTime - 1800,
});
} else {
onSelectTime(selectedOption);
}
setActiveTab(tab);
}}
onValueChange={(value) => setActiveTab(value as ExportTab)}
className={cn("w-full", !isDesktop && "flex min-h-0 flex-1 flex-col")}
>
<TabsList className="grid w-full grid-cols-2">
+1 -4
View File
@@ -60,10 +60,7 @@ const CommandList = React.forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn(
"scrollbar-container max-h-[300px] overflow-y-auto overflow-x-hidden",
className,
)}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
@@ -770,34 +770,6 @@ export default function MotionSearchView({
};
}, [cancelMotionSearchJobViaBeacon]);
const handleBack = useCallback(() => {
if (onBack) {
onBack();
} else {
navigate(-1);
}
}, [navigate, onBack]);
// Dismissing the entry dialog (escape / click outside) before a search has
// run leaves nothing behind it, so cancel the flow instead of revealing an
// empty page.
const handleSearchDialogOpenChange = useCallback(
(nextOpen: boolean) => {
if (
!nextOpen &&
!isSearching &&
!hasSearched &&
searchResults.length === 0
) {
handleBack();
return;
}
setIsSearchDialogOpen(nextOpen);
},
[handleBack, hasSearched, isSearching, searchResults.length],
);
const handleNewSearch = useCallback(() => {
if (jobId && jobCamera) {
void cancelMotionSearchJob(jobId, jobCamera);
@@ -1266,7 +1238,7 @@ export default function MotionSearchView({
<Toaster closeButton={true} position="top-center" />
<MotionSearchDialog
open={isSearchDialogOpen}
onOpenChange={handleSearchDialogOpenChange}
onOpenChange={setIsSearchDialogOpen}
config={config}
cameras={cameras}
selectedCamera={selectedCamera}
@@ -1304,7 +1276,7 @@ export default function MotionSearchView({
className="flex items-center gap-2.5 rounded-lg"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={handleBack}
onClick={() => (onBack ? onBack() : navigate(-1))}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (