diff --git a/package.json b/package.json index 69f28695..bb2f2aaf 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "side-drawer": "^3.1.0", "ts-toolbelt": "^9.6.0", "uuid": "^8.3.2", - "vis-data": "^7.1.3", + "vis-data": "^7.1.4", "vis-timeline": "^7.7.0", "vis-util": "^5.0.2", "xss": "^1.0.14", diff --git a/src/card-condition.ts b/src/card-condition.ts index 36ecf49a..64a7bdc7 100644 --- a/src/card-condition.ts +++ b/src/card-condition.ts @@ -180,7 +180,8 @@ export class CardConditionManager { * Trigger the callback. * @param _ Ignored parameter. */ - protected _triggerChange(_): void { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected _triggerChange(_: MediaQueryListEvent): void { this._callback(); } diff --git a/src/card.ts b/src/card.ts index 52377462..86322916 100644 --- a/src/card.ts +++ b/src/card.ts @@ -53,8 +53,6 @@ import { FrigateCardView, FRIGATE_CARD_VIEWS_USER_SPECIFIED, MediaLoadedInfo, - MEDIA_TYPE_IMAGE, - MEDIA_TYPE_VIDEO, MESSAGE_TYPE_PRIORITIES, MenuButton, Message, @@ -80,7 +78,6 @@ import { isTriggeredState, sideLoadHomeAssistantElements, } from './utils/ha'; -import { getEventID } from './utils/ha/browse-media.js'; import { DeviceList, getAllDevices } from './utils/ha/device-registry.js'; import { ExtendedEntityCache, @@ -94,8 +91,10 @@ import { isValidMediaLoadedInfo } from './utils/media-info.js'; import { View } from './view.js'; import pkg from '../package.json'; import { ViewContext } from 'view'; -import { DataManager } from './utils/data-manager.js'; +import { DataManager } from './utils/data/data-manager.js'; import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js'; +import { DataManagerEngineFactory } from './utils/data/data-manager-engine-factory.js'; +import { RequestCache } from './utils/data/data-manager-cache.js'; /** A note on media callbacks: * @@ -144,6 +143,8 @@ console.info( documentationURL: REPO_URL, }); +type InitializedType = 'initialized' | 'initializing'; + /** * Main FrigateCard class. */ @@ -204,7 +205,6 @@ export class FrigateCard extends LitElement { // A cache of resolved media URLs/mimetypes for use in the whole card. protected _resolvedMediaCache = new ResolvedMediaCache(); - // Shared timeline data manager (for main timeline view and mini-timelines). protected _dataManager?: DataManager; // The mouse handler may be called continually, throttle it to at most once @@ -212,8 +212,7 @@ export class FrigateCard extends LitElement { protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000); // Whether the card has been successfully initialized. - protected _loadedHAElements = false; - protected _loadedLanguages = false; + protected _initialized?: InitializedType; protected _triggers: Map = new Map(); protected _untriggerTimerID: number | null = null; @@ -530,7 +529,8 @@ export class FrigateCard extends LitElement { if ( !this._isBeingCasted() && - (this._view?.isViewerView() || (this._view?.is('timeline') && !!this._view?.media)) + (this._view?.isViewerView() || + (this._view?.is('timeline') && !!this._view?.queryResults?.hasSelectedResult())) ) { buttons.push({ icon: 'mdi:download', @@ -1016,6 +1016,7 @@ export class FrigateCard extends LitElement { } protected _changeView(args?: { view?: View; resetMessage?: boolean }): void { + console.debug(`Frigate Card view change: `, args?.view ?? '[default]'); const changeView = (view: View): void => { if (View.isMediaChange(this._view, view)) { this._currentMediaLoadedInfo = null; @@ -1099,18 +1100,12 @@ export class FrigateCard extends LitElement { * Called before each update. */ protected willUpdate(changedProps: PropertyValues): void { - // Side load the necessary elements if not already initialized (do not need - // to block for the loading to complete). - if (!this._loadedHAElements) { - sideLoadHomeAssistantElements().then((success) => { - if (success) { - this._loadedHAElements = true; - } - }); - } - if (this._cameras && (changedProps.has('_config') || changedProps.has('_cameras'))) { - this._dataManager = new DataManager(this._cameras); + this._dataManager = new DataManager( + new DataManagerEngineFactory(), + this._cameras, + new RequestCache(), + ); } if (changedProps.has('_cardWideConfig')) { @@ -1237,6 +1232,12 @@ export class FrigateCard extends LitElement { } } + /** + * Initialize the card. + */ + protected async _initialize(): Promise { + await Promise.all([sideLoadHomeAssistantElements(), loadLanguages()]); + } /** * Determine whether the element should be updated. * @param changedProps The changed properties if any. @@ -1244,11 +1245,13 @@ export class FrigateCard extends LitElement { */ protected shouldUpdate(changedProps: PropertyValues): boolean { // Load the relevant languages. Cannot do anything until then. - if (!this._loadedLanguages) { - loadLanguages().then(() => { - this._loadedLanguages = true; - this.requestUpdate(); - }); + if (this._initialized !== 'initialized') { + if (this._initialized !== 'initializing') { + this._initialize().then(() => { + this._initialized = 'initialized'; + this.requestUpdate(); + }); + } return false; } @@ -1318,12 +1321,9 @@ export class FrigateCard extends LitElement { // Should not occur. return; } + const media = this._view.queryResults?.getSelectedResult(); - if ( - !this._view.media || - (this._view.media.media_content_type !== MEDIA_TYPE_VIDEO && - this._view.media.media_content_type !== MEDIA_TYPE_IMAGE) - ) { + if (!media) { this._setMessageAndUpdate({ message: localize('error.download_no_media'), type: 'error', @@ -1336,35 +1336,8 @@ export class FrigateCard extends LitElement { return; } - let path: string; - if (this._view.media.frigate?.event) { - const event_id = getEventID(this._view.media); - if (!event_id) { - this._setMessageAndUpdate({ - message: localize('error.download_no_event_id'), - type: 'error', - }); - return; - } - - path = - `/api/frigate/${cameraConfig.frigate.client_id}` + - `/notifications/${event_id}/` + - `${ - this._view.media.media_content_type === MEDIA_TYPE_VIDEO - ? 'clip.mp4' - : 'snapshot.jpg' - }` + - `?download=true`; - } else if (this._view.media.frigate?.recording) { - const recording = this._view.media.frigate.recording; - path = - `/api/frigate/${cameraConfig.frigate.client_id}` + - `/recording/${cameraConfig.frigate.camera_name}` + - `/start/${recording.start_time}` + - `/end/${recording.end_time}` + - `?download=true`; - } else { + const path = this._dataManager?.getMediaDownloadPath(media); + if (!path) { return; } @@ -1412,30 +1385,35 @@ export class FrigateCard extends LitElement { * @returns */ protected _mediaPlayerAction(mediaPlayer: string, action: 'play' | 'stop'): void { - if (!['play', 'stop'].includes(action)) { + if (!['play', 'stop'].includes(action) || !this._view) { return; } - let media_content_id: string; - let media_content_type: string; - const extra = {}; - const cameraConfig = this._getSelectedCameraConfig(); - const cameraEntity = cameraConfig?.camera_entity ?? null; + let media_content_id: string | null = null; + let media_content_type: string | null = null; + let title: string | null = null; + let thumbnail: string | null = null; - if (this._view?.isViewerView() && this._view.media) { - media_content_id = this._view.media.media_content_id; - media_content_type = this._view.media.media_content_type; - extra['thumb'] = this._view.media.thumbnail; - extra['title'] = this._view.media.title; + const cameraConfig = this._getSelectedCameraConfig(); + if (!cameraConfig) { + return; + } + const cameraEntity = cameraConfig.camera_entity ?? null; + const media = this._view.queryResults?.getSelectedResult(); + + if (this._view.isViewerView() && media && this._cameras) { + media_content_id = media.getContentID(cameraConfig); + media_content_type = media.getContentType(); + title = media.getTitle(cameraConfig); + thumbnail = media.getThumbnail(cameraConfig); } else if (this._view?.is('live') && cameraEntity) { - if (this._hass?.states && cameraEntity in this._hass.states) { - extra['thumb'] = - this._hass.states[cameraEntity].attributes.entity_picture ?? null; - } - extra['title'] = getCameraTitle(this._hass, cameraConfig); media_content_id = `media-source://camera/${cameraEntity}`; media_content_type = 'application/vnd.apple.mpegurl'; - } else { + title = getCameraTitle(this._hass, cameraConfig); + thumbnail = this._hass?.states[cameraEntity]?.attributes?.entity_picture ?? null; + } + + if (!media_content_id || !media_content_type) { return; } @@ -1444,7 +1422,10 @@ export class FrigateCard extends LitElement { entity_id: mediaPlayer, media_content_id: media_content_id, media_content_type: media_content_type, - extra: extra, + extra: { + ...(title && { title: title }), + ...(thumbnail && { thumb: thumbnail }), + }, }); } else if (action === 'stop') { this._hass?.callService('media_player', 'media_stop', { diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 8199eb7c..362fdd34 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -41,15 +41,12 @@ export class FrigateCardCarousel extends LitElement { @property({ attribute: false }) public carouselPlugins?: EmblaCarouselPlugins; + @property({ attribute: false }) + public selected = 0; + @property({ attribute: true }) public transitionEffect?: TransitionEffect; - // An override to the startIndex, used to preserve the current carousel - // position after the carousel is destroyed (so it can be restored if - // recreated). - // See: https://github.com/dermotduffy/frigate-hass-card/issues/775 - protected _savedStartIndex: number | null = null; - protected _refSlot: Ref = createRef(); protected _carousel?: EmblaCarouselType; @@ -81,7 +78,7 @@ export class FrigateCardCarousel extends LitElement { // Destroy the carousel when the component is disconnected, which forces the // plugins (which may have registered event handlers) to also be destroyed. // The carousel will automatically reconstruct if the component is re-rendered. - this._destroyCarousel({ savePosition: true }); + this._destroyCarousel(); super.disconnectedCallback(); } @@ -96,7 +93,7 @@ export class FrigateCardCarousel extends LitElement { 'carouselPlugins', ] as const; if (destroyProperties.some((prop) => changedProps.has(prop))) { - this._destroyCarousel({ savePosition: true }); + this._destroyCarousel(); } } @@ -105,31 +102,21 @@ export class FrigateCardCarousel extends LitElement { * @param index Slide number. */ public carouselScrollTo(index: number): void { - const scroll = () => - this._carousel?.scrollTo(index, this.transitionEffect === 'none'); - // This ensures scrolling can work on initial render when the carousel may - // not yet exist. - if (this._carousel) { - scroll(); - } else { - this.updateComplete.then(() => { - scroll(); - }); - } + this.selected = index; } /** * Scroll to the previous slide. */ public carouselScrollPrevious(): void { - this._carousel?.scrollPrev(this.transitionEffect === 'none'); + this.selected = Math.max(0, this.selected - 1); } /** * Scroll to the next slide. */ public carouselScrollNext(): void { - this._carousel?.scrollNext(this.transitionEffect === 'none'); + this.selected = this.selected + 1; } /** @@ -174,11 +161,10 @@ export class FrigateCardCarousel extends LitElement { window.requestAnimationFrame(() => { this._carousel?.reInit({ ...options }); }); - } - const selected = this.getCarouselSelected(); + }; carouselReInit({ - ...(selected && { startIndex: selected.index }), + startIndex: this.selected, }); } @@ -211,6 +197,10 @@ export class FrigateCardCarousel extends LitElement { if (!this._carousel) { this._initCarousel(); } + + if (changedProperties.has('selected')) { + this._carousel?.scrollTo(this.selected, this.transitionEffect === 'none'); + } } /** @@ -218,9 +208,7 @@ export class FrigateCardCarousel extends LitElement { * @param options If `savePosition` is set the existing carousel position * will be saved so it can be restored if the carousel is recreated. */ - protected _destroyCarousel(options?: { savePosition: boolean }): void { - this._savedStartIndex = - (options?.savePosition ? this._carousel?.selectedScrollSnap() : null) ?? null; + protected _destroyCarousel(): void { if (this._carousel) { this._carousel.destroy(); } @@ -248,8 +236,8 @@ export class FrigateCardCarousel extends LitElement { { axis: this.direction == 'horizontal' ? 'x' : 'y', speed: 20, + startIndex: this.selected, ...this.carouselOptions, - ...(this._savedStartIndex !== null && { startIndex: this._savedStartIndex }), }, this.carouselPlugins, ); @@ -262,7 +250,7 @@ export class FrigateCardCarousel extends LitElement { // Make sure every select causes a refresh to allow for re-paint of the // next/previous controls. this.requestUpdate(); - } + }; this._carousel.on('init', selectSlide); this._carousel.on('select', selectSlide); @@ -294,18 +282,14 @@ export class FrigateCardCarousel extends LitElement { protected _slotChanged(): void { // Cannot just re-init, because the slide elements themselves may have // changed, and only a carousel init can pass in new (slotted) children. If - // the slides themselves change, any position the user has set is assumed to - // be abandoned and so the startIndex is reset to whatever the carousel was - // originally configured with. - this._destroyCarousel({ savePosition: false }); + this._destroyCarousel(); this.requestUpdate(); } protected render(): TemplateResult | void { const slides = this._refSlot.value?.assignedElements({ flatten: true }) || []; - const currentSlide = (this._carousel?.selectedScrollSnap() ?? this.carouselOptions?.startIndex) ?? 0; - const showPrevious = this.carouselOptions?.loop || currentSlide > 0; - const showNext = this.carouselOptions?.loop || currentSlide + 1 < slides.length; + const showPrevious = this.carouselOptions?.loop || this.selected > 0; + const showNext = this.carouselOptions?.loop || this.selected + 1 < slides.length; return html`
${showPrevious ? html`` : ``} diff --git a/src/components/drawer.ts b/src/components/drawer.ts index dfff296a..900943ad 100644 --- a/src/components/drawer.ts +++ b/src/components/drawer.ts @@ -126,7 +126,7 @@ export class FrigateCardDrawer extends LitElement {
` : ''} - + this._slotChanged()}> `; } diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 9a4c9816..f0b72ad3 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -19,12 +19,10 @@ import { } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { - fetchChildMediaAndDispatchViewChange, - fetchLatestMediaAndDispatchViewChange, getFullDependentBrowseMediaQueryParametersOrDispatchError, } from '../utils/ha/browse-media'; -import { changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js'; -import { DataManager } from '../utils/data-manager.js'; +import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js'; +import { DataManager } from '../utils/data/data-manager.js'; import { View } from '../view.js'; import { renderProgressIndicator } from './message.js'; import './thumbnail.js'; @@ -66,63 +64,54 @@ export class FrigateCardGallery extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - const mediaType = this.view?.getMediaType(); - if ( - !this.hass || - !this.view || - !this.cameras || - !this.view.isGalleryView() || - !mediaType || - !this.dataManager - ) { - return; - } + // const mediaType = this.view?.getMediaType(); + // if ( + // !this.hass || + // !this.view || + // !this.cameras || + // !this.view.isGalleryView() || + // !mediaType || + // !this.dataManager + // ) { + // return; + // } - if (!this.view.target) { - if (mediaType === 'recordings') { - changeViewToRecentRecordingForCameraAndDependents( - this, - this.hass, - this.dataManager, - this.cameras, - this.view, - { - targetView: 'recordings', - }, - ); - } else { - const browseMediaQueryParameters = - getFullDependentBrowseMediaQueryParametersOrDispatchError( - this, - this.hass, - this.cameras, - this.view.camera, - mediaType, - ); + // if (!this.view.query) { + // if (mediaType === 'recordings') { + // changeViewToRecentRecordingForCameraAndDependents( + // this, + // this.hass, + // this.dataManager, + // this.cameras, + // this.view, + // { + // targetView: 'recordings', + // }, + // ); + // } else { + // changeViewToRecentEventsForCameraAndDependents( + // this, + // this.hass, + // this.dataManager, + // this.cameras, + // this.view, + // { + // targetView: mediaType, + // }, + // ); + // } + // return renderProgressIndicator({ cardWideConfig: this.cardWideConfig }); + // } - if (!browseMediaQueryParameters) { - return; - } - - fetchLatestMediaAndDispatchViewChange( - this, - this.hass, - this.view, - browseMediaQueryParameters, - ); - } - return renderProgressIndicator({ cardWideConfig: this.cardWideConfig }); - } - - return html` - - - `; + // return html` + // + // + // `; } /** @@ -139,203 +128,206 @@ export class FrigateCardGallery extends LitElement { } } -@customElement('frigate-card-gallery-core') -export class FrigateCardGalleryCore extends LitElement { - @property({ attribute: false }) - public hass?: ExtendedHomeAssistant; +// @customElement('frigate-card-gallery-core') +// export class FrigateCardGalleryCore extends LitElement { +// @property({ attribute: false }) +// public hass?: ExtendedHomeAssistant; - @property({ attribute: false }) - public view?: Readonly; +// @property({ attribute: false }) +// public view?: Readonly; - @property({ attribute: false }) - public galleryConfig?: GalleryConfig; +// @property({ attribute: false }) +// public galleryConfig?: GalleryConfig; - @property({ attribute: false }) - public cameras?: Map; +// @property({ attribute: false }) +// public cameras?: Map; - protected _resizeObserver: ResizeObserver; +// protected _resizeObserver: ResizeObserver; - constructor() { - super(); - this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); - } +// constructor() { +// super(); +// this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); +// } - /** - * Component connected callback. - */ - connectedCallback(): void { - super.connectedCallback(); - this._resizeObserver.observe(this); - } +// /** +// * Component connected callback. +// */ +// connectedCallback(): void { +// super.connectedCallback(); +// this._resizeObserver.observe(this); +// } - /** - * Component disconnected callback. - */ - disconnectedCallback(): void { - this._resizeObserver.disconnect(); - super.disconnectedCallback(); - } +// /** +// * Component disconnected callback. +// */ +// disconnectedCallback(): void { +// this._resizeObserver.disconnect(); +// super.disconnectedCallback(); +// } - /** - * Set gallery columns. - */ - protected _setColumnCount(): void { - const thumbnailSize = - this.galleryConfig?.controls.thumbnails.size ?? - frigateCardConfigDefaults.event_gallery.controls.thumbnails.size; - const columns = this.galleryConfig?.controls.thumbnails.show_details - ? Math.max(1, Math.floor(this.clientWidth / THUMBNAIL_DETAILS_WIDTH_MIN)) - : Math.max( - 1, - Math.ceil(this.clientWidth / THUMBNAIL_WIDTH_MAX), - Math.ceil(this.clientWidth / thumbnailSize), - ); +// /** +// * Set gallery columns. +// */ +// protected _setColumnCount(): void { +// const thumbnailSize = +// this.galleryConfig?.controls.thumbnails.size ?? +// frigateCardConfigDefaults.event_gallery.controls.thumbnails.size; +// const columns = this.galleryConfig?.controls.thumbnails.show_details +// ? Math.max(1, Math.floor(this.clientWidth / THUMBNAIL_DETAILS_WIDTH_MIN)) +// : Math.max( +// 1, +// Math.ceil(this.clientWidth / THUMBNAIL_WIDTH_MAX), +// Math.ceil(this.clientWidth / thumbnailSize), +// ); - this.style.setProperty('--frigate-card-gallery-columns', String(columns)); - } +// this.style.setProperty('--frigate-card-gallery-columns', String(columns)); +// } - /** - * Handle gallery resize. - */ - protected _resizeHandler(): void { - this._setColumnCount(); - } +// /** +// * Handle gallery resize. +// */ +// protected _resizeHandler(): void { +// this._setColumnCount(); +// } - /** - * Determine whether the back arrow should be displayed. - * @returns `true` if the back arrow should be displayed, `false` otherwise. - */ - protected _showBackArrow(): boolean { - return ( - !!this.view?.context?.gallery?.previous && - !!this.view.context.gallery.previous.target && - this.view.context.gallery.previous.view === this.view.view - ); - } +// /** +// * Determine whether the back arrow should be displayed. +// * @returns `true` if the back arrow should be displayed, `false` otherwise. +// */ +// protected _shouldShowBackArrow(): boolean { +// return ( +// !!this.view?.context?.gallery?.previous && +// !!this.view.context.gallery.previous.query && +// this.view.context.gallery.previous.view === this.view.view +// ); +// } - /** - * Called when an update will occur. - * @param changedProps The changed properties - */ - protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('galleryConfig')) { - if (this.galleryConfig?.controls.thumbnails.show_details) { - this.setAttribute('details', ''); - } else { - this.removeAttribute('details'); - } - this._setColumnCount(); - if (this.galleryConfig?.controls.thumbnails.size) { - this.style.setProperty( - '--frigate-card-thumbnail-size', - `${this.galleryConfig.controls.thumbnails.size}px`, - ); - } - } - } +// /** +// * Called when an update will occur. +// * @param changedProps The changed properties +// */ +// protected willUpdate(changedProps: PropertyValues): void { +// if (changedProps.has('galleryConfig')) { +// if (this.galleryConfig?.controls.thumbnails.show_details) { +// this.setAttribute('details', ''); +// } else { +// this.removeAttribute('details'); +// } +// this._setColumnCount(); +// if (this.galleryConfig?.controls.thumbnails.size) { +// this.style.setProperty( +// '--frigate-card-thumbnail-size', +// `${this.galleryConfig.controls.thumbnails.size}px`, +// ); +// } +// } +// } - /** - * Master render method. - * @returns A rendered template. - */ - protected render(): TemplateResult | void { - if ( - !this.hass || - !this.view || - !this.view.target || - !this.view.target.children || - !this.view.isGalleryView() || - !this.cameras - ) { - return html``; - } +// // TODO: This is still going to show the gallery view (akin to HA media +// // browser). - return html` - ${this._showBackArrow() - ? html` { - if (this.view && this.view.context?.gallery?.previous) { - this.view.context.gallery.previous.dispatchChangeEvent(this); - } - stopEventFromActivatingCardWideActions(ev); - }} - outlined="" - > - - ` - : ''} - ${this.view.target.children.map( - (child, index) => - html` - ${child.can_expand - ? html` - { - if (this.hass && this.view) { - fetchChildMediaAndDispatchViewChange( - this, - this.hass, - this.view, - child, - { - gallery: { - previous: this.view, - }, - }, - ); - } - stopEventFromActivatingCardWideActions(ev); - }} - outlined="" - > -
${child.title}
-
- ` - : html` { - if (this.view) { - const targetView = this.view.getViewerViewForGalleryView(); - if (targetView) { - this.view - .evolve({ - view: targetView, - childIndex: index, - }) - .dispatchChangeEvent(this); - } - } - stopEventFromActivatingCardWideActions(ev); - }} - > - `} - `, - )} - `; - } +// /** +// * Master render method. +// * @returns A rendered template. +// */ +// protected render(): TemplateResult | void { +// const results = this.view?.queryResults?.getResults(); - /** - * Get styles. - */ - static get styles(): CSSResultGroup { - return unsafeCSS(galleryStyle); - } -} +// if ( +// !results || +// !this.hass || +// !this.view || +// !this.view.isGalleryView() || +// !this.cameras +// ) { +// return html``; +// } + +// return html` +// ${this._shouldShowBackArrow() +// ? html` { +// if (this.view && this.view.context?.gallery?.previous) { +// this.view.context.gallery.previous.dispatchChangeEvent(this); +// } +// stopEventFromActivatingCardWideActions(ev); +// }} +// outlined="" +// > +// +// ` +// : ''} +// ${results.map((child, index) => +// html` +// ${child.can_expand +// ? html` +// { +// if (this.hass && this.view) { +// fetchChildMediaAndDispatchViewChange( +// this, +// this.hass, +// this.view, +// child, +// { +// gallery: { +// previous: this.view, +// }, +// }, +// ); +// } +// stopEventFromActivatingCardWideActions(ev); +// }} +// outlined="" +// > +//
${child.title}
+//
+// ` +// : html` { +// if (this.view) { +// const targetView = this.view.getViewerViewForGalleryView(); +// if (targetView) { +// this.view +// .evolve({ +// view: targetView, +// childIndex: index, +// }) +// .dispatchChangeEvent(this); +// } +// } +// stopEventFromActivatingCardWideActions(ev); +// }} +// > +// `} +// `, +// )} +// `; +// } + +// /** +// * Get styles. +// */ +// static get styles(): CSSResultGroup { +// return unsafeCSS(galleryStyle); +// } +// } declare global { interface HTMLElementTagNameMap { - 'frigate-card-gallery-core': FrigateCardGalleryCore; + //'frigate-card-gallery-core': FrigateCardGalleryCore; 'frigate-card-gallery': FrigateCardGallery; } } diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 67a7c823..e53f8685 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -33,7 +33,6 @@ import { import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; import { contentsChanged } from '../../utils/basic.js'; import { getCameraIcon, getCameraTitle } from '../../utils/camera.js'; -import { getFullDependentBrowseMediaQueryParameters } from '../../utils/ha/browse-media.js'; import { dispatchExistingMediaLoadedInfoAsEvent, dispatchMediaUnloadedEvent, @@ -52,7 +51,7 @@ import '../surround.js'; import { EmblaCarouselPlugins } from '../carousel.js'; import { classMap } from 'lit/directives/class-map.js'; import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js'; -import { DataManager } from '../../utils/data-manager.js'; +import { DataManager } from '../../utils/data/data-manager.js'; import { HomeAssistant } from 'custom-card-helpers'; import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js'; import { HassEntity } from 'home-assistant-js-websocket'; @@ -212,16 +211,6 @@ export class FrigateCardLive extends LitElement { this.conditionState, ) as LiveConfig; - // Does not use getFullDependentBrowseMediaQueryParametersOrDispatchError to - // ensure that non-Frigate cameras will work in live view (they will not - // have a Frigate camera name). - const browseMediaParams = getFullDependentBrowseMediaQueryParameters( - this.hass, - this.cameras, - this.view.camera, - config.controls.thumbnails.media, - ); - // Notes: // - See use of liveConfig and not config below -- the carousel will // independently override the liveConfig to reflect the camera in the @@ -238,10 +227,9 @@ export class FrigateCardLive extends LitElement { html` { diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts index 5679ab52..504acad6 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -126,6 +126,9 @@ export class FrigateCardMediaCarousel extends LitElement { @property({ attribute: false }) public carouselPlugins?: EmblaCarouselPlugins; + @property({ attribute: false, type: Number }) + public selected = 0; + @property({ attribute: true }) public transitionEffect?: TransitionEffect; @@ -418,6 +421,7 @@ export class FrigateCardMediaCarousel extends LitElement { return html` ; @@ -79,32 +68,29 @@ export class FrigateCardSurround extends LitElement { */ protected async _fetchMedia(): Promise { if ( - !this.fetch || + !this.cameras || + !this.dataManager || + !this.fetchMedia || this.inBackground || !this.hass || !this.view || - this.view.target || + this.view.query || !this.thumbnailConfig || this.thumbnailConfig.mode === 'none' || - !this.browseMediaParams || !(this.view.context?.thumbnails?.fetch ?? true) ) { return; } - let parent: FrigateBrowseMediaSource | null; - try { - parent = await multipleBrowseMediaQueryMerged(this.hass, this.browseMediaParams); - } catch (e) { - return dispatchFrigateCardErrorEvent(this, e as FrigateCardError); - } - if (getFirstTrueMediaChildIndex(parent) !== null) { - this.view - ?.evolve({ - target: parent, - childIndex: null, - }) - .dispatchChangeEvent(this); - } + await changeViewToRecentEventsForCameraAndDependents( + this, + this.hass, + this.dataManager, + this.cameras, + this.view, + { + mediaType: this.fetchMedia, + }, + ); } /** @@ -170,25 +156,21 @@ export class FrigateCardSurround extends LitElement { slot=${this.thumbnailConfig.mode} .hass=${this.hass} .config=${this.thumbnailConfig} + .dataManager=${this.dataManager} .view=${this.view} - .target=${this.view.target} .cameras=${this.cameras} - selected=${ifDefined(this.view.childIndex ?? undefined)} + .selected=${this.view.queryResults?.getSelectedIndex() ?? undefined} @frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')} @frigate-card:thumbnail-carousel:tap=${( ev: CustomEvent, ) => { - const child: FrigateBrowseMediaSource | null = - ev.detail.target?.children?.[ev.detail.childIndex] ?? null; - if (child) { + const media = ev.detail.queryResults.getSelectedResult(); + if (media) { this.view ?.evolve({ view: this.view.is('recording') ? 'recording' : 'media', - target: ev.detail.target, - childIndex: ev.detail.childIndex, - ...(child.frigate?.cameraID && { - camera: child.frigate?.cameraID, - }), + queryResults: ev.detail.queryResults, + ...(media.getCameraID() && { camera: media.getCameraID() }), }) .removeContext('timeline') // Send the view change from the source of the tap event, so @@ -200,7 +182,9 @@ export class FrigateCardSurround extends LitElement { > ` : ''} - ${this.timelineConfig?.mode && this.timelineConfig.mode !== 'none' && !this.inBackground + ${this.timelineConfig?.mode && + this.timelineConfig.mode !== 'none' && + !this.inBackground ? html` ; - // Use contentsChanged here to avoid the carousel rebuilding and resetting in - // front of the user, unless the contents have actually changed. - @property({ attribute: false, hasChanged: contentsChanged }) - public target?: FrigateBrowseMediaSource | null; - @property({ attribute: false }) public cameras?: Map; + @property({ attribute: false }) + public dataManager?: DataManager; + protected _refCarousel: Ref = createRef(); // Thumbnail carousels can expand (e.g. drawer-based carousels after the main @@ -59,10 +54,14 @@ export class FrigateCardThumbnailCarousel extends LitElement { @property({ attribute: false }) public config?: ThumbnailsControlConfig; - @property({ attribute: true, type: Number, reflect: true }) - public selected?: number; + @property({ attribute: false }) + public selected? = 0; + + protected _carouselOptions?: EmblaOptionsType = { + containScroll: 'keepSnaps', + dragFree: true, + }; - protected _carouselOptions?: EmblaOptionsType; protected _carouselPlugins: EmblaPluginType[] = [ WheelGesturesPlugin({ // Whether the carousel is vertical or horizontal, interpret y-axis wheel @@ -99,31 +98,20 @@ export class FrigateCardThumbnailCarousel extends LitElement { super.disconnectedCallback(); } - /** - * Get the Embla options to use. - * @returns An EmblaOptionsType object or undefined for no options. - */ - protected _getOptions(): EmblaOptionsType { - return { - containScroll: 'keepSnaps', - dragFree: true, - startIndex: this.selected ?? 0, - }; - } /** * Get slides to include in the render. * @returns The slides to include in the render. */ protected _getSlides(): TemplateResult[] { - if (!this.target || !this.target.children || !this.target.children.length) { + if (!this.view?.query || !this.view.queryResults?.hasResults()) { return []; } const slides: TemplateResult[] = []; - for (let i = 0; i < this.target.children.length; ++i) { - const thumbnail = this._renderThumbnail(this.target, i, slides.length); + for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) { + const thumbnail = this._renderThumbnail(i); if (thumbnail) { - slides.push(thumbnail); + slides[i] = thumbnail; } } return slides; @@ -152,30 +140,6 @@ export class FrigateCardThumbnailCarousel extends LitElement { this.selected === undefined ? '1.0' : '0.4', ); } - - if (!this._carouselOptions) { - // Want to set the initial carousel options just before the first render - // in order to get the startIndex correct in the options. It is not safe - // to rely on carouselScrollTo() post update, since the nested carousel - // may not yet be actual rendered/created. - this._carouselOptions = this._getOptions(); - } - } - - /** - * The updated lifecycle callback for this element. - * @param changedProperties The properties that were changed in this render. - */ - updated(changedProperties: PropertyValues): void { - super.updated(changedProperties); - - if (changedProperties.has('selected')) { - this.updateComplete.then(() => { - if (this.selected !== undefined) { - this._refCarousel.value?.carouselScrollTo(this.selected); - } - }); - } } /** @@ -183,45 +147,40 @@ export class FrigateCardThumbnailCarousel extends LitElement { * @param mediaToRender The media item to render. * @returns A template or void if the item could not be rendered. */ - protected _renderThumbnail( - parent: FrigateBrowseMediaSource, - childIndex: number, - slideIndex: number, - ): TemplateResult | void { - if ( - !parent.children || - !parent.children.length || - !isTrueMedia(parent.children[childIndex]) - ) { + protected _renderThumbnail(index: number): TemplateResult | void { + const media = this.view?.queryResults?.getResult(index) ?? null; + const cameraConfig = media ? this.cameras?.get(media.getCameraID()) : null; + if (!media || !cameraConfig || !this.view) { return; } const classes = { embla__slide: true, - 'slide-selected': this.selected === childIndex, + 'slide-selected': this.selected === index, }; - const cameraConfig = this.view?.camera ? this.cameras?.get(this.view.camera) : null; return html` { - if (this._refCarousel.value?.carouselClickAllowed()) { + @click=${(ev: Event) => { + if ( + this.view && + this.view.queryResults && + this._refCarousel.value?.carouselClickAllowed() + ) { dispatchFrigateCardEvent( this, 'thumbnail-carousel:tap', { - slideIndex: slideIndex, - target: parent, - childIndex: childIndex, + queryResults: this.view.queryResults.clone().selectResult(index), }, ); } @@ -257,6 +216,7 @@ export class FrigateCardThumbnailCarousel extends LitElement { return html` diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index a65380de..fdf76b29 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -3,30 +3,24 @@ import fromUnixTime from 'date-fns/fromUnixTime'; import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; - import { localize } from '../localize/localize.js'; import thumbnailDetailsStyle from '../scss/thumbnail-details.scss'; import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss'; import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss'; import thumbnailStyle from '../scss/thumbnail.scss'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; -import { errorToConsole, prettifyTitle } from '../utils/basic.js'; +import { getDurationString, prettifyTitle } from '../utils/basic.js'; import { getCameraTitle } from '../utils/camera.js'; -import { retainEvent } from '../utils/frigate.js'; -import { getEventDurationString } from '../utils/frigate.js'; import { renderTask } from '../utils/task.js'; import { createFetchThumbnailTask } from '../utils/thumbnail.js'; import { View } from '../view.js'; import type { MediaSeek } from './viewer.js'; import { TaskStatus } from '@lit-labs/task'; -import type { - CameraConfig, - ExtendedHomeAssistant, - FrigateBrowseMediaSource, - FrigateEvent, - FrigateRecording, -} from '../types.js'; +import type { CameraConfig, ExtendedHomeAssistant } from '../types.js'; +import { ViewMedia } from '../view-media.js'; +import { DataManager } from '../utils/data/data-manager.js'; + // The minimum width of a thumbnail with details enabled. export const THUMBNAIL_DETAILS_WIDTH_MIN = 300; @@ -133,26 +127,36 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement { @customElement('frigate-card-thumbnail-details-event') export class FrigateCardThumbnailDetailsEvent extends LitElement { @property({ attribute: false }) - public event?: FrigateEvent; + public media?: ViewMedia; @property({ attribute: false }) public mediaSeek?: MediaSeek; protected render(): TemplateResult | void { - if (!this.event) { + if (!this.media || !this.media.isEvent()) { return; } - const score = (this.event.top_score * 100).toFixed(2) + '%'; - return html`
-
${prettifyTitle(this.event.label)}
-
- ${localize('event.start')}: - ${format(fromUnixTime(this.event.start_time), 'HH:mm:ss')} -
-
- ${localize('event.duration')}: - ${getEventDurationString(this.event)} -
+ const score = this.media.getScore(); + const startTime = this.media.getStartTime(); + const endTime = this.media.getEndTime(); + const what = this.media.getWhat(); + + return html`
+ ${what ? html`
${prettifyTitle(what.join(', '))}
` : ``} + ${startTime + ? html`
+ ${localize('event.start')}: + ${format(startTime, 'HH:mm:ss')} +
+
+ ${localize('event.duration')}: + ${endTime + ? getDurationString(startTime, endTime) + : localize('event.in_progress')} +
` + : ``} ${this.mediaSeek ? html`
${localize('event.seek')} @@ -160,9 +164,11 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
` : html``}
-
- ${score} -
`; + ${score + ? html`
+ ${(score * 100).toFixed(2) + '%'} +
` + : ``}`; } static get styles(): CSSResult { @@ -173,17 +179,21 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement { @customElement('frigate-card-thumbnail-details-recording') export class FrigateCardThumbnailDetailsRecording extends LitElement { @property({ attribute: false }) - public recording?: FrigateRecording; + public media?: ViewMedia; @property({ attribute: false }) public mediaSeek?: MediaSeek; + @property({ attribute: false }) + public cameraTitle?: string; + protected render(): TemplateResult | void { - if (!this.recording) { + if (!this.media) { return; } + const eventCount = this.media.getEventCount(); return html`
-
${prettifyTitle(this.recording.camera) || ''}
+
${this.cameraTitle ?? ''}
${this.mediaSeek ? html`
${localize('recording.seek')} @@ -191,10 +201,12 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
` : html``}
-
- ${this.recording.events} - ${localize('recording.events')} -
`; + ${eventCount !== null + ? html`
+ ${eventCount} + ${localize('recording.events')} +
` + : ``}`; } static get styles(): CSSResult { @@ -204,6 +216,21 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement { @customElement('frigate-card-thumbnail') export class FrigateCardThumbnail extends LitElement { + // HomeAssistant object may be required for thumbnail signing (for Frigate + // events). + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + // DataManager used for marking media as favorite. + @property({ attribute: false }) + public dataManager?: DataManager; + + @property({ attribute: true }) + public media?: ViewMedia; + + @property({ attribute: false }) + public cameraConfig?: CameraConfig; + @property({ attribute: true, type: Boolean }) public details = false; @@ -213,160 +240,123 @@ export class FrigateCardThumbnail extends LitElement { @property({ attribute: true, type: Boolean }) public show_timeline_control = false; - // ====================== - // Target-based interface - // ====================== - @property({ attribute: false }) - public target?: FrigateBrowseMediaSource | null; - - @property({ attribute: false }) - public childIndex?: number; - @property({ attribute: false }) public mediaSeek?: MediaSeek; - // =================================================== - // Raw interface (can override target-based interface) - // =================================================== - @property({ attribute: true }) - public thumbnail?: string; - - @property({ attribute: true }) - public label?: string; - - @property({ attribute: false }) - public event?: FrigateEvent; - - // ================================ - // Optional parameters for controls - // ================================ @property({ attribute: false }) public view?: Readonly; - @property({ attribute: false }) - public hass?: ExtendedHomeAssistant; - - @property({ attribute: false }) - public cameraConfig?: CameraConfig; - /** * Render the element. * @returns A template to display to the user. */ protected render(): TemplateResult | void { - let event: FrigateEvent | null = null; - let recording: FrigateRecording | null = null; - let thumbnail: string | null = null; - let label: string | null = null; - - // Take the event / thumbnail / label from the data-bound media (if specified). - if (this.target && this.target.children && this.childIndex !== undefined) { - const media = this.target.children[this.childIndex]; - event = media.frigate?.event ?? null; - recording = media.frigate?.recording ?? null; - thumbnail = media.thumbnail; - label = media.title; - } - - // Always give the overrides preference (if specified). - if (this.event) { - event = this.event; - } - thumbnail = this.thumbnail ? this.thumbnail : thumbnail; - label = this.label ? this.label : label; - - if (!event && !recording) { + if (!this.media || !this.cameraConfig) { return; } + const thumbnail = this.media.getThumbnail(this.cameraConfig); + const title = this.media.getTitle(this.cameraConfig) ?? ''; + const starClasses = { star: true, - starred: !!event?.retain_indefinitely, + starred: !!this.media?.isFavorite(), }; + const shouldShowTimelineControl = + this.show_timeline_control && + this.view && + (!this.media.isRecording() || + // Only show timeline control if the recording has a start & end time. + (this.media.getStartTime() && this.media.getEndTime())); + const clientID = this.cameraConfig?.frigate.client_id; - return html` ${event + return html` ${this.media.isEvent() ? html`` - : recording + : this.media.isRecording() ? html`` : html``} ${this.show_favorite_control && event && this.hass && clientID ? html` { stopEventFromActivatingCardWideActions(ev); - if (event && this.hass && clientID) { - retainEvent(this.hass, clientID, event.id, !event.retain_indefinitely) - .then(() => { - if (event) { - event.retain_indefinitely = !event.retain_indefinitely; - this.requestUpdate(); - } - }) - .catch((e) => { - errorToConsole(e); - }); + if (this.hass && this.cameraConfig && this.media) { + this.dataManager?.favoriteMedia( + this.hass, + this.cameraConfig, + this.media, + !this.media?.isFavorite(), + ); } }} />` : ``} - ${this.details && event + ${this.details && this.media.isEvent() ? html`` - : this.details && recording + : this.details && this.media.isRecording() ? html`` : html``} - ${this.show_timeline_control + ${shouldShowTimelineControl ? html` { stopEventFromActivatingCardWideActions(ev); - if (event) { + if (!this.view || !this.media) { + return; + } + if (this.media.isEvent()) { this.view - ?.evolve({ + .evolve({ view: 'timeline', - target: this.target, - childIndex: this.childIndex ?? null, + queryResults: this.view.queryResults + ?.clone() + .selectResultIfFound((media) => media === this.media), }) .removeContext('timeline') .dispatchChangeEvent(this); - } else if (recording) { + } else if (this.media.isRecording()) { + const startTime = this.media.getStartTime(); + const endTime = this.media.getStartTime(); + if (!startTime || !endTime) { + return; + } // Specifically reset the media target/childIndex, as we cannot // 'select' an hour in the timeline rather we set the window to // matching values. this.view ?.evolve({ view: 'timeline', - target: null, - childIndex: null, + query: null, }) .mergeInContext({ timeline: { window: { - start: fromUnixTime(recording.start_time), - end: fromUnixTime(recording.end_time), + start: startTime, + end: endTime, }, }, }) diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 0982637c..06357251 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -1,6 +1,5 @@ import add from 'date-fns/add'; import endOfHour from 'date-fns/endOfHour'; -import fromUnixTime from 'date-fns/fromUnixTime'; import differenceInSeconds from 'date-fns/differenceInSeconds'; import startOfHour from 'date-fns/startOfHour'; import sub from 'date-fns/sub'; @@ -17,7 +16,7 @@ import { createRef, ref, Ref } from 'lit/directives/ref.js'; import isEqual from 'lodash-es/isEqual'; import throttle from 'lodash-es/throttle'; import { ViewContext } from 'view'; -import { DataView, DataSet } from 'vis-data/esnext'; +import { DataSet } from 'vis-data/esnext'; import type { DataGroupCollectionType, IdType } from 'vis-timeline/esnext'; import { Timeline, @@ -33,10 +32,8 @@ import timelineCoreStyle from '../scss/timeline-core.scss'; import { CameraConfig, ExtendedHomeAssistant, - FrigateBrowseMediaSource, frigateCardConfigDefaults, - FrigateEvent, - FrigateRecording, + FrigateCardView, TimelineCoreConfig, } from '../types'; import { stopEventFromActivatingCardWideActions } from '../utils/action'; @@ -46,26 +43,19 @@ import { isHoverableDevice, } from '../utils/basic'; import { getAllDependentCameras, getCameraTitle } from '../utils/camera.js'; -import { - getEventMediaContentID, - getEventThumbnailURL, - getEventTitle, -} from '../utils/frigate'; -import { createEventParentForChildren, createChild } from '../utils/ha/browse-media'; import { - changeViewToRecording, - findChildIndex, - generateMediaViewerContextForChildren, + createViewForEvents, + createViewForRecordings, + generateMediaViewerContext, } from '../utils/media-to-view'; -import { - FrigateCardTimelineItem, - sortYoungestToOldest, - DataManager, -} from '../utils/data-manager'; -import { View } from '../view'; +import { DataManager } from '../utils/data/data-manager'; +import { EventMediaQueries, MediaQueries, View } from '../view'; import { dispatchMessageEvent } from './message.js'; import './thumbnail.js'; +import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source'; +import { ViewMedia, ViewMediaClassifier } from '../view-media'; +import { rangesOverlap } from '../utils/data/data-manager-range'; interface FrigateCardGroupData { id: string; @@ -79,14 +69,13 @@ interface TimelineRangeChange extends TimelineWindow { interface TimelineViewContext { // Force a particular timeline window rather than taking the time from an - // event / recording. + // event / recording. The timeline itself does not set this, but respects it + // if set elsewhere. window?: TimelineWindow; - // Whether or not to set the timeline window. + // Whether or not to set the timeline window (either from the window + // parameter, or from an event/recording). noSetWindow?: boolean; - - // Whether or not thumbnails were generated. - generatedThumbnails?: boolean; } declare module 'view' { @@ -95,11 +84,19 @@ declare module 'view' { } } -// An event used to fetch the HASS object. See "Special note" below. -class HASSRequestEvent extends Event { - public hass?: ExtendedHomeAssistant; +// An event used to fetch data required for thumbnail rendering. See special +// note below on why this is necessary. +interface ThumbnailDataRequest { + item: IdType; + hass?: ExtendedHomeAssistant; + dataManager?: DataManager; + cameraConfig?: CameraConfig; + media?: ViewMedia; + view?: View; } +class ThumbnailDataRequestEvent extends CustomEvent {} + const TIMELINE_TARGET_BAR_ID = 'target_bar'; /** @@ -109,49 +106,56 @@ const TIMELINE_TARGET_BAR_ID = 'target_bar'; @customElement('frigate-card-timeline-thumbnail') export class FrigateCardTimelineThumbnail extends LitElement { @property({ attribute: true }) - public thumbnail?: string; + public item?: IdType; @property({ attribute: true, type: Boolean }) public details = false; - @property({ attribute: true }) - public event?: string; - - @property({ attribute: true }) - public label?: string; - /** * Master render method. * @returns A rendered template. */ protected render(): TemplateResult | void { - // Don't display tooltips on touch devices, they just get in the way of - // the drawer. - if (!this.thumbnail || !this.event) { + if (!this.item) { return html``; } /* Special note on what's going on here: * - * This component does not have access to HASS, as there's no way to pass it - * in via the string-based tooltip that timeline supports. Instead dispatch - * an event to request HASS which the timeline adds to the event object - * before execution continues. + * This component does not have access to a variety of properties required + * to render a thumbnail component, as there's no way to pass them in via the + * string-based tooltip that timeline supports. Instead dispatch an event to + * request HASS which the timeline adds to the event object before execution + * continues. */ - const hassRequest = new HASSRequestEvent(`frigate-card:timeline:hass-request`, { - composed: true, - bubbles: true, - }); - this.dispatchEvent(hassRequest); - if (!hassRequest.hass) { + + const dataRequest: ThumbnailDataRequest = { + item: this.item, + }; + this.dispatchEvent( + new ThumbnailDataRequestEvent(`frigate-card:timeline:thumbnail-data-request`, { + composed: true, + bubbles: true, + detail: dataRequest, + }), + ); + + if ( + !dataRequest.hass || + !dataRequest.dataManager || + !dataRequest.cameraConfig || + !dataRequest.media || + !dataRequest.view + ) { return html``; } return html` `; @@ -192,7 +196,8 @@ export class FrigateCardTimelineCore extends LitElement { protected _targetBarVisible = false; protected _refTimeline: Ref = createRef(); protected _timeline?: Timeline; - protected _dataview?: DataView; + + protected _timelineSource: TimelineDataSource | null = null; // Need a way to separate when a user clicks (to pan the timeline) vs when a // user clicks (to choose a recording (non-event) to play). @@ -211,36 +216,40 @@ export class FrigateCardTimelineCore extends LitElement { /** * Get a tooltip for a given timeline event. - * @param source The FrigateBrowseMediaSource in question. + * @param item The TimelineItem in question. * @returns The tooltip as a string to render. */ protected _getTooltip(item: TimelineItem): string { - const event = (item).event; - const clientId = item.group - ? this.cameras?.get(String(item.group))?.frigate.client_id - : null; - if (!this._isHoverableDevice || !event || !clientId) { + if (!this._isHoverableDevice) { // Don't display tooltips on touch devices, they just get in the way of // the drawer. return ''; } - const eventAttr = `event='${JSON.stringify(event)}'`; - const detailsAttr = this.thumbnailDetails ? 'details' : ''; - // Cannot use Lit data-bindings as visjs requires a string for tooltips. // Note that changes to attributes here must be mirrored in the xss // whitelist in `_getOptions()` . return ` `; } + protected _handleThumbnailDataRequest(request: ThumbnailDataRequestEvent): void { + const item = request.detail.item; + const media = this._timelineSource?.dataset.get(item)?.media; + + request.detail.hass = this.hass; + request.detail.cameraConfig = media + ? this.cameras?.get(media.getCameraID()) + : undefined; + request.detail.dataManager = this.dataManager; + request.detail.media = media; + request.detail.view = this.view; + } + /** * Master render method. * @returns A rendered template. @@ -250,9 +259,9 @@ export class FrigateCardTimelineCore extends LitElement { return; } return html`
{ - request.hass = this.hass; - }} + @frigate-card:timeline:thumbnail-data-request=${this._handleThumbnailDataRequest.bind( + this, + )} class="timeline" ${ref(this._refTimeline)} > @@ -337,7 +346,7 @@ export class FrigateCardTimelineCore extends LitElement { !this._locked || (!this.view?.is('timeline') && this._timeline.getSelection().some((id) => { - const item = this._dataview?.get(id); + const item = this._timelineSource?.dataset?.get(id); return ( item && item.start && @@ -376,49 +385,50 @@ export class FrigateCardTimelineCore extends LitElement { * @returns */ protected _setViewDuringRangeChange( - targetTime: Date, - properties: TimelineRangeChange, + _targetTime: Date, + _properties: TimelineRangeChange, ): void { if ( !this._timeline || !this.view || - !this.view.target?.children?.length || + // !this.view.target?.length || !this.dataManager ) { return; } - const canSeek = !!this.view?.isViewerView(); - const context = canSeek - ? generateMediaViewerContextForChildren( - this.dataManager, - this.view.target.children, - targetTime, - ) - : null; + // TODO TODO TODO TODO TODO TODO TODO TODO TODO TODO TODO TODO + // const canSeek = !!this.view?.isViewerView(); + // const context = canSeek + // ? generateMediaViewerContextForChildren( + // this.dataManager, + // this.view.target, + // targetTime, + // ) + // : null; - const childIndex = this._locked - ? null - : findChildIndex( - this.view.target.children, - targetTime, - this._getTimelineCameraIDs(), - properties.event.additionalEvent === 'panright' ? 'end' : 'start', - ); + // const childIndex = this._locked + // ? null + // : findChildIndex( + // this.view.target.children, + // targetTime, + // this._getTimelineCameraIDs(), + // properties.event.additionalEvent === 'panright' ? 'end' : 'start', + // ); - if (canSeek || (childIndex !== null && childIndex !== this.view.childIndex)) { - this.view - .evolve({ - ...(childIndex !== null && { - childIndex: childIndex, - }), - }) // Whether or not to set the timeline window. - .mergeInContext({ - ...this._generateTimelineContext({ noSetWindow: true }), - ...context, - }) - .dispatchChangeEvent(this); - } + // if (canSeek || (childIndex !== null && childIndex !== this.view.childIndex)) { + // this.view + // .evolve({ + // ...(childIndex !== null && { + // childIndex: childIndex, + // }), + // }) // Whether or not to set the timeline window. + // .mergeInContext({ + // ...this._generateTimelineContext({ noSetWindow: true }), + // ...context, + // }) + // .dispatchChangeEvent(this); + // } } /** @@ -429,115 +439,129 @@ export class FrigateCardTimelineCore extends LitElement { // Calls to stopEventFromActivatingCardWideActions() are included for // completeness. Timeline does not support card-wide events and they are // disabled in card.ts in `_getMergedActions`. - if (properties.what === 'item' || this._ignoreClick) { + if ( + this._ignoreClick || + (properties.what && + ['item', 'background', 'group-label', 'axis'].includes(properties.what)) + ) { stopEventFromActivatingCardWideActions(properties.event); } if ( - !this._ignoreClick && - properties.what && - this.hass && - this.dataManager && - this.cameras && - this.view + this._ignoreClick || + !this.hass || + !this._timeline || + !this.cameras || + !this.view || + !this.dataManager || + !properties.what ) { - if ( - this.timelineConfig?.show_recordings && - ['background', 'group-label', 'axis'].includes(properties.what) - ) { - stopEventFromActivatingCardWideActions(properties.event); + return; + } - if (['background', 'group-label'].includes(properties.what)) { - const window = this._timeline?.getWindow(); - if (window) { - if (properties.group) { - changeViewToRecording( - this, - this.hass, - this.dataManager, - this.cameras, - this.view, - { - cameraIDs: new Set([String(properties.group)]), - targetTime: - properties.what === 'background' ? properties.time : window.end, - }, - ); - } else if (this.mini && this.view?.camera) { - // In mini mode group may not be displayed / used, so just use the camera directly. - changeViewToRecording( - this, - this.hass, - this.dataManager, - this.cameras, - this.view, - { - targetTime: window.end, - }, - ); - } - } - } else { - changeViewToRecording( - this, + let viewPromise: Promise | null = null; + + if ( + this.timelineConfig?.show_recordings && + ['background', 'group-label'].includes(properties.what) + ) { + viewPromise = createViewForRecordings( + this.hass, + this.dataManager, + this.cameras, + this.view, + { + targetTime: + properties.what === 'background' + ? properties.time + : this._timeline.getWindow().end, + ...(properties.group && { + cameraIDs: new Set([String(properties.group)]), + }), + }, + ); + } else if (this.timelineConfig?.show_recordings && properties.what === 'axis') { + viewPromise = createViewForRecordings( + this.hass, + this.dataManager, + this.cameras, + this.view, + { + cameraIDs: this._getAllCameraIDs(), + start: startOfHour(properties.time), + end: endOfHour(properties.time), + targetTime: properties.time, + }, + ); + } else if ( + properties.item && + properties.what === 'item' && + this.view.is('recording') + ) { + viewPromise = (async (): Promise => { + if (!properties.item || !this.dataManager || !this.hass) { + return null; + } + const view = await this._createViewWithEventMediaQuery( + this._createEventMediaQuerys(), + { + selectedItem: properties.item, + targetView: 'media', + }, + ); + const results = view?.queryResults?.getResults() ?? null; + if (!results || !view) { + return null; + } + view.mergeInContext( + await generateMediaViewerContext( this.hass, this.dataManager, - this.cameras, - this.view, - { - cameraIDs: this._getAllCameraIDs(), - start: startOfHour(properties.time), - end: endOfHour(properties.time), - targetTime: properties.time, - }, - ); + results, + properties.time, + ), + ); + return view; + })(); + } else if ( + properties.item && + properties.what === 'item' && + this.view.queryResults?.hasResults() + ) { + viewPromise = (async (): Promise => { + if (!this.view?.query) { + return null; } - } else if ( - properties.what === 'item' && - properties.item && - this.view && - this.view.target?.children && - this.dataManager - ) { - let childIndex: number | null = null; - let target: FrigateBrowseMediaSource | null = null; - let context: ViewContext = {}; + return this.view.evolve({ + queryResults: this.view.queryResults + ?.clone() + .resetSelectedResult() + .selectResultIfFound( + (media) => + !!this.cameras && + media.getID(this.cameras.get(media.getCameraID())) === properties.item, + ), + }); + })(); + } - if (this.view.is('recording')) { - const thumbnails = this._generateThumbnails(properties.item); - - if (thumbnails) { - target = thumbnails.target; - childIndex = thumbnails.childIndex; - if (thumbnails.target?.children?.length) { - context = generateMediaViewerContextForChildren( - this.dataManager, - thumbnails.target.children, - properties.time, - ); - } - } - } else { - childIndex = this.view.target.children.findIndex( - (child) => child.frigate?.event?.id === properties.item, - ); - } - - if (childIndex !== null && childIndex >= 0) { - this.view - ?.evolve({ - childIndex: childIndex, - ...(target && { target: target }), - }) - .mergeInContext(context) + if (viewPromise) { + viewPromise.then((view: View | null) => { + if (view) { + view + // If the user is clicking something in the timeline, don't + // subsequently shift the window (it's pretty jarring). + .mergeInContext(this._generateTimelineContext({ noSetWindow: true })) .dispatchChangeEvent(this); - if (!this.view.isViewerView()) { + if (this.view?.is('timeline')) { dispatchFrigateCardEvent(this, 'thumbnails:open'); } - } else if (!this.view.isViewerView()) { - dispatchFrigateCardEvent(this, 'thumbnails:close'); + this._ignoreClick = false; + return; } - } + }); + } else if (this.view?.is('timeline')) { + dispatchFrigateCardEvent(this, 'thumbnails:close'); } this._ignoreClick = false; @@ -545,13 +569,15 @@ export class FrigateCardTimelineCore extends LitElement { /** * Get a broader prefetch window from a start and end basis. - * @param start The earlier date. - * @param end The later date. - * @returns An object with a `start` and `end` key to prefetch. + * @param window The window to broaden. + * @returns A broader timeline. */ - protected _getPrefetchWindow(start: Date, end: Date): [Date, Date] { - const delta = differenceInSeconds(end, start); - return [sub(start, { seconds: delta }), add(end, { seconds: delta })]; + protected _getPrefetchWindow(window: TimelineWindow): TimelineWindow { + const delta = differenceInSeconds(window.end, window.start); + return { + start: sub(window.start, { seconds: delta }), + end: add(window.end, { seconds: delta }), + }; } /** @@ -569,106 +595,77 @@ export class FrigateCardTimelineCore extends LitElement { } this._removeTargetBar(); - if (this.hass && this.cameras && this._timeline && this.timelineConfig) { - const [prefetchStart, prefetchEnd] = this._getPrefetchWindow( - properties.start, - properties.end, - ); - this.dataManager - ?.fetchIfNecessary(this, this.hass, prefetchStart, prefetchEnd) - .then(() => { - // Don't show event thumbnails if the user is looking at recordings, - // as the recording "hours" are the media, not the event - // clips/snapshots. - if (this._timeline && this.view && !this.view?.is('recording')) { - const thumbnails = this._generateThumbnails(); - // Update the view to reflect the new thumbnails and the timeline - // window in the context. - this.view - .evolve({ - target: thumbnails?.target ?? null, - childIndex: thumbnails?.childIndex ?? null, - }) - .mergeInContext(this._generateTimelineContext({ noSetWindow: true })) - .dispatchChangeEvent(this); - } - }); - } + (async (): Promise => { + if (!this.hass || !this.cameras) { + return; + } + + const prefetchedWindow = this._getPrefetchWindow(properties); + await this._timelineSource?.refresh(this.hass, this.cameras, prefetchedWindow); + + // Don't show event thumbnails if the user is looking at recordings, + // as the recording "hours" are the media, not the event + // clips/snapshots. + if (this._timeline && this.view && !this.view?.is('recording')) { + ( + await this._createViewWithEventMediaQuery( + this._createEventMediaQuerys({ window: prefetchedWindow }), + { + noSetWindow: true, + }, + ) + )?.dispatchChangeEvent(this); + } + })(); } - /** - * Regenerate the thumbnails from the timeline events. - * @param selectedItem An id to select from the thumbnails (currently selected - * item is used if none is specified). - * @returns An object with two keys, or null on error. The keys are `target` - * containing all the thumbnails, and `childIndex` to refer to the currently - * selected thumbnail. - */ - protected _generateThumbnails(selectedItem?: IdType): { - target: FrigateBrowseMediaSource; - childIndex: number | null; - } | null { - if (!this._timeline) { + protected _createEventMediaQuerys(options?: { + window?: TimelineWindow; + }): EventMediaQueries | null { + if (!this._timeline || !this._timelineSource) { return null; } - const selected: IdType[] = selectedItem - ? [selectedItem] - : this._timeline.getSelection(); - let childIndex = -1; - const children: FrigateBrowseMediaSource[] = []; - this._dataview - ?.get({ - filter: (item) => item.type !== 'background', - order: sortYoungestToOldest, - }) - .forEach((item) => { - const cameraID = item.group ? String(item.group) : null; - const cameraConfig = cameraID ? this.cameras?.get(cameraID) : null; - const event = item.event; - const media = - event?.has_clip && this.timelineConfig?.media !== 'snapshots' - ? 'clips' - : event?.has_snapshot - ? 'snapshots' - : null; + return new EventMediaQueries( + this._timelineSource.getTimelineEventQueries( + options?.window ?? this._timeline.getWindow(), + ), + ); + } - if ( - cameraID && - cameraConfig && - event && - media && - cameraConfig.frigate.camera_name - ) { - children.push( - createChild( - getEventTitle(event), - getEventMediaContentID( - cameraConfig.frigate.client_id, - cameraConfig.frigate.camera_name, - event.id, - media, - ), - { - thumbnail: getEventThumbnailURL(cameraConfig.frigate.client_id, event), - event: event, - cameraID: cameraID, - }, - ), - ); - if (selected.includes(event.id)) { - childIndex = children.length - 1; - } - } - }); - if (!children.length) { + protected async _createViewWithEventMediaQuery( + query: EventMediaQueries | null, + options?: { + targetView?: FrigateCardView; + selectedItem?: IdType; + noSetWindow?: boolean; + }, + ): Promise { + if (!this.hass || !this.dataManager || !this.cameras || !this.view || !query) { return null; } - - return { - target: createEventParentForChildren('Timeline events', children), - childIndex: childIndex < 0 ? null : childIndex, - }; + const view = await createViewForEvents( + this.hass, + this.dataManager, + this.cameras, + this.view, + { + query: query, + targetView: options?.targetView, + mediaType: this.timelineConfig?.media, + }, + ); + view.mergeInContext( + this._generateTimelineContext({ noSetWindow: options?.noSetWindow }), + ); + if (options?.selectedItem) { + view.queryResults?.selectResultIfFound( + (media) => + !!this.cameras && + media.getID(this.cameras.get(media.getCameraID())) === options.selectedItem, + ); + } + return view; } /** @@ -695,46 +692,54 @@ export class FrigateCardTimelineCore extends LitElement { return new DataSet(groups); } - /** - * Given an event get an appropriate start/end time window around the event. - * @param event The FrigateEvent to consider. - * @returns A tuple of start/end date. - */ - protected _getStartEndFromEvent(event: FrigateEvent): [Date, Date] { - const windowSeconds = this._getConfiguredWindowSeconds(); - if (event.end_time) { - if (event.end_time - event.start_time > windowSeconds) { - // If the event is larger than the configured window, only show the most - // recent portion of the event that fits in the window. - return [ - sub(fromUnixTime(event.end_time), { seconds: windowSeconds }), - fromUnixTime(event.end_time), - ]; - } else { - // If the event is shorter than the configured window, center the event - // in the window. - const gap = windowSeconds - (event.end_time - event.start_time); - return [ - sub(fromUnixTime(event.start_time), { seconds: gap / 2 }), - add(fromUnixTime(event.end_time), { seconds: gap / 2 }), - ]; - } + protected _getPerfectWindowFromMedia(media: ViewMedia): TimelineWindow | null { + if (!ViewMediaClassifier.isMediaWithStartTime(media)) { + return null; } - // If there's no end-time yet, place the start-time in the center of the - // time window. - return [ - sub(fromUnixTime(event.start_time), { seconds: windowSeconds / 2 }), - add(fromUnixTime(event.start_time), { seconds: windowSeconds / 2 }), - ]; - } - /** - * Given a recording get the start/end window. - * @param recording The FrigateRecording to consider. - * @returns A tuple of start/end date. - */ - protected _getStartEndFromRecording(recording: FrigateRecording): [Date, Date] { - return [fromUnixTime(recording.start_time), fromUnixTime(recording.end_time)]; + if (media.isEvent()) { + const windowSeconds = this._getConfiguredWindowSeconds(); + + if (ViewMediaClassifier.isMediaWithStartEndTime(media)) { + if ( + media.getEndTime().getTime() - media.getStartTime().getTime() > + windowSeconds * 1000 + ) { + // If the event is larger than the configured window, only show the most + // recent portion of the event that fits in the window. + return { + start: sub(media.getEndTime(), { seconds: windowSeconds }), + end: media.getEndTime(), + }; + } else { + // If the event is shorter than the configured window, center the event + // in the window. + const gap = + windowSeconds - + (media.getEndTime().getTime() - media.getStartTime().getTime()) / 1000; + return { + start: sub(media.getStartTime(), { seconds: gap / 2 }), + end: add(media.getEndTime(), { seconds: gap / 2 }), + }; + } + } else { + // If there's no end-time yet, place the start-time in the center of the + // time window. + return { + start: sub(media.getStartTime(), { seconds: windowSeconds / 2 }), + end: add(media.getStartTime(), { seconds: windowSeconds / 2 }), + }; + } + } else if ( + media.isRecording() && + ViewMediaClassifier.isMediaWithStartEndTime(media) + ) { + return { + start: media.getStartTime(), + end: media.getEndTime(), + }; + } + return null; } /** @@ -751,12 +756,12 @@ export class FrigateCardTimelineCore extends LitElement { * Get desired timeline start/end time. * @returns A tuple of start/end date. */ - protected _getStartEnd(): [Date, Date] { + protected _getDefaultStartEnd(): TimelineWindow { const end = new Date(); const start = sub(end, { seconds: this._getConfiguredWindowSeconds(), }); - return [start, end]; + return { start: start, end: end }; } /** @@ -778,7 +783,7 @@ export class FrigateCardTimelineCore extends LitElement { return null; } - const [start, end] = this._getStartEnd(); + const defaultWindow = this._getDefaultStartEnd(); // Configuration for the Timeline, see: // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options @@ -796,17 +801,26 @@ export class FrigateCardTimelineCore extends LitElement { maxItems: this.timelineConfig.clustering_threshold, clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => { - // Never include the target media in a cluster, and never group - // different object types together (e.g. person and car). + if (!this.cameras) { + return false; + } + + const media = this.view?.queryResults?.getSelectedResult(); + const selectedId = media?.getID(this.cameras.get(media.getCameraID())); + const firstMedia = (first).media; + const secondMedia = (second).media; + + // Never include the currently selected item in a cluster, and + // never group different object types together (e.g. person and + // car). return ( - [first.type, second.type].every((type) => type !== 'background') && + first.type !== 'background' && first.type === second.type && - !!first.id && - first.id !== this.view?.media?.frigate?.event?.id && - !!second.id && - second.id != this.view?.media?.frigate?.event?.id && - (first).event?.label === - (second).event?.label + first.id !== selectedId && + second.id != selectedId && + !!firstMedia && + !!secondMedia && + firstMedia.isGroupableWith(secondMedia) ); }, } @@ -816,8 +830,8 @@ export class FrigateCardTimelineCore extends LitElement { zoomMax: 1 * 24 * 60 * 60 * 1000, zoomMin: 1 * 1000, selectable: true, - start: start, - end: end, + start: defaultWindow.start, + end: defaultWindow.end, groupHeightMode: 'auto', tooltip: { followMouse: true, @@ -828,12 +842,7 @@ export class FrigateCardTimelineCore extends LitElement { disabled: false, filterOptions: { whiteList: { - 'frigate-card-timeline-thumbnail': [ - 'details', - 'thumbnail', - 'label', - 'event', - ], + 'frigate-card-timeline-thumbnail': ['details', 'item'], div: ['title'], span: ['style'], }, @@ -856,86 +865,91 @@ export class FrigateCardTimelineCore extends LitElement { * Update the timeline from the view object. */ protected async _updateTimelineFromView(): Promise { - if (!this.hass || !this.cameras || !this.view || !this.timelineConfig) { + if ( + !this.hass || + !this.cameras || + !this.view || + !this.timelineConfig || + !this._timelineSource || + !this._timeline + ) { return; } - const event = this.view?.media?.frigate?.event; - const recording = this.view?.media?.frigate?.recording; + const timelineWindow = this._timeline.getWindow(); - const [windowStart, windowEnd] = event - ? this._getStartEndFromEvent(event) - : recording - ? this._getStartEndFromRecording(recording) - : this._getStartEnd(); + // Calculate the timeline window to show. If there is a window set in the + // view context, always honor that. Otherwise, if there's a selected media + // item that is already within the current window (even if it's not + // perfectly positioned) -- leave it as is. Otherwise, change the window to + // perfectly center on the media. + + let desiredWindow = timelineWindow; + const media = this.view.queryResults?.getSelectedResult(); + const mediaWindow: TimelineWindow | null = + media && ViewMediaClassifier.isMediaWithStartEndTime(media) + ? { start: media.getStartTime(), end: media.getEndTime() } + : null; + const context = this.view.context?.timeline; + + if (context && context.window) { + desiredWindow = context.window; + } else if (media && mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) { + const perfectMediaWindow = this._getPerfectWindowFromMedia(media); + if (perfectMediaWindow) { + desiredWindow = perfectMediaWindow; + } + } + const prefetchedWindow = this._getPrefetchWindow(desiredWindow); - let fetched = false; if (!this._pointerHeld) { // Don't fetch any data or touch the timeline in any way if the user is // currently interacting with it. Without this the subsequent data fetches // (via fetchIfNecessary) may update the timeline contents which causes // the visjs timeline to stop dragging/panning operations which is very // disruptive to the user. - const [prefetchStart, prefetchEnd] = this._getPrefetchWindow( - windowStart, - windowEnd, - ); - fetched = !!(await this.dataManager?.fetchIfNecessary( - this, - this.hass, - prefetchStart, - prefetchEnd, - )); - } - - this._timeline?.setSelection(event ? [event.id] : [], { - focus: false, - animation: { - animation: false, - zoom: false, - }, - }); - - if (!this._pointerHeld && event && this._isClustering()) { - // Hack: Clustering may not update unless the dataset changes, artifically - // update the dataset to ensure the newly selected item cannot be included - // in a cluster. Only do this when the pointer is not held to avoid - // interrupting the user and to make the timeline smoother. - this.dataManager?.rewriteItem(event.id); + await this._timelineSource?.refresh(this.hass, this.cameras, prefetchedWindow); } if ( !this._pointerHeld && - !this.view.context?.timeline?.noSetWindow && - this._timeline + media && + ViewMediaClassifier.isMediaWithID(media) && + this._isClustering() ) { - // Regenerate the thumbnails after the selection, to allow the new selection - // to be in the generated view. - const context = this.view.context?.timeline; - const timelineWindow = this._timeline.getWindow(); + // Hack: Clustering may not update unless the dataset changes, artifically + // update the dataset to ensure the newly selected item cannot be included + // in a cluster. Only do this when the pointer is not held to avoid + // interrupting the user and to make the timeline smoother. - // If there's a set context window, always move to it. - if (context?.window && !isEqual(context.window, timelineWindow)) { - this._timeline.setWindow(context.window.start, context.window.end); - } else if (event || recording) { - const source = event ?? (recording as FrigateEvent | FrigateRecording); - const start = fromUnixTime(source.start_time); - const end = source.end_time ? fromUnixTime(source.end_time) : 0; - - // If there's an event or recording outside the current window, move to it. - if ( - start < timelineWindow.start || - start > timelineWindow.end || - (end && (end < timelineWindow.start || end > timelineWindow.end)) - ) { - this._timeline.setWindow(windowStart, windowEnd); - } - } + // Need to this rewrite prior to setting the selection (just below), or + // the selection will be lost on rewrite. + this._timelineSource?.rewriteEvent(media.getID()); } - // Only generate thumbnails if an actual fetch occurred, to avoid getting - // stuck in a loop (the subsequent fetches will not actually fetch since the - // data will have been cached). + const desiredId = !!media && !!media.isEvent() ? media.getID() : null; + if (desiredId) { + this._timeline?.setSelection([desiredId], { + focus: false, + animation: { + animation: false, + zoom: false, + }, + }); + } + + // Set the timeline window if necessary. + if ( + !this._pointerHeld && + !this.view.context?.timeline?.noSetWindow && + !isEqual(desiredWindow, timelineWindow) + ) { + this._timeline.setWindow(desiredWindow.start, desiredWindow.end); + } + + // Only generate thumbnails if the existing query is not an acceptable + // match, to avoid getting stuck in a loop (the subsequent fetches will not + // actually fetch since the data will have been cached). // // Timeline receives a new `view` // -> Events fetched @@ -944,35 +958,45 @@ export class FrigateCardTimelineCore extends LitElement { // -> New view received ... [loop] // // Also don't generate thumbnails in mini-timelines (they will already have - // been generated), or if the media child is a recording. + // been generated), or if the view is for recordings (media thumbnails are + // recordings, not events in this case). + + const freshMediaQuery = this._createEventMediaQuerys({ + window: prefetchedWindow, + }); + if ( - (fetched || !this.view.context?.timeline?.generatedThumbnails) && !this.mini && - !recording + !this.view.is('recording') && + freshMediaQuery && + !this._alreadyHasAcceptableMediaQuery(freshMediaQuery) ) { - const thumbnails = this._generateThumbnails(); - this.view - ?.evolve({ - target: thumbnails?.target ?? null, - childIndex: thumbnails?.childIndex ?? null, - }) - .mergeInContext(this._generateTimelineContext()) + (await this._createViewWithEventMediaQuery(freshMediaQuery)) + ?.mergeInContext(this._generateTimelineContext({ noSetWindow: true })) .dispatchChangeEvent(this); } } + protected _alreadyHasAcceptableMediaQuery(freshMediaQuery: MediaQueries): boolean { + return ( + !!this.dataManager && + !!this.view?.query && + !!this.view.queryResults && + freshMediaQuery.isEqual(this.view.query) && + this.dataManager.areMediaQueriesResultsFresh( + this.view.query, + this.view.queryResults, + ) + ); + } + /** * Generate the context for timeline views. * @param options Configure how the context is set. * @returns The TimelineViewContext object. */ - protected _generateTimelineContext(options?: { - noSetWindow?: boolean; - generatedThumbnails?: boolean; - }): ViewContext { - const newContext: TimelineViewContext = { - generatedThumbnails: options?.generatedThumbnails ?? true, - }; + protected _generateTimelineContext(options?: { noSetWindow?: boolean }): ViewContext { + const newContext: TimelineViewContext = {}; if (options?.noSetWindow) { newContext.noSetWindow = options.noSetWindow; @@ -1003,6 +1027,27 @@ export class FrigateCardTimelineCore extends LitElement { this.removeAttribute('recordings'); } } + + if ( + changedProps.has('dataManager') || + changedProps.has('cameras') || + changedProps.has('timelineConfig') + ) { + if (this.dataManager && this.cameras && this.timelineConfig) { + this._timelineSource = new TimelineDataSource( + this.dataManager, + this._getTimelineCameraIDs(), + this.timelineConfig.media, + ); + } else { + this._timelineSource = null; + } + } + + const oldView = changedProps.get('view'); + if (oldView?.query && this.view?.query && !this.view.query.isEqual(oldView.query)) { + this._timelineSource?.clearEvents(); + } } /** @@ -1020,7 +1065,7 @@ export class FrigateCardTimelineCore extends LitElement { protected updated(changedProperties: PropertyValues): void { super.updated(changedProperties); - if (changedProperties.has('cameras')) { + if (changedProperties.has('cameras') || changedProperties.has('dataManager')) { this._destroy(); } @@ -1028,7 +1073,7 @@ export class FrigateCardTimelineCore extends LitElement { let createdTimeline = false; if ( - this.dataManager && + this._timelineSource && this._refTimeline.value && options && this.timelineConfig && @@ -1052,26 +1097,20 @@ export class FrigateCardTimelineCore extends LitElement { return; } - this._dataview = this.dataManager.createDataView( - this._getTimelineCameraIDs(), - !!this.timelineConfig.show_recordings, - this.timelineConfig.media, - ); - createdTimeline = true; if (this.mini && groups.length === 1) { // In a mini timeline, if there's only one group don't bother grouping // at all. this._timeline = new Timeline( this._refTimeline.value, - this._dataview, + this._timelineSource.dataset, options, ) as Timeline; this.removeAttribute('groups'); } else { this._timeline = new Timeline( this._refTimeline.value, - this._dataview, + this._timelineSource.dataset, groups, options, ) as Timeline; diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 9c7f1c1e..44766235 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -2,7 +2,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit import { customElement, property } from 'lit/decorators.js'; import timelineStyle from '../scss/timeline.scss'; import { CameraConfig, ExtendedHomeAssistant, TimelineConfig } from '../types'; -import { DataManager } from '../utils/data-manager'; +import { DataManager } from '../utils/data/data-manager'; import { View } from '../view'; import './surround.js'; import './timeline-core.js'; @@ -43,7 +43,6 @@ export class FrigateCardTimeline extends LitElement { .view=${this.view} .thumbnailConfig=${this.timelineConfig.controls.thumbnails} .cameras=${this.cameras} - .fetch=${false} > @@ -204,46 +201,52 @@ export class FrigateCardViewerCarousel extends LitElement { @property({ attribute: false, hasChanged: contentsChanged }) public viewerConfig?: ViewerConfig; - @property({ attribute: false }) - public browseMediaQueryParameters?: BrowseMediaQueryParameters[] | null; - @property({ attribute: false }) public resolvedMediaCache?: ResolvedMediaCache; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; - protected _refMediaCarousel: Ref = createRef(); + @property({ attribute: false }) + public cameras?: Map; - // Mapping of slide # to FrigateBrowseMediaSource child #. - // (Folders are not media items that can be rendered). - protected _slideToChild: Record = {}; + protected _refMediaCarousel: Ref = createRef(); protected _carouselOptions?: EmblaOptionsType; protected _carouselPlugins?: EmblaPluginType[]; // A task to resolve target media if lazy loading is disabled. protected _mediaResolutionTask = new Task< - [FrigateBrowseMediaSource | null | undefined], + [ViewerConfig | undefined, Map | undefined, View | undefined], void >( this, - async ([target]: (FrigateBrowseMediaSource | null | undefined)[]): Promise => { - for ( - let i = 0; - !this.viewerConfig?.lazy_load && - this.hass && - target && - target.children && - i < (target.children || []).length; - ++i + async ([viewerConfig, cameras, view]: [ + ViewerConfig | undefined, + Map | undefined, + View | undefined, + ]): Promise => { + if ( + !this.hass || + !viewerConfig?.lazy_load || + !cameras || + !view || + !view.queryResults?.hasResults() ) { - if (isTrueMedia(target.children[i])) { - await resolveMedia(this.hass, target.children[i], this.resolvedMediaCache); - } + return; } + const promises: Promise[] = []; + view.queryResults?.getResults()?.forEach((media: ViewMedia) => { + const mediaContentID = media.getContentID(cameras.get(media.getCameraID())); + if (this.hass && mediaContentID) { + promises.push( + resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache), + ); + } + }); + await Promise.all(promises); }, - () => [this.view?.target], + () => [this.viewerConfig, this.cameras, this.view], ); /** @@ -251,27 +254,8 @@ export class FrigateCardViewerCarousel extends LitElement { * @param changedProperties The properties that were changed in this render. */ updated(changedProperties: PropertyValues): void { - const frigateCardCarousel = this._refMediaCarousel.value?.frigateCardCarousel(); - - if (frigateCardCarousel && changedProperties.has('view')) { + if (changedProperties.has('view')) { const oldView = changedProperties.get('view') as View | undefined; - if (oldView) { - if ( - oldView.target === this.view?.target && - oldView.childIndex !== this.view.childIndex - ) { - const slide = this._getSlideForChild(this.view.childIndex); - if ( - slide !== null && - slide !== frigateCardCarousel.getCarouselSelected()?.index - ) { - // If the media target is the same as already loaded, but isn't of - // the selected slide, scroll to that slide. - frigateCardCarousel.carouselScrollTo(slide); - } - } - } - // Seek into the video if the seek time has changed (this is also called // on media load, since the media may or may not have been loaded at // this point). @@ -282,21 +266,6 @@ export class FrigateCardViewerCarousel extends LitElement { super.updated(changedProperties); } - /** - * Get the slide number given a media child number. - * @param childIndex The child index (relative to `view.target`) - * @returns A number or null if the child is not found. - */ - protected _getSlideForChild(childIndex: number | null | undefined): number | null { - if (childIndex === undefined || childIndex === null) { - return null; - } - const slideIndex = Object.keys(this._slideToChild).find( - (key) => this._slideToChild[key] === childIndex, - ); - return slideIndex !== undefined ? Number(slideIndex) : null; - } - /** * Get the transition effect to use. * @returns An TransitionEffect object. @@ -308,18 +277,6 @@ export class FrigateCardViewerCarousel extends LitElement { ); } - /** - * Get the Embla options to use. - * @returns An EmblaOptionsType object or undefined for no options. - */ - protected _getOptions(): EmblaOptionsType { - return { - // Start the carousel on the selected child number. - startIndex: this._getSlideForChild(this.view?.childIndex) ?? 0, - draggable: this.viewerConfig?.draggable ?? true, - }; - } - /** * The the HLS player on a slide (or current slide if not provided.) * @param slide An optional slide. @@ -344,10 +301,7 @@ export class FrigateCardViewerCarousel extends LitElement { protected _getPlugins(): EmblaPluginType[] { return [ // Only enable wheel plugin if there is more than one media item. - ...(this.view && - this.view.target && - this.view.target.children && - this.view.target.children.length > 1 + ...(this.view?.queryResults?.getResultsCount() ?? 0 > 1 ? [ WheelGesturesPlugin({ // Whether the carousel is vertical or horizontal, interpret y-axis wheel @@ -384,42 +338,20 @@ export class FrigateCardViewerCarousel extends LitElement { * @returns A BrowseMediaNeighbors with indices and objects of true media * neighbors. */ - protected _getMediaNeighbors(): BrowseMediaNeighbors | null { - if ( - !this.view || - !this.view.target || - !this.view.target.children || - this.view.childIndex === null - ) { - return null; + protected _getMediaNeighbors(): [ViewMedia | null, ViewMedia | null] { + const selectedIndex = this.view?.queryResults?.getSelectedIndex() ?? null; + const resultCount = this.view?.queryResults?.getResultsCount() ?? 0; + if (!this.view || !this.view.queryResults || selectedIndex === null) { + return [null, null]; } - // Work backwards from the index to get the previous real media. - let prevIndex: number | null = null; - for (let i = this.view.childIndex - 1; i >= 0; i--) { - const media = this.view.target.children[i]; - if (media && isTrueMedia(media)) { - prevIndex = i; - break; - } - } - - // Work forwards from the index to get the next real media. - let nextIndex: number | null = null; - for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) { - const media = this.view.target.children[i]; - if (media && isTrueMedia(media)) { - nextIndex = i; - break; - } - } - - return { - previousIndex: prevIndex, - previous: prevIndex != null ? this.view.target.children[prevIndex] : null, - nextIndex: nextIndex, - next: nextIndex != null ? this.view.target.children[nextIndex] : null, - }; + const previous: ViewMedia | null = + selectedIndex > 0 ? this.view.queryResults.getResult(selectedIndex - 1) : null; + const next: ViewMedia | null = + selectedIndex + 1 < resultCount + ? this.view.queryResults.getResult(selectedIndex + 1) + : null; + return [previous, next]; } /** @@ -428,91 +360,55 @@ export class FrigateCardViewerCarousel extends LitElement { * @param snapshot The snapshot to find a matching clip for. * @returns The view that would show the matching clip. */ - protected async _findRelatedClipView( - snapshot: FrigateBrowseMediaSource, - ): Promise { + protected async _createRelatedClipView(targetIndex: number): Promise { + const media = this.view?.queryResults?.getResult(targetIndex); + if ( !this.hass || !this.view || - !this.view.target || - !this.view.target.children || - !this.view.target.children.length || - !this.browseMediaQueryParameters + !media || + // If this specific media item has no clip, then do nothing (even if all + // the other media items do). + !ViewMediaClassifier.isFrigateEvent(media) || + !media.hasClip() || + !this.view.query?.areEventQueries() ) { return null; } - const snapshotStartTime = getEventStartTime(snapshot); - if (!snapshotStartTime) { - return null; - } + const newResults: ViewMedia[] = []; + let newSelectedIndex: number | null = null; - // Heuristic: At this point, the user has a particular snapshot that they - // are interested in and want to see a related clip, yet the viewer code - // does not know the exact search criteria that led to that snapshot (e.g. - // it could be a 10-deep folder in the gallery). To give the user to ability - // to 'navigate' in the clips view once they change into that mode, this - // heuristic finds the earliest and latest snapshot that the user is - // currently viewing and mirrors that range into the clips view. Then, - // within the results see if there's a clip that matches the same time as - // the snapshot. - let earliest: number | null = null; - let latest: number | null = null; - for (let i = 0; i < this.view.target.children.length; i++) { - const child = this.view.target.children[i]; - if (!isTrueMedia(child)) { + // Convert the query to a clips equivalent. + const newQuery = this.view.query.clone(); + newQuery.convertToClipsQueries(); + + // Regenerate the whole results stack. + for (let i = 0; i < (this.view.queryResults?.getResultsCount() ?? 0); ++i) { + const media = this.view.queryResults?.getResult(i); + if (!media || !ViewMediaClassifier.isFrigateEvent(media)) { continue; } - const startTime = getEventStartTime(child); - - if (startTime && (earliest === null || startTime < earliest)) { - earliest = startTime; - } - if (startTime && (latest === null || startTime > latest)) { - latest = startTime; + const clipMedia = media.getClipEquivalent(); + if (clipMedia) { + newResults.push(clipMedia); + if (i === targetIndex) { + newSelectedIndex = i; + } } } - if (!earliest || !latest) { + if (newSelectedIndex === null) { return null; } - let clips: FrigateBrowseMediaSource | null; + const newQueryResults = new MediaQueriesResults(newResults); + newQueryResults.selectResult(newSelectedIndex); - const params = overrideMultiBrowseMediaQueryParameters( - this.browseMediaQueryParameters, - { - mediaType: 'clips', - before: latest, - after: earliest, - }, - ); - - try { - clips = await multipleBrowseMediaQueryMerged(this.hass, params); - } catch (e) { - // This is best effort. - return null; - } - - if (!clips || !clips.children || !clips.children.length) { - return null; - } - - for (let i = 0; i < clips.children.length; i++) { - const child = clips.children[i]; - if (!isTrueMedia(child)) { - continue; - } - const clipStartTime = getEventStartTime(child); - if (clipStartTime && clipStartTime === snapshotStartTime) { - return this.view.evolve({ - view: 'clip', - target: clips, - childIndex: i, - }); - } - } - return null; + return this.view.evolve({ + view: 'clip', + query: newQuery, + queryResults: newQueryResults, + }); } /** @@ -523,13 +419,15 @@ export class FrigateCardViewerCarousel extends LitElement { return; } - // Update the childIndex in the view. - const childIndex = this._slideToChild[ev.detail.index]; - if (childIndex !== undefined) { + // The slide may already be selected on load, so don't dispatch a new view + // unless necessary. + if (ev.detail.index !== this.view.queryResults?.getSelectedIndex()) { this.view .evolve({ - childIndex: childIndex, + queryResults: this.view.queryResults?.clone().selectResult(ev.detail.index), }) + // Ensure the timeline is able to update its position. + .mergeInContext({ timeline: { noSetWindow: false } }) .dispatchChangeEvent(this); } } @@ -539,11 +437,11 @@ export class FrigateCardViewerCarousel extends LitElement { * default location will be the Chromecast receiver, not HA). * @param url The media URL */ - protected _canonicalizeHAURL(url?: string): string | undefined { + protected _canonicalizeHAURL(url?: string): string | null { if (this.hass && url && url.startsWith('/')) { return this.hass.hassUrl(url); } - return url; + return url ?? null; } /** @@ -551,44 +449,40 @@ export class FrigateCardViewerCarousel extends LitElement { * @param index The index of the slide to lazy load. * @param slide The slide to lazy load. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars protected _lazyloadSlide(index: number, slide: HTMLElement): void { - const childIndex: number | undefined = this._slideToChild[index]; - - if ( - childIndex === undefined || - !this.hass || - !this.view || - !this.view.target || - !this.view.target.children || - !isTrueMedia(this.view.target.children[childIndex]) - ) { + if (!this.hass || !this.view || !this.view.query || !this.cameras) { return; } - resolveMedia( - this.hass, - this.view.target.children[childIndex], - this.resolvedMediaCache, - ).then((resolvedMedia) => { - if (!resolvedMedia) { - return; - } + const media = this.view.queryResults?.getResult(index); + const mediaContentID = media + ? media.getContentID(this.cameras.get(media.getCameraID())) + : null; + if (!mediaContentID) { + return; + } - // Snapshots. - const img = slide.querySelector('img') as HTMLImageElement; + resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then( + (resolvedMedia) => { + if (!resolvedMedia) { + return; + } - // Frigate >= 0.9.0+ clips. - const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & { - url: string; - }; + // Snapshots. + const img = slide.querySelector('img') as HTMLImageElement; - if (img) { - img.src = this._canonicalizeHAURL(resolvedMedia.url) || ''; - } else if (hls_player) { - hls_player.url = this._canonicalizeHAURL(resolvedMedia.url) || ''; - } - }); + // Frigate >= 0.9.0+ clips. + const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & { + url: string; + }; + + if (img) { + img.src = this._canonicalizeHAURL(resolvedMedia.url) ?? ''; + } else if (hls_player) { + hls_player.url = this._canonicalizeHAURL(resolvedMedia.url) ?? ''; + } + }, + ); } /** @@ -596,21 +490,18 @@ export class FrigateCardViewerCarousel extends LitElement { * @returns The slides to include in the render. */ protected _getSlides(): TemplateResult[] { - if ( - !this.view || - !this.view.target || - !this.view.target.children || - !this.view.target.children.length - ) { + if (!this.view || !this.view.queryResults) { return []; } const slides: TemplateResult[] = []; - for (let i = 0; i < this.view.target.children?.length; ++i) { - const slide = this._renderMediaItem(this.view.target.children[i], slides.length); - - if (slide) { - slides.push(slide); + for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) { + const media = this.view.queryResults.getResult(i); + if (media) { + const slide = this._renderMediaItem(media, i); + if (slide) { + slides[i] = slide; + } } } return slides; @@ -620,8 +511,12 @@ export class FrigateCardViewerCarousel extends LitElement { * Determine if all the media in the carousel are resolved. */ protected _isMediaFullyResolved(): boolean { - for (const child of this.view?.target?.children || []) { - if (!this.resolvedMediaCache?.has(child.media_content_id)) { + if (!this.resolvedMediaCache || !this.cameras) { + return false; + } + for (const media of this.view?.queryResults?.getResults() ?? []) { + const mediaContentID = media.getContentID(this.cameras.get(media.getCameraID())); + if (mediaContentID && !this.resolvedMediaCache.has(mediaContentID)) { return false; } } @@ -633,29 +528,20 @@ export class FrigateCardViewerCarousel extends LitElement { * @param changedProps The changed properties */ protected willUpdate(changedProps: PropertyValues): void { - // Pre-populate a map between real media slides and view child indicies. - if (changedProps.has('view')) { - this._slideToChild = {}; - let i = 0; - (this.view?.target?.children ?? []).forEach((child, index) => { - if (isTrueMedia(child) && ['video', 'image'].includes(child.media_content_type)) { - this._slideToChild[i++] = index; - } - }) - } - if (changedProps.has('viewerConfig')) { updateElementStyleFromMediaLayoutConfig(this, this.viewerConfig?.layout); } if (!this._carouselOptions || changedProps.has('viewerConfig')) { - this._carouselOptions = this._getOptions(); + this._carouselOptions = { + draggable: this.viewerConfig?.draggable ?? true, + }; } if ( !this._carouselPlugins || changedProps.has('viewerConfig') || (changedProps.has('view') && - this.view?.target?.children?.length !== - changedProps.get('view')?.target?.children?.length) + this.view?.queryResults?.getResultsCount() !== + changedProps.get('view')?.queryResults?.getResultsCount()) ) { this._carouselPlugins = this._getPlugins(); } @@ -680,21 +566,20 @@ export class FrigateCardViewerCarousel extends LitElement { * @returns A template to display to the user. */ protected _render(): TemplateResult | void { - const slides = this._getSlides(); - - if (!slides.length || !this.view?.media) { + const media = this.view?.queryResults?.getSelectedResult(); + if (!media || !this.cameras) { return; } - const neighbors = this._getMediaNeighbors(); - const [prev, next] = [neighbors?.previous, neighbors?.next]; + const [prev, next] = this._getMediaNeighbors(); return html` { this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollPrevious(); stopEventFromActivatingCardWideActions(ev); }} > - ${slides} + ${guard(this.view?.queryResults?.getResults(), () => this._getSlides())} { this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext(); @@ -733,10 +620,12 @@ export class FrigateCardViewerCarousel extends LitElement { * Fire a media show event when a slide is selected. */ protected _recordingSeekHandler(): void { - const player = this._getPlayer(); - const childIndex = this.view?.childIndex ?? null; + const selectedIndex = this.view?.queryResults?.getSelectedIndex() ?? null; const seek = - childIndex !== null ? this.view?.context?.mediaViewer?.seek.get(childIndex) : null; + selectedIndex !== null + ? this.view?.context?.mediaViewer?.seek.get(selectedIndex) + : null; + const player = this._getPlayer(); if (player && seek) { player.seek(seek.seekSeconds); } @@ -744,59 +633,53 @@ export class FrigateCardViewerCarousel extends LitElement { /** * Render a single media item in the viewer carousel. - * @param mediaToRender The FrigateBrowseMediaSource to render. - * @param slideIndex The index of the slide to render. + * @param media The ViewMedia to render. + * @param index The (slide|queryResult) index of the item to render. * @returns A rendered template. */ - protected _renderMediaItem( - mediaToRender: FrigateBrowseMediaSource, - slideIndex: number, - ): TemplateResult | void { + protected _renderMediaItem(media: ViewMedia, index: number): TemplateResult | null { // Skip folders as they cannot be rendered by this viewer. - if ( - !this.hass || - !this.view || - !this.viewerConfig || - !isTrueMedia(mediaToRender) || - !['video', 'image'].includes(mediaToRender.media_content_type) - ) { - return; + if (!this.hass || !this.view || !this.viewerConfig || !this.cameras) { + return null; } const lazyLoad = this.viewerConfig.lazy_load; - const resolvedMedia = this.resolvedMediaCache?.get(mediaToRender.media_content_id); + const mediaContentID = media.getContentID(this.cameras.get(media.getCameraID())); + const resolvedMedia = mediaContentID + ? this.resolvedMediaCache?.get(mediaContentID) + : null; if (!resolvedMedia && !lazyLoad) { - return; + return null; } // The media is attached to the player as '.media' which is used in // `_selectSlideMediaShowHandler` (and not used by the player itself). return html`
- ${mediaToRender.media_content_type === 'video' + ${media.isVideo() ? html`) => { - wrapMediaLoadedEventForCarousel(slideIndex, e); + wrapMediaLoadedEventForCarousel(index, e); }} > ` : html` { if ( this._refMediaCarousel.value @@ -804,7 +687,7 @@ export class FrigateCardViewerCarousel extends LitElement { ?.carouselClickAllowed() && this.viewerConfig?.snapshot_click_plays_clip ) { - this._findRelatedClipView(mediaToRender).then((view) => { + this._createRelatedClipView(index).then((view) => { if (view) { view.dispatchChangeEvent(this); } @@ -822,9 +705,9 @@ export class FrigateCardViewerCarousel extends LitElement { // images in media-carousel.ts). Here we need to only call the // media load handler on a 'real' load. !lazyLoad || - lazyloadPlugin?.hasLazyloaded(slideIndex) + lazyloadPlugin?.hasLazyloaded(index) ) { - wrapRawMediaLoadedEventForCarousel(slideIndex, e); + wrapRawMediaLoadedEventForCarousel(index, e); } }}" />`} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index d296043b..a8cf3e39 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -333,7 +333,6 @@ "could_not_render_elements": "Could not render picture elements", "could_not_resolve": "Could not resolve media URL", "diagnostics": "Card diagnostics. Please review for confidential information prior to sharing", - "download_no_event_id": "Could not extract Frigate event id from media", "download_no_media": "No media to download", "download_sign_failed": "Could not sign media URL for download", "duplicate_camera_id": "Duplicate Frigate camera id for the following camera, use the 'id' parameter to uniquely identify cameras", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 8364175e..9def01a7 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -303,7 +303,6 @@ "could_not_render_elements": "Impossibile renderizzare gli elementi dell'immagine", "could_not_resolve": "Impossibile risolvere l'URL dei media", "diagnostics": "Diagnostica delle carte.Si prega di rivedere per informazioni riservate prima di condividere", - "download_no_event_id": "Impossibile estrarre l'evento ID tramite media", "download_no_media": "Nessun media da scaricare", "download_sign_failed": "Impossibile firmare URL multimediale per il download", "duplicate_camera_id": "Duplicato ID dellla telecamera Frigate, utilizzare il parametro 'ID' per identificare in modo univoco le telecamere", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index f93707a5..4aa0a965 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -303,7 +303,6 @@ "could_not_render_elements": "Não foi possível renderizar os elementos da imagem", "could_not_resolve": "Não foi possível resolver o URL de mídia", "diagnostics": "Diagnósticos do cartão. Revise as informações confidenciais antes de compartilhar", - "download_no_event_id": "Não foi possível extrair o Frigate ID do evento da mídia", "download_no_media": "Nenhuma mídia para download", "download_sign_failed": "Não foi possível assinar o URL de mídia para download", "duplicate_camera_id": "Duplique o ID da câmera Frigate para a câmera a seguir, use o parâmetro 'id' para identificar exclusivamente as câmeras", diff --git a/src/types.ts b/src/types.ts index fd2a01e4..a6647bca 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,6 +27,7 @@ export const THUMBNAIL_WIDTH_MIN = 75; */ export type ClipsOrSnapshots = 'clips' | 'snapshots'; +export type ClipsOrSnapshotsOrAll = 'clips' | 'snapshots' | 'all'; export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [ 'live', @@ -661,13 +662,30 @@ export type ImageViewConfig = z.infer; /** * Thumbnail controls configuration section. */ +const thumbnailControlsDefaults = { + mode: 'right' as const, + size: 100, + show_details: true, + show_favorite_control: true, + show_timeline_control: true, +}; const thumbnailsControlSchema = z.object({ - mode: z.enum(['none', 'above', 'below', 'left', 'right']), - size: z.number().min(THUMBNAIL_WIDTH_MIN).max(THUMBNAIL_WIDTH_MAX).optional(), - show_details: z.boolean().optional(), - show_favorite_control: z.boolean().optional(), - show_timeline_control: z.boolean().optional(), + mode: z + .enum(['none', 'above', 'below', 'left', 'right']) + .default(thumbnailControlsDefaults.mode), + size: z + .number() + .min(THUMBNAIL_WIDTH_MIN) + .max(THUMBNAIL_WIDTH_MAX) + .default(thumbnailControlsDefaults.size), + show_details: z.boolean().default(thumbnailControlsDefaults.show_details), + show_favorite_control: z + .boolean() + .default(thumbnailControlsDefaults.show_favorite_control), + show_timeline_control: z + .boolean() + .default(thumbnailControlsDefaults.show_timeline_control), }); export type ThumbnailsControlConfig = z.infer; @@ -752,6 +770,11 @@ const liveImageConfigDefault = { refresh_seconds: 1, }; +const liveThumbnailControlsDefaults = { + ...thumbnailControlsDefaults, + media: 'clips' as const, +}; + const liveConfigDefault = { auto_play: 'all' as const, auto_pause: 'never' as const, @@ -769,14 +792,7 @@ const liveConfigDefault = { size: 48, style: 'chevrons' as const, }, - thumbnails: { - media: 'clips' as const, - size: 100, - show_details: true, - show_favorite_control: true, - show_timeline_control: true, - mode: 'left' as const, - }, + thumbnails: liveThumbnailControlsDefaults, timeline: miniTimelineConfigDefault, title: { mode: 'popup-bottom-right' as const, @@ -785,6 +801,12 @@ const liveConfigDefault = { }, }; +const livethumbnailsControlSchema = thumbnailsControlSchema.extend({ + media: z + .enum(['clips', 'snapshots']) + .default(liveConfigDefault.controls.thumbnails.media), +}); + const liveImageConfigSchema = z.object({ refresh_seconds: z.number().min(0).default(liveConfigDefault.image.refresh_seconds), }); @@ -834,30 +856,9 @@ const liveOverridableConfigSchema = z ), }) .default(liveConfigDefault.controls.next_previous), - thumbnails: thumbnailsControlSchema - .extend({ - mode: thumbnailsControlSchema.shape.mode.default( - liveConfigDefault.controls.thumbnails.mode, - ), - size: thumbnailsControlSchema.shape.size.default( - liveConfigDefault.controls.thumbnails.size, - ), - show_details: thumbnailsControlSchema.shape.show_details.default( - liveConfigDefault.controls.thumbnails.show_details, - ), - show_favorite_control: - thumbnailsControlSchema.shape.show_favorite_control.default( - liveConfigDefault.controls.thumbnails.show_favorite_control, - ), - show_timeline_control: - thumbnailsControlSchema.shape.show_timeline_control.default( - liveConfigDefault.controls.thumbnails.show_timeline_control, - ), - media: z - .enum(['clips', 'snapshots']) - .default(liveConfigDefault.controls.thumbnails.media), - }) - .default(liveConfigDefault.controls.thumbnails), + thumbnails: livethumbnailsControlSchema.default( + liveConfigDefault.controls.thumbnails, + ), timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline), title: titleControlConfigSchema .extend({ @@ -994,13 +995,7 @@ const viewerConfigDefault = { size: 48, style: 'thumbnails' as const, }, - thumbnails: { - size: 100, - show_details: true, - show_favorite_control: true, - show_timeline_control: true, - mode: 'left' as const, - }, + thumbnails: thumbnailControlsDefaults, timeline: miniTimelineConfigDefault, title: { mode: 'popup-bottom-right' as const, @@ -1047,27 +1042,9 @@ const viewerConfigSchema = z next_previous: viewerNextPreviousControlConfigSchema.default( viewerConfigDefault.controls.next_previous, ), - thumbnails: thumbnailsControlSchema - .extend({ - mode: thumbnailsControlSchema.shape.mode.default( - viewerConfigDefault.controls.thumbnails.mode, - ), - size: thumbnailsControlSchema.shape.size.default( - viewerConfigDefault.controls.thumbnails.size, - ), - show_details: thumbnailsControlSchema.shape.show_details.default( - viewerConfigDefault.controls.thumbnails.show_details, - ), - show_favorite_control: - thumbnailsControlSchema.shape.show_favorite_control.default( - viewerConfigDefault.controls.thumbnails.show_favorite_control, - ), - show_timeline_control: - thumbnailsControlSchema.shape.show_timeline_control.default( - viewerConfigDefault.controls.thumbnails.show_timeline_control, - ), - }) - .default(viewerConfigDefault.controls.thumbnails), + thumbnails: thumbnailsControlSchema.default( + viewerConfigDefault.controls.thumbnails, + ), timeline: miniTimelineConfigSchema.default( viewerConfigDefault.controls.timeline, ), @@ -1364,14 +1341,6 @@ export interface BrowseRecordingQueryParameters { hour: number; } -export interface BrowseMediaNeighbors { - previous: FrigateBrowseMediaSource | null; - previousIndex: number | null; - - next: FrigateBrowseMediaSource | null; - nextIndex: number | null; -} - export interface MediaLoadedInfo { width: number; height: number; @@ -1434,7 +1403,7 @@ export const MEDIA_TYPE_VIDEO = 'video' as const; // 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#L46 -interface BrowseMediaSource { +export interface BrowseMediaSource { title: string; media_class: string; media_content_type: string; diff --git a/src/utils/basic.ts b/src/utils/basic.ts index a9f93620..a02b0af7 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -1,7 +1,12 @@ +import differenceInHours from 'date-fns/differenceInHours'; +import differenceInMinutes from 'date-fns/differenceInMinutes'; +import differenceInSeconds from 'date-fns/differenceInSeconds'; import format from 'date-fns/format'; import isEqual from 'lodash-es/isEqual'; import { FrigateCardError } from '../types'; +export type ModifyInterface = Omit & R; + /** * Dispatch a Frigate Card event. * @param element The element to send the event. @@ -51,6 +56,24 @@ export function arrayMove(target: unknown[], from: number, to: number): void { target.splice(to, 0, element); } +/** + * Convert a value to an array if it is not already one. + * @param value: A value (which may be an array). + * @returns An array. + */ +export const arrayify = (value: T | T[]): T[] => { + return Array.isArray(value) ? value : [value]; +}; + +/** + * Convert a value to an set if it is not already one. + * @param value: A value (which may be a set, an array or a T) + * @returns A set of T. + */ +export const setify = (value: T | T[] | Set): Set => { + return value instanceof Set ? value : new Set(arrayify(value)); +}; + /** * Determine if the contents of the n(ew) and o(ld) values have changed. For use * in lit web components that may have a value that changes address but not @@ -68,10 +91,7 @@ export function contentsChanged(n: unknown, o: unknown): boolean { * @param e The Error object. * @param func The Console func to call. */ -export function errorToConsole(e: Error, func?: CallableFunction): void { - if (!func) { - func = console.warn; - } +export function errorToConsole(e: Error, func: CallableFunction = console.warn): void { if (e instanceof FrigateCardError && e.context) { func(e, e.context); } else { @@ -83,9 +103,8 @@ export function errorToConsole(e: Error, func?: CallableFunction): void { * Determine if the device supports hovering. * @returns `true` if the device supports hovering, `false` otherwise. */ -export const isHoverableDevice = (): boolean => window.matchMedia( - '(hover: hover) and (pointer: fine)', -).matches; +export const isHoverableDevice = (): boolean => + window.matchMedia('(hover: hover) and (pointer: fine)').matches; /** * Format a date object to RFC3339. @@ -94,7 +113,7 @@ export const isHoverableDevice = (): boolean => window.matchMedia( */ export const formatDateAndTime = (date: Date): string => { return format(date, 'yyyy-MM-dd HH:mm'); -} +}; /** * Run a function in idle periods. If idle callbacks are not supported (e.g. @@ -105,9 +124,34 @@ export const formatDateAndTime = (date: Date): string => { export const runWhenIdleIfSupported = (func: () => void, timeout?: number): void => { if (window.requestIdleCallback) { window.requestIdleCallback(func, { - ...(timeout && { timeout: timeout}) + ...(timeout && { timeout: timeout }), }); } else { func(); } -} \ No newline at end of file +}; + +/** + * Convenience function to return a string representing the difference in hours, + * minutes and seconds between two dates. Heavily inspired by, and returning the + * same format as, the Frigate UI: + * https://github.com/blakeblackshear/frigate/blob/master/web/src/components/RecordingPlaylist.jsx#L97 + * @param start The start date. + * @param end The end date. + * @returns A duration string. + */ +export function getDurationString(start: Date, end: Date): string { + const hours = differenceInHours(end, start); + const minutes = differenceInMinutes(end, start) - hours * 60; + const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60; + let duration = ''; + + if (hours) { + duration += `${hours}h `; + } + if (minutes) { + duration += `${minutes}m `; + } + duration += `${seconds}s`; + return duration; +} diff --git a/src/utils/camera.ts b/src/utils/camera.ts index 3dd747e6..4f718d5f 100644 --- a/src/utils/camera.ts +++ b/src/utils/camera.ts @@ -81,13 +81,13 @@ export function getCameraIcon( */ export const getAllDependentCameras = ( cameras: Map, - camera?: string, + cameraID?: string, ): Set => { const cameraIDs: Set = new Set(); - const getDependentCameras = (camera: string): void => { - const cameraConfig = cameras.get(camera); + const getDependentCameras = (cameraID: string): void => { + const cameraConfig = cameras.get(cameraID); if (cameraConfig) { - cameraIDs.add(camera); + cameraIDs.add(cameraID); const dependentCameras: Set = new Set(); (cameraConfig.dependencies.cameras || []).forEach((item) => dependentCameras.add(item), @@ -102,39 +102,8 @@ export const getAllDependentCameras = ( } } }; - if (camera) { - getDependentCameras(camera); + if (cameraID) { + getDependentCameras(cameraID); } return cameraIDs; }; - -/** - * Return the cameraIDs of truly unique cameras (some configured cameras may be - * the same Frigate came but with different zone/labels). - * @param cameras The full set of cameras. - * @param cameraIDs The specific IDs to dedup. - */ -export const getTrueCameras = ( - cameras: Map, - cameraIDs: Set, -): Set => { - const getTrueCameraID = (cameraConfig: CameraConfig): string => { - return `${cameraConfig.frigate?.client_id ?? ''}/${ - cameraConfig.frigate.camera_name ?? '' - }`; - }; - - const output = new Set(); - const visitedTrueCameras = new Set(); - cameraIDs.forEach((cameraID: string) => { - const cameraConfig = cameras.get(cameraID) ?? null; - if (cameraConfig && cameraConfig.frigate.camera_name) { - const trueCameraID = getTrueCameraID(cameraConfig); - if (!visitedTrueCameras.has(trueCameraID)) { - output.add(cameraID); - visitedTrueCameras.add(trueCameraID); - } - } - }); - return output; -}; diff --git a/src/utils/data-manager.ts b/src/utils/data-manager.ts deleted file mode 100644 index 4eda65c9..00000000 --- a/src/utils/data-manager.ts +++ /dev/null @@ -1,540 +0,0 @@ -import { HomeAssistant } from 'custom-card-helpers'; -import { DataSet, DataView } from 'vis-data/esnext'; -import type { IdType, TimelineItem } from 'vis-timeline/esnext'; -import { CAMERA_BIRDSEYE } from '../const.js'; -import { - CameraConfig, - ExtendedHomeAssistant, - FrigateCardError, - FrigateEvent, - FrigateEvents, -} from '../types.js'; -import { errorToConsole, runWhenIdleIfSupported } from './basic.js'; -import { - FrigateGetEventsParameters, - getEventsMultiple, - getRecordingSegments, - getRecordingsSummary, - RecordingSegments, - RecordingSummary, -} from './frigate.js'; -import { dispatchFrigateCardErrorEvent } from '../components/message.js'; -import fromUnixTime from 'date-fns/fromUnixTime'; -import throttle from 'lodash-es/throttle'; - -const RECORDING_SEGMENT_TOLERANCE = 60; -const DATA_MANAGER_MAX_AGE_SECONDS = 10; -const DATA_MANAGER_MAX_FETCH_COUNT = 10000; - -export interface FrigateCardTimelineItem extends TimelineItem { - // DataView has issues using datasets with Date objects, so avoid them and use - // numbers instead. - start: number; - end?: number; - event?: FrigateEvent; -} - -type TimelineMediaType = 'all' | 'clips' | 'snapshots'; - -export interface RecordingSegmentsItem { - id: string; - cameraID: string; - start: number; - end: number; -} - -/** - * Sort the timeline items most recent to least recent. - * @param a The first item. - * @param b The second item. - * @returns -1, 0, 1 (standard array sort function configuration). - */ -export const sortYoungestToOldest = ( - a: RecordingSegmentsItem | FrigateCardTimelineItem, - b: RecordingSegmentsItem | FrigateCardTimelineItem, -): number => { - if (a.start < b.start) { - return 1; - } - if (a.start > b.start) { - return -1; - } - return 0; -}; - -/** - * Sort the segments least recent to most recent. - * @param a The first item. - * @param b The second item. - * @returns -1, 0, 1 (standard array sort function configuration). - */ -export const sortOldestToYoungest = ( - a: RecordingSegmentsItem | FrigateCardTimelineItem, - b: RecordingSegmentsItem | FrigateCardTimelineItem, -): number => { - if (a.start < b.start) { - return -1; - } - if (a.start > b.start) { - return 1; - } - return 0; -}; - -/** - * A manager to maintain/fetch timeline events. - */ -export class DataManager { - protected _recordingSummary: Map = new Map(); - protected _recordingSegments = new DataSet(); - - protected _dataset = new DataSet(); - - // The earliest date managed. - protected _dateStart: Date | null = null; - - // The latest date managed. - protected _dateEnd: Date | null = null; - - // The last fetch date. - protected _dateFetch: Date | null = null; - - // The maximum allowable age of fetch data (will not fetch more frequently - // than this). - protected _maxAgeSeconds: number = DATA_MANAGER_MAX_AGE_SECONDS; - - protected _cameras: Map; - - // Garbage collect segments at most once an hour. - protected _throttledSegmentGarbageCollector = throttle( - () => { - runWhenIdleIfSupported(this._garbageCollectSegments.bind(this)); - }, - 60 * 60 * 1000, - { trailing: true }, - ); - - constructor(cameras: Map) { - this._cameras = cameras; - } - - // Get the last event fetch date. - get lastFetchDate(): Date | null { - return this._dateFetch ?? null; - } - - public getRecordingSummaryForCamera(cameraID: string): RecordingSummary | null { - return this._recordingSummary.get(cameraID) ?? null; - } - - /** - * Create a dataview for a given set of camera. - * @param cameraIDs The cameraIDs to include. - * @param showRecordings Whether or not to show recordings. - * @returns A dataview. - */ - public createDataView( - cameraIDs: Set, - showRecordings: boolean, - mediaType: TimelineMediaType, - ): DataView { - return new DataView(this._dataset, { - filter: (item: FrigateCardTimelineItem) => - // Only return items for the given cameras. - !!item.group && - cameraIDs.has(String(item.group)) && - // Don't return recordings if the user does not want them. - (showRecordings || item.type !== 'background') && - // Don't return events that are the wrong media type. - (item.type === 'background' || - mediaType === 'all' || - (mediaType === 'clips' && !!item.event?.has_clip) || - (mediaType === 'snapshots' && !!item.event?.has_snapshot)), - }); - } - - /** - * Create a dataview for segments. - * @returns A dataview. - */ - public createSegmentDataView(): DataView { - return new DataView(this._recordingSegments); - } - - /** - * Get the underlying recording segments dataset. - */ - get recordingSegments(): DataSet { - return this._recordingSegments; - } - - /** - * Rewrite an item as-is. May be useful in cases where clustering may need to - * be recalculated. - * @param id The id to rewrite. - */ - public rewriteItem(id: IdType): void { - // Hack: Clustering may not update unless the dataset changes, artifically - // update the dataset to ensure the newly selected item cannot be included - // in a cluster. - const item = this._dataset.get(id); - if (item) { - this._dataset.updateOnly(item); - } - } - - /** - * Add events for the given camera. - * @param cameraID The camera ID. - * @param events The array of events. - */ - protected _addEvents(cameraID: string, events: FrigateEvents): void { - this._dataset.update( - events.map((event) => ({ - id: event.id, - group: cameraID, - content: '', - event: event, - start: event.start_time * 1000, - type: event.end_time ? 'range' : 'point', - ...(event.end_time && { end: event.end_time * 1000 }), - })), - ); - } - - /** - * Determine if the timeline has coverage for a given range of dates. - * @param start The start of the date range. - * @param end An optional end of the date range. - * @returns - */ - public hasCoverage(now: Date, start: Date, end?: Date): boolean { - // Never fetched: no coverage. - if (!this._dateFetch || !this._dateStart || !this._dateEnd) { - return false; - } - - // If the most recent fetch is older than maxAgeSeconds: no coverage. - if ( - this._maxAgeSeconds && - now.getTime() - this._dateFetch.getTime() > this._maxAgeSeconds * 1000 - ) { - return false; - } - - // If the most requested data is earlier than the earliest stored: no - // coverage. - if (start < this._dateStart) { - return false; - } - - // If there's no end time specified: there IS coverage. - if (!end) { - return true; - } - // If the requested end time is older than the oldest requested: there IS - // coverage. - if (end.getTime() < this._dateEnd.getTime()) { - return true; - } - // If there's no maxAgeSeconds specified: no coverage. - if (!this._maxAgeSeconds) { - return false; - } - // If the requested end time is beyond `_maxAgeSeconds` of now: no coverage. - if (now.getTime() - end.getTime() > this._maxAgeSeconds * 1000) { - return false; - } - - // End time is within `_maxAgeSeconds` of the latest data: there IS - // coverage. - return end.getTime() - this._maxAgeSeconds * 1000 <= this._dateEnd.getTime(); - } - - /** - * Fetch events if no coverage in given range. - * @param element The element to send error events from. - * @param hass The HomeAssistant object. - * @param start Fetch events that start later than this date. - * @param end Fetch events that start earlier than this date. - * @returns `true` if events were fetched, `false` otherwise. - */ - public async fetchIfNecessary( - element: HTMLElement, - hass: ExtendedHomeAssistant, - start: Date, - end: Date, - ): Promise { - // Cannot fetch the future, always clip the end date to now so as to avoid - // checking for coverage that could not possibly exist yet. - const now = new Date(); - end = end > now ? now : end; - - if (this.hasCoverage(now, start, end)) { - return false; - } - - const oldStart = this._dateStart; - const oldEnd = this._dateEnd; - let segmentStart: Date | null = null; - let segmentEnd: Date | null = null; - if (!this._dateStart || start < this._dateStart) { - this._dateStart = start; - segmentStart = start; - } else { - segmentStart = oldEnd ?? end; - } - if (!this._dateEnd || end > this._dateEnd) { - this._dateEnd = end; - segmentEnd = end; - } else { - segmentEnd = oldStart ?? start; - } - - this._dateFetch = new Date(); - - await Promise.all([ - // Events are always fetched for the maximum extent of the managed - // range. This is because events may change at any point in time - // (e.g. a long-running event that ends). - this._fetchEvents(element, hass, this._dateStart, this._dateEnd), - this._fetchRecordingSummary(hass), - ...(segmentEnd > segmentStart - ? [this._fetchRecordingSegments(hass, segmentStart, segmentEnd)] - : []), - ]); - - this._throttledSegmentGarbageCollector(); - return true; - } - - /** - * Garbage collect recording segments that no longer feature in the summary. - */ - protected _garbageCollectSegments(): void { - if (!this._recordingSegments || !this._recordingSummary) { - return; - } - - // Performance: _recordingSegments is potentially very large (e.g. 10K - 1M - // items) and each item must be examined, so care required here to stick to - // nothing worse than O(n) performance. - const getHourID = (cameraID: string, day: number, hour: number): string => { - return `${cameraID}/${day}/${hour}`; - }; - - const goodHours: Set = new Set(); - for (const cameraID of this._recordingSummary.keys()) { - for (const summaryDay of this._recordingSummary?.get(cameraID) ?? []) { - for (const summaryHour of summaryDay.hours) { - goodHours.add(getHourID(cameraID, summaryDay.day.getDate(), summaryHour.hour)); - } - } - } - - const deleteIDs: string[] = []; - this._recordingSegments.forEach((item, id) => { - const startDate = fromUnixTime(item.start / 1000); - const hourID = getHourID(item.cameraID, startDate.getDate(), startDate.getHours()); - - // ~O(1) lookup time for a JS set. - if (!goodHours.has(hourID)) { - deleteIDs.push(String(id)); - } - }); - - this._recordingSegments.remove(deleteIDs); - this._compressRecordingSegmentsOntoTimeline(); - } - - /** - * Fetch recording segments for cameras. - * @param hass The HomeAssistant object. - * @param start Fetch segments that start later than this date. - * @param end Fetch segments that start earlier than this date. - */ - protected async _fetchRecordingSegments( - hass: ExtendedHomeAssistant, - start: Date, - end: Date, - ): Promise { - const results: Map = new Map(); - const fetch = async (camera: string, config?: CameraConfig): Promise => { - if (!config || !config.frigate.camera_name || !hass) { - return; - } - - try { - const cameraResults = await getRecordingSegments( - hass, - config.frigate.client_id, - config.frigate.camera_name, - end, - start, - ); - results.set(camera, cameraResults); - } catch (e) { - errorToConsole(e as Error); - } - }; - - await Promise.all( - Array.from(this._cameras.keys()).map((camera) => - fetch(camera, this._cameras.get(camera)), - ), - ); - - const items: RecordingSegmentsItem[] = []; - results.forEach((segments, cameraID) => { - segments.forEach((segment) => { - items.push({ - id: `${cameraID}/${segment.id}`, - cameraID: cameraID, - start: segment.start_time * 1000, - end: segment.end_time * 1000, - }); - }); - }); - this._recordingSegments.update(items); - this._compressRecordingSegmentsOntoTimeline(); - } - - /** - * Compress recording segments into recordings shown on the timeline - * background. - */ - protected _compressRecordingSegmentsOntoTimeline(): void { - if (!this._recordingSegments.length) { - return; - } - - // Delete all the existing background. - this._dataset.remove( - this._dataset.get({ - filter: (item) => item.type === 'background', - }), - ); - - const convertToRecording = ( - segment: RecordingSegmentsItem, - ): FrigateCardTimelineItem => { - return { - id: `recording-${segment.cameraID}-${segment.id}`, - group: segment.cameraID, - start: segment.start, - end: segment.end, - content: ' ', - type: 'background', - }; - }; - - // Iterate through the segments least to most recent, effectively joining - // segments together that are within a certain tolerance to create large - // blocks that are visualized on the timeline as recordings. - const recordings: FrigateCardTimelineItem[] = []; - - this._cameras.forEach((_, cameraID) => { - const segments = this._recordingSegments.get({ - filter: (item) => item.cameraID === cameraID, - order: sortOldestToYoungest, - }); - let current: RecordingSegmentsItem | null = null; - for (let i = 0; i < segments.length; ++i) { - const item = segments[i]; - - if (!current) { - current = { ...item }; - } else if (current.end + RECORDING_SEGMENT_TOLERANCE * 1000 >= item.start) { - current.end = item.end; - } else { - recordings.push(convertToRecording(current)); - current = null; - } - if (i === segments.length - 1 && current) { - recordings.push(convertToRecording(current)); - } - } - }); - - this._dataset.update(recordings); - } - - /** - * Fetch recording summary. - * @param hass The HomeAssistant object. - */ - protected async _fetchRecordingSummary(hass: ExtendedHomeAssistant): Promise { - const storeRecordingSummary = async ( - cameraID: string, - cameraConfig: CameraConfig, - ): Promise => { - if (!cameraConfig.frigate.camera_name) { - return; - } - try { - this._recordingSummary.set( - cameraID, - await getRecordingsSummary( - hass, - cameraConfig.frigate.client_id, - cameraConfig.frigate.camera_name, - ), - ); - } catch (e) { - // Recording failure should not disrupt the rest of the timeline - // experience. - errorToConsole(e as Error); - } - }; - - await Promise.all( - Array.from(this._cameras.keys()).map(async (cameraID) => { - const cameraConfig = this._cameras.get(cameraID); - if (cameraConfig) { - await storeRecordingSummary(cameraID, cameraConfig); - } - }), - ); - } - - /** - * Fetch events for the timeline. - * @param element The element to send error events from. - * @param hass The HomeAssistant object. - * @param start Fetch events that start later than this date. - * @param end Fetch events that start earlier than this date. - */ - protected async _fetchEvents( - element: HTMLElement, - hass: HomeAssistant, - start: Date, - end: Date, - ): Promise { - const params: Map = new Map(); - - this._cameras.forEach((cameraConfig, cameraID) => { - if ( - cameraConfig.frigate.camera_name && - cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE - ) { - params.set(cameraID, { - instance_id: cameraConfig.frigate.client_id, - camera: cameraConfig.frigate.camera_name, - ...(cameraConfig.frigate.label && { label: cameraConfig.frigate.label }), - ...(cameraConfig.frigate.zone && { label: cameraConfig.frigate.zone }), - before: Math.floor(end.getTime() / 1000), - after: Math.floor(start.getTime() / 1000), - limit: DATA_MANAGER_MAX_FETCH_COUNT, - }); - } - }); - - let results: Map; - try { - results = await getEventsMultiple(hass, params); - } catch (e) { - return dispatchFrigateCardErrorEvent(element, e as FrigateCardError); - } - results.forEach((params, cameraID) => this._addEvents(cameraID, params)); - } -} diff --git a/src/utils/data/data-manager-cache.ts b/src/utils/data/data-manager-cache.ts new file mode 100644 index 00000000..a325285a --- /dev/null +++ b/src/utils/data/data-manager-cache.ts @@ -0,0 +1,135 @@ +import isEqual from 'lodash-es/isEqual'; +import orderBy from 'lodash-es/orderBy'; +import sortedUniqBy from 'lodash-es/sortedUniqBy'; +import { RecordingSegment, RecordingSegments } from '../frigate'; +import { DateRange, MemoryRangeSet } from './data-manager-range'; +import { DataQuery, QueryResults } from './data-types'; + +interface RequestCacheItem { + request: Request; + response: Response; + expires?: Date; +} + +interface DataManagerCache { + get(request: Request): Response | null; + has(request: Request): boolean; + set(request: Request, response: Response, expiry?: Date): void; +} + +export class MemoryRequestCache + implements DataManagerCache +{ + protected _data: RequestCacheItem[] = []; + + public get(request: Request): Response | null { + const now = this._now(); + for (const item of this._data) { + if ( + (!item.expires || now <= item.expires) && + this._contains(request, item.request) + ) { + return item.response; + } + } + return null; + } + + public has(request: Request): boolean { + return !!this.get(request); + } + + public set(request: Request, response: Response, expiry?: Date): void { + this._data.push({ + request: request, + response: response, + expires: expiry, + }); + + // Clean up old requests on set. + this._expireOldRequests(); + } + + protected _now(): Date { + return new Date(); + } + + protected _contains(a: Request, b: Request): boolean { + return isEqual(a, b); + } + + protected _expireOldRequests(): void { + const now = this._now(); + this._data = this._data.filter((item) => !item.expires || now < item.expires); + } +} + +export class RequestCache extends MemoryRequestCache {} + +export class MemoryRangedCache { + protected _ranges: MemoryRangeSet = new MemoryRangeSet(); + protected _data: Data[] = []; + protected _timeFunc: (data: Data) => number; + protected _idFunc: (data: Data) => string; + + constructor(timeFunc: (data: Data) => number, idFunc: (data: Data) => string) { + this._timeFunc = timeFunc; + this._idFunc = idFunc; + } + + public add(range: DateRange, data: Data[]) { + this._ranges.add(range); + this._data = sortedUniqBy( + orderBy(this._data.concat(data), this._timeFunc, 'asc'), + this._idFunc, + ); + } + + public hasCoverage(range: DateRange): boolean { + return this._ranges.hasCoverage(range); + } + + public get(range: DateRange): Data[] | null { + if (!this.hasCoverage(range)) { + return null; + } + + const output: Data[] = []; + for (const data of this._data) { + const start = this._timeFunc(data); + if (start > range.start.getTime()) { + if (start > range.end.getTime()) { + // Data is kept in order. + break; + } + output.push(data); + } + } + return output; + } +} + +export class RecordingSegmentsCache { + protected _segments: Map> = new Map(); + + public add(cameraID: string, range: DateRange, segments: RecordingSegments) { + let cameraSegmentCache: MemoryRangedCache | undefined = + this._segments.get(cameraID); + if (!cameraSegmentCache) { + cameraSegmentCache = new MemoryRangedCache( + (segment: RecordingSegment) => segment.start_time * 1000, + (segment: RecordingSegment) => segment.id, + ); + this._segments.set(cameraID, cameraSegmentCache); + } + cameraSegmentCache.add(range, segments); + } + + public hasCoverage(cameraID: string, range: DateRange): boolean { + return !!this._segments.get(cameraID)?.hasCoverage(range); + } + + public get(cameraID: string, range: DateRange): RecordingSegments | null { + return this._segments.get(cameraID)?.get(range) ?? null; + } +} diff --git a/src/utils/data/data-manager-engine-factory.ts b/src/utils/data/data-manager-engine-factory.ts new file mode 100644 index 00000000..687ba290 --- /dev/null +++ b/src/utils/data/data-manager-engine-factory.ts @@ -0,0 +1,42 @@ +import { CameraConfig } from '../../types'; +import { RecordingSegmentsCache } from './data-manager-cache'; +import { DataManagerEngine } from './data-manager-engine'; +import { FrigateDataManagerEngine } from './data-manager-engine-frigate'; +import { DataQuery } from './data-types'; + +export class DataManagerEngineFactory { + protected _engines: Map = new Map(); + + protected _getOrCreateEngine(engineKey: string): DataManagerEngine | null { + const cachedEngine = this._engines.get(engineKey); + if (cachedEngine) { + return cachedEngine; + } + let newEngine: DataManagerEngine | null = null; + switch (engineKey) { + case 'frigate': + newEngine = new FrigateDataManagerEngine(new RecordingSegmentsCache()); + break; + } + if (newEngine) { + this._engines.set(engineKey, newEngine); + } + return newEngine; + } + + public getEngineForQuery( + cameras: Map, + query: DataQuery, + ): DataManagerEngine | null { + const cameraConfig = cameras.get(query.cameraID); + return cameraConfig ? this.getEngineForCamera(cameraConfig) : null; + } + + public getEngineForCamera(cameraConfig: CameraConfig): DataManagerEngine | null { + let engineKey: string | null = null; + if (cameraConfig.frigate.camera_name) { + engineKey = 'frigate'; + } + return engineKey ? this._getOrCreateEngine(engineKey) : null; + } +} diff --git a/src/utils/data/data-manager-engine-frigate.ts b/src/utils/data/data-manager-engine-frigate.ts new file mode 100644 index 00000000..4ed00aed --- /dev/null +++ b/src/utils/data/data-manager-engine-frigate.ts @@ -0,0 +1,397 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import add from 'date-fns/add'; +import endOfHour from 'date-fns/endOfHour'; +import getUnixTime from 'date-fns/getUnixTime'; +import startOfHour from 'date-fns/startOfHour'; +import { CAMERA_BIRDSEYE } from '../../const'; +import { CameraConfig, FrigateRecording } from '../../types'; +import { MediaQueries, MediaQueriesResults } from '../../view'; +import { ViewMedia, ViewMediaClassifier, ViewMediaFactory } from '../../view-media'; +import { errorToConsole } from '../basic'; +import { + getEvents, + getRecordingSegments, + getRecordingsSummary, + NativeFrigateEventQuery, + NativeFrigateRecordingSegmentsQuery, + RecordingSegments, + RecordingSummary, + retainEvent, +} from '../frigate'; +import { RecordingSegmentsCache } from './data-manager-cache'; +import { + DataManagerEngine, + DATA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, +} from './data-manager-engine'; +import { DataManagerError } from './data-manager-error'; +import { DateRange } from './data-manager-range'; +import { + Engine, + EventQuery, + FrigateEventQueryResults, + FrigateRecordingQueryResults, + FrigateRecordingSegmentsQueryResults, + PartialEventQuery, + PartialRecordingQuery, + PartialRecordingSegmentsQuery, + QueryResults, + QueryResultsType, + QueryReturnType, + QueryType, + RecordingQuery, + RecordingSegmentsQuery, +} from './data-types'; + +const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; +const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; + +class FrigateQueryResultsClassifier { + public static isFrigateEventQueryResults( + results: QueryResults, + ): results is FrigateEventQueryResults { + return results.engine === Engine.Frigate && results.type === QueryResultsType.Event; + } + + public static isFrigateRecordingQueryResults( + results: QueryResults, + ): results is FrigateRecordingQueryResults { + return ( + results.engine === Engine.Frigate && results.type === QueryResultsType.Recording + ); + } + + public static isFrigateRecordingSegmentsResults( + results: QueryResults, + ): results is FrigateRecordingSegmentsQueryResults { + return ( + results.engine === Engine.Frigate && + results.type === QueryResultsType.RecordingSegments + ); + } +} + +export class FrigateDataManagerEngine implements DataManagerEngine { + protected _recordingSegmentsCache: RecordingSegmentsCache; + + constructor(recordingSegmentsCache: RecordingSegmentsCache) { + this._recordingSegmentsCache = recordingSegmentsCache; + } + + public getMediaDownloadPath( + cameraConfig: CameraConfig, + media: ViewMedia, + ): string | null { + let path: string | null = null; + if (ViewMediaClassifier.isFrigateEvent(media)) { + path = + `/api/frigate/${cameraConfig.frigate.client_id}` + + `/notifications/${media.getID()}/` + + `${media.isClip() ? 'clip.mp4' : 'snapshot.jpg'}` + + `?download=true`; + } else if (ViewMediaClassifier.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 path; + } + + public generateDefaultEventQuery( + cameraID: string, + cameraConfig: CameraConfig, + query: PartialEventQuery, + ): EventQuery | null { + return { + type: QueryType.Event, + cameraID: cameraID, + ...(cameraConfig.frigate.label && { label: cameraConfig.frigate.label }), + ...(cameraConfig.frigate.zone && { zone: cameraConfig.frigate.zone }), + ...query, + }; + } + + public generateDefaultRecordingQuery( + cameraID: string, + _cameraConfig: CameraConfig, + query: PartialRecordingQuery, + ): RecordingQuery | null { + return { + type: QueryType.Recording, + cameraID: cameraID, + ...query, + }; + } + + public generateDefaultRecordingSegmentsQuery( + cameraID: string, + _cameraConfig: CameraConfig, + query: PartialRecordingSegmentsQuery, + ): RecordingSegmentsQuery | null { + if (!query.start || !query.end) { + return null; + } + return { + type: QueryType.RecordingSegments, + cameraID: cameraID, + start: query.start, + end: query.end, + ...query, + }; + } + + public async favoriteMedia( + hass: HomeAssistant, + cameraConfig: CameraConfig, + media: ViewMedia, + favorite: boolean, + ): Promise { + const clientID = cameraConfig.frigate.client_id; + if (!ViewMediaClassifier.isFrigateEvent(media)) { + return; + } + + try { + await retainEvent(hass, clientID, media.getID(cameraConfig), favorite); + } catch (e) { + errorToConsole(e as Error); + throw new DataManagerError((e as Error).message); + } + + media.setFavorite(favorite); + } + + public async getEvents( + hass: HomeAssistant, + cameras: Map, + query: EventQuery, + ): Promise | null> { + const cameraConfig = this._getQueryableCameraConfig(cameras, query.cameraID); + if (!cameraConfig) { + return null; + } + + const nativeQuery: NativeFrigateEventQuery = { + instance_id: cameraConfig.frigate.client_id, + camera: cameraConfig.frigate.camera_name, + ...(query.what && { label: query.what }), + ...(query.where && { zone: query.where }), + ...(query?.end && { before: Math.floor(query.end.getTime() / 1000) }), + ...(query?.start && { after: Math.floor(query.start.getTime() / 1000) }), + ...(query?.limit && { limit: query.limit }), + ...(query?.hasClip && { has_clip: query.hasClip }), + ...(query?.hasSnapshot && { has_snapshot: query.hasSnapshot }), + limit: query?.limit ?? DATA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, + }; + + try { + const result: FrigateEventQueryResults = { + type: QueryResultsType.Event, + engine: Engine.Frigate, + events: await getEvents(hass, nativeQuery), + expiry: add(new Date(), { seconds: EVENT_REQUEST_CACHE_MAX_AGE_SECONDS }), + }; + return result; + } catch (e) { + errorToConsole(e as Error); + throw new DataManagerError((e as Error).message, query); + } + } + + public async getRecordings( + hass: HomeAssistant, + cameras: Map, + query: RecordingQuery, + ): Promise | null> { + const cameraConfig = this._getQueryableCameraConfig(cameras, query.cameraID); + if (!cameraConfig) { + return null; + } + if (!cameraConfig || !cameraConfig.frigate.camera_name) { + return null; + } + + let recordingSummary: RecordingSummary; + try { + recordingSummary = await getRecordingsSummary( + hass, + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + ); + } catch (e) { + errorToConsole(e as Error); + throw new DataManagerError((e as Error).message, query); + } + + const recordings: FrigateRecording[] = []; + for (const dayData of recordingSummary ?? []) { + for (const hourData of dayData.hours) { + const hour = add(dayData.day, { hours: hourData.hour }); + const startHour = startOfHour(hour); + const endHour = endOfHour(hour); + if ( + (!query.start || startHour >= query.start) && + (!query.end || endHour <= query.end) + ) { + recordings.push({ + camera: cameraConfig.frigate.camera_name, + start_time: getUnixTime(startHour), + end_time: getUnixTime(endHour), + events: hourData.events, + }); + } + } + } + + return { + type: QueryResultsType.Recording, + engine: Engine.Frigate, + recordings: recordings, + expiry: add(new Date(), { + seconds: RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS, + }), + }; + } + + public async getRecordingSegments( + hass: HomeAssistant, + cameras: Map, + query: RecordingSegmentsQuery, + ): Promise | null> { + const cameraConfig = this._getQueryableCameraConfig(cameras, query.cameraID); + if (!cameraConfig || !cameraConfig.frigate.camera_name) { + return null; + } + + const range: DateRange = { start: query.start, end: query.end }; + + // A note on Frigate Recording Segments: + // - Unlike other query types, there is an internal cache at the engine + // level for segments to allow caching "within an existing query" (e.g. if + // we already cached hour 1-8, we will avoid a fetch if we request hours + // 2-3 even though the query is different -- the segments won't be). This + // is since the volume of data in segment transfers can be high, and the + // segments can be used in high frequency situations (e.g. video seeking). + const cachedSegments = this._recordingSegmentsCache.get(query.cameraID, range); + if (cachedSegments) { + return { + type: QueryResultsType.RecordingSegments, + engine: Engine.Frigate, + segments: cachedSegments, + }; + } + + const request: NativeFrigateRecordingSegmentsQuery = { + instance_id: cameraConfig.frigate.client_id, + camera: cameraConfig.frigate.camera_name, + after: Math.floor(query.start.getTime() / 1000), + before: Math.floor(query.end.getTime() / 1000), + }; + + let segments: RecordingSegments; + try { + segments = await getRecordingSegments(hass, request); + } catch (e) { + errorToConsole(e as Error); + throw new DataManagerError((e as Error).message, query); + } + + this._recordingSegmentsCache.add(query.cameraID, range, segments); + + return { + type: QueryResultsType.RecordingSegments, + engine: Engine.Frigate, + segments: segments, + }; + } + + public generateMediaFromEvents( + query: EventQuery, + results: QueryReturnType, + ): ViewMedia[] | null { + if (!FrigateQueryResultsClassifier.isFrigateEventQueryResults(results)) { + return null; + } + + const output: ViewMedia[] = []; + for (const event of results.events) { + let mediaType: 'clip' | 'snapshot' | null = null; + if ( + !query.hasClip && + !query.hasSnapshot && + (event.has_clip || event.has_snapshot) + ) { + mediaType = event.has_clip ? 'clip' : 'snapshot'; + } else if (query.hasSnapshot && event.has_snapshot) { + mediaType = 'snapshot'; + } else if (query.hasClip && event.has_clip) { + mediaType = 'clip'; + } + if (!mediaType) { + continue; + } + const media = ViewMediaFactory.createViewMediaFromFrigateEvent( + mediaType, + query.cameraID, + event, + ); + if (media) { + output.push(media); + } + } + return output; + } + + public generateMediaFromRecordings( + query: RecordingQuery, + results: QueryReturnType, + ): ViewMedia[] | null { + if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(results)) { + return null; + } + + const output: ViewMedia[] = []; + for (const recording of results.recordings) { + const media = ViewMediaFactory.createViewMediaFromFrigateRecording( + query.cameraID, + recording, + ); + if (media) { + output.push(media); + } + } + return output; + } + + public areMediaQueriesResultsFresh( + queries: MediaQueries, + results: MediaQueriesResults, + ): boolean { + let freshThreshold: number | null = null; + if (queries.areEventQueries()) { + freshThreshold = EVENT_REQUEST_CACHE_MAX_AGE_SECONDS; + } else if (queries.areRecordingQueries()) { + freshThreshold = RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS; + } + const now = new Date(); + const resultsTimestamp = results.getResultsTimestamp(); + return ( + !freshThreshold || + !resultsTimestamp || + add(resultsTimestamp, { seconds: freshThreshold }) >= now + ); + } + + protected _getQueryableCameraConfig( + cameras: Map, + cameraID: string, + ): CameraConfig | null { + const cameraConfig = cameras.get(cameraID); + if (!cameraConfig || cameraConfig.frigate.camera_name == CAMERA_BIRDSEYE) { + return null; + } + return cameraConfig; + } +} diff --git a/src/utils/data/data-manager-engine.ts b/src/utils/data/data-manager-engine.ts new file mode 100644 index 00000000..171a2785 --- /dev/null +++ b/src/utils/data/data-manager-engine.ts @@ -0,0 +1,77 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { CameraConfig } from '../../types'; +import { MediaQueries, MediaQueriesResults } from '../../view'; +import { ViewMedia } from '../../view-media'; +import { + EventQuery, + PartialEventQuery, + PartialRecordingQuery, + PartialRecordingSegmentsQuery, + QueryReturnType, + RecordingQuery, + RecordingSegmentsQuery, +} from './data-types'; + +export const DATA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; + +export interface DataManagerEngine { + generateDefaultEventQuery( + cameraID: string, + cameraConfig: CameraConfig, + query: PartialEventQuery, + ): EventQuery | null; + + generateDefaultRecordingQuery( + cameraID: string, + cameraConfig: CameraConfig, + query: PartialRecordingQuery, + ): RecordingQuery | null; + + generateDefaultRecordingSegmentsQuery( + cameraID: string, + cameraConfig: CameraConfig, + query: PartialRecordingSegmentsQuery, + ): RecordingSegmentsQuery | null; + + getEvents( + hass: HomeAssistant, + cameras: Map, + query: EventQuery, + ): Promise | null>; + + getRecordings( + hass: HomeAssistant, + cameras: Map, + query: RecordingQuery, + ): Promise | null>; + + getRecordingSegments( + hass: HomeAssistant, + cameras: Map, + query: RecordingSegmentsQuery, + ): Promise | null>; + + generateMediaFromEvents( + query: EventQuery, + results: QueryReturnType, + ): ViewMedia[] | null; + + generateMediaFromRecordings( + query: RecordingQuery, + results: QueryReturnType, + ): ViewMedia[] | null; + + getMediaDownloadPath(cameraConfig: CameraConfig, media: ViewMedia): string | null; + + favoriteMedia( + hass: HomeAssistant, + cameraConfig: CameraConfig, + media: ViewMedia, + favorite: boolean, + ): Promise; + + areMediaQueriesResultsFresh( + queries: MediaQueries, + results: MediaQueriesResults, + ): boolean; +} diff --git a/src/utils/data/data-manager-error.ts b/src/utils/data/data-manager-error.ts new file mode 100644 index 00000000..ccd19902 --- /dev/null +++ b/src/utils/data/data-manager-error.ts @@ -0,0 +1,3 @@ +import { FrigateCardError } from '../../types'; + +export class DataManagerError extends FrigateCardError {} diff --git a/src/utils/data/data-manager-range.ts b/src/utils/data/data-manager-range.ts new file mode 100644 index 00000000..daec7960 --- /dev/null +++ b/src/utils/data/data-manager-range.ts @@ -0,0 +1,84 @@ +import cloneDeep from 'lodash-es/cloneDeep'; +import orderBy from 'lodash-es/orderBy'; + +interface Range { + start: T; + end: T; +} + +export type DateRange = Range; + +export class MemoryRangeSet { + protected _ranges: DateRange[]; + + constructor(ranges?: DateRange[]) { + this._ranges = ranges ?? []; + } + + public clone(): MemoryRangeSet { + return new MemoryRangeSet(cloneDeep(this._ranges)); + } + + public hasCoverage(range: DateRange): boolean { + return this._ranges.some((cachedRange) => + this._isEntirelyContained(cachedRange, range), + ); + } + + public add(range: DateRange): void { + this._ranges.push(range); + this._ranges = compressRanges(this._ranges); + } + + protected _isEntirelyContained(bigger: DateRange, smaller: DateRange): boolean { + return smaller.start >= bigger.start && smaller.end <= bigger.end; + } +} + +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.end >= b.start && a.end <= b.end) || + // a encompasses the entire range of b. + (a.start <= b.start && a.end >= b.end) + ); +} + +export const compressRanges = ( + ranges: Range[], + toleranceSeconds = 0, +): Range[] => { + const compressedRanges: Range[] = []; + ranges = orderBy(ranges, (range) => range.start, 'asc'); + + let current: Range | null = null; + for (let i = 0; i < ranges.length; ++i) { + const item = ranges[i]; + const itemStartSeconds = + item.start instanceof Date ? item.start.getTime() : item.start; + + if (!current) { + current = { ...item }; + continue; + } + + const currentEndSeconds = + current.end instanceof Date ? current.end.getTime() : (current.end as number); + + if (currentEndSeconds + toleranceSeconds * 1000 >= itemStartSeconds) { + if (item.end > current.end) { + current.end = item.end; + } + } else { + compressedRanges.push(current); + current = { ...item }; + } + } + if (current) { + compressedRanges.push(current); + } + + return compressedRanges; +}; diff --git a/src/utils/data/data-manager-util.ts b/src/utils/data/data-manager-util.ts new file mode 100644 index 00000000..5a56741b --- /dev/null +++ b/src/utils/data/data-manager-util.ts @@ -0,0 +1,44 @@ +import startOfHour from 'date-fns/startOfHour'; +import endOfHour from 'date-fns/endOfHour'; +import startOfDay from 'date-fns/startOfDay'; +import endOfDay from 'date-fns/endOfDay'; +import endOfMinute from 'date-fns/endOfMinute'; +import endOfWeek from 'date-fns/endOfWeek'; +import startOfWeek from 'date-fns/startOfWeek'; +import { DateRange } from './data-manager-range'; + +export const convertRangeToCacheFriendlyTimes = ( + range: DateRange, + options?: { + endCap?: boolean; + }, +): DateRange => { + const widthSeconds = (range.end.getTime() - range.start.getTime()) / 1000; + let cacheableStart: Date; + let cacheableEnd: Date; + + if (widthSeconds <= 60 * 60) { + cacheableStart = startOfHour(range.start); + cacheableEnd = endOfHour(range.end); + } else if (widthSeconds <= 60 * 60 * 24) { + cacheableStart = startOfDay(range.start); + cacheableEnd = endOfDay(range.end); + } else { + cacheableStart = startOfWeek(range.start); + cacheableEnd = endOfWeek(range.end); + } + + if (options?.endCap) { + cacheableEnd = endOfMinute(capEndDate(cacheableEnd)); + } + + return { + start: cacheableStart, + end: cacheableEnd, + }; +}; + +export const capEndDate = (end: Date): Date => { + const now = new Date(); + return end > now ? now : end; +}; diff --git a/src/utils/data/data-manager.ts b/src/utils/data/data-manager.ts new file mode 100644 index 00000000..a6027d54 --- /dev/null +++ b/src/utils/data/data-manager.ts @@ -0,0 +1,312 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { CameraConfig } from '../../types.js'; +import { arrayify, setify } from '../basic.js'; +import { + DataQuery, + EventQuery, + EventQueryResults, + PartialDataQuery, + PartialEventQuery, + PartialQueryConcreteType, + PartialRecordingQuery, + PartialRecordingSegmentsQuery, + QueryResults, + QueryResultsType, + QueryReturnType, + QueryType, + RecordingQuery, + RecordingQueryResults, + RecordingSegmentsQuery, + RecordingSegmentsQueryResults, +} from './data-types.js'; +import orderBy from 'lodash-es/orderBy'; +import { DataManagerEngineFactory } from './data-manager-engine-factory.js'; +import { ViewMedia } from '../../view-media.js'; +import { MediaQueries, MediaQueriesResults } from '../../view.js'; +import { MemoryRequestCache } from './data-manager-cache.js'; + +export class QueryClassifier { + public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery { + return query.type === QueryType.Event; + } + public static isRecordingQuery( + query: DataQuery | PartialDataQuery, + ): query is RecordingQuery { + return query.type === QueryType.Recording; + } + public static isRecordingSegmentsQuery( + query: DataQuery | PartialDataQuery, + ): query is RecordingSegmentsQuery { + return query.type === QueryType.RecordingSegments; + } +} + +export class QueryResultClassifier { + public static isEventQueryResult( + queryResults: QueryResults, + ): queryResults is EventQueryResults { + return queryResults.type === QueryResultsType.Event; + } + public static isRecordingQuery( + queryResults: QueryResults, + ): queryResults is RecordingQueryResults { + return queryResults.type === QueryResultsType.Recording; + } + public static isRecordingSegmentsQuery( + queryResults: QueryResults, + ): queryResults is RecordingSegmentsQueryResults { + return queryResults.type === QueryResultsType.RecordingSegments; + } +} + +export type RequestCache = MemoryRequestCache; + +export class DataManager { + protected _engineFactory: DataManagerEngineFactory; + protected _cameras: Map; + protected _requestCache: RequestCache; + + constructor( + engineFactory: DataManagerEngineFactory, + cameras: Map, + requestCache: RequestCache, + ) { + this._engineFactory = engineFactory; + this._cameras = cameras; + this._requestCache = requestCache; + } + + public generateDefaultEventQueries( + cameraIDs: string | Set, + partialQuery: PartialEventQuery, + ): EventQuery[] { + return this._generateDefaultQueries(cameraIDs, { + ...partialQuery, + type: QueryType.Event, + }); + } + + public generateDefaultRecordingQueries( + cameraIDs: string | Set, + partialQuery: PartialRecordingQuery, + ): RecordingQuery[] { + return this._generateDefaultQueries(cameraIDs, { + ...partialQuery, + type: QueryType.Recording, + }); + } + + public generateDefaultRecordingSegmentsQueries( + cameraIDs: string | Set, + partialQuery: PartialRecordingSegmentsQuery, + ): RecordingSegmentsQuery[] { + return this._generateDefaultQueries(cameraIDs, { + ...partialQuery, + type: QueryType.RecordingSegments, + }); + } + + protected _generateDefaultQueries>( + cameraIDs: string | Set, + partialQuery: PQT, + ): PartialQueryConcreteType[] { + const concreteQueries: PartialQueryConcreteType[] = []; + const _cameraIDs = setify(cameraIDs); + + _cameraIDs.forEach((cameraID) => { + const cameraConfig = this._cameras.get(cameraID); + if (!cameraConfig) { + return; + } + + const engine = this._engineFactory.getEngineForCamera(cameraConfig); + if (!engine) { + return; + } + + let query: DataQuery | null = null; + if (QueryClassifier.isEventQuery(partialQuery)) { + query = engine.generateDefaultEventQuery(cameraID, cameraConfig, partialQuery); + } else if (QueryClassifier.isRecordingQuery(partialQuery)) { + query = engine.generateDefaultRecordingQuery( + cameraID, + cameraConfig, + partialQuery, + ); + } else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) { + query = engine.generateDefaultRecordingSegmentsQuery( + cameraID, + cameraConfig, + partialQuery, + ); + } + + if (query) { + concreteQueries.push(query as PartialQueryConcreteType); + } + }); + return concreteQueries; + } + + public async getEvents( + hass: HomeAssistant, + query: EventQuery | EventQuery[], + ): Promise> { + return await this._handleQuery(hass, query); + } + + public async getRecordings( + hass: HomeAssistant, + query: RecordingQuery | RecordingQuery[], + ): Promise> { + return await this._handleQuery(hass, query); + } + + public async getRecordingSegments( + hass: HomeAssistant, + query: RecordingSegmentsQuery | RecordingSegmentsQuery[], + ): Promise> { + return await this._handleQuery(hass, query); + } + + public async executeMediaQuery( + hass: HomeAssistant, + mediaQuerys: MediaQueries, + ): Promise { + const queries: (RecordingQuery | EventQuery)[] | null = mediaQuerys.getQueries(); + if (!queries) { + return null; + } + + const results = await this._handleQuery(hass, queries); + + const mediaArray: ViewMedia[] = []; + for (const [query, result] of results.entries()) { + const engine = this._engineFactory.getEngineForQuery(this._cameras, query); + if (engine) { + let media: ViewMedia[] | null = null; + if ( + QueryClassifier.isEventQuery(query) && + QueryResultClassifier.isEventQueryResult(result) + ) { + media = engine.generateMediaFromEvents(query, result); + } else if ( + QueryClassifier.isRecordingQuery(query) && + QueryResultClassifier.isRecordingQuery(result) + ) { + media = engine.generateMediaFromRecordings(query, result); + } + if (media) { + mediaArray.push(...media); + } + } + } + + return mediaArray.length + ? new MediaQueriesResults( + orderBy(mediaArray, (media) => media.getStartTime(), 'desc'), + // Select the first (most-recent) item. + 0, + ) + : null; + } + + public getMediaDownloadPath(media: ViewMedia): string | null { + const cameraConfig = this._cameras.get(media.getCameraID()); + const engine = cameraConfig + ? this._engineFactory.getEngineForCamera(cameraConfig) + : null; + if (!cameraConfig || !engine) { + return null; + } + return engine.getMediaDownloadPath(cameraConfig, media); + } + + public async favoriteMedia( + hass: HomeAssistant, + cameraConfig: CameraConfig, + media: ViewMedia, + favorite: boolean, + ): Promise { + const engine = this._engineFactory.getEngineForCamera(cameraConfig); + if (engine) { + engine.favoriteMedia(hass, cameraConfig, media, favorite); + } + } + + public areMediaQueriesResultsFresh( + queries: MediaQueries, + results: MediaQueriesResults, + ): boolean { + const cameraIDs: Set = new Set(); + (queries.getQueries() ?? []).forEach((query) => cameraIDs.add(query.cameraID)); + for (const cameraID of cameraIDs) { + const cameraConfig = this._cameras.get(cameraID); + if (!cameraConfig) { + return false; + } + const engine = this._engineFactory.getEngineForCamera(cameraConfig); + if (!engine || !engine.areMediaQueriesResultsFresh(queries, results)) { + return false; + } + } + return true; + } + + protected async _handleQuery( + hass: HomeAssistant, + query: QT | QT[], + ): Promise>> { + const _queries = arrayify(query); + const results = new Map>(); + + const queryStartTime = new Date(); + let queryCachedCount = 0; + + const processQuery = async (query: QT): Promise => { + const cachedResult: QueryReturnType | null = this._requestCache.get( + query, + ) as QueryReturnType | null; + if (cachedResult) { + queryCachedCount++; + results.set(query, cachedResult); + return; + } + + const engine = this._engineFactory.getEngineForQuery(this._cameras, query); + if (!engine) { + return; + } + + let result: QueryResults | null = null; + if (QueryClassifier.isEventQuery(query)) { + result = await engine.getEvents(hass, this._cameras, query); + } else if (QueryClassifier.isRecordingQuery(query)) { + result = await engine.getRecordings(hass, this._cameras, query); + } else if (QueryClassifier.isRecordingSegmentsQuery(query)) { + result = await engine.getRecordingSegments(hass, this._cameras, query); + } + + if (result) { + if (result.expiry) { + this._requestCache.set(query, result, result.expiry); + } + results.set(query, result as QueryReturnType); + } + }; + + await Promise.all(_queries.map((query) => processQuery(query))); + + console.debug( + 'Frigate Card DataManager request (Cached:', + `${queryCachedCount}/${_queries.length},`, + `Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, + 'Queries:', + _queries, + ', Results:', + results, + ')', + ); + return results; + } +} diff --git a/src/utils/data/data-types.ts b/src/utils/data/data-types.ts new file mode 100644 index 00000000..afafa4d9 --- /dev/null +++ b/src/utils/data/data-types.ts @@ -0,0 +1,138 @@ +import { FrigateEvents, FrigateRecording } from '../../types'; +import { RecordingSegments } from '../frigate'; + +// ==== +// Base +// ==== + +export enum QueryType { + Event = 'event-query', + Recording = 'recording-query', + RecordingSegments = 'recording-segments-query', +} + +export enum QueryResultsType { + Event = 'event-results', + Recording = 'recording-results', + RecordingSegments = 'recording-segments-results', +} + +export enum Engine { + Frigate = 'frigate', +} + +export interface DataQuery { + type: QueryType; + cameraID: string; +} +export type PartialDataQuery = Partial; + +export interface TimeBasedDataQuery { + start: Date; + end: Date; +} + +export interface LimitedDataQuery { + limit: number; +} + +export interface MediaQuery + extends DataQuery, + Partial, + Partial {} + +export interface QueryResults { + type: QueryResultsType; + engine: Engine; + expiry?: Date; +} + +export type QueryReturnType = QT extends EventQuery + ? EventQueryResults + : QT extends RecordingQuery + ? RecordingQueryResults + : QT extends RecordingSegmentsQuery + ? RecordingSegmentsQueryResults + : never; +export type PartialQueryConcreteType = PQT extends PartialEventQuery + ? EventQuery + : PQT extends PartialRecordingQuery + ? RecordingQuery + : PQT extends PartialRecordingSegmentsQuery + ? RecordingSegmentsQuery + : never; + +// =========== +// Event Query +// =========== + +export interface EventQuery extends MediaQuery { + type: QueryType.Event; + + // Frigate equivalent: has_snapshot + hasSnapshot?: boolean; + + // Frigate equivalent: has_clip + hasClip?: boolean; + + // Frigate equivalent: label + what?: string; + + // Frigate equivalent: zone + where?: string; +} +export type PartialEventQuery = Partial; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface EventQueryResults extends QueryResults { + type: QueryResultsType.Event; +} + +// =============== +// Recording Query +// =============== + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface RecordingQuery extends MediaQuery { + type: QueryType.Recording; +} +export type PartialRecordingQuery = Partial; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface RecordingQueryResults extends QueryResults { + type: QueryResultsType.Recording; +} + +// ======================== +// Recording Segments Query +// ======================== + +export interface RecordingSegmentsQuery extends DataQuery, TimeBasedDataQuery { + type: QueryType.RecordingSegments; +} +export type PartialRecordingSegmentsQuery = Partial; +//export type PartialRecordingSegmentsQuery = Partial & { type: QueryType.RecordingSegments }; + +export interface RecordingSegmentsQueryResults extends QueryResults { + type: QueryResultsType.RecordingSegments; + segments: RecordingSegments; +} + +// ======================== +// Frigate concrete results +// ======================== + +export interface FrigateEventQueryResults extends EventQueryResults { + engine: Engine.Frigate; + events: FrigateEvents; +} + +export interface FrigateRecordingQueryResults extends RecordingQueryResults { + engine: Engine.Frigate; + recordings: FrigateRecording[]; +} + +export interface FrigateRecordingSegmentsQueryResults + extends RecordingSegmentsQueryResults { + engine: Engine.Frigate; +} diff --git a/src/utils/frigate.ts b/src/utils/frigate.ts index ade6cf3a..432f6bf2 100644 --- a/src/utils/frigate.ts +++ b/src/utils/frigate.ts @@ -1,19 +1,15 @@ import { HomeAssistant } from 'custom-card-helpers'; import utcToZonedTime from 'date-fns-tz/utcToZonedTime'; -import differenceInHours from 'date-fns/differenceInHours'; -import differenceInMinutes from 'date-fns/differenceInMinutes'; -import differenceInSeconds from 'date-fns/differenceInSeconds'; import fromUnixTime from 'date-fns/fromUnixTime'; import { z } from 'zod'; import { localize } from '../localize/localize'; import { - BrowseRecordingQueryParameters, ClipsOrSnapshots, - ExtendedHomeAssistant, FrigateCardError, FrigateEvent, FrigateEvents, frigateEventsSchema, + FrigateRecording, } from '../types'; import { formatDateAndTime, prettifyTitle } from './basic'; import { homeAssistantWSRequest } from './ha'; @@ -63,6 +59,8 @@ const recordingSegmentSchema = z.object({ end_time: z.number(), id: z.string(), }); +export type RecordingSegment = z.infer; + const recordingSegmentsSchema = recordingSegmentSchema.array(); export type RecordingSegments = z.infer; @@ -80,7 +78,7 @@ export type RetainResult = z.infer; * @returns A RecordingSummary object. */ export const getRecordingsSummary = async ( - hass: ExtendedHomeAssistant, + hass: HomeAssistant, client_id: string, camera_name: string, ): Promise => { @@ -96,31 +94,29 @@ export const getRecordingsSummary = async ( ); }; +export interface NativeFrigateRecordingSegmentsQuery { + instance_id: string; + camera: string; + after: number; + before: number; +} + /** * Get the recording segments. May throw. * @param hass The Home Assistant object. - * @param client_id The Frigate client_id. - * @param camera_name The Frigate camera name. - * @param before The segment low watermark. - * @param after The segment high watermark. + * @param params The recording segment query parameters. * @returns A RecordingSegments object. */ export const getRecordingSegments = async ( - hass: ExtendedHomeAssistant, - client_id: string, - camera_name: string, - before: Date, - after: Date, + hass: HomeAssistant, + params: NativeFrigateRecordingSegmentsQuery, ): Promise => { return await homeAssistantWSRequest( hass, recordingSegmentsSchema, { type: 'frigate/recordings/get', - instance_id: client_id, - camera: camera_name, - before: Math.floor(before.getTime() / 1000), - after: Math.ceil(after.getTime() / 1000), + ...params, }, true, ); @@ -159,7 +155,7 @@ export async function retainEvent( } } -export interface FrigateGetEventsParameters { +export interface NativeFrigateEventQuery { instance_id?: string; camera?: string; label?: string; @@ -179,7 +175,7 @@ export interface FrigateGetEventsParameters { */ export const getEvents = async ( hass: HomeAssistant, - params?: FrigateGetEventsParameters, + params?: NativeFrigateEventQuery, ): Promise => { return await homeAssistantWSRequest( hass, @@ -192,29 +188,6 @@ export const getEvents = async ( ); }; -/** - * Get multiple sets of events. - * @param hass The Home Assistant object. - * @param params A Map of parameters keyed on any key. - * @returns A Map of key -> events. - */ -export const getEventsMultiple = async ( - hass: HomeAssistant, - params: Map, -): Promise> => { - const output: Map = new Map(); - const getEventsAndStore = async ( - key: T, - param: FrigateGetEventsParameters, - ): Promise => { - output.set(key, await getEvents(hass, param)); - }; - await Promise.all( - Array.from(params).map(([key, param]) => getEventsAndStore(key, param)), - ); - return output; -}; - /** * Given an event generate a title. * @param event @@ -233,6 +206,12 @@ export const getEventTitle = (event: FrigateEvent): string => { )}%]`; }; +export const getRecordingTitle = (recording: FrigateRecording): string => { + return `${prettifyTitle(recording.camera)} ${formatDateAndTime( + fromUnixTime(recording.start_time), + )}`; +}; + /** * Get a thumbnail URL for an event. * @param clientId The Frigate client id. @@ -254,10 +233,10 @@ export const getEventThumbnailURL = (clientId: string, event: FrigateEvent): str export const getEventMediaContentID = ( clientId: string, cameraName: string, - id: string, + event: FrigateEvent, mediaType: ClipsOrSnapshots, ): string => { - return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${id}`; + return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${event.id}`; }; /** @@ -267,43 +246,19 @@ export const getEventMediaContentID = ( * @returns A recording identifier. */ export const getRecordingMediaContentID = ( - params: BrowseRecordingQueryParameters, + clientId: string, + cameraName: string, + recording: FrigateRecording, ): string => { + const date = fromUnixTime(recording.start_time); return [ 'media-source://frigate', - params.clientId, + clientId, 'recordings', - `${params.year}-${String(params.month).padStart(2, '0')}`, - String(params.day).padStart(2, '0'), - String(params.hour).padStart(2, '0'), - params.cameraName, + cameraName, + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String( + String(date.getDate()).padStart(2, '0'), + )}`, + String(date.getHours()).padStart(2, '0'), ].join('/'); }; - -/** - * Convenience function to convert a timestamp to hours, minutes and seconds - * string. Heavily inspired by, and returning the same format as, the Frigate - * UI: https://github.com/blakeblackshear/frigate/blob/master/web/src/components/RecordingPlaylist.jsx#L97 - * @param event The Frigate event. - * @returns A duration string. - */ -export function getEventDurationString(event: FrigateEvent): string { - if (!event.end_time) { - return localize('event.in_progress'); - } - const start = fromUnixTime(event.start_time); - const end = fromUnixTime(event.end_time); - const hours = differenceInHours(end, start); - const minutes = differenceInMinutes(end, start) - hours * 60; - const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60; - let duration = ''; - - if (hours) { - duration += `${hours}h `; - } - if (minutes) { - duration += `${minutes}m `; - } - duration += `${seconds}s`; - return duration; -} diff --git a/src/utils/ha/browse-media.ts b/src/utils/ha/browse-media.ts index 3b277b69..2bece47c 100644 --- a/src/utils/ha/browse-media.ts +++ b/src/utils/ha/browse-media.ts @@ -1,11 +1,6 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { ViewContext } from 'view'; import { homeAssistantWSRequest } from '.'; -import { - dispatchErrorMessageEvent, - dispatchFrigateCardErrorEvent, - dispatchMessageEvent, -} from '../../components/message.js'; +import { dispatchErrorMessageEvent } from '../../components/message.js'; import { localize } from '../../localize/localize.js'; import { BrowseMediaQueryParameters, @@ -14,7 +9,6 @@ import { ClipsOrSnapshots, FrigateBrowseMediaSource, frigateBrowseMediaSourceSchema, - FrigateCardError, FrigateEvent, FrigateRecording, MEDIA_CLASS_PLAYLIST, @@ -22,7 +16,6 @@ import { MEDIA_TYPE_PLAYLIST, MEDIA_TYPE_VIDEO, } from '../../types.js'; -import { View } from '../../view.js'; import { getAllDependentCameras, getCameraTitle } from '../camera.js'; /** @@ -119,7 +112,7 @@ const browseMediaQuery = async ( if (params.cameraID) { result.children?.forEach((child: FrigateBrowseMediaSource) => { (child.frigate ??= {}).cameraID = params.cameraID; - }) + }); } return result; }; @@ -185,7 +178,10 @@ export const mergeFrigateBrowseMediaSources = async ( } } - return createEventParentForChildren('Merged events', children.sort(sortYoungestToOldest)); + return createEventParentForChildren( + 'Merged events', + children.sort(sortYoungestToOldest), + ); }; /** @@ -290,81 +286,6 @@ export const getFullDependentBrowseMediaQueryParametersOrDispatchError = ( return params; }; -/** - * Fetch the latest media and dispatch a change view event to reflect the - * results. If no media is found a suitable message event will be triggered - * instead. - * @param element The HTMLElement to dispatch events from. - * @param hass The Home Assistant object. - * @param view The current view to evolve. - * @param browseMediaQueryParameters The media parameters to query with. - * @returns - */ -export const fetchLatestMediaAndDispatchViewChange = async ( - element: HTMLElement, - hass: HomeAssistant, - view: Readonly, - browseMediaQueryParameters: BrowseMediaQueryParameters | BrowseMediaQueryParameters[], -): Promise => { - let parent: FrigateBrowseMediaSource | null; - try { - parent = await multipleBrowseMediaQueryMerged(hass, browseMediaQueryParameters); - } catch (e) { - return dispatchFrigateCardErrorEvent(element, e as FrigateCardError); - } - const childIndex = getFirstTrueMediaChildIndex(parent); - if (!parent || !parent.children || childIndex == null) { - return dispatchMessageEvent( - element, - view.isClipRelatedView() - ? localize('common.no_clip') - : localize('common.no_snapshot'), - 'info', - { - icon: view.isClipRelatedView() ? 'mdi:filmstrip-off' : 'mdi:camera-off', - }, - ); - } - - view - .evolve({ - target: parent, - childIndex: childIndex, - }) - .dispatchChangeEvent(element); -}; - -/** - * Fetch the media of a child FrigateBrowseMediaSource object and dispatch a change - * view event to reflect the results. - * @param node The HTMLElement to dispatch events from. - * @param hass The Home Assistant object. - * @param view The current view to evolve. - * @param child The FrigateBrowseMediaSource child to query for. - * @returns - */ -export const fetchChildMediaAndDispatchViewChange = async ( - element: HTMLElement, - hass: HomeAssistant, - view: Readonly, - child: Readonly, - context?: ViewContext, -): Promise => { - let parent: FrigateBrowseMediaSource; - try { - parent = await browseMedia(hass, child.media_content_id); - } catch (e) { - return dispatchFrigateCardErrorEvent(element, e as FrigateCardError); - } - - view - .evolve({ - target: parent, - }) - .mergeInContext(context) - .dispatchChangeEvent(element); -}; - /** * Given an array of media children, create a parent for them. * @param title The title to use for the parent. @@ -402,7 +323,7 @@ export const createChild = ( thumbnail?: string; recording?: FrigateRecording; event?: FrigateEvent; - cameraID?: string, + cameraID?: string; }, ): FrigateBrowseMediaSource => { const result: FrigateBrowseMediaSource = { @@ -413,10 +334,10 @@ export const createChild = ( can_play: true, can_expand: false, thumbnail: options?.thumbnail ?? null, - children: null - } + children: null, + }; if (options?.recording || options?.cameraID || options?.event) { - result.frigate = {} + result.frigate = {}; if (options?.event) { result.frigate.event = options.event; } @@ -443,38 +364,12 @@ export const sortYoungestToOldest = ( const a_source = a.frigate?.event ?? a.frigate?.recording; const b_source = b.frigate?.event ?? b.frigate?.recording; - if ( - !a_source || - (b_source && b_source.start_time > a_source.start_time) - ) { + if (!a_source || (b_source && b_source.start_time > a_source.start_time)) { return 1; } - if ( - !b_source || - (a_source && b_source.start_time < a_source.start_time) - ) { + if (!b_source || (a_source && b_source.start_time < a_source.start_time)) { return -1; } return 0; }; -/** - * Generate a recording identifier. - * @param hass The HomeAssistant object. - * @param params The recording parameters to use in the identifer. - * @returns A recording identifier. - */ -export const generateRecordingIdentifier = ( - params: BrowseRecordingQueryParameters, -): string => { - return [ - 'media-source://frigate', - params.clientId, - 'recordings', - params.cameraName, - `${params.year}-${String(params.month).padStart(2, '0')}-${String( - params.day, - ).padStart(2, '0')}`, - String(params.hour).padStart(2, '0'), - ].join('/'); -}; diff --git a/src/utils/ha/resolved-media.ts b/src/utils/ha/resolved-media.ts index b9e6abfd..d948a69c 100644 --- a/src/utils/ha/resolved-media.ts +++ b/src/utils/ha/resolved-media.ts @@ -1,11 +1,7 @@ import { HomeAssistant } from 'custom-card-helpers'; import QuickLRU from 'quick-lru'; import { homeAssistantWSRequest } from '.'; -import { - FrigateBrowseMediaSource, - ResolvedMedia, - resolvedMediaSchema, -} from '../../types.js'; +import { ResolvedMedia, resolvedMediaSchema } from '../../types.js'; import { errorToConsole } from '../basic'; // It's important the cache size be at least as large as the largest likely @@ -53,25 +49,22 @@ export class ResolvedMediaCache { /** * Resolve a given media source item. * @param hass The Home Assistant object. - * @param mediaSource The media source object. + * @param mediaContentID The media content ID. * @param cache An optional ResolvedMediaCache object. * @returns The resolved media or `null`. */ export const resolveMedia = async ( hass: HomeAssistant, - mediaSource?: FrigateBrowseMediaSource, + mediaContentID: string, cache?: ResolvedMediaCache, ): Promise => { - if (!mediaSource) { - return null; - } - const cachedValue = cache ? cache.get(mediaSource.media_content_id) : undefined; + const cachedValue = cache ? cache.get(mediaContentID) : undefined; if (cachedValue) { return cachedValue; } const request = { type: 'media_source/resolve_media', - media_content_id: mediaSource.media_content_id, + media_content_id: mediaContentID, }; let resolvedMedia: ResolvedMedia | null = null; try { @@ -80,7 +73,7 @@ export const resolveMedia = async ( errorToConsole(e as Error); } if (cache && resolvedMedia) { - cache.set(mediaSource.media_content_id, resolvedMedia); + cache.set(mediaContentID, resolvedMedia); } return resolvedMedia; }; diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index c6bb6413..cd14ab7b 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -1,27 +1,73 @@ import add from 'date-fns/add'; -import endOfHour from 'date-fns/endOfHour'; import fromUnixTime from 'date-fns/fromUnixTime'; -import getUnixTime from 'date-fns/getUnixTime'; import startOfHour from 'date-fns/startOfHour'; import sub from 'date-fns/sub'; import { ViewContext } from 'view'; -import { dispatchMessageEvent } from '../components/message'; -import { localize } from '../localize/localize'; -import { CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource } from '../types'; -import { View } from '../view'; -import { formatDateAndTime, prettifyTitle } from './basic'; -import { getRecordingMediaContentID } from './frigate'; -import { - createChild, - createEventParentForChildren, - sortYoungestToOldest, -} from './ha/browse-media'; -import { - RecordingSegmentsItem, - sortOldestToYoungest, - DataManager, -} from './data-manager'; -import { getAllDependentCameras, getTrueCameras } from './camera.js'; +import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types'; +import { EventMediaQueries, RecordingMediaQueries, View } from '../view'; +import { RecordingSegments } from './frigate'; +import { DataManager } from './data/data-manager'; +import { getAllDependentCameras } from './camera.js'; +import { ViewMedia, ViewMediaClassifier } from '../view-media'; +import { HomeAssistant } from 'custom-card-helpers'; + +export const changeViewToRecentEventsForCameraAndDependents = async ( + element: HTMLElement, + hass: HomeAssistant, + dataManager: DataManager, + cameras: Map, + view: View, + options?: { + mediaType?: ClipsOrSnapshotsOrAll; + targetView?: FrigateCardView; + }, +): Promise => { + ( + await createViewForEvents(hass, dataManager, cameras, view, { + ...options, + limit: 50, // Capture the 50 most recent events. + }) + ).dispatchChangeEvent(element); +}; + +export const createViewForEvents = async ( + hass: HomeAssistant, + dataManager: DataManager, + cameras: Map, + view: View, + options?: { + query?: EventMediaQueries; + cameraIDs?: Set; + mediaType?: ClipsOrSnapshotsOrAll; + targetView?: FrigateCardView; + limit?: number; + }, +): Promise => { + let query: EventMediaQueries; + if (options?.query) { + query = options.query; + } else { + const cameraIDs: Set = options?.cameraIDs + ? options.cameraIDs + : new Set(getAllDependentCameras(cameras, view.camera)); + + const queries = dataManager.generateDefaultEventQueries(cameraIDs, { + ...(options?.limit && { limit: options.limit }), + ...((!options?.mediaType || ['clips', 'all'].includes(options.mediaType)) && { + has_clip: true, + }), + ...(options?.mediaType === 'snapshots' && { has_snapshot: true }), + }); + query = new EventMediaQueries(queries); + } + const queryResults = await dataManager.executeMediaQuery(hass, query); + + return view?.evolve({ + view: options?.targetView, + query: query, + queryResults: queryResults, + }); +}; /** * Change the view to a recent recording. @@ -34,7 +80,7 @@ import { getAllDependentCameras, getTrueCameras } from './camera.js'; */ export const changeViewToRecentRecordingForCameraAndDependents = async ( element: HTMLElement, - hass: ExtendedHomeAssistant, + hass: HomeAssistant, dataManager: DataManager, cameras: Map, view: View, @@ -43,20 +89,19 @@ export const changeViewToRecentRecordingForCameraAndDependents = async ( }, ): Promise => { const now = new Date(); - - await changeViewToRecording(element, hass, dataManager, cameras, view, { - ...options, - - // Fetch 1 days worth of recordings (including recordings that are for the current hour). - cameraIDs: getAllDependentCameras(cameras, view.camera), - start: sub(now, { days: 1 }), - end: add(now, { hours: 1 }), - }); + ( + await createViewForRecordings(hass, dataManager, cameras, view, { + ...options, + // Fetch 7 days worth of recordings (including recordings that are for the + // current hour). + start: sub(now, { days: 7 }), + end: add(now, { hours: 1 }), + }) + ).dispatchChangeEvent(element); }; /** - * Change the view to a recording. - * @param element The element to dispatch the view change from. + * Create a view for recordings. * @param hass The Home Assistant object. * @param dataManager The datamanager to use for data access. * @param cameras The camera configurations. @@ -65,9 +110,8 @@ export const changeViewToRecentRecordingForCameraAndDependents = async ( * targetTime to seek to, a targetView to dispatch to and a set of cameraIDs to * restrict to. */ -export const changeViewToRecording = async ( - element: HTMLElement, - hass: ExtendedHomeAssistant, +export const createViewForRecordings = async ( + hass: HomeAssistant, dataManager: DataManager, cameras: Map, view: View, @@ -78,165 +122,104 @@ export const changeViewToRecording = async ( start?: Date; end?: Date; }, -): Promise => { - if (options && options.start && options.end) { - await dataManager.fetchIfNecessary(element, hass, options.start, options.end); - } - +): Promise => { const cameraIDs: Set = options?.cameraIDs ? options.cameraIDs - : new Set([view.camera]); - const children = createRecordingChildren(dataManager, cameras, cameraIDs, { - ...(options?.start && options?.end && { start: options.start, end: options.end }), + : new Set(getAllDependentCameras(cameras, view.camera)); + + const queries = dataManager.generateDefaultRecordingQueries(cameraIDs, { + ...(options?.start && { start: options.start }), + ...(options?.end && { end: options.end }), }); - if (!children.length) { - return dispatchMessageEvent(element, localize('common.no_recording'), 'info', { - icon: 'mdi:album', - }); + const query = new RecordingMediaQueries(queries); + const queryResults = await dataManager.executeMediaQuery(hass, query); + + let viewerContext: ViewContext | undefined = {}; + const mediaArray = queryResults?.getResults(); + if (queryResults && mediaArray && options?.targetTime) { + queryResults.selectBestResult((media) => + findClosestMediaIndex(media, options.targetTime as Date, cameraIDs), + ); + viewerContext = await generateMediaViewerContext( + hass, + dataManager, + mediaArray, + options.targetTime, + ); } - const viewerContext = options?.targetTime - ? generateMediaViewerContextForChildren(dataManager, children, options.targetTime) - : {}; - const childIndex = options?.targetTime - ? findChildIndex(children, options.targetTime, cameraIDs) - : null; - const child = childIndex !== null ? children[childIndex] ?? null : null; - - view - ?.evolve({ - view: options?.targetView ? options.targetView : 'recording', - target: createEventParentForChildren(localize('common.recordings'), children), - childIndex: childIndex ?? 0, - ...(child?.frigate?.cameraID && { camera: child.frigate?.cameraID }), - }) - .mergeInContext(viewerContext) - .dispatchChangeEvent(element); -}; - -/** - * Create recording objects. - * @param dataManager The datamanager to use for data access. - * @param cameras The camera configurations. - * @param cameraIDs The camera IDs to include recordings for. - * @param options A specific window (start and end) to allow recordings for. - * @returns - */ -const createRecordingChildren = ( - dataManager: DataManager, - cameras: Map, - cameraIDs: Set, - options?: { - start?: Date; - end?: Date; - }, -): FrigateBrowseMediaSource[] => { - const children: FrigateBrowseMediaSource[] = []; - - for (const cameraID of getTrueCameras(cameras, cameraIDs)) { - const config = cameras.get(cameraID) ?? null; - const recordingSummary = dataManager.getRecordingSummaryForCamera(cameraID); - if (!config?.frigate.camera_name || !recordingSummary) { - continue; - } - - for (const dayData of recordingSummary) { - for (const hourData of dayData.hours) { - const hour = add(dayData.day, { hours: hourData.hour }); - const startHour = startOfHour(hour); - const endHour = endOfHour(hour); - - if ( - (!options?.start || startHour >= options.start) && - (!options?.end || endHour <= options.end) - ) { - children.push( - createChild( - `${prettifyTitle(config.frigate.camera_name)} ${formatDateAndTime(hour)}`, - getRecordingMediaContentID({ - clientId: config.frigate.client_id, - year: dayData.day.getFullYear(), - month: dayData.day.getMonth() + 1, - day: dayData.day.getDate(), - hour: hourData.hour, - cameraName: config.frigate.camera_name, - }), - { - recording: { - camera: config.frigate.camera_name, - start_time: getUnixTime(startHour), - end_time: getUnixTime(endHour), - events: hourData.events, - }, - cameraID: cameraID, - }, - ), - ); - } - } - } - } - // Sort the events by time (to align recordings for different cameras at the - // same time). - return children.sort(sortYoungestToOldest); + return ( + view + ?.evolve({ + view: options?.targetView ? options.targetView : 'recording', + query: query, + queryResults: queryResults, + }) + .mergeInContext(viewerContext) ?? null + ); }; /** * Generate the media view context for a set of media children (used to set * seek times into each media item). + * @param hass The Home Assistant object. * @param dataManager The datamanager to use for data access. - * @param children The media children. + * @param media The media. * @param targetTime The target time. * @returns The ViewContext. */ -export const generateMediaViewerContextForChildren = ( +export const generateMediaViewerContext = async ( + hass: HomeAssistant, dataManager: DataManager, - children: FrigateBrowseMediaSource[], + media: ViewMedia[], targetTime: Date, -): ViewContext => { +): Promise => { const seek = new Map(); - const segmentsDataset = dataManager.recordingSegments; const hourStart = startOfHour(targetTime); - children.forEach((child, index) => { - const source = child.frigate?.recording ?? child.frigate?.event; - if (source && source.end_time && child.frigate?.cameraID) { - const start = source.start_time * 1000; - const end = source.end_time * 1000; - let seekSeconds: number | null = null; + for (const [index, child] of media.entries()) { + if (!ViewMediaClassifier.isMediaWithStartEndTime(child)) { + continue; + } - if (targetTime.getTime() >= start && targetTime.getTime() <= end) { - const segments = segmentsDataset.get({ - filter: (segment) => - segment.cameraID === child.frigate?.cameraID && - segment.start >= start && - segment.end <= end, - order: sortOldestToYoungest, - }); + const start = child.getStartTime(); + const end = child.getEndTime(); + let seekSeconds: number | null = null; + + if (targetTime >= start && targetTime <= end) { + const query = dataManager.generateDefaultRecordingSegmentsQueries( + child.getCameraID(), + { + start: start, + end: end, + }, + )[0]; + const segments = (await dataManager.getRecordingSegments(hass, query)).get(query); + + if (segments) { seekSeconds = getSeekTimeInSegments( // Recordings start from the top of the hour. - child.frigate.recording ? hourStart : fromUnixTime(source.start_time), + child.isRecording() ? hourStart : start, targetTime, - segments, + segments.segments, ); } - - if (seekSeconds !== null) { - seek.set(index, { - seekSeconds: seekSeconds, - seekTime: targetTime.getTime() / 1000, - }); - } } - }); + + if (seekSeconds !== null) { + seek.set(index, { + seekSeconds: seekSeconds, + seekTime: targetTime.getTime() / 1000, + }); + } + } return seek.size > 0 ? { mediaViewer: { seek: seek } } : {}; }; /** - * Find the relevant recording child given a date target. - * @param children The FrigateBrowseMediaSource[] children. Must be sorted - * most recent first. + * Find the closest matching media object. + * @param mediaArray The media. Must be sorted most recent first. * @param targetTime The target time used to find the relevant child. * @param cameraIDs The camera IDs to search for. * @param refPoint Whether to find based on the start or end of the @@ -244,8 +227,8 @@ export const generateMediaViewerContextForChildren = ( * the best match. * @returns The childindex or null if no matching child is found. */ -export const findChildIndex = ( - children: FrigateBrowseMediaSource[], +export const findClosestMediaIndex = ( + mediaArray: ViewMedia[], targetTime: Date, cameraIDs: Set, refPoint?: 'start' | 'end', @@ -257,27 +240,28 @@ export const findChildIndex = ( } | undefined; - for (let i = 0; i < children.length; ++i) { - const child = children[i]; - if (child.frigate?.cameraID && cameraIDs.has(child.frigate.cameraID)) { - const source = child.frigate.event ?? child.frigate.recording; - if (!source?.start_time || !source?.end_time) { - continue; - } - const startTime = fromUnixTime(source.start_time); - const endTime = fromUnixTime(source.end_time); + for (let i = 0; i < mediaArray.length; ++i) { + const media = mediaArray[i]; + if ( + !cameraIDs.has(media.getCameraID()) || + !ViewMediaClassifier.isMediaWithStartEndTime(media) + ) { + continue; + } - if (startTime <= targetTime && endTime >= targetTime) { - if (!refPoint) { - return i; - } - const delta = - refPoint === 'end' - ? endTime.getTime() - targetTime.getTime() - : targetTime.getTime() - startTime.getTime(); - if (!bestMatch || delta < bestMatch.delta) { - bestMatch = { index: i, delta: delta }; - } + const startTime = media.getStartTime(); + const endTime = media.getEndTime(); + + if (startTime <= targetTime && endTime >= targetTime) { + if (!refPoint) { + return i; + } + const delta = + refPoint === 'end' + ? endTime.getTime() - targetTime.getTime() + : targetTime.getTime() - startTime.getTime(); + if (!bestMatch || delta < bestMatch.delta) { + bestMatch = { index: i, delta: delta }; } } } @@ -295,7 +279,7 @@ export const findChildIndex = ( const getSeekTimeInSegments = ( startTime: Date, targetTime: Date, - segments: RecordingSegmentsItem[], + segments: RecordingSegments, ): number | null => { if (!segments.length) { return null; @@ -304,13 +288,14 @@ const getSeekTimeInSegments = ( // Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27 for (const segment of segments) { - if (segment.start > targetTime.getTime()) { + const segmentStart = fromUnixTime(segment.start_time); + if (segmentStart > targetTime) { break; } - const start = - segment.start < startTime.getTime() ? startTime.getTime() : segment.start; - const end = segment.end > targetTime.getTime() ? targetTime.getTime() : segment.end; - seekMilliseconds += end - start; + const segmentEnd = fromUnixTime(segment.end_time); + const start = segmentStart < startTime ? startTime : segmentStart; + const end = segmentEnd > targetTime ? targetTime : segmentEnd; + seekMilliseconds += end.getTime() - start.getTime(); } return seekMilliseconds / 1000; }; diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts new file mode 100644 index 00000000..4f83422a --- /dev/null +++ b/src/utils/timeline-source.ts @@ -0,0 +1,225 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import sub from 'date-fns/sub'; +import { DataSet } from 'vis-data'; +import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext'; +import { CameraConfig, ClipsOrSnapshotsOrAll } from '../types'; +import { DataManager } from './data/data-manager'; +import { EventQuery } from './data/data-types'; +import { RecordingSegment, RecordingSegments } from './frigate'; +import { capEndDate, convertRangeToCacheFriendlyTimes } from './data/data-manager-util'; +import { EventMediaQueries } from '../view'; +import { ViewMedia } from '../view-media'; +import { compressRanges, MemoryRangeSet } from './data/data-manager-range'; +import { ModifyInterface } from './basic'; + +// Allow timeline freshness to be at least this number of seconds out of date +// (caching times in the data-engine may increase the effective delay). +const TIMELINE_FRESHNESS_TOLERANCE_SECONDS = 30; + +// Number of seconds gap allowable in order to consider two recording segments +// to be consecutive. Some low performance cameras have trouble and without a +// generous allowance here the timeline may be littered with individual segments +// instead of clean recording blocks. +const TIMELINE_RECORDING_SEGMENT_CONSECUTIVE_TOLERANCE_SECONDS = 60; + +export interface FrigateCardTimelineItem extends TimelineItem { + // Use numbers to avoid significant volumes of Date object construction (for + // high-quantity recording segments). + start: number; + end?: number; + media?: ViewMedia; +} + +export class TimelineDataSource { + protected _dataManager: DataManager; + protected _dataset: DataSet = new DataSet(); + + // The ranges in which recordings have been calculated and added for. + protected _recordingRanges = new MemoryRangeSet(); + + protected _cameraIDs: Set; + protected _mediaType: ClipsOrSnapshotsOrAll; + + constructor( + dataManager: DataManager, + cameraIDs: Set, + media: ClipsOrSnapshotsOrAll, + ) { + this._dataManager = dataManager; + this._cameraIDs = cameraIDs; + this._mediaType = media; + } + + get dataset(): DataSet { + return this._dataset; + } + + public clearEvents(): void { + this._dataset.remove( + this._dataset.get({ + filter: (item) => item.type !== 'background', + }), + ); + } + + public rewriteEvent(id: IdType): void { + // Hack: For timeline uses of the event dataset clustering may not update + // unless the dataset changes, artifically update the dataset to ensure the + // newly selected item cannot be included in a cluster. + + // Hack2: Cannot use `updateOnly` here, as vis-data loses the object + // prototype, see: https://github.com/visjs/vis-data/issues/997 . Instead, + // remove then add. + const item = this._dataset.get(id); + if (item) { + this._dataset.remove(id); + this._dataset.add(item); + } + } + + public async refresh( + hass: HomeAssistant, + cameras: Map, + window: TimelineWindow, + ): Promise { + await Promise.all([ + this._refreshEvents(hass, cameras, window), + this._refreshRecordings(hass, window), + ]); + } + + public getTimelineEventQueries(window: TimelineWindow): EventQuery[] { + const _window = convertRangeToCacheFriendlyTimes(window, { + endCap: true, + }); + return this._dataManager.generateDefaultEventQueries(this._cameraIDs, { + start: _window.start, + end: _window.end, + ...(this._mediaType === 'clips' && { hasClip: true }), + ...(this._mediaType === 'snapshots' && { hasSnapshot: true }), + }); + } + + protected async _refreshEvents( + hass: HomeAssistant, + cameras: Map, + window: TimelineWindow, + ): Promise { + const query = new EventMediaQueries(this.getTimelineEventQueries(window)); + const results = await this._dataManager.executeMediaQuery(hass, query); + for (const media of results?.getResults() ?? []) { + const endTime = media.getEndTime(); + const startTime = media.getStartTime(); + const id = media.getID(cameras.get(media.getCameraID())); + if (id && startTime) { + this._dataset.update({ + id: id, + group: media.getCameraID(), + content: '', + media: media, + start: startTime.getTime(), + type: endTime ? 'range' : 'point', + ...(endTime && { end: endTime.getTime() }), + }); + } + } + } + + protected async _refreshRecordings( + hass: HomeAssistant, + window: TimelineWindow, + ): Promise { + type FrigateCardTimelineItemWithEnd = ModifyInterface< + FrigateCardTimelineItem, + { end: number } + >; + + const convertSegmentToRecording = ( + cameraID: string, + segment: RecordingSegment, + ): FrigateCardTimelineItemWithEnd => { + return { + id: `recording-${cameraID}-${segment.id}`, + group: cameraID, + start: segment.start_time * 1000, + end: segment.end_time * 1000, + content: '', + type: 'background', + }; + }; + + const getExistingRecordingsForCameraID = ( + cameraID: string, + ): FrigateCardTimelineItemWithEnd[] => { + return this._dataset.get({ + filter: (item) => + item.type == 'background' && item.group === cameraID && item.end !== undefined, + }) as FrigateCardTimelineItemWithEnd[]; + }; + + const deleteRecordingsForCameraID = (cameraID: string): void => { + this._dataset.remove( + this._dataset.get({ + filter: (item) => item.type === 'background' && item.group === cameraID, + }), + ); + }; + + const addRecordings = (recordings: FrigateCardTimelineItemWithEnd[]): void => { + this._dataset.add(recordings); + }; + + // Calculate an end date that's slightly short of the current time to allow + // for caching up to the freshness tolerance. + const end = sub(capEndDate(window.end), { + seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS, + }); + const hasCoverage = this._recordingRanges.hasCoverage({ + start: window.start, + end: end, + }); + if (hasCoverage) { + return; + } + + const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window, { + endCap: true, + }); + + const queries = this._dataManager.generateDefaultRecordingSegmentsQueries( + this._cameraIDs, + { + start: cacheFriendlyWindow.start, + end: cacheFriendlyWindow.end, + }, + ); + + const results = await this._dataManager.getRecordingSegments(hass, queries); + + const newSegments: Map = new Map(); + for (const [query, result] of results) { + let destination: RecordingSegments | undefined = newSegments.get(query.cameraID); + if (!destination) { + destination = []; + newSegments.set(query.cameraID, destination); + } + result.segments.forEach((segment) => destination?.push(segment)); + } + + for (const [cameraID, segments] of newSegments.entries()) { + const existingRecordings = getExistingRecordingsForCameraID(cameraID); + const mergedRecordings = existingRecordings.concat( + segments.map((segment) => convertSegmentToRecording(cameraID, segment)), + ); + const compressedRecordings = compressRanges( + mergedRecordings, + TIMELINE_RECORDING_SEGMENT_CONSECUTIVE_TOLERANCE_SECONDS, + ) as FrigateCardTimelineItemWithEnd[]; + + deleteRecordingsForCameraID(cameraID); + addRecordings(compressedRecordings); + } + + this._recordingRanges.add({ start: window.start, end: end }); + } +} diff --git a/src/view-media.ts b/src/view-media.ts new file mode 100644 index 00000000..d6d850ef --- /dev/null +++ b/src/view-media.ts @@ -0,0 +1,333 @@ +import fromUnixTime from 'date-fns/fromUnixTime'; +import isEqual from 'lodash-es/isEqual'; +import { + BrowseMediaSource, + CameraConfig, + FrigateEvent, + FrigateRecording, + MEDIA_TYPE_IMAGE, +} from './types.js'; +import { ModifyInterface } from './utils/basic.js'; +import { + getEventMediaContentID, + getEventThumbnailURL, + getEventTitle, + getRecordingMediaContentID, + getRecordingTitle, +} from './utils/frigate.js'; + +export type ViewMediaType = 'clip' | 'snapshot' | 'recording'; +export type ViewMediaSourceType = FrigateEvent | FrigateRecording | BrowseMediaSource; + +export class ViewMediaClassifier { + public static isFrigateMedia( + media: ViewMedia, + ): media is FrigateEventViewMedia | FrigateRecordingViewMedia { + return this.isFrigateEvent(media) || this.isFrigateRecording(media); + } + public static isFrigateEvent(media: ViewMedia): media is FrigateEventViewMedia { + return media instanceof FrigateEventViewMedia; + } + public static isFrigateRecording( + media: ViewMedia, + ): media is FrigateRecordingViewMedia { + return media instanceof FrigateRecordingViewMedia; + } + + // Typescript conveniences. + public static isMediaWithStartEndTime(media: ViewMedia): media is ModifyInterface< + ViewMedia, + { + getStartTime(): Date; + getEndTime(): Date; + } + > { + return !!media.getStartTime() && !!media.getEndTime(); + } + public static isMediaWithStartTime(media: ViewMedia): media is ModifyInterface< + ViewMedia, + { + getStartTime(): Date; + } + > { + return !!media.getStartTime(); + } + public static isMediaWithEndTime(media: ViewMedia): media is ModifyInterface< + ViewMedia, + { + getEndTime(): Date; + } + > { + return !!media.getEndTime(); + } + public static isMediaWithID(media: ViewMedia): media is ModifyInterface< + ViewMedia, + { + getID(): string; + } + > { + return !!media.getID(); + } +} + +class ViewMediaBase { + protected _mediaType: ViewMediaType; + protected _cameraID: string; + protected _source: T; + + constructor(mediaType: ViewMediaType, cameraID: string, source: T) { + this._mediaType = mediaType; + this._cameraID = cameraID; + this._source = source; + } + + public isEvent(): boolean { + return this._mediaType === 'clip' || this._mediaType === 'snapshot'; + } + public isRecording(): boolean { + return this._mediaType === 'recording'; + } + public isClip(): boolean { + return this._mediaType === 'clip'; + } + public isSnapshot(): boolean { + return this._mediaType === 'snapshot'; + } + public getContentType(): 'image' | 'video' { + return this._mediaType === 'snapshot' ? 'image' : 'video'; + } + public getCameraID(): string { + return this._cameraID; + } + public getMediaType(): ViewMediaType { + return this._mediaType; + } + public isVideo(): boolean { + return this.isClip() || this.isRecording(); + } + public getSource(): T { + return this._source; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getID(_cameraConfig?: CameraConfig): string | null { + return null; + } + public getStartTime(): Date | null { + return null; + } + public getEndTime(): Date | null { + return null; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getContentID(_cameraConfig?: CameraConfig): string | null { + return null; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getTitle(_cameraConfig?: CameraConfig): string | null { + return null; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getThumbnail(_cameraConfig?: CameraConfig): string | null { + return null; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public isGroupableWith(that: ViewMedia): boolean { + return ( + this.getMediaType() === that.getMediaType() && + isEqual(this.getWhere(), that.getWhere()) && + isEqual(this.getWhat(), that.getWhat()) + ); + } + public isFavorite(): boolean | null { + return null; + } + + // Sets the favorite attribute (if any). This purely sets the media item as a + // favorite in JS. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public setFavorite(_favorite: boolean): void { + return; + } + public getWhat(): string[] | null { + return null; + } + public getWhere(): string[] | null { + return null; + } + public getScore(): number | null { + return null; + } + public getEventCount(): number | null { + return null; + } +} + +// Creates a 'public interface only' version of ViewMediaBase for use elsewhere +// (typescript struggles with the ViewMediaClassifier classification functions +// used above if the object has data elements). +export type ViewMedia = { + [P in keyof ViewMediaBase]: ViewMediaBase[P]; +}; + +export class HomeAssistantBrowserViewMedia extends ViewMediaBase { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getID(_cameraConfig?: CameraConfig): string | null { + return this._source.media_content_id; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getContentID(_cameraConfig?: CameraConfig): string | null { + return this._source.media_content_id; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getTitle(_cameraConfig?: CameraConfig): string | null { + return this._source.title; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getThumbnail(_cameraConfig?: CameraConfig): string | null { + return this._source.thumbnail; + } +} + +export class FrigateEventViewMedia extends ViewMediaBase { + public hasClip(): boolean { + return !!this._source.has_clip; + } + public getClipEquivalent(): ViewMedia | null { + if (!this.hasClip()) { + return null; + } + return ViewMediaFactory.createViewMediaFromFrigateEvent( + 'clip', + this._cameraID, + this._source, + ); + } + public getStartTime(): Date { + return fromUnixTime(this._source.start_time); + } + public getEndTime(): Date | null { + return this._source.end_time ? fromUnixTime(this._source.end_time) : null; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getID(_cameraConfig?: CameraConfig): string { + return this._source.id; + } + public getContentID(cameraConfig?: CameraConfig): string | null { + if ( + !cameraConfig || + !cameraConfig.frigate.client_id || + !cameraConfig.frigate.camera_name + ) { + return null; + } + return getEventMediaContentID( + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + this._source, + this.isClip() ? 'clips' : 'snapshots', + ); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getTitle(_cameraConfig?: CameraConfig): string | null { + return getEventTitle(this._source); + } + + public getThumbnail(cameraConfig?: CameraConfig): string | null { + if (cameraConfig?.frigate.client_id) { + return getEventThumbnailURL(cameraConfig.frigate.client_id, this._source); + } + return null; + } + public isFavorite(): boolean | null { + return this._source.retain_indefinitely ?? null; + } + public setFavorite(favorite: boolean): void { + this._source.retain_indefinitely = favorite; + } + public getWhat(): string[] | null { + return [this._source.label]; + } + public getWhere(): string[] | null { + const zones = this._source.zones; + return zones.length ? zones : null; + } + public getScore(): number | null { + return this._source.top_score; + } +} + +export class FrigateRecordingViewMedia extends ViewMediaBase { + public getID(cameraConfig?: CameraConfig): string | null { + // ID name is derived from the real camera name (not CameraID) since the + // recordings for the same camera across multiple zones will be the same and + // can be dedup'd from this id. + if (cameraConfig) { + return `${cameraConfig.frigate?.client_id ?? ''}/${ + cameraConfig.frigate.camera_name ?? '' + }/${this._source.start_time}/${this._source.end_time}}`; + } + return null; + } + public getStartTime(): Date { + return fromUnixTime(this._source.start_time); + } + public getEndTime(): Date { + return fromUnixTime(this._source.end_time); + } + public getContentID(cameraConfig?: CameraConfig): string | null { + if ( + !cameraConfig || + !cameraConfig.frigate.client_id || + !cameraConfig.frigate.camera_name + ) { + return null; + } + return getRecordingMediaContentID( + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + this._source, + ); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getTitle(_cameraConfig?: CameraConfig): string | null { + return getRecordingTitle(this._source); + } + public getEventCount(): number { + return this._source.events; + } +} + +export class ViewMediaFactory { + static createViewMediaFromFrigateEvent( + type: 'clip' | 'snapshot', + cameraID: string, + event: FrigateEvent, + ): ViewMedia | null { + if ( + (type === 'clip' && event.has_clip) || + (type === 'snapshot' && event.has_snapshot) + ) { + return new FrigateEventViewMedia(type, cameraID, event); + } + return null; + } + + static createViewMediaFromFrigateRecording( + cameraID: string, + recording: FrigateRecording, + ): ViewMedia | null { + return new FrigateRecordingViewMedia('recording', cameraID, recording); + } + + static createViewMediaFromBrowseMediaSource( + cameraID: string, + browseMedia: BrowseMediaSource, + ): ViewMedia | null { + return new HomeAssistantBrowserViewMedia( + browseMedia.media_content_type === MEDIA_TYPE_IMAGE ? 'snapshot' : 'clip', + cameraID, + browseMedia, + ); + } +} diff --git a/src/view.ts b/src/view.ts index fcc5177f..45e81555 100644 --- a/src/view.ts +++ b/src/view.ts @@ -1,18 +1,50 @@ +// TODO: Do I need getMediaType below? +// TODO: Improve data storage in data-manager to allow fetching by limit not time. +// TODO: Live should get most recent events regardless of when they were. +// TODO: Refactor thumbnailsControlSchema to all use the shortform for other thumbnail users beyond live. +// TODO: Should be able to set live media to 'all' and have it work. +// TODO: If I replace the indexdb backend with just a map / array, does it work? Might be better. +// TODO: limit param in recordings should do something +// TODO: Callers of all async methods of data-engine need to catch errors. +// TODO: Search for references to frigate.js and see where it's being called outside of the dataManager. Can I collapse some of those functions in? +// TODO: Are there elements of ViewMedia (e.g. getEventCount) that should be moved into subclasses (e.g. a recording subclass). +// TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript +// TODO: In MediaQueriesBase, do we need to generic? Just have T be a MediaQuery? +// TODO: Are areEventQueries and areRecordingQueries should be in a classifier to keep with the pattern used elsewhere. +// TODO: Callers to the creation of new views for events/recordings need to dispatch events themselves when none are found. +// TODO: Examine how much of utils/frigate.ts can be moved into the Frigate data engine. +// TODO: Add garbage collecting of segments not present in the recording summaries anymore. +// TODO: Do I need to dedup recordings? (i.e. multiple zones on same camera may need to be dedup'd somewhere before returning the view). The media getID() call may be useful for this. +// TODO: Verify that scrolling the timeline will seek forward in both Frigate recordings & events. +// TODO: In generateMediaViewerContext there is an assumption that recordings start/end on the hour, which is true for Frigate but that assumption should be in the engine. +// TODO: Do a fresh media query in the viewer on snapshot click, since the first query may (e.g.) only have requested events with snapshots (which would miss an event with just a clip). +// TODO: In the viewer @click handlers should I use this.selected instead of calling carouselScrollPrevious() +// TODO: Implement seeking when the timeline is dragged. +// TODO: Can _timelineClickHandler be an async method in timeline-core to improve cleanliness? +// TODO: Can _timelineRangeChangedHandler be an async method in timeline-core to improve cleanliness? +// TODO: What should the timeline do when an event is clicked on that is not in the queryResults (or if queryResults is empty)? +// TODO: Should the timeline data source clear events (as it currently does) when the query changes? +// TODO: Implement gallery. + +import isEqual from 'lodash-es/isEqual'; +import clone from 'lodash-es/clone.js'; +import cloneDeep from 'lodash-es/cloneDeep.js'; import { ViewContext } from 'view'; import { - FrigateBrowseMediaSource, FrigateCardUserSpecifiedView, FrigateCardView, FRIGATE_CARD_VIEWS_USER_SPECIFIED, FRIGATE_CARD_VIEW_DEFAULT, } from './types.js'; import { dispatchFrigateCardEvent } from './utils/basic.js'; +import { EventQuery, MediaQuery, RecordingQuery } from './utils/data/data-types.js'; +import { ViewMedia } from './view-media.js'; export interface ViewEvolveParameters { view?: FrigateCardView; camera?: string; - target?: FrigateBrowseMediaSource | null; - childIndex?: number | null; + query?: MediaQueries | null; + queryResults?: MediaQueriesResults | null; context?: ViewContext | null; } @@ -21,18 +53,170 @@ export interface ViewParameters extends ViewEvolveParameters { camera: string; } +export class MediaQueriesBase { + protected _queries: T[] | null = null; + + protected constructor(queries?: T[]) { + if (queries) { + this._queries = queries; + } + } + + public clone(): MediaQueriesBase { + return cloneDeep(this); + } + + public isEqual(that: MediaQueries): boolean { + return isEqual(this.getQueries(), that.getQueries()); + } + + public areEventQueries(): this is EventMediaQueries { + return this instanceof EventMediaQueries; + } + + public areRecordingQueries(): this is RecordingMediaQueries { + return this instanceof RecordingMediaQueries; + } + + public getQueries(): T[] | null { + return this._queries; + } + + public setQueries(queries: T[]): void { + this._queries = queries; + } + + public setQueriesTime(start: Date, end: Date) { + for (const query of this._queries ?? []) { + query.start = start; + query.end = end; + } + } +} + +export class EventMediaQueries extends MediaQueriesBase { + constructor(queries?: EventQuery[]) { + super(queries); + } + + public convertToClipsQueries(): void { + for (const query of this._queries ?? []) { + delete query.hasSnapshot; + query.hasClip = true; + } + } + + public clone(): EventMediaQueries { + return cloneDeep(this); + } +} + +export class RecordingMediaQueries extends MediaQueriesBase { + constructor(queries?: RecordingQuery[]) { + super(queries); + } +} + +export type MediaQueries = EventMediaQueries | RecordingMediaQueries; + +export class MediaQueriesResults { + protected _results: ViewMedia[] | null = null; + protected _resultsTimestamp: Date | null = null; + protected _selectedIndex: number | null = null; + + constructor(results?: ViewMedia[], selectedIndex?: number) { + if (results) { + this.setResults(results); + } + if (selectedIndex !== undefined) { + this.selectResult(selectedIndex); + } + } + + public clone(): MediaQueriesResults { + // Shallow clone -- will reuse the same _results object (as there are no + // methods that support modification of the results themselves, and since + // changing the selectedIndex on a consistent set of results is a common + // operation). + return clone(this); + } + + public getResults(): ViewMedia[] | null { + return this._results; + } + public getResultsCount(): number { + return this._results?.length ?? 0; + } + public hasResults(): boolean { + return !!this._results; + } + public setResults(results: ViewMedia[]) { + this._results = results; + this._resultsTimestamp = new Date(); + } + public getResult(index?: number): ViewMedia | null { + if (!this._results || index === undefined) { + return null; + } + return this._results[index]; + } + public getSelectedResult(): ViewMedia | null { + return this._selectedIndex === null ? null : this.getResult(this._selectedIndex); + } + public getSelectedIndex(): number | null { + return this._selectedIndex; + } + public hasSelectedResult(): boolean { + return this.getSelectedResult() !== null; + } + public resetSelectedResult(): MediaQueriesResults { + this._selectedIndex = null; + return this; + } + public getResultsTimestamp(): Date | null { + return this._resultsTimestamp; + } + + public selectResult(index: number): MediaQueriesResults { + if (this._results && index >= 0 && index < this._results.length) { + this._selectedIndex = index; + } + return this; + } + public selectResultIfFound(func: (media: ViewMedia) => boolean): MediaQueriesResults { + for (const [index, result] of this._results?.entries() ?? []) { + if (func(result)) { + this._selectedIndex = index; + break; + } + } + return this; + } + public selectBestResult( + func: (media: ViewMedia[]) => number | null, + ): MediaQueriesResults { + if (this._results) { + const resultIndex = func(this._results); + if (resultIndex !== null) { + this._selectedIndex = resultIndex; + } + } + return this; + } +} + export class View { public view: FrigateCardView; public camera: string; - public target: FrigateBrowseMediaSource | null; - public childIndex: number | null; + public query: MediaQueries | null; + public queryResults: MediaQueriesResults | null; public context: ViewContext | null; constructor(params: ViewParameters) { this.view = params.view; this.camera = params.camera; - this.target = params.target ?? null; - this.childIndex = params.childIndex ?? null; + this.query = params.query ?? null; + this.queryResults = params.queryResults ?? null; this.context = params.context ?? null; } @@ -71,10 +255,12 @@ export class View { !curr || prev.view !== curr.view || prev.camera !== curr.camera || - // When in the live view, the target/childIndex are the events that - // happened in the past -- not reflective of the actual live media viewer. + // When in the live view, the target contains the events that happened in + // the past -- not reflective of the actual live media viewer. (curr.view !== 'live' && - (prev.target !== curr.target || prev.childIndex !== curr.childIndex)) + (prev.queryResults !== curr.queryResults || + prev.queryResults?.getSelectedResult() !== + curr.queryResults?.getSelectedResult())) ); } @@ -85,8 +271,11 @@ export class View { return new View({ view: this.view, camera: this.camera, - target: this.target, - childIndex: this.childIndex, + query: this.query?.clone() ?? null, + queryResults: this.queryResults?.clone() ?? null, + // target: this.target, + // targetIndex: this.targetIndex, + // targetFingerprint: this.targetFingerprint, context: this.context, }); } @@ -100,8 +289,11 @@ export class View { return new View({ view: params.view !== undefined ? params.view : this.view, camera: params.camera !== undefined ? params.camera : this.camera, - target: params.target !== undefined ? params.target : this.target, - childIndex: params.childIndex !== undefined ? params.childIndex : this.childIndex, + query: params.query !== undefined ? params.query : this.query?.clone() ?? null, + queryResults: + params.queryResults !== undefined + ? params.queryResults + : this.queryResults?.clone() ?? null, context: params.context !== undefined ? params.context : this.context, }); } @@ -123,7 +315,7 @@ export class View { */ public removeContext(key: keyof ViewContext): View { if (this.context) { - delete(this.context[key]); + delete this.context[key]; } return this; } @@ -188,7 +380,7 @@ export class View { /** * Determine if a view is related to a recording or recordings. */ - public isRecordingRelatedView(): boolean { + public isRecordingRelatedView(): boolean { return ['recording', 'recordings'].includes(this.view); } @@ -207,18 +399,6 @@ export class View { : null; } - /** - * Get the media item that should be played. - **/ - get media(): FrigateBrowseMediaSource | null { - if (this.target) { - if (this.target.children && this.childIndex !== null) { - return this.target.children[this.childIndex] ?? null; - } - } - return null; - } - /** * Dispatch an event to request a view change. * @param target The target dispatching the event. diff --git a/yarn.lock b/yarn.lock index f05034d5..2d4207ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3167,7 +3167,7 @@ uuid@^8.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -vis-data@^7.1.3: +vis-data@^7.1.4: version "7.1.4" resolved "https://registry.yarnpkg.com/vis-data/-/vis-data-7.1.4.tgz#90e5e796a79e1901de14c0808fb32a1a0735c1dc" integrity sha512-usy+ePX1XnArNvJ5BavQod7YRuGQE1pjFl+pu7IS6rCom2EBoG0o1ZzCqf3l5US6MW51kYkLR+efxRbnjxNl7w==