diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index a4166b9c..7fed3abc 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -180,12 +180,13 @@ cameras: # [...] ``` -| Option | Default | Description | -| ------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `type` | `auto` | The default media type to show for this camera. One of `auto`, `events`, `recordings`, `reviews` or `folder`. See [Media Types](#media-types) for description of each. | -| `events_type` | `all` | If `type` is `events`, what subtype of events to show. One of `clips`, `snapshots` or `all` (default). | -| `reviewed` | `unreviewed` | Whether to filter the media based on review status. One of `unreviewed` (default, shows only unreviewed media), `reviewed` (shorts only reviewed media) or `all` (show regardless of whether reviewed or unreviewed). Only relevant when `type` is `reviews` or `auto`. | -| `folders` | | An optional list of folder IDs to use when `type` is `folder`. If not specified, and `type` is `folder`, will default to showing the default (first) configured folder. See [Folder Configuration](../folders.md) and the [worked example](../../examples.md?id=show-a-folder-as-a-cameras-default-media). | +| Option | Default | Description | +| ------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | `auto` | The default media type to show for this camera. One of `auto`, `events`, `recordings`, `reviews` or `folder`. See [Media Types](#media-types) for description of each. | +| `events_type` | `all` | If `type` is `events`, what subtype of events to show. One of `clips`, `snapshots` or `all` (default). | +| `reviewed` | `unreviewed` | Whether to filter the media based on review status. One of `unreviewed` (default, shows only unreviewed media), `reviewed` (shorts only reviewed media) or `all` (show regardless of whether reviewed or unreviewed). Only relevant when `type` is `reviews` or `auto`. | +| `severities` | | An optional list of severities to restrict review media to. Any of `high` (Frigate alerts), `medium` (Frigate detections) or `low`. If unset, media of every severity is shown. Only relevant when `type` is `reviews` or `auto`. Use `[high]` to keep the thumbnail bar, gallery and timeline to Frigate alerts only, so that the labels a camera is not configured to alert on do not crowd out the ones it is. | +| `folders` | | An optional list of folder IDs to use when `type` is `folder`. If not specified, and `type` is `folder`, will default to showing the default (first) configured folder. See [Folder Configuration](../folders.md) and the [worked example](../../examples.md?id=show-a-folder-as-a-cameras-default-media). | ### Media Types diff --git a/src/components-lib/editor/schema/cameras.ts b/src/components-lib/editor/schema/cameras.ts index a60515ed..6946361b 100644 --- a/src/components-lib/editor/schema/cameras.ts +++ b/src/components-lib/editor/schema/cameras.ts @@ -432,6 +432,18 @@ export const getCameraSchema = (options: CameraSchemaOptions): HAFormSchema[] => { value: 'all', label: localize('config.cameras.media.revieweds.all') }, ]), }, + { + name: 'severities', + label: localize('config.cameras.media.severities'), + selector: createSelectSelector( + [ + { value: 'high', label: localize('common.severities.high') }, + { value: 'medium', label: localize('common.severities.medium') }, + { value: 'low', label: localize('common.severities.low') }, + ], + { multiple: true }, + ), + }, { name: 'folders', selector: createSelectSelector(options.folders, { multiple: true }), diff --git a/src/config/schema/cameras.ts b/src/config/schema/cameras.ts index 2b18a739..c6c30963 100644 --- a/src/config/schema/cameras.ts +++ b/src/config/schema/cameras.ts @@ -223,6 +223,10 @@ const cameraMediaConfigSchema = z.object({ reviewed: z .enum(CAMERA_MEDIA_REVIEWED_FILTERS) .default(cameraMediaConfigDefault.reviewed), + + // Restrict review media to these severities (e.g. `[high]` for Frigate + // alerts only). Unset means all severities. + severities: severitySchema.array().min(1).optional(), }); export const cameraConfigSchema = z diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 11f24cff..a9d94950 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -167,6 +167,7 @@ "reviewed": "Only reviewed", "unreviewed": "Only unreviewed" }, + "severities": "Severity filter", "type": "Default type" }, "motioneye": { diff --git a/src/view/unified-query-builder.ts b/src/view/unified-query-builder.ts index 361c3b0e..cf26acc5 100644 --- a/src/view/unified-query-builder.ts +++ b/src/view/unified-query-builder.ts @@ -16,7 +16,10 @@ import { QuerySource, type QueryFilters } from '../query-source.js'; import { VIEW_MEDIA_TYPES, type ViewMediaType } from '../types'; import { arrayify } from '../utils/basic'; import { UnifiedQuery, type QueryNode } from '../view/unified-query'; -import { getReviewedQueryFilterFromConfig } from './utils/query-filter'; +import { + getReviewedQueryFilterFromConfig, + getSeverityQueryFilterFromConfig, +} from './utils/query-filter'; interface MediaQueryBuildOptions extends QueryFilters { start?: Date; @@ -356,7 +359,7 @@ export class UnifiedQueryBuilder { return this._buildQueryNodesCapabilityUnchecked( spec, cameraID, - this._resolveReviewedFilter(spec, cameraID, options), + this._resolveConfiguredFilters(spec, cameraID, options), ); } @@ -404,7 +407,7 @@ export class UnifiedQueryBuilder { this._buildQueryNodesCapabilityUnchecked( cameraSpec, cameraID, - this._resolveReviewedFilter(cameraSpec, cameraID, options), + this._resolveConfiguredFilters(cameraSpec, cameraID, options), ), ); } @@ -522,6 +525,32 @@ export class UnifiedQueryBuilder { }; } + private _resolveConfiguredFilters( + spec: MediaTypeSpec, + cameraID: string, + options?: QueryFiltersOptions, + ): QueryFiltersOptions | undefined { + return this._resolveSeverityFilter( + spec, + cameraID, + this._resolveReviewedFilter(spec, cameraID, options), + ); + } + + private _resolveSeverityFilter( + spec: MediaTypeSpec, + cameraID: string, + options?: QueryFiltersOptions, + ): QueryFiltersOptions | undefined { + if (spec.mediaType !== 'reviews' || options?.severity !== undefined) { + return options; + } + const severity = getSeverityQueryFilterFromConfig( + this._cameraManager.getStore().getCameraConfig(cameraID)?.media?.severities, + ); + return severity ? { ...options, severity } : options; + } + private _resolveReviewedFilter( spec: MediaTypeSpec, cameraID: string, diff --git a/src/view/utils/query-filter.ts b/src/view/utils/query-filter.ts index bcad8b94..e5332a43 100644 --- a/src/view/utils/query-filter.ts +++ b/src/view/utils/query-filter.ts @@ -1,4 +1,5 @@ import type { CameraMediaReviewedFilter } from '../../config/schema/cameras'; +import type { Severity } from '../../severity'; import type { ViewItem } from '../item'; import { ViewItemClassifier } from '../item-classifier'; import type { UnifiedQuery } from '../unified-query'; @@ -44,3 +45,14 @@ export function getReviewedQueryFilterFromConfig( ): boolean | undefined { return reviewed === 'reviewed' ? true : reviewed === 'all' ? undefined : false; } + +/** + * Convert a severities config value to a query filter. + * @param severities The config value (an array of severities, or undefined) + * @returns A set of severities to filter on, or undefined for all severities. + */ +export function getSeverityQueryFilterFromConfig( + severities?: Severity[], +): Set | undefined { + return severities?.length ? new Set(severities) : undefined; +} diff --git a/tests/view/unified-query-builder.test.ts b/tests/view/unified-query-builder.test.ts index f7a320cf..36699429 100644 --- a/tests/view/unified-query-builder.test.ts +++ b/tests/view/unified-query-builder.test.ts @@ -700,6 +700,91 @@ describe('UnifiedQueryBuilder', () => { expect(query.getNodes()[0]).not.toHaveProperty('reviewed'); }); + it('should apply severities filter from config for reviews query', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'auto', severities: ['high'] } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue( + createCapabilities({ reviews: true }), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Review, + severity: new Set(['high']), + }); + }); + + it('should not apply severities filter when unset in config', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'auto' } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue( + createCapabilities({ reviews: true }), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + assert(query); + assert(isReviewQuery(query.getNodes()[0])); + expect(query.getNodes()[0]).not.toHaveProperty('severity'); + }); + + it('should not apply severities filter to non-review queries', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'auto', severities: ['high'] } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue( + createCapabilities({ clips: true }), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Event, + hasClip: true, + }); + expect(query.getNodes()[0]).not.toHaveProperty('severity'); + }); + + it('should prefer an explicit severity option over the config', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'auto', severities: ['high'] } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue( + createCapabilities({ reviews: true }), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(undefined, { + severity: new Set(['medium'] as const), + }); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Review, + severity: new Set(['medium']), + }); + }); + it('should build clips query for camera with clips capability', () => { const { cameraManager, foldersManager, store } = createMocks(); store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); diff --git a/tests/view/utils/query-filter.test.ts b/tests/view/utils/query-filter.test.ts index 1a1c6bec..d553196a 100644 --- a/tests/view/utils/query-filter.test.ts +++ b/tests/view/utils/query-filter.test.ts @@ -6,6 +6,7 @@ import type { UnifiedQuery } from '../../../src/view/unified-query'; import { getReviewedQueryFilterFromConfig, getReviewedQueryFilterFromQuery, + getSeverityQueryFilterFromConfig, } from '../../../src/view/utils/query-filter'; import { createFolder } from '../../test-utils'; import { createEventQuery, TestViewMedia } from '../test-utils'; @@ -81,4 +82,21 @@ describe('query-filter', () => { expect(getReviewedQueryFilterFromConfig('unreviewed')).toBe(false); }); }); + + describe('getSeverityQueryFilterFromConfig', () => { + it('should return undefined when unset', () => { + expect(getSeverityQueryFilterFromConfig(undefined)).toBeUndefined(); + }); + + it('should return undefined when empty', () => { + expect(getSeverityQueryFilterFromConfig([])).toBeUndefined(); + }); + + it('should return a set of the configured severities', () => { + expect(getSeverityQueryFilterFromConfig(['high'])).toEqual(new Set(['high'])); + expect(getSeverityQueryFilterFromConfig(['high', 'medium'])).toEqual( + new Set(['high', 'medium']), + ); + }); + }); });