1247 lines
39 KiB
TypeScript
1247 lines
39 KiB
TypeScript
import {
|
|
afterAll,
|
|
afterEach,
|
|
beforeAll,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi,
|
|
} from 'vitest';
|
|
import { mock } from 'vitest-mock-extended';
|
|
|
|
import {
|
|
ReolinkCameraManagerEngine,
|
|
ReolinkQueryResultsClassifier,
|
|
} from '../../../src/camera-manager/reolink/engine-reolink';
|
|
import type { ReolinkEventQueryResults } from '../../../src/camera-manager/reolink/types';
|
|
import { CameraManagerStore } from '../../../src/camera-manager/store';
|
|
import {
|
|
CameraManagerRequestCache,
|
|
Engine,
|
|
QueryResultsType,
|
|
QueryType,
|
|
type EventQuery,
|
|
type QueryReturnType,
|
|
} from '../../../src/camera-manager/types';
|
|
import { browseMediaSchema, type BrowseMedia } from '../../../src/ha/browse-media/types';
|
|
import { BrowseMediaWalker } from '../../../src/ha/browse-media/walker';
|
|
import type { DeviceRegistryManager } from '../../../src/ha/registry/device';
|
|
import type { EntityRegistryManager } from '../../../src/ha/registry/entity/types';
|
|
import { ResolvedMediaCache } from '../../../src/ha/resolved-media';
|
|
import { homeAssistantWSRequest } from '../../../src/ha/ws-request';
|
|
import { QuerySource } from '../../../src/query-source';
|
|
import { createCameraConfig } from '../../config/test-utils';
|
|
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
|
import { createHASS, createHASSManager, createRegistryEntity } from '../../test-utils';
|
|
import { createInitializedCamera, createStore } from '../test-utils';
|
|
|
|
vi.mock('../../../src/ha/ws-request');
|
|
|
|
const TEST_CAMERAS: BrowseMedia = {
|
|
title: 'Reolink',
|
|
media_class: 'channel',
|
|
media_content_type: 'playlist',
|
|
media_content_id: 'media-source://reolink',
|
|
children_media_class: 'directory',
|
|
can_play: false,
|
|
can_expand: true,
|
|
thumbnail: null,
|
|
children: [
|
|
{
|
|
title: 'Back Yard',
|
|
media_class: 'directory',
|
|
media_content_type: 'playlist',
|
|
media_content_id: 'media-source://reolink/CAM|01J8XHYTNH77WE3C654K03KX1F|0',
|
|
children_media_class: null,
|
|
can_play: false,
|
|
can_expand: true,
|
|
thumbnail: null,
|
|
},
|
|
],
|
|
};
|
|
|
|
const TEST_DIRECTORIES: BrowseMedia = {
|
|
title: 'Back Yard Low res.',
|
|
media_class: 'channel',
|
|
media_content_type: 'playlist',
|
|
media_content_id: 'media-source://reolink/DAYS|01J8XHYTNH77WE3C654K03KX1F|0|sub',
|
|
children_media_class: 'directory',
|
|
can_play: false,
|
|
can_expand: true,
|
|
thumbnail: null,
|
|
children: [
|
|
{
|
|
title: '2024/11/4',
|
|
media_class: 'directory',
|
|
media_content_type: 'playlist',
|
|
media_content_id:
|
|
'media-source://reolink/DAY|01J8XHYTNH77WE3C654K03KX1F|0|sub|2024|11|4',
|
|
children_media_class: null,
|
|
can_play: false,
|
|
can_expand: true,
|
|
thumbnail: null,
|
|
},
|
|
{
|
|
title: '2024/11/5',
|
|
media_class: 'directory',
|
|
media_content_type: 'playlist',
|
|
media_content_id:
|
|
'media-source://reolink/DAY|01J8XHYTNH77WE3C654K03KX1F|0|sub|2024|11|5',
|
|
children_media_class: null,
|
|
can_play: false,
|
|
can_expand: true,
|
|
thumbnail: null,
|
|
},
|
|
{
|
|
title: '2024/11/6',
|
|
media_class: 'directory',
|
|
media_content_type: 'playlist',
|
|
media_content_id:
|
|
'media-source://reolink/DAY|01J8XHYTNH77WE3C654K03KX1F|0|sub|2024|11|6',
|
|
children_media_class: null,
|
|
can_play: false,
|
|
can_expand: true,
|
|
thumbnail: null,
|
|
},
|
|
],
|
|
};
|
|
|
|
const TEST_FILES: BrowseMedia = {
|
|
title: 'Back Yard Low res. 2024/11/4',
|
|
media_class: 'channel',
|
|
media_content_type: 'playlist',
|
|
media_content_id: 'media-source://reolink/FILES|01J8XHYTNH77WE3C654K03KX1F|0|sub',
|
|
children_media_class: 'video',
|
|
can_play: false,
|
|
can_expand: true,
|
|
thumbnail: null,
|
|
children: [
|
|
{
|
|
title: '21:23:53 0:00:34',
|
|
media_class: 'video',
|
|
media_content_type: 'video',
|
|
media_content_id:
|
|
'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052353_211_S.mp4',
|
|
children_media_class: null,
|
|
can_play: true,
|
|
can_expand: false,
|
|
thumbnail: null,
|
|
},
|
|
{
|
|
title: '21:29:05 0:00:41',
|
|
media_class: 'video',
|
|
media_content_type: 'video',
|
|
media_content_id:
|
|
'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052905_211_S.mp4',
|
|
children_media_class: null,
|
|
can_play: true,
|
|
can_expand: false,
|
|
thumbnail: null,
|
|
},
|
|
{
|
|
title: '21:35:05 0:00:41 Zebra Person Vehicle',
|
|
media_class: 'video',
|
|
media_content_type: 'video',
|
|
media_content_id:
|
|
'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052905_211_S.mp4',
|
|
children_media_class: null,
|
|
can_play: true,
|
|
can_expand: false,
|
|
thumbnail: null,
|
|
},
|
|
{
|
|
title: '21:37:00',
|
|
media_class: 'video',
|
|
media_content_type: 'video',
|
|
media_content_id:
|
|
'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052905_211_S.mp4',
|
|
children_media_class: null,
|
|
can_play: true,
|
|
can_expand: false,
|
|
thumbnail: null,
|
|
},
|
|
{
|
|
title: '22:04:49 0:00:35',
|
|
media_class: 'video',
|
|
media_content_type: 'video',
|
|
media_content_id:
|
|
'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_060449_211_S.mp4',
|
|
children_media_class: null,
|
|
can_play: true,
|
|
can_expand: false,
|
|
thumbnail: null,
|
|
},
|
|
],
|
|
};
|
|
|
|
const createEngine = (options?: {
|
|
browseMediaManager?: BrowseMediaWalker;
|
|
entityRegistryManager?: EntityRegistryManager;
|
|
}): ReolinkCameraManagerEngine => {
|
|
return new ReolinkCameraManagerEngine(
|
|
options?.entityRegistryManager ?? new EntityRegistryManagerMock(),
|
|
mock<DeviceRegistryManager>(),
|
|
createHASSManager(),
|
|
options?.browseMediaManager ?? new BrowseMediaWalker(),
|
|
new ResolvedMediaCache(),
|
|
new CameraManagerRequestCache(),
|
|
);
|
|
};
|
|
|
|
const cameraEntity = createRegistryEntity({
|
|
entity_id: 'camera.office',
|
|
unique_id: '85270002TS7D4RUP_0_main',
|
|
platform: 'reolink',
|
|
config_entry_id: '01J8XHYTNH77WE3C654K03KX1F',
|
|
});
|
|
|
|
const createPopulatedEngine = (): ReolinkCameraManagerEngine => {
|
|
const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]);
|
|
return createEngine({ entityRegistryManager });
|
|
};
|
|
|
|
const createStoreWithReolinkCamera = async (
|
|
engine: ReolinkCameraManagerEngine,
|
|
): Promise<CameraManagerStore> => {
|
|
const store = new CameraManagerStore();
|
|
const camera = await engine.createCamera(
|
|
createCameraConfig({ camera_entity: 'camera.office', id: 'office' }),
|
|
);
|
|
store.addCamera(camera);
|
|
return store;
|
|
};
|
|
|
|
describe('ReolinkQueryResultsClassifier', () => {
|
|
it('should correctly identify matching results', () => {
|
|
expect(
|
|
ReolinkQueryResultsClassifier.isReolinkEventQueryResults({
|
|
type: QueryResultsType.Event,
|
|
engine: Engine.Reolink,
|
|
}),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('should correctly identify non-matching results', () => {
|
|
expect(
|
|
ReolinkQueryResultsClassifier.isReolinkEventQueryResults({
|
|
type: QueryResultsType.Event,
|
|
engine: Engine.MotionEye,
|
|
}),
|
|
).toBeFalsy();
|
|
});
|
|
});
|
|
|
|
describe('ReolinkCameraManagerEngine', () => {
|
|
it('should get correct engine type', () => {
|
|
const engine = createEngine();
|
|
expect(engine.getEngineType()).toBe('reolink');
|
|
});
|
|
|
|
it('should create camera', async () => {
|
|
const engine = createPopulatedEngine();
|
|
const config = createCameraConfig({
|
|
camera_entity: 'camera.office',
|
|
unique_id: 'office',
|
|
});
|
|
|
|
const camera = await engine.createCamera(config);
|
|
|
|
expect(camera.getConfig()).toBe(config);
|
|
expect(camera.getEngine()).toBe(engine);
|
|
expect(camera.getCapabilities()?.getRawCapabilities()).toEqual({
|
|
'2-way-audio': false,
|
|
clips: true,
|
|
'remote-control-entity': true,
|
|
live: true,
|
|
menu: true,
|
|
substream: true,
|
|
trigger: true,
|
|
});
|
|
});
|
|
|
|
it('should get camera metadata', () => {
|
|
const cameraConfig = createCameraConfig({
|
|
title: 'Office',
|
|
camera_entity: 'camera.office',
|
|
icon: 'mdi:camera',
|
|
});
|
|
const engine = createEngine();
|
|
expect(engine.getCameraMetadata(createHASS(), cameraConfig)).toEqual({
|
|
engineIcon: 'reolink',
|
|
icon: {
|
|
icon: 'mdi:camera',
|
|
entity: 'camera.office',
|
|
fallback: 'mdi:video',
|
|
},
|
|
title: 'Office',
|
|
});
|
|
});
|
|
|
|
describe('should get camera endpoints', () => {
|
|
it('should return ui endpoint', async () => {
|
|
const engine = createPopulatedEngine();
|
|
const camera = await engine.createCamera(
|
|
createCameraConfig({
|
|
camera_entity: 'camera.office',
|
|
reolink: {
|
|
url: 'http://path-to-reolink',
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(camera.getEndpoints()).toEqual(
|
|
expect.objectContaining({
|
|
ui: { endpoint: 'http://path-to-reolink' },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should return go2rtc endpoint', async () => {
|
|
const engine = createPopulatedEngine();
|
|
const camera = await engine.createCamera(
|
|
createCameraConfig({
|
|
camera_entity: 'camera.office',
|
|
go2rtc: {
|
|
url: 'http://path-to-go2rtc',
|
|
stream: 'stream',
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(camera.getEndpoints()).toEqual(
|
|
expect.objectContaining({
|
|
go2rtc: { endpoint: 'http://path-to-go2rtc/api/ws?src=stream', sign: false },
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('should get events', () => {
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('should get no event for unsupported features', () => {
|
|
it.each([
|
|
['with favorite', { favorite: true }],
|
|
['with tags', { tags: new Set(['gate']) }],
|
|
['with what', { what: new Set(['car']) }],
|
|
['with where', { where: new Set(['office']) }],
|
|
['with hasSnapshot', { hasSnapshot: true }],
|
|
])('%s', async (_name: string, query: Partial<EventQuery>) => {
|
|
const engine = createEngine();
|
|
expect(
|
|
await engine.getEvents(createHASS(), createStore(), {
|
|
...query,
|
|
source: QuerySource.Camera,
|
|
cameraIDs: new Set(['office']),
|
|
type: QueryType.Event,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('should get events successfully without cache', async () => {
|
|
const engine = createPopulatedEngine();
|
|
const store = await createStoreWithReolinkCamera(engine);
|
|
|
|
vi.mocked(homeAssistantWSRequest)
|
|
.mockResolvedValueOnce(TEST_CAMERAS)
|
|
.mockResolvedValueOnce(TEST_DIRECTORIES)
|
|
.mockResolvedValueOnce(TEST_FILES);
|
|
|
|
const events = await engine.getEvents(
|
|
createHASS(),
|
|
store,
|
|
{
|
|
source: QuerySource.Camera,
|
|
type: QueryType.Event,
|
|
cameraIDs: new Set(['office']),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
},
|
|
{
|
|
useCache: false,
|
|
},
|
|
);
|
|
|
|
expect(events).toEqual(
|
|
new Map([
|
|
[
|
|
{
|
|
cameraIDs: new Set(['office']),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
source: 'camera',
|
|
type: 'event-query',
|
|
},
|
|
{
|
|
browseMedia: [
|
|
{
|
|
_metadata: {
|
|
cameraID: 'office',
|
|
// Date parsed from directory '2024/11/4' + time '21:37:00' in local time
|
|
startDate: new Date(2024, 10, 4, 21, 37, 0),
|
|
endDate: new Date(2024, 10, 4, 21, 37, 0),
|
|
},
|
|
can_expand: false,
|
|
can_play: true,
|
|
children_media_class: null,
|
|
media_class: 'video',
|
|
media_content_id:
|
|
'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052905_211_S.mp4',
|
|
media_content_type: 'video',
|
|
thumbnail: null,
|
|
title: '21:37:00',
|
|
},
|
|
{
|
|
_metadata: {
|
|
cameraID: 'office',
|
|
// Date parsed from directory '2024/11/4' + time '21:35:05' with duration in local time
|
|
endDate: new Date(2024, 10, 4, 21, 35, 46),
|
|
startDate: new Date(2024, 10, 4, 21, 35, 5),
|
|
what: ['person', 'vehicle', 'zebra'],
|
|
},
|
|
can_expand: false,
|
|
can_play: true,
|
|
children_media_class: null,
|
|
media_class: 'video',
|
|
media_content_id:
|
|
'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052905_211_S.mp4',
|
|
media_content_type: 'video',
|
|
thumbnail: null,
|
|
title: '21:35:05 0:00:41 Zebra Person Vehicle',
|
|
},
|
|
{
|
|
_metadata: {
|
|
cameraID: 'office',
|
|
// Date parsed from directory '2024/11/4' + time '21:29:05' with duration in local time
|
|
endDate: new Date(2024, 10, 4, 21, 29, 46),
|
|
startDate: new Date(2024, 10, 4, 21, 29, 5),
|
|
},
|
|
can_expand: false,
|
|
can_play: true,
|
|
children_media_class: null,
|
|
media_class: 'video',
|
|
media_content_id:
|
|
'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052905_211_S.mp4',
|
|
media_content_type: 'video',
|
|
thumbnail: null,
|
|
title: '21:29:05 0:00:41',
|
|
},
|
|
{
|
|
_metadata: {
|
|
cameraID: 'office',
|
|
// Date parsed from directory '2024/11/4' + time '21:23:53' with duration in local time
|
|
endDate: new Date(2024, 10, 4, 21, 24, 27),
|
|
startDate: new Date(2024, 10, 4, 21, 23, 53),
|
|
},
|
|
can_expand: false,
|
|
can_play: true,
|
|
children_media_class: null,
|
|
media_class: 'video',
|
|
media_content_id:
|
|
'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052353_211_S.mp4',
|
|
media_content_type: 'video',
|
|
thumbnail: null,
|
|
title: '21:23:53 0:00:34',
|
|
},
|
|
],
|
|
engine: 'reolink',
|
|
type: 'event-results',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('should cache event requests', async () => {
|
|
const engine = createPopulatedEngine();
|
|
const store = await createStoreWithReolinkCamera(engine);
|
|
|
|
vi.mocked(homeAssistantWSRequest)
|
|
.mockResolvedValueOnce(TEST_CAMERAS)
|
|
.mockResolvedValueOnce(TEST_DIRECTORIES)
|
|
.mockResolvedValueOnce(TEST_FILES);
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await engine.getEvents(
|
|
createHASS(),
|
|
store,
|
|
{
|
|
source: QuerySource.Camera,
|
|
type: QueryType.Event,
|
|
cameraIDs: new Set(['office']),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
},
|
|
{
|
|
useCache: true,
|
|
},
|
|
);
|
|
}
|
|
|
|
expect(homeAssistantWSRequest).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('should request high resolution if configured', async () => {
|
|
const engine = createPopulatedEngine();
|
|
const camera = await engine.createCamera(
|
|
createCameraConfig({
|
|
camera_entity: 'camera.office',
|
|
id: 'office',
|
|
reolink: {
|
|
media_resolution: 'high',
|
|
},
|
|
}),
|
|
);
|
|
|
|
const store = new CameraManagerStore();
|
|
store.addCamera(camera);
|
|
|
|
vi.mocked(homeAssistantWSRequest)
|
|
.mockResolvedValueOnce(TEST_CAMERAS)
|
|
.mockResolvedValueOnce(TEST_DIRECTORIES)
|
|
.mockResolvedValueOnce(TEST_FILES);
|
|
|
|
const hass = createHASS();
|
|
await engine.getEvents(hass, store, {
|
|
source: QuerySource.Camera,
|
|
type: QueryType.Event,
|
|
cameraIDs: new Set(['office']),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
});
|
|
|
|
expect(homeAssistantWSRequest).toHaveBeenCalledWith(
|
|
hass,
|
|
browseMediaSchema,
|
|
expect.objectContaining({
|
|
media_content_id:
|
|
// Media source request will refer to 'main' not 'sub'.
|
|
'media-source://reolink/RES|01J8XHYTNH77WE3C654K03KX1F|0|main',
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe('should ignore malformed directories', () => {
|
|
it('malformed directory title', async () => {
|
|
const engine = createPopulatedEngine();
|
|
const store = await createStoreWithReolinkCamera(engine);
|
|
|
|
vi.mocked(homeAssistantWSRequest)
|
|
.mockResolvedValueOnce(TEST_CAMERAS)
|
|
.mockResolvedValueOnce({
|
|
title: 'Back Yard Low res.',
|
|
media_class: 'channel',
|
|
media_content_type: 'playlist',
|
|
media_content_id:
|
|
'media-source://reolink/DAYS|01J8XHYTNH77WE3C654K03KX1F|0|sub',
|
|
children_media_class: 'directory',
|
|
can_play: false,
|
|
can_expand: true,
|
|
thumbnail: null,
|
|
children: [
|
|
{
|
|
// Malformed date.
|
|
title: '__MALFORMED__',
|
|
media_class: 'directory',
|
|
media_content_type: 'playlist',
|
|
media_content_id:
|
|
'media-source://reolink/DAY|01J8XHYTNH77WE3C654K03KX1F|0|sub|2024|11|4',
|
|
children_media_class: null,
|
|
can_play: false,
|
|
can_expand: true,
|
|
thumbnail: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
const events = await engine.getEvents(createHASS(), store, {
|
|
source: QuerySource.Camera,
|
|
type: QueryType.Event,
|
|
cameraIDs: new Set(['office']),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
});
|
|
|
|
expect(events).toEqual(
|
|
new Map([
|
|
[
|
|
{
|
|
cameraIDs: new Set(['office']),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
source: 'camera',
|
|
type: 'event-query',
|
|
},
|
|
{
|
|
browseMedia: [],
|
|
engine: 'reolink',
|
|
type: 'event-results',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('should ignore invalid cameras', () => {
|
|
it('camera without a config entry id', async () => {
|
|
const entity = createRegistryEntity({
|
|
entity_id: 'camera.office',
|
|
unique_id: '85270002TS7D4RUP_0_main',
|
|
platform: 'reolink',
|
|
|
|
// Cannot fetch events without a config_entry_id.
|
|
config_entry_id: null,
|
|
});
|
|
const entityRegistryManager = new EntityRegistryManagerMock([entity]);
|
|
|
|
const engine = createEngine({ entityRegistryManager });
|
|
const store = await createStoreWithReolinkCamera(engine);
|
|
|
|
const events = await engine.getEvents(createHASS(), store, {
|
|
source: QuerySource.Camera,
|
|
type: QueryType.Event,
|
|
cameraIDs: new Set(['office']),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
});
|
|
|
|
expect(events).toEqual(
|
|
new Map([
|
|
[
|
|
{
|
|
cameraIDs: new Set(['office']),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
source: 'camera',
|
|
type: 'event-query',
|
|
},
|
|
{
|
|
browseMedia: [],
|
|
engine: 'reolink',
|
|
type: 'event-results',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('should reject requests for non-reolink cameras', async () => {
|
|
const engine = createPopulatedEngine();
|
|
|
|
const store = new CameraManagerStore();
|
|
store.addCamera(
|
|
await createInitializedCamera(createCameraConfig({ id: 'office' }), engine),
|
|
);
|
|
|
|
const hass = createHASS();
|
|
const events = await engine.getEvents(hass, store, {
|
|
source: QuerySource.Camera,
|
|
type: QueryType.Event,
|
|
cameraIDs: new Set(['office']),
|
|
});
|
|
|
|
expect(events).toEqual(
|
|
new Map([
|
|
[
|
|
{
|
|
cameraIDs: new Set(['office']),
|
|
source: 'camera',
|
|
type: 'event-query',
|
|
},
|
|
{
|
|
browseMedia: [],
|
|
engine: 'reolink',
|
|
type: 'event-results',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('should ignore no cameras', async () => {
|
|
const engine = createPopulatedEngine();
|
|
const store = await createStoreWithReolinkCamera(engine);
|
|
|
|
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce({
|
|
title: 'Reolink',
|
|
media_class: 'channel',
|
|
media_content_type: 'playlist',
|
|
media_content_id: 'media-source://reolink',
|
|
children_media_class: 'directory',
|
|
can_play: false,
|
|
can_expand: true,
|
|
thumbnail: null,
|
|
children: [
|
|
// No cameras.
|
|
],
|
|
});
|
|
|
|
const events = await engine.getEvents(createHASS(), store, {
|
|
source: QuerySource.Camera,
|
|
type: QueryType.Event,
|
|
cameraIDs: new Set(['office']),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
});
|
|
|
|
expect(events).toEqual(
|
|
new Map([
|
|
[
|
|
{
|
|
cameraIDs: new Set(['office']),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
source: 'camera',
|
|
type: 'event-query',
|
|
},
|
|
{
|
|
browseMedia: [],
|
|
engine: 'reolink',
|
|
type: 'event-results',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('should ignore malformed camera', async () => {
|
|
const engine = createPopulatedEngine();
|
|
const store = await createStoreWithReolinkCamera(engine);
|
|
|
|
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce({
|
|
title: 'Reolink',
|
|
media_class: 'channel',
|
|
media_content_type: 'playlist',
|
|
media_content_id: 'media-source://reolink',
|
|
children_media_class: 'directory',
|
|
can_play: false,
|
|
can_expand: true,
|
|
thumbnail: null,
|
|
children: [
|
|
{
|
|
title: 'Back Yard',
|
|
media_class: 'directory',
|
|
media_content_type: 'playlist',
|
|
media_content_id: 'media-source://reolink/__MALFORMED__',
|
|
children_media_class: null,
|
|
can_play: false,
|
|
can_expand: true,
|
|
thumbnail: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
const events = await engine.getEvents(createHASS(), store, {
|
|
source: QuerySource.Camera,
|
|
type: QueryType.Event,
|
|
cameraIDs: new Set(['office']),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
});
|
|
|
|
expect(events).toEqual(
|
|
new Map([
|
|
[
|
|
{
|
|
cameraIDs: new Set(['office']),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
source: 'camera',
|
|
type: 'event-query',
|
|
},
|
|
{
|
|
browseMedia: [],
|
|
engine: 'reolink',
|
|
type: 'event-results',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('should ignore malformed media', () => {
|
|
it('malformed file title', async () => {
|
|
const engine = createPopulatedEngine();
|
|
const store = await createStoreWithReolinkCamera(engine);
|
|
|
|
vi.mocked(homeAssistantWSRequest)
|
|
.mockResolvedValueOnce(TEST_CAMERAS)
|
|
.mockResolvedValueOnce(TEST_DIRECTORIES)
|
|
.mockResolvedValueOnce({
|
|
title: 'Back Yard Low res. 2024/11/4',
|
|
media_class: 'channel',
|
|
media_content_type: 'playlist',
|
|
media_content_id:
|
|
'media-source://reolink/FILES|01J8XHYTNH77WE3C654K03KX1F|0|sub',
|
|
children_media_class: 'video',
|
|
can_play: false,
|
|
can_expand: true,
|
|
thumbnail: null,
|
|
children: [
|
|
{
|
|
// Title has invalid title (should be timestamp).
|
|
title: 'AA:BB:CC',
|
|
media_class: 'video',
|
|
media_content_type: 'video',
|
|
media_content_id:
|
|
'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052353_211_S.mp4',
|
|
children_media_class: null,
|
|
can_play: true,
|
|
can_expand: false,
|
|
thumbnail: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
const events = await engine.getEvents(createHASS(), store, {
|
|
source: QuerySource.Camera,
|
|
type: QueryType.Event,
|
|
cameraIDs: new Set(['office']),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
});
|
|
|
|
expect(events).toEqual(
|
|
new Map([
|
|
[
|
|
{
|
|
cameraIDs: new Set(['office']),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
source: 'camera',
|
|
type: 'event-query',
|
|
},
|
|
{
|
|
browseMedia: [],
|
|
engine: 'reolink',
|
|
type: 'event-results',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('malformed start time', async () => {
|
|
const engine = createPopulatedEngine();
|
|
const store = await createStoreWithReolinkCamera(engine);
|
|
|
|
vi.mocked(homeAssistantWSRequest)
|
|
.mockResolvedValueOnce(TEST_CAMERAS)
|
|
.mockResolvedValueOnce(TEST_DIRECTORIES)
|
|
.mockResolvedValueOnce({
|
|
title: 'Back Yard Low res. 2024/11/4',
|
|
media_class: 'channel',
|
|
media_content_type: 'playlist',
|
|
media_content_id:
|
|
'media-source://reolink/FILES|01J8XHYTNH77WE3C654K03KX1F|0|sub',
|
|
children_media_class: 'video',
|
|
can_play: false,
|
|
can_expand: true,
|
|
thumbnail: null,
|
|
children: [
|
|
{
|
|
// Invalid hour.
|
|
title: '29:23:53 0:00:34',
|
|
media_class: 'video',
|
|
media_content_type: 'video',
|
|
media_content_id:
|
|
'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052353_211_S.mp4',
|
|
children_media_class: null,
|
|
can_play: true,
|
|
can_expand: false,
|
|
thumbnail: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
const events = await engine.getEvents(createHASS(), store, {
|
|
source: QuerySource.Camera,
|
|
type: QueryType.Event,
|
|
cameraIDs: new Set(['office']),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
});
|
|
|
|
expect(events).toEqual(
|
|
new Map([
|
|
[
|
|
{
|
|
cameraIDs: new Set(['office']),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
source: 'camera',
|
|
type: 'event-query',
|
|
},
|
|
{
|
|
browseMedia: [],
|
|
engine: 'reolink',
|
|
type: 'event-results',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('malformed duration', async () => {
|
|
const engine = createPopulatedEngine();
|
|
const store = await createStoreWithReolinkCamera(engine);
|
|
|
|
vi.mocked(homeAssistantWSRequest)
|
|
.mockResolvedValueOnce(TEST_CAMERAS)
|
|
.mockResolvedValueOnce(TEST_DIRECTORIES)
|
|
.mockResolvedValueOnce({
|
|
title: 'Back Yard Low res. 2024/11/4',
|
|
media_class: 'channel',
|
|
media_content_type: 'playlist',
|
|
media_content_id:
|
|
'media-source://reolink/FILES|01J8XHYTNH77WE3C654K03KX1F|0|sub',
|
|
children_media_class: 'video',
|
|
can_play: false,
|
|
can_expand: true,
|
|
thumbnail: null,
|
|
children: [
|
|
{
|
|
// Invalid duration.
|
|
title: '21:23:53 INVALID_DURATION',
|
|
media_class: 'video',
|
|
media_content_type: 'video',
|
|
media_content_id:
|
|
'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052353_211_S.mp4',
|
|
children_media_class: null,
|
|
can_play: true,
|
|
can_expand: false,
|
|
thumbnail: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
const events = await engine.getEvents(createHASS(), store, {
|
|
source: QuerySource.Camera,
|
|
type: QueryType.Event,
|
|
cameraIDs: new Set(['office']),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
});
|
|
|
|
expect(events).toEqual(
|
|
new Map([
|
|
[
|
|
{
|
|
cameraIDs: new Set(['office']),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
source: 'camera',
|
|
type: 'event-query',
|
|
},
|
|
{
|
|
browseMedia: [
|
|
{
|
|
_metadata: {
|
|
cameraID: 'office',
|
|
// End time and start time will match.
|
|
endDate: new Date('2024-11-04T21:23:53'),
|
|
startDate: new Date('2024-11-04T21:23:53'),
|
|
},
|
|
can_expand: false,
|
|
can_play: true,
|
|
children_media_class: null,
|
|
media_class: 'video',
|
|
media_content_id:
|
|
'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052353_211_S.mp4',
|
|
media_content_type: 'video',
|
|
thumbnail: null,
|
|
title: '21:23:53 INVALID_DURATION',
|
|
},
|
|
],
|
|
engine: 'reolink',
|
|
type: 'event-results',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('should generate media from events', () => {
|
|
it('should generate media successfully', () => {
|
|
const query: EventQuery = {
|
|
source: QuerySource.Camera,
|
|
type: QueryType.Event,
|
|
cameraIDs: new Set(['office']),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
};
|
|
|
|
const results: ReolinkEventQueryResults = {
|
|
browseMedia: [
|
|
{
|
|
_metadata: {
|
|
cameraID: 'office',
|
|
endDate: new Date('2024-11-04T22:00:00'),
|
|
startDate: new Date('2024-11-04T21:00:00'),
|
|
},
|
|
can_expand: false,
|
|
can_play: true,
|
|
children_media_class: null,
|
|
media_class: 'video',
|
|
media_content_id:
|
|
'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052353_211_S.mp4',
|
|
media_content_type: 'video',
|
|
thumbnail: null,
|
|
title: '21:23:53 1:00:00',
|
|
},
|
|
],
|
|
engine: Engine.Reolink,
|
|
type: QueryResultsType.Event,
|
|
};
|
|
|
|
const store = new CameraManagerStore();
|
|
const engine = createEngine();
|
|
const media = engine.generateMediaFromEvents(createHASS(), store, query, results);
|
|
expect(media?.length).toBe(1);
|
|
expect(media?.[0].getCameraID()).toBe('office');
|
|
expect(media?.[0].getStartTime()).toEqual(new Date('2024-11-04T21:00:00'));
|
|
expect(media?.[0].getEndTime()).toEqual(new Date('2024-11-04T22:00:00'));
|
|
expect(media?.[0].getID()).toBe('office/2024-11-04 21:00:00');
|
|
expect(media?.[0].getContentID()).toBe(
|
|
'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052353_211_S.mp4',
|
|
);
|
|
expect(media?.[0].getTitle()).toBe('2024-11-04 21:00');
|
|
});
|
|
|
|
it('should reject non-reolink results', () => {
|
|
const query: EventQuery = {
|
|
source: QuerySource.Camera,
|
|
type: QueryType.Event,
|
|
cameraIDs: new Set(['office']),
|
|
start: new Date('2024-11-04T21:00:00'),
|
|
end: new Date('2024-11-04T22:00:00'),
|
|
};
|
|
|
|
const results: QueryReturnType<EventQuery> = {
|
|
engine: Engine.Frigate,
|
|
type: QueryResultsType.Event,
|
|
};
|
|
|
|
const store = new CameraManagerStore();
|
|
const engine = createEngine();
|
|
const media = engine.generateMediaFromEvents(createHASS(), store, query, results);
|
|
expect(media).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('should get media metadata', () => {
|
|
beforeAll(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2024-11-17T15:06:00'));
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('should get media metadata successfully', async () => {
|
|
const engine = createPopulatedEngine();
|
|
const store = await createStoreWithReolinkCamera(engine);
|
|
|
|
vi.mocked(homeAssistantWSRequest)
|
|
.mockResolvedValueOnce(TEST_CAMERAS)
|
|
.mockResolvedValueOnce(TEST_DIRECTORIES);
|
|
|
|
const metadata = await engine.getMediaMetadata(
|
|
createHASS(),
|
|
store,
|
|
{
|
|
type: QueryType.MediaMetadata,
|
|
cameraIDs: new Set(['office']),
|
|
},
|
|
{ useCache: false },
|
|
);
|
|
|
|
expect(metadata).toEqual(
|
|
new Map([
|
|
[
|
|
{
|
|
cameraIDs: new Set(['office']),
|
|
type: 'media-metadata',
|
|
},
|
|
{
|
|
cached: false,
|
|
engine: 'reolink',
|
|
expiry: new Date('2024-11-17T15:07:00'),
|
|
metadata: {
|
|
days: new Set(['2024-11-06', '2024-11-05', '2024-11-04']),
|
|
},
|
|
type: 'media-metadata-results',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('should cache media metadata', async () => {
|
|
const engine = createPopulatedEngine();
|
|
const store = await createStoreWithReolinkCamera(engine);
|
|
|
|
vi.mocked(homeAssistantWSRequest)
|
|
.mockResolvedValueOnce(TEST_CAMERAS)
|
|
.mockResolvedValueOnce(TEST_DIRECTORIES);
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await engine.getMediaMetadata(
|
|
createHASS(),
|
|
store,
|
|
{
|
|
type: QueryType.MediaMetadata,
|
|
cameraIDs: new Set(['office']),
|
|
},
|
|
{ useCache: true },
|
|
);
|
|
}
|
|
|
|
expect(homeAssistantWSRequest).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
describe('should ignore invalid cameras', () => {
|
|
it('should get no metdata for camera without a config entry id', async () => {
|
|
const entity = createRegistryEntity({
|
|
entity_id: 'camera.office',
|
|
unique_id: '85270002TS7D4RUP_0_main',
|
|
platform: 'reolink',
|
|
|
|
// Cannot fetch events without a config_entry_id.
|
|
config_entry_id: null,
|
|
});
|
|
const entityRegistryManager = new EntityRegistryManagerMock([entity]);
|
|
|
|
const engine = createEngine({ entityRegistryManager });
|
|
const store = await createStoreWithReolinkCamera(engine);
|
|
|
|
const metadata = await engine.getMediaMetadata(createHASS(), store, {
|
|
type: QueryType.MediaMetadata,
|
|
cameraIDs: new Set(['office']),
|
|
});
|
|
|
|
expect(metadata).toEqual(
|
|
new Map([
|
|
[
|
|
{
|
|
cameraIDs: new Set(['office']),
|
|
type: 'media-metadata',
|
|
},
|
|
{
|
|
cached: false,
|
|
engine: 'reolink',
|
|
expiry: new Date('2024-11-17T15:07:00'),
|
|
metadata: {},
|
|
type: 'media-metadata-results',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('should get no metdata for non-reolink camera', async () => {
|
|
const engine = createPopulatedEngine();
|
|
|
|
const store = new CameraManagerStore();
|
|
store.addCamera(
|
|
await createInitializedCamera(createCameraConfig({ id: 'office' }), engine),
|
|
);
|
|
|
|
const metadata = await engine.getMediaMetadata(createHASS(), store, {
|
|
type: QueryType.MediaMetadata,
|
|
cameraIDs: new Set(['office']),
|
|
});
|
|
|
|
expect(metadata).toEqual(
|
|
new Map([
|
|
[
|
|
{
|
|
cameraIDs: new Set(['office']),
|
|
type: 'media-metadata',
|
|
},
|
|
{
|
|
cached: false,
|
|
engine: 'reolink',
|
|
expiry: new Date('2024-11-17T15:07:00'),
|
|
metadata: {},
|
|
type: 'media-metadata-results',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('should ignore malformed directories', () => {
|
|
it('malformed directory title', async () => {
|
|
const engine = createPopulatedEngine();
|
|
const store = await createStoreWithReolinkCamera(engine);
|
|
|
|
vi.mocked(homeAssistantWSRequest)
|
|
.mockResolvedValueOnce(TEST_CAMERAS)
|
|
.mockResolvedValueOnce({
|
|
title: 'Back Yard Low res.',
|
|
media_class: 'channel',
|
|
media_content_type: 'playlist',
|
|
media_content_id:
|
|
'media-source://reolink/DAYS|01J8XHYTNH77WE3C654K03KX1F|0|sub',
|
|
children_media_class: 'directory',
|
|
can_play: false,
|
|
can_expand: true,
|
|
thumbnail: null,
|
|
children: [
|
|
{
|
|
// Malformed date.
|
|
title: '__MALFORMED__',
|
|
media_class: 'directory',
|
|
media_content_type: 'playlist',
|
|
media_content_id:
|
|
'media-source://reolink/DAY|01J8XHYTNH77WE3C654K03KX1F|0|sub|2024|11|4',
|
|
children_media_class: null,
|
|
can_play: false,
|
|
can_expand: true,
|
|
thumbnail: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
const metadata = await engine.getMediaMetadata(createHASS(), store, {
|
|
type: QueryType.MediaMetadata,
|
|
cameraIDs: new Set(['office']),
|
|
});
|
|
|
|
expect(metadata).toEqual(
|
|
new Map([
|
|
[
|
|
{
|
|
cameraIDs: new Set(['office']),
|
|
type: 'media-metadata',
|
|
},
|
|
{
|
|
cached: false,
|
|
engine: 'reolink',
|
|
expiry: new Date('2024-11-17T15:07:00'),
|
|
metadata: {},
|
|
type: 'media-metadata-results',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|