feat: Add support for Frigate reviews / detections [initial PR] (#2315)

- Add support for Frigate reviews / detections.
 - Add support for GenAI metadata.
- Significant internal refactor to more flexible "UnifiedQuery" to allow
mixing cameras with simple metadata and review metadata (e.g. a timeline
view of a Frigate camera with reviews, and a Reolink camera with simple
metadata).
 - Add support for folder media as camera media.

There are a few more PRs to commit prior to this going live, but
commiting this for now due to the scale of the change.

BREAKING CHANGE: `media_type` and `events_type` are retired under
`live`, `viewer` and `timeline` configuration sections, instead media
type is associated (once) with the camera under `media`.
This commit is contained in:
Dermot Duffy
2026-01-19 13:32:50 -08:00
committed by GitHub
parent 6eb0a87ca8
commit fc32727860
215 changed files with 14491 additions and 6160 deletions
+24 -8
View File
@@ -6,13 +6,9 @@ import {
CONF_CAMERAS_GLOBAL_PTZ,
CONF_DIMENSIONS_HEIGHT,
CONF_ELEMENTS,
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
CONF_OVERRIDES,
CONF_PROFILES,
CONF_STATUS_BAR,
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
CONF_VIEW_DEFAULT_CYCLE_CAMERA,
CONF_VIEW_DEFAULT_RESET_ENTITIES,
CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS,
@@ -825,18 +821,19 @@ const UPGRADES = [
upgradeWithOverrides('media_viewer.auto_unmute', (data) =>
data === 'all' ? ['selected', 'visible'] : data === 'never' ? null : arrayify(data),
),
upgradeMoveToWithOverrides(
'live.controls.thumbnails.media',
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
'live.controls.thumbnails.events_media_type',
),
upgradeMoveToWithOverrides('timeline.media', CONF_TIMELINE_EVENTS_MEDIA_TYPE),
upgradeMoveToWithOverrides('timeline.media', 'timeline.events_media_type'),
upgradeMoveToWithOverrides(
'live.controls.timeline.media',
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
'live.controls.timeline.events_media_type',
),
upgradeMoveToWithOverrides(
'media_viewer.controls.timeline.media',
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
'media_viewer.controls.timeline.events_media_type',
),
upgradeMoveToWithOverrides('view.scan', CONF_VIEW_TRIGGERS),
upgradeMoveToWithOverrides(
@@ -944,4 +941,23 @@ const UPGRADES = [
frigateCardToAdvancedCameraCardStyleTransform,
),
upgradeMoveToWithOverrides('menu.buttons.frigate', 'menu.buttons.iris'),
// v8.0.0+
upgradeMoveToWithOverrides(
'live.controls.thumbnails.media_type',
'cameras_global.media.type',
),
upgradeMoveToWithOverrides(
'live.controls.thumbnails.events_media_type',
'cameras_global.media.events_type',
{
transform: (val) => {
// 'all' is the default, delete it
if (val === 'all') {
return null;
}
return val;
},
},
),
];
@@ -7,6 +7,7 @@ const GENERAL_ACTIONS = [
'download',
'expand',
'fullscreen',
'info',
'live_substream_off',
'live_substream_on',
'menu_toggle',
@@ -0,0 +1,14 @@
import { z } from 'zod';
import { advancedCameraCardCustomActionsBaseSchema } from './base';
export const setReviewActionConfigSchema =
advancedCameraCardCustomActionsBaseSchema.extend({
advanced_camera_card_action: z.literal('set_review'),
// Mark a review as reviewed or unreviewed.
// - true = mark reviewed
// - false = mark unreviewed
// - undefined = toggle reviewed status
reviewed: z.boolean().optional(),
});
export type SetReviewActionConfig = z.infer<typeof setReviewActionConfigSchema>;
+4
View File
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { statusBarItemBaseSchema } from '../common/status-bar';
import { severitySchema } from '../common/severity';
import { advancedCameraCardCustomActionsBaseSchema } from './custom/base';
import { cameraSelectActionConfigSchema } from './custom/camera-select';
import { viewDisplayModeActionConfigSchema } from './custom/display-mode';
@@ -12,6 +13,7 @@ import { ptzActionConfigSchema } from './custom/ptz';
import { ptzControlsActionConfigSchema } from './custom/ptz-controls';
import { ptzDigitalActionConfigSchema } from './custom/ptz-digital';
import { ptzMultiActionSchema } from './custom/ptz-multi';
import { setReviewActionConfigSchema } from './custom/set-review';
import { sleepActionConfigSchema } from './custom/sleep';
import { substreamSelectActionConfigSchema } from './custom/substream-select';
import { viewActionConfigSchema } from './custom/view';
@@ -51,6 +53,7 @@ const advancedCameraCardCustomActionSchema = z.union([
ptzControlsActionConfigSchema,
ptzDigitalActionConfigSchema,
ptzMultiActionSchema,
setReviewActionConfigSchema,
sleepActionConfigSchema,
statusBarActionConfigSchema,
substreamSelectActionConfigSchema,
@@ -106,6 +109,7 @@ const statusBarItemElementsBaseSchema = statusBarItemBaseSchema.extend({
sufficient: z.boolean().default(false).optional(),
exclusive: z.boolean().default(false).optional(),
expand: z.boolean().default(false).optional(),
severity: severitySchema.optional(),
actions: actionsBaseSchema.optional(),
});
+39 -1
View File
@@ -3,6 +3,8 @@ import { capabilityKeys } from '../../types';
import { mediaLayoutConfigSchema } from './camera/media-layout';
import { ptzCameraConfigDefaults, ptzCameraConfigSchema } from './camera/ptz';
import { aspectRatioSchema } from './common/aspect-ratio';
import { eventsMediaTypeSchema } from './common/events-media';
import { severitySchema } from './common/severity';
import { imageBaseConfigSchema, imageConfigDefault } from './common/image';
const CAMERA_TRIGGER_EVENT_TYPES = [
@@ -124,8 +126,12 @@ export const cameraConfigDefault = {
triggers: {
motion: false,
occupancy: false,
events: [...CAMERA_TRIGGER_EVENT_TYPES],
events: [],
entities: [],
reviews: {
severities: ['high' as const],
description: true,
},
},
proxy: {
dynamic: true,
@@ -173,6 +179,26 @@ const cameraDimensionsSchema = z.object({
});
export type CameraDimensionsConfig = z.infer<typeof cameraDimensionsSchema>;
// Camera media configuration for default media type in live/timeline views.
const CAMERA_MEDIA_TYPES = [
'auto',
'reviews',
'events',
'recordings',
'folder',
] as const;
export type CameraMediaType = (typeof CAMERA_MEDIA_TYPES)[number];
const cameraMediaConfigDefault = {
type: 'auto' as CameraMediaType,
};
const cameraMediaConfigSchema = z.object({
type: z.enum(CAMERA_MEDIA_TYPES).default(cameraMediaConfigDefault.type),
events_type: eventsMediaTypeSchema.optional(),
folders: z.array(z.string()).optional(),
});
export const cameraConfigSchema = z
.object({
camera_entity: z.string().optional(),
@@ -209,6 +235,16 @@ export const cameraConfigSchema = z
.enum(CAMERA_TRIGGER_EVENT_TYPES)
.array()
.default(cameraConfigDefault.triggers.events),
reviews: z
.object({
severities: severitySchema
.array()
.default([...cameraConfigDefault.triggers.reviews.severities]),
description: z
.boolean()
.default(cameraConfigDefault.triggers.reviews.description),
})
.default(cameraConfigDefault.triggers.reviews),
})
.default(cameraConfigDefault.triggers),
@@ -274,6 +310,8 @@ export const cameraConfigSchema = z
dimensions: cameraDimensionsSchema.optional(),
media: cameraMediaConfigSchema.optional(),
proxy: proxyConfigSchema.default(cameraConfigDefault.proxy),
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1650
+2
View File
@@ -22,6 +22,8 @@ export const VIEWS_USER_SPECIFIED = [
'snapshots',
'recording',
'recordings',
'review',
'reviews',
'image',
'timeline',
] as const;
@@ -9,8 +9,10 @@ const thumbnailControlsBaseDefaults = {
size: THUMBNAIL_WIDTH_DEFAULT,
show_details: true,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
show_timeline_control: false,
show_download_control: false,
show_review_control: true,
show_info_control: true,
};
// Configuration for the actual rendered thumbnail.
@@ -30,6 +32,12 @@ export const thumbnailsControlBaseSchema = z.object({
show_download_control: z
.boolean()
.default(thumbnailControlsBaseDefaults.show_download_control),
show_review_control: z
.boolean()
.default(thumbnailControlsBaseDefaults.show_review_control),
show_info_control: z
.boolean()
.default(thumbnailControlsBaseDefaults.show_info_control),
});
export type ThumbnailsControlBaseConfig = z.infer<typeof thumbnailsControlBaseSchema>;
+1 -14
View File
@@ -1,10 +1,7 @@
import { z } from 'zod';
import { eventsMediaTypeSchema } from '../events-media';
import { thumbnailControlsDefaults } from './thumbnails';
const timelineCoreConfigDefault = {
export const timelineCoreConfigDefault = {
clustering_threshold: 3,
events_media_type: 'all' as const,
window_seconds: 60 * 60,
show_recordings: true,
style: 'stack' as const,
@@ -26,9 +23,6 @@ export const timelineCoreConfigSchema = z.object({
.number()
.optional()
.default(timelineCoreConfigDefault.clustering_threshold),
events_media_type: eventsMediaTypeSchema
.optional()
.default(timelineCoreConfigDefault.events_media_type),
window_seconds: z
.number()
.min(1 * 60)
@@ -58,10 +52,3 @@ export const miniTimelineConfigSchema = timelineCoreConfigSchema.extend({
style: timelineCoreConfigSchema.shape.style.default(miniTimelineConfigDefault.style),
});
export type MiniTimelineControlConfig = z.infer<typeof miniTimelineConfigSchema>;
export const timelineConfigDefault = {
...timelineCoreConfigDefault,
controls: {
thumbnails: thumbnailControlsDefaults,
},
};
+4
View File
@@ -0,0 +1,4 @@
import { z } from 'zod';
import { SEVERITIES } from '../../../types';
export const severitySchema = z.enum(SEVERITIES);
@@ -13,3 +13,4 @@ export const menuBaseSchema = z.object({
icon: z.string().optional(),
permanent: z.boolean().default(false).optional(),
});
export type MenuItemBase = z.infer<typeof menuBaseSchema>;
+2 -18
View File
@@ -11,7 +11,6 @@ import {
miniTimelineConfigSchema,
} from './common/controls/timeline';
import { viewDisplaySchema } from './common/display';
import { eventsMediaTypeSchema } from './common/events-media';
import {
MEDIA_ACTION_NEGATIVE_CONDITIONS,
MEDIA_ACTION_POSITIVE_CONDITIONS,
@@ -41,12 +40,6 @@ const microphoneConfigSchema = z
.default(microphoneConfigDefault);
export type MicrophoneConfig = z.infer<typeof microphoneConfigSchema>;
const liveThumbnailControlsDefaults = {
...thumbnailControlsDefaults,
media_type: 'events' as const,
events_media_type: 'all' as const,
};
export const liveConfigDefault = {
auto_play: [...MEDIA_ACTION_POSITIVE_CONDITIONS],
auto_pause: [],
@@ -67,7 +60,7 @@ export const liveConfigDefault = {
style: 'chevrons' as const,
},
ptz: ptzControlsDefaults,
thumbnails: liveThumbnailControlsDefaults,
thumbnails: thumbnailControlsDefaults,
timeline: miniTimelineConfigDefault,
wheel: true,
},
@@ -76,15 +69,6 @@ export const liveConfigDefault = {
},
};
const livethumbnailsControlSchema = thumbnailsControlSchema.extend({
media_type: z
.enum(['events', 'recordings'])
.default(liveConfigDefault.controls.thumbnails.media_type),
events_media_type: eventsMediaTypeSchema.default(
liveConfigDefault.controls.thumbnails.events_media_type,
),
});
export const liveConfigSchema = z
.object({
auto_pause: z
@@ -118,7 +102,7 @@ export const liveConfigSchema = z
})
.default(liveConfigDefault.controls.next_previous),
ptz: ptzControlsConfigSchema.default(liveConfigDefault.controls.ptz),
thumbnails: livethumbnailsControlSchema.default(
thumbnails: thumbnailsControlSchema.default(
liveConfigDefault.controls.thumbnails,
),
timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline),
+10 -4
View File
@@ -29,7 +29,7 @@ export const menuConfigDefault = {
buttons: {
camera_ui: visibleButtonDefault,
cameras: visibleButtonDefault,
clips: visibleButtonDefault,
clips: hiddenButtonDefault,
ptz_home: hiddenButtonDefault,
display_mode: visibleButtonDefault,
download: visibleButtonDefault,
@@ -38,6 +38,7 @@ export const menuConfigDefault = {
iris: visibleButtonDefault,
fullscreen: visibleButtonDefault,
image: hiddenButtonDefault,
info: visibleButtonDefault,
live: visibleButtonDefault,
media_player: visibleButtonDefault,
microphone: {
@@ -48,8 +49,10 @@ export const menuConfigDefault = {
play: hiddenButtonDefault,
ptz_controls: hiddenButtonDefault,
recordings: hiddenButtonDefault,
reviews: visibleButtonDefault,
set_review: visibleButtonDefault,
screenshot: hiddenButtonDefault,
snapshots: visibleButtonDefault,
snapshots: hiddenButtonDefault,
substreams: visibleButtonDefault,
timeline: visibleButtonDefault,
},
@@ -76,7 +79,7 @@ export const menuConfigSchema = z
.object({
camera_ui: visibleButtonSchema.default(menuConfigDefault.buttons.camera_ui),
cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras),
clips: visibleButtonSchema.default(menuConfigDefault.buttons.clips),
clips: hiddenButtonSchema.default(menuConfigDefault.buttons.clips),
ptz_home: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_home),
display_mode: visibleButtonSchema.default(
menuConfigDefault.buttons.display_mode,
@@ -87,6 +90,7 @@ export const menuConfigSchema = z
iris: visibleButtonSchema.default(menuConfigDefault.buttons.iris),
fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen),
image: hiddenButtonSchema.default(menuConfigDefault.buttons.image),
info: visibleButtonSchema.default(menuConfigDefault.buttons.info),
live: visibleButtonSchema.default(menuConfigDefault.buttons.live),
media_player: visibleButtonSchema.default(
menuConfigDefault.buttons.media_player,
@@ -102,8 +106,10 @@ export const menuConfigSchema = z
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
ptz_controls: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_controls),
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
reviews: visibleButtonSchema.default(menuConfigDefault.buttons.reviews),
set_review: visibleButtonSchema.default(menuConfigDefault.buttons.set_review),
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
snapshots: visibleButtonSchema.default(menuConfigDefault.buttons.snapshots),
snapshots: hiddenButtonSchema.default(menuConfigDefault.buttons.snapshots),
substreams: visibleButtonSchema.default(menuConfigDefault.buttons.substreams),
timeline: visibleButtonSchema.default(menuConfigDefault.buttons.timeline),
})
+2
View File
@@ -23,6 +23,7 @@ export const statusBarConfigDefault = {
items: {
engine: statusBarItemDefault,
resolution: statusBarItemDefault,
severity: statusBarItemDefault,
technology: statusBarItemDefault,
title: statusBarItemDefault,
},
@@ -50,6 +51,7 @@ export const statusBarConfigSchema = z
resolution: statusBarItemBaseSchema.default(
statusBarConfigDefault.items.resolution,
),
severity: statusBarItemBaseSchema.default(statusBarConfigDefault.items.severity),
title: statusBarItemBaseSchema.default(statusBarConfigDefault.items.title),
})
.default(statusBarConfigDefault.items),
+13 -5
View File
@@ -1,17 +1,25 @@
import { z } from 'zod';
import { thumbnailsControlSchema } from './common/controls/thumbnails';
import {
timelineConfigDefault,
thumbnailControlsDefaults,
thumbnailsControlSchema,
} from './common/controls/thumbnails';
import {
timelineCoreConfigDefault,
timelineCoreConfigSchema,
} from './common/controls/timeline';
export const timelineConfigDefault = {
...timelineCoreConfigDefault,
controls: {
thumbnails: thumbnailControlsDefaults,
},
};
export const timelineConfigSchema = timelineCoreConfigSchema
.extend({
controls: z
.object({
thumbnails: thumbnailsControlSchema.default(
timelineConfigDefault.controls.thumbnails,
),
thumbnails: thumbnailsControlSchema.default(thumbnailControlsDefaults),
})
.default(timelineConfigDefault.controls),
})
+1 -2
View File
@@ -3,7 +3,6 @@ import { deepRemoveDefaults } from '../../utils/zod';
import { automationsSchema } from './automations';
import { cameraConfigDefault, cameraConfigSchema, camerasConfigSchema } from './cameras';
import { cardIDRegex } from './common/const';
import { timelineConfigDefault } from './common/controls/timeline';
import { imageConfigDefault } from './common/image';
import { DebugConfig, debugConfigDefault, debugConfigSchema } from './debug';
import { dimensionsConfigSchema } from './dimensions';
@@ -22,7 +21,7 @@ import {
import { profilesSchema } from './profiles';
import { remoteControlConfigDefault, remoteControlConfigSchema } from './remote-control';
import { statusBarConfigDefault, statusBarConfigSchema } from './status-bar';
import { timelineConfigSchema } from './timeline';
import { timelineConfigDefault, timelineConfigSchema } from './timeline';
import { viewConfigDefault, viewConfigSchema } from './view';
import { viewerConfigDefault, viewerConfigSchema } from './viewer';