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
+108 -11
View File
@@ -11,8 +11,6 @@ import {
QueryResultClassifier,
} from '../../src/camera-manager/manager.js';
import {
CameraEndpoints,
CameraEndpointsContext,
CameraEvent,
CameraManagerCameraMetadata,
Engine,
@@ -27,23 +25,24 @@ import { CardController } from '../../src/card-controller/controller.js';
import { sortItems } from '../../src/card-controller/view/sort.js';
import { CameraConfig } from '../../src/config/schema/cameras.js';
import { HomeAssistant } from '../../src/ha/types.js';
import { QuerySource } from '../../src/query-source.js';
import { Endpoint, PTZMovementType } from '../../src/types.js';
import { ViewFolder, ViewItem, ViewMedia } from '../../src/view/item.js';
import { ViewItemCapabilities } from '../../src/view/types.js';
import {
TestViewMedia,
createInitializedCamera,
createCameraConfig,
createCapabilities,
createCardAPI,
createConfig,
createFolder,
createHASS,
createInitializedCamera,
generateViewMediaArray,
} from '../test-utils.js';
describe('QueryClassifier', async () => {
it('should classify event query', async () => {
describe('QueryClassifier', () => {
it('should classify event query', () => {
expect(CameraQueryClassifier.isEventQuery({ type: QueryType.Event })).toBeTruthy();
expect(
CameraQueryClassifier.isEventQuery({ type: QueryType.Recording }),
@@ -55,7 +54,7 @@ describe('QueryClassifier', async () => {
CameraQueryClassifier.isEventQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
});
it('should classify recording query', async () => {
it('should classify recording query', () => {
expect(
CameraQueryClassifier.isRecordingQuery({ type: QueryType.Event }),
).toBeFalsy();
@@ -69,7 +68,7 @@ describe('QueryClassifier', async () => {
CameraQueryClassifier.isRecordingQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
});
it('should classify recording segments query', async () => {
it('should classify recording segments query', () => {
expect(
CameraQueryClassifier.isRecordingSegmentsQuery({ type: QueryType.Event }),
).toBeFalsy();
@@ -85,7 +84,7 @@ describe('QueryClassifier', async () => {
CameraQueryClassifier.isRecordingSegmentsQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
});
it('should classify media metadata query', async () => {
it('should classify media metadata query', () => {
expect(
CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.Event }),
).toBeFalsy();
@@ -99,6 +98,19 @@ describe('QueryClassifier', async () => {
CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.MediaMetadata }),
).toBeTruthy();
});
it('should classify review query', () => {
expect(CameraQueryClassifier.isReviewQuery({ type: QueryType.Event })).toBeFalsy();
expect(
CameraQueryClassifier.isReviewQuery({ type: QueryType.Recording }),
).toBeFalsy();
expect(
CameraQueryClassifier.isReviewQuery({ type: QueryType.RecordingSegments }),
).toBeFalsy();
expect(
CameraQueryClassifier.isReviewQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
expect(CameraQueryClassifier.isReviewQuery({ type: QueryType.Review })).toBeTruthy();
});
});
describe('QueryResultClassifier', async () => {
@@ -195,9 +207,32 @@ describe('QueryResultClassifier', async () => {
),
).toBeTruthy();
});
it('should classify review query result', async () => {
expect(
QueryResultClassifier.isReviewQueryResult(createResults(QueryResultsType.Event)),
).toBeFalsy();
expect(
QueryResultClassifier.isReviewQueryResult(
createResults(QueryResultsType.Recording),
),
).toBeFalsy();
expect(
QueryResultClassifier.isReviewQueryResult(
createResults(QueryResultsType.RecordingSegments),
),
).toBeFalsy();
expect(
QueryResultClassifier.isReviewQueryResult(
createResults(QueryResultsType.MediaMetadata),
),
).toBeFalsy();
expect(
QueryResultClassifier.isReviewQueryResult(createResults(QueryResultsType.Review)),
).toBeTruthy();
});
});
describe('CameraManager', async () => {
describe('CameraManager', () => {
const baseCameraConfig = {
id: 'id',
camera_entity: 'camera.foo',
@@ -205,6 +240,7 @@ describe('CameraManager', async () => {
};
const baseEventQuery: EventQuery = {
source: QuerySource.Camera,
type: QueryType.Event as const,
cameraIDs: new Set(['id']),
};
@@ -215,6 +251,7 @@ describe('CameraManager', async () => {
};
const baseRecordingQuery = {
source: QuerySource.Camera as const,
type: QueryType.Recording as const,
cameraIDs: new Set(['id']),
};
@@ -266,7 +303,7 @@ describe('CameraManager', async () => {
return new CameraManager(api, { factory: mockFactory });
};
it('should construct', async () => {
it('should construct', () => {
const manager = new CameraManager(createCardAPI());
expect(manager.getStore()).toBeTruthy();
});
@@ -440,6 +477,11 @@ describe('CameraManager', async () => {
'generateDefaultRecordingSegmentsQuery',
'generateDefaultRecordingSegmentsQueries',
],
[
QueryType.Review as const,
'generateDefaultReviewQuery',
'generateDefaultReviewQueries',
],
])(
'basic %s',
async (
@@ -482,6 +524,33 @@ describe('CameraManager', async () => {
});
});
describe('getDefaultQueryParameters', () => {
it('should return empty object for non-existent camera', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, mock<CameraManagerEngine>());
expect(
manager.getDefaultQueryParameters('not_a_camera', QueryType.Event),
).toEqual({});
});
it('should return parameters from engine for existing camera', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine);
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
engine.getDefaultQueryParameters.mockReturnValue({ what: new Set(['person']) });
expect(manager.getDefaultQueryParameters('id', QueryType.Event)).toEqual({
what: new Set(['person']),
});
});
});
it('should merge defaults correctly', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
@@ -590,6 +659,34 @@ describe('CameraManager', async () => {
});
});
describe('should review media', () => {
it('without camera', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api);
const media = new TestViewMedia();
await manager.reviewMedia(media, true);
});
it('successfully', async () => {
const api = createCardAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const engine = mock<CameraManagerEngine>();
vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic);
const manager = createCameraManager(api, engine);
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
const media = new TestViewMedia({ cameraID: 'id' });
await manager.reviewMedia(media, true);
expect(engine.reviewMedia).toBeCalledWith(hass, expect.anything(), media, true);
});
});
describe('should get recordings', () => {
it('successfully', async () => {
const api = createCardAPI();
@@ -740,7 +837,7 @@ describe('CameraManager', async () => {
new TestViewMedia({
startTime: add(dateBase, { days: 2 }),
}),
new ViewFolder(createFolder()),
new ViewFolder(createFolder(), []),
];
it('without hass', async () => {