feat: Add cameras[].media.severities to filter review media by severity
Review queries were issued without a severity filter, so the thumbnail
bar, gallery and timeline showed every Frigate review item -- alerts and
detections alike -- regardless of what a camera is configured to alert
on. Severity could only be narrowed interactively, in the media filter
drawer, and that choice did not persist.
`media.severities` resolves into the default query the same way
`media.reviewed` does, and is overridden by an explicit severity filter
so the media filter drawer keeps working. Unset means all severities, so
existing behaviour is unchanged.
cameras:
- camera_entity: camera.office
media:
severities:
- high
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
"reviewed": "Only reviewed",
|
||||
"unreviewed": "Only unreviewed"
|
||||
},
|
||||
"severities": "Severity filter",
|
||||
"type": "Default type"
|
||||
},
|
||||
"motioneye": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Severity> | undefined {
|
||||
return severities?.length ? new Set(severities) : undefined;
|
||||
}
|
||||
|
||||
@@ -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']));
|
||||
|
||||
@@ -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']),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user