Compare commits

...

3 Commits

Author SHA1 Message Date
Patrick Decat
4e6c1f74e0
Merge 7ec1d5d2c6 into 0a8f499640 2026-01-19 13:56:23 +00:00
Josh Hawkins
0a8f499640
Miscellaneous fixes (0.17 beta) (#21683)
Some checks failed
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
* misc triggers tweaks

i18n fixes
fix toaster color
fix clicking on labels selecting incorrect checkbox

* update copilot instructions

* lpr docs tweaks

* add retry params to gemini

* i18n fix

* ensure users only see recognized plates from accessible cameras in explore

* ensure all zone filters are converted to pixels

zone-level filters were never converted from percentage area to pixels. RuntimeFilterConfig was only applied to filters at the camera level, not zone.filters.

Fixes https://github.com/blakeblackshear/frigate/discussions/21694

* add test for percentage based zone filters

* use export id for key instead of name

* update gemini docs
2026-01-18 06:36:27 -07:00
Patrick Decat
7ec1d5d2c6
feat: add max_size to recording settings
Resolves #9275
2025-12-31 19:34:12 +01:00
17 changed files with 246 additions and 31 deletions

View File

@ -1,2 +1,3 @@
Never write strings in the frontend directly, always write to and reference the relevant translations file.
Always conform new and refactored code to the existing coding style in the project.
- For Frigate NVR, never write strings in the frontend directly. Since the project uses `react-i18next`, use `t()` and write the English string in the relevant translations file in `web/public/locales/en`.
- Always conform new and refactored code to the existing coding style in the project.
- Always have a way to test your work and confirm your changes. When running backend tests, use `python3 -u -m unittest`.

View File

@ -66,8 +66,6 @@ Some models are labeled as **hybrid** (capable of both thinking and instruct tas
**Recommendation:**
Always select the `-instruct` or documented instruct/tagged variant of any model you use in your Frigate configuration. If in doubt, refer to your model providers documentation or model library for guidance on the correct model variant to use.
### Supported Models
You must use a vision capable model with Frigate. Current model variants can be found [in their model library](https://ollama.com/search?c=vision). Note that Frigate will not automatically download the model you specify in your config, you must download the model to your local instance of Ollama first i.e. by running `ollama pull qwen3-vl:2b-instruct` on your Ollama server/Docker container. Note that the model specified in Frigate's config must match the downloaded model tag.
@ -93,7 +91,7 @@ genai:
## Google Gemini
Google Gemini has a free tier allowing [15 queries per minute](https://ai.google.dev/pricing) to the API, which is more than sufficient for standard Frigate usage.
Google Gemini has a [free tier](https://ai.google.dev/pricing) for the API, however the limits may not be sufficient for standard Frigate usage. Choose a plan appropriate for your installation.
### Supported Models
@ -114,7 +112,7 @@ To start using Gemini, you must first get an API key from [Google AI Studio](htt
genai:
provider: gemini
api_key: "{FRIGATE_GEMINI_API_KEY}"
model: gemini-2.0-flash
model: gemini-2.5-flash
```
:::note

View File

@ -68,8 +68,8 @@ Fine-tune the LPR feature using these optional parameters at the global level of
- Default: `1000` pixels. Note: this is intentionally set very low as it is an _area_ measurement (length x width). For reference, 1000 pixels represents a ~32x32 pixel square in your camera image.
- Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant plates.
- **`device`**: Device to use to run license plate detection _and_ recognition models.
- Default: `CPU`
- This can be `CPU`, `GPU`, or the GPU's device number. For users without a model that detects license plates natively, using a GPU may increase performance of the YOLOv9 license plate detector model. See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_enrichments.md) documentation. However, for users who run a model that detects `license_plate` natively, there is little to no performance gain reported with running LPR on GPU compared to the CPU.
- Default: `None`
- This is auto-selected by Frigate and can be `CPU`, `GPU`, or the GPU's device number. For users without a model that detects license plates natively, using a GPU may increase performance of the YOLOv9 license plate detector model. See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_enrichments.md) documentation. However, for users who run a model that detects `license_plate` natively, there is little to no performance gain reported with running LPR on GPU compared to the CPU.
- **`model_size`**: The size of the model used to identify regions of text on plates.
- Default: `small`
- This can be `small` or `large`.
@ -432,6 +432,6 @@ If you are using a model that natively detects `license_plate`, add an _object m
If you are not using a model that natively detects `license_plate` or you are using dedicated LPR camera mode, only a _motion mask_ over your text is required.
### I see "Error running ... model" in my logs. How can I fix this?
### I see "Error running ... model" in my logs, or my inference time is very high. How can I fix this?
This usually happens when your GPU is unable to compile or use one of the LPR models. Set your `device` to `CPU` and try again. GPU acceleration only provides a slight performance increase, and the models are lightweight enough to run without issue on most CPUs.

View File

@ -510,6 +510,12 @@ record:
# Optional: Number of minutes to wait between cleanup runs (default: shown below)
# This can be used to reduce the frequency of deleting recording segments from disk if you want to minimize i/o
expire_interval: 60
# Optional: Maximum size of recordings in MB or string format (e.g. 10GB). (default: shown below)
# This serves as a hard limit for the size of the recordings for this camera.
# If the total size of recordings exceeds this limit, the oldest recordings will be deleted
# until the total size is below the limit, regardless of retention settings.
# 0 means no limit.
max_size: 0
# Optional: Two-way sync recordings database with disk on startup and once a day (default: shown below).
sync_recordings: False
# Optional: Continuous retention settings

View File

@ -23,7 +23,12 @@ from markupsafe import escape
from peewee import SQL, fn, operator
from pydantic import ValidationError
from frigate.api.auth import allow_any_authenticated, allow_public, require_role
from frigate.api.auth import (
allow_any_authenticated,
allow_public,
get_allowed_cameras_for_filter,
require_role,
)
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
from frigate.api.defs.request.app_body import AppConfigSetBody
from frigate.api.defs.tags import Tags
@ -687,13 +692,19 @@ def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
@router.get(
"/recognized_license_plates", dependencies=[Depends(allow_any_authenticated())]
)
def get_recognized_license_plates(split_joined: Optional[int] = None):
def get_recognized_license_plates(
split_joined: Optional[int] = None,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
try:
query = (
Event.select(
SQL("json_extract(data, '$.recognized_license_plate') AS plate")
)
.where(SQL("json_extract(data, '$.recognized_license_plate') IS NOT NULL"))
.where(
(SQL("json_extract(data, '$.recognized_license_plate') IS NOT NULL"))
& (Event.camera << allowed_cameras)
)
.distinct()
)
recognized_license_plates = [row[0] for row in query.tuples()]

View File

@ -1,10 +1,11 @@
from enum import Enum
from typing import Optional
from typing import Optional, Union
from pydantic import Field
from pydantic import Field, field_validator
from frigate.const import MAX_PRE_CAPTURE
from frigate.review.types import SeverityEnum
from frigate.util.size import parse_size_to_mb
from ..base import FrigateBaseModel
@ -81,6 +82,10 @@ class RecordConfig(FrigateBaseModel):
default=60,
title="Number of minutes to wait between cleanup runs.",
)
max_size: Union[float, str] = Field(
default=0,
title="Maximum size of recordings in MB or string format (e.g. 10GB).",
)
continuous: RecordRetainConfig = Field(
default_factory=RecordRetainConfig,
title="Continuous recording retention settings.",
@ -104,6 +109,16 @@ class RecordConfig(FrigateBaseModel):
default=None, title="Keep track of original state of recording."
)
@field_validator("max_size", mode="before")
@classmethod
def parse_max_size(cls, v: Union[float, str], info: object) -> float:
if isinstance(v, str):
try:
return parse_size_to_mb(v)
except ValueError:
raise ValueError(f"Invalid size string: {v}")
return v
@property
def event_pre_capture(self) -> int:
return max(

View File

@ -662,6 +662,13 @@ class FrigateConfig(FrigateBaseModel):
# generate zone contours
if len(camera_config.zones) > 0:
for zone in camera_config.zones.values():
if zone.filters:
for object_name, filter_config in zone.filters.items():
zone.filters[object_name] = RuntimeFilterConfig(
frame_shape=camera_config.frame_shape,
**filter_config.model_dump(exclude_unset=True),
)
zone.generate_contour(camera_config.frame_shape)
# Set live view stream if none is set

View File

@ -24,6 +24,14 @@ class GeminiClient(GenAIClient):
http_options_dict = {
"api_version": "v1",
"timeout": int(self.timeout * 1000), # requires milliseconds
"retry_options": types.HttpRetryOptions(
attempts=3,
initial_delay=1.0,
max_delay=60.0,
exp_base=2.0,
jitter=1.0,
http_status_codes=[429, 500, 502, 503, 504],
),
}
if isinstance(self.genai_config.provider_options, dict):

View File

@ -20,6 +20,17 @@ from frigate.util.time import get_tomorrow_at_time
logger = logging.getLogger(__name__)
def get_directory_size(directory: str) -> float:
"""Get the size of a directory in MB."""
total_size = 0
for dirpath, dirnames, filenames in os.walk(directory):
for f in filenames:
fp = os.path.join(dirpath, f)
if not os.path.islink(fp):
total_size += os.path.getsize(fp)
return total_size / 1000000
class RecordingCleanup(threading.Thread):
"""Cleanup existing recordings based on retention config."""
@ -120,6 +131,7 @@ class RecordingCleanup(threading.Thread):
Recordings.objects,
Recordings.motion,
Recordings.dBFS,
Recordings.segment_size,
)
.where(
(Recordings.camera == config.name)
@ -206,6 +218,10 @@ class RecordingCleanup(threading.Thread):
Recordings.id << deleted_recordings_list[i : i + max_deletes]
).execute()
# Check if we need to enforce max_size
if config.record.max_size > 0:
self.enforce_max_size(config, deleted_recordings)
previews: list[Previews] = (
Previews.select(
Previews.id,
@ -266,6 +282,52 @@ class RecordingCleanup(threading.Thread):
Previews.id << deleted_previews_list[i : i + max_deletes]
).execute()
def enforce_max_size(
self, config: CameraConfig, deleted_recordings: set[str]
) -> None:
"""Ensure that the camera recordings do not exceed the max size."""
# Get all recordings for this camera
recordings: Recordings = (
Recordings.select(
Recordings.id,
Recordings.path,
Recordings.segment_size,
)
.where(
(Recordings.camera == config.name)
& (Recordings.id.not_in(list(deleted_recordings)))
)
.order_by(Recordings.start_time)
.namedtuples()
.iterator()
)
total_size = 0
recordings_list = []
for recording in recordings:
recordings_list.append(recording)
total_size += recording.segment_size
# If the total size is less than the max size, we are good
if total_size <= config.record.max_size:
return
# Delete recordings until we are under the max size
recordings_to_delete = []
for recording in recordings_list:
total_size -= recording.segment_size
recordings_to_delete.append(recording.id)
Path(recording.path).unlink(missing_ok=True)
if total_size <= config.record.max_size:
break
# Delete from database
max_deletes = 100000
for i in range(0, len(recordings_to_delete), max_deletes):
Recordings.delete().where(
Recordings.id << recordings_to_delete[i : i + max_deletes]
).execute()
def expire_recordings(self) -> None:
"""Delete recordings based on retention config."""
logger.debug("Start expire recordings.")

View File

@ -429,6 +429,29 @@ class TestConfig(unittest.TestCase):
frigate_config = FrigateConfig(**config)
assert "-rtsp_transport" in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
def test_record_max_size_validation(self):
config = {
"mqtt": {"host": "mqtt"},
"record": {"max_size": "10GB"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.record.max_size == 10000
def test_ffmpeg_params_global(self):
config = {
"ffmpeg": {"input_args": "-re"},
@ -632,6 +655,49 @@ class TestConfig(unittest.TestCase):
)
assert frigate_config.cameras["back"].zones["test"].color != (0, 0, 0)
def test_zone_filter_area_percent_converts_to_pixels(self):
config = {
"mqtt": {"host": "mqtt"},
"record": {
"alerts": {
"retain": {
"days": 20,
}
}
},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"zones": {
"notification": {
"coordinates": "0.03,1,0.025,0,0.626,0,0.643,1",
"objects": ["person"],
"filters": {"person": {"min_area": 0.1}},
}
},
}
},
}
frigate_config = FrigateConfig(**config)
expected_min_area = int(1080 * 1920 * 0.1)
assert (
frigate_config.cameras["back"]
.zones["notification"]
.filters["person"]
.min_area
== expected_min_area
)
def test_zone_relative_matches_explicit(self):
config = {
"mqtt": {"host": "mqtt"},

View File

@ -31,3 +31,15 @@ class TestRecordRetention(unittest.TestCase):
)
assert not segment_info.should_discard_segment(RetainModeEnum.motion)
assert segment_info.should_discard_segment(RetainModeEnum.active_objects)
def test_size_utility(self):
from frigate.util.size import parse_size_to_mb
assert parse_size_to_mb("10GB") == 10240
assert parse_size_to_mb("10MB") == 10
assert parse_size_to_mb("1024KB") == 1
assert parse_size_to_mb("1048576B") == 1
assert parse_size_to_mb("10") == 10
with self.assertRaises(ValueError):
parse_size_to_mb("invalid")

21
frigate/util/size.py Normal file
View File

@ -0,0 +1,21 @@
"""Utility for parsing size strings."""
def parse_size_to_mb(size_str: str) -> float:
"""Parse a size string to megabytes."""
size_str = size_str.strip().upper()
if size_str.endswith("TB"):
return float(size_str[:-2]) * 1024 * 1024
elif size_str.endswith("GB"):
return float(size_str[:-2]) * 1024
elif size_str.endswith("MB"):
return float(size_str[:-2])
elif size_str.endswith("KB"):
return float(size_str[:-2]) / 1024
elif size_str.endswith("B"):
return float(size_str[:-1]) / (1024 * 1024)
else:
try:
return float(size_str)
except ValueError:
raise ValueError(f"Invalid size string: {size_str}")

View File

@ -3,6 +3,7 @@
"untilForTime": "Until {{time}}",
"untilForRestart": "Until Frigate restarts.",
"untilRestart": "Until restart",
"never": "Never",
"ago": "{{timeAgo}} ago",
"justNow": "Just now",
"today": "Today",

View File

@ -268,7 +268,7 @@ export default function CreateTriggerDialog({
<FormItem className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<FormLabel className="text-base">
{t("enabled", { ns: "common" })}
{t("button.enabled", { ns: "common" })}
</FormLabel>
<div className="text-sm text-muted-foreground">
{t("triggers.dialog.form.enabled.description")}
@ -394,7 +394,10 @@ export default function CreateTriggerDialog({
</FormLabel>
<div className="space-y-2">
{availableActions.map((action) => (
<div key={action} className="flex items-center space-x-2">
<label
key={action}
className="flex cursor-pointer items-center space-x-2"
>
<FormControl>
<Checkbox
checked={form
@ -416,10 +419,10 @@ export default function CreateTriggerDialog({
}}
/>
</FormControl>
<FormLabel className="text-sm font-normal">
<span className="text-sm font-normal">
{t(`triggers.actions.${action}`)}
</FormLabel>
</div>
</span>
</label>
))}
</div>
<FormDescription>

View File

@ -142,7 +142,10 @@ export default function Step3ThresholdAndActions({
<FormLabel>{t("triggers.dialog.form.actions.title")}</FormLabel>
<div className="space-y-2">
{availableActions.map((action) => (
<div key={action} className="flex items-center space-x-2">
<label
key={action}
className="flex cursor-pointer items-center space-x-2"
>
<FormControl>
<Checkbox
checked={form
@ -164,10 +167,10 @@ export default function Step3ThresholdAndActions({
}}
/>
</FormControl>
<FormLabel className="text-sm font-normal">
<span className="text-sm font-normal">
{t(`triggers.actions.${action}`)}
</FormLabel>
</div>
</span>
</label>
))}
</div>
<FormDescription>
@ -197,9 +200,7 @@ export default function Step3ThresholdAndActions({
{isLoading && <ActivityIndicator className="mr-2 size-5" />}
{isLoading
? t("button.saving", { ns: "common" })
: t("triggers.dialog.form.save", {
defaultValue: "Save Trigger",
})}
: t("button.save", { ns: "common" })}
</Button>
</div>
</form>

View File

@ -206,7 +206,7 @@ function Exports() {
>
{Object.values(exports).map((item) => (
<ExportCard
key={item.name}
key={item.id}
className={
search == "" || filteredExports.includes(item) ? "" : "hidden"
}

View File

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Toaster, toast } from "sonner";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
import useSWR from "swr";
import axios from "axios";
import { Button } from "@/components/ui/button";
@ -598,7 +599,7 @@ export default function TriggerView({
date_style: "medium",
},
)
: "Never"}
: t("never", { ns: "common" })}
</span>
{trigger_status?.triggers[trigger.name]
?.triggering_event_id && (
@ -663,7 +664,9 @@ export default function TriggerView({
<TableHeader className="sticky top-0 bg-muted/50">
<TableRow>
<TableHead className="w-4"></TableHead>
<TableHead>{t("name", { ns: "common" })}</TableHead>
<TableHead>
{t("name", { ns: "triggers.table.name" })}
</TableHead>
<TableHead>{t("triggers.table.type")}</TableHead>
<TableHead>
{t("triggers.table.lastTriggered")}
@ -759,7 +762,7 @@ export default function TriggerView({
date_style: "medium",
},
)
: "Never"}
: t("time.never", { ns: "common" })}
</span>
{trigger_status?.triggers[trigger.name]
?.triggering_event_id && (