mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-01 20:36:51 +03:00
Compare commits
15
Commits
796bfdc79a
...
e8061d2c8e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8061d2c8e | ||
|
|
d02a1156b7 | ||
|
|
c17538aff9 | ||
|
|
b535fb0b27 | ||
|
|
25cb4d9008 | ||
|
|
cb15ba5b60 | ||
|
|
ec1238e723 | ||
|
|
c575fb223b | ||
|
|
9fa345f192 | ||
|
|
7b55c4b758 | ||
|
|
570e2e3f76 | ||
|
|
39fba9b0a7 | ||
|
|
328a26b169 | ||
|
|
311fb1bd19 | ||
|
|
48b1426891 |
@@ -262,6 +262,19 @@ In this example:
|
||||
|
||||
- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups).
|
||||
|
||||
:::note
|
||||
|
||||
If a user isn't getting the role you expect, enable debug logging to see exactly what headers Frigate is receiving from your proxy:
|
||||
|
||||
```yaml
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
frigate.api.auth: debug
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
#### Port Considerations
|
||||
|
||||
**Authenticated Port (8971)**
|
||||
|
||||
+28
-12
@@ -386,7 +386,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)
|
||||
@@ -396,13 +398,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)
|
||||
@@ -484,22 +504,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",
|
||||
|
||||
+61
-34
@@ -9,7 +9,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 (
|
||||
@@ -171,11 +171,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"}
|
||||
@@ -183,16 +191,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(
|
||||
@@ -489,27 +491,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=(
|
||||
|
||||
@@ -23,7 +23,6 @@ from frigate.const import (
|
||||
EXPIRE_AUDIO_ACTIVITY,
|
||||
INSERT_MANY_RECORDINGS,
|
||||
INSERT_PREVIEW,
|
||||
NOTIFICATION_TEST,
|
||||
REQUEST_REGION_GRID,
|
||||
UPDATE_AUDIO_ACTIVITY,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
@@ -57,7 +56,6 @@ _WS_BLOCKED_TOPICS = frozenset(
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_BIRDSEYE_LAYOUT,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
NOTIFICATION_TEST,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -288,6 +288,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
max(0, face_box[0]) : min(frame.shape[1], face_box[2]),
|
||||
]
|
||||
|
||||
if face_frame.size == 0:
|
||||
logger.debug(f"Empty face crop for {id}")
|
||||
return
|
||||
|
||||
res = self.recognizer.classify(face_frame)
|
||||
|
||||
if not res:
|
||||
|
||||
@@ -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 ################################################
|
||||
####################################################################################################################
|
||||
|
||||
@@ -115,6 +115,13 @@ class TestCheckWsAuthorization(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
def test_viewer_blocked_from_notification_test(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"notification_test", "viewer", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
# --- Admin access ---
|
||||
|
||||
def test_admin_can_send_restart(self):
|
||||
@@ -134,6 +141,13 @@ class TestCheckWsAuthorization(unittest.TestCase):
|
||||
_check_ws_authorization("front_door/ptz", "admin", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_admin_can_send_notification_test(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"notification_test", "admin", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
# --- Comma-separated roles ---
|
||||
|
||||
def test_comma_separated_admin_viewer_grants_admin(self):
|
||||
|
||||
@@ -684,22 +684,21 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
# check for config updates
|
||||
updated_topics = self.camera_config_subscriber.check_for_updates()
|
||||
|
||||
if "enabled" in updated_topics:
|
||||
for camera in updated_topics["enabled"]:
|
||||
if self.camera_states[camera].prev_enabled is None:
|
||||
self.camera_states[camera].prev_enabled = self.config.cameras[
|
||||
camera
|
||||
].enabled
|
||||
elif "add" in updated_topics:
|
||||
for camera in updated_topics["add"]:
|
||||
self.config.cameras[camera] = (
|
||||
self.camera_config_subscriber.camera_configs[camera]
|
||||
)
|
||||
self.create_camera_state(camera)
|
||||
elif "remove" in updated_topics:
|
||||
# a single drain can carry several topics at once, so add and
|
||||
# remove are handled independently rather than as exclusive branches
|
||||
for camera in updated_topics.get("add", []):
|
||||
self.config.cameras[camera] = (
|
||||
self.camera_config_subscriber.camera_configs[camera]
|
||||
)
|
||||
self.create_camera_state(camera)
|
||||
|
||||
if "remove" in updated_topics:
|
||||
for camera in updated_topics["remove"]:
|
||||
removed_camera_state = self.camera_states[camera]
|
||||
removed_camera_state.shutdown()
|
||||
camera_state = self.camera_states.get(camera)
|
||||
if camera_state is None:
|
||||
continue
|
||||
|
||||
camera_state.shutdown()
|
||||
self.camera_states.pop(camera)
|
||||
self.camera_activity.pop(camera, None)
|
||||
self.last_motion_detected.pop(camera, None)
|
||||
|
||||
@@ -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"')
|
||||
@@ -227,16 +227,18 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
aria-expanded={open}
|
||||
disabled={disabled || readonly}
|
||||
className={cn(
|
||||
"justify-between font-normal",
|
||||
"min-w-0 justify-between font-normal",
|
||||
!currentLabel && "text-muted-foreground",
|
||||
fieldClassName,
|
||||
)}
|
||||
>
|
||||
{currentLabel ??
|
||||
t("configForm.genaiModel.placeholder", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Select or enter a model…",
|
||||
})}
|
||||
<span className="truncate">
|
||||
{currentLabel ??
|
||||
t("configForm.genaiModel.placeholder", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Select or enter a model…",
|
||||
})}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -263,12 +265,14 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
value={trimmedSearch}
|
||||
onSelect={() => commit(trimmedSearch)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("configForm.genaiModel.useCustom", {
|
||||
ns: "views/settings",
|
||||
value: trimmedSearch,
|
||||
defaultValue: 'Use "{{value}}"',
|
||||
})}
|
||||
<Plus className="mr-2 h-4 w-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("configForm.genaiModel.useCustom", {
|
||||
ns: "views/settings",
|
||||
value: trimmedSearch,
|
||||
defaultValue: 'Use "{{value}}"',
|
||||
})}
|
||||
</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
@@ -287,11 +291,11 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
"mr-2 h-4 w-4 shrink-0",
|
||||
value === model ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{model}
|
||||
<span className="truncate">{model}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
@@ -129,7 +129,7 @@ export function GeneralFilterContent({
|
||||
className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
|
||||
htmlFor={item}
|
||||
>
|
||||
{item.replaceAll("_", " ")}
|
||||
{t(`logger.logLevel.${item}`, { ns: "views/settings" })}
|
||||
</Label>
|
||||
<Switch
|
||||
key={item}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LogSeverity } from "@/types/log";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { isIOS } from "react-device-detect";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ChipProps = {
|
||||
className?: string;
|
||||
@@ -50,6 +51,7 @@ type LogChipProps = {
|
||||
onClickSeverity?: () => void;
|
||||
};
|
||||
export function LogChip({ severity, onClickSeverity }: LogChipProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const severityClassName = useMemo(() => {
|
||||
switch (severity) {
|
||||
case "info":
|
||||
@@ -73,7 +75,7 @@ export function LogChip({ severity, onClickSeverity }: LogChipProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{severity}
|
||||
{t(`logger.logLevel.${severity}`, { ns: "views/settings" })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -60,7 +60,10 @@ const CommandList = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
className={cn(
|
||||
"scrollbar-container max-h-[300px] overflow-y-auto overflow-x-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -770,6 +770,34 @@ export default function MotionSearchView({
|
||||
};
|
||||
}, [cancelMotionSearchJobViaBeacon]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
}, [navigate, onBack]);
|
||||
|
||||
// Dismissing the entry dialog (escape / click outside) before a search has
|
||||
// run leaves nothing behind it, so cancel the flow instead of revealing an
|
||||
// empty page.
|
||||
const handleSearchDialogOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (
|
||||
!nextOpen &&
|
||||
!isSearching &&
|
||||
!hasSearched &&
|
||||
searchResults.length === 0
|
||||
) {
|
||||
handleBack();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearchDialogOpen(nextOpen);
|
||||
},
|
||||
[handleBack, hasSearched, isSearching, searchResults.length],
|
||||
);
|
||||
|
||||
const handleNewSearch = useCallback(() => {
|
||||
if (jobId && jobCamera) {
|
||||
void cancelMotionSearchJob(jobId, jobCamera);
|
||||
@@ -1238,7 +1266,7 @@ export default function MotionSearchView({
|
||||
<Toaster closeButton={true} position="top-center" />
|
||||
<MotionSearchDialog
|
||||
open={isSearchDialogOpen}
|
||||
onOpenChange={setIsSearchDialogOpen}
|
||||
onOpenChange={handleSearchDialogOpenChange}
|
||||
config={config}
|
||||
cameras={cameras}
|
||||
selectedCamera={selectedCamera}
|
||||
@@ -1276,7 +1304,7 @@ export default function MotionSearchView({
|
||||
className="flex items-center gap-2.5 rounded-lg"
|
||||
aria-label={t("label.back", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={() => (onBack ? onBack() : navigate(-1))}
|
||||
onClick={handleBack}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
|
||||
Reference in New Issue
Block a user