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
+117 -13
View File
@@ -5,7 +5,11 @@ import { FullscreenManager } from '../card-controller/fullscreen/fullscreen-mana
import { MediaPlayerManager } from '../card-controller/media-player-manager';
import { MicrophoneManager } from '../card-controller/microphone-manager';
import { ViewManager } from '../card-controller/view/view-manager';
import { VIEWS_USER_SPECIFIED } from '../config/schema/common/const';
import {
AdvancedCameraCardView,
VIEWS_USER_SPECIFIED,
} from '../config/schema/common/const';
import { MenuItemBase } from '../config/schema/elements/custom/menu/base';
import { MenuItem } from '../config/schema/elements/custom/menu/types';
import { AdvancedCameraCardConfig } from '../config/schema/types';
import { getEntityTitle } from '../ha/get-entity-title';
@@ -19,6 +23,7 @@ import {
createMediaPlayerAction,
createPTZControlsAction,
createPTZMultiAction,
createSetReviewAction,
createViewAction,
isAdvancedCameraCardCustomAction,
} from '../utils/action';
@@ -27,7 +32,6 @@ import { isBeingCasted } from '../utils/casting';
import { getPTZTarget } from '../utils/ptz';
import { getStreamCameraID, hasSubstream } from '../utils/substream';
import { ViewItemClassifier } from '../view/item-classifier';
import { QueryClassifier } from '../view/query-classifier';
import { View } from '../view/view';
import { getCameraIDsForViewName, isViewSupportedByCamera } from '../view/view-support';
@@ -77,9 +81,12 @@ export class MenuButtonController {
this._getClipsButton(config, cameraManager, foldersManager, options?.view),
this._getSnapshotsButton(config, cameraManager, foldersManager, options?.view),
this._getRecordingsButton(config, cameraManager, foldersManager, options?.view),
this._getReviewsButton(config, cameraManager, foldersManager, options?.view),
this._getImageButton(config, cameraManager, foldersManager, options?.view),
this._getTimelineButton(config, cameraManager, foldersManager, options?.view),
this._getDownloadButton(config, cameraManager, options?.view),
this._getInfoButton(config, cameraManager, options?.view),
this._getSetReviewButton(config, options?.view),
this._getCameraUIButton(config, options?.showCameraUIButton),
this._getMicrophoneButton(
config,
@@ -245,17 +252,45 @@ export class MenuButtonController {
: null;
}
/**
* Determine if a media button (clips/snapshots) should be shown. These are
* hidden by default if reviews are supported.
*/
protected _shouldShowEventMediaButton(
viewName: 'clips' | 'snapshots',
buttonConfig: MenuItemBase,
cameraManager: CameraManager,
foldersManager: FoldersManager,
view?: View | null,
): boolean {
const supportsEventView =
view &&
isViewSupportedByCamera(viewName, cameraManager, foldersManager, view.camera);
const supportsReviewsView =
view &&
isViewSupportedByCamera('reviews', cameraManager, foldersManager, view.camera);
// Show if: explicitly enabled OR (supports view AND reviews not supported)
return !!supportsEventView && (buttonConfig.enabled || !supportsReviewsView);
}
protected _getClipsButton(
config: AdvancedCameraCardConfig,
cameraManager: CameraManager,
foldersManager: FoldersManager,
view?: View | null,
): MenuItem | null {
return view &&
isViewSupportedByCamera('clips', cameraManager, foldersManager, view.camera)
return this._shouldShowEventMediaButton(
'clips',
config.menu.buttons.clips,
cameraManager,
foldersManager,
view,
)
? {
icon: 'mdi:filmstrip',
...config.menu.buttons.clips,
enabled: true,
type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.clips'),
style: view?.is('clips') ? this._getEmphasizedStyle() : {},
@@ -271,11 +306,17 @@ export class MenuButtonController {
foldersManager: FoldersManager,
view?: View | null,
): MenuItem | null {
return view &&
isViewSupportedByCamera('snapshots', cameraManager, foldersManager, view.camera)
return this._shouldShowEventMediaButton(
'snapshots',
config.menu.buttons.snapshots,
cameraManager,
foldersManager,
view,
)
? {
icon: 'mdi:camera',
...config.menu.buttons.snapshots,
enabled: true,
type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.snapshots'),
style: view?.is('snapshots') ? this._getEmphasizedStyle() : {},
@@ -305,6 +346,26 @@ export class MenuButtonController {
: null;
}
protected _getReviewsButton(
config: AdvancedCameraCardConfig,
cameraManager: CameraManager,
foldersManager: FoldersManager,
view?: View | null,
): MenuItem | null {
return view &&
isViewSupportedByCamera('reviews', cameraManager, foldersManager, view.camera)
? {
icon: 'mdi:play-box-multiple',
...config.menu.buttons.reviews,
type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.reviews'),
style: view.is('reviews') ? this._getEmphasizedStyle() : {},
tap_action: createViewAction('reviews'),
hold_action: createViewAction('review'),
}
: null;
}
protected _getImageButton(
config: AdvancedCameraCardConfig,
cameraManager: CameraManager,
@@ -365,6 +426,49 @@ export class MenuButtonController {
return null;
}
protected _getInfoButton(
config: AdvancedCameraCardConfig,
_cameraManager: CameraManager,
view?: View | null,
): MenuItem | null {
const selectedItem = view?.queryResults?.getSelectedResult();
if (!ViewItemClassifier.isMedia(selectedItem)) {
return null;
}
return {
icon: 'mdi:information-outline',
...config.menu.buttons.info,
type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.menu.buttons.info'),
tap_action: createGeneralAction('info'),
};
}
protected _getSetReviewButton(
config: AdvancedCameraCardConfig,
view?: View | null,
): MenuItem | null {
const selectedItem = view?.queryResults?.getSelectedResult();
if (!view?.isViewerView() || !ViewItemClassifier.isReview(selectedItem)) {
return null;
}
const isReviewed = selectedItem.isReviewed();
if (isReviewed === null) {
return null;
}
return {
icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline',
...config.menu.buttons.set_review,
type: 'custom:advanced-camera-card-menu-icon',
title: isReviewed
? localize('common.set_reviews.unreviewed')
: localize('common.set_reviews.reviewed'),
tap_action: createSetReviewAction(),
style: isReviewed ? this._getEmphasizedStyle() : {},
};
}
protected _getCameraUIButton(
config: AdvancedCameraCardConfig,
showCameraUIButton?: boolean,
@@ -667,9 +771,8 @@ export class MenuButtonController {
}
if (folders.length === 1) {
const isSelected =
QueryClassifier.isFolderQuery(view?.query) &&
view.query.getQuery()?.folder.id === folders[0][0];
const folderID = folders[0][0];
const isSelected = !!view?.query?.getFolderQueries(folderID).length;
const folder = folders[0][1];
return {
@@ -684,9 +787,7 @@ export class MenuButtonController {
}
const submenuItems = folders.map(([id, folder]) => {
const isSelected =
QueryClassifier.isFolderQuery(view?.query) &&
view.query.getQuery()?.folder.id === id;
const isSelected = !!view?.query?.getFolderQueries(id).length;
return {
enabled: true,
@@ -736,6 +837,7 @@ export class MenuButtonController {
button: MenuItem,
options?: MenuButtonControllerOptions,
): StyleInfo {
// Review
for (const actionSet of [
button.tap_action,
button.double_tap_action,
@@ -752,7 +854,9 @@ export class MenuButtonController {
VIEWS_USER_SPECIFIED.some(
(viewName) =>
viewName === action.advanced_camera_card_action &&
options?.view?.is(action.advanced_camera_card_action),
options?.view?.is(
action.advanced_camera_card_action as AdvancedCameraCardView,
),
) ||
(action.advanced_camera_card_action === 'default' &&
options?.view?.is(config.view.default)) ||