Compare commits

...

10 Commits

Author SHA1 Message Date
gwmullin
ade6497176
Merge c575fb223b into 08be019bed 2026-05-31 12:17:27 +01:00
Josh Hawkins
08be019bed
Miscellaneous fixes (#23358)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / Assemble and push default build (push) Blocked by required conditions
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
CI / Synaptics Build (push) Blocked by required conditions
* improve visibility of blurred icon buttons

* add motion search to history actions menu and mobile drawer

* i18n

* use pure css for motion search dialog video

* defer profile restoration until subscribers are connected

* change order of features in mobile review settings drawer
2026-05-30 21:35:03 -06: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
11 changed files with 275 additions and 211 deletions

View File

@ -389,82 +389,106 @@ def events_explore(
limit: int = 10,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
# get distinct labels for all events
distinct_labels = (
Event.select(Event.label)
.where(Event.camera << allowed_cameras)
.distinct()
.order_by(Event.label)
if not allowed_cameras:
return JSONResponse(content=[])
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,
)
label_counts = {}
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()
.where((Event.label == label) & (Event.camera << allowed_cameras))
.order_by(Event.start_time.desc())
.limit(limit)
.iterator()
)
# count total events for this label
label_counts[label] = (
Event.select()
.where((Event.label == label) & (Event.camera << allowed_cameras))
.count()
)
yield from label_events
def process_events():
for event in event_generator():
processed_event = {
"id": event.id,
"camera": event.camera,
"label": event.label,
"zones": event.zones,
"start_time": event.start_time,
"end_time": event.end_time,
"has_clip": event.has_clip,
"has_snapshot": event.has_snapshot,
"plus_id": event.plus_id,
"retain_indefinitely": event.retain_indefinitely,
"sub_label": event.sub_label,
"top_score": event.top_score,
"false_positive": event.false_positive,
"box": event.box,
"data": {
k: v
for k, v in event.data.items()
if k
in [
"type",
"score",
"top_score",
"description",
"sub_label_score",
"average_estimated_speed",
"velocity_angle",
"path_data",
"recognized_license_plate",
"recognized_license_plate_score",
]
},
"event_count": label_counts[event.label],
}
yield processed_event
# convert iterator to list and sort
processed_events = sorted(
process_events(),
key=lambda x: (x["event_count"], x["start_time"]),
reverse=True,
# Single query: per-label COUNT and top-N ranking by start_time computed
# via window functions in a CTE, then filtered to rn <= limit
event_count = (
fn.COUNT(Event.id).over(partition_by=[Event.label]).alias("event_count")
)
rn = (
fn.ROW_NUMBER()
.over(partition_by=[Event.label], order_by=[Event.start_time.desc()])
.alias("rn")
)
base_query = Event.select(
*explore_columns,
event_count,
rn,
).where(Event.camera << allowed_cameras)
ranked = base_query.cte("ranked")
query = (
Event.select(
ranked.c.id,
ranked.c.camera,
ranked.c.label,
ranked.c.sub_label,
ranked.c.zones,
ranked.c.start_time,
ranked.c.end_time,
ranked.c.has_clip,
ranked.c.has_snapshot,
ranked.c.plus_id,
ranked.c.retain_indefinitely,
ranked.c.top_score,
ranked.c.false_positive,
ranked.c.box,
ranked.c.data,
ranked.c.event_count,
)
.from_(ranked)
.with_cte(ranked)
.where(ranked.c.rn <= limit)
.order_by(ranked.c.event_count.desc(), ranked.c.start_time.desc())
.objects()
)
allowed_data_keys = {
"type",
"score",
"top_score",
"description",
"sub_label_score",
"average_estimated_speed",
"velocity_angle",
"path_data",
"recognized_license_plate",
"recognized_license_plate_score",
}
processed_events = [
{
"id": event.id,
"camera": event.camera,
"label": event.label,
"zones": event.zones,
"start_time": event.start_time,
"end_time": event.end_time,
"has_clip": event.has_clip,
"has_snapshot": event.has_snapshot,
"plus_id": event.plus_id,
"retain_indefinitely": event.retain_indefinitely,
"sub_label": event.sub_label,
"top_score": event.top_score,
"false_positive": event.false_positive,
"box": event.box,
"data": {
k: v for k, v in (event.data or {}).items() if k in allowed_data_keys
},
"event_count": event.event_count,
}
for event in query
]
return JSONResponse(content=processed_events)
@ -487,22 +511,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",

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).execute()
return JSONResponse(
content=(

View File

@ -343,13 +343,21 @@ class FrigateApp:
)
self.dispatcher.profile_manager = self.profile_manager
def restore_active_profile(self) -> None:
"""Re-activate the persisted profile after subscribers are connected.
ZMQ PUB/SUB drops messages with no subscribers, so activation must
run after every config_updater subscriber is up.
"""
if self.profile_manager is None:
return
persisted = ProfileManager.load_persisted_profile()
if persisted and any(
persisted in cam.profiles for cam in self.config.cameras.values()
):
logger.info("Restoring persisted profile '%s'", persisted)
# don't clear runtime overrides here, restore_runtime_state() later
# in startup replays it on top of the activated profile
# runtime overrides are layered on top via restore_runtime_state()
self.profile_manager.activate_profile(
persisted, clear_runtime_overrides=False
)
@ -617,6 +625,7 @@ class FrigateApp:
self.start_watchdog()
# restore persisted runtime overrides on top of config
self.restore_active_profile()
self.dispatcher.restore_runtime_state()
self.init_auth()

View File

@ -0,0 +1,27 @@
"""Peewee migrations -- 036_add_perf_indexes.py.
Adds composite/single-column indexes to speed up the most common queries
issued by the web UI on initial page load:
- event(camera, start_time DESC): /events list filtered by camera + time range
- reviewsegment(camera, start_time DESC): /api/review filtered by camera + time range
- reviewsegment(end_time): supports the end_time > after half of /api/review's range
The existing event(label, start_time DESC) index from migration 027 already
covers /events/explore, so it is intentionally not duplicated here.
"""
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"')

View File

@ -67,7 +67,7 @@
"needsReview": "Needs review",
"securityConcern": "Security concern",
"motionSearch": {
"menuItem": "Motion search",
"menuItem": "Motion Search",
"openMenu": "Camera options"
},
"motionPreviews": {

View File

@ -14,8 +14,8 @@ const BlurredIconButton = forwardRef<HTMLDivElement, BlurredIconButtonProps>(
)}
{...rest}
>
<div className="pointer-events-none absolute inset-0 m-auto size-5 scale-95 rounded-full bg-black opacity-0 blur-sm transition-all duration-200 group-hover:scale-100 group-hover:opacity-100 group-hover:blur-xl" />
<div className="relative z-10 cursor-pointer text-white/85 hover:text-white">
<div className="pointer-events-none absolute inset-0 m-auto size-5 scale-95 rounded-full bg-black opacity-30 blur-md transition-all duration-200 group-hover:scale-100 group-hover:opacity-100 group-hover:blur-xl" />
<div className="relative z-10 cursor-pointer text-white/85 drop-shadow-[0_1px_1px_rgba(0,0,0,0.9)] hover:text-white">
{children}
</div>
</div>

View File

@ -12,14 +12,21 @@ type ActionsDropdownProps = {
onDebugReplayClick?: () => void;
onExportClick: () => void;
onShareTimestampClick: () => void;
onMotionSearchClick?: () => void;
};
export default function ActionsDropdown({
onDebugReplayClick,
onExportClick,
onShareTimestampClick,
onMotionSearchClick,
}: Readonly<ActionsDropdownProps>) {
const { t } = useTranslation(["components/dialog", "views/replay", "common"]);
const { t } = useTranslation([
"components/dialog",
"views/replay",
"views/events",
"common",
]);
return (
<DropdownMenu>
@ -42,6 +49,11 @@ export default function ActionsDropdown({
<DropdownMenuItem onClick={onShareTimestampClick}>
{t("recording.shareTimestamp.label", { ns: "components/dialog" })}
</DropdownMenuItem>
{onMotionSearchClick && (
<DropdownMenuItem onClick={onMotionSearchClick}>
{t("motionSearch.menuItem", { ns: "views/events" })}
</DropdownMenuItem>
)}
{onDebugReplayClick && (
<DropdownMenuItem onClick={onDebugReplayClick}>
{t("title", { ns: "views/replay" })}

View File

@ -3,7 +3,7 @@ import { baseUrl } from "@/api/baseUrl";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Button } from "../ui/button";
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
import { LuBug, LuShare2 } from "react-icons/lu";
import { LuBug, LuSearch, LuShare2 } from "react-icons/lu";
import { TimeRange } from "@/types/timeline";
import { ExportContent, ExportPreviewDialog, ExportTab } from "./ExportDialog";
import {
@ -46,6 +46,7 @@ const DRAWER_FEATURES = [
"filter",
"debug-replay",
"share-timestamp",
"motion-search",
] as const;
export type DrawerFeatures = (typeof DRAWER_FEATURES)[number];
const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [
@ -54,6 +55,7 @@ const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [
"filter",
"debug-replay",
"share-timestamp",
"motion-search",
];
type MobileReviewSettingsDrawerProps = {
@ -75,6 +77,7 @@ type MobileReviewSettingsDrawerProps = {
setDebugReplayMode?: (mode: ExportMode) => void;
setDebugReplayRange?: (range: TimeRange | undefined) => void;
onShareTimestamp?: (timestamp: number) => void;
onMotionSearch?: () => void;
onUpdateFilter: (filter: ReviewFilter) => void;
setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void;
@ -99,6 +102,7 @@ export default function MobileReviewSettingsDrawer({
setDebugReplayMode = () => {},
setDebugReplayRange = () => {},
onShareTimestamp = () => {},
onMotionSearch,
onUpdateFilter,
setRange,
setMode,
@ -108,6 +112,7 @@ export default function MobileReviewSettingsDrawer({
"views/recording",
"components/dialog",
"views/replay",
"views/events",
"common",
]);
const isAdmin = useIsAdmin();
@ -343,27 +348,6 @@ export default function MobileReviewSettingsDrawer({
{t("export")}
</Button>
)}
{features.includes("share-timestamp") && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("recording.shareTimestamp.label", {
ns: "components/dialog",
})}
onClick={() => {
const initialTimestamp = Math.floor(currentTime);
setShareTimestampAtOpen(initialTimestamp);
setCustomShareTimestamp(initialTimestamp);
setSelectedShareOption("current");
setDrawerMode("share-timestamp");
}}
>
<LuShare2 className="size-5 rounded-md bg-secondary-foreground stroke-secondary p-1" />
{t("recording.shareTimestamp.label", {
ns: "components/dialog",
})}
</Button>
)}
{features.includes("calendar") && (
<Button
className="flex w-full items-center justify-center gap-2"
@ -390,6 +374,40 @@ export default function MobileReviewSettingsDrawer({
{t("filter")}
</Button>
)}
{features.includes("share-timestamp") && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("recording.shareTimestamp.label", {
ns: "components/dialog",
})}
onClick={() => {
const initialTimestamp = Math.floor(currentTime);
setShareTimestampAtOpen(initialTimestamp);
setCustomShareTimestamp(initialTimestamp);
setSelectedShareOption("current");
setDrawerMode("share-timestamp");
}}
>
<LuShare2 className="size-5 rounded-md bg-secondary-foreground stroke-secondary p-1" />
{t("recording.shareTimestamp.label", {
ns: "components/dialog",
})}
</Button>
)}
{features.includes("motion-search") && onMotionSearch && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("motionSearch.menuItem", { ns: "views/events" })}
onClick={() => {
onMotionSearch();
setDrawerMode("none");
}}
>
<LuSearch className="size-5 rounded-md bg-secondary-foreground stroke-secondary p-1" />
{t("motionSearch.menuItem", { ns: "views/events" })}
</Button>
)}
{isAdmin && features.includes("debug-replay") && (
<Button
className="flex w-full items-center justify-center gap-2"

View File

@ -56,11 +56,9 @@ export default function Events() {
false,
);
const [recording, setRecording] = useOverlayState<RecordingStartingPoint>(
"recording",
undefined,
false,
);
const [recording, setRecording] = useOverlayState<
RecordingStartingPoint | undefined
>("recording", undefined, false);
const [motionPreviewsCamera, setMotionPreviewsCamera] = useOverlayState<
string | undefined
>("motionPreviewsCamera", undefined);
@ -668,6 +666,10 @@ export default function Events() {
filter={reviewFilter}
updateFilter={onUpdateFilter}
refreshData={reloadData}
onMotionSearch={(camera) => {
setMotionSearchCamera(camera);
setRecording(undefined);
}}
/>
);
}

View File

@ -112,35 +112,8 @@ export default function MotionSearchDialog({
}: MotionSearchDialogProps) {
const { t } = useTranslation(["views/motionSearch", "common"]);
const apiHost = useApiHost();
const [containerNode, setContainerNode] = useState<HTMLDivElement | null>(
null,
);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const containerWidth = containerSize.width;
const containerHeight = containerSize.height;
const [imageLoaded, setImageLoaded] = useState(false);
useEffect(() => {
if (!containerNode) {
return;
}
const measure = () => {
const rect = containerNode.getBoundingClientRect();
setContainerSize((prev) =>
prev.width === rect.width && prev.height === rect.height
? prev
: { width: rect.width, height: rect.height },
);
};
measure();
const observer = new ResizeObserver(() => measure());
observer.observe(containerNode);
return () => observer.disconnect();
}, [containerNode]);
const cameraConfig = useMemo(() => {
if (!selectedCamera) return undefined;
return config.cameras[selectedCamera];
@ -169,28 +142,6 @@ export default function MotionSearchDialog({
setIsDrawingROI(true);
}, [isSearching, polygonPoints.length, setIsDrawingROI, setPolygonPoints]);
const imageSize = useMemo(() => {
if (!containerWidth || !containerHeight || !cameraConfig) {
return { width: 0, height: 0 };
}
const cameraAspectRatio =
cameraConfig.detect.width / cameraConfig.detect.height;
const availableAspectRatio = containerWidth / containerHeight;
if (availableAspectRatio >= cameraAspectRatio) {
return {
width: containerHeight * cameraAspectRatio,
height: containerHeight,
};
}
return {
width: containerWidth,
height: containerWidth / cameraAspectRatio,
};
}, [containerWidth, containerHeight, cameraConfig]);
useEffect(() => {
setImageLoaded(false);
}, [selectedCamera]);
@ -280,19 +231,9 @@ export default function MotionSearchDialog({
height: "100%",
}}
>
<div
ref={setContainerNode}
className="relative flex w-full items-center justify-center overflow-hidden rounded-lg border bg-secondary"
style={{ aspectRatio: "16 / 9" }}
>
<div className="relative flex aspect-video w-full items-center justify-center overflow-hidden rounded-lg border bg-secondary">
{selectedCamera && cameraConfig ? (
<div
className="relative"
style={{
width: imageSize.width || "100%",
height: imageSize.height || "100%",
}}
>
<div className="relative h-full w-full">
<img
alt={t("dialog.previewAlt", {
camera: selectedCamera,

View File

@ -95,6 +95,7 @@ type RecordingViewProps = {
filter?: ReviewFilter;
updateFilter: (newFilter: ReviewFilter) => void;
refreshData?: () => void;
onMotionSearch?: (camera: string) => void;
};
export function RecordingView({
startCamera,
@ -107,6 +108,7 @@ export function RecordingView({
filter,
updateFilter,
refreshData,
onMotionSearch,
}: RecordingViewProps) {
const { t } = useTranslation(["views/events", "components/dialog"]);
const { data: config } = useSWR<FrigateConfig>("config");
@ -725,6 +727,9 @@ export function RecordingView({
setCustomShareTimestamp(initialTimestamp);
setShareTimestampOpen(true);
}}
onMotionSearchClick={
onMotionSearch ? () => onMotionSearch(mainCamera) : undefined
}
onDebugReplayClick={
isAdmin
? () => {
@ -807,6 +812,9 @@ export function RecordingView({
}
}}
onShareTimestamp={onShareReviewLink}
onMotionSearch={
onMotionSearch ? () => onMotionSearch(mainCamera) : undefined
}
onUpdateFilter={updateFilter}
setRange={setExportRange}
setMode={setExportMode}