From e0335c529c091f0a510043516af307cae9a2b565 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 13 Dec 2024 07:39:14 -0800 Subject: [PATCH] fix: Only fetch Reolink media for cameras that support it (#1747) --- .../browse-media/engine-browse-media.ts | 5 +- src/camera-manager/reolink/engine-reolink.ts | 53 +++- src/camera-manager/reolink/types.ts | 5 + .../ha/browse-media/browse-media-manager.ts | 10 +- .../reolink/engine-reolink.test.ts | 247 ++++++++++++++---- 5 files changed, 248 insertions(+), 72 deletions(-) diff --git a/src/camera-manager/browse-media/engine-browse-media.ts b/src/camera-manager/browse-media/engine-browse-media.ts index e8ad9aa7..4eb98044 100644 --- a/src/camera-manager/browse-media/engine-browse-media.ts +++ b/src/camera-manager/browse-media/engine-browse-media.ts @@ -20,7 +20,6 @@ import { PartialEventQuery, QueryType, } from '../types'; -import { BrowseMediaMetadata } from './types'; /** * A base class for cameras that read events from HA BrowseMedia interface. @@ -29,7 +28,7 @@ export class BrowseMediaCameraManagerEngine extends GenericCameraManagerEngine implements CameraManagerEngine { - protected _browseMediaManager: BrowseMediaManager; + protected _browseMediaManager: BrowseMediaManager; protected _entityRegistryManager: EntityRegistryManager; protected _resolvedMediaCache: ResolvedMediaCache; protected _requestCache: RequestCache; @@ -37,7 +36,7 @@ export class BrowseMediaCameraManagerEngine public constructor( entityRegistryManager: EntityRegistryManager, stateWatcher: StateWatcherSubscriptionInterface, - browseMediaManager: BrowseMediaManager, + browseMediaManager: BrowseMediaManager, resolvedMediaCache: ResolvedMediaCache, requestCache: RequestCache, eventCallback?: CameraEventCallback, diff --git a/src/camera-manager/reolink/engine-reolink.ts b/src/camera-manager/reolink/engine-reolink.ts index e209748b..de53d98c 100644 --- a/src/camera-manager/reolink/engine-reolink.ts +++ b/src/camera-manager/reolink/engine-reolink.ts @@ -40,7 +40,7 @@ import { import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; import reolinkLogo from './assets/reolink.svg'; import { ReolinkCamera } from './camera'; -import { ReolinkEventQueryResults } from './types'; +import { BrowseMediaReolinkCameraMetadata, ReolinkEventQueryResults } from './types'; export class ReolinkQueryResultsClassifier { public static isReolinkEventQueryResults( @@ -51,8 +51,7 @@ export class ReolinkQueryResultsClassifier { } export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { - protected _directoryCache = new MemoryRequestCache(); - protected _fileCache = new MemoryRequestCache(); + protected _cache = new MemoryRequestCache(); public getEngineType(): Engine { return Engine.Reolink; @@ -115,6 +114,21 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { : null; } + protected _reolinkCameraMetadataGenerator( + media: BrowseMedia, + ): BrowseMediaReolinkCameraMetadata | null { + // Example: "media-source://reolink/CAM|01J8XHYTNH77WE3C654K03KX1F|0" + const result = media.media_content_id.match( + /^media-source:\/\/reolink\/CAM\|(?.+)\|(?\d+)$/, + ); + return result?.groups + ? { + configEntryID: result.groups.configEntryID, + channel: Number(result.groups.channel), + } + : null; + } + public async createCamera( hass: HomeAssistant, cameraConfig: CameraConfig, @@ -164,6 +178,34 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { return null; } + // First fetch all the Reolink cameras that show up under the media root, + // that match the expected camera. Some Reolink cameras will not show up + // here causing errors. + // https://github.com/dermotduffy/frigate-hass-card/issues/1723 + const camerasWithMedia = await this._browseMediaManager.walkBrowseMedias( + hass, + [ + { + targets: [`media-source://reolink`], + metadataGenerator: ( + media: BrowseMedia, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _parent?: RichBrowseMedia, + ) => this._reolinkCameraMetadataGenerator(media), + matcher: (media: RichBrowseMedia): boolean => + media._metadata?.channel === camera.getChannel() && + media._metadata?.configEntryID === configID, + }, + ], + { + ...(engineOptions?.useCache !== false && { cache: this._cache }), + }, + ); + + if (!camerasWithMedia?.length) { + return null; + } + return await this._browseMediaManager.walkBrowseMedias( hass, [ @@ -172,7 +214,6 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { `media-source://reolink/RES|${configID}|${camera.getChannel()}|` + `${cameraConfig.reolink?.media_resolution === 'low' ? 'sub' : 'main'}`, ], - concurrency: Infinity, metadataGenerator: ( media: BrowseMedia, // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -186,7 +227,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { }, ], { - ...(engineOptions?.useCache !== false && { cache: this._directoryCache }), + ...(engineOptions?.useCache !== false && { cache: this._cache }), }, ); } @@ -251,7 +292,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { }, ], { - ...(engineOptions?.useCache !== false && { cache: this._fileCache }), + ...(engineOptions?.useCache !== false && { cache: this._cache }), }, ); } diff --git a/src/camera-manager/reolink/types.ts b/src/camera-manager/reolink/types.ts index c4e732d7..e045a6b1 100644 --- a/src/camera-manager/reolink/types.ts +++ b/src/camera-manager/reolink/types.ts @@ -2,6 +2,11 @@ import { RichBrowseMedia } from '../../utils/ha/browse-media/types'; import { BrowseMediaMetadata } from '../browse-media/types'; import { Engine, EventQueryResults } from '../types'; +export interface BrowseMediaReolinkCameraMetadata { + configEntryID: string; + channel: number; +} + // ============================== // Reolink concrete query results // ============================== diff --git a/src/utils/ha/browse-media/browse-media-manager.ts b/src/utils/ha/browse-media/browse-media-manager.ts index e5f1ce4a..a52ad127 100644 --- a/src/utils/ha/browse-media/browse-media-manager.ts +++ b/src/utils/ha/browse-media/browse-media-manager.ts @@ -56,9 +56,9 @@ export interface BrowseMediaStep { type BrowseMediaStepAdvancer = (media: RichBrowseMedia[]) => BrowseMediaStep[]; -export class BrowseMediaManager { +export class BrowseMediaManager { // Walk down a browse media tree according to instructions included in `steps`. - public async walkBrowseMedias( + public async walkBrowseMedias( hass: HomeAssistant, steps: BrowseMediaStep[] | null, options?: { @@ -77,7 +77,7 @@ export class BrowseMediaManager { ).flat(); } - protected async _walkBrowseMedia( + protected async _walkBrowseMedia( hass: HomeAssistant, step: BrowseMediaStep, options?: { @@ -92,7 +92,6 @@ export class BrowseMediaManager { async (target) => await this._browseMedia(hass, target, { cache: options?.cache, - matcher: step.matcher, metadataGenerator: step.metadataGenerator, }), ); @@ -121,12 +120,11 @@ export class BrowseMediaManager { return await this.walkBrowseMedias(hass, nextSteps, options); } - protected async _browseMedia( + protected async _browseMedia( hass: HomeAssistant, target: string | RichBrowseMedia, options?: { cache?: BrowseMediaCache; - matcher?: RichBrowseMediaPredicate; metadataGenerator?: RichMetadataGenerator; }, ): Promise> { diff --git a/tests/camera-manager/reolink/engine-reolink.test.ts b/tests/camera-manager/reolink/engine-reolink.test.ts index 011584fd..1dc8219c 100644 --- a/tests/camera-manager/reolink/engine-reolink.test.ts +++ b/tests/camera-manager/reolink/engine-reolink.test.ts @@ -9,7 +9,6 @@ import { vi, } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { BrowseMediaMetadata } from '../../../src/camera-manager/browse-media/types'; import { RequestCache } from '../../../src/camera-manager/cache'; import { ReolinkCameraManagerEngine, @@ -43,6 +42,29 @@ import { vi.mock('../../../src/utils/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', @@ -136,13 +158,13 @@ const TEST_FILES: BrowseMedia = { }; const createEngine = (options?: { - browseMediaManager?: BrowseMediaManager; + browseMediaManager?: BrowseMediaManager; entityRegistryManager?: EntityRegistryManager; }): ReolinkCameraManagerEngine => { return new ReolinkCameraManagerEngine( options?.entityRegistryManager ?? mock(), mock(), - options?.browseMediaManager ?? new BrowseMediaManager(), + options?.browseMediaManager ?? new BrowseMediaManager(), new ResolvedMediaCache(), new RequestCache(), ); @@ -306,6 +328,7 @@ describe('ReolinkCameraManagerEngine', () => { const store = await createStoreWithReolinkCamera(engine); vi.mocked(homeAssistantWSRequest) + .mockResolvedValueOnce(TEST_CAMERAS) .mockResolvedValueOnce(TEST_DIRECTORIES) .mockResolvedValueOnce(TEST_FILES); @@ -380,6 +403,7 @@ describe('ReolinkCameraManagerEngine', () => { const store = await createStoreWithReolinkCamera(engine); vi.mocked(homeAssistantWSRequest) + .mockResolvedValueOnce(TEST_CAMERAS) .mockResolvedValueOnce(TEST_DIRECTORIES) .mockResolvedValueOnce(TEST_FILES); @@ -399,7 +423,7 @@ describe('ReolinkCameraManagerEngine', () => { ); } - expect(homeAssistantWSRequest).toHaveBeenCalledTimes(2); + expect(homeAssistantWSRequest).toHaveBeenCalledTimes(3); }); it('should request high resolution if configured', async () => { @@ -419,6 +443,7 @@ describe('ReolinkCameraManagerEngine', () => { store.addCamera(camera); vi.mocked(homeAssistantWSRequest) + .mockResolvedValueOnce(TEST_CAMERAS) .mockResolvedValueOnce(TEST_DIRECTORIES) .mockResolvedValueOnce(TEST_FILES); @@ -446,31 +471,33 @@ describe('ReolinkCameraManagerEngine', () => { const engine = createPopulatedEngine(); const store = await createStoreWithReolinkCamera(engine); - vi.mocked(homeAssistantWSRequest).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, - }, - ], - }); + 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, { type: QueryType.Event, @@ -568,6 +595,103 @@ describe('ReolinkCameraManagerEngine', () => { ]), ); }); + + 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, { + 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'), + 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, { + 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'), + type: 'event-query', + }, + { + browseMedia: [], + engine: 'reolink', + type: 'event-results', + }, + ], + ]), + ); + }); }); describe('should ignore malformed media', () => { @@ -576,6 +700,7 @@ describe('ReolinkCameraManagerEngine', () => { const store = await createStoreWithReolinkCamera(engine); vi.mocked(homeAssistantWSRequest) + .mockResolvedValueOnce(TEST_CAMERAS) .mockResolvedValueOnce(TEST_DIRECTORIES) .mockResolvedValueOnce({ title: 'Back Yard Low res. 2024/11/4', @@ -634,6 +759,7 @@ describe('ReolinkCameraManagerEngine', () => { const store = await createStoreWithReolinkCamera(engine); vi.mocked(homeAssistantWSRequest) + .mockResolvedValueOnce(TEST_CAMERAS) .mockResolvedValueOnce(TEST_DIRECTORIES) .mockResolvedValueOnce({ title: 'Back Yard Low res. 2024/11/4', @@ -692,6 +818,7 @@ describe('ReolinkCameraManagerEngine', () => { const store = await createStoreWithReolinkCamera(engine); vi.mocked(homeAssistantWSRequest) + .mockResolvedValueOnce(TEST_CAMERAS) .mockResolvedValueOnce(TEST_DIRECTORIES) .mockResolvedValueOnce({ title: 'Back Yard Low res. 2024/11/4', @@ -850,7 +977,9 @@ describe('ReolinkCameraManagerEngine', () => { const engine = createPopulatedEngine(); const store = await createStoreWithReolinkCamera(engine); - vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(TEST_DIRECTORIES); + vi.mocked(homeAssistantWSRequest) + .mockResolvedValueOnce(TEST_CAMERAS) + .mockResolvedValueOnce(TEST_DIRECTORIES); const metadata = await engine.getMediaMetadata( createHASS(), @@ -887,7 +1016,9 @@ describe('ReolinkCameraManagerEngine', () => { const engine = createPopulatedEngine(); const store = await createStoreWithReolinkCamera(engine); - vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(TEST_DIRECTORIES); + vi.mocked(homeAssistantWSRequest) + .mockResolvedValueOnce(TEST_CAMERAS) + .mockResolvedValueOnce(TEST_DIRECTORIES); for (let i = 0; i < 10; i++) { await engine.getMediaMetadata( @@ -901,7 +1032,7 @@ describe('ReolinkCameraManagerEngine', () => { ); } - expect(homeAssistantWSRequest).toHaveBeenCalledTimes(1); + expect(homeAssistantWSRequest).toHaveBeenCalledTimes(2); }); describe('should ignore invalid cameras', () => { @@ -979,31 +1110,33 @@ describe('ReolinkCameraManagerEngine', () => { const engine = createPopulatedEngine(); const store = await createStoreWithReolinkCamera(engine); - vi.mocked(homeAssistantWSRequest).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, - }, - ], - }); + 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,