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
@@ -6,7 +6,6 @@ describe('should handle camera_select action', () => {
it('with valid camera and view', async () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
const action = new CameraSelectAction(
{},
@@ -37,7 +36,6 @@ describe('should handle camera_select action', () => {
camera: 'camera.office',
}),
);
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
const action = new CameraSelectAction(
{},
@@ -61,7 +59,6 @@ describe('should handle camera_select action', () => {
view: 'timeline',
}),
);
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
const action = new CameraSelectAction(
{},
@@ -100,7 +97,6 @@ describe('should handle camera_select action', () => {
view: 'live',
}),
);
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
const action = new CameraSelectAction(
{},
@@ -127,7 +123,6 @@ describe('should handle camera_select action', () => {
it('with triggered camera', async () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(api.getTriggersManager().getMostRecentlyTriggeredCameraID).mockReturnValue(
'camera.office',
);
@@ -157,7 +152,6 @@ describe('should handle camera_select action', () => {
it('without camera or triggered camera', async () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(api.getTriggersManager().getMostRecentlyTriggeredCameraID).mockReturnValue(
'camera.office',
);
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from 'vitest';
import { InfoAction } from '../../../../src/card-controller/actions/actions/info';
import { QueryResults } from '../../../../src/view/query-results';
import { View } from '../../../../src/view/view';
import { createCardAPI, TestViewMedia } from '../../../test-utils';
describe('InfoAction', () => {
it('should handle info action with media', async () => {
const api = createCardAPI();
const item = new TestViewMedia();
const view = new View({
view: 'clip',
camera: 'camera',
queryResults: new QueryResults({
results: [item],
selectedIndex: 0,
}),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const action = new InfoAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'info',
},
);
await action.execute(api);
expect(api.getOverlayMessageManager().setMessage).toBeCalled();
});
it('should not handle info action without media', async () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(null);
const action = new InfoAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'info',
},
);
await action.execute(api);
expect(api.getOverlayMessageManager().setMessage).not.toBeCalled();
});
});
@@ -4,7 +4,7 @@ import {
PartialZoomSettings,
ZoomSettingsObserved,
} from '../../../../src/components-lib/zoom/types';
import { PTZAction } from '../../../../src/config/ptz';
import { PTZAction } from '../../../../src/config/schema/actions/custom/ptz';
import { createCardAPI, createView } from '../../../test-utils';
describe('should handle ptz digital action', () => {
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { Capabilities } from '../../../../src/camera-manager/capabilities';
import { PTZMultiAction } from '../../../../src/card-controller/actions/actions/ptz-multi';
import { PTZMovementType } from '../../../../src/types';
import {
createCameraManager,
createCardAPI,
@@ -23,7 +24,7 @@ describe('should handle ptz multi action', () => {
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
@@ -0,0 +1,142 @@
import { assert, describe, expect, it, vi } from 'vitest';
import { SetReviewAction } from '../../../../src/card-controller/actions/actions/set-review';
import { ViewMedia, ViewMediaType } from '../../../../src/view/item';
import { QueryResults } from '../../../../src/view/query-results';
import { createCardAPI, createView, TestViewMedia } from '../../../test-utils';
describe('SetReviewAction', () => {
it('should mark item as reviewed', async () => {
const api = createCardAPI();
const item = new TestViewMedia({
cameraID: 'camera.office',
mediaType: 'review' as ViewMediaType,
reviewed: false,
});
const queryResults = new QueryResults({ results: [item], selectedIndex: 0 });
const view = createView({ queryResults });
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const action = new SetReviewAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'set_review',
reviewed: true,
},
);
await action.execute(api);
expect(api.getViewItemManager().reviewMedia).toBeCalledWith(item, true);
// Original item is NOT mutated; a clone is created and replaced.
expect(item.isReviewed()).toBe(false);
const setViewParams = vi.mocked(api.getViewManager().setViewByParameters).mock
.calls[0][0];
const newResults = setViewParams?.params?.queryResults;
expect(newResults).toBeInstanceOf(QueryResults);
const newItem = newResults?.getSelectedResult();
expect(newItem).not.toBe(item);
assert(newItem instanceof ViewMedia);
expect(newItem?.isReviewed()).toBe(true);
});
it('should toggle review status when reviewed is not specified', async () => {
const api = createCardAPI();
const item = new TestViewMedia({
cameraID: 'camera.office',
mediaType: 'review' as ViewMediaType,
reviewed: true,
});
const queryResults = new QueryResults({ results: [item], selectedIndex: 0 });
const view = createView({ queryResults });
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const action = new SetReviewAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'set_review',
},
);
await action.execute(api);
// Toggle: isReviewed was true, so reviewed should be set to false.
expect(api.getViewItemManager().reviewMedia).toBeCalledWith(item, false);
// Original item is NOT mutated; a clone is created and replaced.
expect(item.isReviewed()).toBe(true);
const setViewParams = vi.mocked(api.getViewManager().setViewByParameters).mock
.calls[0][0];
const newResults = setViewParams?.params?.queryResults;
expect(newResults).toBeInstanceOf(QueryResults);
const newItem = newResults?.getSelectedResult();
expect(newItem).not.toBe(item);
assert(newItem instanceof ViewMedia);
expect(newItem?.isReviewed()).toBe(false);
});
it('should not act on non-review media', async () => {
const api = createCardAPI();
const item = new TestViewMedia({
cameraID: 'camera.office',
mediaType: ViewMediaType.Clip,
});
const queryResults = new QueryResults({ results: [item], selectedIndex: 0 });
const view = createView({ queryResults });
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const action = new SetReviewAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'set_review',
reviewed: true,
},
);
await action.execute(api);
expect(api.getViewItemManager().reviewMedia).not.toBeCalled();
});
it('should not act without a view', async () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(null);
const action = new SetReviewAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'set_review',
reviewed: true,
},
);
await action.execute(api);
expect(api.getViewItemManager().reviewMedia).not.toBeCalled();
});
it('should not act without query results', async () => {
const api = createCardAPI();
const view = createView();
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const action = new SetReviewAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'set_review',
reviewed: true,
},
);
await action.execute(api);
expect(api.getViewItemManager().reviewMedia).not.toBeCalled();
});
});
@@ -6,8 +6,10 @@ import { CustomAction } from '../../../src/card-controller/actions/actions/custo
import { DefaultAction } from '../../../src/card-controller/actions/actions/default';
import { DisplayModeSelectAction } from '../../../src/card-controller/actions/actions/display-mode-select';
import { DownloadAction } from '../../../src/card-controller/actions/actions/download';
import { EffectAction } from '../../../src/card-controller/actions/actions/effect';
import { ExpandAction } from '../../../src/card-controller/actions/actions/expand';
import { FullscreenAction } from '../../../src/card-controller/actions/actions/fullscreen';
import { InfoAction } from '../../../src/card-controller/actions/actions/info';
import { InternalCallbackAction } from '../../../src/card-controller/actions/actions/internal-callback';
import { LogAction } from '../../../src/card-controller/actions/actions/log';
import { MediaPlayerAction } from '../../../src/card-controller/actions/actions/media-player';
@@ -29,6 +31,7 @@ import { PTZDigitalAction } from '../../../src/card-controller/actions/actions/p
import { PTZMultiAction } from '../../../src/card-controller/actions/actions/ptz-multi';
import { ReloadAction } from '../../../src/card-controller/actions/actions/reload';
import { ScreenshotAction } from '../../../src/card-controller/actions/actions/screenshot';
import { SetReviewAction } from '../../../src/card-controller/actions/actions/set-review';
import { SleepAction } from '../../../src/card-controller/actions/actions/sleep';
import { StatusBarAction } from '../../../src/card-controller/actions/actions/status-bar';
import { SubstreamOffAction } from '../../../src/card-controller/actions/actions/substream-off';
@@ -41,7 +44,6 @@ import { ViewAction } from '../../../src/card-controller/actions/actions/view';
import { ActionFactory } from '../../../src/card-controller/actions/factory';
import { INTERNAL_CALLBACK_ACTION } from '../../../src/config/schema/actions/custom/internal';
import { ActionConfig } from '../../../src/config/schema/actions/types';
import { EffectAction } from '../../../src/card-controller/actions/actions/effect';
// @vitest-environment jsdom
describe('ActionFactory', () => {
@@ -101,6 +103,7 @@ describe('ActionFactory', () => {
[{ advanced_camera_card_action: 'folder' as const }, ViewAction],
[{ advanced_camera_card_action: 'folders' as const }, ViewAction],
[{ advanced_camera_card_action: 'fullscreen' as const }, FullscreenAction],
[{ advanced_camera_card_action: 'info' as const }, InfoAction],
[{ advanced_camera_card_action: 'image' as const }, ViewAction],
[
{ advanced_camera_card_action: 'live_substream_off' as const },
@@ -189,6 +192,7 @@ describe('ActionFactory', () => {
InternalCallbackAction,
],
[{ advanced_camera_card_action: 'reload' as const }, ReloadAction],
[{ advanced_camera_card_action: 'set_review' as const }, SetReviewAction],
[{ advanced_camera_card_action: 'effect' as const }, EffectAction],
])(
'advanced_camera_card_action: $advanced_camera_card_action',