mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-11 09:12:48 +03:00
Compare commits
2
Commits
39f76e4a2c
...
eef55045b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef55045b6 | ||
|
|
2961449d4d |
+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 (
|
||||
@@ -173,11 +173,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"}
|
||||
@@ -185,16 +193,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(
|
||||
@@ -491,27 +493,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=(
|
||||
|
||||
@@ -497,6 +497,43 @@ class TestHttpReview(BaseTestHttp):
|
||||
)
|
||||
assert user_review.has_been_reviewed == True
|
||||
|
||||
def test_reviews_concurrent_insert_peewee_ignore(self):
|
||||
"""Validates that on_conflict_ignore() silently skips a duplicate insert
|
||||
|
||||
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 ################################################
|
||||
####################################################################################################################
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Peewee migrations -- 039_add_perf_indexes.py.
|
||||
|
||||
Adds a composite (camera, start_time) index 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")'
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator, database, fake=False, **kwargs):
|
||||
migrator.sql('DROP INDEX IF EXISTS "event_camera_start_time"')
|
||||
@@ -80,7 +80,7 @@ type ExportOption = (typeof EXPORT_OPTIONS)[number];
|
||||
export type ExportTab = "export" | "multi";
|
||||
|
||||
// length of a range seeded around the current playback time
|
||||
const MULTI_CAMERA_RANGE_SECONDS = 3600;
|
||||
const MULTI_CAMERA_RANGE_SECONDS = 1800;
|
||||
const TIMELINE_SELECTION_SECONDS = 60;
|
||||
|
||||
type ExportDialogProps = {
|
||||
|
||||
@@ -231,6 +231,14 @@ export function RecordingView({
|
||||
|
||||
const [debugReplayMode, setDebugReplayMode] = useState<ExportMode>("none");
|
||||
const [debugReplayRange, setDebugReplayRange] = useState<TimeRange>();
|
||||
|
||||
// while the range handles are up the player has to stay put: the
|
||||
// timeline auto-scrolls to follow the playhead once the interaction
|
||||
// timeout lapses, yanking the view out from under the drag
|
||||
const selectingRange =
|
||||
exportMode == "timeline" ||
|
||||
exportMode == "timeline_multi" ||
|
||||
debugReplayMode == "timeline";
|
||||
const [shareTimestampOpen, setShareTimestampOpen] = useState(false);
|
||||
const [shareTimestampAtOpen, setShareTimestampAtOpen] = useState(
|
||||
Math.floor(startTime),
|
||||
@@ -338,6 +346,19 @@ export function RecordingView({
|
||||
updateSelectedSegment,
|
||||
]);
|
||||
|
||||
const wasPlayingBeforeSelectRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectingRange) {
|
||||
wasPlayingBeforeSelectRef.current =
|
||||
mainControllerRef.current?.isPlaying() ?? false;
|
||||
mainControllerRef.current?.pause();
|
||||
} else if (wasPlayingBeforeSelectRef.current) {
|
||||
wasPlayingBeforeSelectRef.current = false;
|
||||
mainControllerRef.current?.play();
|
||||
}
|
||||
}, [selectingRange]);
|
||||
|
||||
const manuallySetCurrentTime = useCallback(
|
||||
(time: number, play: boolean = false) => {
|
||||
if (!currentTimeRange) {
|
||||
@@ -396,7 +417,7 @@ export function RecordingView({
|
||||
}, [navigate, recording?.navigationSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrubbing) {
|
||||
if (!scrubbing && !selectingRange) {
|
||||
if (Math.abs(currentTime - playerTime) > 10) {
|
||||
if (
|
||||
currentTimeRange.after <= currentTime &&
|
||||
@@ -423,9 +444,10 @@ export function RecordingView({
|
||||
mainControllerRef.current?.play();
|
||||
}
|
||||
}
|
||||
// we only want to seek when current time doesn't match the player update time
|
||||
// we only want to seek when current time doesn't match the player update
|
||||
// time, and once more on the way out of a range selection
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentTime, scrubbing]);
|
||||
}, [currentTime, scrubbing, selectingRange]);
|
||||
|
||||
const [fullResolution, setFullResolution] = useState<VideoResolutionType>({
|
||||
width: 0,
|
||||
@@ -737,13 +759,7 @@ export function RecordingView({
|
||||
latestTime={timeRange.before}
|
||||
mode={debugReplayMode}
|
||||
range={debugReplayRange}
|
||||
setRange={(range: TimeRange | undefined) => {
|
||||
setDebugReplayRange(range);
|
||||
|
||||
if (range != undefined) {
|
||||
mainControllerRef.current?.pause();
|
||||
}
|
||||
}}
|
||||
setRange={setDebugReplayRange}
|
||||
setMode={setDebugReplayMode}
|
||||
/>
|
||||
)}
|
||||
@@ -756,13 +772,7 @@ export function RecordingView({
|
||||
mode={exportMode}
|
||||
range={exportRange}
|
||||
showPreview={showExportPreview}
|
||||
setRange={(range) => {
|
||||
setExportRange(range);
|
||||
|
||||
if (range != undefined) {
|
||||
mainControllerRef.current?.pause();
|
||||
}
|
||||
}}
|
||||
setRange={setExportRange}
|
||||
setMode={setExportMode}
|
||||
setShowPreview={setShowExportPreview}
|
||||
/>
|
||||
@@ -925,13 +935,7 @@ export function RecordingView({
|
||||
debugReplayMode={debugReplayMode}
|
||||
debugReplayRange={debugReplayRange}
|
||||
setDebugReplayMode={setDebugReplayMode}
|
||||
setDebugReplayRange={(range: TimeRange | undefined) => {
|
||||
setDebugReplayRange(range);
|
||||
|
||||
if (range != undefined) {
|
||||
mainControllerRef.current?.pause();
|
||||
}
|
||||
}}
|
||||
setDebugReplayRange={setDebugReplayRange}
|
||||
onShareTimestamp={onShareReviewLink}
|
||||
onMotionSearch={
|
||||
onMotionSearch ? () => onMotionSearch(mainCamera) : undefined
|
||||
|
||||
Reference in New Issue
Block a user