Compare commits

...
15 Commits
Author SHA1 Message Date
gwmullinandGitHub e1b805183d Merge b535fb0b27 into c007661a71 2026-07-03 20:49:59 +01:00
Blake BlackshearandGitHub c007661a71 Enhancements (#23611)
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
* better default handling if no proxy chain is present

* add note about SameSite for jwt cookie
2026-07-02 07:05:00 -05:00
9b02c7318d Miscellaneous fixes (#23610)
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
* Handle back seeking going to previous clip

* scope /recordings/unavailable query to the caller's allowed cameras

* listen for config updates in activity manager

* don't set search after awaited request

Intentionally do NOT setSearch() to mark the open event submitted. This runs after the awaited request, by which point the user may have closed the dialog; re-setting the parent's selected event would resurrect it and the force-open effect would reopen it (see #23599). The local "submitted" state covers the open card, and mutate() updates the events cache so the grid and any future open reflect the result.

* fix ruff

#23201 removed pathlib import but for some reason it's just now causing ruff to fail

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-07-01 15:03:34 -05:00
Gdub b535fb0b27 Add a testcase for validating that on_conflict_ignore bypasses what was formerly an IntegrityError 2026-06-26 14:02:51 -07:00
Gdub 25cb4d9008 Add on_conflict_ignore, replacing the try/catch/pass on IntegrityError 2026-06-26 13:37:05 -07:00
Greg cb15ba5b60 Update peewee index migration description 2026-06-26 12:14:20 -07:00
Gdub ec1238e723 Remove the CTE and restore the generator with sub-queries, which is more performance (thanks Nick and Blake for testing against your larger DB!) 2026-06-26 11:08:53 -07:00
Greg c575fb223b Editor fail, re-ruff format. 2026-05-18 14:18:17 -07:00
Greg 9fa345f192 Remove 2x unnecessary index on reviewsegment, remove reference to prior code implementation in comment in event.py 2026-05-18 14:16:43 -07:00
Gdub 7b55c4b758 Rerun ruff formatting. 2026-05-18 13:39:12 -07:00
Gdub 570e2e3f76 Slightly simplify review logic and avoid duplicating the json response for empty review IDs. 2026-05-18 13:30:46 -07:00
Greg 39fba9b0a7 Use peewee instead of rw sql for the CTE query. 2026-05-11 16:46:43 -07:00
Greg 328a26b169 Collapse a few sequential queries into a single one. 2026-05-11 15:45:35 -07:00
Greg 311fb1bd19 Rewrite to use a CTE to leverage speedups by using sqllite internal optimization to do a single query instead of a starter query to get distinct labels and a subsequent loop of querys per distinct event labels.
Frigate is currently shipping sqlite 3.46.1, which is above the minimum version 3.25 needed for CTEs.
2026-05-08 16:18:37 -07:00
Greg 48b1426891 Add additional indicies on event and review tables. Every events or timeline endpoint filters on event start time and camera, this should speed things up by avoiding a range scan on the table. 2026-05-08 15:59:23 -07:00
14 changed files with 297 additions and 69 deletions
@@ -3,6 +3,7 @@
import json
import os
import sys
from pathlib import Path
from typing import Any
from ruamel.yaml import YAML
+16 -8
View File
@@ -254,7 +254,14 @@ rateLimiter = RateLimiter()
def get_remote_addr(request: Request):
route = list(reversed(request.headers.get("x-forwarded-for").split(",")))
# fall back to the direct TCP peer when no proxy chain is present
direct_addr = request.client.host if request.client else None
forwarded_for = request.headers.get("x-forwarded-for")
if not forwarded_for:
return direct_addr or "127.0.0.1"
route = list(reversed(forwarded_for.split(",")))
logger.debug(f"IP Route: {[r for r in route]}")
trusted_proxies = []
for proxy in request.app.frigate_config.auth.trusted_proxies:
@@ -291,13 +298,8 @@ def get_remote_addr(request: Request):
logger.debug(f"First untrusted IP: {str(ip)}")
return str(ip)
# if there wasn't anything in the route, just return the default
remote_addr = None
if hasattr(request, "remote_addr"):
remote_addr = request.remote_addr
return remote_addr or "127.0.0.1"
# every hop in the route was trusted, so fall back to the direct peer
return direct_addr or "127.0.0.1"
def _cleanup_first_load_seen() -> None:
@@ -416,6 +418,12 @@ def create_encoded_jwt(user, role, expiration, secret):
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure):
# TODO: ideally this would set secure as well, but that requires TLS
# SameSite is intentionally left unset (browsers default to Lax). Setting
# SameSite=Lax/Strict would stop the cookie from being sent in cross-origin
# iframes, breaking embedded views such as the Home Assistant Frigate card.
# CSRF is instead mitigated by requiring a custom X-CSRF-TOKEN header, which
# cross-origin pages cannot set without a CORS preflight that Frigate never
# grants (see check_csrf in api/fastapi_app.py).
response.set_cookie(
key=cookie_name,
value=encoded_jwt,
+28 -12
View File
@@ -389,7 +389,9 @@ def events_explore(
limit: int = 10,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
# get distinct labels for all events
if not allowed_cameras:
return JSONResponse(content=[])
distinct_labels = (
Event.select(Event.label)
.where(Event.camera << allowed_cameras)
@@ -399,13 +401,31 @@ def events_explore(
label_counts = {}
explore_columns = (
Event.id,
Event.camera,
Event.label,
Event.sub_label,
Event.zones,
Event.start_time,
Event.end_time,
Event.has_clip,
Event.has_snapshot,
Event.plus_id,
Event.retain_indefinitely,
Event.top_score,
Event.false_positive,
Event.box,
Event.data,
)
def event_generator():
for label_obj in distinct_labels.iterator():
label = label_obj.label
# get most recent events for this label
label_events = (
Event.select()
Event.select(*explore_columns)
.where((Event.label == label) & (Event.camera << allowed_cameras))
.order_by(Event.start_time.desc())
.limit(limit)
@@ -487,22 +507,18 @@ async def event_ids(ids: str, request: Request):
status_code=400,
)
for event_id in ids:
try:
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
# we should not fail the entire request if an event is not found
continue
try:
events = Event.select().where(Event.id << ids).dicts().iterator()
return JSONResponse(list(events))
events = list(Event.select().where(Event.id << ids).dicts().iterator())
except Exception:
return JSONResponse(
content=({"success": False, "message": "Events not found"}), status_code=400
)
for event in events:
await require_camera_access(event["camera"], request=request)
return JSONResponse(events)
@router.get(
"/events/search",
+9 -11
View File
@@ -269,12 +269,12 @@ async def no_recordings(
cameras = params.cameras
if cameras != "all":
requested = set(unquote(cameras).split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content=[])
cameras = ",".join(filtered)
camera_list = list(requested.intersection(allowed_cameras))
else:
cameras = allowed_cameras
camera_list = list(allowed_cameras)
if not camera_list:
return JSONResponse(content=[])
before = params.before or datetime.datetime.now().timestamp()
after = (
@@ -283,12 +283,10 @@ async def no_recordings(
)
scale = params.scale
clauses = [(Recordings.end_time >= after) & (Recordings.start_time <= before)]
if cameras != "all":
camera_list = cameras.split(",")
clauses.append((Recordings.camera << camera_list))
else:
camera_list = allowed_cameras
clauses = [
(Recordings.end_time >= after) & (Recordings.start_time <= before),
(Recordings.camera << camera_list),
]
# Get recording start times
data: list[Recordings] = (
+61 -34
View File
@@ -10,7 +10,7 @@ import pandas as pd
from fastapi import APIRouter, Request
from fastapi.params import Depends
from fastapi.responses import JSONResponse
from peewee import Case, DoesNotExist, IntegrityError, fn, operator
from peewee import Case, DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict
from frigate.api.auth import (
@@ -172,11 +172,19 @@ async def review_ids(request: Request, ids: str):
status_code=400,
)
try:
reviews = list(
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
)
except Exception:
return JSONResponse(
content=({"success": False, "message": "Review segments not found"}),
status_code=400,
)
found_ids = {r["id"] for r in reviews}
for review_id in ids:
try:
review = ReviewSegment.get(ReviewSegment.id == review_id)
await require_camera_access(review.camera, request=request)
except DoesNotExist:
if review_id not in found_ids:
return JSONResponse(
content=(
{"success": False, "message": f"Review {review_id} not found"}
@@ -184,16 +192,10 @@ async def review_ids(request: Request, ids: str):
status_code=404,
)
try:
reviews = (
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
)
return JSONResponse(list(reviews))
except Exception:
return JSONResponse(
content=({"success": False, "message": "Review segments not found"}),
status_code=400,
)
for review in reviews:
await require_camera_access(review["camera"], request=request)
return JSONResponse(reviews)
@router.get(
@@ -490,27 +492,52 @@ async def set_multiple_reviewed(
user_id = current_user["username"]
for review_id in body.ids:
try:
review = ReviewSegment.get(ReviewSegment.id == review_id)
await require_camera_access(review.camera, request=request)
review_status = UserReviewStatus.get(
UserReviewStatus.user_id == user_id,
UserReviewStatus.review_segment == review_id,
reviews = list(
ReviewSegment.select(ReviewSegment.id, ReviewSegment.camera).where(
ReviewSegment.id << body.ids
)
)
for review in reviews:
await require_camera_access(review.camera, request=request)
found_ids = [r.id for r in reviews]
if found_ids:
existing_statuses = list(
UserReviewStatus.select().where(
(UserReviewStatus.user_id == user_id)
& (UserReviewStatus.review_segment << found_ids)
)
# Update based on the reviewed parameter
if review_status.has_been_reviewed != body.reviewed:
review_status.has_been_reviewed = body.reviewed
review_status.save()
except DoesNotExist:
try:
UserReviewStatus.create(
user_id=user_id,
review_segment=ReviewSegment.get(id=review_id),
has_been_reviewed=body.reviewed,
)
status_by_review = {s.review_segment_id: s for s in existing_statuses}
to_update = []
to_create = []
for review_id in found_ids:
if review_id in status_by_review:
status = status_by_review[review_id]
if status.has_been_reviewed != body.reviewed:
status.has_been_reviewed = body.reviewed
to_update.append(status)
else:
to_create.append(
{
"user_id": user_id,
"review_segment_id": review_id,
"has_been_reviewed": body.reviewed,
}
)
except (DoesNotExist, IntegrityError):
pass
if to_update:
UserReviewStatus.bulk_update(
to_update, fields=[UserReviewStatus.has_been_reviewed], batch_size=100
)
if to_create:
UserReviewStatus.insert_many(to_create).on_conflict_ignore().execute()
return JSONResponse(
content=(
+45
View File
@@ -13,6 +13,10 @@ from frigate.comms.event_metadata_updater import (
EventMetadataTypeEnum,
)
from frigate.config import CameraConfig, FrigateConfig
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
)
logger = logging.getLogger(__name__)
@@ -29,6 +33,11 @@ class CameraActivityManager:
self.zone_all_object_counts: dict[str, Counter] = {}
self.zone_active_object_counts: dict[str, Counter] = {}
self.all_zone_labels: dict[str, set[str]] = {}
self.config_subscriber = CameraConfigUpdateSubscriber(
config,
config.cameras,
[CameraConfigUpdateEnum.zones, CameraConfigUpdateEnum.objects],
)
for camera_config in config.cameras.values():
if not camera_config.enabled_in_config:
@@ -56,7 +65,40 @@ class CameraActivityManager:
else camera_config.objects.track
)
def __rebuild_zone_labels(self) -> None:
"""Rebuild zone label tracking after a runtime zones/objects change."""
new_zone_labels: dict[str, set[str]] = {}
for camera_config in self.config.cameras.values():
if not camera_config.enabled_in_config or camera_config.name is None:
continue
for zone, zone_config in camera_config.zones.items():
new_zone_labels.setdefault(zone, set()).update(
zone_config.objects
if zone_config.objects
else camera_config.objects.track
)
# drop counters for zones that no longer exist
for zone in list(self.zone_all_object_counts.keys()):
if zone not in new_zone_labels:
self.zone_all_object_counts.pop(zone, None)
self.zone_active_object_counts.pop(zone, None)
# ensure counters exist for new zones so the first count is published
for zone in new_zone_labels:
self.zone_all_object_counts.setdefault(zone, Counter())
self.zone_active_object_counts.setdefault(zone, Counter())
self.all_zone_labels = new_zone_labels
def update_activity(self, new_activity: dict[str, dict[str, Any]]) -> None:
updated_topics = self.config_subscriber.check_for_updates()
if "zones" in updated_topics or "objects" in updated_topics:
self.__rebuild_zone_labels()
all_objects: list[dict[str, Any]] = []
for camera in new_activity.keys():
@@ -161,6 +203,9 @@ class CameraActivityManager:
self.publish(f"{camera}/all", sum(list(all_objects.values())))
self.publish(f"{camera}/all/active", sum(list(active_objects.values())))
def stop(self) -> None:
self.config_subscriber.stop()
class AudioActivityManager:
def __init__(
+2
View File
@@ -397,6 +397,8 @@ class Dispatcher:
comm.publish(topic, payload, retain)
def stop(self) -> None:
self.camera_activity.stop()
for comm in self.comms:
comm.stop()
+52
View File
@@ -475,3 +475,55 @@ class TestHttpMedia(BaseTestHttp):
assert response.status_code == 200
assert response.json() == []
def test_recordings_unavailable_cameras_all_scopes_to_allowed_cameras(self):
"""cameras=all must not error and must only consider allowed cameras.
allowed_cameras is mocked to ["front_door"]. A back_door recording that
would otherwise fill the gap must be ignored, and the request must not
500 the way it did when cameras was reassigned to a list.
"""
with AuthTestClient(self.app) as client:
# front_door has a 20s gap (1010-1030).
Recordings.insert(
id="front_a",
path="/media/recordings/front_a.mp4",
camera="front_door",
start_time=1000,
end_time=1010,
duration=10,
motion=0,
).execute()
Recordings.insert(
id="front_b",
path="/media/recordings/front_b.mp4",
camera="front_door",
start_time=1030,
end_time=1040,
duration=10,
motion=0,
).execute()
# back_door is not in allowed_cameras; its full-window coverage must
# not mask the front_door gap.
Recordings.insert(
id="back_a",
path="/media/recordings/back_a.mp4",
camera="back_door",
start_time=1000,
end_time=1040,
duration=40,
motion=0,
).execute()
response = client.get(
"/recordings/unavailable",
params={
"after": 1000,
"before": 1040,
"scale": 5,
"cameras": "all",
},
)
assert response.status_code == 200
assert response.json() == [{"start_time": 1010, "end_time": 1030}]
+37
View File
@@ -497,6 +497,43 @@ class TestHttpReview(BaseTestHttp):
)
assert user_review.has_been_reviewed == True
def test_post_reviews_viewed_concurrent_duplicate_does_not_raise(self):
"""Regression: concurrent requests marking the same review must not 500.
Two requests can both SELECT and find no existing status, then both try
to INSERT, hitting the unique (user_id, review_segment) constraint.
on_conflict_ignore() must silently skip the duplicate instead of raising
an IntegrityError (which was previously caught with try/except).
"""
id = "123456.random"
with AuthTestClient(self.app):
super().insert_mock_review_segment(id)
# Simulate the first request having already committed its insert.
self._insert_user_review_status(id, reviewed=True)
# Simulate the second concurrent request attempting the same insert.
UserReviewStatus.insert_many(
[
{
"user_id": self.user_id,
"review_segment_id": id,
"has_been_reviewed": True,
}
]
).on_conflict_ignore().execute()
# Exactly one row should exist; no exception should have been raised.
count = (
UserReviewStatus.select()
.where(
(UserReviewStatus.user_id == self.user_id)
& (UserReviewStatus.review_segment == id)
)
.count()
)
assert count == 1
####################################################################################################################
################################### POST reviews/delete Endpoint ################################################
####################################################################################################################
+21
View File
@@ -0,0 +1,21 @@
"""Peewee migrations -- 036_add_perf_indexes.py.
Adds composite/single-column indexes to speed up single-camera queries
issued by the web UI.
"""
import peewee as pw
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.sql(
'CREATE INDEX IF NOT EXISTS "event_camera_start_time" '
'ON "event" ("camera", "start_time" DESC)'
)
def rollback(migrator, database, fake=False, **kwargs):
migrator.sql('DROP INDEX IF EXISTS "event_camera_start_time"')
@@ -1263,7 +1263,6 @@ function ObjectDetailsTab({
}
setState("submitted");
setSearch({ ...search, plus_id: "new_upload" });
mutate(
(key) => isEventsKey(key),
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
@@ -1286,7 +1285,7 @@ function ObjectDetailsTab({
);
}
},
[search, mutate, mapSearchResults, setSearch, isEventsKey, t],
[search, mutate, mapSearchResults, isEventsKey, t],
);
const popoverContainerRef = useRef<HTMLDivElement | null>(null);
+10 -2
View File
@@ -47,6 +47,7 @@ type HlsVideoPlayerProps = {
frigateControls?: boolean;
inpointOffset?: number;
onClipEnded?: (currentTime: number) => void;
onClipPrevious?: (diff: number) => void;
onPlayerLoaded?: () => void;
onTimeUpdate?: (time: number) => void;
onPlaying?: () => void;
@@ -74,6 +75,7 @@ export default function HlsVideoPlayer({
frigateControls = true,
inpointOffset = 0,
onClipEnded,
onClipPrevious,
onPlayerLoaded,
onTimeUpdate,
onPlaying,
@@ -339,11 +341,17 @@ export default function HlsVideoPlayer({
onSeek={(diff) => {
const currentTime = videoRef.current?.currentTime;
if (!videoRef.current || !currentTime) {
if (!videoRef.current || currentTime == undefined) {
return;
}
videoRef.current.currentTime = Math.max(0, currentTime + diff);
const newTime = currentTime + diff;
if (newTime < 0 && onClipPrevious) {
onClipPrevious(diff);
} else {
videoRef.current.currentTime = Math.max(0, newTime);
}
}}
onSetPlaybackRate={(rate) => {
setPlaybackRate(rate, true);
@@ -49,6 +49,7 @@ type DynamicVideoPlayerProps = {
onControllerReady: (controller: DynamicVideoController) => void;
onTimestampUpdate?: (timestamp: number) => void;
onClipEnded?: () => void;
onClipPrevious?: (diff: number) => void;
onSeekToTime?: (timestamp: number, play?: boolean) => void;
setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
toggleFullscreen: () => void;
@@ -68,6 +69,7 @@ export default function DynamicVideoPlayer({
onControllerReady,
onTimestampUpdate,
onClipEnded,
onClipPrevious,
onSeekToTime,
setFullResolution,
toggleFullscreen,
@@ -343,6 +345,7 @@ export default function DynamicVideoPlayer({
onTimeUpdate={onTimeUpdate}
onPlayerLoaded={onPlayerLoaded}
onClipEnded={onValidateClipEnd}
onClipPrevious={onClipPrevious}
onSeekToTime={(timestamp, play) => {
if (onSeekToTime) {
onSeekToTime(timestamp, play);
+11
View File
@@ -336,6 +336,16 @@ export function RecordingView({
[currentTimeRange, updateSelectedSegment],
);
const onClipPrevious = useCallback(
(diff: number) => {
manuallySetCurrentTime(
currentTime + diff,
mainControllerRef.current?.isPlaying() ?? false,
);
},
[currentTime, manuallySetCurrentTime],
);
const onShareReviewLink = useCallback(
(timestamp: number) => {
const reviewUrl = createRecordingReviewUrl(location.pathname, {
@@ -902,6 +912,7 @@ export function RecordingView({
);
}}
onClipEnded={onClipEnded}
onClipPrevious={onClipPrevious}
onSeekToTime={manuallySetCurrentTime}
onControllerReady={(controller) => {
mainControllerRef.current = controller;