feat: Add severity media filter (#2323)

This commit is contained in:
Dermot Duffy
2026-01-24 21:25:10 -08:00
committed by GitHub
parent f4e905a7fb
commit c03ddd9a13
22 changed files with 404 additions and 69 deletions
+1 -1
View File
@@ -6,12 +6,12 @@ import { CameraConfig } from '../../config/schema/cameras';
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { SEVERITIES } from '../../severity';
import {
CapabilitiesRaw,
Endpoint,
PTZCapabilities,
PTZMovementType,
SEVERITIES,
} from '../../types';
import { errorToConsole } from '../../utils/basic';
import { Camera, CameraInitializationOptions } from '../camera';
+34 -15
View File
@@ -62,7 +62,6 @@ import { FrigateViewMediaClassifier } from './media-classifier';
import {
NativeFrigateEventQuery,
NativeFrigateRecordingSegmentsQuery,
NativeFrigateReviewQuery,
getEventSummary,
getEvents,
getRecordingSegments,
@@ -86,7 +85,7 @@ const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
const MEDIA_METADATA_REQUEST_CACHE_AGE_SECONDS = 60;
const REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
class FrigateQueryResultsClassifier {
export class FrigateQueryResultsClassifier {
public static isFrigateEventQueryResults(
results: QueryResults,
): results is FrigateEventQueryResults {
@@ -484,7 +483,14 @@ export class FrigateCameraManagerEngine
query: ReviewQuery,
engineOptions?: EngineOptions,
): Promise<ReviewQueryResultsMap | null> {
if (hasUnsupportedFilters(query, { what: true, where: true, reviewed: true })) {
if (
hasUnsupportedFilters(query, {
what: true,
where: true,
reviewed: true,
severity: true,
})
) {
return null;
}
@@ -505,23 +511,36 @@ export class FrigateCameraManagerEngine
return;
}
const nativeQuery: NativeFrigateReviewQuery = {
instance_id: instanceID,
cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(store, cameraIDs)),
...(query.what && { labels: Array.from(query.what) }),
...(query.where && { zones: Array.from(query.where) }),
...(query.end && { before: Math.floor(query.end.getTime() / 1000) }),
...(query.start && { after: Math.floor(query.start.getTime() / 1000) }),
...(query.limit && { limit: query.limit }),
...(query.severity && { severity: FRIGATE_SEVERITY_MAP[query.severity] }),
...(query.reviewed !== undefined && { reviewed: query.reviewed }),
};
const severities = query.severity?.size ? Array.from(query.severity) : [undefined];
// Frigate only supports querying for a single severity, so we generate
// multiple queries and combine the results.
const reviewPromises = severities
// Frigate does not support a 'low' severity.
.filter((severity) => severity !== 'low')
.map((severity) => (!!severity ? FRIGATE_SEVERITY_MAP[severity] : undefined))
.map(
async (frigateSeverity) =>
await getReviews(hass, {
instance_id: instanceID,
cameras: Array.from(
this._getFrigateCameraNamesForCameraIDs(store, cameraIDs),
),
...(query.what && { labels: Array.from(query.what) }),
...(query.where && { zones: Array.from(query.where) }),
...(query.end && { before: Math.floor(query.end.getTime() / 1000) }),
...(query.start && { after: Math.floor(query.start.getTime() / 1000) }),
...(query.limit && { limit: query.limit }),
severity: frigateSeverity,
...(query.reviewed !== undefined && { reviewed: query.reviewed }),
}),
);
const result: FrigateReviewQueryResults = {
type: QueryResultsType.Review,
engine: Engine.Frigate,
instanceID: instanceID,
reviews: await getReviews(hass, nativeQuery),
reviews: (await Promise.all(reviewPromises)).flat(),
expiry: add(new Date(), { seconds: REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS }),
cached: false,
};
+1 -1
View File
@@ -1,7 +1,7 @@
import { fromUnixTime } from 'date-fns';
import { isEqual } from 'lodash-es';
import { CameraConfig } from '../../config/schema/cameras';
import { Severity } from '../../types';
import { Severity } from '../../severity';
import {
EventViewMedia,
RecordingViewMedia,
+1 -1
View File
@@ -181,7 +181,7 @@ export const getPTZInfo = async (
);
};
export interface NativeFrigateReviewQuery {
interface NativeFrigateReviewQuery {
instance_id: string;
cameras?: string[];
labels?: string[];
+1 -2
View File
@@ -146,7 +146,6 @@ export interface FrigateRecordingSegmentsQueryResults
export const FRIGATE_SEVERITY_MAP = {
high: 'alert',
medium: 'detection',
low: 'significant_motion',
} as const;
export type FrigateReviewSeverity =
@@ -170,7 +169,7 @@ const frigateReviewDataSchema = z.object({
const frigateReviewSchema = z.object({
id: z.string(),
camera: z.string(),
severity: z.enum(['alert', 'detection', 'significant_motion']),
severity: z.enum(['alert', 'detection']),
start_time: z.number(),
end_time: z.number().nullable(),
thumb_path: z.string().nullable(),
+1 -1
View File
@@ -1,6 +1,6 @@
import { toZonedTime } from 'date-fns-tz';
import { CameraConfig } from '../../config/schema/cameras';
import { Severity } from '../../types';
import { Severity } from '../../severity';
import { formatDateAndTime, prettifyTitle } from '../../utils/basic';
import {
FrigateEvent,
+1 -3
View File
@@ -2,7 +2,7 @@ import { ExpiringEqualityCache } from '../cache/expiring-cache';
import { SSLCiphers } from '../config/schema/cameras';
import { AdvancedCameraCardView } from '../config/schema/common/const';
import { BaseQuery, QueryFilters, QuerySource } from '../query-source';
import { CapabilityKey, Endpoint, Icon, Severity } from '../types';
import { CapabilityKey, Endpoint, Icon } from '../types';
import { ViewMedia } from '../view/item';
// ====
@@ -252,8 +252,6 @@ export interface MediaMetadataQueryResults extends QueryResults {
export interface ReviewQuery extends MediaQuery {
type: QueryType.Review;
severity?: Severity;
}
export type PartialReviewQuery = Partial<ReviewQuery>;
+48 -32
View File
@@ -11,7 +11,7 @@ import {
sub,
} from 'date-fns';
import { LitElement } from 'lit';
import { isEqual, orderBy, uniqWith } from 'lodash-es';
import { isEqual, orderBy } from 'lodash-es';
import { CameraManager, CameraQueryClassifier } from '../camera-manager/manager';
import { DateRange, PartialDateRange } from '../camera-manager/range';
import {
@@ -25,6 +25,7 @@ import { ViewManagerInterface } from '../card-controller/view/types';
import { SelectOption, SelectValues } from '../components/select';
import { CardWideConfig } from '../config/schema/types';
import { localize } from '../localize/localize';
import { SEVERITIES, Severity } from '../severity';
import { ViewMediaType } from '../types';
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
import { UnifiedQueryBuilder } from '../view/unified-query-builder';
@@ -38,6 +39,7 @@ export interface MediaFilterCoreDefaults {
when?: string;
where?: string[];
tags?: string[];
severity?: Severity[];
}
export enum MediaFilterCoreFavoriteSelection {
@@ -80,6 +82,7 @@ export class MediaFilterController {
protected _tagsOptions: SelectOption[] = [];
protected _favoriteOptions: SelectOption[];
protected _reviewedOptions: SelectOption[];
protected _severityOptions: SelectOption[];
protected _defaults: MediaFilterCoreDefaults | null = null;
protected _viewManager: ViewManagerInterface | null = null;
@@ -125,6 +128,10 @@ export class MediaFilterController {
label: localize('media_filter.not_reviewed'),
},
];
this._severityOptions = SEVERITIES.map((severity) => ({
value: severity,
label: localize(`common.severities.${severity}`),
}));
this._staticWhenOptions = [
{
value: MediaFilterCoreWhen.Today,
@@ -174,6 +181,9 @@ export class MediaFilterController {
public getReviewedOptions(): SelectOption[] {
return this._reviewedOptions;
}
public getSeverityOptions(): SelectOption[] {
return this._severityOptions;
}
public getDefaults(): MediaFilterCoreDefaults | null {
return this._defaults;
}
@@ -198,6 +208,7 @@ export class MediaFilterController {
where?: SelectValues;
what?: SelectValues;
tags?: SelectValues;
severity?: SelectValues;
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_ev?: unknown,
@@ -221,6 +232,7 @@ export class MediaFilterController {
const where = getArrayValueAsSet(values.where);
const what = getArrayValueAsSet(values.what);
const tags = getArrayValueAsSet(values.tags);
const severity = getArrayValueAsSet<Severity>(values.severity);
const limit = cardWideConfig.performance?.features.media_chunk_size;
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
@@ -236,6 +248,7 @@ export class MediaFilterController {
...(tags && { tags }),
...(what && { what }),
...(where && { where }),
...(severity && { severity }),
},
);
@@ -280,6 +293,7 @@ export class MediaFilterController {
let favorite: MediaFilterCoreFavoriteSelection | undefined;
let reviewed: MediaFilterCoreReviewedSelection | undefined;
let tags: string[] | undefined;
let severity: Severity[] | undefined;
const cameraIDsFromQuery = query.getAllCameraIDs();
@@ -326,44 +340,38 @@ export class MediaFilterController {
mediaTypes.push(MediaFilterMediaType.Reviews);
}
if (eventQueries.length > 0) {
const whatSets = uniqWith(
eventQueries.map((q) => q.what),
isEqual,
);
if (whatSets.length === 1 && eventQueries[0].what?.size) {
what = [...eventQueries[0].what];
}
const whereSets = uniqWith(
eventQueries.map((q) => q.where),
isEqual,
);
if (whereSets.length === 1 && eventQueries[0].where?.size) {
where = [...eventQueries[0].where];
}
const tagsSets = uniqWith(
eventQueries.map((q) => q.tags),
isEqual,
);
if (tagsSets.length === 1 && eventQueries[0].tags?.size) {
tags = [...eventQueries[0].tags];
}
const whatSets = eventQueries.map((q) => q.what);
if (this._hasSingleUniqueValue(whatSets) && whatSets[0]?.size) {
what = [...whatSets[0]];
}
const whereSets = eventQueries.map((q) => q.where);
if (this._hasSingleUniqueValue(whereSets) && whereSets[0]?.size) {
where = [...whereSets[0]];
}
const tagsSets = eventQueries.map((q) => q.tags);
if (this._hasSingleUniqueValue(tagsSets) && tagsSets[0]?.size) {
tags = [...tagsSets[0]];
}
// Extract reviewed from review queries (only if explicitly set to true/false)
const reviewQueries = query.getMediaQueries<ReviewQuery>({ type: QueryType.Review });
if (reviewQueries.length > 0) {
const reviewedValues = new Set(reviewQueries.map((q) => q.reviewed));
if (reviewedValues.size === 1) {
const rev = [...reviewedValues][0];
if (rev !== undefined) {
reviewed = rev
? MediaFilterCoreReviewedSelection.Reviewed
: MediaFilterCoreReviewedSelection.NotReviewed;
}
const reviewedValues = new Set(reviewQueries.map((q) => q.reviewed));
if (reviewedValues.size === 1) {
const rev = [...reviewedValues][0];
if (rev !== undefined) {
reviewed = rev
? MediaFilterCoreReviewedSelection.Reviewed
: MediaFilterCoreReviewedSelection.NotReviewed;
}
}
const severitySets = reviewQueries.map((q) => q.severity);
if (this._hasSingleUniqueValue(severitySets) && severitySets[0]?.size) {
severity = [...severitySets[0]];
}
this._defaults = {
...(mediaTypes.length && { mediaTypes }),
...(cameraIDs && { cameraIDs }),
@@ -372,6 +380,7 @@ export class MediaFilterController {
...(favorite !== undefined && { favorite }),
...(reviewed !== undefined && { reviewed }),
...(tags && { tags }),
...(severity && { severity }),
};
}
@@ -489,4 +498,11 @@ export class MediaFilterController {
return this._stringToDateRange(values.selected);
}
}
protected _hasSingleUniqueValue(sets: (Set<unknown> | undefined)[]): boolean {
if (sets.length === 0) {
return false;
}
return sets.every((s) => isEqual(s, sets[0]));
}
}
+18 -1
View File
@@ -59,6 +59,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
protected _refWhere: Ref<AdvancedCameraCardSelect> = createRef();
protected _refFavorite: Ref<AdvancedCameraCardSelect> = createRef();
protected _refReviewed: Ref<AdvancedCameraCardSelect> = createRef();
protected _refSeverity: Ref<AdvancedCameraCardSelect> = createRef();
protected _refTags: Ref<AdvancedCameraCardSelect> = createRef();
protected willUpdate(changedProps: PropertyValues): void {
@@ -119,6 +120,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
where: this._refWhere.value?.value ?? undefined,
what: this._refWhat.value?.value ?? undefined,
tags: this._refTags.value?.value ?? undefined,
severity: this._refSeverity.value?.value ?? undefined,
},
);
};
@@ -260,7 +262,22 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
clearable
@advanced-camera-card:select:change=${() => valueChange()}
>
</advanced-camera-card-select>`;
</advanced-camera-card-select>
${this._mediaFilterController.getSeverityOptions().length
? html`
<advanced-camera-card-select
${ref(this._refSeverity)}
label=${localize('common.severity')}
placeholder=${localize('media_filter.select_severity')}
.options=${this._mediaFilterController.getSeverityOptions()}
.initialValue=${defaults?.severity}
clearable
multiple
@advanced-camera-card:select:change=${() => valueChange()}
>
</advanced-camera-card-select>
`
: ''}`;
}
static get styles(): CSSResultGroup {
+7 -1
View File
@@ -1,5 +1,6 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import overlayMessageStyle from '../scss/overlay-message.scss';
import { MetadataField, OverlayMessage, OverlayMessageControl } from '../types.js';
@@ -90,8 +91,13 @@ export class AdvancedCameraCardOverlayMessage extends LitElement {
}
protected _renderDetail(detail: MetadataField, isHeading = false): TemplateResult {
const classes = {
detail: true,
heading: isHeading,
[`emphasis-${detail.emphasis}`]: !!detail.emphasis,
};
return html`
<div class="detail ${isHeading ? 'heading' : ''}">
<div class="${classMap(classes)}">
${detail.icon
? html`<advanced-camera-card-icon
title=${detail.hint ?? ''}
+1 -1
View File
@@ -1,4 +1,4 @@
import { z } from 'zod';
import { SEVERITIES } from '../../../types';
import { SEVERITIES } from '../../../severity';
export const severitySchema = z.enum(SEVERITIES);
+1
View File
@@ -777,6 +777,7 @@
"select_favorite": "Select favorite...",
"select_media_type": "Select media type...",
"select_reviewed": "Select reviewed...",
"select_severity": "Select severity...",
"select_tag": "Select tag...",
"select_what": "Select what...",
"select_when": "Select when...",
+6 -1
View File
@@ -1,3 +1,5 @@
import { Severity } from './severity';
export enum QuerySource {
// Camera queries are handled by CameraManager.
Camera = 'camera',
@@ -20,6 +22,7 @@ export interface QueryFilters {
what?: Set<string>;
where?: Set<string>;
reviewed?: boolean;
severity?: Set<Severity>;
}
/**
@@ -32,6 +35,7 @@ interface SupportedFilters {
what?: boolean;
where?: boolean;
reviewed?: boolean;
severity?: boolean;
}
/**
@@ -47,6 +51,7 @@ export const hasUnsupportedFilters = (
(!supported.tags && !!query.tags?.size) ||
(!supported.what && !!query.what?.size) ||
(!supported.where && !!query.where?.size) ||
(!supported.reviewed && query.reviewed !== undefined)
(!supported.reviewed && query.reviewed !== undefined) ||
(!supported.severity && !!query.severity?.size)
);
};
+13
View File
@@ -260,6 +260,19 @@
opacity: 1;
}
}
&.emphasis-low advanced-camera-card-icon {
color: var(--advanced-camera-card-foreground-severity-low-color);
opacity: 1;
}
&.emphasis-medium advanced-camera-card-icon {
color: var(--advanced-camera-card-foreground-severity-medium-color);
opacity: 1;
}
&.emphasis-high advanced-camera-card-icon {
color: var(--advanced-camera-card-foreground-severity-high-color);
opacity: 1;
}
}
.description {
+2
View File
@@ -0,0 +1,2 @@
export const SEVERITIES = ['high', 'medium', 'low'] as const;
export type Severity = (typeof SEVERITIES)[number];
+1 -3
View File
@@ -1,6 +1,7 @@
import { z } from 'zod';
import type { EffectOptions } from './components-lib/effects/types';
import type { LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from './ha/types';
import { Severity } from './severity';
// UI-facing media types for galleries and views.
export const VIEW_MEDIA_TYPES = ['clips', 'snapshots', 'recordings', 'reviews'] as const;
@@ -221,6 +222,3 @@ export interface EffectsControllerAPI {
stopEffect(effect: EffectName): void;
toggleEffect(effect: EffectName, options?: EffectOptions): Promise<void>;
}
export const SEVERITIES = ['high', 'medium', 'low'] as const;
export type Severity = (typeof SEVERITIES)[number];
+1 -1
View File
@@ -1,7 +1,7 @@
import { clone } from 'lodash-es';
import { FolderPathComponent } from '../card-controller/folders/types';
import { FolderConfig } from '../config/schema/folders';
import { Severity } from '../types';
import { Severity } from '../severity';
export enum ViewMediaType {
Clip = 'clip',
+1
View File
@@ -537,6 +537,7 @@ export class UnifiedQueryBuilder {
...(options?.what && { what: options.what }),
...(options?.where && { where: options.where }),
...(options?.reviewed !== undefined && { reviewed: options.reviewed }),
...(options?.severity && { severity: options.severity }),
};
}