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
@@ -53,7 +53,7 @@ export class ActionsManager implements ActionsExecutor {
let specificActions: Actions | undefined = undefined;
if (view?.is('live')) {
specificActions = config?.live.actions;
} else if (view?.isMediaGalleryView()) {
} else if (view?.isGalleryView()) {
specificActions = config?.media_gallery?.actions;
} else if (view?.isViewerView()) {
specificActions = config?.media_viewer.actions;
+12 -12
View File
@@ -21,26 +21,26 @@ export class BaseAction<T extends ActionConfig> implements Action {
protected _shouldSeekConfirmation(api: CardActionsAPI): boolean {
const hass = api.getHASSManager().getHASS();
const action: ActionConfig = this._action;
return (
(typeof this._action.confirmation === 'boolean' && this._action.confirmation) ||
(typeof this._action.confirmation === 'object' &&
(!this._action.confirmation.exemptions ||
!this._action.confirmation.exemptions.some(
(entry) => entry.user === hass?.user.id,
)))
(typeof action.confirmation === 'boolean' && action.confirmation) ||
(typeof action.confirmation === 'object' &&
(!action.confirmation.exemptions ||
!action.confirmation.exemptions.some((entry) => entry.user === hass?.user.id)))
);
}
public async execute(api: CardActionsAPI): Promise<void> {
if (this._shouldSeekConfirmation(api)) {
const actionName = isAdvancedCameraCardCustomAction(this._action)
? this._action.advanced_camera_card_action
: this._action.action;
const action: ActionConfig = this._action;
const baseAction = action.action;
const actionName = isAdvancedCameraCardCustomAction(action)
? action.advanced_camera_card_action
: baseAction;
const text =
(typeof this._action.confirmation === 'object'
? this._action.confirmation.text
: null) ?? `${localize('actions.confirmation')}: ${actionName}`;
(typeof action.confirmation === 'object' ? action.confirmation.text : null) ??
`${localize('actions.confirmation')}: ${actionName}`;
if (!confirm(text)) {
throw new ActionAbortError(localize('actions.abort'));
}
@@ -0,0 +1,28 @@
import { MediaDetailsController } from '../../../components-lib/media/details-controller';
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
import { ViewItemClassifier } from '../../../view/item-classifier';
import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
export class InfoAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const item = api.getViewManager().getView()?.queryResults?.getSelectedResult();
if (!ViewItemClassifier.isMedia(item)) {
return;
}
const controller = new MediaDetailsController();
controller.calculate(api.getCameraManager(), item);
api.getOverlayMessageManager().setMessage(
controller.getMessage({
hass: api.getHASSManager().getHASS() ?? undefined,
viewItemManager: api.getViewItemManager(),
viewManagerEpoch: api.getViewManager().getEpoch(),
capabilities: api.getViewItemManager().getCapabilities(item),
}),
);
}
}
@@ -0,0 +1,34 @@
import { SetReviewActionConfig } from '../../../config/schema/actions/custom/set-review';
import { ViewItemClassifier } from '../../../view/item-classifier';
import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
export class SetReviewAction extends AdvancedCameraCardAction<SetReviewActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const view = api.getViewManager().getView();
const queryResults = view?.queryResults;
const item = queryResults?.getSelectedResult();
if (!ViewItemClassifier.isReview(item) || !queryResults) {
return;
}
const targetReviewedState = this._action.reviewed ?? !item.isReviewed();
await api.getViewItemManager().reviewMedia(item, targetReviewedState);
// Clone the item to ensure Lit detects the change.
// Test-case: Setting a media item reviewed via the menu, should update the
// reviewed state in a thumbnail.
const clonedItem = item.clone();
clonedItem.setReviewed(targetReviewedState);
api.getViewManager().setViewByParameters({
params: {
queryResults: queryResults.clone().replaceItem(item, clonedItem),
},
});
}
}
+9 -1
View File
@@ -9,9 +9,10 @@ import { CustomAction } from './actions/custom';
import { DefaultAction } from './actions/default';
import { DisplayModeSelectAction } from './actions/display-mode-select';
import { DownloadAction } from './actions/download';
import { ExpandAction } from './actions/expand';
import { EffectAction } from './actions/effect';
import { ExpandAction } from './actions/expand';
import { FullscreenAction } from './actions/fullscreen';
import { InfoAction } from './actions/info';
import { InternalCallbackAction } from './actions/internal-callback';
import { LogAction } from './actions/log';
import { MediaPlayerAction } from './actions/media-player';
@@ -33,6 +34,7 @@ import { PTZDigitalAction } from './actions/ptz-digital';
import { PTZMultiAction } from './actions/ptz-multi';
import { ReloadAction } from './actions/reload';
import { ScreenshotAction } from './actions/screenshot';
import { SetReviewAction } from './actions/set-review';
import { SleepAction } from './actions/sleep';
import { StatusBarAction } from './actions/status-bar';
import { SubstreamOffAction } from './actions/substream-off';
@@ -93,6 +95,8 @@ export class ActionFactory {
case 'live':
case 'recording':
case 'recordings':
case 'review':
case 'reviews':
case 'snapshot':
case 'snapshots':
case 'timeline':
@@ -110,6 +114,8 @@ export class ActionFactory {
return new ExpandAction(context, action, options?.config);
case 'fullscreen':
return new FullscreenAction(context, action, options?.config);
case 'info':
return new InfoAction(context, action, options?.config);
case 'menu_toggle':
return new MenuToggleAction(context, action, options?.config);
case 'camera_select':
@@ -156,6 +162,8 @@ export class ActionFactory {
return new StatusBarAction(context, action, options?.config);
case 'reload':
return new ReloadAction(context, action, options?.config);
case 'set_review':
return new SetReviewAction(context, action, options?.config);
case INTERNAL_CALLBACK_ACTION:
return new InternalCallbackAction(context, action, options?.config);
}