Compare commits

...
4 Commits
Author SHA1 Message Date
Nicolas MowenandGitHub de416b7ae7 Remove invalid hardware acceleration step in recording troubleshooting (#24395)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
Remove the invalid suggestion in docs
2026-09-17 13:28:51 -05:00
Josh HawkinsandGitHub 06967fec91 Fix explore paging for non-date sorts (#24392)
* fix explore paging for non-date sorts

Explore paged every sort by passing the last row's `start_time` as a `before` or `after` cursor, which only works when rows are ordered by `start_time`. For score, speed, and relevance sorts, each page dropped every match newer than that row and repeated older rows from earlier pages, so infinite scroll stopped after a few pages. `/events` and `/events/search` now accept `offset`, and Explore pages non-date sorts by offset. Date sorts keep the cursor because `useSWRInfinite` only revalidates the first page, and cursor keys for later pages follow it while offset keys don't. Score and speed sorts on `/events` break ties on `id` so offset pages stay stable.

* order search ties by id and reject negative offsets

`/events/search` sorted in Python over a query with no `ORDER BY`, so tied scores, speeds, or distances kept whatever order SQLite returned, which isn't guaranteed to match across page requests. The query is now ordered by id and the stable sorts keep that order for ties. `offset` also accepted negative values, which sliced from the end of the search results.
2026-09-17 11:14:20 -06:00
Nicolas MowenandGitHub d69107de33 Handle sub labeled objects to still show up in review filter (#24391) 2026-09-17 11:54:54 -05:00
Nicolas MowenandGitHub 04480a18b6 Revert QSV ffmpeg framerate filter (#24384)
* Update version

* Move back to QSV framerate filter

* Use standard fps filter instead
2026-09-17 11:17:45 -05:00
11 changed files with 279 additions and 32 deletions
+1 -1
View File
@@ -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
+2 -10
View File
@@ -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:
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
+102
View File
@@ -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
+92
View File
@@ -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")
+26 -6
View File
@@ -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 ||
+1
View File
@@ -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;