fix: Only fetch Reolink media for cameras that support it (#1747)

This commit is contained in:
Dermot Duffy
2024-12-13 07:39:14 -08:00
committed by GitHub
parent 1992db2144
commit e0335c529c
5 changed files with 248 additions and 72 deletions
@@ -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<BrowseMediaMetadata>;
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<BrowseMediaMetadata>,
browseMediaManager: BrowseMediaManager,
resolvedMediaCache: ResolvedMediaCache,
requestCache: RequestCache,
eventCallback?: CameraEventCallback,
+47 -6
View File
@@ -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<string, BrowseMedia>();
protected _fileCache = new MemoryRequestCache<string, BrowseMedia>();
protected _cache = new MemoryRequestCache<string, BrowseMedia>();
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\|(?<configEntryID>.+)\|(?<channel>\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<BrowseMediaReolinkCameraMetadata>,
) => this._reolinkCameraMetadataGenerator(media),
matcher: (media: RichBrowseMedia<BrowseMediaReolinkCameraMetadata>): 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 }),
},
);
}
+5
View File
@@ -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
// ==============================
@@ -56,9 +56,9 @@ export interface BrowseMediaStep<M> {
type BrowseMediaStepAdvancer<M> = (media: RichBrowseMedia<M>[]) => BrowseMediaStep<M>[];
export class BrowseMediaManager<M> {
export class BrowseMediaManager {
// Walk down a browse media tree according to instructions included in `steps`.
public async walkBrowseMedias(
public async walkBrowseMedias<M>(
hass: HomeAssistant,
steps: BrowseMediaStep<M>[] | null,
options?: {
@@ -77,7 +77,7 @@ export class BrowseMediaManager<M> {
).flat();
}
protected async _walkBrowseMedia(
protected async _walkBrowseMedia<M>(
hass: HomeAssistant,
step: BrowseMediaStep<M>,
options?: {
@@ -92,7 +92,6 @@ export class BrowseMediaManager<M> {
async (target) =>
await this._browseMedia(hass, target, {
cache: options?.cache,
matcher: step.matcher,
metadataGenerator: step.metadataGenerator,
}),
);
@@ -121,12 +120,11 @@ export class BrowseMediaManager<M> {
return await this.walkBrowseMedias(hass, nextSteps, options);
}
protected async _browseMedia(
protected async _browseMedia<M>(
hass: HomeAssistant,
target: string | RichBrowseMedia<M>,
options?: {
cache?: BrowseMediaCache<M>;
matcher?: RichBrowseMediaPredicate<M>;
metadataGenerator?: RichMetadataGenerator<M>;
},
): Promise<RichBrowseMedia<M>> {
@@ -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<BrowseMediaMetadata>;
browseMediaManager?: BrowseMediaManager;
entityRegistryManager?: EntityRegistryManager;
}): ReolinkCameraManagerEngine => {
return new ReolinkCameraManagerEngine(
options?.entityRegistryManager ?? mock<EntityRegistryManager>(),
mock<StateWatcher>(),
options?.browseMediaManager ?? new BrowseMediaManager<BrowseMediaMetadata>(),
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,