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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import {
FrigateEventViewMedia,
FrigateReviewViewMedia,
} from '../../../src/camera-manager/frigate/media';
import { ViewMediaType } from '../../../src/view/item';
import { createFrigateEvent, createFrigateReview } from '../../test-utils';
describe('FrigateReviewViewMedia', () => {
it('should get description when scene is present', () => {
const review = createFrigateReview({
data: {
objects: [],
zones: [],
metadata: {
scene: 'A person walking',
title: 'Title',
},
},
});
const media = new FrigateReviewViewMedia(
'camera',
review,
'content_id',
'thumbnail',
);
expect(media.getDescription()).toBe('A person walking');
});
it('should get null description when scene is absent', () => {
const review = createFrigateReview({
data: {
objects: [],
zones: [],
metadata: {
title: 'Title',
// scene is absent.
},
},
});
const media = new FrigateReviewViewMedia(
'camera',
review,
'content_id',
'thumbnail',
);
expect(media.getDescription()).toBeNull();
});
});
describe('FrigateEventViewMedia', () => {
it('should get description when description is present', () => {
const event = createFrigateEvent({
data: {
description: 'A person walking',
},
});
const media = new FrigateEventViewMedia(
ViewMediaType.Clip,
'camera',
event,
'content_id',
'thumbnail',
);
expect(media.getDescription()).toBe('A person walking');
});
it('should get null description when data is absent', () => {
const event = createFrigateEvent();
const media = new FrigateEventViewMedia(
ViewMediaType.Clip,
'camera',
event,
'content_id',
'thumbnail',
);
expect(media.getDescription()).toBeNull();
});
});
+101 -1
View File
@@ -5,17 +5,22 @@ import {
getPTZInfo,
getRecordingSegments,
getRecordingsSummary,
getReviews,
retainEvent,
setReviewsReviewed,
} from '../../../src/camera-manager/frigate/requests';
import {
EventSummary,
eventSummarySchema,
FrigateEvent,
frigateEventsSchema,
FrigateReview,
frigateReviewsSchema,
ptzInfoSchema,
recordingSegmentsSchema,
recordingSummarySchema,
retainResultSchema,
reviewResultSchema,
} from '../../../src/camera-manager/frigate/types';
import { RecordingSegment } from '../../../src/camera-manager/types';
import { homeAssistantWSRequest } from '../../../src/ha/ws-request';
@@ -197,7 +202,7 @@ describe('frigate requests', () => {
);
});
it('should get PTZ info', async () => {
it('should get PTZInfo', async () => {
const ptzInfo = [
{
name: 'camera.office',
@@ -219,4 +224,99 @@ describe('frigate requests', () => {
true,
);
});
it('should get reviews', async () => {
const reviews: FrigateReview[] = [
{
id: 'review_id',
camera: 'camera',
start_time: 0,
end_time: 1,
severity: 'alert',
thumb_path: 'thumb.jpg',
data: {
objects: [],
zones: [],
},
has_been_reviewed: false,
},
];
const hass = createHASS();
vi.mocked(homeAssistantWSRequest).mockResolvedValue(reviews);
expect(
await getReviews(hass, {
instance_id: 'clientID',
cameras: ['camera'],
labels: ['person'],
zones: ['zone'],
severity: 'alert',
after: 0,
before: 1,
limit: 10,
reviewed: false,
}),
).toBe(reviews);
expect(homeAssistantWSRequest).toBeCalledWith(
hass,
frigateReviewsSchema,
expect.objectContaining({
type: 'frigate/reviews/get',
instance_id: 'clientID',
cameras: ['camera'],
labels: ['person'],
zones: ['zone'],
severity: 'alert',
after: 0,
before: 1,
limit: 10,
reviewed: false,
}),
true,
);
});
describe('should set reviews reviewed', async () => {
it('successfully', async () => {
vi.mocked(homeAssistantWSRequest).mockResolvedValue({
success: true,
message: 'success',
});
const hass = createHASS();
setReviewsReviewed(hass, 'clientID', ['review_id'], true);
expect(homeAssistantWSRequest).toBeCalledWith(
hass,
reviewResultSchema,
expect.objectContaining({
type: 'frigate/reviews/viewed',
instance_id: 'clientID',
ids: ['review_id'],
viewed: true,
}),
);
});
it('unsuccessfully', async () => {
vi.mocked(homeAssistantWSRequest).mockResolvedValue({
success: false,
message: 'failed',
});
const hass = createHASS();
await expect(
setReviewsReviewed(hass, 'clientID', ['review_id'], true),
).rejects.toThrowError(/Failed to receive response from Home Assistant/);
expect(homeAssistantWSRequest).toBeCalledWith(
hass,
reviewResultSchema,
expect.objectContaining({
type: 'frigate/reviews/viewed',
instance_id: 'clientID',
ids: ['review_id'],
viewed: true,
}),
);
});
});
});
+110
View File
@@ -8,12 +8,17 @@ import {
getRecordingID,
getRecordingMediaContentID,
getRecordingTitle,
getReviewMediaContentID,
getReviewSeverity,
getReviewThumbnailURL,
getReviewTitle,
} from '../../../src/camera-manager/frigate/util';
import { CameraConfig } from '../../../src/config/schema/cameras';
import {
createCameraConfig,
createFrigateEvent,
createFrigateRecording,
createFrigateReview,
} from '../../test-utils';
describe('getEventTitle', () => {
@@ -152,3 +157,108 @@ describe('getRecordingID', () => {
).toBe('//1682776800000/1682780399000');
});
});
describe('getReviewTitle', () => {
const start = new Date('2023-05-06T10:43:00');
const end = new Date('2023-05-06T10:44:12');
afterEach(() => {
vi.useRealTimers();
});
it('should get finished review title without objects', () => {
expect(
getReviewTitle(
createFrigateReview({
start_time: start.getTime() / 1000,
end_time: end.getTime() / 1000,
data: {
objects: [],
zones: [],
},
}),
),
).toBe('2023-05-06 10:43 [72s]');
});
it('should get finished review title with objects', () => {
expect(
getReviewTitle(
createFrigateReview({
start_time: start.getTime() / 1000,
end_time: end.getTime() / 1000,
data: {
objects: ['person', 'dog'],
},
}),
),
).toBe('Person, Dog');
});
it('should get in-progress review title', () => {
vi.useFakeTimers();
vi.setSystemTime(add(start, { seconds: 60 }));
expect(
getReviewTitle(
createFrigateReview({
start_time: start.getTime() / 1000,
end_time: null,
data: {
objects: [],
},
}),
),
).toBe('2023-05-06 10:43 [60s]');
});
});
describe('getReviewMediaContentID', () => {
it('should get review content ID', () => {
expect(
getReviewMediaContentID(
'clientid',
'kitchen',
createFrigateReview({
start_time: new Date('2023-04-29T14:00:00').getTime() / 1000,
}),
),
).toBe('media-source://frigate/clientid/recordings/kitchen/2023-04-29/14');
});
});
describe('getReviewThumbnailURL', () => {
it('should get thumbnail URL', () => {
expect(
getReviewThumbnailURL(
'clientid',
createFrigateReview({
thumb_path: '/media/frigate/thumb.jpg',
}),
),
).toBe('/api/frigate/clientid/thumb.jpg');
});
it('should return null when no thumb path', () => {
expect(
getReviewThumbnailURL(
'clientid',
createFrigateReview({
thumb_path: null,
}),
),
).toBeNull();
});
});
describe('getReviewSeverity', () => {
it('should get alert severity', () => {
expect(getReviewSeverity('alert')).toBe('high');
});
it('should get detection severity', () => {
expect(getReviewSeverity('detection')).toBe('medium');
});
it('should get significant_motion severity', () => {
expect(getReviewSeverity('significant_motion')).toBe('low');
});
});
@@ -1,6 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { FrigateEventWatcher } from '../../../src/camera-manager/frigate/event-watcher.js';
import { FrigateEventChange } from '../../../src/camera-manager/frigate/types.js';
import {
FrigateEventChange,
FrigateReviewChange,
} from '../../../src/camera-manager/frigate/types.js';
import {
FrigateEventWatcher,
FrigateReviewWatcher,
} from '../../../src/camera-manager/frigate/watcher.js';
import { HomeAssistant } from '../../../src/ha/types.js';
import { createHASS } from '../../test-utils.js';
@@ -25,7 +31,41 @@ const createEventChange = (): FrigateEventChange => {
},
};
};
const createReviewChange = (): FrigateReviewChange => {
return {
type: 'new',
before: {
id: '123',
camera: 'front_door',
severity: 'alert',
start_time: 123,
end_time: null,
thumb_path: null,
has_been_reviewed: false,
data: {
metadata: {
title: 'Title before',
scene: 'Scene before',
},
},
},
after: {
id: '123',
camera: 'front_door',
severity: 'alert',
start_time: 123,
end_time: null,
thumb_path: null,
has_been_reviewed: false,
data: {
metadata: {
title: 'Title after',
scene: 'Scene after',
},
},
},
};
};
const callHASubscribeMessageCallback = (
hass: HomeAssistant,
data: unknown,
@@ -99,7 +139,7 @@ describe('FrigateEventWatcher', () => {
expect(callback).not.toBeCalled();
expect(spy).toBeCalledWith(
'Received non-JSON payload as Frigate event',
'Received non-JSON payload from subscription: frigate/events/subscribe',
'NOT_JSON',
);
});
@@ -122,7 +162,7 @@ describe('FrigateEventWatcher', () => {
expect(callback).not.toBeCalled();
expect(spy).toBeCalledWith(
'Received malformed Frigate event from Home Assistant',
'Received malformed message from subscription: frigate/events/subscribe',
data,
);
});
@@ -199,3 +239,95 @@ describe('FrigateEventWatcher', () => {
});
});
});
describe('FrigateReviewWatcher', () => {
it('should subscribe to a given topic once', async () => {
const stateWatcher = new FrigateReviewWatcher();
const hass = createHASS();
await stateWatcher.subscribe(hass, {
instanceID: 'frigate',
callback: vi.fn(),
});
await stateWatcher.subscribe(hass, {
instanceID: 'frigate',
callback: vi.fn(),
});
expect(hass.connection.subscribeMessage).toBeCalledWith(
expect.any(Function),
expect.objectContaining({
type: 'frigate/reviews/subscribe',
}),
);
expect(hass.connection.subscribeMessage).toBeCalledTimes(1);
});
describe('should call handler', () => {
afterEach(() => {
vi.resetAllMocks();
});
it('with a review change', async () => {
const stateWatcher = new FrigateReviewWatcher();
const hass = createHASS();
const callback = vi.fn();
const request = {
instanceID: 'frigate',
callback: callback,
};
await stateWatcher.subscribe(hass, request);
const reviewChange = createReviewChange();
callHASubscribeMessageCallback(hass, JSON.stringify(reviewChange));
expect(callback).toBeCalledWith(reviewChange);
});
it('with a genai review change', async () => {
const stateWatcher = new FrigateReviewWatcher();
const hass = createHASS();
const callback = vi.fn();
const request = {
instanceID: 'frigate',
callback: callback,
};
await stateWatcher.subscribe(hass, request);
const reviewChange = createReviewChange();
reviewChange.type = 'genai';
callHASubscribeMessageCallback(hass, JSON.stringify(reviewChange));
expect(callback).toBeCalledWith(reviewChange);
});
it('with invalid JSON', async () => {
const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true);
const stateWatcher = new FrigateReviewWatcher();
const hass = createHASS();
const callback = vi.fn();
const request = {
instanceID: 'frigate',
callback: callback,
};
await stateWatcher.subscribe(hass, request);
callHASubscribeMessageCallback(hass, 'NOT_JSON');
expect(callback).not.toBeCalled();
expect(spy).toBeCalledWith(
'Received non-JSON payload from subscription: frigate/reviews/subscribe',
'NOT_JSON',
);
});
});
});