Skip to content

Commit c03ddd9

Browse files
authored
feat: Add severity media filter (#2323)
1 parent f4e905a commit c03ddd9

File tree

22 files changed

+404
-69
lines changed

22 files changed

+404
-69
lines changed

src/camera-manager/frigate/camera.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ import { CameraConfig } from '../../config/schema/cameras';
66
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
77
import { HomeAssistant } from '../../ha/types';
88
import { localize } from '../../localize/localize';
9+
import { SEVERITIES } from '../../severity';
910
import {
1011
CapabilitiesRaw,
1112
Endpoint,
1213
PTZCapabilities,
1314
PTZMovementType,
14-
SEVERITIES,
1515
} from '../../types';
1616
import { errorToConsole } from '../../utils/basic';
1717
import { Camera, CameraInitializationOptions } from '../camera';

src/camera-manager/frigate/engine-frigate.ts

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ import { FrigateViewMediaClassifier } from './media-classifier';
6262
import {
6363
NativeFrigateEventQuery,
6464
NativeFrigateRecordingSegmentsQuery,
65-
NativeFrigateReviewQuery,
6665
getEventSummary,
6766
getEvents,
6867
getRecordingSegments,
@@ -86,7 +85,7 @@ const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
8685
const MEDIA_METADATA_REQUEST_CACHE_AGE_SECONDS = 60;
8786
const REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
8887

89-
class FrigateQueryResultsClassifier {
88+
export class FrigateQueryResultsClassifier {
9089
public static isFrigateEventQueryResults(
9190
results: QueryResults,
9291
): results is FrigateEventQueryResults {
@@ -484,7 +483,14 @@ export class FrigateCameraManagerEngine
484483
query: ReviewQuery,
485484
engineOptions?: EngineOptions,
486485
): Promise<ReviewQueryResultsMap | null> {
487-
if (hasUnsupportedFilters(query, { what: true, where: true, reviewed: true })) {
486+
if (
487+
hasUnsupportedFilters(query, {
488+
what: true,
489+
where: true,
490+
reviewed: true,
491+
severity: true,
492+
})
493+
) {
488494
return null;
489495
}
490496

@@ -505,23 +511,36 @@ export class FrigateCameraManagerEngine
505511
return;
506512
}
507513

508-
const nativeQuery: NativeFrigateReviewQuery = {
509-
instance_id: instanceID,
510-
cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(store, cameraIDs)),
511-
...(query.what && { labels: Array.from(query.what) }),
512-
...(query.where && { zones: Array.from(query.where) }),
513-
...(query.end && { before: Math.floor(query.end.getTime() / 1000) }),
514-
...(query.start && { after: Math.floor(query.start.getTime() / 1000) }),
515-
...(query.limit && { limit: query.limit }),
516-
...(query.severity && { severity: FRIGATE_SEVERITY_MAP[query.severity] }),
517-
...(query.reviewed !== undefined && { reviewed: query.reviewed }),
518-
};
514+
const severities = query.severity?.size ? Array.from(query.severity) : [undefined];
515+
516+
// Frigate only supports querying for a single severity, so we generate
517+
// multiple queries and combine the results.
518+
const reviewPromises = severities
519+
// Frigate does not support a 'low' severity.
520+
.filter((severity) => severity !== 'low')
521+
.map((severity) => (!!severity ? FRIGATE_SEVERITY_MAP[severity] : undefined))
522+
.map(
523+
async (frigateSeverity) =>
524+
await getReviews(hass, {
525+
instance_id: instanceID,
526+
cameras: Array.from(
527+
this._getFrigateCameraNamesForCameraIDs(store, cameraIDs),
528+
),
529+
...(query.what && { labels: Array.from(query.what) }),
530+
...(query.where && { zones: Array.from(query.where) }),
531+
...(query.end && { before: Math.floor(query.end.getTime() / 1000) }),
532+
...(query.start && { after: Math.floor(query.start.getTime() / 1000) }),
533+
...(query.limit && { limit: query.limit }),
534+
severity: frigateSeverity,
535+
...(query.reviewed !== undefined && { reviewed: query.reviewed }),
536+
}),
537+
);
519538

520539
const result: FrigateReviewQueryResults = {
521540
type: QueryResultsType.Review,
522541
engine: Engine.Frigate,
523542
instanceID: instanceID,
524-
reviews: await getReviews(hass, nativeQuery),
543+
reviews: (await Promise.all(reviewPromises)).flat(),
525544
expiry: add(new Date(), { seconds: REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS }),
526545
cached: false,
527546
};

src/camera-manager/frigate/media.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { fromUnixTime } from 'date-fns';
22
import { isEqual } from 'lodash-es';
33
import { CameraConfig } from '../../config/schema/cameras';
4-
import { Severity } from '../../types';
4+
import { Severity } from '../../severity';
55
import {
66
EventViewMedia,
77
RecordingViewMedia,

src/camera-manager/frigate/requests.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ export const getPTZInfo = async (
181181
);
182182
};
183183

184-
export interface NativeFrigateReviewQuery {
184+
interface NativeFrigateReviewQuery {
185185
instance_id: string;
186186
cameras?: string[];
187187
labels?: string[];

src/camera-manager/frigate/types.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,6 @@ export interface FrigateRecordingSegmentsQueryResults
146146
export const FRIGATE_SEVERITY_MAP = {
147147
high: 'alert',
148148
medium: 'detection',
149-
low: 'significant_motion',
150149
} as const;
151150

152151
export type FrigateReviewSeverity =
@@ -170,7 +169,7 @@ const frigateReviewDataSchema = z.object({
170169
const frigateReviewSchema = z.object({
171170
id: z.string(),
172171
camera: z.string(),
173-
severity: z.enum(['alert', 'detection', 'significant_motion']),
172+
severity: z.enum(['alert', 'detection']),
174173
start_time: z.number(),
175174
end_time: z.number().nullable(),
176175
thumb_path: z.string().nullable(),

src/camera-manager/frigate/util.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { toZonedTime } from 'date-fns-tz';
22
import { CameraConfig } from '../../config/schema/cameras';
3-
import { Severity } from '../../types';
3+
import { Severity } from '../../severity';
44
import { formatDateAndTime, prettifyTitle } from '../../utils/basic';
55
import {
66
FrigateEvent,

src/camera-manager/types.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ExpiringEqualityCache } from '../cache/expiring-cache';
22
import { SSLCiphers } from '../config/schema/cameras';
33
import { AdvancedCameraCardView } from '../config/schema/common/const';
44
import { BaseQuery, QueryFilters, QuerySource } from '../query-source';
5-
import { CapabilityKey, Endpoint, Icon, Severity } from '../types';
5+
import { CapabilityKey, Endpoint, Icon } from '../types';
66
import { ViewMedia } from '../view/item';
77

88
// ====
@@ -252,8 +252,6 @@ export interface MediaMetadataQueryResults extends QueryResults {
252252

253253
export interface ReviewQuery extends MediaQuery {
254254
type: QueryType.Review;
255-
256-
severity?: Severity;
257255
}
258256
export type PartialReviewQuery = Partial<ReviewQuery>;
259257

src/components-lib/media-filter-controller.ts

Lines changed: 48 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
sub,
1212
} from 'date-fns';
1313
import { LitElement } from 'lit';
14-
import { isEqual, orderBy, uniqWith } from 'lodash-es';
14+
import { isEqual, orderBy } from 'lodash-es';
1515
import { CameraManager, CameraQueryClassifier } from '../camera-manager/manager';
1616
import { DateRange, PartialDateRange } from '../camera-manager/range';
1717
import {
@@ -25,6 +25,7 @@ import { ViewManagerInterface } from '../card-controller/view/types';
2525
import { SelectOption, SelectValues } from '../components/select';
2626
import { CardWideConfig } from '../config/schema/types';
2727
import { localize } from '../localize/localize';
28+
import { SEVERITIES, Severity } from '../severity';
2829
import { ViewMediaType } from '../types';
2930
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
3031
import { UnifiedQueryBuilder } from '../view/unified-query-builder';
@@ -38,6 +39,7 @@ export interface MediaFilterCoreDefaults {
3839
when?: string;
3940
where?: string[];
4041
tags?: string[];
42+
severity?: Severity[];
4143
}
4244

4345
export enum MediaFilterCoreFavoriteSelection {
@@ -80,6 +82,7 @@ export class MediaFilterController {
8082
protected _tagsOptions: SelectOption[] = [];
8183
protected _favoriteOptions: SelectOption[];
8284
protected _reviewedOptions: SelectOption[];
85+
protected _severityOptions: SelectOption[];
8386

8487
protected _defaults: MediaFilterCoreDefaults | null = null;
8588
protected _viewManager: ViewManagerInterface | null = null;
@@ -125,6 +128,10 @@ export class MediaFilterController {
125128
label: localize('media_filter.not_reviewed'),
126129
},
127130
];
131+
this._severityOptions = SEVERITIES.map((severity) => ({
132+
value: severity,
133+
label: localize(`common.severities.${severity}`),
134+
}));
128135
this._staticWhenOptions = [
129136
{
130137
value: MediaFilterCoreWhen.Today,
@@ -174,6 +181,9 @@ export class MediaFilterController {
174181
public getReviewedOptions(): SelectOption[] {
175182
return this._reviewedOptions;
176183
}
184+
public getSeverityOptions(): SelectOption[] {
185+
return this._severityOptions;
186+
}
177187
public getDefaults(): MediaFilterCoreDefaults | null {
178188
return this._defaults;
179189
}
@@ -198,6 +208,7 @@ export class MediaFilterController {
198208
where?: SelectValues;
199209
what?: SelectValues;
200210
tags?: SelectValues;
211+
severity?: SelectValues;
201212
},
202213
// eslint-disable-next-line @typescript-eslint/no-unused-vars
203214
_ev?: unknown,
@@ -221,6 +232,7 @@ export class MediaFilterController {
221232
const where = getArrayValueAsSet(values.where);
222233
const what = getArrayValueAsSet(values.what);
223234
const tags = getArrayValueAsSet(values.tags);
235+
const severity = getArrayValueAsSet<Severity>(values.severity);
224236
const limit = cardWideConfig.performance?.features.media_chunk_size;
225237

226238
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
@@ -236,6 +248,7 @@ export class MediaFilterController {
236248
...(tags && { tags }),
237249
...(what && { what }),
238250
...(where && { where }),
251+
...(severity && { severity }),
239252
},
240253
);
241254

@@ -280,6 +293,7 @@ export class MediaFilterController {
280293
let favorite: MediaFilterCoreFavoriteSelection | undefined;
281294
let reviewed: MediaFilterCoreReviewedSelection | undefined;
282295
let tags: string[] | undefined;
296+
let severity: Severity[] | undefined;
283297

284298
const cameraIDsFromQuery = query.getAllCameraIDs();
285299

@@ -326,44 +340,38 @@ export class MediaFilterController {
326340
mediaTypes.push(MediaFilterMediaType.Reviews);
327341
}
328342

329-
if (eventQueries.length > 0) {
330-
const whatSets = uniqWith(
331-
eventQueries.map((q) => q.what),
332-
isEqual,
333-
);
334-
if (whatSets.length === 1 && eventQueries[0].what?.size) {
335-
what = [...eventQueries[0].what];
336-
}
337-
const whereSets = uniqWith(
338-
eventQueries.map((q) => q.where),
339-
isEqual,
340-
);
341-
if (whereSets.length === 1 && eventQueries[0].where?.size) {
342-
where = [...eventQueries[0].where];
343-
}
344-
const tagsSets = uniqWith(
345-
eventQueries.map((q) => q.tags),
346-
isEqual,
347-
);
348-
if (tagsSets.length === 1 && eventQueries[0].tags?.size) {
349-
tags = [...eventQueries[0].tags];
350-
}
343+
const whatSets = eventQueries.map((q) => q.what);
344+
if (this._hasSingleUniqueValue(whatSets) && whatSets[0]?.size) {
345+
what = [...whatSets[0]];
346+
}
347+
348+
const whereSets = eventQueries.map((q) => q.where);
349+
if (this._hasSingleUniqueValue(whereSets) && whereSets[0]?.size) {
350+
where = [...whereSets[0]];
351+
}
352+
353+
const tagsSets = eventQueries.map((q) => q.tags);
354+
if (this._hasSingleUniqueValue(tagsSets) && tagsSets[0]?.size) {
355+
tags = [...tagsSets[0]];
351356
}
352357

353358
// Extract reviewed from review queries (only if explicitly set to true/false)
354359
const reviewQueries = query.getMediaQueries<ReviewQuery>({ type: QueryType.Review });
355-
if (reviewQueries.length > 0) {
356-
const reviewedValues = new Set(reviewQueries.map((q) => q.reviewed));
357-
if (reviewedValues.size === 1) {
358-
const rev = [...reviewedValues][0];
359-
if (rev !== undefined) {
360-
reviewed = rev
361-
? MediaFilterCoreReviewedSelection.Reviewed
362-
: MediaFilterCoreReviewedSelection.NotReviewed;
363-
}
360+
const reviewedValues = new Set(reviewQueries.map((q) => q.reviewed));
361+
if (reviewedValues.size === 1) {
362+
const rev = [...reviewedValues][0];
363+
if (rev !== undefined) {
364+
reviewed = rev
365+
? MediaFilterCoreReviewedSelection.Reviewed
366+
: MediaFilterCoreReviewedSelection.NotReviewed;
364367
}
365368
}
366369

370+
const severitySets = reviewQueries.map((q) => q.severity);
371+
if (this._hasSingleUniqueValue(severitySets) && severitySets[0]?.size) {
372+
severity = [...severitySets[0]];
373+
}
374+
367375
this._defaults = {
368376
...(mediaTypes.length && { mediaTypes }),
369377
...(cameraIDs && { cameraIDs }),
@@ -372,6 +380,7 @@ export class MediaFilterController {
372380
...(favorite !== undefined && { favorite }),
373381
...(reviewed !== undefined && { reviewed }),
374382
...(tags && { tags }),
383+
...(severity && { severity }),
375384
};
376385
}
377386

@@ -489,4 +498,11 @@ export class MediaFilterController {
489498
return this._stringToDateRange(values.selected);
490499
}
491500
}
501+
502+
protected _hasSingleUniqueValue(sets: (Set<unknown> | undefined)[]): boolean {
503+
if (sets.length === 0) {
504+
return false;
505+
}
506+
return sets.every((s) => isEqual(s, sets[0]));
507+
}
492508
}

src/components/media-filter.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
5959
protected _refWhere: Ref<AdvancedCameraCardSelect> = createRef();
6060
protected _refFavorite: Ref<AdvancedCameraCardSelect> = createRef();
6161
protected _refReviewed: Ref<AdvancedCameraCardSelect> = createRef();
62+
protected _refSeverity: Ref<AdvancedCameraCardSelect> = createRef();
6263
protected _refTags: Ref<AdvancedCameraCardSelect> = createRef();
6364

6465
protected willUpdate(changedProps: PropertyValues): void {
@@ -119,6 +120,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
119120
where: this._refWhere.value?.value ?? undefined,
120121
what: this._refWhat.value?.value ?? undefined,
121122
tags: this._refTags.value?.value ?? undefined,
123+
severity: this._refSeverity.value?.value ?? undefined,
122124
},
123125
);
124126
};
@@ -260,7 +262,22 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
260262
clearable
261263
@advanced-camera-card:select:change=${() => valueChange()}
262264
>
263-
</advanced-camera-card-select>`;
265+
</advanced-camera-card-select>
266+
${this._mediaFilterController.getSeverityOptions().length
267+
? html`
268+
<advanced-camera-card-select
269+
${ref(this._refSeverity)}
270+
label=${localize('common.severity')}
271+
placeholder=${localize('media_filter.select_severity')}
272+
.options=${this._mediaFilterController.getSeverityOptions()}
273+
.initialValue=${defaults?.severity}
274+
clearable
275+
multiple
276+
@advanced-camera-card:select:change=${() => valueChange()}
277+
>
278+
</advanced-camera-card-select>
279+
`
280+
: ''}`;
264281
}
265282

266283
static get styles(): CSSResultGroup {

src/components/overlay-message.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
22
import { customElement, property } from 'lit/decorators.js';
3+
import { classMap } from 'lit/directives/class-map.js';
34
import { createRef, Ref, ref } from 'lit/directives/ref.js';
45
import overlayMessageStyle from '../scss/overlay-message.scss';
56
import { MetadataField, OverlayMessage, OverlayMessageControl } from '../types.js';
@@ -90,8 +91,13 @@ export class AdvancedCameraCardOverlayMessage extends LitElement {
9091
}
9192

9293
protected _renderDetail(detail: MetadataField, isHeading = false): TemplateResult {
94+
const classes = {
95+
detail: true,
96+
heading: isHeading,
97+
[`emphasis-${detail.emphasis}`]: !!detail.emphasis,
98+
};
9399
return html`
94-
<div class="detail ${isHeading ? 'heading' : ''}">
100+
<div class="${classMap(classes)}">
95101
${detail.icon
96102
? html`<advanced-camera-card-icon
97103
title=${detail.hint ?? ''}

0 commit comments

Comments
 (0)