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:
@@ -181,10 +181,11 @@ cameras:
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| 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. |
|
| `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). |
|
| `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`. |
|
| `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). |
|
| `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
|
### Media Types
|
||||||
|
|||||||
@@ -432,6 +432,18 @@ export const getCameraSchema = (options: CameraSchemaOptions): HAFormSchema[] =>
|
|||||||
{ value: 'all', label: localize('config.cameras.media.revieweds.all') },
|
{ 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',
|
name: 'folders',
|
||||||
selector: createSelectSelector(options.folders, { multiple: true }),
|
selector: createSelectSelector(options.folders, { multiple: true }),
|
||||||
|
|||||||
@@ -223,6 +223,10 @@ const cameraMediaConfigSchema = z.object({
|
|||||||
reviewed: z
|
reviewed: z
|
||||||
.enum(CAMERA_MEDIA_REVIEWED_FILTERS)
|
.enum(CAMERA_MEDIA_REVIEWED_FILTERS)
|
||||||
.default(cameraMediaConfigDefault.reviewed),
|
.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
|
export const cameraConfigSchema = z
|
||||||
|
|||||||
@@ -167,6 +167,7 @@
|
|||||||
"reviewed": "Only reviewed",
|
"reviewed": "Only reviewed",
|
||||||
"unreviewed": "Only unreviewed"
|
"unreviewed": "Only unreviewed"
|
||||||
},
|
},
|
||||||
|
"severities": "Severity filter",
|
||||||
"type": "Default type"
|
"type": "Default type"
|
||||||
},
|
},
|
||||||
"motioneye": {
|
"motioneye": {
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import { QuerySource, type QueryFilters } from '../query-source.js';
|
|||||||
import { VIEW_MEDIA_TYPES, type ViewMediaType } from '../types';
|
import { VIEW_MEDIA_TYPES, type ViewMediaType } from '../types';
|
||||||
import { arrayify } from '../utils/basic';
|
import { arrayify } from '../utils/basic';
|
||||||
import { UnifiedQuery, type QueryNode } from '../view/unified-query';
|
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 {
|
interface MediaQueryBuildOptions extends QueryFilters {
|
||||||
start?: Date;
|
start?: Date;
|
||||||
@@ -356,7 +359,7 @@ export class UnifiedQueryBuilder {
|
|||||||
return this._buildQueryNodesCapabilityUnchecked(
|
return this._buildQueryNodesCapabilityUnchecked(
|
||||||
spec,
|
spec,
|
||||||
cameraID,
|
cameraID,
|
||||||
this._resolveReviewedFilter(spec, cameraID, options),
|
this._resolveConfiguredFilters(spec, cameraID, options),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +407,7 @@ export class UnifiedQueryBuilder {
|
|||||||
this._buildQueryNodesCapabilityUnchecked(
|
this._buildQueryNodesCapabilityUnchecked(
|
||||||
cameraSpec,
|
cameraSpec,
|
||||||
cameraID,
|
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(
|
private _resolveReviewedFilter(
|
||||||
spec: MediaTypeSpec,
|
spec: MediaTypeSpec,
|
||||||
cameraID: string,
|
cameraID: string,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CameraMediaReviewedFilter } from '../../config/schema/cameras';
|
import type { CameraMediaReviewedFilter } from '../../config/schema/cameras';
|
||||||
|
import type { Severity } from '../../severity';
|
||||||
import type { ViewItem } from '../item';
|
import type { ViewItem } from '../item';
|
||||||
import { ViewItemClassifier } from '../item-classifier';
|
import { ViewItemClassifier } from '../item-classifier';
|
||||||
import type { UnifiedQuery } from '../unified-query';
|
import type { UnifiedQuery } from '../unified-query';
|
||||||
@@ -44,3 +45,14 @@ export function getReviewedQueryFilterFromConfig(
|
|||||||
): boolean | undefined {
|
): boolean | undefined {
|
||||||
return reviewed === 'reviewed' ? true : reviewed === 'all' ? undefined : false;
|
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');
|
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', () => {
|
it('should build clips query for camera with clips capability', () => {
|
||||||
const { cameraManager, foldersManager, store } = createMocks();
|
const { cameraManager, foldersManager, store } = createMocks();
|
||||||
store.getCameraIDs.mockReturnValue(new Set(['camera.office']));
|
store.getCameraIDs.mockReturnValue(new Set(['camera.office']));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { UnifiedQuery } from '../../../src/view/unified-query';
|
|||||||
import {
|
import {
|
||||||
getReviewedQueryFilterFromConfig,
|
getReviewedQueryFilterFromConfig,
|
||||||
getReviewedQueryFilterFromQuery,
|
getReviewedQueryFilterFromQuery,
|
||||||
|
getSeverityQueryFilterFromConfig,
|
||||||
} from '../../../src/view/utils/query-filter';
|
} from '../../../src/view/utils/query-filter';
|
||||||
import { createFolder } from '../../test-utils';
|
import { createFolder } from '../../test-utils';
|
||||||
import { createEventQuery, TestViewMedia } from '../test-utils';
|
import { createEventQuery, TestViewMedia } from '../test-utils';
|
||||||
@@ -81,4 +82,21 @@ describe('query-filter', () => {
|
|||||||
expect(getReviewedQueryFilterFromConfig('unreviewed')).toBe(false);
|
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