mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
Reviewed buttons (#10271)
* mark items as reviewed when they are opened * Update api to use json and add button to mark all as reviewed * fix api so last24 hours has its own review summary * fix sidebar spacing * formatting * Bug fixes * Make motion activity respect filters
This commit is contained in:
parent
b5edcd2fae
commit
68ed18d3f4
@ -76,11 +76,112 @@ def review():
|
||||
def review_summary():
|
||||
tz_name = request.args.get("timezone", default="utc", type=str)
|
||||
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
|
||||
day_ago = (datetime.now() - timedelta(hours=24)).timestamp()
|
||||
month_ago = (datetime.now() - timedelta(days=30)).timestamp()
|
||||
|
||||
cameras = request.args.get("cameras", "all")
|
||||
labels = request.args.get("labels", "all")
|
||||
|
||||
clauses = [(ReviewSegment.start_time > day_ago)]
|
||||
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((ReviewSegment.camera << camera_list))
|
||||
|
||||
if labels != "all":
|
||||
# use matching so segments with multiple labels
|
||||
# still match on a search where any label matches
|
||||
label_clauses = []
|
||||
filtered_labels = labels.split(",")
|
||||
|
||||
for label in filtered_labels:
|
||||
label_clauses.append(
|
||||
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
||||
)
|
||||
|
||||
label_clause = reduce(operator.or_, label_clauses)
|
||||
clauses.append((label_clause))
|
||||
|
||||
last_24 = (
|
||||
ReviewSegment.select(
|
||||
fn.SUM(
|
||||
Case(
|
||||
None,
|
||||
[
|
||||
(
|
||||
(ReviewSegment.severity == "alert"),
|
||||
ReviewSegment.has_been_reviewed,
|
||||
)
|
||||
],
|
||||
0,
|
||||
)
|
||||
).alias("reviewed_alert"),
|
||||
fn.SUM(
|
||||
Case(
|
||||
None,
|
||||
[
|
||||
(
|
||||
(ReviewSegment.severity == "detection"),
|
||||
ReviewSegment.has_been_reviewed,
|
||||
)
|
||||
],
|
||||
0,
|
||||
)
|
||||
).alias("reviewed_detection"),
|
||||
fn.SUM(
|
||||
Case(
|
||||
None,
|
||||
[
|
||||
(
|
||||
(ReviewSegment.severity == "significant_motion"),
|
||||
ReviewSegment.has_been_reviewed,
|
||||
)
|
||||
],
|
||||
0,
|
||||
)
|
||||
).alias("reviewed_motion"),
|
||||
fn.SUM(
|
||||
Case(
|
||||
None,
|
||||
[
|
||||
(
|
||||
(ReviewSegment.severity == "alert"),
|
||||
1,
|
||||
)
|
||||
],
|
||||
0,
|
||||
)
|
||||
).alias("total_alert"),
|
||||
fn.SUM(
|
||||
Case(
|
||||
None,
|
||||
[
|
||||
(
|
||||
(ReviewSegment.severity == "detection"),
|
||||
1,
|
||||
)
|
||||
],
|
||||
0,
|
||||
)
|
||||
).alias("total_detection"),
|
||||
fn.SUM(
|
||||
Case(
|
||||
None,
|
||||
[
|
||||
(
|
||||
(ReviewSegment.severity == "significant_motion"),
|
||||
1,
|
||||
)
|
||||
],
|
||||
0,
|
||||
)
|
||||
).alias("total_motion"),
|
||||
)
|
||||
.where(reduce(operator.and_, clauses))
|
||||
.dicts()
|
||||
.get()
|
||||
)
|
||||
|
||||
clauses = [(ReviewSegment.start_time > month_ago)]
|
||||
|
||||
if cameras != "all":
|
||||
@ -101,7 +202,7 @@ def review_summary():
|
||||
label_clause = reduce(operator.or_, label_clauses)
|
||||
clauses.append((label_clause))
|
||||
|
||||
groups = (
|
||||
last_month = (
|
||||
ReviewSegment.select(
|
||||
fn.strftime(
|
||||
"%Y-%m-%d",
|
||||
@ -192,29 +293,20 @@ def review_summary():
|
||||
.order_by(ReviewSegment.start_time.desc())
|
||||
)
|
||||
|
||||
return jsonify([e for e in groups.dicts().iterator()])
|
||||
data = {
|
||||
"last24Hours": last_24,
|
||||
}
|
||||
|
||||
for e in last_month.dicts().iterator():
|
||||
data[e["day"]] = e
|
||||
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@ReviewBp.route("/review/<id>/viewed", methods=("POST",))
|
||||
def set_reviewed(id):
|
||||
try:
|
||||
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == id)
|
||||
except DoesNotExist:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Review " + id + " not found"}), 404
|
||||
)
|
||||
|
||||
review.has_been_reviewed = True
|
||||
review.save()
|
||||
|
||||
return make_response(
|
||||
jsonify({"success": True, "message": "Reviewed " + id + " viewed"}), 200
|
||||
)
|
||||
|
||||
|
||||
@ReviewBp.route("/reviews/<ids>/viewed", methods=("POST",))
|
||||
def set_multiple_reviewed(ids: str):
|
||||
list_of_ids = ids.split(",")
|
||||
@ReviewBp.route("/reviews/viewed", methods=("POST",))
|
||||
def set_multiple_reviewed():
|
||||
json: dict[str, any] = request.get_json(silent=True) or {}
|
||||
list_of_ids = json.get("ids", "")
|
||||
|
||||
if not list_of_ids or len(list_of_ids) == 0:
|
||||
return make_response(
|
||||
@ -264,13 +356,17 @@ def delete_reviews(ids: str):
|
||||
@ReviewBp.route("/review/activity")
|
||||
def review_activity():
|
||||
"""Get motion and audio activity."""
|
||||
cameras = request.args.get("cameras", "all")
|
||||
before = request.args.get("before", type=float, default=datetime.now().timestamp())
|
||||
after = request.args.get(
|
||||
"after", type=float, default=(datetime.now() - timedelta(hours=1)).timestamp()
|
||||
)
|
||||
|
||||
# get scale in seconds
|
||||
scale = request.args.get("scale", type=int, default=30)
|
||||
clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)]
|
||||
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((Recordings.camera << camera_list))
|
||||
|
||||
all_recordings: list[Recordings] = (
|
||||
Recordings.select(
|
||||
@ -280,7 +376,7 @@ def review_activity():
|
||||
Recordings.motion,
|
||||
Recordings.dBFS,
|
||||
)
|
||||
.where((Recordings.start_time > after) & (Recordings.end_time < before))
|
||||
.where(reduce(operator.and_, clauses))
|
||||
.order_by(Recordings.start_time.asc())
|
||||
.iterator()
|
||||
)
|
||||
@ -298,6 +394,9 @@ def review_activity():
|
||||
}
|
||||
)
|
||||
|
||||
# get scale in seconds
|
||||
scale = request.args.get("scale", type=int, default=30)
|
||||
|
||||
# resample data using pandas to get activity on scaled basis
|
||||
df = pd.DataFrame(data, columns=["start_time", "motion", "audio"])
|
||||
|
||||
|
@ -22,7 +22,7 @@ export default function ReviewActionGroup({
|
||||
|
||||
const onMarkAsReviewed = useCallback(async () => {
|
||||
const idList = selectedReviews.join(",");
|
||||
await axios.post(`reviews/${idList}/viewed`);
|
||||
await axios.post(`reviews/viewed`, { ids: idList });
|
||||
setSelectedReviews([]);
|
||||
pullLatestData();
|
||||
}, [selectedReviews, setSelectedReviews, pullLatestData]);
|
||||
|
@ -13,20 +13,23 @@ function Sidebar() {
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
<div className="w-full flex flex-col gap-0 items-center">
|
||||
<Logo className="w-8 h-8 mb-6" />
|
||||
{navbarLinks.map((item) => (
|
||||
<div key={item.id}>
|
||||
<NavItem
|
||||
className={`mx-[10px] ${item.id == 1 ? "mb-2" : "mb-4"}`}
|
||||
Icon={item.icon}
|
||||
title={item.title}
|
||||
url={item.url}
|
||||
dev={item.dev}
|
||||
/>
|
||||
{item.id == 1 && item.url == location.pathname && (
|
||||
<CameraGroupSelector className="mb-4" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{navbarLinks.map((item) => {
|
||||
const showCameraGroups =
|
||||
item.id == 1 && item.url == location.pathname;
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<NavItem
|
||||
className={`mx-[10px] ${showCameraGroups ? "mb-2" : "mb-4"}`}
|
||||
Icon={item.icon}
|
||||
title={item.title}
|
||||
url={item.url}
|
||||
dev={item.dev}
|
||||
/>
|
||||
{showCameraGroups && <CameraGroupSelector className="mb-4" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SettingsNavItems className="hidden md:flex flex-col items-center mb-8" />
|
||||
</aside>
|
||||
|
@ -29,7 +29,7 @@ type DynamicVideoPlayerProps = {
|
||||
timeRange: { start: number; end: number };
|
||||
cameraPreviews: Preview[];
|
||||
previewOnly?: boolean;
|
||||
onControllerReady?: (controller: DynamicVideoController) => void;
|
||||
onControllerReady: (controller: DynamicVideoController) => void;
|
||||
onClick?: () => void;
|
||||
};
|
||||
export default function DynamicVideoPlayer({
|
||||
@ -86,14 +86,17 @@ export default function DynamicVideoPlayer({
|
||||
}, [camera, config, previewOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!controller) {
|
||||
if (!playerRef.current && !previewRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onControllerReady) {
|
||||
if (controller) {
|
||||
onControllerReady(controller);
|
||||
}
|
||||
}, [controller, onControllerReady]);
|
||||
|
||||
// we only want to fire once when players are ready
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playerRef, previewRef]);
|
||||
|
||||
const [hasRecordingAtTime, setHasRecordingAtTime] = useState(true);
|
||||
|
||||
@ -277,10 +280,6 @@ export default function DynamicVideoPlayer({
|
||||
player.on("ended", () =>
|
||||
controller.fireClipChangeEvent("forward"),
|
||||
);
|
||||
|
||||
if (onControllerReady) {
|
||||
onControllerReady(controller);
|
||||
}
|
||||
}}
|
||||
onDispose={() => {
|
||||
playerRef.current = undefined;
|
||||
|
@ -10,6 +10,7 @@ const buttonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
select: "bg-select text-white hover:bg-select/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
|
@ -115,9 +115,7 @@ export default function Events() {
|
||||
|
||||
// review summary
|
||||
|
||||
const { data: reviewSummary, mutate: updateSummary } = useSWR<
|
||||
ReviewSummary[]
|
||||
>([
|
||||
const { data: reviewSummary, mutate: updateSummary } = useSWR<ReviewSummary>([
|
||||
"review/summary",
|
||||
{
|
||||
timezone: timezone,
|
||||
@ -164,7 +162,7 @@ export default function Events() {
|
||||
|
||||
const markItemAsReviewed = useCallback(
|
||||
async (review: ReviewSegment) => {
|
||||
const resp = await axios.post(`review/${review.id}/viewed`);
|
||||
const resp = await axios.post(`reviews/viewed`, { ids: [review.id] });
|
||||
|
||||
if (resp.status == 200) {
|
||||
updateSegments(
|
||||
@ -197,23 +195,30 @@ export default function Events() {
|
||||
);
|
||||
|
||||
updateSummary(
|
||||
(data: ReviewSummary[] | undefined) => {
|
||||
(data: ReviewSummary | undefined) => {
|
||||
if (!data) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const day = new Date(review.start_time * 1000);
|
||||
const key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`;
|
||||
const index = data.findIndex((summary) => summary.day == key);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (index == -1) {
|
||||
let key;
|
||||
if (day.getTime() > today.getTime()) {
|
||||
key = "last24Hours";
|
||||
} else {
|
||||
key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`;
|
||||
}
|
||||
|
||||
if (!Object.keys(data).includes(key)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const item = data[index];
|
||||
return [
|
||||
...data.slice(0, index),
|
||||
{
|
||||
const item = data[key];
|
||||
return {
|
||||
...data,
|
||||
[key]: {
|
||||
...item,
|
||||
reviewed_alert:
|
||||
review.severity == "alert"
|
||||
@ -228,8 +233,7 @@ export default function Events() {
|
||||
? item.reviewed_motion + 1
|
||||
: item.reviewed_motion,
|
||||
},
|
||||
...data.slice(index + 1),
|
||||
];
|
||||
};
|
||||
},
|
||||
{ revalidate: false, populateCache: true },
|
||||
);
|
||||
@ -279,6 +283,11 @@ export default function Events() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// mark item as reviewed since it has been opened
|
||||
if (!selectedReview?.has_been_reviewed) {
|
||||
markItemAsReviewed(selectedReview);
|
||||
}
|
||||
|
||||
return {
|
||||
camera: selectedReview.camera,
|
||||
severity: selectedReview.severity,
|
||||
|
@ -28,7 +28,7 @@ export type ReviewFilter = {
|
||||
showReviewed?: 0 | 1;
|
||||
};
|
||||
|
||||
export type ReviewSummary = {
|
||||
type ReviewSummaryDay = {
|
||||
day: string;
|
||||
reviewed_alert: number;
|
||||
reviewed_detection: number;
|
||||
@ -38,6 +38,10 @@ export type ReviewSummary = {
|
||||
total_motion: number;
|
||||
};
|
||||
|
||||
export type ReviewSummary = {
|
||||
[day: string]: ReviewSummaryDay;
|
||||
};
|
||||
|
||||
export type MotionData = {
|
||||
start_time: number;
|
||||
motion: number;
|
||||
|
@ -35,10 +35,11 @@ import { LuFolderCheck } from "react-icons/lu";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import useSWR from "swr";
|
||||
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type EventViewProps = {
|
||||
reviewPages?: ReviewSegment[][];
|
||||
reviewSummary?: ReviewSummary[];
|
||||
reviewSummary?: ReviewSummary;
|
||||
relevantPreviews?: Preview[];
|
||||
timeRange: { before: number; after: number };
|
||||
reachedEnd: boolean;
|
||||
@ -74,17 +75,17 @@ export default function EventView({
|
||||
// review counts
|
||||
|
||||
const reviewCounts = useMemo(() => {
|
||||
if (!reviewSummary || reviewSummary.length == 0) {
|
||||
if (!reviewSummary) {
|
||||
return { alert: 0, detection: 0, significant_motion: 0 };
|
||||
}
|
||||
|
||||
let summary;
|
||||
if (filter?.before == undefined) {
|
||||
summary = reviewSummary[0];
|
||||
summary = reviewSummary["last24Hours"];
|
||||
} else {
|
||||
const day = new Date(filter.before * 1000);
|
||||
const key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`;
|
||||
summary = reviewSummary.find((check) => check.day == key);
|
||||
summary = reviewSummary[key];
|
||||
}
|
||||
|
||||
if (!summary) {
|
||||
@ -211,9 +212,11 @@ export default function EventView({
|
||||
<ToggleGroup
|
||||
className="*:px-3 *:py-4 *:rounded-2xl"
|
||||
type="single"
|
||||
defaultValue="alert"
|
||||
size="sm"
|
||||
onValueChange={(value: ReviewSeverity) => setSeverity(value)}
|
||||
value={severity}
|
||||
onValueChange={(value: ReviewSeverity) =>
|
||||
value ? setSeverity(value) : null
|
||||
} // don't allow the severity to be unselected
|
||||
>
|
||||
<ToggleGroupItem
|
||||
className={`${severity == "alert" ? "" : "text-gray-500"}`}
|
||||
@ -241,9 +244,7 @@ export default function EventView({
|
||||
aria-label="Select motion"
|
||||
>
|
||||
<MdCircle className="size-2 md:mr-[10px] text-severity_motion" />
|
||||
<div className="hidden md:block">
|
||||
Motion ∙ {reviewCounts.significant_motion}
|
||||
</div>
|
||||
<div className="hidden md:block">Motion</div>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
@ -303,6 +304,7 @@ type DetectionReviewProps = {
|
||||
detection: ReviewSegment[];
|
||||
significant_motion: ReviewSegment[];
|
||||
};
|
||||
itemsToReview?: number;
|
||||
relevantPreviews?: Preview[];
|
||||
pagingObserver: MutableRefObject<IntersectionObserver | null>;
|
||||
selectedReviews: string[];
|
||||
@ -320,6 +322,7 @@ function DetectionReview({
|
||||
contentRef,
|
||||
currentItems,
|
||||
reviewItems,
|
||||
itemsToReview,
|
||||
relevantPreviews,
|
||||
pagingObserver,
|
||||
selectedReviews,
|
||||
@ -359,6 +362,17 @@ function DetectionReview({
|
||||
[isValidating, pagingObserver, reachedEnd, loadNextPage],
|
||||
);
|
||||
|
||||
const markAllReviewed = useCallback(async () => {
|
||||
if (!currentItems) {
|
||||
return;
|
||||
}
|
||||
|
||||
await axios.post(`reviews/viewed`, {
|
||||
ids: currentItems?.map((seg) => seg.id),
|
||||
});
|
||||
pullLatestData();
|
||||
}, [currentItems, pullLatestData]);
|
||||
|
||||
// timeline interaction
|
||||
|
||||
const { alignStartDateToTimeline } = useEventUtils(
|
||||
@ -453,7 +467,7 @@ function DetectionReview({
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isValidating && currentItems == null && (
|
||||
{(itemsToReview == 0 || (currentItems == null && !isValidating)) && (
|
||||
<div className="size-full flex flex-col justify-center items-center">
|
||||
<LuFolderCheck className="size-16" />
|
||||
There are no {severity.replace(/_/g, " ")} items to review
|
||||
@ -489,13 +503,27 @@ function DetectionReview({
|
||||
onClick={onSelectReview}
|
||||
/>
|
||||
</div>
|
||||
{lastRow && !reachedEnd && <ActivityIndicator />}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : severity != "alert" ? (
|
||||
) : itemsToReview != 0 ? (
|
||||
<div ref={lastReviewRef} />
|
||||
) : null}
|
||||
{currentItems && (
|
||||
<div className="col-span-full flex justify-center items-center">
|
||||
{reachedEnd ? (
|
||||
<Button
|
||||
className="text-white"
|
||||
variant="select"
|
||||
onClick={markAllReviewed}
|
||||
>
|
||||
Mark all items as reviewed
|
||||
</Button>
|
||||
) : (
|
||||
<ActivityIndicator />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[55px] md:w-[100px] mt-2 overflow-y-auto no-scrollbar">
|
||||
@ -574,6 +602,7 @@ function MotionReview({
|
||||
before: timeRange.before,
|
||||
after: timeRange.after,
|
||||
scale: segmentDuration / 2,
|
||||
cameras: filter?.cameras?.join(",") ?? null,
|
||||
},
|
||||
]);
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user