From 304c7e31647ae7049cc43041bf1f90d8e8f3aaaa Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 26 Mar 2023 16:42:21 -0700 Subject: [PATCH] Initial motionEye commit. --- package.json | 4 +- .../browse-media/engine-browse-media.ts | 170 +++++++ src/camera-manager/browse-media/media.ts | 75 +++ src/camera-manager/browse-media/types.ts | 5 + src/camera-manager/cache.ts | 2 +- src/camera-manager/engine-factory.ts | 20 +- src/camera-manager/engine.ts | 9 +- .../frigate/assets/frigate-logo-dark.svg | 3 + src/camera-manager/frigate/engine-frigate.ts | 57 +-- src/camera-manager/frigate/media.ts | 14 + src/camera-manager/frigate/types.ts | 1 + src/camera-manager/generic/engine-generic.ts | 12 +- src/camera-manager/manager.ts | 47 +- .../motioneye/assets/motioneye-logo.svg | 242 ++++++++++ .../motioneye/engine-motioneye.ts | 427 ++++++++++++++++++ src/camera-manager/motioneye/icon.ts | 51 +++ src/camera-manager/motioneye/types.ts | 12 + src/camera-manager/range.ts | 2 +- src/camera-manager/types.ts | 2 + src/camera-manager/util.ts | 16 + src/card.ts | 6 +- src/components/gallery.ts | 14 +- src/components/live/live.ts | 8 +- src/components/media-carousel.ts | 4 + src/components/menu.ts | 2 +- src/components/submenu.ts | 8 +- src/components/thumbnail.ts | 60 +-- src/components/timeline-core.ts | 22 +- src/components/title-control.ts | 13 +- src/components/viewer.ts | 111 ++++- src/const.ts | 15 +- src/declarations.d.ts | 1 + src/editor.ts | 52 ++- src/localize/languages/en.json | 17 +- src/localize/languages/it.json | 13 + src/localize/languages/pt-BR.json | 13 + src/patches/ha-camera-stream.ts | 9 - src/patches/ha-hls-player.ts | 2 +- src/scss/gallery.scss | 5 +- src/scss/message.scss | 4 +- src/scss/thumbnail-feature-event.scss | 6 + src/scss/title-control.scss | 7 + src/scss/viewer-provider.scss | 1 + src/types.ts | 76 +++- src/utils/basic.ts | 4 + src/utils/download.ts | 35 +- src/utils/endpoint.ts | 25 +- .../ha/browse-media/browse-media-manager.ts | 158 +++++++ src/utils/ha/browse-media/types.ts | 41 ++ src/utils/ha/entity-registry/types.ts | 1 + src/utils/ha/index.ts | 7 +- src/utils/media.ts | 14 +- src/utils/thumbnail.ts | 38 +- src/view/media.ts | 11 + yarn.lock | 51 ++- 55 files changed, 1780 insertions(+), 245 deletions(-) create mode 100644 src/camera-manager/browse-media/engine-browse-media.ts create mode 100644 src/camera-manager/browse-media/media.ts create mode 100644 src/camera-manager/browse-media/types.ts create mode 100644 src/camera-manager/frigate/assets/frigate-logo-dark.svg create mode 100644 src/camera-manager/motioneye/assets/motioneye-logo.svg create mode 100644 src/camera-manager/motioneye/engine-motioneye.ts create mode 100644 src/camera-manager/motioneye/icon.ts create mode 100644 src/camera-manager/motioneye/types.ts create mode 100644 src/utils/ha/browse-media/browse-media-manager.ts create mode 100644 src/utils/ha/browse-media/types.ts diff --git a/package.json b/package.json index 690ca3fd..ffc3a4e1 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "vis-util": "^5.0.2", "web-dialog": "^0.0.11", "xss": "^1.0.14", - "zod": "^3.20.6" + "zod": "^3.21.4" }, "devDependencies": { "@babel/core": "^7.19.0", @@ -51,7 +51,7 @@ "@babel/plugin-proposal-decorators": "^7.19.0", "@rollup/plugin-babel": "^5.3.1", "@rollup/plugin-commonjs": "^22.0.2", - "@rollup/plugin-image": "^2.1.1", + "@rollup/plugin-image": "^3.0.2", "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-node-resolve": "^13.3.0", "@rollup/plugin-replace": "^4.0.0", diff --git a/src/camera-manager/browse-media/engine-browse-media.ts b/src/camera-manager/browse-media/engine-browse-media.ts new file mode 100644 index 00000000..a051da35 --- /dev/null +++ b/src/camera-manager/browse-media/engine-browse-media.ts @@ -0,0 +1,170 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { CameraConfig, ExtendedHomeAssistant } from '../../types'; +import { ViewMedia } from '../../view/media'; +import { + CameraManagerMediaCapabilities, + DataQuery, + EventQuery, + PartialEventQuery, + CameraConfigs, + CameraManagerCameraCapabilities, + QueryType, + CameraEndpoint, +} from '../types'; +import { EntityRegistryManager } from '../../utils/ha/entity-registry'; +import { CameraManagerEngine } from '../engine'; +import { GenericCameraManagerEngine } from '../generic/engine-generic'; +import { CameraInitializationError } from '../error'; +import { localize } from '../../localize/localize'; +import { Entity } from '../../utils/ha/entity-registry/types'; +import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager'; +import { + BROWSE_MEDIA_CACHE_SECONDS, + RichBrowseMedia, +} from '../../utils/ha/browse-media/types'; +import { BrowseMediaMetadata } from './types'; +import { rangesOverlap } from '../range'; +import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media'; +import { canonicalizeHAURL } from '../../utils/ha'; +import { RequestCache } from '../cache'; + +/** + * A base class for cameras that read events from HA BrowseMedia interface. + */ +export class BrowseMediaCameraManagerEngine + extends GenericCameraManagerEngine + implements CameraManagerEngine +{ + protected _cameraEntities: Map = new Map(); + protected _browseMediaManager: BrowseMediaManager; + protected _resolvedMediaCache: ResolvedMediaCache; + protected _requestCache: RequestCache; + + public constructor( + browseMediaManager: BrowseMediaManager, + resolvedMediaCache: ResolvedMediaCache, + requestCache: RequestCache, + ) { + super(); + this._browseMediaManager = browseMediaManager; + this._resolvedMediaCache = resolvedMediaCache; + this._requestCache = requestCache; + } + + public async initializeCamera( + hass: HomeAssistant, + entityRegistryManager: EntityRegistryManager, + cameraConfig: CameraConfig, + ): Promise { + const entity = cameraConfig.camera_entity + ? await entityRegistryManager.getEntity(hass, cameraConfig.camera_entity) + : null; + if (!entity || !cameraConfig.camera_entity) { + throw new CameraInitializationError( + localize('error.no_camera_entity'), + cameraConfig, + ); + } + this._cameraEntities.set(cameraConfig.camera_entity, entity); + return cameraConfig; + } + + public generateDefaultEventQuery( + _cameras: CameraConfigs, + cameraIDs: Set, + query: PartialEventQuery, + ): EventQuery[] | null { + return [ + { + type: QueryType.Event, + cameraIDs: cameraIDs, + ...query, + }, + ]; + } + + /** + * 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, + media: ViewMedia, + ): Promise { + const contentID = media.getContentID(); + if (!contentID) { + return null; + } + const resolvedMedia = await resolveMedia(hass, contentID, this._resolvedMediaCache); + return resolvedMedia + ? { endpoint: canonicalizeHAURL(hass, resolvedMedia.url) } + : null; + } + + public getQueryResultMaxAge(query: DataQuery): number | null { + if (query.type === QueryType.Event) { + return BROWSE_MEDIA_CACHE_SECONDS; + } + return null; + } + + public getCameraCapabilities( + cameraConfig: CameraConfig, + ): CameraManagerCameraCapabilities | null { + const parentCapabilities = super.getCameraCapabilities(cameraConfig); + if (!parentCapabilities) { + return null; + } + return { + ...parentCapabilities, + supportsClips: true, + supportsSnapshots: true, + supportsTimeline: true, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities { + return { + canFavorite: false, + canDownload: true, + }; + } +} diff --git a/src/camera-manager/browse-media/media.ts b/src/camera-manager/browse-media/media.ts new file mode 100644 index 00000000..f9214eea --- /dev/null +++ b/src/camera-manager/browse-media/media.ts @@ -0,0 +1,75 @@ +import isEqual from 'lodash-es/isEqual'; +import { formatDateAndTime } from '../../utils/basic'; +import { MEDIA_CLASS_VIDEO, RichBrowseMedia } from '../../utils/ha/browse-media/types'; +import { + ViewMedia, + EventViewMedia, + ViewMediaType, + VideoContentType, +} from '../../view/media'; +import { BrowseMediaMetadata } from '../browse-media/types'; + +class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMedia { + protected _browseMedia: RichBrowseMedia; + + constructor( + mediaType: ViewMediaType, + cameraID: string, + browseMedia: RichBrowseMedia, + ) { + super(mediaType, cameraID); + this._browseMedia = browseMedia; + } + + public hasClip(): boolean { + return this._browseMedia.media_class === MEDIA_CLASS_VIDEO; + } + public getStartTime(): Date | null { + return this._browseMedia._metadata?.startDate ?? null; + } + public getEndTime(): Date | null { + return null; + } + public getVideoContentType(): VideoContentType | null { + return VideoContentType.MP4; + } + public getID(): string { + return this.getContentID(); + } + public getContentID(): string { + return this._browseMedia.media_content_id; + } + public getTitle(): string | null { + const startTime = this.getStartTime(); + return startTime ? formatDateAndTime(startTime) : this._browseMedia.title; + } + public getThumbnail(): string | null { + return this._browseMedia.thumbnail; + } + public getWhat(): string[] | null { + return null; + } + public getScore(): number | null { + return null; + } + public getTags(): string[] | null { + return null; + } + public isGroupableWith(that: EventViewMedia): boolean { + return ( + this.getMediaType() === that.getMediaType() && + isEqual(this.getWhere(), that.getWhere()) && + isEqual(this.getWhat(), that.getWhat()) + ); + } +} + +export class BrowseMediaViewMediaFactory { + static createEventViewMedia( + mediaType: 'clip' | 'snapshot', + browseMedia: RichBrowseMedia, + cameraID: string, + ): BrowseMediaEventViewMedia | null { + return new BrowseMediaEventViewMedia(mediaType, cameraID, browseMedia); + } +} diff --git a/src/camera-manager/browse-media/types.ts b/src/camera-manager/browse-media/types.ts new file mode 100644 index 00000000..09226f98 --- /dev/null +++ b/src/camera-manager/browse-media/types.ts @@ -0,0 +1,5 @@ +export interface BrowseMediaMetadata { + cameraID: string; + startDate: Date; + endDate: Date; +} diff --git a/src/camera-manager/cache.ts b/src/camera-manager/cache.ts index 291810da..a9a060fe 100644 --- a/src/camera-manager/cache.ts +++ b/src/camera-manager/cache.ts @@ -16,7 +16,7 @@ interface CameraManagerCache { set(request: Request, response: Response, expiry?: Date): void; } -class MemoryRequestCache +export class MemoryRequestCache implements CameraManagerCache { protected _data: RequestCacheItem[] = []; diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index 850dddd2..42069466 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -1,25 +1,32 @@ import { HomeAssistant } from 'custom-card-helpers'; import { localize } from '../localize/localize'; import { CameraConfig, CardWideConfig } from '../types'; +import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager'; +import { BrowseMedia } from '../utils/ha/browse-media/types'; import { EntityRegistryManager } from '../utils/ha/entity-registry'; import { Entity } from '../utils/ha/entity-registry/types'; -import { RecordingSegmentsCache, RequestCache } from './cache'; +import { ResolvedMediaCache } from '../utils/ha/resolved-media'; +import { MemoryRequestCache, RecordingSegmentsCache, RequestCache } from './cache'; import { CameraManagerEngine } from './engine'; import { CameraInitializationError } from './error'; import { FrigateCameraManagerEngine } from './frigate/engine-frigate'; import { GenericCameraManagerEngine } from './generic/engine-generic'; +import { MotionEyeCameraManagerEngine } from './motioneye/engine-motioneye'; import { Engine } from './types'; export class CameraManagerEngineFactory { protected _entityRegistryManager: EntityRegistryManager; + protected _resolvedMediaCache: ResolvedMediaCache; protected _cardWideConfig: CardWideConfig; constructor( entityRegistryManager: EntityRegistryManager, + resolvedMediaCache: ResolvedMediaCache, cardWideConfig: CardWideConfig, ) { this._entityRegistryManager = entityRegistryManager; this._cardWideConfig = cardWideConfig; + this._resolvedMediaCache = resolvedMediaCache; } public createEngine(engine: Engine): CameraManagerEngine | null { @@ -35,6 +42,12 @@ export class CameraManagerEngineFactory { new RequestCache(), ); break; + case Engine.MotionEye: + cameraManagerEngine = new MotionEyeCameraManagerEngine( + new BrowseMediaManager(new MemoryRequestCache()), + this._resolvedMediaCache, + new RequestCache(), + ); } return cameraManagerEngine; } @@ -50,6 +63,8 @@ export class CameraManagerEngineFactory { let engine: Engine | null = null; if (cameraConfig.engine === 'frigate') { engine = Engine.Frigate; + } else if (cameraConfig.engine === 'motioneye') { + engine = Engine.MotionEye; } else if (cameraConfig.engine === 'auto') { const cameraEntity = cameraConfig.camera_entity; @@ -70,6 +85,9 @@ export class CameraManagerEngineFactory { case 'frigate': engine = Engine.Frigate; break; + case 'motioneye': + engine = Engine.MotionEye; + break; default: engine = Engine.Generic; } diff --git a/src/camera-manager/engine.ts b/src/camera-manager/engine.ts index 521b665a..518113c8 100644 --- a/src/camera-manager/engine.ts +++ b/src/camera-manager/engine.ts @@ -1,5 +1,5 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { CameraConfig } from '../types'; +import { CameraConfig, ExtendedHomeAssistant } from '../types'; import { EntityRegistryManager } from '../utils/ha/entity-registry'; import { ViewMedia } from '../view/media'; import { @@ -24,6 +24,7 @@ import { MediaMetadataQuery, MediaMetadataQueryResultsMap, EngineOptions, + CameraEndpoint, } from './types'; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; @@ -90,7 +91,11 @@ export interface CameraManagerEngine { results: QueryReturnType, ): ViewMedia[] | null; - getMediaDownloadPath(cameraConfig: CameraConfig, media: ViewMedia): string | null; + getMediaDownloadPath( + hass: ExtendedHomeAssistant, + cameraConfig: CameraConfig, + media: ViewMedia, + ): Promise; favoriteMedia( hass: HomeAssistant, diff --git a/src/camera-manager/frigate/assets/frigate-logo-dark.svg b/src/camera-manager/frigate/assets/frigate-logo-dark.svg new file mode 100644 index 00000000..16cd275c --- /dev/null +++ b/src/camera-manager/frigate/assets/frigate-logo-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 5fd0a4b5..1e1788d4 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -2,7 +2,7 @@ import { HomeAssistant } from 'custom-card-helpers'; import add from 'date-fns/add'; import endOfHour from 'date-fns/endOfHour'; import startOfHour from 'date-fns/startOfHour'; -import { CameraConfig, CardWideConfig } from '../../types'; +import { CameraConfig, CardWideConfig, ExtendedHomeAssistant } from '../../types'; import { ViewMedia } from '../../view/media'; import { RecordingSegmentsCache, RequestCache } from '../cache'; import { @@ -80,6 +80,7 @@ import { localize } from '../../localize/localize'; import uniq from 'lodash-es/uniq'; import format from 'date-fns/format'; import { GenericCameraManagerEngine } from '../generic/engine-generic'; +import frigateLogo from './assets/frigate-logo-dark.svg'; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; @@ -302,26 +303,32 @@ export class FrigateCameraManagerEngine return null; } - public getMediaDownloadPath( + public async getMediaDownloadPath( + _hass: ExtendedHomeAssistant, cameraConfig: CameraConfig, media: ViewMedia, - ): string | null { - let path: string | null = null; + ): Promise { if (FrigateViewMediaClassifier.isFrigateEvent(media)) { - path = - `/api/frigate/${cameraConfig.frigate.client_id}` + - `/notifications/${media.getID()}/` + - `${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` + - `?download=true`; + return { + endpoint: + `/api/frigate/${cameraConfig.frigate.client_id}` + + `/notifications/${media.getID()}/` + + `${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` + + `?download=true`, + sign: true, + }; } else if (FrigateViewMediaClassifier.isFrigateRecording(media)) { - path = - `/api/frigate/${cameraConfig.frigate.client_id}` + - `/recording/${cameraConfig.frigate.camera_name}` + - `/start/${Math.floor(media.getStartTime().getTime() / 1000)}` + - `/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` + - `?download=true`; + return { + endpoint: + `/api/frigate/${cameraConfig.frigate.client_id}` + + `/recording/${cameraConfig.frigate.camera_name}` + + `/start/${Math.floor(media.getStartTime().getTime() / 1000)}` + + `/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` + + `?download=true`, + sign: true, + }; } - return path; + return null; } public generateDefaultEventQuery( @@ -1097,6 +1104,7 @@ export class FrigateCameraManagerEngine cameraConfig.id ?? '', icon: metadata.icon, + engineLogo: frigateLogo, }; } @@ -1178,7 +1186,7 @@ export class FrigateCameraManagerEngine }; const getWebRTCCard = (): CameraEndpoint | null => { - // By defaykt use the frigate camera name which is the default recommended + // By default use the frigate camera name which is the default recommended // setup as per: // https://deploy-preview-4055--frigate-docs.netlify.app/guides/configuring_go2rtc/ // @@ -1194,14 +1202,11 @@ export class FrigateCameraManagerEngine const jsmpeg = getJSMPEG(); const webrtcCard = getWebRTCCard(); - return ui || go2rtc || jsmpeg - ? { - ...(ui && { ui: ui }), - ...(go2rtc && { go2rtc: go2rtc }), - ...(jsmpeg && { jsmpeg: jsmpeg }), - ...(jsmpeg && { jsmpeg: jsmpeg }), - ...(webrtcCard && { webrtcCard: webrtcCard }), - } - : null; + return { + ...(ui && { ui: ui }), + ...(go2rtc && { go2rtc: go2rtc }), + ...(jsmpeg && { jsmpeg: jsmpeg }), + ...(webrtcCard && { webrtcCard: webrtcCard }), + }; } } diff --git a/src/camera-manager/frigate/media.ts b/src/camera-manager/frigate/media.ts index e98b4ee3..ed6f6aa4 100644 --- a/src/camera-manager/frigate/media.ts +++ b/src/camera-manager/frigate/media.ts @@ -6,6 +6,7 @@ import { EventViewMedia, RecordingViewMedia, ViewMediaType, + VideoContentType, } from '../../view/media'; import { FrigateEvent, FrigateRecording } from './types'; import { @@ -51,6 +52,14 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia { public getEndTime(): Date | null { return this._event.end_time ? fromUnixTime(this._event.end_time) : null; } + public inProgress(): boolean | null { + // In Frigate, events/recordings always have end times unless they are in + // progress. + return !this.getEndTime(); + } + public getVideoContentType(): VideoContentType | null { + return VideoContentType.HLS; + } public getID(): string { return this._event.id; } @@ -123,6 +132,11 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie public getEndTime(): Date { return this._recording.endTime; } + public inProgress(): boolean | null { + // In Frigate, events/recordings always have end times unless they are in + // progress. + return !this.getEndTime(); + } public getContentID(): string | null { return this._contentID; } diff --git a/src/camera-manager/frigate/types.ts b/src/camera-manager/frigate/types.ts index a80481eb..ef18041c 100644 --- a/src/camera-manager/frigate/types.ts +++ b/src/camera-manager/frigate/types.ts @@ -67,6 +67,7 @@ export interface FrigateRecording { export const eventSummarySchema = z .object({ camera: z.string(), + // Days in RFC3339 format. day: z.string(), label: z.string(), sub_label: z.string().nullable(), diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index b9022251..f2d50209 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { HomeAssistant } from 'custom-card-helpers'; -import { CameraConfig } from '../../types'; +import { CameraConfig, ExtendedHomeAssistant } from '../../types'; import { ViewMedia } from '../../view/media'; import { CameraManagerCameraMetadata, @@ -25,6 +25,7 @@ import { MediaMetadataQuery, MediaMetadataQueryResultsMap, EngineOptions, + CameraEndpoint, } from '../types'; import { getEntityIcon, getEntityTitle } from '../../utils/ha'; import { EntityRegistryManager } from '../../utils/ha/entity-registry'; @@ -112,10 +113,11 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { return null; } - public getMediaDownloadPath( + public async getMediaDownloadPath( + _hass: ExtendedHomeAssistant, _cameraConfig: CameraConfig, _media: ViewMedia, - ): string | null { + ): Promise { return null; } @@ -174,12 +176,12 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { ): CameraManagerCameraCapabilities | null { return { canFavoriteEvents: false, - canFavoriteRecordings:false, + canFavoriteRecordings: false, supportsClips: false, supportsRecordings: false, supportsSnapshots: false, supportsTimeline: false, - } + }; } public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities | null { diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index d2b0a104..e128e400 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -1,5 +1,10 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { CameraConfig, CamerasConfig, CardWideConfig } from '../types.js'; +import { + CameraConfig, + CamerasConfig, + CardWideConfig, + ExtendedHomeAssistant, +} from '../types.js'; import { allPromises, arrayify, setify } from '../utils/basic.js'; import { CameraManagerCameraCapabilities, @@ -34,11 +39,10 @@ import { MediaMetadataQuery, MediaMetadataQueryResults, EngineOptions, + CameraEndpoint, } from './types.js'; -import orderBy from 'lodash-es/orderBy'; import { CameraManagerEngineFactory } from './engine-factory.js'; import { ViewMedia } from '../view/media.js'; -import uniqBy from 'lodash-es/uniqBy'; import { CameraManagerEngine } from './engine.js'; import sum from 'lodash-es/sum'; import add from 'date-fns/add'; @@ -50,6 +54,7 @@ import { CameraInitializationError } from './error.js'; import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js'; import cloneDeep from 'lodash-es/cloneDeep'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js'; +import { sortMedia } from './util.js'; class QueryClassifier { public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery { @@ -356,7 +361,7 @@ export class CameraManager { concreteQueries.push(query as PartialQueryConcreteType); } } - return concreteQueries; + return concreteQueries.length ? concreteQueries : null; } public async getEvents( @@ -459,20 +464,31 @@ export class CameraManager { return null; } + const outputMedia = sortMedia(results.concat(newChunkMedia)); + + // If the media did not _ACTUALLY_ get longer, there is no new media despite + // the increased limit, so just return null. + if (outputMedia.length === results.length) { + return null; + } + return { queries: extendedQueries, - results: this._sortMedia(results.concat(newChunkMedia)), + results: outputMedia, }; } - public getMediaDownloadPath(media: ViewMedia): string | null { + public async getMediaDownloadPath( + hass: ExtendedHomeAssistant, + media: ViewMedia, + ): Promise { const cameraConfig = this._store.getCameraConfigForMedia(media); const engine = this._store.getEngineForMedia(media); if (!cameraConfig || !engine) { return null; } - return engine.getMediaDownloadPath(cameraConfig, media); + return await engine.getMediaDownloadPath(hass, cameraConfig, media); } public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null { @@ -623,7 +639,7 @@ export class CameraManager { await Promise.all(_queries.map((query) => processQuery(query))); const cachedOutputQueries = sum( - Array.from(results.values()).map((result) => Number(result.cached)), + Array.from(results.values()).map((result) => Number(result.cached ?? 0)), ); log( @@ -681,20 +697,7 @@ export class CameraManager { } } } - return this._sortMedia(mediaArray); - } - - protected _sortMedia(mediaArray: ViewMedia[]): ViewMedia[] { - return orderBy( - // Ensure uniqueness by the ID (if specified), otherwise all elements - // are assumed to be unique. - uniqBy(mediaArray, (media) => media.getID() ?? media), - - // Sort all items leading oldest -> youngest (so media is loaded in this - // order in the viewer which matches the left-to-right timeline order). - (media) => media.getStartTime(), - 'asc', - ); + return sortMedia(mediaArray); } public getCameraEndpoints( diff --git a/src/camera-manager/motioneye/assets/motioneye-logo.svg b/src/camera-manager/motioneye/assets/motioneye-logo.svg new file mode 100644 index 00000000..28ba99bb --- /dev/null +++ b/src/camera-manager/motioneye/assets/motioneye-logo.svg @@ -0,0 +1,242 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts new file mode 100644 index 00000000..7e471eaa --- /dev/null +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -0,0 +1,427 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { CameraConfig } from '../../types'; +import { ViewMedia } from '../../view/media'; +import { + CameraConfigs, + CameraEndpoint, + CameraEndpoints, + CameraEndpointsContext, + CameraManagerCameraMetadata, + Engine, + EngineOptions, + EventQuery, + EventQueryResults, + EventQueryResultsMap, + MediaMetadataQuery, + MediaMetadataQueryResults, + MediaMetadataQueryResultsMap, + QueryResults, + QueryResultsType, + QueryReturnType, +} from '../types'; +import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine'; +import { + BrowseMediaStep, + BrowseMediaTarget, +} from '../../utils/ha/browse-media/browse-media-manager'; +import { allPromises, formatDate, isValidDate } from '../../utils/basic'; +import endOfDay from 'date-fns/endOfDay'; +import { + BROWSE_MEDIA_CACHE_SECONDS, + BrowseMedia, + MEDIA_CLASS_IMAGE, + MEDIA_CLASS_VIDEO, + RichBrowseMedia, +} from '../../utils/ha/browse-media/types'; +import parse from 'date-fns/parse'; +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 { BrowseMediaMetadata } from '../browse-media/types'; +import { BrowseMediaViewMediaFactory } from '../browse-media/media'; +import motioneyeLogo from './assets/motioneye-logo.svg'; + +class MotionEyeQueryResultsClassifier { + public static isMotionEyeEventQueryResults( + results: QueryResults, + ): results is MotionEyeEventQueryResults { + return ( + results.engine === Engine.MotionEye && results.type === QueryResultsType.Event + ); + } +} + +const MOTIONEYE_REPL_SUBSTITUTIONS: Record = { + '%Y': 'yyyy', + '%m': 'MM', + '%d': 'dd', + '%H': 'HH', + '%M': 'mm', + '%S': 'SS', +}; +const MOTIONEYE_REPL_REGEXP = new RegExp(/(%Y|%m|%d|%H|%M|%S)/g); + +export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine { + public getEngineType(): Engine { + return Engine.MotionEye; + } + + protected _convertMotionEyeTimeFormatToDateFNS(part: string): string { + return part.replace( + MOTIONEYE_REPL_REGEXP, + (_, key) => MOTIONEYE_REPL_SUBSTITUTIONS[key], + ); + } + + // Get metadata for a MotionEye media file. + protected _motionEyeMetadataGeneratorFile( + cameraID: string, + dateFormat: string | null, + media: BrowseMedia, + parent?: RichBrowseMedia, + ): BrowseMediaMetadata | null { + let startDate = parent?._metadata?.startDate ?? new Date(); + if (dateFormat) { + const extensionlessTitle = media.title.replace(/\.[^/.]+$/, ''); + startDate = parse(extensionlessTitle, dateFormat, startDate); + if (!isValidDate(startDate)) { + return null; + } + } + return { + cameraID: cameraID, + startDate: startDate, + // MotionEye only has start times, the event is effectively a 'point' + endDate: startDate, + }; + } + + // Get metadata for a MotionEye media directory. + protected _motionEyeMetadataGeneratorDirectory( + cameraID: string, + dateFormat: string | null, + media: BrowseMedia, + parent?: RichBrowseMedia, + ): BrowseMediaMetadata | null { + let startDate = parent?._metadata?.startDate ?? new Date(); + if (dateFormat) { + const parsedDate = parse(media.title, dateFormat, startDate); + if (!isValidDate(parsedDate)) { + return null; + } + startDate = startOfDay(parsedDate); + } + return { + cameraID: cameraID, + startDate: startDate, + endDate: parent?._metadata?.endDate ?? endOfDay(startDate), + }; + } + + // Get media directories that match a given criteria. + protected async _getMatchingDirectories( + hass: HomeAssistant, + cameras: CameraConfigs, + cameraID: string, + matchOptions?: { + start?: Date; + end?: Date; + hasClip?: boolean; + hasSnapshot?: boolean; + } | null, + engineOptions?: EngineOptions, + ): Promise[] | null> { + const cameraEntityID = cameras.get(cameraID)?.camera_entity; + const entity = cameraEntityID ? this._cameraEntities.get(cameraEntityID) : null; + const configID = entity?.config_entry_id; + const deviceID = entity?.device_id; + const cameraConfig = cameras.get(cameraID); + + if (!configID || !deviceID || !cameraConfig) { + return null; + } + + const generateNextStep = ( + parts: string[], + media: BrowseMediaTarget[], + ): BrowseMediaStep[] => { + const next = parts.shift(); + if (!next) { + return []; + } + + const dateFormat = next.includes('%') + ? this._convertMotionEyeTimeFormatToDateFNS(next) + : null; + + return [ + { + targets: media, + metadataGenerator: ( + media: BrowseMedia, + parent?: RichBrowseMedia, + ) => + this._motionEyeMetadataGeneratorDirectory( + cameraID, + dateFormat, + media, + parent, + ), + matcher: (media: RichBrowseMedia) => + media.can_expand && + (!!dateFormat || media.title === next) && + this._mediaIsWithinDates(media, matchOptions?.start, matchOptions?.end), + advance: (media) => generateNextStep(parts, media), + }, + ]; + }; + + // For motionEye snapshots and clips are mutually exclusive. + return await this._browseMediaManager.walkBrowseMedias( + hass, + [ + ...(matchOptions?.hasClip !== false && !matchOptions?.hasSnapshot + ? generateNextStep( + cameraConfig.motioneye.movies.directory_pattern.split('/'), + [`media-source://motioneye/${configID}#${deviceID}#movies`], + ) + : []), + ...(matchOptions?.hasSnapshot !== false && !matchOptions?.hasClip + ? generateNextStep( + cameraConfig.motioneye.images.directory_pattern.split('/'), + [`media-source://motioneye/${configID}#${deviceID}#images`], + ) + : []), + ], + { + useCache: engineOptions?.useCache, + }, + ); + } + + public async getEvents( + hass: HomeAssistant, + cameras: CameraConfigs, + query: EventQuery, + engineOptions?: EngineOptions, + ): Promise { + // MotionEye does not support these query types and they will never match. + if (query.favorite || query.tags?.size || query.what?.size || query.where?.size) { + return null; + } + + const output: EventQueryResultsMap = new Map(); + const getEventsForCamera = async (cameraID: string): Promise => { + const perCameraQuery = { ...query, cameraIDs: new Set([cameraID]) }; + const cachedResult = + engineOptions?.useCache ?? true ? this._requestCache.get(perCameraQuery) : null; + if (cachedResult) { + output.set(perCameraQuery, cachedResult as EventQueryResults); + return; + } + + const cameraConfig = cameras.get(cameraID); + if (!cameraConfig) { + return; + } + + const directories = await this._getMatchingDirectories( + hass, + cameras, + cameraID, + perCameraQuery, + engineOptions, + ); + if (!directories || !directories.length) { + return; + } + + const moviesDateFormat = this._convertMotionEyeTimeFormatToDateFNS( + cameraConfig.motioneye.movies.file_pattern, + ); + const imagesDateFormat = this._convertMotionEyeTimeFormatToDateFNS( + cameraConfig.motioneye.images.file_pattern, + ); + + const media = await this._browseMediaManager.walkBrowseMedias( + hass, + [ + { + targets: directories, + metadataGenerator: ( + media: BrowseMedia, + parent?: RichBrowseMedia, + ) => { + if ( + media.media_class === MEDIA_CLASS_IMAGE || + media.media_class === MEDIA_CLASS_VIDEO + ) { + return this._motionEyeMetadataGeneratorFile( + cameraID, + media.media_class === MEDIA_CLASS_IMAGE + ? imagesDateFormat + : moviesDateFormat, + media, + parent, + ); + } + return null; + }, + matcher: (media: RichBrowseMedia) => + !media.can_expand && + this._mediaIsWithinDates(media, perCameraQuery.start, perCameraQuery.end), + }, + ], + { useCache: engineOptions?.useCache }, + ); + + // Sort by most recent then slice at the query limit. + const sortedMedia = orderBy( + media, + (media: RichBrowseMedia) => media._metadata?.startDate, + 'desc', + ).slice(0, perCameraQuery.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT); + + const result: MotionEyeEventQueryResults = { + type: QueryResultsType.Event, + engine: Engine.MotionEye, + browseMedia: sortedMedia, + }; + + if (engineOptions?.useCache ?? true) { + this._requestCache.set( + perCameraQuery, + { ...result, cached: true }, + result.expiry, + ); + } + output.set(perCameraQuery, result); + }; + + await allPromises(query.cameraIDs, (cameraID) => getEventsForCamera(cameraID)); + return output.size ? output : null; + } + + public generateMediaFromEvents( + _hass: HomeAssistant, + _cameras: CameraConfigs, + _query: EventQuery, + results: QueryReturnType, + ): ViewMedia[] | null { + 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; + } + + public async getMediaMetadata( + hass: HomeAssistant, + cameras: CameraConfigs, + query: MediaMetadataQuery, + engineOptions?: EngineOptions, + ): Promise { + const output: MediaMetadataQueryResultsMap = new Map(); + if ((engineOptions?.useCache ?? true) && this._requestCache.has(query)) { + const cachedResult = ( + this._requestCache.get(query) + ); + if (cachedResult) { + output.set(query, cachedResult as MediaMetadataQueryResults); + return output; + } + } + + const days: Set = new Set(); + const getDaysForCamera = async (cameraID: string): Promise => { + const directories = await this._getMatchingDirectories( + hass, + cameras, + cameraID, + null, + engineOptions, + ); + for (const dayDirectory of directories ?? []) { + if (dayDirectory._metadata) { + days.add(formatDate(dayDirectory._metadata?.startDate)); + } + } + }; + + await allPromises(query.cameraIDs, (cameraID) => getDaysForCamera(cameraID)); + + const result: MediaMetadataQueryResults = { + type: QueryResultsType.MediaMetadata, + engine: Engine.MotionEye, + metadata: { + ...(days.size && { days: days }), + }, + expiry: add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }), + cached: false, + }; + + if (engineOptions?.useCache ?? true) { + this._requestCache.set(query, { ...result, cached: true }, result.expiry); + } + output.set(query, result); + return output; + } + + public getCameraMetadata( + hass: HomeAssistant, + cameraConfig: CameraConfig, + ): CameraManagerCameraMetadata { + const metadata = super.getCameraMetadata(hass, cameraConfig); + return { + ...metadata, + engineLogo: motioneyeLogo, + }; + } + + public getCameraEndpoints( + cameraConfig: CameraConfig, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _context?: CameraEndpointsContext, + ): CameraEndpoints | null { + const getUIEndpoint = (): CameraEndpoint | null => { + return cameraConfig.motioneye?.url + ? { + endpoint: cameraConfig.motioneye.url, + } + : null; + }; + + const ui = getUIEndpoint(); + return { + ...(ui && { ui: ui }), + }; + } +} diff --git a/src/camera-manager/motioneye/icon.ts b/src/camera-manager/motioneye/icon.ts new file mode 100644 index 00000000..f294c17e --- /dev/null +++ b/src/camera-manager/motioneye/icon.ts @@ -0,0 +1,51 @@ +// Converted from https://raw.githubusercontent.com/motioneye-project/motioneye/python2/motioneye/static/img/motioneye-icon.svg . +export const MOTIONEYE_ICON_SVG_VIEWBOX = '0 0 64 64'; +export const MOTIONEYE_ICON_SVG_PATH = + 'M 49.65,10.81 ' + + 'C 44.24,10.84 36.85,13.50 31.48,15.96 ' + + '25.84,13.92 20.04,10.69 13.50,10.84 ' + + '13.07,10.85 12.65,10.87 12.20,10.91 ' + + '12.20,10.91 7.08,11.33 7.08,11.33 ' + + '7.08,11.33 11.94,12.95 11.94,12.95 ' + + '18.62,15.13 24.49,16.51 29.66,25.48 ' + + '30.86,25.48 33.22,25.48 34.34,25.48 ' + + '39.49,16.57 45.66,15.08 52.02,12.95 ' + + '52.02,12.95 56.83,11.39 56.83,11.39 ' + + '56.83,11.39 51.83,10.91 51.83,10.91 ' + + '51.15,10.84 50.43,10.80 49.65,10.81 ' + + '49.65,10.81 49.65,10.81 49.65,10.81 Z ' + + 'M 32.00,5.00 ' + + 'C 26.53,5.00 21.45,6.75 17.20,9.54 ' + + '21.80,10.04 26.33,11.22 31.48,13.76 ' + + '36.69,11.11 42.02,10.00 46.83,9.45 ' + + '42.57,6.64 37.48,5.00 32.00,5.00 Z ' + + 'M 43.42,22.65 ' + + 'C 41.70,22.65 40.31,24.05 40.31,25.77 ' + + '40.31,27.49 41.70,28.88 43.42,28.88 ' + + '45.14,28.88 46.54,27.49 46.54,25.77 ' + + '46.54,24.05 45.14,22.65 43.42,22.65 Z ' + + 'M 20.58,22.65 ' + + 'C 18.86,22.65 17.46,24.05 17.46,25.77 ' + + '17.46,27.49 18.86,28.88 20.58,28.88 ' + + '22.30,28.88 23.69,27.49 23.69,25.77 ' + + '23.69,24.05 22.30,22.65 20.58,22.65 Z ' + + 'M 11.91,14.02 ' + + 'C 7.61,18.80 5.00,25.06 5.00,32.00 ' + + '5.00,46.91 17.09,59.00 32.00,59.00 ' + + '46.91,59.00 59.00,46.91 59.00,32.00 ' + + '59.00,25.09 56.40,18.80 52.12,14.02 ' + + '50.08,14.77 48.04,15.65 46.02,16.78 ' + + '49.92,17.91 52.77,21.53 52.77,25.77 ' + + '52.77,30.90 48.59,35.12 43.42,35.12 ' + + '39.04,35.12 35.36,32.09 34.34,28.04 ' + + '34.34,28.04 29.66,28.04 29.66,28.04 ' + + '28.65,32.09 24.96,35.12 20.58,35.12 ' + + '15.41,35.12 11.20,30.90 11.20,25.77 ' + + '11.20,21.48 14.16,17.83 18.14,16.75 ' + + '16.12,15.65 14.04,14.79 11.91,14.02 ' + + '11.91,14.02 11.91,14.02 11.91,14.02 Z ' + + 'M 32.00,30.96 ' + + 'C 32.64,33.35 33.33,35.72 36.15,37.19 ' + + '36.15,37.19 32.00,43.42 32.00,43.42 ' + + '32.00,43.42 27.85,37.19 27.85,37.19 ' + + '30.32,35.44 31.46,33.29 32.00,30.96 Z'; diff --git a/src/camera-manager/motioneye/types.ts b/src/camera-manager/motioneye/types.ts new file mode 100644 index 00000000..107854aa --- /dev/null +++ b/src/camera-manager/motioneye/types.ts @@ -0,0 +1,12 @@ +import { RichBrowseMedia } from '../../utils/ha/browse-media/types'; +import { BrowseMediaMetadata } from '../browse-media/types'; +import { Engine, EventQueryResults } from '../types'; + +// ================================ +// MotionEye concrete query results +// ================================ + +export interface MotionEyeEventQueryResults extends EventQueryResults { + engine: Engine.MotionEye; + browseMedia: RichBrowseMedia[]; +} diff --git a/src/camera-manager/range.ts b/src/camera-manager/range.ts index 6c3b7666..8518b646 100644 --- a/src/camera-manager/range.ts +++ b/src/camera-manager/range.ts @@ -80,7 +80,7 @@ export const rangesOverlap = (a: DateRange, b: DateRange): boolean => { return ( // a starts within the range of b. (a.start >= b.start && a.start <= b.end) || - // a events within the range of b. + // a ends within the range of b. (a.end >= b.start && a.end <= b.end) || // a encompasses the entire range of b. (a.start <= b.start && a.end >= b.end) diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index 94b39e61..2cb60907 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -22,6 +22,7 @@ export enum QueryResultsType { export enum Engine { Frigate = 'frigate', Generic = 'generic', + MotionEye = 'motioneye', } export interface DataQuery { @@ -110,6 +111,7 @@ export interface CameraManagerMediaCapabilities { export interface CameraManagerCameraMetadata { title: string; icon: string; + engineLogo?: string; } export interface CameraEndpointsContext { diff --git a/src/camera-manager/util.ts b/src/camera-manager/util.ts index 3f4693cd..e5e56812 100644 --- a/src/camera-manager/util.ts +++ b/src/camera-manager/util.ts @@ -4,6 +4,9 @@ import startOfDay from 'date-fns/startOfDay'; import endOfDay from 'date-fns/endOfDay'; import endOfMinute from 'date-fns/endOfMinute'; import { DateRange } from './range'; +import orderBy from 'lodash-es/orderBy'; +import uniqBy from 'lodash-es/uniqBy'; +import { ViewMedia } from '../view/media'; export const convertRangeToCacheFriendlyTimes = ( range: DateRange, @@ -37,3 +40,16 @@ export const capEndDate = (end: Date): Date => { const now = new Date(); return end > now ? now : end; }; + +export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => { + return orderBy( + // Ensure uniqueness by the ID (if specified), otherwise all elements + // are assumed to be unique. + uniqBy(mediaArray, (media) => media.getID() ?? media), + + // Sort all items leading oldest -> youngest (so media is loaded in this + // order in the viewer which matches the left-to-right timeline order). + (media) => media.getStartTime(), + 'asc', + ); +}; diff --git a/src/card.ts b/src/card.ts index fe97c6de..f01ece5c 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1091,7 +1091,11 @@ class FrigateCard extends LitElement { cardWideConfig: CardWideConfig, ): Promise { this._cameraManager = new CameraManager( - new CameraManagerEngineFactory(this._entityRegistryManager, cardWideConfig), + new CameraManagerEngineFactory( + this._entityRegistryManager, + this._resolvedMediaCache, + cardWideConfig, + ), this._cardWideConfig, ); diff --git a/src/components/gallery.ts b/src/components/gallery.ts index f9794769..4413778d 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -23,7 +23,7 @@ import { } from '../utils/media-to-view.js'; import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js'; import { View } from '../view/view.js'; -import { dispatchMessageEvent, renderProgressIndicator } from './message.js'; +import { renderMessage, renderProgressIndicator } from './message.js'; import './thumbnail.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; @@ -432,13 +432,19 @@ export class FrigateCardGalleryCore extends LitElement { } if ((this.view?.queryResults?.getResultsCount() ?? 0) === 0) { - return dispatchMessageEvent(this, localize('common.no_media'), 'info', { + // Note that this is not throwing up an error message for the card to + // handle (as typical), but rather directly rendering the message into the + // gallery. This is to allow the filter to still be available when a given + // filter selection returns no media. + return renderMessage({ + type: 'info', + message: localize('common.no_media'), icon: 'mdi:multimedia', }); } const selected = this.view?.queryResults?.getSelectedResult(); - return html` + return html`
${this._showLoaderTop ? html`${renderProgressIndicator({ cardWideConfig: this.cardWideConfig, @@ -490,7 +496,7 @@ export class FrigateCardGalleryCore extends LitElement { componentRef: this._refLoaderBottom, })}` : ''} - `; +
`; } public updated(changedProps: PropertyValues): void { diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 28700c7b..3f6a7b9b 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -539,8 +539,9 @@ export class FrigateCardLiveCarousel extends LitElement { - this.cameraManager?.getCameraEndpoints(cameraID), + .cameraEndpoints=${guard( + [this.cameraManager, cameraID], + () => this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined, )} .label=${cameraMetadata?.title ?? ''} .liveConfig=${config} @@ -638,6 +639,7 @@ export class FrigateCardLiveCarousel extends LitElement { .label="${cameraMetadataCurrent ? `${localize('common.live')}: ${cameraMetadataCurrent.title}` : ''}" + .logo="${cameraMetadataCurrent?.engineLogo}" .titlePopupConfig=${config.controls.title} .selected=${this._getSelectedCameraIndex()} transitionEffect=${this._getTransitionEffect()} @@ -723,7 +725,7 @@ export class FrigateCardLiveProvider protected _refProvider: Ref = createRef(); public async play(): Promise { - playMediaMutingIfNecessary(this._refProvider.value); + playMediaMutingIfNecessary(this, this._refProvider.value); } public pause(): void { diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts index 56d63fcb..f22806a1 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -115,6 +115,9 @@ export class FrigateCardMediaCarousel extends LitElement { @property({ attribute: false }) public label?: string; + @property({ attribute: false }) + public logo?: string; + @property({ attribute: false }) public titlePopupConfig?: TitleControlConfig; @@ -420,6 +423,7 @@ export class FrigateCardMediaCarousel extends LitElement { ${ref(this._titleControlRef)} .config=${this.titlePopupConfig} .text="${this.label}" + .logo="${this.logo}" .fitInto=${this as HTMLElement} > ` diff --git a/src/components/menu.ts b/src/components/menu.ts index 010861a1..1ad8eae2 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -247,7 +247,7 @@ export class FrigateCardMenu extends LitElement { `; } - let stateParameters: StateParameters = { ...button }; + let stateParameters = { ...button } as StateParameters; const svgPath = stateParameters.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_SVG_PATH : ''; diff --git a/src/components/submenu.ts b/src/components/submenu.ts index 4e9af581..1f3abe45 100644 --- a/src/components/submenu.ts +++ b/src/components/submenu.ts @@ -9,7 +9,7 @@ import { } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { styleMap } from 'lit/directives/style-map.js'; +import { StyleInfo, styleMap } from 'lit/directives/style-map.js'; import { actionHandler } from '../action-handler-directive.js'; import submenuStyle from '../scss/submenu.scss'; import { @@ -39,7 +39,7 @@ export class FrigateCardSubmenu extends LitElement { if (!this.hass) { return; } - const stateParameters = refreshDynamicStateParameters(this.hass, { ...item }); + const stateParameters = refreshDynamicStateParameters(this.hass, { ...item } as StateParameters); const getIcon = (stateParameters: StateParameters): TemplateResult => { if (stateParameters.icon) { return html` stopEventFromActivatingCardWideActions(ev)} > ${startTime} -
- - ${endTime} -
` + ${duration || inProgress + ? html`
+ + ${duration ? html`${duration}` : ''} + ${inProgress + ? html`${inProgress}` + : ''} +
` + : ''}` : ''} ${this.cameraTitle ? html`
@@ -266,11 +269,9 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement { const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null; const rawEndTime = this.media.getEndTime(); - const endTime = rawStartTime - ? rawEndTime - ? getDurationString(rawStartTime, rawEndTime) - : localize('event.in_progress') - : null; + const duration = + rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null; + const inProgress = this.media.inProgress() ? localize('recording.in_progress') : null; const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null; @@ -284,17 +285,24 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
${startTime ? html`
- + ${startTime}
-
- - ${endTime} -
` + ${duration || inProgress + ? html`
+ + ${duration ? html`${duration}` : ''} + ${inProgress + ? html`${inProgress}` + : ''} +
` + : ''}` : ''} ${seek ? html`
diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 8fcd38aa..11d53568 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -525,10 +525,13 @@ export class FrigateCardTimelineCore extends LitElement { .selectResultIfFound((media) => media.getID() === properties.item); if (!newResults || !newResults.hasSelectedResult()) { - // This can happen if this is a recording query (with recorded hours) - // and an event is clicked on the timeline, or if the current thumbnails - // is a filtered view from the media gallery (i.e. any case where the - // thumbnails may not be match the events on the timeline). + // This can happen in a few situations: + // - If this is a recording query (with recorded hours) and an event is + // clicked on the timeline + // - If the current thumbnails/results is a filtered view from the media + // gallery (i.e. any case where the thumbnails may not be match the + // events on the timeline, e.g. in the snapshots viewer but + // mini-timeline showing all media). const fullEventView = await this._createViewWithEventMediaQuery( this._createEventMediaQuerys(), { @@ -551,10 +554,7 @@ export class FrigateCardTimelineCore extends LitElement { } if (view) { - view - // If the user is clicking something in the timeline, don't - // subsequently shift the window (it's pretty jarring). - .dispatchChangeEvent(this); + view.dispatchChangeEvent(this); if (this.view?.is('timeline')) { dispatchFrigateCardEvent(this, 'thumbnails:open'); @@ -895,8 +895,10 @@ export class FrigateCardTimelineCore extends LitElement { const mediaStartTime = media?.getStartTime(); const mediaEndTime = media?.getEndTime(); const mediaWindow: TimelineWindow | null = - media && mediaStartTime && mediaEndTime - ? { start: mediaStartTime, end: mediaEndTime } + media && mediaStartTime + // If this media has no end time, it's just a "point" in time so the + // range effectively starts/ends at the same time. + ? { start: mediaStartTime, end: mediaEndTime ?? mediaStartTime } : null; const context = this.view.context?.timeline; diff --git a/src/components/title-control.ts b/src/components/title-control.ts index bdd524e1..16f14c9e 100644 --- a/src/components/title-control.ts +++ b/src/components/title-control.ts @@ -1,7 +1,6 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { customElement, property } from 'lit/decorators.js'; - import { TitleControlConfig } from '../types.js'; import titleStyle from '../scss/title-control.scss'; @@ -21,6 +20,9 @@ export class FrigateCardTitleControl extends LitElement { @property({ attribute: false }) public fitInto?: HTMLElement; + @property({ attribute: false }) + public logo?: string; + protected _toastRef: Ref = createRef(); /** @@ -44,6 +46,7 @@ export class FrigateCardTitleControl extends LitElement { .text="${this.text}" .fitInto=${this.fitInto} > + ${this.logo ? html`` : ''} `; } @@ -58,7 +61,7 @@ export class FrigateCardTitleControl extends LitElement { /** * Show the toast. */ - public hide(): void { + public hide(): void { if (this._toastRef.value) { // Set it to false first, to ensure the timer resets. this._toastRef.value.opened = false; @@ -85,7 +88,7 @@ export class FrigateCardTitleControl extends LitElement { } declare global { - interface HTMLElementTagNameMap { - "frigate-card-title-control": FrigateCardTitleControl - } + interface HTMLElementTagNameMap { + 'frigate-card-title-control': FrigateCardTitleControl; + } } diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 36029fd6..fd6c04f2 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -45,7 +45,7 @@ import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents, } from '../utils/media-to-view.js'; -import { ViewMedia } from '../view/media.js'; +import { VideoContentType, ViewMedia } from '../view/media.js'; import { ViewMediaClassifier } from '../view/media-classifier'; import { guard } from 'lit/directives/guard.js'; import { localize } from '../localize/localize.js'; @@ -53,6 +53,10 @@ import { MediaQueriesResults } from '../view/media-queries-results.js'; import { canonicalizeHAURL } from '../utils/ha/index.js'; import { dispatchMediaLoadedEvent } from '../utils/media-info.js'; import { playMediaMutingIfNecessary } from '../utils/media.js'; +import { + hideMediaControlsTemporarily, + MEDIA_LOAD_CONTROLS_HIDE_SECONDS, +} from '../utils/media.js'; export interface MediaViewerViewContext { seek?: Date; @@ -399,7 +403,13 @@ export class FrigateCardViewerCarousel extends LitElement { const media = this.view?.queryResults?.getSelectedResult() ?? this.view?.queryResults?.getResult(resultCount - 1); - if (!media || !this.view || !this.view.queryResults) { + if ( + !this.hass || + !this.cameraManager || + !media || + !this.view || + !this.view.queryResults + ) { return; } @@ -416,6 +426,11 @@ export class FrigateCardViewerCarousel extends LitElement { } }; + const cameraMetadata = this.cameraManager.getCameraMetadata( + this.hass, + media.getCameraID(), + ); + return html` ({ @@ -426,6 +441,7 @@ export class FrigateCardViewerCarousel extends LitElement { this._getPlugins.bind(this), )} .label=${media.getTitle() ?? undefined} + .logo=${cameraMetadata?.engineLogo} .titlePopupConfig=${this.viewerConfig?.controls.title} .selected=${this.view?.queryResults?.getSelectedIndex() ?? 0} transitionEffect=${this._getTransitionEffect()} @@ -544,30 +560,53 @@ export class FrigateCardViewerProvider @property({ attribute: false }) public cardWideConfig?: CardWideConfig; - protected _refVideoProvider: Ref = createRef(); + protected _refFrigateCardMediaPlayer: Ref = + createRef(); + protected _refVideoProvider: Ref = createRef(); public async play(): Promise { - playMediaMutingIfNecessary(this._refVideoProvider.value); + playMediaMutingIfNecessary( + this, + this._refFrigateCardMediaPlayer.value ?? this._refVideoProvider.value, + ); } public pause(): void { - this._refVideoProvider.value?.pause(); + (this._refFrigateCardMediaPlayer.value || this._refVideoProvider.value)?.pause(); } public mute(): void { - this._refVideoProvider.value?.mute(); + if (this._refFrigateCardMediaPlayer.value) { + this._refFrigateCardMediaPlayer.value?.mute(); + } else if (this._refVideoProvider.value) { + this._refVideoProvider.value.muted = true; + } } public unmute(): void { - this._refVideoProvider.value?.unmute(); + if (this._refFrigateCardMediaPlayer.value) { + this._refFrigateCardMediaPlayer.value?.mute(); + } else if (this._refVideoProvider.value) { + this._refVideoProvider.value.muted = false; + } } public isMuted(): boolean { - return this._refVideoProvider.value?.isMuted() ?? true; + if (this._refFrigateCardMediaPlayer.value) { + return this._refFrigateCardMediaPlayer.value?.isMuted() ?? true; + } else if (this._refVideoProvider.value) { + return this._refVideoProvider.value.muted; + } + return true; } public seek(seconds: number): void { - this._refVideoProvider.value?.seek(seconds); + if (this._refFrigateCardMediaPlayer.value) { + return this._refFrigateCardMediaPlayer.value.seek(seconds); + } else if (this._refVideoProvider.value) { + hideMediaControlsTemporarily(this._refVideoProvider.value); + this._refVideoProvider.value.currentTime = seconds; + } } /** @@ -666,19 +705,47 @@ export class FrigateCardViewerProvider } return ViewMediaClassifier.isVideo(this.media) - ? html` - ` + ? this.media.getVideoContentType() === VideoContentType.HLS + ? html` + ` + : html` + + ` : html` ` : icon.path - ? html` ` + ? html` + + ` : ``} ${localize(labelPath)}
@@ -1411,7 +1424,42 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ), )} `, - )}`, + )} + ${this._putInSubmenu( + MENU_CAMERAS_MOTIONEYE, + cameraIndex, + 'config.cameras.motioneye.editor_label', + { path: MOTIONEYE_ICON_SVG_PATH, viewBox: MOTIONEYE_ICON_SVG_VIEWBOX }, + html` + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_MOTIONEYE_URL, cameraIndex), + )} + ${this._renderStringInput( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN, + cameraIndex, + ), + )} + ${this._renderStringInput( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN, + cameraIndex, + ), + )} + ${this._renderStringInput( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN, + cameraIndex, + ), + )} + ${this._renderStringInput( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN, + cameraIndex, + ), + )} + `, + )} `, )} ${this._putInSubmenu( MENU_CAMERAS_LIVE_PROVIDER, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 12931232..f1bc2bc0 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -57,6 +57,18 @@ "image": "Home Assistant images", "webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)" }, + "motioneye": { + "editor_label": "MotionEye Options", + "images": { + "directory_pattern": "Images directory pattern", + "file_pattern": "Images file pattern" + }, + "movies": { + "directory_pattern": "Movies directory pattern", + "file_pattern": "Movies file pattern" + }, + "url": "MotionEye UI URL" + }, "title": "Title for this camera (Autodetected from entity)", "triggers": { "entities": "Trigger from other entities", @@ -428,14 +440,15 @@ "whens": { "past_month": "Past Month", "past_week": "Past Week", - "today": "Today", - "yesterday": "Yesterday" + "today": "Today", + "yesterday": "Yesterday" }, "where": "Where" }, "recording": { "camera": "Camera", "duration": "Duration", + "in_progress": "In Progress", "events": "Events", "seek": "Seek", "start": "Start" diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 94040d26..802aab00 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -57,6 +57,18 @@ "image": "", "webrtc-card": "Scheda WebRTC (ovvero la scheda WebRTC di Alexxit)" }, + "motioneye": { + "editor_label": "", + "images": { + "directory_pattern": "", + "file_pattern": "" + }, + "movies": { + "directory_pattern": "", + "file_pattern": "" + }, + "url": "" + }, "title": "Titolo per questa telecamera (Autoidentificato dall'entità)", "triggers": { "entities": "Trigger da altre entità", @@ -427,6 +439,7 @@ "camera": "", "duration": "", "events": "Eventi", + "in_progress": "In corso", "seek": "Cercare", "start": "" }, diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 9a788ea8..6498842a 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -57,6 +57,18 @@ "image": "", "webrtc-card": "Cartão WebRTC (de @AlexxIT)" }, + "motioneye": { + "editor_label": "", + "images": { + "directory_pattern": "", + "file_pattern": "" + }, + "movies": { + "directory_pattern": "", + "file_pattern": "" + }, + "url": "" + }, "title": "Título para esta câmera (detectado automaticamente pela entidade)", "triggers": { "entities": "Acionar a partir de outras entidades", @@ -427,6 +439,7 @@ "camera": "", "duration": "", "events": "Eventos", + "in_progress": "Em andamento", "seek": "Procurar", "start": "" }, diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index 99e6c204..d805963d 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -28,14 +28,6 @@ customElements.whenDefined('ha-camera-stream').then(() => { const computeMJPEGStreamUrl = (entity: CameraEntity): string => `/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`; - const computeObjectId = (entityId: string): string => - entityId.substr(entityId.indexOf('.') + 1); - - const computeStateName = (stateObj: HassEntity): string => - stateObj.attributes.friendly_name === undefined - ? computeObjectId(stateObj.entity_id).replace(/_/g, ' ') - : stateObj.attributes.friendly_name || ''; - const STREAM_TYPE_HLS = 'hls'; const STREAM_TYPE_WEB_RTC = 'web_rtc'; @@ -100,7 +92,6 @@ customElements.whenDefined('ha-camera-stream').then(() => { .src=${typeof this._connected == 'undefined' || this._connected ? computeMJPEGStreamUrl(this.stateObj) : ''} - .alt=${`Preview of the ${computeStateName(this.stateObj)} camera.`} /> `; } diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index 39c96113..b797da40 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -57,7 +57,7 @@ customElements.whenDefined('ha-hls-player').then(() => { } public isMuted(): boolean { - return this._video?.muted() ?? true; + return this._video?.muted ?? true; } public seek(seconds: number): void { diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss index 2a691227..f6a230ec 100644 --- a/src/scss/gallery.scss +++ b/src/scss/gallery.scss @@ -1,6 +1,7 @@ :host { width: 100%; - height: auto; + height: 100%; + display: block; overflow: auto; // Hide scrollbar: IE and Edge @@ -11,7 +12,9 @@ --frigate-card-gallery-gap: 3px; --frigate-card-gallery-columns: 4; +} +.grid { display: grid; grid-template-columns: repeat(var(--frigate-card-gallery-columns), minmax(0, 1fr)); grid-auto-rows: min-content; diff --git a/src/scss/message.scss b/src/scss/message.scss index 3340f886..17486702 100644 --- a/src/scss/message.scss +++ b/src/scss/message.scss @@ -1,8 +1,10 @@ @use 'dotdotdot.scss'; :host { - min-height: 100%; + display: block; + height: 100%; width: 100%; + display: flex; flex-direction: column; justify-content: center; diff --git a/src/scss/thumbnail-feature-event.scss b/src/scss/thumbnail-feature-event.scss index 82851164..b10149f5 100644 --- a/src/scss/thumbnail-feature-event.scss +++ b/src/scss/thumbnail-feature-event.scss @@ -1,6 +1,12 @@ :host { display: block; overflow: hidden; + + aspect-ratio: 1 / 1; + + display: flex; + justify-content: center; + align-items: center; } img { diff --git a/src/scss/title-control.scss b/src/scss/title-control.scss index b8d2a862..6f2c9264 100644 --- a/src/scss/title-control.scss +++ b/src/scss/title-control.scss @@ -6,4 +6,11 @@ paper-toast { max-width: unset; min-width: unset; + display: flex; + align-items: center; +} + +paper-toast img { + max-height: 24px; + padding-left: 10px; } \ No newline at end of file diff --git a/src/scss/viewer-provider.scss b/src/scss/viewer-provider.scss index f9adee75..6e5f94e6 100644 --- a/src/scss/viewer-provider.scss +++ b/src/scss/viewer-provider.scss @@ -7,6 +7,7 @@ } img, +video, frigate-card-ha-hls-player { display: block; width: 100%; diff --git a/src/types.ts b/src/types.ts index e3c67717..dc4e4a76 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,6 @@ import { CallServiceActionConfig, ConfirmationRestrictionConfig, - CustomActionConfig, HomeAssistant, LovelaceCardConfig, MoreInfoActionConfig, @@ -95,7 +94,7 @@ const MEDIA_ACTION_POSITIVE_CONDITIONS = [ export type AutoUnmuteCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number]; export type AutoPlayCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number]; -const ENGINES = ['auto', 'frigate', 'generic'] as const; +const ENGINES = ['auto', 'frigate', 'generic', 'motioneye'] as const; export class FrigateCardError extends Error { context?: unknown; @@ -182,15 +181,13 @@ const moreInfoActionSchema = schemaForType< action: z.literal('more-info'), }), ); -const customActionSchema = schemaForType< - CustomActionConfig & ExtendedConfirmationRestrictionConfig ->()( - actionBaseSchema - .extend({ - action: z.literal('fire-dom-event'), - }) - .passthrough(), -); + +const customActionSchema = actionBaseSchema + .extend({ + action: z.literal('fire-dom-event'), + }) + .passthrough(); + const noActionSchema = schemaForType< NoActionConfig & ExtendedConfirmationRestrictionConfig >()( @@ -450,19 +447,29 @@ const jsmpegConfigSchema = z.object({ * Camera configuration section */ const cameraConfigDefault = { - live_provider: 'auto' as const, - engine: 'auto' as const, - frigate: { - client_id: 'frigate' as const, - }, dependencies: { all_cameras: false, cameras: [], }, + engine: 'auto' as const, + frigate: { + client_id: 'frigate' as const, + }, + hide: false, image: { refresh_seconds: 1, }, - hide: false, + live_provider: 'auto' as const, + motioneye: { + images: { + directory_pattern: '%Y-%m-%d' as const, + file_pattern: '%H-%M-%S' as const, + }, + movies: { + directory_pattern: '%Y-%m-%d' as const, + file_pattern: '%H-%M-%S' as const, + }, + }, triggers: { motion: false, occupancy: true, @@ -505,7 +512,6 @@ const cameraConfigSchema = z engine: z.enum(ENGINES).default('auto'), frigate: z .object({ - // No URL validation to allow relative URLs within HA (e.g. Frigate addon). url: z.string().optional(), client_id: z.string().default(cameraConfigDefault.frigate.client_id), camera_name: z.string().optional(), @@ -513,6 +519,35 @@ const cameraConfigSchema = z zones: z.string().array().optional(), }) .default(cameraConfigDefault.frigate), + motioneye: z + .object({ + url: z.string().optional(), + images: z + .object({ + directory_pattern: z + .string() + .includes('%') + .default(cameraConfigDefault.motioneye.images.directory_pattern), + file_pattern: z + .string() + .includes('%') + .default(cameraConfigDefault.motioneye.images.file_pattern), + }) + .default(cameraConfigDefault.motioneye.images), + movies: z + .object({ + directory_pattern: z + .string() + .includes('%') + .default(cameraConfigDefault.motioneye.movies.directory_pattern), + file_pattern: z + .string() + .includes('%') + .default(cameraConfigDefault.motioneye.movies.file_pattern), + }) + .default(cameraConfigDefault.motioneye.movies), + }) + .default(cameraConfigDefault.motioneye), // Live provider options. live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider), @@ -1321,10 +1356,7 @@ export const frigateCardConfigSchema = z.object({ // Card ID (used for query string commands). Restrict contents to only values // that be easily used in a URL. - card_id: z - .string() - .regex(/^\w+$/) - .optional(), + card_id: z.string().regex(/^\w+$/).optional(), // Stock lovelace card config. type: z.string(), diff --git a/src/utils/basic.ts b/src/utils/basic.ts index e3d227a3..29872a47 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -196,3 +196,7 @@ export const isSuperset = (superset: Set, subset: Set) => { export const sleep = async (seconds: number) => { await new Promise((r) => setTimeout(r, seconds * 1000)); }; + +export const isValidDate = (date: Date): boolean => { + return !isNaN(date.getTime()); +} \ No newline at end of file diff --git a/src/utils/download.ts b/src/utils/download.ts index fe93acd9..574a574e 100644 --- a/src/utils/download.ts +++ b/src/utils/download.ts @@ -10,23 +10,32 @@ export const downloadMedia = async ( cameraManager: CameraManager, media: ViewMedia, ): Promise => { - const path = cameraManager.getMediaDownloadPath(media); - if (!path) { + const download = await cameraManager.getMediaDownloadPath(hass, media); + if (!download) { throw new FrigateCardError(localize('error.download_no_media')); } - let response: string | null | undefined; - try { - response = await homeAssistantSignPath(hass, path); - } catch (e) { - errorToConsole(e as Error); + let finalURL = download.endpoint; + if (download.sign) { + let response: string | null | undefined; + try { + response = await homeAssistantSignPath(hass, download.endpoint); + } catch (e) { + errorToConsole(e as Error); + } + + if (!response) { + throw new FrigateCardError(localize('error.download_sign_failed')); + } + finalURL = response; } - if (!response) { - throw new FrigateCardError(localize('error.download_sign_failed')); - } + // The download attribute only works on the same origin. + // See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attributes + const isSameOrigin = new URL(finalURL).origin === window.location.origin; if ( + !isSameOrigin || navigator.userAgent.startsWith('Home Assistant/') || navigator.userAgent.startsWith('HomeAssistant/') ) { @@ -36,13 +45,13 @@ export const downloadMedia = async ( // User-agents are specified here: // - Android: https://github.com/home-assistant/android/blob/master/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt#L107 // - iOS: https://github.com/home-assistant/iOS/blob/master/Sources/Shared/API/HAAPI.swift#L75 - window.open(response, '_blank'); + window.open(finalURL, '_blank'); } else { // Use the HTML5 download attribute to prevent a new window from // temporarily opening. const link = document.createElement('a'); - link.setAttribute('download', ''); - link.href = response; + link.setAttribute('download', 'download'); + link.href = finalURL; link.click(); link.remove(); } diff --git a/src/utils/endpoint.ts b/src/utils/endpoint.ts index 58e5605b..e90c5c0f 100644 --- a/src/utils/endpoint.ts +++ b/src/utils/endpoint.ts @@ -11,23 +11,22 @@ export const getEndpointAddressOrDispatchError = async ( endpoint: CameraEndpoint, expires?: number, ): Promise => { - let address: string | null; if (!endpoint.sign) { - address = endpoint.endpoint; - } else { - let response: string | null | undefined; - try { - response = await homeAssistantSignPath(hass, endpoint.endpoint, expires); - } catch (e) { - errorToConsole(e as Error); - return null; - } - address = response ? response.replace(/^http/i, 'ws') : null; + return endpoint.endpoint; } - if (!address) { + let response: string | null | undefined; + try { + response = await homeAssistantSignPath(hass, endpoint.endpoint, expires); + } catch (e) { + errorToConsole(e as Error); + return null; + } + + if (!response) { dispatchErrorMessageEvent(element, localize('error.failed_sign')); return null; } - return address; + + return response.replace(/^http/i, 'ws'); }; diff --git a/src/utils/ha/browse-media/browse-media-manager.ts b/src/utils/ha/browse-media/browse-media-manager.ts new file mode 100644 index 00000000..d46d988a --- /dev/null +++ b/src/utils/ha/browse-media/browse-media-manager.ts @@ -0,0 +1,158 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import add from 'date-fns/add'; +import { homeAssistantWSRequest } from '..'; +import { MemoryRequestCache } from '../../../camera-manager/cache'; +import { allPromises } from '../../basic'; +import { + BrowseMedia, + browseMediaSchema, + BROWSE_MEDIA_CACHE_SECONDS, + RichBrowseMedia, +} from './types'; + +type BrowseMediaCache = MemoryRequestCache>; +type RichMetadataGenerator = ( + media: BrowseMedia, + parent?: RichBrowseMedia, +) => M | null; + +export type BrowseMediaTarget = string | RichBrowseMedia; +type RichBrowseMediaPredicate = (media: RichBrowseMedia) => boolean; + +export interface BrowseMediaStep { + // The targets to start the media walk from. + targets: BrowseMediaTarget[]; + + // All children of the target have the metadata generator applied to them + // first. + metadataGenerator?: RichMetadataGenerator; + + // If those children pass this matcher, then they will be included in the + // output. + matcher: RichBrowseMediaPredicate; + + // advance will be called to generate a next step (or null if the child should + // just be included straight through to the output with no further steps). + advance?: BrowseMediaStepAdvancer; +} + +type BrowseMediaStepAdvancer = (media: RichBrowseMedia[]) => BrowseMediaStep[]; + +export class BrowseMediaManager { + protected _cache: BrowseMediaCache; + + constructor(cache: BrowseMediaCache) { + this._cache = cache; + } + + // Walk down a browse media tree according to instructions included in `steps`. + public async walkBrowseMedias( + hass: HomeAssistant, + steps: BrowseMediaStep[] | null, + options?: { + useCache?: boolean; + }, + ): Promise[]> { + if (!steps || !steps.length) { + return []; + } + return ( + await allPromises( + steps, + async (step) => await this._walkBrowseMedia(hass, step, options), + ) + ).flat(); + } + + protected async _walkBrowseMedia( + hass: HomeAssistant, + step: BrowseMediaStep, + options?: { + useCache?: boolean; + }, + ): Promise[]> { + const media = await allPromises( + step.targets, + async (target) => + await this._browseMedia(hass, target, { + useCache: options?.useCache, + metadataGenerator: step.metadataGenerator, + }), + ); + + const newTargets: RichBrowseMedia[] = []; + for (const parent of media) { + for (const child of parent.children ?? []) { + if (step.matcher(child)) { + newTargets.push(child); + } + } + } + + const nextSteps = step.advance ? step.advance(newTargets) : null; + if (!nextSteps || !nextSteps.length) { + return newTargets; + } + + const targetsIncludedInNextSteps = new Set( + nextSteps.map((nextStep) => nextStep.targets).flat(), + ); + const finished: RichBrowseMedia[] = []; + + // Any new target that doesn't have a proposed 'next step' is assumed to be + // ready to return. + for (const target of newTargets) { + if (!targetsIncludedInNextSteps.has(target)) { + finished.push(target); + } + } + + const downstream = await this.walkBrowseMedias(hass, nextSteps, options); + return finished.concat(downstream); + } + + protected async _browseMedia( + hass: HomeAssistant, + target: string | RichBrowseMedia, + options?: { + useCache?: boolean; + metadataGenerator?: RichMetadataGenerator; + }, + ): Promise> { + const mediaContentID = typeof target === 'object' ? target.media_content_id : target; + const cachedResult = + options?.useCache ?? true ? this._cache.get(mediaContentID) : null; + if (cachedResult) { + return cachedResult; + } + + const request = { + type: 'media_source/browse_media', + media_content_id: mediaContentID, + }; + const browseMedia = (await homeAssistantWSRequest( + hass, + browseMediaSchema, + request, + )) as RichBrowseMedia; + + if (options?.metadataGenerator) { + for (const child of browseMedia.children ?? []) { + child._metadata = + options.metadataGenerator( + child, + typeof target === 'object' ? target : undefined, + ) ?? undefined; + } + } + + if (options?.useCache ?? true) { + this._cache.set( + mediaContentID, + browseMedia, + add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }), + ); + } + return browseMedia; + } +} diff --git a/src/utils/ha/browse-media/types.ts b/src/utils/ha/browse-media/types.ts new file mode 100644 index 00000000..5f00dc37 --- /dev/null +++ b/src/utils/ha/browse-media/types.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; + +// Recursive type, cannot use type interference: +// See: https://github.com/colinhacks/zod#recursive-types +// +// Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_player/browse_media.py#L90 +export interface BrowseMedia { + title: string; + media_class: string; + media_content_type: string; + media_content_id: string; + can_play: boolean; + can_expand: boolean; + children_media_class?: string | null; + thumbnail: string | null; + children?: BrowseMedia[] | null; +} + +export const browseMediaSchema: z.ZodSchema = z.lazy(() => + z.object({ + title: z.string(), + media_class: z.string(), + media_content_type: z.string(), + media_content_id: z.string(), + can_play: z.boolean(), + can_expand: z.boolean(), + children_media_class: z.string().nullable().optional(), + thumbnail: z.string().nullable(), + children: z.array(browseMediaSchema).nullable().optional(), + }), +); + +export interface RichBrowseMedia extends BrowseMedia { + _metadata?: M; + children?: RichBrowseMedia[] | null; +} + +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 diff --git a/src/utils/ha/entity-registry/types.ts b/src/utils/ha/entity-registry/types.ts index 80fedd79..702b6b04 100644 --- a/src/utils/ha/entity-registry/types.ts +++ b/src/utils/ha/entity-registry/types.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; export const entitySchema = z.object({ config_entry_id: z.string().nullable(), + device_id: z.string().nullable(), disabled_by: z.string().nullable(), entity_id: z.string(), hidden_by: z.string().nullable(), diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts index f8bc3720..880291d5 100644 --- a/src/utils/ha/index.ts +++ b/src/utils/ha/index.ts @@ -354,12 +354,13 @@ export const isCardInPanel = (card: HTMLElement): boolean => { * location will be the Chromecast receiver, not HA). * @param url The media URL */ -export const canonicalizeHAURL = ( +export function canonicalizeHAURL(hass: ExtendedHomeAssistant, url: string): string; +export function canonicalizeHAURL( hass: ExtendedHomeAssistant, url?: string, -): string | null => { +): string | null { if (hass && url && url.startsWith('/')) { return hass.hassUrl(url); } return url ?? null; -}; +} diff --git a/src/utils/media.ts b/src/utils/media.ts index be16f46a..541baa0d 100644 --- a/src/utils/media.ts +++ b/src/utils/media.ts @@ -31,20 +31,22 @@ export const hideMediaControlsTemporarily = ( }; /** - * Play a piece of media, muting it if necessary. - * @param underlyingPlayer + * + * @param player The Frigate Card Media Player object. + * @param video An underlying video or media player upon which to call play. */ export const playMediaMutingIfNecessary = async ( - player?: FrigateCardMediaPlayer, + player: FrigateCardMediaPlayer, + video?: HTMLVideoElement | FrigateCardMediaPlayer, ): Promise => { // If the play call fails, and the media is not already muted, mute it first // and then try again. This works around some browsers that prevent // auto-play unless the video is muted. - if (player?.play) { - player.play().catch((ev) => { + if (video?.play) { + video.play().catch((ev) => { if (ev.name === 'NotAllowedError' && !player.isMuted()) { player.mute(); - player.play().catch(); + video.play().catch(); } }); } diff --git a/src/utils/thumbnail.ts b/src/utils/thumbnail.ts index b13dd57d..1c370cb7 100644 --- a/src/utils/thumbnail.ts +++ b/src/utils/thumbnail.ts @@ -2,6 +2,11 @@ import { Task } from '@lit-labs/task'; import { ReactiveControllerHost } from '@lit/reactive-element'; import { HomeAssistant } from 'custom-card-helpers'; +// See: https://github.com/sindresorhus/is-absolute-url +// Scheme: https://tools.ietf.org/html/rfc3986#section-3.1 +// Absolute URL: https://tools.ietf.org/html/rfc3986#section-4.3 +const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*?:/; + /** * Fetch a thumbnail URL and return a data URL. * @param hass Home Assistant object. @@ -12,10 +17,10 @@ const fetchThumbnail = async ( hass: HomeAssistant, thumbnailURL: string, ): Promise => { - if (!hass) { + if (!hass || !thumbnailURL) { return null; } - if (thumbnailURL?.startsWith('data:')) { + if (thumbnailURL.startsWith('data:') || thumbnailURL.match(ABSOLUTE_URL_REGEX)) { return thumbnailURL; } return new Promise((resolve, reject) => { @@ -57,21 +62,18 @@ export const createFetchThumbnailTask = ( getThumbnailURL: () => string | undefined, autoRun = true, ): Task => { - return new Task( - host, - { - // Do not re-run the task if hass changes, unless it was previously undefined. - args: (): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()], - task: async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise< - string | null - > => { - const hass = getHASS(); - if (!haveHASS || !hass || !thumbnailURL) { - return null; - } - return fetchThumbnail(hass, thumbnailURL); - }, - autoRun: autoRun, + return new Task(host, { + // Do not re-run the task if hass changes, unless it was previously undefined. + args: (): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()], + task: async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise< + string | null + > => { + const hass = getHASS(); + if (!haveHASS || !hass || !thumbnailURL) { + return null; + } + return fetchThumbnail(hass, thumbnailURL); }, - ); + autoRun: autoRun, + }); }; diff --git a/src/view/media.ts b/src/view/media.ts index ee9bd832..40adb086 100644 --- a/src/view/media.ts +++ b/src/view/media.ts @@ -1,5 +1,10 @@ export type ViewMediaType = 'clip' | 'snapshot' | 'recording'; +export enum VideoContentType { + MP4 = "mp4", + HLS = "hls", +} + export class ViewMedia { protected _mediaType: ViewMediaType; protected _cameraID: string; @@ -17,6 +22,9 @@ export class ViewMedia { public getMediaType(): ViewMediaType { return this._mediaType; } + public getVideoContentType(): VideoContentType | null { + return null; + } public getID(): string | null { return null; } @@ -26,6 +34,9 @@ export class ViewMedia { public getEndTime(): Date | null { return null; } + public inProgress(): boolean | null { + return null; + } public getContentID(): string | null { return null; } diff --git a/yarn.lock b/yarn.lock index 77bbc94c..465531ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -689,15 +689,18 @@ __metadata: languageName: node linkType: hard -"@rollup/plugin-image@npm:^2.1.1": - version: 2.1.1 - resolution: "@rollup/plugin-image@npm:2.1.1" +"@rollup/plugin-image@npm:^3.0.2": + version: 3.0.2 + resolution: "@rollup/plugin-image@npm:3.0.2" dependencies: - "@rollup/pluginutils": ^3.1.0 - mini-svg-data-uri: ^1.2.3 + "@rollup/pluginutils": ^5.0.1 + mini-svg-data-uri: ^1.4.4 peerDependencies: - rollup: ^1.20.0 || ^2.0.0 - checksum: a629c8f22233ca159c23655fdbc3449dab3c939372178ed4462fc9c525cc4ecd8b11fae359eb94be4f769d26f48b85fb18eb16ce1fbc33ed16b6a7c1f84391f6 + rollup: ^1.20.0||^2.0.0||^3.0.0 + peerDependenciesMeta: + rollup: + optional: true + checksum: f9d8f587f10c51398fa8c23f1543e3073f969cf7e4acd7f401e02a3e3752702a9eb289ddb14009733ce37d04474c549aba9e7d13ebf50e26226266ba51546b69 languageName: node linkType: hard @@ -763,6 +766,22 @@ __metadata: languageName: node linkType: hard +"@rollup/pluginutils@npm:^5.0.1": + version: 5.0.2 + resolution: "@rollup/pluginutils@npm:5.0.2" + dependencies: + "@types/estree": ^1.0.0 + estree-walker: ^2.0.2 + picomatch: ^2.3.1 + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0 + peerDependenciesMeta: + rollup: + optional: true + checksum: edea15e543bebc7dcac3b0ac8bc7b8e8e6dbd46e2864dbe5dd28072de1fbd5b0e10d545a610c0edaa178e8a7ac432e2a2a52e547ece1308471412caba47db8ce + languageName: node + linkType: hard + "@stencil/core@npm:^2.20.0, @stencil/core@npm:^2.3.0": version: 2.22.2 resolution: "@stencil/core@npm:2.22.2" @@ -814,7 +833,7 @@ __metadata: languageName: node linkType: hard -"@types/estree@npm:*": +"@types/estree@npm:*, @types/estree@npm:^1.0.0": version: 1.0.0 resolution: "@types/estree@npm:1.0.0" checksum: 910d97fb7092c6738d30a7430ae4786a38542023c6302b95d46f49420b797f21619cdde11fa92b338366268795884111c2eb10356e4bd2c8ad5b92941e9e6443 @@ -2251,7 +2270,7 @@ __metadata: languageName: node linkType: hard -"estree-walker@npm:^2.0.1": +"estree-walker@npm:^2.0.1, estree-walker@npm:^2.0.2": version: 2.0.2 resolution: "estree-walker@npm:2.0.2" checksum: 6151e6f9828abe2259e57f5fd3761335bb0d2ebd76dc1a01048ccee22fabcfef3c0859300f6d83ff0d1927849368775ec5a6d265dde2f6de5a1be1721cd94efc @@ -2418,7 +2437,7 @@ __metadata: "@lit-labs/task": ^1.1.3 "@rollup/plugin-babel": ^5.3.1 "@rollup/plugin-commonjs": ^22.0.2 - "@rollup/plugin-image": ^2.1.1 + "@rollup/plugin-image": ^3.0.2 "@rollup/plugin-json": ^4.1.0 "@rollup/plugin-node-resolve": ^13.3.0 "@rollup/plugin-replace": ^4.0.0 @@ -2464,7 +2483,7 @@ __metadata: vis-util: ^5.0.2 web-dialog: ^0.0.11 xss: ^1.0.14 - zod: ^3.20.6 + zod: ^3.21.4 languageName: unknown linkType: soft @@ -3504,7 +3523,7 @@ __metadata: languageName: node linkType: hard -"mini-svg-data-uri@npm:^1.2.3": +"mini-svg-data-uri@npm:^1.4.4": version: 1.4.4 resolution: "mini-svg-data-uri@npm:1.4.4" bin: @@ -5483,9 +5502,9 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.20.6": - version: 3.20.6 - resolution: "zod@npm:3.20.6" - checksum: 804b1934b8b5e2fa3750bec90043e8118b201f330b9957b8b768389a971acadf812d2060cf62921086512dab4af691d10490acb03333da58fc485c0791893c89 +"zod@npm:^3.21.4": + version: 3.21.4 + resolution: "zod@npm:3.21.4" + checksum: f185ba87342ff16f7a06686767c2b2a7af41110c7edf7c1974095d8db7a73792696bcb4a00853de0d2edeb34a5b2ea6a55871bc864227dace682a0a28de33e1f languageName: node linkType: hard