mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 12:48:58 +03:00
Compare commits
8
Commits
analytics
...
de416b7ae7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de416b7ae7 | ||
|
|
06967fec91 | ||
|
|
d69107de33 | ||
|
|
04480a18b6 | ||
|
|
b02aea03cd | ||
|
|
51171319a4 | ||
|
|
b1b725b80a | ||
|
|
50a2b6729e |
@@ -1,7 +1,7 @@
|
|||||||
default_target: local
|
default_target: local
|
||||||
|
|
||||||
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
|
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
|
||||||
VERSION = 0.18.0
|
VERSION = 0.18.1
|
||||||
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
|
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
|
||||||
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
|
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||||
BOARDS= #Initialized empty
|
BOARDS= #Initialized empty
|
||||||
|
|||||||
@@ -191,14 +191,12 @@ cameras:
|
|||||||
detect:
|
detect:
|
||||||
enabled: false
|
enabled: false
|
||||||
record:
|
record:
|
||||||
enabled: false
|
enabled: true
|
||||||
profiles:
|
profiles:
|
||||||
away:
|
away:
|
||||||
enabled: true
|
enabled: true
|
||||||
detect:
|
detect:
|
||||||
enabled: true
|
enabled: true
|
||||||
record:
|
|
||||||
enabled: true
|
|
||||||
home:
|
home:
|
||||||
enabled: false
|
enabled: false
|
||||||
```
|
```
|
||||||
@@ -251,6 +249,12 @@ Leaving the `objects` section empty (or omitting `track`) does not clear the lis
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
### Why can't a profile enable recording when it's disabled in the base config?
|
||||||
|
|
||||||
|
Frigate only sets up a camera's recording stream at startup when recording is enabled in the base config, so enabling it later from a profile has no effect. The same applies to turning recording on from the UI or MQTT.
|
||||||
|
|
||||||
|
To keep recording off by default, leave `record.enabled: true` in the base config and create a profile that sets `record.enabled: false`. Activate that profile and it will be restored automatically when Frigate starts.
|
||||||
|
|
||||||
### Can I schedule profiles to be enabled or disabled at certain times?
|
### Can I schedule profiles to be enabled or disabled at certain times?
|
||||||
|
|
||||||
Not within Frigate itself. Frigate is an NVR, not an automation platform, so it intentionally does not include a scheduler for activating profiles. Instead, activate profiles from an automation platform that already handles time- and event-based triggers well, such as [Home Assistant](https://www.home-assistant.io/) or [Node-RED](https://nodered.org/). These integrate with Frigate and give you far more robust and flexible scheduling than a built-in scheduler could.
|
Not within Frigate itself. Frigate is an NVR, not an automation platform, so it intentionally does not include a scheduler for activating profiles. Instead, activate profiles from an automation platform that already handles time- and event-based triggers well, such as [Home Assistant](https://www.home-assistant.io/) or [Node-RED](https://nodered.org/). These integrate with Frigate and give you far more robust and flexible scheduling than a built-in scheduler could.
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ Yes. Models and metadata are stored in the `model_cache` directory within the co
|
|||||||
|
|
||||||
### Can I keep using my Frigate+ models even if I do not renew my subscription?
|
### Can I keep using my Frigate+ models even if I do not renew my subscription?
|
||||||
|
|
||||||
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models trained with your subscription are yours to keep and use forever. However, do note that the terms and conditions prohibit you from sharing, reselling, or creating derivative products from the models.
|
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models you train during an active subscription remain licensed for your continued use even after your subscription ends — models already in your model cache will keep working indefinitely. An active subscription is required to train new models and download new versions.
|
||||||
|
|
||||||
|
### Can I use Frigate+ models commercially?
|
||||||
|
|
||||||
|
A standard subscription covers use on camera systems you own or operate, including for your business. A shop, restaurant, warehouse, or office running Frigate+ at its own locations (including multiple locations) is exactly the kind of use the subscription is for.
|
||||||
|
What the standard subscription does not cover is using Frigate+ models to provide a product or service to others. If you're deploying models at your customers' sites, bundling them with hardware you sell, or running them as part of a hosted or managed service, even if your customers never receive the model files themselves, you'll need a commercial license.
|
||||||
|
Note that professional installers are fine under standard subscriptions when each customer holds their own Frigate+ subscription. The commercial license is for cases where your license powers your customers' sites.
|
||||||
|
|
||||||
### Why can't I submit images to Frigate+?
|
### Why can't I submit images to Frigate+?
|
||||||
|
|
||||||
|
|||||||
@@ -65,11 +65,11 @@ Some users may find that Frigate+ models result in more false positives initiall
|
|||||||
|
|
||||||
Frigate+ models support a more relevant set of objects for security cameras. The labels for annotation in Frigate+ are configurable by editing the camera in the Cameras section of Frigate+. Currently, the following objects are supported:
|
Frigate+ models support a more relevant set of objects for security cameras. The labels for annotation in Frigate+ are configurable by editing the camera in the Cameras section of Frigate+. Currently, the following objects are supported:
|
||||||
|
|
||||||
- **People**: `person`, `face`
|
- **People**: `person`, `face`, `baby`
|
||||||
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `license_plate`
|
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `garbage truck`, `license_plate`
|
||||||
- **Delivery Logos**: `amazon`, `usps`, `ups`, `fedex`, `dhl`, `an_post`, `purolator`, `postnl`, `nzpost`, `postnord`, `gls`, `dpd`, `canada_post`, `royal_mail`
|
- **Delivery Logos**: `amazon`, `usps`, `ups`, `fedex`, `dhl`, `an_post`, `purolator`, `postnl`, `nzpost`, `postnord`, `gls`, `dpd`, `canada_post`, `royal_mail`
|
||||||
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`
|
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`, `possum`, `rodent`
|
||||||
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`
|
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`, `baby_stroller`
|
||||||
|
|
||||||
Other object types available in the default Frigate model are not available. Additional object types will be added in future releases.
|
Other object types available in the default Frigate model are not available. Additional object types will be added in future releases.
|
||||||
|
|
||||||
@@ -77,9 +77,12 @@ Other object types available in the default Frigate model are not available. Add
|
|||||||
|
|
||||||
Candidate labels are also available for annotation. These labels don't have enough data to be included in the model yet, but using them will help add support sooner. You can enable these labels by editing the camera settings.
|
Candidate labels are also available for annotation. These labels don't have enough data to be included in the model yet, but using them will help add support sooner. You can enable these labels by editing the camera settings.
|
||||||
|
|
||||||
Where possible, these labels are mapped to existing labels during training. For example, any `baby` labels are mapped to `person` until support for new labels is added.
|
Where possible, these labels are mapped to existing labels during training. For example, any `duck` labels are mapped to `bird` until support for new labels is added.
|
||||||
|
|
||||||
The candidate labels are: `baby`, `bpost`, `badger`, `possum`, `rodent`, `chicken`, `groundhog`, `boar`, `hedgehog`, `tractor`, `golf cart`, `garbage truck`, `bus`, `sports ball`, `la_poste`, `lawnmower`, `heron`, `rickshaw`, `wombat`, `auspost`, `aramex`, `bobcat`, `mustelid`, `transoflex`, `airplane`, `drone`, `mountain_lion`, `crocodile`, `turkey`, `baby_stroller`, `monkey`, `coyote`, `porcupine`, `parcelforce`, `sheep`, `snake`, `helicopter`, `lizard`, `duck`, `hermes`, `cargus`, `fan_courier`, `sameday`
|
- **Vehicles**: `tractor`, `golf_cart`, `bus`, `airplane`, `helicopter`, `rickshaw`, `scooter`
|
||||||
|
- **Delivery Logos**: `bpost`, `auspost`, `aramex`, `transoflex`, `parcelforce`, `hermes`, `cargus`, `fan_courier`, `sameday`, `la_poste`
|
||||||
|
- **Animals**: `badger`, `chicken`, `duck`, `turkey`, `groundhog`, `boar`, `hedgehog`, `wombat`, `bobcat`, `mustelid`, `mountain_lion`, `crocodile`, `monkey`, `coyote`, `porcupine`, `sheep`, `snake`, `lizard`, `heron`, `elk`, `moose`, `pig`, `donkey`, `civet`
|
||||||
|
- **Other**: `sports_ball`, `drone`, `lawnmower`
|
||||||
|
|
||||||
Candidate labels are not available for automatic suggestions.
|
Candidate labels are not available for automatic suggestions.
|
||||||
|
|
||||||
|
|||||||
@@ -397,19 +397,11 @@ dmesg | grep -i -E "gpu|drm|reset|hang"
|
|||||||
|
|
||||||
Messages like `trying reset from guc_exec_queue_timedout_job` or similar GPU reset/hang messages indicate a driver or hardware issue. Ensure your kernel and GPU drivers (especially Intel) are up to date.
|
Messages like `trying reset from guc_exec_queue_timedout_job` or similar GPU reset/hang messages indicate a driver or hardware issue. Ensure your kernel and GPU drivers (especially Intel) are up to date.
|
||||||
|
|
||||||
#### Step 6: Verify hardware acceleration configuration
|
#### Step 6: Verify go2rtc stream configuration
|
||||||
|
|
||||||
An incorrect `hwaccel_args` preset can cause ffmpeg to fail silently or consume excessive CPU, starving the detector of resources.
|
|
||||||
|
|
||||||
- After upgrading Frigate, verify your preset matches your hardware (e.g., `preset-intel-qsv-h264` instead of the deprecated `preset-vaapi`).
|
|
||||||
- For h265 cameras, use the corresponding h265 preset (e.g., `preset-intel-qsv-h265`).
|
|
||||||
- Note that `hwaccel_args` are only relevant for the detect stream. Frigate does not decode the record stream.
|
|
||||||
|
|
||||||
#### Step 7: Verify go2rtc stream configuration
|
|
||||||
|
|
||||||
Ensure that the ffmpeg source names in your go2rtc configuration match the correct camera stream. A misconfigured stream name (e.g., copying a config from one camera to another without updating the stream reference) will cause the wrong stream to be used or the stream to fail entirely.
|
Ensure that the ffmpeg source names in your go2rtc configuration match the correct camera stream. A misconfigured stream name (e.g., copying a config from one camera to another without updating the stream reference) will cause the wrong stream to be used or the stream to fail entirely.
|
||||||
|
|
||||||
#### Step 8: Check system resources
|
#### Step 7: Check system resources
|
||||||
|
|
||||||
If none of the above apply, the issue may be a general resource constraint. Monitor the following on your host:
|
If none of the above apply, the issue may be a general resource constraint. Monitor the following on your host:
|
||||||
|
|
||||||
|
|||||||
Vendored
+20
@@ -4073,6 +4073,16 @@ paths:
|
|||||||
- type: 'null'
|
- type: 'null'
|
||||||
default: 100
|
default: 100
|
||||||
title: Limit
|
title: Limit
|
||||||
|
- name: offset
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
anyOf:
|
||||||
|
- type: integer
|
||||||
|
minimum: 0
|
||||||
|
- type: 'null'
|
||||||
|
default: 0
|
||||||
|
title: Offset
|
||||||
- name: after
|
- name: after
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
@@ -4378,6 +4388,16 @@ paths:
|
|||||||
- type: 'null'
|
- type: 'null'
|
||||||
default: 50
|
default: 50
|
||||||
title: Limit
|
title: Limit
|
||||||
|
- name: offset
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
anyOf:
|
||||||
|
- type: integer
|
||||||
|
minimum: 0
|
||||||
|
- type: 'null'
|
||||||
|
default: 0
|
||||||
|
title: Offset
|
||||||
- name: cameras
|
- name: cameras
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class EventsQueryParams(BaseModel):
|
|||||||
zone: str | None = "all"
|
zone: str | None = "all"
|
||||||
zones: str | None = "all"
|
zones: str | None = "all"
|
||||||
limit: int | None = 100
|
limit: int | None = 100
|
||||||
|
offset: int | None = Field(0, ge=0)
|
||||||
after: float | None = None
|
after: float | None = None
|
||||||
before: float | None = None
|
before: float | None = None
|
||||||
time_range: str | None = DEFAULT_TIME_RANGE
|
time_range: str | None = DEFAULT_TIME_RANGE
|
||||||
@@ -55,6 +56,7 @@ class EventsSearchQueryParams(BaseModel):
|
|||||||
deprecated=True,
|
deprecated=True,
|
||||||
)
|
)
|
||||||
limit: int | None = 50
|
limit: int | None = 50
|
||||||
|
offset: int | None = Field(0, ge=0)
|
||||||
cameras: str | None = "all"
|
cameras: str | None = "all"
|
||||||
labels: str | None = "all"
|
labels: str | None = "all"
|
||||||
sub_labels: str | None = "all"
|
sub_labels: str | None = "all"
|
||||||
|
|||||||
+11
-2
@@ -129,6 +129,7 @@ def events(
|
|||||||
zones = zone
|
zones = zone
|
||||||
|
|
||||||
limit = params.limit
|
limit = params.limit
|
||||||
|
offset = params.offset
|
||||||
after = params.after
|
after = params.after
|
||||||
before = params.before
|
before = params.before
|
||||||
time_range = params.time_range
|
time_range = params.time_range
|
||||||
@@ -361,11 +362,15 @@ def events(
|
|||||||
else:
|
else:
|
||||||
order_by = Event.start_time.desc()
|
order_by = Event.start_time.desc()
|
||||||
|
|
||||||
|
# offset paging needs a stable order when scores or speeds tie
|
||||||
|
tiebreaker = [Event.id] if sort and sort.startswith(("score", "speed")) else []
|
||||||
|
|
||||||
events = (
|
events = (
|
||||||
Event.select(*selected_columns)
|
Event.select(*selected_columns)
|
||||||
.where(reduce(operator.and_, clauses))
|
.where(reduce(operator.and_, clauses))
|
||||||
.order_by(order_by)
|
.order_by(order_by, *tiebreaker)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
.dicts()
|
.dicts()
|
||||||
.iterator()
|
.iterator()
|
||||||
)
|
)
|
||||||
@@ -518,6 +523,7 @@ def events_search(
|
|||||||
search_type = params.search_type
|
search_type = params.search_type
|
||||||
include_thumbnails = params.include_thumbnails
|
include_thumbnails = params.include_thumbnails
|
||||||
limit = params.limit
|
limit = params.limit
|
||||||
|
offset = params.offset
|
||||||
sort = params.sort
|
sort = params.sort
|
||||||
|
|
||||||
# Filters
|
# Filters
|
||||||
@@ -824,6 +830,9 @@ def events_search(
|
|||||||
if search_results:
|
if search_results:
|
||||||
events_query = events_query.where(Event.id << list(search_results.keys()))
|
events_query = events_query.where(Event.id << list(search_results.keys()))
|
||||||
|
|
||||||
|
# sorts below are stable, so this orders ties for offset paging
|
||||||
|
events_query = events_query.order_by(Event.id)
|
||||||
|
|
||||||
# Fetch events and process them in a single pass
|
# Fetch events and process them in a single pass
|
||||||
processed_events = []
|
processed_events = []
|
||||||
for event in events_query.dicts():
|
for event in events_query.dicts():
|
||||||
@@ -881,7 +890,7 @@ def events_search(
|
|||||||
processed_events.sort(key=lambda x: x["start_time"], reverse=True)
|
processed_events.sort(key=lambda x: x["start_time"], reverse=True)
|
||||||
|
|
||||||
# Limit the number of events returned
|
# Limit the number of events returned
|
||||||
processed_events = processed_events[:limit]
|
processed_events = processed_events[offset:][:limit]
|
||||||
|
|
||||||
return JSONResponse(content=processed_events)
|
return JSONResponse(content=processed_events)
|
||||||
|
|
||||||
|
|||||||
+20
-11
@@ -43,6 +43,22 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(tags=[Tags.review])
|
router = APIRouter(tags=[Tags.review])
|
||||||
|
|
||||||
|
|
||||||
|
def get_label_clause(label: str, include_audio: bool = True):
|
||||||
|
"""Build a clause matching a label within a review segment's data.
|
||||||
|
|
||||||
|
Verified objects are stored with a `-verified` suffix (eg. `person-verified`)
|
||||||
|
so that variant is matched as well.
|
||||||
|
"""
|
||||||
|
clause = (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') | (
|
||||||
|
ReviewSegment.data["objects"].cast("text") % f'*"{label}-verified"*'
|
||||||
|
)
|
||||||
|
|
||||||
|
if include_audio:
|
||||||
|
clause |= ReviewSegment.data["audio"].cast("text") % f'*"{label}"*'
|
||||||
|
|
||||||
|
return clause
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/review",
|
"/review",
|
||||||
response_model=list[ReviewSegmentResponse],
|
response_model=list[ReviewSegmentResponse],
|
||||||
@@ -92,10 +108,7 @@ async def review(
|
|||||||
filtered_labels = labels.split(",")
|
filtered_labels = labels.split(",")
|
||||||
|
|
||||||
for label in filtered_labels:
|
for label in filtered_labels:
|
||||||
label_clauses.append(
|
label_clauses.append(get_label_clause(label))
|
||||||
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
|
||||||
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
|
|
||||||
)
|
|
||||||
clauses.append(reduce(operator.or_, label_clauses))
|
clauses.append(reduce(operator.or_, label_clauses))
|
||||||
|
|
||||||
if zones != "all":
|
if zones != "all":
|
||||||
@@ -236,10 +249,7 @@ async def review_summary(
|
|||||||
filtered_labels = labels.split(",")
|
filtered_labels = labels.split(",")
|
||||||
|
|
||||||
for label in filtered_labels:
|
for label in filtered_labels:
|
||||||
label_clauses.append(
|
label_clauses.append(get_label_clause(label))
|
||||||
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
|
||||||
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
|
|
||||||
)
|
|
||||||
clauses.append(reduce(operator.or_, label_clauses))
|
clauses.append(reduce(operator.or_, label_clauses))
|
||||||
if zones != "all":
|
if zones != "all":
|
||||||
# use matching so segments with multiple zones
|
# use matching so segments with multiple zones
|
||||||
@@ -337,9 +347,8 @@ async def review_summary(
|
|||||||
filtered_labels = labels.split(",")
|
filtered_labels = labels.split(",")
|
||||||
|
|
||||||
for label in filtered_labels:
|
for label in filtered_labels:
|
||||||
label_clauses.append(
|
label_clauses.append(get_label_clause(label, include_audio=False))
|
||||||
ReviewSegment.data["objects"].cast("text") % f'*"{label}"*'
|
|
||||||
)
|
|
||||||
clauses.append(reduce(operator.or_, label_clauses))
|
clauses.append(reduce(operator.or_, label_clauses))
|
||||||
|
|
||||||
# Find the time range of available data
|
# Find the time range of available data
|
||||||
|
|||||||
@@ -121,8 +121,8 @@ PRESETS_HW_ACCEL_SCALE = {
|
|||||||
"preset-rpi-64-h264": "-r {0} -vf fps={0},scale={1}:{2}",
|
"preset-rpi-64-h264": "-r {0} -vf fps={0},scale={1}:{2}",
|
||||||
"preset-rpi-64-h265": "-r {0} -vf fps={0},scale={1}:{2}",
|
"preset-rpi-64-h265": "-r {0} -vf fps={0},scale={1}:{2}",
|
||||||
FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12",
|
FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12",
|
||||||
"preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
|
"preset-intel-qsv-h264": "-r {0} -vf fps={0},vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
|
||||||
"preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
|
"preset-intel-qsv-h265": "-r {0} -vf fps={0},vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
|
||||||
FFMPEG_HWACCEL_NVIDIA: "-r {0} -vf fps={0},scale_cuda=w={1}:h={2},hwdownload,format=nv12",
|
FFMPEG_HWACCEL_NVIDIA: "-r {0} -vf fps={0},scale_cuda=w={1}:h={2},hwdownload,format=nv12",
|
||||||
"preset-jetson-h264": "-r {0}", # scaled in decoder
|
"preset-jetson-h264": "-r {0}", # scaled in decoder
|
||||||
"preset-jetson-h265": "-r {0}", # scaled in decoder
|
"preset-jetson-h265": "-r {0}", # scaled in decoder
|
||||||
|
|||||||
@@ -168,6 +168,29 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
assert events[0]["id"] == id
|
assert events[0]["id"] == id
|
||||||
assert events[1]["id"] == id2
|
assert events[1]["id"] == id2
|
||||||
|
|
||||||
|
def test_get_event_list_offset_pages_score_sort(self):
|
||||||
|
now = datetime.now().timestamp()
|
||||||
|
scores = [0.6, 0.9, 0.7, 0.95, 0.8]
|
||||||
|
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
for i, score in enumerate(scores):
|
||||||
|
super().insert_mock_event(
|
||||||
|
f"event-{i}", start_time=now + i, data={"score": score}
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {"sort": "score_desc"}
|
||||||
|
full = [e["id"] for e in client.get("/events", params=params).json()]
|
||||||
|
paged = [
|
||||||
|
e["id"]
|
||||||
|
for offset in (0, 2, 4)
|
||||||
|
for e in client.get(
|
||||||
|
"/events", params={**params, "limit": 2, "offset": offset}
|
||||||
|
).json()
|
||||||
|
]
|
||||||
|
|
||||||
|
assert full == ["event-3", "event-1", "event-4", "event-2", "event-0"]
|
||||||
|
assert paged == full
|
||||||
|
|
||||||
def test_get_event_list_match_multilingual_attribute(self):
|
def test_get_event_list_match_multilingual_attribute(self):
|
||||||
event_id = "123456.zh"
|
event_id = "123456.zh"
|
||||||
attribute = "中文标签"
|
attribute = "中文标签"
|
||||||
@@ -219,6 +242,85 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
assert events[0]["id"] == event_id
|
assert events[0]["id"] == event_id
|
||||||
|
|
||||||
|
def test_events_search_offset_pages_score_sort(self):
|
||||||
|
now = datetime.now().timestamp()
|
||||||
|
scores = [0.6, 0.9, 0.7, 0.95, 0.8]
|
||||||
|
ids = [f"event-{i}" for i in range(len(scores))]
|
||||||
|
mock_embeddings = Mock()
|
||||||
|
mock_embeddings.search_thumbnail.return_value = [
|
||||||
|
(event_id, 0.1 * i) for i, event_id in enumerate(ids)
|
||||||
|
]
|
||||||
|
|
||||||
|
self.app.frigate_config.semantic_search.enabled = True
|
||||||
|
self.app.embeddings = mock_embeddings
|
||||||
|
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
for i, score in enumerate(scores):
|
||||||
|
super().insert_mock_event(
|
||||||
|
ids[i], start_time=now + i, data={"score": score}
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"search_type": "similarity",
|
||||||
|
"event_id": ids[0],
|
||||||
|
"sort": "score_desc",
|
||||||
|
}
|
||||||
|
paged = [
|
||||||
|
e["id"]
|
||||||
|
for offset in (0, 2, 4)
|
||||||
|
for e in client.get(
|
||||||
|
"/events/search",
|
||||||
|
params={**params, "limit": 2, "offset": offset},
|
||||||
|
).json()
|
||||||
|
]
|
||||||
|
|
||||||
|
assert paged == ["event-3", "event-1", "event-4", "event-2", "event-0"]
|
||||||
|
|
||||||
|
def test_events_search_offset_pages_orders_ties_by_id(self):
|
||||||
|
now = datetime.now().timestamp()
|
||||||
|
ids = ["event-c", "event-a", "event-b"]
|
||||||
|
mock_embeddings = Mock()
|
||||||
|
mock_embeddings.search_thumbnail.return_value = [
|
||||||
|
(event_id, 0.1) for event_id in ids
|
||||||
|
]
|
||||||
|
|
||||||
|
self.app.frigate_config.semantic_search.enabled = True
|
||||||
|
self.app.embeddings = mock_embeddings
|
||||||
|
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
for i, event_id in enumerate(ids):
|
||||||
|
super().insert_mock_event(
|
||||||
|
event_id, start_time=now + i, data={"score": 0.8}
|
||||||
|
)
|
||||||
|
|
||||||
|
for sort in ("score_desc", "relevance"):
|
||||||
|
params = {
|
||||||
|
"search_type": "similarity",
|
||||||
|
"event_id": ids[0],
|
||||||
|
"sort": sort,
|
||||||
|
}
|
||||||
|
paged = [
|
||||||
|
e["id"]
|
||||||
|
for offset in (0, 1, 2)
|
||||||
|
for e in client.get(
|
||||||
|
"/events/search",
|
||||||
|
params={**params, "limit": 1, "offset": offset},
|
||||||
|
).json()
|
||||||
|
]
|
||||||
|
|
||||||
|
assert paged == ["event-a", "event-b", "event-c"]
|
||||||
|
|
||||||
|
def test_event_list_rejects_negative_offset(self):
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
response = client.get("/events", params={"offset": -5})
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/events/search",
|
||||||
|
params={"query": "car", "offset": -5},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_similarity_search_hides_unauthorized_anchor_event(self):
|
def test_similarity_search_hides_unauthorized_anchor_event(self):
|
||||||
mock_embeddings = Mock()
|
mock_embeddings = Mock()
|
||||||
self.app.frigate_config.semantic_search.enabled = True
|
self.app.frigate_config.semantic_search.enabled = True
|
||||||
|
|||||||
@@ -240,9 +240,101 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
assert len(response_json) == 1
|
assert len(response_json) == 1
|
||||||
assert response_json[0]["id"] == id_reviewed
|
assert response_json[0]["id"] == id_reviewed
|
||||||
|
|
||||||
|
def test_get_review_with_label_filter_matches_verified(self):
|
||||||
|
"""Test that a label filter also matches the `-verified` variant."""
|
||||||
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
super().insert_mock_review_segment(
|
||||||
|
"123456.person", now, now + 2, data={"objects": ["person"]}
|
||||||
|
)
|
||||||
|
super().insert_mock_review_segment(
|
||||||
|
"123456.verified", now, now + 2, data={"objects": ["person-verified"]}
|
||||||
|
)
|
||||||
|
super().insert_mock_review_segment(
|
||||||
|
"123456.car", now, now + 2, data={"objects": ["car"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"labels": "person",
|
||||||
|
"after": now - 1,
|
||||||
|
"before": now + 3,
|
||||||
|
}
|
||||||
|
response = client.get("/review", params=params)
|
||||||
|
assert response.status_code == 200
|
||||||
|
response_json = response.json()
|
||||||
|
assert {r["id"] for r in response_json} == {
|
||||||
|
"123456.person",
|
||||||
|
"123456.verified",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_get_review_with_label_filter_does_not_match_prefix(self):
|
||||||
|
"""Test that a label filter does not match labels that only share a prefix."""
|
||||||
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
super().insert_mock_review_segment(
|
||||||
|
"123456.carrot", now, now + 2, data={"objects": ["carrot"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"labels": "car",
|
||||||
|
"after": now - 1,
|
||||||
|
"before": now + 3,
|
||||||
|
}
|
||||||
|
response = client.get("/review", params=params)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.json()) == 0
|
||||||
|
|
||||||
|
def test_get_review_with_audio_label_filter(self):
|
||||||
|
"""Test that a label filter still matches audio labels."""
|
||||||
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
super().insert_mock_review_segment(
|
||||||
|
"123456.audio", now, now + 2, data={"audio": ["speech"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"labels": "speech",
|
||||||
|
"after": now - 1,
|
||||||
|
"before": now + 3,
|
||||||
|
}
|
||||||
|
response = client.get("/review", params=params)
|
||||||
|
assert response.status_code == 200
|
||||||
|
response_json = response.json()
|
||||||
|
assert len(response_json) == 1
|
||||||
|
assert response_json[0]["id"] == "123456.audio"
|
||||||
|
|
||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
################################### GET /review/summary Endpoint #################################################
|
################################### GET /review/summary Endpoint #################################################
|
||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
|
def test_get_review_summary_label_filter_matches_verified(self):
|
||||||
|
"""Test that the summary label filter also matches the `-verified` variant."""
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
super().insert_mock_review_segment(
|
||||||
|
"123456.verified", data={"objects": ["person-verified"]}
|
||||||
|
)
|
||||||
|
super().insert_mock_review_segment(
|
||||||
|
"123456.car", data={"objects": ["car"]}, severity=SeverityEnum.detection
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"cameras": "front_door",
|
||||||
|
"labels": "person",
|
||||||
|
"zones": "all",
|
||||||
|
"timezone": "utc",
|
||||||
|
}
|
||||||
|
response = client.get("/review/summary", params=params)
|
||||||
|
assert response.status_code == 200
|
||||||
|
response_json = response.json()
|
||||||
|
assert response_json["last24Hours"]["total_alert"] == 1
|
||||||
|
assert response_json["last24Hours"]["total_detection"] == 0
|
||||||
|
|
||||||
|
today_formatted = datetime.today().strftime("%Y-%m-%d")
|
||||||
|
assert response_json[today_formatted]["total_alert"] == 1
|
||||||
|
assert response_json[today_formatted]["total_detection"] == 0
|
||||||
|
|
||||||
def test_get_review_summary_all_filters(self):
|
def test_get_review_summary_all_filters(self):
|
||||||
with AuthTestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
super().insert_mock_review_segment("123456.random")
|
super().insert_mock_review_segment("123456.random")
|
||||||
|
|||||||
@@ -1952,7 +1952,11 @@
|
|||||||
"modelSizeLarge": "The 'large' model is optimized for multi-line license plates. The 'small' model provides better performance over 'large' and should be used unless your region uses multi-line plate formats."
|
"modelSizeLarge": "The 'large' model is optimized for multi-line license plates. The 'small' model provides better performance over 'large' and should be used unless your region uses multi-line plate formats."
|
||||||
},
|
},
|
||||||
"record": {
|
"record": {
|
||||||
"noRecordRole": "No streams have the record role defined. Recording will not function."
|
"noRecordRole": "No streams have the record role defined. Recording will not function.",
|
||||||
|
"profileBaseDisabled": "Recording is disabled in this camera's base config, so enabling it in a profile has no effect. Enable recording in the base config and use a profile to disable it instead."
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"profileBaseDisabled": "No cameras have notifications enabled in their base config, so enabling them in a profile has no effect. Enable notifications in the base config of at least one camera."
|
||||||
},
|
},
|
||||||
"birdseye": {
|
"birdseye": {
|
||||||
"objectsModeDetectDisabled": "Birdseye is set to 'objects' mode, but object detection is disabled for this camera. The camera will not appear in Birdseye."
|
"objectsModeDetectDisabled": "Birdseye is set to 'objects' mode, but object detection is disabled for this camera. The camera will not appear in Birdseye."
|
||||||
|
|||||||
@@ -8,6 +8,23 @@ const notifications: SectionConfigOverrides = {
|
|||||||
fieldGroups: {},
|
fieldGroups: {},
|
||||||
hiddenFields: ["enabled_in_config"],
|
hiddenFields: ["enabled_in_config"],
|
||||||
advancedFields: [],
|
advancedFields: [],
|
||||||
|
fieldMessages: [
|
||||||
|
{
|
||||||
|
key: "profile-base-notifications-disabled",
|
||||||
|
field: "enabled",
|
||||||
|
messageKey: "configMessages.notifications.profileBaseDisabled",
|
||||||
|
severity: "warning",
|
||||||
|
position: "after",
|
||||||
|
condition: (ctx) =>
|
||||||
|
!!ctx.profileName &&
|
||||||
|
ctx.formData?.enabled === true &&
|
||||||
|
!Object.values(ctx.fullConfig.cameras).some(
|
||||||
|
(camera) =>
|
||||||
|
camera.enabled_in_config &&
|
||||||
|
camera.notifications.enabled_in_config,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
uiSchema: {
|
uiSchema: {
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ const record: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
fieldMessages: [
|
||||||
|
{
|
||||||
|
key: "profile-base-record-disabled",
|
||||||
|
field: "enabled",
|
||||||
|
messageKey: "configMessages.record.profileBaseDisabled",
|
||||||
|
severity: "warning",
|
||||||
|
position: "after",
|
||||||
|
docLink:
|
||||||
|
"/configuration/profiles#why-cant-a-profile-enable-recording-when-its-disabled-in-the-base-config",
|
||||||
|
condition: (ctx) =>
|
||||||
|
!!ctx.profileName &&
|
||||||
|
ctx.formData?.enabled === true &&
|
||||||
|
ctx.fullCameraConfig?.record.enabled_in_config === false,
|
||||||
|
},
|
||||||
|
],
|
||||||
fieldDocs: {
|
fieldDocs: {
|
||||||
"alerts.pre_capture":
|
"alerts.pre_capture":
|
||||||
"/configuration/record#pre-capture-and-post-capture",
|
"/configuration/record#pre-capture-and-post-capture",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type MessageConditionContext = {
|
|||||||
fullCameraConfig?: CameraConfig;
|
fullCameraConfig?: CameraConfig;
|
||||||
level: "global" | "camera";
|
level: "global" | "camera";
|
||||||
cameraName?: string;
|
cameraName?: string;
|
||||||
|
profileName?: string;
|
||||||
formData: ConfigSectionData;
|
formData: ConfigSectionData;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -619,9 +619,10 @@ export function ConfigSection({
|
|||||||
: undefined,
|
: undefined,
|
||||||
level: effectiveLevel,
|
level: effectiveLevel,
|
||||||
cameraName,
|
cameraName,
|
||||||
|
profileName,
|
||||||
formData: currentFormData as ConfigSectionData,
|
formData: currentFormData as ConfigSectionData,
|
||||||
};
|
};
|
||||||
}, [config, currentFormData, effectiveLevel, cameraName]);
|
}, [config, currentFormData, effectiveLevel, cameraName, profileName]);
|
||||||
|
|
||||||
const { activeMessages, activeFieldMessages } = useConfigMessages(
|
const { activeMessages, activeFieldMessages } = useConfigMessages(
|
||||||
sectionConfig.messages,
|
sectionConfig.messages,
|
||||||
|
|||||||
@@ -198,7 +198,19 @@ export default function Explore() {
|
|||||||
|
|
||||||
const [url, params] = searchQuery;
|
const [url, params] = searchQuery;
|
||||||
|
|
||||||
const isAscending = params.sort?.includes("date_asc");
|
// a start_time cursor only works when rows are ordered by start_time,
|
||||||
|
// so every other sort pages by offset
|
||||||
|
const isDateSort =
|
||||||
|
params.sort === "date_asc" ||
|
||||||
|
params.sort === "date_desc" ||
|
||||||
|
(!params.sort && url === "events");
|
||||||
|
|
||||||
|
if (pageIndex > 0 && !isDateSort) {
|
||||||
|
return [
|
||||||
|
url,
|
||||||
|
{ ...params, offset: pageIndex * API_LIMIT, limit: API_LIMIT },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (pageIndex > 0 && previousPageData) {
|
if (pageIndex > 0 && previousPageData) {
|
||||||
const lastDate = previousPageData[previousPageData.length - 1].start_time;
|
const lastDate = previousPageData[previousPageData.length - 1].start_time;
|
||||||
@@ -206,7 +218,8 @@ export default function Explore() {
|
|||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
...params,
|
...params,
|
||||||
[isAscending ? "after" : "before"]: lastDate.toString(),
|
[params.sort === "date_asc" ? "after" : "before"]:
|
||||||
|
lastDate.toString(),
|
||||||
limit: API_LIMIT,
|
limit: API_LIMIT,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -238,10 +251,17 @@ export default function Explore() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchResults = useMemo(
|
// offset pages can overlap when results shift between page fetches
|
||||||
() => (data ? ([] as SearchResult[]).concat(...data) : []),
|
const searchResults = useMemo(() => {
|
||||||
[data],
|
if (!data) return [];
|
||||||
);
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return data.flat().filter((result) => {
|
||||||
|
if (seen.has(result.id)) return false;
|
||||||
|
seen.add(result.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
const isLoadingInitialData = !data && !isValidating;
|
const isLoadingInitialData = !data && !isValidating;
|
||||||
const isLoadingMore =
|
const isLoadingMore =
|
||||||
isLoadingInitialData ||
|
isLoadingInitialData ||
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export type SearchQueryParams = {
|
|||||||
max_speed?: number;
|
max_speed?: number;
|
||||||
search_type?: string;
|
search_type?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
in_progress?: number;
|
in_progress?: number;
|
||||||
include_thumbnails?: number;
|
include_thumbnails?: number;
|
||||||
query?: string;
|
query?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user