diff --git a/src/camera-manager/browse-media/engine-browse-media.ts b/src/camera-manager/browse-media/engine-browse-media.ts index a051da35..751c00b8 100644 --- a/src/camera-manager/browse-media/engine-browse-media.ts +++ b/src/camera-manager/browse-media/engine-browse-media.ts @@ -20,6 +20,8 @@ import { Entity } from '../../utils/ha/entity-registry/types'; import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager'; import { BROWSE_MEDIA_CACHE_SECONDS, + MEDIA_CLASS_IMAGE, + MEDIA_CLASS_VIDEO, RichBrowseMedia, } from '../../utils/ha/browse-media/types'; import { BrowseMediaMetadata } from './types'; @@ -27,6 +29,89 @@ import { rangesOverlap } from '../range'; import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media'; import { canonicalizeHAURL } from '../../utils/ha'; import { RequestCache } from '../cache'; +import { BrowseMediaViewMediaFactory } from './media'; + +/** + * A utility method to determine if a browse media object matches against a + * start and end date. + * @param media The browse media object (with rich metadata). + * @param start The optional start date. + * @param end The optional end date. + * @returns `true` if the media falls within the provided dates. + */ +export const isMediaWithinDates = ( + media: RichBrowseMedia, + start?: Date, + end?: Date, +): boolean => { + // If no date is specified at all, everything matches. + const dateReference = start ?? end; + if (!dateReference) { + return true; + } + + // If there's no metadata, nothing matches. + if (!media._metadata) { + return false; + } + + // Determine if: + // - The media starts within the query timeframe. + // - The media ends within the query timeframe. + // - The media entirely encompasses the query timeframe. + return rangesOverlap( + { + start: media._metadata.startDate, + end: media._metadata.endDate, + }, + { + start: start ?? dateReference, + end: end ?? dateReference, + }, + ); +}; + +export const getViewMediaFromBrowseMediaArray = ( + browseMedia: RichBrowseMedia[], +): ViewMedia[] | null => { + const lookup: Map = new Map(); + for (const browseMediaItem of browseMedia) { + const cameraID = browseMediaItem._metadata?.cameraID; + if (!cameraID) { + continue; + } + + const mediaType = + browseMediaItem.media_class === MEDIA_CLASS_VIDEO + ? 'clip' + : browseMediaItem.media_class === MEDIA_CLASS_IMAGE + ? 'snapshot' + : null; + + if (!mediaType) { + continue; + } + const media = BrowseMediaViewMediaFactory.createEventViewMedia( + mediaType, + browseMediaItem, + cameraID, + ); + + if (media) { + const id = media.getID(); + const existing = lookup.get(id); + // De-duplicate events with precisely the same ID (same + // hour/minute/second) choosing clip > snapshot. + if ( + !existing || + (existing.getMediaType() === 'snapshot' && media.getMediaType() === 'clip') + ) { + lookup.set(id, media); + } + } + } + return [...lookup.values()]; +}; /** * A base class for cameras that read events from HA BrowseMedia interface. @@ -83,46 +168,6 @@ export class BrowseMediaCameraManagerEngine ]; } - /** - * A utility method to determine if a browse media object matches against a - * start and end date. - * @param media The browse media object (with rich metadata). - * @param start The optional start date. - * @param end The optional end date. - * @returns `true` if the media falls within the provided dates. - */ - protected _mediaIsWithinDates = ( - media: RichBrowseMedia, - start?: Date, - end?: Date, - ): boolean => { - // If no date is specified at all, everything matches. - const dateReference = start ?? end; - if (!dateReference) { - return true; - } - - // If there's no metadata, nothing matches. - if (!media._metadata) { - return false; - } - - // Determine if: - // - The media starts within the query timeframe. - // - The media ends within the query timeframe. - // - The media entirely encompasses the query timeframe. - return rangesOverlap( - { - start: media._metadata.startDate, - end: media._metadata.endDate, - }, - { - start: start ?? dateReference, - end: end ?? dateReference, - }, - ); - }; - public async getMediaDownloadPath( hass: ExtendedHomeAssistant, _cameraConfig: CameraConfig, diff --git a/src/camera-manager/browse-media/media.ts b/src/camera-manager/browse-media/media.ts index f9214eea..12f7803d 100644 --- a/src/camera-manager/browse-media/media.ts +++ b/src/camera-manager/browse-media/media.ts @@ -1,6 +1,7 @@ +import format from 'date-fns/format'; import isEqual from 'lodash-es/isEqual'; import { formatDateAndTime } from '../../utils/basic'; -import { MEDIA_CLASS_VIDEO, RichBrowseMedia } from '../../utils/ha/browse-media/types'; +import { RichBrowseMedia } from '../../utils/ha/browse-media/types'; import { ViewMedia, EventViewMedia, @@ -11,6 +12,7 @@ import { BrowseMediaMetadata } from '../browse-media/types'; class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMedia { protected _browseMedia: RichBrowseMedia; + protected _id: string; constructor( mediaType: ViewMediaType, @@ -19,11 +21,19 @@ class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMedia { ) { super(mediaType, cameraID); this._browseMedia = browseMedia; + + // Generate a custom ID that uses the start date (to allow multiple + // BrowseMedia objects (e.g. images and movies) to be de-duplicated). + if (browseMedia._metadata?.startDate) { + this._id = `${cameraID}/${format( + browseMedia._metadata.startDate, + 'yyyy-MM-dd HH:mm:ss', + )}`; + } else { + this._id = browseMedia.media_content_id; + } } - public hasClip(): boolean { - return this._browseMedia.media_class === MEDIA_CLASS_VIDEO; - } public getStartTime(): Date | null { return this._browseMedia._metadata?.startDate ?? null; } @@ -34,7 +44,7 @@ class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMedia { return VideoContentType.MP4; } public getID(): string { - return this.getContentID(); + return this._id; } public getContentID(): string { return this._browseMedia.media_content_id; diff --git a/src/camera-manager/frigate/media.ts b/src/camera-manager/frigate/media.ts index ed6f6aa4..b4ae2228 100644 --- a/src/camera-manager/frigate/media.ts +++ b/src/camera-manager/frigate/media.ts @@ -43,9 +43,6 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia { this._subLabels = subLabels ?? null; } - public hasClip(): boolean { - return !!this._event.has_clip; - } public getStartTime(): Date { return fromUnixTime(this._event.start_time); } diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts index 7e471eaa..645a6634 100644 --- a/src/camera-manager/motioneye/engine-motioneye.ts +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -38,9 +38,12 @@ import { MotionEyeEventQueryResults } from './types'; import orderBy from 'lodash-es/orderBy'; import startOfDay from 'date-fns/startOfDay'; import add from 'date-fns/add'; -import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media'; +import { + BrowseMediaCameraManagerEngine, + getViewMediaFromBrowseMediaArray, + isMediaWithinDates, +} from '../browse-media/engine-browse-media'; import { BrowseMediaMetadata } from '../browse-media/types'; -import { BrowseMediaViewMediaFactory } from '../browse-media/media'; import motioneyeLogo from './assets/motioneye-logo.svg'; class MotionEyeQueryResultsClassifier { @@ -172,7 +175,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine matcher: (media: RichBrowseMedia) => media.can_expand && (!!dateFormat || media.title === next) && - this._mediaIsWithinDates(media, matchOptions?.start, matchOptions?.end), + isMediaWithinDates(media, matchOptions?.start, matchOptions?.end), advance: (media) => generateNextStep(parts, media), }, ]; @@ -271,7 +274,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine }, matcher: (media: RichBrowseMedia) => !media.can_expand && - this._mediaIsWithinDates(media, perCameraQuery.start, perCameraQuery.end), + isMediaWithinDates(media, perCameraQuery.start, perCameraQuery.end), }, ], { useCache: engineOptions?.useCache }, @@ -313,34 +316,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine if (!MotionEyeQueryResultsClassifier.isMotionEyeEventQueryResults(results)) { return null; } - - const output: ViewMedia[] = []; - for (const browseMedia of results.browseMedia) { - const cameraID = browseMedia._metadata?.cameraID; - if (!cameraID) { - continue; - } - - const mediaType = - browseMedia.media_class === MEDIA_CLASS_VIDEO - ? 'clip' - : browseMedia.media_class === MEDIA_CLASS_IMAGE - ? 'snapshot' - : null; - - if (!mediaType) { - continue; - } - const media = BrowseMediaViewMediaFactory.createEventViewMedia( - mediaType, - browseMedia, - cameraID, - ); - if (media) { - output.push(media); - } - } - return output; + return getViewMediaFromBrowseMediaArray(results.browseMedia); } public async getMediaMetadata( diff --git a/src/components/viewer.ts b/src/components/viewer.ts index fd6c04f2..309c6d78 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -621,10 +621,6 @@ export class FrigateCardViewerProvider // If this specific media item has no clip, then do nothing (even if all // the other media items do). !ViewMediaClassifier.isEvent(this.media) || - // If the event certainly has no clip, don't bother going further. If - // we're not sure for this camera type (i.e. hasClip() === null) the query - // will proceed anyway. - this.media.hasClip() === false || !MediaQueriesClassifier.areEventQueries(this.view.query) ) { return; diff --git a/src/utils/ha/browse-media/types.ts b/src/utils/ha/browse-media/types.ts index 5f00dc37..15c3109c 100644 --- a/src/utils/ha/browse-media/types.ts +++ b/src/utils/ha/browse-media/types.ts @@ -38,4 +38,4 @@ export interface RichBrowseMedia extends BrowseMedia { export const MEDIA_CLASS_VIDEO = 'video' as const; export const MEDIA_CLASS_IMAGE = 'image' as const; -export const BROWSE_MEDIA_CACHE_SECONDS = 60 as const; \ No newline at end of file +export const BROWSE_MEDIA_CACHE_SECONDS = 60 as const; diff --git a/src/view/media.ts b/src/view/media.ts index 40adb086..5af18c04 100644 --- a/src/view/media.ts +++ b/src/view/media.ts @@ -71,7 +71,6 @@ export interface EventViewMedia extends ViewMedia { getWhat(): string[] | null; getTags(): string[] | null; isGroupableWith(that: EventViewMedia): boolean; - hasClip(): boolean | null; } export interface RecordingViewMedia extends ViewMedia {