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:
flan
2026-09-04 17:56:20 +00:00
parent 54e261fd54
commit 88e1fba401
8 changed files with 171 additions and 9 deletions
+7 -6
View File
@@ -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 }),
+4
View File
@@ -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
+1
View File
@@ -167,6 +167,7 @@
"reviewed": "Only reviewed",
"unreviewed": "Only unreviewed"
},
"severities": "Severity filter",
"type": "Default type"
},
"motioneye": {
+32 -3
View File
@@ -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,
+12
View File
@@ -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;
}
+85
View File
@@ -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']));
+18
View File
@@ -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']),
);
});
});
});