From 093925cb40ff69d3231d706d1150f97f2320ec8b Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 2 Jan 2023 16:07:43 -0800 Subject: [PATCH 01/74] Initial code for major engine refactor. --- package.json | 2 +- src/card-condition.ts | 3 +- src/card.ts | 133 ++- src/components/carousel.ts | 56 +- src/components/drawer.ts | 2 +- src/components/gallery.ts | 468 +++++---- src/components/live/live.ts | 35 +- src/components/media-carousel.ts | 4 + src/components/message.ts | 6 +- src/components/surround.ts | 72 +- src/components/thumbnail-carousel.ts | 114 +-- src/components/thumbnail.ts | 232 +++-- src/components/timeline-core.ts | 921 +++++++++--------- src/components/timeline.ts | 3 +- src/components/viewer.ts | 491 ++++------ src/localize/languages/en.json | 1 - src/localize/languages/it.json | 1 - src/localize/languages/pt-BR.json | 1 - src/types.ts | 117 +-- src/utils/basic.ts | 64 +- src/utils/camera.ts | 43 +- src/utils/data-manager.ts | 540 ---------- src/utils/data/data-manager-cache.ts | 135 +++ src/utils/data/data-manager-engine-factory.ts | 42 + src/utils/data/data-manager-engine-frigate.ts | 397 ++++++++ src/utils/data/data-manager-engine.ts | 77 ++ src/utils/data/data-manager-error.ts | 3 + src/utils/data/data-manager-range.ts | 84 ++ src/utils/data/data-manager-util.ts | 44 + src/utils/data/data-manager.ts | 312 ++++++ src/utils/data/data-types.ts | 138 +++ src/utils/frigate.ts | 115 +-- src/utils/ha/browse-media.ts | 129 +-- src/utils/ha/resolved-media.ts | 19 +- src/utils/media-to-view.ts | 365 ++++--- src/utils/timeline-source.ts | 225 +++++ src/view-media.ts | 333 +++++++ src/view.ts | 236 ++++- yarn.lock | 2 +- 39 files changed, 3506 insertions(+), 2459 deletions(-) delete mode 100644 src/utils/data-manager.ts create mode 100644 src/utils/data/data-manager-cache.ts create mode 100644 src/utils/data/data-manager-engine-factory.ts create mode 100644 src/utils/data/data-manager-engine-frigate.ts create mode 100644 src/utils/data/data-manager-engine.ts create mode 100644 src/utils/data/data-manager-error.ts create mode 100644 src/utils/data/data-manager-range.ts create mode 100644 src/utils/data/data-manager-util.ts create mode 100644 src/utils/data/data-manager.ts create mode 100644 src/utils/data/data-types.ts create mode 100644 src/utils/timeline-source.ts create mode 100644 src/view-media.ts 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== From 66ee65f8cccfb6ea827cb6e106c1f2d5f15814fa Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 2 Jan 2023 16:30:48 -0800 Subject: [PATCH 02/74] Use query type instead of view. --- src/components/surround.ts | 2 +- src/components/timeline-core.ts | 6 ++-- src/components/viewer.ts | 4 +-- src/view.ts | 61 ++++++++------------------------- 4 files changed, 20 insertions(+), 53 deletions(-) diff --git a/src/components/surround.ts b/src/components/surround.ts index 79036f1b..d46754ca 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -168,7 +168,7 @@ export class FrigateCardSurround extends LitElement { if (media) { this.view ?.evolve({ - view: this.view.is('recording') ? 'recording' : 'media', + view: 'media', queryResults: ev.detail.queryResults, ...(media.getCameraID() && { camera: media.getCameraID() }), }) diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 06357251..c7393d95 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -496,7 +496,7 @@ export class FrigateCardTimelineCore extends LitElement { } else if ( properties.item && properties.what === 'item' && - this.view.is('recording') + this.view.query?.areRecordingQueries() ) { viewPromise = (async (): Promise => { if (!properties.item || !this.dataManager || !this.hass) { @@ -606,7 +606,7 @@ export class FrigateCardTimelineCore extends LitElement { // 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')) { + if (this._timeline && this.view && !this.view.query?.areRecordingQueries()) { ( await this._createViewWithEventMediaQuery( this._createEventMediaQuerys({ window: prefetchedWindow }), @@ -967,7 +967,7 @@ export class FrigateCardTimelineCore extends LitElement { if ( !this.mini && - !this.view.is('recording') && + !this.view.query?.areRecordingQueries() && freshMediaQuery && !this._alreadyHasAcceptableMediaQuery(freshMediaQuery) ) { diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 7859ad5b..02031b71 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -124,7 +124,7 @@ export class FrigateCardViewer extends LitElement { // search for. When the query *is* specified, the view is not required to // indicate the media type (e.g. the mixed 'media' view from the // timeline). - const mediaType = this.view.getMediaType(); + const mediaType = this.view.getDefaultMediaType(); if (!browseMediaQueryParameters || !mediaType) { return; } @@ -148,7 +148,7 @@ export class FrigateCardViewer extends LitElement { this.cameras, this.view, { - targetView: mediaType === 'clips' ? 'clip' : 'snapshot', + targetView: 'media', }, ); } diff --git a/src/view.ts b/src/view.ts index 45e81555..9b5c1991 100644 --- a/src/view.ts +++ b/src/view.ts @@ -1,4 +1,4 @@ -// TODO: Do I need getMediaType below? +// TODO: Implement gallery. // 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. @@ -24,7 +24,6 @@ // 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'; @@ -334,20 +333,6 @@ export class View { return ['clips', 'snapshots', 'recordings'].includes(this.view); } - /** - * Get the viewer view given a gallery view. - */ - public getViewerViewForGalleryView(): 'clip' | 'snapshot' | 'recording' | null { - if (this.is('clips')) { - return 'clip'; - } else if (this.is('snapshots')) { - return 'snapshot'; - } else if (this.is('recordings')) { - return 'recording'; - } - return null; - } - /** * Determine if a view is of a piece of media (including the media viewer, * live view, image view -- anything that can create a MediaLoadedInfo event). @@ -364,39 +349,21 @@ export class View { } /** - * Determine if a view is related to a clip or clips. - */ - public isClipRelatedView(): boolean { - return ['clip', 'clips'].includes(this.view); - } - - /** - * Determine if a view is related to a snapshot or snapshots. - */ - public isSnapshotRelatedView(): boolean { - return ['snapshot', 'snapshots'].includes(this.view); - } - - /** - * Determine if a view is related to a recording or recordings. - */ - public isRecordingRelatedView(): boolean { - return ['recording', 'recordings'].includes(this.view); - } - - /** - * Get the media type for this view if available. - * @returns Whether the media is `clips`, `snapshots`, `recordings` or unknown + * Get the default media type for this view if available. + * @returns Whether the default media is `clips`, `snapshots`, `recordings` or unknown * (`null`). */ - public getMediaType(): 'clips' | 'snapshots' | 'recordings' | null { - return this.isClipRelatedView() - ? 'clips' - : this.isSnapshotRelatedView() - ? 'snapshots' - : this.isRecordingRelatedView() - ? 'recordings' - : null; + public getDefaultMediaType(): 'clips' | 'snapshots' | 'recordings' | null { + if (['clip', 'clips'].includes(this.view)) { + return 'clips'; + } + if (['snapshot', 'snapshots'].includes(this.view)) { + return 'snapshots'; + } + if (['recording', 'recordings'].includes(this.view)) { + return 'recordings'; + } + return null; } /** From 6415597e9b407b1d0d91f365229c404da776d6c7 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 2 Jan 2023 16:38:17 -0800 Subject: [PATCH 03/74] Sort TODO list. --- src/view.ts | 53 +++++++++++++++++++++++++++-------------------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/src/view.ts b/src/view.ts index 9b5c1991..b331739c 100644 --- a/src/view.ts +++ b/src/view.ts @@ -1,29 +1,30 @@ -// TODO: Implement gallery. -// 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? +// Easy: +// - 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: Refactor thumbnailsControlSchema to all use the shortform for other thumbnail users beyond live. +// - TODO: limit param in recordings should do something +// - TODO: Should be able to set live media to 'all' and have it work. +// - TODO: Are there elements of ViewMedia (e.g. getEventCount) that should be moved into subclasses (e.g. a recording subclass). +// - 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: In the viewer @click handlers should I use this.selected instead of calling carouselScrollPrevious() +// - 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? + +// Medium: +// - TODO: Callers of all async methods of data-engine need to catch errors. +// - 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: 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: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript + +// Hard: +// - TODO: Implement dragging the timeline seeking forward in both Frigate recordings & events. +// - TODO: Implement gallery. +// - 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: 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? + import isEqual from 'lodash-es/isEqual'; import clone from 'lodash-es/clone.js'; From 5be6ee86e0ca17f6db6be30113a8d6d28a5faa8f Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 2 Jan 2023 17:55:39 -0800 Subject: [PATCH 04/74] Timeline performance improvements. --- src/components/timeline-core.ts | 8 ++-- src/utils/data/data-manager-cache.ts | 8 +--- src/utils/data/data-manager-range.ts | 61 ++++++++++++++++++++---- src/utils/timeline-source.ts | 69 ++++++++++++++++++++++------ 4 files changed, 112 insertions(+), 34 deletions(-) diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index c7393d95..1d8ab4b8 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -626,10 +626,12 @@ export class FrigateCardTimelineCore extends LitElement { return null; } + const cacheFriendlyWindow = this._timelineSource.getCacheFriendlyEventWindow( + options?.window ?? this._timeline.getWindow(), + ); + return new EventMediaQueries( - this._timelineSource.getTimelineEventQueries( - options?.window ?? this._timeline.getWindow(), - ), + this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow), ); } diff --git a/src/utils/data/data-manager-cache.ts b/src/utils/data/data-manager-cache.ts index a325285a..ad6ee7d1 100644 --- a/src/utils/data/data-manager-cache.ts +++ b/src/utils/data/data-manager-cache.ts @@ -23,7 +23,7 @@ export class MemoryRequestCache protected _data: RequestCacheItem[] = []; public get(request: Request): Response | null { - const now = this._now(); + const now = new Date(); for (const item of this._data) { if ( (!item.expires || now <= item.expires) && @@ -50,16 +50,12 @@ export class MemoryRequestCache 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(); + const now = new Date(); this._data = this._data.filter((item) => !item.expires || now < item.expires); } } diff --git a/src/utils/data/data-manager-range.ts b/src/utils/data/data-manager-range.ts index daec7960..6c3b7666 100644 --- a/src/utils/data/data-manager-range.ts +++ b/src/utils/data/data-manager-range.ts @@ -1,4 +1,3 @@ -import cloneDeep from 'lodash-es/cloneDeep'; import orderBy from 'lodash-es/orderBy'; interface Range { @@ -8,20 +7,22 @@ interface Range { export type DateRange = Range; -export class MemoryRangeSet { +interface MemoryRangeSetInterface { + hasCoverage(range: T): boolean; + add(range: T): void; + clear(): void; +} + +export class MemoryRangeSet implements MemoryRangeSetInterface { 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), + rangeIsEntirelyContained(cachedRange, range), ); } @@ -30,11 +31,51 @@ export class MemoryRangeSet { this._ranges = compressRanges(this._ranges); } - protected _isEntirelyContained(bigger: DateRange, smaller: DateRange): boolean { - return smaller.start >= bigger.start && smaller.end <= bigger.end; + public clear(): void { + this._ranges = []; } } +interface ExpiringRange extends Range { + expires: Date; +} + +export class ExpiringMemoryRangeSet + implements MemoryRangeSetInterface> +{ + protected _ranges: ExpiringRange[]; + + constructor(ranges?: ExpiringRange[]) { + this._ranges = ranges ?? []; + } + + public hasCoverage(range: DateRange): boolean { + const now = new Date(); + return this._ranges.some( + (cachedRange) => + now < cachedRange.expires && rangeIsEntirelyContained(cachedRange, range), + ); + } + + public add(range: ExpiringRange): void { + this._expireOldRanges(); + this._ranges.push(range); + } + + protected _expireOldRanges(): void { + const now = new Date(); + this._ranges = this._ranges.filter((range) => now < range.expires); + } + + public clear(): void { + this._ranges = []; + } +} + +const rangeIsEntirelyContained = (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. @@ -44,7 +85,7 @@ export const rangesOverlap = (a: DateRange, b: DateRange): boolean => { // a encompasses the entire range of b. (a.start <= b.start && a.end >= b.end) ); -} +}; export const compressRanges = ( ranges: Range[], diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index 4f83422a..badfbf30 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -1,4 +1,5 @@ import { HomeAssistant } from 'custom-card-helpers'; +import add from 'date-fns/add'; import sub from 'date-fns/sub'; import { DataSet } from 'vis-data'; import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext'; @@ -9,7 +10,11 @@ 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 { + compressRanges, + ExpiringMemoryRangeSet, + MemoryRangeSet, +} from './data/data-manager-range'; import { ModifyInterface } from './basic'; // Allow timeline freshness to be at least this number of seconds out of date @@ -35,8 +40,15 @@ export class TimelineDataSource { protected _dataset: DataSet = new DataSet(); // The ranges in which recordings have been calculated and added for. + // Calculating recordings is a very expensive process since it is based on + // segments (not just the fetch is expensive, but the JS to dedup and turn the + // high-N segments into a smaller number of consecutive recording blocks). protected _recordingRanges = new MemoryRangeSet(); + // Cache event ranges since re-adding the same events is a timeline + // performance killer (even if the request results are cached). + protected _eventRanges = new ExpiringMemoryRangeSet(); + protected _cameraIDs: Set; protected _mediaType: ClipsOrSnapshotsOrAll; @@ -55,6 +67,7 @@ export class TimelineDataSource { } public clearEvents(): void { + this._eventRanges.clear(); this._dataset.remove( this._dataset.get({ filter: (item) => item.type !== 'background', @@ -88,13 +101,16 @@ export class TimelineDataSource { ]); } - public getTimelineEventQueries(window: TimelineWindow): EventQuery[] { - const _window = convertRangeToCacheFriendlyTimes(window, { + public getCacheFriendlyEventWindow(window: TimelineWindow): TimelineWindow { + return convertRangeToCacheFriendlyTimes(window, { endCap: true, }); + } + + public getTimelineEventQueries(window: TimelineWindow): EventQuery[] { return this._dataManager.generateDefaultEventQueries(this._cameraIDs, { - start: _window.start, - end: _window.end, + start: window.start, + end: window.end, ...(this._mediaType === 'clips' && { hasClip: true }), ...(this._mediaType === 'snapshots' && { hasSnapshot: true }), }); @@ -105,7 +121,22 @@ export class TimelineDataSource { cameras: Map, window: TimelineWindow, ): Promise { - const query = new EventMediaQueries(this.getTimelineEventQueries(window)); + if ( + this._eventRanges.hasCoverage({ + start: window.start, + end: sub(capEndDate(window.end), { + seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS, + }), + }) + ) { + return; + } + + const cacheFriendlyWindow = this.getCacheFriendlyEventWindow(window); + const query = new EventMediaQueries( + this.getTimelineEventQueries(cacheFriendlyWindow), + ); + const results = await this._dataManager.executeMediaQuery(hass, query); for (const media of results?.getResults() ?? []) { const endTime = media.getEndTime(); @@ -123,6 +154,11 @@ export class TimelineDataSource { }); } } + + this._eventRanges.add({ + ...cacheFriendlyWindow, + expires: add(new Date(), { seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS }), + }); } protected async _refreshRecordings( @@ -171,14 +207,14 @@ export class TimelineDataSource { // 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) { + if ( + this._recordingRanges.hasCoverage({ + start: window.start, + end: sub(capEndDate(window.end), { + seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS, + }), + }) + ) { return; } @@ -220,6 +256,9 @@ export class TimelineDataSource { addRecordings(compressedRecordings); } - this._recordingRanges.add({ start: window.start, end: end }); + this._recordingRanges.add({ + start: cacheFriendlyWindow.start, + end: cacheFriendlyWindow.end, + }); } } From e0d2dfaa608b8a2f2121713559d127bee846367d Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 2 Jan 2023 20:52:12 -0800 Subject: [PATCH 05/74] Dispatch message if the viewer has no media to show. --- src/components/timeline-core.ts | 6 ++++-- src/components/viewer.ts | 11 +++++++++-- src/localize/languages/en.json | 6 +----- src/localize/languages/it.json | 5 +---- src/localize/languages/pt-BR.json | 5 +---- src/utils/data/data-manager.ts | 12 +++++------- src/utils/media-to-view.ts | 4 ++-- src/view.ts | 7 +++---- 8 files changed, 26 insertions(+), 30 deletions(-) diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 1d8ab4b8..1bee6125 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -509,8 +509,10 @@ export class FrigateCardTimelineCore extends LitElement { targetView: 'media', }, ); - const results = view?.queryResults?.getResults() ?? null; - if (!results || !view) { + const results = view?.queryResults?.getResults(); + // Specifically ensure there are _some_ results before dispatching the + // view change. + if (!view || !results || !results.length) { return null; } view.mergeInContext( diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 02031b71..87793467 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -12,7 +12,7 @@ import { import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; -import { renderProgressIndicator } from '../components/message.js'; +import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js'; import viewerStyle from '../scss/viewer.scss'; import viewerCarouselStyle from '../scss/viewer-carousel.scss'; import { @@ -53,6 +53,7 @@ import { } from '../utils/media-to-view.js'; import { ViewMedia, ViewMediaClassifier } from '../view-media.js'; import { guard } from 'lit/directives/guard.js'; +import { localize } from '../localize/localize.js'; export interface MediaSeek { // Specifies the point at which this recording should be played, the @@ -566,6 +567,12 @@ export class FrigateCardViewerCarousel extends LitElement { * @returns A template to display to the user. */ protected _render(): TemplateResult | void { + if ((this.view?.queryResults?.getResultsCount() ?? 0) === 0) { + return dispatchMessageEvent(this, localize('common.no_media'), 'info', { + icon: 'mdi:multimedia', + }); + } + const media = this.view?.queryResults?.getSelectedResult(); if (!media || !this.cameras) { return; @@ -622,7 +629,7 @@ export class FrigateCardViewerCarousel extends LitElement { protected _recordingSeekHandler(): void { const selectedIndex = this.view?.queryResults?.getSelectedIndex() ?? null; const seek = - selectedIndex !== null + selectedIndex !== null ? this.view?.context?.mediaViewer?.seek.get(selectedIndex) : null; const player = this._getPlayer(); diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index a8cf3e39..47220e12 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -3,11 +3,7 @@ "frigate_card": "Frigate card", "frigate_card_description": "A Lovelace card for use with Frigate", "live": "Live", - "no_clip": "No recent clip", - "no_clips": "No clips", - "no_snapshot": "No recent snapshot", - "no_snapshots": "No snapshots", - "no_recording": "No recent recording", + "no_media": "No media to display", "recordings": "Recordings", "version": "Version" }, diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 9def01a7..5889e7c1 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -3,10 +3,7 @@ "frigate_card": "Frigate card", "frigate_card_description": "Una scheda Lovelace per l'uso con Frigate", "live": "Live", - "no_clip": "Nessuna clip recente", - "no_clips": "Nessun clip", - "no_snapshot": "Nessuna istantanea recente", - "no_snapshots": "Nessuna istantanea", + "no_media": "", "recordings": "Registrazioni", "version": "Versione" }, diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 4aa0a965..61295352 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -3,10 +3,7 @@ "frigate_card": "Cartão Frigate", "frigate_card_description": "Um cartão da Lovelace para usar com Frigate", "live": "Ao Vivo", - "no_clip": "Sem clip recente", - "no_clips": "Sem clips", - "no_snapshot": "Sem snapshot recente", - "no_snapshots": "Sem snapshots", + "no_media": "", "recordings": "Gravações", "version": "Versão" }, diff --git a/src/utils/data/data-manager.ts b/src/utils/data/data-manager.ts index a6027d54..75ab4a27 100644 --- a/src/utils/data/data-manager.ts +++ b/src/utils/data/data-manager.ts @@ -202,13 +202,11 @@ export class DataManager { } } - return mediaArray.length - ? new MediaQueriesResults( - orderBy(mediaArray, (media) => media.getStartTime(), 'desc'), - // Select the first (most-recent) item. - 0, - ) - : null; + return new MediaQueriesResults( + orderBy(mediaArray, (media) => media.getStartTime(), 'desc'), + // Select the first (most-recent) item. + mediaArray.length ? 0 : null, + ); } public getMediaDownloadPath(media: ViewMedia): string | null { diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index cd14ab7b..eec0346b 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -54,9 +54,9 @@ export const createViewForEvents = async ( const queries = dataManager.generateDefaultEventQueries(cameraIDs, { ...(options?.limit && { limit: options.limit }), ...((!options?.mediaType || ['clips', 'all'].includes(options.mediaType)) && { - has_clip: true, + hasClip: true, }), - ...(options?.mediaType === 'snapshots' && { has_snapshot: true }), + ...(options?.mediaType === 'snapshots' && { hasSnapshot: true }), }); query = new EventMediaQueries(queries); } diff --git a/src/view.ts b/src/view.ts index b331739c..8a10bea4 100644 --- a/src/view.ts +++ b/src/view.ts @@ -6,7 +6,6 @@ // - TODO: Are there elements of ViewMedia (e.g. getEventCount) that should be moved into subclasses (e.g. a recording subclass). // - 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: In the viewer @click handlers should I use this.selected instead of calling carouselScrollPrevious() // - 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? @@ -124,7 +123,7 @@ export class MediaQueriesResults { protected _resultsTimestamp: Date | null = null; protected _selectedIndex: number | null = null; - constructor(results?: ViewMedia[], selectedIndex?: number) { + constructor(results?: ViewMedia[], selectedIndex?: number | null) { if (results) { this.setResults(results); } @@ -177,8 +176,8 @@ export class MediaQueriesResults { return this._resultsTimestamp; } - public selectResult(index: number): MediaQueriesResults { - if (this._results && index >= 0 && index < this._results.length) { + public selectResult(index: number | null): MediaQueriesResults { + if (index === null || (this._results && index >= 0 && index < this._results.length)) { this._selectedIndex = index; } return this; From 39b44863ce503e9c05f11eec7bed1b186051776e Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 2 Jan 2023 21:10:20 -0800 Subject: [PATCH 06/74] Rename DataManager to CameraManager. --- .../data-manager-cache.ts => camera/cache.ts} | 10 ++--- src/camera/engine-factory.ts | 42 ++++++++++++++++++ .../engine-frigate.ts} | 38 ++++++++-------- .../engine.ts} | 12 ++--- src/camera/error.ts | 3 ++ .../data-manager.ts => camera/manager.ts} | 22 +++++----- .../data-manager-range.ts => camera/range.ts} | 0 .../data/data-types.ts => camera/types.ts} | 4 +- .../data-manager-util.ts => camera/util.ts} | 2 +- src/card.ts | 22 +++++----- src/components/gallery.ts | 10 ++--- src/components/live/live.ts | 6 +-- src/components/surround.ts | 12 ++--- src/components/thumbnail-carousel.ts | 6 +-- src/components/thumbnail.ts | 8 ++-- src/components/timeline-core.ts | 44 +++++++++---------- src/components/timeline.ts | 6 +-- src/components/viewer.ts | 12 ++--- src/utils/data/data-manager-engine-factory.ts | 42 ------------------ src/utils/data/data-manager-error.ts | 3 -- src/utils/media-to-view.ts | 36 +++++++-------- src/utils/timeline-source.ts | 26 +++++------ src/view.ts | 4 +- 23 files changed, 183 insertions(+), 187 deletions(-) rename src/{utils/data/data-manager-cache.ts => camera/cache.ts} (92%) create mode 100644 src/camera/engine-factory.ts rename src/{utils/data/data-manager-engine-frigate.ts => camera/engine-frigate.ts} (92%) rename src/{utils/data/data-manager-engine.ts => camera/engine.ts} (86%) create mode 100644 src/camera/error.ts rename src/{utils/data/data-manager.ts => camera/manager.ts} (94%) rename src/{utils/data/data-manager-range.ts => camera/range.ts} (100%) rename src/{utils/data/data-types.ts => camera/types.ts} (96%) rename src/{utils/data/data-manager-util.ts => camera/util.ts} (96%) delete mode 100644 src/utils/data/data-manager-engine-factory.ts delete mode 100644 src/utils/data/data-manager-error.ts diff --git a/src/utils/data/data-manager-cache.ts b/src/camera/cache.ts similarity index 92% rename from src/utils/data/data-manager-cache.ts rename to src/camera/cache.ts index ad6ee7d1..f7bf53f0 100644 --- a/src/utils/data/data-manager-cache.ts +++ b/src/camera/cache.ts @@ -1,9 +1,9 @@ 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'; +import { RecordingSegment, RecordingSegments } from '../utils/frigate'; +import { DateRange, MemoryRangeSet } from './range'; +import { DataQuery, QueryResults } from './types'; interface RequestCacheItem { request: Request; @@ -11,14 +11,14 @@ interface RequestCacheItem { expires?: Date; } -interface DataManagerCache { +interface CameraManagerCache { get(request: Request): Response | null; has(request: Request): boolean; set(request: Request, response: Response, expiry?: Date): void; } export class MemoryRequestCache - implements DataManagerCache + implements CameraManagerCache { protected _data: RequestCacheItem[] = []; diff --git a/src/camera/engine-factory.ts b/src/camera/engine-factory.ts new file mode 100644 index 00000000..7e36b18d --- /dev/null +++ b/src/camera/engine-factory.ts @@ -0,0 +1,42 @@ +import { CameraConfig } from '../types'; +import { RecordingSegmentsCache } from './cache'; +import { CameraManagerEngine } from './engine'; +import { FrigateCameraManagerEngine } from './engine-frigate'; +import { DataQuery } from './types'; + +export class CameraManagerEngineFactory { + protected _engines: Map = new Map(); + + protected _getOrCreateEngine(engineKey: string): CameraManagerEngine | null { + const cachedEngine = this._engines.get(engineKey); + if (cachedEngine) { + return cachedEngine; + } + let newEngine: CameraManagerEngine | null = null; + switch (engineKey) { + case 'frigate': + newEngine = new FrigateCameraManagerEngine(new RecordingSegmentsCache()); + break; + } + if (newEngine) { + this._engines.set(engineKey, newEngine); + } + return newEngine; + } + + public getEngineForQuery( + cameras: Map, + query: DataQuery, + ): CameraManagerEngine | null { + const cameraConfig = cameras.get(query.cameraID); + return cameraConfig ? this.getEngineForCamera(cameraConfig) : null; + } + + public getEngineForCamera(cameraConfig: CameraConfig): CameraManagerEngine | 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/camera/engine-frigate.ts similarity index 92% rename from src/utils/data/data-manager-engine-frigate.ts rename to src/camera/engine-frigate.ts index 4ed00aed..5b82adb1 100644 --- a/src/utils/data/data-manager-engine-frigate.ts +++ b/src/camera/engine-frigate.ts @@ -3,11 +3,11 @@ 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 { CAMERA_BIRDSEYE } from '../const'; +import { CameraConfig, FrigateRecording } from '../types'; +import { MediaQueries, MediaQueriesResults } from '../view'; +import { ViewMedia, ViewMediaClassifier, ViewMediaFactory } from '../view-media'; +import { errorToConsole } from '../utils/basic'; import { getEvents, getRecordingSegments, @@ -17,14 +17,14 @@ import { RecordingSegments, RecordingSummary, retainEvent, -} from '../frigate'; -import { RecordingSegmentsCache } from './data-manager-cache'; +} from '../utils/frigate'; +import { RecordingSegmentsCache } from './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'; + CameraManagerEngine, + CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, +} from './engine'; +import { CameraManagerError } from './error'; +import { DateRange } from './range'; import { Engine, EventQuery, @@ -40,7 +40,7 @@ import { QueryType, RecordingQuery, RecordingSegmentsQuery, -} from './data-types'; +} from './types'; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; @@ -70,7 +70,7 @@ class FrigateQueryResultsClassifier { } } -export class FrigateDataManagerEngine implements DataManagerEngine { +export class FrigateCameraManagerEngine implements CameraManagerEngine { protected _recordingSegmentsCache: RecordingSegmentsCache; constructor(recordingSegmentsCache: RecordingSegmentsCache) { @@ -157,7 +157,7 @@ export class FrigateDataManagerEngine implements DataManagerEngine { await retainEvent(hass, clientID, media.getID(cameraConfig), favorite); } catch (e) { errorToConsole(e as Error); - throw new DataManagerError((e as Error).message); + throw new CameraManagerError((e as Error).message); } media.setFavorite(favorite); @@ -183,7 +183,7 @@ export class FrigateDataManagerEngine implements DataManagerEngine { ...(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, + limit: query?.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, }; try { @@ -196,7 +196,7 @@ export class FrigateDataManagerEngine implements DataManagerEngine { return result; } catch (e) { errorToConsole(e as Error); - throw new DataManagerError((e as Error).message, query); + throw new CameraManagerError((e as Error).message, query); } } @@ -222,7 +222,7 @@ export class FrigateDataManagerEngine implements DataManagerEngine { ); } catch (e) { errorToConsole(e as Error); - throw new DataManagerError((e as Error).message, query); + throw new CameraManagerError((e as Error).message, query); } const recordings: FrigateRecording[] = []; @@ -295,7 +295,7 @@ export class FrigateDataManagerEngine implements DataManagerEngine { segments = await getRecordingSegments(hass, request); } catch (e) { errorToConsole(e as Error); - throw new DataManagerError((e as Error).message, query); + throw new CameraManagerError((e as Error).message, query); } this._recordingSegmentsCache.add(query.cameraID, range, segments); diff --git a/src/utils/data/data-manager-engine.ts b/src/camera/engine.ts similarity index 86% rename from src/utils/data/data-manager-engine.ts rename to src/camera/engine.ts index 171a2785..f1c27aae 100644 --- a/src/utils/data/data-manager-engine.ts +++ b/src/camera/engine.ts @@ -1,7 +1,7 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { CameraConfig } from '../../types'; -import { MediaQueries, MediaQueriesResults } from '../../view'; -import { ViewMedia } from '../../view-media'; +import { CameraConfig } from '../types'; +import { MediaQueries, MediaQueriesResults } from '../view'; +import { ViewMedia } from '../view-media'; import { EventQuery, PartialEventQuery, @@ -10,11 +10,11 @@ import { QueryReturnType, RecordingQuery, RecordingSegmentsQuery, -} from './data-types'; +} from './types'; -export const DATA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; +export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; -export interface DataManagerEngine { +export interface CameraManagerEngine { generateDefaultEventQuery( cameraID: string, cameraConfig: CameraConfig, diff --git a/src/camera/error.ts b/src/camera/error.ts new file mode 100644 index 00000000..b131e5c3 --- /dev/null +++ b/src/camera/error.ts @@ -0,0 +1,3 @@ +import { FrigateCardError } from '../types'; + +export class CameraManagerError extends FrigateCardError {} diff --git a/src/utils/data/data-manager.ts b/src/camera/manager.ts similarity index 94% rename from src/utils/data/data-manager.ts rename to src/camera/manager.ts index 75ab4a27..7c79c31b 100644 --- a/src/utils/data/data-manager.ts +++ b/src/camera/manager.ts @@ -1,6 +1,6 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { CameraConfig } from '../../types.js'; -import { arrayify, setify } from '../basic.js'; +import { CameraConfig } from '../types.js'; +import { arrayify, setify } from '../utils/basic.js'; import { DataQuery, EventQuery, @@ -18,12 +18,12 @@ import { RecordingQueryResults, RecordingSegmentsQuery, RecordingSegmentsQueryResults, -} from './data-types.js'; +} from './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'; +import { CameraManagerEngineFactory } from './engine-factory.js'; +import { ViewMedia } from '../view-media.js'; +import { MediaQueries, MediaQueriesResults } from '../view.js'; +import { MemoryRequestCache } from './cache.js'; export class QueryClassifier { public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery { @@ -61,13 +61,13 @@ export class QueryResultClassifier { export type RequestCache = MemoryRequestCache; -export class DataManager { - protected _engineFactory: DataManagerEngineFactory; +export class CameraManager { + protected _engineFactory: CameraManagerEngineFactory; protected _cameras: Map; protected _requestCache: RequestCache; constructor( - engineFactory: DataManagerEngineFactory, + engineFactory: CameraManagerEngineFactory, cameras: Map, requestCache: RequestCache, ) { @@ -296,7 +296,7 @@ export class DataManager { await Promise.all(_queries.map((query) => processQuery(query))); console.debug( - 'Frigate Card DataManager request (Cached:', + 'Frigate Card CameraManager request (Cached:', `${queryCachedCount}/${_queries.length},`, `Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, 'Queries:', diff --git a/src/utils/data/data-manager-range.ts b/src/camera/range.ts similarity index 100% rename from src/utils/data/data-manager-range.ts rename to src/camera/range.ts diff --git a/src/utils/data/data-types.ts b/src/camera/types.ts similarity index 96% rename from src/utils/data/data-types.ts rename to src/camera/types.ts index afafa4d9..bdb63985 100644 --- a/src/utils/data/data-types.ts +++ b/src/camera/types.ts @@ -1,5 +1,5 @@ -import { FrigateEvents, FrigateRecording } from '../../types'; -import { RecordingSegments } from '../frigate'; +import { FrigateEvents, FrigateRecording } from '../types'; +import { RecordingSegments } from '../utils/frigate'; // ==== // Base diff --git a/src/utils/data/data-manager-util.ts b/src/camera/util.ts similarity index 96% rename from src/utils/data/data-manager-util.ts rename to src/camera/util.ts index 5a56741b..9b09ef95 100644 --- a/src/utils/data/data-manager-util.ts +++ b/src/camera/util.ts @@ -5,7 +5,7 @@ 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'; +import { DateRange } from './range'; export const convertRangeToCacheFriendlyTimes = ( range: DateRange, diff --git a/src/card.ts b/src/card.ts index 86322916..4ead268e 100644 --- a/src/card.ts +++ b/src/card.ts @@ -91,10 +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/data-manager.js'; +import { CameraManager } from './camera/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'; +import { CameraManagerEngineFactory } from './camera/engine-factory.js'; +import { RequestCache } from './camera/cache.js'; /** A note on media callbacks: * @@ -205,7 +205,7 @@ export class FrigateCard extends LitElement { // A cache of resolved media URLs/mimetypes for use in the whole card. protected _resolvedMediaCache = new ResolvedMediaCache(); - protected _dataManager?: DataManager; + protected _cameraManager?: CameraManager; // The mouse handler may be called continually, throttle it to at most once // per second for performance reasons. @@ -1101,8 +1101,8 @@ export class FrigateCard extends LitElement { */ protected willUpdate(changedProps: PropertyValues): void { if (this._cameras && (changedProps.has('_config') || changedProps.has('_cameras'))) { - this._dataManager = new DataManager( - new DataManagerEngineFactory(), + this._cameraManager = new CameraManager( + new CameraManagerEngineFactory(), this._cameras, new RequestCache(), ); @@ -1336,7 +1336,7 @@ export class FrigateCard extends LitElement { return; } - const path = this._dataManager?.getMediaDownloadPath(media); + const path = this._cameraManager?.getMediaDownloadPath(media); if (!path) { return; } @@ -2018,7 +2018,7 @@ export class FrigateCard extends LitElement { .view=${this._view} .cameras=${this._cameras} .galleryConfig=${this._getConfig().event_gallery} - .dataManager=${this._dataManager} + .cameraManager=${this._cameraManager} .cardWideConfig=${this._cardWideConfig} > ` @@ -2030,7 +2030,7 @@ export class FrigateCard extends LitElement { .cameras=${this._cameras} .viewerConfig=${this._getConfig().media_viewer} .resolvedMediaCache=${this._resolvedMediaCache} - .dataManager=${this._dataManager} + .cameraManager=${this._cameraManager} .cardWideConfig=${this._cardWideConfig} > ` @@ -2041,7 +2041,7 @@ export class FrigateCard extends LitElement { .view=${this._view} .cameras=${this._cameras} .timelineConfig=${this._getConfig().timeline} - .dataManager=${this._dataManager} + .cameraManager=${this._cameraManager} > ` : ``} @@ -2062,7 +2062,7 @@ export class FrigateCard extends LitElement { .conditionState=${this._conditionState} .liveOverrides=${getOverridesByKey(this._getConfig().overrides, 'live')} .cameras=${this._cameras} - .dataManager=${this._dataManager} + .cameraManager=${this._cameraManager} .cardWideConfig=${this._cardWideConfig} class="${classMap(liveClasses)}" > diff --git a/src/components/gallery.ts b/src/components/gallery.ts index f0b72ad3..46658f46 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -22,7 +22,7 @@ import { getFullDependentBrowseMediaQueryParametersOrDispatchError, } from '../utils/ha/browse-media'; import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js'; -import { DataManager } from '../utils/data/data-manager.js'; +import { CameraManager } from '../camera/manager.js'; import { View } from '../view.js'; import { renderProgressIndicator } from './message.js'; import './thumbnail.js'; @@ -54,7 +54,7 @@ export class FrigateCardGallery extends LitElement { public cameras?: Map; @property({ attribute: false }) - public dataManager?: DataManager; + public cameraManager?: CameraManager; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; @@ -71,7 +71,7 @@ export class FrigateCardGallery extends LitElement { // !this.cameras || // !this.view.isGalleryView() || // !mediaType || - // !this.dataManager + // !this.cameraManager // ) { // return; // } @@ -81,7 +81,7 @@ export class FrigateCardGallery extends LitElement { // changeViewToRecentRecordingForCameraAndDependents( // this, // this.hass, - // this.dataManager, + // this.cameraManager, // this.cameras, // this.view, // { @@ -92,7 +92,7 @@ export class FrigateCardGallery extends LitElement { // changeViewToRecentEventsForCameraAndDependents( // this, // this.hass, - // this.dataManager, + // this.cameraManager, // this.cameras, // this.view, // { diff --git a/src/components/live/live.ts b/src/components/live/live.ts index e53f8685..d1c609f7 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -51,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/data-manager.js'; +import { CameraManager } from '../../camera/manager.js'; import { HomeAssistant } from 'custom-card-helpers'; import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js'; import { HassEntity } from 'home-assistant-js-websocket'; @@ -115,7 +115,7 @@ export class FrigateCardLive extends LitElement { public liveOverrides?: LiveOverrides; @property({ attribute: false }) - public dataManager?: DataManager; + public cameraManager?: CameraManager; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; @@ -231,7 +231,7 @@ export class FrigateCardLive extends LitElement { .thumbnailConfig=${config.controls.thumbnails} .timelineConfig=${config.controls.timeline} .cameras=${this.cameras} - .dataManager=${this.dataManager} + .cameraManager=${this.cameraManager} .inBackground=${this._inBackground} @frigate-card:message=${(ev: CustomEvent) => { this._renderKey++; diff --git a/src/components/surround.ts b/src/components/surround.ts index d46754ca..460dbabe 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -16,7 +16,7 @@ import { ThumbnailsControlConfig, } from '../types.js'; import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; -import { DataManager } from '../utils/data/data-manager.js'; +import { CameraManager } from '../camera/manager.js'; import { View } from '../view.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; import './surround-basic.js'; @@ -58,7 +58,7 @@ export class FrigateCardSurround extends LitElement { public cameras?: Map; @property({ attribute: false }) - public dataManager?: DataManager; + public cameraManager?: CameraManager; /** * Fetch thumbnail media when a target is not specified in the view (e.g. for @@ -69,7 +69,7 @@ export class FrigateCardSurround extends LitElement { protected async _fetchMedia(): Promise { if ( !this.cameras || - !this.dataManager || + !this.cameraManager || !this.fetchMedia || this.inBackground || !this.hass || @@ -84,7 +84,7 @@ export class FrigateCardSurround extends LitElement { await changeViewToRecentEventsForCameraAndDependents( this, this.hass, - this.dataManager, + this.cameraManager, this.cameras, this.view, { @@ -156,7 +156,7 @@ export class FrigateCardSurround extends LitElement { slot=${this.thumbnailConfig.mode} .hass=${this.hass} .config=${this.thumbnailConfig} - .dataManager=${this.dataManager} + .cameraManager=${this.cameraManager} .view=${this.view} .cameras=${this.cameras} .selected=${this.view.queryResults?.getSelectedIndex() ?? undefined} @@ -194,7 +194,7 @@ export class FrigateCardSurround extends LitElement { .timelineConfig=${this.timelineConfig} .thumbnailDetails=${this.thumbnailConfig?.show_details} .thumbnailSize=${this.thumbnailConfig?.size} - .dataManager=${this.dataManager} + .cameraManager=${this.cameraManager} > ` : ''} diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 82b4fa1e..d4e3becd 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -24,7 +24,7 @@ import { FrigateCardCarousel } from './carousel.js'; import './thumbnail.js'; import './carousel.js'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { DataManager } from '../utils/data/data-manager.js'; +import { CameraManager } from '../camera/manager.js'; export interface ThumbnailCarouselTap { queryResults: MediaQueriesResults; @@ -42,7 +42,7 @@ export class FrigateCardThumbnailCarousel extends LitElement { public cameras?: Map; @property({ attribute: false }) - public dataManager?: DataManager; + public cameraManager?: CameraManager; protected _refCarousel: Ref = createRef(); @@ -161,7 +161,7 @@ export class FrigateCardThumbnailCarousel extends LitElement { return html` { stopEventFromActivatingCardWideActions(ev); if (this.hass && this.cameraConfig && this.media) { - this.dataManager?.favoriteMedia( + this.cameraManager?.favoriteMedia( this.hass, this.cameraConfig, this.media, diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 1bee6125..28be5db1 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -49,13 +49,13 @@ import { createViewForRecordings, generateMediaViewerContext, } from '../utils/media-to-view'; -import { DataManager } from '../utils/data/data-manager'; +import { CameraManager } from '../camera/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'; +import { rangesOverlap } from '../camera/range'; interface FrigateCardGroupData { id: string; @@ -89,7 +89,7 @@ declare module 'view' { interface ThumbnailDataRequest { item: IdType; hass?: ExtendedHomeAssistant; - dataManager?: DataManager; + cameraManager?: CameraManager; cameraConfig?: CameraConfig; media?: ViewMedia; view?: View; @@ -142,7 +142,7 @@ export class FrigateCardTimelineThumbnail extends LitElement { if ( !dataRequest.hass || - !dataRequest.dataManager || + !dataRequest.cameraManager || !dataRequest.cameraConfig || !dataRequest.media || !dataRequest.view @@ -152,7 +152,7 @@ export class FrigateCardTimelineThumbnail extends LitElement { return html` => { - if (!properties.item || !this.dataManager || !this.hass) { + if (!properties.item || !this.cameraManager || !this.hass) { return null; } const view = await this._createViewWithEventMediaQuery( @@ -518,7 +518,7 @@ export class FrigateCardTimelineCore extends LitElement { view.mergeInContext( await generateMediaViewerContext( this.hass, - this.dataManager, + this.cameraManager, results, properties.time, ), @@ -645,12 +645,12 @@ export class FrigateCardTimelineCore extends LitElement { noSetWindow?: boolean; }, ): Promise { - if (!this.hass || !this.dataManager || !this.cameras || !this.view || !query) { + if (!this.hass || !this.cameraManager || !this.cameras || !this.view || !query) { return null; } const view = await createViewForEvents( this.hass, - this.dataManager, + this.cameraManager, this.cameras, this.view, { @@ -983,11 +983,11 @@ export class FrigateCardTimelineCore extends LitElement { protected _alreadyHasAcceptableMediaQuery(freshMediaQuery: MediaQueries): boolean { return ( - !!this.dataManager && + !!this.cameraManager && !!this.view?.query && !!this.view.queryResults && freshMediaQuery.isEqual(this.view.query) && - this.dataManager.areMediaQueriesResultsFresh( + this.cameraManager.areMediaQueriesResultsFresh( this.view.query, this.view.queryResults, ) @@ -1033,13 +1033,13 @@ export class FrigateCardTimelineCore extends LitElement { } if ( - changedProps.has('dataManager') || + changedProps.has('cameraManager') || changedProps.has('cameras') || changedProps.has('timelineConfig') ) { - if (this.dataManager && this.cameras && this.timelineConfig) { + if (this.cameraManager && this.cameras && this.timelineConfig) { this._timelineSource = new TimelineDataSource( - this.dataManager, + this.cameraManager, this._getTimelineCameraIDs(), this.timelineConfig.media, ); @@ -1069,7 +1069,7 @@ export class FrigateCardTimelineCore extends LitElement { protected updated(changedProperties: PropertyValues): void { super.updated(changedProperties); - if (changedProperties.has('cameras') || changedProperties.has('dataManager')) { + if (changedProperties.has('cameras') || changedProperties.has('cameraManager')) { this._destroy(); } diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 44766235..064a70fe 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/data-manager'; +import { CameraManager } from '../camera/manager'; import { View } from '../view'; import './surround.js'; import './timeline-core.js'; @@ -27,7 +27,7 @@ export class FrigateCardTimeline extends LitElement { public timelineConfig?: TimelineConfig; @property({ attribute: false }) - public dataManager?: DataManager; + public cameraManager?: CameraManager; /** * Master render method. @@ -51,7 +51,7 @@ export class FrigateCardTimeline extends LitElement { .timelineConfig=${this.timelineConfig} .thumbnailDetails=${this.timelineConfig.controls.thumbnails.show_details} .thumbnailSize=${this.timelineConfig.controls.thumbnails.size} - .dataManager=${this.dataManager} + .cameraManager=${this.cameraManager} > `; diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 87793467..503fcd84 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -46,7 +46,7 @@ import '../patches/ha-hls-player'; import './surround.js'; import { renderTask } from '../utils/task.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; -import { DataManager } from '../utils/data/data-manager.js'; +import { CameraManager } from '../camera/manager.js'; import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents, @@ -92,7 +92,7 @@ export class FrigateCardViewer extends LitElement { public resolvedMediaCache?: ResolvedMediaCache; @property({ attribute: false }) - public dataManager?: DataManager; + public cameraManager?: CameraManager; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; @@ -107,7 +107,7 @@ export class FrigateCardViewer extends LitElement { !this.view || !this.cameras || !this.viewerConfig || - !this.dataManager + !this.cameraManager ) { return; } @@ -134,7 +134,7 @@ export class FrigateCardViewer extends LitElement { changeViewToRecentRecordingForCameraAndDependents( this, this.hass, - this.dataManager, + this.cameraManager, this.cameras, this.view, { @@ -145,7 +145,7 @@ export class FrigateCardViewer extends LitElement { changeViewToRecentEventsForCameraAndDependents( this, this.hass, - this.dataManager, + this.cameraManager, this.cameras, this.view, { @@ -161,7 +161,7 @@ export class FrigateCardViewer extends LitElement { .view=${this.view} .thumbnailConfig=${this.viewerConfig.controls.thumbnails} .timelineConfig=${this.viewerConfig.controls.timeline} - .dataManager=${this.dataManager} + .cameraManager=${this.cameraManager} .cameras=${this.cameras} > = 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-error.ts b/src/utils/data/data-manager-error.ts deleted file mode 100644 index ccd19902..00000000 --- a/src/utils/data/data-manager-error.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { FrigateCardError } from '../../types'; - -export class DataManagerError extends FrigateCardError {} diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index eec0346b..dfa4640e 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -6,7 +6,7 @@ import { ViewContext } from 'view'; import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types'; import { EventMediaQueries, RecordingMediaQueries, View } from '../view'; import { RecordingSegments } from './frigate'; -import { DataManager } from './data/data-manager'; +import { CameraManager } from '../camera/manager'; import { getAllDependentCameras } from './camera.js'; import { ViewMedia, ViewMediaClassifier } from '../view-media'; import { HomeAssistant } from 'custom-card-helpers'; @@ -14,7 +14,7 @@ import { HomeAssistant } from 'custom-card-helpers'; export const changeViewToRecentEventsForCameraAndDependents = async ( element: HTMLElement, hass: HomeAssistant, - dataManager: DataManager, + cameraManager: CameraManager, cameras: Map, view: View, options?: { @@ -23,7 +23,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async ( }, ): Promise => { ( - await createViewForEvents(hass, dataManager, cameras, view, { + await createViewForEvents(hass, cameraManager, cameras, view, { ...options, limit: 50, // Capture the 50 most recent events. }) @@ -32,7 +32,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async ( export const createViewForEvents = async ( hass: HomeAssistant, - dataManager: DataManager, + cameraManager: CameraManager, cameras: Map, view: View, options?: { @@ -51,7 +51,7 @@ export const createViewForEvents = async ( ? options.cameraIDs : new Set(getAllDependentCameras(cameras, view.camera)); - const queries = dataManager.generateDefaultEventQueries(cameraIDs, { + const queries = cameraManager.generateDefaultEventQueries(cameraIDs, { ...(options?.limit && { limit: options.limit }), ...((!options?.mediaType || ['clips', 'all'].includes(options.mediaType)) && { hasClip: true, @@ -60,7 +60,7 @@ export const createViewForEvents = async ( }); query = new EventMediaQueries(queries); } - const queryResults = await dataManager.executeMediaQuery(hass, query); + const queryResults = await cameraManager.executeMediaQuery(hass, query); return view?.evolve({ view: options?.targetView, @@ -73,7 +73,7 @@ export const createViewForEvents = async ( * Change the view to a recent recording. * @param element The element to dispatch the view change from. * @param hass The Home Assistant object. - * @param dataManager The datamanager to use for data access. + * @param cameraManager The datamanager to use for data access. * @param cameras The camera configurations. * @param view The current view. * @param options A set of cameraIDs to fetch recordings for, and a targetView to dispatch to. @@ -81,7 +81,7 @@ export const createViewForEvents = async ( export const changeViewToRecentRecordingForCameraAndDependents = async ( element: HTMLElement, hass: HomeAssistant, - dataManager: DataManager, + cameraManager: CameraManager, cameras: Map, view: View, options?: { @@ -90,7 +90,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async ( ): Promise => { const now = new Date(); ( - await createViewForRecordings(hass, dataManager, cameras, view, { + await createViewForRecordings(hass, cameraManager, cameras, view, { ...options, // Fetch 7 days worth of recordings (including recordings that are for the // current hour). @@ -103,7 +103,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async ( /** * Create a view for recordings. * @param hass The Home Assistant object. - * @param dataManager The datamanager to use for data access. + * @param cameraManager The datamanager to use for data access. * @param cameras The camera configurations. * @param view The current view. * @param options A specific window (start and end) to fetch recordings for, a @@ -112,7 +112,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async ( */ export const createViewForRecordings = async ( hass: HomeAssistant, - dataManager: DataManager, + cameraManager: CameraManager, cameras: Map, view: View, options?: { @@ -127,13 +127,13 @@ export const createViewForRecordings = async ( ? options.cameraIDs : new Set(getAllDependentCameras(cameras, view.camera)); - const queries = dataManager.generateDefaultRecordingQueries(cameraIDs, { + const queries = cameraManager.generateDefaultRecordingQueries(cameraIDs, { ...(options?.start && { start: options.start }), ...(options?.end && { end: options.end }), }); const query = new RecordingMediaQueries(queries); - const queryResults = await dataManager.executeMediaQuery(hass, query); + const queryResults = await cameraManager.executeMediaQuery(hass, query); let viewerContext: ViewContext | undefined = {}; const mediaArray = queryResults?.getResults(); @@ -143,7 +143,7 @@ export const createViewForRecordings = async ( ); viewerContext = await generateMediaViewerContext( hass, - dataManager, + cameraManager, mediaArray, options.targetTime, ); @@ -164,14 +164,14 @@ export const createViewForRecordings = async ( * 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 cameraManager The datamanager to use for data access. * @param media The media. * @param targetTime The target time. * @returns The ViewContext. */ export const generateMediaViewerContext = async ( hass: HomeAssistant, - dataManager: DataManager, + cameraManager: CameraManager, media: ViewMedia[], targetTime: Date, ): Promise => { @@ -188,14 +188,14 @@ export const generateMediaViewerContext = async ( let seekSeconds: number | null = null; if (targetTime >= start && targetTime <= end) { - const query = dataManager.generateDefaultRecordingSegmentsQueries( + const query = cameraManager.generateDefaultRecordingSegmentsQueries( child.getCameraID(), { start: start, end: end, }, )[0]; - const segments = (await dataManager.getRecordingSegments(hass, query)).get(query); + const segments = (await cameraManager.getRecordingSegments(hass, query)).get(query); if (segments) { seekSeconds = getSeekTimeInSegments( diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index badfbf30..3ddc9a94 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -4,17 +4,13 @@ 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 { CameraManager } from '../camera/manager'; +import { EventQuery } from '../camera/types'; import { RecordingSegment, RecordingSegments } from './frigate'; -import { capEndDate, convertRangeToCacheFriendlyTimes } from './data/data-manager-util'; +import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera/util'; import { EventMediaQueries } from '../view'; import { ViewMedia } from '../view-media'; -import { - compressRanges, - ExpiringMemoryRangeSet, - MemoryRangeSet, -} from './data/data-manager-range'; +import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet } from '../camera/range'; import { ModifyInterface } from './basic'; // Allow timeline freshness to be at least this number of seconds out of date @@ -36,7 +32,7 @@ export interface FrigateCardTimelineItem extends TimelineItem { } export class TimelineDataSource { - protected _dataManager: DataManager; + protected _cameraManager: CameraManager; protected _dataset: DataSet = new DataSet(); // The ranges in which recordings have been calculated and added for. @@ -53,11 +49,11 @@ export class TimelineDataSource { protected _mediaType: ClipsOrSnapshotsOrAll; constructor( - dataManager: DataManager, + cameraManager: CameraManager, cameraIDs: Set, media: ClipsOrSnapshotsOrAll, ) { - this._dataManager = dataManager; + this._cameraManager = cameraManager; this._cameraIDs = cameraIDs; this._mediaType = media; } @@ -108,7 +104,7 @@ export class TimelineDataSource { } public getTimelineEventQueries(window: TimelineWindow): EventQuery[] { - return this._dataManager.generateDefaultEventQueries(this._cameraIDs, { + return this._cameraManager.generateDefaultEventQueries(this._cameraIDs, { start: window.start, end: window.end, ...(this._mediaType === 'clips' && { hasClip: true }), @@ -137,7 +133,7 @@ export class TimelineDataSource { this.getTimelineEventQueries(cacheFriendlyWindow), ); - const results = await this._dataManager.executeMediaQuery(hass, query); + const results = await this._cameraManager.executeMediaQuery(hass, query); for (const media of results?.getResults() ?? []) { const endTime = media.getEndTime(); const startTime = media.getStartTime(); @@ -222,7 +218,7 @@ export class TimelineDataSource { endCap: true, }); - const queries = this._dataManager.generateDefaultRecordingSegmentsQueries( + const queries = this._cameraManager.generateDefaultRecordingSegmentsQueries( this._cameraIDs, { start: cacheFriendlyWindow.start, @@ -230,7 +226,7 @@ export class TimelineDataSource { }, ); - const results = await this._dataManager.getRecordingSegments(hass, queries); + const results = await this._cameraManager.getRecordingSegments(hass, queries); const newSegments: Map = new Map(); for (const [query, result] of results) { diff --git a/src/view.ts b/src/view.ts index 8a10bea4..13aac7f8 100644 --- a/src/view.ts +++ b/src/view.ts @@ -1,5 +1,5 @@ // Easy: -// - 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: Search for references to frigate.js and see where it's being called outside of the cameraManager. Can I collapse some of those functions in? // - TODO: Refactor thumbnailsControlSchema to all use the shortform for other thumbnail users beyond live. // - TODO: limit param in recordings should do something // - TODO: Should be able to set live media to 'all' and have it work. @@ -36,7 +36,7 @@ import { FRIGATE_CARD_VIEW_DEFAULT, } from './types.js'; import { dispatchFrigateCardEvent } from './utils/basic.js'; -import { EventQuery, MediaQuery, RecordingQuery } from './utils/data/data-types.js'; +import { EventQuery, MediaQuery, RecordingQuery } from './camera/types.js'; import { ViewMedia } from './view-media.js'; export interface ViewEvolveParameters { From d8614a3e8de87d86733b724ce396064802c2be52 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 2 Jan 2023 21:20:35 -0800 Subject: [PATCH 07/74] Move frigate utils under the camera manager directory for Frigate. --- src/camera/cache.ts | 2 +- src/camera/engine-factory.ts | 2 +- src/camera/{ => frigate}/engine-frigate.ts | 22 +++++++++++----------- src/{utils => camera/frigate}/frigate.ts | 15 ++++++++------- src/camera/types.ts | 2 +- src/components/menu.ts | 2 +- src/editor.ts | 2 +- src/utils/media-to-view.ts | 2 +- src/utils/timeline-source.ts | 2 +- src/view-media.ts | 2 +- 10 files changed, 27 insertions(+), 26 deletions(-) rename src/camera/{ => frigate}/engine-frigate.ts (96%) rename src/{utils => camera/frigate}/frigate.ts (95%) diff --git a/src/camera/cache.ts b/src/camera/cache.ts index f7bf53f0..5ae93c3f 100644 --- a/src/camera/cache.ts +++ b/src/camera/cache.ts @@ -1,7 +1,7 @@ import isEqual from 'lodash-es/isEqual'; import orderBy from 'lodash-es/orderBy'; import sortedUniqBy from 'lodash-es/sortedUniqBy'; -import { RecordingSegment, RecordingSegments } from '../utils/frigate'; +import { RecordingSegment, RecordingSegments } from './frigate/frigate'; import { DateRange, MemoryRangeSet } from './range'; import { DataQuery, QueryResults } from './types'; diff --git a/src/camera/engine-factory.ts b/src/camera/engine-factory.ts index 7e36b18d..c249829e 100644 --- a/src/camera/engine-factory.ts +++ b/src/camera/engine-factory.ts @@ -1,7 +1,7 @@ import { CameraConfig } from '../types'; import { RecordingSegmentsCache } from './cache'; import { CameraManagerEngine } from './engine'; -import { FrigateCameraManagerEngine } from './engine-frigate'; +import { FrigateCameraManagerEngine } from './frigate/engine-frigate'; import { DataQuery } from './types'; export class CameraManagerEngineFactory { diff --git a/src/camera/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts similarity index 96% rename from src/camera/engine-frigate.ts rename to src/camera/frigate/engine-frigate.ts index 5b82adb1..ea2a7332 100644 --- a/src/camera/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -3,11 +3,11 @@ 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 '../utils/basic'; +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 '../../utils/basic'; import { getEvents, getRecordingSegments, @@ -17,14 +17,14 @@ import { RecordingSegments, RecordingSummary, retainEvent, -} from '../utils/frigate'; -import { RecordingSegmentsCache } from './cache'; +} from './frigate'; +import { RecordingSegmentsCache } from '../cache'; import { CameraManagerEngine, CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, -} from './engine'; -import { CameraManagerError } from './error'; -import { DateRange } from './range'; +} from '../engine'; +import { CameraManagerError } from '../error'; +import { DateRange } from '../range'; import { Engine, EventQuery, @@ -40,7 +40,7 @@ import { QueryType, RecordingQuery, RecordingSegmentsQuery, -} from './types'; +} from '../types'; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; diff --git a/src/utils/frigate.ts b/src/camera/frigate/frigate.ts similarity index 95% rename from src/utils/frigate.ts rename to src/camera/frigate/frigate.ts index 432f6bf2..bf6ddf08 100644 --- a/src/utils/frigate.ts +++ b/src/camera/frigate/frigate.ts @@ -2,7 +2,7 @@ import { HomeAssistant } from 'custom-card-helpers'; import utcToZonedTime from 'date-fns-tz/utcToZonedTime'; import fromUnixTime from 'date-fns/fromUnixTime'; import { z } from 'zod'; -import { localize } from '../localize/localize'; +import { localize } from '../../localize/localize'; import { ClipsOrSnapshots, FrigateCardError, @@ -10,9 +10,9 @@ import { FrigateEvents, frigateEventsSchema, FrigateRecording, -} from '../types'; -import { formatDateAndTime, prettifyTitle } from './basic'; -import { homeAssistantWSRequest } from './ha'; +} from '../../types'; +import { formatDateAndTime, prettifyTitle } from '../../utils/basic'; +import { homeAssistantWSRequest } from '../../utils/ha'; export const FRIGATE_ICON_SVG_PATH = 'm 4.8759466,22.743573 c 0.0866,0.69274 0.811811,1.16359 0.37885,1.27183 ' + @@ -226,7 +226,7 @@ export const getEventThumbnailURL = (clientId: string, event: FrigateEvent): str * Get a media content ID for an event. * @param clientId The Frigate client id. * @param cameraName The Frigate camera name. - * @param id The event id. + * @param event The Frigate event. * @param mediaType The media type required. * @returns A string media content id. */ @@ -241,8 +241,9 @@ export const getEventMediaContentID = ( /** * Generate a recording identifier. - * @param hass The HomeAssistant object. - * @param params The recording parameters to use in the identifer. + * @param clientId The Frigate client id. + * @param cameraName The Frigate camera name. + * @param recording The Frigate recording. * @returns A recording identifier. */ export const getRecordingMediaContentID = ( diff --git a/src/camera/types.ts b/src/camera/types.ts index bdb63985..846bd652 100644 --- a/src/camera/types.ts +++ b/src/camera/types.ts @@ -1,5 +1,5 @@ import { FrigateEvents, FrigateRecording } from '../types'; -import { RecordingSegments } from '../utils/frigate'; +import { RecordingSegments } from './frigate/frigate'; // ==== // Base diff --git a/src/components/menu.ts b/src/components/menu.ts index 6201f4a7..789deec8 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -27,7 +27,7 @@ import { frigateCardHasAction, getActionConfigGivenAction } from '../utils/action.js'; -import { FRIGATE_ICON_SVG_PATH } from '../utils/frigate.js'; +import { FRIGATE_ICON_SVG_PATH } from '../camera/frigate/frigate.js'; import { refreshDynamicStateParameters } from '../utils/ha'; import './submenu.js'; diff --git a/src/editor.ts b/src/editor.ts index ef0c0002..0077cf25 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -141,7 +141,7 @@ import { } from './types.js'; import { arrayMove } from './utils/basic.js'; import { getCameraID, getCameraTitle } from './utils/camera.js'; -import { FRIGATE_ICON_SVG_PATH } from './utils/frigate.js'; +import { FRIGATE_ICON_SVG_PATH } from './camera/frigate/frigate.js'; import { getEntitiesFromHASS, sideLoadHomeAssistantElements } from './utils/ha'; import { setLowPerformanceProfile } from './performance.js'; diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index dfa4640e..9f301698 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -5,7 +5,7 @@ import sub from 'date-fns/sub'; import { ViewContext } from 'view'; import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types'; import { EventMediaQueries, RecordingMediaQueries, View } from '../view'; -import { RecordingSegments } from './frigate'; +import { RecordingSegments } from '../camera/frigate/frigate'; import { CameraManager } from '../camera/manager'; import { getAllDependentCameras } from './camera.js'; import { ViewMedia, ViewMediaClassifier } from '../view-media'; diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index 3ddc9a94..18e401f2 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -6,7 +6,7 @@ import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext'; import { CameraConfig, ClipsOrSnapshotsOrAll } from '../types'; import { CameraManager } from '../camera/manager'; import { EventQuery } from '../camera/types'; -import { RecordingSegment, RecordingSegments } from './frigate'; +import { RecordingSegment, RecordingSegments } from '../camera/frigate/frigate'; import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera/util'; import { EventMediaQueries } from '../view'; import { ViewMedia } from '../view-media'; diff --git a/src/view-media.ts b/src/view-media.ts index d6d850ef..84448d66 100644 --- a/src/view-media.ts +++ b/src/view-media.ts @@ -14,7 +14,7 @@ import { getEventTitle, getRecordingMediaContentID, getRecordingTitle, -} from './utils/frigate.js'; +} from './camera/frigate/frigate.js'; export type ViewMediaType = 'clip' | 'snapshot' | 'recording'; export type ViewMediaSourceType = FrigateEvent | FrigateRecording | BrowseMediaSource; From e08cb95b1697f0a42627f9938f175b0568c8cb0f Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 2 Jan 2023 21:52:25 -0800 Subject: [PATCH 08/74] Move frigate util under the camera manager hierarchy. --- src/camera/cache.ts | 6 +- src/camera/frigate/engine-frigate.ts | 16 +- src/camera/frigate/frigate.ts | 265 --------------------------- src/camera/frigate/icon.ts | 20 ++ src/camera/frigate/requests.ts | 128 +++++++++++++ src/camera/frigate/schema.ts | 33 ++++ src/camera/frigate/util.ts | 84 +++++++++ src/camera/types.ts | 9 +- src/components/menu.ts | 2 +- src/editor.ts | 2 +- src/types.ts | 7 + src/utils/media-to-view.ts | 5 +- src/utils/timeline-source.ts | 7 +- src/view-media.ts | 2 +- 14 files changed, 289 insertions(+), 297 deletions(-) delete mode 100644 src/camera/frigate/frigate.ts create mode 100644 src/camera/frigate/icon.ts create mode 100644 src/camera/frigate/requests.ts create mode 100644 src/camera/frigate/schema.ts create mode 100644 src/camera/frigate/util.ts diff --git a/src/camera/cache.ts b/src/camera/cache.ts index 5ae93c3f..9679d31b 100644 --- a/src/camera/cache.ts +++ b/src/camera/cache.ts @@ -1,7 +1,7 @@ import isEqual from 'lodash-es/isEqual'; import orderBy from 'lodash-es/orderBy'; import sortedUniqBy from 'lodash-es/sortedUniqBy'; -import { RecordingSegment, RecordingSegments } from './frigate/frigate'; +import { RecordingSegment } from '../types'; import { DateRange, MemoryRangeSet } from './range'; import { DataQuery, QueryResults } from './types'; @@ -108,7 +108,7 @@ export class MemoryRangedCache { export class RecordingSegmentsCache { protected _segments: Map> = new Map(); - public add(cameraID: string, range: DateRange, segments: RecordingSegments) { + public add(cameraID: string, range: DateRange, segments: RecordingSegment[]) { let cameraSegmentCache: MemoryRangedCache | undefined = this._segments.get(cameraID); if (!cameraSegmentCache) { @@ -125,7 +125,7 @@ export class RecordingSegmentsCache { return !!this._segments.get(cameraID)?.hasCoverage(range); } - public get(cameraID: string, range: DateRange): RecordingSegments | null { + public get(cameraID: string, range: DateRange): RecordingSegment[] | null { return this._segments.get(cameraID)?.get(range) ?? null; } } diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index ea2a7332..d7a955fb 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -4,20 +4,10 @@ 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 { CameraConfig, FrigateRecording, RecordingSegment } from '../../types'; import { MediaQueries, MediaQueriesResults } from '../../view'; import { ViewMedia, ViewMediaClassifier, ViewMediaFactory } from '../../view-media'; import { errorToConsole } from '../../utils/basic'; -import { - getEvents, - getRecordingSegments, - getRecordingsSummary, - NativeFrigateEventQuery, - NativeFrigateRecordingSegmentsQuery, - RecordingSegments, - RecordingSummary, - retainEvent, -} from './frigate'; import { RecordingSegmentsCache } from '../cache'; import { CameraManagerEngine, @@ -41,6 +31,8 @@ import { RecordingQuery, RecordingSegmentsQuery, } from '../types'; +import { RecordingSummary } from './schema'; +import { getEvents, getRecordingSegments, getRecordingsSummary, NativeFrigateEventQuery, NativeFrigateRecordingSegmentsQuery, retainEvent } from './requests'; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; @@ -290,7 +282,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { before: Math.floor(query.end.getTime() / 1000), }; - let segments: RecordingSegments; + let segments: RecordingSegment[]; try { segments = await getRecordingSegments(hass, request); } catch (e) { diff --git a/src/camera/frigate/frigate.ts b/src/camera/frigate/frigate.ts deleted file mode 100644 index bf6ddf08..00000000 --- a/src/camera/frigate/frigate.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { HomeAssistant } from 'custom-card-helpers'; -import utcToZonedTime from 'date-fns-tz/utcToZonedTime'; -import fromUnixTime from 'date-fns/fromUnixTime'; -import { z } from 'zod'; -import { localize } from '../../localize/localize'; -import { - ClipsOrSnapshots, - FrigateCardError, - FrigateEvent, - FrigateEvents, - frigateEventsSchema, - FrigateRecording, -} from '../../types'; -import { formatDateAndTime, prettifyTitle } from '../../utils/basic'; -import { homeAssistantWSRequest } from '../../utils/ha'; - -export const FRIGATE_ICON_SVG_PATH = - 'm 4.8759466,22.743573 c 0.0866,0.69274 0.811811,1.16359 0.37885,1.27183 ' + - '-0.43297,0.10824 -2.32718,-3.43665 -2.7601492,-4.95202 -0.4329602,-1.51538 ' + - '-0.6764993,-3.22017 -0.5682593,-4.19434 0.1082301,-0.97417 5.7097085,-2.48955 ' + - '5.7097085,-2.89545 0,-0.4059 -1.81304,-0.0271 -1.89422,-0.35178 -0.0812,-0.32472 ' + - '1.36925,-0.12989 1.75892,-0.64945 0.60885,-0.81181 1.3800713,-0.6765 1.8671505,' + - '-1.1094696 0.4870902,-0.4329599 1.0824089,-2.0836399 1.1906589,-2.7871996 0.108241,' + - '-0.70357 -1.0824084,-1.51538 -1.4071389,-2.05658 -0.3247195,-0.54121 0.7035702,' + - '-0.92005 3.1931099,-1.94834 2.48954,-1.02829 10.39114,-3.30134994 10.49938,' + - '-3.03074994 0.10824,0.27061 -2.59779,1.40713994 -4.492,2.11069994 -1.89422,0.70357 ' + - '-4.97909,2.05658 -4.97909,2.43542 0,0.37885 0.16236,0.67651 0.0541,1.54244 -0.10824,' + - '0.86593 -0.12123,1.2702597 -0.32472,1.8400997 -0.1353,0.37884 -0.2706,1.27183 ' + - '0,2.0836295 0.21648,0.64945 0.92005,1.13653 1.24477,1.24478 0.2706,0.018 1.01746,' + - '0.0433 1.8401,0 1.02829,-0.0541 2.48954,0.0541 2.48954,0.32472 0,0.2706 -2.21894,' + - '0.10824 -2.21894,0.48708 0,0.37885 2.27306,-0.0541 2.21894,0.32473 -0.0541,0.37884 ' + - '-1.89422,0.21648 -2.86839,0.21648 -0.77933,0 -1.93031,-0.0361 -2.43542,-0.21648 ' + - 'l -0.10824,0.37884 c -0.18038,0 -0.55744,0.10824 -0.94711,0.10824 -0.48708,0 ' + - '-0.51414,0.16236 -1.40713,0.16236 -0.892989,0 -0.622391,-0.0541 -1.4341894,-0.10824 ' + - '-0.81181,-0.0541 -3.842561,2.27306 -4.383761,3.03075 -0.54121,0.75768 ' + - '-0.21649,2.59778 -0.21649,3.43665 0,0.75379 -0.10824,2.43542 0,3.30135 z'; - -const recordingSummaryHourSchema = z.object({ - hour: z.preprocess((arg) => Number(arg), z.number().min(0).max(23)), - duration: z.number().min(0), - events: z.number().min(0), -}); - -const recordingSummarySchema = z - .object({ - day: z.preprocess((arg) => { - // Must provide the hour:minute:second on parsing or Javascript will - // assume *UTC* midnight. - return typeof arg === 'string' ? new Date(`${arg}T00:00:00`) : arg; - }, z.date()), - events: z.number(), - hours: recordingSummaryHourSchema.array(), - }) - .array(); -export type RecordingSummary = z.infer; - -const recordingSegmentSchema = z.object({ - start_time: z.number(), - end_time: z.number(), - id: z.string(), -}); -export type RecordingSegment = z.infer; - -const recordingSegmentsSchema = recordingSegmentSchema.array(); -export type RecordingSegments = z.infer; - -const retainResultSchema = z.object({ - success: z.boolean(), - message: z.string(), -}); -export type RetainResult = z.infer; - -/** - * Get the recordings summary. May throw. - * @param hass The Home Assistant object. - * @param client_id The Frigate client_id. - * @param camera_name The Frigate camera name. - * @returns A RecordingSummary object. - */ -export const getRecordingsSummary = async ( - hass: HomeAssistant, - client_id: string, - camera_name: string, -): Promise => { - return await homeAssistantWSRequest( - hass, - recordingSummarySchema, - { - type: 'frigate/recordings/summary', - instance_id: client_id, - camera: camera_name, - }, - true, - ); -}; - -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 params The recording segment query parameters. - * @returns A RecordingSegments object. - */ -export const getRecordingSegments = async ( - hass: HomeAssistant, - params: NativeFrigateRecordingSegmentsQuery, -): Promise => { - return await homeAssistantWSRequest( - hass, - recordingSegmentsSchema, - { - type: 'frigate/recordings/get', - ...params, - }, - true, - ); -}; - -/** - * Request that Frigate retain an event. May throw. - * @param hass The HomeAssistant object. - * @param client_id The Frigate client_id. - * @param eventID The event ID to retain. - * @param retain `true` to retain or `false` to unretain. - */ -export async function retainEvent( - hass: HomeAssistant, - client_id: string, - eventID: string, - retain: boolean, -): Promise { - const retainRequest = { - type: 'frigate/event/retain', - instance_id: client_id, - event_id: eventID, - retain: retain, - }; - const response = await homeAssistantWSRequest( - hass, - retainResultSchema, - retainRequest, - true, - ); - if (!response.success) { - throw new FrigateCardError(localize('error.failed_retain'), { - request: retainRequest, - response: response, - }); - } -} - -export interface NativeFrigateEventQuery { - instance_id?: string; - camera?: string; - label?: string; - zone?: string; - after?: number; - before?: number; - limit?: number; - has_clip?: boolean; - has_snapshot?: boolean; -} - -/** - * Get events over websocket. May throw. - * @param hass The Home Assistant object. - * @param params The events search parameters. - * @returns An array of 'FrigateEvent's. - */ -export const getEvents = async ( - hass: HomeAssistant, - params?: NativeFrigateEventQuery, -): Promise => { - return await homeAssistantWSRequest( - hass, - frigateEventsSchema, - { - type: 'frigate/events/get', - ...params, - }, - true, - ); -}; - -/** - * Given an event generate a title. - * @param event - */ -export const getEventTitle = (event: FrigateEvent): string => { - const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - const durationSeconds = Math.round( - event.end_time - ? event.end_time - event.start_time - : Date.now() / 1000 - event.start_time, - ); - return `${formatDateAndTime( - utcToZonedTime(event.start_time * 1000, localTimezone), - )} [${durationSeconds}s, ${prettifyTitle(event.label)} ${Math.round( - event.top_score * 100, - )}%]`; -}; - -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. - * @param event The event. - * @returns A string URL. - */ -export const getEventThumbnailURL = (clientId: string, event: FrigateEvent): string => { - return `/api/frigate/${clientId}/thumbnail/${event.id}`; -}; - -/** - * Get a media content ID for an event. - * @param clientId The Frigate client id. - * @param cameraName The Frigate camera name. - * @param event The Frigate event. - * @param mediaType The media type required. - * @returns A string media content id. - */ -export const getEventMediaContentID = ( - clientId: string, - cameraName: string, - event: FrigateEvent, - mediaType: ClipsOrSnapshots, -): string => { - return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${event.id}`; -}; - -/** - * Generate a recording identifier. - * @param clientId The Frigate client id. - * @param cameraName The Frigate camera name. - * @param recording The Frigate recording. - * @returns A recording identifier. - */ -export const getRecordingMediaContentID = ( - clientId: string, - cameraName: string, - recording: FrigateRecording, -): string => { - const date = fromUnixTime(recording.start_time); - return [ - 'media-source://frigate', - clientId, - 'recordings', - cameraName, - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String( - String(date.getDate()).padStart(2, '0'), - )}`, - String(date.getHours()).padStart(2, '0'), - ].join('/'); -}; diff --git a/src/camera/frigate/icon.ts b/src/camera/frigate/icon.ts new file mode 100644 index 00000000..3e075f2e --- /dev/null +++ b/src/camera/frigate/icon.ts @@ -0,0 +1,20 @@ +export const FRIGATE_ICON_SVG_PATH = + 'm 4.8759466,22.743573 c 0.0866,0.69274 0.811811,1.16359 0.37885,1.27183 ' + + '-0.43297,0.10824 -2.32718,-3.43665 -2.7601492,-4.95202 -0.4329602,-1.51538 ' + + '-0.6764993,-3.22017 -0.5682593,-4.19434 0.1082301,-0.97417 5.7097085,-2.48955 ' + + '5.7097085,-2.89545 0,-0.4059 -1.81304,-0.0271 -1.89422,-0.35178 -0.0812,-0.32472 ' + + '1.36925,-0.12989 1.75892,-0.64945 0.60885,-0.81181 1.3800713,-0.6765 1.8671505,' + + '-1.1094696 0.4870902,-0.4329599 1.0824089,-2.0836399 1.1906589,-2.7871996 0.108241,' + + '-0.70357 -1.0824084,-1.51538 -1.4071389,-2.05658 -0.3247195,-0.54121 0.7035702,' + + '-0.92005 3.1931099,-1.94834 2.48954,-1.02829 10.39114,-3.30134994 10.49938,' + + '-3.03074994 0.10824,0.27061 -2.59779,1.40713994 -4.492,2.11069994 -1.89422,0.70357 ' + + '-4.97909,2.05658 -4.97909,2.43542 0,0.37885 0.16236,0.67651 0.0541,1.54244 -0.10824,' + + '0.86593 -0.12123,1.2702597 -0.32472,1.8400997 -0.1353,0.37884 -0.2706,1.27183 ' + + '0,2.0836295 0.21648,0.64945 0.92005,1.13653 1.24477,1.24478 0.2706,0.018 1.01746,' + + '0.0433 1.8401,0 1.02829,-0.0541 2.48954,0.0541 2.48954,0.32472 0,0.2706 -2.21894,' + + '0.10824 -2.21894,0.48708 0,0.37885 2.27306,-0.0541 2.21894,0.32473 -0.0541,0.37884 ' + + '-1.89422,0.21648 -2.86839,0.21648 -0.77933,0 -1.93031,-0.0361 -2.43542,-0.21648 ' + + 'l -0.10824,0.37884 c -0.18038,0 -0.55744,0.10824 -0.94711,0.10824 -0.48708,0 ' + + '-0.51414,0.16236 -1.40713,0.16236 -0.892989,0 -0.622391,-0.0541 -1.4341894,-0.10824 ' + + '-0.81181,-0.0541 -3.842561,2.27306 -4.383761,3.03075 -0.54121,0.75768 ' + + '-0.21649,2.59778 -0.21649,3.43665 0,0.75379 -0.10824,2.43542 0,3.30135 z'; diff --git a/src/camera/frigate/requests.ts b/src/camera/frigate/requests.ts new file mode 100644 index 00000000..6f119f3a --- /dev/null +++ b/src/camera/frigate/requests.ts @@ -0,0 +1,128 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { localize } from '../../localize/localize'; +import { + FrigateCardError, + FrigateEvents, + frigateEventsSchema, + RecordingSegment, +} from '../../types'; +import { homeAssistantWSRequest } from '../../utils/ha'; +import { recordingSegmentsSchema, RecordingSummary, recordingSummarySchema, RetainResult, retainResultSchema } from './schema'; + +/** + * Get the recordings summary. May throw. + * @param hass The Home Assistant object. + * @param client_id The Frigate client_id. + * @param camera_name The Frigate camera name. + * @returns A RecordingSummary object. + */ +export const getRecordingsSummary = async ( + hass: HomeAssistant, + client_id: string, + camera_name: string, +): Promise => { + return await homeAssistantWSRequest( + hass, + recordingSummarySchema, + { + type: 'frigate/recordings/summary', + instance_id: client_id, + camera: camera_name, + }, + true, + ); +}; + +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 params The recording segment query parameters. + * @returns A RecordingSegments object. + */ +export const getRecordingSegments = async ( + hass: HomeAssistant, + params: NativeFrigateRecordingSegmentsQuery, +): Promise => { + return await homeAssistantWSRequest( + hass, + recordingSegmentsSchema, + { + type: 'frigate/recordings/get', + ...params, + }, + true, + ); +}; + +/** + * Request that Frigate retain an event. May throw. + * @param hass The HomeAssistant object. + * @param client_id The Frigate client_id. + * @param eventID The event ID to retain. + * @param retain `true` to retain or `false` to unretain. + */ +export async function retainEvent( + hass: HomeAssistant, + client_id: string, + eventID: string, + retain: boolean, +): Promise { + const retainRequest = { + type: 'frigate/event/retain', + instance_id: client_id, + event_id: eventID, + retain: retain, + }; + const response = await homeAssistantWSRequest( + hass, + retainResultSchema, + retainRequest, + true, + ); + if (!response.success) { + throw new FrigateCardError(localize('error.failed_retain'), { + request: retainRequest, + response: response, + }); + } +} + +export interface NativeFrigateEventQuery { + instance_id?: string; + camera?: string; + label?: string; + zone?: string; + after?: number; + before?: number; + limit?: number; + has_clip?: boolean; + has_snapshot?: boolean; +} + +/** + * Get events over websocket. May throw. + * @param hass The Home Assistant object. + * @param params The events search parameters. + * @returns An array of 'FrigateEvent's. + */ +export const getEvents = async ( + hass: HomeAssistant, + params?: NativeFrigateEventQuery, +): Promise => { + return await homeAssistantWSRequest( + hass, + frigateEventsSchema, + { + type: 'frigate/events/get', + ...params, + }, + true, + ); +}; diff --git a/src/camera/frigate/schema.ts b/src/camera/frigate/schema.ts new file mode 100644 index 00000000..599de6b3 --- /dev/null +++ b/src/camera/frigate/schema.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +const recordingSummaryHourSchema = z.object({ + hour: z.preprocess((arg) => Number(arg), z.number().min(0).max(23)), + duration: z.number().min(0), + events: z.number().min(0), +}); + +export const recordingSummarySchema = z + .object({ + day: z.preprocess((arg) => { + // Must provide the hour:minute:second on parsing or Javascript will + // assume *UTC* midnight. + return typeof arg === 'string' ? new Date(`${arg}T00:00:00`) : arg; + }, z.date()), + events: z.number(), + hours: recordingSummaryHourSchema.array(), + }) + .array(); +export type RecordingSummary = z.infer; + +const recordingSegmentSchema = z.object({ + start_time: z.number(), + end_time: z.number(), + id: z.string(), +}); +export const recordingSegmentsSchema = recordingSegmentSchema.array(); + +export const retainResultSchema = z.object({ + success: z.boolean(), + message: z.string(), +}); +export type RetainResult = z.infer; diff --git a/src/camera/frigate/util.ts b/src/camera/frigate/util.ts new file mode 100644 index 00000000..01728b4e --- /dev/null +++ b/src/camera/frigate/util.ts @@ -0,0 +1,84 @@ +import utcToZonedTime from 'date-fns-tz/utcToZonedTime'; +import fromUnixTime from 'date-fns/fromUnixTime'; +import { + ClipsOrSnapshots, + FrigateEvent, + FrigateRecording, +} from '../../types'; +import { formatDateAndTime, prettifyTitle } from '../../utils/basic'; + +/** + * Given an event generate a title. + * @param event + */ +export const getEventTitle = (event: FrigateEvent): string => { + const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const durationSeconds = Math.round( + event.end_time + ? event.end_time - event.start_time + : Date.now() / 1000 - event.start_time, + ); + return `${formatDateAndTime( + utcToZonedTime(event.start_time * 1000, localTimezone), + )} [${durationSeconds}s, ${prettifyTitle(event.label)} ${Math.round( + event.top_score * 100, + )}%]`; +}; + +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. + * @param event The event. + * @returns A string URL. + */ +export const getEventThumbnailURL = (clientId: string, event: FrigateEvent): string => { + return `/api/frigate/${clientId}/thumbnail/${event.id}`; +}; + +/** + * Get a media content ID for an event. + * @param clientId The Frigate client id. + * @param cameraName The Frigate camera name. + * @param event The Frigate event. + * @param mediaType The media type required. + * @returns A string media content id. + */ +export const getEventMediaContentID = ( + clientId: string, + cameraName: string, + event: FrigateEvent, + mediaType: ClipsOrSnapshots, +): string => { + return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${event.id}`; +}; + +/** + * Generate a recording identifier. + * @param clientId The Frigate client id. + * @param cameraName The Frigate camera name. + * @param recording The Frigate recording. + * @returns A recording identifier. + */ +export const getRecordingMediaContentID = ( + clientId: string, + cameraName: string, + recording: FrigateRecording, +): string => { + const date = fromUnixTime(recording.start_time); + return [ + 'media-source://frigate', + clientId, + 'recordings', + cameraName, + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String( + String(date.getDate()).padStart(2, '0'), + )}`, + String(date.getHours()).padStart(2, '0'), + ].join('/'); +}; diff --git a/src/camera/types.ts b/src/camera/types.ts index 846bd652..0cedc17b 100644 --- a/src/camera/types.ts +++ b/src/camera/types.ts @@ -1,5 +1,4 @@ -import { FrigateEvents, FrigateRecording } from '../types'; -import { RecordingSegments } from './frigate/frigate'; +import { FrigateEvents, FrigateRecording, RecordingSegment } from '../types'; // ==== // Base @@ -83,7 +82,6 @@ export interface EventQuery extends MediaQuery { } export type PartialEventQuery = Partial; -// eslint-disable-next-line @typescript-eslint/no-empty-interface export interface EventQueryResults extends QueryResults { type: QueryResultsType.Event; } @@ -92,13 +90,11 @@ export interface EventQueryResults extends QueryResults { // 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; } @@ -111,11 +107,10 @@ 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; + segments: RecordingSegment[]; } // ======================== diff --git a/src/components/menu.ts b/src/components/menu.ts index 789deec8..758b1a93 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -27,7 +27,7 @@ import { frigateCardHasAction, getActionConfigGivenAction } from '../utils/action.js'; -import { FRIGATE_ICON_SVG_PATH } from '../camera/frigate/frigate.js'; +import { FRIGATE_ICON_SVG_PATH } from '../camera/frigate/icon.js'; import { refreshDynamicStateParameters } from '../utils/ha'; import './submenu.js'; diff --git a/src/editor.ts b/src/editor.ts index 0077cf25..d4389cd0 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -141,7 +141,7 @@ import { } from './types.js'; import { arrayMove } from './utils/basic.js'; import { getCameraID, getCameraTitle } from './utils/camera.js'; -import { FRIGATE_ICON_SVG_PATH } from './camera/frigate/frigate.js'; +import { FRIGATE_ICON_SVG_PATH } from './camera/frigate/icon.js'; import { getEntitiesFromHASS, sideLoadHomeAssistantElements } from './utils/ha'; import { setLowPerformanceProfile } from './performance.js'; diff --git a/src/types.ts b/src/types.ts index a6647bca..67875087 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1498,3 +1498,10 @@ export type ExtendedEntity = z.infer; export const entityListSchema = entitySchema.array(); export type EntityList = z.infer; + +// Generic recording segment type (inspired by Frigate recording segments). +export interface RecordingSegment { + start_time: number; + end_time: number; + id: string; +} diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 9f301698..0e8633b4 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -3,9 +3,8 @@ import fromUnixTime from 'date-fns/fromUnixTime'; import startOfHour from 'date-fns/startOfHour'; import sub from 'date-fns/sub'; import { ViewContext } from 'view'; -import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types'; +import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView, RecordingSegment } from '../types'; import { EventMediaQueries, RecordingMediaQueries, View } from '../view'; -import { RecordingSegments } from '../camera/frigate/frigate'; import { CameraManager } from '../camera/manager'; import { getAllDependentCameras } from './camera.js'; import { ViewMedia, ViewMediaClassifier } from '../view-media'; @@ -279,7 +278,7 @@ export const findClosestMediaIndex = ( const getSeekTimeInSegments = ( startTime: Date, targetTime: Date, - segments: RecordingSegments, + segments: RecordingSegment[], ): number | null => { if (!segments.length) { return null; diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index 18e401f2..3e3ee7b1 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -3,10 +3,9 @@ import add from 'date-fns/add'; 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 { CameraConfig, ClipsOrSnapshotsOrAll, RecordingSegment } from '../types'; import { CameraManager } from '../camera/manager'; import { EventQuery } from '../camera/types'; -import { RecordingSegment, RecordingSegments } from '../camera/frigate/frigate'; import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera/util'; import { EventMediaQueries } from '../view'; import { ViewMedia } from '../view-media'; @@ -228,9 +227,9 @@ export class TimelineDataSource { const results = await this._cameraManager.getRecordingSegments(hass, queries); - const newSegments: Map = new Map(); + const newSegments: Map = new Map(); for (const [query, result] of results) { - let destination: RecordingSegments | undefined = newSegments.get(query.cameraID); + let destination: RecordingSegment[] | undefined = newSegments.get(query.cameraID); if (!destination) { destination = []; newSegments.set(query.cameraID, destination); diff --git a/src/view-media.ts b/src/view-media.ts index 84448d66..b63067bb 100644 --- a/src/view-media.ts +++ b/src/view-media.ts @@ -14,7 +14,7 @@ import { getEventTitle, getRecordingMediaContentID, getRecordingTitle, -} from './camera/frigate/frigate.js'; +} from './camera/frigate/util.js'; export type ViewMediaType = 'clip' | 'snapshot' | 'recording'; export type ViewMediaSourceType = FrigateEvent | FrigateRecording | BrowseMediaSource; From 5385d4d5fd61b80e96c3f313eafd00cfcb15eb00 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 2 Jan 2023 22:30:21 -0800 Subject: [PATCH 09/74] Move some Frigate specific interfaces under the Frigate engine. --- src/camera/frigate/engine-frigate.ts | 4 ++-- src/camera/frigate/requests.ts | 6 ++--- src/camera/frigate/{schema.ts => types.ts} | 25 +++++++++++++++++++ src/camera/frigate/util.ts | 7 ++---- src/camera/types.ts | 5 ++-- src/types.ts | 28 ++-------------------- src/utils/ha/browse-media.ts | 3 +-- src/view-media.ts | 3 +-- src/view.ts | 1 + 9 files changed, 39 insertions(+), 43 deletions(-) rename src/camera/frigate/{schema.ts => types.ts} (61%) diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index d7a955fb..327f9139 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -4,7 +4,7 @@ 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, RecordingSegment } from '../../types'; +import { CameraConfig, RecordingSegment } from '../../types'; import { MediaQueries, MediaQueriesResults } from '../../view'; import { ViewMedia, ViewMediaClassifier, ViewMediaFactory } from '../../view-media'; import { errorToConsole } from '../../utils/basic'; @@ -31,7 +31,7 @@ import { RecordingQuery, RecordingSegmentsQuery, } from '../types'; -import { RecordingSummary } from './schema'; +import { FrigateRecording, RecordingSummary } from './types'; import { getEvents, getRecordingSegments, getRecordingsSummary, NativeFrigateEventQuery, NativeFrigateRecordingSegmentsQuery, retainEvent } from './requests'; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; diff --git a/src/camera/frigate/requests.ts b/src/camera/frigate/requests.ts index 6f119f3a..0675e364 100644 --- a/src/camera/frigate/requests.ts +++ b/src/camera/frigate/requests.ts @@ -2,12 +2,10 @@ import { HomeAssistant } from 'custom-card-helpers'; import { localize } from '../../localize/localize'; import { FrigateCardError, - FrigateEvents, - frigateEventsSchema, RecordingSegment, } from '../../types'; import { homeAssistantWSRequest } from '../../utils/ha'; -import { recordingSegmentsSchema, RecordingSummary, recordingSummarySchema, RetainResult, retainResultSchema } from './schema'; +import { FrigateEvent, frigateEventsSchema, recordingSegmentsSchema, RecordingSummary, recordingSummarySchema, RetainResult, retainResultSchema } from './types'; /** * Get the recordings summary. May throw. @@ -115,7 +113,7 @@ export interface NativeFrigateEventQuery { export const getEvents = async ( hass: HomeAssistant, params?: NativeFrigateEventQuery, -): Promise => { +): Promise => { return await homeAssistantWSRequest( hass, frigateEventsSchema, diff --git a/src/camera/frigate/schema.ts b/src/camera/frigate/types.ts similarity index 61% rename from src/camera/frigate/schema.ts rename to src/camera/frigate/types.ts index 599de6b3..26608e77 100644 --- a/src/camera/frigate/schema.ts +++ b/src/camera/frigate/types.ts @@ -1,5 +1,22 @@ import { z } from 'zod'; +export const eventSchema = z.object({ + camera: z.string(), + end_time: z.number().nullable(), + false_positive: z.boolean().nullable(), + has_clip: z.boolean(), + has_snapshot: z.boolean(), + id: z.string(), + label: z.string(), + start_time: z.number(), + top_score: z.number(), + zones: z.string().array(), + retain_indefinitely: z.boolean().optional(), +}); +export const frigateEventsSchema = eventSchema.array(); + +export type FrigateEvent = z.infer; + const recordingSummaryHourSchema = z.object({ hour: z.preprocess((arg) => Number(arg), z.number().min(0).max(23)), duration: z.number().min(0), @@ -31,3 +48,11 @@ export const retainResultSchema = z.object({ message: z.string(), }); export type RetainResult = z.infer; + +export interface FrigateRecording { + // Frigate camera name (may not be unique) + camera: string; + start_time: number; + end_time: number; + events: number; +} \ No newline at end of file diff --git a/src/camera/frigate/util.ts b/src/camera/frigate/util.ts index 01728b4e..42a8becd 100644 --- a/src/camera/frigate/util.ts +++ b/src/camera/frigate/util.ts @@ -1,11 +1,8 @@ import utcToZonedTime from 'date-fns-tz/utcToZonedTime'; import fromUnixTime from 'date-fns/fromUnixTime'; -import { - ClipsOrSnapshots, - FrigateEvent, - FrigateRecording, -} from '../../types'; +import { ClipsOrSnapshots } from '../../types'; import { formatDateAndTime, prettifyTitle } from '../../utils/basic'; +import { FrigateEvent, FrigateRecording } from './types'; /** * Given an event generate a title. diff --git a/src/camera/types.ts b/src/camera/types.ts index 0cedc17b..159f02db 100644 --- a/src/camera/types.ts +++ b/src/camera/types.ts @@ -1,4 +1,5 @@ -import { FrigateEvents, FrigateRecording, RecordingSegment } from '../types'; +import { RecordingSegment } from '../types'; +import { FrigateEvent, FrigateRecording } from './frigate/types'; // ==== // Base @@ -119,7 +120,7 @@ export interface RecordingSegmentsQueryResults extends QueryResults { export interface FrigateEventQueryResults extends EventQueryResults { engine: Engine.Frigate; - events: FrigateEvents; + events: FrigateEvent[]; } export interface FrigateRecordingQueryResults extends RecordingQueryResults { diff --git a/src/types.ts b/src/types.ts index 67875087..44752342 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,7 @@ import { } from 'custom-card-helpers'; import { StyleInfo } from 'lit/directives/style-map.js'; import { z } from 'zod'; +import { eventSchema, FrigateEvent, FrigateRecording } from './camera/frigate/types.js'; import { deepRemoveDefaults } from './utils/zod.js'; // The min allowed size of buttons. @@ -1415,14 +1416,6 @@ export interface BrowseMediaSource { children?: BrowseMediaSource[] | null; } -export interface FrigateRecording { - // Frigate camera name (may not be unique) - camera: string; - start_time: number; - end_time: number; - events: number; -} - export interface FrigateBrowseMediaSource extends BrowseMediaSource { children?: FrigateBrowseMediaSource[] | null; frigate?: { @@ -1432,23 +1425,6 @@ export interface FrigateBrowseMediaSource extends BrowseMediaSource { }; } -export const frigateEventSchema = z.object({ - camera: z.string(), - end_time: z.number().nullable(), - false_positive: z.boolean().nullable(), - has_clip: z.boolean(), - has_snapshot: z.boolean(), - id: z.string(), - label: z.string(), - start_time: z.number(), - top_score: z.number(), - zones: z.string().array(), - retain_indefinitely: z.boolean().optional(), -}); -export type FrigateEvent = z.infer; - -export const frigateEventsSchema = frigateEventSchema.array(); -export type FrigateEvents = z.infer; export const frigateBrowseMediaSourceSchema: z.ZodSchema = z.lazy( () => @@ -1464,7 +1440,7 @@ export const frigateBrowseMediaSourceSchema: z.ZodSchema = z. children: z.array(frigateBrowseMediaSourceSchema).nullable().optional(), frigate: z .object({ - event: frigateEventSchema, + event: eventSchema, }) .optional(), }), diff --git a/src/utils/ha/browse-media.ts b/src/utils/ha/browse-media.ts index 2bece47c..e824e353 100644 --- a/src/utils/ha/browse-media.ts +++ b/src/utils/ha/browse-media.ts @@ -1,5 +1,6 @@ import { HomeAssistant } from 'custom-card-helpers'; import { homeAssistantWSRequest } from '.'; +import { FrigateEvent, FrigateRecording } from '../../camera/frigate/types'; import { dispatchErrorMessageEvent } from '../../components/message.js'; import { localize } from '../../localize/localize.js'; import { @@ -9,8 +10,6 @@ import { ClipsOrSnapshots, FrigateBrowseMediaSource, frigateBrowseMediaSourceSchema, - FrigateEvent, - FrigateRecording, MEDIA_CLASS_PLAYLIST, MEDIA_CLASS_VIDEO, MEDIA_TYPE_PLAYLIST, diff --git a/src/view-media.ts b/src/view-media.ts index b63067bb..67952a7d 100644 --- a/src/view-media.ts +++ b/src/view-media.ts @@ -3,8 +3,6 @@ import isEqual from 'lodash-es/isEqual'; import { BrowseMediaSource, CameraConfig, - FrigateEvent, - FrigateRecording, MEDIA_TYPE_IMAGE, } from './types.js'; import { ModifyInterface } from './utils/basic.js'; @@ -15,6 +13,7 @@ import { getRecordingMediaContentID, getRecordingTitle, } from './camera/frigate/util.js'; +import { FrigateEvent, FrigateRecording } from './camera/frigate/types.js'; export type ViewMediaType = 'clip' | 'snapshot' | 'recording'; export type ViewMediaSourceType = FrigateEvent | FrigateRecording | BrowseMediaSource; diff --git a/src/view.ts b/src/view.ts index 13aac7f8..97779e4a 100644 --- a/src/view.ts +++ b/src/view.ts @@ -20,6 +20,7 @@ // Hard: // - TODO: Implement dragging the timeline seeking forward in both Frigate recordings & events. // - TODO: Implement gallery. +// - TODO: Remove FrigateBrowseMediaSource if not necessary (post-gallery). // - 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: 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? From 962311f241df81ceb28db3bee708d5dca939ceb0 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 2 Jan 2023 22:48:45 -0800 Subject: [PATCH 10/74] Change handler methods to async for cleanliness. --- src/components/timeline-core.ts | 119 ++++++++++++++------------------ src/view.ts | 15 ++-- 2 files changed, 58 insertions(+), 76 deletions(-) diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 28be5db1..aa4f1692 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -435,7 +435,7 @@ export class FrigateCardTimelineCore extends LitElement { * Called whenever the timeline is clicked. * @param properties The properties of the timeline click event. */ - protected _timelineClickHandler(properties: TimelineEventPropertiesResult): void { + protected async _timelineClickHandler(properties: TimelineEventPropertiesResult): Promise { // Calls to stopEventFromActivatingCardWideActions() are included for // completeness. Timeline does not support card-wide events and they are // disabled in card.ts in `_getMergedActions`. @@ -459,13 +459,13 @@ export class FrigateCardTimelineCore extends LitElement { return; } - let viewPromise: Promise | null = null; + let view: View | null = null; if ( this.timelineConfig?.show_recordings && ['background', 'group-label'].includes(properties.what) ) { - viewPromise = createViewForRecordings( + view = await createViewForRecordings( this.hass, this.cameraManager, this.cameras, @@ -481,7 +481,7 @@ export class FrigateCardTimelineCore extends LitElement { }, ); } else if (this.timelineConfig?.show_recordings && properties.what === 'axis') { - viewPromise = createViewForRecordings( + view = await createViewForRecordings( this.hass, this.cameraManager, this.cameras, @@ -498,24 +498,18 @@ export class FrigateCardTimelineCore extends LitElement { properties.what === 'item' && this.view.query?.areRecordingQueries() ) { - viewPromise = (async (): Promise => { - if (!properties.item || !this.cameraManager || !this.hass) { - return null; - } - const view = await this._createViewWithEventMediaQuery( - this._createEventMediaQuerys(), - { - selectedItem: properties.item, - targetView: 'media', - }, - ); - const results = view?.queryResults?.getResults(); - // Specifically ensure there are _some_ results before dispatching the - // view change. - if (!view || !results || !results.length) { - return null; - } - view.mergeInContext( + const eventView = await this._createViewWithEventMediaQuery( + this._createEventMediaQuerys(), + { + selectedItem: properties.item, + targetView: 'media', + }, + ); + const results = eventView?.queryResults?.getResults(); + // Specifically ensure there are _some_ results before dispatching the + // view change. + if (eventView && results && results.length) { + eventView.mergeInContext( await generateMediaViewerContext( this.hass, this.cameraManager, @@ -523,18 +517,15 @@ export class FrigateCardTimelineCore extends LitElement { properties.time, ), ); - return view; - })(); + view = eventView; + } } else if ( properties.item && properties.what === 'item' && - this.view.queryResults?.hasResults() + this.view.queryResults?.hasResults() && + this.view.query ) { - viewPromise = (async (): Promise => { - if (!this.view?.query) { - return null; - } - return this.view.evolve({ + view = this.view.evolve({ queryResults: this.view.queryResults ?.clone() .resetSelectedResult() @@ -544,24 +535,18 @@ export class FrigateCardTimelineCore extends LitElement { media.getID(this.cameras.get(media.getCameraID())) === properties.item, ), }); - })(); } - 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?.is('timeline')) { - dispatchFrigateCardEvent(this, 'thumbnails:open'); - } - this._ignoreClick = false; - return; - } - }); + 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?.is('timeline')) { + dispatchFrigateCardEvent(this, 'thumbnails:open'); + } } else if (this.view?.is('timeline')) { dispatchFrigateCardEvent(this, 'thumbnails:close'); } @@ -586,39 +571,37 @@ export class FrigateCardTimelineCore extends LitElement { * Handle a range change in the timeline. * @param properties vis.js provided range information. */ - protected _timelineRangeChangedHandler(properties: { + protected async _timelineRangeChangedHandler(properties: { start: Date; end: Date; byUser: boolean; event: Event & { additionalEvent: string }; - }): void { + }): Promise { if (!properties.byUser) { return; } this._removeTargetBar(); - (async (): Promise => { - if (!this.hass || !this.cameras) { - return; - } + if (!this.hass || !this.cameras) { + return; + } - const prefetchedWindow = this._getPrefetchWindow(properties); - await this._timelineSource?.refresh(this.hass, this.cameras, prefetchedWindow); + 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.query?.areRecordingQueries()) { - ( - await this._createViewWithEventMediaQuery( - this._createEventMediaQuerys({ window: prefetchedWindow }), - { - noSetWindow: true, - }, - ) - )?.dispatchChangeEvent(this); - } - })(); + // 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.query?.areRecordingQueries()) { + ( + await this._createViewWithEventMediaQuery( + this._createEventMediaQuerys({ window: prefetchedWindow }), + { + noSetWindow: true, + }, + ) + )?.dispatchChangeEvent(this); + } } protected _createEventMediaQuerys(options?: { diff --git a/src/view.ts b/src/view.ts index 97779e4a..e930692d 100644 --- a/src/view.ts +++ b/src/view.ts @@ -1,5 +1,4 @@ // Easy: -// - TODO: Search for references to frigate.js and see where it's being called outside of the cameraManager. Can I collapse some of those functions in? // - TODO: Refactor thumbnailsControlSchema to all use the shortform for other thumbnail users beyond live. // - TODO: limit param in recordings should do something // - TODO: Should be able to set live media to 'all' and have it work. @@ -7,15 +6,15 @@ // - 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: In the viewer @click handlers should I use this.selected instead of calling carouselScrollPrevious() -// - 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? // Medium: -// - TODO: Callers of all async methods of data-engine need to catch errors. -// - 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: 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: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript +// - TODO: Callers of all async methods of data-engine need to catch errors. +// - 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: 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: Move view/ stuff into a view directory. +// - TODO: Move frigate specific view-media under the camera manager. +// - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript // Hard: // - TODO: Implement dragging the timeline seeking forward in both Frigate recordings & events. From 9cdf4e8e4d5312d3d9a39e8e057d7f48e7380283 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 3 Jan 2023 19:55:36 -0800 Subject: [PATCH 11/74] Use a MediaQueriesClassifier for consistency. --- src/camera/frigate/engine-frigate.ts | 6 ++--- src/components/timeline-core.ts | 36 ++++++++++++++++------------ src/components/viewer.ts | 4 ++-- src/view.ts | 29 +++++++++++++--------- 4 files changed, 44 insertions(+), 31 deletions(-) diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 327f9139..9d9bf05d 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -5,7 +5,7 @@ import getUnixTime from 'date-fns/getUnixTime'; import startOfHour from 'date-fns/startOfHour'; import { CAMERA_BIRDSEYE } from '../../const'; import { CameraConfig, RecordingSegment } from '../../types'; -import { MediaQueries, MediaQueriesResults } from '../../view'; +import { MediaQueries, MediaQueriesClassifier, MediaQueriesResults } from '../../view'; import { ViewMedia, ViewMediaClassifier, ViewMediaFactory } from '../../view-media'; import { errorToConsole } from '../../utils/basic'; import { RecordingSegmentsCache } from '../cache'; @@ -362,9 +362,9 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { results: MediaQueriesResults, ): boolean { let freshThreshold: number | null = null; - if (queries.areEventQueries()) { + if (MediaQueriesClassifier.areEventQueries(queries)) { freshThreshold = EVENT_REQUEST_CACHE_MAX_AGE_SECONDS; - } else if (queries.areRecordingQueries()) { + } else if (MediaQueriesClassifier.areRecordingQueries(queries)) { freshThreshold = RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS; } const now = new Date(); diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index aa4f1692..edd6f04f 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -50,7 +50,7 @@ import { generateMediaViewerContext, } from '../utils/media-to-view'; import { CameraManager } from '../camera/manager'; -import { EventMediaQueries, MediaQueries, View } from '../view'; +import { EventMediaQueries, MediaQueries, MediaQueriesClassifier, View } from '../view'; import { dispatchMessageEvent } from './message.js'; import './thumbnail.js'; import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source'; @@ -435,7 +435,9 @@ export class FrigateCardTimelineCore extends LitElement { * Called whenever the timeline is clicked. * @param properties The properties of the timeline click event. */ - protected async _timelineClickHandler(properties: TimelineEventPropertiesResult): Promise { + protected async _timelineClickHandler( + properties: TimelineEventPropertiesResult, + ): Promise { // Calls to stopEventFromActivatingCardWideActions() are included for // completeness. Timeline does not support card-wide events and they are // disabled in card.ts in `_getMergedActions`. @@ -496,7 +498,7 @@ export class FrigateCardTimelineCore extends LitElement { } else if ( properties.item && properties.what === 'item' && - this.view.query?.areRecordingQueries() + MediaQueriesClassifier.areRecordingQueries(this.view.query) ) { const eventView = await this._createViewWithEventMediaQuery( this._createEventMediaQuerys(), @@ -525,16 +527,16 @@ export class FrigateCardTimelineCore extends LitElement { this.view.queryResults?.hasResults() && this.view.query ) { - view = this.view.evolve({ - queryResults: this.view.queryResults - ?.clone() - .resetSelectedResult() - .selectResultIfFound( - (media) => - !!this.cameras && - media.getID(this.cameras.get(media.getCameraID())) === properties.item, - ), - }); + view = this.view.evolve({ + queryResults: this.view.queryResults + ?.clone() + .resetSelectedResult() + .selectResultIfFound( + (media) => + !!this.cameras && + media.getID(this.cameras.get(media.getCameraID())) === properties.item, + ), + }); } if (view) { @@ -592,7 +594,11 @@ export class FrigateCardTimelineCore extends LitElement { // 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.query?.areRecordingQueries()) { + if ( + this._timeline && + this.view && + !MediaQueriesClassifier.areRecordingQueries(this.view.query) + ) { ( await this._createViewWithEventMediaQuery( this._createEventMediaQuerys({ window: prefetchedWindow }), @@ -954,7 +960,7 @@ export class FrigateCardTimelineCore extends LitElement { if ( !this.mini && - !this.view.query?.areRecordingQueries() && + !MediaQueriesClassifier.areRecordingQueries(this.view.query) && freshMediaQuery && !this._alreadyHasAcceptableMediaQuery(freshMediaQuery) ) { diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 503fcd84..98243ec2 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -30,7 +30,7 @@ import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { contentsChanged } from '../utils/basic.js'; import { getFullDependentBrowseMediaQueryParametersOrDispatchError } from '../utils/ha/browse-media.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; -import { MediaQueriesResults, View } from '../view.js'; +import { MediaQueriesClassifier, MediaQueriesResults, View } from '../view.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { Lazyload } from './embla-plugins/lazyload.js'; import { @@ -372,7 +372,7 @@ export class FrigateCardViewerCarousel extends LitElement { // the other media items do). !ViewMediaClassifier.isFrigateEvent(media) || !media.hasClip() || - !this.view.query?.areEventQueries() + !MediaQueriesClassifier.areEventQueries(this.view.query) ) { return null; } diff --git a/src/view.ts b/src/view.ts index e930692d..3cc0c2fe 100644 --- a/src/view.ts +++ b/src/view.ts @@ -4,7 +4,6 @@ // - TODO: Should be able to set live media to 'all' and have it work. // - TODO: Are there elements of ViewMedia (e.g. getEventCount) that should be moved into subclasses (e.g. a recording subclass). // - 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: In the viewer @click handlers should I use this.selected instead of calling carouselScrollPrevious() // Medium: @@ -24,7 +23,6 @@ // - 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? - import isEqual from 'lodash-es/isEqual'; import clone from 'lodash-es/clone.js'; import cloneDeep from 'lodash-es/cloneDeep.js'; @@ -52,6 +50,20 @@ export interface ViewParameters extends ViewEvolveParameters { camera: string; } +export class MediaQueriesClassifier { + public static areEventQueries( + queries?: MediaQueries | null, + ): queries is EventMediaQueries { + return queries instanceof EventMediaQueries; + } + + public static areRecordingQueries( + queries?: MediaQueries | null, + ): queries is RecordingMediaQueries { + return queries instanceof RecordingMediaQueries; + } +} + export class MediaQueriesBase { protected _queries: T[] | null = null; @@ -69,14 +81,6 @@ export class MediaQueriesBase { 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; } @@ -177,7 +181,10 @@ export class MediaQueriesResults { } public selectResult(index: number | null): MediaQueriesResults { - if (index === null || (this._results && index >= 0 && index < this._results.length)) { + if ( + index === null || + (this._results && index >= 0 && index < this._results.length) + ) { this._selectedIndex = index; } return this; From 980ba1fc6f9806ddbca02934361300c54e83e04b Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 3 Jan 2023 20:42:31 -0800 Subject: [PATCH 12/74] Break apart view.ts into multiple files. --- src/camera/engine.ts | 5 +- src/camera/frigate/engine-frigate.ts | 7 +- src/camera/manager.ts | 5 +- src/card.ts | 2 +- src/components/gallery.ts | 2 +- src/components/image.ts | 2 +- src/components/live/live.ts | 2 +- src/components/surround.ts | 2 +- src/components/thumbnail-carousel.ts | 3 +- src/components/thumbnail.ts | 4 +- src/components/timeline-core.ts | 7 +- src/components/timeline.ts | 2 +- src/components/viewer.ts | 7 +- src/utils/media-to-view.ts | 6 +- src/utils/timeline-source.ts | 4 +- src/view/media-classifier.ts | 53 +++++++++ src/view/media-queries-classifier.ts | 15 +++ src/view/media-queries-results.ts | 91 ++++++++++++++ src/view/media-queries.ts | 61 ++++++++++ src/{view-media.ts => view/media.ts} | 62 +--------- src/{ => view}/view.ts | 172 +-------------------------- 21 files changed, 264 insertions(+), 250 deletions(-) create mode 100644 src/view/media-classifier.ts create mode 100644 src/view/media-queries-classifier.ts create mode 100644 src/view/media-queries-results.ts create mode 100644 src/view/media-queries.ts rename src/{view-media.ts => view/media.ts} (84%) rename src/{ => view}/view.ts (63%) diff --git a/src/camera/engine.ts b/src/camera/engine.ts index f1c27aae..a4f9c554 100644 --- a/src/camera/engine.ts +++ b/src/camera/engine.ts @@ -1,7 +1,7 @@ import { HomeAssistant } from 'custom-card-helpers'; import { CameraConfig } from '../types'; -import { MediaQueries, MediaQueriesResults } from '../view'; -import { ViewMedia } from '../view-media'; +import { MediaQueriesResults } from "../view/media-queries-results"; +import { ViewMedia } from '../view/media'; import { EventQuery, PartialEventQuery, @@ -11,6 +11,7 @@ import { RecordingQuery, RecordingSegmentsQuery, } from './types'; +import { MediaQueries } from '../view/media-queries'; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 9d9bf05d..21823a80 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -5,8 +5,10 @@ import getUnixTime from 'date-fns/getUnixTime'; import startOfHour from 'date-fns/startOfHour'; import { CAMERA_BIRDSEYE } from '../../const'; import { CameraConfig, RecordingSegment } from '../../types'; -import { MediaQueries, MediaQueriesClassifier, MediaQueriesResults } from '../../view'; -import { ViewMedia, ViewMediaClassifier, ViewMediaFactory } from '../../view-media'; +import { MediaQueriesResults } from "../../view/media-queries-results"; +import { MediaQueriesClassifier } from "../../view/media-queries-classifier"; +import { ViewMedia, ViewMediaFactory } from '../../view/media'; +import { ViewMediaClassifier } from "../../view/media-classifier"; import { errorToConsole } from '../../utils/basic'; import { RecordingSegmentsCache } from '../cache'; import { @@ -33,6 +35,7 @@ import { } from '../types'; import { FrigateRecording, RecordingSummary } from './types'; import { getEvents, getRecordingSegments, getRecordingsSummary, NativeFrigateEventQuery, NativeFrigateRecordingSegmentsQuery, retainEvent } from './requests'; +import { MediaQueries } from '../../view/media-queries'; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; diff --git a/src/camera/manager.ts b/src/camera/manager.ts index 7c79c31b..c433dba0 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -21,9 +21,10 @@ import { } from './types.js'; import orderBy from 'lodash-es/orderBy'; import { CameraManagerEngineFactory } from './engine-factory.js'; -import { ViewMedia } from '../view-media.js'; -import { MediaQueries, MediaQueriesResults } from '../view.js'; +import { ViewMedia } from '../view/media.js'; +import { MediaQueriesResults } from '../view/media-queries-results'; import { MemoryRequestCache } from './cache.js'; +import { MediaQueries } from '../view/media-queries.js'; export class QueryClassifier { public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery { diff --git a/src/card.ts b/src/card.ts index 4ead268e..049d4ffe 100644 --- a/src/card.ts +++ b/src/card.ts @@ -88,7 +88,7 @@ import { import { ResolvedMediaCache } from './utils/ha/resolved-media.js'; import { supportsFeature } from './utils/ha/update.js'; import { isValidMediaLoadedInfo } from './utils/media-info.js'; -import { View } from './view.js'; +import { View } from './view/view.js'; import pkg from '../package.json'; import { ViewContext } from 'view'; import { CameraManager } from './camera/manager.js'; diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 46658f46..3ada96bd 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -23,7 +23,7 @@ import { } from '../utils/ha/browse-media'; import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js'; import { CameraManager } from '../camera/manager.js'; -import { View } from '../view.js'; +import { View } from '../view/view.js'; import { renderProgressIndicator } from './message.js'; import './thumbnail.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; diff --git a/src/components/image.ts b/src/components/image.ts index 2975f33e..7f9144c9 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -22,7 +22,7 @@ import { createMediaLoadedInfo, dispatchExistingMediaLoadedInfoAsEvent, } from '../utils/media-info.js'; -import { View } from '../view.js'; +import { View } from '../view/view.js'; import { dispatchErrorMessageEvent } from './message.js'; import { contentsChanged } from '../utils/basic.js'; import isEqual from 'lodash-es/isEqual'; diff --git a/src/components/live/live.ts b/src/components/live/live.ts index d1c609f7..a121bdcc 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -37,7 +37,7 @@ import { dispatchExistingMediaLoadedInfoAsEvent, dispatchMediaUnloadedEvent, } from '../../utils/media-info.js'; -import { dispatchViewContextChangeEvent, View } from '../../view.js'; +import { dispatchViewContextChangeEvent, View } from '../../view/view.js'; import { AutoMediaPlugin } from './../embla-plugins/automedia.js'; import { Lazyload } from './../embla-plugins/lazyload.js'; import { diff --git a/src/components/surround.ts b/src/components/surround.ts index 460dbabe..2b271514 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -17,7 +17,7 @@ import { } from '../types.js'; import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; import { CameraManager } from '../camera/manager.js'; -import { View } from '../view.js'; +import { View } from '../view/view.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; import './surround-basic.js'; import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view'; diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index d4e3becd..d061165e 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -19,7 +19,8 @@ import { } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js'; -import { MediaQueriesResults, View } from '../view.js'; +import { View } from '../view/view.js'; +import { MediaQueriesResults } from "../view/media-queries-results"; import { FrigateCardCarousel } from './carousel.js'; import './thumbnail.js'; import './carousel.js'; diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 25786eaa..eed00459 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -13,12 +13,12 @@ import { getDurationString, prettifyTitle } from '../utils/basic.js'; import { getCameraTitle } from '../utils/camera.js'; import { renderTask } from '../utils/task.js'; import { createFetchThumbnailTask } from '../utils/thumbnail.js'; -import { View } from '../view.js'; +import { View } from '../view/view.js'; import type { MediaSeek } from './viewer.js'; import { TaskStatus } from '@lit-labs/task'; import type { CameraConfig, ExtendedHomeAssistant } from '../types.js'; -import { ViewMedia } from '../view-media.js'; +import { ViewMedia } from '../view/media.js'; import { CameraManager } from '../camera/manager.js'; // The minimum width of a thumbnail with details enabled. diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index edd6f04f..dce25cd6 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -50,12 +50,15 @@ import { generateMediaViewerContext, } from '../utils/media-to-view'; import { CameraManager } from '../camera/manager'; -import { EventMediaQueries, MediaQueries, MediaQueriesClassifier, View } from '../view'; +import { EventMediaQueries, MediaQueries } from "../view/media-queries"; +import { MediaQueriesClassifier } from "../view/media-queries-classifier"; import { dispatchMessageEvent } from './message.js'; import './thumbnail.js'; import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source'; -import { ViewMedia, ViewMediaClassifier } from '../view-media'; +import { ViewMedia } from '../view/media'; +import { ViewMediaClassifier } from "../view/media-classifier"; import { rangesOverlap } from '../camera/range'; +import { View } from '../view/view'; interface FrigateCardGroupData { id: string; diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 064a70fe..581167fa 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -3,7 +3,7 @@ import { customElement, property } from 'lit/decorators.js'; import timelineStyle from '../scss/timeline.scss'; import { CameraConfig, ExtendedHomeAssistant, TimelineConfig } from '../types'; import { CameraManager } from '../camera/manager'; -import { View } from '../view'; +import { View } from '../view/view'; import './surround.js'; import './timeline-core.js'; diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 98243ec2..56224076 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -30,7 +30,9 @@ import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { contentsChanged } from '../utils/basic.js'; import { getFullDependentBrowseMediaQueryParametersOrDispatchError } from '../utils/ha/browse-media.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; -import { MediaQueriesClassifier, MediaQueriesResults, View } from '../view.js'; +import { View } from '../view/view.js'; +import { MediaQueriesResults } from "../view/media-queries-results"; +import { MediaQueriesClassifier } from "../view/media-queries-classifier"; import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { Lazyload } from './embla-plugins/lazyload.js'; import { @@ -51,7 +53,8 @@ import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents, } from '../utils/media-to-view.js'; -import { ViewMedia, ViewMediaClassifier } from '../view-media.js'; +import { ViewMedia } from '../view/media.js'; +import { ViewMediaClassifier } from "../view/media-classifier"; import { guard } from 'lit/directives/guard.js'; import { localize } from '../localize/localize.js'; diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 0e8633b4..ba1df50d 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -4,10 +4,12 @@ import startOfHour from 'date-fns/startOfHour'; import sub from 'date-fns/sub'; import { ViewContext } from 'view'; import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView, RecordingSegment } from '../types'; -import { EventMediaQueries, RecordingMediaQueries, View } from '../view'; +import { View } from '../view/view'; +import { EventMediaQueries, RecordingMediaQueries } from "../view/media-queries"; import { CameraManager } from '../camera/manager'; import { getAllDependentCameras } from './camera.js'; -import { ViewMedia, ViewMediaClassifier } from '../view-media'; +import { ViewMedia } from '../view/media'; +import { ViewMediaClassifier } from "../view/media-classifier"; import { HomeAssistant } from 'custom-card-helpers'; export const changeViewToRecentEventsForCameraAndDependents = async ( diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index 3e3ee7b1..d31e513b 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -7,8 +7,8 @@ import { CameraConfig, ClipsOrSnapshotsOrAll, RecordingSegment } from '../types' import { CameraManager } from '../camera/manager'; import { EventQuery } from '../camera/types'; import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera/util'; -import { EventMediaQueries } from '../view'; -import { ViewMedia } from '../view-media'; +import { EventMediaQueries } from "../view/media-queries"; +import { ViewMedia } from '../view/media'; import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet } from '../camera/range'; import { ModifyInterface } from './basic'; diff --git a/src/view/media-classifier.ts b/src/view/media-classifier.ts new file mode 100644 index 00000000..c5324bea --- /dev/null +++ b/src/view/media-classifier.ts @@ -0,0 +1,53 @@ +import { ModifyInterface } from '../utils/basic.js'; +import { ViewMedia, FrigateEventViewMedia, FrigateRecordingViewMedia } from './media'; + +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(); + } +} diff --git a/src/view/media-queries-classifier.ts b/src/view/media-queries-classifier.ts new file mode 100644 index 00000000..4508e97f --- /dev/null +++ b/src/view/media-queries-classifier.ts @@ -0,0 +1,15 @@ +import { EventMediaQueries, MediaQueries, RecordingMediaQueries } from './media-queries'; + +export class MediaQueriesClassifier { + public static areEventQueries( + queries?: MediaQueries | null, + ): queries is EventMediaQueries { + return queries instanceof EventMediaQueries; + } + + public static areRecordingQueries( + queries?: MediaQueries | null, + ): queries is RecordingMediaQueries { + return queries instanceof RecordingMediaQueries; + } +} diff --git a/src/view/media-queries-results.ts b/src/view/media-queries-results.ts new file mode 100644 index 00000000..5dc5b13e --- /dev/null +++ b/src/view/media-queries-results.ts @@ -0,0 +1,91 @@ +import clone from 'lodash-es/clone.js'; +import { ViewMedia } from './media.js'; + +export class MediaQueriesResults { + protected _results: ViewMedia[] | null = null; + protected _resultsTimestamp: Date | null = null; + protected _selectedIndex: number | null = null; + + constructor(results?: ViewMedia[], selectedIndex?: number | null) { + 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 | null): MediaQueriesResults { + if ( + index === null || + (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; + } +} diff --git a/src/view/media-queries.ts b/src/view/media-queries.ts new file mode 100644 index 00000000..0a674619 --- /dev/null +++ b/src/view/media-queries.ts @@ -0,0 +1,61 @@ +import isEqual from 'lodash-es/isEqual'; +import cloneDeep from 'lodash-es/cloneDeep.js'; +import { EventQuery, MediaQuery, RecordingQuery } from '../camera/types.js'; + +export type MediaQueries = EventMediaQueries | RecordingMediaQueries; + +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 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); + } +} diff --git a/src/view-media.ts b/src/view/media.ts similarity index 84% rename from src/view-media.ts rename to src/view/media.ts index 67952a7d..13347ff2 100644 --- a/src/view-media.ts +++ b/src/view/media.ts @@ -1,74 +1,18 @@ import fromUnixTime from 'date-fns/fromUnixTime'; import isEqual from 'lodash-es/isEqual'; -import { - BrowseMediaSource, - CameraConfig, - MEDIA_TYPE_IMAGE, -} from './types.js'; -import { ModifyInterface } from './utils/basic.js'; +import { BrowseMediaSource, CameraConfig, MEDIA_TYPE_IMAGE } from '../types.js'; import { getEventMediaContentID, getEventThumbnailURL, getEventTitle, getRecordingMediaContentID, getRecordingTitle, -} from './camera/frigate/util.js'; -import { FrigateEvent, FrigateRecording } from './camera/frigate/types.js'; +} from '../camera/frigate/util.js'; +import { FrigateEvent, FrigateRecording } from '../camera/frigate/types.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; diff --git a/src/view.ts b/src/view/view.ts similarity index 63% rename from src/view.ts rename to src/view/view.ts index 3cc0c2fe..d969c69b 100644 --- a/src/view.ts +++ b/src/view/view.ts @@ -23,19 +23,16 @@ // - 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? -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 { 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 './camera/types.js'; -import { ViewMedia } from './view-media.js'; +} from '../types.js'; +import { dispatchFrigateCardEvent } from '../utils/basic.js'; +import { MediaQueries } from './media-queries'; +import { MediaQueriesResults } from './media-queries-results'; export interface ViewEvolveParameters { view?: FrigateCardView; @@ -50,167 +47,6 @@ export interface ViewParameters extends ViewEvolveParameters { camera: string; } -export class MediaQueriesClassifier { - public static areEventQueries( - queries?: MediaQueries | null, - ): queries is EventMediaQueries { - return queries instanceof EventMediaQueries; - } - - public static areRecordingQueries( - queries?: MediaQueries | null, - ): queries is RecordingMediaQueries { - return queries instanceof RecordingMediaQueries; - } -} - -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 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 | null) { - 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 | null): MediaQueriesResults { - if ( - index === null || - (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; From 401e7c5255740df15b0bb72f733b93096dc700b3 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 3 Jan 2023 21:10:36 -0800 Subject: [PATCH 13/74] Add limit support for recordings. --- src/camera/frigate/engine-frigate.ts | 32 ++++++++++++++++++++++------ src/view/view.ts | 2 -- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 21823a80..1260344c 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -5,10 +5,10 @@ import getUnixTime from 'date-fns/getUnixTime'; import startOfHour from 'date-fns/startOfHour'; import { CAMERA_BIRDSEYE } from '../../const'; import { CameraConfig, RecordingSegment } from '../../types'; -import { MediaQueriesResults } from "../../view/media-queries-results"; -import { MediaQueriesClassifier } from "../../view/media-queries-classifier"; +import { MediaQueriesResults } from '../../view/media-queries-results'; +import { MediaQueriesClassifier } from '../../view/media-queries-classifier'; import { ViewMedia, ViewMediaFactory } from '../../view/media'; -import { ViewMediaClassifier } from "../../view/media-classifier"; +import { ViewMediaClassifier } from '../../view/media-classifier'; import { errorToConsole } from '../../utils/basic'; import { RecordingSegmentsCache } from '../cache'; import { @@ -34,8 +34,16 @@ import { RecordingSegmentsQuery, } from '../types'; import { FrigateRecording, RecordingSummary } from './types'; -import { getEvents, getRecordingSegments, getRecordingsSummary, NativeFrigateEventQuery, NativeFrigateRecordingSegmentsQuery, retainEvent } from './requests'; +import { + getEvents, + getRecordingSegments, + getRecordingsSummary, + NativeFrigateEventQuery, + NativeFrigateRecordingSegmentsQuery, + retainEvent, +} from './requests'; import { MediaQueries } from '../../view/media-queries'; +import orderBy from 'lodash-es/orderBy'; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; @@ -182,13 +190,12 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { }; try { - const result: FrigateEventQueryResults = { + return { 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 CameraManagerError((e as Error).message, query); @@ -220,7 +227,8 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { throw new CameraManagerError((e as Error).message, query); } - const recordings: FrigateRecording[] = []; + let recordings: FrigateRecording[] = []; + for (const dayData of recordingSummary ?? []) { for (const hourData of dayData.hours) { const hour = add(dayData.day, { hours: hourData.hour }); @@ -240,6 +248,16 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } } + if (query.limit !== undefined) { + // Frigate does not natively support a way to limit recording searches so + // this simulates it. + recordings = orderBy( + recordings, + (recording: FrigateRecording) => recording.start_time, + 'desc', + ).slice(0, query.limit); + } + return { type: QueryResultsType.Recording, engine: Engine.Frigate, diff --git a/src/view/view.ts b/src/view/view.ts index d969c69b..471ac1b1 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,6 +1,5 @@ // Easy: // - TODO: Refactor thumbnailsControlSchema to all use the shortform for other thumbnail users beyond live. -// - TODO: limit param in recordings should do something // - TODO: Should be able to set live media to 'all' and have it work. // - TODO: Are there elements of ViewMedia (e.g. getEventCount) that should be moved into subclasses (e.g. a recording subclass). // - TODO: In MediaQueriesBase, do we need to generic? Just have T be a MediaQuery? @@ -11,7 +10,6 @@ // - 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: 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: Move view/ stuff into a view directory. // - TODO: Move frigate specific view-media under the camera manager. // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript From 65aa9716fc8ce9ca42f3b084788eebcfd19a2677 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 3 Jan 2023 21:30:54 -0800 Subject: [PATCH 14/74] Clean up thumbnail schema handling. --- src/types.ts | 72 ++++++++++++------------------------------------ src/view/view.ts | 1 - 2 files changed, 17 insertions(+), 56 deletions(-) diff --git a/src/types.ts b/src/types.ts index 44752342..a0b26f60 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1070,41 +1070,28 @@ export type ViewerConfig = z.infer; /** * Event gallery configuration section (clips, snapshots). */ +const galleryThumbnailControlsDefaults = { + ...thumbnailControlsDefaults, + show_details: false, +}; + const galleryConfigDefault = { controls: { - thumbnails: { - size: 100, - show_details: false, - show_favorite_control: true, - show_timeline_control: true, - }, + thumbnails: galleryThumbnailControlsDefaults, }, }; +const gallerythumbnailsControlSchema = thumbnailsControlSchema.extend({ + show_details: z.boolean().default(galleryThumbnailControlsDefaults.show_details), +}); + const galleryConfigSchema = z .object({ controls: z .object({ - thumbnails: thumbnailsControlSchema - // Gallery shows thumbnails "centrally" so no need for the mode. - .omit({ mode: true }) - .extend({ - size: thumbnailsControlSchema.shape.size.default( - galleryConfigDefault.controls.thumbnails.size, - ), - show_details: thumbnailsControlSchema.shape.show_details.default( - galleryConfigDefault.controls.thumbnails.show_details, - ), - show_favorite_control: - thumbnailsControlSchema.shape.show_favorite_control.default( - galleryConfigDefault.controls.thumbnails.show_favorite_control, - ), - show_timeline_control: - thumbnailsControlSchema.shape.show_timeline_control.default( - galleryConfigDefault.controls.thumbnails.show_timeline_control, - ), - }) - .default(galleryConfigDefault.controls.thumbnails), + thumbnails: gallerythumbnailsControlSchema.default( + galleryConfigDefault.controls.thumbnails, + ), }) .default(galleryConfigDefault.controls), }) @@ -1144,13 +1131,7 @@ const dimensionsConfigSchema = z const timelineConfigDefault = { ...timelineCoreConfigDefault, controls: { - thumbnails: { - mode: 'left' as const, - size: 100, - show_details: true, - show_favorite_control: true, - show_timeline_control: true, - }, + thumbnails: thumbnailControlsDefaults, }, }; @@ -1158,27 +1139,9 @@ const timelineConfigSchema = timelineCoreConfigSchema .extend({ controls: z .object({ - thumbnails: thumbnailsControlSchema - .extend({ - mode: thumbnailsControlSchema.shape.mode.default( - timelineConfigDefault.controls.thumbnails.mode, - ), - size: thumbnailsControlSchema.shape.size.default( - timelineConfigDefault.controls.thumbnails.size, - ), - show_details: thumbnailsControlSchema.shape.show_details.default( - timelineConfigDefault.controls.thumbnails.show_details, - ), - show_favorite_control: - thumbnailsControlSchema.shape.show_favorite_control.default( - timelineConfigDefault.controls.thumbnails.show_favorite_control, - ), - show_timeline_control: - thumbnailsControlSchema.shape.show_timeline_control.default( - timelineConfigDefault.controls.thumbnails.show_timeline_control, - ), - }) - .default(timelineConfigDefault.controls.thumbnails), + thumbnails: thumbnailsControlSchema.default( + timelineConfigDefault.controls.thumbnails, + ), }) .default(timelineConfigDefault.controls), }) @@ -1425,7 +1388,6 @@ export interface FrigateBrowseMediaSource extends BrowseMediaSource { }; } - export const frigateBrowseMediaSourceSchema: z.ZodSchema = z.lazy( () => z.object({ diff --git a/src/view/view.ts b/src/view/view.ts index 471ac1b1..ba038822 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,5 +1,4 @@ // Easy: -// - 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: Are there elements of ViewMedia (e.g. getEventCount) that should be moved into subclasses (e.g. a recording subclass). // - TODO: In MediaQueriesBase, do we need to generic? Just have T be a MediaQuery? From 0f5f5b7ddfdbcbbeb95d65ac0cdcc1bcbb192a7a Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 3 Jan 2023 21:50:29 -0800 Subject: [PATCH 15/74] Support 'all' as media type for live thumbnails. --- README.md | 2 +- src/types.ts | 4 ++-- src/utils/media-to-view.ts | 4 +--- src/view/view.ts | 1 - 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index adf07ca8..54a4f106 100644 --- a/README.md +++ b/README.md @@ -392,7 +392,7 @@ live: | `show_details` | `false` | :white_check_mark: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.| | `show_favorite_control` | `true` | :white_check_mark: | Whether to show the favorite ('star') control on each thumbnail.| | `show_timeline_control` | `true` | :white_check_mark: | Whether to show the timeline ('target') control on each thumbnail.| -| `media` | `clips` | :white_check_mark: | Whether to show `clips` or `snapshots` in the thumbnail carousel in the `live` view.| +| `media` | `all` | :white_check_mark: | Whether to show `clips`, `snapshots` or `all` in the thumbnail carousel in the `live` view.| #### Live Controls: Next / Previous diff --git a/src/types.ts b/src/types.ts index a0b26f60..99dfd490 100644 --- a/src/types.ts +++ b/src/types.ts @@ -773,7 +773,7 @@ const liveImageConfigDefault = { const liveThumbnailControlsDefaults = { ...thumbnailControlsDefaults, - media: 'clips' as const, + media: 'all' as const, }; const liveConfigDefault = { @@ -804,7 +804,7 @@ const liveConfigDefault = { const livethumbnailsControlSchema = thumbnailsControlSchema.extend({ media: z - .enum(['clips', 'snapshots']) + .enum(['all', 'clips', 'snapshots']) .default(liveConfigDefault.controls.thumbnails.media), }); diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index ba1df50d..164e3150 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -54,9 +54,7 @@ export const createViewForEvents = async ( const queries = cameraManager.generateDefaultEventQueries(cameraIDs, { ...(options?.limit && { limit: options.limit }), - ...((!options?.mediaType || ['clips', 'all'].includes(options.mediaType)) && { - hasClip: true, - }), + ...(options?.mediaType === 'clips' && { hasClip: true }), ...(options?.mediaType === 'snapshots' && { hasSnapshot: true }), }); query = new EventMediaQueries(queries); diff --git a/src/view/view.ts b/src/view/view.ts index ba038822..53b159bb 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,5 +1,4 @@ // Easy: -// - TODO: Should be able to set live media to 'all' and have it work. // - TODO: Are there elements of ViewMedia (e.g. getEventCount) that should be moved into subclasses (e.g. a recording subclass). // - TODO: In MediaQueriesBase, do we need to generic? Just have T be a MediaQuery? // - TODO: In the viewer @click handlers should I use this.selected instead of calling carouselScrollPrevious() From 1762b5530f7d18d4c7eefb044e833d36dea19a78 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Thu, 5 Jan 2023 20:07:28 -0800 Subject: [PATCH 16/74] Refactor media classifiers. --- src/camera/frigate/engine-frigate.ts | 2 +- src/components/thumbnail.ts | 23 +++---- src/components/timeline-core.ts | 68 +++++++++---------- src/components/viewer.ts | 2 +- src/utils/basic.ts | 4 +- src/utils/media-to-view.ts | 39 ++++++----- src/view/media-classifier.ts | 51 +++++--------- src/view/media.ts | 99 +++++++++------------------- src/view/view.ts | 1 - 9 files changed, 115 insertions(+), 174 deletions(-) diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 1260344c..6f48409c 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -89,7 +89,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { path = `/api/frigate/${cameraConfig.frigate.client_id}` + `/notifications/${media.getID()}/` + - `${media.isClip() ? 'clip.mp4' : 'snapshot.jpg'}` + + `${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` + `?download=true`; } else if (ViewMediaClassifier.isFrigateRecording(media)) { path = diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index eed00459..4bb51cfe 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -18,8 +18,9 @@ import type { MediaSeek } from './viewer.js'; import { TaskStatus } from '@lit-labs/task'; import type { CameraConfig, ExtendedHomeAssistant } from '../types.js'; -import { ViewMedia } from '../view/media.js'; +import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js'; import { CameraManager } from '../camera/manager.js'; +import { ViewMediaClassifier } from '../view/media-classifier.js'; // The minimum width of a thumbnail with details enabled. export const THUMBNAIL_DETAILS_WIDTH_MIN = 300; @@ -127,13 +128,13 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement { @customElement('frigate-card-thumbnail-details-event') export class FrigateCardThumbnailDetailsEvent extends LitElement { @property({ attribute: false }) - public media?: ViewMedia; + public media?: EventViewMedia; @property({ attribute: false }) public mediaSeek?: MediaSeek; protected render(): TemplateResult | void { - if (!this.media || !this.media.isEvent()) { + if (!this.media) { return; } const score = this.media.getScore(); @@ -179,7 +180,7 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement { @customElement('frigate-card-thumbnail-details-recording') export class FrigateCardThumbnailDetailsRecording extends LitElement { @property({ attribute: false }) - public media?: ViewMedia; + public media?: RecordingViewMedia; @property({ attribute: false }) public mediaSeek?: MediaSeek; @@ -266,19 +267,19 @@ export class FrigateCardThumbnail extends LitElement { const shouldShowTimelineControl = this.show_timeline_control && this.view && - (!this.media.isRecording() || + (!ViewMediaClassifier.isRecording(this.media) || // 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` ${this.media.isEvent() + return html` ${ViewMediaClassifier.isEvent(this.media) ? html`` - : this.media.isRecording() + : ViewMediaClassifier.isRecording(this.media) ? html`` : ``} - ${this.details && this.media.isEvent() + ${this.details && ViewMediaClassifier.isEvent(this.media) ? html`` - : this.details && this.media.isRecording() + : this.details && ViewMediaClassifier.isRecording(this.media) ? html` - windowSeconds * 1000 - ) { + if (startTime && endTime) { + if (endTime.getTime() - startTime.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(), + start: sub(endTime, { seconds: windowSeconds }), + end: endTime, }; } 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; + const gap = windowSeconds - (endTime.getTime() - startTime.getTime()) / 1000; return { - start: sub(media.getStartTime(), { seconds: gap / 2 }), - end: add(media.getEndTime(), { seconds: gap / 2 }), + start: sub(startTime, { seconds: gap / 2 }), + end: add(endTime, { seconds: gap / 2 }), }; } - } else { + } else if (startTime) { // 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 }), + start: sub(startTime, { seconds: windowSeconds / 2 }), + end: add(startTime, { seconds: windowSeconds / 2 }), }; } - } else if ( - media.isRecording() && - ViewMediaClassifier.isMediaWithStartEndTime(media) - ) { + } else if (ViewMediaClassifier.isRecording(media) && startTime && endTime) { return { - start: media.getStartTime(), - end: media.getEndTime(), + start: startTime, + end: endTime, }; } return null; @@ -816,6 +807,8 @@ export class FrigateCardTimelineCore extends LitElement { second.id != selectedId && !!firstMedia && !!secondMedia && + ViewMediaClassifier.isEvent(firstMedia) && + ViewMediaClassifier.isEvent(secondMedia) && firstMedia.isGroupableWith(secondMedia) ); }, @@ -882,9 +875,11 @@ export class FrigateCardTimelineCore extends LitElement { let desiredWindow = timelineWindow; const media = this.view.queryResults?.getSelectedResult(); + const mediaStartTime = media?.getStartTime(); + const mediaEndTime = media?.getEndTime(); const mediaWindow: TimelineWindow | null = - media && ViewMediaClassifier.isMediaWithStartEndTime(media) - ? { start: media.getStartTime(), end: media.getEndTime() } + media && mediaStartTime && mediaEndTime + ? { start: mediaStartTime, end: mediaEndTime } : null; const context = this.view.context?.timeline; @@ -907,12 +902,8 @@ export class FrigateCardTimelineCore extends LitElement { await this._timelineSource?.refresh(this.hass, this.cameras, prefetchedWindow); } - if ( - !this._pointerHeld && - media && - ViewMediaClassifier.isMediaWithID(media) && - this._isClustering() - ) { + const mediaID = media?.getID(); + if (!this._pointerHeld && media && mediaID && 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 @@ -920,10 +911,11 @@ export class FrigateCardTimelineCore extends LitElement { // Need to this rewrite prior to setting the selection (just below), or // the selection will be lost on rewrite. - this._timelineSource?.rewriteEvent(media.getID()); + this._timelineSource?.rewriteEvent(mediaID); } - const desiredId = !!media && !!media.isEvent() ? media.getID() : null; + const desiredId = + !!media && ViewMediaClassifier.isEvent(media) ? media.getID() : null; if (desiredId) { this._timeline?.setSelection([desiredId], { focus: false, diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 56224076..84ea3802 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -666,7 +666,7 @@ export class FrigateCardViewerCarousel extends LitElement { // `_selectSlideMediaShowHandler` (and not used by the player itself). return html`
- ${media.isVideo() + ${ViewMediaClassifier.isVideo(media) ? html` = Omit & R; - /** * Dispatch a Frigate Card event. - * @param element The element to send the event. + * @param target The target from which send the event. * @param name The name of the Frigate card event to send. * @param detail An optional detail object to attach. */ diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 164e3150..1aac8a47 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -3,13 +3,18 @@ import fromUnixTime from 'date-fns/fromUnixTime'; import startOfHour from 'date-fns/startOfHour'; import sub from 'date-fns/sub'; import { ViewContext } from 'view'; -import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView, RecordingSegment } from '../types'; +import { + CameraConfig, + ClipsOrSnapshotsOrAll, + FrigateCardView, + RecordingSegment, +} from '../types'; import { View } from '../view/view'; -import { EventMediaQueries, RecordingMediaQueries } from "../view/media-queries"; +import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; import { CameraManager } from '../camera/manager'; import { getAllDependentCameras } from './camera.js'; import { ViewMedia } from '../view/media'; -import { ViewMediaClassifier } from "../view/media-classifier"; +import { ViewMediaClassifier } from '../view/media-classifier'; import { HomeAssistant } from 'custom-card-helpers'; export const changeViewToRecentEventsForCameraAndDependents = async ( @@ -178,12 +183,12 @@ export const generateMediaViewerContext = async ( const hourStart = startOfHour(targetTime); for (const [index, child] of media.entries()) { - if (!ViewMediaClassifier.isMediaWithStartEndTime(child)) { + const start = child.getStartTime(); + const end = child.getEndTime(); + if (!start || !end) { continue; } - const start = child.getStartTime(); - const end = child.getEndTime(); let seekSeconds: number | null = null; if (targetTime >= start && targetTime <= end) { @@ -194,12 +199,14 @@ export const generateMediaViewerContext = async ( end: end, }, )[0]; - const segments = (await cameraManager.getRecordingSegments(hass, query)).get(query); + const segments = (await cameraManager.getRecordingSegments(hass, query)).get( + query, + ); if (segments) { seekSeconds = getSeekTimeInSegments( // Recordings start from the top of the hour. - child.isRecording() ? hourStart : start, + ViewMediaClassifier.isRecording(child) ? hourStart : start, targetTime, segments.segments, ); @@ -241,24 +248,20 @@ export const findClosestMediaIndex = ( for (let i = 0; i < mediaArray.length; ++i) { const media = mediaArray[i]; - if ( - !cameraIDs.has(media.getCameraID()) || - !ViewMediaClassifier.isMediaWithStartEndTime(media) - ) { + const start = media.getStartTime(); + const end = media.getEndTime(); + if (!cameraIDs.has(media.getCameraID()) || !start || !end) { continue; } - const startTime = media.getStartTime(); - const endTime = media.getEndTime(); - - if (startTime <= targetTime && endTime >= targetTime) { + if (start <= targetTime && end >= targetTime) { if (!refPoint) { return i; } const delta = refPoint === 'end' - ? endTime.getTime() - targetTime.getTime() - : targetTime.getTime() - startTime.getTime(); + ? end.getTime() - targetTime.getTime() + : targetTime.getTime() - start.getTime(); if (!bestMatch || delta < bestMatch.delta) { bestMatch = { index: i, delta: delta }; } diff --git a/src/view/media-classifier.ts b/src/view/media-classifier.ts index c5324bea..6f91af21 100644 --- a/src/view/media-classifier.ts +++ b/src/view/media-classifier.ts @@ -1,5 +1,10 @@ -import { ModifyInterface } from '../utils/basic.js'; -import { ViewMedia, FrigateEventViewMedia, FrigateRecordingViewMedia } from './media'; +import { + ViewMedia, + FrigateEventViewMedia, + FrigateRecordingViewMedia, + RecordingViewMedia, + EventViewMedia, +} from './media'; export class ViewMediaClassifier { public static isFrigateMedia( @@ -15,39 +20,19 @@ export class ViewMediaClassifier { ): 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 isEvent(media: ViewMedia): media is EventViewMedia { + return this.isClip(media) || this.isSnapshot(media); } - public static isMediaWithStartTime(media: ViewMedia): media is ModifyInterface< - ViewMedia, - { - getStartTime(): Date; - } - > { - return !!media.getStartTime(); + public static isRecording(media: ViewMedia): media is RecordingViewMedia { + return media.getMediaType() === 'recording'; } - public static isMediaWithEndTime(media: ViewMedia): media is ModifyInterface< - ViewMedia, - { - getEndTime(): Date; - } - > { - return !!media.getEndTime(); + public static isClip(media: ViewMedia): boolean { + return media.getMediaType() === 'clip'; } - public static isMediaWithID(media: ViewMedia): media is ModifyInterface< - ViewMedia, - { - getID(): string; - } - > { - return !!media.getID(); + public static isSnapshot(media: ViewMedia): boolean { + return media.getMediaType() === 'snapshot'; + } + public static isVideo(media: ViewMedia): boolean { + return this.isClip(media) || this.isRecording(media); } } diff --git a/src/view/media.ts b/src/view/media.ts index 13347ff2..15633795 100644 --- a/src/view/media.ts +++ b/src/view/media.ts @@ -1,6 +1,6 @@ import fromUnixTime from 'date-fns/fromUnixTime'; import isEqual from 'lodash-es/isEqual'; -import { BrowseMediaSource, CameraConfig, MEDIA_TYPE_IMAGE } from '../types.js'; +import { CameraConfig } from '../types.js'; import { getEventMediaContentID, getEventThumbnailURL, @@ -9,9 +9,10 @@ import { getRecordingTitle, } from '../camera/frigate/util.js'; import { FrigateEvent, FrigateRecording } from '../camera/frigate/types.js'; +import { ViewMediaClassifier } from './media-classifier.js'; export type ViewMediaType = 'clip' | 'snapshot' | 'recording'; -export type ViewMediaSourceType = FrigateEvent | FrigateRecording | BrowseMediaSource; +export type ViewMediaSourceType = FrigateEvent | FrigateRecording; class ViewMediaBase { protected _mediaType: ViewMediaType; @@ -23,19 +24,6 @@ class ViewMediaBase { 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'; } @@ -45,9 +33,6 @@ class ViewMediaBase { public getMediaType(): ViewMediaType { return this._mediaType; } - public isVideo(): boolean { - return this.isClip() || this.isRecording(); - } public getSource(): T { return this._source; } @@ -73,14 +58,6 @@ class ViewMediaBase { 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; } @@ -91,18 +68,19 @@ class ViewMediaBase { 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; - } +} + +export interface EventViewMedia extends ViewMedia { + getScore(): number | null; + getWhat(): string[] | null; + isGroupableWith(that: EventViewMedia): boolean; +} + +export interface RecordingViewMedia extends ViewMedia { + getEventCount(): number | null; } // Creates a 'public interface only' version of ViewMediaBase for use elsewhere @@ -112,26 +90,10 @@ 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 { +export class FrigateEventViewMedia + extends ViewMediaBase + implements EventViewMedia +{ public hasClip(): boolean { return !!this._source.has_clip; } @@ -167,7 +129,7 @@ export class FrigateEventViewMedia extends ViewMediaBase { cameraConfig.frigate.client_id, cameraConfig.frigate.camera_name, this._source, - this.isClip() ? 'clips' : 'snapshots', + ViewMediaClassifier.isClip(this) ? 'clips' : 'snapshots', ); } @@ -198,9 +160,21 @@ export class FrigateEventViewMedia extends ViewMediaBase { public getScore(): number | null { return this._source.top_score; } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public isGroupableWith(that: EventViewMedia): boolean { + return ( + this.getMediaType() === that.getMediaType() && + isEqual(this.getWhere(), that.getWhere()) && + isEqual(this.getWhat(), that.getWhat()) + ); + } } -export class FrigateRecordingViewMedia extends ViewMediaBase { +export class FrigateRecordingViewMedia + extends ViewMediaBase + implements RecordingViewMedia +{ 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 @@ -262,15 +236,4 @@ export class ViewMediaFactory { ): 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/view.ts b/src/view/view.ts index 53b159bb..dd8df6bb 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,5 +1,4 @@ // Easy: -// - TODO: Are there elements of ViewMedia (e.g. getEventCount) that should be moved into subclasses (e.g. a recording subclass). // - TODO: In MediaQueriesBase, do we need to generic? Just have T be a MediaQuery? // - TODO: In the viewer @click handlers should I use this.selected instead of calling carouselScrollPrevious() From eefe665f25bb063b2ad7edea42eb66ddb480b12d Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Thu, 5 Jan 2023 21:59:35 -0800 Subject: [PATCH 17/74] Remove confusing carousel methods. --- src/components/carousel.ts | 22 --------- src/components/live/live.ts | 91 ++++++++++++++----------------------- src/components/viewer.ts | 43 +++++++++++------- src/view/media.ts | 3 +- src/view/view.ts | 4 -- 5 files changed, 61 insertions(+), 102 deletions(-) diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 362fdd34..e1ab24bf 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -97,28 +97,6 @@ export class FrigateCardCarousel extends LitElement { } } - /** - * Scroll to a particular slide. - * @param index Slide number. - */ - public carouselScrollTo(index: number): void { - this.selected = index; - } - - /** - * Scroll to the previous slide. - */ - public carouselScrollPrevious(): void { - this.selected = Math.max(0, this.selected - 1); - } - - /** - * Scroll to the next slide. - */ - public carouselScrollNext(): void { - this.selected = this.selected + 1; - } - /** * Get the selected slide. * @returns A CarouselSelect object (index & element). diff --git a/src/components/live/live.ts b/src/components/live/live.ts index a121bdcc..91f0600b 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -48,7 +48,7 @@ import { import '../next-prev-control.js'; import '../title-control.js'; import '../surround.js'; -import { EmblaCarouselPlugins } from '../carousel.js'; +import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js'; import { classMap } from 'lit/directives/class-map.js'; import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js'; import { CameraManager } from '../../camera/manager.js'; @@ -316,28 +316,9 @@ export class FrigateCardLiveCarousel extends LitElement { super.updated(changedProperties); const frigateCardMediaCarousel = this._refMediaCarousel.value; - const frigateCardCarousel = frigateCardMediaCarousel?.frigateCardCarousel(); - - if (changedProperties.has('view')) { - const oldView = changedProperties.get('view') as View | undefined; - if ( - frigateCardCarousel && - this.view?.camera && - (!oldView || this.view?.camera !== oldView.camera) - ) { - const slide: number | undefined = this._cameraToSlide[this.view.camera]; - if ( - slide !== undefined && - slide !== frigateCardCarousel.getCarouselSelected()?.index - ) { - frigateCardCarousel.carouselScrollTo(slide); - } - } - } if ( frigateCardMediaCarousel && - frigateCardCarousel && changedProperties.has('inBackground') ) { // If this has changed to be in the background (i.e. preloaded but not @@ -463,25 +444,26 @@ export class FrigateCardLiveCarousel extends LitElement { /** * Handle the user selecting a new slide in the carousel. */ - protected _setViewHandler(): void { - const selectedCameraIndex = this._refMediaCarousel.value - ?.frigateCardCarousel() - ?.getCarouselSelected()?.index; - if (selectedCameraIndex === undefined || !this.view || !this.cameras) { - return; + protected _setViewHandler(ev: CustomEvent): void { + if (this.cameras && ev.detail.index !== this._getSelectedCameraIndex()) { + this._setViewCameraID(Array.from(this.cameras.keys())[ev.detail.index]); } + } - this.view - .evolve({ - camera: Array.from(this.cameras.keys())[selectedCameraIndex], - // Reset the query and query results. - query: null, - queryResults: null, - }) - // Don't yet fetch thumbnails (they will be fetched when the carousel - // settles). - .mergeInContext({ thumbnails: { fetch: false } }) - .dispatchChangeEvent(this); + protected _setViewCameraID(cameraID?: string | null): void { + if (cameraID) { + this.view + ?.evolve({ + camera: cameraID, + // Reset the query and query results. + query: null, + queryResults: null, + }) + // Don't yet fetch thumbnails (they will be fetched when the carousel + // settles). + .mergeInContext({ thumbnails: { fetch: false } }) + .dispatchChangeEvent(this); + } } /** @@ -549,7 +531,7 @@ export class FrigateCardLiveCarousel extends LitElement { `; } - protected _getCameraNeighbors(): [CameraConfig | null, CameraConfig | null] { + protected _getCameraIDsOfNeighbors(): [string | null, string | null] { if (!this.cameras || !this.view || !this.hass) { return [null, null]; } @@ -560,15 +542,10 @@ export class FrigateCardLiveCarousel extends LitElement { return [null, null]; } - const prev = - this.cameras.get( - keys[currentIndex > 0 ? currentIndex - 1 : this.cameras.size - 1], - ) ?? null; - const next = - this.cameras.get( - keys[currentIndex + 1 < this.cameras.size ? currentIndex + 1 : 0], - ) ?? null; - return [prev, next]; + return [ + keys[currentIndex > 0 ? currentIndex - 1 : this.cameras.size - 1], + keys[currentIndex + 1 < this.cameras.size ? currentIndex + 1 : 0], + ]; } /** @@ -588,7 +565,7 @@ export class FrigateCardLiveCarousel extends LitElement { this.conditionState, ) as LiveConfig; - const [prev, next] = this._getCameraNeighbors(); + const [prevID, nextID] = this._getCameraIDsOfNeighbors(); const title = getCameraTitle(this.hass, this.cameras.get(this.view.camera)); // Notes on the below: @@ -627,13 +604,11 @@ export class FrigateCardLiveCarousel extends LitElement { .hass=${this.hass} .direction=${'previous'} .controlConfig=${config.controls.next_previous} - .label=${getCameraTitle(this.hass, prev)} - .icon=${getCameraIcon(this.hass, prev)} - ?disabled=${prev == null} + .label=${getCameraTitle(this.hass, prevID ? this.cameras.get(prevID) : null)} + .icon=${getCameraIcon(this.hass, prevID ? this.cameras.get(prevID) : null)} + ?disabled=${prevID === null} @click=${(ev) => { - this._refMediaCarousel.value - ?.frigateCardCarousel() - ?.carouselScrollPrevious(); + this._setViewCameraID(prevID); stopEventFromActivatingCardWideActions(ev); }} > @@ -644,11 +619,11 @@ export class FrigateCardLiveCarousel extends LitElement { .hass=${this.hass} .direction=${'next'} .controlConfig=${config.controls.next_previous} - .label=${getCameraTitle(this.hass, next)} - .icon=${getCameraIcon(this.hass, next)} - ?disabled=${next == null} + .label=${getCameraTitle(this.hass, nextID ? this.cameras.get(nextID) : null)} + .icon=${getCameraIcon(this.hass, nextID ? this.cameras.get(nextID) : null)} + ?disabled=${nextID === null} @click=${(ev) => { - this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext(); + this._setViewCameraID(nextID); stopEventFromActivatingCardWideActions(ev); }} > diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 84ea3802..d6c1c0f6 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -31,8 +31,8 @@ import { contentsChanged } from '../utils/basic.js'; import { getFullDependentBrowseMediaQueryParametersOrDispatchError } from '../utils/ha/browse-media.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; import { View } from '../view/view.js'; -import { MediaQueriesResults } from "../view/media-queries-results"; -import { MediaQueriesClassifier } from "../view/media-queries-classifier"; +import { MediaQueriesResults } from '../view/media-queries-results'; +import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { Lazyload } from './embla-plugins/lazyload.js'; import { @@ -54,7 +54,7 @@ import { changeViewToRecentRecordingForCameraAndDependents, } from '../utils/media-to-view.js'; import { ViewMedia } from '../view/media.js'; -import { ViewMediaClassifier } from "../view/media-classifier"; +import { ViewMediaClassifier } from '../view/media-classifier'; import { guard } from 'lit/directives/guard.js'; import { localize } from '../localize/localize.js'; @@ -419,21 +419,21 @@ export class FrigateCardViewerCarousel extends LitElement { * Handle the user selecting a new slide in the carousel. */ protected _setViewHandler(ev: CustomEvent): void { - if (!this._refMediaCarousel.value || !this.view) { - return; + if (ev.detail.index !== this.view?.queryResults?.getSelectedIndex()) { + this._setViewSelectedIndex(ev.detail.index); } + } + protected _setViewSelectedIndex(index: number): void { // 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({ - queryResults: this.view.queryResults?.clone().selectResult(ev.detail.index), - }) - // Ensure the timeline is able to update its position. - .mergeInContext({ timeline: { noSetWindow: false } }) - .dispatchChangeEvent(this); - } + this.view + ?.evolve({ + queryResults: this.view.queryResults?.clone().selectResult(index), + }) + // Ensure the timeline is able to update its position. + .mergeInContext({ timeline: { noSetWindow: false } }) + .dispatchChangeEvent(this); } /** @@ -583,6 +583,17 @@ export class FrigateCardViewerCarousel extends LitElement { const [prev, next] = this._getMediaNeighbors(); + const scroll = (direction: 'previous' | 'next'): void => { + const currentIndex = this.view?.queryResults?.getSelectedIndex() ?? null; + if (!this.view || !this.view?.queryResults || currentIndex === null) { + return; + } + const newIndex = direction === 'previous' ? currentIndex - 1 : currentIndex + 1; + if (newIndex >= 0 && newIndex < this.view.queryResults.getResultsCount()) { + this._setViewSelectedIndex(newIndex); + } + }; + return html` { - this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollPrevious(); + scroll('previous'); stopEventFromActivatingCardWideActions(ev); }} > @@ -619,7 +630,7 @@ export class FrigateCardViewerCarousel extends LitElement { .label=${next?.getTitle() ?? ''} ?disabled=${!next} @click=${(ev) => { - this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext(); + scroll('next'); stopEventFromActivatingCardWideActions(ev); }} > diff --git a/src/view/media.ts b/src/view/media.ts index 15633795..17119fb2 100644 --- a/src/view/media.ts +++ b/src/view/media.ts @@ -9,7 +9,6 @@ import { getRecordingTitle, } from '../camera/frigate/util.js'; import { FrigateEvent, FrigateRecording } from '../camera/frigate/types.js'; -import { ViewMediaClassifier } from './media-classifier.js'; export type ViewMediaType = 'clip' | 'snapshot' | 'recording'; export type ViewMediaSourceType = FrigateEvent | FrigateRecording; @@ -129,7 +128,7 @@ export class FrigateEventViewMedia cameraConfig.frigate.client_id, cameraConfig.frigate.camera_name, this._source, - ViewMediaClassifier.isClip(this) ? 'clips' : 'snapshots', + this._mediaType === 'clip' ? 'clips' : 'snapshots', ); } diff --git a/src/view/view.ts b/src/view/view.ts index dd8df6bb..66fdbff1 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,7 +1,3 @@ -// Easy: -// - TODO: In MediaQueriesBase, do we need to generic? Just have T be a MediaQuery? -// - TODO: In the viewer @click handlers should I use this.selected instead of calling carouselScrollPrevious() - // Medium: // - TODO: Callers of all async methods of data-engine need to catch errors. // - TODO: Add garbage collecting of segments not present in the recording summaries anymore. From 191c901952be2fb8d7bc51842fcd0a07cb8f858e Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 8 Jan 2023 08:11:19 -0800 Subject: [PATCH 18/74] Do a fresh media query on snapshot->clip conversion. --- src/components/viewer.ts | 66 ++++++++++++++---------------------- src/utils/basic.ts | 2 ++ src/utils/timeline-source.ts | 2 +- src/view/view.ts | 2 +- 4 files changed, 30 insertions(+), 42 deletions(-) diff --git a/src/components/viewer.ts b/src/components/viewer.ts index d6c1c0f6..13177a25 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -153,6 +153,7 @@ export class FrigateCardViewer extends LitElement { this.view, { targetView: 'media', + mediaType: mediaType, }, ); } @@ -173,6 +174,7 @@ export class FrigateCardViewer extends LitElement { .cameras=${this.cameras} .viewerConfig=${this.viewerConfig} .resolvedMediaCache=${this.resolvedMediaCache} + .cameraManager=${this.cameraManager} .cardWideConfig=${this.cardWideConfig} > @@ -214,6 +216,9 @@ export class FrigateCardViewerCarousel extends LitElement { @property({ attribute: false }) public cameras?: Map; + @property({ attribute: false }) + public cameraManager?: CameraManager; + protected _refMediaCarousel: Ref = createRef(); protected _carouselOptions?: EmblaOptionsType; @@ -359,17 +364,16 @@ export class FrigateCardViewerCarousel extends LitElement { } /** - * Get a clip view that matches a given snapshot. Includes clips within the - * same range as the current view. - * @param snapshot The snapshot to find a matching clip for. - * @returns The view that would show the matching clip. + * Dispatch a clip view that matches the current (snapshot) query. + * @param index The index of the selected media. */ - protected async _createRelatedClipView(targetIndex: number): Promise { - const media = this.view?.queryResults?.getResult(targetIndex); + protected async _dispatchRelatedClipView(index: number): Promise { + const media = this.view?.queryResults?.getResult(index); if ( !this.hass || !this.view || + !this.cameraManager || !media || // If this specific media item has no clip, then do nothing (even if all // the other media items do). @@ -377,42 +381,28 @@ export class FrigateCardViewerCarousel extends LitElement { !media.hasClip() || !MediaQueriesClassifier.areEventQueries(this.view.query) ) { - return null; + return; } - const newResults: ViewMedia[] = []; - let newSelectedIndex: number | null = null; - // Convert the query to a clips equivalent. - const newQuery = this.view.query.clone(); - newQuery.convertToClipsQueries(); + const clipQuery = this.view.query.clone(); + clipQuery.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 clipMedia = media.getClipEquivalent(); - if (clipMedia) { - newResults.push(clipMedia); - if (i === targetIndex) { - newSelectedIndex = i; - } - } - } - if (newSelectedIndex === null) { - return null; + const results = await this.cameraManager.executeMediaQuery(this.hass, clipQuery); + if (!results) { + return; } - const newQueryResults = new MediaQueriesResults(newResults); - newQueryResults.selectResult(newSelectedIndex); + results.selectResultIfFound((clipMedia) => clipMedia.getID() === media.getID()); + if (!results.hasSelectedResult()) { + return; + } - return this.view.evolve({ - view: 'clip', - query: newQuery, - queryResults: newQueryResults, - }); + this.view.evolve({ + view: 'media', + query: clipQuery, + queryResults: results, + }).dispatchChangeEvent(this); } /** @@ -708,11 +698,7 @@ export class FrigateCardViewerCarousel extends LitElement { ?.carouselClickAllowed() && this.viewerConfig?.snapshot_click_plays_clip ) { - this._createRelatedClipView(index).then((view) => { - if (view) { - view.dispatchChangeEvent(this); - } - }); + this._dispatchRelatedClipView(index); } }} @load="${(e: Event) => { diff --git a/src/utils/basic.ts b/src/utils/basic.ts index cff8a7eb..4a1c5e81 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -5,6 +5,8 @@ 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 target The target from which send the event. diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index d31e513b..36b52734 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -10,7 +10,7 @@ import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera/util'; import { EventMediaQueries } from "../view/media-queries"; import { ViewMedia } from '../view/media'; import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet } from '../camera/range'; -import { ModifyInterface } from './basic'; +import { ModifyInterface } from './basic.js'; // 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). diff --git a/src/view/view.ts b/src/view/view.ts index 66fdbff1..5105b366 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -2,11 +2,11 @@ // - TODO: Callers of all async methods of data-engine need to catch errors. // - 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: 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: Move frigate specific view-media under the camera manager. // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript // Hard: +// - TODO: There are circumstances when lazy load doesn't work (e.g. snapshot -> clips re-query) // - TODO: Implement dragging the timeline seeking forward in both Frigate recordings & events. // - TODO: Implement gallery. // - TODO: Remove FrigateBrowseMediaSource if not necessary (post-gallery). From e85d46d84a13e173827e491bae2dc398016dc6b8 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 8 Jan 2023 10:34:18 -0800 Subject: [PATCH 19/74] Reset carousel plugins if results change. --- src/components/viewer.ts | 41 +++++++++++++++------------------------- src/view/view.ts | 1 - 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 13177a25..82e16687 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -31,7 +31,6 @@ import { contentsChanged } from '../utils/basic.js'; import { getFullDependentBrowseMediaQueryParametersOrDispatchError } from '../utils/ha/browse-media.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; import { View } from '../view/view.js'; -import { MediaQueriesResults } from '../view/media-queries-results'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { Lazyload } from './embla-plugins/lazyload.js'; @@ -174,7 +173,7 @@ export class FrigateCardViewer extends LitElement { .cameras=${this.cameras} .viewerConfig=${this.viewerConfig} .resolvedMediaCache=${this.resolvedMediaCache} - .cameraManager=${this.cameraManager} + .cameraManager=${this.cameraManager} .cardWideConfig=${this.cardWideConfig} > @@ -221,9 +220,6 @@ export class FrigateCardViewerCarousel extends LitElement { 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< [ViewerConfig | undefined, Map | undefined, View | undefined], @@ -398,11 +394,13 @@ export class FrigateCardViewerCarousel extends LitElement { return; } - this.view.evolve({ - view: 'media', - query: clipQuery, - queryResults: results, - }).dispatchChangeEvent(this); + this.view + .evolve({ + view: 'media', + query: clipQuery, + queryResults: results, + }) + .dispatchChangeEvent(this); } /** @@ -525,20 +523,6 @@ export class FrigateCardViewerCarousel extends LitElement { if (changedProps.has('viewerConfig')) { updateElementStyleFromMediaLayoutConfig(this, this.viewerConfig?.layout); } - if (!this._carouselOptions || changedProps.has('viewerConfig')) { - this._carouselOptions = { - draggable: this.viewerConfig?.draggable ?? true, - }; - } - if ( - !this._carouselPlugins || - changedProps.has('viewerConfig') || - (changedProps.has('view') && - this.view?.queryResults?.getResultsCount() !== - changedProps.get('view')?.queryResults?.getResultsCount()) - ) { - this._carouselPlugins = this._getPlugins(); - } } /** @@ -586,8 +570,13 @@ export class FrigateCardViewerCarousel extends LitElement { return html` ({ + draggable: this.viewerConfig?.draggable ?? true, + }))} + .carouselPlugins=${guard( + [this.viewerConfig, this.view?.queryResults?.getResults()], + this._getPlugins.bind(this), + )} .label=${media.getTitle() ?? undefined} .titlePopupConfig=${this.viewerConfig?.controls.title} .selected=${this.view?.queryResults?.getSelectedIndex() ?? 0} diff --git a/src/view/view.ts b/src/view/view.ts index 5105b366..182ca7c9 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -6,7 +6,6 @@ // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript // Hard: -// - TODO: There are circumstances when lazy load doesn't work (e.g. snapshot -> clips re-query) // - TODO: Implement dragging the timeline seeking forward in both Frigate recordings & events. // - TODO: Implement gallery. // - TODO: Remove FrigateBrowseMediaSource if not necessary (post-gallery). From 54f63d0cf3e8effacd9cdc4e486bb593f52ade57 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 8 Jan 2023 13:45:17 -0800 Subject: [PATCH 20/74] Add error handling for camera manager calls. --- src/camera/error.ts | 3 -- src/camera/frigate/engine-frigate.ts | 56 ++++++++-------------------- src/camera/manager.ts | 13 ++++++- src/components/thumbnail.ts | 24 +++++++----- src/components/timeline-core.ts | 9 ++++- src/components/viewer.ts | 12 +++++- src/utils/media-to-view.ts | 55 +++++++++++++++++++++------ src/utils/timeline-source.ts | 19 +++++++--- src/view/view.ts | 1 - 9 files changed, 118 insertions(+), 74 deletions(-) delete mode 100644 src/camera/error.ts diff --git a/src/camera/error.ts b/src/camera/error.ts deleted file mode 100644 index b131e5c3..00000000 --- a/src/camera/error.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { FrigateCardError } from '../types'; - -export class CameraManagerError extends FrigateCardError {} diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 6f48409c..208958ea 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -4,18 +4,16 @@ 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, RecordingSegment } from '../../types'; +import { CameraConfig } from '../../types'; import { MediaQueriesResults } from '../../view/media-queries-results'; import { MediaQueriesClassifier } from '../../view/media-queries-classifier'; import { ViewMedia, ViewMediaFactory } from '../../view/media'; import { ViewMediaClassifier } from '../../view/media-classifier'; -import { errorToConsole } from '../../utils/basic'; import { RecordingSegmentsCache } from '../cache'; import { CameraManagerEngine, CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, } from '../engine'; -import { CameraManagerError } from '../error'; import { DateRange } from '../range'; import { Engine, @@ -33,7 +31,7 @@ import { RecordingQuery, RecordingSegmentsQuery, } from '../types'; -import { FrigateRecording, RecordingSummary } from './types'; +import { FrigateRecording } from './types'; import { getEvents, getRecordingSegments, @@ -156,13 +154,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { return; } - try { - await retainEvent(hass, clientID, media.getID(cameraConfig), favorite); - } catch (e) { - errorToConsole(e as Error); - throw new CameraManagerError((e as Error).message); - } - + await retainEvent(hass, clientID, media.getID(cameraConfig), favorite); media.setFavorite(favorite); } @@ -189,17 +181,12 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { limit: query?.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, }; - try { - return { - type: QueryResultsType.Event, - engine: Engine.Frigate, - events: await getEvents(hass, nativeQuery), - expiry: add(new Date(), { seconds: EVENT_REQUEST_CACHE_MAX_AGE_SECONDS }), - }; - } catch (e) { - errorToConsole(e as Error); - throw new CameraManagerError((e as Error).message, query); - } + return { + type: QueryResultsType.Event, + engine: Engine.Frigate, + events: await getEvents(hass, nativeQuery), + expiry: add(new Date(), { seconds: EVENT_REQUEST_CACHE_MAX_AGE_SECONDS }), + }; } public async getRecordings( @@ -215,17 +202,11 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { 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 CameraManagerError((e as Error).message, query); - } + const recordingSummary = await getRecordingsSummary( + hass, + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + ); let recordings: FrigateRecording[] = []; @@ -303,14 +284,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { before: Math.floor(query.end.getTime() / 1000), }; - let segments: RecordingSegment[]; - try { - segments = await getRecordingSegments(hass, request); - } catch (e) { - errorToConsole(e as Error); - throw new CameraManagerError((e as Error).message, query); - } - + const segments = await getRecordingSegments(hass, request); this._recordingSegmentsCache.add(query.cameraID, range, segments); return { diff --git a/src/camera/manager.ts b/src/camera/manager.ts index c433dba0..2d3afda2 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -229,7 +229,18 @@ export class CameraManager { ): Promise { const engine = this._engineFactory.getEngineForCamera(cameraConfig); if (engine) { - engine.favoriteMedia(hass, cameraConfig, media, favorite); + const queryStartTime = new Date(); + await engine.favoriteMedia(hass, cameraConfig, media, favorite); + + console.debug( + 'Frigate Card CameraManager favorite request (', + `Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, + 'Media:', + media.getID(), + ', Favorite:', + favorite, + ')', + ); } } diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 4bb51cfe..a116a90e 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -9,7 +9,7 @@ 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 { getDurationString, prettifyTitle } from '../utils/basic.js'; +import { errorToConsole, getDurationString, prettifyTitle } from '../utils/basic.js'; import { getCameraTitle } from '../utils/camera.js'; import { renderTask } from '../utils/task.js'; import { createFetchThumbnailTask } from '../utils/thumbnail.js'; @@ -289,20 +289,26 @@ export class FrigateCardThumbnail extends LitElement { .date=${this.media.getStartTime() ?? undefined} >` : html``} - ${this.show_favorite_control && event && this.hass && clientID + ${this.show_favorite_control && this.media && this.hass && clientID ? html` { + @click=${async (ev: Event) => { stopEventFromActivatingCardWideActions(ev); if (this.hass && this.cameraConfig && this.media) { - this.cameraManager?.favoriteMedia( - this.hass, - this.cameraConfig, - this.media, - !this.media?.isFavorite(), - ); + try { + await this.cameraManager?.favoriteMedia( + this.hass, + this.cameraConfig, + this.media, + !this.media?.isFavorite(), + ); + } catch (e) { + errorToConsole(e as Error); + return; + } + this.requestUpdate(); } }} />` diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 7f290255..f33d2149 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -471,6 +471,7 @@ export class FrigateCardTimelineCore extends LitElement { ['background', 'group-label'].includes(properties.what) ) { view = await createViewForRecordings( + this, this.hass, this.cameraManager, this.cameras, @@ -487,6 +488,7 @@ export class FrigateCardTimelineCore extends LitElement { ); } else if (this.timelineConfig?.show_recordings && properties.what === 'axis') { view = await createViewForRecordings( + this, this.hass, this.cameraManager, this.cameras, @@ -641,6 +643,7 @@ export class FrigateCardTimelineCore extends LitElement { return null; } const view = await createViewForEvents( + this, this.hass, this.cameraManager, this.cameras, @@ -651,6 +654,9 @@ export class FrigateCardTimelineCore extends LitElement { mediaType: this.timelineConfig?.media, }, ); + if (!view) { + return null; + } view.mergeInContext( this._generateTimelineContext({ noSetWindow: options?.noSetWindow }), ); @@ -813,7 +819,8 @@ export class FrigateCardTimelineCore extends LitElement { ); }, } - : (false as TimelineOptionsCluster), + : // Timeline type information is incorrect requiring this 'as'. + (false as unknown as TimelineOptionsCluster), minHeight: '100%', maxHeight: '100%', zoomMax: 1 * 24 * 60 * 60 * 1000, diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 82e16687..e8fc69f6 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -27,7 +27,7 @@ import { ViewerConfig, } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; -import { contentsChanged } from '../utils/basic.js'; +import { contentsChanged, errorToConsole } from '../utils/basic.js'; import { getFullDependentBrowseMediaQueryParametersOrDispatchError } from '../utils/ha/browse-media.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; import { View } from '../view/view.js'; @@ -56,6 +56,7 @@ import { ViewMedia } from '../view/media.js'; import { ViewMediaClassifier } from '../view/media-classifier'; import { guard } from 'lit/directives/guard.js'; import { localize } from '../localize/localize.js'; +import { MediaQueriesResults } from '../view/media-queries-results.js'; export interface MediaSeek { // Specifies the point at which this recording should be played, the @@ -384,7 +385,14 @@ export class FrigateCardViewerCarousel extends LitElement { const clipQuery = this.view.query.clone(); clipQuery.convertToClipsQueries(); - const results = await this.cameraManager.executeMediaQuery(this.hass, clipQuery); + let results: MediaQueriesResults | null; + try { + results = await this.cameraManager.executeMediaQuery(this.hass, clipQuery); + } catch (e) { + errorToConsole(e as Error); + return; + } + if (!results) { return; } diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 1aac8a47..fcba2c4b 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -16,6 +16,10 @@ import { getAllDependentCameras } from './camera.js'; import { ViewMedia } from '../view/media'; import { ViewMediaClassifier } from '../view/media-classifier'; import { HomeAssistant } from 'custom-card-helpers'; +import { dispatchFrigateCardErrorEvent } from '../components/message'; +import { MediaQueriesResults } from '../view/media-queries-results'; +import { errorToConsole } from './basic'; +import { RecordingSegmentsQueryResults } from '../camera/types'; export const changeViewToRecentEventsForCameraAndDependents = async ( element: HTMLElement, @@ -29,14 +33,15 @@ export const changeViewToRecentEventsForCameraAndDependents = async ( }, ): Promise => { ( - await createViewForEvents(hass, cameraManager, cameras, view, { + await createViewForEvents(element, hass, cameraManager, cameras, view, { ...options, limit: 50, // Capture the 50 most recent events. }) - ).dispatchChangeEvent(element); + )?.dispatchChangeEvent(element); }; export const createViewForEvents = async ( + element: HTMLElement, hass: HomeAssistant, cameraManager: CameraManager, cameras: Map, @@ -48,7 +53,7 @@ export const createViewForEvents = async ( targetView?: FrigateCardView; limit?: number; }, -): Promise => { +): Promise => { let query: EventMediaQueries; if (options?.query) { query = options.query; @@ -64,7 +69,15 @@ export const createViewForEvents = async ( }); query = new EventMediaQueries(queries); } - const queryResults = await cameraManager.executeMediaQuery(hass, query); + + let queryResults: MediaQueriesResults | null; + try { + queryResults = await cameraManager.executeMediaQuery(hass, query); + } catch (e) { + errorToConsole(e as Error); + dispatchFrigateCardErrorEvent(element, e as Error); + return null; + } return view?.evolve({ view: options?.targetView, @@ -94,18 +107,19 @@ export const changeViewToRecentRecordingForCameraAndDependents = async ( ): Promise => { const now = new Date(); ( - await createViewForRecordings(hass, cameraManager, cameras, view, { + await createViewForRecordings(element, hass, cameraManager, 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); + )?.dispatchChangeEvent(element); }; /** * Create a view for recordings. + * @param element The element to dispatch the view change from. * @param hass The Home Assistant object. * @param cameraManager The datamanager to use for data access. * @param cameras The camera configurations. @@ -115,6 +129,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async ( * restrict to. */ export const createViewForRecordings = async ( + element: HTMLElement, hass: HomeAssistant, cameraManager: CameraManager, cameras: Map, @@ -126,7 +141,7 @@ export const createViewForRecordings = async ( start?: Date; end?: Date; }, -): Promise => { +): Promise => { const cameraIDs: Set = options?.cameraIDs ? options.cameraIDs : new Set(getAllDependentCameras(cameras, view.camera)); @@ -137,7 +152,15 @@ export const createViewForRecordings = async ( }); const query = new RecordingMediaQueries(queries); - const queryResults = await cameraManager.executeMediaQuery(hass, query); + let queryResults: MediaQueriesResults | null; + + try { + queryResults = await cameraManager.executeMediaQuery(hass, query); + } catch (e) { + errorToConsole(e as Error); + dispatchFrigateCardErrorEvent(element, e as Error); + return null; + } let viewerContext: ViewContext | undefined = {}; const mediaArray = queryResults?.getResults(); @@ -199,9 +222,19 @@ export const generateMediaViewerContext = async ( end: end, }, )[0]; - const segments = (await cameraManager.getRecordingSegments(hass, query)).get( - query, - ); + let segments: RecordingSegmentsQueryResults | null; + + try { + segments = (await cameraManager.getRecordingSegments(hass, query)).get( + query, + ) ?? null; + } catch (e) { + errorToConsole(e as Error); + // View context is never critical. Ignore errors which will at least + // allow the video to load even if it doesn't seek to the correct + // location. + return {}; + } if (segments) { seekSeconds = getSeekTimeInSegments( diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index 36b52734..7cdfe4f7 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -10,7 +10,7 @@ import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera/util'; import { EventMediaQueries } from "../view/media-queries"; import { ViewMedia } from '../view/media'; import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet } from '../camera/range'; -import { ModifyInterface } from './basic.js'; +import { errorToConsole, ModifyInterface } from './basic.js'; // 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). @@ -90,10 +90,19 @@ export class TimelineDataSource { cameras: Map, window: TimelineWindow, ): Promise { - await Promise.all([ - this._refreshEvents(hass, cameras, window), - this._refreshRecordings(hass, window), - ]); + try { + await Promise.all([ + this._refreshEvents(hass, cameras, window), + this._refreshRecordings(hass, window), + ]); + } catch (e) { + errorToConsole(e as Error); + + // Intentionally ignore errors here, since it is likely the user will + // change the range again and a subsequent call may work. To do otherwise + // would be jarring to the timeline experience in the case of transient + // errors from the backend. + } } public getCacheFriendlyEventWindow(window: TimelineWindow): TimelineWindow { diff --git a/src/view/view.ts b/src/view/view.ts index 182ca7c9..a66056c7 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,5 +1,4 @@ // Medium: -// - TODO: Callers of all async methods of data-engine need to catch errors. // - 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: Move frigate specific view-media under the camera manager. From 9c1ea3b9220c22766e29eb87d01c6b3aab10a2c0 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 8 Jan 2023 15:35:58 -0800 Subject: [PATCH 21/74] Dedup recordings. --- src/camera/frigate/engine-frigate.ts | 4 ++-- src/camera/manager.ts | 15 ++++++++++++++- src/view/view.ts | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 208958ea..cb78ed42 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -108,8 +108,8 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { return { type: QueryType.Event, cameraID: cameraID, - ...(cameraConfig.frigate.label && { label: cameraConfig.frigate.label }), - ...(cameraConfig.frigate.zone && { zone: cameraConfig.frigate.zone }), + ...(cameraConfig.frigate.label && { what: cameraConfig.frigate.label }), + ...(cameraConfig.frigate.zone && { where: cameraConfig.frigate.zone }), ...query, }; } diff --git a/src/camera/manager.ts b/src/camera/manager.ts index 2d3afda2..61f16d3b 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -25,6 +25,7 @@ import { ViewMedia } from '../view/media.js'; import { MediaQueriesResults } from '../view/media-queries-results'; import { MemoryRequestCache } from './cache.js'; import { MediaQueries } from '../view/media-queries.js'; +import uniqBy from 'lodash-es/uniqBy'; export class QueryClassifier { public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery { @@ -204,7 +205,19 @@ export class CameraManager { } return new MediaQueriesResults( - orderBy(mediaArray, (media) => media.getStartTime(), 'desc'), + orderBy( + // Ensure uniqueness by the ID (if specified), otherwise all elements + // are assumed to be unique. + uniqBy( + mediaArray, + (media) => media.getID(this._cameras.get(media.getCameraID())) ?? media, + ), + + // Sort all items leading with the most recent. + (media) => media.getStartTime(), + 'desc', + ), + // Select the first (most-recent) item. mediaArray.length ? 0 : null, ); diff --git a/src/view/view.ts b/src/view/view.ts index a66056c7..9411f86c 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,8 +1,8 @@ // Medium: // - 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: Move frigate specific view-media under the camera manager. // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript +// - TODO: Engine should not need cameraConfig to favorite since it already has cameras. // Hard: // - TODO: Implement dragging the timeline seeking forward in both Frigate recordings & events. From 54a4dd854fa28ffef0caf07120f7866f952e9a97 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 8 Jan 2023 15:41:20 -0800 Subject: [PATCH 22/74] Don't pass cameraConfig in with favouriteMedia request. --- src/camera/manager.ts | 5 ++++- src/components/thumbnail.ts | 3 +-- src/view/view.ts | 1 - 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/camera/manager.ts b/src/camera/manager.ts index 61f16d3b..1cbddacd 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -236,10 +236,13 @@ export class CameraManager { public async favoriteMedia( hass: HomeAssistant, - cameraConfig: CameraConfig, media: ViewMedia, favorite: boolean, ): Promise { + const cameraConfig = this._cameras.get(media.getCameraID()); + if (!cameraConfig) { + return; + } const engine = this._engineFactory.getEngineForCamera(cameraConfig); if (engine) { const queryStartTime = new Date(); diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index a116a90e..f7b3aeee 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -296,11 +296,10 @@ export class FrigateCardThumbnail extends LitElement { title=${localize('thumbnail.retain_indefinitely')} @click=${async (ev: Event) => { stopEventFromActivatingCardWideActions(ev); - if (this.hass && this.cameraConfig && this.media) { + if (this.hass && this.media) { try { await this.cameraManager?.favoriteMedia( this.hass, - this.cameraConfig, this.media, !this.media?.isFavorite(), ); diff --git a/src/view/view.ts b/src/view/view.ts index 9411f86c..10e41f5d 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -2,7 +2,6 @@ // - TODO: Add garbage collecting of segments not present in the recording summaries anymore. // - TODO: Move frigate specific view-media under the camera manager. // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript -// - TODO: Engine should not need cameraConfig to favorite since it already has cameras. // Hard: // - TODO: Implement dragging the timeline seeking forward in both Frigate recordings & events. From 36f14e153c53b91f9132c17933dcffa9bd4fda3a Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 8 Jan 2023 19:32:13 -0800 Subject: [PATCH 23/74] Add segment garbage collection. --- src/camera/cache.ts | 29 +++++++++ src/camera/frigate/engine-frigate.ts | 88 ++++++++++++++++++++++++++-- src/camera/frigate/types.ts | 7 +-- src/view/view.ts | 10 ++-- 4 files changed, 118 insertions(+), 16 deletions(-) diff --git a/src/camera/cache.ts b/src/camera/cache.ts index 9679d31b..a0d997e7 100644 --- a/src/camera/cache.ts +++ b/src/camera/cache.ts @@ -103,6 +103,20 @@ export class MemoryRangedCache { } return output; } + + public size(): number { + return this._data.length; + } + + /** + * Remove old data that matches a given predicate. No change to the covered + * ranges is made, i.e. this is asserting authoritiatively that this data does + * not exist in the current ranges. + * @param predicate A predicate to run on each data element. + */ + public expireMatches(predicate: (data: Data) => boolean): void { + this._data = this._data.filter(predicate); + } } export class RecordingSegmentsCache { @@ -128,4 +142,19 @@ export class RecordingSegmentsCache { public get(cameraID: string, range: DateRange): RecordingSegment[] | null { return this._segments.get(cameraID)?.get(range) ?? null; } + + public getCache(cameraID: string): MemoryRangedCache | null { + return this._segments.get(cameraID) ?? null; + } + + public getCameraIDs(): string[] { + return [...this._segments.keys()]; + } + + public expireMatches( + cameraID: string, + func: (segment: RecordingSegment) => boolean, + ): void { + this._segments.get(cameraID)?.expireMatches(func); + } } diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index cb78ed42..04552145 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -1,10 +1,9 @@ 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 } from '../../types'; +import { CameraConfig, RecordingSegment } from '../../types'; import { MediaQueriesResults } from '../../view/media-queries-results'; import { MediaQueriesClassifier } from '../../view/media-queries-classifier'; import { ViewMedia, ViewMediaFactory } from '../../view/media'; @@ -42,6 +41,10 @@ import { } from './requests'; import { MediaQueries } from '../../view/media-queries'; import orderBy from 'lodash-es/orderBy'; +import throttle from 'lodash-es/throttle'; +import { runWhenIdleIfSupported } from '../../utils/basic'; +import { fromUnixTime } from 'date-fns'; +import { sum } from 'lodash-es'; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; @@ -74,6 +77,13 @@ class FrigateQueryResultsClassifier { export class FrigateCameraManagerEngine implements CameraManagerEngine { protected _recordingSegmentsCache: RecordingSegmentsCache; + // Garbage collect segments at most once an hour. + protected _throttledSegmentGarbageCollector = throttle( + this._garbageCollectSegments.bind(this), + 60 * 60 * 1000, + { trailing: true }, + ); + constructor(recordingSegmentsCache: RecordingSegmentsCache) { this._recordingSegmentsCache = recordingSegmentsCache; } @@ -220,9 +230,9 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { (!query.end || endHour <= query.end) ) { recordings.push({ - camera: cameraConfig.frigate.camera_name, - start_time: getUnixTime(startHour), - end_time: getUnixTime(endHour), + cameraID: query.cameraID, + startTime: startHour, + endTime: endHour, events: hourData.events, }); } @@ -234,7 +244,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { // this simulates it. recordings = orderBy( recordings, - (recording: FrigateRecording) => recording.start_time, + (recording: FrigateRecording) => recording.startTime, 'desc', ).slice(0, query.limit); } @@ -287,6 +297,8 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { const segments = await getRecordingSegments(hass, request); this._recordingSegmentsCache.add(query.cameraID, range, segments); + runWhenIdleIfSupported(() => this._throttledSegmentGarbageCollector(hass, cameras)); + return { type: QueryResultsType.RecordingSegments, engine: Engine.Frigate, @@ -381,4 +393,68 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } return cameraConfig; } + + /** + * Garbage collect recording segments that no longer feature in the recordings + * returned by the Frigate backend. + */ + protected async _garbageCollectSegments( + hass: HomeAssistant, + cameras: Map, + ): Promise { + const cameraIDs = this._recordingSegmentsCache.getCameraIDs(); + const recordingQueries: RecordingQuery[] = cameraIDs.map((cameraID) => ({ + cameraID: cameraID, + type: QueryType.Recording, + })); + + const countSegments = () => + sum( + cameraIDs.map( + (cameraID) => this._recordingSegmentsCache.getCache(cameraID)?.size() ?? 0, + ), + ); + const segmentsStart = countSegments(); + + const results: Map = new Map(); + + await Promise.all( + recordingQueries.map((query) => + (async () => { + const recordings = await this.getRecordings(hass, cameras, query); + if (recordings && recordings.engine === Engine.Frigate) { + results.set(query, recordings as FrigateRecordingQueryResults); + } + })(), + ), + ); + + // 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, startTime: Date): string => { + return `${cameraID}/${startTime.getDate()}/${startTime.getHours()}`; + }; + + for (const [query, result] of results) { + const goodHours: Set = new Set(); + for (const recording of result.recordings) { + goodHours.add(getHourID(recording.cameraID, recording.startTime)); + } + + this._recordingSegmentsCache.expireMatches( + query.cameraID, + (segment: RecordingSegment) => { + const hourID = getHourID(query.cameraID, fromUnixTime(segment.start_time)); + // ~O(1) lookup time for a JS set. + return goodHours.has(hourID); + }, + ); + } + + console.debug( + 'Frigate Card recording segment garbage collection: ' + + `Released ${segmentsStart - countSegments()} segment(s)`, + ); + } } diff --git a/src/camera/frigate/types.ts b/src/camera/frigate/types.ts index 26608e77..81135e91 100644 --- a/src/camera/frigate/types.ts +++ b/src/camera/frigate/types.ts @@ -50,9 +50,8 @@ export const retainResultSchema = z.object({ export type RetainResult = z.infer; export interface FrigateRecording { - // Frigate camera name (may not be unique) - camera: string; - start_time: number; - end_time: number; + cameraID: string; + startTime: Date; + endTime: Date; events: number; } \ No newline at end of file diff --git a/src/view/view.ts b/src/view/view.ts index 10e41f5d..f358aad2 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,7 +1,7 @@ // Medium: -// - TODO: Add garbage collecting of segments not present in the recording summaries anymore. // - TODO: Move frigate specific view-media under the camera manager. // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript +// - TODO: Use sortedUniqBy in the engine sort/uniq combo. // Hard: // - TODO: Implement dragging the timeline seeking forward in both Frigate recordings & events. @@ -85,8 +85,9 @@ export class View { !curr || prev.view !== curr.view || prev.camera !== curr.camera || - // When in the live view, the target contains the events that happened in - // the past -- not reflective of the actual live media viewer. + // When in the live view, the queryResults contain the events that + // happened in the past -- not reflective of the actual live media viewer + // the user is seeing. (curr.view !== 'live' && (prev.queryResults !== curr.queryResults || prev.queryResults?.getSelectedResult() !== @@ -103,9 +104,6 @@ export class View { camera: this.camera, query: this.query?.clone() ?? null, queryResults: this.queryResults?.clone() ?? null, - // target: this.target, - // targetIndex: this.targetIndex, - // targetFingerprint: this.targetFingerprint, context: this.context, }); } From f073f0678935e5a608f5e672e01acef0933b0dec Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 8 Jan 2023 20:17:33 -0800 Subject: [PATCH 24/74] Refactor Frigate specific details out of ViewMediaBase. --- src/camera/frigate/engine-frigate.ts | 16 ++- src/camera/frigate/media-classifier.ts | 18 +++ src/camera/frigate/media.ts | 164 +++++++++++++++++++++++ src/camera/frigate/util.ts | 21 +-- src/components/thumbnail.ts | 4 +- src/components/viewer.ts | 7 +- src/utils/ha/browse-media.ts | 44 ++++--- src/view/media-classifier.ts | 27 +--- src/view/media.ts | 176 +------------------------ 9 files changed, 243 insertions(+), 234 deletions(-) create mode 100644 src/camera/frigate/media-classifier.ts create mode 100644 src/camera/frigate/media.ts diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 04552145..c27d3297 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -6,8 +6,7 @@ import { CAMERA_BIRDSEYE } from '../../const'; import { CameraConfig, RecordingSegment } from '../../types'; import { MediaQueriesResults } from '../../view/media-queries-results'; import { MediaQueriesClassifier } from '../../view/media-queries-classifier'; -import { ViewMedia, ViewMediaFactory } from '../../view/media'; -import { ViewMediaClassifier } from '../../view/media-classifier'; +import { ViewMedia } from '../../view/media'; import { RecordingSegmentsCache } from '../cache'; import { CameraManagerEngine, @@ -45,6 +44,9 @@ import throttle from 'lodash-es/throttle'; import { runWhenIdleIfSupported } from '../../utils/basic'; import { fromUnixTime } from 'date-fns'; import { sum } from 'lodash-es'; +import { FrigateViewMediaClassifier } from './media-classifier'; +import { ViewMediaClassifier } from '../../view/media-classifier'; +import { FrigateViewMediaFactory } from './media'; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; @@ -93,13 +95,13 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { media: ViewMedia, ): string | null { let path: string | null = null; - if (ViewMediaClassifier.isFrigateEvent(media)) { + if (FrigateViewMediaClassifier.isFrigateEvent(media)) { path = `/api/frigate/${cameraConfig.frigate.client_id}` + `/notifications/${media.getID()}/` + `${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` + `?download=true`; - } else if (ViewMediaClassifier.isFrigateRecording(media)) { + } else if (FrigateViewMediaClassifier.isFrigateRecording(media)) { path = `/api/frigate/${cameraConfig.frigate.client_id}` + `/recording/${cameraConfig.frigate.camera_name}` + @@ -160,7 +162,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { favorite: boolean, ): Promise { const clientID = cameraConfig.frigate.client_id; - if (!ViewMediaClassifier.isFrigateEvent(media)) { + if (!FrigateViewMediaClassifier.isFrigateEvent(media)) { return; } @@ -331,7 +333,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { if (!mediaType) { continue; } - const media = ViewMediaFactory.createViewMediaFromFrigateEvent( + const media = FrigateViewMediaFactory.createEventViewMedia( mediaType, query.cameraID, event, @@ -353,7 +355,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { const output: ViewMedia[] = []; for (const recording of results.recordings) { - const media = ViewMediaFactory.createViewMediaFromFrigateRecording( + const media = FrigateViewMediaFactory.createRecordingViewMedia( query.cameraID, recording, ); diff --git a/src/camera/frigate/media-classifier.ts b/src/camera/frigate/media-classifier.ts new file mode 100644 index 00000000..442d6b0d --- /dev/null +++ b/src/camera/frigate/media-classifier.ts @@ -0,0 +1,18 @@ +import { ViewMedia } from '../../view/media'; +import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media'; + +export class FrigateViewMediaClassifier { + 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; + } +} diff --git a/src/camera/frigate/media.ts b/src/camera/frigate/media.ts new file mode 100644 index 00000000..b58b8c07 --- /dev/null +++ b/src/camera/frigate/media.ts @@ -0,0 +1,164 @@ +import fromUnixTime from 'date-fns/fromUnixTime'; +import isEqual from 'lodash-es/isEqual'; +import { CameraConfig } from '../../types'; +import { ViewMediaBase, EventViewMedia, RecordingViewMedia } from '../../view/media'; +import { FrigateEvent, FrigateRecording } from './types'; +import { + getEventMediaContentID, + getEventThumbnailURL, + getEventTitle, + getRecordingMediaContentID, + getRecordingTitle, +} from './util'; + +export class FrigateEventViewMedia + extends ViewMediaBase + implements EventViewMedia +{ + public hasClip(): boolean { + return !!this._source.has_clip; + } + 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._mediaType === 'clip' ? '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; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public isGroupableWith(that: EventViewMedia): boolean { + return ( + this.getMediaType() === that.getMediaType() && + isEqual(this.getWhere(), that.getWhere()) && + isEqual(this.getWhat(), that.getWhat()) + ); + } + + public getClipEquivalent(): EventViewMedia | null { + if (!this.hasClip()) { + return null; + } + return FrigateViewMediaFactory.createEventViewMedia( + 'clip', + this._cameraID, + this._source, + ); + } +} + +export class FrigateRecordingViewMedia + extends ViewMediaBase + implements RecordingViewMedia +{ + 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.startTime.getTime()}/${this._source.endTime.getTime()}}`; + } + return null; + } + public getStartTime(): Date { + return this._source.startTime; + } + public getEndTime(): Date { + return this._source.endTime; + } + 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 { + if (!cameraConfig) { + return null; + } + return getRecordingTitle(cameraConfig, this._source); + } + public getEventCount(): number { + return this._source.events; + } +} + +export class FrigateViewMediaFactory { + static createEventViewMedia( + type: 'clip' | 'snapshot', + cameraID: string, + event: FrigateEvent, + ): FrigateEventViewMedia | null { + if ( + (type === 'clip' && event.has_clip) || + (type === 'snapshot' && event.has_snapshot) + ) { + return new FrigateEventViewMedia(type, cameraID, event); + } + return null; + } + + static createRecordingViewMedia( + cameraID: string, + recording: FrigateRecording, + ): FrigateRecordingViewMedia | null { + return new FrigateRecordingViewMedia('recording', cameraID, recording); + } +} diff --git a/src/camera/frigate/util.ts b/src/camera/frigate/util.ts index 42a8becd..112ba4ff 100644 --- a/src/camera/frigate/util.ts +++ b/src/camera/frigate/util.ts @@ -1,6 +1,5 @@ import utcToZonedTime from 'date-fns-tz/utcToZonedTime'; -import fromUnixTime from 'date-fns/fromUnixTime'; -import { ClipsOrSnapshots } from '../../types'; +import { CameraConfig, ClipsOrSnapshots } from '../../types'; import { formatDateAndTime, prettifyTitle } from '../../utils/basic'; import { FrigateEvent, FrigateRecording } from './types'; @@ -22,9 +21,12 @@ export const getEventTitle = (event: FrigateEvent): string => { )}%]`; }; -export const getRecordingTitle = (recording: FrigateRecording): string => { - return `${prettifyTitle(recording.camera)} ${formatDateAndTime( - fromUnixTime(recording.start_time), +export const getRecordingTitle = ( + cameraConfig: CameraConfig, + recording: FrigateRecording, +): string => { + return `${prettifyTitle(cameraConfig.frigate.camera_name)} ${formatDateAndTime( + recording.startTime, )}`; }; @@ -67,15 +69,16 @@ export const getRecordingMediaContentID = ( cameraName: string, recording: FrigateRecording, ): string => { - const date = fromUnixTime(recording.start_time); return [ 'media-source://frigate', clientId, 'recordings', cameraName, - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String( - String(date.getDate()).padStart(2, '0'), + `${recording.startTime.getFullYear()}-${String( + recording.startTime.getMonth() + 1, + ).padStart(2, '0')}-${String( + String(recording.startTime.getDate()).padStart(2, '0'), )}`, - String(date.getHours()).padStart(2, '0'), + String(recording.startTime.getHours()).padStart(2, '0'), ].join('/'); }; diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index f7b3aeee..531b6c82 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -128,7 +128,7 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement { @customElement('frigate-card-thumbnail-details-event') export class FrigateCardThumbnailDetailsEvent extends LitElement { @property({ attribute: false }) - public media?: EventViewMedia; + public media?: EventViewMedia; @property({ attribute: false }) public mediaSeek?: MediaSeek; @@ -180,7 +180,7 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement { @customElement('frigate-card-thumbnail-details-recording') export class FrigateCardThumbnailDetailsRecording extends LitElement { @property({ attribute: false }) - public media?: RecordingViewMedia; + public media?: RecordingViewMedia; @property({ attribute: false }) public mediaSeek?: MediaSeek; diff --git a/src/components/viewer.ts b/src/components/viewer.ts index e8fc69f6..1d697e4b 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -374,8 +374,11 @@ export class FrigateCardViewerCarousel extends LitElement { !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() || + !ViewMediaClassifier.isEvent(media) || + // If the event certainly has no clip, don't bother going further. If + // we're not sure for this camera type (i.e. hasClip() === null) the query + // will proceed anyway. + media.hasClip() === false || !MediaQueriesClassifier.areEventQueries(this.view.query) ) { return; diff --git a/src/utils/ha/browse-media.ts b/src/utils/ha/browse-media.ts index e824e353..f744c870 100644 --- a/src/utils/ha/browse-media.ts +++ b/src/utils/ha/browse-media.ts @@ -179,7 +179,8 @@ export const mergeFrigateBrowseMediaSources = async ( return createEventParentForChildren( 'Merged events', - children.sort(sortYoungestToOldest), + children + // TODO: Restore sort or remove: children.sort(sortYoungestToOldest), ); }; @@ -350,25 +351,26 @@ export const createChild = ( return result; }; -/** - * 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: FrigateBrowseMediaSource, - b: FrigateBrowseMediaSource, -): number => { - const a_source = a.frigate?.event ?? a.frigate?.recording; - const b_source = b.frigate?.event ?? b.frigate?.recording; +// TODO Remove or fix this if not needed post gallery. +// /** +// * 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: FrigateBrowseMediaSource, +// b: FrigateBrowseMediaSource, +// ): number => { +// 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)) { - return 1; - } +// 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)) { - return -1; - } - return 0; -}; +// if (!b_source || (a_source && b_source.start_time < a_source.start_time)) { +// return -1; +// } +// return 0; +// }; diff --git a/src/view/media-classifier.ts b/src/view/media-classifier.ts index 6f91af21..ab71290d 100644 --- a/src/view/media-classifier.ts +++ b/src/view/media-classifier.ts @@ -1,29 +1,10 @@ -import { - ViewMedia, - FrigateEventViewMedia, - FrigateRecordingViewMedia, - RecordingViewMedia, - EventViewMedia, -} from './media'; +import { ViewMedia, RecordingViewMedia, EventViewMedia } from './media'; 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; - } - public static isEvent(media: ViewMedia): media is EventViewMedia { + public static isEvent(media: ViewMedia): media is EventViewMedia { return this.isClip(media) || this.isSnapshot(media); } - public static isRecording(media: ViewMedia): media is RecordingViewMedia { + public static isRecording(media: ViewMedia): media is RecordingViewMedia { return media.getMediaType() === 'recording'; } public static isClip(media: ViewMedia): boolean { @@ -33,6 +14,6 @@ export class ViewMediaClassifier { return media.getMediaType() === 'snapshot'; } public static isVideo(media: ViewMedia): boolean { - return this.isClip(media) || this.isRecording(media); + return this.isClip(media) || this.isRecording(media); } } diff --git a/src/view/media.ts b/src/view/media.ts index 17119fb2..6b87f65d 100644 --- a/src/view/media.ts +++ b/src/view/media.ts @@ -1,19 +1,7 @@ -import fromUnixTime from 'date-fns/fromUnixTime'; -import isEqual from 'lodash-es/isEqual'; import { CameraConfig } from '../types.js'; -import { - getEventMediaContentID, - getEventThumbnailURL, - getEventTitle, - getRecordingMediaContentID, - getRecordingTitle, -} from '../camera/frigate/util.js'; -import { FrigateEvent, FrigateRecording } from '../camera/frigate/types.js'; - export type ViewMediaType = 'clip' | 'snapshot' | 'recording'; -export type ViewMediaSourceType = FrigateEvent | FrigateRecording; -class ViewMediaBase { +export class ViewMediaBase { protected _mediaType: ViewMediaType; protected _cameraID: string; protected _source: T; @@ -72,167 +60,15 @@ class ViewMediaBase { } } -export interface EventViewMedia extends ViewMedia { +export interface EventViewMedia extends ViewMediaBase { getScore(): number | null; getWhat(): string[] | null; - isGroupableWith(that: EventViewMedia): boolean; + isGroupableWith(that: EventViewMedia): boolean; + hasClip(): boolean | null; } -export interface RecordingViewMedia extends ViewMedia { +export interface RecordingViewMedia extends ViewMediaBase { getEventCount(): number | 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 FrigateEventViewMedia - extends ViewMediaBase - implements EventViewMedia -{ - 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._mediaType === 'clip' ? '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; - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public isGroupableWith(that: EventViewMedia): boolean { - return ( - this.getMediaType() === that.getMediaType() && - isEqual(this.getWhere(), that.getWhere()) && - isEqual(this.getWhat(), that.getWhat()) - ); - } -} - -export class FrigateRecordingViewMedia - extends ViewMediaBase - implements RecordingViewMedia -{ - 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); - } -} +export type ViewMedia = ViewMediaBase; From 09c38d96f60712dffccee0b85c6c882d987992d5 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 8 Jan 2023 20:24:37 -0800 Subject: [PATCH 25/74] Fix TODO items. --- src/camera/frigate/engine-frigate.ts | 2 +- src/view/view.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index c27d3297..0d23839f 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -83,7 +83,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { protected _throttledSegmentGarbageCollector = throttle( this._garbageCollectSegments.bind(this), 60 * 60 * 1000, - { trailing: true }, + { leading: false, trailing: true }, ); constructor(recordingSegmentsCache: RecordingSegmentsCache) { diff --git a/src/view/view.ts b/src/view/view.ts index f358aad2..bcc83f77 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,12 +1,12 @@ // Medium: -// - TODO: Move frigate specific view-media under the camera manager. // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript -// - TODO: Use sortedUniqBy in the engine sort/uniq combo. +// - TODO: getRecordingTitle should use getCameraTitle but need hass. // Hard: // - TODO: Implement dragging the timeline seeking forward in both Frigate recordings & events. // - TODO: Implement gallery. // - TODO: Remove FrigateBrowseMediaSource if not necessary (post-gallery). +// - TODO: Remove browse-media.ts TODOs. // - 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: 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? From 7896dfeb3cef27a28334e76859ffd417329b77c1 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 8 Jan 2023 20:31:38 -0800 Subject: [PATCH 26/74] Rip out unnecessary view media templating. --- src/camera/frigate/media.ts | 73 +++++++++++++++++++++--------------- src/components/thumbnail.ts | 4 +- src/view/media-classifier.ts | 4 +- src/view/media.ts | 17 +++------ 4 files changed, 52 insertions(+), 46 deletions(-) diff --git a/src/camera/frigate/media.ts b/src/camera/frigate/media.ts index b58b8c07..89965b8e 100644 --- a/src/camera/frigate/media.ts +++ b/src/camera/frigate/media.ts @@ -1,7 +1,12 @@ import fromUnixTime from 'date-fns/fromUnixTime'; import isEqual from 'lodash-es/isEqual'; import { CameraConfig } from '../../types'; -import { ViewMediaBase, EventViewMedia, RecordingViewMedia } from '../../view/media'; +import { + ViewMedia, + EventViewMedia, + RecordingViewMedia, + ViewMediaType, +} from '../../view/media'; import { FrigateEvent, FrigateRecording } from './types'; import { getEventMediaContentID, @@ -11,22 +16,26 @@ import { getRecordingTitle, } from './util'; -export class FrigateEventViewMedia - extends ViewMediaBase - implements EventViewMedia -{ +export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia { + protected _event: FrigateEvent; + + constructor(mediaType: ViewMediaType, cameraID: string, event: FrigateEvent) { + super(mediaType, cameraID); + this._event = event; + } + public hasClip(): boolean { - return !!this._source.has_clip; + return !!this._event.has_clip; } public getStartTime(): Date { - return fromUnixTime(this._source.start_time); + return fromUnixTime(this._event.start_time); } public getEndTime(): Date | null { - return this._source.end_time ? fromUnixTime(this._source.end_time) : null; + return this._event.end_time ? fromUnixTime(this._event.end_time) : null; } // eslint-disable-next-line @typescript-eslint/no-unused-vars public getID(_cameraConfig?: CameraConfig): string { - return this._source.id; + return this._event.id; } public getContentID(cameraConfig?: CameraConfig): string | null { if ( @@ -39,41 +48,41 @@ export class FrigateEventViewMedia return getEventMediaContentID( cameraConfig.frigate.client_id, cameraConfig.frigate.camera_name, - this._source, + this._event, this._mediaType === 'clip' ? 'clips' : 'snapshots', ); } // eslint-disable-next-line @typescript-eslint/no-unused-vars public getTitle(_cameraConfig?: CameraConfig): string | null { - return getEventTitle(this._source); + return getEventTitle(this._event); } public getThumbnail(cameraConfig?: CameraConfig): string | null { if (cameraConfig?.frigate.client_id) { - return getEventThumbnailURL(cameraConfig.frigate.client_id, this._source); + return getEventThumbnailURL(cameraConfig.frigate.client_id, this._event); } return null; } public isFavorite(): boolean | null { - return this._source.retain_indefinitely ?? null; + return this._event.retain_indefinitely ?? null; } public setFavorite(favorite: boolean): void { - this._source.retain_indefinitely = favorite; + this._event.retain_indefinitely = favorite; } public getWhat(): string[] | null { - return [this._source.label]; + return [this._event.label]; } public getWhere(): string[] | null { - const zones = this._source.zones; + const zones = this._event.zones; return zones.length ? zones : null; } public getScore(): number | null { - return this._source.top_score; + return this._event.top_score; } // eslint-disable-next-line @typescript-eslint/no-unused-vars - public isGroupableWith(that: EventViewMedia): boolean { + public isGroupableWith(that: EventViewMedia): boolean { return ( this.getMediaType() === that.getMediaType() && isEqual(this.getWhere(), that.getWhere()) && @@ -81,22 +90,26 @@ export class FrigateEventViewMedia ); } - public getClipEquivalent(): EventViewMedia | null { + public getClipEquivalent(): EventViewMedia | null { if (!this.hasClip()) { return null; } return FrigateViewMediaFactory.createEventViewMedia( 'clip', this._cameraID, - this._source, + this._event, ); } } -export class FrigateRecordingViewMedia - extends ViewMediaBase - implements RecordingViewMedia -{ +export class FrigateRecordingViewMedia extends ViewMedia implements RecordingViewMedia { + protected _recording: FrigateRecording; + + constructor(mediaType: ViewMediaType, cameraID: string, recording: FrigateRecording) { + super(mediaType, cameraID); + this._recording = recording; + } + 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 @@ -104,15 +117,15 @@ export class FrigateRecordingViewMedia if (cameraConfig) { return `${cameraConfig.frigate?.client_id ?? ''}/${ cameraConfig.frigate.camera_name ?? '' - }/${this._source.startTime.getTime()}/${this._source.endTime.getTime()}}`; + }/${this._recording.startTime.getTime()}/${this._recording.endTime.getTime()}}`; } return null; } public getStartTime(): Date { - return this._source.startTime; + return this._recording.startTime; } public getEndTime(): Date { - return this._source.endTime; + return this._recording.endTime; } public getContentID(cameraConfig?: CameraConfig): string | null { if ( @@ -125,7 +138,7 @@ export class FrigateRecordingViewMedia return getRecordingMediaContentID( cameraConfig.frigate.client_id, cameraConfig.frigate.camera_name, - this._source, + this._recording, ); } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -133,10 +146,10 @@ export class FrigateRecordingViewMedia if (!cameraConfig) { return null; } - return getRecordingTitle(cameraConfig, this._source); + return getRecordingTitle(cameraConfig, this._recording); } public getEventCount(): number { - return this._source.events; + return this._recording.events; } } diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 531b6c82..f7b3aeee 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -128,7 +128,7 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement { @customElement('frigate-card-thumbnail-details-event') export class FrigateCardThumbnailDetailsEvent extends LitElement { @property({ attribute: false }) - public media?: EventViewMedia; + public media?: EventViewMedia; @property({ attribute: false }) public mediaSeek?: MediaSeek; @@ -180,7 +180,7 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement { @customElement('frigate-card-thumbnail-details-recording') export class FrigateCardThumbnailDetailsRecording extends LitElement { @property({ attribute: false }) - public media?: RecordingViewMedia; + public media?: RecordingViewMedia; @property({ attribute: false }) public mediaSeek?: MediaSeek; diff --git a/src/view/media-classifier.ts b/src/view/media-classifier.ts index ab71290d..be9668c1 100644 --- a/src/view/media-classifier.ts +++ b/src/view/media-classifier.ts @@ -1,10 +1,10 @@ import { ViewMedia, RecordingViewMedia, EventViewMedia } from './media'; export class ViewMediaClassifier { - public static isEvent(media: ViewMedia): media is EventViewMedia { + public static isEvent(media: ViewMedia): media is EventViewMedia { return this.isClip(media) || this.isSnapshot(media); } - public static isRecording(media: ViewMedia): media is RecordingViewMedia { + public static isRecording(media: ViewMedia): media is RecordingViewMedia { return media.getMediaType() === 'recording'; } public static isClip(media: ViewMedia): boolean { diff --git a/src/view/media.ts b/src/view/media.ts index 6b87f65d..0d051e3f 100644 --- a/src/view/media.ts +++ b/src/view/media.ts @@ -1,15 +1,13 @@ import { CameraConfig } from '../types.js'; export type ViewMediaType = 'clip' | 'snapshot' | 'recording'; -export class ViewMediaBase { +export class ViewMedia { protected _mediaType: ViewMediaType; protected _cameraID: string; - protected _source: T; - constructor(mediaType: ViewMediaType, cameraID: string, source: T) { + constructor(mediaType: ViewMediaType, cameraID: string) { this._mediaType = mediaType; this._cameraID = cameraID; - this._source = source; } public getContentType(): 'image' | 'video' { return this._mediaType === 'snapshot' ? 'image' : 'video'; @@ -20,9 +18,6 @@ export class ViewMediaBase { public getMediaType(): ViewMediaType { return this._mediaType; } - public getSource(): T { - return this._source; - } // eslint-disable-next-line @typescript-eslint/no-unused-vars public getID(_cameraConfig?: CameraConfig): string | null { return null; @@ -60,15 +55,13 @@ export class ViewMediaBase { } } -export interface EventViewMedia extends ViewMediaBase { +export interface EventViewMedia extends ViewMedia { getScore(): number | null; getWhat(): string[] | null; - isGroupableWith(that: EventViewMedia): boolean; + isGroupableWith(that: EventViewMedia): boolean; hasClip(): boolean | null; } -export interface RecordingViewMedia extends ViewMediaBase { +export interface RecordingViewMedia extends ViewMedia { getEventCount(): number | null; } - -export type ViewMedia = ViewMediaBase; From a718e5febefb0014553ad4b74828f7037e548339 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 8 Jan 2023 21:30:20 -0800 Subject: [PATCH 27/74] Move more static information into the media. --- src/camera/engine-factory.ts | 6 +- src/camera/engine.ts | 2 + src/camera/frigate/engine-frigate.ts | 7 +- src/camera/frigate/media.ts | 148 ++++++++++++++------------- src/camera/frigate/util.ts | 15 +++ src/camera/manager.ts | 12 ++- src/card.ts | 6 +- src/components/thumbnail.ts | 7 +- src/view/media.ts | 13 +-- 9 files changed, 119 insertions(+), 97 deletions(-) diff --git a/src/camera/engine-factory.ts b/src/camera/engine-factory.ts index c249829e..7e367d6f 100644 --- a/src/camera/engine-factory.ts +++ b/src/camera/engine-factory.ts @@ -32,7 +32,11 @@ export class CameraManagerEngineFactory { return cameraConfig ? this.getEngineForCamera(cameraConfig) : null; } - public getEngineForCamera(cameraConfig: CameraConfig): CameraManagerEngine | null { + public getEngineForCamera(cameraConfig?: CameraConfig): CameraManagerEngine | null { + if (!cameraConfig) { + return null; + } + let engineKey: string | null = null; if (cameraConfig.frigate.camera_name) { engineKey = 'frigate'; diff --git a/src/camera/engine.ts b/src/camera/engine.ts index a4f9c554..21513008 100644 --- a/src/camera/engine.ts +++ b/src/camera/engine.ts @@ -53,11 +53,13 @@ export interface CameraManagerEngine { ): Promise | null>; generateMediaFromEvents( + cameraConfig: CameraConfig, query: EventQuery, results: QueryReturnType, ): ViewMedia[] | null; generateMediaFromRecordings( + cameraConfig: CameraConfig, query: RecordingQuery, results: QueryReturnType, ): ViewMedia[] | null; diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 0d23839f..07ddcd2b 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -161,12 +161,11 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { media: ViewMedia, favorite: boolean, ): Promise { - const clientID = cameraConfig.frigate.client_id; if (!FrigateViewMediaClassifier.isFrigateEvent(media)) { return; } - await retainEvent(hass, clientID, media.getID(cameraConfig), favorite); + await retainEvent(hass, cameraConfig.frigate.client_id, media.getID(), favorite); media.setFavorite(favorite); } @@ -309,6 +308,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } public generateMediaFromEvents( + cameraConfig: CameraConfig, query: EventQuery, results: QueryReturnType, ): ViewMedia[] | null { @@ -336,6 +336,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { const media = FrigateViewMediaFactory.createEventViewMedia( mediaType, query.cameraID, + cameraConfig, event, ); if (media) { @@ -346,6 +347,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } public generateMediaFromRecordings( + cameraConfig: CameraConfig, query: RecordingQuery, results: QueryReturnType, ): ViewMedia[] | null { @@ -358,6 +360,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { const media = FrigateViewMediaFactory.createRecordingViewMedia( query.cameraID, recording, + cameraConfig, ); if (media) { output.push(media); diff --git a/src/camera/frigate/media.ts b/src/camera/frigate/media.ts index 89965b8e..46fd38ad 100644 --- a/src/camera/frigate/media.ts +++ b/src/camera/frigate/media.ts @@ -12,16 +12,27 @@ import { getEventMediaContentID, getEventThumbnailURL, getEventTitle, + getRecordingID, getRecordingMediaContentID, getRecordingTitle, } from './util'; export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia { protected _event: FrigateEvent; + protected _contentID: string; + protected _thumbnail: string; - constructor(mediaType: ViewMediaType, cameraID: string, event: FrigateEvent) { + constructor( + mediaType: ViewMediaType, + cameraID: string, + event: FrigateEvent, + contentID: string, + thumbnail: string, + ) { super(mediaType, cameraID); this._event = event; + this._contentID = contentID; + this._thumbnail = thumbnail; } public hasClip(): boolean { @@ -33,36 +44,17 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia { public getEndTime(): Date | null { return this._event.end_time ? fromUnixTime(this._event.end_time) : null; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public getID(_cameraConfig?: CameraConfig): string { + public getID(): string { return this._event.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._event, - this._mediaType === 'clip' ? 'clips' : 'snapshots', - ); + public getContentID(): string { + return this._contentID; } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public getTitle(_cameraConfig?: CameraConfig): string | null { + public getTitle(): string | null { return getEventTitle(this._event); } - - public getThumbnail(cameraConfig?: CameraConfig): string | null { - if (cameraConfig?.frigate.client_id) { - return getEventThumbnailURL(cameraConfig.frigate.client_id, this._event); - } - return null; + public getThumbnail(): string | null { + return this._thumbnail; } public isFavorite(): boolean | null { return this._event.retain_indefinitely ?? null; @@ -89,37 +81,31 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia { isEqual(this.getWhat(), that.getWhat()) ); } - - public getClipEquivalent(): EventViewMedia | null { - if (!this.hasClip()) { - return null; - } - return FrigateViewMediaFactory.createEventViewMedia( - 'clip', - this._cameraID, - this._event, - ); - } } export class FrigateRecordingViewMedia extends ViewMedia implements RecordingViewMedia { protected _recording: FrigateRecording; + protected _id: string; + protected _contentID: string; + protected _title: string; - constructor(mediaType: ViewMediaType, cameraID: string, recording: FrigateRecording) { + constructor( + mediaType: ViewMediaType, + cameraID: string, + recording: FrigateRecording, + id: string, + contentID: string, + title: string, + ) { super(mediaType, cameraID); this._recording = recording; + this._id = id; + this._contentID = contentID; + this._title = title; } - 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._recording.startTime.getTime()}/${this._recording.endTime.getTime()}}`; - } - return null; + public getID(): string { + return this._id; } public getStartTime(): Date { return this._recording.startTime; @@ -127,26 +113,11 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie public getEndTime(): Date { return this._recording.endTime; } - 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._recording, - ); + public getContentID(): string | null { + return this._contentID; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public getTitle(cameraConfig?: CameraConfig): string | null { - if (!cameraConfig) { - return null; - } - return getRecordingTitle(cameraConfig, this._recording); + public getTitle(): string | null { + return this._title; } public getEventCount(): number { return this._recording.events; @@ -155,23 +126,54 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie export class FrigateViewMediaFactory { static createEventViewMedia( - type: 'clip' | 'snapshot', + mediaType: 'clip' | 'snapshot', cameraID: string, + cameraConfig: CameraConfig, event: FrigateEvent, ): FrigateEventViewMedia | null { if ( - (type === 'clip' && event.has_clip) || - (type === 'snapshot' && event.has_snapshot) + (mediaType === 'clip' && !event.has_clip) || + (mediaType === 'snapshot' && !event.has_snapshot) || + !cameraConfig.frigate.client_id || + !cameraConfig.frigate.camera_name ) { - return new FrigateEventViewMedia(type, cameraID, event); + return null; } - return null; + + return new FrigateEventViewMedia( + mediaType, + cameraID, + event, + getEventMediaContentID( + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + event, + mediaType === 'clip' ? 'clips' : 'snapshots', + ), + getEventThumbnailURL(cameraConfig.frigate.client_id, event), + ); } static createRecordingViewMedia( cameraID: string, recording: FrigateRecording, + cameraConfig: CameraConfig, ): FrigateRecordingViewMedia | null { - return new FrigateRecordingViewMedia('recording', cameraID, recording); + if (!cameraConfig.frigate.client_id || !cameraConfig.frigate.camera_name) { + return null; + } + + return new FrigateRecordingViewMedia( + 'recording', + cameraID, + recording, + getRecordingID(cameraConfig, recording), + getRecordingMediaContentID( + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + recording, + ), + getRecordingTitle(cameraConfig, recording), + ); } } diff --git a/src/camera/frigate/util.ts b/src/camera/frigate/util.ts index 112ba4ff..b916ada9 100644 --- a/src/camera/frigate/util.ts +++ b/src/camera/frigate/util.ts @@ -82,3 +82,18 @@ export const getRecordingMediaContentID = ( String(recording.startTime.getHours()).padStart(2, '0'), ].join('/'); }; + +/** + * Get a recording ID for internal de-duping. + */ +export const getRecordingID = ( + cameraConfig: CameraConfig, + recording: FrigateRecording, +): string => { + // 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. + return `${cameraConfig.frigate?.client_id ?? ''}/${ + cameraConfig.frigate.camera_name ?? '' + }/${recording.startTime.getTime()}/${recording.endTime.getTime()}}`; +}; diff --git a/src/camera/manager.ts b/src/camera/manager.ts index 1cbddacd..609791bb 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -184,19 +184,21 @@ export class CameraManager { const mediaArray: ViewMedia[] = []; for (const [query, result] of results.entries()) { - const engine = this._engineFactory.getEngineForQuery(this._cameras, query); - if (engine) { + const cameraConfig = this._cameras.get(query.cameraID); + const engine = this._engineFactory.getEngineForCamera(cameraConfig); + + if (engine && cameraConfig) { let media: ViewMedia[] | null = null; if ( QueryClassifier.isEventQuery(query) && QueryResultClassifier.isEventQueryResult(result) ) { - media = engine.generateMediaFromEvents(query, result); + media = engine.generateMediaFromEvents(cameraConfig, query, result); } else if ( QueryClassifier.isRecordingQuery(query) && QueryResultClassifier.isRecordingQuery(result) ) { - media = engine.generateMediaFromRecordings(query, result); + media = engine.generateMediaFromRecordings(cameraConfig, query, result); } if (media) { mediaArray.push(...media); @@ -210,7 +212,7 @@ export class CameraManager { // are assumed to be unique. uniqBy( mediaArray, - (media) => media.getID(this._cameras.get(media.getCameraID())) ?? media, + (media) => media.getID() ?? media, ), // Sort all items leading with the most recent. diff --git a/src/card.ts b/src/card.ts index 049d4ffe..377ac9b7 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1402,10 +1402,10 @@ export class FrigateCard extends LitElement { const media = this._view.queryResults?.getSelectedResult(); if (this._view.isViewerView() && media && this._cameras) { - media_content_id = media.getContentID(cameraConfig); + media_content_id = media.getContentID(); media_content_type = media.getContentType(); - title = media.getTitle(cameraConfig); - thumbnail = media.getThumbnail(cameraConfig); + title = media.getTitle(); + thumbnail = media.getThumbnail(); } else if (this._view?.is('live') && cameraEntity) { media_content_id = `media-source://camera/${cameraEntity}`; media_content_type = 'application/vnd.apple.mpegurl'; diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index f7b3aeee..2065b603 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -256,8 +256,8 @@ export class FrigateCardThumbnail extends LitElement { return; } - const thumbnail = this.media.getThumbnail(this.cameraConfig); - const title = this.media.getTitle(this.cameraConfig) ?? ''; + const thumbnail = this.media.getThumbnail(); + const title = this.media.getTitle() ?? ''; const starClasses = { star: true, @@ -271,7 +271,6 @@ export class FrigateCardThumbnail extends LitElement { // 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` ${ViewMediaClassifier.isEvent(this.media) ? html`` : html``} - ${this.show_favorite_control && this.media && this.hass && clientID + ${this.show_favorite_control && this.media && this.hass ? html` Date: Mon, 9 Jan 2023 11:45:44 -0800 Subject: [PATCH 28/74] Restore initial draft of video seeking. --- src/camera/frigate/engine-frigate.ts | 4 ++ src/camera/manager.ts | 13 ++-- src/camera/types.ts | 1 + src/components/timeline-core.ts | 98 ++++++++++++++++------------ src/view/view.ts | 3 +- 5 files changed, 70 insertions(+), 49 deletions(-) diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 07ddcd2b..646f4ecd 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -197,6 +197,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { engine: Engine.Frigate, events: await getEvents(hass, nativeQuery), expiry: add(new Date(), { seconds: EVENT_REQUEST_CACHE_MAX_AGE_SECONDS }), + cached: false, }; } @@ -257,6 +258,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { expiry: add(new Date(), { seconds: RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS, }), + cached: false, }; } @@ -285,6 +287,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { type: QueryResultsType.RecordingSegments, engine: Engine.Frigate, segments: cachedSegments, + cached: true, }; } @@ -304,6 +307,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { type: QueryResultsType.RecordingSegments, engine: Engine.Frigate, segments: segments, + cached: false, }; } diff --git a/src/camera/manager.ts b/src/camera/manager.ts index 609791bb..c0bcb851 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -210,10 +210,7 @@ export class CameraManager { orderBy( // Ensure uniqueness by the ID (if specified), otherwise all elements // are assumed to be unique. - uniqBy( - mediaArray, - (media) => media.getID() ?? media, - ), + uniqBy(mediaArray, (media) => media.getID() ?? media), // Sort all items leading with the most recent. (media) => media.getStartTime(), @@ -315,9 +312,15 @@ export class CameraManager { result = await engine.getRecordingSegments(hass, this._cameras, query); } + // The engine may independently cached the results. Respect that in our + // debug logging. + if (result?.cached) { + queryCachedCount++; + } + if (result) { if (result.expiry) { - this._requestCache.set(query, result, result.expiry); + this._requestCache.set(query, { ...result, cached: true }, result.expiry); } results.set(query, result as QueryReturnType); } diff --git a/src/camera/types.ts b/src/camera/types.ts index 159f02db..f8a9f831 100644 --- a/src/camera/types.ts +++ b/src/camera/types.ts @@ -45,6 +45,7 @@ export interface QueryResults { type: QueryResultsType; engine: Engine; expiry?: Date; + cached?: boolean; } export type QueryReturnType = QT extends EventQuery diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index f33d2149..3e9c3404 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -47,6 +47,7 @@ import { getAllDependentCameras, getCameraTitle } from '../utils/camera.js'; import { createViewForEvents, createViewForRecordings, + findClosestMediaIndex, generateMediaViewerContext, } from '../utils/media-to-view'; import { CameraManager } from '../camera/manager'; @@ -387,51 +388,66 @@ export class FrigateCardTimelineCore extends LitElement { * @param properties The range change properties. * @returns */ - protected _setViewDuringRangeChange( - _targetTime: Date, - _properties: TimelineRangeChange, - ): void { + protected async _setViewDuringRangeChange( + targetTime: Date, + properties: TimelineRangeChange, + ): Promise { + const results = this.view?.queryResults; + const media = results?.getResults(); if ( + !media || + !results || !this._timeline || !this.view || - // !this.view.target?.length || + !this.hass || + !this.cameraManager || !this.cameraManager ) { return; } - // TODO TODO TODO TODO TODO TODO TODO TODO TODO TODO TODO TODO - // const canSeek = !!this.view?.isViewerView(); - // const context = canSeek - // ? generateMediaViewerContextForChildren( - // this.cameraManager, - // this.view.target, - // targetTime, - // ) - // : null; + const canSeek = !!this.view?.isViewerView(); - // const childIndex = this._locked - // ? null - // : findChildIndex( - // this.view.target.children, - // targetTime, - // this._getTimelineCameraIDs(), - // properties.event.additionalEvent === 'panright' ? 'end' : 'start', - // ); + const context = canSeek + ? await generateMediaViewerContext( + this.hass, + this.cameraManager, + media, + targetTime, + ) + : null; - // 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); - // } + const newResults = this._locked + ? null + : results + .clone() + .resetSelectedResult() + .selectBestResult((media) => + findClosestMediaIndex( + media, + targetTime, + this._getTimelineCameraIDs(), + properties.event.additionalEvent === 'panright' ? 'end' : 'start', + ), + ); + + if ( + canSeek || + (newResults && + newResults.hasSelectedResult() && + newResults.getResult() !== results.getResult()) + ) { + this.view + .evolve({ + ...(newResults && + newResults.hasSelectedResult() && { queryResults: newResults }), + }) // Whether or not to set the timeline window. + .mergeInContext({ + ...this._generateTimelineContext({ noSetWindow: true }), + ...context, + }) + .dispatchChangeEvent(this); + } } /** @@ -537,9 +553,7 @@ export class FrigateCardTimelineCore extends LitElement { ?.clone() .resetSelectedResult() .selectResultIfFound( - (media) => - !!this.cameras && - media.getID(this.cameras.get(media.getCameraID())) === properties.item, + (media) => !!this.cameras && media.getID() === properties.item, ), }); } @@ -662,9 +676,7 @@ export class FrigateCardTimelineCore extends LitElement { ); if (options?.selectedItem) { view.queryResults?.selectResultIfFound( - (media) => - !!this.cameras && - media.getID(this.cameras.get(media.getCameraID())) === options.selectedItem, + (media) => !!this.cameras && media.getID() === options.selectedItem, ); } return view; @@ -799,7 +811,7 @@ export class FrigateCardTimelineCore extends LitElement { } const media = this.view?.queryResults?.getSelectedResult(); - const selectedId = media?.getID(this.cameras.get(media.getCameraID())); + const selectedId = media?.getID(); const firstMedia = (first).media; const secondMedia = (second).media; @@ -810,7 +822,7 @@ export class FrigateCardTimelineCore extends LitElement { first.type !== 'background' && first.type === second.type && first.id !== selectedId && - second.id != selectedId && + second.id !== selectedId && !!firstMedia && !!secondMedia && ViewMediaClassifier.isEvent(firstMedia) && diff --git a/src/view/view.ts b/src/view/view.ts index bcc83f77..e8cd9f97 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,6 +1,7 @@ -// Medium: +// Minor / later: // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript // - TODO: getRecordingTitle should use getCameraTitle but need hass. +// - TODO: Pass timezone to recordings & event summary endpoint. // Hard: // - TODO: Implement dragging the timeline seeking forward in both Frigate recordings & events. From 3e749d03cbd5dd00e3ece9de12d5683bca51b3af Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 9 Jan 2023 11:48:00 -0800 Subject: [PATCH 29/74] Minor viewer renames/fixes. --- src/components/viewer.ts | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 1d697e4b..35df33db 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -1,5 +1,5 @@ import { Task } from '@lit-labs/task'; -import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; +import { EmblaPluginType } from 'embla-carousel'; import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { CSSResultGroup, @@ -243,7 +243,7 @@ export class FrigateCardViewerCarousel extends LitElement { } const promises: Promise[] = []; view.queryResults?.getResults()?.forEach((media: ViewMedia) => { - const mediaContentID = media.getContentID(cameras.get(media.getCameraID())); + const mediaContentID = media.getContentID(); if (this.hass && mediaContentID) { promises.push( resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache), @@ -266,7 +266,7 @@ export class FrigateCardViewerCarousel extends LitElement { // on media load, since the media may or may not have been loaded at // this point). if (this.view?.context?.mediaViewer !== oldView?.context?.mediaViewer) { - this._recordingSeekHandler(); + this._seekHandler(); } } super.updated(changedProperties); @@ -458,9 +458,7 @@ export class FrigateCardViewerCarousel extends LitElement { } const media = this.view.queryResults?.getResult(index); - const mediaContentID = media - ? media.getContentID(this.cameras.get(media.getCameraID())) - : null; + const mediaContentID = media ? media.getContentID() : null; if (!mediaContentID) { return; } @@ -518,7 +516,7 @@ export class FrigateCardViewerCarousel extends LitElement { return false; } for (const media of this.view?.queryResults?.getResults() ?? []) { - const mediaContentID = media.getContentID(this.cameras.get(media.getCameraID())); + const mediaContentID = media.getContentID(); if (mediaContentID && !this.resolvedMediaCache.has(mediaContentID)) { return false; } @@ -593,15 +591,14 @@ export class FrigateCardViewerCarousel extends LitElement { .selected=${this.view?.queryResults?.getSelectedIndex() ?? 0} transitionEffect=${this._getTransitionEffect()} @frigate-card:media-carousel:select=${this._setViewHandler.bind(this)} - @frigate-card:media:loaded=${this._recordingSeekHandler.bind(this)} + @frigate-card:media:loaded=${this._seekHandler.bind(this)} > { @@ -615,8 +612,7 @@ export class FrigateCardViewerCarousel extends LitElement { .hass=${this.hass} .direction=${'next'} .controlConfig=${this.viewerConfig?.controls.next_previous} - .thumbnail=${next?.getThumbnail(this.cameras.get(next.getCameraID())) ?? - undefined} + .thumbnail=${next?.getThumbnail() ?? undefined} .label=${next?.getTitle() ?? ''} ?disabled=${!next} @click=${(ev) => { @@ -630,7 +626,7 @@ export class FrigateCardViewerCarousel extends LitElement { /** * Fire a media show event when a slide is selected. */ - protected _recordingSeekHandler(): void { + protected _seekHandler(): void { const selectedIndex = this.view?.queryResults?.getSelectedIndex() ?? null; const seek = selectedIndex !== null @@ -655,7 +651,7 @@ export class FrigateCardViewerCarousel extends LitElement { } const lazyLoad = this.viewerConfig.lazy_load; - const mediaContentID = media.getContentID(this.cameras.get(media.getCameraID())); + const mediaContentID = media.getContentID(); const resolvedMedia = mediaContentID ? this.resolvedMediaCache?.get(mediaContentID) : null; From 13816c3025d5d86cf14f74e8851bc22f9406fe86 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 9 Jan 2023 20:08:23 -0800 Subject: [PATCH 30/74] Add timezone to diagnostics. --- src/card.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/card.ts b/src/card.ts index 377ac9b7..bccf1e5a 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1557,6 +1557,7 @@ export class FrigateCard extends LitElement { date: new Date(), frigate_version: Object.fromEntries(frigateVersionMap), lang: getLanguage(), + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, git: { ...(pkg['gitVersion'] && { build_version: pkg['gitVersion'] }), ...(pkg['buildDate'] && { build_date: pkg['buildDate'] }), From 6b2036676c87289043faf70fa794b828283227f0 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 9 Jan 2023 21:15:15 -0800 Subject: [PATCH 31/74] Move seeking support into the engine. --- src/camera/engine.ts | 7 ++ src/camera/frigate/engine-frigate.ts | 58 +++++++++++++ src/camera/frigate/requests.ts | 1 + src/camera/manager.ts | 22 +++++ src/components/thumbnail-carousel.ts | 3 +- src/components/thumbnail.ts | 19 ++--- src/components/timeline-core.ts | 24 +----- src/components/viewer.ts | 48 ++++++----- src/utils/media-to-view.ts | 123 ++------------------------- src/utils/timeline-source.ts | 6 +- src/view/media.ts | 5 ++ src/view/view.ts | 2 - 12 files changed, 142 insertions(+), 176 deletions(-) diff --git a/src/camera/engine.ts b/src/camera/engine.ts index 21513008..3fef2d24 100644 --- a/src/camera/engine.ts +++ b/src/camera/engine.ts @@ -77,4 +77,11 @@ export interface CameraManagerEngine { queries: MediaQueries, results: MediaQueriesResults, ): boolean; + + getMediaSeekTime( + hass: HomeAssistant, + cameras: Map, + media: ViewMedia, + target: Date, + ): Promise; } diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 646f4ecd..1ccb0374 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -392,6 +392,32 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { ); } + public async getMediaSeekTime( + hass: HomeAssistant, + cameras: Map, + media: ViewMedia, + target: Date, + ): Promise { + const start = media.getStartTime(); + const end = media.getEndTime(); + if (!start || !end || target < start || target > end) { + return null; + } + + const query: RecordingSegmentsQuery = { + cameraID: media.getCameraID(), + start: start, + end: end, + type: QueryType.RecordingSegments, + }; + + const segments = await this.getRecordingSegments(hass, cameras, query); + const out = segments + ? this._getSeekTimeInSegments(start, target, segments.segments) + : null; + return out; + } + protected _getQueryableCameraConfig( cameras: Map, cameraID: string, @@ -466,4 +492,36 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { `Released ${segmentsStart - countSegments()} segment(s)`, ); } + + /** + * Get the number of seconds to seek into a video stream consisting of the + * provided segments to reach the target time provided. + * @param startTime The earliest allowable time to seek from. + * @param targetTime Target time. + * @param segments An array of segments dataset items. Must be sorted from oldest to youngest. + * @returns + */ + protected _getSeekTimeInSegments( + startTime: Date, + targetTime: Date, + segments: RecordingSegment[], + ): number | null { + if (!segments.length) { + return null; + } + let seekMilliseconds = 0; + + // Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27 + for (const segment of segments) { + const segmentStart = fromUnixTime(segment.start_time); + if (segmentStart > targetTime) { + break; + } + 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/camera/frigate/requests.ts b/src/camera/frigate/requests.ts index 0675e364..4ff5782c 100644 --- a/src/camera/frigate/requests.ts +++ b/src/camera/frigate/requests.ts @@ -26,6 +26,7 @@ export const getRecordingsSummary = async ( type: 'frigate/recordings/summary', instance_id: client_id, camera: camera_name, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }, true, ); diff --git a/src/camera/manager.ts b/src/camera/manager.ts index c0bcb851..2dc1cb2d 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -278,6 +278,28 @@ export class CameraManager { return true; } + public async getMediaSeekTime( + hass: HomeAssistant, + media: ViewMedia, + target: Date, + ): Promise { + const startTime = media.getStartTime(); + const endTime = media.getEndTime(); + const cameraConfig = this._cameras.get(media.getCameraID()); + if ( + !cameraConfig || + !startTime || + !endTime || + target < startTime || + target > endTime + ) { + return null; + } + + const engine = this._engineFactory.getEngineForCamera(cameraConfig); + return (await engine?.getMediaSeekTime(hass, this._cameras, media, target)) ?? null; + } + protected async _handleQuery( hass: HomeAssistant, query: QT | QT[], diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index d061165e..0f7d75ac 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -160,6 +160,7 @@ export class FrigateCardThumbnailCarousel extends LitElement { 'slide-selected': this.selected === index, }; + const seekTarget = this.view?.context?.mediaViewer?.seek; return html`
` : ``} - ${this.mediaSeek + ${this.seek ? html`
${localize('event.seek')} - ${format(fromUnixTime(this.mediaSeek.seekTime), 'HH:mm:ss')} + ${format(this.seek, 'HH:mm:ss')}
` : html``}
@@ -183,7 +182,7 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement { public media?: RecordingViewMedia; @property({ attribute: false }) - public mediaSeek?: MediaSeek; + public seek?: Date; @property({ attribute: false }) public cameraTitle?: string; @@ -195,10 +194,10 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement { const eventCount = this.media.getEventCount(); return html`
${this.cameraTitle ?? ''}
- ${this.mediaSeek + ${this.seek ? html`
${localize('recording.seek')} - ${format(fromUnixTime(this.mediaSeek.seekTime), 'HH:mm:ss')} + ${format(this.seek, 'HH:mm:ss')}
` : html``}
@@ -242,7 +241,7 @@ export class FrigateCardThumbnail extends LitElement { public show_timeline_control = false; @property({ attribute: false }) - public mediaSeek?: MediaSeek; + public seek?: Date; @property({ attribute: false }) public view?: Readonly; @@ -314,13 +313,13 @@ export class FrigateCardThumbnail extends LitElement { ${this.details && ViewMediaClassifier.isEvent(this.media) ? html`` : this.details && ViewMediaClassifier.isRecording(this.media) ? html`` : html``} ${shouldShowTimelineControl diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 3e9c3404..71acd690 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -48,7 +48,6 @@ import { createViewForEvents, createViewForRecordings, findClosestMediaIndex, - generateMediaViewerContext, } from '../utils/media-to-view'; import { CameraManager } from '../camera/manager'; import { EventMediaQueries, MediaQueries } from '../view/media-queries'; @@ -407,16 +406,6 @@ export class FrigateCardTimelineCore extends LitElement { } const canSeek = !!this.view?.isViewerView(); - - const context = canSeek - ? await generateMediaViewerContext( - this.hass, - this.cameraManager, - media, - targetTime, - ) - : null; - const newResults = this._locked ? null : results @@ -444,7 +433,7 @@ export class FrigateCardTimelineCore extends LitElement { }) // Whether or not to set the timeline window. .mergeInContext({ ...this._generateTimelineContext({ noSetWindow: true }), - ...context, + ...(canSeek && { mediaViewer: { seek: targetTime }}) }) .dispatchChangeEvent(this); } @@ -533,12 +522,7 @@ export class FrigateCardTimelineCore extends LitElement { // view change. if (eventView && results && results.length) { eventView.mergeInContext( - await generateMediaViewerContext( - this.hass, - this.cameraManager, - results, - properties.time, - ), + {mediaViewer: {seek: properties.time}} ); view = eventView; } @@ -608,7 +592,7 @@ export class FrigateCardTimelineCore extends LitElement { } const prefetchedWindow = this._getPrefetchWindow(properties); - await this._timelineSource?.refresh(this.hass, this.cameras, prefetchedWindow); + await this._timelineSource?.refresh(this.hass, prefetchedWindow); // Don't show event thumbnails if the user is looking at recordings, // as the recording "hours" are the media, not the event @@ -918,7 +902,7 @@ export class FrigateCardTimelineCore extends LitElement { // (via fetchIfNecessary) may update the timeline contents which causes // the visjs timeline to stop dragging/panning operations which is very // disruptive to the user. - await this._timelineSource?.refresh(this.hass, this.cameras, prefetchedWindow); + await this._timelineSource?.refresh(this.hass, prefetchedWindow); } const mediaID = media?.getID(); diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 35df33db..33427328 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -58,17 +58,8 @@ import { guard } from 'lit/directives/guard.js'; import { localize } from '../localize/localize.js'; import { MediaQueriesResults } from '../view/media-queries-results.js'; -export interface MediaSeek { - // Specifies the point at which this recording should be played, the - // seek_time is the date of the desired play point (for display purposes - // usually), and seek_seconds is the number of seconds to seek into the video - // stream to reach that point. - seekTime: number; - seekSeconds: number; -} - export interface MediaViewerViewContext { - seek: Map; + seek?: Date; } declare module 'view' { @@ -260,6 +251,8 @@ export class FrigateCardViewerCarousel extends LitElement { * @param changedProperties The properties that were changed in this render. */ updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + if (changedProperties.has('view')) { const oldView = changedProperties.get('view') as View | undefined; // Seek into the video if the seek time has changed (this is also called @@ -269,7 +262,6 @@ export class FrigateCardViewerCarousel extends LitElement { this._seekHandler(); } } - super.updated(changedProperties); } /** @@ -418,17 +410,26 @@ export class FrigateCardViewerCarousel extends LitElement { * Handle the user selecting a new slide in the carousel. */ protected _setViewHandler(ev: CustomEvent): void { + // 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._setViewSelectedIndex(ev.detail.index); } } protected _setViewSelectedIndex(index: number): void { - // The slide may already be selected on load, so don't dispatch a new view - // unless necessary. + const newResults = this.view?.queryResults?.clone().selectResult(index); + if (!newResults) { + return; + } + const cameraID = newResults.getSelectedResult()?.getCameraID(); + this.view ?.evolve({ - queryResults: this.view.queryResults?.clone().selectResult(index), + queryResults: newResults, + + // Always change the camera to the owner of the selected media. + ...(cameraID && { camera: cameraID }), }) // Ensure the timeline is able to update its position. .mergeInContext({ timeline: { noSetWindow: false } }) @@ -626,15 +627,18 @@ export class FrigateCardViewerCarousel extends LitElement { /** * Fire a media show event when a slide is selected. */ - protected _seekHandler(): void { - const selectedIndex = this.view?.queryResults?.getSelectedIndex() ?? null; - const seek = - selectedIndex !== null - ? this.view?.context?.mediaViewer?.seek.get(selectedIndex) - : null; + protected async _seekHandler(): Promise { + const seek = this.view?.context?.mediaViewer?.seek; + const media = this.view?.queryResults?.getSelectedResult(); + if (!this.hass || !media || !seek) { + return; + } + + const seekTime = + (await this.cameraManager?.getMediaSeekTime(this.hass, media, seek)) ?? null; const player = this._getPlayer(); - if (player && seek) { - player.seek(seek.seekSeconds); + if (player && seekTime !== null) { + player.seek(seekTime); } } diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index fcba2c4b..137b54e4 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -1,25 +1,16 @@ import add from 'date-fns/add'; -import fromUnixTime from 'date-fns/fromUnixTime'; -import startOfHour from 'date-fns/startOfHour'; import sub from 'date-fns/sub'; import { ViewContext } from 'view'; -import { - CameraConfig, - ClipsOrSnapshotsOrAll, - FrigateCardView, - RecordingSegment, -} from '../types'; +import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types'; import { View } from '../view/view'; import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; import { CameraManager } from '../camera/manager'; import { getAllDependentCameras } from './camera.js'; import { ViewMedia } from '../view/media'; -import { ViewMediaClassifier } from '../view/media-classifier'; import { HomeAssistant } from 'custom-card-helpers'; import { dispatchFrigateCardErrorEvent } from '../components/message'; import { MediaQueriesResults } from '../view/media-queries-results'; import { errorToConsole } from './basic'; -import { RecordingSegmentsQueryResults } from '../camera/types'; export const changeViewToRecentEventsForCameraAndDependents = async ( element: HTMLElement, @@ -168,12 +159,11 @@ export const createViewForRecordings = async ( queryResults.selectBestResult((media) => findClosestMediaIndex(media, options.targetTime as Date, cameraIDs), ); - viewerContext = await generateMediaViewerContext( - hass, - cameraManager, - mediaArray, - options.targetTime, - ); + viewerContext = { + mediaViewer: { + seek: options.targetTime, + }, + }; } return ( @@ -187,75 +177,6 @@ export const createViewForRecordings = async ( ); }; -/** - * 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 cameraManager The datamanager to use for data access. - * @param media The media. - * @param targetTime The target time. - * @returns The ViewContext. - */ -export const generateMediaViewerContext = async ( - hass: HomeAssistant, - cameraManager: CameraManager, - media: ViewMedia[], - targetTime: Date, -): Promise => { - const seek = new Map(); - const hourStart = startOfHour(targetTime); - - for (const [index, child] of media.entries()) { - const start = child.getStartTime(); - const end = child.getEndTime(); - if (!start || !end) { - continue; - } - - let seekSeconds: number | null = null; - - if (targetTime >= start && targetTime <= end) { - const query = cameraManager.generateDefaultRecordingSegmentsQueries( - child.getCameraID(), - { - start: start, - end: end, - }, - )[0]; - let segments: RecordingSegmentsQueryResults | null; - - try { - segments = (await cameraManager.getRecordingSegments(hass, query)).get( - query, - ) ?? null; - } catch (e) { - errorToConsole(e as Error); - // View context is never critical. Ignore errors which will at least - // allow the video to load even if it doesn't seek to the correct - // location. - return {}; - } - - if (segments) { - seekSeconds = getSeekTimeInSegments( - // Recordings start from the top of the hour. - ViewMediaClassifier.isRecording(child) ? hourStart : start, - targetTime, - segments.segments, - ); - } - } - - if (seekSeconds !== null) { - seek.set(index, { - seekSeconds: seekSeconds, - seekTime: targetTime.getTime() / 1000, - }); - } - } - return seek.size > 0 ? { mediaViewer: { seek: seek } } : {}; -}; - /** * Find the closest matching media object. * @param mediaArray The media. Must be sorted most recent first. @@ -302,35 +223,3 @@ export const findClosestMediaIndex = ( } return bestMatch ? bestMatch.index : null; }; - -/** - * Get the number of seconds to seek into a video stream consisting of the - * provided segments to reach the target time provided. - * @param startTime The earliest allowable time to seek from. - * @param targetTime Target time. - * @param segments An array of segments dataset items. Must be sorted from oldest to youngest. - * @returns - */ -const getSeekTimeInSegments = ( - startTime: Date, - targetTime: Date, - segments: RecordingSegment[], -): number | null => { - if (!segments.length) { - return null; - } - let seekMilliseconds = 0; - - // Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27 - for (const segment of segments) { - const segmentStart = fromUnixTime(segment.start_time); - if (segmentStart > targetTime) { - break; - } - 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 index 7cdfe4f7..fea859b3 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -87,12 +87,11 @@ export class TimelineDataSource { public async refresh( hass: HomeAssistant, - cameras: Map, window: TimelineWindow, ): Promise { try { await Promise.all([ - this._refreshEvents(hass, cameras, window), + this._refreshEvents(hass, window), this._refreshRecordings(hass, window), ]); } catch (e) { @@ -122,7 +121,6 @@ export class TimelineDataSource { protected async _refreshEvents( hass: HomeAssistant, - cameras: Map, window: TimelineWindow, ): Promise { if ( @@ -145,7 +143,7 @@ export class TimelineDataSource { for (const media of results?.getResults() ?? []) { const endTime = media.getEndTime(); const startTime = media.getStartTime(); - const id = media.getID(cameras.get(media.getCameraID())); + const id = media.getID(); if (id && startTime) { this._dataset.update({ id: id, diff --git a/src/view/media.ts b/src/view/media.ts index ac04a520..a1f198e7 100644 --- a/src/view/media.ts +++ b/src/view/media.ts @@ -38,6 +38,11 @@ export class ViewMedia { public isFavorite(): boolean | null { return null; } + public includesTime(seek: Date): boolean { + const startTime = this.getStartTime(); + const endTime = this.getEndTime(); + return !!startTime && !!endTime && seek >= startTime && seek <= endTime; + } // Sets the favorite attribute (if any). This purely sets the media item as a // favorite in JS. diff --git a/src/view/view.ts b/src/view/view.ts index e8cd9f97..78fe5bd6 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,14 +1,12 @@ // Minor / later: // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript // - TODO: getRecordingTitle should use getCameraTitle but need hass. -// - TODO: Pass timezone to recordings & event summary endpoint. // Hard: // - TODO: Implement dragging the timeline seeking forward in both Frigate recordings & events. // - TODO: Implement gallery. // - TODO: Remove FrigateBrowseMediaSource if not necessary (post-gallery). // - TODO: Remove browse-media.ts TODOs. -// - 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: 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? From 8eb88cb1d4e66b14f4509b1de46634bb52810dfa Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 10 Jan 2023 19:25:49 -0800 Subject: [PATCH 32/74] Minor timeline UX improvements. --- src/components/timeline-core.ts | 20 +++++++++++++++----- src/utils/media-to-view.ts | 13 ++++++------- src/utils/timeline-source.ts | 2 +- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 71acd690..c90cfeb6 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -400,7 +400,10 @@ export class FrigateCardTimelineCore extends LitElement { !this.view || !this.hass || !this.cameraManager || - !this.cameraManager + !this.cameraManager || + // Skip range changes that do not have hammerjs pan directions associated + // with them, as these outliers cause media matching issues below. + !properties.event.additionalEvent ) { return; } @@ -433,7 +436,7 @@ export class FrigateCardTimelineCore extends LitElement { }) // Whether or not to set the timeline window. .mergeInContext({ ...this._generateTimelineContext({ noSetWindow: true }), - ...(canSeek && { mediaViewer: { seek: targetTime }}) + ...(canSeek && { mediaViewer: { seek: targetTime } }), }) .dispatchChangeEvent(this); } @@ -521,9 +524,7 @@ export class FrigateCardTimelineCore extends LitElement { // Specifically ensure there are _some_ results before dispatching the // view change. if (eventView && results && results.length) { - eventView.mergeInContext( - {mediaViewer: {seek: properties.time}} - ); + eventView.mergeInContext({ mediaViewer: { seek: properties.time } }); view = eventView; } } else if ( @@ -662,6 +663,15 @@ export class FrigateCardTimelineCore extends LitElement { view.queryResults?.selectResultIfFound( (media) => !!this.cameras && media.getID() === options.selectedItem, ); + } else { + // If not asked to select a new item, persist the currently selected item + // if possible. + const currentlySelectedResult = this.view.queryResults?.getSelectedResult(); + if (currentlySelectedResult) { + view.queryResults?.selectResultIfFound( + (media) => media.getID() === currentlySelectedResult.getID(), + ); + } } return view; } diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 137b54e4..22517e6d 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -200,16 +200,15 @@ export const findClosestMediaIndex = ( } | undefined; - for (let i = 0; i < mediaArray.length; ++i) { - const media = mediaArray[i]; - const start = media.getStartTime(); - const end = media.getEndTime(); - if (!cameraIDs.has(media.getCameraID()) || !start || !end) { + for (const [i, media] of mediaArray.entries()) { + if (!cameraIDs.has(media.getCameraID())) { continue; } - if (start <= targetTime && end >= targetTime) { - if (!refPoint) { + if (media.includesTime(targetTime)) { + const start = media.getStartTime(); + const end = media.getEndTime(); + if (!refPoint || !start || !end) { return i; } const delta = diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index fea859b3..9caf462c 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -3,7 +3,7 @@ import add from 'date-fns/add'; import sub from 'date-fns/sub'; import { DataSet } from 'vis-data'; import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext'; -import { CameraConfig, ClipsOrSnapshotsOrAll, RecordingSegment } from '../types'; +import { ClipsOrSnapshotsOrAll, RecordingSegment } from '../types'; import { CameraManager } from '../camera/manager'; import { EventQuery } from '../camera/types'; import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera/util'; From 7d8e06cff3fb69687430b111d75c9d34a37c270f Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 10 Jan 2023 20:13:10 -0800 Subject: [PATCH 33/74] Timeline noSetWindow cleanup. --- src/components/timeline-core.ts | 54 ++++++++++++--------------------- src/components/viewer.ts | 2 -- 2 files changed, 19 insertions(+), 37 deletions(-) diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index c90cfeb6..ffcd51c2 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -72,13 +72,9 @@ interface TimelineRangeChange extends TimelineWindow { interface TimelineViewContext { // Force a particular timeline window rather than taking the time from an - // event / recording. The timeline itself does not set this, but respects it - // if set elsewhere. + // event / recording. The timeline itself never sets this, but respects it if + // set elsewhere on first load. window?: TimelineWindow; - - // Whether or not to set the timeline window (either from the window - // parameter, or from an event/recording). - noSetWindow?: boolean; } declare module 'view' { @@ -435,7 +431,6 @@ export class FrigateCardTimelineCore extends LitElement { newResults.hasSelectedResult() && { queryResults: newResults }), }) // Whether or not to set the timeline window. .mergeInContext({ - ...this._generateTimelineContext({ noSetWindow: true }), ...(canSeek && { mediaViewer: { seek: targetTime } }), }) .dispatchChangeEvent(this); @@ -547,7 +542,6 @@ export class FrigateCardTimelineCore extends LitElement { 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?.is('timeline')) { @@ -605,10 +599,7 @@ export class FrigateCardTimelineCore extends LitElement { ) { ( await this._createViewWithEventMediaQuery( - this._createEventMediaQuerys({ window: prefetchedWindow }), - { - noSetWindow: true, - }, + this._createEventMediaQuerys({ window: this._timeline.getWindow() }), ) )?.dispatchChangeEvent(this); } @@ -635,7 +626,6 @@ export class FrigateCardTimelineCore extends LitElement { options?: { targetView?: FrigateCardView; selectedItem?: IdType; - noSetWindow?: boolean; }, ): Promise { if (!this.hass || !this.cameraManager || !this.cameras || !this.view || !query) { @@ -656,9 +646,6 @@ export class FrigateCardTimelineCore extends LitElement { if (!view) { return null; } - view.mergeInContext( - this._generateTimelineContext({ noSetWindow: options?.noSetWindow }), - ); if (options?.selectedItem) { view.queryResults?.selectResultIfFound( (media) => !!this.cameras && media.getID() === options.selectedItem, @@ -940,11 +927,7 @@ export class FrigateCardTimelineCore extends LitElement { } // Set the timeline window if necessary. - if ( - !this._pointerHeld && - !this.view.context?.timeline?.noSetWindow && - !isEqual(desiredWindow, timelineWindow) - ) { + if (!this._pointerHeld && !isEqual(desiredWindow, timelineWindow)) { this._timeline.setWindow(desiredWindow.start, desiredWindow.end); } @@ -963,7 +946,7 @@ export class FrigateCardTimelineCore extends LitElement { // recordings, not events in this case). const freshMediaQuery = this._createEventMediaQuerys({ - window: prefetchedWindow, + window: desiredWindow, }); if ( @@ -973,7 +956,17 @@ export class FrigateCardTimelineCore extends LitElement { !this._alreadyHasAcceptableMediaQuery(freshMediaQuery) ) { (await this._createViewWithEventMediaQuery(freshMediaQuery)) - ?.mergeInContext(this._generateTimelineContext({ noSetWindow: true })) + ?.mergeInContext(this._removeWindowFromContext()) + .dispatchChangeEvent(this); + } else if (this.view.context?.timeline?.window) { + // No matter what, always remove the window context if it's set, otherwise + // the timeline can 'jump' (e.g. if window context is set, timeline window + // gets set to that, then the user subsequently manually moves the + // timeline but then a view is re-dispatched for some other reason -- it + // would cause the timeline to jump back to the original context window) . + this.view + .clone() + ?.mergeInContext(this._removeWindowFromContext()) .dispatchChangeEvent(this); } } @@ -993,15 +986,11 @@ export class FrigateCardTimelineCore extends LitElement { /** * Generate the context for timeline views. - * @param options Configure how the context is set. * @returns The TimelineViewContext object. */ - protected _generateTimelineContext(options?: { noSetWindow?: boolean }): ViewContext { - const newContext: TimelineViewContext = {}; - - if (options?.noSetWindow) { - newContext.noSetWindow = options.noSetWindow; - } + protected _removeWindowFromContext(): ViewContext { + const newContext = {...this.view?.context?.timeline} + delete newContext.window; return { timeline: newContext }; } @@ -1044,11 +1033,6 @@ export class FrigateCardTimelineCore extends LitElement { this._timelineSource = null; } } - - const oldView = changedProps.get('view'); - if (oldView?.query && this.view?.query && !this.view.query.isEqual(oldView.query)) { - this._timelineSource?.clearEvents(); - } } /** diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 33427328..dfc9310b 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -431,8 +431,6 @@ export class FrigateCardViewerCarousel extends LitElement { // Always change the camera to the owner of the selected media. ...(cameraID && { camera: cameraID }), }) - // Ensure the timeline is able to update its position. - .mergeInContext({ timeline: { noSetWindow: false } }) .dispatchChangeEvent(this); } From 5da79d5172d2a4810a2fbed7bf8356f0bb0c585d Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 10 Jan 2023 20:31:37 -0800 Subject: [PATCH 34/74] Reduce cache-friendly range. --- src/camera/util.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/camera/util.ts b/src/camera/util.ts index 9b09ef95..3f4693cd 100644 --- a/src/camera/util.ts +++ b/src/camera/util.ts @@ -3,8 +3,6 @@ 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 './range'; export const convertRangeToCacheFriendlyTimes = ( @@ -20,12 +18,9 @@ export const convertRangeToCacheFriendlyTimes = ( if (widthSeconds <= 60 * 60) { cacheableStart = startOfHour(range.start); cacheableEnd = endOfHour(range.end); - } else if (widthSeconds <= 60 * 60 * 24) { + } else { cacheableStart = startOfDay(range.start); cacheableEnd = endOfDay(range.end); - } else { - cacheableStart = startOfWeek(range.start); - cacheableEnd = endOfWeek(range.end); } if (options?.endCap) { From 1feec926ddb5fdcaa49cb2be2e5cfb948fc5215f Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 10 Jan 2023 20:32:13 -0800 Subject: [PATCH 35/74] Remove TODO. --- src/view/view.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/view/view.ts b/src/view/view.ts index 78fe5bd6..24893d6c 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -3,7 +3,6 @@ // - TODO: getRecordingTitle should use getCameraTitle but need hass. // Hard: -// - TODO: Implement dragging the timeline seeking forward in both Frigate recordings & events. // - TODO: Implement gallery. // - TODO: Remove FrigateBrowseMediaSource if not necessary (post-gallery). // - TODO: Remove browse-media.ts TODOs. From 33e7f308ed1306627923b169fa9755469e3685e0 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 11 Jan 2023 19:58:51 -0800 Subject: [PATCH 36/74] Force the timeline window always. --- src/components/timeline-core.ts | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index ffcd51c2..f9c8d05a 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -71,9 +71,6 @@ interface TimelineRangeChange extends TimelineWindow { } interface TimelineViewContext { - // Force a particular timeline window rather than taking the time from an - // event / recording. The timeline itself never sets this, but respects it if - // set elsewhere on first load. window?: TimelineWindow; } @@ -432,6 +429,7 @@ export class FrigateCardTimelineCore extends LitElement { }) // Whether or not to set the timeline window. .mergeInContext({ ...(canSeek && { mediaViewer: { seek: targetTime } }), + ...this._setWindowInContext(properties) }) .dispatchChangeEvent(this); } @@ -601,7 +599,9 @@ export class FrigateCardTimelineCore extends LitElement { await this._createViewWithEventMediaQuery( this._createEventMediaQuerys({ window: this._timeline.getWindow() }), ) - )?.dispatchChangeEvent(this); + ) + ?.mergeInContext(this._setWindowInContext()) + ?.dispatchChangeEvent(this); } } @@ -956,17 +956,7 @@ export class FrigateCardTimelineCore extends LitElement { !this._alreadyHasAcceptableMediaQuery(freshMediaQuery) ) { (await this._createViewWithEventMediaQuery(freshMediaQuery)) - ?.mergeInContext(this._removeWindowFromContext()) - .dispatchChangeEvent(this); - } else if (this.view.context?.timeline?.window) { - // No matter what, always remove the window context if it's set, otherwise - // the timeline can 'jump' (e.g. if window context is set, timeline window - // gets set to that, then the user subsequently manually moves the - // timeline but then a view is re-dispatched for some other reason -- it - // would cause the timeline to jump back to the original context window) . - this.view - .clone() - ?.mergeInContext(this._removeWindowFromContext()) + ?.mergeInContext(this._setWindowInContext(desiredWindow)) .dispatchChangeEvent(this); } } @@ -988,10 +978,14 @@ export class FrigateCardTimelineCore extends LitElement { * Generate the context for timeline views. * @returns The TimelineViewContext object. */ - protected _removeWindowFromContext(): ViewContext { - const newContext = {...this.view?.context?.timeline} - delete newContext.window; - return { timeline: newContext }; + protected _setWindowInContext(window?: TimelineWindow): ViewContext { + const newWindow = window ?? this._timeline?.getWindow(); + return { + timeline: { + ...this.view?.context?.timeline, + ...(newWindow && { window: newWindow }), + }, + }; } /** From 7b0cde0e50e011a3a31be1a2bc21e1b88766513c Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 14 Jan 2023 10:56:20 -0800 Subject: [PATCH 37/74] Convert gallery to new engine (initial draft). --- src/components/gallery.ts | 416 ++++++++++++++++---------------------- 1 file changed, 175 insertions(+), 241 deletions(-) diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 3ada96bd..6ac59663 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -19,25 +19,15 @@ import { } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { - getFullDependentBrowseMediaQueryParametersOrDispatchError, -} from '../utils/ha/browse-media'; -import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js'; + changeViewToRecentEventsForCameraAndDependents, + changeViewToRecentRecordingForCameraAndDependents, +} from '../utils/media-to-view.js'; import { CameraManager } from '../camera/manager.js'; import { View } from '../view/view.js'; import { renderProgressIndicator } from './message.js'; import './thumbnail.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; - -interface GalleryViewContext { - // Keep track of the previous view to allow returning to a higher-level folder. - previous?: View; -} - -declare module 'view' { - interface ViewContext { - gallery?: GalleryViewContext; - } -} +import './media-filter'; @customElement('frigate-card-gallery') export class FrigateCardGallery extends LitElement { @@ -64,54 +54,55 @@ 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.cameraManager - // ) { - // return; - // } + if ( + !this.hass || + !this.view || + !this.cameras || + !this.view.isGalleryView() || + !this.cameraManager + ) { + return; + } - // if (!this.view.query) { - // if (mediaType === 'recordings') { - // changeViewToRecentRecordingForCameraAndDependents( - // this, - // this.hass, - // this.cameraManager, - // this.cameras, - // this.view, - // { - // targetView: 'recordings', - // }, - // ); - // } else { - // changeViewToRecentEventsForCameraAndDependents( - // this, - // this.hass, - // this.cameraManager, - // this.cameras, - // this.view, - // { - // targetView: mediaType, - // }, - // ); - // } - // return renderProgressIndicator({ cardWideConfig: this.cardWideConfig }); - // } + if (!this.view.query) { + if (this.view.is('recordings')) { + changeViewToRecentRecordingForCameraAndDependents( + this, + this.hass, + this.cameraManager, + this.cameras, + this.view, + ); + } else { + const mediaType = this.view.is('snapshots') + ? 'snapshots' + : this.view.is('clips') + ? 'clips' + : null; + changeViewToRecentEventsForCameraAndDependents( + this, + this.hass, + this.cameraManager, + this.cameras, + this.view, + { + ...(mediaType && { mediaType: mediaType }), + }, + ); + } + return renderProgressIndicator({ cardWideConfig: this.cardWideConfig }); + } - // return html` - // - // - // `; + return html` + + + `; } /** @@ -128,206 +119,149 @@ 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; + @property({ attribute: false }) + public cameraManager?: CameraManager; -// constructor() { -// super(); -// this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); -// } + protected _resizeObserver: ResizeObserver; -// /** -// * Component connected callback. -// */ -// connectedCallback(): void { -// super.connectedCallback(); -// this._resizeObserver.observe(this); -// } + constructor() { + super(); + this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); + } -// /** -// * Component disconnected callback. -// */ -// disconnectedCallback(): void { -// this._resizeObserver.disconnect(); -// super.disconnectedCallback(); -// } + /** + * Component connected callback. + */ + connectedCallback(): void { + super.connectedCallback(); + this._resizeObserver.observe(this); + } -// /** -// * 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), -// ); + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + this._resizeObserver.disconnect(); + super.disconnectedCallback(); + } -// this.style.setProperty('--frigate-card-gallery-columns', String(columns)); -// } + /** + * 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), + ); -// /** -// * Handle gallery resize. -// */ -// protected _resizeHandler(): void { -// this._setColumnCount(); -// } + this.style.setProperty('--frigate-card-gallery-columns', String(columns)); + } -// /** -// * 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 -// ); -// } + /** + * Handle gallery resize. + */ + protected _resizeHandler(): void { + this._setColumnCount(); + } -// /** -// * 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`, + ); + } + } + } -// // TODO: This is still going to show the gallery view (akin to HA media -// // browser). + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + const results = this.view?.queryResults?.getResults(); -// /** -// * Master render method. -// * @returns A rendered template. -// */ -// protected render(): TemplateResult | void { -// const results = this.view?.queryResults?.getResults(); + if ( + !results || + !this.hass || + !this.view || + !this.view.isGalleryView() || + !this.cameras + ) { + return html``; + } -// if ( -// !results || -// !this.hass || -// !this.view || -// !this.view.isGalleryView() || -// !this.cameras -// ) { -// return html``; -// } + return html` ${results.map( + (media, index) => + html` { + if (this.view) { + this.view + .evolve({ + view: 'media', + queryResults: this.view.queryResults?.clone().selectResult(index), + }) + .dispatchChangeEvent(this); + } + stopEventFromActivatingCardWideActions(ev); + }} + > + `, + )}`; + } -// 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); -// } -// } + /** + * 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; } } From d0a6f0928f6781b3aa6f5cf71f269c4f0a9116e9 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 15 Jan 2023 11:30:32 -0800 Subject: [PATCH 38/74] Add 'infinite' gallery support. --- src/camera/manager.ts | 155 ++++++++++++++++++++++++++--------- src/card.ts | 4 +- src/components/gallery.ts | 147 ++++++++++++++++++++++++++------- src/components/viewer.ts | 2 +- src/scss/dotdotdot.scss | 23 ++++++ src/scss/gallery.scss | 7 +- src/scss/message.scss | 28 +------ src/utils/basic.ts | 8 ++ src/utils/media-to-view.ts | 4 +- src/utils/timeline-source.ts | 2 +- src/view/media-queries.ts | 19 +---- src/view/view.ts | 2 + 12 files changed, 286 insertions(+), 115 deletions(-) create mode 100644 src/scss/dotdotdot.scss diff --git a/src/camera/manager.ts b/src/camera/manager.ts index 2dc1cb2d..27bb146b 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -5,6 +5,7 @@ import { DataQuery, EventQuery, EventQueryResults, + MediaQuery, PartialDataQuery, PartialEventQuery, PartialQueryConcreteType, @@ -61,6 +62,11 @@ export class QueryResultClassifier { } } +export interface ExtendedMediaQueryResult { + queries: T[]; + results: ViewMedia[]; +} + export type RequestCache = MemoryRequestCache; export class CameraManager { @@ -171,57 +177,89 @@ export class CameraManager { return await this._handleQuery(hass, query); } - public async executeMediaQuery( + public async executeMediaQueries( hass: HomeAssistant, - mediaQuerys: MediaQueries, + mediaQueries: MediaQueries, ): Promise { - const queries: (RecordingQuery | EventQuery)[] | null = mediaQuerys.getQueries(); + const queries: (RecordingQuery | EventQuery)[] | null = mediaQueries.getQueries(); if (!queries) { return null; } - - const results = await this._handleQuery(hass, queries); - - const mediaArray: ViewMedia[] = []; - for (const [query, result] of results.entries()) { - const cameraConfig = this._cameras.get(query.cameraID); - const engine = this._engineFactory.getEngineForCamera(cameraConfig); - - if (engine && cameraConfig) { - let media: ViewMedia[] | null = null; - if ( - QueryClassifier.isEventQuery(query) && - QueryResultClassifier.isEventQueryResult(result) - ) { - media = engine.generateMediaFromEvents(cameraConfig, query, result); - } else if ( - QueryClassifier.isRecordingQuery(query) && - QueryResultClassifier.isRecordingQuery(result) - ) { - media = engine.generateMediaFromRecordings(cameraConfig, query, result); - } - if (media) { - mediaArray.push(...media); - } - } - } + const mediaArray = this._convertQueryResultsToMedia( + await this._handleQuery(hass, queries), + ); return new MediaQueriesResults( - orderBy( - // Ensure uniqueness by the ID (if specified), otherwise all elements - // are assumed to be unique. - uniqBy(mediaArray, (media) => media.getID() ?? media), - - // Sort all items leading with the most recent. - (media) => media.getStartTime(), - 'desc', - ), + mediaArray, // Select the first (most-recent) item. mediaArray.length ? 0 : null, ); } + public async extendMediaQueries( + hass: HomeAssistant, + queries: T[], + results: ViewMedia[], + direction: 'earlier' | 'later', + chunkSize: number, + ): Promise | null> { + const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => { + let output: Date | null = null; + for (const result of results) { + const startTime = result.getStartTime(); + if ( + startTime && + (!output || + (want === 'earliest' && startTime < output) || + (want === 'latest' && startTime > output)) + ) { + output = startTime; + } + } + return output; + }; + + // The queries associated with the chunk to fetch. + const newChunkQueries: T[] = []; + + // The re-constituted combined query. + const newCombinedQueries: T[] = []; + + for (const query of queries) { + const newChunkQuery = { ...query }; + const newCombinedQuery = { ...query }; + + if (direction === 'later') { + newChunkQuery.start = getTimeFromResults('latest') ?? undefined; + newChunkQuery.end = undefined; + newCombinedQuery.end = undefined; + } else if (direction === 'earlier') { + newChunkQuery.end = getTimeFromResults('earliest') ?? undefined; + newChunkQuery.start = undefined; + newCombinedQuery.start = undefined; + } + newChunkQuery.limit = chunkSize; + newCombinedQuery.limit = (query.limit ?? 0) + chunkSize; + + newCombinedQueries.push(newCombinedQuery); + newChunkQueries.push(newChunkQuery); + } + + const newChunkMedia = this._convertQueryResultsToMedia( + await this._handleQuery(hass, newChunkQueries), + ); + + if (!newChunkMedia.length) { + return null; + } + + return { + queries: newCombinedQueries, + results: this._sortMedia(results.concat(newChunkMedia)), + }; + } + public getMediaDownloadPath(media: ViewMedia): string | null { const cameraConfig = this._cameras.get(media.getCameraID()); const engine = cameraConfig @@ -362,4 +400,45 @@ export class CameraManager { ); return results; } + + protected _convertQueryResultsToMedia( + results: Map>, + ): ViewMedia[] { + const mediaArray: ViewMedia[] = []; + for (const [query, result] of results.entries()) { + const cameraConfig = this._cameras.get(query.cameraID); + const engine = this._engineFactory.getEngineForCamera(cameraConfig); + + if (engine && cameraConfig) { + let media: ViewMedia[] | null = null; + if ( + QueryClassifier.isEventQuery(query) && + QueryResultClassifier.isEventQueryResult(result) + ) { + media = engine.generateMediaFromEvents(cameraConfig, query, result); + } else if ( + QueryClassifier.isRecordingQuery(query) && + QueryResultClassifier.isRecordingQuery(result) + ) { + media = engine.generateMediaFromRecordings(cameraConfig, query, result); + } + if (media) { + mediaArray.push(...media); + } + } + } + return this._sortMedia(mediaArray); + } + + protected _sortMedia(mediaArray: ViewMedia[]): ViewMedia[] { + return orderBy( + // Ensure uniqueness by the ID (if specified), otherwise all elements + // are assumed to be unique. + uniqBy(mediaArray, (media) => media.getID() ?? media), + + // Sort all items leading with the most recent. + (media) => media.getStartTime(), + 'desc', + ); + } } diff --git a/src/card.ts b/src/card.ts index bccf1e5a..8a224290 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1021,9 +1021,11 @@ export class FrigateCard extends LitElement { if (View.isMediaChange(this._view, view)) { this._currentMediaLoadedInfo = null; } + if (this._view?.view !== view.view) { + this._resetMainScroll(); + } this._view = view; this._generateConditionState(); - this._resetMainScroll(); }; if (args?.resetMessage ?? true) { diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 6ac59663..766534b2 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -7,7 +7,7 @@ import { TemplateResult, unsafeCSS, } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import galleryStyle from '../scss/gallery.scss'; import { CameraConfig, @@ -22,12 +22,20 @@ import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents, } from '../utils/media-to-view.js'; -import { CameraManager } from '../camera/manager.js'; +import { CameraManager, ExtendedMediaQueryResult } from '../camera/manager.js'; import { View } from '../view/view.js'; import { renderProgressIndicator } from './message.js'; import './thumbnail.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; import './media-filter'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { MediaQueriesClassifier } from '../view/media-queries-classifier'; +import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; +import { EventQuery, MediaQuery, RecordingQuery } from '../camera/types'; +import { MediaQueriesResults } from '../view/media-queries-results'; +import { errorToConsole } from '../utils/basic'; + +const GALLERY_MEDIA_CHUNK_SIZE = 100; @customElement('frigate-card-gallery') export class FrigateCardGallery extends LitElement { @@ -136,11 +144,19 @@ export class FrigateCardGalleryCore extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; + protected _intersectionObserver: IntersectionObserver; protected _resizeObserver: ResizeObserver; + protected _refSentinel: Ref = createRef(); + + @state() + protected _showExtensionLoader = true; constructor() { super(); this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); + this._intersectionObserver = new IntersectionObserver( + this._intersectionHandler.bind(this), + ); } /** @@ -149,6 +165,10 @@ export class FrigateCardGalleryCore extends LitElement { connectedCallback(): void { super.connectedCallback(); this._resizeObserver.observe(this); + + // Request update in order to ensure the intersection observer reconnects + // with the sentinel. + this.requestUpdate(); } /** @@ -156,6 +176,7 @@ export class FrigateCardGalleryCore extends LitElement { */ disconnectedCallback(): void { this._resizeObserver.disconnect(); + this._intersectionObserver.disconnect(); super.disconnectedCallback(); } @@ -184,6 +205,57 @@ export class FrigateCardGalleryCore extends LitElement { this._setColumnCount(); } + protected async _intersectionHandler( + entries: IntersectionObserverEntry[], + ): Promise { + if (!this.cameraManager || !this.hass || !this.view) { + return; + } + if (entries.every((entry) => !entry.isIntersecting)) { + return; + } + + this._showExtensionLoader = false; + + const query = this.view?.query; + const rawQueries: MediaQuery[] | null = query?.getQueries() ?? null; + const existingMedia = this.view.queryResults?.getResults(); + if (!query || !rawQueries || !existingMedia) { + return; + } + + let extension: ExtendedMediaQueryResult | null; + try { + extension = await this.cameraManager.extendMediaQueries( + this.hass, + rawQueries, + existingMedia, + 'earlier', + GALLERY_MEDIA_CHUNK_SIZE, + ); + } catch (e) { + errorToConsole(e as Error); + return; + } + + if (extension) { + const newMediaQueries = MediaQueriesClassifier.areEventQueries(query) + ? new EventMediaQueries(extension.queries as EventQuery[]) + : MediaQueriesClassifier.areRecordingQueries(query) + ? new RecordingMediaQueries(extension.queries as RecordingQuery[]) + : null; + + if (newMediaQueries) { + this.view + ?.evolve({ + query: newMediaQueries, + queryResults: new MediaQueriesResults(extension.results), + }) + .dispatchChangeEvent(this); + } + } + } + /** * Called when an update will occur. * @param changedProps The changed properties @@ -203,6 +275,9 @@ export class FrigateCardGalleryCore extends LitElement { ); } } + if (changedProps.has('view')) { + this._showExtensionLoader = true; + } } /** @@ -222,33 +297,47 @@ export class FrigateCardGalleryCore extends LitElement { return html``; } - return html` ${results.map( - (media, index) => - html` { - if (this.view) { - this.view - .evolve({ - view: 'media', - queryResults: this.view.queryResults?.clone().selectResult(index), - }) - .dispatchChangeEvent(this); - } - stopEventFromActivatingCardWideActions(ev); - }} - > - `, - )}`; + return html` + ${results.map( + (media, index) => + html` { + if (this.view) { + this.view + .evolve({ + view: 'media', + queryResults: this.view.queryResults?.clone().selectResult(index), + }) + .dispatchChangeEvent(this); + } + stopEventFromActivatingCardWideActions(ev); + }} + > + `, + )} + ${this._showExtensionLoader + ? html` + + ` + : ''} + `; + } + + public updated(): void { + if (this._refSentinel.value) { + this._intersectionObserver.disconnect(); + this._intersectionObserver.observe(this._refSentinel.value); + } } /** diff --git a/src/components/viewer.ts b/src/components/viewer.ts index dfc9310b..3c53afe1 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -382,7 +382,7 @@ export class FrigateCardViewerCarousel extends LitElement { let results: MediaQueriesResults | null; try { - results = await this.cameraManager.executeMediaQuery(this.hass, clipQuery); + results = await this.cameraManager.executeMediaQueries(this.hass, clipQuery); } catch (e) { errorToConsole(e as Error); return; diff --git a/src/scss/dotdotdot.scss b/src/scss/dotdotdot.scss new file mode 100644 index 00000000..784a6b45 --- /dev/null +++ b/src/scss/dotdotdot.scss @@ -0,0 +1,23 @@ +.dotdotdot:after { + @keyframes dots { + 0%, + 20% { + content: '.'; + } + 40% { + content: '..'; + } + 60% { + content: '...'; + } + 90%, + 100% { + content: ''; + } + } + + animation: dots 2s linear infinite; + content: ''; + display: inline-block; + width: 3em; +} diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss index ad22eb9d..93f95bda 100644 --- a/src/scss/gallery.scss +++ b/src/scss/gallery.scss @@ -1,3 +1,5 @@ +@use 'dotdotdot.scss'; + :host { width: 100%; height: auto; @@ -31,7 +33,10 @@ text-align: center; color: var(--primary-text-color, white); border: 1px solid var(--primary-color); - border-radius: var(--frigate-card-css-border-radius, var(--ha-card-border-radius, 4px)); + border-radius: var( + --frigate-card-css-border-radius, + var(--ha-card-border-radius, 4px) + ); // Folder background color should match the thumbnail element background // color. diff --git a/src/scss/message.scss b/src/scss/message.scss index 953c9331..ff52434d 100644 --- a/src/scss/message.scss +++ b/src/scss/message.scss @@ -1,3 +1,5 @@ +@use 'dotdotdot.scss'; + :host { min-height: 100%; width: 100%; @@ -56,28 +58,4 @@ div.message div.icon { .message ha-icon, ha-circular-progress { padding: 10px; -} - -.dotdotdot:after { - @keyframes dots { - 0%, - 20% { - content: '.'; - } - 40% { - content: '..'; - } - 60% { - content: '...'; - } - 90%, - 100% { - content: ''; - } - } - - animation: dots 2s linear infinite; - content: ''; - display: inline-block; - width: 3em; -} +} \ No newline at end of file diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 4a1c5e81..040781bd 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -155,3 +155,11 @@ export function getDurationString(start: Date, end: Date): string { duration += `${seconds}s`; return duration; } + +/** + * For debug purposes only. + * @param seconds + */ +export const sleep = async (seconds: number) => { + await new Promise(r => setTimeout(r, seconds * 1000)); +} \ No newline at end of file diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 22517e6d..58f70f8a 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -63,7 +63,7 @@ export const createViewForEvents = async ( let queryResults: MediaQueriesResults | null; try { - queryResults = await cameraManager.executeMediaQuery(hass, query); + queryResults = await cameraManager.executeMediaQueries(hass, query); } catch (e) { errorToConsole(e as Error); dispatchFrigateCardErrorEvent(element, e as Error); @@ -146,7 +146,7 @@ export const createViewForRecordings = async ( let queryResults: MediaQueriesResults | null; try { - queryResults = await cameraManager.executeMediaQuery(hass, query); + queryResults = await cameraManager.executeMediaQueries(hass, query); } catch (e) { errorToConsole(e as Error); dispatchFrigateCardErrorEvent(element, e as Error); diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index 9caf462c..3ddd9136 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -139,7 +139,7 @@ export class TimelineDataSource { this.getTimelineEventQueries(cacheFriendlyWindow), ); - const results = await this._cameraManager.executeMediaQuery(hass, query); + const results = await this._cameraManager.executeMediaQueries(hass, query); for (const media of results?.getResults() ?? []) { const endTime = media.getEndTime(); const startTime = media.getStartTime(); diff --git a/src/view/media-queries.ts b/src/view/media-queries.ts index 0a674619..abdb8784 100644 --- a/src/view/media-queries.ts +++ b/src/view/media-queries.ts @@ -7,7 +7,7 @@ export type MediaQueries = EventMediaQueries | RecordingMediaQueries; export class MediaQueriesBase { protected _queries: T[] | null = null; - protected constructor(queries?: T[]) { + public constructor(queries?: T[]) { if (queries) { this._queries = queries; } @@ -28,20 +28,9 @@ export class MediaQueriesBase { 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; @@ -54,8 +43,4 @@ export class EventMediaQueries extends MediaQueriesBase { } } -export class RecordingMediaQueries extends MediaQueriesBase { - constructor(queries?: RecordingQuery[]) { - super(queries); - } -} +export class RecordingMediaQueries extends MediaQueriesBase {} diff --git a/src/view/view.ts b/src/view/view.ts index 24893d6c..fb355802 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,6 +1,8 @@ // Minor / later: // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript // - TODO: getRecordingTitle should use getCameraTitle but need hass. +// - TODO: Take MediaQueries wrappers out of the camera manager. +// - TODO: Event gallery show_details default does not work. // Hard: // - TODO: Implement gallery. From e5924db352ee3ce85b236b8768313d8fba95bd16 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 16 Jan 2023 14:07:20 -0800 Subject: [PATCH 39/74] Initial version of media filter components. --- src/components/gallery.ts | 38 +++-- src/components/media-filter-core.ts | 209 ++++++++++++++++++++++++++++ src/components/media-filter.ts | 143 +++++++++++++++++++ src/localize/languages/en.json | 13 ++ src/localize/languages/it.json | 13 ++ src/localize/languages/pt-BR.json | 13 ++ src/scss/gallery.scss | 4 +- src/scss/media-filter.scss | 5 + src/view/view.ts | 7 + tsconfig.json | 1 + 10 files changed, 431 insertions(+), 15 deletions(-) create mode 100644 src/components/media-filter-core.ts create mode 100644 src/components/media-filter.ts create mode 100644 src/scss/media-filter.scss diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 766534b2..aa2ce708 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -27,13 +27,13 @@ import { View } from '../view/view.js'; import { renderProgressIndicator } from './message.js'; import './thumbnail.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; -import './media-filter'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; import { EventQuery, MediaQuery, RecordingQuery } from '../camera/types'; import { MediaQueriesResults } from '../view/media-queries-results'; import { errorToConsole } from '../utils/basic'; +import "./media-filter"; const GALLERY_MEDIA_CHUNK_SIZE = 100; @@ -101,15 +101,25 @@ export class FrigateCardGallery extends LitElement { return renderProgressIndicator({ cardWideConfig: this.cardWideConfig }); } + // TODO Make this slot choice configuration left/right. return html` - - + + + + + + `; } @@ -146,7 +156,7 @@ export class FrigateCardGalleryCore extends LitElement { protected _intersectionObserver: IntersectionObserver; protected _resizeObserver: ResizeObserver; - protected _refSentinel: Ref = createRef(); + protected _refLoader: Ref = createRef(); @state() protected _showExtensionLoader = true; @@ -167,7 +177,7 @@ export class FrigateCardGalleryCore extends LitElement { this._resizeObserver.observe(this); // Request update in order to ensure the intersection observer reconnects - // with the sentinel. + // with the loader sentinel. this.requestUpdate(); } @@ -326,7 +336,7 @@ export class FrigateCardGalleryCore extends LitElement { `, )} ${this._showExtensionLoader - ? html` + ? html` ` : ''} @@ -334,9 +344,9 @@ export class FrigateCardGalleryCore extends LitElement { } public updated(): void { - if (this._refSentinel.value) { + if (this._refLoader.value) { this._intersectionObserver.disconnect(); - this._intersectionObserver.observe(this._refSentinel.value); + this._intersectionObserver.observe(this._refLoader.value); } } diff --git a/src/components/media-filter-core.ts b/src/components/media-filter-core.ts new file mode 100644 index 00000000..692edcde --- /dev/null +++ b/src/components/media-filter-core.ts @@ -0,0 +1,209 @@ +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { DateRange } from '../camera/range'; +import { localize } from '../localize/localize'; +import mediaFilterStyle from '../scss/media-filter.scss'; +import { ExtendedHomeAssistant } from '../types'; +import { dispatchFrigateCardEvent } from '../utils/basic'; + +export interface ValueLabel { + value?: T; + label: string; +} + +export interface MediaFilterCoreSelection { + camera?: string[]; + what?: string[]; + where?: string[]; + when?: MediaFilterCoreWhenSelection; + favorite?: MediaFilterCoreFavoriteSelection; +} + +type FilterElement = HTMLElement & { + selectedItem?: ValueLabel; +}; + +export enum MediaFilterCoreFavoriteSelection { + All = 'all', + Favorite = 'favorite', + NotFavorite = 'not-favorite', +} + +export enum MediaFilterCoreWhen { + All = 'all', + Today = 'today', + Yesterday = 'yesterday', + PastWeek = 'past-week', + PastMonth = 'past-month', + Custom = 'custom', +} + +export interface MediaFilterCoreWhenSelection { + selection: MediaFilterCoreWhen; + custom?: DateRange; +} + +@customElement('frigate-card-media-filter-core') +export class FrigateCardMediaFilterCore extends LitElement { + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public whenOptions?: ValueLabel[]; + + @property({ attribute: false }) + public cameraOptions?: ValueLabel[]; + + @property({ attribute: false }) + public whatOptions?: ValueLabel[]; + + @property({ attribute: false }) + public whereOptions?: ValueLabel[]; + + protected _refWhen: Ref> = createRef(); + protected _refCamera: Ref> = createRef(); + protected _refWhat: Ref> = createRef(); + protected _refWhere: Ref> = createRef(); + protected _refFavorite: Ref> = + createRef(); + + protected _valueChangedHandler(ev: CustomEvent<{ value: unknown }>): void { + // Handler is called on initial load -- skip it. + if (!ev.detail.value) { + return; + } + const values: MediaFilterCoreSelection = { + ...(this._refWhen.value && { + when: this._refWhen.value.selectedItem?.value as MediaFilterCoreWhenSelection, + }), + ...(this._refCamera.value && { + camera: this._refCamera.value.selectedItem?.value + ? [this._refCamera.value.selectedItem?.value] + : undefined, + }), + ...(this._refWhat.value && { + what: this._refWhat.value.selectedItem?.value + ? [this._refWhat.value.selectedItem?.value] + : undefined, + }), + ...(this._refWhere.value && { + where: this._refWhere.value.selectedItem?.value + ? [this._refWhere.value.selectedItem?.value] + : undefined, + }), + ...(this._refFavorite.value && { + favorite: this._refFavorite.value.selectedItem?.value as + | MediaFilterCoreFavoriteSelection + | undefined, + }), + }; + dispatchFrigateCardEvent(this, 'media-filter-core:change', values); + } + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + const favoriteOptions: ValueLabel[] = [ + { + value: MediaFilterCoreFavoriteSelection.All, + label: localize('media_filter.all'), + }, + { + value: MediaFilterCoreFavoriteSelection.Favorite, + label: localize('media_filter.favorite'), + }, + { + value: MediaFilterCoreFavoriteSelection.NotFavorite, + label: localize('media_filter.not_favorite'), + }, + ]; + + // Time based options are not pre-computed here to ensure relative dates + // (e.g. 'today') are always calculated when activated not when rendered. + const whenOptions = [ + { + value: { selection: MediaFilterCoreWhen.All }, + label: localize('media_filter.all'), + }, + { + value: { selection: MediaFilterCoreWhen.Today }, + label: localize('media_filter.today'), + }, + { + value: { selection: MediaFilterCoreWhen.Yesterday }, + label: localize('media_filter.yesterday'), + }, + { + value: { selection: MediaFilterCoreWhen.PastWeek }, + label: localize('media_filter.past_week'), + }, + { + value: { selection: MediaFilterCoreWhen.PastMonth }, + label: localize('media_filter.past_month'), + }, + ...(this.whenOptions ?? []), + ]; + + return html` + + ${this.cameraOptions + ? html` ` + : ''} + ${this.whatOptions + ? html` ` + : ''} + ${this.whereOptions + ? html`` + : ''} + + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(mediaFilterStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-media-filter-core': FrigateCardMediaFilterCore; + } +} diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts new file mode 100644 index 00000000..8841720e --- /dev/null +++ b/src/components/media-filter.ts @@ -0,0 +1,143 @@ +import { sub } from 'date-fns'; +import endOfDay from 'date-fns/endOfDay'; +import endOfYesterday from 'date-fns/endOfYesterday'; +import endOfToday from 'date-fns/esm/endOfToday'; +import startOfToday from 'date-fns/esm/startOfToday'; +import startOfYesterday from 'date-fns/startOfYesterday'; +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { CameraManager } from '../camera/manager'; +import { DateRange } from '../camera/range'; +import { CameraConfig, ExtendedHomeAssistant } from '../types'; +import { getCameraTitle } from '../utils/camera'; +import { + MediaFilterCoreFavoriteSelection, + MediaFilterCoreSelection, + MediaFilterCoreWhen, + MediaFilterCoreWhenSelection, + ValueLabel, +} from './media-filter-core'; +import './surround.js'; +import './timeline-core.js'; + +@customElement('frigate-card-media-filter') +export class FrigateCardMediaFilter extends LitElement { + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public cameras?: Map; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + protected _cameraOptions: ValueLabel[] = []; + + protected _convertWhenToDateRange( + value?: MediaFilterCoreWhenSelection, + ): DateRange | null { + if (!value) { + return null; + } + if (value.selection === MediaFilterCoreWhen.Custom && value.custom) { + return value.custom; + } + const now = new Date(); + switch (value.selection) { + case MediaFilterCoreWhen.Today: + return { start: startOfToday(), end: endOfToday() }; + case MediaFilterCoreWhen.Yesterday: + return { start: startOfYesterday(), end: endOfYesterday() }; + case MediaFilterCoreWhen.PastWeek: + return { start: sub(now, { days: 7 }), end: endOfDay(now) }; + case MediaFilterCoreWhen.PastMonth: + return { start: sub(now, { months: 1 }), end: endOfDay(now) }; + } + return null; + } + + protected _convertFavoriteToBoolean( + value?: MediaFilterCoreFavoriteSelection, + ): boolean | null { + if (!value || value === MediaFilterCoreFavoriteSelection.All) { + return null; + } + return value === MediaFilterCoreFavoriteSelection.Favorite; + } + + protected _mediaFilterHandler(ev: CustomEvent): void { + const convertedTime = this._convertWhenToDateRange(ev.detail.when); + const convertedFavorite = this._convertFavoriteToBoolean(ev.detail.favorite); + const details = { + ...ev.detail, + ...(convertedTime && { when: convertedTime }), + ...(convertedFavorite && { favorite: convertedFavorite }), + }; + // TODO: remove. + console.debug('Received media filter choices:', details); + } + + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('cameras') && this.cameras) { + this._cameraOptions = Array.from(this.cameras.entries()).map( + ([cameraID, cameraConfig]) => ({ + value: cameraID, + label: getCameraTitle(this.hass, cameraConfig), + }), + ); + } + } + + protected render(): TemplateResult | void { + // TODO Replace with real custom when options + // TODO Replace with real custom what options + // TODO Replace with real custom where options + const whereOptions = [{ value: 'steps', label: 'Front Steps' }]; + + const whatOptions = [ + { value: 'car', label: 'Car' }, + { value: 'person', label: 'Person' }, + ]; + + const whenOptions = [ + { + value: { + selection: MediaFilterCoreWhen.Custom, + custom: { start: startOfToday(), end: endOfToday() }, + }, + label: 'December 2021', + }, + ]; + + return html` + `; + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: block; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-media-filter': FrigateCardMediaFilter; + } +} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 47220e12..3aab668b 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -364,6 +364,19 @@ "start": "Start", "seek": "Seek" }, + "media_filter": { + "all": "All", + "camera": "Camera", + "favorite": "Favorite", + "not_favorite": "Not Favorite", + "past_month": "Past Month", + "past_week": "Past Week", + "today": "Today", + "what": "What", + "when": "When", + "where": "Where", + "yesterday": "Yesterday" + }, "recording": { "events": "Events", "seek": "Seek" diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 5889e7c1..3c55142b 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -335,6 +335,19 @@ "start": "Avvia", "seek": "Cercare" }, + "media_filter": { + "all": "", + "camera": "", + "favorite": "", + "not_favorite": "", + "past_month": "", + "past_week": "", + "today": "", + "what": "", + "when": "", + "where": "", + "yesterday": "" + }, "recording": { "events": "Eventi", "seek": "Cercare" diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 61295352..03d1bfb7 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -335,6 +335,19 @@ "start": "Início", "seek": "Procurar" }, + "media_filter": { + "all": "", + "camera": "", + "favorite": "", + "not_favorite": "", + "past_month": "", + "past_week": "", + "today": "", + "what": "", + "when": "", + "where": "", + "yesterday": "" + }, "recording": { "events": "Eventos", "seek": "Procurar" diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss index 93f95bda..173261eb 100644 --- a/src/scss/gallery.scss +++ b/src/scss/gallery.scss @@ -16,7 +16,7 @@ display: grid; grid-template-columns: repeat(var(--frigate-card-gallery-columns), minmax(0, 1fr)); - grid-auto-rows: 1fr; + grid-auto-rows: min-content; gap: var(--frigate-card-gallery-gap); } @@ -26,6 +26,8 @@ } :host ha-card { + min-width: 100%; + aspect-ratio: 1/1; display: flex; justify-content: center; align-items: center; diff --git a/src/scss/media-filter.scss b/src/scss/media-filter.scss new file mode 100644 index 00000000..895364c4 --- /dev/null +++ b/src/scss/media-filter.scss @@ -0,0 +1,5 @@ +:host { + display: flex; + flex-direction: column; + overflow: auto; +} diff --git a/src/view/view.ts b/src/view/view.ts index fb355802..ac8a3655 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -2,7 +2,14 @@ // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript // - TODO: getRecordingTitle should use getCameraTitle but need hass. // - TODO: Take MediaQueries wrappers out of the camera manager. + +// Gallery: // - TODO: Event gallery show_details default does not work. +// - TODO: Use spinner for gallery loading / cardwideconfig not dotdotdot +// - TODO: Handle all TODOs in media filter and media filter core. +// - TODO: Make media filter slot choice configurable (gallery.ts). +// - TODO: Configurable drawer icons (e.g. filter). +// - TODO: Filter panel expands from right can occasionally 'stick' open. // Hard: // - TODO: Implement gallery. diff --git a/tsconfig.json b/tsconfig.json index b5045a7e..0b7a905d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,7 @@ // imported by a custom card directly. "globalTags": [ "ha-card", + "ha-combo-box", "ha-icon", "ha-icon-button", "ha-button-menu", From 92b75c4b1a6247ff784c67ba7637e7b88aaddd7a Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 16 Jan 2023 14:16:29 -0800 Subject: [PATCH 40/74] Configurable drawer icons. --- src/components/drawer.ts | 12 +++++++++++- src/components/gallery.ts | 9 ++++++++- src/components/surround-basic.ts | 23 ++++++++++++++++++----- src/view/view.ts | 1 - 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/components/drawer.ts b/src/components/drawer.ts index 900943ad..0639c707 100644 --- a/src/components/drawer.ts +++ b/src/components/drawer.ts @@ -15,6 +15,11 @@ import drawerStyle from '../scss/drawer.scss'; import { stopEventFromActivatingCardWideActions } from '../utils/action'; import { isHoverableDevice } from '../utils/basic'; +export interface DrawerIcons { + open?: string; + closed?: string; +} + @customElement('frigate-card-drawer') export class FrigateCardDrawer extends LitElement { @property({ attribute: true, reflect: true }) @@ -26,6 +31,9 @@ export class FrigateCardDrawer extends LitElement { @property({ type: Boolean, reflect: true, attribute: true }) public open = false; + @property({ attribute: false }) + public icons?: DrawerIcons; + // The 'empty' attribute is used in the styling to change the drawer // visibility and that of all descendants if there is no content. Styling is // used rather than display or hidden in order to ensure the contents continue @@ -111,7 +119,9 @@ export class FrigateCardDrawer extends LitElement { > { // Only open the drawer on mousenter when the device // supports hover (otherwise iOS may end up passing on diff --git a/src/components/gallery.ts b/src/components/gallery.ts index aa2ce708..0d14c132 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -103,7 +103,14 @@ export class FrigateCardGallery extends LitElement { // TODO Make this slot choice configuration left/right. return html` - + = createRef(); protected _refDrawerRight: Ref = createRef(); protected _boundDrawerHandler = this._drawerHandler.bind(this); @@ -53,10 +58,18 @@ export class FrigateCardSurroundBasic extends LitElement { protected render(): TemplateResult | void { return html` - + - + `; diff --git a/src/view/view.ts b/src/view/view.ts index ac8a3655..ddd3e63e 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -8,7 +8,6 @@ // - TODO: Use spinner for gallery loading / cardwideconfig not dotdotdot // - TODO: Handle all TODOs in media filter and media filter core. // - TODO: Make media filter slot choice configurable (gallery.ts). -// - TODO: Configurable drawer icons (e.g. filter). // - TODO: Filter panel expands from right can occasionally 'stick' open. // Hard: From 04d3ca3f464b58b9ec200692f120f13a1b6f82fb Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 23 Jan 2023 21:37:30 -0800 Subject: [PATCH 41/74] Convert to batch queries. --- src/camera/engine-factory.ts | 67 ++-- src/camera/engine.ts | 41 +- src/camera/frigate/engine-frigate.ts | 546 ++++++++++++++++++--------- src/camera/frigate/requests.ts | 6 +- src/camera/manager.ts | 208 +++++----- src/camera/types.ts | 14 +- src/card.ts | 1 - src/components/timeline-core.ts | 11 +- src/utils/media-to-view.ts | 15 +- src/utils/timeline-source.ts | 36 +- 10 files changed, 600 insertions(+), 345 deletions(-) diff --git a/src/camera/engine-factory.ts b/src/camera/engine-factory.ts index 7e367d6f..1a3bdbb6 100644 --- a/src/camera/engine-factory.ts +++ b/src/camera/engine-factory.ts @@ -1,35 +1,32 @@ import { CameraConfig } from '../types'; -import { RecordingSegmentsCache } from './cache'; +import { RecordingSegmentsCache, RequestCache } from './cache'; import { CameraManagerEngine } from './engine'; import { FrigateCameraManagerEngine } from './frigate/engine-frigate'; -import { DataQuery } from './types'; +import { Engine } from './types'; + +type CameraManagerEngineCameraIDMap = Map>; export class CameraManagerEngineFactory { - protected _engines: Map = new Map(); + protected _engines: Map = new Map(); - protected _getOrCreateEngine(engineKey: string): CameraManagerEngine | null { - const cachedEngine = this._engines.get(engineKey); + public getEngine(engine: Engine): CameraManagerEngine | null { + const cachedEngine = this._engines.get(engine); if (cachedEngine) { return cachedEngine; } - let newEngine: CameraManagerEngine | null = null; - switch (engineKey) { - case 'frigate': - newEngine = new FrigateCameraManagerEngine(new RecordingSegmentsCache()); + let cameraManagerEngine: CameraManagerEngine | null = null; + switch (engine) { + case Engine.Frigate: + cameraManagerEngine = new FrigateCameraManagerEngine( + new RecordingSegmentsCache(), + new RequestCache(), + ); break; } - if (newEngine) { - this._engines.set(engineKey, newEngine); + if (cameraManagerEngine) { + this._engines.set(engine, cameraManagerEngine); } - return newEngine; - } - - public getEngineForQuery( - cameras: Map, - query: DataQuery, - ): CameraManagerEngine | null { - const cameraConfig = cameras.get(query.cameraID); - return cameraConfig ? this.getEngineForCamera(cameraConfig) : null; + return cameraManagerEngine; } public getEngineForCamera(cameraConfig?: CameraConfig): CameraManagerEngine | null { @@ -37,10 +34,34 @@ export class CameraManagerEngineFactory { return null; } - let engineKey: string | null = null; + let engine: Engine | null = null; if (cameraConfig.frigate.camera_name) { - engineKey = 'frigate'; + engine = Engine.Frigate; } - return engineKey ? this._getOrCreateEngine(engineKey) : null; + return engine ? this.getEngine(engine) : null; + } + + public getEnginesForCameraIDs( + cameras: Map, + cameraIDs: Set, + ): CameraManagerEngineCameraIDMap | null { + const output: CameraManagerEngineCameraIDMap = new Map(); + + for (const cameraID of cameraIDs) { + const cameraConfig = cameras.get(cameraID); + if (!cameraConfig) { + continue; + } + + const engine = this.getEngineForCamera(cameraConfig); + if (!engine) { + continue; + } + if (!output.has(engine)) { + output.set(engine, new Set()); + } + output.get(engine)?.add(cameraID); + } + return output; } } diff --git a/src/camera/engine.ts b/src/camera/engine.ts index 3fef2d24..904c9bdf 100644 --- a/src/camera/engine.ts +++ b/src/camera/engine.ts @@ -1,65 +1,67 @@ import { HomeAssistant } from 'custom-card-helpers'; import { CameraConfig } from '../types'; -import { MediaQueriesResults } from "../view/media-queries-results"; import { ViewMedia } from '../view/media'; import { + DataQuery, EventQuery, + EventQueryResultsMap, PartialEventQuery, PartialRecordingQuery, PartialRecordingSegmentsQuery, QueryReturnType, RecordingQuery, + RecordingQueryResultsMap, RecordingSegmentsQuery, + RecordingSegmentsQueryResultsMap, } from './types'; -import { MediaQueries } from '../view/media-queries'; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; export interface CameraManagerEngine { generateDefaultEventQuery( - cameraID: string, - cameraConfig: CameraConfig, + cameras: Map, + cameraIDs: Set, query: PartialEventQuery, - ): EventQuery | null; + ): EventQuery[] | null; generateDefaultRecordingQuery( - cameraID: string, - cameraConfig: CameraConfig, + cameras: Map, + cameraIDs: Set, query: PartialRecordingQuery, - ): RecordingQuery | null; + ): RecordingQuery[] | null; generateDefaultRecordingSegmentsQuery( - cameraID: string, - cameraConfig: CameraConfig, + cameras: Map, + cameraIDs: Set, query: PartialRecordingSegmentsQuery, - ): RecordingSegmentsQuery | null; + ): RecordingSegmentsQuery[] | null; getEvents( hass: HomeAssistant, cameras: Map, query: EventQuery, - ): Promise | null>; + ): Promise; getRecordings( hass: HomeAssistant, cameras: Map, query: RecordingQuery, - ): Promise | null>; + ): Promise; getRecordingSegments( hass: HomeAssistant, cameras: Map, query: RecordingSegmentsQuery, - ): Promise | null>; + ): Promise; generateMediaFromEvents( - cameraConfig: CameraConfig, + cameras: Map, query: EventQuery, results: QueryReturnType, ): ViewMedia[] | null; generateMediaFromRecordings( - cameraConfig: CameraConfig, + cameras: Map, query: RecordingQuery, results: QueryReturnType, ): ViewMedia[] | null; @@ -73,10 +75,9 @@ export interface CameraManagerEngine { favorite: boolean, ): Promise; - areMediaQueriesResultsFresh( - queries: MediaQueries, - results: MediaQueriesResults, - ): boolean; + getQueryResultMaxAge( + query: DataQuery + ): number | null; getMediaSeekTime( hass: HomeAssistant, diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 1ccb0374..c2acf1b9 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -4,18 +4,19 @@ import endOfHour from 'date-fns/endOfHour'; import startOfHour from 'date-fns/startOfHour'; import { CAMERA_BIRDSEYE } from '../../const'; import { CameraConfig, RecordingSegment } from '../../types'; -import { MediaQueriesResults } from '../../view/media-queries-results'; -import { MediaQueriesClassifier } from '../../view/media-queries-classifier'; import { ViewMedia } from '../../view/media'; -import { RecordingSegmentsCache } from '../cache'; +import { RequestCache, RecordingSegmentsCache } from '../cache'; import { CameraManagerEngine, CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, } from '../engine'; import { DateRange } from '../range'; import { + DataQuery, Engine, EventQuery, + EventQueryResults, + EventQueryResultsMap, FrigateEventQueryResults, FrigateRecordingQueryResults, FrigateRecordingSegmentsQueryResults, @@ -27,7 +28,10 @@ import { QueryReturnType, QueryType, RecordingQuery, + RecordingQueryResults, + RecordingQueryResultsMap, RecordingSegmentsQuery, + RecordingSegmentsQueryResultsMap, } from '../types'; import { FrigateRecording } from './types'; import { @@ -38,7 +42,6 @@ import { NativeFrigateRecordingSegmentsQuery, retainEvent, } from './requests'; -import { MediaQueries } from '../../view/media-queries'; import orderBy from 'lodash-es/orderBy'; import throttle from 'lodash-es/throttle'; import { runWhenIdleIfSupported } from '../../utils/basic'; @@ -78,6 +81,7 @@ class FrigateQueryResultsClassifier { export class FrigateCameraManagerEngine implements CameraManagerEngine { protected _recordingSegmentsCache: RecordingSegmentsCache; + protected _requestCache: RequestCache; // Garbage collect segments at most once an hour. protected _throttledSegmentGarbageCollector = throttle( @@ -86,8 +90,12 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { { leading: false, trailing: true }, ); - constructor(recordingSegmentsCache: RecordingSegmentsCache) { + constructor( + recordingSegmentsCache: RecordingSegmentsCache, + requestCache: RequestCache, + ) { this._recordingSegmentsCache = recordingSegmentsCache; + this._requestCache = requestCache; } public getMediaDownloadPath( @@ -113,46 +121,81 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } public generateDefaultEventQuery( - cameraID: string, - cameraConfig: CameraConfig, + cameras: Map, + cameraIDs: Set, query: PartialEventQuery, - ): EventQuery | null { - return { - type: QueryType.Event, - cameraID: cameraID, - ...(cameraConfig.frigate.label && { what: cameraConfig.frigate.label }), - ...(cameraConfig.frigate.zone && { where: cameraConfig.frigate.zone }), - ...query, - }; + ): EventQuery[] | null { + const relevantCameraConfigs = Array.from(cameraIDs).map((cameraID) => + cameras.get(cameraID), + ); + + // If there isn't a label or zone specified, we can come up with a single + // batch query for Frigate that will match across all cameras. + const canDoBatchQuery = relevantCameraConfigs.every( + (cameraConfig) => !cameraConfig?.frigate.label && !cameraConfig?.frigate.zone, + ); + + if (canDoBatchQuery) { + return [ + { + type: QueryType.Event, + cameraIDs: cameraIDs, + ...query, + }, + ]; + } + + const output: EventQuery[] = []; + for (const cameraID of cameraIDs) { + const cameraConfig = cameras.get(cameraID); + if (cameraConfig) { + output.push({ + type: QueryType.Event, + cameraIDs: new Set([cameraID]), + ...(cameraConfig.frigate.label && { + what: new Set([cameraConfig.frigate.label]), + }), + ...(cameraConfig.frigate.zone && { + where: new Set([cameraConfig.frigate.zone]), + }), + ...query, + }); + } + } + return output.length ? output : null; } public generateDefaultRecordingQuery( - cameraID: string, - _cameraConfig: CameraConfig, + _cameras: Map, + cameraIDs: Set, query: PartialRecordingQuery, - ): RecordingQuery | null { - return { - type: QueryType.Recording, - cameraID: cameraID, - ...query, - }; + ): RecordingQuery[] | null { + return [ + { + type: QueryType.Recording, + cameraIDs: cameraIDs, + ...query, + }, + ]; } public generateDefaultRecordingSegmentsQuery( - cameraID: string, - _cameraConfig: CameraConfig, + _cameras: Map, + cameraIDs: Set, query: PartialRecordingSegmentsQuery, - ): RecordingSegmentsQuery | null { + ): RecordingSegmentsQuery[] | null { if (!query.start || !query.end) { return null; } - return { - type: QueryType.RecordingSegments, - cameraID: cameraID, - start: query.start, - end: query.end, - ...query, - }; + return [ + { + type: QueryType.RecordingSegments, + cameraIDs: cameraIDs, + start: query.start, + end: query.end, + ...query, + }, + ]; } public async favoriteMedia( @@ -169,150 +212,275 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { media.setFavorite(favorite); } + protected _buildInstanceToCameraIDMapFromQuery( + cameras: Map, + query: DataQuery, + ): Map> { + const output: Map> = new Map(); + for (const cameraID of query.cameraIDs) { + const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + const clientID = cameraConfig?.frigate.client_id; + if (clientID) { + if (!output.has(clientID)) { + output.set(clientID, new Set()); + } + output.get(clientID)?.add(cameraID); + } + } + return output; + } + + protected _getFrigateCameraNamesForCameraIDs( + cameras: Map, + cameraIDs: Set, + ): Set { + const output = new Set(); + for (const cameraID of cameraIDs) { + const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + if (cameraConfig?.frigate.camera_name) { + output.add(cameraConfig.frigate.camera_name); + } + } + return output; + } + public async getEvents( hass: HomeAssistant, cameras: Map, query: EventQuery, - ): Promise | null> { - const cameraConfig = this._getQueryableCameraConfig(cameras, query.cameraID); - if (!cameraConfig) { - return null; - } + ): Promise { + const output: EventQueryResultsMap = new Map(); - 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 ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, + const processInstanceQuery = async ( + instanceID: string, + cameraIDs?: Set, + ): Promise => { + if (!cameraIDs || !cameraIDs.size) { + return; + } + const instanceQuery = { ...query, cameraIDs: cameraIDs }; + const cachedResult = this._requestCache.get(instanceQuery); + if (cachedResult) { + output.set(query, cachedResult as EventQueryResults); + return; + } + + const nativeQuery: NativeFrigateEventQuery = { + instance_id: instanceID, + cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs)), + ...(query.what && { label: Array.from(query.what) }), + ...(query.where && { zone: Array.from(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 ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, + }; + + const result: FrigateEventQueryResults = { + type: QueryResultsType.Event, + engine: Engine.Frigate, + instanceID: instanceID, + events: await getEvents(hass, nativeQuery), + expiry: add(new Date(), { seconds: EVENT_REQUEST_CACHE_MAX_AGE_SECONDS }), + cached: false, + }; + + this._requestCache.set(query, { ...result, cached: true }, result.expiry); + output.set(instanceQuery, result); }; - return { - type: QueryResultsType.Event, - engine: Engine.Frigate, - events: await getEvents(hass, nativeQuery), - expiry: add(new Date(), { seconds: EVENT_REQUEST_CACHE_MAX_AGE_SECONDS }), - cached: false, - }; + // Frigate allows multiple cameras to be searched for events in a single + // query. Break them down into groups of cameras per Frigate instance, then + // query once per instance for all cameras in that instance. + const instances = this._buildInstanceToCameraIDMapFromQuery(cameras, query); + + await Promise.all( + Array.from(instances.keys()).map((instanceID) => + processInstanceQuery(instanceID, instances.get(instanceID)), + ), + ); + return output.size ? output : null; } 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; - } + ): Promise { + const output: RecordingQueryResultsMap = new Map(); - const recordingSummary = await getRecordingsSummary( - hass, - cameraConfig.frigate.client_id, - cameraConfig.frigate.camera_name, - ); + const processQuery = async (query: RecordingQuery): Promise => { + const cachedResult = this._requestCache.get(query); + if (cachedResult) { + output.set(query, cachedResult as RecordingQueryResults); + return; + } - let recordings: FrigateRecording[] = []; + // There will only ever be a single cameraID specified for queries in this + // inner function. + const cameraID = [...query.cameraIDs][0]; + const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + if (!cameraConfig || !cameraConfig.frigate.camera_name) { + return; + } - 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({ - cameraID: query.cameraID, - startTime: startHour, - endTime: endHour, - events: hourData.events, - }); + const recordingSummary = await getRecordingsSummary( + hass, + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + ); + + let 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({ + cameraID: cameraID, + startTime: startHour, + endTime: endHour, + events: hourData.events, + }); + } } } - } - if (query.limit !== undefined) { - // Frigate does not natively support a way to limit recording searches so - // this simulates it. - recordings = orderBy( - recordings, - (recording: FrigateRecording) => recording.startTime, - 'desc', - ).slice(0, query.limit); - } + if (query.limit !== undefined) { + // Frigate does not natively support a way to limit recording searches so + // this simulates it. + recordings = orderBy( + recordings, + (recording: FrigateRecording) => recording.startTime, + 'desc', + ).slice(0, query.limit); + } - return { - type: QueryResultsType.Recording, - engine: Engine.Frigate, - recordings: recordings, - expiry: add(new Date(), { - seconds: RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS, - }), - cached: false, + const result: FrigateRecordingQueryResults = { + type: QueryResultsType.Recording, + engine: Engine.Frigate, + instanceID: cameraConfig.frigate.client_id, + recordings: recordings, + expiry: add(new Date(), { + seconds: RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS, + }), + cached: false, + }; + this._requestCache.set(query, { ...result, cached: true }, result.expiry); + output.set(query, result); }; + + // Frigate recordings can only be queried for a single camera, so fan out + // the inbound query into multiple outbound queries. + await Promise.all( + Array.from(query.cameraIDs).map((cameraID) => + processQuery({ ...query, cameraIDs: new Set([cameraID]) }), + ), + ); + return output.size ? output : null; } 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; - } + ): Promise { + const output: RecordingSegmentsQueryResultsMap = new Map(); - const range: DateRange = { start: query.start, end: query.end }; + const processQuery = async (query: RecordingSegmentsQuery): Promise => { + // There will only ever be a single cameraID specified for queries in this + // inner function. + const cameraID = [...query.cameraIDs][0]; + const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + if (!cameraConfig || !cameraConfig.frigate.camera_name) { + return; + } - // 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 { + const range: DateRange = { start: query.start, end: query.end }; + + // A note on Frigate Recording Segments: + // - 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(cameraID, range); + if (cachedSegments) { + output.set(query, { + type: QueryResultsType.RecordingSegments, + engine: Engine.Frigate, + instanceID: cameraConfig.frigate.client_id, + segments: cachedSegments, + cached: true, + }); + return; + } + + 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), + }; + + const segments = await getRecordingSegments(hass, request); + this._recordingSegmentsCache.add(cameraID, range, segments); + + output.set(query, { type: QueryResultsType.RecordingSegments, engine: Engine.Frigate, - segments: cachedSegments, - cached: true, - }; - } - - 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), + instanceID: cameraConfig.frigate.client_id, + segments: segments, + cached: false, + }); }; - const segments = await getRecordingSegments(hass, request); - this._recordingSegmentsCache.add(query.cameraID, range, segments); + // Frigate recording segments can only be queried for a single camera, so + // fan out the inbound query into multiple outbound queries. + await Promise.all( + Array.from(query.cameraIDs).map((cameraID) => + processQuery({ ...query, cameraIDs: new Set([cameraID]) }), + ), + ); runWhenIdleIfSupported(() => this._throttledSegmentGarbageCollector(hass, cameras)); + return output.size ? output : null; + } - return { - type: QueryResultsType.RecordingSegments, - engine: Engine.Frigate, - segments: segments, - cached: false, - }; + protected _getCameraIDMatch( + cameras: Map, + query: DataQuery, + instanceID: string, + cameraName: string, + ): string | null { + // If the query is only for a single cameraID, all results are assumed to + // belong to it for performance reasons. Otherwise, we need to map the + // instanceID and camera name for the known cameras, and get the precise + // cameraID that matches the expected instance ID / camera name. + if (query.cameraIDs.size === 1) { + return [...query.cameraIDs][0]; + } + for (const [cameraID, cameraConfig] of cameras.entries()) { + if ( + cameraConfig.frigate.client_id === instanceID && + cameraConfig.frigate.camera_name === cameraName + ) { + return cameraID; + } + } + return null; } public generateMediaFromEvents( - cameraConfig: CameraConfig, + cameras: Map, query: EventQuery, results: QueryReturnType, ): ViewMedia[] | null { @@ -322,6 +490,19 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { const output: ViewMedia[] = []; for (const event of results.events) { + const cameraID = this._getCameraIDMatch( + cameras, + query, + results.instanceID, + event.camera, + ); + if (!cameraID) { + continue; + } + const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + if (!cameraConfig) { + continue; + } let mediaType: 'clip' | 'snapshot' | null = null; if ( !query.hasClip && @@ -339,7 +520,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } const media = FrigateViewMediaFactory.createEventViewMedia( mediaType, - query.cameraID, + cameraID, cameraConfig, event, ); @@ -351,8 +532,8 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } public generateMediaFromRecordings( - cameraConfig: CameraConfig, - query: RecordingQuery, + cameras: Map, + _query: RecordingQuery, results: QueryReturnType, ): ViewMedia[] | null { if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(results)) { @@ -361,8 +542,12 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { const output: ViewMedia[] = []; for (const recording of results.recordings) { + const cameraConfig = this._getQueryableCameraConfig(cameras, recording.cameraID); + if (!cameraConfig) { + continue; + } const media = FrigateViewMediaFactory.createRecordingViewMedia( - query.cameraID, + recording.cameraID, recording, cameraConfig, ); @@ -373,23 +558,13 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { return output; } - public areMediaQueriesResultsFresh( - queries: MediaQueries, - results: MediaQueriesResults, - ): boolean { - let freshThreshold: number | null = null; - if (MediaQueriesClassifier.areEventQueries(queries)) { - freshThreshold = EVENT_REQUEST_CACHE_MAX_AGE_SECONDS; - } else if (MediaQueriesClassifier.areRecordingQueries(queries)) { - freshThreshold = RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS; + public getQueryResultMaxAge(query: DataQuery): number | null { + if (query.type === QueryType.Event) { + return EVENT_REQUEST_CACHE_MAX_AGE_SECONDS; + } else if (query.type === QueryType.Recording) { + return RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS; } - const now = new Date(); - const resultsTimestamp = results.getResultsTimestamp(); - return ( - !freshThreshold || - !resultsTimestamp || - add(resultsTimestamp, { seconds: freshThreshold }) >= now - ); + return null; } public async getMediaSeekTime( @@ -404,18 +579,26 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { return null; } + const cameraID = media.getCameraID(); const query: RecordingSegmentsQuery = { - cameraID: media.getCameraID(), + cameraIDs: new Set([cameraID]), start: start, end: end, type: QueryType.RecordingSegments, }; - const segments = await this.getRecordingSegments(hass, cameras, query); - const out = segments - ? this._getSeekTimeInSegments(start, target, segments.segments) - : null; - return out; + const results = await this.getRecordingSegments(hass, cameras, query); + + if (results) { + return this._getSeekTimeInSegments( + start, + target, + // There will only be a single result since Frigate recording segments + // searches are per camera which is specified singularly above. + Array.from(results.values())[0].segments, + ); + } + return null; } protected _getQueryableCameraConfig( @@ -438,10 +621,10 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { cameras: Map, ): Promise { const cameraIDs = this._recordingSegmentsCache.getCameraIDs(); - const recordingQueries: RecordingQuery[] = cameraIDs.map((cameraID) => ({ - cameraID: cameraID, + const recordingQuery: RecordingQuery = { + cameraIDs: new Set(cameraIDs), type: QueryType.Recording, - })); + }; const countSegments = () => sum( @@ -451,19 +634,6 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { ); const segmentsStart = countSegments(); - const results: Map = new Map(); - - await Promise.all( - recordingQueries.map((query) => - (async () => { - const recordings = await this.getRecordings(hass, cameras, query); - if (recordings && recordings.engine === Engine.Frigate) { - results.set(query, recordings as FrigateRecordingQueryResults); - } - })(), - ), - ); - // 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. @@ -471,16 +641,28 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { return `${cameraID}/${startTime.getDate()}/${startTime.getHours()}`; }; + const results = await this.getRecordings(hass, cameras, recordingQuery); + if (!results) { + return; + } + for (const [query, result] of results) { + if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(result)) { + continue; + } + const goodHours: Set = new Set(); for (const recording of result.recordings) { goodHours.add(getHourID(recording.cameraID, recording.startTime)); } + // Frigate recordings are always executed individually, so there'll only + // be a single results. + const cameraID = Array.from(query.cameraIDs)[0]; this._recordingSegmentsCache.expireMatches( - query.cameraID, + cameraID, (segment: RecordingSegment) => { - const hourID = getHourID(query.cameraID, fromUnixTime(segment.start_time)); + const hourID = getHourID(cameraID, fromUnixTime(segment.start_time)); // ~O(1) lookup time for a JS set. return goodHours.has(hourID); }, diff --git a/src/camera/frigate/requests.ts b/src/camera/frigate/requests.ts index 4ff5782c..f4473598 100644 --- a/src/camera/frigate/requests.ts +++ b/src/camera/frigate/requests.ts @@ -95,9 +95,9 @@ export async function retainEvent( export interface NativeFrigateEventQuery { instance_id?: string; - camera?: string; - label?: string; - zone?: string; + cameras?: string[]; + labels?: string[]; + zones?: string[]; after?: number; before?: number; limit?: number; diff --git a/src/camera/manager.ts b/src/camera/manager.ts index 27bb146b..d85869fe 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -5,6 +5,7 @@ import { DataQuery, EventQuery, EventQueryResults, + EventQueryResultsMap, MediaQuery, PartialDataQuery, PartialEventQuery, @@ -17,16 +18,21 @@ import { QueryType, RecordingQuery, RecordingQueryResults, + RecordingQueryResultsMap, RecordingSegmentsQuery, RecordingSegmentsQueryResults, + RecordingSegmentsQueryResultsMap, + ResultsMap, } from './types.js'; import orderBy from 'lodash-es/orderBy'; import { CameraManagerEngineFactory } from './engine-factory.js'; import { ViewMedia } from '../view/media.js'; import { MediaQueriesResults } from '../view/media-queries-results'; -import { MemoryRequestCache } from './cache.js'; import { MediaQueries } from '../view/media-queries.js'; import uniqBy from 'lodash-es/uniqBy'; +import { CameraManagerEngine } from './engine.js'; +import sum from 'lodash-es/sum'; +import add from 'date-fns/add'; export class QueryClassifier { public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery { @@ -67,27 +73,22 @@ export interface ExtendedMediaQueryResult { results: ViewMedia[]; } -export type RequestCache = MemoryRequestCache; - export class CameraManager { protected _engineFactory: CameraManagerEngineFactory; protected _cameras: Map; - protected _requestCache: RequestCache; constructor( engineFactory: CameraManagerEngineFactory, cameras: Map, - requestCache: RequestCache, ) { this._engineFactory = engineFactory; this._cameras = cameras; - this._requestCache = requestCache; } public generateDefaultEventQueries( cameraIDs: string | Set, partialQuery: PartialEventQuery, - ): EventQuery[] { + ): EventQuery[] | null { return this._generateDefaultQueries(cameraIDs, { ...partialQuery, type: QueryType.Event, @@ -97,7 +98,7 @@ export class CameraManager { public generateDefaultRecordingQueries( cameraIDs: string | Set, partialQuery: PartialRecordingQuery, - ): RecordingQuery[] { + ): RecordingQuery[] | null { return this._generateDefaultQueries(cameraIDs, { ...partialQuery, type: QueryType.Recording, @@ -107,73 +108,76 @@ export class CameraManager { public generateDefaultRecordingSegmentsQueries( cameraIDs: string | Set, partialQuery: PartialRecordingSegmentsQuery, - ): RecordingSegmentsQuery[] { + ): RecordingSegmentsQuery[] | null { return this._generateDefaultQueries(cameraIDs, { ...partialQuery, type: QueryType.RecordingSegments, }); } - protected _generateDefaultQueries>( + protected _generateDefaultQueries( cameraIDs: string | Set, partialQuery: PQT, - ): PartialQueryConcreteType[] { + ): PartialQueryConcreteType[] | null { const concreteQueries: PartialQueryConcreteType[] = []; const _cameraIDs = setify(cameraIDs); - _cameraIDs.forEach((cameraID) => { - const cameraConfig = this._cameras.get(cameraID); - if (!cameraConfig) { - return; - } + const engines = this._engineFactory.getEnginesForCameraIDs( + this._cameras, + _cameraIDs, + ); - const engine = this._engineFactory.getEngineForCamera(cameraConfig); - if (!engine) { - return; - } + if (!engines) { + return null; + } - let query: DataQuery | null = null; + for (const [engine, cameraIDs] of engines) { + let queries: DataQuery[] | null = null; if (QueryClassifier.isEventQuery(partialQuery)) { - query = engine.generateDefaultEventQuery(cameraID, cameraConfig, partialQuery); + queries = engine.generateDefaultEventQuery( + this._cameras, + cameraIDs, + partialQuery, + ); } else if (QueryClassifier.isRecordingQuery(partialQuery)) { - query = engine.generateDefaultRecordingQuery( - cameraID, - cameraConfig, + queries = engine.generateDefaultRecordingQuery( + this._cameras, + cameraIDs, partialQuery, ); } else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) { - query = engine.generateDefaultRecordingSegmentsQuery( - cameraID, - cameraConfig, + queries = engine.generateDefaultRecordingSegmentsQuery( + this._cameras, + cameraIDs, partialQuery, ); } - if (query) { + for (const query of queries ?? []) { concreteQueries.push(query as PartialQueryConcreteType); } - }); + } return concreteQueries; } public async getEvents( hass: HomeAssistant, query: EventQuery | EventQuery[], - ): Promise> { + ): Promise { return await this._handleQuery(hass, query); } public async getRecordings( hass: HomeAssistant, query: RecordingQuery | RecordingQuery[], - ): Promise> { + ): Promise { return await this._handleQuery(hass, query); } public async getRecordingSegments( hass: HomeAssistant, query: RecordingSegmentsQuery | RecordingSegmentsQuery[], - ): Promise> { + ): Promise { return await this._handleQuery(hass, query); } @@ -301,16 +305,29 @@ export class CameraManager { 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; + const now = new Date(); + const resultsTimestamp = results.getResultsTimestamp(); + + if (!resultsTimestamp) { + return false; + } + + for (const query of queries.getQueries() ?? []) { + const engines = this._engineFactory.getEnginesForCameraIDs( + this._cameras, + query.cameraIDs, + ); + for (const [engine, cameraIDs] of engines ?? []) { + const maxAgeSeconds = engine.getQueryResultMaxAge({ + ...query, + cameraIDs: cameraIDs, + }); + if ( + maxAgeSeconds !== null && + add(resultsTimestamp, { seconds: maxAgeSeconds }) < now + ) { + return false; + } } } return true; @@ -344,83 +361,96 @@ export class CameraManager { ): Promise>> { const _queries = arrayify(query); const results = new Map>(); - const queryStartTime = new Date(); - let queryCachedCount = 0; + + const processEngineQuery = async ( + engine: CameraManagerEngine, + query?: QT, + ): Promise => { + if (!query) { + return; + } + + let engineResult: Map> | null = null; + if (QueryClassifier.isEventQuery(query)) { + engineResult = (await engine.getEvents(hass, this._cameras, query)) as Map< + QT, + QueryReturnType + > | null; + } else if (QueryClassifier.isRecordingQuery(query)) { + engineResult = (await engine.getRecordings(hass, this._cameras, query)) as Map< + QT, + QueryReturnType + > | null; + } else if (QueryClassifier.isRecordingSegmentsQuery(query)) { + engineResult = (await engine.getRecordingSegments( + hass, + this._cameras, + query, + )) as Map> | null; + } + + engineResult?.forEach((value, key) => results.set(key, value)); + }; const processQuery = async (query: QT): Promise => { - const cachedResult: QueryReturnType | null = this._requestCache.get( - query, - ) as QueryReturnType | null; - if (cachedResult) { - queryCachedCount++; - results.set(query, cachedResult); + const engines = this._engineFactory.getEnginesForCameraIDs( + this._cameras, + query.cameraIDs, + ); + if (!engines) { 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); - } - - // The engine may independently cached the results. Respect that in our - // debug logging. - if (result?.cached) { - queryCachedCount++; - } - - if (result) { - if (result.expiry) { - this._requestCache.set(query, { ...result, cached: true }, result.expiry); - } - results.set(query, result as QueryReturnType); - } + await Promise.all( + Array.from(engines.keys()).map((engine) => + processEngineQuery(engine, { ...query, cameraIDs: engines.get(engine) }), + ), + ); }; await Promise.all(_queries.map((query) => processQuery(query))); + const cachedOutputQueries = sum( + Array.from(results.values()).map((result) => Number(result.cached)), + ); + console.debug( - 'Frigate Card CameraManager request (Cached:', - `${queryCachedCount}/${_queries.length},`, - `Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, - 'Queries:', + 'Frigate Card CameraManager request [Input queries:', + _queries.length, + ', Cached output queries:', + cachedOutputQueries, + ', Total output queries:', + results.size, + ', Duration:', + `${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, + ', Queries:', _queries, ', Results:', results, - ')', + ']', ); return results; } protected _convertQueryResultsToMedia( - results: Map>, + results: ResultsMap, ): ViewMedia[] { const mediaArray: ViewMedia[] = []; for (const [query, result] of results.entries()) { - const cameraConfig = this._cameras.get(query.cameraID); - const engine = this._engineFactory.getEngineForCamera(cameraConfig); + const engine = this._engineFactory.getEngine(result.engine); - if (engine && cameraConfig) { + if (engine) { let media: ViewMedia[] | null = null; if ( QueryClassifier.isEventQuery(query) && QueryResultClassifier.isEventQueryResult(result) ) { - media = engine.generateMediaFromEvents(cameraConfig, query, result); + media = engine.generateMediaFromEvents(this._cameras, query, result); } else if ( QueryClassifier.isRecordingQuery(query) && QueryResultClassifier.isRecordingQuery(result) ) { - media = engine.generateMediaFromRecordings(cameraConfig, query, result); + media = engine.generateMediaFromRecordings(this._cameras, query, result); } if (media) { mediaArray.push(...media); diff --git a/src/camera/types.ts b/src/camera/types.ts index f8a9f831..a3a947f9 100644 --- a/src/camera/types.ts +++ b/src/camera/types.ts @@ -23,7 +23,7 @@ export enum Engine { export interface DataQuery { type: QueryType; - cameraID: string; + cameraIDs: Set; } export type PartialDataQuery = Partial; @@ -63,6 +63,11 @@ export type PartialQueryConcreteType = PQT extends PartialEventQuery ? RecordingSegmentsQuery : never; +export type ResultsMap = Map>; +export type EventQueryResultsMap = ResultsMap; +export type RecordingQueryResultsMap = ResultsMap; +export type RecordingSegmentsQueryResultsMap = ResultsMap; + // =========== // Event Query // =========== @@ -77,10 +82,10 @@ export interface EventQuery extends MediaQuery { hasClip?: boolean; // Frigate equivalent: label - what?: string; + what?: Set; // Frigate equivalent: zone - where?: string; + where?: Set; } export type PartialEventQuery = Partial; @@ -121,15 +126,18 @@ export interface RecordingSegmentsQueryResults extends QueryResults { export interface FrigateEventQueryResults extends EventQueryResults { engine: Engine.Frigate; + instanceID: string; events: FrigateEvent[]; } export interface FrigateRecordingQueryResults extends RecordingQueryResults { engine: Engine.Frigate; + instanceID: string; recordings: FrigateRecording[]; } export interface FrigateRecordingSegmentsQueryResults extends RecordingSegmentsQueryResults { engine: Engine.Frigate; + instanceID: string; } diff --git a/src/card.ts b/src/card.ts index 8a224290..478fd580 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1106,7 +1106,6 @@ export class FrigateCard extends LitElement { this._cameraManager = new CameraManager( new CameraManagerEngineFactory(), this._cameras, - new RequestCache(), ); } diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index f9c8d05a..67269885 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -429,7 +429,7 @@ export class FrigateCardTimelineCore extends LitElement { }) // Whether or not to set the timeline window. .mergeInContext({ ...(canSeek && { mediaViewer: { seek: targetTime } }), - ...this._setWindowInContext(properties) + ...this._setWindowInContext(properties), }) .dispatchChangeEvent(this); } @@ -616,9 +616,12 @@ export class FrigateCardTimelineCore extends LitElement { options?.window ?? this._timeline.getWindow(), ); - return new EventMediaQueries( - this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow), - ); + const eventQueries = + this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow); + if (!eventQueries) { + return null; + } + return new EventMediaQueries(eventQueries); } protected async _createViewWithEventMediaQuery( diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 58f70f8a..48e132c6 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -53,12 +53,15 @@ export const createViewForEvents = async ( ? options.cameraIDs : new Set(getAllDependentCameras(cameras, view.camera)); - const queries = cameraManager.generateDefaultEventQueries(cameraIDs, { + const eventQueries = cameraManager.generateDefaultEventQueries(cameraIDs, { ...(options?.limit && { limit: options.limit }), ...(options?.mediaType === 'clips' && { hasClip: true }), ...(options?.mediaType === 'snapshots' && { hasSnapshot: true }), }); - query = new EventMediaQueries(queries); + if (!eventQueries) { + return null; + } + query = new EventMediaQueries(eventQueries); } let queryResults: MediaQueriesResults | null; @@ -137,12 +140,16 @@ export const createViewForRecordings = async ( ? options.cameraIDs : new Set(getAllDependentCameras(cameras, view.camera)); - const queries = cameraManager.generateDefaultRecordingQueries(cameraIDs, { + const recordingQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, { ...(options?.start && { start: options.start }), ...(options?.end && { end: options.end }), }); - const query = new RecordingMediaQueries(queries); + if (!recordingQueries) { + return null; + } + + const query = new RecordingMediaQueries(recordingQueries); let queryResults: MediaQueriesResults | null; try { diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index 3ddd9136..81d5cd41 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -7,7 +7,7 @@ import { ClipsOrSnapshotsOrAll, RecordingSegment } from '../types'; import { CameraManager } from '../camera/manager'; import { EventQuery } from '../camera/types'; import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera/util'; -import { EventMediaQueries } from "../view/media-queries"; +import { EventMediaQueries } from '../view/media-queries'; import { ViewMedia } from '../view/media'; import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet } from '../camera/range'; import { errorToConsole, ModifyInterface } from './basic.js'; @@ -85,10 +85,7 @@ export class TimelineDataSource { } } - public async refresh( - hass: HomeAssistant, - window: TimelineWindow, - ): Promise { + public async refresh(hass: HomeAssistant, window: TimelineWindow): Promise { try { await Promise.all([ this._refreshEvents(hass, window), @@ -110,7 +107,7 @@ export class TimelineDataSource { }); } - public getTimelineEventQueries(window: TimelineWindow): EventQuery[] { + public getTimelineEventQueries(window: TimelineWindow): EventQuery[] | null { return this._cameraManager.generateDefaultEventQueries(this._cameraIDs, { start: window.start, end: window.end, @@ -135,9 +132,11 @@ export class TimelineDataSource { } const cacheFriendlyWindow = this.getCacheFriendlyEventWindow(window); - const query = new EventMediaQueries( - this.getTimelineEventQueries(cacheFriendlyWindow), - ); + const eventQueries = this.getTimelineEventQueries(cacheFriendlyWindow) + if (!eventQueries) { + return; + } + const query = new EventMediaQueries(eventQueries); const results = await this._cameraManager.executeMediaQueries(hass, query); for (const media of results?.getResults() ?? []) { @@ -224,7 +223,7 @@ export class TimelineDataSource { endCap: true, }); - const queries = this._cameraManager.generateDefaultRecordingSegmentsQueries( + const recordingQueries = this._cameraManager.generateDefaultRecordingSegmentsQueries( this._cameraIDs, { start: cacheFriendlyWindow.start, @@ -232,16 +231,21 @@ export class TimelineDataSource { }, ); - const results = await this._cameraManager.getRecordingSegments(hass, queries); + if (!recordingQueries) { + return; + } + const results = await this._cameraManager.getRecordingSegments(hass, recordingQueries); const newSegments: Map = new Map(); for (const [query, result] of results) { - let destination: RecordingSegment[] | undefined = newSegments.get(query.cameraID); - if (!destination) { - destination = []; - newSegments.set(query.cameraID, destination); + for (const cameraID of query.cameraIDs) { + let destination: RecordingSegment[] | undefined = newSegments.get(cameraID); + if (!destination) { + destination = []; + newSegments.set(cameraID, destination); + } + result.segments.forEach((segment) => destination?.push(segment)); } - result.segments.forEach((segment) => destination?.push(segment)); } for (const [cameraID, segments] of newSegments.entries()) { From 6ca7ef85ec12bd983e9f2170587b2180097b4c18 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 23 Jan 2023 21:49:18 -0800 Subject: [PATCH 42/74] Minor cleanup. --- src/camera/frigate/engine-frigate.ts | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index c2acf1b9..9446c135 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -311,16 +311,17 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { ): Promise { const output: RecordingQueryResultsMap = new Map(); - const processQuery = async (query: RecordingQuery): Promise => { + const processQuery = async ( + baseQuery: RecordingQuery, + cameraID: string, + ): Promise => { + const query = { ...baseQuery, cameraIDs: new Set([cameraID]) }; const cachedResult = this._requestCache.get(query); if (cachedResult) { output.set(query, cachedResult as RecordingQueryResults); return; } - // There will only ever be a single cameraID specified for queries in this - // inner function. - const cameraID = [...query.cameraIDs][0]; const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); if (!cameraConfig || !cameraConfig.frigate.camera_name) { return; @@ -380,9 +381,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { // Frigate recordings can only be queried for a single camera, so fan out // the inbound query into multiple outbound queries. await Promise.all( - Array.from(query.cameraIDs).map((cameraID) => - processQuery({ ...query, cameraIDs: new Set([cameraID]) }), - ), + Array.from(query.cameraIDs).map((cameraID) => processQuery(query, cameraID)), ); return output.size ? output : null; } @@ -394,10 +393,11 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { ): Promise { const output: RecordingSegmentsQueryResultsMap = new Map(); - const processQuery = async (query: RecordingSegmentsQuery): Promise => { - // There will only ever be a single cameraID specified for queries in this - // inner function. - const cameraID = [...query.cameraIDs][0]; + const processQuery = async ( + baseQuery: RecordingSegmentsQuery, + cameraID: string, + ): Promise => { + const query = { ...baseQuery, cameraIDs: new Set([cameraID]) }; const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); if (!cameraConfig || !cameraConfig.frigate.camera_name) { return; @@ -446,9 +446,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { // Frigate recording segments can only be queried for a single camera, so // fan out the inbound query into multiple outbound queries. await Promise.all( - Array.from(query.cameraIDs).map((cameraID) => - processQuery({ ...query, cameraIDs: new Set([cameraID]) }), - ), + Array.from(query.cameraIDs).map((cameraID) => processQuery(query, cameraID)), ); runWhenIdleIfSupported(() => this._throttledSegmentGarbageCollector(hass, cameras)); From bd19ed7a74feb110610a1ee1361ccc4690531607 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 24 Jan 2023 21:55:38 -0800 Subject: [PATCH 43/74] Add initial media filtering for the gallery. --- src/camera/frigate/engine-frigate.ts | 5 +- src/camera/frigate/requests.ts | 1 + src/camera/manager.ts | 12 +- src/camera/types.ts | 2 + src/components/gallery.ts | 2 + src/components/media-filter-core.ts | 197 ++++++++++++++++----------- src/components/media-filter.ts | 66 ++++++--- src/components/thumbnail.ts | 36 +++-- src/utils/thumbnail.ts | 2 +- src/view/view.ts | 1 + 10 files changed, 208 insertions(+), 116 deletions(-) diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 9446c135..0d0aa9f3 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -268,13 +268,14 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { const nativeQuery: NativeFrigateEventQuery = { instance_id: instanceID, cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs)), - ...(query.what && { label: Array.from(query.what) }), - ...(query.where && { zone: Array.from(query.where) }), + ...(query.what && { labels: Array.from(query.what) }), + ...(query.where && { zones: Array.from(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 }), + ...(query.favorite && { favorites: query.favorite }), limit: query?.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, }; diff --git a/src/camera/frigate/requests.ts b/src/camera/frigate/requests.ts index f4473598..dcb75ec4 100644 --- a/src/camera/frigate/requests.ts +++ b/src/camera/frigate/requests.ts @@ -103,6 +103,7 @@ export interface NativeFrigateEventQuery { limit?: number; has_clip?: boolean; has_snapshot?: boolean; + favorites?: boolean; } /** diff --git a/src/camera/manager.ts b/src/camera/manager.ts index d85869fe..17142044 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -87,31 +87,31 @@ export class CameraManager { public generateDefaultEventQueries( cameraIDs: string | Set, - partialQuery: PartialEventQuery, + partialQuery?: PartialEventQuery, ): EventQuery[] | null { return this._generateDefaultQueries(cameraIDs, { - ...partialQuery, type: QueryType.Event, + ...partialQuery, }); } public generateDefaultRecordingQueries( cameraIDs: string | Set, - partialQuery: PartialRecordingQuery, + partialQuery?: PartialRecordingQuery, ): RecordingQuery[] | null { return this._generateDefaultQueries(cameraIDs, { - ...partialQuery, type: QueryType.Recording, + ...partialQuery, }); } public generateDefaultRecordingSegmentsQueries( cameraIDs: string | Set, - partialQuery: PartialRecordingSegmentsQuery, + partialQuery?: PartialRecordingSegmentsQuery, ): RecordingSegmentsQuery[] | null { return this._generateDefaultQueries(cameraIDs, { - ...partialQuery, type: QueryType.RecordingSegments, + ...partialQuery, }); } diff --git a/src/camera/types.ts b/src/camera/types.ts index a3a947f9..ebd61e80 100644 --- a/src/camera/types.ts +++ b/src/camera/types.ts @@ -86,6 +86,8 @@ export interface EventQuery extends MediaQuery { // Frigate equivalent: zone where?: Set; + + favorite?: boolean; } export type PartialEventQuery = Partial; diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 0d14c132..c73cfb1e 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -115,6 +115,8 @@ export class FrigateCardGallery extends LitElement { .hass=${this.hass} .cameras=${this.cameras} .cameraManager=${this.cameraManager} + .view=${this.view} + .mediaLimit=${GALLERY_MEDIA_CHUNK_SIZE} slot="right" > diff --git a/src/components/media-filter-core.ts b/src/components/media-filter-core.ts index 692edcde..458c2b6c 100644 --- a/src/components/media-filter-core.ts +++ b/src/components/media-filter-core.ts @@ -1,4 +1,11 @@ -import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { DateRange } from '../camera/range'; @@ -13,9 +20,9 @@ export interface ValueLabel { } export interface MediaFilterCoreSelection { - camera?: string[]; - what?: string[]; - where?: string[]; + cameraIDs?: Set; + what?: Set; + where?: Set; when?: MediaFilterCoreWhenSelection; favorite?: MediaFilterCoreFavoriteSelection; } @@ -25,13 +32,11 @@ type FilterElement = HTMLElement & { }; export enum MediaFilterCoreFavoriteSelection { - All = 'all', Favorite = 'favorite', NotFavorite = 'not-favorite', } export enum MediaFilterCoreWhen { - All = 'all', Today = 'today', Yesterday = 'yesterday', PastWeek = 'past-week', @@ -50,10 +55,10 @@ export class FrigateCardMediaFilterCore extends LitElement { public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - public whenOptions?: ValueLabel[]; + public cameraOptions?: ValueLabel[]; @property({ attribute: false }) - public cameraOptions?: ValueLabel[]; + public whenOptions?: ValueLabel[]; @property({ attribute: false }) public whatOptions?: ValueLabel[]; @@ -61,54 +66,27 @@ export class FrigateCardMediaFilterCore extends LitElement { @property({ attribute: false }) public whereOptions?: ValueLabel[]; - protected _refWhen: Ref> = createRef(); + @property({ attribute: false }) + public defaults?: MediaFilterCoreSelection; + + protected _cameraOptions?: ValueLabel[]; + protected _whenOptions?: ValueLabel[]; + protected _whatOptions?: ValueLabel[]; + protected _whereOptions?: ValueLabel[]; + protected _favoriteOptions: ValueLabel[]; + protected _refCamera: Ref> = createRef(); + protected _refWhen: Ref> = createRef(); protected _refWhat: Ref> = createRef(); protected _refWhere: Ref> = createRef(); protected _refFavorite: Ref> = createRef(); - protected _valueChangedHandler(ev: CustomEvent<{ value: unknown }>): void { - // Handler is called on initial load -- skip it. - if (!ev.detail.value) { - return; - } - const values: MediaFilterCoreSelection = { - ...(this._refWhen.value && { - when: this._refWhen.value.selectedItem?.value as MediaFilterCoreWhenSelection, - }), - ...(this._refCamera.value && { - camera: this._refCamera.value.selectedItem?.value - ? [this._refCamera.value.selectedItem?.value] - : undefined, - }), - ...(this._refWhat.value && { - what: this._refWhat.value.selectedItem?.value - ? [this._refWhat.value.selectedItem?.value] - : undefined, - }), - ...(this._refWhere.value && { - where: this._refWhere.value.selectedItem?.value - ? [this._refWhere.value.selectedItem?.value] - : undefined, - }), - ...(this._refFavorite.value && { - favorite: this._refFavorite.value.selectedItem?.value as - | MediaFilterCoreFavoriteSelection - | undefined, - }), - }; - dispatchFrigateCardEvent(this, 'media-filter-core:change', values); - } - - /** - * Master render method. - * @returns A rendered template. - */ - protected render(): TemplateResult | void { - const favoriteOptions: ValueLabel[] = [ + constructor() { + super(); + this._favoriteOptions = [ { - value: MediaFilterCoreFavoriteSelection.All, + value: undefined, label: localize('media_filter.all'), }, { @@ -120,39 +98,100 @@ export class FrigateCardMediaFilterCore extends LitElement { label: localize('media_filter.not_favorite'), }, ]; + } - // Time based options are not pre-computed here to ensure relative dates - // (e.g. 'today') are always calculated when activated not when rendered. - const whenOptions = [ - { - value: { selection: MediaFilterCoreWhen.All }, - label: localize('media_filter.all'), - }, - { - value: { selection: MediaFilterCoreWhen.Today }, - label: localize('media_filter.today'), - }, - { - value: { selection: MediaFilterCoreWhen.Yesterday }, - label: localize('media_filter.yesterday'), - }, - { - value: { selection: MediaFilterCoreWhen.PastWeek }, - label: localize('media_filter.past_week'), - }, - { - value: { selection: MediaFilterCoreWhen.PastMonth }, - label: localize('media_filter.past_month'), - }, - ...(this.whenOptions ?? []), - ]; + protected _valueChangedHandler(ev: CustomEvent<{ value: unknown }>): void { + // Handler is called on initial load -- skip it. + if (!ev.detail.value) { + return; + } + const values: MediaFilterCoreSelection = { + ...(this._refCamera.value && + this._refCamera.value.selectedItem?.value && { + cameraIDs: new Set([this._refCamera.value.selectedItem.value]), + }), + ...(this._refWhen.value && + this._refWhen.value.selectedItem?.value && { + when: this._refWhen.value.selectedItem?.value, + }), + ...(this._refWhat.value && + this._refWhat.value.selectedItem?.value && { + what: new Set([this._refWhat.value.selectedItem.value]), + }), + ...(this._refWhere.value && + this._refWhere.value.selectedItem?.value && { + where: new Set([this._refWhere.value.selectedItem?.value]), + }), + ...(this._refFavorite.value && + this._refFavorite.value.selectedItem?.value !== undefined && { + favorite: this._refFavorite.value.selectedItem?.value, + }), + }; + dispatchFrigateCardEvent(this, 'media-filter-core:change', values); + } + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('cameraOptions')) { + this._cameraOptions = [ + { + value: undefined, + label: localize('media_filter.all'), + }, + ...(this.cameraOptions ?? []), + ]; + } + if (changedProps.has('whenOptions')) { + // Time based options are not pre-computed here to ensure relative dates + // (e.g. 'today') are always calculated when activated not when rendered. + this._whenOptions = [ + { + value: undefined, + label: localize('media_filter.all'), + }, + { + value: { selection: MediaFilterCoreWhen.Today }, + label: localize('media_filter.today'), + }, + { + value: { selection: MediaFilterCoreWhen.Yesterday }, + label: localize('media_filter.yesterday'), + }, + { + value: { selection: MediaFilterCoreWhen.PastWeek }, + label: localize('media_filter.past_week'), + }, + { + value: { selection: MediaFilterCoreWhen.PastMonth }, + label: localize('media_filter.past_month'), + }, + ...(this.whenOptions ?? []), + ]; + } + if (changedProps.has('whatOptions')) { + this._whatOptions = [ + { value: undefined, label: localize('media_filter.all') }, + ...(this.whatOptions ?? []), + ]; + } + if (changedProps.has('whereOptions')) { + this._whereOptions = [ + { value: undefined, label: localize('media_filter.all') }, + ...(this.whereOptions ?? []), + ]; + } + } + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { return html` @@ -161,7 +200,7 @@ export class FrigateCardMediaFilterCore extends LitElement { ${ref(this._refCamera)} .hass=${this.hass} .label=${localize('media_filter.camera')} - .items=${this.cameraOptions} + .items=${this._cameraOptions} .allowCustomValue=${false} @value-changed=${this._valueChangedHandler.bind(this)} >` @@ -171,7 +210,7 @@ export class FrigateCardMediaFilterCore extends LitElement { ${ref(this._refWhat)} .hass=${this.hass} .label=${localize('media_filter.what')} - .items=${this.whatOptions} + .items="${this._whatOptions}" .allowCustomValue=${false} @value-changed=${this._valueChangedHandler.bind(this)} >` @@ -181,7 +220,7 @@ export class FrigateCardMediaFilterCore extends LitElement { ${ref(this._refWhere)} .hass=${this.hass} .label=${localize('media_filter.where')} - .items=${this.whereOptions} + .items=${this._whereOptions} .allowCustomValue=${false} @value-changed=${this._valueChangedHandler.bind(this)} >` @@ -190,7 +229,7 @@ export class FrigateCardMediaFilterCore extends LitElement { ${ref(this._refFavorite)} .hass=${this.hass} .label=${localize('media_filter.favorite')} - .items=${favoriteOptions} + .items=${this._favoriteOptions} .allowCustomValue=${false} @value-changed=${this._valueChangedHandler.bind(this)} > diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index 8841720e..9ff2c354 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -1,8 +1,9 @@ -import { sub } from 'date-fns'; +import sub from 'date-fns/sub'; import endOfDay from 'date-fns/endOfDay'; import endOfYesterday from 'date-fns/endOfYesterday'; import endOfToday from 'date-fns/esm/endOfToday'; import startOfToday from 'date-fns/esm/startOfToday'; +import startOfDay from 'date-fns/startOfDay'; import startOfYesterday from 'date-fns/startOfYesterday'; import { css, @@ -17,6 +18,7 @@ import { CameraManager } from '../camera/manager'; import { DateRange } from '../camera/range'; import { CameraConfig, ExtendedHomeAssistant } from '../types'; import { getCameraTitle } from '../utils/camera'; +import { View } from '../view/view'; import { MediaFilterCoreFavoriteSelection, MediaFilterCoreSelection, @@ -26,6 +28,9 @@ import { } from './media-filter-core'; import './surround.js'; import './timeline-core.js'; +import { EventQuery, QueryType } from '../camera/types'; +import { EventMediaQueries } from '../view/media-queries'; +import { createViewForEvents } from '../utils/media-to-view.js'; @customElement('frigate-card-media-filter') export class FrigateCardMediaFilter extends LitElement { @@ -38,6 +43,12 @@ export class FrigateCardMediaFilter extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; + @property({ attribute: false }) + public view?: View; + + @property({ attribute: false }) + public mediaLimit?: number; + protected _cameraOptions: ValueLabel[] = []; protected _convertWhenToDateRange( @@ -46,9 +57,6 @@ export class FrigateCardMediaFilter extends LitElement { if (!value) { return null; } - if (value.selection === MediaFilterCoreWhen.Custom && value.custom) { - return value.custom; - } const now = new Date(); switch (value.selection) { case MediaFilterCoreWhen.Today: @@ -56,9 +64,13 @@ export class FrigateCardMediaFilter extends LitElement { case MediaFilterCoreWhen.Yesterday: return { start: startOfYesterday(), end: endOfYesterday() }; case MediaFilterCoreWhen.PastWeek: - return { start: sub(now, { days: 7 }), end: endOfDay(now) }; + return { start: startOfDay(sub(now, { days: 7 })), end: endOfDay(now) }; case MediaFilterCoreWhen.PastMonth: - return { start: sub(now, { months: 1 }), end: endOfDay(now) }; + return { start: startOfDay(sub(now, { months: 1 })), end: endOfDay(now) }; + case MediaFilterCoreWhen.Custom: + if (value.custom) { + return value.custom; + } } return null; } @@ -66,22 +78,44 @@ export class FrigateCardMediaFilter extends LitElement { protected _convertFavoriteToBoolean( value?: MediaFilterCoreFavoriteSelection, ): boolean | null { - if (!value || value === MediaFilterCoreFavoriteSelection.All) { + if (!value) { return null; } return value === MediaFilterCoreFavoriteSelection.Favorite; } - protected _mediaFilterHandler(ev: CustomEvent): void { - const convertedTime = this._convertWhenToDateRange(ev.detail.when); - const convertedFavorite = this._convertFavoriteToBoolean(ev.detail.favorite); - const details = { - ...ev.detail, - ...(convertedTime && { when: convertedTime }), - ...(convertedFavorite && { favorite: convertedFavorite }), + protected async _mediaFilterHandler( + ev: CustomEvent, + ): Promise { + if (!this.cameras || !this.cameraManager || !this.hass || !this.view) { + return; + } + const mediaFilter = ev.detail; + const convertedTime = this._convertWhenToDateRange(mediaFilter.when); + const convertedFavorite = this._convertFavoriteToBoolean(mediaFilter.favorite); + + const query: EventQuery = { + type: QueryType.Event, + cameraIDs: mediaFilter.cameraIDs ?? new Set(this.cameras.keys()), + ...(mediaFilter.what && { what: mediaFilter.what }), + ...(mediaFilter.where && { where: mediaFilter.where }), + ...(convertedFavorite !== null && { favorite: convertedFavorite }), + ...(convertedTime && { start: convertedTime.start, end: convertedTime.end }), + ...(this.mediaLimit && { limit: this.mediaLimit }), }; - // TODO: remove. - console.debug('Received media filter choices:', details); + + ( + await createViewForEvents( + this, + this.hass, + this.cameraManager, + this.cameras, + this.view, + { + query: new EventMediaQueries([query]), + }, + ) + )?.dispatchChangeEvent(this); } protected willUpdate(changedProps: PropertyValues): void { diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index a5f7ab3c..dcdd7b52 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -1,6 +1,5 @@ import format from 'date-fns/format'; -import fromUnixTime from 'date-fns/fromUnixTime'; -import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { CSSResult, html, LitElement, PropertyValues, 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'; @@ -12,9 +11,9 @@ import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { errorToConsole, getDurationString, prettifyTitle } from '../utils/basic.js'; import { getCameraTitle } from '../utils/camera.js'; import { renderTask } from '../utils/task.js'; -import { createFetchThumbnailTask } from '../utils/thumbnail.js'; +import { createFetchThumbnailTask, FetchThumbnailTaskArgs } from '../utils/thumbnail.js'; import { View } from '../view/view.js'; -import { TaskStatus } from '@lit-labs/task'; +import { Task, TaskStatus } from '@lit-labs/task'; import type { CameraConfig, ExtendedHomeAssistant } from '../types.js'; import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js'; @@ -32,12 +31,7 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; - protected _embedThumbnailTask = createFetchThumbnailTask( - this, - () => this.hass, - () => this.thumbnail, - false, - ); + protected _embedThumbnailTask?: Task; // Only load thumbnails on view in case there is a very large number of them. protected _intersectionObserver: IntersectionObserver; @@ -65,20 +59,38 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement { this._intersectionObserver.disconnect(); } + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('thumbnail')) { + this._embedThumbnailTask = createFetchThumbnailTask( + this, + () => this.hass, + () => this.thumbnail, + false, + ); + // Reset the observer so the initial intersection handler call will set + // the visibility correctly. + this._intersectionObserver.unobserve(this); + this._intersectionObserver.observe(this); + } + } + /** * Called when the live view intersects with the viewport. * @param entries The IntersectionObserverEntry entries (should be only 1). */ protected _intersectionHandler(entries: IntersectionObserverEntry[]): void { if ( - this._embedThumbnailTask.status === TaskStatus.INITIAL && + this._embedThumbnailTask?.status === TaskStatus.INITIAL && entries.some((entry) => entry.isIntersecting) ) { - this._embedThumbnailTask.run(); + this._embedThumbnailTask?.run(); } } protected render(): TemplateResult | void { + if (!this._embedThumbnailTask) { + return; + } const imageOff = html` Date: Thu, 26 Jan 2023 21:55:07 -0800 Subject: [PATCH 44/74] Fetch metadata from engine. --- src/camera/engine-factory.ts | 9 ++- src/camera/engine.ts | 10 ++- src/camera/frigate/engine-frigate.ts | 63 +++++++++++++++-- src/camera/frigate/requests.ts | 45 +++++++++--- src/camera/frigate/types.ts | 23 ++++-- src/camera/manager.ts | 42 ++++++++++- src/camera/types.ts | 6 ++ src/components/media-filter.ts | 101 +++++++++++++++++++++------ src/utils/basic.ts | 21 +++++- src/view/view.ts | 1 + 10 files changed, 270 insertions(+), 51 deletions(-) diff --git a/src/camera/engine-factory.ts b/src/camera/engine-factory.ts index 1a3bdbb6..889ff185 100644 --- a/src/camera/engine-factory.ts +++ b/src/camera/engine-factory.ts @@ -62,6 +62,13 @@ export class CameraManagerEngineFactory { } output.get(engine)?.add(cameraID); } - return output; + return output.size ? output : null; + } + + public getAllEngines( + cameras: Map, + ): CameraManagerEngine[] | null { + const engines = this.getEnginesForCameraIDs(cameras, new Set(cameras.keys())); + return engines ? [...engines.keys()] : null; } } diff --git a/src/camera/engine.ts b/src/camera/engine.ts index 904c9bdf..cbf67284 100644 --- a/src/camera/engine.ts +++ b/src/camera/engine.ts @@ -5,6 +5,7 @@ import { DataQuery, EventQuery, EventQueryResultsMap, + MediaMetadata, PartialEventQuery, PartialRecordingQuery, PartialRecordingSegmentsQuery, @@ -75,9 +76,7 @@ export interface CameraManagerEngine { favorite: boolean, ): Promise; - getQueryResultMaxAge( - query: DataQuery - ): number | null; + getQueryResultMaxAge(query: DataQuery): number | null; getMediaSeekTime( hass: HomeAssistant, @@ -85,4 +84,9 @@ export interface CameraManagerEngine { media: ViewMedia, target: Date, ): Promise; + + getMediaMetadata( + hass: HomeAssistant, + cameras: Map, + ): Promise; } diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 0d0aa9f3..2220a01c 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -20,6 +20,7 @@ import { FrigateEventQueryResults, FrigateRecordingQueryResults, FrigateRecordingSegmentsQueryResults, + MediaMetadata, PartialEventQuery, PartialRecordingQuery, PartialRecordingSegmentsQuery, @@ -36,6 +37,7 @@ import { import { FrigateRecording } from './types'; import { getEvents, + getEventSummary, getRecordingSegments, getRecordingsSummary, NativeFrigateEventQuery, @@ -44,7 +46,7 @@ import { } from './requests'; import orderBy from 'lodash-es/orderBy'; import throttle from 'lodash-es/throttle'; -import { runWhenIdleIfSupported } from '../../utils/basic'; +import { allPromises, runWhenIdleIfSupported } from '../../utils/basic'; import { fromUnixTime } from 'date-fns'; import { sum } from 'lodash-es'; import { FrigateViewMediaClassifier } from './media-classifier'; @@ -214,10 +216,10 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { protected _buildInstanceToCameraIDMapFromQuery( cameras: Map, - query: DataQuery, + cameraIDs: Set, ): Map> { const output: Map> = new Map(); - for (const cameraID of query.cameraIDs) { + for (const cameraID of cameraIDs) { const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); const clientID = cameraConfig?.frigate.client_id; if (clientID) { @@ -295,7 +297,10 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { // Frigate allows multiple cameras to be searched for events in a single // query. Break them down into groups of cameras per Frigate instance, then // query once per instance for all cameras in that instance. - const instances = this._buildInstanceToCameraIDMapFromQuery(cameras, query); + const instances = this._buildInstanceToCameraIDMapFromQuery( + cameras, + query.cameraIDs, + ); await Promise.all( Array.from(instances.keys()).map((instanceID) => @@ -611,6 +616,56 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { return cameraConfig; } + public async getMediaMetadata( + hass: HomeAssistant, + cameras: Map, + ): Promise { + const what: Set = new Set(); + const where: Set = new Set(); + const days: Set = new Set(); + + const instances = this._buildInstanceToCameraIDMapFromQuery( + cameras, + new Set(cameras.keys()), + ); + + const processQuery = async ( + instanceID: string, + cameraIDs: Set, + ): Promise => { + const cameraNames = this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs); + for (const entry of await getEventSummary(hass, instanceID)) { + if (!cameraNames.has(entry.camera)) { + // If this entry applies to a camera that *is* in this Frigate + // instance, but is *not* a configured camera in the card, skip it. + continue; + } + if (entry.label) { + what.add(entry.label); + } + if (entry.zones.length) { + entry.zones.forEach(where.add, where); + } + if (entry.day) { + days.add(entry.day); + } + } + }; + + await allPromises([...instances.entries()], ([instanceID, cameraIDs]) => + processQuery(instanceID, cameraIDs), + ); + + if (!what.size && !where.size && !days.size) { + return null; + } + return { + ...(what.size && { what: what }), + ...(where.size && { where: where }), + ...(days.size && { days: days }), + }; + } + /** * Garbage collect recording segments that no longer feature in the recordings * returned by the Frigate backend. diff --git a/src/camera/frigate/requests.ts b/src/camera/frigate/requests.ts index dcb75ec4..01d48878 100644 --- a/src/camera/frigate/requests.ts +++ b/src/camera/frigate/requests.ts @@ -1,22 +1,29 @@ import { HomeAssistant } from 'custom-card-helpers'; import { localize } from '../../localize/localize'; -import { - FrigateCardError, - RecordingSegment, -} from '../../types'; +import { FrigateCardError, RecordingSegment } from '../../types'; import { homeAssistantWSRequest } from '../../utils/ha'; -import { FrigateEvent, frigateEventsSchema, recordingSegmentsSchema, RecordingSummary, recordingSummarySchema, RetainResult, retainResultSchema } from './types'; +import { + EventSummary, + eventSummarySchema, + FrigateEvent, + frigateEventsSchema, + recordingSegmentsSchema, + RecordingSummary, + recordingSummarySchema, + RetainResult, + retainResultSchema, +} from './types'; /** * Get the recordings summary. May throw. * @param hass The Home Assistant object. - * @param client_id The Frigate client_id. + * @param clientID The Frigate clientID. * @param camera_name The Frigate camera name. * @returns A RecordingSummary object. */ export const getRecordingsSummary = async ( hass: HomeAssistant, - client_id: string, + clientID: string, camera_name: string, ): Promise => { return await homeAssistantWSRequest( @@ -24,7 +31,7 @@ export const getRecordingsSummary = async ( recordingSummarySchema, { type: 'frigate/recordings/summary', - instance_id: client_id, + instance_id: clientID, camera: camera_name, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }, @@ -63,19 +70,19 @@ export const getRecordingSegments = async ( /** * Request that Frigate retain an event. May throw. * @param hass The HomeAssistant object. - * @param client_id The Frigate client_id. + * @param clientID The Frigate clientID. * @param eventID The event ID to retain. * @param retain `true` to retain or `false` to unretain. */ export async function retainEvent( hass: HomeAssistant, - client_id: string, + clientID: string, eventID: string, retain: boolean, ): Promise { const retainRequest = { type: 'frigate/event/retain', - instance_id: client_id, + instance_id: clientID, event_id: eventID, retain: retain, }; @@ -126,3 +133,19 @@ export const getEvents = async ( true, ); }; + +export const getEventSummary = async ( + hass: HomeAssistant, + clientID: string, +): Promise => { + return await homeAssistantWSRequest( + hass, + eventSummarySchema, + { + type: 'frigate/events/summary', + instance_id: clientID, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + true, + ); +}; diff --git a/src/camera/frigate/types.ts b/src/camera/frigate/types.ts index 81135e91..41a0cd6b 100644 --- a/src/camera/frigate/types.ts +++ b/src/camera/frigate/types.ts @@ -1,4 +1,9 @@ import { z } from 'zod'; +import { dayToDate } from '../../utils/basic'; + +const dayStringToDate = (arg: unknown): Date | unknown => { + return typeof arg === 'string' ? dayToDate(arg) : arg; +}; export const eventSchema = z.object({ camera: z.string(), @@ -25,11 +30,7 @@ const recordingSummaryHourSchema = z.object({ export const recordingSummarySchema = z .object({ - day: z.preprocess((arg) => { - // Must provide the hour:minute:second on parsing or Javascript will - // assume *UTC* midnight. - return typeof arg === 'string' ? new Date(`${arg}T00:00:00`) : arg; - }, z.date()), + day: z.preprocess(dayStringToDate, z.date()), events: z.number(), hours: recordingSummaryHourSchema.array(), }) @@ -54,4 +55,14 @@ export interface FrigateRecording { startTime: Date; endTime: Date; events: number; -} \ No newline at end of file +} + +export const eventSummarySchema = z + .object({ + camera: z.string(), + day: z.string(), + label: z.string(), + zones: z.string().array(), + }) + .array(); +export type EventSummary = z.infer; diff --git a/src/camera/manager.ts b/src/camera/manager.ts index 17142044..89597178 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -1,11 +1,12 @@ import { HomeAssistant } from 'custom-card-helpers'; import { CameraConfig } from '../types.js'; -import { arrayify, setify } from '../utils/basic.js'; +import { allPromises, arrayify, setify } from '../utils/basic.js'; import { DataQuery, EventQuery, EventQueryResults, EventQueryResultsMap, + MediaMetadata, MediaQuery, PartialDataQuery, PartialEventQuery, @@ -115,6 +116,45 @@ export class CameraManager { }); } + public async getMediaMetadata( + hass: HomeAssistant, + ): Promise { + const what: Set = new Set(); + const where: Set = new Set(); + const days: Set = new Set(); + + const engines = this._engineFactory.getAllEngines(this._cameras); + if (!engines) { + return null; + } + + const processMetadata = async (engine: CameraManagerEngine): Promise => { + const engineMetadata = await engine.getMediaMetadata(hass, this._cameras); + if (engineMetadata) { + if (engineMetadata.what) { + engineMetadata.what.forEach(what.add, what); + } + if (engineMetadata.where) { + engineMetadata.where.forEach(where.add, where); + } + if (engineMetadata.days) { + engineMetadata.days.forEach(days.add, days); + } + } + } + + await allPromises(engines, (engine) => processMetadata(engine)); + + if (!what.size && !where.size && !days.size) { + return null; + } + return { + ...(what.size && { what: what }), + ...(where.size && { where: where }), + ...(days.size && { days: days }), + } + } + protected _generateDefaultQueries( cameraIDs: string | Set, partialQuery: PQT, diff --git a/src/camera/types.ts b/src/camera/types.ts index ebd61e80..6c5b1ed8 100644 --- a/src/camera/types.ts +++ b/src/camera/types.ts @@ -68,6 +68,12 @@ export type EventQueryResultsMap = ResultsMap; export type RecordingQueryResultsMap = ResultsMap; export type RecordingSegmentsQueryResultsMap = ResultsMap; +export interface MediaMetadata { + where?: Set; + what?: Set; + days?: Set; +} + // =========== // Event Query // =========== diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index 9ff2c354..18cb55fa 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -11,6 +11,8 @@ import { html, LitElement, PropertyValues, + ReactiveController, + ReactiveControllerHost, TemplateResult, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; @@ -31,6 +33,11 @@ import './timeline-core.js'; import { EventQuery, QueryType } from '../camera/types'; import { EventMediaQueries } from '../view/media-queries'; import { createViewForEvents } from '../utils/media-to-view.js'; +import { HomeAssistant } from 'custom-card-helpers'; +import { prettifyTitle } from '../utils/basic'; +import format from 'date-fns/format'; +import parse from 'date-fns/parse'; +import endOfMonth from 'date-fns/endOfMonth'; @customElement('frigate-card-media-filter') export class FrigateCardMediaFilter extends LitElement { @@ -50,6 +57,7 @@ export class FrigateCardMediaFilter extends LitElement { public mediaLimit?: number; protected _cameraOptions: ValueLabel[] = []; + protected _mediaMetadataController?: MediaMetadataController; protected _convertWhenToDateRange( value?: MediaFilterCoreWhenSelection, @@ -127,35 +135,23 @@ export class FrigateCardMediaFilter extends LitElement { }), ); } + + if (changedProps.has('cameraManager') && this.hass && this.cameraManager) { + this._mediaMetadataController = new MediaMetadataController( + this, + this.hass, + this.cameraManager, + ); + } } protected render(): TemplateResult | void { - // TODO Replace with real custom when options - // TODO Replace with real custom what options - // TODO Replace with real custom where options - const whereOptions = [{ value: 'steps', label: 'Front Steps' }]; - - const whatOptions = [ - { value: 'car', label: 'Car' }, - { value: 'person', label: 'Person' }, - ]; - - const whenOptions = [ - { - value: { - selection: MediaFilterCoreWhen.Custom, - custom: { start: startOfToday(), end: endOfToday() }, - }, - label: 'December 2021', - }, - ]; - return html` `; @@ -170,6 +166,65 @@ export class FrigateCardMediaFilter extends LitElement { } } +export class MediaMetadataController implements ReactiveController { + protected _host: ReactiveControllerHost; + protected _hass: HomeAssistant; + protected _cameraManager: CameraManager; + + public whenOptions: ValueLabel[] = []; + public whatOptions: ValueLabel[] = []; + public whereOptions: ValueLabel[] = []; + + constructor( + host: ReactiveControllerHost, + hass: HomeAssistant, + cameraManager: CameraManager, + ) { + this._host = host; + this._hass = hass; + this._cameraManager = cameraManager; + host.addController(this); + } + + async hostConnected() { + const metadata = await this._cameraManager.getMediaMetadata(this._hass); + if (metadata) { + if (metadata.what) { + this.whatOptions = [...metadata.what] + .sort() + .map((what) => ({ value: what, label: prettifyTitle(what) })); + } + if (metadata.where) { + this.whereOptions = [...metadata.where] + .sort() + .map((where) => ({ value: where, label: prettifyTitle(where) })); + } + if (metadata.days) { + const yearMonths: Set = new Set(); + [...metadata.days].forEach((day) => { + // An efficient conversion: 2023-01-26 -> 2023-01 + yearMonths.add(day.substring(0, 7)); + }); + const monthStarts: Date[] = []; + yearMonths.forEach((yearMonth) => { + monthStarts.push(parse(yearMonth, 'yyyy-MM', new Date())); + }); + this.whenOptions = monthStarts + .sort() + .reverse() + .map((monthStart) => ({ + label: format(monthStart, 'MMMM yyyy'), + value: { + selection: MediaFilterCoreWhen.Custom, + custom: { start: monthStart, end: endOfMonth(monthStart) }, + }, + })); + } + this._host.requestUpdate(); + } + } +} + declare global { interface HTMLElementTagNameMap { 'frigate-card-media-filter': FrigateCardMediaFilter; diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 040781bd..19ce2cb6 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -32,6 +32,7 @@ export function dispatchFrigateCardEvent( * @param input The input Frigate (camera/label/zone) name. * @returns A prettified name. */ +export function prettifyTitle(input: string): string; export function prettifyTitle(input?: string): string | undefined { if (!input) { return undefined; @@ -158,8 +159,24 @@ export function getDurationString(start: Date, end: Date): string { /** * For debug purposes only. - * @param seconds + * @param seconds */ export const sleep = async (seconds: number) => { - await new Promise(r => setTimeout(r, seconds * 1000)); + await new Promise((r) => setTimeout(r, seconds * 1000)); +}; + +export const allPromises = async ( + items: T[], + func: (arg: T) => void, +): Promise => { + await Promise.all(Array.from(items).map((item) => func(item))); +}; + +/** + * Simple efficient YYYY-MM-DD -> date converter. + */ +export const dayToDate = (day: string): Date => { + // Must provide the hour:minute:second on parsing or Javascript will assume + // *UTC* midnight. + return new Date(`${day}T00:00:00`); } \ No newline at end of file diff --git a/src/view/view.ts b/src/view/view.ts index 07cb9d20..5386fcf3 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -2,6 +2,7 @@ // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript // - TODO: getRecordingTitle should use getCameraTitle but need hass. // - TODO: Take MediaQueries wrappers out of the camera manager. +// - TODO: View a media in the gallery from September, then notice timeline missing the item. // Gallery: // - TODO: Event gallery show_details default does not work. From 3cf278bd01fc795c1ffd37c3818dd60e5b224ae8 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 28 Jan 2023 09:28:36 -0800 Subject: [PATCH 45/74] Media query extension should still honor time start/end. --- src/camera/manager.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/camera/manager.ts b/src/camera/manager.ts index 89597178..4e992dd3 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -268,25 +268,22 @@ export class CameraManager { const newChunkQueries: T[] = []; // The re-constituted combined query. - const newCombinedQueries: T[] = []; + const extendedQueries: T[] = []; for (const query of queries) { const newChunkQuery = { ...query }; - const newCombinedQuery = { ...query }; if (direction === 'later') { newChunkQuery.start = getTimeFromResults('latest') ?? undefined; - newChunkQuery.end = undefined; - newCombinedQuery.end = undefined; } else if (direction === 'earlier') { newChunkQuery.end = getTimeFromResults('earliest') ?? undefined; - newChunkQuery.start = undefined; - newCombinedQuery.start = undefined; } newChunkQuery.limit = chunkSize; - newCombinedQuery.limit = (query.limit ?? 0) + chunkSize; - newCombinedQueries.push(newCombinedQuery); + extendedQueries.push({ + ...query, + limit: (query.limit ?? 0) + chunkSize, + }); newChunkQueries.push(newChunkQuery); } @@ -299,7 +296,7 @@ export class CameraManager { } return { - queries: newCombinedQueries, + queries: extendedQueries, results: this._sortMedia(results.concat(newChunkMedia)), }; } From 931e9febce64d9ac2183c89a7103140332a6c62f Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 28 Jan 2023 15:19:20 -0800 Subject: [PATCH 46/74] Support media type in core filter. --- src/components/media-filter-core.ts | 155 +++++++++++++++++++--------- src/components/media-filter.ts | 9 +- src/localize/languages/en.json | 18 +++- src/localize/languages/it.json | 18 +++- src/localize/languages/pt-BR.json | 18 +++- src/scss/media-filter-core.scss | 16 +++ src/scss/media-filter.scss | 5 +- 7 files changed, 164 insertions(+), 75 deletions(-) create mode 100644 src/scss/media-filter-core.scss diff --git a/src/components/media-filter-core.ts b/src/components/media-filter-core.ts index 458c2b6c..0085e9d3 100644 --- a/src/components/media-filter-core.ts +++ b/src/components/media-filter-core.ts @@ -10,7 +10,7 @@ import { customElement, property } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { DateRange } from '../camera/range'; import { localize } from '../localize/localize'; -import mediaFilterStyle from '../scss/media-filter.scss'; +import mediaFilterCoreStyle from '../scss/media-filter-core.scss'; import { ExtendedHomeAssistant } from '../types'; import { dispatchFrigateCardEvent } from '../utils/basic'; @@ -20,6 +20,7 @@ export interface ValueLabel { } export interface MediaFilterCoreSelection { + mediaType?: MediaFilterMediaType; cameraIDs?: Set; what?: Set; where?: Set; @@ -44,11 +45,26 @@ export enum MediaFilterCoreWhen { Custom = 'custom', } +export enum MediaFilterMediaType { + Clips = 'clips', + Snapshots = 'snapshots', + Recordings = 'recordings', +} + export interface MediaFilterCoreWhenSelection { selection: MediaFilterCoreWhen; custom?: DateRange; } +export enum MediaFilterControl { + mediaType = 'mediaType', + when = 'when', + camera = 'camera', + what = 'what', + where = 'where', + favorite = 'favorite', +} + @customElement('frigate-card-media-filter-core') export class FrigateCardMediaFilterCore extends LitElement { @property({ attribute: false }) @@ -69,12 +85,17 @@ export class FrigateCardMediaFilterCore extends LitElement { @property({ attribute: false }) public defaults?: MediaFilterCoreSelection; + @property({ attribute: false }) + public controls?: Record; + protected _cameraOptions?: ValueLabel[]; protected _whenOptions?: ValueLabel[]; protected _whatOptions?: ValueLabel[]; protected _whereOptions?: ValueLabel[]; protected _favoriteOptions: ValueLabel[]; + protected _mediaTypeOptions: ValueLabel[]; + protected _refMediaType: Ref> = createRef(); protected _refCamera: Ref> = createRef(); protected _refWhen: Ref> = createRef(); protected _refWhat: Ref> = createRef(); @@ -98,6 +119,20 @@ export class FrigateCardMediaFilterCore extends LitElement { label: localize('media_filter.not_favorite'), }, ]; + this._mediaTypeOptions = [ + { + value: MediaFilterMediaType.Clips, + label: localize('media_filter.media_types.clips'), + }, + { + value: MediaFilterMediaType.Snapshots, + label: localize('media_filter.media_types.snapshots'), + }, + { + value: MediaFilterMediaType.Recordings, + label: localize('media_filter.media_types.recordings'), + }, + ]; } protected _valueChangedHandler(ev: CustomEvent<{ value: unknown }>): void { @@ -106,6 +141,10 @@ export class FrigateCardMediaFilterCore extends LitElement { return; } const values: MediaFilterCoreSelection = { + ...(this._refMediaType.value && + this._refMediaType.value.selectedItem?.value && { + mediaType: this._refMediaType.value.selectedItem?.value, + }), ...(this._refCamera.value && this._refCamera.value.selectedItem?.value && { cameraIDs: new Set([this._refCamera.value.selectedItem.value]), @@ -150,19 +189,19 @@ export class FrigateCardMediaFilterCore extends LitElement { }, { value: { selection: MediaFilterCoreWhen.Today }, - label: localize('media_filter.today'), + label: localize('media_filter.whens.today'), }, { value: { selection: MediaFilterCoreWhen.Yesterday }, - label: localize('media_filter.yesterday'), + label: localize('media_filter.whens.yesterday'), }, { value: { selection: MediaFilterCoreWhen.PastWeek }, - label: localize('media_filter.past_week'), + label: localize('media_filter.whens.past_week'), }, { value: { selection: MediaFilterCoreWhen.PastMonth }, - label: localize('media_filter.past_month'), + label: localize('media_filter.whens.past_month'), }, ...(this.whenOptions ?? []), ]; @@ -186,58 +225,72 @@ export class FrigateCardMediaFilterCore extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - return html` - - ${this.cameraOptions - ? html` ` + : ''} + ${this.controls?.when ?? true + ? html`` + : ''} + ${(this.controls?.camera ?? true) && this.cameraOptions + ? html` ` + : ''} + ${(this.controls?.what ?? true) && this.whatOptions + ? html` ` + : ''} + ${(this.controls?.where ?? true) && this.whereOptions + ? html`` + : ''} + ${this.controls?.favorite ?? true + ? html` + ` - : ''} - ${this.whatOptions - ? html` ` - : ''} - ${this.whereOptions - ? html`` - : ''} - - `; + > + ` + : ''}`; } static get styles(): CSSResultGroup { - return unsafeCSS(mediaFilterStyle); + return unsafeCSS(mediaFilterCoreStyle); } } diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index 18cb55fa..20cc4871 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -6,7 +6,6 @@ import startOfToday from 'date-fns/esm/startOfToday'; import startOfDay from 'date-fns/startOfDay'; import startOfYesterday from 'date-fns/startOfYesterday'; import { - css, CSSResultGroup, html, LitElement, @@ -14,6 +13,7 @@ import { ReactiveController, ReactiveControllerHost, TemplateResult, + unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { CameraManager } from '../camera/manager'; @@ -38,6 +38,7 @@ import { prettifyTitle } from '../utils/basic'; import format from 'date-fns/format'; import parse from 'date-fns/parse'; import endOfMonth from 'date-fns/endOfMonth'; +import mediaFilterStyle from '../scss/media-filter.scss'; @customElement('frigate-card-media-filter') export class FrigateCardMediaFilter extends LitElement { @@ -158,11 +159,7 @@ export class FrigateCardMediaFilter extends LitElement { } static get styles(): CSSResultGroup { - return css` - :host { - display: block; - } - `; + return unsafeCSS(mediaFilterStyle); } } diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 3aab668b..bd50e0d4 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -368,14 +368,22 @@ "all": "All", "camera": "Camera", "favorite": "Favorite", + "media_type": "Media Type", + "media_types": { + "clips": "Clips", + "snapshots": "Snapshots", + "recordings": "Recordings" + }, "not_favorite": "Not Favorite", - "past_month": "Past Month", - "past_week": "Past Week", - "today": "Today", "what": "What", "when": "When", - "where": "Where", - "yesterday": "Yesterday" + "whens": { + "past_month": "Past Month", + "past_week": "Past Week", + "today": "Today", + "yesterday": "Yesterday" + }, + "where": "Where" }, "recording": { "events": "Events", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 3c55142b..86833976 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -339,14 +339,22 @@ "all": "", "camera": "", "favorite": "", + "media_type": "", + "media_types": { + "clips": "", + "snapshots": "", + "recordings": "" + }, "not_favorite": "", - "past_month": "", - "past_week": "", - "today": "", "what": "", "when": "", - "where": "", - "yesterday": "" + "whens": { + "past_month": "", + "past_week": "", + "today": "", + "yesterday": "" + }, + "where": "" }, "recording": { "events": "Eventi", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 03d1bfb7..f934ff8a 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -339,14 +339,22 @@ "all": "", "camera": "", "favorite": "", + "media_type": "", + "media_types": { + "clips": "", + "snapshots": "", + "recordings": "" + }, "not_favorite": "", - "past_month": "", - "past_week": "", - "today": "", "what": "", "when": "", - "where": "", - "yesterday": "" + "whens": { + "past_month": "", + "past_week": "", + "today": "", + "yesterday": "" + }, + "where": "" }, "recording": { "events": "Eventos", diff --git a/src/scss/media-filter-core.scss b/src/scss/media-filter-core.scss new file mode 100644 index 00000000..101909bf --- /dev/null +++ b/src/scss/media-filter-core.scss @@ -0,0 +1,16 @@ +:host { + height: 100%; + display: flex; + flex-direction: column; + overflow: auto; + + // Hide scrollbar: Firefox + scrollbar-width: none; + // Hide scrollbar: IE and Edge + -ms-overflow-style: none; +} + +/* Hide scrollbar for Chrome, Safari and Opera */ +:host::-webkit-scrollbar { + display: none; +} diff --git a/src/scss/media-filter.scss b/src/scss/media-filter.scss index 895364c4..478abc91 100644 --- a/src/scss/media-filter.scss +++ b/src/scss/media-filter.scss @@ -1,5 +1,4 @@ :host { - display: flex; - flex-direction: column; - overflow: auto; + display: block; + height: 100%; } From 1ad600c2312a0afc9498593213e82cfc2260a10d Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 08:02:36 -0800 Subject: [PATCH 47/74] Improve media selection logic. --- src/camera/engine-factory.ts | 13 ++++ src/camera/engine.ts | 5 ++ src/camera/frigate/engine-frigate.ts | 15 ++++ src/camera/manager.ts | 22 ++++++ src/camera/types.ts | 13 ++++ src/components/media-filter-core.ts | 18 ++--- src/components/media-filter.ts | 112 ++++++++++++++++++++------- src/components/thumbnail.ts | 17 +++- src/utils/media-to-view.ts | 92 +++++++++++++++------- src/view/view.ts | 1 + 10 files changed, 243 insertions(+), 65 deletions(-) diff --git a/src/camera/engine-factory.ts b/src/camera/engine-factory.ts index 889ff185..d678d97b 100644 --- a/src/camera/engine-factory.ts +++ b/src/camera/engine-factory.ts @@ -1,4 +1,5 @@ import { CameraConfig } from '../types'; +import { ViewMedia } from '../view/media'; import { RecordingSegmentsCache, RequestCache } from './cache'; import { CameraManagerEngine } from './engine'; import { FrigateCameraManagerEngine } from './frigate/engine-frigate'; @@ -65,6 +66,18 @@ export class CameraManagerEngineFactory { return output.size ? output : null; } + public getEngineForMedia( + cameras: Map, + media: ViewMedia, + ): CameraManagerEngine | null { + const cameraID = media.getCameraID(); + if (!cameraID) { + return null; + } + const engines = this.getEnginesForCameraIDs(cameras, new Set([cameraID])); + return engines ? ([...engines.keys()][0] ?? null) : null; + } + public getAllEngines( cameras: Map, ): CameraManagerEngine[] | null { diff --git a/src/camera/engine.ts b/src/camera/engine.ts index cbf67284..646821a4 100644 --- a/src/camera/engine.ts +++ b/src/camera/engine.ts @@ -14,6 +14,8 @@ import { RecordingQueryResultsMap, RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, + CameraManagerEngineCapabilities, + CameraManagerMediaCapabilities, } from './types'; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; @@ -89,4 +91,7 @@ export interface CameraManagerEngine { hass: HomeAssistant, cameras: Map, ): Promise; + + getCapabilities(): CameraManagerEngineCapabilities | null; + getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null; } diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 2220a01c..4199c013 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -12,6 +12,8 @@ import { } from '../engine'; import { DateRange } from '../range'; import { + CameraManagerEngineCapabilities, + CameraManagerMediaCapabilities, DataQuery, Engine, EventQuery, @@ -760,4 +762,17 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } return seekMilliseconds / 1000; } + + public getCapabilities(): CameraManagerEngineCapabilities { + return { + canFavoriteEvents: true, + canFavoriteRecordings: false, + } + } + + public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities { + return { + canFavorite: ViewMediaClassifier.isEvent(media) + } + } } diff --git a/src/camera/manager.ts b/src/camera/manager.ts index 4e992dd3..caa7c265 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -2,6 +2,8 @@ import { HomeAssistant } from 'custom-card-helpers'; import { CameraConfig } from '../types.js'; import { allPromises, arrayify, setify } from '../utils/basic.js'; import { + CameraManagerCapabilities, + CameraManagerMediaCapabilities, DataQuery, EventQuery, EventQueryResults, @@ -312,6 +314,26 @@ export class CameraManager { return engine.getMediaDownloadPath(cameraConfig, media); } + public getCapabilities(): CameraManagerCapabilities | null { + const engines = this._engineFactory.getAllEngines(this._cameras); + if (!engines) { + return null; + } + + return { + canFavoriteEvents: engines.some((engine) => engine.getCapabilities()?.canFavoriteEvents), + canFavoriteRecordings: engines.some((engine) => engine.getCapabilities()?.canFavoriteRecordings), + } + } + + public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null { + const engine = this._engineFactory.getEngineForMedia(this._cameras, media); + if (!engine) { + return null; + } + return engine.getMediaCapabilities(media); + } + public async favoriteMedia( hass: HomeAssistant, media: ViewMedia, diff --git a/src/camera/types.ts b/src/camera/types.ts index 6c5b1ed8..109fd439 100644 --- a/src/camera/types.ts +++ b/src/camera/types.ts @@ -74,6 +74,17 @@ export interface MediaMetadata { days?: Set; } +interface BaseCapabilities { + canFavoriteEvents: boolean; + canFavoriteRecordings: boolean; +} + +export type CameraManagerCapabilities = BaseCapabilities; +export type CameraManagerEngineCapabilities = BaseCapabilities; +export interface CameraManagerMediaCapabilities { + canFavorite: boolean; +} + // =========== // Event Query // =========== @@ -107,6 +118,8 @@ export interface EventQueryResults extends QueryResults { export interface RecordingQuery extends MediaQuery { type: QueryType.Recording; + + favorite?: boolean; } export type PartialRecordingQuery = Partial; diff --git a/src/components/media-filter-core.ts b/src/components/media-filter-core.ts index 0085e9d3..deb1b895 100644 --- a/src/components/media-filter-core.ts +++ b/src/components/media-filter-core.ts @@ -56,14 +56,14 @@ export interface MediaFilterCoreWhenSelection { custom?: DateRange; } -export enum MediaFilterControl { - mediaType = 'mediaType', - when = 'when', - camera = 'camera', - what = 'what', - where = 'where', - favorite = 'favorite', -} +export type MediaFilterControls = { + mediaType?: boolean; + when?: boolean; + camera?: boolean; + what?: boolean; + where?: boolean; + favorite?: boolean; +}; @customElement('frigate-card-media-filter-core') export class FrigateCardMediaFilterCore extends LitElement { @@ -86,7 +86,7 @@ export class FrigateCardMediaFilterCore extends LitElement { public defaults?: MediaFilterCoreSelection; @property({ attribute: false }) - public controls?: Record; + public controls?: MediaFilterControls; protected _cameraOptions?: ValueLabel[]; protected _whenOptions?: ValueLabel[]; diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index 20cc4871..3e98be31 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -16,29 +16,34 @@ import { unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { CameraManager } from '../camera/manager'; +import { + CameraManager, +} from '../camera/manager'; import { DateRange } from '../camera/range'; import { CameraConfig, ExtendedHomeAssistant } from '../types'; import { getCameraTitle } from '../utils/camera'; import { View } from '../view/view'; import { + MediaFilterControls, MediaFilterCoreFavoriteSelection, MediaFilterCoreSelection, MediaFilterCoreWhen, MediaFilterCoreWhenSelection, + MediaFilterMediaType, ValueLabel, } from './media-filter-core'; import './surround.js'; import './timeline-core.js'; -import { EventQuery, QueryType } from '../camera/types'; -import { EventMediaQueries } from '../view/media-queries'; -import { createViewForEvents } from '../utils/media-to-view.js'; +import { EventQuery, QueryType, RecordingQuery } from '../camera/types'; +import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; +import { createViewForEvents, createViewForRecordings } from '../utils/media-to-view.js'; import { HomeAssistant } from 'custom-card-helpers'; import { prettifyTitle } from '../utils/basic'; import format from 'date-fns/format'; import parse from 'date-fns/parse'; import endOfMonth from 'date-fns/endOfMonth'; import mediaFilterStyle from '../scss/media-filter.scss'; +import { MediaQueriesClassifier } from '../view/media-queries-classifier'; @customElement('frigate-card-media-filter') export class FrigateCardMediaFilter extends LitElement { @@ -96,35 +101,68 @@ export class FrigateCardMediaFilter extends LitElement { protected async _mediaFilterHandler( ev: CustomEvent, ): Promise { - if (!this.cameras || !this.cameraManager || !this.hass || !this.view) { + const mediaFilter = ev.detail; + if ( + !this.cameras || + !this.cameraManager || + !this.hass || + !this.view || + !mediaFilter.mediaType + ) { return; } - const mediaFilter = ev.detail; + const convertedTime = this._convertWhenToDateRange(mediaFilter.when); const convertedFavorite = this._convertFavoriteToBoolean(mediaFilter.favorite); - const query: EventQuery = { - type: QueryType.Event, - cameraIDs: mediaFilter.cameraIDs ?? new Set(this.cameras.keys()), - ...(mediaFilter.what && { what: mediaFilter.what }), - ...(mediaFilter.where && { where: mediaFilter.where }), - ...(convertedFavorite !== null && { favorite: convertedFavorite }), - ...(convertedTime && { start: convertedTime.start, end: convertedTime.end }), - ...(this.mediaLimit && { limit: this.mediaLimit }), - }; + if ( + mediaFilter.mediaType === MediaFilterMediaType.Clips || + mediaFilter.mediaType === MediaFilterMediaType.Snapshots + ) { + const query: EventQuery = { + type: QueryType.Event, + cameraIDs: mediaFilter.cameraIDs ?? new Set(this.cameras.keys()), + ...(mediaFilter.what && { what: mediaFilter.what }), + ...(mediaFilter.where && { where: mediaFilter.where }), + ...(convertedFavorite !== null && { favorite: convertedFavorite }), + ...(convertedTime && { start: convertedTime.start, end: convertedTime.end }), + ...(this.mediaLimit && { limit: this.mediaLimit }), + ...(mediaFilter.mediaType === MediaFilterMediaType.Clips && { hasClip: true }), + ...(mediaFilter.mediaType === MediaFilterMediaType.Snapshots && { hasSnapshot: true }) + }; - ( - await createViewForEvents( - this, - this.hass, - this.cameraManager, - this.cameras, - this.view, - { - query: new EventMediaQueries([query]), - }, - ) - )?.dispatchChangeEvent(this); + ( + await createViewForEvents( + this, + this.hass, + this.cameraManager, + this.cameras, + this.view, + { + query: new EventMediaQueries([query]), + }, + ) + )?.dispatchChangeEvent(this); + } else if (mediaFilter.mediaType === MediaFilterMediaType.Recordings) { + const query: RecordingQuery = { + type: QueryType.Recording, + cameraIDs: mediaFilter.cameraIDs ?? new Set(this.cameras.keys()), + ...(convertedTime && { start: convertedTime.start, end: convertedTime.end }), + }; + + ( + await createViewForRecordings( + this, + this.hass, + this.cameraManager, + this.cameras, + this.view, + { + query: new RecordingMediaQueries([query]), + }, + ) + )?.dispatchChangeEvent(this); + } } protected willUpdate(changedProps: PropertyValues): void { @@ -147,12 +185,32 @@ export class FrigateCardMediaFilter extends LitElement { } protected render(): TemplateResult | void { + const areEvents = !!( + this.view?.query && MediaQueriesClassifier.areEventQueries(this.view.query) + ); + const areRecordings = !!( + this.view?.query && MediaQueriesClassifier.areRecordingQueries(this.view.query) + ); + const managerCapabilities = this.cameraManager?.getCapabilities(); + + // Which media controls are shown depends on the view. + const controls: MediaFilterControls = { + what: areEvents, + where: areEvents, + favorite: areEvents + ? !!managerCapabilities?.canFavoriteEvents + : areRecordings + ? !!managerCapabilities?.canFavoriteRecordings + : false, + }; + return html` `; diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index dcdd7b52..c9de4450 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -1,5 +1,12 @@ import format from 'date-fns/format'; -import { CSSResult, html, LitElement, PropertyValues, TemplateResult, unsafeCSS } from 'lit'; +import { + CSSResult, + html, + LitElement, + PropertyValues, + 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'; @@ -282,6 +289,12 @@ export class FrigateCardThumbnail extends LitElement { // Only show timeline control if the recording has a start & end time. (this.media.getStartTime() && this.media.getEndTime())); + const shouldShowFavoriteControl = + this.show_favorite_control && + this.media && + this.hass && + this.cameraManager?.getMediaCapabilities(this.media)?.canFavorite; + return html` ${ViewMediaClassifier.isEvent(this.media) ? html`` : html``} - ${this.show_favorite_control && this.media && this.hass + ${shouldShowFavoriteControl ? html` => { let query: EventMediaQueries; + const cameraIDs: Set = options?.cameraIDs + ? options.cameraIDs + : new Set(getAllDependentCameras(cameras, view.camera)); + if (options?.query) { query = options.query; } else { - const cameraIDs: Set = options?.cameraIDs - ? options.cameraIDs - : new Set(getAllDependentCameras(cameras, view.camera)); - const eventQueries = cameraManager.generateDefaultEventQueries(cameraIDs, { ...(options?.limit && { limit: options.limit }), ...(options?.mediaType === 'clips' && { hasClip: true }), @@ -64,20 +68,21 @@ export const createViewForEvents = async ( query = new EventMediaQueries(eventQueries); } - let queryResults: MediaQueriesResults | null; - try { - queryResults = await cameraManager.executeMediaQueries(hass, query); - } catch (e) { - errorToConsole(e as Error); - dispatchFrigateCardErrorEvent(element, e as Error); + if (!query) { return null; } - return view?.evolve({ - view: options?.targetView, - query: query, - queryResults: queryResults, - }); + return executeMediaQueryForView( + element, + hass, + cameraManager, + view, + query, + options?.targetView ?? 'clips', + { + cameraIDs: cameraIDs, + }, + ); }; /** @@ -129,6 +134,7 @@ export const createViewForRecordings = async ( cameras: Map, view: View, options?: { + query?: RecordingMediaQueries; cameraIDs?: Set; targetView?: 'recording' | 'recordings'; targetTime?: Date; @@ -140,16 +146,48 @@ export const createViewForRecordings = async ( ? options.cameraIDs : new Set(getAllDependentCameras(cameras, view.camera)); - const recordingQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, { - ...(options?.start && { start: options.start }), - ...(options?.end && { end: options.end }), - }); + let query: RecordingMediaQueries; + if (options?.query) { + query = options.query; + } else { + const recordingQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, { + ...(options?.start && { start: options.start }), + ...(options?.end && { end: options.end }), + }); - if (!recordingQueries) { - return null; + if (!recordingQueries) { + return null; + } + + query = new RecordingMediaQueries(recordingQueries); } - const query = new RecordingMediaQueries(recordingQueries); + return executeMediaQueryForView( + element, + hass, + cameraManager, + view, + query, + options?.targetView ?? 'recordings', + { + cameraIDs: cameraIDs, + ...(options?.targetTime && { targetTime: options.targetTime }), + }, + ); +}; + +const executeMediaQueryForView = async ( + element: HTMLElement, + hass: HomeAssistant, + cameraManager: CameraManager, + view: View, + query: MediaQueries, + targetView: FrigateCardView, + options?: { + cameraIDs: Set; + targetTime?: Date; + }, +): Promise => { let queryResults: MediaQueriesResults | null; try { @@ -162,9 +200,9 @@ export const createViewForRecordings = async ( let viewerContext: ViewContext | undefined = {}; const mediaArray = queryResults?.getResults(); - if (queryResults && mediaArray && options?.targetTime) { + if (queryResults && mediaArray && options?.targetTime && options.cameraIDs) { queryResults.selectBestResult((media) => - findClosestMediaIndex(media, options.targetTime as Date, cameraIDs), + findClosestMediaIndex(media, options.targetTime as Date, options.cameraIDs), ); viewerContext = { mediaViewer: { @@ -176,7 +214,7 @@ export const createViewForRecordings = async ( return ( view ?.evolve({ - view: options?.targetView ? options.targetView : 'recording', + view: targetView, query: query, queryResults: queryResults, }) @@ -215,7 +253,7 @@ export const findClosestMediaIndex = ( if (media.includesTime(targetTime)) { const start = media.getStartTime(); const end = media.getEndTime(); - if (!refPoint || !start || !end) { + if (!refPoint || !start || !end) { return i; } const delta = diff --git a/src/view/view.ts b/src/view/view.ts index 5386fcf3..a2fba263 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -3,6 +3,7 @@ // - TODO: getRecordingTitle should use getCameraTitle but need hass. // - TODO: Take MediaQueries wrappers out of the camera manager. // - TODO: View a media in the gallery from September, then notice timeline missing the item. +// - TODO: Debug statement in card wide config. // Gallery: // - TODO: Event gallery show_details default does not work. From 5fb2a9d3ed10b5191e6c957f3b5ec1ee08e50c94 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 11:26:48 -0800 Subject: [PATCH 48/74] Include metadata from recordings. --- src/camera/frigate/engine-frigate.ts | 41 ++++++++++++++++++++++------ src/components/media-filter.ts | 2 +- src/utils/basic.ts | 9 ++++++ src/view/view.ts | 1 + 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 4199c013..5916017a 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -48,7 +48,7 @@ import { } from './requests'; import orderBy from 'lodash-es/orderBy'; import throttle from 'lodash-es/throttle'; -import { allPromises, runWhenIdleIfSupported } from '../../utils/basic'; +import { allPromises, formatDate, runWhenIdleIfSupported } from '../../utils/basic'; import { fromUnixTime } from 'date-fns'; import { sum } from 'lodash-es'; import { FrigateViewMediaClassifier } from './media-classifier'; @@ -127,7 +127,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { public generateDefaultEventQuery( cameras: Map, cameraIDs: Set, - query: PartialEventQuery, + query?: PartialEventQuery, ): EventQuery[] | null { const relevantCameraConfigs = Array.from(cameraIDs).map((cameraID) => cameras.get(cameraID), @@ -172,8 +172,8 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { public generateDefaultRecordingQuery( _cameras: Map, cameraIDs: Set, - query: PartialRecordingQuery, - ): RecordingQuery[] | null { + query?: PartialRecordingQuery, + ): RecordingQuery[] { return [ { type: QueryType.Recording, @@ -631,7 +631,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { new Set(cameras.keys()), ); - const processQuery = async ( + const processEventSummary = async ( instanceID: string, cameraIDs: Set, ): Promise => { @@ -654,9 +654,34 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } }; - await allPromises([...instances.entries()], ([instanceID, cameraIDs]) => - processQuery(instanceID, cameraIDs), - ); + const processRecordings = async ( + cameraIDs: Set): Promise => { + + const recordings = await this.getRecordings(hass, cameras, { + type: QueryType.Recording, + cameraIDs: cameraIDs, + }); + if (!recordings) { + return; + } + + for (const result of recordings.values()) { + if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(result)) { + continue; + } + + for (const recording of result.recordings) { + // Frigate recordings are always 1 hour long, i.e. never span a day. + days.add(formatDate(recording.startTime)); + } + } + } + + await allPromises([...instances.entries()], ([instanceID, cameraIDs]) => (async () => { + await Promise.all([ + processEventSummary(instanceID, cameraIDs), + processRecordings(cameraIDs)]); + })()); if (!what.size && !where.size && !days.size) { return null; diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index 3e98be31..a3a49c4e 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -257,7 +257,7 @@ export class MediaMetadataController implements ReactiveController { if (metadata.days) { const yearMonths: Set = new Set(); [...metadata.days].forEach((day) => { - // An efficient conversion: 2023-01-26 -> 2023-01 + // An efficient conversion: "2023-01-26" -> "2023-01" yearMonths.add(day.substring(0, 7)); }); const monthStarts: Date[] = []; diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 19ce2cb6..200b3939 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -116,6 +116,15 @@ export const formatDateAndTime = (date: Date): string => { return format(date, 'yyyy-MM-dd HH:mm'); }; +/** + * Format a date object to RFC3339. + * @param date A Date object. + * @returns A date. + */ +export const formatDate = (date: Date): string => { + return format(date, 'yyyy-MM-dd'); +}; + /** * Run a function in idle periods. If idle callbacks are not supported (e.g. * Safari) the callback is run immediately. diff --git a/src/view/view.ts b/src/view/view.ts index a2fba263..1018831c 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -12,6 +12,7 @@ // - TODO: Make media filter slot choice configurable (gallery.ts). // - TODO: Filter panel expands from right can occasionally 'stick' open. // - TODO: Media type selection, i.e. events or recordings. +// - TODO: Cache every summary. // Hard: // - TODO: Implement gallery. From 32edaee192bd047d3fc4ee887f309e71ae678aab Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 11:57:07 -0800 Subject: [PATCH 49/74] Handle media metadata exceptions. --- src/components/media-filter.ts | 81 +++++++++++++++++++--------------- src/view/view.ts | 2 - 2 files changed, 45 insertions(+), 38 deletions(-) diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index a3a49c4e..36266909 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -34,11 +34,11 @@ import { } from './media-filter-core'; import './surround.js'; import './timeline-core.js'; -import { EventQuery, QueryType, RecordingQuery } from '../camera/types'; +import { EventQuery, MediaMetadata, QueryType, RecordingQuery } from '../camera/types'; import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; import { createViewForEvents, createViewForRecordings } from '../utils/media-to-view.js'; import { HomeAssistant } from 'custom-card-helpers'; -import { prettifyTitle } from '../utils/basic'; +import { errorToConsole, prettifyTitle } from '../utils/basic'; import format from 'date-fns/format'; import parse from 'date-fns/parse'; import endOfMonth from 'date-fns/endOfMonth'; @@ -242,41 +242,50 @@ export class MediaMetadataController implements ReactiveController { } async hostConnected() { - const metadata = await this._cameraManager.getMediaMetadata(this._hass); - if (metadata) { - if (metadata.what) { - this.whatOptions = [...metadata.what] - .sort() - .map((what) => ({ value: what, label: prettifyTitle(what) })); - } - if (metadata.where) { - this.whereOptions = [...metadata.where] - .sort() - .map((where) => ({ value: where, label: prettifyTitle(where) })); - } - if (metadata.days) { - const yearMonths: Set = new Set(); - [...metadata.days].forEach((day) => { - // An efficient conversion: "2023-01-26" -> "2023-01" - yearMonths.add(day.substring(0, 7)); - }); - const monthStarts: Date[] = []; - yearMonths.forEach((yearMonth) => { - monthStarts.push(parse(yearMonth, 'yyyy-MM', new Date())); - }); - this.whenOptions = monthStarts - .sort() - .reverse() - .map((monthStart) => ({ - label: format(monthStart, 'MMMM yyyy'), - value: { - selection: MediaFilterCoreWhen.Custom, - custom: { start: monthStart, end: endOfMonth(monthStart) }, - }, - })); - } - this._host.requestUpdate(); + let metadata: MediaMetadata | null; + try { + metadata = await this._cameraManager.getMediaMetadata(this._hass); + } catch (e) { + errorToConsole(e as Error); + return; } + + if (!metadata) { + return; + } + + if (metadata.what) { + this.whatOptions = [...metadata.what] + .sort() + .map((what) => ({ value: what, label: prettifyTitle(what) })); + } + if (metadata.where) { + this.whereOptions = [...metadata.where] + .sort() + .map((where) => ({ value: where, label: prettifyTitle(where) })); + } + if (metadata.days) { + const yearMonths: Set = new Set(); + [...metadata.days].forEach((day) => { + // An efficient conversion: "2023-01-26" -> "2023-01" + yearMonths.add(day.substring(0, 7)); + }); + const monthStarts: Date[] = []; + yearMonths.forEach((yearMonth) => { + monthStarts.push(parse(yearMonth, 'yyyy-MM', new Date())); + }); + this.whenOptions = monthStarts + .sort() + .reverse() + .map((monthStart) => ({ + label: format(monthStart, 'MMMM yyyy'), + value: { + selection: MediaFilterCoreWhen.Custom, + custom: { start: monthStart, end: endOfMonth(monthStart) }, + }, + })); + } + this._host.requestUpdate(); } } diff --git a/src/view/view.ts b/src/view/view.ts index 1018831c..c098fe45 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -11,8 +11,6 @@ // - TODO: Handle all TODOs in media filter and media filter core. // - TODO: Make media filter slot choice configurable (gallery.ts). // - TODO: Filter panel expands from right can occasionally 'stick' open. -// - TODO: Media type selection, i.e. events or recordings. -// - TODO: Cache every summary. // Hard: // - TODO: Implement gallery. From b1f56dd02cb33b31e154d4a86f87b6fbfa536d69 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 13:31:21 -0800 Subject: [PATCH 50/74] Set the correct view depending on media filter selections. --- src/camera/frigate/util.ts | 3 +- src/card.ts | 1 - src/components/media-filter-core.ts | 12 +++ src/components/media-filter.ts | 114 ++++++++++++++++++++++++++-- src/components/surround.ts | 1 + src/utils/media-to-view.ts | 50 ++++++------ 6 files changed, 145 insertions(+), 36 deletions(-) diff --git a/src/camera/frigate/util.ts b/src/camera/frigate/util.ts index b916ada9..08debd95 100644 --- a/src/camera/frigate/util.ts +++ b/src/camera/frigate/util.ts @@ -25,7 +25,8 @@ export const getRecordingTitle = ( cameraConfig: CameraConfig, recording: FrigateRecording, ): string => { - return `${prettifyTitle(cameraConfig.frigate.camera_name)} ${formatDateAndTime( + const cameraName = prettifyTitle(cameraConfig.frigate.camera_name ?? ''); + return `${cameraName ? `${cameraName} ` : ''}${formatDateAndTime( recording.startTime, )}`; }; diff --git a/src/card.ts b/src/card.ts index 478fd580..023dd199 100644 --- a/src/card.ts +++ b/src/card.ts @@ -94,7 +94,6 @@ import { ViewContext } from 'view'; import { CameraManager } from './camera/manager.js'; import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js'; import { CameraManagerEngineFactory } from './camera/engine-factory.js'; -import { RequestCache } from './camera/cache.js'; /** A note on media callbacks: * diff --git a/src/components/media-filter-core.ts b/src/components/media-filter-core.ts index deb1b895..6d1c8bf1 100644 --- a/src/components/media-filter-core.ts +++ b/src/components/media-filter-core.ts @@ -232,6 +232,7 @@ export class FrigateCardMediaFilterCore extends LitElement { .label=${localize('media_filter.media_type')} .items=${this._mediaTypeOptions} .allowCustomValue=${false} + .value=${this.defaults?.mediaType} @value-changed=${this._valueChangedHandler.bind(this)} >` : ''} @@ -242,6 +243,7 @@ export class FrigateCardMediaFilterCore extends LitElement { .label=${localize('media_filter.when')} .items=${this._whenOptions} .allowCustomValue=${false} + .value=${this.defaults?.when} @value-changed=${this._valueChangedHandler.bind(this)} >` : ''} @@ -252,6 +254,9 @@ export class FrigateCardMediaFilterCore extends LitElement { .label=${localize('media_filter.camera')} .items=${this._cameraOptions} .allowCustomValue=${false} + .value=${this.defaults?.cameraIDs?.size === 1 + ? [...this.defaults.cameraIDs][0] + : undefined} @value-changed=${this._valueChangedHandler.bind(this)} >` : ''} @@ -262,6 +267,9 @@ export class FrigateCardMediaFilterCore extends LitElement { .label=${localize('media_filter.what')} .items="${this._whatOptions}" .allowCustomValue=${false} + .value=${this.defaults?.what?.size === 1 + ? [...this.defaults.what][0] + : undefined} @value-changed=${this._valueChangedHandler.bind(this)} >` : ''} @@ -272,6 +280,9 @@ export class FrigateCardMediaFilterCore extends LitElement { .label=${localize('media_filter.where')} .items=${this._whereOptions} .allowCustomValue=${false} + .value=${this.defaults?.where?.size === 1 + ? [...this.defaults.where][0] + : undefined} @value-changed=${this._valueChangedHandler.bind(this)} >` : ''} @@ -283,6 +294,7 @@ export class FrigateCardMediaFilterCore extends LitElement { .label=${localize('media_filter.favorite')} .items=${this._favoriteOptions} .allowCustomValue=${false} + .value=${this.defaults?.favorite} @value-changed=${this._valueChangedHandler.bind(this)} > ` diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index 36266909..d1c5c3a9 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -16,9 +16,7 @@ import { unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { - CameraManager, -} from '../camera/manager'; +import { CameraManager } from '../camera/manager'; import { DateRange } from '../camera/range'; import { CameraConfig, ExtendedHomeAssistant } from '../types'; import { getCameraTitle } from '../utils/camera'; @@ -44,6 +42,8 @@ import parse from 'date-fns/parse'; import endOfMonth from 'date-fns/endOfMonth'; import mediaFilterStyle from '../scss/media-filter.scss'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; +import uniqWith from 'lodash-es/uniqWith'; +import isEqual from 'lodash-es/isEqual'; @customElement('frigate-card-media-filter') export class FrigateCardMediaFilter extends LitElement { @@ -114,21 +114,33 @@ export class FrigateCardMediaFilter extends LitElement { const convertedTime = this._convertWhenToDateRange(mediaFilter.when); const convertedFavorite = this._convertFavoriteToBoolean(mediaFilter.favorite); + const cameraIDs = mediaFilter.cameraIDs ?? new Set(this.cameras.keys()); + // A note on views: + // - In the below, if the user selects a camera to view media for, the main + // view camera is also set to that value (e.g. a user browsing the + // gallery, chooses a different camera in the media filter, then + // subsequently chooses the live button -- they would expect the live view + // for that filtered camera not the prior camera). + // - Similarly, if the user chooses clips or snapshots, set the actual view + // to 'clips' or 'snapshots' in order to ensure the right icon is shown as + // selected in the menu. if ( mediaFilter.mediaType === MediaFilterMediaType.Clips || mediaFilter.mediaType === MediaFilterMediaType.Snapshots ) { const query: EventQuery = { type: QueryType.Event, - cameraIDs: mediaFilter.cameraIDs ?? new Set(this.cameras.keys()), + cameraIDs: cameraIDs, ...(mediaFilter.what && { what: mediaFilter.what }), ...(mediaFilter.where && { where: mediaFilter.where }), ...(convertedFavorite !== null && { favorite: convertedFavorite }), ...(convertedTime && { start: convertedTime.start, end: convertedTime.end }), ...(this.mediaLimit && { limit: this.mediaLimit }), ...(mediaFilter.mediaType === MediaFilterMediaType.Clips && { hasClip: true }), - ...(mediaFilter.mediaType === MediaFilterMediaType.Snapshots && { hasSnapshot: true }) + ...(mediaFilter.mediaType === MediaFilterMediaType.Snapshots && { + hasSnapshot: true, + }), }; ( @@ -140,13 +152,20 @@ export class FrigateCardMediaFilter extends LitElement { this.view, { query: new EventMediaQueries([query]), + + // See 'A note on views' above for these two arguments. + ...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }), + targetView: + mediaFilter.mediaType === MediaFilterMediaType.Clips + ? 'clips' + : 'snapshots', }, ) )?.dispatchChangeEvent(this); } else if (mediaFilter.mediaType === MediaFilterMediaType.Recordings) { const query: RecordingQuery = { type: QueryType.Recording, - cameraIDs: mediaFilter.cameraIDs ?? new Set(this.cameras.keys()), + cameraIDs: cameraIDs, ...(convertedTime && { start: convertedTime.start, end: convertedTime.end }), }; @@ -159,6 +178,10 @@ export class FrigateCardMediaFilter extends LitElement { this.view, { query: new RecordingMediaQueries([query]), + + // See 'A note on views' above for these two arguments. + ...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }), + targetView: 'recordings', }, ) )?.dispatchChangeEvent(this); @@ -184,6 +207,82 @@ export class FrigateCardMediaFilter extends LitElement { } } + protected _getDefaultsFromView(): MediaFilterCoreSelection | undefined { + if (!this.view) { + return undefined; + } + + let mediaType: MediaFilterMediaType | undefined; + let cameraIDs: Set | undefined; + let what: Set | undefined; + let where: Set | undefined; + let favorite: boolean | undefined; + + if (MediaQueriesClassifier.areEventQueries(this.view.query)) { + const queries = this.view.query.getQueries(); + if (!queries) { + return; + } + + const hasClips = uniqWith( + queries.map((query) => query.hasClip), + isEqual, + ); + const hasSnapshots = uniqWith( + queries.map((query) => query.hasSnapshot), + isEqual, + ); + if (hasClips.length === 1 && hasSnapshots.length === 1) { + mediaType = !!hasClips[0] + ? MediaFilterMediaType.Clips + : !!hasSnapshots[0] + ? MediaFilterMediaType.Snapshots + : undefined; + } + + const cameraIDSets = uniqWith( + queries.map((query) => query.cameraIDs), + isEqual, + ); + if (cameraIDSets.length === 1) { + cameraIDs = queries[0].cameraIDs; + } + const whatSets = uniqWith( + queries.map((query) => query.what), + isEqual, + ); + if (whatSets.length === 1) { + what = queries[0].what; + } + const whereSets = uniqWith( + queries.map((query) => query.where), + isEqual, + ); + if (whereSets.length === 1) { + where = queries[0].where; + } + const favoriteValues = uniqWith( + queries.map((query) => query.favorite), + isEqual, + ); + if (favoriteValues.length === 1) { + favorite = queries[0].favorite; + } + } + + return { + ...(mediaType && { mediaType: mediaType }), + ...(cameraIDs && { cameraIDs: cameraIDs }), + ...(what && { what: what }), + ...(where && { where: where }), + ...(favorite !== undefined && { + favorite: favorite + ? MediaFilterCoreFavoriteSelection.Favorite + : MediaFilterCoreFavoriteSelection.NotFavorite, + }), + }; + } + protected render(): TemplateResult | void { const areEvents = !!( this.view?.query && MediaQueriesClassifier.areEventQueries(this.view.query) @@ -203,6 +302,7 @@ export class FrigateCardMediaFilter extends LitElement { ? !!managerCapabilities?.canFavoriteRecordings : false, }; + const defaults = this._getDefaultsFromView(); return html` `; @@ -249,7 +350,6 @@ export class MediaMetadataController implements ReactiveController { errorToConsole(e as Error); return; } - if (!metadata) { return; } diff --git a/src/components/surround.ts b/src/components/surround.ts index 2b271514..c67d6e43 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -88,6 +88,7 @@ export class FrigateCardSurround extends LitElement { this.cameras, this.view, { + targetView: this.view.view, mediaType: this.fetchMedia, }, ); diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 0b79a4cb..7d7d9fe0 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -45,6 +45,7 @@ export const createViewForEvents = async ( query?: EventMediaQueries; cameraIDs?: Set; mediaType?: ClipsOrSnapshotsOrAll; + targetCameraID?: string; targetView?: FrigateCardView; limit?: number; }, @@ -72,17 +73,11 @@ export const createViewForEvents = async ( return null; } - return executeMediaQueryForView( - element, - hass, - cameraManager, - view, - query, - options?.targetView ?? 'clips', - { - cameraIDs: cameraIDs, - }, - ); + return executeMediaQueryForView(element, hass, cameraManager, view, query, { + cameraIDs: cameraIDs, + targetView: options?.targetView, + targetCameraID: options?.targetCameraID, + }); }; /** @@ -136,6 +131,7 @@ export const createViewForRecordings = async ( options?: { query?: RecordingMediaQueries; cameraIDs?: Set; + targetCameraID?: string; targetView?: 'recording' | 'recordings'; targetTime?: Date; start?: Date; @@ -162,18 +158,12 @@ export const createViewForRecordings = async ( query = new RecordingMediaQueries(recordingQueries); } - return executeMediaQueryForView( - element, - hass, - cameraManager, - view, - query, - options?.targetView ?? 'recordings', - { - cameraIDs: cameraIDs, - ...(options?.targetTime && { targetTime: options.targetTime }), - }, - ); + return executeMediaQueryForView(element, hass, cameraManager, view, query, { + cameraIDs: cameraIDs, + targetView: options?.targetView, + targetCameraID: options?.targetCameraID, + targetTime: options?.targetTime, + }); }; const executeMediaQueryForView = async ( @@ -182,9 +172,10 @@ const executeMediaQueryForView = async ( cameraManager: CameraManager, view: View, query: MediaQueries, - targetView: FrigateCardView, options?: { - cameraIDs: Set; + cameraIDs?: Set; + targetCameraID?: string; + targetView?: FrigateCardView; targetTime?: Date; }, ): Promise => { @@ -214,9 +205,10 @@ const executeMediaQueryForView = async ( return ( view ?.evolve({ - view: targetView, query: query, queryResults: queryResults, + view: options?.targetView, + camera: options?.targetCameraID, }) .mergeInContext(viewerContext) ?? null ); @@ -235,7 +227,7 @@ const executeMediaQueryForView = async ( export const findClosestMediaIndex = ( mediaArray: ViewMedia[], targetTime: Date, - cameraIDs: Set, + cameraIDs?: Set, refPoint?: 'start' | 'end', ): number | null => { let bestMatch: @@ -245,6 +237,10 @@ export const findClosestMediaIndex = ( } | undefined; + if (!cameraIDs) { + return null; + } + for (const [i, media] of mediaArray.entries()) { if (!cameraIDs.has(media.getCameraID())) { continue; From 95ced8f177747bf4b1914bed6c79e6bd332ad43a Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 13:40:29 -0800 Subject: [PATCH 51/74] Fix event details default. --- src/editor.ts | 15 ++++++++++++--- src/view/view.ts | 1 - 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/editor.ts b/src/editor.ts index d4389cd0..9a79231f 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -1007,6 +1007,11 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor configPathShowDetails: string, configPathShowFavoriteControl: string, configPathShowTimelineControl: string, + defaults: { + show_details: boolean; + show_favorite_control: boolean; + show_timeline_control: boolean; + }, options?: { configPathMedia?: string; configPathMode?: string; @@ -1043,21 +1048,21 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor })} ${this._renderSwitch( configPathShowDetails, - this._defaults.live.controls.thumbnails.show_details, + defaults.show_details, { label: localize('config.common.controls.thumbnails.show_details'), }, )} ${this._renderSwitch( configPathShowFavoriteControl, - this._defaults.live.controls.thumbnails.show_favorite_control, + defaults.show_favorite_control, { label: localize('config.common.controls.thumbnails.show_favorite_control'), }, )} ${this._renderSwitch( configPathShowTimelineControl, - this._defaults.live.controls.thumbnails.show_timeline_control, + defaults.show_timeline_control, { label: localize('config.common.controls.thumbnails.show_timeline_control'), }, @@ -1570,6 +1575,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + this._defaults.live.controls.thumbnails, { configPathMedia: CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA, configPathMode: CONF_LIVE_CONTROLS_THUMBNAILS_MODE, @@ -1617,6 +1623,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + this._defaults.event_gallery.controls.thumbnails, )}
` : ''} @@ -1675,6 +1682,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + this._defaults.media_viewer.controls.thumbnails, { configPathMode: CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE, }, @@ -1735,6 +1743,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + this._defaults.timeline.controls.thumbnails, { configPathMode: CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE, }, diff --git a/src/view/view.ts b/src/view/view.ts index c098fe45..40ba4e34 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -6,7 +6,6 @@ // - TODO: Debug statement in card wide config. // Gallery: -// - TODO: Event gallery show_details default does not work. // - TODO: Use spinner for gallery loading / cardwideconfig not dotdotdot // - TODO: Handle all TODOs in media filter and media filter core. // - TODO: Make media filter slot choice configurable (gallery.ts). From 17c4a5a85f064f435c468a5da805ec5000059cd2 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 13:54:39 -0800 Subject: [PATCH 52/74] Use standard spinner for gallery loading. --- src/components/gallery.ts | 21 ++++++++++++-------- src/components/message.ts | 3 +++ src/scss/gallery.scss | 41 ++++----------------------------------- src/view/view.ts | 1 - 4 files changed, 20 insertions(+), 46 deletions(-) diff --git a/src/components/gallery.ts b/src/components/gallery.ts index c73cfb1e..b283b3a3 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -27,13 +27,13 @@ import { View } from '../view/view.js'; import { renderProgressIndicator } from './message.js'; import './thumbnail.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; -import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { createRef, Ref } from 'lit/directives/ref.js'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; import { EventQuery, MediaQuery, RecordingQuery } from '../camera/types'; import { MediaQueriesResults } from '../view/media-queries-results'; import { errorToConsole } from '../utils/basic'; -import "./media-filter"; +import './media-filter'; const GALLERY_MEDIA_CHUNK_SIZE = 100; @@ -105,10 +105,10 @@ export class FrigateCardGallery extends LitElement { return html` @@ -163,6 +164,9 @@ export class FrigateCardGalleryCore extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + protected _intersectionObserver: IntersectionObserver; protected _resizeObserver: ResizeObserver; protected _refLoader: Ref = createRef(); @@ -345,9 +349,10 @@ export class FrigateCardGalleryCore extends LitElement { `, )} ${this._showExtensionLoader - ? html` - - ` + ? html`${renderProgressIndicator({ + cardWideConfig: this.cardWideConfig, + componentRef: this._refLoader, + })}` : ''} `; } diff --git a/src/components/message.ts b/src/components/message.ts index a2687d04..66ffd686 100644 --- a/src/components/message.ts +++ b/src/components/message.ts @@ -1,6 +1,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; +import { ref, Ref } from 'lit/directives/ref.js'; import { TROUBLESHOOTING_URL } from '../const.js'; import { localize } from '../localize/localize.js'; import messageStyle from '../scss/message.scss'; @@ -118,9 +119,11 @@ export function renderMessage(message: Message): TemplateResult { export function renderProgressIndicator(options?: { message?: string; cardWideConfig?: CardWideConfig; + componentRef?: Ref, }): TemplateResult { return html` Date: Sun, 29 Jan 2023 14:26:49 -0800 Subject: [PATCH 53/74] Make filter mode configurable. --- README.md | 36 +++++++++++++++++-- src/components/gallery.ts | 34 ++++++++++-------- src/const.ts | 2 ++ src/editor.ts | 57 +++++++++++++++++++++++++++---- src/localize/languages/en.json | 9 +++++ src/localize/languages/it.json | 9 +++++ src/localize/languages/pt-BR.json | 9 +++++ src/types.ts | 10 ++++++ src/view/view.ts | 2 -- 9 files changed, 142 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 54a4f106..6898a9ac 100644 --- a/README.md +++ b/README.md @@ -548,11 +548,39 @@ See the [fully expanded event gallery configuration example](#config-expanded-ev | Option | Default | Overridable | Description | | - | - | - | - | -| `size` | 100 | :heavy_multiplication_x: | The size of the thumbnails in the event gallery in pixels. Must be >= `75` and <= `175`.| -| `show_details` | `false` | :heavy_multiplication_x: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.| +| `controls` | | :heavy_multiplication_x: | Configuration for the Media viewer controls. See below. | +| `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `event_gallery` (e.g. `clips`, `snapshots`, `recordings`). See [actions](#actions) below.| + +#### Event Gallery Controls: Filter + +All configuration is under: + +```yaml +event_gallery: + controls: + filter: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `mode` | `right` | :heavy_multiplication_x: | Whether to show the gallery media filter to the `left`, to the `right` or `none` for no media filter. | + +#### Event Gallery Controls: Thumbnails + +All configuration is under: + +```yaml +event_gallery: + controls: + thumbnails: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `size` | 100 | :heavy_multiplication_x: | The size of the thumbnails in the gallery. Must be >= `75` and <= `175`.| +| `show_details` | `false` | :heavy_multiplication_x: | Whether to show media details (e.g. duration, start time, object detected, etc) alongside the thumbnail.| | `show_favorite_control` | `true` | :heavy_multiplication_x: | Whether to show the favorite ('star') control on each thumbnail.| | `show_timeline_control` | `true` | :heavy_multiplication_x: | Whether to show the timeline ('target') control on each thumbnail.| -| `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `event_gallery` (e.g. `clips`, `snapshots`, `recordings`). See [actions](#actions) below.| ### Image Options @@ -1575,6 +1603,8 @@ Reference: [Event Gallery Options](#event-gallery-options). ```yaml event_gallery: controls: + filter: + mode: 'right' thumbnails: size: 100 show_details: false diff --git a/src/components/gallery.ts b/src/components/gallery.ts index b283b3a3..217667b3 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -37,6 +37,11 @@ import './media-filter'; const GALLERY_MEDIA_CHUNK_SIZE = 100; +const GALLERY_MEDIA_FILTER_MENU_ICONS = { + closed: 'mdi:filter-cog-outline', + open: 'mdi:filter-cog', +}; + @customElement('frigate-card-gallery') export class FrigateCardGallery extends LitElement { @property({ attribute: false }) @@ -101,25 +106,26 @@ export class FrigateCardGallery extends LitElement { return renderProgressIndicator({ cardWideConfig: this.cardWideConfig }); } - // TODO Make this slot choice configuration left/right. return html` - - + ${this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none' + ? html` + ` + : ''} ` : ''} ${this._renderOptionSetHeader('media_viewer')} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index bd50e0d4..3820cfd5 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -79,6 +79,15 @@ "window_seconds": "The default length of the timeline view in seconds" }, "controls": { + "filter": { + "editor_label": "Media Filter", + "mode": "Filter mode", + "modes": { + "none": "No media filter", + "left": "Media filter in a drawer to the left", + "right": "Media filter in a drawer to the right" + } + }, "next_previous": { "editor_label": "Next & Previous", "size": "Next & previous control size in pixels", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 86833976..cd3b2146 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -78,6 +78,15 @@ "window_seconds": "La lunghezza predefinita della vista della sequenza temporale in secondi" }, "controls": { + "filter": { + "editor_label": "", + "mode": "", + "modes": { + "none": "", + "left": "", + "right": "" + } + }, "next_previous": { "editor_label": "", "size": "Successiva e Precedenti dimensioni di controllo nei pixel", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index f934ff8a..6eeda043 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -78,6 +78,15 @@ "window_seconds": "A duração padrão da visualização da linha do tempo em segundos" }, "controls": { + "filter": { + "editor_label": "", + "mode": "", + "modes": { + "none": "", + "left": "", + "right": "" + } + }, "next_previous": { "editor_label": "", "size": "Tamanho de controle próximo e anterior", diff --git a/src/types.ts b/src/types.ts index 99dfd490..17ab774d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1078,6 +1078,9 @@ const galleryThumbnailControlsDefaults = { const galleryConfigDefault = { controls: { thumbnails: galleryThumbnailControlsDefaults, + filter: { + mode: 'right' as const, + }, }, }; @@ -1092,6 +1095,13 @@ const galleryConfigSchema = z thumbnails: gallerythumbnailsControlSchema.default( galleryConfigDefault.controls.thumbnails, ), + filter: z + .object({ + mode: z + .enum(['none', 'left', 'right']) + .default(galleryConfigDefault.controls.filter.mode), + }) + .default(galleryConfigDefault.controls.filter), }) .default(galleryConfigDefault.controls), }) diff --git a/src/view/view.ts b/src/view/view.ts index a67508fe..f9e99f0f 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -6,8 +6,6 @@ // - TODO: Debug statement in card wide config. // Gallery: -// - TODO: Handle all TODOs in media filter and media filter core. -// - TODO: Make media filter slot choice configurable (gallery.ts). // - TODO: Filter panel expands from right can occasionally 'stick' open. // Hard: From 49e5cc5c5e13af8965869b337b3bd04bc13cfde2 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 15:56:47 -0800 Subject: [PATCH 54/74] Make debug logging controllable by config. --- src/camera/engine-factory.ts | 8 ++++++- src/camera/frigate/engine-frigate.ts | 34 ++++++++++++++++------------ src/camera/manager.ts | 12 +++++++--- src/card.ts | 14 +++++++++--- src/types.ts | 12 ++++++++++ src/utils/basic.ts | 8 ------- src/utils/debug.ts | 15 ++++++++++++ src/view/view.ts | 2 -- 8 files changed, 74 insertions(+), 31 deletions(-) create mode 100644 src/utils/debug.ts diff --git a/src/camera/engine-factory.ts b/src/camera/engine-factory.ts index d678d97b..07295bb8 100644 --- a/src/camera/engine-factory.ts +++ b/src/camera/engine-factory.ts @@ -1,4 +1,4 @@ -import { CameraConfig } from '../types'; +import { CameraConfig, CardWideConfig } from '../types'; import { ViewMedia } from '../view/media'; import { RecordingSegmentsCache, RequestCache } from './cache'; import { CameraManagerEngine } from './engine'; @@ -9,6 +9,11 @@ type CameraManagerEngineCameraIDMap = Map>; export class CameraManagerEngineFactory { protected _engines: Map = new Map(); + protected _cardWideConfig: CardWideConfig; + + constructor(cardWideConfig: CardWideConfig) { + this._cardWideConfig = cardWideConfig; + } public getEngine(engine: Engine): CameraManagerEngine | null { const cachedEngine = this._engines.get(engine); @@ -19,6 +24,7 @@ export class CameraManagerEngineFactory { switch (engine) { case Engine.Frigate: cameraManagerEngine = new FrigateCameraManagerEngine( + this._cardWideConfig, new RecordingSegmentsCache(), new RequestCache(), ); diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 5916017a..96f727b6 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -3,7 +3,7 @@ import add from 'date-fns/add'; import endOfHour from 'date-fns/endOfHour'; import startOfHour from 'date-fns/startOfHour'; import { CAMERA_BIRDSEYE } from '../../const'; -import { CameraConfig, RecordingSegment } from '../../types'; +import { CameraConfig, CardWideConfig, RecordingSegment } from '../../types'; import { ViewMedia } from '../../view/media'; import { RequestCache, RecordingSegmentsCache } from '../cache'; import { @@ -54,6 +54,7 @@ import { sum } from 'lodash-es'; import { FrigateViewMediaClassifier } from './media-classifier'; import { ViewMediaClassifier } from '../../view/media-classifier'; import { FrigateViewMediaFactory } from './media'; +import { log } from '../../utils/debug'; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; @@ -86,6 +87,7 @@ class FrigateQueryResultsClassifier { export class FrigateCameraManagerEngine implements CameraManagerEngine { protected _recordingSegmentsCache: RecordingSegmentsCache; protected _requestCache: RequestCache; + protected _cardWideConfig: CardWideConfig; // Garbage collect segments at most once an hour. protected _throttledSegmentGarbageCollector = throttle( @@ -95,9 +97,11 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { ); constructor( + cardWideConfig: CardWideConfig, recordingSegmentsCache: RecordingSegmentsCache, requestCache: RequestCache, ) { + this._cardWideConfig = cardWideConfig; this._recordingSegmentsCache = recordingSegmentsCache; this._requestCache = requestCache; } @@ -654,9 +658,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } }; - const processRecordings = async ( - cameraIDs: Set): Promise => { - + const processRecordings = async (cameraIDs: Set): Promise => { const recordings = await this.getRecordings(hass, cameras, { type: QueryType.Recording, cameraIDs: cameraIDs, @@ -675,13 +677,16 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { days.add(formatDate(recording.startTime)); } } - } + }; - await allPromises([...instances.entries()], ([instanceID, cameraIDs]) => (async () => { - await Promise.all([ - processEventSummary(instanceID, cameraIDs), - processRecordings(cameraIDs)]); - })()); + await allPromises([...instances.entries()], ([instanceID, cameraIDs]) => + (async () => { + await Promise.all([ + processEventSummary(instanceID, cameraIDs), + processRecordings(cameraIDs), + ]); + })(), + ); if (!what.size && !where.size && !days.size) { return null; @@ -750,7 +755,8 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { ); } - console.debug( + log( + this._cardWideConfig, 'Frigate Card recording segment garbage collection: ' + `Released ${segmentsStart - countSegments()} segment(s)`, ); @@ -792,12 +798,12 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { return { canFavoriteEvents: true, canFavoriteRecordings: false, - } + }; } public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities { return { - canFavorite: ViewMediaClassifier.isEvent(media) - } + canFavorite: ViewMediaClassifier.isEvent(media), + }; } } diff --git a/src/camera/manager.ts b/src/camera/manager.ts index caa7c265..6738c7da 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -1,5 +1,5 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { CameraConfig } from '../types.js'; +import { CameraConfig, CardWideConfig } from '../types.js'; import { allPromises, arrayify, setify } from '../utils/basic.js'; import { CameraManagerCapabilities, @@ -36,6 +36,7 @@ import uniqBy from 'lodash-es/uniqBy'; import { CameraManagerEngine } from './engine.js'; import sum from 'lodash-es/sum'; import add from 'date-fns/add'; +import { log } from '../utils/debug.js'; export class QueryClassifier { public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery { @@ -79,13 +80,16 @@ export interface ExtendedMediaQueryResult { export class CameraManager { protected _engineFactory: CameraManagerEngineFactory; protected _cameras: Map; + protected _cardWideConfig?: CardWideConfig; constructor( engineFactory: CameraManagerEngineFactory, cameras: Map, + cardWideConfig?: CardWideConfig, ) { this._engineFactory = engineFactory; this._cameras = cameras; + this._cardWideConfig = cardWideConfig; } public generateDefaultEventQueries( @@ -348,7 +352,8 @@ export class CameraManager { const queryStartTime = new Date(); await engine.favoriteMedia(hass, cameraConfig, media, favorite); - console.debug( + log( + this._cardWideConfig, 'Frigate Card CameraManager favorite request (', `Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, 'Media:', @@ -473,7 +478,8 @@ export class CameraManager { Array.from(results.values()).map((result) => Number(result.cached)), ); - console.debug( + log( + this._cardWideConfig, 'Frigate Card CameraManager request [Input queries:', _queries.length, ', Cached output queries:', diff --git a/src/card.ts b/src/card.ts index 023dd199..08355943 100644 --- a/src/card.ts +++ b/src/card.ts @@ -94,6 +94,7 @@ import { ViewContext } from 'view'; import { CameraManager } from './camera/manager.js'; import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js'; import { CameraManagerEngineFactory } from './camera/engine-factory.js'; +import { log } from './utils/debug.js'; /** A note on media callbacks: * @@ -988,6 +989,7 @@ export class FrigateCard extends LitElement { this._config = config; this._cardWideConfig = { performance: config.performance, + debug: config.debug, }; this._overriddenConfig = undefined; @@ -1015,7 +1017,7 @@ export class FrigateCard extends LitElement { } protected _changeView(args?: { view?: View; resetMessage?: boolean }): void { - console.debug(`Frigate Card view change: `, args?.view ?? '[default]'); + log(this._cardWideConfig, `Frigate Card view change: `, args?.view ?? '[default]'); const changeView = (view: View): void => { if (View.isMediaChange(this._view, view)) { this._currentMediaLoadedInfo = null; @@ -1101,10 +1103,16 @@ export class FrigateCard extends LitElement { * Called before each update. */ protected willUpdate(changedProps: PropertyValues): void { - if (this._cameras && (changedProps.has('_config') || changedProps.has('_cameras'))) { + if ( + this._cameras && this._cardWideConfig && + (changedProps.has('_config') || + changedProps.has('_cameras') || + changedProps.has('_cardWideConfig')) + ) { this._cameraManager = new CameraManager( - new CameraManagerEngineFactory(), + new CameraManagerEngineFactory(this._cardWideConfig), this._cameras, + this._cardWideConfig, ); } diff --git a/src/types.ts b/src/types.ts index 17ab774d..221372ca 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1220,8 +1220,18 @@ const performanceConfigSchema = z .default(performanceConfigDefault); export type PerformanceConfig = z.infer; +const debugConfigDefault = { + logging: false, +}; + +const debugConfigSchema = z.object({ + logging: z.boolean().default(debugConfigDefault.logging), +}).default(debugConfigDefault); +export type DebugConfig = z.infer; + export interface CardWideConfig { performance?: PerformanceConfig; + debug?: DebugConfig; } /** @@ -1240,6 +1250,7 @@ export const frigateCardConfigSchema = z.object({ dimensions: dimensionsConfigSchema, timeline: timelineConfigSchema, performance: performanceConfigSchema, + debug: debugConfigSchema, // Configuration overrides. overrides: overridesSchema, @@ -1265,6 +1276,7 @@ export const frigateCardConfigDefaults = { image: imageConfigDefault, timeline: timelineConfigDefault, performance: performanceConfigDefault, + debug: debugConfigDefault, }; const menuButtonSchema = z.discriminatedUnion('type', [ diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 200b3939..53823d0c 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -166,14 +166,6 @@ export function getDurationString(start: Date, end: Date): string { return duration; } -/** - * For debug purposes only. - * @param seconds - */ -export const sleep = async (seconds: number) => { - await new Promise((r) => setTimeout(r, seconds * 1000)); -}; - export const allPromises = async ( items: T[], func: (arg: T) => void, diff --git a/src/utils/debug.ts b/src/utils/debug.ts new file mode 100644 index 00000000..863be998 --- /dev/null +++ b/src/utils/debug.ts @@ -0,0 +1,15 @@ +import { CardWideConfig } from '../types'; + +export const log = (cardWideConfig?: CardWideConfig, ...args: unknown[]) => { + if (cardWideConfig?.debug?.logging) { + console.debug(...args); + } +}; + +/** + * For debug purposes only. + * @param seconds + */ +export const sleep = async (seconds: number) => { + await new Promise((r) => setTimeout(r, seconds * 1000)); +}; diff --git a/src/view/view.ts b/src/view/view.ts index f9e99f0f..847f3a17 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -3,13 +3,11 @@ // - TODO: getRecordingTitle should use getCameraTitle but need hass. // - TODO: Take MediaQueries wrappers out of the camera manager. // - TODO: View a media in the gallery from September, then notice timeline missing the item. -// - TODO: Debug statement in card wide config. // Gallery: // - TODO: Filter panel expands from right can occasionally 'stick' open. // Hard: -// - TODO: Implement gallery. // - TODO: Remove FrigateBrowseMediaSource if not necessary (post-gallery). // - TODO: Remove browse-media.ts TODOs. // - TODO: What should the timeline do when an event is clicked on that is not in the queryResults (or if queryResults is empty)? From eed436d77f598089b1d468ccedcd3e088545b9bf Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 16:07:56 -0800 Subject: [PATCH 55/74] Get camera name the correct way. --- src/camera/engine.ts | 2 ++ src/camera/frigate/engine-frigate.ts | 3 +++ src/camera/frigate/media.ts | 4 +++- src/camera/frigate/util.ts | 7 +++++-- src/camera/manager.ts | 7 +++++-- src/view/view.ts | 1 - 6 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/camera/engine.ts b/src/camera/engine.ts index 646821a4..a090bb8f 100644 --- a/src/camera/engine.ts +++ b/src/camera/engine.ts @@ -58,12 +58,14 @@ export interface CameraManagerEngine { ): Promise; generateMediaFromEvents( + hass: HomeAssistant, cameras: Map, query: EventQuery, results: QueryReturnType, ): ViewMedia[] | null; generateMediaFromRecordings( + hass: HomeAssistant, cameras: Map, query: RecordingQuery, results: QueryReturnType, diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 96f727b6..90326cd3 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -490,6 +490,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } public generateMediaFromEvents( + _hass: HomeAssistant, cameras: Map, query: EventQuery, results: QueryReturnType, @@ -542,6 +543,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } public generateMediaFromRecordings( + hass: HomeAssistant, cameras: Map, _query: RecordingQuery, results: QueryReturnType, @@ -557,6 +559,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { continue; } const media = FrigateViewMediaFactory.createRecordingViewMedia( + hass, recording.cameraID, recording, cameraConfig, diff --git a/src/camera/frigate/media.ts b/src/camera/frigate/media.ts index 46fd38ad..196ba49f 100644 --- a/src/camera/frigate/media.ts +++ b/src/camera/frigate/media.ts @@ -1,3 +1,4 @@ +import { HomeAssistant } from 'custom-card-helpers'; import fromUnixTime from 'date-fns/fromUnixTime'; import isEqual from 'lodash-es/isEqual'; import { CameraConfig } from '../../types'; @@ -155,6 +156,7 @@ export class FrigateViewMediaFactory { } static createRecordingViewMedia( + hass: HomeAssistant, cameraID: string, recording: FrigateRecording, cameraConfig: CameraConfig, @@ -173,7 +175,7 @@ export class FrigateViewMediaFactory { cameraConfig.frigate.camera_name, recording, ), - getRecordingTitle(cameraConfig, recording), + getRecordingTitle(hass, cameraConfig, recording), ); } } diff --git a/src/camera/frigate/util.ts b/src/camera/frigate/util.ts index 08debd95..fe8d6075 100644 --- a/src/camera/frigate/util.ts +++ b/src/camera/frigate/util.ts @@ -1,6 +1,8 @@ +import { HomeAssistant } from 'custom-card-helpers'; import utcToZonedTime from 'date-fns-tz/utcToZonedTime'; import { CameraConfig, ClipsOrSnapshots } from '../../types'; import { formatDateAndTime, prettifyTitle } from '../../utils/basic'; +import { getCameraTitle } from '../../utils/camera'; import { FrigateEvent, FrigateRecording } from './types'; /** @@ -22,11 +24,12 @@ export const getEventTitle = (event: FrigateEvent): string => { }; export const getRecordingTitle = ( + hass: HomeAssistant, cameraConfig: CameraConfig, recording: FrigateRecording, ): string => { - const cameraName = prettifyTitle(cameraConfig.frigate.camera_name ?? ''); - return `${cameraName ? `${cameraName} ` : ''}${formatDateAndTime( + const cameraTitle = getCameraTitle(hass, cameraConfig); + return `${cameraTitle ? `${cameraTitle} ` : ''}${formatDateAndTime( recording.startTime, )}`; }; diff --git a/src/camera/manager.ts b/src/camera/manager.ts index 6738c7da..0017b059 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -236,6 +236,7 @@ export class CameraManager { return null; } const mediaArray = this._convertQueryResultsToMedia( + hass, await this._handleQuery(hass, queries), ); @@ -294,6 +295,7 @@ export class CameraManager { } const newChunkMedia = this._convertQueryResultsToMedia( + hass, await this._handleQuery(hass, newChunkQueries), ); @@ -498,6 +500,7 @@ export class CameraManager { } protected _convertQueryResultsToMedia( + hass: HomeAssistant, results: ResultsMap, ): ViewMedia[] { const mediaArray: ViewMedia[] = []; @@ -510,12 +513,12 @@ export class CameraManager { QueryClassifier.isEventQuery(query) && QueryResultClassifier.isEventQueryResult(result) ) { - media = engine.generateMediaFromEvents(this._cameras, query, result); + media = engine.generateMediaFromEvents(hass, this._cameras, query, result); } else if ( QueryClassifier.isRecordingQuery(query) && QueryResultClassifier.isRecordingQuery(result) ) { - media = engine.generateMediaFromRecordings(this._cameras, query, result); + media = engine.generateMediaFromRecordings(hass, this._cameras, query, result); } if (media) { mediaArray.push(...media); diff --git a/src/view/view.ts b/src/view/view.ts index 847f3a17..77482d66 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,6 +1,5 @@ // Minor / later: // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript -// - TODO: getRecordingTitle should use getCameraTitle but need hass. // - TODO: Take MediaQueries wrappers out of the camera manager. // - TODO: View a media in the gallery from September, then notice timeline missing the item. From 0df6faaf7dcfff44a9575341a2fb433041753d3a Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 18:30:07 -0800 Subject: [PATCH 56/74] Move camera name and icon into camera manager. --- src/camera/engine.ts | 6 ++++ src/camera/frigate/engine-frigate.ts | 30 ++++++++++++++-- src/camera/frigate/media.ts | 4 +-- src/camera/frigate/util.ts | 10 ++---- src/camera/manager.ts | 12 +++++++ src/camera/types.ts | 5 +++ src/card.ts | 22 ++++++++---- src/components/live/live.ts | 51 ++++++++++++++++++++-------- src/components/media-filter.ts | 5 +-- src/components/thumbnail.ts | 11 +++--- src/components/timeline-core.ts | 28 +++++++++------ src/editor.ts | 33 +++++++++++++++--- src/utils/basic.ts | 1 + src/utils/camera.ts | 50 --------------------------- src/utils/ha/browse-media.ts | 7 ++-- 15 files changed, 165 insertions(+), 110 deletions(-) diff --git a/src/camera/engine.ts b/src/camera/engine.ts index a090bb8f..22fc836f 100644 --- a/src/camera/engine.ts +++ b/src/camera/engine.ts @@ -16,6 +16,7 @@ import { RecordingSegmentsQueryResultsMap, CameraManagerEngineCapabilities, CameraManagerMediaCapabilities, + CameraManagerCameraMetadata, } from './types'; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; @@ -94,6 +95,11 @@ export interface CameraManagerEngine { cameras: Map, ): Promise; + getCameraMetadata( + hass: HomeAssistant, + cameraConfig: CameraConfig, + ): CameraManagerCameraMetadata; + getCapabilities(): CameraManagerEngineCapabilities | null; getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null; } diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 90326cd3..3e7c6b5b 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -12,6 +12,7 @@ import { } from '../engine'; import { DateRange } from '../range'; import { + CameraManagerCameraMetadata, CameraManagerEngineCapabilities, CameraManagerMediaCapabilities, DataQuery, @@ -48,13 +49,19 @@ import { } from './requests'; import orderBy from 'lodash-es/orderBy'; import throttle from 'lodash-es/throttle'; -import { allPromises, formatDate, runWhenIdleIfSupported } from '../../utils/basic'; +import { + allPromises, + formatDate, + prettifyTitle, + runWhenIdleIfSupported, +} from '../../utils/basic'; import { fromUnixTime } from 'date-fns'; import { sum } from 'lodash-es'; import { FrigateViewMediaClassifier } from './media-classifier'; import { ViewMediaClassifier } from '../../view/media-classifier'; import { FrigateViewMediaFactory } from './media'; import { log } from '../../utils/debug'; +import { getEntityIcon, getEntityTitle } from '../../utils/ha'; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; @@ -559,10 +566,10 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { continue; } const media = FrigateViewMediaFactory.createRecordingViewMedia( - hass, recording.cameraID, recording, cameraConfig, + this.getCameraMetadata(hass, cameraConfig).title, ); if (media) { output.push(media); @@ -809,4 +816,23 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { canFavorite: ViewMediaClassifier.isEvent(media), }; } + + public getCameraMetadata( + hass: HomeAssistant, + cameraConfig: CameraConfig, + ): CameraManagerCameraMetadata { + return { + title: + cameraConfig.title ?? + getEntityTitle(hass, cameraConfig.camera_entity) ?? + getEntityTitle(hass, cameraConfig.webrtc_card?.entity) ?? + prettifyTitle(cameraConfig.frigate?.camera_name) ?? + cameraConfig.id ?? + '', + icon: + cameraConfig?.icon ?? + getEntityIcon(hass, cameraConfig.camera_entity) ?? + 'mdi:video', + }; + } } diff --git a/src/camera/frigate/media.ts b/src/camera/frigate/media.ts index 196ba49f..0b5f5a3e 100644 --- a/src/camera/frigate/media.ts +++ b/src/camera/frigate/media.ts @@ -156,10 +156,10 @@ export class FrigateViewMediaFactory { } static createRecordingViewMedia( - hass: HomeAssistant, cameraID: string, recording: FrigateRecording, cameraConfig: CameraConfig, + cameraTitle: string, ): FrigateRecordingViewMedia | null { if (!cameraConfig.frigate.client_id || !cameraConfig.frigate.camera_name) { return null; @@ -175,7 +175,7 @@ export class FrigateViewMediaFactory { cameraConfig.frigate.camera_name, recording, ), - getRecordingTitle(hass, cameraConfig, recording), + getRecordingTitle(cameraTitle, recording), ); } } diff --git a/src/camera/frigate/util.ts b/src/camera/frigate/util.ts index fe8d6075..0be47429 100644 --- a/src/camera/frigate/util.ts +++ b/src/camera/frigate/util.ts @@ -1,8 +1,6 @@ -import { HomeAssistant } from 'custom-card-helpers'; import utcToZonedTime from 'date-fns-tz/utcToZonedTime'; import { CameraConfig, ClipsOrSnapshots } from '../../types'; import { formatDateAndTime, prettifyTitle } from '../../utils/basic'; -import { getCameraTitle } from '../../utils/camera'; import { FrigateEvent, FrigateRecording } from './types'; /** @@ -24,14 +22,10 @@ export const getEventTitle = (event: FrigateEvent): string => { }; export const getRecordingTitle = ( - hass: HomeAssistant, - cameraConfig: CameraConfig, + cameraTitle: string, recording: FrigateRecording, ): string => { - const cameraTitle = getCameraTitle(hass, cameraConfig); - return `${cameraTitle ? `${cameraTitle} ` : ''}${formatDateAndTime( - recording.startTime, - )}`; + return `${cameraTitle} ${formatDateAndTime(recording.startTime)}`; }; /** diff --git a/src/camera/manager.ts b/src/camera/manager.ts index 0017b059..6c04cdfa 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -2,6 +2,7 @@ import { HomeAssistant } from 'custom-card-helpers'; import { CameraConfig, CardWideConfig } from '../types.js'; import { allPromises, arrayify, setify } from '../utils/basic.js'; import { + CameraManagerCameraMetadata, CameraManagerCapabilities, CameraManagerMediaCapabilities, DataQuery, @@ -539,4 +540,15 @@ export class CameraManager { 'desc', ); } + + public getCameraMetadata( + hass: HomeAssistant, + cameraConfig?: CameraConfig, + ): CameraManagerCameraMetadata | null { + const engine = this._engineFactory.getEngineForCamera(cameraConfig); + if (!engine || !cameraConfig) { + return null; + } + return engine.getCameraMetadata(hass, cameraConfig); + } } diff --git a/src/camera/types.ts b/src/camera/types.ts index 109fd439..c7ca2380 100644 --- a/src/camera/types.ts +++ b/src/camera/types.ts @@ -85,6 +85,11 @@ export interface CameraManagerMediaCapabilities { canFavorite: boolean; } +export interface CameraManagerCameraMetadata { + title: string; + icon: string; +} + // =========== // Event Query // =========== diff --git a/src/card.ts b/src/card.ts index 08355943..df7d5e37 100644 --- a/src/card.ts +++ b/src/card.ts @@ -67,7 +67,7 @@ import { getActionConfigGivenAction, } from './utils/action.js'; import { contentsChanged, errorToConsole } from './utils/basic.js'; -import { getCameraIcon, getCameraID, getCameraTitle } from './utils/camera.js'; +import { getCameraID } from './utils/camera.js'; import { getEntityIcon, getEntityTitle, @@ -403,12 +403,16 @@ export class FrigateCard extends LitElement { const action = createFrigateCardCustomAction('camera_select', { camera: camera, }); + const metadata = this._hass + ? this._cameraManager?.getCameraMetadata(this._hass, config) ?? undefined + : undefined; + return { enabled: true, - icon: getCameraIcon(this._hass, config), + icon: metadata?.icon, entity: config.camera_entity, state_color: true, - title: getCameraTitle(this._hass, config), + title: metadata?.title, selected: this._view?.camera === camera, ...(action && { tap_action: action }), }; @@ -1104,7 +1108,8 @@ export class FrigateCard extends LitElement { */ protected willUpdate(changedProps: PropertyValues): void { if ( - this._cameras && this._cardWideConfig && + this._cameras && + this._cardWideConfig && (changedProps.has('_config') || changedProps.has('_cameras') || changedProps.has('_cardWideConfig')) @@ -1393,7 +1398,12 @@ export class FrigateCard extends LitElement { * @returns */ protected _mediaPlayerAction(mediaPlayer: string, action: 'play' | 'stop'): void { - if (!['play', 'stop'].includes(action) || !this._view) { + if ( + !['play', 'stop'].includes(action) || + !this._view || + !this._hass || + !this._cameraManager + ) { return; } @@ -1417,7 +1427,7 @@ export class FrigateCard extends LitElement { } else if (this._view?.is('live') && cameraEntity) { media_content_id = `media-source://camera/${cameraEntity}`; media_content_type = 'application/vnd.apple.mpegurl'; - title = getCameraTitle(this._hass, cameraConfig); + title = this._cameraManager.getCameraMetadata(this._hass, cameraConfig)?.title ?? null; thumbnail = this._hass?.states[cameraEntity]?.attributes?.entity_picture ?? null; } diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 91f0600b..f21d736b 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -32,7 +32,6 @@ import { } from '../../types.js'; import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; import { contentsChanged } from '../../utils/basic.js'; -import { getCameraIcon, getCameraTitle } from '../../utils/camera.js'; import { dispatchExistingMediaLoadedInfoAsEvent, dispatchMediaUnloadedEvent, @@ -87,7 +86,7 @@ export const getStateObjOrDispatchError = ( if (stateObj.state === 'unavailable') { dispatchMessageEvent(element, localize('error.live_camera_unavailable'), 'info', { icon: 'mdi:connection', - context: getCameraTitle(hass, cameraConfig), + context: cameraConfig, }); return null; } @@ -261,6 +260,7 @@ export class FrigateCardLive extends LitElement { .conditionState=${this.conditionState} .liveOverrides=${this.liveOverrides} .cardWideConfig=${this.cardWideConfig} + .cameraManager=${this.cameraManager} > `, @@ -304,6 +304,9 @@ export class FrigateCardLiveCarousel extends LitElement { @property({ attribute: false }) public cardWideConfig?: CardWideConfig; + @property({ attribute: false }) + public cameraManager?: CameraManager; + // Index between camera name and slide number. protected _cameraToSlide: Record = {}; protected _refMediaCarousel: Ref = createRef(); @@ -317,10 +320,7 @@ export class FrigateCardLiveCarousel extends LitElement { const frigateCardMediaCarousel = this._refMediaCarousel.value; - if ( - frigateCardMediaCarousel && - changedProperties.has('inBackground') - ) { + if (frigateCardMediaCarousel && changedProperties.has('inBackground')) { // If this has changed to be in the background (i.e. preloaded but not // visible) take the appropriate play/pause/mute/unmute actions. if (this.inBackground) { @@ -493,7 +493,7 @@ export class FrigateCardLiveCarousel extends LitElement { cameraConfig: CameraConfig, slideIndex: number, ): TemplateResult | void { - if (!this.liveConfig) { + if (!this.liveConfig || !this.hass || !this.cameraManager) { return; } // The conditionState object contains the currently live camera, which (in @@ -510,12 +510,14 @@ export class FrigateCardLiveCarousel extends LitElement { conditionState, ) as LiveConfig; + const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraConfig); + return html`
{ this._setViewCameraID(prevID); @@ -619,8 +640,8 @@ export class FrigateCardLiveCarousel extends LitElement { .hass=${this.hass} .direction=${'next'} .controlConfig=${config.controls.next_previous} - .label=${getCameraTitle(this.hass, nextID ? this.cameras.get(nextID) : null)} - .icon=${getCameraIcon(this.hass, nextID ? this.cameras.get(nextID) : null)} + .label=${cameraMetadataNext?.title ?? ''} + .icon=${cameraMetadataNext?.icon} ?disabled=${nextID === null} @click=${(ev) => { this._setViewCameraID(nextID); diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index d1c5c3a9..d1a81e50 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -19,7 +19,6 @@ import { customElement, property } from 'lit/decorators.js'; import { CameraManager } from '../camera/manager'; import { DateRange } from '../camera/range'; import { CameraConfig, ExtendedHomeAssistant } from '../types'; -import { getCameraTitle } from '../utils/camera'; import { View } from '../view/view'; import { MediaFilterControls, @@ -193,7 +192,9 @@ export class FrigateCardMediaFilter extends LitElement { this._cameraOptions = Array.from(this.cameras.entries()).map( ([cameraID, cameraConfig]) => ({ value: cameraID, - label: getCameraTitle(this.hass, cameraConfig), + label: this.hass + ? this.cameraManager?.getCameraMetadata(this.hass, cameraConfig)?.title ?? '' + : '', }), ); } diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index c9de4450..e7256366 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -16,7 +16,6 @@ import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording. import thumbnailStyle from '../scss/thumbnail.scss'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { errorToConsole, getDurationString, prettifyTitle } from '../utils/basic.js'; -import { getCameraTitle } from '../utils/camera.js'; import { renderTask } from '../utils/task.js'; import { createFetchThumbnailTask, FetchThumbnailTaskArgs } from '../utils/thumbnail.js'; import { View } from '../view/view.js'; @@ -270,7 +269,7 @@ export class FrigateCardThumbnail extends LitElement { * @returns A template to display to the user. */ protected render(): TemplateResult | void { - if (!this.media || !this.cameraConfig) { + if (!this.media || !this.cameraConfig || !this.cameraManager || !this.hass) { return; } @@ -295,6 +294,8 @@ export class FrigateCardThumbnail extends LitElement { this.hass && this.cameraManager?.getMediaCapabilities(this.media)?.canFavorite; + const cameraTitle = this.cameraManager.getCameraMetadata(this.hass, this.cameraConfig)?.title; + return html` ${ViewMediaClassifier.isEvent(this.media) ? html`` : html``} @@ -343,7 +342,7 @@ export class FrigateCardThumbnail extends LitElement { : this.details && ViewMediaClassifier.isRecording(this.media) ? html`` : html``} diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 67269885..58cdb705 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -42,7 +42,7 @@ import { dispatchFrigateCardEvent, isHoverableDevice, } from '../utils/basic'; -import { getAllDependentCameras, getCameraTitle } from '../utils/camera.js'; +import { getAllDependentCameras } from '../utils/camera.js'; import { createViewForEvents, @@ -675,16 +675,22 @@ export class FrigateCardTimelineCore extends LitElement { this._getTimelineCameraIDs().forEach((cameraID) => { const cameraConfig = this.cameras?.get(cameraID); - if (cameraConfig) { - if ( - cameraConfig.frigate.camera_name && - cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE - ) { - groups.push({ - id: cameraID, - content: getCameraTitle(this.hass, cameraConfig), - }); - } + if (!this.hass || !cameraConfig || !this.cameraManager) { + return; + } + const cameraMetadata = this.cameraManager.getCameraMetadata( + this.hass, + cameraConfig, + ); + if ( + cameraMetadata && + cameraConfig.frigate.camera_name && + cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE + ) { + groups.push({ + id: cameraID, + content: cameraMetadata.title, + }); } }); return new DataSet(groups); diff --git a/src/editor.ts b/src/editor.ts index 00100d3e..bb5fa1b1 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -140,10 +140,14 @@ import { THUMBNAIL_WIDTH_MAX, THUMBNAIL_WIDTH_MIN, } from './types.js'; -import { arrayMove } from './utils/basic.js'; -import { getCameraID, getCameraTitle } from './utils/camera.js'; +import { arrayMove, prettifyTitle } from './utils/basic.js'; +import { getCameraID } from './utils/camera.js'; import { FRIGATE_ICON_SVG_PATH } from './camera/frigate/icon.js'; -import { getEntitiesFromHASS, sideLoadHomeAssistantElements } from './utils/ha'; +import { + getEntitiesFromHASS, + getEntityTitle, + sideLoadHomeAssistantElements, +} from './utils/ha'; import { setLowPerformanceProfile } from './performance.js'; const MENU_BUTTONS = 'buttons'; @@ -708,8 +712,29 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor cameraIndex: number, cameraConfig: RawFrigateCardConfig, ): string { + // Attempt to render a recognizable name for the camera, starting with the + // most likely to be useful and working our ways towards the least useful. + // This is only used for the editor since the card itself can use the + // cameraManager. return ( - getCameraTitle(this.hass, cameraConfig) || + (typeof cameraConfig?.title === 'string' && cameraConfig.title) || + (typeof cameraConfig?.camera_entity === 'string' + ? getEntityTitle(this.hass, cameraConfig.camera_entity) + : '') || + (typeof cameraConfig?.webrtc_card === 'object' && + cameraConfig.webrtc_card && + typeof cameraConfig.webrtc_card['entity'] === 'string' && + cameraConfig.webrtc_card['entity']) || + // Usage of engine specific logic here is allowed as an exception, since + // the camera manager cannot be started with an unparsed and unloaded + // config. + (typeof cameraConfig?.frigate === 'object' && + cameraConfig.frigate && + typeof cameraConfig?.frigate['camera_name'] === 'string' && + cameraConfig.frigate['camera_name'] + ? prettifyTitle(cameraConfig.frigate['camera_name']) + : '') || + (typeof cameraConfig?.id === 'string' && cameraConfig.id) || localize('editor.camera') + ' #' + cameraIndex ); } diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 53823d0c..e7cabecc 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -33,6 +33,7 @@ export function dispatchFrigateCardEvent( * @returns A prettified name. */ export function prettifyTitle(input: string): string; +export function prettifyTitle(input?: string): string | undefined; export function prettifyTitle(input?: string): string | undefined { if (!input) { return undefined; diff --git a/src/utils/camera.ts b/src/utils/camera.ts index 4f718d5f..bcc19710 100644 --- a/src/utils/camera.ts +++ b/src/utils/camera.ts @@ -1,7 +1,4 @@ -import { HomeAssistant } from 'custom-card-helpers'; import { CameraConfig, RawFrigateCardConfig } from '../types.js'; -import { prettifyTitle } from './basic.js'; -import { getEntityIcon, getEntityTitle } from './ha'; /** * Get a camera id. @@ -26,53 +23,6 @@ export function getCameraID( ); } -/** - * Get a camera text title. - * @param hass The Home Assistant object. - * @param config The camera config (either parsed or raw). - * @returns A title string. - */ -export function getCameraTitle( - hass?: HomeAssistant, - config?: CameraConfig | RawFrigateCardConfig | null, -): string { - // Attempt to render a recognizable name for the camera, - // starting with the most likely to be useful and working our - // ways towards the least useful. Extra type checking here since this is also - // used on raw configuration in the editor. - return ( - (typeof config?.title === 'string' && config.title) || - (typeof config?.camera_entity === 'string' - ? getEntityTitle(hass, config.camera_entity) - : '') || - (typeof config?.webrtc_card === 'object' && - config.webrtc_card && - typeof config.webrtc_card['entity'] === 'string' && - config.webrtc_card['entity']) || - (typeof config?.frigate === 'object' && - config.frigate && - typeof config?.frigate['camera_name'] === 'string' && - config.frigate['camera_name'] - ? prettifyTitle(config.frigate['camera_name']) - : '') || - (typeof config?.id === 'string' && config.id) || - '' - ); -} - -/** - * Get a camera icon. - * @param hass The Home Assistant object. - * @param config The camera config. - * @returns An icon string. - */ -export function getCameraIcon( - hass?: HomeAssistant, - config?: CameraConfig | null, -): string { - return config?.icon || getEntityIcon(hass, config?.camera_entity) || 'mdi:video'; -} - /** * Get all cameras that depend on a given camera. * @param cameras Cameras map. diff --git a/src/utils/ha/browse-media.ts b/src/utils/ha/browse-media.ts index f744c870..1128c19c 100644 --- a/src/utils/ha/browse-media.ts +++ b/src/utils/ha/browse-media.ts @@ -5,7 +5,6 @@ import { dispatchErrorMessageEvent } from '../../components/message.js'; import { localize } from '../../localize/localize.js'; import { BrowseMediaQueryParameters, - BrowseRecordingQueryParameters, CameraConfig, ClipsOrSnapshots, FrigateBrowseMediaSource, @@ -15,7 +14,7 @@ import { MEDIA_TYPE_PLAYLIST, MEDIA_TYPE_VIDEO, } from '../../types.js'; -import { getAllDependentCameras, getCameraTitle } from '../camera.js'; +import { getAllDependentCameras } from '../camera.js'; /** * Return the Frigate event_id given a FrigateBrowseMediaSource object. @@ -189,7 +188,7 @@ export const mergeFrigateBrowseMediaSources = async ( * @returns A BrowseMediaQueryParameters object. */ export const getBrowseMediaQueryParameters = ( - hass: HomeAssistant, + _hass: HomeAssistant, cameraID: string, cameraConfig?: CameraConfig, overrides?: Partial, @@ -202,7 +201,7 @@ export const getBrowseMediaQueryParameters = ( cameraName: cameraConfig.frigate.camera_name, label: cameraConfig.frigate.label, zone: cameraConfig.frigate.zone, - title: getCameraTitle(hass, cameraConfig), + title: '', // TODO: Replace: getCameraTitle(hass, cameraConfig), cameraID: cameraID, ...overrides, }; From 730eb429834d891dac1ac4ce37085fa3d66c3fe7 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 19:02:24 -0800 Subject: [PATCH 57/74] Rename camera directory to camera-manager. --- src/camera/cache.ts | 160 ----- src/camera/engine-factory.ts | 93 --- src/camera/engine.ts | 105 ---- src/camera/frigate/engine-frigate.ts | 838 ------------------------- src/camera/frigate/icon.ts | 20 - src/camera/frigate/media-classifier.ts | 18 - src/camera/frigate/media.ts | 181 ------ src/camera/frigate/requests.ts | 151 ----- src/camera/frigate/types.ts | 68 -- src/camera/frigate/util.ts | 97 --- src/camera/manager.ts | 554 ---------------- src/camera/range.ts | 125 ---- src/camera/types.ts | 169 ----- src/camera/util.ts | 39 -- src/card.ts | 4 +- src/components/gallery.ts | 4 +- src/components/live/live.ts | 2 +- src/components/media-filter-core.ts | 2 +- src/components/media-filter.ts | 6 +- src/components/menu.ts | 2 +- src/components/surround.ts | 2 +- src/components/thumbnail-carousel.ts | 2 +- src/components/thumbnail.ts | 2 +- src/components/timeline-core.ts | 4 +- src/components/timeline.ts | 2 +- src/components/viewer.ts | 2 +- src/editor.ts | 2 +- src/types.ts | 2 +- src/utils/ha/browse-media.ts | 2 +- src/utils/media-to-view.ts | 2 +- src/utils/timeline-source.ts | 8 +- src/view/media-queries.ts | 2 +- 32 files changed, 26 insertions(+), 2644 deletions(-) delete mode 100644 src/camera/cache.ts delete mode 100644 src/camera/engine-factory.ts delete mode 100644 src/camera/engine.ts delete mode 100644 src/camera/frigate/engine-frigate.ts delete mode 100644 src/camera/frigate/icon.ts delete mode 100644 src/camera/frigate/media-classifier.ts delete mode 100644 src/camera/frigate/media.ts delete mode 100644 src/camera/frigate/requests.ts delete mode 100644 src/camera/frigate/types.ts delete mode 100644 src/camera/frigate/util.ts delete mode 100644 src/camera/manager.ts delete mode 100644 src/camera/range.ts delete mode 100644 src/camera/types.ts delete mode 100644 src/camera/util.ts diff --git a/src/camera/cache.ts b/src/camera/cache.ts deleted file mode 100644 index a0d997e7..00000000 --- a/src/camera/cache.ts +++ /dev/null @@ -1,160 +0,0 @@ -import isEqual from 'lodash-es/isEqual'; -import orderBy from 'lodash-es/orderBy'; -import sortedUniqBy from 'lodash-es/sortedUniqBy'; -import { RecordingSegment } from '../types'; -import { DateRange, MemoryRangeSet } from './range'; -import { DataQuery, QueryResults } from './types'; - -interface RequestCacheItem { - request: Request; - response: Response; - expires?: Date; -} - -interface CameraManagerCache { - get(request: Request): Response | null; - has(request: Request): boolean; - set(request: Request, response: Response, expiry?: Date): void; -} - -export class MemoryRequestCache - implements CameraManagerCache -{ - protected _data: RequestCacheItem[] = []; - - public get(request: Request): Response | null { - const now = new Date(); - 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 _contains(a: Request, b: Request): boolean { - return isEqual(a, b); - } - - protected _expireOldRequests(): void { - const now = new Date(); - 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; - } - - public size(): number { - return this._data.length; - } - - /** - * Remove old data that matches a given predicate. No change to the covered - * ranges is made, i.e. this is asserting authoritiatively that this data does - * not exist in the current ranges. - * @param predicate A predicate to run on each data element. - */ - public expireMatches(predicate: (data: Data) => boolean): void { - this._data = this._data.filter(predicate); - } -} - -export class RecordingSegmentsCache { - protected _segments: Map> = new Map(); - - public add(cameraID: string, range: DateRange, segments: RecordingSegment[]) { - 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): RecordingSegment[] | null { - return this._segments.get(cameraID)?.get(range) ?? null; - } - - public getCache(cameraID: string): MemoryRangedCache | null { - return this._segments.get(cameraID) ?? null; - } - - public getCameraIDs(): string[] { - return [...this._segments.keys()]; - } - - public expireMatches( - cameraID: string, - func: (segment: RecordingSegment) => boolean, - ): void { - this._segments.get(cameraID)?.expireMatches(func); - } -} diff --git a/src/camera/engine-factory.ts b/src/camera/engine-factory.ts deleted file mode 100644 index 07295bb8..00000000 --- a/src/camera/engine-factory.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { CameraConfig, CardWideConfig } from '../types'; -import { ViewMedia } from '../view/media'; -import { RecordingSegmentsCache, RequestCache } from './cache'; -import { CameraManagerEngine } from './engine'; -import { FrigateCameraManagerEngine } from './frigate/engine-frigate'; -import { Engine } from './types'; - -type CameraManagerEngineCameraIDMap = Map>; - -export class CameraManagerEngineFactory { - protected _engines: Map = new Map(); - protected _cardWideConfig: CardWideConfig; - - constructor(cardWideConfig: CardWideConfig) { - this._cardWideConfig = cardWideConfig; - } - - public getEngine(engine: Engine): CameraManagerEngine | null { - const cachedEngine = this._engines.get(engine); - if (cachedEngine) { - return cachedEngine; - } - let cameraManagerEngine: CameraManagerEngine | null = null; - switch (engine) { - case Engine.Frigate: - cameraManagerEngine = new FrigateCameraManagerEngine( - this._cardWideConfig, - new RecordingSegmentsCache(), - new RequestCache(), - ); - break; - } - if (cameraManagerEngine) { - this._engines.set(engine, cameraManagerEngine); - } - return cameraManagerEngine; - } - - public getEngineForCamera(cameraConfig?: CameraConfig): CameraManagerEngine | null { - if (!cameraConfig) { - return null; - } - - let engine: Engine | null = null; - if (cameraConfig.frigate.camera_name) { - engine = Engine.Frigate; - } - return engine ? this.getEngine(engine) : null; - } - - public getEnginesForCameraIDs( - cameras: Map, - cameraIDs: Set, - ): CameraManagerEngineCameraIDMap | null { - const output: CameraManagerEngineCameraIDMap = new Map(); - - for (const cameraID of cameraIDs) { - const cameraConfig = cameras.get(cameraID); - if (!cameraConfig) { - continue; - } - - const engine = this.getEngineForCamera(cameraConfig); - if (!engine) { - continue; - } - if (!output.has(engine)) { - output.set(engine, new Set()); - } - output.get(engine)?.add(cameraID); - } - return output.size ? output : null; - } - - public getEngineForMedia( - cameras: Map, - media: ViewMedia, - ): CameraManagerEngine | null { - const cameraID = media.getCameraID(); - if (!cameraID) { - return null; - } - const engines = this.getEnginesForCameraIDs(cameras, new Set([cameraID])); - return engines ? ([...engines.keys()][0] ?? null) : null; - } - - public getAllEngines( - cameras: Map, - ): CameraManagerEngine[] | null { - const engines = this.getEnginesForCameraIDs(cameras, new Set(cameras.keys())); - return engines ? [...engines.keys()] : null; - } -} diff --git a/src/camera/engine.ts b/src/camera/engine.ts deleted file mode 100644 index 22fc836f..00000000 --- a/src/camera/engine.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { HomeAssistant } from 'custom-card-helpers'; -import { CameraConfig } from '../types'; -import { ViewMedia } from '../view/media'; -import { - DataQuery, - EventQuery, - EventQueryResultsMap, - MediaMetadata, - PartialEventQuery, - PartialRecordingQuery, - PartialRecordingSegmentsQuery, - QueryReturnType, - RecordingQuery, - RecordingQueryResultsMap, - RecordingSegmentsQuery, - RecordingSegmentsQueryResultsMap, - CameraManagerEngineCapabilities, - CameraManagerMediaCapabilities, - CameraManagerCameraMetadata, -} from './types'; - -export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; - -export interface CameraManagerEngine { - generateDefaultEventQuery( - cameras: Map, - cameraIDs: Set, - query: PartialEventQuery, - ): EventQuery[] | null; - - generateDefaultRecordingQuery( - cameras: Map, - cameraIDs: Set, - query: PartialRecordingQuery, - ): RecordingQuery[] | null; - - generateDefaultRecordingSegmentsQuery( - cameras: Map, - cameraIDs: Set, - query: PartialRecordingSegmentsQuery, - ): RecordingSegmentsQuery[] | null; - - getEvents( - hass: HomeAssistant, - cameras: Map, - query: EventQuery, - ): Promise; - - getRecordings( - hass: HomeAssistant, - cameras: Map, - query: RecordingQuery, - ): Promise; - - getRecordingSegments( - hass: HomeAssistant, - cameras: Map, - query: RecordingSegmentsQuery, - ): Promise; - - generateMediaFromEvents( - hass: HomeAssistant, - cameras: Map, - query: EventQuery, - results: QueryReturnType, - ): ViewMedia[] | null; - - generateMediaFromRecordings( - hass: HomeAssistant, - cameras: Map, - query: RecordingQuery, - results: QueryReturnType, - ): ViewMedia[] | null; - - getMediaDownloadPath(cameraConfig: CameraConfig, media: ViewMedia): string | null; - - favoriteMedia( - hass: HomeAssistant, - cameraConfig: CameraConfig, - media: ViewMedia, - favorite: boolean, - ): Promise; - - getQueryResultMaxAge(query: DataQuery): number | null; - - getMediaSeekTime( - hass: HomeAssistant, - cameras: Map, - media: ViewMedia, - target: Date, - ): Promise; - - getMediaMetadata( - hass: HomeAssistant, - cameras: Map, - ): Promise; - - getCameraMetadata( - hass: HomeAssistant, - cameraConfig: CameraConfig, - ): CameraManagerCameraMetadata; - - getCapabilities(): CameraManagerEngineCapabilities | null; - getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null; -} diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts deleted file mode 100644 index 3e7c6b5b..00000000 --- a/src/camera/frigate/engine-frigate.ts +++ /dev/null @@ -1,838 +0,0 @@ -import { HomeAssistant } from 'custom-card-helpers'; -import add from 'date-fns/add'; -import endOfHour from 'date-fns/endOfHour'; -import startOfHour from 'date-fns/startOfHour'; -import { CAMERA_BIRDSEYE } from '../../const'; -import { CameraConfig, CardWideConfig, RecordingSegment } from '../../types'; -import { ViewMedia } from '../../view/media'; -import { RequestCache, RecordingSegmentsCache } from '../cache'; -import { - CameraManagerEngine, - CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, -} from '../engine'; -import { DateRange } from '../range'; -import { - CameraManagerCameraMetadata, - CameraManagerEngineCapabilities, - CameraManagerMediaCapabilities, - DataQuery, - Engine, - EventQuery, - EventQueryResults, - EventQueryResultsMap, - FrigateEventQueryResults, - FrigateRecordingQueryResults, - FrigateRecordingSegmentsQueryResults, - MediaMetadata, - PartialEventQuery, - PartialRecordingQuery, - PartialRecordingSegmentsQuery, - QueryResults, - QueryResultsType, - QueryReturnType, - QueryType, - RecordingQuery, - RecordingQueryResults, - RecordingQueryResultsMap, - RecordingSegmentsQuery, - RecordingSegmentsQueryResultsMap, -} from '../types'; -import { FrigateRecording } from './types'; -import { - getEvents, - getEventSummary, - getRecordingSegments, - getRecordingsSummary, - NativeFrigateEventQuery, - NativeFrigateRecordingSegmentsQuery, - retainEvent, -} from './requests'; -import orderBy from 'lodash-es/orderBy'; -import throttle from 'lodash-es/throttle'; -import { - allPromises, - formatDate, - prettifyTitle, - runWhenIdleIfSupported, -} from '../../utils/basic'; -import { fromUnixTime } from 'date-fns'; -import { sum } from 'lodash-es'; -import { FrigateViewMediaClassifier } from './media-classifier'; -import { ViewMediaClassifier } from '../../view/media-classifier'; -import { FrigateViewMediaFactory } from './media'; -import { log } from '../../utils/debug'; -import { getEntityIcon, getEntityTitle } from '../../utils/ha'; - -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 FrigateCameraManagerEngine implements CameraManagerEngine { - protected _recordingSegmentsCache: RecordingSegmentsCache; - protected _requestCache: RequestCache; - protected _cardWideConfig: CardWideConfig; - - // Garbage collect segments at most once an hour. - protected _throttledSegmentGarbageCollector = throttle( - this._garbageCollectSegments.bind(this), - 60 * 60 * 1000, - { leading: false, trailing: true }, - ); - - constructor( - cardWideConfig: CardWideConfig, - recordingSegmentsCache: RecordingSegmentsCache, - requestCache: RequestCache, - ) { - this._cardWideConfig = cardWideConfig; - this._recordingSegmentsCache = recordingSegmentsCache; - this._requestCache = requestCache; - } - - public getMediaDownloadPath( - cameraConfig: CameraConfig, - media: ViewMedia, - ): string | null { - let path: string | null = null; - if (FrigateViewMediaClassifier.isFrigateEvent(media)) { - path = - `/api/frigate/${cameraConfig.frigate.client_id}` + - `/notifications/${media.getID()}/` + - `${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` + - `?download=true`; - } else if (FrigateViewMediaClassifier.isFrigateRecording(media)) { - path = - `/api/frigate/${cameraConfig.frigate.client_id}` + - `/recording/${cameraConfig.frigate.camera_name}` + - `/start/${Math.floor(media.getStartTime().getTime() / 1000)}` + - `/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` + - `?download=true`; - } - return path; - } - - public generateDefaultEventQuery( - cameras: Map, - cameraIDs: Set, - query?: PartialEventQuery, - ): EventQuery[] | null { - const relevantCameraConfigs = Array.from(cameraIDs).map((cameraID) => - cameras.get(cameraID), - ); - - // If there isn't a label or zone specified, we can come up with a single - // batch query for Frigate that will match across all cameras. - const canDoBatchQuery = relevantCameraConfigs.every( - (cameraConfig) => !cameraConfig?.frigate.label && !cameraConfig?.frigate.zone, - ); - - if (canDoBatchQuery) { - return [ - { - type: QueryType.Event, - cameraIDs: cameraIDs, - ...query, - }, - ]; - } - - const output: EventQuery[] = []; - for (const cameraID of cameraIDs) { - const cameraConfig = cameras.get(cameraID); - if (cameraConfig) { - output.push({ - type: QueryType.Event, - cameraIDs: new Set([cameraID]), - ...(cameraConfig.frigate.label && { - what: new Set([cameraConfig.frigate.label]), - }), - ...(cameraConfig.frigate.zone && { - where: new Set([cameraConfig.frigate.zone]), - }), - ...query, - }); - } - } - return output.length ? output : null; - } - - public generateDefaultRecordingQuery( - _cameras: Map, - cameraIDs: Set, - query?: PartialRecordingQuery, - ): RecordingQuery[] { - return [ - { - type: QueryType.Recording, - cameraIDs: cameraIDs, - ...query, - }, - ]; - } - - public generateDefaultRecordingSegmentsQuery( - _cameras: Map, - cameraIDs: Set, - query: PartialRecordingSegmentsQuery, - ): RecordingSegmentsQuery[] | null { - if (!query.start || !query.end) { - return null; - } - return [ - { - type: QueryType.RecordingSegments, - cameraIDs: cameraIDs, - start: query.start, - end: query.end, - ...query, - }, - ]; - } - - public async favoriteMedia( - hass: HomeAssistant, - cameraConfig: CameraConfig, - media: ViewMedia, - favorite: boolean, - ): Promise { - if (!FrigateViewMediaClassifier.isFrigateEvent(media)) { - return; - } - - await retainEvent(hass, cameraConfig.frigate.client_id, media.getID(), favorite); - media.setFavorite(favorite); - } - - protected _buildInstanceToCameraIDMapFromQuery( - cameras: Map, - cameraIDs: Set, - ): Map> { - const output: Map> = new Map(); - for (const cameraID of cameraIDs) { - const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); - const clientID = cameraConfig?.frigate.client_id; - if (clientID) { - if (!output.has(clientID)) { - output.set(clientID, new Set()); - } - output.get(clientID)?.add(cameraID); - } - } - return output; - } - - protected _getFrigateCameraNamesForCameraIDs( - cameras: Map, - cameraIDs: Set, - ): Set { - const output = new Set(); - for (const cameraID of cameraIDs) { - const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); - if (cameraConfig?.frigate.camera_name) { - output.add(cameraConfig.frigate.camera_name); - } - } - return output; - } - - public async getEvents( - hass: HomeAssistant, - cameras: Map, - query: EventQuery, - ): Promise { - const output: EventQueryResultsMap = new Map(); - - const processInstanceQuery = async ( - instanceID: string, - cameraIDs?: Set, - ): Promise => { - if (!cameraIDs || !cameraIDs.size) { - return; - } - const instanceQuery = { ...query, cameraIDs: cameraIDs }; - const cachedResult = this._requestCache.get(instanceQuery); - if (cachedResult) { - output.set(query, cachedResult as EventQueryResults); - return; - } - - const nativeQuery: NativeFrigateEventQuery = { - instance_id: instanceID, - cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs)), - ...(query.what && { labels: Array.from(query.what) }), - ...(query.where && { zones: Array.from(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 }), - ...(query.favorite && { favorites: query.favorite }), - limit: query?.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, - }; - - const result: FrigateEventQueryResults = { - type: QueryResultsType.Event, - engine: Engine.Frigate, - instanceID: instanceID, - events: await getEvents(hass, nativeQuery), - expiry: add(new Date(), { seconds: EVENT_REQUEST_CACHE_MAX_AGE_SECONDS }), - cached: false, - }; - - this._requestCache.set(query, { ...result, cached: true }, result.expiry); - output.set(instanceQuery, result); - }; - - // Frigate allows multiple cameras to be searched for events in a single - // query. Break them down into groups of cameras per Frigate instance, then - // query once per instance for all cameras in that instance. - const instances = this._buildInstanceToCameraIDMapFromQuery( - cameras, - query.cameraIDs, - ); - - await Promise.all( - Array.from(instances.keys()).map((instanceID) => - processInstanceQuery(instanceID, instances.get(instanceID)), - ), - ); - return output.size ? output : null; - } - - public async getRecordings( - hass: HomeAssistant, - cameras: Map, - query: RecordingQuery, - ): Promise { - const output: RecordingQueryResultsMap = new Map(); - - const processQuery = async ( - baseQuery: RecordingQuery, - cameraID: string, - ): Promise => { - const query = { ...baseQuery, cameraIDs: new Set([cameraID]) }; - const cachedResult = this._requestCache.get(query); - if (cachedResult) { - output.set(query, cachedResult as RecordingQueryResults); - return; - } - - const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); - if (!cameraConfig || !cameraConfig.frigate.camera_name) { - return; - } - - const recordingSummary = await getRecordingsSummary( - hass, - cameraConfig.frigate.client_id, - cameraConfig.frigate.camera_name, - ); - - let 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({ - cameraID: cameraID, - startTime: startHour, - endTime: endHour, - events: hourData.events, - }); - } - } - } - - if (query.limit !== undefined) { - // Frigate does not natively support a way to limit recording searches so - // this simulates it. - recordings = orderBy( - recordings, - (recording: FrigateRecording) => recording.startTime, - 'desc', - ).slice(0, query.limit); - } - - const result: FrigateRecordingQueryResults = { - type: QueryResultsType.Recording, - engine: Engine.Frigate, - instanceID: cameraConfig.frigate.client_id, - recordings: recordings, - expiry: add(new Date(), { - seconds: RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS, - }), - cached: false, - }; - this._requestCache.set(query, { ...result, cached: true }, result.expiry); - output.set(query, result); - }; - - // Frigate recordings can only be queried for a single camera, so fan out - // the inbound query into multiple outbound queries. - await Promise.all( - Array.from(query.cameraIDs).map((cameraID) => processQuery(query, cameraID)), - ); - return output.size ? output : null; - } - - public async getRecordingSegments( - hass: HomeAssistant, - cameras: Map, - query: RecordingSegmentsQuery, - ): Promise { - const output: RecordingSegmentsQueryResultsMap = new Map(); - - const processQuery = async ( - baseQuery: RecordingSegmentsQuery, - cameraID: string, - ): Promise => { - const query = { ...baseQuery, cameraIDs: new Set([cameraID]) }; - const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); - if (!cameraConfig || !cameraConfig.frigate.camera_name) { - return; - } - - const range: DateRange = { start: query.start, end: query.end }; - - // A note on Frigate Recording Segments: - // - 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(cameraID, range); - if (cachedSegments) { - output.set(query, { - type: QueryResultsType.RecordingSegments, - engine: Engine.Frigate, - instanceID: cameraConfig.frigate.client_id, - segments: cachedSegments, - cached: true, - }); - return; - } - - 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), - }; - - const segments = await getRecordingSegments(hass, request); - this._recordingSegmentsCache.add(cameraID, range, segments); - - output.set(query, { - type: QueryResultsType.RecordingSegments, - engine: Engine.Frigate, - instanceID: cameraConfig.frigate.client_id, - segments: segments, - cached: false, - }); - }; - - // Frigate recording segments can only be queried for a single camera, so - // fan out the inbound query into multiple outbound queries. - await Promise.all( - Array.from(query.cameraIDs).map((cameraID) => processQuery(query, cameraID)), - ); - - runWhenIdleIfSupported(() => this._throttledSegmentGarbageCollector(hass, cameras)); - return output.size ? output : null; - } - - protected _getCameraIDMatch( - cameras: Map, - query: DataQuery, - instanceID: string, - cameraName: string, - ): string | null { - // If the query is only for a single cameraID, all results are assumed to - // belong to it for performance reasons. Otherwise, we need to map the - // instanceID and camera name for the known cameras, and get the precise - // cameraID that matches the expected instance ID / camera name. - if (query.cameraIDs.size === 1) { - return [...query.cameraIDs][0]; - } - for (const [cameraID, cameraConfig] of cameras.entries()) { - if ( - cameraConfig.frigate.client_id === instanceID && - cameraConfig.frigate.camera_name === cameraName - ) { - return cameraID; - } - } - return null; - } - - public generateMediaFromEvents( - _hass: HomeAssistant, - cameras: Map, - query: EventQuery, - results: QueryReturnType, - ): ViewMedia[] | null { - if (!FrigateQueryResultsClassifier.isFrigateEventQueryResults(results)) { - return null; - } - - const output: ViewMedia[] = []; - for (const event of results.events) { - const cameraID = this._getCameraIDMatch( - cameras, - query, - results.instanceID, - event.camera, - ); - if (!cameraID) { - continue; - } - const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); - if (!cameraConfig) { - continue; - } - 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 = FrigateViewMediaFactory.createEventViewMedia( - mediaType, - cameraID, - cameraConfig, - event, - ); - if (media) { - output.push(media); - } - } - return output; - } - - public generateMediaFromRecordings( - hass: HomeAssistant, - cameras: Map, - _query: RecordingQuery, - results: QueryReturnType, - ): ViewMedia[] | null { - if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(results)) { - return null; - } - - const output: ViewMedia[] = []; - for (const recording of results.recordings) { - const cameraConfig = this._getQueryableCameraConfig(cameras, recording.cameraID); - if (!cameraConfig) { - continue; - } - const media = FrigateViewMediaFactory.createRecordingViewMedia( - recording.cameraID, - recording, - cameraConfig, - this.getCameraMetadata(hass, cameraConfig).title, - ); - if (media) { - output.push(media); - } - } - return output; - } - - public getQueryResultMaxAge(query: DataQuery): number | null { - if (query.type === QueryType.Event) { - return EVENT_REQUEST_CACHE_MAX_AGE_SECONDS; - } else if (query.type === QueryType.Recording) { - return RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS; - } - return null; - } - - public async getMediaSeekTime( - hass: HomeAssistant, - cameras: Map, - media: ViewMedia, - target: Date, - ): Promise { - const start = media.getStartTime(); - const end = media.getEndTime(); - if (!start || !end || target < start || target > end) { - return null; - } - - const cameraID = media.getCameraID(); - const query: RecordingSegmentsQuery = { - cameraIDs: new Set([cameraID]), - start: start, - end: end, - type: QueryType.RecordingSegments, - }; - - const results = await this.getRecordingSegments(hass, cameras, query); - - if (results) { - return this._getSeekTimeInSegments( - start, - target, - // There will only be a single result since Frigate recording segments - // searches are per camera which is specified singularly above. - Array.from(results.values())[0].segments, - ); - } - return null; - } - - 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; - } - - public async getMediaMetadata( - hass: HomeAssistant, - cameras: Map, - ): Promise { - const what: Set = new Set(); - const where: Set = new Set(); - const days: Set = new Set(); - - const instances = this._buildInstanceToCameraIDMapFromQuery( - cameras, - new Set(cameras.keys()), - ); - - const processEventSummary = async ( - instanceID: string, - cameraIDs: Set, - ): Promise => { - const cameraNames = this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs); - for (const entry of await getEventSummary(hass, instanceID)) { - if (!cameraNames.has(entry.camera)) { - // If this entry applies to a camera that *is* in this Frigate - // instance, but is *not* a configured camera in the card, skip it. - continue; - } - if (entry.label) { - what.add(entry.label); - } - if (entry.zones.length) { - entry.zones.forEach(where.add, where); - } - if (entry.day) { - days.add(entry.day); - } - } - }; - - const processRecordings = async (cameraIDs: Set): Promise => { - const recordings = await this.getRecordings(hass, cameras, { - type: QueryType.Recording, - cameraIDs: cameraIDs, - }); - if (!recordings) { - return; - } - - for (const result of recordings.values()) { - if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(result)) { - continue; - } - - for (const recording of result.recordings) { - // Frigate recordings are always 1 hour long, i.e. never span a day. - days.add(formatDate(recording.startTime)); - } - } - }; - - await allPromises([...instances.entries()], ([instanceID, cameraIDs]) => - (async () => { - await Promise.all([ - processEventSummary(instanceID, cameraIDs), - processRecordings(cameraIDs), - ]); - })(), - ); - - if (!what.size && !where.size && !days.size) { - return null; - } - return { - ...(what.size && { what: what }), - ...(where.size && { where: where }), - ...(days.size && { days: days }), - }; - } - - /** - * Garbage collect recording segments that no longer feature in the recordings - * returned by the Frigate backend. - */ - protected async _garbageCollectSegments( - hass: HomeAssistant, - cameras: Map, - ): Promise { - const cameraIDs = this._recordingSegmentsCache.getCameraIDs(); - const recordingQuery: RecordingQuery = { - cameraIDs: new Set(cameraIDs), - type: QueryType.Recording, - }; - - const countSegments = () => - sum( - cameraIDs.map( - (cameraID) => this._recordingSegmentsCache.getCache(cameraID)?.size() ?? 0, - ), - ); - const segmentsStart = countSegments(); - - // 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, startTime: Date): string => { - return `${cameraID}/${startTime.getDate()}/${startTime.getHours()}`; - }; - - const results = await this.getRecordings(hass, cameras, recordingQuery); - if (!results) { - return; - } - - for (const [query, result] of results) { - if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(result)) { - continue; - } - - const goodHours: Set = new Set(); - for (const recording of result.recordings) { - goodHours.add(getHourID(recording.cameraID, recording.startTime)); - } - - // Frigate recordings are always executed individually, so there'll only - // be a single results. - const cameraID = Array.from(query.cameraIDs)[0]; - this._recordingSegmentsCache.expireMatches( - cameraID, - (segment: RecordingSegment) => { - const hourID = getHourID(cameraID, fromUnixTime(segment.start_time)); - // ~O(1) lookup time for a JS set. - return goodHours.has(hourID); - }, - ); - } - - log( - this._cardWideConfig, - 'Frigate Card recording segment garbage collection: ' + - `Released ${segmentsStart - countSegments()} segment(s)`, - ); - } - - /** - * Get the number of seconds to seek into a video stream consisting of the - * provided segments to reach the target time provided. - * @param startTime The earliest allowable time to seek from. - * @param targetTime Target time. - * @param segments An array of segments dataset items. Must be sorted from oldest to youngest. - * @returns - */ - protected _getSeekTimeInSegments( - startTime: Date, - targetTime: Date, - segments: RecordingSegment[], - ): number | null { - if (!segments.length) { - return null; - } - let seekMilliseconds = 0; - - // Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27 - for (const segment of segments) { - const segmentStart = fromUnixTime(segment.start_time); - if (segmentStart > targetTime) { - break; - } - 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; - } - - public getCapabilities(): CameraManagerEngineCapabilities { - return { - canFavoriteEvents: true, - canFavoriteRecordings: false, - }; - } - - public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities { - return { - canFavorite: ViewMediaClassifier.isEvent(media), - }; - } - - public getCameraMetadata( - hass: HomeAssistant, - cameraConfig: CameraConfig, - ): CameraManagerCameraMetadata { - return { - title: - cameraConfig.title ?? - getEntityTitle(hass, cameraConfig.camera_entity) ?? - getEntityTitle(hass, cameraConfig.webrtc_card?.entity) ?? - prettifyTitle(cameraConfig.frigate?.camera_name) ?? - cameraConfig.id ?? - '', - icon: - cameraConfig?.icon ?? - getEntityIcon(hass, cameraConfig.camera_entity) ?? - 'mdi:video', - }; - } -} diff --git a/src/camera/frigate/icon.ts b/src/camera/frigate/icon.ts deleted file mode 100644 index 3e075f2e..00000000 --- a/src/camera/frigate/icon.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const FRIGATE_ICON_SVG_PATH = - 'm 4.8759466,22.743573 c 0.0866,0.69274 0.811811,1.16359 0.37885,1.27183 ' + - '-0.43297,0.10824 -2.32718,-3.43665 -2.7601492,-4.95202 -0.4329602,-1.51538 ' + - '-0.6764993,-3.22017 -0.5682593,-4.19434 0.1082301,-0.97417 5.7097085,-2.48955 ' + - '5.7097085,-2.89545 0,-0.4059 -1.81304,-0.0271 -1.89422,-0.35178 -0.0812,-0.32472 ' + - '1.36925,-0.12989 1.75892,-0.64945 0.60885,-0.81181 1.3800713,-0.6765 1.8671505,' + - '-1.1094696 0.4870902,-0.4329599 1.0824089,-2.0836399 1.1906589,-2.7871996 0.108241,' + - '-0.70357 -1.0824084,-1.51538 -1.4071389,-2.05658 -0.3247195,-0.54121 0.7035702,' + - '-0.92005 3.1931099,-1.94834 2.48954,-1.02829 10.39114,-3.30134994 10.49938,' + - '-3.03074994 0.10824,0.27061 -2.59779,1.40713994 -4.492,2.11069994 -1.89422,0.70357 ' + - '-4.97909,2.05658 -4.97909,2.43542 0,0.37885 0.16236,0.67651 0.0541,1.54244 -0.10824,' + - '0.86593 -0.12123,1.2702597 -0.32472,1.8400997 -0.1353,0.37884 -0.2706,1.27183 ' + - '0,2.0836295 0.21648,0.64945 0.92005,1.13653 1.24477,1.24478 0.2706,0.018 1.01746,' + - '0.0433 1.8401,0 1.02829,-0.0541 2.48954,0.0541 2.48954,0.32472 0,0.2706 -2.21894,' + - '0.10824 -2.21894,0.48708 0,0.37885 2.27306,-0.0541 2.21894,0.32473 -0.0541,0.37884 ' + - '-1.89422,0.21648 -2.86839,0.21648 -0.77933,0 -1.93031,-0.0361 -2.43542,-0.21648 ' + - 'l -0.10824,0.37884 c -0.18038,0 -0.55744,0.10824 -0.94711,0.10824 -0.48708,0 ' + - '-0.51414,0.16236 -1.40713,0.16236 -0.892989,0 -0.622391,-0.0541 -1.4341894,-0.10824 ' + - '-0.81181,-0.0541 -3.842561,2.27306 -4.383761,3.03075 -0.54121,0.75768 ' + - '-0.21649,2.59778 -0.21649,3.43665 0,0.75379 -0.10824,2.43542 0,3.30135 z'; diff --git a/src/camera/frigate/media-classifier.ts b/src/camera/frigate/media-classifier.ts deleted file mode 100644 index 442d6b0d..00000000 --- a/src/camera/frigate/media-classifier.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ViewMedia } from '../../view/media'; -import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media'; - -export class FrigateViewMediaClassifier { - 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; - } -} diff --git a/src/camera/frigate/media.ts b/src/camera/frigate/media.ts deleted file mode 100644 index 0b5f5a3e..00000000 --- a/src/camera/frigate/media.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { HomeAssistant } from 'custom-card-helpers'; -import fromUnixTime from 'date-fns/fromUnixTime'; -import isEqual from 'lodash-es/isEqual'; -import { CameraConfig } from '../../types'; -import { - ViewMedia, - EventViewMedia, - RecordingViewMedia, - ViewMediaType, -} from '../../view/media'; -import { FrigateEvent, FrigateRecording } from './types'; -import { - getEventMediaContentID, - getEventThumbnailURL, - getEventTitle, - getRecordingID, - getRecordingMediaContentID, - getRecordingTitle, -} from './util'; - -export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia { - protected _event: FrigateEvent; - protected _contentID: string; - protected _thumbnail: string; - - constructor( - mediaType: ViewMediaType, - cameraID: string, - event: FrigateEvent, - contentID: string, - thumbnail: string, - ) { - super(mediaType, cameraID); - this._event = event; - this._contentID = contentID; - this._thumbnail = thumbnail; - } - - public hasClip(): boolean { - return !!this._event.has_clip; - } - public getStartTime(): Date { - return fromUnixTime(this._event.start_time); - } - public getEndTime(): Date | null { - return this._event.end_time ? fromUnixTime(this._event.end_time) : null; - } - public getID(): string { - return this._event.id; - } - public getContentID(): string { - return this._contentID; - } - public getTitle(): string | null { - return getEventTitle(this._event); - } - public getThumbnail(): string | null { - return this._thumbnail; - } - public isFavorite(): boolean | null { - return this._event.retain_indefinitely ?? null; - } - public setFavorite(favorite: boolean): void { - this._event.retain_indefinitely = favorite; - } - public getWhat(): string[] | null { - return [this._event.label]; - } - public getWhere(): string[] | null { - const zones = this._event.zones; - return zones.length ? zones : null; - } - public getScore(): number | null { - return this._event.top_score; - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public isGroupableWith(that: EventViewMedia): boolean { - return ( - this.getMediaType() === that.getMediaType() && - isEqual(this.getWhere(), that.getWhere()) && - isEqual(this.getWhat(), that.getWhat()) - ); - } -} - -export class FrigateRecordingViewMedia extends ViewMedia implements RecordingViewMedia { - protected _recording: FrigateRecording; - protected _id: string; - protected _contentID: string; - protected _title: string; - - constructor( - mediaType: ViewMediaType, - cameraID: string, - recording: FrigateRecording, - id: string, - contentID: string, - title: string, - ) { - super(mediaType, cameraID); - this._recording = recording; - this._id = id; - this._contentID = contentID; - this._title = title; - } - - public getID(): string { - return this._id; - } - public getStartTime(): Date { - return this._recording.startTime; - } - public getEndTime(): Date { - return this._recording.endTime; - } - public getContentID(): string | null { - return this._contentID; - } - public getTitle(): string | null { - return this._title; - } - public getEventCount(): number { - return this._recording.events; - } -} - -export class FrigateViewMediaFactory { - static createEventViewMedia( - mediaType: 'clip' | 'snapshot', - cameraID: string, - cameraConfig: CameraConfig, - event: FrigateEvent, - ): FrigateEventViewMedia | null { - if ( - (mediaType === 'clip' && !event.has_clip) || - (mediaType === 'snapshot' && !event.has_snapshot) || - !cameraConfig.frigate.client_id || - !cameraConfig.frigate.camera_name - ) { - return null; - } - - return new FrigateEventViewMedia( - mediaType, - cameraID, - event, - getEventMediaContentID( - cameraConfig.frigate.client_id, - cameraConfig.frigate.camera_name, - event, - mediaType === 'clip' ? 'clips' : 'snapshots', - ), - getEventThumbnailURL(cameraConfig.frigate.client_id, event), - ); - } - - static createRecordingViewMedia( - cameraID: string, - recording: FrigateRecording, - cameraConfig: CameraConfig, - cameraTitle: string, - ): FrigateRecordingViewMedia | null { - if (!cameraConfig.frigate.client_id || !cameraConfig.frigate.camera_name) { - return null; - } - - return new FrigateRecordingViewMedia( - 'recording', - cameraID, - recording, - getRecordingID(cameraConfig, recording), - getRecordingMediaContentID( - cameraConfig.frigate.client_id, - cameraConfig.frigate.camera_name, - recording, - ), - getRecordingTitle(cameraTitle, recording), - ); - } -} diff --git a/src/camera/frigate/requests.ts b/src/camera/frigate/requests.ts deleted file mode 100644 index 01d48878..00000000 --- a/src/camera/frigate/requests.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { HomeAssistant } from 'custom-card-helpers'; -import { localize } from '../../localize/localize'; -import { FrigateCardError, RecordingSegment } from '../../types'; -import { homeAssistantWSRequest } from '../../utils/ha'; -import { - EventSummary, - eventSummarySchema, - FrigateEvent, - frigateEventsSchema, - recordingSegmentsSchema, - RecordingSummary, - recordingSummarySchema, - RetainResult, - retainResultSchema, -} from './types'; - -/** - * Get the recordings summary. May throw. - * @param hass The Home Assistant object. - * @param clientID The Frigate clientID. - * @param camera_name The Frigate camera name. - * @returns A RecordingSummary object. - */ -export const getRecordingsSummary = async ( - hass: HomeAssistant, - clientID: string, - camera_name: string, -): Promise => { - return await homeAssistantWSRequest( - hass, - recordingSummarySchema, - { - type: 'frigate/recordings/summary', - instance_id: clientID, - camera: camera_name, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - }, - true, - ); -}; - -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 params The recording segment query parameters. - * @returns A RecordingSegments object. - */ -export const getRecordingSegments = async ( - hass: HomeAssistant, - params: NativeFrigateRecordingSegmentsQuery, -): Promise => { - return await homeAssistantWSRequest( - hass, - recordingSegmentsSchema, - { - type: 'frigate/recordings/get', - ...params, - }, - true, - ); -}; - -/** - * Request that Frigate retain an event. May throw. - * @param hass The HomeAssistant object. - * @param clientID The Frigate clientID. - * @param eventID The event ID to retain. - * @param retain `true` to retain or `false` to unretain. - */ -export async function retainEvent( - hass: HomeAssistant, - clientID: string, - eventID: string, - retain: boolean, -): Promise { - const retainRequest = { - type: 'frigate/event/retain', - instance_id: clientID, - event_id: eventID, - retain: retain, - }; - const response = await homeAssistantWSRequest( - hass, - retainResultSchema, - retainRequest, - true, - ); - if (!response.success) { - throw new FrigateCardError(localize('error.failed_retain'), { - request: retainRequest, - response: response, - }); - } -} - -export interface NativeFrigateEventQuery { - instance_id?: string; - cameras?: string[]; - labels?: string[]; - zones?: string[]; - after?: number; - before?: number; - limit?: number; - has_clip?: boolean; - has_snapshot?: boolean; - favorites?: boolean; -} - -/** - * Get events over websocket. May throw. - * @param hass The Home Assistant object. - * @param params The events search parameters. - * @returns An array of 'FrigateEvent's. - */ -export const getEvents = async ( - hass: HomeAssistant, - params?: NativeFrigateEventQuery, -): Promise => { - return await homeAssistantWSRequest( - hass, - frigateEventsSchema, - { - type: 'frigate/events/get', - ...params, - }, - true, - ); -}; - -export const getEventSummary = async ( - hass: HomeAssistant, - clientID: string, -): Promise => { - return await homeAssistantWSRequest( - hass, - eventSummarySchema, - { - type: 'frigate/events/summary', - instance_id: clientID, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - }, - true, - ); -}; diff --git a/src/camera/frigate/types.ts b/src/camera/frigate/types.ts deleted file mode 100644 index 41a0cd6b..00000000 --- a/src/camera/frigate/types.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { z } from 'zod'; -import { dayToDate } from '../../utils/basic'; - -const dayStringToDate = (arg: unknown): Date | unknown => { - return typeof arg === 'string' ? dayToDate(arg) : arg; -}; - -export const eventSchema = z.object({ - camera: z.string(), - end_time: z.number().nullable(), - false_positive: z.boolean().nullable(), - has_clip: z.boolean(), - has_snapshot: z.boolean(), - id: z.string(), - label: z.string(), - start_time: z.number(), - top_score: z.number(), - zones: z.string().array(), - retain_indefinitely: z.boolean().optional(), -}); -export const frigateEventsSchema = eventSchema.array(); - -export type FrigateEvent = z.infer; - -const recordingSummaryHourSchema = z.object({ - hour: z.preprocess((arg) => Number(arg), z.number().min(0).max(23)), - duration: z.number().min(0), - events: z.number().min(0), -}); - -export const recordingSummarySchema = z - .object({ - day: z.preprocess(dayStringToDate, z.date()), - events: z.number(), - hours: recordingSummaryHourSchema.array(), - }) - .array(); -export type RecordingSummary = z.infer; - -const recordingSegmentSchema = z.object({ - start_time: z.number(), - end_time: z.number(), - id: z.string(), -}); -export const recordingSegmentsSchema = recordingSegmentSchema.array(); - -export const retainResultSchema = z.object({ - success: z.boolean(), - message: z.string(), -}); -export type RetainResult = z.infer; - -export interface FrigateRecording { - cameraID: string; - startTime: Date; - endTime: Date; - events: number; -} - -export const eventSummarySchema = z - .object({ - camera: z.string(), - day: z.string(), - label: z.string(), - zones: z.string().array(), - }) - .array(); -export type EventSummary = z.infer; diff --git a/src/camera/frigate/util.ts b/src/camera/frigate/util.ts deleted file mode 100644 index 0be47429..00000000 --- a/src/camera/frigate/util.ts +++ /dev/null @@ -1,97 +0,0 @@ -import utcToZonedTime from 'date-fns-tz/utcToZonedTime'; -import { CameraConfig, ClipsOrSnapshots } from '../../types'; -import { formatDateAndTime, prettifyTitle } from '../../utils/basic'; -import { FrigateEvent, FrigateRecording } from './types'; - -/** - * Given an event generate a title. - * @param event - */ -export const getEventTitle = (event: FrigateEvent): string => { - const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - const durationSeconds = Math.round( - event.end_time - ? event.end_time - event.start_time - : Date.now() / 1000 - event.start_time, - ); - return `${formatDateAndTime( - utcToZonedTime(event.start_time * 1000, localTimezone), - )} [${durationSeconds}s, ${prettifyTitle(event.label)} ${Math.round( - event.top_score * 100, - )}%]`; -}; - -export const getRecordingTitle = ( - cameraTitle: string, - recording: FrigateRecording, -): string => { - return `${cameraTitle} ${formatDateAndTime(recording.startTime)}`; -}; - -/** - * Get a thumbnail URL for an event. - * @param clientId The Frigate client id. - * @param event The event. - * @returns A string URL. - */ -export const getEventThumbnailURL = (clientId: string, event: FrigateEvent): string => { - return `/api/frigate/${clientId}/thumbnail/${event.id}`; -}; - -/** - * Get a media content ID for an event. - * @param clientId The Frigate client id. - * @param cameraName The Frigate camera name. - * @param event The Frigate event. - * @param mediaType The media type required. - * @returns A string media content id. - */ -export const getEventMediaContentID = ( - clientId: string, - cameraName: string, - event: FrigateEvent, - mediaType: ClipsOrSnapshots, -): string => { - return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${event.id}`; -}; - -/** - * Generate a recording identifier. - * @param clientId The Frigate client id. - * @param cameraName The Frigate camera name. - * @param recording The Frigate recording. - * @returns A recording identifier. - */ -export const getRecordingMediaContentID = ( - clientId: string, - cameraName: string, - recording: FrigateRecording, -): string => { - return [ - 'media-source://frigate', - clientId, - 'recordings', - cameraName, - `${recording.startTime.getFullYear()}-${String( - recording.startTime.getMonth() + 1, - ).padStart(2, '0')}-${String( - String(recording.startTime.getDate()).padStart(2, '0'), - )}`, - String(recording.startTime.getHours()).padStart(2, '0'), - ].join('/'); -}; - -/** - * Get a recording ID for internal de-duping. - */ -export const getRecordingID = ( - cameraConfig: CameraConfig, - recording: FrigateRecording, -): string => { - // 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. - return `${cameraConfig.frigate?.client_id ?? ''}/${ - cameraConfig.frigate.camera_name ?? '' - }/${recording.startTime.getTime()}/${recording.endTime.getTime()}}`; -}; diff --git a/src/camera/manager.ts b/src/camera/manager.ts deleted file mode 100644 index 6c04cdfa..00000000 --- a/src/camera/manager.ts +++ /dev/null @@ -1,554 +0,0 @@ -import { HomeAssistant } from 'custom-card-helpers'; -import { CameraConfig, CardWideConfig } from '../types.js'; -import { allPromises, arrayify, setify } from '../utils/basic.js'; -import { - CameraManagerCameraMetadata, - CameraManagerCapabilities, - CameraManagerMediaCapabilities, - DataQuery, - EventQuery, - EventQueryResults, - EventQueryResultsMap, - MediaMetadata, - MediaQuery, - PartialDataQuery, - PartialEventQuery, - PartialQueryConcreteType, - PartialRecordingQuery, - PartialRecordingSegmentsQuery, - QueryResults, - QueryResultsType, - QueryReturnType, - QueryType, - RecordingQuery, - RecordingQueryResults, - RecordingQueryResultsMap, - RecordingSegmentsQuery, - RecordingSegmentsQueryResults, - RecordingSegmentsQueryResultsMap, - ResultsMap, -} from './types.js'; -import orderBy from 'lodash-es/orderBy'; -import { CameraManagerEngineFactory } from './engine-factory.js'; -import { ViewMedia } from '../view/media.js'; -import { MediaQueriesResults } from '../view/media-queries-results'; -import { MediaQueries } from '../view/media-queries.js'; -import uniqBy from 'lodash-es/uniqBy'; -import { CameraManagerEngine } from './engine.js'; -import sum from 'lodash-es/sum'; -import add from 'date-fns/add'; -import { log } from '../utils/debug.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 interface ExtendedMediaQueryResult { - queries: T[]; - results: ViewMedia[]; -} - -export class CameraManager { - protected _engineFactory: CameraManagerEngineFactory; - protected _cameras: Map; - protected _cardWideConfig?: CardWideConfig; - - constructor( - engineFactory: CameraManagerEngineFactory, - cameras: Map, - cardWideConfig?: CardWideConfig, - ) { - this._engineFactory = engineFactory; - this._cameras = cameras; - this._cardWideConfig = cardWideConfig; - } - - public generateDefaultEventQueries( - cameraIDs: string | Set, - partialQuery?: PartialEventQuery, - ): EventQuery[] | null { - return this._generateDefaultQueries(cameraIDs, { - type: QueryType.Event, - ...partialQuery, - }); - } - - public generateDefaultRecordingQueries( - cameraIDs: string | Set, - partialQuery?: PartialRecordingQuery, - ): RecordingQuery[] | null { - return this._generateDefaultQueries(cameraIDs, { - type: QueryType.Recording, - ...partialQuery, - }); - } - - public generateDefaultRecordingSegmentsQueries( - cameraIDs: string | Set, - partialQuery?: PartialRecordingSegmentsQuery, - ): RecordingSegmentsQuery[] | null { - return this._generateDefaultQueries(cameraIDs, { - type: QueryType.RecordingSegments, - ...partialQuery, - }); - } - - public async getMediaMetadata( - hass: HomeAssistant, - ): Promise { - const what: Set = new Set(); - const where: Set = new Set(); - const days: Set = new Set(); - - const engines = this._engineFactory.getAllEngines(this._cameras); - if (!engines) { - return null; - } - - const processMetadata = async (engine: CameraManagerEngine): Promise => { - const engineMetadata = await engine.getMediaMetadata(hass, this._cameras); - if (engineMetadata) { - if (engineMetadata.what) { - engineMetadata.what.forEach(what.add, what); - } - if (engineMetadata.where) { - engineMetadata.where.forEach(where.add, where); - } - if (engineMetadata.days) { - engineMetadata.days.forEach(days.add, days); - } - } - } - - await allPromises(engines, (engine) => processMetadata(engine)); - - if (!what.size && !where.size && !days.size) { - return null; - } - return { - ...(what.size && { what: what }), - ...(where.size && { where: where }), - ...(days.size && { days: days }), - } - } - - protected _generateDefaultQueries( - cameraIDs: string | Set, - partialQuery: PQT, - ): PartialQueryConcreteType[] | null { - const concreteQueries: PartialQueryConcreteType[] = []; - const _cameraIDs = setify(cameraIDs); - - const engines = this._engineFactory.getEnginesForCameraIDs( - this._cameras, - _cameraIDs, - ); - - if (!engines) { - return null; - } - - for (const [engine, cameraIDs] of engines) { - let queries: DataQuery[] | null = null; - if (QueryClassifier.isEventQuery(partialQuery)) { - queries = engine.generateDefaultEventQuery( - this._cameras, - cameraIDs, - partialQuery, - ); - } else if (QueryClassifier.isRecordingQuery(partialQuery)) { - queries = engine.generateDefaultRecordingQuery( - this._cameras, - cameraIDs, - partialQuery, - ); - } else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) { - queries = engine.generateDefaultRecordingSegmentsQuery( - this._cameras, - cameraIDs, - partialQuery, - ); - } - - for (const query of queries ?? []) { - 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 executeMediaQueries( - hass: HomeAssistant, - mediaQueries: MediaQueries, - ): Promise { - const queries: (RecordingQuery | EventQuery)[] | null = mediaQueries.getQueries(); - if (!queries) { - return null; - } - const mediaArray = this._convertQueryResultsToMedia( - hass, - await this._handleQuery(hass, queries), - ); - - return new MediaQueriesResults( - mediaArray, - - // Select the first (most-recent) item. - mediaArray.length ? 0 : null, - ); - } - - public async extendMediaQueries( - hass: HomeAssistant, - queries: T[], - results: ViewMedia[], - direction: 'earlier' | 'later', - chunkSize: number, - ): Promise | null> { - const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => { - let output: Date | null = null; - for (const result of results) { - const startTime = result.getStartTime(); - if ( - startTime && - (!output || - (want === 'earliest' && startTime < output) || - (want === 'latest' && startTime > output)) - ) { - output = startTime; - } - } - return output; - }; - - // The queries associated with the chunk to fetch. - const newChunkQueries: T[] = []; - - // The re-constituted combined query. - const extendedQueries: T[] = []; - - for (const query of queries) { - const newChunkQuery = { ...query }; - - if (direction === 'later') { - newChunkQuery.start = getTimeFromResults('latest') ?? undefined; - } else if (direction === 'earlier') { - newChunkQuery.end = getTimeFromResults('earliest') ?? undefined; - } - newChunkQuery.limit = chunkSize; - - extendedQueries.push({ - ...query, - limit: (query.limit ?? 0) + chunkSize, - }); - newChunkQueries.push(newChunkQuery); - } - - const newChunkMedia = this._convertQueryResultsToMedia( - hass, - await this._handleQuery(hass, newChunkQueries), - ); - - if (!newChunkMedia.length) { - return null; - } - - return { - queries: extendedQueries, - results: this._sortMedia(results.concat(newChunkMedia)), - }; - } - - 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 getCapabilities(): CameraManagerCapabilities | null { - const engines = this._engineFactory.getAllEngines(this._cameras); - if (!engines) { - return null; - } - - return { - canFavoriteEvents: engines.some((engine) => engine.getCapabilities()?.canFavoriteEvents), - canFavoriteRecordings: engines.some((engine) => engine.getCapabilities()?.canFavoriteRecordings), - } - } - - public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null { - const engine = this._engineFactory.getEngineForMedia(this._cameras, media); - if (!engine) { - return null; - } - return engine.getMediaCapabilities(media); - } - - public async favoriteMedia( - hass: HomeAssistant, - media: ViewMedia, - favorite: boolean, - ): Promise { - const cameraConfig = this._cameras.get(media.getCameraID()); - if (!cameraConfig) { - return; - } - const engine = this._engineFactory.getEngineForCamera(cameraConfig); - if (engine) { - const queryStartTime = new Date(); - await engine.favoriteMedia(hass, cameraConfig, media, favorite); - - log( - this._cardWideConfig, - 'Frigate Card CameraManager favorite request (', - `Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, - 'Media:', - media.getID(), - ', Favorite:', - favorite, - ')', - ); - } - } - - public areMediaQueriesResultsFresh( - queries: MediaQueries, - results: MediaQueriesResults, - ): boolean { - const now = new Date(); - const resultsTimestamp = results.getResultsTimestamp(); - - if (!resultsTimestamp) { - return false; - } - - for (const query of queries.getQueries() ?? []) { - const engines = this._engineFactory.getEnginesForCameraIDs( - this._cameras, - query.cameraIDs, - ); - for (const [engine, cameraIDs] of engines ?? []) { - const maxAgeSeconds = engine.getQueryResultMaxAge({ - ...query, - cameraIDs: cameraIDs, - }); - if ( - maxAgeSeconds !== null && - add(resultsTimestamp, { seconds: maxAgeSeconds }) < now - ) { - return false; - } - } - } - return true; - } - - public async getMediaSeekTime( - hass: HomeAssistant, - media: ViewMedia, - target: Date, - ): Promise { - const startTime = media.getStartTime(); - const endTime = media.getEndTime(); - const cameraConfig = this._cameras.get(media.getCameraID()); - if ( - !cameraConfig || - !startTime || - !endTime || - target < startTime || - target > endTime - ) { - return null; - } - - const engine = this._engineFactory.getEngineForCamera(cameraConfig); - return (await engine?.getMediaSeekTime(hass, this._cameras, media, target)) ?? null; - } - - protected async _handleQuery( - hass: HomeAssistant, - query: QT | QT[], - ): Promise>> { - const _queries = arrayify(query); - const results = new Map>(); - const queryStartTime = new Date(); - - const processEngineQuery = async ( - engine: CameraManagerEngine, - query?: QT, - ): Promise => { - if (!query) { - return; - } - - let engineResult: Map> | null = null; - if (QueryClassifier.isEventQuery(query)) { - engineResult = (await engine.getEvents(hass, this._cameras, query)) as Map< - QT, - QueryReturnType - > | null; - } else if (QueryClassifier.isRecordingQuery(query)) { - engineResult = (await engine.getRecordings(hass, this._cameras, query)) as Map< - QT, - QueryReturnType - > | null; - } else if (QueryClassifier.isRecordingSegmentsQuery(query)) { - engineResult = (await engine.getRecordingSegments( - hass, - this._cameras, - query, - )) as Map> | null; - } - - engineResult?.forEach((value, key) => results.set(key, value)); - }; - - const processQuery = async (query: QT): Promise => { - const engines = this._engineFactory.getEnginesForCameraIDs( - this._cameras, - query.cameraIDs, - ); - if (!engines) { - return; - } - await Promise.all( - Array.from(engines.keys()).map((engine) => - processEngineQuery(engine, { ...query, cameraIDs: engines.get(engine) }), - ), - ); - }; - - await Promise.all(_queries.map((query) => processQuery(query))); - - const cachedOutputQueries = sum( - Array.from(results.values()).map((result) => Number(result.cached)), - ); - - log( - this._cardWideConfig, - 'Frigate Card CameraManager request [Input queries:', - _queries.length, - ', Cached output queries:', - cachedOutputQueries, - ', Total output queries:', - results.size, - ', Duration:', - `${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, - ', Queries:', - _queries, - ', Results:', - results, - ']', - ); - return results; - } - - protected _convertQueryResultsToMedia( - hass: HomeAssistant, - results: ResultsMap, - ): ViewMedia[] { - const mediaArray: ViewMedia[] = []; - for (const [query, result] of results.entries()) { - const engine = this._engineFactory.getEngine(result.engine); - - if (engine) { - let media: ViewMedia[] | null = null; - if ( - QueryClassifier.isEventQuery(query) && - QueryResultClassifier.isEventQueryResult(result) - ) { - media = engine.generateMediaFromEvents(hass, this._cameras, query, result); - } else if ( - QueryClassifier.isRecordingQuery(query) && - QueryResultClassifier.isRecordingQuery(result) - ) { - media = engine.generateMediaFromRecordings(hass, this._cameras, query, result); - } - if (media) { - mediaArray.push(...media); - } - } - } - return this._sortMedia(mediaArray); - } - - protected _sortMedia(mediaArray: ViewMedia[]): ViewMedia[] { - return orderBy( - // Ensure uniqueness by the ID (if specified), otherwise all elements - // are assumed to be unique. - uniqBy(mediaArray, (media) => media.getID() ?? media), - - // Sort all items leading with the most recent. - (media) => media.getStartTime(), - 'desc', - ); - } - - public getCameraMetadata( - hass: HomeAssistant, - cameraConfig?: CameraConfig, - ): CameraManagerCameraMetadata | null { - const engine = this._engineFactory.getEngineForCamera(cameraConfig); - if (!engine || !cameraConfig) { - return null; - } - return engine.getCameraMetadata(hass, cameraConfig); - } -} diff --git a/src/camera/range.ts b/src/camera/range.ts deleted file mode 100644 index 6c3b7666..00000000 --- a/src/camera/range.ts +++ /dev/null @@ -1,125 +0,0 @@ -import orderBy from 'lodash-es/orderBy'; - -interface Range { - start: T; - end: T; -} - -export type DateRange = Range; - -interface MemoryRangeSetInterface { - hasCoverage(range: T): boolean; - add(range: T): void; - clear(): void; -} - -export class MemoryRangeSet implements MemoryRangeSetInterface { - protected _ranges: DateRange[]; - - constructor(ranges?: DateRange[]) { - this._ranges = ranges ?? []; - } - - public hasCoverage(range: DateRange): boolean { - return this._ranges.some((cachedRange) => - rangeIsEntirelyContained(cachedRange, range), - ); - } - - public add(range: DateRange): void { - this._ranges.push(range); - this._ranges = compressRanges(this._ranges); - } - - public clear(): void { - this._ranges = []; - } -} - -interface ExpiringRange extends Range { - expires: Date; -} - -export class ExpiringMemoryRangeSet - implements MemoryRangeSetInterface> -{ - protected _ranges: ExpiringRange[]; - - constructor(ranges?: ExpiringRange[]) { - this._ranges = ranges ?? []; - } - - public hasCoverage(range: DateRange): boolean { - const now = new Date(); - return this._ranges.some( - (cachedRange) => - now < cachedRange.expires && rangeIsEntirelyContained(cachedRange, range), - ); - } - - public add(range: ExpiringRange): void { - this._expireOldRanges(); - this._ranges.push(range); - } - - protected _expireOldRanges(): void { - const now = new Date(); - this._ranges = this._ranges.filter((range) => now < range.expires); - } - - public clear(): void { - this._ranges = []; - } -} - -const rangeIsEntirelyContained = (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/camera/types.ts b/src/camera/types.ts deleted file mode 100644 index c7ca2380..00000000 --- a/src/camera/types.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { RecordingSegment } from '../types'; -import { FrigateEvent, FrigateRecording } from './frigate/types'; - -// ==== -// 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; - cameraIDs: Set; -} -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; - cached?: boolean; -} - -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; - -export type ResultsMap = Map>; -export type EventQueryResultsMap = ResultsMap; -export type RecordingQueryResultsMap = ResultsMap; -export type RecordingSegmentsQueryResultsMap = ResultsMap; - -export interface MediaMetadata { - where?: Set; - what?: Set; - days?: Set; -} - -interface BaseCapabilities { - canFavoriteEvents: boolean; - canFavoriteRecordings: boolean; -} - -export type CameraManagerCapabilities = BaseCapabilities; -export type CameraManagerEngineCapabilities = BaseCapabilities; -export interface CameraManagerMediaCapabilities { - canFavorite: boolean; -} - -export interface CameraManagerCameraMetadata { - title: string; - icon: string; -} - -// =========== -// 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?: Set; - - // Frigate equivalent: zone - where?: Set; - - favorite?: boolean; -} -export type PartialEventQuery = Partial; - -export interface EventQueryResults extends QueryResults { - type: QueryResultsType.Event; -} - -// =============== -// Recording Query -// =============== - -export interface RecordingQuery extends MediaQuery { - type: QueryType.Recording; - - favorite?: boolean; -} -export type PartialRecordingQuery = Partial; - -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 interface RecordingSegmentsQueryResults extends QueryResults { - type: QueryResultsType.RecordingSegments; - segments: RecordingSegment[]; -} - -// ======================== -// Frigate concrete results -// ======================== - -export interface FrigateEventQueryResults extends EventQueryResults { - engine: Engine.Frigate; - instanceID: string; - events: FrigateEvent[]; -} - -export interface FrigateRecordingQueryResults extends RecordingQueryResults { - engine: Engine.Frigate; - instanceID: string; - recordings: FrigateRecording[]; -} - -export interface FrigateRecordingSegmentsQueryResults - extends RecordingSegmentsQueryResults { - engine: Engine.Frigate; - instanceID: string; -} diff --git a/src/camera/util.ts b/src/camera/util.ts deleted file mode 100644 index 3f4693cd..00000000 --- a/src/camera/util.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 { DateRange } from './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 { - cacheableStart = startOfDay(range.start); - cacheableEnd = endOfDay(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/card.ts b/src/card.ts index df7d5e37..91d236a1 100644 --- a/src/card.ts +++ b/src/card.ts @@ -91,9 +91,9 @@ import { isValidMediaLoadedInfo } from './utils/media-info.js'; import { View } from './view/view.js'; import pkg from '../package.json'; import { ViewContext } from 'view'; -import { CameraManager } from './camera/manager.js'; +import { CameraManager } from './camera-manager/manager.js'; import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js'; -import { CameraManagerEngineFactory } from './camera/engine-factory.js'; +import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js'; import { log } from './utils/debug.js'; /** A note on media callbacks: diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 217667b3..46c000bb 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -22,7 +22,7 @@ import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents, } from '../utils/media-to-view.js'; -import { CameraManager, ExtendedMediaQueryResult } from '../camera/manager.js'; +import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js'; import { View } from '../view/view.js'; import { renderProgressIndicator } from './message.js'; import './thumbnail.js'; @@ -30,7 +30,7 @@ import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; import { createRef, Ref } from 'lit/directives/ref.js'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; -import { EventQuery, MediaQuery, RecordingQuery } from '../camera/types'; +import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types'; import { MediaQueriesResults } from '../view/media-queries-results'; import { errorToConsole } from '../utils/basic'; import './media-filter'; diff --git a/src/components/live/live.ts b/src/components/live/live.ts index f21d736b..e7f68134 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -50,7 +50,7 @@ import '../surround.js'; import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js'; import { classMap } from 'lit/directives/class-map.js'; import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js'; -import { CameraManager } from '../../camera/manager.js'; +import { CameraManager } from '../../camera-manager/manager.js'; import { HomeAssistant } from 'custom-card-helpers'; import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js'; import { HassEntity } from 'home-assistant-js-websocket'; diff --git a/src/components/media-filter-core.ts b/src/components/media-filter-core.ts index 6d1c8bf1..365b560d 100644 --- a/src/components/media-filter-core.ts +++ b/src/components/media-filter-core.ts @@ -8,7 +8,7 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; -import { DateRange } from '../camera/range'; +import { DateRange } from '../camera-manager/range'; import { localize } from '../localize/localize'; import mediaFilterCoreStyle from '../scss/media-filter-core.scss'; import { ExtendedHomeAssistant } from '../types'; diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index d1a81e50..63493265 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -16,8 +16,8 @@ import { unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { CameraManager } from '../camera/manager'; -import { DateRange } from '../camera/range'; +import { CameraManager } from '../camera-manager/manager'; +import { DateRange } from '../camera-manager/range'; import { CameraConfig, ExtendedHomeAssistant } from '../types'; import { View } from '../view/view'; import { @@ -31,7 +31,7 @@ import { } from './media-filter-core'; import './surround.js'; import './timeline-core.js'; -import { EventQuery, MediaMetadata, QueryType, RecordingQuery } from '../camera/types'; +import { EventQuery, MediaMetadata, QueryType, RecordingQuery } from '../camera-manager/types'; import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; import { createViewForEvents, createViewForRecordings } from '../utils/media-to-view.js'; import { HomeAssistant } from 'custom-card-helpers'; diff --git a/src/components/menu.ts b/src/components/menu.ts index 758b1a93..4c04b78d 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -27,7 +27,7 @@ import { frigateCardHasAction, getActionConfigGivenAction } from '../utils/action.js'; -import { FRIGATE_ICON_SVG_PATH } from '../camera/frigate/icon.js'; +import { FRIGATE_ICON_SVG_PATH } from '../camera-manager/frigate/icon.js'; import { refreshDynamicStateParameters } from '../utils/ha'; import './submenu.js'; diff --git a/src/components/surround.ts b/src/components/surround.ts index c67d6e43..1044c972 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -16,7 +16,7 @@ import { ThumbnailsControlConfig, } from '../types.js'; import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; -import { CameraManager } from '../camera/manager.js'; +import { CameraManager } from '../camera-manager/manager.js'; import { View } from '../view/view.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; import './surround-basic.js'; diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 0f7d75ac..4e9625b7 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -25,7 +25,7 @@ import { FrigateCardCarousel } from './carousel.js'; import './thumbnail.js'; import './carousel.js'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { CameraManager } from '../camera/manager.js'; +import { CameraManager } from '../camera-manager/manager.js'; export interface ThumbnailCarouselTap { queryResults: MediaQueriesResults; diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index e7256366..35f4b429 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -23,7 +23,7 @@ import { Task, TaskStatus } from '@lit-labs/task'; import type { CameraConfig, ExtendedHomeAssistant } from '../types.js'; import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js'; -import { CameraManager } from '../camera/manager.js'; +import { CameraManager } from '../camera-manager/manager.js'; import { ViewMediaClassifier } from '../view/media-classifier.js'; // The minimum width of a thumbnail with details enabled. diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 58cdb705..037b6712 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -49,7 +49,7 @@ import { createViewForRecordings, findClosestMediaIndex, } from '../utils/media-to-view'; -import { CameraManager } from '../camera/manager'; +import { CameraManager } from '../camera-manager/manager'; import { EventMediaQueries, MediaQueries } from '../view/media-queries'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { dispatchMessageEvent } from './message.js'; @@ -57,7 +57,7 @@ import './thumbnail.js'; import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source'; import { ViewMedia } from '../view/media'; import { ViewMediaClassifier } from '../view/media-classifier'; -import { rangesOverlap } from '../camera/range'; +import { rangesOverlap } from '../camera-manager/range'; import { View } from '../view/view'; interface FrigateCardGroupData { diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 581167fa..24618d4c 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 { CameraManager } from '../camera/manager'; +import { CameraManager } from '../camera-manager/manager'; import { View } from '../view/view'; import './surround.js'; import './timeline-core.js'; diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 3c53afe1..de203459 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -47,7 +47,7 @@ import '../patches/ha-hls-player'; import './surround.js'; import { renderTask } from '../utils/task.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; -import { CameraManager } from '../camera/manager.js'; +import { CameraManager } from '../camera-manager/manager.js'; import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents, diff --git a/src/editor.ts b/src/editor.ts index bb5fa1b1..29ae6893 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -142,7 +142,7 @@ import { } from './types.js'; import { arrayMove, prettifyTitle } from './utils/basic.js'; import { getCameraID } from './utils/camera.js'; -import { FRIGATE_ICON_SVG_PATH } from './camera/frigate/icon.js'; +import { FRIGATE_ICON_SVG_PATH } from './camera-manager/frigate/icon.js'; import { getEntitiesFromHASS, getEntityTitle, diff --git a/src/types.ts b/src/types.ts index 221372ca..57d0b471 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,7 +13,7 @@ import { } from 'custom-card-helpers'; import { StyleInfo } from 'lit/directives/style-map.js'; import { z } from 'zod'; -import { eventSchema, FrigateEvent, FrigateRecording } from './camera/frigate/types.js'; +import { eventSchema, FrigateEvent, FrigateRecording } from './camera-manager/frigate/types.js'; import { deepRemoveDefaults } from './utils/zod.js'; // The min allowed size of buttons. diff --git a/src/utils/ha/browse-media.ts b/src/utils/ha/browse-media.ts index 1128c19c..b78de684 100644 --- a/src/utils/ha/browse-media.ts +++ b/src/utils/ha/browse-media.ts @@ -1,6 +1,6 @@ import { HomeAssistant } from 'custom-card-helpers'; import { homeAssistantWSRequest } from '.'; -import { FrigateEvent, FrigateRecording } from '../../camera/frigate/types'; +import { FrigateEvent, FrigateRecording } from '../../camera-manager/frigate/types'; import { dispatchErrorMessageEvent } from '../../components/message.js'; import { localize } from '../../localize/localize.js'; import { diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 7d7d9fe0..7e3aa277 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -8,7 +8,7 @@ import { MediaQueries, RecordingMediaQueries, } from '../view/media-queries'; -import { CameraManager } from '../camera/manager'; +import { CameraManager } from '../camera-manager/manager'; import { getAllDependentCameras } from './camera.js'; import { ViewMedia } from '../view/media'; import { HomeAssistant } from 'custom-card-helpers'; diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index 81d5cd41..cd749ba7 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -4,12 +4,12 @@ import sub from 'date-fns/sub'; import { DataSet } from 'vis-data'; import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext'; import { ClipsOrSnapshotsOrAll, RecordingSegment } from '../types'; -import { CameraManager } from '../camera/manager'; -import { EventQuery } from '../camera/types'; -import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera/util'; +import { CameraManager } from '../camera-manager/manager'; +import { EventQuery } from '../camera-manager/types'; +import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util'; import { EventMediaQueries } from '../view/media-queries'; import { ViewMedia } from '../view/media'; -import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet } from '../camera/range'; +import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet } from '../camera-manager/range'; import { errorToConsole, ModifyInterface } from './basic.js'; // Allow timeline freshness to be at least this number of seconds out of date diff --git a/src/view/media-queries.ts b/src/view/media-queries.ts index abdb8784..6ba2dfab 100644 --- a/src/view/media-queries.ts +++ b/src/view/media-queries.ts @@ -1,6 +1,6 @@ import isEqual from 'lodash-es/isEqual'; import cloneDeep from 'lodash-es/cloneDeep.js'; -import { EventQuery, MediaQuery, RecordingQuery } from '../camera/types.js'; +import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types.js'; export type MediaQueries = EventMediaQueries | RecordingMediaQueries; From edb7a8826663b01aa03ab7dd131f20c53303c9c0 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 19:16:30 -0800 Subject: [PATCH 58/74] Add moved folder. --- src/camera-manager/cache.ts | 160 ++++ src/camera-manager/engine-factory.ts | 93 ++ src/camera-manager/engine.ts | 105 +++ src/camera-manager/frigate/engine-frigate.ts | 838 ++++++++++++++++++ src/camera-manager/frigate/icon.ts | 20 + .../frigate/media-classifier.ts | 18 + src/camera-manager/frigate/media.ts | 181 ++++ src/camera-manager/frigate/requests.ts | 151 ++++ src/camera-manager/frigate/types.ts | 68 ++ src/camera-manager/frigate/util.ts | 97 ++ src/camera-manager/manager.ts | 554 ++++++++++++ src/camera-manager/range.ts | 125 +++ src/camera-manager/types.ts | 169 ++++ src/camera-manager/util.ts | 39 + 14 files changed, 2618 insertions(+) create mode 100644 src/camera-manager/cache.ts create mode 100644 src/camera-manager/engine-factory.ts create mode 100644 src/camera-manager/engine.ts create mode 100644 src/camera-manager/frigate/engine-frigate.ts create mode 100644 src/camera-manager/frigate/icon.ts create mode 100644 src/camera-manager/frigate/media-classifier.ts create mode 100644 src/camera-manager/frigate/media.ts create mode 100644 src/camera-manager/frigate/requests.ts create mode 100644 src/camera-manager/frigate/types.ts create mode 100644 src/camera-manager/frigate/util.ts create mode 100644 src/camera-manager/manager.ts create mode 100644 src/camera-manager/range.ts create mode 100644 src/camera-manager/types.ts create mode 100644 src/camera-manager/util.ts diff --git a/src/camera-manager/cache.ts b/src/camera-manager/cache.ts new file mode 100644 index 00000000..a0d997e7 --- /dev/null +++ b/src/camera-manager/cache.ts @@ -0,0 +1,160 @@ +import isEqual from 'lodash-es/isEqual'; +import orderBy from 'lodash-es/orderBy'; +import sortedUniqBy from 'lodash-es/sortedUniqBy'; +import { RecordingSegment } from '../types'; +import { DateRange, MemoryRangeSet } from './range'; +import { DataQuery, QueryResults } from './types'; + +interface RequestCacheItem { + request: Request; + response: Response; + expires?: Date; +} + +interface CameraManagerCache { + get(request: Request): Response | null; + has(request: Request): boolean; + set(request: Request, response: Response, expiry?: Date): void; +} + +export class MemoryRequestCache + implements CameraManagerCache +{ + protected _data: RequestCacheItem[] = []; + + public get(request: Request): Response | null { + const now = new Date(); + 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 _contains(a: Request, b: Request): boolean { + return isEqual(a, b); + } + + protected _expireOldRequests(): void { + const now = new Date(); + 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; + } + + public size(): number { + return this._data.length; + } + + /** + * Remove old data that matches a given predicate. No change to the covered + * ranges is made, i.e. this is asserting authoritiatively that this data does + * not exist in the current ranges. + * @param predicate A predicate to run on each data element. + */ + public expireMatches(predicate: (data: Data) => boolean): void { + this._data = this._data.filter(predicate); + } +} + +export class RecordingSegmentsCache { + protected _segments: Map> = new Map(); + + public add(cameraID: string, range: DateRange, segments: RecordingSegment[]) { + 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): RecordingSegment[] | null { + return this._segments.get(cameraID)?.get(range) ?? null; + } + + public getCache(cameraID: string): MemoryRangedCache | null { + return this._segments.get(cameraID) ?? null; + } + + public getCameraIDs(): string[] { + return [...this._segments.keys()]; + } + + public expireMatches( + cameraID: string, + func: (segment: RecordingSegment) => boolean, + ): void { + this._segments.get(cameraID)?.expireMatches(func); + } +} diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts new file mode 100644 index 00000000..07295bb8 --- /dev/null +++ b/src/camera-manager/engine-factory.ts @@ -0,0 +1,93 @@ +import { CameraConfig, CardWideConfig } from '../types'; +import { ViewMedia } from '../view/media'; +import { RecordingSegmentsCache, RequestCache } from './cache'; +import { CameraManagerEngine } from './engine'; +import { FrigateCameraManagerEngine } from './frigate/engine-frigate'; +import { Engine } from './types'; + +type CameraManagerEngineCameraIDMap = Map>; + +export class CameraManagerEngineFactory { + protected _engines: Map = new Map(); + protected _cardWideConfig: CardWideConfig; + + constructor(cardWideConfig: CardWideConfig) { + this._cardWideConfig = cardWideConfig; + } + + public getEngine(engine: Engine): CameraManagerEngine | null { + const cachedEngine = this._engines.get(engine); + if (cachedEngine) { + return cachedEngine; + } + let cameraManagerEngine: CameraManagerEngine | null = null; + switch (engine) { + case Engine.Frigate: + cameraManagerEngine = new FrigateCameraManagerEngine( + this._cardWideConfig, + new RecordingSegmentsCache(), + new RequestCache(), + ); + break; + } + if (cameraManagerEngine) { + this._engines.set(engine, cameraManagerEngine); + } + return cameraManagerEngine; + } + + public getEngineForCamera(cameraConfig?: CameraConfig): CameraManagerEngine | null { + if (!cameraConfig) { + return null; + } + + let engine: Engine | null = null; + if (cameraConfig.frigate.camera_name) { + engine = Engine.Frigate; + } + return engine ? this.getEngine(engine) : null; + } + + public getEnginesForCameraIDs( + cameras: Map, + cameraIDs: Set, + ): CameraManagerEngineCameraIDMap | null { + const output: CameraManagerEngineCameraIDMap = new Map(); + + for (const cameraID of cameraIDs) { + const cameraConfig = cameras.get(cameraID); + if (!cameraConfig) { + continue; + } + + const engine = this.getEngineForCamera(cameraConfig); + if (!engine) { + continue; + } + if (!output.has(engine)) { + output.set(engine, new Set()); + } + output.get(engine)?.add(cameraID); + } + return output.size ? output : null; + } + + public getEngineForMedia( + cameras: Map, + media: ViewMedia, + ): CameraManagerEngine | null { + const cameraID = media.getCameraID(); + if (!cameraID) { + return null; + } + const engines = this.getEnginesForCameraIDs(cameras, new Set([cameraID])); + return engines ? ([...engines.keys()][0] ?? null) : null; + } + + public getAllEngines( + cameras: Map, + ): CameraManagerEngine[] | null { + const engines = this.getEnginesForCameraIDs(cameras, new Set(cameras.keys())); + return engines ? [...engines.keys()] : null; + } +} diff --git a/src/camera-manager/engine.ts b/src/camera-manager/engine.ts new file mode 100644 index 00000000..22fc836f --- /dev/null +++ b/src/camera-manager/engine.ts @@ -0,0 +1,105 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { CameraConfig } from '../types'; +import { ViewMedia } from '../view/media'; +import { + DataQuery, + EventQuery, + EventQueryResultsMap, + MediaMetadata, + PartialEventQuery, + PartialRecordingQuery, + PartialRecordingSegmentsQuery, + QueryReturnType, + RecordingQuery, + RecordingQueryResultsMap, + RecordingSegmentsQuery, + RecordingSegmentsQueryResultsMap, + CameraManagerEngineCapabilities, + CameraManagerMediaCapabilities, + CameraManagerCameraMetadata, +} from './types'; + +export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; + +export interface CameraManagerEngine { + generateDefaultEventQuery( + cameras: Map, + cameraIDs: Set, + query: PartialEventQuery, + ): EventQuery[] | null; + + generateDefaultRecordingQuery( + cameras: Map, + cameraIDs: Set, + query: PartialRecordingQuery, + ): RecordingQuery[] | null; + + generateDefaultRecordingSegmentsQuery( + cameras: Map, + cameraIDs: Set, + query: PartialRecordingSegmentsQuery, + ): RecordingSegmentsQuery[] | null; + + getEvents( + hass: HomeAssistant, + cameras: Map, + query: EventQuery, + ): Promise; + + getRecordings( + hass: HomeAssistant, + cameras: Map, + query: RecordingQuery, + ): Promise; + + getRecordingSegments( + hass: HomeAssistant, + cameras: Map, + query: RecordingSegmentsQuery, + ): Promise; + + generateMediaFromEvents( + hass: HomeAssistant, + cameras: Map, + query: EventQuery, + results: QueryReturnType, + ): ViewMedia[] | null; + + generateMediaFromRecordings( + hass: HomeAssistant, + cameras: Map, + query: RecordingQuery, + results: QueryReturnType, + ): ViewMedia[] | null; + + getMediaDownloadPath(cameraConfig: CameraConfig, media: ViewMedia): string | null; + + favoriteMedia( + hass: HomeAssistant, + cameraConfig: CameraConfig, + media: ViewMedia, + favorite: boolean, + ): Promise; + + getQueryResultMaxAge(query: DataQuery): number | null; + + getMediaSeekTime( + hass: HomeAssistant, + cameras: Map, + media: ViewMedia, + target: Date, + ): Promise; + + getMediaMetadata( + hass: HomeAssistant, + cameras: Map, + ): Promise; + + getCameraMetadata( + hass: HomeAssistant, + cameraConfig: CameraConfig, + ): CameraManagerCameraMetadata; + + getCapabilities(): CameraManagerEngineCapabilities | null; + getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null; +} diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts new file mode 100644 index 00000000..3e7c6b5b --- /dev/null +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -0,0 +1,838 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import add from 'date-fns/add'; +import endOfHour from 'date-fns/endOfHour'; +import startOfHour from 'date-fns/startOfHour'; +import { CAMERA_BIRDSEYE } from '../../const'; +import { CameraConfig, CardWideConfig, RecordingSegment } from '../../types'; +import { ViewMedia } from '../../view/media'; +import { RequestCache, RecordingSegmentsCache } from '../cache'; +import { + CameraManagerEngine, + CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, +} from '../engine'; +import { DateRange } from '../range'; +import { + CameraManagerCameraMetadata, + CameraManagerEngineCapabilities, + CameraManagerMediaCapabilities, + DataQuery, + Engine, + EventQuery, + EventQueryResults, + EventQueryResultsMap, + FrigateEventQueryResults, + FrigateRecordingQueryResults, + FrigateRecordingSegmentsQueryResults, + MediaMetadata, + PartialEventQuery, + PartialRecordingQuery, + PartialRecordingSegmentsQuery, + QueryResults, + QueryResultsType, + QueryReturnType, + QueryType, + RecordingQuery, + RecordingQueryResults, + RecordingQueryResultsMap, + RecordingSegmentsQuery, + RecordingSegmentsQueryResultsMap, +} from '../types'; +import { FrigateRecording } from './types'; +import { + getEvents, + getEventSummary, + getRecordingSegments, + getRecordingsSummary, + NativeFrigateEventQuery, + NativeFrigateRecordingSegmentsQuery, + retainEvent, +} from './requests'; +import orderBy from 'lodash-es/orderBy'; +import throttle from 'lodash-es/throttle'; +import { + allPromises, + formatDate, + prettifyTitle, + runWhenIdleIfSupported, +} from '../../utils/basic'; +import { fromUnixTime } from 'date-fns'; +import { sum } from 'lodash-es'; +import { FrigateViewMediaClassifier } from './media-classifier'; +import { ViewMediaClassifier } from '../../view/media-classifier'; +import { FrigateViewMediaFactory } from './media'; +import { log } from '../../utils/debug'; +import { getEntityIcon, getEntityTitle } from '../../utils/ha'; + +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 FrigateCameraManagerEngine implements CameraManagerEngine { + protected _recordingSegmentsCache: RecordingSegmentsCache; + protected _requestCache: RequestCache; + protected _cardWideConfig: CardWideConfig; + + // Garbage collect segments at most once an hour. + protected _throttledSegmentGarbageCollector = throttle( + this._garbageCollectSegments.bind(this), + 60 * 60 * 1000, + { leading: false, trailing: true }, + ); + + constructor( + cardWideConfig: CardWideConfig, + recordingSegmentsCache: RecordingSegmentsCache, + requestCache: RequestCache, + ) { + this._cardWideConfig = cardWideConfig; + this._recordingSegmentsCache = recordingSegmentsCache; + this._requestCache = requestCache; + } + + public getMediaDownloadPath( + cameraConfig: CameraConfig, + media: ViewMedia, + ): string | null { + let path: string | null = null; + if (FrigateViewMediaClassifier.isFrigateEvent(media)) { + path = + `/api/frigate/${cameraConfig.frigate.client_id}` + + `/notifications/${media.getID()}/` + + `${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` + + `?download=true`; + } else if (FrigateViewMediaClassifier.isFrigateRecording(media)) { + path = + `/api/frigate/${cameraConfig.frigate.client_id}` + + `/recording/${cameraConfig.frigate.camera_name}` + + `/start/${Math.floor(media.getStartTime().getTime() / 1000)}` + + `/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` + + `?download=true`; + } + return path; + } + + public generateDefaultEventQuery( + cameras: Map, + cameraIDs: Set, + query?: PartialEventQuery, + ): EventQuery[] | null { + const relevantCameraConfigs = Array.from(cameraIDs).map((cameraID) => + cameras.get(cameraID), + ); + + // If there isn't a label or zone specified, we can come up with a single + // batch query for Frigate that will match across all cameras. + const canDoBatchQuery = relevantCameraConfigs.every( + (cameraConfig) => !cameraConfig?.frigate.label && !cameraConfig?.frigate.zone, + ); + + if (canDoBatchQuery) { + return [ + { + type: QueryType.Event, + cameraIDs: cameraIDs, + ...query, + }, + ]; + } + + const output: EventQuery[] = []; + for (const cameraID of cameraIDs) { + const cameraConfig = cameras.get(cameraID); + if (cameraConfig) { + output.push({ + type: QueryType.Event, + cameraIDs: new Set([cameraID]), + ...(cameraConfig.frigate.label && { + what: new Set([cameraConfig.frigate.label]), + }), + ...(cameraConfig.frigate.zone && { + where: new Set([cameraConfig.frigate.zone]), + }), + ...query, + }); + } + } + return output.length ? output : null; + } + + public generateDefaultRecordingQuery( + _cameras: Map, + cameraIDs: Set, + query?: PartialRecordingQuery, + ): RecordingQuery[] { + return [ + { + type: QueryType.Recording, + cameraIDs: cameraIDs, + ...query, + }, + ]; + } + + public generateDefaultRecordingSegmentsQuery( + _cameras: Map, + cameraIDs: Set, + query: PartialRecordingSegmentsQuery, + ): RecordingSegmentsQuery[] | null { + if (!query.start || !query.end) { + return null; + } + return [ + { + type: QueryType.RecordingSegments, + cameraIDs: cameraIDs, + start: query.start, + end: query.end, + ...query, + }, + ]; + } + + public async favoriteMedia( + hass: HomeAssistant, + cameraConfig: CameraConfig, + media: ViewMedia, + favorite: boolean, + ): Promise { + if (!FrigateViewMediaClassifier.isFrigateEvent(media)) { + return; + } + + await retainEvent(hass, cameraConfig.frigate.client_id, media.getID(), favorite); + media.setFavorite(favorite); + } + + protected _buildInstanceToCameraIDMapFromQuery( + cameras: Map, + cameraIDs: Set, + ): Map> { + const output: Map> = new Map(); + for (const cameraID of cameraIDs) { + const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + const clientID = cameraConfig?.frigate.client_id; + if (clientID) { + if (!output.has(clientID)) { + output.set(clientID, new Set()); + } + output.get(clientID)?.add(cameraID); + } + } + return output; + } + + protected _getFrigateCameraNamesForCameraIDs( + cameras: Map, + cameraIDs: Set, + ): Set { + const output = new Set(); + for (const cameraID of cameraIDs) { + const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + if (cameraConfig?.frigate.camera_name) { + output.add(cameraConfig.frigate.camera_name); + } + } + return output; + } + + public async getEvents( + hass: HomeAssistant, + cameras: Map, + query: EventQuery, + ): Promise { + const output: EventQueryResultsMap = new Map(); + + const processInstanceQuery = async ( + instanceID: string, + cameraIDs?: Set, + ): Promise => { + if (!cameraIDs || !cameraIDs.size) { + return; + } + const instanceQuery = { ...query, cameraIDs: cameraIDs }; + const cachedResult = this._requestCache.get(instanceQuery); + if (cachedResult) { + output.set(query, cachedResult as EventQueryResults); + return; + } + + const nativeQuery: NativeFrigateEventQuery = { + instance_id: instanceID, + cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs)), + ...(query.what && { labels: Array.from(query.what) }), + ...(query.where && { zones: Array.from(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 }), + ...(query.favorite && { favorites: query.favorite }), + limit: query?.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, + }; + + const result: FrigateEventQueryResults = { + type: QueryResultsType.Event, + engine: Engine.Frigate, + instanceID: instanceID, + events: await getEvents(hass, nativeQuery), + expiry: add(new Date(), { seconds: EVENT_REQUEST_CACHE_MAX_AGE_SECONDS }), + cached: false, + }; + + this._requestCache.set(query, { ...result, cached: true }, result.expiry); + output.set(instanceQuery, result); + }; + + // Frigate allows multiple cameras to be searched for events in a single + // query. Break them down into groups of cameras per Frigate instance, then + // query once per instance for all cameras in that instance. + const instances = this._buildInstanceToCameraIDMapFromQuery( + cameras, + query.cameraIDs, + ); + + await Promise.all( + Array.from(instances.keys()).map((instanceID) => + processInstanceQuery(instanceID, instances.get(instanceID)), + ), + ); + return output.size ? output : null; + } + + public async getRecordings( + hass: HomeAssistant, + cameras: Map, + query: RecordingQuery, + ): Promise { + const output: RecordingQueryResultsMap = new Map(); + + const processQuery = async ( + baseQuery: RecordingQuery, + cameraID: string, + ): Promise => { + const query = { ...baseQuery, cameraIDs: new Set([cameraID]) }; + const cachedResult = this._requestCache.get(query); + if (cachedResult) { + output.set(query, cachedResult as RecordingQueryResults); + return; + } + + const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + if (!cameraConfig || !cameraConfig.frigate.camera_name) { + return; + } + + const recordingSummary = await getRecordingsSummary( + hass, + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + ); + + let 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({ + cameraID: cameraID, + startTime: startHour, + endTime: endHour, + events: hourData.events, + }); + } + } + } + + if (query.limit !== undefined) { + // Frigate does not natively support a way to limit recording searches so + // this simulates it. + recordings = orderBy( + recordings, + (recording: FrigateRecording) => recording.startTime, + 'desc', + ).slice(0, query.limit); + } + + const result: FrigateRecordingQueryResults = { + type: QueryResultsType.Recording, + engine: Engine.Frigate, + instanceID: cameraConfig.frigate.client_id, + recordings: recordings, + expiry: add(new Date(), { + seconds: RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS, + }), + cached: false, + }; + this._requestCache.set(query, { ...result, cached: true }, result.expiry); + output.set(query, result); + }; + + // Frigate recordings can only be queried for a single camera, so fan out + // the inbound query into multiple outbound queries. + await Promise.all( + Array.from(query.cameraIDs).map((cameraID) => processQuery(query, cameraID)), + ); + return output.size ? output : null; + } + + public async getRecordingSegments( + hass: HomeAssistant, + cameras: Map, + query: RecordingSegmentsQuery, + ): Promise { + const output: RecordingSegmentsQueryResultsMap = new Map(); + + const processQuery = async ( + baseQuery: RecordingSegmentsQuery, + cameraID: string, + ): Promise => { + const query = { ...baseQuery, cameraIDs: new Set([cameraID]) }; + const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + if (!cameraConfig || !cameraConfig.frigate.camera_name) { + return; + } + + const range: DateRange = { start: query.start, end: query.end }; + + // A note on Frigate Recording Segments: + // - 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(cameraID, range); + if (cachedSegments) { + output.set(query, { + type: QueryResultsType.RecordingSegments, + engine: Engine.Frigate, + instanceID: cameraConfig.frigate.client_id, + segments: cachedSegments, + cached: true, + }); + return; + } + + 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), + }; + + const segments = await getRecordingSegments(hass, request); + this._recordingSegmentsCache.add(cameraID, range, segments); + + output.set(query, { + type: QueryResultsType.RecordingSegments, + engine: Engine.Frigate, + instanceID: cameraConfig.frigate.client_id, + segments: segments, + cached: false, + }); + }; + + // Frigate recording segments can only be queried for a single camera, so + // fan out the inbound query into multiple outbound queries. + await Promise.all( + Array.from(query.cameraIDs).map((cameraID) => processQuery(query, cameraID)), + ); + + runWhenIdleIfSupported(() => this._throttledSegmentGarbageCollector(hass, cameras)); + return output.size ? output : null; + } + + protected _getCameraIDMatch( + cameras: Map, + query: DataQuery, + instanceID: string, + cameraName: string, + ): string | null { + // If the query is only for a single cameraID, all results are assumed to + // belong to it for performance reasons. Otherwise, we need to map the + // instanceID and camera name for the known cameras, and get the precise + // cameraID that matches the expected instance ID / camera name. + if (query.cameraIDs.size === 1) { + return [...query.cameraIDs][0]; + } + for (const [cameraID, cameraConfig] of cameras.entries()) { + if ( + cameraConfig.frigate.client_id === instanceID && + cameraConfig.frigate.camera_name === cameraName + ) { + return cameraID; + } + } + return null; + } + + public generateMediaFromEvents( + _hass: HomeAssistant, + cameras: Map, + query: EventQuery, + results: QueryReturnType, + ): ViewMedia[] | null { + if (!FrigateQueryResultsClassifier.isFrigateEventQueryResults(results)) { + return null; + } + + const output: ViewMedia[] = []; + for (const event of results.events) { + const cameraID = this._getCameraIDMatch( + cameras, + query, + results.instanceID, + event.camera, + ); + if (!cameraID) { + continue; + } + const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + if (!cameraConfig) { + continue; + } + 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 = FrigateViewMediaFactory.createEventViewMedia( + mediaType, + cameraID, + cameraConfig, + event, + ); + if (media) { + output.push(media); + } + } + return output; + } + + public generateMediaFromRecordings( + hass: HomeAssistant, + cameras: Map, + _query: RecordingQuery, + results: QueryReturnType, + ): ViewMedia[] | null { + if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(results)) { + return null; + } + + const output: ViewMedia[] = []; + for (const recording of results.recordings) { + const cameraConfig = this._getQueryableCameraConfig(cameras, recording.cameraID); + if (!cameraConfig) { + continue; + } + const media = FrigateViewMediaFactory.createRecordingViewMedia( + recording.cameraID, + recording, + cameraConfig, + this.getCameraMetadata(hass, cameraConfig).title, + ); + if (media) { + output.push(media); + } + } + return output; + } + + public getQueryResultMaxAge(query: DataQuery): number | null { + if (query.type === QueryType.Event) { + return EVENT_REQUEST_CACHE_MAX_AGE_SECONDS; + } else if (query.type === QueryType.Recording) { + return RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS; + } + return null; + } + + public async getMediaSeekTime( + hass: HomeAssistant, + cameras: Map, + media: ViewMedia, + target: Date, + ): Promise { + const start = media.getStartTime(); + const end = media.getEndTime(); + if (!start || !end || target < start || target > end) { + return null; + } + + const cameraID = media.getCameraID(); + const query: RecordingSegmentsQuery = { + cameraIDs: new Set([cameraID]), + start: start, + end: end, + type: QueryType.RecordingSegments, + }; + + const results = await this.getRecordingSegments(hass, cameras, query); + + if (results) { + return this._getSeekTimeInSegments( + start, + target, + // There will only be a single result since Frigate recording segments + // searches are per camera which is specified singularly above. + Array.from(results.values())[0].segments, + ); + } + return null; + } + + 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; + } + + public async getMediaMetadata( + hass: HomeAssistant, + cameras: Map, + ): Promise { + const what: Set = new Set(); + const where: Set = new Set(); + const days: Set = new Set(); + + const instances = this._buildInstanceToCameraIDMapFromQuery( + cameras, + new Set(cameras.keys()), + ); + + const processEventSummary = async ( + instanceID: string, + cameraIDs: Set, + ): Promise => { + const cameraNames = this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs); + for (const entry of await getEventSummary(hass, instanceID)) { + if (!cameraNames.has(entry.camera)) { + // If this entry applies to a camera that *is* in this Frigate + // instance, but is *not* a configured camera in the card, skip it. + continue; + } + if (entry.label) { + what.add(entry.label); + } + if (entry.zones.length) { + entry.zones.forEach(where.add, where); + } + if (entry.day) { + days.add(entry.day); + } + } + }; + + const processRecordings = async (cameraIDs: Set): Promise => { + const recordings = await this.getRecordings(hass, cameras, { + type: QueryType.Recording, + cameraIDs: cameraIDs, + }); + if (!recordings) { + return; + } + + for (const result of recordings.values()) { + if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(result)) { + continue; + } + + for (const recording of result.recordings) { + // Frigate recordings are always 1 hour long, i.e. never span a day. + days.add(formatDate(recording.startTime)); + } + } + }; + + await allPromises([...instances.entries()], ([instanceID, cameraIDs]) => + (async () => { + await Promise.all([ + processEventSummary(instanceID, cameraIDs), + processRecordings(cameraIDs), + ]); + })(), + ); + + if (!what.size && !where.size && !days.size) { + return null; + } + return { + ...(what.size && { what: what }), + ...(where.size && { where: where }), + ...(days.size && { days: days }), + }; + } + + /** + * Garbage collect recording segments that no longer feature in the recordings + * returned by the Frigate backend. + */ + protected async _garbageCollectSegments( + hass: HomeAssistant, + cameras: Map, + ): Promise { + const cameraIDs = this._recordingSegmentsCache.getCameraIDs(); + const recordingQuery: RecordingQuery = { + cameraIDs: new Set(cameraIDs), + type: QueryType.Recording, + }; + + const countSegments = () => + sum( + cameraIDs.map( + (cameraID) => this._recordingSegmentsCache.getCache(cameraID)?.size() ?? 0, + ), + ); + const segmentsStart = countSegments(); + + // 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, startTime: Date): string => { + return `${cameraID}/${startTime.getDate()}/${startTime.getHours()}`; + }; + + const results = await this.getRecordings(hass, cameras, recordingQuery); + if (!results) { + return; + } + + for (const [query, result] of results) { + if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(result)) { + continue; + } + + const goodHours: Set = new Set(); + for (const recording of result.recordings) { + goodHours.add(getHourID(recording.cameraID, recording.startTime)); + } + + // Frigate recordings are always executed individually, so there'll only + // be a single results. + const cameraID = Array.from(query.cameraIDs)[0]; + this._recordingSegmentsCache.expireMatches( + cameraID, + (segment: RecordingSegment) => { + const hourID = getHourID(cameraID, fromUnixTime(segment.start_time)); + // ~O(1) lookup time for a JS set. + return goodHours.has(hourID); + }, + ); + } + + log( + this._cardWideConfig, + 'Frigate Card recording segment garbage collection: ' + + `Released ${segmentsStart - countSegments()} segment(s)`, + ); + } + + /** + * Get the number of seconds to seek into a video stream consisting of the + * provided segments to reach the target time provided. + * @param startTime The earliest allowable time to seek from. + * @param targetTime Target time. + * @param segments An array of segments dataset items. Must be sorted from oldest to youngest. + * @returns + */ + protected _getSeekTimeInSegments( + startTime: Date, + targetTime: Date, + segments: RecordingSegment[], + ): number | null { + if (!segments.length) { + return null; + } + let seekMilliseconds = 0; + + // Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27 + for (const segment of segments) { + const segmentStart = fromUnixTime(segment.start_time); + if (segmentStart > targetTime) { + break; + } + 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; + } + + public getCapabilities(): CameraManagerEngineCapabilities { + return { + canFavoriteEvents: true, + canFavoriteRecordings: false, + }; + } + + public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities { + return { + canFavorite: ViewMediaClassifier.isEvent(media), + }; + } + + public getCameraMetadata( + hass: HomeAssistant, + cameraConfig: CameraConfig, + ): CameraManagerCameraMetadata { + return { + title: + cameraConfig.title ?? + getEntityTitle(hass, cameraConfig.camera_entity) ?? + getEntityTitle(hass, cameraConfig.webrtc_card?.entity) ?? + prettifyTitle(cameraConfig.frigate?.camera_name) ?? + cameraConfig.id ?? + '', + icon: + cameraConfig?.icon ?? + getEntityIcon(hass, cameraConfig.camera_entity) ?? + 'mdi:video', + }; + } +} diff --git a/src/camera-manager/frigate/icon.ts b/src/camera-manager/frigate/icon.ts new file mode 100644 index 00000000..3e075f2e --- /dev/null +++ b/src/camera-manager/frigate/icon.ts @@ -0,0 +1,20 @@ +export const FRIGATE_ICON_SVG_PATH = + 'm 4.8759466,22.743573 c 0.0866,0.69274 0.811811,1.16359 0.37885,1.27183 ' + + '-0.43297,0.10824 -2.32718,-3.43665 -2.7601492,-4.95202 -0.4329602,-1.51538 ' + + '-0.6764993,-3.22017 -0.5682593,-4.19434 0.1082301,-0.97417 5.7097085,-2.48955 ' + + '5.7097085,-2.89545 0,-0.4059 -1.81304,-0.0271 -1.89422,-0.35178 -0.0812,-0.32472 ' + + '1.36925,-0.12989 1.75892,-0.64945 0.60885,-0.81181 1.3800713,-0.6765 1.8671505,' + + '-1.1094696 0.4870902,-0.4329599 1.0824089,-2.0836399 1.1906589,-2.7871996 0.108241,' + + '-0.70357 -1.0824084,-1.51538 -1.4071389,-2.05658 -0.3247195,-0.54121 0.7035702,' + + '-0.92005 3.1931099,-1.94834 2.48954,-1.02829 10.39114,-3.30134994 10.49938,' + + '-3.03074994 0.10824,0.27061 -2.59779,1.40713994 -4.492,2.11069994 -1.89422,0.70357 ' + + '-4.97909,2.05658 -4.97909,2.43542 0,0.37885 0.16236,0.67651 0.0541,1.54244 -0.10824,' + + '0.86593 -0.12123,1.2702597 -0.32472,1.8400997 -0.1353,0.37884 -0.2706,1.27183 ' + + '0,2.0836295 0.21648,0.64945 0.92005,1.13653 1.24477,1.24478 0.2706,0.018 1.01746,' + + '0.0433 1.8401,0 1.02829,-0.0541 2.48954,0.0541 2.48954,0.32472 0,0.2706 -2.21894,' + + '0.10824 -2.21894,0.48708 0,0.37885 2.27306,-0.0541 2.21894,0.32473 -0.0541,0.37884 ' + + '-1.89422,0.21648 -2.86839,0.21648 -0.77933,0 -1.93031,-0.0361 -2.43542,-0.21648 ' + + 'l -0.10824,0.37884 c -0.18038,0 -0.55744,0.10824 -0.94711,0.10824 -0.48708,0 ' + + '-0.51414,0.16236 -1.40713,0.16236 -0.892989,0 -0.622391,-0.0541 -1.4341894,-0.10824 ' + + '-0.81181,-0.0541 -3.842561,2.27306 -4.383761,3.03075 -0.54121,0.75768 ' + + '-0.21649,2.59778 -0.21649,3.43665 0,0.75379 -0.10824,2.43542 0,3.30135 z'; diff --git a/src/camera-manager/frigate/media-classifier.ts b/src/camera-manager/frigate/media-classifier.ts new file mode 100644 index 00000000..442d6b0d --- /dev/null +++ b/src/camera-manager/frigate/media-classifier.ts @@ -0,0 +1,18 @@ +import { ViewMedia } from '../../view/media'; +import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media'; + +export class FrigateViewMediaClassifier { + 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; + } +} diff --git a/src/camera-manager/frigate/media.ts b/src/camera-manager/frigate/media.ts new file mode 100644 index 00000000..0b5f5a3e --- /dev/null +++ b/src/camera-manager/frigate/media.ts @@ -0,0 +1,181 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import fromUnixTime from 'date-fns/fromUnixTime'; +import isEqual from 'lodash-es/isEqual'; +import { CameraConfig } from '../../types'; +import { + ViewMedia, + EventViewMedia, + RecordingViewMedia, + ViewMediaType, +} from '../../view/media'; +import { FrigateEvent, FrigateRecording } from './types'; +import { + getEventMediaContentID, + getEventThumbnailURL, + getEventTitle, + getRecordingID, + getRecordingMediaContentID, + getRecordingTitle, +} from './util'; + +export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia { + protected _event: FrigateEvent; + protected _contentID: string; + protected _thumbnail: string; + + constructor( + mediaType: ViewMediaType, + cameraID: string, + event: FrigateEvent, + contentID: string, + thumbnail: string, + ) { + super(mediaType, cameraID); + this._event = event; + this._contentID = contentID; + this._thumbnail = thumbnail; + } + + public hasClip(): boolean { + return !!this._event.has_clip; + } + public getStartTime(): Date { + return fromUnixTime(this._event.start_time); + } + public getEndTime(): Date | null { + return this._event.end_time ? fromUnixTime(this._event.end_time) : null; + } + public getID(): string { + return this._event.id; + } + public getContentID(): string { + return this._contentID; + } + public getTitle(): string | null { + return getEventTitle(this._event); + } + public getThumbnail(): string | null { + return this._thumbnail; + } + public isFavorite(): boolean | null { + return this._event.retain_indefinitely ?? null; + } + public setFavorite(favorite: boolean): void { + this._event.retain_indefinitely = favorite; + } + public getWhat(): string[] | null { + return [this._event.label]; + } + public getWhere(): string[] | null { + const zones = this._event.zones; + return zones.length ? zones : null; + } + public getScore(): number | null { + return this._event.top_score; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public isGroupableWith(that: EventViewMedia): boolean { + return ( + this.getMediaType() === that.getMediaType() && + isEqual(this.getWhere(), that.getWhere()) && + isEqual(this.getWhat(), that.getWhat()) + ); + } +} + +export class FrigateRecordingViewMedia extends ViewMedia implements RecordingViewMedia { + protected _recording: FrigateRecording; + protected _id: string; + protected _contentID: string; + protected _title: string; + + constructor( + mediaType: ViewMediaType, + cameraID: string, + recording: FrigateRecording, + id: string, + contentID: string, + title: string, + ) { + super(mediaType, cameraID); + this._recording = recording; + this._id = id; + this._contentID = contentID; + this._title = title; + } + + public getID(): string { + return this._id; + } + public getStartTime(): Date { + return this._recording.startTime; + } + public getEndTime(): Date { + return this._recording.endTime; + } + public getContentID(): string | null { + return this._contentID; + } + public getTitle(): string | null { + return this._title; + } + public getEventCount(): number { + return this._recording.events; + } +} + +export class FrigateViewMediaFactory { + static createEventViewMedia( + mediaType: 'clip' | 'snapshot', + cameraID: string, + cameraConfig: CameraConfig, + event: FrigateEvent, + ): FrigateEventViewMedia | null { + if ( + (mediaType === 'clip' && !event.has_clip) || + (mediaType === 'snapshot' && !event.has_snapshot) || + !cameraConfig.frigate.client_id || + !cameraConfig.frigate.camera_name + ) { + return null; + } + + return new FrigateEventViewMedia( + mediaType, + cameraID, + event, + getEventMediaContentID( + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + event, + mediaType === 'clip' ? 'clips' : 'snapshots', + ), + getEventThumbnailURL(cameraConfig.frigate.client_id, event), + ); + } + + static createRecordingViewMedia( + cameraID: string, + recording: FrigateRecording, + cameraConfig: CameraConfig, + cameraTitle: string, + ): FrigateRecordingViewMedia | null { + if (!cameraConfig.frigate.client_id || !cameraConfig.frigate.camera_name) { + return null; + } + + return new FrigateRecordingViewMedia( + 'recording', + cameraID, + recording, + getRecordingID(cameraConfig, recording), + getRecordingMediaContentID( + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + recording, + ), + getRecordingTitle(cameraTitle, recording), + ); + } +} diff --git a/src/camera-manager/frigate/requests.ts b/src/camera-manager/frigate/requests.ts new file mode 100644 index 00000000..01d48878 --- /dev/null +++ b/src/camera-manager/frigate/requests.ts @@ -0,0 +1,151 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { localize } from '../../localize/localize'; +import { FrigateCardError, RecordingSegment } from '../../types'; +import { homeAssistantWSRequest } from '../../utils/ha'; +import { + EventSummary, + eventSummarySchema, + FrigateEvent, + frigateEventsSchema, + recordingSegmentsSchema, + RecordingSummary, + recordingSummarySchema, + RetainResult, + retainResultSchema, +} from './types'; + +/** + * Get the recordings summary. May throw. + * @param hass The Home Assistant object. + * @param clientID The Frigate clientID. + * @param camera_name The Frigate camera name. + * @returns A RecordingSummary object. + */ +export const getRecordingsSummary = async ( + hass: HomeAssistant, + clientID: string, + camera_name: string, +): Promise => { + return await homeAssistantWSRequest( + hass, + recordingSummarySchema, + { + type: 'frigate/recordings/summary', + instance_id: clientID, + camera: camera_name, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + true, + ); +}; + +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 params The recording segment query parameters. + * @returns A RecordingSegments object. + */ +export const getRecordingSegments = async ( + hass: HomeAssistant, + params: NativeFrigateRecordingSegmentsQuery, +): Promise => { + return await homeAssistantWSRequest( + hass, + recordingSegmentsSchema, + { + type: 'frigate/recordings/get', + ...params, + }, + true, + ); +}; + +/** + * Request that Frigate retain an event. May throw. + * @param hass The HomeAssistant object. + * @param clientID The Frigate clientID. + * @param eventID The event ID to retain. + * @param retain `true` to retain or `false` to unretain. + */ +export async function retainEvent( + hass: HomeAssistant, + clientID: string, + eventID: string, + retain: boolean, +): Promise { + const retainRequest = { + type: 'frigate/event/retain', + instance_id: clientID, + event_id: eventID, + retain: retain, + }; + const response = await homeAssistantWSRequest( + hass, + retainResultSchema, + retainRequest, + true, + ); + if (!response.success) { + throw new FrigateCardError(localize('error.failed_retain'), { + request: retainRequest, + response: response, + }); + } +} + +export interface NativeFrigateEventQuery { + instance_id?: string; + cameras?: string[]; + labels?: string[]; + zones?: string[]; + after?: number; + before?: number; + limit?: number; + has_clip?: boolean; + has_snapshot?: boolean; + favorites?: boolean; +} + +/** + * Get events over websocket. May throw. + * @param hass The Home Assistant object. + * @param params The events search parameters. + * @returns An array of 'FrigateEvent's. + */ +export const getEvents = async ( + hass: HomeAssistant, + params?: NativeFrigateEventQuery, +): Promise => { + return await homeAssistantWSRequest( + hass, + frigateEventsSchema, + { + type: 'frigate/events/get', + ...params, + }, + true, + ); +}; + +export const getEventSummary = async ( + hass: HomeAssistant, + clientID: string, +): Promise => { + return await homeAssistantWSRequest( + hass, + eventSummarySchema, + { + type: 'frigate/events/summary', + instance_id: clientID, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + true, + ); +}; diff --git a/src/camera-manager/frigate/types.ts b/src/camera-manager/frigate/types.ts new file mode 100644 index 00000000..41a0cd6b --- /dev/null +++ b/src/camera-manager/frigate/types.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; +import { dayToDate } from '../../utils/basic'; + +const dayStringToDate = (arg: unknown): Date | unknown => { + return typeof arg === 'string' ? dayToDate(arg) : arg; +}; + +export const eventSchema = z.object({ + camera: z.string(), + end_time: z.number().nullable(), + false_positive: z.boolean().nullable(), + has_clip: z.boolean(), + has_snapshot: z.boolean(), + id: z.string(), + label: z.string(), + start_time: z.number(), + top_score: z.number(), + zones: z.string().array(), + retain_indefinitely: z.boolean().optional(), +}); +export const frigateEventsSchema = eventSchema.array(); + +export type FrigateEvent = z.infer; + +const recordingSummaryHourSchema = z.object({ + hour: z.preprocess((arg) => Number(arg), z.number().min(0).max(23)), + duration: z.number().min(0), + events: z.number().min(0), +}); + +export const recordingSummarySchema = z + .object({ + day: z.preprocess(dayStringToDate, z.date()), + events: z.number(), + hours: recordingSummaryHourSchema.array(), + }) + .array(); +export type RecordingSummary = z.infer; + +const recordingSegmentSchema = z.object({ + start_time: z.number(), + end_time: z.number(), + id: z.string(), +}); +export const recordingSegmentsSchema = recordingSegmentSchema.array(); + +export const retainResultSchema = z.object({ + success: z.boolean(), + message: z.string(), +}); +export type RetainResult = z.infer; + +export interface FrigateRecording { + cameraID: string; + startTime: Date; + endTime: Date; + events: number; +} + +export const eventSummarySchema = z + .object({ + camera: z.string(), + day: z.string(), + label: z.string(), + zones: z.string().array(), + }) + .array(); +export type EventSummary = z.infer; diff --git a/src/camera-manager/frigate/util.ts b/src/camera-manager/frigate/util.ts new file mode 100644 index 00000000..0be47429 --- /dev/null +++ b/src/camera-manager/frigate/util.ts @@ -0,0 +1,97 @@ +import utcToZonedTime from 'date-fns-tz/utcToZonedTime'; +import { CameraConfig, ClipsOrSnapshots } from '../../types'; +import { formatDateAndTime, prettifyTitle } from '../../utils/basic'; +import { FrigateEvent, FrigateRecording } from './types'; + +/** + * Given an event generate a title. + * @param event + */ +export const getEventTitle = (event: FrigateEvent): string => { + const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const durationSeconds = Math.round( + event.end_time + ? event.end_time - event.start_time + : Date.now() / 1000 - event.start_time, + ); + return `${formatDateAndTime( + utcToZonedTime(event.start_time * 1000, localTimezone), + )} [${durationSeconds}s, ${prettifyTitle(event.label)} ${Math.round( + event.top_score * 100, + )}%]`; +}; + +export const getRecordingTitle = ( + cameraTitle: string, + recording: FrigateRecording, +): string => { + return `${cameraTitle} ${formatDateAndTime(recording.startTime)}`; +}; + +/** + * Get a thumbnail URL for an event. + * @param clientId The Frigate client id. + * @param event The event. + * @returns A string URL. + */ +export const getEventThumbnailURL = (clientId: string, event: FrigateEvent): string => { + return `/api/frigate/${clientId}/thumbnail/${event.id}`; +}; + +/** + * Get a media content ID for an event. + * @param clientId The Frigate client id. + * @param cameraName The Frigate camera name. + * @param event The Frigate event. + * @param mediaType The media type required. + * @returns A string media content id. + */ +export const getEventMediaContentID = ( + clientId: string, + cameraName: string, + event: FrigateEvent, + mediaType: ClipsOrSnapshots, +): string => { + return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${event.id}`; +}; + +/** + * Generate a recording identifier. + * @param clientId The Frigate client id. + * @param cameraName The Frigate camera name. + * @param recording The Frigate recording. + * @returns A recording identifier. + */ +export const getRecordingMediaContentID = ( + clientId: string, + cameraName: string, + recording: FrigateRecording, +): string => { + return [ + 'media-source://frigate', + clientId, + 'recordings', + cameraName, + `${recording.startTime.getFullYear()}-${String( + recording.startTime.getMonth() + 1, + ).padStart(2, '0')}-${String( + String(recording.startTime.getDate()).padStart(2, '0'), + )}`, + String(recording.startTime.getHours()).padStart(2, '0'), + ].join('/'); +}; + +/** + * Get a recording ID for internal de-duping. + */ +export const getRecordingID = ( + cameraConfig: CameraConfig, + recording: FrigateRecording, +): string => { + // 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. + return `${cameraConfig.frigate?.client_id ?? ''}/${ + cameraConfig.frigate.camera_name ?? '' + }/${recording.startTime.getTime()}/${recording.endTime.getTime()}}`; +}; diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts new file mode 100644 index 00000000..6c04cdfa --- /dev/null +++ b/src/camera-manager/manager.ts @@ -0,0 +1,554 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { CameraConfig, CardWideConfig } from '../types.js'; +import { allPromises, arrayify, setify } from '../utils/basic.js'; +import { + CameraManagerCameraMetadata, + CameraManagerCapabilities, + CameraManagerMediaCapabilities, + DataQuery, + EventQuery, + EventQueryResults, + EventQueryResultsMap, + MediaMetadata, + MediaQuery, + PartialDataQuery, + PartialEventQuery, + PartialQueryConcreteType, + PartialRecordingQuery, + PartialRecordingSegmentsQuery, + QueryResults, + QueryResultsType, + QueryReturnType, + QueryType, + RecordingQuery, + RecordingQueryResults, + RecordingQueryResultsMap, + RecordingSegmentsQuery, + RecordingSegmentsQueryResults, + RecordingSegmentsQueryResultsMap, + ResultsMap, +} from './types.js'; +import orderBy from 'lodash-es/orderBy'; +import { CameraManagerEngineFactory } from './engine-factory.js'; +import { ViewMedia } from '../view/media.js'; +import { MediaQueriesResults } from '../view/media-queries-results'; +import { MediaQueries } from '../view/media-queries.js'; +import uniqBy from 'lodash-es/uniqBy'; +import { CameraManagerEngine } from './engine.js'; +import sum from 'lodash-es/sum'; +import add from 'date-fns/add'; +import { log } from '../utils/debug.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 interface ExtendedMediaQueryResult { + queries: T[]; + results: ViewMedia[]; +} + +export class CameraManager { + protected _engineFactory: CameraManagerEngineFactory; + protected _cameras: Map; + protected _cardWideConfig?: CardWideConfig; + + constructor( + engineFactory: CameraManagerEngineFactory, + cameras: Map, + cardWideConfig?: CardWideConfig, + ) { + this._engineFactory = engineFactory; + this._cameras = cameras; + this._cardWideConfig = cardWideConfig; + } + + public generateDefaultEventQueries( + cameraIDs: string | Set, + partialQuery?: PartialEventQuery, + ): EventQuery[] | null { + return this._generateDefaultQueries(cameraIDs, { + type: QueryType.Event, + ...partialQuery, + }); + } + + public generateDefaultRecordingQueries( + cameraIDs: string | Set, + partialQuery?: PartialRecordingQuery, + ): RecordingQuery[] | null { + return this._generateDefaultQueries(cameraIDs, { + type: QueryType.Recording, + ...partialQuery, + }); + } + + public generateDefaultRecordingSegmentsQueries( + cameraIDs: string | Set, + partialQuery?: PartialRecordingSegmentsQuery, + ): RecordingSegmentsQuery[] | null { + return this._generateDefaultQueries(cameraIDs, { + type: QueryType.RecordingSegments, + ...partialQuery, + }); + } + + public async getMediaMetadata( + hass: HomeAssistant, + ): Promise { + const what: Set = new Set(); + const where: Set = new Set(); + const days: Set = new Set(); + + const engines = this._engineFactory.getAllEngines(this._cameras); + if (!engines) { + return null; + } + + const processMetadata = async (engine: CameraManagerEngine): Promise => { + const engineMetadata = await engine.getMediaMetadata(hass, this._cameras); + if (engineMetadata) { + if (engineMetadata.what) { + engineMetadata.what.forEach(what.add, what); + } + if (engineMetadata.where) { + engineMetadata.where.forEach(where.add, where); + } + if (engineMetadata.days) { + engineMetadata.days.forEach(days.add, days); + } + } + } + + await allPromises(engines, (engine) => processMetadata(engine)); + + if (!what.size && !where.size && !days.size) { + return null; + } + return { + ...(what.size && { what: what }), + ...(where.size && { where: where }), + ...(days.size && { days: days }), + } + } + + protected _generateDefaultQueries( + cameraIDs: string | Set, + partialQuery: PQT, + ): PartialQueryConcreteType[] | null { + const concreteQueries: PartialQueryConcreteType[] = []; + const _cameraIDs = setify(cameraIDs); + + const engines = this._engineFactory.getEnginesForCameraIDs( + this._cameras, + _cameraIDs, + ); + + if (!engines) { + return null; + } + + for (const [engine, cameraIDs] of engines) { + let queries: DataQuery[] | null = null; + if (QueryClassifier.isEventQuery(partialQuery)) { + queries = engine.generateDefaultEventQuery( + this._cameras, + cameraIDs, + partialQuery, + ); + } else if (QueryClassifier.isRecordingQuery(partialQuery)) { + queries = engine.generateDefaultRecordingQuery( + this._cameras, + cameraIDs, + partialQuery, + ); + } else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) { + queries = engine.generateDefaultRecordingSegmentsQuery( + this._cameras, + cameraIDs, + partialQuery, + ); + } + + for (const query of queries ?? []) { + 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 executeMediaQueries( + hass: HomeAssistant, + mediaQueries: MediaQueries, + ): Promise { + const queries: (RecordingQuery | EventQuery)[] | null = mediaQueries.getQueries(); + if (!queries) { + return null; + } + const mediaArray = this._convertQueryResultsToMedia( + hass, + await this._handleQuery(hass, queries), + ); + + return new MediaQueriesResults( + mediaArray, + + // Select the first (most-recent) item. + mediaArray.length ? 0 : null, + ); + } + + public async extendMediaQueries( + hass: HomeAssistant, + queries: T[], + results: ViewMedia[], + direction: 'earlier' | 'later', + chunkSize: number, + ): Promise | null> { + const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => { + let output: Date | null = null; + for (const result of results) { + const startTime = result.getStartTime(); + if ( + startTime && + (!output || + (want === 'earliest' && startTime < output) || + (want === 'latest' && startTime > output)) + ) { + output = startTime; + } + } + return output; + }; + + // The queries associated with the chunk to fetch. + const newChunkQueries: T[] = []; + + // The re-constituted combined query. + const extendedQueries: T[] = []; + + for (const query of queries) { + const newChunkQuery = { ...query }; + + if (direction === 'later') { + newChunkQuery.start = getTimeFromResults('latest') ?? undefined; + } else if (direction === 'earlier') { + newChunkQuery.end = getTimeFromResults('earliest') ?? undefined; + } + newChunkQuery.limit = chunkSize; + + extendedQueries.push({ + ...query, + limit: (query.limit ?? 0) + chunkSize, + }); + newChunkQueries.push(newChunkQuery); + } + + const newChunkMedia = this._convertQueryResultsToMedia( + hass, + await this._handleQuery(hass, newChunkQueries), + ); + + if (!newChunkMedia.length) { + return null; + } + + return { + queries: extendedQueries, + results: this._sortMedia(results.concat(newChunkMedia)), + }; + } + + 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 getCapabilities(): CameraManagerCapabilities | null { + const engines = this._engineFactory.getAllEngines(this._cameras); + if (!engines) { + return null; + } + + return { + canFavoriteEvents: engines.some((engine) => engine.getCapabilities()?.canFavoriteEvents), + canFavoriteRecordings: engines.some((engine) => engine.getCapabilities()?.canFavoriteRecordings), + } + } + + public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null { + const engine = this._engineFactory.getEngineForMedia(this._cameras, media); + if (!engine) { + return null; + } + return engine.getMediaCapabilities(media); + } + + public async favoriteMedia( + hass: HomeAssistant, + media: ViewMedia, + favorite: boolean, + ): Promise { + const cameraConfig = this._cameras.get(media.getCameraID()); + if (!cameraConfig) { + return; + } + const engine = this._engineFactory.getEngineForCamera(cameraConfig); + if (engine) { + const queryStartTime = new Date(); + await engine.favoriteMedia(hass, cameraConfig, media, favorite); + + log( + this._cardWideConfig, + 'Frigate Card CameraManager favorite request (', + `Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, + 'Media:', + media.getID(), + ', Favorite:', + favorite, + ')', + ); + } + } + + public areMediaQueriesResultsFresh( + queries: MediaQueries, + results: MediaQueriesResults, + ): boolean { + const now = new Date(); + const resultsTimestamp = results.getResultsTimestamp(); + + if (!resultsTimestamp) { + return false; + } + + for (const query of queries.getQueries() ?? []) { + const engines = this._engineFactory.getEnginesForCameraIDs( + this._cameras, + query.cameraIDs, + ); + for (const [engine, cameraIDs] of engines ?? []) { + const maxAgeSeconds = engine.getQueryResultMaxAge({ + ...query, + cameraIDs: cameraIDs, + }); + if ( + maxAgeSeconds !== null && + add(resultsTimestamp, { seconds: maxAgeSeconds }) < now + ) { + return false; + } + } + } + return true; + } + + public async getMediaSeekTime( + hass: HomeAssistant, + media: ViewMedia, + target: Date, + ): Promise { + const startTime = media.getStartTime(); + const endTime = media.getEndTime(); + const cameraConfig = this._cameras.get(media.getCameraID()); + if ( + !cameraConfig || + !startTime || + !endTime || + target < startTime || + target > endTime + ) { + return null; + } + + const engine = this._engineFactory.getEngineForCamera(cameraConfig); + return (await engine?.getMediaSeekTime(hass, this._cameras, media, target)) ?? null; + } + + protected async _handleQuery( + hass: HomeAssistant, + query: QT | QT[], + ): Promise>> { + const _queries = arrayify(query); + const results = new Map>(); + const queryStartTime = new Date(); + + const processEngineQuery = async ( + engine: CameraManagerEngine, + query?: QT, + ): Promise => { + if (!query) { + return; + } + + let engineResult: Map> | null = null; + if (QueryClassifier.isEventQuery(query)) { + engineResult = (await engine.getEvents(hass, this._cameras, query)) as Map< + QT, + QueryReturnType + > | null; + } else if (QueryClassifier.isRecordingQuery(query)) { + engineResult = (await engine.getRecordings(hass, this._cameras, query)) as Map< + QT, + QueryReturnType + > | null; + } else if (QueryClassifier.isRecordingSegmentsQuery(query)) { + engineResult = (await engine.getRecordingSegments( + hass, + this._cameras, + query, + )) as Map> | null; + } + + engineResult?.forEach((value, key) => results.set(key, value)); + }; + + const processQuery = async (query: QT): Promise => { + const engines = this._engineFactory.getEnginesForCameraIDs( + this._cameras, + query.cameraIDs, + ); + if (!engines) { + return; + } + await Promise.all( + Array.from(engines.keys()).map((engine) => + processEngineQuery(engine, { ...query, cameraIDs: engines.get(engine) }), + ), + ); + }; + + await Promise.all(_queries.map((query) => processQuery(query))); + + const cachedOutputQueries = sum( + Array.from(results.values()).map((result) => Number(result.cached)), + ); + + log( + this._cardWideConfig, + 'Frigate Card CameraManager request [Input queries:', + _queries.length, + ', Cached output queries:', + cachedOutputQueries, + ', Total output queries:', + results.size, + ', Duration:', + `${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, + ', Queries:', + _queries, + ', Results:', + results, + ']', + ); + return results; + } + + protected _convertQueryResultsToMedia( + hass: HomeAssistant, + results: ResultsMap, + ): ViewMedia[] { + const mediaArray: ViewMedia[] = []; + for (const [query, result] of results.entries()) { + const engine = this._engineFactory.getEngine(result.engine); + + if (engine) { + let media: ViewMedia[] | null = null; + if ( + QueryClassifier.isEventQuery(query) && + QueryResultClassifier.isEventQueryResult(result) + ) { + media = engine.generateMediaFromEvents(hass, this._cameras, query, result); + } else if ( + QueryClassifier.isRecordingQuery(query) && + QueryResultClassifier.isRecordingQuery(result) + ) { + media = engine.generateMediaFromRecordings(hass, this._cameras, query, result); + } + if (media) { + mediaArray.push(...media); + } + } + } + return this._sortMedia(mediaArray); + } + + protected _sortMedia(mediaArray: ViewMedia[]): ViewMedia[] { + return orderBy( + // Ensure uniqueness by the ID (if specified), otherwise all elements + // are assumed to be unique. + uniqBy(mediaArray, (media) => media.getID() ?? media), + + // Sort all items leading with the most recent. + (media) => media.getStartTime(), + 'desc', + ); + } + + public getCameraMetadata( + hass: HomeAssistant, + cameraConfig?: CameraConfig, + ): CameraManagerCameraMetadata | null { + const engine = this._engineFactory.getEngineForCamera(cameraConfig); + if (!engine || !cameraConfig) { + return null; + } + return engine.getCameraMetadata(hass, cameraConfig); + } +} diff --git a/src/camera-manager/range.ts b/src/camera-manager/range.ts new file mode 100644 index 00000000..6c3b7666 --- /dev/null +++ b/src/camera-manager/range.ts @@ -0,0 +1,125 @@ +import orderBy from 'lodash-es/orderBy'; + +interface Range { + start: T; + end: T; +} + +export type DateRange = Range; + +interface MemoryRangeSetInterface { + hasCoverage(range: T): boolean; + add(range: T): void; + clear(): void; +} + +export class MemoryRangeSet implements MemoryRangeSetInterface { + protected _ranges: DateRange[]; + + constructor(ranges?: DateRange[]) { + this._ranges = ranges ?? []; + } + + public hasCoverage(range: DateRange): boolean { + return this._ranges.some((cachedRange) => + rangeIsEntirelyContained(cachedRange, range), + ); + } + + public add(range: DateRange): void { + this._ranges.push(range); + this._ranges = compressRanges(this._ranges); + } + + public clear(): void { + this._ranges = []; + } +} + +interface ExpiringRange extends Range { + expires: Date; +} + +export class ExpiringMemoryRangeSet + implements MemoryRangeSetInterface> +{ + protected _ranges: ExpiringRange[]; + + constructor(ranges?: ExpiringRange[]) { + this._ranges = ranges ?? []; + } + + public hasCoverage(range: DateRange): boolean { + const now = new Date(); + return this._ranges.some( + (cachedRange) => + now < cachedRange.expires && rangeIsEntirelyContained(cachedRange, range), + ); + } + + public add(range: ExpiringRange): void { + this._expireOldRanges(); + this._ranges.push(range); + } + + protected _expireOldRanges(): void { + const now = new Date(); + this._ranges = this._ranges.filter((range) => now < range.expires); + } + + public clear(): void { + this._ranges = []; + } +} + +const rangeIsEntirelyContained = (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/camera-manager/types.ts b/src/camera-manager/types.ts new file mode 100644 index 00000000..c7ca2380 --- /dev/null +++ b/src/camera-manager/types.ts @@ -0,0 +1,169 @@ +import { RecordingSegment } from '../types'; +import { FrigateEvent, FrigateRecording } from './frigate/types'; + +// ==== +// 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; + cameraIDs: Set; +} +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; + cached?: boolean; +} + +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; + +export type ResultsMap = Map>; +export type EventQueryResultsMap = ResultsMap; +export type RecordingQueryResultsMap = ResultsMap; +export type RecordingSegmentsQueryResultsMap = ResultsMap; + +export interface MediaMetadata { + where?: Set; + what?: Set; + days?: Set; +} + +interface BaseCapabilities { + canFavoriteEvents: boolean; + canFavoriteRecordings: boolean; +} + +export type CameraManagerCapabilities = BaseCapabilities; +export type CameraManagerEngineCapabilities = BaseCapabilities; +export interface CameraManagerMediaCapabilities { + canFavorite: boolean; +} + +export interface CameraManagerCameraMetadata { + title: string; + icon: string; +} + +// =========== +// 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?: Set; + + // Frigate equivalent: zone + where?: Set; + + favorite?: boolean; +} +export type PartialEventQuery = Partial; + +export interface EventQueryResults extends QueryResults { + type: QueryResultsType.Event; +} + +// =============== +// Recording Query +// =============== + +export interface RecordingQuery extends MediaQuery { + type: QueryType.Recording; + + favorite?: boolean; +} +export type PartialRecordingQuery = Partial; + +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 interface RecordingSegmentsQueryResults extends QueryResults { + type: QueryResultsType.RecordingSegments; + segments: RecordingSegment[]; +} + +// ======================== +// Frigate concrete results +// ======================== + +export interface FrigateEventQueryResults extends EventQueryResults { + engine: Engine.Frigate; + instanceID: string; + events: FrigateEvent[]; +} + +export interface FrigateRecordingQueryResults extends RecordingQueryResults { + engine: Engine.Frigate; + instanceID: string; + recordings: FrigateRecording[]; +} + +export interface FrigateRecordingSegmentsQueryResults + extends RecordingSegmentsQueryResults { + engine: Engine.Frigate; + instanceID: string; +} diff --git a/src/camera-manager/util.ts b/src/camera-manager/util.ts new file mode 100644 index 00000000..3f4693cd --- /dev/null +++ b/src/camera-manager/util.ts @@ -0,0 +1,39 @@ +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 { DateRange } from './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 { + cacheableStart = startOfDay(range.start); + cacheableEnd = endOfDay(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; +}; From 767d4ee7a0034b1951f938a68794eccbcfd54656 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 19:28:34 -0800 Subject: [PATCH 59/74] Handle timeline item click corner case. --- src/components/timeline-core.ts | 59 +++++++++++++++++---------------- src/view/view.ts | 2 -- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 037b6712..50122d5a 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -503,37 +503,38 @@ export class FrigateCardTimelineCore extends LitElement { ); } else if ( properties.item && - properties.what === 'item' && - MediaQueriesClassifier.areRecordingQueries(this.view.query) + properties.what === 'item' ) { - const eventView = await this._createViewWithEventMediaQuery( - this._createEventMediaQuerys(), - { - selectedItem: properties.item, - targetView: 'media', - }, - ); - const results = eventView?.queryResults?.getResults(); - // Specifically ensure there are _some_ results before dispatching the - // view change. - if (eventView && results && results.length) { - eventView.mergeInContext({ mediaViewer: { seek: properties.time } }); - view = eventView; + const newResults = this.view.queryResults?.clone() + .resetSelectedResult() + .selectResultIfFound( + (media) => !!this.cameras && media.getID() === properties.item, + ); + + if (!newResults || !newResults.hasSelectedResult()) { + // This can happen if this is a recording query (with recorded hours) + // and an event is clicked on the timeline, or if the current thumbnails + // is a filtered view from the media gallery (i.e. any case where the + // thumbnails may not be match the events on the timeline). + const fullEventView = await this._createViewWithEventMediaQuery( + this._createEventMediaQuerys(), + { + selectedItem: properties.item, + targetView: 'media', + }, + ); + if (fullEventView?.queryResults?.hasResults()) { + view = fullEventView; + } + } else { + view = this.view.evolve({ + queryResults: newResults, + }); + } + + if (view?.queryResults?.hasResults()) { + view.mergeInContext({ mediaViewer: { seek: properties.time } }); } - } else if ( - properties.item && - properties.what === 'item' && - this.view.queryResults?.hasResults() && - this.view.query - ) { - view = this.view.evolve({ - queryResults: this.view.queryResults - ?.clone() - .resetSelectedResult() - .selectResultIfFound( - (media) => !!this.cameras && media.getID() === properties.item, - ), - }); } if (view) { diff --git a/src/view/view.ts b/src/view/view.ts index 77482d66..5c015e6d 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -9,8 +9,6 @@ // Hard: // - TODO: Remove FrigateBrowseMediaSource if not necessary (post-gallery). // - TODO: Remove browse-media.ts TODOs. -// - 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? import { ViewContext } from 'view'; import { From 01c3a79ff10877dee1d8737319fcbaba036b5cd7 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 20:00:45 -0800 Subject: [PATCH 60/74] Remove the MediaQuery wrapper from the camera manager. --- src/camera-manager/manager.ts | 52 +++++++++++-------------------- src/components/thumbnail.ts | 1 - src/components/timeline-core.ts | 24 +++++++------- src/components/timeline.ts | 1 + src/components/viewer.ts | 13 +++++--- src/utils/media-to-view.ts | 19 ++++++++--- src/utils/timeline-source.ts | 19 ++++++----- src/view/media-queries-results.ts | 4 +-- src/view/media-queries.ts | 5 --- 9 files changed, 69 insertions(+), 69 deletions(-) diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 6c04cdfa..46c84256 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -31,8 +31,6 @@ import { import orderBy from 'lodash-es/orderBy'; import { CameraManagerEngineFactory } from './engine-factory.js'; import { ViewMedia } from '../view/media.js'; -import { MediaQueriesResults } from '../view/media-queries-results'; -import { MediaQueries } from '../view/media-queries.js'; import uniqBy from 'lodash-es/uniqBy'; import { CameraManagerEngine } from './engine.js'; import sum from 'lodash-es/sum'; @@ -123,9 +121,7 @@ export class CameraManager { }); } - public async getMediaMetadata( - hass: HomeAssistant, - ): Promise { + public async getMediaMetadata(hass: HomeAssistant): Promise { const what: Set = new Set(); const where: Set = new Set(); const days: Set = new Set(); @@ -148,7 +144,7 @@ export class CameraManager { engineMetadata.days.forEach(days.add, days); } } - } + }; await allPromises(engines, (engine) => processMetadata(engine)); @@ -159,7 +155,7 @@ export class CameraManager { ...(what.size && { what: what }), ...(where.size && { where: where }), ...(days.size && { days: days }), - } + }; } protected _generateDefaultQueries( @@ -228,25 +224,14 @@ export class CameraManager { return await this._handleQuery(hass, query); } - public async executeMediaQueries( + public async executeMediaQueries( hass: HomeAssistant, - mediaQueries: MediaQueries, - ): Promise { - const queries: (RecordingQuery | EventQuery)[] | null = mediaQueries.getQueries(); - if (!queries) { - return null; - } - const mediaArray = this._convertQueryResultsToMedia( + queries: T[], + ): Promise { + return this._convertQueryResultsToMedia( hass, await this._handleQuery(hass, queries), ); - - return new MediaQueriesResults( - mediaArray, - - // Select the first (most-recent) item. - mediaArray.length ? 0 : null, - ); } public async extendMediaQueries( @@ -328,9 +313,13 @@ export class CameraManager { } return { - canFavoriteEvents: engines.some((engine) => engine.getCapabilities()?.canFavoriteEvents), - canFavoriteRecordings: engines.some((engine) => engine.getCapabilities()?.canFavoriteRecordings), - } + canFavoriteEvents: engines.some( + (engine) => engine.getCapabilities()?.canFavoriteEvents, + ), + canFavoriteRecordings: engines.some( + (engine) => engine.getCapabilities()?.canFavoriteRecordings, + ), + }; } public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null { @@ -368,18 +357,13 @@ export class CameraManager { } } - public areMediaQueriesResultsFresh( - queries: MediaQueries, - results: MediaQueriesResults, + public areMediaQueriesResultsFresh( + queries: T[], + resultsTimestamp: Date, ): boolean { const now = new Date(); - const resultsTimestamp = results.getResultsTimestamp(); - if (!resultsTimestamp) { - return false; - } - - for (const query of queries.getQueries() ?? []) { + for (const query of queries) { const engines = this._engineFactory.getEnginesForCameraIDs( this._cameras, query.cameraIDs, diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 35f4b429..84e95fc2 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -239,7 +239,6 @@ export class FrigateCardThumbnail extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; - // CameraManager used for marking media as favorite. @property({ attribute: false }) public cameraManager?: CameraManager; diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 50122d5a..fa5dd832 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -59,6 +59,7 @@ import { ViewMedia } from '../view/media'; import { ViewMediaClassifier } from '../view/media-classifier'; import { rangesOverlap } from '../camera-manager/range'; import { View } from '../view/view'; +import { MediaQuery } from '../camera-manager/types'; interface FrigateCardGroupData { id: string; @@ -501,11 +502,9 @@ export class FrigateCardTimelineCore extends LitElement { targetTime: properties.time, }, ); - } else if ( - properties.item && - properties.what === 'item' - ) { - const newResults = this.view.queryResults?.clone() + } else if (properties.item && properties.what === 'item') { + const newResults = this.view.queryResults + ?.clone() .resetSelectedResult() .selectResultIfFound( (media) => !!this.cameras && media.getID() === properties.item, @@ -972,14 +971,17 @@ export class FrigateCardTimelineCore extends LitElement { } protected _alreadyHasAcceptableMediaQuery(freshMediaQuery: MediaQueries): boolean { + const currentQueries = this.view?.query?.getQueries(); + const currentResultTimestamp = this.view?.queryResults?.getResultsTimestamp(); + return ( !!this.cameraManager && - !!this.view?.query && - !!this.view.queryResults && - freshMediaQuery.isEqual(this.view.query) && - this.cameraManager.areMediaQueriesResultsFresh( - this.view.query, - this.view.queryResults, + !!currentQueries && + !!currentResultTimestamp && + isEqual(currentQueries, freshMediaQuery.getQueries()) && + this.cameraManager.areMediaQueriesResultsFresh( + currentQueries, + currentResultTimestamp, ) ); } diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 24618d4c..b8a8446b 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -42,6 +42,7 @@ export class FrigateCardTimeline extends LitElement { .hass=${this.hass} .view=${this.view} .thumbnailConfig=${this.timelineConfig.controls.thumbnails} + .cameraManager=${this.cameraManager} .cameras=${this.cameras} > clipMedia.getID() === media.getID()); if (!results.hasSelectedResult()) { return; diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 7e3aa277..01b59eba 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -15,6 +15,7 @@ import { HomeAssistant } from 'custom-card-helpers'; import { dispatchFrigateCardErrorEvent } from '../components/message'; import { MediaQueriesResults } from '../view/media-queries-results'; import { errorToConsole } from './basic'; +import { MediaQuery } from '../camera-manager/types'; export const changeViewToRecentEventsForCameraAndDependents = async ( element: HTMLElement, @@ -179,19 +180,29 @@ const executeMediaQueryForView = async ( targetTime?: Date; }, ): Promise => { - let queryResults: MediaQueriesResults | null; + let mediaArray: ViewMedia[] | null; + + const queries = query.getQueries(); + if (!queries) { + return null; + } try { - queryResults = await cameraManager.executeMediaQueries(hass, query); + mediaArray = await cameraManager.executeMediaQueries(hass, queries); } catch (e) { errorToConsole(e as Error); dispatchFrigateCardErrorEvent(element, e as Error); return null; } + if (!mediaArray) { + return null; + } + + const queryResults = new MediaQueriesResults(mediaArray); let viewerContext: ViewContext | undefined = {}; - const mediaArray = queryResults?.getResults(); - if (queryResults && mediaArray && options?.targetTime && options.cameraIDs) { + + if (options?.targetTime && options.cameraIDs) { queryResults.selectBestResult((media) => findClosestMediaIndex(media, options.targetTime as Date, options.cameraIDs), ); diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index cd749ba7..de6d3b02 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -7,9 +7,12 @@ import { ClipsOrSnapshotsOrAll, RecordingSegment } from '../types'; import { CameraManager } from '../camera-manager/manager'; import { EventQuery } from '../camera-manager/types'; import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util'; -import { EventMediaQueries } from '../view/media-queries'; import { ViewMedia } from '../view/media'; -import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet } from '../camera-manager/range'; +import { + compressRanges, + ExpiringMemoryRangeSet, + MemoryRangeSet, +} from '../camera-manager/range'; import { errorToConsole, ModifyInterface } from './basic.js'; // Allow timeline freshness to be at least this number of seconds out of date @@ -132,14 +135,13 @@ export class TimelineDataSource { } const cacheFriendlyWindow = this.getCacheFriendlyEventWindow(window); - const eventQueries = this.getTimelineEventQueries(cacheFriendlyWindow) + const eventQueries = this.getTimelineEventQueries(cacheFriendlyWindow); if (!eventQueries) { return; } - const query = new EventMediaQueries(eventQueries); - const results = await this._cameraManager.executeMediaQueries(hass, query); - for (const media of results?.getResults() ?? []) { + const mediaArray = await this._cameraManager.executeMediaQueries(hass, eventQueries); + for (const media of mediaArray ?? []) { const endTime = media.getEndTime(); const startTime = media.getStartTime(); const id = media.getID(); @@ -234,7 +236,10 @@ export class TimelineDataSource { if (!recordingQueries) { return; } - const results = await this._cameraManager.getRecordingSegments(hass, recordingQueries); + const results = await this._cameraManager.getRecordingSegments( + hass, + recordingQueries, + ); const newSegments: Map = new Map(); for (const [query, result] of results) { diff --git a/src/view/media-queries-results.ts b/src/view/media-queries-results.ts index 5dc5b13e..b6de37cd 100644 --- a/src/view/media-queries-results.ts +++ b/src/view/media-queries-results.ts @@ -10,9 +10,7 @@ export class MediaQueriesResults { if (results) { this.setResults(results); } - if (selectedIndex !== undefined) { - this.selectResult(selectedIndex); - } + this.selectResult(selectedIndex ?? 0); } public clone(): MediaQueriesResults { diff --git a/src/view/media-queries.ts b/src/view/media-queries.ts index 6ba2dfab..f77a79e3 100644 --- a/src/view/media-queries.ts +++ b/src/view/media-queries.ts @@ -1,4 +1,3 @@ -import isEqual from 'lodash-es/isEqual'; import cloneDeep from 'lodash-es/cloneDeep.js'; import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types.js'; @@ -17,10 +16,6 @@ export class MediaQueriesBase { return cloneDeep(this); } - public isEqual(that: MediaQueries): boolean { - return isEqual(this.getQueries(), that.getQueries()); - } - public getQueries(): T[] | null { return this._queries; } From d0ded29741de66d9b06aa66bc636d9a29318fe5d Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 20:06:18 -0800 Subject: [PATCH 61/74] Minor fixes. --- src/components/media-filter.ts | 1 - src/view/view.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index 63493265..f6f343de 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -377,7 +377,6 @@ export class MediaMetadataController implements ReactiveController { }); this.whenOptions = monthStarts .sort() - .reverse() .map((monthStart) => ({ label: format(monthStart, 'MMMM yyyy'), value: { diff --git a/src/view/view.ts b/src/view/view.ts index 5c015e6d..679f3900 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,6 +1,5 @@ // Minor / later: // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript -// - TODO: Take MediaQueries wrappers out of the camera manager. // - TODO: View a media in the gallery from September, then notice timeline missing the item. // Gallery: From a7cab0ea1abd5b0f195ebb080a732d3230ceac82 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 20:18:38 -0800 Subject: [PATCH 62/74] Fix query extension bug. --- src/camera-manager/manager.ts | 10 ++++++++-- src/components/gallery.ts | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 46c84256..272e23c0 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -267,9 +267,15 @@ export class CameraManager { const newChunkQuery = { ...query }; if (direction === 'later') { - newChunkQuery.start = getTimeFromResults('latest') ?? undefined; + const latestResult = getTimeFromResults('latest'); + if (latestResult) { + newChunkQuery.start = latestResult; + } } else if (direction === 'earlier') { - newChunkQuery.end = getTimeFromResults('earliest') ?? undefined; + const earliestResult = getTimeFromResults('earliest'); + if (earliestResult) { + newChunkQuery.end = earliestResult; + } } newChunkQuery.limit = chunkSize; diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 46c000bb..ee1432d2 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -247,7 +247,7 @@ export class FrigateCardGalleryCore extends LitElement { this._showExtensionLoader = false; const query = this.view?.query; - const rawQueries: MediaQuery[] | null = query?.getQueries() ?? null; + const rawQueries = query?.getQueries() ?? null; const existingMedia = this.view.queryResults?.getResults(); if (!query || !rawQueries || !existingMedia) { return; @@ -255,7 +255,7 @@ export class FrigateCardGalleryCore extends LitElement { let extension: ExtendedMediaQueryResult | null; try { - extension = await this.cameraManager.extendMediaQueries( + extension = await this.cameraManager.extendMediaQueries( this.hass, rawQueries, existingMedia, From a5921e9f9159cc9dea0a4734043693a4c8270091 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 20:44:08 -0800 Subject: [PATCH 63/74] Import cleanups to avoid circular imports. --- src/camera-manager/types.ts | 8 +- src/components/viewer.ts | 11 +- src/types.ts | 45 +---- src/utils/ha/browse-media.ts | 375 ----------------------------------- src/view/view.ts | 4 - 5 files changed, 13 insertions(+), 430 deletions(-) delete mode 100644 src/utils/ha/browse-media.ts diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index c7ca2380..d2d9b5ba 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -1,4 +1,3 @@ -import { RecordingSegment } from '../types'; import { FrigateEvent, FrigateRecording } from './frigate/types'; // ==== @@ -48,6 +47,13 @@ export interface QueryResults { cached?: boolean; } +// Generic recording segment type (inspired by Frigate recording segments). +export interface RecordingSegment { + start_time: number; + end_time: number; + id: string; +} + export type QueryReturnType = QT extends EventQuery ? EventQueryResults : QT extends RecordingQuery diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 4c1ba8d7..d68f0dea 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -28,7 +28,6 @@ import { } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { contentsChanged, errorToConsole } from '../utils/basic.js'; -import { getFullDependentBrowseMediaQueryParametersOrDispatchError } from '../utils/ha/browse-media.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; import { View } from '../view/view.js'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; @@ -106,21 +105,13 @@ export class FrigateCardViewer extends LitElement { return; } - const browseMediaQueryParameters = - getFullDependentBrowseMediaQueryParametersOrDispatchError( - this, - this.hass, - this.cameras, - this.view.camera, - ); - if (!this.view.queryResults?.hasResults()) { // If the query is not specified, the view must tell us which mediaType to // search for. When the query *is* specified, the view is not required to // indicate the media type (e.g. the mixed 'media' view from the // timeline). const mediaType = this.view.getDefaultMediaType(); - if (!browseMediaQueryParameters || !mediaType) { + if (!mediaType) { return; } diff --git a/src/types.ts b/src/types.ts index 57d0b471..4788a607 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,7 +13,6 @@ import { } from 'custom-card-helpers'; import { StyleInfo } from 'lit/directives/style-map.js'; import { z } from 'zod'; -import { eventSchema, FrigateEvent, FrigateRecording } from './camera-manager/frigate/types.js'; import { deepRemoveDefaults } from './utils/zod.js'; // The min allowed size of buttons. @@ -1224,9 +1223,11 @@ const debugConfigDefault = { logging: false, }; -const debugConfigSchema = z.object({ - logging: z.boolean().default(debugConfigDefault.logging), -}).default(debugConfigDefault); +const debugConfigSchema = z + .object({ + logging: z.boolean().default(debugConfigDefault.logging), + }) + .default(debugConfigDefault); export type DebugConfig = z.infer; export interface CardWideConfig { @@ -1401,35 +1402,6 @@ export interface BrowseMediaSource { children?: BrowseMediaSource[] | null; } -export interface FrigateBrowseMediaSource extends BrowseMediaSource { - children?: FrigateBrowseMediaSource[] | null; - frigate?: { - event?: FrigateEvent; - recording?: FrigateRecording; - cameraID?: string; - }; -} - -export const frigateBrowseMediaSourceSchema: z.ZodSchema = z.lazy( - () => - z.object({ - title: z.string(), - media_class: z.string(), - media_content_type: z.string(), - media_content_id: z.string(), - can_play: z.boolean(), - can_expand: z.boolean(), - children_media_class: z.string().nullable().optional(), - thumbnail: z.string().nullable(), - children: z.array(frigateBrowseMediaSourceSchema).nullable().optional(), - frigate: z - .object({ - event: eventSchema, - }) - .optional(), - }), -); - // Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_source/models.py export const resolvedMediaSchema = z.object({ url: z.string(), @@ -1458,10 +1430,3 @@ export type ExtendedEntity = z.infer; export const entityListSchema = entitySchema.array(); export type EntityList = z.infer; - -// Generic recording segment type (inspired by Frigate recording segments). -export interface RecordingSegment { - start_time: number; - end_time: number; - id: string; -} diff --git a/src/utils/ha/browse-media.ts b/src/utils/ha/browse-media.ts deleted file mode 100644 index b78de684..00000000 --- a/src/utils/ha/browse-media.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { HomeAssistant } from 'custom-card-helpers'; -import { homeAssistantWSRequest } from '.'; -import { FrigateEvent, FrigateRecording } from '../../camera-manager/frigate/types'; -import { dispatchErrorMessageEvent } from '../../components/message.js'; -import { localize } from '../../localize/localize.js'; -import { - BrowseMediaQueryParameters, - CameraConfig, - ClipsOrSnapshots, - FrigateBrowseMediaSource, - frigateBrowseMediaSourceSchema, - MEDIA_CLASS_PLAYLIST, - MEDIA_CLASS_VIDEO, - MEDIA_TYPE_PLAYLIST, - MEDIA_TYPE_VIDEO, -} from '../../types.js'; -import { getAllDependentCameras } from '../camera.js'; - -/** - * Return the Frigate event_id given a FrigateBrowseMediaSource object. - * @param media The event to get the id from. - * @returns The `event_id` or `null` if not successfully parsed. - */ -export const getEventID = (media: FrigateBrowseMediaSource): string | null => { - return media.frigate?.event?.id ?? null; -}; - -/** - * Return the event start time given a FrigateBrowseMediaSource object. - * @param browseMedia The media object to get the start time from. - * @returns The start time in unix/epoch time, or null if it cannot be determined. - */ -export const getEventStartTime = (media: FrigateBrowseMediaSource): number | null => { - return media.frigate?.event?.start_time ?? null; -}; - -/** - * Determine if a FrigateBrowseMediaSource object is truly a media item (vs a folder). - * @param media The media object. - * @returns `true` if it's truly a media item, `false` otherwise. - */ -export const isTrueMedia = (media?: FrigateBrowseMediaSource): boolean => { - return !!media && !media.can_expand; -}; - -/** - * From a FrigateBrowseMediaSource item extract the first true media item from the - * children (i.e. a clip/snapshot, not a folder). - * @param media The media object with children. - * @returns The first true media item found. - */ -export const getFirstTrueMediaChildIndex = ( - media: FrigateBrowseMediaSource | null, -): number | null => { - if (!media || !media.children) { - return null; - } - const index = media.children.findIndex((child) => isTrueMedia(child)); - return index >= 0 ? index : null; -}; - -/** - * Browse Frigate media with a media content id. May throw. - * @param hass The HomeAssistant object. - * @param media_content_id The media content id to browse. - * @returns A FrigateBrowseMediaSource object or null on malformed. - */ -const browseMedia = async ( - hass: HomeAssistant, - media_content_id: string, -): Promise => { - const request = { - type: 'media_source/browse_media', - media_content_id: media_content_id, - }; - return await homeAssistantWSRequest(hass, frigateBrowseMediaSourceSchema, request); -}; - -/** - * Browse Frigate media with a media query. May throw. - * @param hass The HomeAssistant object. - * @param params The search parameters to use to search for media. - * @returns A FrigateBrowseMediaSource object or null on malformed. - */ -const browseMediaQuery = async ( - hass: HomeAssistant, - params: BrowseMediaQueryParameters, -): Promise => { - const result = await browseMedia( - hass, - // Defined in: - // https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py - [ - 'media-source://frigate', - params.clientId, - 'event-search', - params.mediaType, - - // If the name field ends in '.all' the integration will return up to 10K events. - params.unlimited ? '.all' : '', - params.after ? String(Math.floor(params.after)) : '', - params.before ? String(Math.ceil(params.before)) : '', - params.cameraName, - params.label, - params.zone, - ].join('/'), - ); - // If a cameraID was specified, imprint each child with that id for - // traceability. - if (params.cameraID) { - result.children?.forEach((child: FrigateBrowseMediaSource) => { - (child.frigate ??= {}).cameraID = params.cameraID; - }); - } - return result; -}; - -/** - * Browse multiple Frigate media queries. May throw. - * @param hass The HomeAssistant object. - * @param params An array of search parameters to use to search for media. - * @returns A map of FrigateBrowseMediaSource object or null on malformed. - */ -export const multipleBrowseMediaQuery = async ( - hass: HomeAssistant, - params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[], -): Promise> => { - params = Array.isArray(params) ? params : [params]; - const output: Map = new Map(); - await Promise.all( - params.map(async (param: BrowseMediaQueryParameters): Promise => { - output.set(param, await browseMediaQuery(hass, param)); - }), - ); - return output; -}; - -/** - * Browse multiple Frigate media queries, then merged them. May throw. - * @param hass The HomeAssistant object. - * @param params An array of search parameters to use to search for media. - * @returns A single FrigateBrowseMediaSource object or null on malformed. - */ -export const multipleBrowseMediaQueryMerged = async ( - hass: HomeAssistant, - params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[], -): Promise => { - return mergeFrigateBrowseMediaSources(await multipleBrowseMediaQuery(hass, params)); -}; - -/** - * Merge multiple FrigateBrowseMediaSource into a single. Note that this may - * use information from the query to differentiate results that may otherwise - * be identical. - * @param input A map of query -> result. - * @returns A single FrigateBrowseMediaSource object. - */ -export const mergeFrigateBrowseMediaSources = async ( - input: Map, -): Promise => { - const children: FrigateBrowseMediaSource[] = []; - - for (const [query, result] of input.entries()) { - for (const child of result.children || []) { - if (isTrueMedia(child)) { - children.push(child); - } else { - // If there are multiple inputs, separate the folder names with the - // query title (if available). - if (query.title && input.size > 1) { - children.push({ ...child, title: `[${query.title}] ${child.title}` }); - } else { - children.push(child); - } - } - } - } - - return createEventParentForChildren( - 'Merged events', - children - // TODO: Restore sort or remove: children.sort(sortYoungestToOldest), - ); -}; - -/** - * Get the parameters to search for media. - * @returns A BrowseMediaQueryParameters object. - */ -export const getBrowseMediaQueryParameters = ( - _hass: HomeAssistant, - cameraID: string, - cameraConfig?: CameraConfig, - overrides?: Partial, -): BrowseMediaQueryParameters | null => { - if (!cameraConfig || !cameraConfig.frigate.camera_name) { - return null; - } - return { - clientId: cameraConfig.frigate.client_id, - cameraName: cameraConfig.frigate.camera_name, - label: cameraConfig.frigate.label, - zone: cameraConfig.frigate.zone, - title: '', // TODO: Replace: getCameraTitle(hass, cameraConfig), - cameraID: cameraID, - ...overrides, - }; -}; - -/** - * Apply overrides to multiple query parameters. - * @param parameters An array of query parameters. - * @param overrides The overrides to apply. - * @returns The override query parameters. - */ -export const overrideMultiBrowseMediaQueryParameters = ( - parameters: BrowseMediaQueryParameters[], - overrides: Partial, -): BrowseMediaQueryParameters[] => { - const output: BrowseMediaQueryParameters[] = []; - parameters.forEach((param) => { - output.push({ ...param, ...overrides }); - }); - return output; -}; - -/** - * Get BrowseMediaQueryParameters for a camera (including its dependencies). - * @param hass Home Assistant object. - * @param cameras Cameras map. - * @param camera Name of the current camera. - * @param mediaType Optional media type to include in the parameters. - * @returns An array of query parameters. - */ -export const getFullDependentBrowseMediaQueryParameters = ( - hass: HomeAssistant, - cameras: Map, - camera: string, - mediaType?: ClipsOrSnapshots, -): BrowseMediaQueryParameters[] | null => { - const cameraIDs = getAllDependentCameras(cameras, camera); - const params: BrowseMediaQueryParameters[] = []; - for (const cameraID of cameraIDs) { - const param = getBrowseMediaQueryParameters( - hass, - cameraID, - cameras.get(cameraID), - mediaType ? { mediaType: mediaType } : {}, - ); - if (param) { - params.push(param); - } - } - return params.length ? params : null; -}; - -/** - * Get BrowseMediaQueryParameters for a camera (including its dependencies) or dispatch an error. - * @param element The element from which to dispatch the error. - * @param hass Home Assistant object. - * @param cameras Cameras map. - * @param camera Name of the current camera. - * @param mediaType Optional media type to include in the parameters. - * @returns An array of query parameters. - */ -export const getFullDependentBrowseMediaQueryParametersOrDispatchError = ( - element: HTMLElement, - hass: HomeAssistant, - cameras: Map, - camera: string, - mediaType?: ClipsOrSnapshots, -): BrowseMediaQueryParameters[] | null => { - const params = getFullDependentBrowseMediaQueryParameters( - hass, - cameras, - camera, - mediaType, - ); - if (!params) { - dispatchErrorMessageEvent(element, localize('error.no_camera_name'), { - context: cameras.get(camera), - }); - return null; - } - return params; -}; - -/** - * Given an array of media children, create a parent for them. - * @param title The title to use for the parent. - * @param children The children media items. - * @returns A single parent containing the children. - */ -export const createEventParentForChildren = ( - title: string, - children: FrigateBrowseMediaSource[], -): FrigateBrowseMediaSource => { - return { - title: title, - media_class: MEDIA_CLASS_PLAYLIST, - media_content_type: MEDIA_TYPE_PLAYLIST, - media_content_id: '', - can_play: false, - can_expand: true, - children_media_class: MEDIA_CLASS_PLAYLIST, - thumbnail: null, - children: children, - }; -}; - -/** - * Given a media video child with a given media_content_id. - * @param title The title to use for the child. - * @param mediaContentID The media content id to use for the child. - * @param children The children media items. - * @returns A single parent containing the children. - */ -export const createChild = ( - title: string, - mediaContentID: string, - options?: { - thumbnail?: string; - recording?: FrigateRecording; - event?: FrigateEvent; - cameraID?: string; - }, -): FrigateBrowseMediaSource => { - const result: FrigateBrowseMediaSource = { - title: title, - media_class: MEDIA_CLASS_VIDEO, - media_content_type: MEDIA_TYPE_VIDEO, - media_content_id: mediaContentID, - can_play: true, - can_expand: false, - thumbnail: options?.thumbnail ?? null, - children: null, - }; - if (options?.recording || options?.cameraID || options?.event) { - result.frigate = {}; - if (options?.event) { - result.frigate.event = options.event; - } - if (options?.recording) { - result.frigate.recording = options.recording; - } - if (options?.cameraID) { - result.frigate.cameraID = options.cameraID; - } - } - return result; -}; - -// TODO Remove or fix this if not needed post gallery. -// /** -// * 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: FrigateBrowseMediaSource, -// b: FrigateBrowseMediaSource, -// ): number => { -// 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)) { -// return 1; -// } - -// if (!b_source || (a_source && b_source.start_time < a_source.start_time)) { -// return -1; -// } -// return 0; -// }; diff --git a/src/view/view.ts b/src/view/view.ts index 679f3900..36244521 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -5,10 +5,6 @@ // Gallery: // - TODO: Filter panel expands from right can occasionally 'stick' open. -// Hard: -// - TODO: Remove FrigateBrowseMediaSource if not necessary (post-gallery). -// - TODO: Remove browse-media.ts TODOs. - import { ViewContext } from 'view'; import { FrigateCardUserSpecifiedView, From ac295327ece06571a13447fb34f685cb5d96b417 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 22:05:09 -0800 Subject: [PATCH 64/74] Correctly calculate cameraIDs to display. --- src/components/surround.ts | 17 ++++++++++++++++- src/components/timeline-core.ts | 19 +++++++++---------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/components/surround.ts b/src/components/surround.ts index 1044c972..9fd63dd3 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -21,6 +21,7 @@ import { View } from '../view/view.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; import './surround-basic.js'; import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view'; +import { getAllDependentCameras } from '../utils/camera.js'; interface ThumbnailViewContext { // Whether or not to fetch thumbnails. @@ -124,12 +125,25 @@ export class FrigateCardSurround extends LitElement { } } + protected _getCameraIDsForView(): Set | null { + if (!this.view || !this.cameras) { + return null; + } + if (this.view?.is('live')) { + return getAllDependentCameras(this.cameras, this.view.camera); + } + if (this.view.isViewerView()) { + return new Set(this.view.queryResults?.getResults()?.map((media) => media.getCameraID())); + } + return null; + } + /** * Master render method. * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.thumbnailConfig) { + if (!this.hass || !this.view || !this.thumbnailConfig || !this.cameras) { return; } @@ -191,6 +205,7 @@ export class FrigateCardSurround extends LitElement { .hass=${this.hass} .view=${this.view} .cameras=${this.cameras} + .cameraIDs=${this._getCameraIDsForView() ?? undefined} .mini=${true} .timelineConfig=${this.timelineConfig} .thumbnailDetails=${this.thumbnailConfig?.show_details} diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index fa5dd832..c5f38630 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -179,11 +179,16 @@ export class FrigateCardTimelineCore extends LitElement { @property({ attribute: false }) public thumbnailSize?: number; - // Whether or not this is a mini-timeline for a different view (e.g. media - // viewer). + // Whether or not this is a mini-timeline (in mini-mode the component takes a + // supportive role for other views). @property({ attribute: true, type: Boolean, reflect: true }) public mini = false; + // Which cameraIDs to include in the timeline. If not specified, all cameraIDs + // are shown. + @property({ attribute: false }) + public cameraIDs?: Set; + @property({ attribute: false }) public cameraManager?: CameraManager; @@ -282,10 +287,7 @@ export class FrigateCardTimelineCore extends LitElement { * @returns A set of camera ids (may be empty). */ protected _getTimelineCameraIDs(): Set { - if (!this.mini || !this.cameras) { - return this._getAllCameraIDs(); - } - return getAllDependentCameras(this.cameras, this.view?.camera); + return this.cameraIDs ?? this._getAllCameraIDs(); } /** @@ -1068,10 +1070,7 @@ export class FrigateCardTimelineCore extends LitElement { this._refTimeline.value && options && this.timelineConfig && - (changedProperties.has('timelineConfig') || - (this.mini && - changedProperties.has('view') && - this.view?.camera !== changedProperties.get('view').camera)) + (changedProperties.has('timelineConfig') || changedProperties.has('cameraIDs')) ) { if (this._timeline) { this._destroy(); From 93e8a8e71bb13cd9414ffb602ba10a43d24b914d Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 30 Jan 2023 19:53:29 -0800 Subject: [PATCH 65/74] Avoid resetting timeline when cameraIDs change. --- src/components/surround.ts | 23 ++++++++++++++++++++--- src/components/timeline-core.ts | 7 +++---- src/view/view.ts | 10 ++++------ 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/components/surround.ts b/src/components/surround.ts index 9fd63dd3..6affa9b4 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -61,6 +61,8 @@ export class FrigateCardSurround extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; + protected _cameraIDsForTimeline?: Set; + /** * Fetch thumbnail media when a target is not specified in the view (e.g. for * the live view). @@ -113,6 +115,16 @@ export class FrigateCardSurround extends LitElement { import('./timeline.js'); } + // Only reset the timeline cameraIDs when the media materially changes (and + // not on every view change, since the view will change frequently when the + // user is scrubbing video). + if ( + changedProperties.has('view') && + View.isMediaChange(changedProperties.get('view'), this.view) + ) { + this._cameraIDsForTimeline = this._getCameraIDsForTimeline() ?? undefined; + } + // Once the component will certainly update, dispatch a media request. Only // do so if properties relevant to the request have changed (as per their // hasChanged). @@ -125,7 +137,7 @@ export class FrigateCardSurround extends LitElement { } } - protected _getCameraIDsForView(): Set | null { + protected _getCameraIDsForTimeline(): Set | null { if (!this.view || !this.cameras) { return null; } @@ -133,7 +145,12 @@ export class FrigateCardSurround extends LitElement { return getAllDependentCameras(this.cameras, this.view.camera); } if (this.view.isViewerView()) { - return new Set(this.view.queryResults?.getResults()?.map((media) => media.getCameraID())); + return new Set( + this.view.query + ?.getQueries() + ?.map((query) => [...query.cameraIDs]) + .flat(), + ); } return null; } @@ -205,7 +222,7 @@ export class FrigateCardSurround extends LitElement { .hass=${this.hass} .view=${this.view} .cameras=${this.cameras} - .cameraIDs=${this._getCameraIDsForView() ?? undefined} + .cameraIDs=${this._cameraIDsForTimeline} .mini=${true} .timelineConfig=${this.timelineConfig} .thumbnailDetails=${this.thumbnailConfig?.show_details} diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index c5f38630..e15a21c5 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -42,7 +42,6 @@ import { dispatchFrigateCardEvent, isHoverableDevice, } from '../utils/basic'; -import { getAllDependentCameras } from '../utils/camera.js'; import { createViewForEvents, @@ -186,7 +185,7 @@ export class FrigateCardTimelineCore extends LitElement { // Which cameraIDs to include in the timeline. If not specified, all cameraIDs // are shown. - @property({ attribute: false }) + @property({ attribute: false, hasChanged: contentsChanged }) public cameraIDs?: Set; @property({ attribute: false }) @@ -343,8 +342,7 @@ export class FrigateCardTimelineCore extends LitElement { const targetBarOn = !this._locked || - (!this.view?.is('timeline') && - this._timeline.getSelection().some((id) => { + (this.mini && this._timeline.getSelection().some((id) => { const item = this._timelineSource?.dataset?.get(id); return ( item && @@ -1049,6 +1047,7 @@ export class FrigateCardTimelineCore extends LitElement { protected _destroy(): void { this._timeline?.destroy(); this._timeline = undefined; + this._targetBarVisible = false; } /** diff --git a/src/view/view.ts b/src/view/view.ts index 36244521..48d86d12 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,7 +1,7 @@ // Minor / later: // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript -// - TODO: View a media in the gallery from September, then notice timeline missing the item. - +// - TODO: Changing camera in live view, timeline stays the same. +// - TODO: Timeline initial load, then drag, appears to reset? // Gallery: // - TODO: Filter panel expands from right can occasionally 'stick' open. @@ -82,10 +82,8 @@ export class View { // When in the live view, the queryResults contain the events that // happened in the past -- not reflective of the actual live media viewer // the user is seeing. - (curr.view !== 'live' && - (prev.queryResults !== curr.queryResults || - prev.queryResults?.getSelectedResult() !== - curr.queryResults?.getSelectedResult())) + // TODO: Put stuff here that can be used in timeline reset code + (curr.view !== 'live' && (prev.queryResults !== curr.queryResults || prev.queryResults?.getSelectedResult() !== curr.queryResults?.getSelectedResult())) ); } From f9f2a494c6fa261469dc1bf2b6b9a882349886ca Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 30 Jan 2023 20:21:12 -0800 Subject: [PATCH 66/74] Avoid dispatching range changed events if there's nothing to be gained. --- src/components/timeline-core.ts | 19 ++++++++++++++----- src/utils/basic.ts | 11 ++++++++++- src/view/media-queries-results.ts | 23 +++++++++++++++++++++++ src/view/view.ts | 1 - 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index e15a21c5..a54153e4 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -595,13 +595,22 @@ export class FrigateCardTimelineCore extends LitElement { this.view && !MediaQueriesClassifier.areRecordingQueries(this.view.query) ) { - ( - await this._createViewWithEventMediaQuery( + const newView = await this._createViewWithEventMediaQuery( this._createEventMediaQuerys({ window: this._timeline.getWindow() }), ) - ) - ?.mergeInContext(this._setWindowInContext()) - ?.dispatchChangeEvent(this); + + // Specifically avoid dispatching new results on range change unless there + // is something to be gained by doing so. Example usecase: On initial view + // load in mini timeline mode, the first 50 events are fetched -- the + // first drag of the timeline should not dispatch new results unless + // something is actually useful (as otherwise it creates a visible + // 'flicker' for the user as the viewer reloads all the media). + const newResults = newView?.queryResults; + if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) { + newView + ?.mergeInContext(this._setWindowInContext()) + ?.dispatchChangeEvent(this); + } } } diff --git a/src/utils/basic.ts b/src/utils/basic.ts index e7cabecc..dc89e6d8 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -181,4 +181,13 @@ export const dayToDate = (day: string): Date => { // Must provide the hour:minute:second on parsing or Javascript will assume // *UTC* midnight. return new Date(`${day}T00:00:00`); -} \ No newline at end of file +}; + +export const isSuperset = (superset: Set, subset: Set) => { + for (const item of subset) { + if (!superset.has(item)) { + return false; + } + } + return true; +}; diff --git a/src/view/media-queries-results.ts b/src/view/media-queries-results.ts index b6de37cd..318ae794 100644 --- a/src/view/media-queries-results.ts +++ b/src/view/media-queries-results.ts @@ -1,4 +1,5 @@ import clone from 'lodash-es/clone.js'; +import { isSuperset } from '../utils/basic.js'; import { ViewMedia } from './media.js'; export class MediaQueriesResults { @@ -21,6 +22,28 @@ export class MediaQueriesResults { return clone(this); } + public isSupersetOf(that: MediaQueriesResults): boolean { + if (!this._results || !that._results) { + return false; + } + + const thisMediaIDs = new Set(this._results.map((media) => media.getID())); + const thatMediaIDs = new Set(that._results.map((media) => media.getID())); + + if ( + !thisMediaIDs || + !thatMediaIDs || + // If either media sets contain a null identifier (i.e. a media item with + // no ID) we must assume this is not a subset as multiple media items may + // reduce to the same null identifier above. + thisMediaIDs.has(null) || + thatMediaIDs.has(null) + ) { + return false; + } + return isSuperset(thisMediaIDs, thatMediaIDs); + } + public getResults(): ViewMedia[] | null { return this._results; } diff --git a/src/view/view.ts b/src/view/view.ts index 48d86d12..e9acae99 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,7 +1,6 @@ // Minor / later: // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript // - TODO: Changing camera in live view, timeline stays the same. -// - TODO: Timeline initial load, then drag, appears to reset? // Gallery: // - TODO: Filter panel expands from right can occasionally 'stick' open. From d1755eedcbc7176094909a7b784a09c0b94d54dc Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 30 Jan 2023 20:30:26 -0800 Subject: [PATCH 67/74] Reset the timeline data source when the cameraIDs change. --- src/components/timeline-core.ts | 16 ++++++++-------- src/utils/timeline-source.ts | 13 ++----------- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index a54153e4..3e75ae9e 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -186,7 +186,7 @@ export class FrigateCardTimelineCore extends LitElement { // Which cameraIDs to include in the timeline. If not specified, all cameraIDs // are shown. @property({ attribute: false, hasChanged: contentsChanged }) - public cameraIDs?: Set; + public cameraIDs?: Set; @property({ attribute: false }) public cameraManager?: CameraManager; @@ -342,7 +342,8 @@ export class FrigateCardTimelineCore extends LitElement { const targetBarOn = !this._locked || - (this.mini && this._timeline.getSelection().some((id) => { + (this.mini && + this._timeline.getSelection().some((id) => { const item = this._timelineSource?.dataset?.get(id); return ( item && @@ -596,8 +597,8 @@ export class FrigateCardTimelineCore extends LitElement { !MediaQueriesClassifier.areRecordingQueries(this.view.query) ) { const newView = await this._createViewWithEventMediaQuery( - this._createEventMediaQuerys({ window: this._timeline.getWindow() }), - ) + this._createEventMediaQuerys({ window: this._timeline.getWindow() }), + ); // Specifically avoid dispatching new results on range change unless there // is something to be gained by doing so. Example usecase: On initial view @@ -607,9 +608,7 @@ export class FrigateCardTimelineCore extends LitElement { // 'flicker' for the user as the viewer reloads all the media). const newResults = newView?.queryResults; if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) { - newView - ?.mergeInContext(this._setWindowInContext()) - ?.dispatchChangeEvent(this); + newView?.mergeInContext(this._setWindowInContext())?.dispatchChangeEvent(this); } } } @@ -1036,7 +1035,8 @@ export class FrigateCardTimelineCore extends LitElement { if ( changedProps.has('cameraManager') || changedProps.has('cameras') || - changedProps.has('timelineConfig') + changedProps.has('timelineConfig') || + changedProps.has('cameraIDs') ) { if (this.cameraManager && this.cameras && this.timelineConfig) { this._timelineSource = new TimelineDataSource( diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index de6d3b02..ab84cf00 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -3,9 +3,9 @@ import add from 'date-fns/add'; import sub from 'date-fns/sub'; import { DataSet } from 'vis-data'; import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext'; -import { ClipsOrSnapshotsOrAll, RecordingSegment } from '../types'; +import { ClipsOrSnapshotsOrAll } from '../types'; import { CameraManager } from '../camera-manager/manager'; -import { EventQuery } from '../camera-manager/types'; +import { EventQuery, RecordingSegment } from '../camera-manager/types'; import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util'; import { ViewMedia } from '../view/media'; import { @@ -64,15 +64,6 @@ export class TimelineDataSource { return this._dataset; } - public clearEvents(): void { - this._eventRanges.clear(); - 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 From f3149d2ff358f52841a08bc830df96c7d866412d Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 30 Jan 2023 20:37:40 -0800 Subject: [PATCH 68/74] Mark TODO as fixed. --- src/view/view.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/view/view.ts b/src/view/view.ts index e9acae99..1e4249ae 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,6 +1,5 @@ // Minor / later: // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript -// - TODO: Changing camera in live view, timeline stays the same. // Gallery: // - TODO: Filter panel expands from right can occasionally 'stick' open. From a9074bd44ef6ead964ec21153de74ccd4c3aaef4 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 30 Jan 2023 21:11:21 -0800 Subject: [PATCH 69/74] Results of running `ts-prune`. --- package.json | 4 +- src/action-handler-directive.ts | 2 +- src/camera-manager/cache.ts | 7 +- src/camera-manager/frigate/types.ts | 2 +- src/camera-manager/manager.ts | 4 +- src/camera-manager/types.ts | 4 +- src/card-condition.ts | 2 +- src/card.ts | 2 +- src/components/embla-plugins/automedia.ts | 2 +- src/components/embla-plugins/lazyload.ts | 8 +-- src/components/media-carousel.ts | 4 +- src/components/media-filter-core.ts | 2 +- src/config-mgmt.ts | 4 +- src/const.ts | 15 ++--- src/types.ts | 78 +++-------------------- src/utils/debug.ts | 1 + src/utils/ha/device-registry.ts | 2 +- src/utils/ha/index.ts | 2 +- src/utils/ha/update.ts | 8 +-- src/utils/icons/alarm-panel-icon.ts | 19 ------ src/utils/icons/cover-icon.ts | 24 ------- src/utils/icons/sensor-icon.ts | 2 +- src/utils/media.ts | 2 +- src/utils/thumbnail.ts | 2 +- src/view/media-queries.ts | 2 +- src/view/view.ts | 6 +- yarn.lock | 67 ++++++++++++++++++- 27 files changed, 116 insertions(+), 161 deletions(-) diff --git a/package.json b/package.json index bb2f2aaf..0d78b3ca 100644 --- a/package.json +++ b/package.json @@ -70,12 +70,14 @@ "rollup-plugin-typescript2": "^0.33.0", "rollup-plugin-visualizer": "^5.8.2", "sass": "^1.54.9", + "ts-prune": "^0.10.3", "typescript": "^4.8.3" }, "scripts": { "start": "rollup -c --watch", "build": "yarn run lint && yarn run rollup", "lint": "eslint 'src/**/*.ts'", - "rollup": "rollup -c" + "rollup": "rollup -c", + "prune": "ts-prune" } } diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index b1e52948..d62eb71e 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -165,7 +165,7 @@ const getActionHandler = (): ActionHandler => { return actionhandler as ActionHandler; }; -export const actionHandlerBind = ( +const actionHandlerBind = ( element: ActionHandlerElement, options?: FrigateCardActionHandlerOptions, ): void => { diff --git a/src/camera-manager/cache.ts b/src/camera-manager/cache.ts index a0d997e7..291810da 100644 --- a/src/camera-manager/cache.ts +++ b/src/camera-manager/cache.ts @@ -1,9 +1,8 @@ import isEqual from 'lodash-es/isEqual'; import orderBy from 'lodash-es/orderBy'; import sortedUniqBy from 'lodash-es/sortedUniqBy'; -import { RecordingSegment } from '../types'; import { DateRange, MemoryRangeSet } from './range'; -import { DataQuery, QueryResults } from './types'; +import { DataQuery, QueryResults, RecordingSegment } from './types'; interface RequestCacheItem { request: Request; @@ -17,7 +16,7 @@ interface CameraManagerCache { set(request: Request, response: Response, expiry?: Date): void; } -export class MemoryRequestCache +class MemoryRequestCache implements CameraManagerCache { protected _data: RequestCacheItem[] = []; @@ -62,7 +61,7 @@ export class MemoryRequestCache export class RequestCache extends MemoryRequestCache {} -export class MemoryRangedCache { +class MemoryRangedCache { protected _ranges: MemoryRangeSet = new MemoryRangeSet(); protected _data: Data[] = []; protected _timeFunc: (data: Data) => number; diff --git a/src/camera-manager/frigate/types.ts b/src/camera-manager/frigate/types.ts index 41a0cd6b..a8184d3d 100644 --- a/src/camera-manager/frigate/types.ts +++ b/src/camera-manager/frigate/types.ts @@ -5,7 +5,7 @@ const dayStringToDate = (arg: unknown): Date | unknown => { return typeof arg === 'string' ? dayToDate(arg) : arg; }; -export const eventSchema = z.object({ +const eventSchema = z.object({ camera: z.string(), end_time: z.number().nullable(), false_positive: z.boolean().nullable(), diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 272e23c0..c3e0a11c 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -37,7 +37,7 @@ import sum from 'lodash-es/sum'; import add from 'date-fns/add'; import { log } from '../utils/debug.js'; -export class QueryClassifier { +class QueryClassifier { public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery { return query.type === QueryType.Event; } @@ -53,7 +53,7 @@ export class QueryClassifier { } } -export class QueryResultClassifier { +class QueryResultClassifier { public static isEventQueryResult( queryResults: QueryResults, ): queryResults is EventQueryResults { diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index d2d9b5ba..a736bec9 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -26,12 +26,12 @@ export interface DataQuery { } export type PartialDataQuery = Partial; -export interface TimeBasedDataQuery { +interface TimeBasedDataQuery { start: Date; end: Date; } -export interface LimitedDataQuery { +interface LimitedDataQuery { limit: number; } diff --git a/src/card-condition.ts b/src/card-condition.ts index 64a7bdc7..cdbd6113 100644 --- a/src/card-condition.ts +++ b/src/card-condition.ts @@ -21,7 +21,7 @@ class ConditionStateRequestEvent extends Event { public conditionState?: ConditionState; } -export function evaluateCondition( +function evaluateCondition( condition?: Readonly, state?: Readonly, ): boolean { diff --git a/src/card.ts b/src/card.ts index 91d236a1..20458682 100644 --- a/src/card.ts +++ b/src/card.ts @@ -149,7 +149,7 @@ type InitializedType = 'initialized' | 'initializing'; * Main FrigateCard class. */ @customElement('frigate-card') -export class FrigateCard extends LitElement { +class FrigateCard extends LitElement { @state() protected _hass?: ExtendedHomeAssistant; diff --git a/src/components/embla-plugins/automedia.ts b/src/components/embla-plugins/automedia.ts index 078eac90..a4f3dbd6 100644 --- a/src/components/embla-plugins/automedia.ts +++ b/src/components/embla-plugins/automedia.ts @@ -27,7 +27,7 @@ const defaultOptions: OptionsType = { breakpoints: {}, }; -export type AutoMediaOptionsType = Partial +type AutoMediaOptionsType = Partial export type AutoMediaType = CreatePluginType< { diff --git a/src/components/embla-plugins/lazyload.ts b/src/components/embla-plugins/lazyload.ts index 3fbdb9f4..311ee6cc 100644 --- a/src/components/embla-plugins/lazyload.ts +++ b/src/components/embla-plugins/lazyload.ts @@ -3,7 +3,7 @@ import { CreatePluginType } from 'embla-carousel/components/Plugins'; import EmblaCarousel, { EmblaCarouselType, EmblaEventType } from 'embla-carousel'; import { LazyUnloadCondition } from '../../types'; -export type OptionsType = CreateOptionsType<{ +type OptionsType = CreateOptionsType<{ // Number of slides to lazyload left/right of selected (0 == only selected // slide). lazyLoadCount?: number; @@ -13,15 +13,15 @@ export type OptionsType = CreateOptionsType<{ lazyUnloadCallback?: (index: number, slide: HTMLElement) => void; }>; -export const defaultOptions: OptionsType = { +const defaultOptions: OptionsType = { active: true, breakpoints: {}, lazyLoadCount: 0, }; -export type LazyloadOptionsType = Partial; +type LazyloadOptionsType = Partial; -export type LazyloadType = CreatePluginType< +type LazyloadType = CreatePluginType< { hasLazyloaded(index: number): boolean; }, diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts index 504acad6..253b97f7 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -28,12 +28,12 @@ const getEmptyImageSrc = (width: number, height: number) => `data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}"%3E%3C/svg%3E`; export const IMG_EMPTY = getEmptyImageSrc(16, 9); -export interface CarouselMediaLoadedInfo { +interface CarouselMediaLoadedInfo { slide: number; mediaLoadedInfo: MediaLoadedInfo; } -export interface CarouselMediaUnloadedInfo { +interface CarouselMediaUnloadedInfo { slide: number; } diff --git a/src/components/media-filter-core.ts b/src/components/media-filter-core.ts index 365b560d..c356b70c 100644 --- a/src/components/media-filter-core.ts +++ b/src/components/media-filter-core.ts @@ -66,7 +66,7 @@ export type MediaFilterControls = { }; @customElement('frigate-card-media-filter-core') -export class FrigateCardMediaFilterCore extends LitElement { +class FrigateCardMediaFilterCore extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; diff --git a/src/config-mgmt.ts b/src/config-mgmt.ts index ab2ebd21..f6a7f16e 100644 --- a/src/config-mgmt.ts +++ b/src/config-mgmt.ts @@ -117,7 +117,7 @@ export const isConfigUpgradeable = function (obj: RawFrigateCardConfig): boolean * @param obj Configuration object. * @returns `true` if the configuration was modified. */ -export const trimConfig = function (obj: RawFrigateCardConfig): boolean { +const trimConfig = function (obj: RawFrigateCardConfig): boolean { const keys = Object.keys(obj); let modified = false; for (let i = 0; i < keys.length; i++) { @@ -221,7 +221,7 @@ const deleteProperty = function (_value: unknown): number | null | undefined { * @param transform An optional transform for the value. * @returns `true` if the configuration was modified. */ -export const moveConfigValue = ( +const moveConfigValue = ( obj: RawFrigateCardConfig, oldPath: string, newPath: string, diff --git a/src/const.ts b/src/const.ts index afb5c279..75ee787b 100644 --- a/src/const.ts +++ b/src/const.ts @@ -35,7 +35,7 @@ export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES = export const CONF_ELEMENTS = 'elements' as const; -export const CONF_VIEW = 'view' as const; +const CONF_VIEW = 'view' as const; export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const; export const CONF_VIEW_DARK_MODE = `${CONF_VIEW}.dark_mode` as const; export const CONF_VIEW_DEFAULT = `${CONF_VIEW}.default` as const; @@ -53,7 +53,7 @@ export const CONF_VIEW_SCAN_UNTRIGGER_RESET = export const CONF_VIEW_SCAN_UNTRIGGER_SECONDS = `${CONF_VIEW_SCAN}.untrigger_seconds` as const; -export const CONF_EVENT_GALLERY = 'event_gallery' as const; +const CONF_EVENT_GALLERY = 'event_gallery' as const; export const CONF_EVENT_GALLERY_CONTROLS_FILTER_MODE = `${CONF_EVENT_GALLERY}.controls.filter.mode` as const; export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS = @@ -111,7 +111,7 @@ export const CONF_MEDIA_VIEWER_LAYOUT_POSITION_X = export const CONF_MEDIA_VIEWER_LAYOUT_POSITION_Y = `${CONF_MEDIA_VIEWER}.layout.position.y` as const; -export const CONF_LIVE = 'live' as const; +const CONF_LIVE = 'live' as const; export const CONF_LIVE_AUTO_PLAY = `${CONF_LIVE}.auto_play` as const; export const CONF_LIVE_AUTO_PAUSE = `${CONF_LIVE}.auto_pause` as const; export const CONF_LIVE_AUTO_MUTE = `${CONF_LIVE}.auto_mute` as const; @@ -151,7 +151,6 @@ export const CONF_LIVE_LAYOUT_FIT = `${CONF_LIVE}.layout.fit` as const; export const CONF_LIVE_LAYOUT_POSITION_X = `${CONF_LIVE}.layout.position.x` as const; export const CONF_LIVE_LAYOUT_POSITION_Y = `${CONF_LIVE}.layout.position.y` as const; export const CONF_LIVE_DRAGGABLE = `${CONF_LIVE}.draggable` as const; -export const CONF_LIVE_JSMPEG = `${CONF_LIVE}.jsmpeg` as const; export const CONF_LIVE_LAZY_LOAD = `${CONF_LIVE}.lazy_load` as const; export const CONF_LIVE_LAZY_UNLOAD = `${CONF_LIVE}.lazy_unload` as const; export const CONF_LIVE_PRELOAD = `${CONF_LIVE}.preload` as const; @@ -160,7 +159,7 @@ export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD = `${CONF_LIVE}.show_image_during_load` as const; export const CONF_LIVE_WEBRTC_CARD = `${CONF_LIVE}.webrtc_card` as const; -export const CONF_IMAGE = 'image' as const; +const CONF_IMAGE = 'image' as const; export const CONF_IMAGE_LAYOUT_FIT = `${CONF_IMAGE}.layout.fit` as const; export const CONF_IMAGE_LAYOUT_POSITION_X = `${CONF_IMAGE}.layout.position.x` as const; export const CONF_IMAGE_LAYOUT_POSITION_Y = `${CONF_IMAGE}.layout.position.y` as const; @@ -168,7 +167,7 @@ export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const; export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const; export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const; -export const CONF_TIMELINE = 'timeline' as const; +const CONF_TIMELINE = 'timeline' as const; export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const; export const CONF_TIMELINE_CLUSTERING_THRESHOLD = `${CONF_TIMELINE}.clustering_threshold` as const; @@ -205,14 +204,14 @@ export const CONF_MENU_BUTTONS_MEDIA_PLAYER = export const CONF_MENU_BUTTONS_SNAPSHOTS = `${CONF_MENU}.buttons.snapshots` as const; export const CONF_MENU_BUTTONS_TIMELINE = `${CONF_MENU}.buttons.timeline` as const; -export const CONF_DIMENSIONS = 'dimensions' as const; +const CONF_DIMENSIONS = 'dimensions' as const; export const CONF_DIMENSIONS_ASPECT_RATIO = `${CONF_DIMENSIONS}.aspect_ratio` as const; export const CONF_DIMENSIONS_ASPECT_RATIO_MODE = `${CONF_DIMENSIONS}.aspect_ratio_mode` as const; export const CONF_OVERRIDES = 'overrides' as const; -export const CONF_PERFORMANCE = 'performance' as const; +const CONF_PERFORMANCE = 'performance' as const; export const CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR = `${CONF_PERFORMANCE}.features.animated_progress_indicator`; export const CONF_PERFORMANCE_PROFILE = `${CONF_PERFORMANCE}.profile`; export const CONF_PERFORMANCE_STYLE_BOX_SHADOW = `${CONF_PERFORMANCE}.style.box_shadow`; diff --git a/src/types.ts b/src/types.ts index 4788a607..9f1d433a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,7 +64,7 @@ const FRIGATE_MENU_STYLES = [ const FRIGATE_MENU_POSITIONS = ['left', 'right', 'top', 'bottom'] as const; const FRIGATE_MENU_ALIGNMENTS = FRIGATE_MENU_POSITIONS; -export const FRIGATE_MENU_PRIORITY_DEFAULT = 50; +const FRIGATE_MENU_PRIORITY_DEFAULT = 50; export const FRIGATE_MENU_PRIORITY_MAX = 100; const LIVE_PROVIDERS = ['auto', 'image', 'ha', 'frigate-jsmpeg', 'webrtc-card'] as const; @@ -131,7 +131,7 @@ const actionBaseSchema = z.object({ // HA accepts either a boolean or a ConfirmationRestrictionConfig object. // `custom-card-helpers` currently only supports the latter. For maximum // compatibility, this card supports what HA supports. -export interface ExtendedConfirmationRestrictionConfig { +interface ExtendedConfirmationRestrictionConfig { confirmation?: boolean | ConfirmationRestrictionConfig; } @@ -380,7 +380,7 @@ const customSchema = z /** * Camera configuration section */ -export const cameraConfigDefault = { +const cameraConfigDefault = { live_provider: 'auto' as const, frigate: { client_id: 'frigate' as const, @@ -460,12 +460,12 @@ const menuBaseSchema = z.object({ icon: z.string().optional(), }); -export const menuIconSchema = menuBaseSchema.merge(iconSchema).extend({ +const menuIconSchema = menuBaseSchema.merge(iconSchema).extend({ type: z.literal('custom:frigate-card-menu-icon'), }); export type MenuIcon = z.infer; -export const menuStateIconSchema = menuBaseSchema +const menuStateIconSchema = menuBaseSchema .merge(stateIconSchema) .extend({ type: z.literal('custom:frigate-card-menu-state-icon'), @@ -573,7 +573,6 @@ const pictureElementSchema = z.union([ conditionalSchema, customSchema, ]); -export type PictureElement = z.infer; const pictureElementsSchema = pictureElementSchema.array().optional(); export type PictureElements = z.infer; @@ -643,7 +642,7 @@ const viewConfigSchema = z * Image view configuration section. */ -export const IMAGE_MODES = ['screensaver', 'camera', 'url'] as const; +const IMAGE_MODES = ['screensaver', 'camera', 'url'] as const; const imageConfigDefault = { mode: 'url' as const, refresh_seconds: 0, @@ -701,8 +700,6 @@ const timelineCoreConfigDefault = { }; const timelineMediaSchema = z.enum(['all', 'clips', 'snapshots']); -export type TimelineMedia = z.infer; - const timelineCoreConfigSchema = z.object({ clustering_threshold: z .number() @@ -1011,9 +1008,6 @@ const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.ex viewerConfigDefault.controls.next_previous.size, ), }); -export type ViewerNextPreviousControlConfig = z.infer< - typeof viewerNextPreviousControlConfigSchema ->; const viewerConfigSchema = z .object({ @@ -1228,7 +1222,7 @@ const debugConfigSchema = z logging: z.boolean().default(debugConfigDefault.logging), }) .default(debugConfigDefault); -export type DebugConfig = z.infer; +type DebugConfig = z.infer; export interface CardWideConfig { performance?: PerformanceConfig; @@ -1294,40 +1288,6 @@ export interface ExtendedHomeAssistant extends HomeAssistant { }; } -export interface BrowseMediaQueryParameters { - // ======================================== - // Parameters used to construct media query - // ======================================== - mediaType?: 'clips' | 'snapshots'; - clientId: string; - cameraName: string; - label?: string; - zone?: string; - before?: number; - after?: number; - unlimited?: boolean; - - // ======================================== - // Parameters used to differentiate results - // ======================================== - // Optional title to be used for separating results when merging multiple - // sets of results. See `mergeFrigateBrowseMediaSources()` . - title?: string; - - // Optional camera-id to which this query is associated. May be used to map - // results to a particular camera within the card. - cameraID?: string; -} - -export interface BrowseRecordingQueryParameters { - clientId: string; - cameraName: string; - year: number; - month: number; - day: number; - hour: number; -} - export interface MediaLoadedInfo { width: number; height: number; @@ -1380,28 +1340,6 @@ export interface CardHelpers { * Home Assistant API types. */ -export const MEDIA_CLASS_PLAYLIST = 'playlist' as const; -export const MEDIA_CLASS_VIDEO = 'video' as const; -export const MEDIA_TYPE_PLAYLIST = 'playlist' as const; -export const MEDIA_TYPE_IMAGE = 'image' as const; -export const MEDIA_TYPE_VIDEO = 'video' as const; - -// Recursive type, cannot use type interference: -// See: https://github.com/colinhacks/zod#recursive-types -// -// Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_player/browse_media.py#L46 -export interface BrowseMediaSource { - title: string; - media_class: string; - media_content_type: string; - media_content_id: string; - can_play: boolean; - can_expand: boolean; - children_media_class?: string | null; - thumbnail: string | null; - children?: BrowseMediaSource[] | null; -} - // Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_source/models.py export const resolvedMediaSchema = z.object({ url: z.string(), @@ -1414,7 +1352,7 @@ export const signedPathSchema = z.object({ }); export type SignedPath = z.infer; -export const entitySchema = z.object({ +const entitySchema = z.object({ config_entry_id: z.string().nullable(), disabled_by: z.string().nullable(), entity_id: z.string(), diff --git a/src/utils/debug.ts b/src/utils/debug.ts index 863be998..9316ee09 100644 --- a/src/utils/debug.ts +++ b/src/utils/debug.ts @@ -10,6 +10,7 @@ export const log = (cardWideConfig?: CardWideConfig, ...args: unknown[]) => { * For debug purposes only. * @param seconds */ +// ts-prune-ignore-next export const sleep = async (seconds: number) => { await new Promise((r) => setTimeout(r, seconds * 1000)); }; diff --git a/src/utils/ha/device-registry.ts b/src/utils/ha/device-registry.ts index 84278eda..45694418 100644 --- a/src/utils/ha/device-registry.ts +++ b/src/utils/ha/device-registry.ts @@ -7,7 +7,7 @@ const deviceSchema = z.object({ config_entries: z.string().array(), manufacturer: z.string().nullable(), }) -export const deviceListSchema = deviceSchema.array(); +const deviceListSchema = deviceSchema.array(); export type DeviceList = z.infer; /** diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts index 516f711c..29583608 100644 --- a/src/utils/ha/index.ts +++ b/src/utils/ha/index.ts @@ -205,7 +205,7 @@ function computeStyle(state: HassEntity): StyleInfo { * @param stateObj The HassEntity object from `hass.states`. * @returns A string state, e.g. 'on'. */ -export const computeActiveState = (stateObj: HassEntity): string => { +const computeActiveState = (stateObj: HassEntity): string => { const domain = stateObj.entity_id.split('.')[0]; let state = stateObj.state; diff --git a/src/utils/ha/update.ts b/src/utils/ha/update.ts index 9499b89d..f7af7974 100644 --- a/src/utils/ha/update.ts +++ b/src/utils/ha/update.ts @@ -4,11 +4,7 @@ import { HassEntityBase } from 'home-assistant-js-websocket'; -export const UPDATE_SUPPORT_INSTALL = 1; -export const UPDATE_SUPPORT_SPECIFIC_VERSION = 2; -export const UPDATE_SUPPORT_PROGRESS = 4; -export const UPDATE_SUPPORT_BACKUP = 8; -export const UPDATE_SUPPORT_RELEASE_NOTES = 16; +const UPDATE_SUPPORT_PROGRESS = 4; interface UpdateEntityAttributes extends HassEntityAttributeBase { auto_update: boolean | null; @@ -28,7 +24,7 @@ export interface UpdateEntity extends HassEntityBase { export const supportsFeature = (stateObj: HassEntity, feature: number): boolean => ((stateObj.attributes.supported_features ?? 0) & feature) !== 0; -export const updateUsesProgress = (entity: UpdateEntity): boolean => +const updateUsesProgress = (entity: UpdateEntity): boolean => supportsFeature(entity, UPDATE_SUPPORT_PROGRESS) && typeof entity.attributes.in_progress === 'number'; diff --git a/src/utils/icons/alarm-panel-icon.ts b/src/utils/icons/alarm-panel-icon.ts index fd9915fa..b0bb5da9 100644 --- a/src/utils/icons/alarm-panel-icon.ts +++ b/src/utils/icons/alarm-panel-icon.ts @@ -21,22 +21,3 @@ export const alarmPanelIcon = (state?: string) => { return 'mdi:shield'; } }; - -export const alarmPanelIconAction = (state?: string) => { - switch (state) { - case 'armed_away': - return 'mdi:shield-lock-outline'; - case 'armed_vacation': - return 'mdi:shield-airplane-outline'; - case 'armed_home': - return 'mdi:shield-home-outline'; - case 'armed_night': - return 'mdi:shield-moon-outline'; - case 'armed_custom_bypass': - return 'mdi:shield-half-full'; - case 'disarmed': - return 'mdi:shield-off-outline'; - default: - return 'mdi:shield-outline'; - } -}; diff --git a/src/utils/icons/cover-icon.ts b/src/utils/icons/cover-icon.ts index 37cd15d6..44b5985b 100644 --- a/src/utils/icons/cover-icon.ts +++ b/src/utils/icons/cover-icon.ts @@ -87,27 +87,3 @@ export const coverIcon = (state?: string, entity?: HassEntity): string => { return 'mdi:window-open'; } }; - -export const computeOpenIcon = (stateObj: HassEntity): string => { - switch (stateObj.attributes.device_class) { - case 'awning': - case 'curtain': - case 'door': - case 'gate': - return 'mdi:arrow-expand-horizontal'; - default: - return 'mdi:arrow-up'; - } -}; - -export const computeCloseIcon = (stateObj: HassEntity): string => { - switch (stateObj.attributes.device_class) { - case 'awning': - case 'curtain': - case 'door': - case 'gate': - return 'mdi:arrow-collapse-horizontal'; - default: - return 'mdi:arrow-down'; - } -}; diff --git a/src/utils/icons/sensor-icon.ts b/src/utils/icons/sensor-icon.ts index 885641ef..8331a9aa 100644 --- a/src/utils/icons/sensor-icon.ts +++ b/src/utils/icons/sensor-icon.ts @@ -70,7 +70,7 @@ const batteryStateIcon = ( return batteryIcon(battery, batteryCharging); }; -export const batteryIcon = ( +const batteryIcon = ( batteryState: number | string, batteryCharging?: boolean, ) => { diff --git a/src/utils/media.ts b/src/utils/media.ts index 0e300087..b7aabcae 100644 --- a/src/utils/media.ts +++ b/src/utils/media.ts @@ -2,7 +2,7 @@ // to give a cleaner UI appearance, see: // https://github.com/dermotduffy/frigate-hass-card/issues/856 export const MEDIA_LOAD_CONTROLS_HIDE_SECONDS = 2; -export const MEDIA_SEEK_CONTROLS_HIDE_SECONDS = 1; +const MEDIA_SEEK_CONTROLS_HIDE_SECONDS = 1; /** * Temporarily hide media controls. diff --git a/src/utils/thumbnail.ts b/src/utils/thumbnail.ts index b1cc8aa0..b13dd57d 100644 --- a/src/utils/thumbnail.ts +++ b/src/utils/thumbnail.ts @@ -8,7 +8,7 @@ import { HomeAssistant } from 'custom-card-helpers'; * @param thumbnailURL The thumbnail URL. * @returns A base64 encoded data URL for the thumbnail. */ -export const fetchThumbnail = async ( +const fetchThumbnail = async ( hass: HomeAssistant, thumbnailURL: string, ): Promise => { diff --git a/src/view/media-queries.ts b/src/view/media-queries.ts index f77a79e3..718c1d98 100644 --- a/src/view/media-queries.ts +++ b/src/view/media-queries.ts @@ -3,7 +3,7 @@ import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types. export type MediaQueries = EventMediaQueries | RecordingMediaQueries; -export class MediaQueriesBase { +class MediaQueriesBase { protected _queries: T[] | null = null; public constructor(queries?: T[]) { diff --git a/src/view/view.ts b/src/view/view.ts index 1e4249ae..ad754ee6 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,5 +1,3 @@ -// Minor / later: -// - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript // Gallery: // - TODO: Filter panel expands from right can occasionally 'stick' open. @@ -14,7 +12,7 @@ import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { MediaQueries } from './media-queries'; import { MediaQueriesResults } from './media-queries-results'; -export interface ViewEvolveParameters { +interface ViewEvolveParameters { view?: FrigateCardView; camera?: string; query?: MediaQueries | null; @@ -22,7 +20,7 @@ export interface ViewEvolveParameters { context?: ViewContext | null; } -export interface ViewParameters extends ViewEvolveParameters { +interface ViewParameters extends ViewEvolveParameters { view: FrigateCardView; camera: string; } diff --git a/yarn.lock b/yarn.lock index 2d4207ae..c6950d31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -521,6 +521,16 @@ resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== +"@ts-morph/common@~0.12.3": + version "0.12.3" + resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.12.3.tgz#a96e250217cd30e480ab22ec6a0ebbe65fd784ff" + integrity sha512-4tUmeLyXJnJWvTFOKtcNJ1yh0a3SsTLi2MUoyj8iUNznFRN1ZquaNe7Oukqrnki2FzZkm0J9adCNLDZxUzvj+w== + dependencies: + fast-glob "^3.2.7" + minimatch "^3.0.4" + mkdirp "^1.0.4" + path-browserify "^1.0.1" + "@types/bluebird@^3.5.36": version "3.5.36" resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.36.tgz#00d9301d4dc35c2f6465a8aec634bb533674c652" @@ -870,6 +880,11 @@ cliui@^7.0.2: strip-ansi "^6.0.0" wrap-ansi "^7.0.0" +code-block-writer@^11.0.0: + version "11.0.3" + resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-11.0.3.tgz#9eec2993edfb79bfae845fbc093758c0a0b73b76" + integrity sha512-NiujjUFB4SwScJq2bwbYUtXbZhBSlY6vYzm++3Q6oC+U+injTqfPYFK8wS9COOmb2lueqp0ZRB4nK1VYeHgNyw== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -904,6 +919,11 @@ commander@^2.20.0, commander@^2.20.3: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== +commander@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" + integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== + commander@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" @@ -1475,7 +1495,7 @@ fast-diff@^1.1.2: resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03" integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== -fast-glob@^3.2.9: +fast-glob@^3.2.7, fast-glob@^3.2.9: version "3.2.12" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w== @@ -2002,6 +2022,11 @@ json5@^1.0.1: dependencies: minimist "^1.2.0" +json5@^2.1.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + json5@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c" @@ -2097,6 +2122,11 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ== +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + lru-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" @@ -2175,6 +2205,11 @@ minimist@^1.2.0, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== +mkdirp@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + moment@^2.29.4: version "2.29.4" resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" @@ -2368,6 +2403,11 @@ parse-json@^5.0.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" +path-browserify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" + integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -3078,6 +3118,31 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +"true-myth@^4.1.0": + version "4.1.1" + resolved "https://registry.yarnpkg.com/true-myth/-/true-myth-4.1.1.tgz#ff4ac9d5130276e34aa338757e2416ec19248ba2" + integrity sha512-rqy30BSpxPznbbTcAcci90oZ1YR4DqvKcNXNerG5gQBU2v4jk0cygheiul5J6ExIMrgDVuanv/MkGfqZbKrNNg== + +ts-morph@^13.0.1: + version "13.0.3" + resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-13.0.3.tgz#c0c51d1273ae2edb46d76f65161eb9d763444c1d" + integrity sha512-pSOfUMx8Ld/WUreoSzvMFQG5i9uEiWIsBYjpU9+TTASOeUa89j5HykomeqVULm1oqWtBdleI3KEFRLrlA3zGIw== + dependencies: + "@ts-morph/common" "~0.12.3" + code-block-writer "^11.0.0" + +ts-prune@^0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/ts-prune/-/ts-prune-0.10.3.tgz#b6c71a525543b38dcf947a7d3adfb7f9e8b91f38" + integrity sha512-iS47YTbdIcvN8Nh/1BFyziyUqmjXz7GVzWu02RaZXqb+e/3Qe1B7IQ4860krOeCGUeJmterAlaM2FRH0Ue0hjw== + dependencies: + commander "^6.2.1" + cosmiconfig "^7.0.1" + json5 "^2.1.3" + lodash "^4.17.21" + "true-myth" "^4.1.0" + ts-morph "^13.0.1" + ts-toolbelt@^9.6.0: version "9.6.0" resolved "https://registry.yarnpkg.com/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz#50a25426cfed500d4a09bd1b3afb6f28879edfd5" From d27b9d9520fa0b41020c91a85ac9b07dff037441 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 30 Jan 2023 21:22:58 -0800 Subject: [PATCH 70/74] Tweaks to reduce bundle size. --- src/camera-manager/frigate/engine-frigate.ts | 7 ++++--- src/camera-manager/frigate/requests.ts | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 3e7c6b5b..98fc83f0 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -3,7 +3,7 @@ import add from 'date-fns/add'; import endOfHour from 'date-fns/endOfHour'; import startOfHour from 'date-fns/startOfHour'; import { CAMERA_BIRDSEYE } from '../../const'; -import { CameraConfig, CardWideConfig, RecordingSegment } from '../../types'; +import { CameraConfig, CardWideConfig } from '../../types'; import { ViewMedia } from '../../view/media'; import { RequestCache, RecordingSegmentsCache } from '../cache'; import { @@ -34,6 +34,7 @@ import { RecordingQuery, RecordingQueryResults, RecordingQueryResultsMap, + RecordingSegment, RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, } from '../types'; @@ -55,8 +56,8 @@ import { prettifyTitle, runWhenIdleIfSupported, } from '../../utils/basic'; -import { fromUnixTime } from 'date-fns'; -import { sum } from 'lodash-es'; +import fromUnixTime from 'date-fns/fromUnixTime'; +import sum from 'lodash-es/sum'; import { FrigateViewMediaClassifier } from './media-classifier'; import { ViewMediaClassifier } from '../../view/media-classifier'; import { FrigateViewMediaFactory } from './media'; diff --git a/src/camera-manager/frigate/requests.ts b/src/camera-manager/frigate/requests.ts index 01d48878..d3a4542b 100644 --- a/src/camera-manager/frigate/requests.ts +++ b/src/camera-manager/frigate/requests.ts @@ -1,7 +1,8 @@ import { HomeAssistant } from 'custom-card-helpers'; import { localize } from '../../localize/localize'; -import { FrigateCardError, RecordingSegment } from '../../types'; +import { FrigateCardError } from '../../types'; import { homeAssistantWSRequest } from '../../utils/ha'; +import { RecordingSegment } from '../types'; import { EventSummary, eventSummarySchema, From c898f038bd1980bb460755667bf4687a7504ae07 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 30 Jan 2023 21:24:59 -0800 Subject: [PATCH 71/74] Remove old TODO. --- src/view/view.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/view/view.ts b/src/view/view.ts index ad754ee6..040dcc45 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -78,8 +78,10 @@ export class View { // When in the live view, the queryResults contain the events that // happened in the past -- not reflective of the actual live media viewer // the user is seeing. - // TODO: Put stuff here that can be used in timeline reset code - (curr.view !== 'live' && (prev.queryResults !== curr.queryResults || prev.queryResults?.getSelectedResult() !== curr.queryResults?.getSelectedResult())) + (curr.view !== 'live' && + (prev.queryResults !== curr.queryResults || + prev.queryResults?.getSelectedResult() !== + curr.queryResults?.getSelectedResult())) ); } From c86a091546d0452fde0093aac1888cd314a9cb3e Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 5 Feb 2023 20:27:24 -0800 Subject: [PATCH 72/74] Implement media filtering. --- package.json | 2 + src/camera-manager/types.ts | 8 +- src/components/gallery.ts | 1 + src/components/media-filter-core.ts | 313 ------------------ src/components/media-filter.ts | 480 +++++++++++++++++++--------- src/components/select.ts | 89 ++++++ src/localize/languages/en.json | 6 + src/localize/languages/it.json | 6 + src/localize/languages/pt-BR.json | 6 + src/scoped-elements/gr-select.js | 24 ++ src/scss/media-filter-core.scss | 16 - src/scss/media-filter.scss | 18 +- src/scss/select.scss | 11 + src/view/view.ts | 2 +- yarn.lock | 62 ++++ 15 files changed, 558 insertions(+), 486 deletions(-) delete mode 100644 src/components/media-filter-core.ts create mode 100644 src/components/select.ts create mode 100644 src/scoped-elements/gr-select.js delete mode 100644 src/scss/media-filter-core.scss create mode 100644 src/scss/select.scss diff --git a/package.json b/package.json index 0d78b3ca..838c052f 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "dependencies": { "@cycjimmy/jsmpeg-player": "^6.0.4", "@egjs/hammerjs": "^2.0.17", + "@graphiteds/core": "^1.9.6", + "@lit-labs/scoped-registry-mixin": "^1.0.1", "@lit-labs/task": "^1.1.3", "@types/bluebird": "^3.5.36", "component-emitter": "^1.3.0", diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index a736bec9..2e85a6d0 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -38,7 +38,9 @@ interface LimitedDataQuery { export interface MediaQuery extends DataQuery, Partial, - Partial {} + Partial { + favorite?: boolean; +} export interface QueryResults { type: QueryResultsType; @@ -114,8 +116,6 @@ export interface EventQuery extends MediaQuery { // Frigate equivalent: zone where?: Set; - - favorite?: boolean; } export type PartialEventQuery = Partial; @@ -129,8 +129,6 @@ export interface EventQueryResults extends QueryResults { export interface RecordingQuery extends MediaQuery { type: QueryType.Recording; - - favorite?: boolean; } export type PartialRecordingQuery = Partial; diff --git a/src/components/gallery.ts b/src/components/gallery.ts index ee1432d2..2959e4ca 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -34,6 +34,7 @@ import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types' import { MediaQueriesResults } from '../view/media-queries-results'; import { errorToConsole } from '../utils/basic'; import './media-filter'; +import "./surround-basic"; const GALLERY_MEDIA_CHUNK_SIZE = 100; diff --git a/src/components/media-filter-core.ts b/src/components/media-filter-core.ts deleted file mode 100644 index c356b70c..00000000 --- a/src/components/media-filter-core.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { - CSSResultGroup, - html, - LitElement, - PropertyValues, - TemplateResult, - unsafeCSS, -} from 'lit'; -import { customElement, property } from 'lit/decorators.js'; -import { createRef, ref, Ref } from 'lit/directives/ref.js'; -import { DateRange } from '../camera-manager/range'; -import { localize } from '../localize/localize'; -import mediaFilterCoreStyle from '../scss/media-filter-core.scss'; -import { ExtendedHomeAssistant } from '../types'; -import { dispatchFrigateCardEvent } from '../utils/basic'; - -export interface ValueLabel { - value?: T; - label: string; -} - -export interface MediaFilterCoreSelection { - mediaType?: MediaFilterMediaType; - cameraIDs?: Set; - what?: Set; - where?: Set; - when?: MediaFilterCoreWhenSelection; - favorite?: MediaFilterCoreFavoriteSelection; -} - -type FilterElement = HTMLElement & { - selectedItem?: ValueLabel; -}; - -export enum MediaFilterCoreFavoriteSelection { - Favorite = 'favorite', - NotFavorite = 'not-favorite', -} - -export enum MediaFilterCoreWhen { - Today = 'today', - Yesterday = 'yesterday', - PastWeek = 'past-week', - PastMonth = 'past-month', - Custom = 'custom', -} - -export enum MediaFilterMediaType { - Clips = 'clips', - Snapshots = 'snapshots', - Recordings = 'recordings', -} - -export interface MediaFilterCoreWhenSelection { - selection: MediaFilterCoreWhen; - custom?: DateRange; -} - -export type MediaFilterControls = { - mediaType?: boolean; - when?: boolean; - camera?: boolean; - what?: boolean; - where?: boolean; - favorite?: boolean; -}; - -@customElement('frigate-card-media-filter-core') -class FrigateCardMediaFilterCore extends LitElement { - @property({ attribute: false }) - public hass?: ExtendedHomeAssistant; - - @property({ attribute: false }) - public cameraOptions?: ValueLabel[]; - - @property({ attribute: false }) - public whenOptions?: ValueLabel[]; - - @property({ attribute: false }) - public whatOptions?: ValueLabel[]; - - @property({ attribute: false }) - public whereOptions?: ValueLabel[]; - - @property({ attribute: false }) - public defaults?: MediaFilterCoreSelection; - - @property({ attribute: false }) - public controls?: MediaFilterControls; - - protected _cameraOptions?: ValueLabel[]; - protected _whenOptions?: ValueLabel[]; - protected _whatOptions?: ValueLabel[]; - protected _whereOptions?: ValueLabel[]; - protected _favoriteOptions: ValueLabel[]; - protected _mediaTypeOptions: ValueLabel[]; - - protected _refMediaType: Ref> = createRef(); - protected _refCamera: Ref> = createRef(); - protected _refWhen: Ref> = createRef(); - protected _refWhat: Ref> = createRef(); - protected _refWhere: Ref> = createRef(); - protected _refFavorite: Ref> = - createRef(); - - constructor() { - super(); - this._favoriteOptions = [ - { - value: undefined, - label: localize('media_filter.all'), - }, - { - value: MediaFilterCoreFavoriteSelection.Favorite, - label: localize('media_filter.favorite'), - }, - { - value: MediaFilterCoreFavoriteSelection.NotFavorite, - label: localize('media_filter.not_favorite'), - }, - ]; - this._mediaTypeOptions = [ - { - value: MediaFilterMediaType.Clips, - label: localize('media_filter.media_types.clips'), - }, - { - value: MediaFilterMediaType.Snapshots, - label: localize('media_filter.media_types.snapshots'), - }, - { - value: MediaFilterMediaType.Recordings, - label: localize('media_filter.media_types.recordings'), - }, - ]; - } - - protected _valueChangedHandler(ev: CustomEvent<{ value: unknown }>): void { - // Handler is called on initial load -- skip it. - if (!ev.detail.value) { - return; - } - const values: MediaFilterCoreSelection = { - ...(this._refMediaType.value && - this._refMediaType.value.selectedItem?.value && { - mediaType: this._refMediaType.value.selectedItem?.value, - }), - ...(this._refCamera.value && - this._refCamera.value.selectedItem?.value && { - cameraIDs: new Set([this._refCamera.value.selectedItem.value]), - }), - ...(this._refWhen.value && - this._refWhen.value.selectedItem?.value && { - when: this._refWhen.value.selectedItem?.value, - }), - ...(this._refWhat.value && - this._refWhat.value.selectedItem?.value && { - what: new Set([this._refWhat.value.selectedItem.value]), - }), - ...(this._refWhere.value && - this._refWhere.value.selectedItem?.value && { - where: new Set([this._refWhere.value.selectedItem?.value]), - }), - ...(this._refFavorite.value && - this._refFavorite.value.selectedItem?.value !== undefined && { - favorite: this._refFavorite.value.selectedItem?.value, - }), - }; - dispatchFrigateCardEvent(this, 'media-filter-core:change', values); - } - - protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('cameraOptions')) { - this._cameraOptions = [ - { - value: undefined, - label: localize('media_filter.all'), - }, - ...(this.cameraOptions ?? []), - ]; - } - if (changedProps.has('whenOptions')) { - // Time based options are not pre-computed here to ensure relative dates - // (e.g. 'today') are always calculated when activated not when rendered. - this._whenOptions = [ - { - value: undefined, - label: localize('media_filter.all'), - }, - { - value: { selection: MediaFilterCoreWhen.Today }, - label: localize('media_filter.whens.today'), - }, - { - value: { selection: MediaFilterCoreWhen.Yesterday }, - label: localize('media_filter.whens.yesterday'), - }, - { - value: { selection: MediaFilterCoreWhen.PastWeek }, - label: localize('media_filter.whens.past_week'), - }, - { - value: { selection: MediaFilterCoreWhen.PastMonth }, - label: localize('media_filter.whens.past_month'), - }, - ...(this.whenOptions ?? []), - ]; - } - if (changedProps.has('whatOptions')) { - this._whatOptions = [ - { value: undefined, label: localize('media_filter.all') }, - ...(this.whatOptions ?? []), - ]; - } - if (changedProps.has('whereOptions')) { - this._whereOptions = [ - { value: undefined, label: localize('media_filter.all') }, - ...(this.whereOptions ?? []), - ]; - } - } - - /** - * Master render method. - * @returns A rendered template. - */ - protected render(): TemplateResult | void { - return html` ${this.controls?.mediaType ?? true - ? html` ` - : ''} - ${this.controls?.when ?? true - ? html`` - : ''} - ${(this.controls?.camera ?? true) && this.cameraOptions - ? html` ` - : ''} - ${(this.controls?.what ?? true) && this.whatOptions - ? html` ` - : ''} - ${(this.controls?.where ?? true) && this.whereOptions - ? html`` - : ''} - ${this.controls?.favorite ?? true - ? html` - - ` - : ''}`; - } - - static get styles(): CSSResultGroup { - return unsafeCSS(mediaFilterCoreStyle); - } -} - -declare global { - interface HTMLElementTagNameMap { - 'frigate-card-media-filter-core': FrigateCardMediaFilterCore; - } -} diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index f6f343de..f8fd3f1a 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -1,10 +1,3 @@ -import sub from 'date-fns/sub'; -import endOfDay from 'date-fns/endOfDay'; -import endOfYesterday from 'date-fns/endOfYesterday'; -import endOfToday from 'date-fns/esm/endOfToday'; -import startOfToday from 'date-fns/esm/startOfToday'; -import startOfDay from 'date-fns/startOfDay'; -import startOfYesterday from 'date-fns/startOfYesterday'; import { CSSResultGroup, html, @@ -16,38 +9,73 @@ import { unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { CameraManager } from '../camera-manager/manager'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { DateRange } from '../camera-manager/range'; -import { CameraConfig, ExtendedHomeAssistant } from '../types'; -import { View } from '../view/view'; -import { - MediaFilterControls, - MediaFilterCoreFavoriteSelection, - MediaFilterCoreSelection, - MediaFilterCoreWhen, - MediaFilterCoreWhenSelection, - MediaFilterMediaType, - ValueLabel, -} from './media-filter-core'; -import './surround.js'; -import './timeline-core.js'; -import { EventQuery, MediaMetadata, QueryType, RecordingQuery } from '../camera-manager/types'; -import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; -import { createViewForEvents, createViewForRecordings } from '../utils/media-to-view.js'; -import { HomeAssistant } from 'custom-card-helpers'; -import { errorToConsole, prettifyTitle } from '../utils/basic'; -import format from 'date-fns/format'; -import parse from 'date-fns/parse'; -import endOfMonth from 'date-fns/endOfMonth'; +import { localize } from '../localize/localize'; import mediaFilterStyle from '../scss/media-filter.scss'; -import { MediaQueriesClassifier } from '../view/media-queries-classifier'; +import { CameraConfig } from '../types'; +import { createViewForEvents, createViewForRecordings } from '../utils/media-to-view.js'; +import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic'; +import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin'; +import './select'; +import { FrigateCardSelect, SelectOption, SelectValues } from './select'; import uniqWith from 'lodash-es/uniqWith'; +import sub from 'date-fns/sub'; +import endOfDay from 'date-fns/endOfDay'; +import endOfYesterday from 'date-fns/endOfYesterday'; +import endOfToday from 'date-fns/esm/endOfToday'; +import startOfToday from 'date-fns/esm/startOfToday'; +import startOfDay from 'date-fns/startOfDay'; +import startOfYesterday from 'date-fns/startOfYesterday'; +import parse from 'date-fns/parse'; +import { MediaQueriesClassifier } from '../view/media-queries-classifier'; +import { View } from '../view/view'; +import { CameraManager } from '../camera-manager/manager'; +import { HomeAssistant } from 'custom-card-helpers'; +import { + EventQuery, + MediaMetadata, + QueryType, + RecordingQuery, +} from '../camera-manager/types'; +import format from 'date-fns/format'; +import endOfMonth from 'date-fns/endOfMonth'; import isEqual from 'lodash-es/isEqual'; +import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; +import './select.js'; +import orderBy from 'lodash-es/orderBy'; + +interface MediaFilterCoreDefaults { + mediaType?: MediaFilterMediaType; + cameraIDs?: string[]; + what?: string[]; + where?: string[]; + favorite?: MediaFilterCoreFavoriteSelection; + when?: string; +} + +export enum MediaFilterCoreFavoriteSelection { + Favorite = 'favorite', + NotFavorite = 'not-favorite', +} + +export enum MediaFilterCoreWhen { + Today = 'today', + Yesterday = 'yesterday', + PastWeek = 'past-week', + PastMonth = 'past-month', +} + +export enum MediaFilterMediaType { + Clips = 'clips', + Snapshots = 'snapshots', + Recordings = 'recordings', +} @customElement('frigate-card-media-filter') -export class FrigateCardMediaFilter extends LitElement { +class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { @property({ attribute: false }) - public hass?: ExtendedHomeAssistant; + public hass?: HomeAssistant; @property({ attribute: false }) public cameras?: Map; @@ -61,17 +89,73 @@ export class FrigateCardMediaFilter extends LitElement { @property({ attribute: false }) public mediaLimit?: number; - protected _cameraOptions: ValueLabel[] = []; + static elementDefinitions = { + 'frigate-card-select': FrigateCardSelect, + }; + protected _mediaMetadataController?: MediaMetadataController; - protected _convertWhenToDateRange( - value?: MediaFilterCoreWhenSelection, - ): DateRange | null { - if (!value) { + protected _mediaTypeOptions: SelectOption[]; + protected _cameraOptions?: SelectOption[]; + protected _whenOptions?: SelectOption[]; + protected _favoriteOptions: SelectOption[]; + + protected _defaults: MediaFilterCoreDefaults | null = null; + + protected _refMediaType: Ref = createRef(); + protected _refCamera: Ref = createRef(); + protected _refWhen: Ref = createRef(); + protected _refWhat: Ref = createRef(); + protected _refWhere: Ref = createRef(); + protected _refFavorite: Ref = createRef(); + + constructor() { + super(); + this._favoriteOptions = [ + { + value: MediaFilterCoreFavoriteSelection.Favorite, + label: localize('media_filter.favorite'), + }, + { + value: MediaFilterCoreFavoriteSelection.NotFavorite, + label: localize('media_filter.not_favorite'), + }, + ]; + this._mediaTypeOptions = [ + { + value: MediaFilterMediaType.Clips, + label: localize('media_filter.media_types.clips'), + }, + { + value: MediaFilterMediaType.Snapshots, + label: localize('media_filter.media_types.snapshots'), + }, + { + value: MediaFilterMediaType.Recordings, + label: localize('media_filter.media_types.recordings'), + }, + ]; + } + + protected _stringToDateRange(input: string): DateRange { + const dates = input.split(','); + return { + start: parse(dates[0], 'yyyy-MM-dd', new Date()), + end: parse(dates[1], 'yyyy-MM-dd', new Date()), + }; + } + + protected _dateRangeToString(when: DateRange): string { + return `${formatDate(when.start)},${formatDate(when.end)}`; + } + + protected _getWhen(): DateRange | null { + const value = this._refWhen.value?.value; + if (!value || Array.isArray(value)) { return null; } const now = new Date(); - switch (value.selection) { + switch (value) { case MediaFilterCoreWhen.Today: return { start: startOfToday(), end: endOfToday() }; case MediaFilterCoreWhen.Yesterday: @@ -80,40 +164,37 @@ export class FrigateCardMediaFilter extends LitElement { return { start: startOfDay(sub(now, { days: 7 })), end: endOfDay(now) }; case MediaFilterCoreWhen.PastMonth: return { start: startOfDay(sub(now, { months: 1 })), end: endOfDay(now) }; - case MediaFilterCoreWhen.Custom: - if (value.custom) { - return value.custom; - } + default: + return this._stringToDateRange(value); } - return null; } - protected _convertFavoriteToBoolean( - value?: MediaFilterCoreFavoriteSelection, - ): boolean | null { - if (!value) { - return null; - } - return value === MediaFilterCoreFavoriteSelection.Favorite; - } - - protected async _mediaFilterHandler( - ev: CustomEvent, + protected async _valueChangedHandler( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _ev: CustomEvent<{ value: unknown }>, ): Promise { - const mediaFilter = ev.detail; - if ( - !this.cameras || - !this.cameraManager || - !this.hass || - !this.view || - !mediaFilter.mediaType - ) { + if (!this.hass || !this.cameras || !this.cameraManager || !this.view) { return; } - const convertedTime = this._convertWhenToDateRange(mediaFilter.when); - const convertedFavorite = this._convertFavoriteToBoolean(mediaFilter.favorite); - const cameraIDs = mediaFilter.cameraIDs ?? new Set(this.cameras.keys()); + const getArrayValueAsSet = (val?: SelectValues): Set | null => { + // The reported value may be '' if the field is clearable (i.e. the user + // can click 'x'). + if (val && Array.isArray(val) && val.length && !val.includes('')) { + return new Set([...val]); + } + return null; + }; + + const cameraIDs = + getArrayValueAsSet(this._refCamera.value?.value) ?? new Set(this.cameras.keys()); + const mediaType = this._refMediaType.value?.value as + | MediaFilterMediaType + | undefined; + const when = this._getWhen(); + const favorite = this._refFavorite.value?.value + ? this._refFavorite.value.value === MediaFilterCoreFavoriteSelection.Favorite + : null; // A note on views: // - In the below, if the user selects a camera to view media for, the main @@ -125,22 +206,27 @@ export class FrigateCardMediaFilter extends LitElement { // to 'clips' or 'snapshots' in order to ensure the right icon is shown as // selected in the menu. if ( - mediaFilter.mediaType === MediaFilterMediaType.Clips || - mediaFilter.mediaType === MediaFilterMediaType.Snapshots + mediaType === MediaFilterMediaType.Clips || + mediaType === MediaFilterMediaType.Snapshots ) { - const query: EventQuery = { - type: QueryType.Event, - cameraIDs: cameraIDs, - ...(mediaFilter.what && { what: mediaFilter.what }), - ...(mediaFilter.where && { where: mediaFilter.where }), - ...(convertedFavorite !== null && { favorite: convertedFavorite }), - ...(convertedTime && { start: convertedTime.start, end: convertedTime.end }), - ...(this.mediaLimit && { limit: this.mediaLimit }), - ...(mediaFilter.mediaType === MediaFilterMediaType.Clips && { hasClip: true }), - ...(mediaFilter.mediaType === MediaFilterMediaType.Snapshots && { - hasSnapshot: true, - }), - }; + const where = getArrayValueAsSet(this._refWhere.value?.value); + const what = getArrayValueAsSet(this._refWhat.value?.value); + + const queries: EventQuery[] = [ + { + type: QueryType.Event, + cameraIDs: cameraIDs, + ...(what && { what: what }), + ...(where && { where: where }), + ...(favorite !== null && { favorite: favorite }), + ...(when && { start: when.start, end: when.end }), + ...(this.mediaLimit && { limit: this.mediaLimit }), + ...(mediaType === MediaFilterMediaType.Clips && { hasClip: true }), + ...(mediaType === MediaFilterMediaType.Snapshots && { + hasSnapshot: true, + }), + }, + ]; ( await createViewForEvents( @@ -150,22 +236,19 @@ export class FrigateCardMediaFilter extends LitElement { this.cameras, this.view, { - query: new EventMediaQueries([query]), + query: new EventMediaQueries(queries), // See 'A note on views' above for these two arguments. ...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }), - targetView: - mediaFilter.mediaType === MediaFilterMediaType.Clips - ? 'clips' - : 'snapshots', + targetView: mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots', }, ) )?.dispatchChangeEvent(this); - } else if (mediaFilter.mediaType === MediaFilterMediaType.Recordings) { + } else if (mediaType === MediaFilterMediaType.Recordings) { const query: RecordingQuery = { type: QueryType.Recording, cameraIDs: cameraIDs, - ...(convertedTime && { start: convertedTime.start, end: convertedTime.end }), + ...(when && { start: when.start, end: when.end }), }; ( @@ -206,23 +289,74 @@ export class FrigateCardMediaFilter extends LitElement { this.cameraManager, ); } + + // Relative time based options are not pre-computed here to ensure relative + // dates (e.g. 'today') are always calculated when activated not when + // rendered. + this._whenOptions = [ + { + value: MediaFilterCoreWhen.Today, + label: localize('media_filter.whens.today'), + }, + { + value: MediaFilterCoreWhen.Yesterday, + label: localize('media_filter.whens.yesterday'), + }, + { + value: MediaFilterCoreWhen.PastWeek, + label: localize('media_filter.whens.past_week'), + }, + { + value: MediaFilterCoreWhen.PastMonth, + label: localize('media_filter.whens.past_month'), + }, + ...(this._mediaMetadataController?.whenOptions ?? []), + ]; + + if (changedProps.has('view')) { + const newDefaults = this._getDefaultsFromView(); + if (!isEqual(newDefaults, this._defaults)) { + this._defaults = newDefaults; + } + } } - protected _getDefaultsFromView(): MediaFilterCoreSelection | undefined { - if (!this.view) { - return undefined; + protected _getDefaultsFromView(): MediaFilterCoreDefaults | null { + const queries = this.view?.query?.getQueries(); + if (!this.view || !queries) { + return null; } let mediaType: MediaFilterMediaType | undefined; - let cameraIDs: Set | undefined; - let what: Set | undefined; - let where: Set | undefined; - let favorite: boolean | undefined; + let cameraIDs: string[] | undefined; + let what: string[] | undefined; + let where: string[] | undefined; + let favorite: MediaFilterCoreFavoriteSelection | undefined; + + const cameraIDSets = uniqWith( + queries.map((query) => query.cameraIDs), + isEqual, + ); + // Special note: If all cameras are selected, this is the same as no + // selector at all. + if (cameraIDSets.length === 1 && queries[0].cameraIDs.size !== this.cameras?.size) { + cameraIDs = [...queries[0].cameraIDs]; + } + + const favoriteValues = uniqWith( + queries.map((query) => query.favorite), + isEqual, + ); + if (favoriteValues.length === 1 && queries[0].favorite !== undefined) { + favorite = queries[0].favorite + ? MediaFilterCoreFavoriteSelection.Favorite + : MediaFilterCoreFavoriteSelection.NotFavorite; + } if (MediaQueriesClassifier.areEventQueries(this.view.query)) { const queries = this.view.query.getQueries(); if (!queries) { - return; + return null; } const hasClips = uniqWith( @@ -241,34 +375,22 @@ export class FrigateCardMediaFilter extends LitElement { : undefined; } - const cameraIDSets = uniqWith( - queries.map((query) => query.cameraIDs), - isEqual, - ); - if (cameraIDSets.length === 1) { - cameraIDs = queries[0].cameraIDs; - } const whatSets = uniqWith( queries.map((query) => query.what), isEqual, ); - if (whatSets.length === 1) { - what = queries[0].what; + if (whatSets.length === 1 && queries[0].what?.size) { + what = [...queries[0].what]; } const whereSets = uniqWith( queries.map((query) => query.where), isEqual, ); - if (whereSets.length === 1) { - where = queries[0].where; - } - const favoriteValues = uniqWith( - queries.map((query) => query.favorite), - isEqual, - ); - if (favoriteValues.length === 1) { - favorite = queries[0].favorite; + if (whereSets.length === 1 && queries[0].where?.size) { + where = [...queries[0].where]; } + } else if (MediaQueriesClassifier.areRecordingQueries(this.view.query)) { + mediaType = MediaFilterMediaType.Recordings; } return { @@ -276,15 +398,15 @@ export class FrigateCardMediaFilter extends LitElement { ...(cameraIDs && { cameraIDs: cameraIDs }), ...(what && { what: what }), ...(where && { where: where }), - ...(favorite !== undefined && { - favorite: favorite - ? MediaFilterCoreFavoriteSelection.Favorite - : MediaFilterCoreFavoriteSelection.NotFavorite, - }), + ...(favorite !== undefined && { favorite: favorite }), }; } protected render(): TemplateResult | void { + if (!this._mediaMetadataController) { + return; + } + const areEvents = !!( this.view?.query && MediaQueriesClassifier.areEventQueries(this.view.query) ); @@ -294,28 +416,82 @@ export class FrigateCardMediaFilter extends LitElement { const managerCapabilities = this.cameraManager?.getCapabilities(); // Which media controls are shown depends on the view. - const controls: MediaFilterControls = { - what: areEvents, - where: areEvents, - favorite: areEvents - ? !!managerCapabilities?.canFavoriteEvents - : areRecordings - ? !!managerCapabilities?.canFavoriteRecordings - : false, - }; - const defaults = this._getDefaultsFromView(); + const showFavoriteControl = areEvents + ? !!managerCapabilities?.canFavoriteEvents + : areRecordings + ? !!managerCapabilities?.canFavoriteRecordings + : false; - return html` - `; + return html` + + + + + + ${areEvents && this._mediaMetadataController.whatOptions.length + ? html` + ` + : ''} + ${areEvents && this._mediaMetadataController.whereOptions.length + ? html` + ` + : ''} + ${showFavoriteControl + ? html` + + + ` + : ''}`; } static get styles(): CSSResultGroup { @@ -328,9 +504,9 @@ export class MediaMetadataController implements ReactiveController { protected _hass: HomeAssistant; protected _cameraManager: CameraManager; - public whenOptions: ValueLabel[] = []; - public whatOptions: ValueLabel[] = []; - public whereOptions: ValueLabel[] = []; + public whenOptions: SelectOption[] = []; + public whatOptions: SelectOption[] = []; + public whereOptions: SelectOption[] = []; constructor( host: ReactiveControllerHost, @@ -343,6 +519,10 @@ export class MediaMetadataController implements ReactiveController { host.addController(this); } + protected _dateRangeToString(when: DateRange): string { + return `${formatDate(when.start)},${formatDate(when.end)}`; + } + async hostConnected() { let metadata: MediaMetadata | null; try { @@ -375,15 +555,15 @@ export class MediaMetadataController implements ReactiveController { yearMonths.forEach((yearMonth) => { monthStarts.push(parse(yearMonth, 'yyyy-MM', new Date())); }); - this.whenOptions = monthStarts - .sort() - .map((monthStart) => ({ + this.whenOptions = orderBy(monthStarts, (date) => date.getTime(), 'desc').map( + (monthStart) => ({ label: format(monthStart, 'MMMM yyyy'), - value: { - selection: MediaFilterCoreWhen.Custom, - custom: { start: monthStart, end: endOfMonth(monthStart) }, - }, - })); + value: this._dateRangeToString({ + start: monthStart, + end: endOfMonth(monthStart), + }), + }), + ); } this._host.requestUpdate(); } diff --git a/src/components/select.ts b/src/components/select.ts new file mode 100644 index 00000000..a56930a9 --- /dev/null +++ b/src/components/select.ts @@ -0,0 +1,89 @@ +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { property } from 'lit/decorators.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import selectStyle from '../scss/select.scss'; +import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic'; +import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin'; +import { grSelectElements } from '../scoped-elements/gr-select'; +import isEqual from 'lodash-es/isEqual'; +import '../scoped-elements/gr-select'; + +export interface SelectOption { + label: string; + value: string; +} + +export type SelectValues = string | string[]; + +type SelectElement = HTMLElement & { + value: SelectValues; +}; + +export class FrigateCardSelect extends ScopedRegistryHost(LitElement) { + @property({ attribute: false, hasChanged: contentsChanged }) + public options?: SelectOption[]; + + @property({ attribute: false, hasChanged: contentsChanged }) + public value?: SelectValues; + + @property({ attribute: true }) + public label?: string; + + @property({ attribute: true }) + public placeholder?: string; + + @property({ attribute: true, type: Boolean }) + public multiple?: boolean = false; + + @property({ attribute: true, type: Boolean }) + public clearable?: boolean = false; + + protected _previouslyReportedValue?: SelectValues; + protected _refSelect: Ref = createRef(); + + static elementDefinitions = { + ...grSelectElements, + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected _valueChangedHandler(_ev: CustomEvent<{ value: unknown }>): void { + const value: SelectValues | undefined = this._refSelect.value?.value; + // The underlying gr-select element is very sensitive and occasionally fires + // the change event even if the value has not actually changed. Prevent that + // from propagating upwards. + if (value !== undefined && !isEqual(this.value, value)) { + this.value = value; + dispatchFrigateCardEvent(this, 'select:change', value); + } + } + + protected render(): TemplateResult | void { + return html` + ${this.options?.map( + (option) => + html`${option.label}`, + )} + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(selectStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-select': FrigateCardSelect; + } +} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 3820cfd5..c2dafc51 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -384,6 +384,12 @@ "recordings": "Recordings" }, "not_favorite": "Not Favorite", + "select_camera": "Select camera...", + "select_favorite": "Select favorite...", + "select_media_type": "Select media type...", + "select_what": "Select what...", + "select_when": "Select when...", + "select_where": "Select where...", "what": "What", "when": "When", "whens": { diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index cd3b2146..dd735149 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -355,6 +355,12 @@ "recordings": "" }, "not_favorite": "", + "select_camera": "", + "select_favorite": "", + "select_media_type": "", + "select_what": "", + "select_when": "", + "select_where": "", "what": "", "when": "", "whens": { diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 6eeda043..24b125b3 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -355,6 +355,12 @@ "recordings": "" }, "not_favorite": "", + "select_camera": "", + "select_favorite": "", + "select_media_type": "", + "select_what": "", + "select_when": "", + "select_where": "", "what": "", "when": "", "whens": { diff --git a/src/scoped-elements/gr-select.js b/src/scoped-elements/gr-select.js new file mode 100644 index 00000000..ac71fe58 --- /dev/null +++ b/src/scoped-elements/gr-select.js @@ -0,0 +1,24 @@ +import { GrSelect } from '@graphiteds/core/components/gr-select'; +import { GrMenuItem } from '@graphiteds/core/components/gr-menu-item'; + +// It was difficult to find a multi-select web component that matches these criteria: +// - Open source. +// - Supports being in a ScopedRegistry out of the box (i.e. does not auto-register with customElements). +// - Looks attractive / compatible with mostly Material elements. +// - Styleable +// - Does not bloat output size considerably. + +// Web components evaluated (https://open-wc.org/guides/community/component-libraries/): +// - Material: No multiselect component. +// - Freshwords/@crayon: Considerable bloat in output due to i18n translations +// that are used by _other_ components. +// - Carbon Design System: Workable, but less moderm / Material-like. +// - UI5: Auto-registers globally. +// - Vaadin: Auto-registers globally. +// - Liquid: Not open source. +// - [Many others]: No multiselect component. + +export const grSelectElements = { + 'gr-select': GrSelect, + 'gr-menu-item': GrMenuItem, +}; diff --git a/src/scss/media-filter-core.scss b/src/scss/media-filter-core.scss deleted file mode 100644 index 101909bf..00000000 --- a/src/scss/media-filter-core.scss +++ /dev/null @@ -1,16 +0,0 @@ -:host { - height: 100%; - display: flex; - flex-direction: column; - overflow: auto; - - // Hide scrollbar: Firefox - scrollbar-width: none; - // Hide scrollbar: IE and Edge - -ms-overflow-style: none; -} - -/* Hide scrollbar for Chrome, Safari and Opera */ -:host::-webkit-scrollbar { - display: none; -} diff --git a/src/scss/media-filter.scss b/src/scss/media-filter.scss index 478abc91..ae4eec8c 100644 --- a/src/scss/media-filter.scss +++ b/src/scss/media-filter.scss @@ -1,4 +1,20 @@ :host { - display: block; + display: flex; + + flex-direction: column; + overflow: auto; + + // Hide scrollbar: Firefox + scrollbar-width: none; + // Hide scrollbar: IE and Edge + -ms-overflow-style: none; + height: 100%; + width: 300px; + margin: 5px; +} + +/* Hide scrollbar for Chrome, Safari and Opera */ +:host::-webkit-scrollbar { + display: none; } diff --git a/src/scss/select.scss b/src/scss/select.scss new file mode 100644 index 00000000..408f457d --- /dev/null +++ b/src/scss/select.scss @@ -0,0 +1,11 @@ +@use '@graphiteds/core/css/core.css'; + +:host { + // The graphite css (above) loads variables into :root, which is lost in the + // shadow DOM, so copy them into the host. + @extend :root; +} + +gr-select { + padding: 5px; +} diff --git a/src/view/view.ts b/src/view/view.ts index 040dcc45..d6896995 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,5 +1,5 @@ // Gallery: -// - TODO: Filter panel expands from right can occasionally 'stick' open. +// - TODO: View a recording from the gallery => timeline should span the hour. import { ViewContext } from 'view'; import { diff --git a/yarn.lock b/yarn.lock index c6950d31..4158f5c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -270,6 +270,13 @@ resolved "https://registry.yarnpkg.com/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.0.4.tgz#2ad00118a48bef30db0115ba873b335fa1559ae7" integrity sha512-w3E1nwSDKPJaBD3bq/aMvR3hgUJpyCrqX2jJbQAjE9g4xk9bRkeAIH1h1mBtdRFMpTzUlE3GX8r44lXEH0dVVQ== +"@duetds/date-picker@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@duetds/date-picker/-/date-picker-1.4.0.tgz#4bdbc15aa4b6307b8b156d8d9c5fdf67f6b6783b" + integrity sha512-jx4oSIrZAVsXYLyQGXbEmuo0gEXAqrNIGp3rG8L03XSxFFFca/bTpCj5v6yd48vVWOtAXD8y04S/+flOimra0A== + dependencies: + "@stencil/core" "^2.3.0" + "@egjs/hammerjs@^2.0.17": version "2.0.17" resolved "https://registry.yarnpkg.com/@egjs/hammerjs/-/hammerjs-2.0.17.tgz#5dc02af75a6a06e4c2db0202cae38c9263895124" @@ -338,6 +345,15 @@ dependencies: emojis-list "^3.0.0" +"@graphiteds/core@^1.9.6": + version "1.9.6" + resolved "https://registry.yarnpkg.com/@graphiteds/core/-/core-1.9.6.tgz#bbfb862a917b52d3610a09b2b8168bf9428e4007" + integrity sha512-aJ8f17wj7qPbVXH13ZR+xSztN45H0pH46I2cBV4NKnDO3xrqf88VLnOU7USGTJZ53FqgL/UGBAJfv5WjbU0dHw== + dependencies: + "@duetds/date-picker" "^1.4.0" + "@popperjs/core" "^2.11.5" + "@stencil/core" "^2.20.0" + "@humanwhocodes/config-array@^0.10.4": version "0.10.4" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.4.tgz#01e7366e57d2ad104feea63e72248f22015c520c" @@ -410,6 +426,19 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@lit-labs/scoped-registry-mixin@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@lit-labs/scoped-registry-mixin/-/scoped-registry-mixin-1.0.1.tgz#0ad266c029f1eb385711d2cd26252baadf145a5d" + integrity sha512-7aKnBKb5izcTjICO4VdeQLRYpZoB8FepJjG7TmE2oe0cU8cb4MUA4NFCjze+vbE+wP/55xv4dAMFOuJN9EG26w== + dependencies: + "@lit/reactive-element" "^1.0.0" + lit "^2.0.0" + +"@lit-labs/ssr-dom-shim@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.0.0.tgz#427e19a2765681fd83411cd72c55ba80a01e0523" + integrity sha512-ic93MBXfApIFTrup4a70M/+ddD8xdt2zxxj9sRwHQzhS9ag/syqkD8JPdTXsc1gUy2K8TTirhlCqyTEM/sifNw== + "@lit-labs/task@^1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@lit-labs/task/-/task-1.1.3.tgz#1215ae8ca746ecc895813463b81b2ce4b823098a" @@ -417,6 +446,13 @@ dependencies: "@lit/reactive-element" "^1.1.0" +"@lit/reactive-element@^1.0.0", "@lit/reactive-element@^1.6.0": + version "1.6.1" + resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.6.1.tgz#0d958b6d479d0e3db5fc1132ecc4fa84be3f0b93" + integrity sha512-va15kYZr7KZNNPZdxONGQzpUr+4sxVu7V/VG7a8mRfPPXUyhEYj5RzXCQmGrlP3tAh0L3HHm5AjBMFYRqlM9SA== + dependencies: + "@lit-labs/ssr-dom-shim" "^1.0.0" + "@lit/reactive-element@^1.1.0", "@lit/reactive-element@^1.3.0", "@lit/reactive-element@^1.4.0": version "1.4.1" resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.4.1.tgz#3f587eec5708692135bc9e94cf396130604979f3" @@ -443,6 +479,11 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@popperjs/core@^2.11.5": + version "2.11.6" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45" + integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw== + "@rollup/plugin-babel@^5.3.1": version "5.3.1" resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283" @@ -516,6 +557,11 @@ estree-walker "^2.0.1" picomatch "^2.2.2" +"@stencil/core@^2.20.0", "@stencil/core@^2.3.0": + version "2.22.2" + resolved "https://registry.yarnpkg.com/@stencil/core/-/core-2.22.2.tgz#f518b6bfae85352c72cd07fcaf2f2b61e0581fd9" + integrity sha512-r+vbxsGNcBaV1VDOYW25lv4QfXTlNoIb5GpUX7rZ+cr59yqYCZC5tlV+IzX6YgHKW62ulCc9M3RYtTfHtNbNNw== + "@trysound/sax@0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" @@ -2079,6 +2125,22 @@ lit-html@^2.2.0, lit-html@^2.3.0: dependencies: "@types/trusted-types" "^2.0.2" +lit-html@^2.6.0: + version "2.6.1" + resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.6.1.tgz#eb29f0b0c2ab54ea77379db11fc011b0c71f1cda" + integrity sha512-Z3iw+E+3KKFn9t2YKNjsXNEu/LRLI98mtH/C6lnFg7kvaqPIzPn124Yd4eT/43lyqrejpc5Wb6BHq3fdv4S8Rw== + dependencies: + "@types/trusted-types" "^2.0.2" + +lit@^2.0.0: + version "2.6.1" + resolved "https://registry.yarnpkg.com/lit/-/lit-2.6.1.tgz#5951a2098b9bde5b328c73b55c15fdc0eefd96d7" + integrity sha512-DT87LD64f8acR7uVp7kZfhLRrHkfC/N4BVzAtnw9Yg8087mbBJ//qedwdwX0kzDbxgPccWRW6mFwGbRQIxy0pw== + dependencies: + "@lit/reactive-element" "^1.6.0" + lit-element "^3.2.0" + lit-html "^2.6.0" + lit@^2.1.1, lit@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/lit/-/lit-2.3.1.tgz#2cf1c2042da1e44c7a7cc72dff2d72303fd26f48" From aa6d990ee8c30c695d12ff079b3cedac903a59fc Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 6 Feb 2023 20:31:02 -0800 Subject: [PATCH 73/74] Minor fixes and upgrades. --- .gitignore | 1 + package.json | 2 +- src/camera-manager/frigate/requests.ts | 5 +- src/scss/media-filter.scss | 4 + src/scss/select.scss | 4 - yarn.lock | 8885 +++++++++++++++--------- 6 files changed, 5491 insertions(+), 3410 deletions(-) diff --git a/.gitignore b/.gitignore index 68327c31..cb2e0455 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /.rpt2_cache/ package-lock.json /dist +/.yarn/ .env .envrc diff --git a/package.json b/package.json index 838c052f..d7731871 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "vis-timeline": "^7.7.0", "vis-util": "^5.0.2", "xss": "^1.0.14", - "zod": "^3.19.0" + "zod": "^3.20.2" }, "devDependencies": { "@babel/core": "^7.19.0", diff --git a/src/camera-manager/frigate/requests.ts b/src/camera-manager/frigate/requests.ts index d3a4542b..3cf751ba 100644 --- a/src/camera-manager/frigate/requests.ts +++ b/src/camera-manager/frigate/requests.ts @@ -27,7 +27,7 @@ export const getRecordingsSummary = async ( clientID: string, camera_name: string, ): Promise => { - return await homeAssistantWSRequest( + return (await homeAssistantWSRequest( hass, recordingSummarySchema, { @@ -37,7 +37,8 @@ export const getRecordingsSummary = async ( timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }, true, - ); + // See: https://github.com/colinhacks/zod/pull/1752 + )) as RecordingSummary; }; export interface NativeFrigateRecordingSegmentsQuery { diff --git a/src/scss/media-filter.scss b/src/scss/media-filter.scss index ae4eec8c..46ad7beb 100644 --- a/src/scss/media-filter.scss +++ b/src/scss/media-filter.scss @@ -18,3 +18,7 @@ :host::-webkit-scrollbar { display: none; } + +frigate-card-select { + padding: 5px; +} \ No newline at end of file diff --git a/src/scss/select.scss b/src/scss/select.scss index 408f457d..ad238004 100644 --- a/src/scss/select.scss +++ b/src/scss/select.scss @@ -5,7 +5,3 @@ // shadow DOM, so copy them into the host. @extend :root; } - -gr-select { - padding: 5px; -} diff --git a/yarn.lock b/yarn.lock index 4158f5c4..790427da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1,3403 +1,5482 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@ampproject/remapping@^2.1.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" - integrity sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w== - dependencies: - "@jridgewell/gen-mapping" "^0.1.0" - "@jridgewell/trace-mapping" "^0.3.9" - -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" - integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q== - dependencies: - "@babel/highlight" "^7.18.6" - -"@babel/compat-data@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.19.0.tgz#2a592fd89bacb1fcde68de31bee4f2f2dacb0e86" - integrity sha512-y5rqgTTPTmaF5e2nVhOxw+Ur9HDJLsWb6U/KpgUzRZEdPfE6VOubXBKLdbcUTijzRptednSBDQbYZBOSqJxpJw== - -"@babel/core@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.19.0.tgz#d2f5f4f2033c00de8096be3c9f45772563e150c3" - integrity sha512-reM4+U7B9ss148rh2n1Qs9ASS+w94irYXga7c2jaQv9RVzpS7Mv1a9rnYYwuDa45G+DkORt9g6An2k/V4d9LbQ== - dependencies: - "@ampproject/remapping" "^2.1.0" - "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.19.0" - "@babel/helper-compilation-targets" "^7.19.0" - "@babel/helper-module-transforms" "^7.19.0" - "@babel/helpers" "^7.19.0" - "@babel/parser" "^7.19.0" - "@babel/template" "^7.18.10" - "@babel/traverse" "^7.19.0" - "@babel/types" "^7.19.0" - convert-source-map "^1.7.0" - debug "^4.1.0" - gensync "^1.0.0-beta.2" - json5 "^2.2.1" - semver "^6.3.0" - -"@babel/generator@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.19.0.tgz#785596c06425e59334df2ccee63ab166b738419a" - integrity sha512-S1ahxf1gZ2dpoiFgA+ohK9DIpz50bJ0CWs7Zlzb54Z4sG8qmdIrGrVqmy1sAtTVRb+9CU6U8VqT9L0Zj7hxHVg== - dependencies: - "@babel/types" "^7.19.0" - "@jridgewell/gen-mapping" "^0.3.2" - jsesc "^2.5.1" - -"@babel/helper-annotate-as-pure@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz#eaa49f6f80d5a33f9a5dd2276e6d6e451be0a6bb" - integrity sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-compilation-targets@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.0.tgz#537ec8339d53e806ed422f1e06c8f17d55b96bb0" - integrity sha512-Ai5bNWXIvwDvWM7njqsG3feMlL9hCVQsPYXodsZyLwshYkZVJt59Gftau4VrE8S9IT9asd2uSP1hG6wCNw+sXA== - dependencies: - "@babel/compat-data" "^7.19.0" - "@babel/helper-validator-option" "^7.18.6" - browserslist "^4.20.2" - semver "^6.3.0" - -"@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.19.0.tgz#bfd6904620df4e46470bae4850d66be1054c404b" - integrity sha512-NRz8DwF4jT3UfrmUoZjd0Uph9HQnP30t7Ash+weACcyNkiYTywpIjDBgReJMKgr+n86sn2nPVVmJ28Dm053Kqw== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.19.0" - "@babel/helper-member-expression-to-functions" "^7.18.9" - "@babel/helper-optimise-call-expression" "^7.18.6" - "@babel/helper-replace-supers" "^7.18.9" - "@babel/helper-split-export-declaration" "^7.18.6" - -"@babel/helper-environment-visitor@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be" - integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg== - -"@babel/helper-function-name@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz#941574ed5390682e872e52d3f38ce9d1bef4648c" - integrity sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w== - dependencies: - "@babel/template" "^7.18.10" - "@babel/types" "^7.19.0" - -"@babel/helper-hoist-variables@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678" - integrity sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-member-expression-to-functions@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz#1531661e8375af843ad37ac692c132841e2fd815" - integrity sha512-RxifAh2ZoVU67PyKIO4AMi1wTenGfMR/O/ae0CCRqwgBAt5v7xjdtRw7UoSbsreKrQn5t7r89eruK/9JjYHuDg== - dependencies: - "@babel/types" "^7.18.9" - -"@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz#1e3ebdbbd08aad1437b428c50204db13c5a3ca6e" - integrity sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-module-transforms@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.19.0.tgz#309b230f04e22c58c6a2c0c0c7e50b216d350c30" - integrity sha512-3HBZ377Fe14RbLIA+ac3sY4PTgpxHVkFrESaWhoI5PuyXPBBX8+C34qblV9G89ZtycGJCmCI/Ut+VUDK4bltNQ== - dependencies: - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-module-imports" "^7.18.6" - "@babel/helper-simple-access" "^7.18.6" - "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/helper-validator-identifier" "^7.18.6" - "@babel/template" "^7.18.10" - "@babel/traverse" "^7.19.0" - "@babel/types" "^7.19.0" - -"@babel/helper-optimise-call-expression@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz#9369aa943ee7da47edab2cb4e838acf09d290ffe" - integrity sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz#4796bb14961521f0f8715990bee2fb6e51ce21bf" - integrity sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw== - -"@babel/helper-replace-supers@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.18.9.tgz#1092e002feca980fbbb0bd4d51b74a65c6a500e6" - integrity sha512-dNsWibVI4lNT6HiuOIBr1oyxo40HvIVmbwPUm3XZ7wMh4k2WxrxTqZwSqw/eEmXDS9np0ey5M2bz9tBmO9c+YQ== - dependencies: - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-member-expression-to-functions" "^7.18.9" - "@babel/helper-optimise-call-expression" "^7.18.6" - "@babel/traverse" "^7.18.9" - "@babel/types" "^7.18.9" - -"@babel/helper-simple-access@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.18.6.tgz#d6d8f51f4ac2978068df934b569f08f29788c7ea" - integrity sha512-iNpIgTgyAvDQpDj76POqg+YEt8fPxx3yaNBg3S30dxNKm2SWfYhD0TGrK/Eu9wHpUW63VQU894TsTg+GLbUa1g== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-split-export-declaration@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz#7367949bc75b20c6d5a5d4a97bba2824ae8ef075" - integrity sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-string-parser@^7.18.10": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz#181f22d28ebe1b3857fa575f5c290b1aaf659b56" - integrity sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw== - -"@babel/helper-validator-identifier@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076" - integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g== - -"@babel/helper-validator-option@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz#bf0d2b5a509b1f336099e4ff36e1a63aa5db4db8" - integrity sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw== - -"@babel/helpers@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.19.0.tgz#f30534657faf246ae96551d88dd31e9d1fa1fc18" - integrity sha512-DRBCKGwIEdqY3+rPJgG/dKfQy9+08rHIAJx8q2p+HSWP87s2HCrQmaAMMyMll2kIXKCW0cO1RdQskx15Xakftg== - dependencies: - "@babel/template" "^7.18.10" - "@babel/traverse" "^7.19.0" - "@babel/types" "^7.19.0" - -"@babel/highlight@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf" - integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g== - dependencies: - "@babel/helper-validator-identifier" "^7.18.6" - chalk "^2.0.0" - js-tokens "^4.0.0" - -"@babel/parser@^7.18.10", "@babel/parser@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.19.0.tgz#497fcafb1d5b61376959c1c338745ef0577aa02c" - integrity sha512-74bEXKX2h+8rrfQUfsBfuZZHzsEs6Eql4pqy/T4Nn6Y9wNPggQOqD6z6pn5Bl8ZfysKouFZT/UXEH94ummEeQw== - -"@babel/plugin-proposal-class-properties@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz#b110f59741895f7ec21a6fff696ec46265c446a3" - integrity sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-proposal-decorators@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.19.0.tgz#5a3bc0699ee34117c73c960a5396ffce104c4eaa" - integrity sha512-Bo5nOSjiJccjv00+BrDkmfeBLBi2B0qe8ygj24KdL8VdwtZz+710NCwehF+x/Ng+0mkHx5za2eAofmvVFLF4Fg== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.19.0" - "@babel/helper-plugin-utils" "^7.19.0" - "@babel/helper-replace-supers" "^7.18.9" - "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/plugin-syntax-decorators" "^7.19.0" - -"@babel/plugin-syntax-decorators@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.19.0.tgz#5f13d1d8fce96951bea01a10424463c9a5b3a599" - integrity sha512-xaBZUEDntt4faL1yN8oIFlhfXeQAWJW7CLKYsHTUqriCUbj8xOra8bfxxKGi/UwExPFBuPdH4XfHc9rGQhrVkQ== - dependencies: - "@babel/helper-plugin-utils" "^7.19.0" - -"@babel/template@^7.18.10": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71" - integrity sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA== - dependencies: - "@babel/code-frame" "^7.18.6" - "@babel/parser" "^7.18.10" - "@babel/types" "^7.18.10" - -"@babel/traverse@^7.18.9", "@babel/traverse@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.19.0.tgz#eb9c561c7360005c592cc645abafe0c3c4548eed" - integrity sha512-4pKpFRDh+utd2mbRC8JLnlsMUii3PMHjpL6a0SZ4NMZy7YFP9aXORxEhdMVOc9CpWtDF09IkciQLEhK7Ml7gRA== - dependencies: - "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.19.0" - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.19.0" - "@babel/helper-hoist-variables" "^7.18.6" - "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/parser" "^7.19.0" - "@babel/types" "^7.19.0" - debug "^4.1.0" - globals "^11.1.0" - -"@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.19.0.tgz#75f21d73d73dc0351f3368d28db73465f4814600" - integrity sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA== - dependencies: - "@babel/helper-string-parser" "^7.18.10" - "@babel/helper-validator-identifier" "^7.18.6" - to-fast-properties "^2.0.0" - -"@cycjimmy/jsmpeg-player@^6.0.4": - version "6.0.4" - resolved "https://registry.yarnpkg.com/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.0.4.tgz#2ad00118a48bef30db0115ba873b335fa1559ae7" - integrity sha512-w3E1nwSDKPJaBD3bq/aMvR3hgUJpyCrqX2jJbQAjE9g4xk9bRkeAIH1h1mBtdRFMpTzUlE3GX8r44lXEH0dVVQ== - -"@duetds/date-picker@^1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@duetds/date-picker/-/date-picker-1.4.0.tgz#4bdbc15aa4b6307b8b156d8d9c5fdf67f6b6783b" - integrity sha512-jx4oSIrZAVsXYLyQGXbEmuo0gEXAqrNIGp3rG8L03XSxFFFca/bTpCj5v6yd48vVWOtAXD8y04S/+flOimra0A== - dependencies: - "@stencil/core" "^2.3.0" - -"@egjs/hammerjs@^2.0.17": - version "2.0.17" - resolved "https://registry.yarnpkg.com/@egjs/hammerjs/-/hammerjs-2.0.17.tgz#5dc02af75a6a06e4c2db0202cae38c9263895124" - integrity sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A== - dependencies: - "@types/hammerjs" "^2.0.36" - -"@eslint/eslintrc@^1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.1.tgz#de0807bfeffc37b964a7d0400e0c348ce5a2543d" - integrity sha512-OhSY22oQQdw3zgPOOwdoj01l/Dzl1Z+xyUP33tkSN+aqyEhymJCcPHyXt+ylW8FSe0TfRC2VG+ROQOapD0aZSQ== - dependencies: - ajv "^6.12.4" - debug "^4.3.2" - espree "^9.4.0" - globals "^13.15.0" - ignore "^5.2.0" - import-fresh "^3.2.1" - js-yaml "^4.1.0" - minimatch "^3.1.2" - strip-json-comments "^3.1.1" - -"@formatjs/ecma402-abstract@1.11.4": - version "1.11.4" - resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.4.tgz#b962dfc4ae84361f9f08fbce411b4e4340930eda" - integrity sha512-EBikYFp2JCdIfGEb5G9dyCkTGDmC57KSHhRQOC3aYxoPWVZvfWCDjZwkGYHN7Lis/fmuWl906bnNTJifDQ3sXw== - dependencies: - "@formatjs/intl-localematcher" "0.2.25" - tslib "^2.1.0" - -"@formatjs/fast-memoize@1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-1.2.1.tgz#e6f5aee2e4fd0ca5edba6eba7668e2d855e0fc21" - integrity sha512-Rg0e76nomkz3vF9IPlKeV+Qynok0r7YZjL6syLz4/urSg0IbjPZCB/iYUMNsYA643gh4mgrX3T7KEIFIxJBQeg== - dependencies: - tslib "^2.1.0" - -"@formatjs/icu-messageformat-parser@2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.1.0.tgz#a54293dd7f098d6a6f6a084ab08b6d54a3e8c12d" - integrity sha512-Qxv/lmCN6hKpBSss2uQ8IROVnta2r9jd3ymUEIjm2UyIkUCHVcbUVRGL/KS/wv7876edvsPe+hjHVJ4z8YuVaw== - dependencies: - "@formatjs/ecma402-abstract" "1.11.4" - "@formatjs/icu-skeleton-parser" "1.3.6" - tslib "^2.1.0" - -"@formatjs/icu-skeleton-parser@1.3.6": - version "1.3.6" - resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.6.tgz#4ce8c0737d6f07b735288177049e97acbf2e8964" - integrity sha512-I96mOxvml/YLrwU2Txnd4klA7V8fRhb6JG/4hm3VMNmeJo1F03IpV2L3wWt7EweqNLES59SZ4d6hVOPCSf80Bg== - dependencies: - "@formatjs/ecma402-abstract" "1.11.4" - tslib "^2.1.0" - -"@formatjs/intl-localematcher@0.2.25": - version "0.2.25" - resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.2.25.tgz#60892fe1b271ec35ba07a2eb018a2dd7bca6ea3a" - integrity sha512-YmLcX70BxoSopLFdLr1Ds99NdlTI2oWoLbaUW2M406lxOIPzE1KQhRz2fPUkq34xVZQaihCoU29h0KK7An3bhA== - dependencies: - tslib "^2.1.0" - -"@formatjs/intl-utils@^3.8.4": - version "3.8.4" - resolved "https://registry.yarnpkg.com/@formatjs/intl-utils/-/intl-utils-3.8.4.tgz#291baac91001db428fc3275c515a3e40fbe95945" - integrity sha512-j5C6NyfKevIxsfLK8KwO1C0vvP7k1+h4A9cFpc+cr6mEwCc1sPkr17dzh0Ke6k9U5pQccAQoXdcNBl3IYa4+ZQ== - dependencies: - emojis-list "^3.0.0" - -"@graphiteds/core@^1.9.6": - version "1.9.6" - resolved "https://registry.yarnpkg.com/@graphiteds/core/-/core-1.9.6.tgz#bbfb862a917b52d3610a09b2b8168bf9428e4007" - integrity sha512-aJ8f17wj7qPbVXH13ZR+xSztN45H0pH46I2cBV4NKnDO3xrqf88VLnOU7USGTJZ53FqgL/UGBAJfv5WjbU0dHw== - dependencies: - "@duetds/date-picker" "^1.4.0" - "@popperjs/core" "^2.11.5" - "@stencil/core" "^2.20.0" - -"@humanwhocodes/config-array@^0.10.4": - version "0.10.4" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.4.tgz#01e7366e57d2ad104feea63e72248f22015c520c" - integrity sha512-mXAIHxZT3Vcpg83opl1wGlVZ9xydbfZO3r5YfRSH6Gpp2J/PfdBP0wbDa2sO6/qRbcalpoevVyW6A/fI6LfeMw== - dependencies: - "@humanwhocodes/object-schema" "^1.2.1" - debug "^4.1.1" - minimatch "^3.0.4" - -"@humanwhocodes/gitignore-to-minimatch@^1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz#316b0a63b91c10e53f242efb4ace5c3b34e8728d" - integrity sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA== - -"@humanwhocodes/module-importer@^1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" - integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== - -"@humanwhocodes/object-schema@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" - integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== - -"@jridgewell/gen-mapping@^0.1.0": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" - integrity sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w== - dependencies: - "@jridgewell/set-array" "^1.0.0" - "@jridgewell/sourcemap-codec" "^1.4.10" - -"@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2": - version "0.3.2" - resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9" - integrity sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A== - dependencies: - "@jridgewell/set-array" "^1.0.1" - "@jridgewell/sourcemap-codec" "^1.4.10" - "@jridgewell/trace-mapping" "^0.3.9" - -"@jridgewell/resolve-uri@^3.0.3": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" - integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== - -"@jridgewell/set-array@^1.0.0", "@jridgewell/set-array@^1.0.1": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" - integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== - -"@jridgewell/source-map@^0.3.2": - version "0.3.2" - resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.2.tgz#f45351aaed4527a298512ec72f81040c998580fb" - integrity sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw== - dependencies: - "@jridgewell/gen-mapping" "^0.3.0" - "@jridgewell/trace-mapping" "^0.3.9" - -"@jridgewell/sourcemap-codec@^1.4.10": - version "1.4.14" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" - integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== - -"@jridgewell/trace-mapping@^0.3.9": - version "0.3.15" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz#aba35c48a38d3fd84b37e66c9c0423f9744f9774" - integrity sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g== - dependencies: - "@jridgewell/resolve-uri" "^3.0.3" - "@jridgewell/sourcemap-codec" "^1.4.10" - -"@lit-labs/scoped-registry-mixin@^1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@lit-labs/scoped-registry-mixin/-/scoped-registry-mixin-1.0.1.tgz#0ad266c029f1eb385711d2cd26252baadf145a5d" - integrity sha512-7aKnBKb5izcTjICO4VdeQLRYpZoB8FepJjG7TmE2oe0cU8cb4MUA4NFCjze+vbE+wP/55xv4dAMFOuJN9EG26w== - dependencies: - "@lit/reactive-element" "^1.0.0" - lit "^2.0.0" - -"@lit-labs/ssr-dom-shim@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.0.0.tgz#427e19a2765681fd83411cd72c55ba80a01e0523" - integrity sha512-ic93MBXfApIFTrup4a70M/+ddD8xdt2zxxj9sRwHQzhS9ag/syqkD8JPdTXsc1gUy2K8TTirhlCqyTEM/sifNw== - -"@lit-labs/task@^1.1.3": - version "1.1.3" - resolved "https://registry.yarnpkg.com/@lit-labs/task/-/task-1.1.3.tgz#1215ae8ca746ecc895813463b81b2ce4b823098a" - integrity sha512-Ta5rdbmuDaYAZwlkjezCoqcnn+J54XBAGI0X4PsCjycY1nJ/Ntbd+YY/eMdfnu777qrvznuqanTa4r1ptPXXxA== - dependencies: - "@lit/reactive-element" "^1.1.0" - -"@lit/reactive-element@^1.0.0", "@lit/reactive-element@^1.6.0": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.6.1.tgz#0d958b6d479d0e3db5fc1132ecc4fa84be3f0b93" - integrity sha512-va15kYZr7KZNNPZdxONGQzpUr+4sxVu7V/VG7a8mRfPPXUyhEYj5RzXCQmGrlP3tAh0L3HHm5AjBMFYRqlM9SA== - dependencies: - "@lit-labs/ssr-dom-shim" "^1.0.0" - -"@lit/reactive-element@^1.1.0", "@lit/reactive-element@^1.3.0", "@lit/reactive-element@^1.4.0": - version "1.4.1" - resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.4.1.tgz#3f587eec5708692135bc9e94cf396130604979f3" - integrity sha512-qDv4851VFSaBWzpS02cXHclo40jsbAjRXnebNXpm0uVg32kCneZPo9RYVQtrTNICtZ+1wAYHu1ZtxWSWMbKrBw== - -"@nodelib/fs.scandir@2.1.5": - version "2.1.5" - resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" - integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== - dependencies: - "@nodelib/fs.stat" "2.0.5" - run-parallel "^1.1.9" - -"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" - integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== - -"@nodelib/fs.walk@^1.2.3": - version "1.2.8" - resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" - integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== - dependencies: - "@nodelib/fs.scandir" "2.1.5" - fastq "^1.6.0" - -"@popperjs/core@^2.11.5": - version "2.11.6" - resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45" - integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw== - -"@rollup/plugin-babel@^5.3.1": - version "5.3.1" - resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283" - integrity sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q== - dependencies: - "@babel/helper-module-imports" "^7.10.4" - "@rollup/pluginutils" "^3.1.0" - -"@rollup/plugin-commonjs@^22.0.2": - version "22.0.2" - resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-22.0.2.tgz#ee8ca8415cda30d383b4096aad5222435b4b69b6" - integrity sha512-//NdP6iIwPbMTcazYsiBMbJW7gfmpHom33u1beiIoHDEM0Q9clvtQB1T0efvMqHeKsGohiHo97BCPCkBXdscwg== - dependencies: - "@rollup/pluginutils" "^3.1.0" - commondir "^1.0.1" - estree-walker "^2.0.1" - glob "^7.1.6" - is-reference "^1.2.1" - magic-string "^0.25.7" - resolve "^1.17.0" - -"@rollup/plugin-image@^2.1.1": - version "2.1.1" - resolved "https://registry.yarnpkg.com/@rollup/plugin-image/-/plugin-image-2.1.1.tgz#898d6b59ac0025d7971ef45640ab330cb0663b0c" - integrity sha512-AgP4U85zuQJdUopLUCM+hTf45RepgXeTb8EJsleExVy99dIoYpt3ZlDYJdKmAc2KLkNntCDg6BPJvgJU3uGF+g== - dependencies: - "@rollup/pluginutils" "^3.1.0" - mini-svg-data-uri "^1.2.3" - -"@rollup/plugin-json@^4.1.0": - version "4.1.0" - resolved "https://registry.yarnpkg.com/@rollup/plugin-json/-/plugin-json-4.1.0.tgz#54e09867ae6963c593844d8bd7a9c718294496f3" - integrity sha512-yfLbTdNS6amI/2OpmbiBoW12vngr5NW2jCJVZSBEz+H5KfUJZ2M7sDjk0U6GOOdCWFVScShte29o9NezJ53TPw== - dependencies: - "@rollup/pluginutils" "^3.0.8" - -"@rollup/plugin-node-resolve@^13.3.0": - version "13.3.0" - resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.3.0.tgz#da1c5c5ce8316cef96a2f823d111c1e4e498801c" - integrity sha512-Lus8rbUo1eEcnS4yTFKLZrVumLPY+YayBdWXgFSHYhTT2iJbMhoaaBL3xl5NCdeRytErGr8tZ0L71BMRmnlwSw== - dependencies: - "@rollup/pluginutils" "^3.1.0" - "@types/resolve" "1.17.1" - deepmerge "^4.2.2" - is-builtin-module "^3.1.0" - is-module "^1.0.0" - resolve "^1.19.0" - -"@rollup/plugin-replace@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@rollup/plugin-replace/-/plugin-replace-4.0.0.tgz#e34c457d6a285f0213359740b43f39d969b38a67" - integrity sha512-+rumQFiaNac9y64OHtkHGmdjm7us9bo1PlbgQfdihQtuNxzjpaB064HbRnewUOggLQxVCCyINfStkgmBeQpv1g== - dependencies: - "@rollup/pluginutils" "^3.1.0" - magic-string "^0.25.7" - -"@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b" - integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg== - dependencies: - "@types/estree" "0.0.39" - estree-walker "^1.0.1" - picomatch "^2.2.2" - -"@rollup/pluginutils@^4.1.2": - version "4.2.1" - resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d" - integrity sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ== - dependencies: - estree-walker "^2.0.1" - picomatch "^2.2.2" - -"@stencil/core@^2.20.0", "@stencil/core@^2.3.0": - version "2.22.2" - resolved "https://registry.yarnpkg.com/@stencil/core/-/core-2.22.2.tgz#f518b6bfae85352c72cd07fcaf2f2b61e0581fd9" - integrity sha512-r+vbxsGNcBaV1VDOYW25lv4QfXTlNoIb5GpUX7rZ+cr59yqYCZC5tlV+IzX6YgHKW62ulCc9M3RYtTfHtNbNNw== - -"@trysound/sax@0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" - integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== - -"@ts-morph/common@~0.12.3": - version "0.12.3" - resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.12.3.tgz#a96e250217cd30e480ab22ec6a0ebbe65fd784ff" - integrity sha512-4tUmeLyXJnJWvTFOKtcNJ1yh0a3SsTLi2MUoyj8iUNznFRN1ZquaNe7Oukqrnki2FzZkm0J9adCNLDZxUzvj+w== - dependencies: - fast-glob "^3.2.7" - minimatch "^3.0.4" - mkdirp "^1.0.4" - path-browserify "^1.0.1" - -"@types/bluebird@^3.5.36": - version "3.5.36" - resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.36.tgz#00d9301d4dc35c2f6465a8aec634bb533674c652" - integrity sha512-HBNx4lhkxN7bx6P0++W8E289foSu8kO8GCk2unhuVggO+cE7rh9DhZUyPhUxNRG9m+5B5BTKxZQ5ZP92x/mx9Q== - -"@types/cssnano@^5.0.0": - version "5.1.0" - resolved "https://registry.yarnpkg.com/@types/cssnano/-/cssnano-5.1.0.tgz#a83529c148dccc984bccbe33563941f0fa6bd1e9" - integrity sha512-ikR+18UpFGgvaWSur4og6SJYF/6QEYHXvrIt36dp81p1MG3cAPTYDMBJGeyWa3LCnqEbgNMHKRb+FP0NrXtoWQ== - dependencies: - cssnano "*" - -"@types/estree@*": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.0.tgz#5fb2e536c1ae9bf35366eed879e827fa59ca41c2" - integrity sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ== - -"@types/estree@0.0.39": - version "0.0.39" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" - integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== - -"@types/hammerjs@^2.0.36": - version "2.0.41" - resolved "https://registry.yarnpkg.com/@types/hammerjs/-/hammerjs-2.0.41.tgz#f6ecf57d1b12d2befcce00e928a6a097c22980aa" - integrity sha512-ewXv/ceBaJprikMcxCmWU1FKyMAQ2X7a9Gtmzw8fcg2kIePI1crERDM818W+XYrxqdBBOdlf2rm137bU+BltCA== - -"@types/json-schema@^7.0.9": - version "7.0.11" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" - integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== - -"@types/json5@^0.0.29": - version "0.0.29" - resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" - integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== - -"@types/lodash-es@^4.17.5": - version "4.17.6" - resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.6.tgz#c2ed4c8320ffa6f11b43eb89e9eaeec65966a0a0" - integrity sha512-R+zTeVUKDdfoRxpAryaQNRKk3105Rrgx2CFRClIgRGaqDTdjsm8h6IYA8ir584W3ePzkZfst5xIgDwYrlh9HLg== - dependencies: - "@types/lodash" "*" - -"@types/lodash@*": - version "4.14.184" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.184.tgz#23f96cd2a21a28e106dc24d825d4aa966de7a9fe" - integrity sha512-RoZphVtHbxPZizt4IcILciSWiC6dcn+eZ8oX9IWEYfDMcocdd42f7NPI6fQj+6zI8y4E0L7gu2pcZKLGTRaV9Q== - -"@types/node@*": - version "18.7.16" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.16.tgz#0eb3cce1e37c79619943d2fd903919fc30850601" - integrity sha512-EQHhixfu+mkqHMZl1R2Ovuvn47PUw18azMJOTwSZr9/fhzHNGXAJ0ma0dayRVchprpCj0Kc1K1xKoWaATWF1qg== - -"@types/parse-json@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" - integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== - -"@types/resolve@1.17.1": - version "1.17.1" - resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6" - integrity sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw== - dependencies: - "@types/node" "*" - -"@types/trusted-types@^2.0.2": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756" - integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg== - -"@typescript-eslint/eslint-plugin@^5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.36.2.tgz#6df092a20e0f9ec748b27f293a12cb39d0c1fe4d" - integrity sha512-OwwR8LRwSnI98tdc2z7mJYgY60gf7I9ZfGjN5EjCwwns9bdTuQfAXcsjSB2wSQ/TVNYSGKf4kzVXbNGaZvwiXw== - dependencies: - "@typescript-eslint/scope-manager" "5.36.2" - "@typescript-eslint/type-utils" "5.36.2" - "@typescript-eslint/utils" "5.36.2" - debug "^4.3.4" - functional-red-black-tree "^1.0.1" - ignore "^5.2.0" - regexpp "^3.2.0" - semver "^7.3.7" - tsutils "^3.21.0" - -"@typescript-eslint/parser@^5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.36.2.tgz#3ddf323d3ac85a25295a55fcb9c7a49ab4680ddd" - integrity sha512-qS/Kb0yzy8sR0idFspI9Z6+t7mqk/oRjnAYfewG+VN73opAUvmYL3oPIMmgOX6CnQS6gmVIXGshlb5RY/R22pA== - dependencies: - "@typescript-eslint/scope-manager" "5.36.2" - "@typescript-eslint/types" "5.36.2" - "@typescript-eslint/typescript-estree" "5.36.2" - debug "^4.3.4" - -"@typescript-eslint/scope-manager@5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.36.2.tgz#a75eb588a3879ae659514780831370642505d1cd" - integrity sha512-cNNP51L8SkIFSfce8B1NSUBTJTu2Ts4nWeWbFrdaqjmn9yKrAaJUBHkyTZc0cL06OFHpb+JZq5AUHROS398Orw== - dependencies: - "@typescript-eslint/types" "5.36.2" - "@typescript-eslint/visitor-keys" "5.36.2" - -"@typescript-eslint/type-utils@5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.36.2.tgz#752373f4babf05e993adf2cd543a763632826391" - integrity sha512-rPQtS5rfijUWLouhy6UmyNquKDPhQjKsaKH0WnY6hl/07lasj8gPaH2UD8xWkePn6SC+jW2i9c2DZVDnL+Dokw== - dependencies: - "@typescript-eslint/typescript-estree" "5.36.2" - "@typescript-eslint/utils" "5.36.2" - debug "^4.3.4" - tsutils "^3.21.0" - -"@typescript-eslint/types@5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.36.2.tgz#a5066e500ebcfcee36694186ccc57b955c05faf9" - integrity sha512-9OJSvvwuF1L5eS2EQgFUbECb99F0mwq501w0H0EkYULkhFa19Qq7WFbycdw1PexAc929asupbZcgjVIe6OK/XQ== - -"@typescript-eslint/typescript-estree@5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.36.2.tgz#0c93418b36c53ba0bc34c61fe9405c4d1d8fe560" - integrity sha512-8fyH+RfbKc0mTspfuEjlfqA4YywcwQK2Amcf6TDOwaRLg7Vwdu4bZzyvBZp4bjt1RRjQ5MDnOZahxMrt2l5v9w== - dependencies: - "@typescript-eslint/types" "5.36.2" - "@typescript-eslint/visitor-keys" "5.36.2" - debug "^4.3.4" - globby "^11.1.0" - is-glob "^4.0.3" - semver "^7.3.7" - tsutils "^3.21.0" - -"@typescript-eslint/utils@5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.36.2.tgz#b01a76f0ab244404c7aefc340c5015d5ce6da74c" - integrity sha512-uNcopWonEITX96v9pefk9DC1bWMdkweeSsewJ6GeC7L6j2t0SJywisgkr9wUTtXk90fi2Eljj90HSHm3OGdGRg== - dependencies: - "@types/json-schema" "^7.0.9" - "@typescript-eslint/scope-manager" "5.36.2" - "@typescript-eslint/types" "5.36.2" - "@typescript-eslint/typescript-estree" "5.36.2" - eslint-scope "^5.1.1" - eslint-utils "^3.0.0" - -"@typescript-eslint/visitor-keys@5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.36.2.tgz#2f8f78da0a3bad3320d2ac24965791ac39dace5a" - integrity sha512-BtRvSR6dEdrNt7Net2/XDjbYKU5Ml6GqJgVfXT0CxTCJlnIqK7rAGreuWKMT2t8cFUT2Msv5oxw0GMRD7T5J7A== - dependencies: - "@typescript-eslint/types" "5.36.2" - eslint-visitor-keys "^3.3.0" - -acorn-jsx@^5.3.2: - version "5.3.2" - resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" - integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== - -acorn@^8.5.0, acorn@^8.8.0: - version "8.8.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8" - integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== - -ajv@^6.10.0, ajv@^6.12.4: - version "6.12.6" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" - integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== - dependencies: - fast-deep-equal "^3.1.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - -ansi-regex@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - -ansi-styles@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" - integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== - dependencies: - color-convert "^1.9.0" - -ansi-styles@^4.0.0, ansi-styles@^4.1.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" - integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== - dependencies: - color-convert "^2.0.1" - -anymatch@~3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" - integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== - dependencies: - normalize-path "^3.0.0" - picomatch "^2.0.4" - -argparse@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" - integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== - -array-includes@^3.1.4: - version "3.1.5" - resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.5.tgz#2c320010db8d31031fd2a5f6b3bbd4b1aad31bdb" - integrity sha512-iSDYZMMyTPkiFasVqfuAQnWAYcvO/SeBSCGKePoEthjp4LEMTe4uLc7b025o4jAZpHhihh8xPo99TNWUWWkGDQ== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.19.5" - get-intrinsic "^1.1.1" - is-string "^1.0.7" - -array-union@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" - integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== - -array.prototype.flat@^1.2.5: - version "1.3.0" - resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.0.tgz#0b0c1567bf57b38b56b4c97b8aa72ab45e4adc7b" - integrity sha512-12IUEkHsAhA4DY5s0FPgNXIdc8VRSqD9Zp78a5au9abH/SOBrsp082JOWFNTjkMozh8mqcdiKuaLGhPeYztxSw== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - es-abstract "^1.19.2" - es-shim-unscopables "^1.0.0" - -balanced-match@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" - integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== - -binary-extensions@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" - integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== - -boolbase@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" - integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== - -brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - -braces@^3.0.2, braces@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" - integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== - dependencies: - fill-range "^7.0.1" - -browserslist@^4.0.0, browserslist@^4.16.6, browserslist@^4.20.2, browserslist@^4.20.3: - version "4.21.3" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.3.tgz#5df277694eb3c48bc5c4b05af3e8b7e09c5a6d1a" - integrity sha512-898rgRXLAyRkM1GryrrBHGkqA5hlpkV5MhtZwg9QXeiyLUYs2k00Un05aX5l2/yJIOObYKOpS2JNo8nJDE7fWQ== - dependencies: - caniuse-lite "^1.0.30001370" - electron-to-chromium "^1.4.202" - node-releases "^2.0.6" - update-browserslist-db "^1.0.5" - -buffer-from@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" - integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== - -builtin-modules@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6" - integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== - -call-bind@^1.0.0, call-bind@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" - integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== - dependencies: - function-bind "^1.1.1" - get-intrinsic "^1.0.2" - -callsites@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" - integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== - -caniuse-api@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0" - integrity sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw== - dependencies: - browserslist "^4.0.0" - caniuse-lite "^1.0.0" - lodash.memoize "^4.1.2" - lodash.uniq "^4.5.0" - -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001370: - version "1.0.30001393" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001393.tgz#1aa161e24fe6af2e2ccda000fc2b94be0b0db356" - integrity sha512-N/od11RX+Gsk+1qY/jbPa0R6zJupEa0lxeBG598EbrtblxVCTJsQwbRBm6+V+rxpc5lHKdsXb9RY83cZIPLseA== - -chalk@^2.0.0: - version "2.4.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" - integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -chalk@^4.0.0: - version "4.1.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" - integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - -"chokidar@>=3.0.0 <4.0.0": - version "3.5.3" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" - integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== - dependencies: - anymatch "~3.1.2" - braces "~3.0.2" - glob-parent "~5.1.2" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.6.0" - optionalDependencies: - fsevents "~2.3.2" - -cliui@^7.0.2: - version "7.0.4" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" - integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.0" - wrap-ansi "^7.0.0" - -code-block-writer@^11.0.0: - version "11.0.3" - resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-11.0.3.tgz#9eec2993edfb79bfae845fbc093758c0a0b73b76" - integrity sha512-NiujjUFB4SwScJq2bwbYUtXbZhBSlY6vYzm++3Q6oC+U+injTqfPYFK8wS9COOmb2lueqp0ZRB4nK1VYeHgNyw== - -color-convert@^1.9.0: - version "1.9.3" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" - integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== - dependencies: - color-name "1.1.3" - -color-convert@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" - integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== - dependencies: - color-name "~1.1.4" - -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== - -color-name@~1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - -colord@^2.9.1: - version "2.9.3" - resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43" - integrity sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw== - -commander@^2.20.0, commander@^2.20.3: - version "2.20.3" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" - integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== - -commander@^6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" - integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== - -commander@^7.2.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" - integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== - -commondir@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" - integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== - -component-emitter@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" - integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== - -confusing-browser-globals@^1.0.10: - version "1.0.11" - resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz#ae40e9b57cdd3915408a2805ebd3a5585608dc81" - integrity sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA== - -convert-source-map@^1.7.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369" - integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA== - dependencies: - safe-buffer "~5.1.1" - -cosmiconfig@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.0.1.tgz#714d756522cace867867ccb4474c5d01bbae5d6d" - integrity sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ== - dependencies: - "@types/parse-json" "^4.0.0" - import-fresh "^3.2.1" - parse-json "^5.0.0" - path-type "^4.0.0" - yaml "^1.10.0" - -cross-spawn@^7.0.2: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== - dependencies: - path-key "^3.1.0" - shebang-command "^2.0.0" - which "^2.0.1" - -crypto@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/crypto/-/crypto-1.0.1.tgz#2af1b7cad8175d24c8a1b0778255794a21803037" - integrity sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig== - -css-declaration-sorter@^6.3.0: - version "6.3.1" - resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-6.3.1.tgz#be5e1d71b7a992433fb1c542c7a1b835e45682ec" - integrity sha512-fBffmak0bPAnyqc/HO8C3n2sHrp9wcqQz6ES9koRF2/mLOVAx9zIQ3Y7R29sYCteTPqMCwns4WYQoCX91Xl3+w== - -css-select@^4.1.3: - version "4.3.0" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.3.0.tgz#db7129b2846662fd8628cfc496abb2b59e41529b" - integrity sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ== - dependencies: - boolbase "^1.0.0" - css-what "^6.0.1" - domhandler "^4.3.1" - domutils "^2.8.0" - nth-check "^2.0.1" - -css-tree@^1.1.2, css-tree@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" - integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q== - dependencies: - mdn-data "2.0.14" - source-map "^0.6.1" - -css-what@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" - integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== - -cssesc@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" - integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== - -cssfilter@0.0.10: - version "0.0.10" - resolved "https://registry.yarnpkg.com/cssfilter/-/cssfilter-0.0.10.tgz#c6d2672632a2e5c83e013e6864a42ce8defd20ae" - integrity sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw== - -cssnano-preset-default@^5.2.12: - version "5.2.12" - resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-5.2.12.tgz#ebe6596ec7030e62c3eb2b3c09f533c0644a9a97" - integrity sha512-OyCBTZi+PXgylz9HAA5kHyoYhfGcYdwFmyaJzWnzxuGRtnMw/kR6ilW9XzlzlRAtB6PLT/r+prYgkef7hngFew== - dependencies: - css-declaration-sorter "^6.3.0" - cssnano-utils "^3.1.0" - postcss-calc "^8.2.3" - postcss-colormin "^5.3.0" - postcss-convert-values "^5.1.2" - postcss-discard-comments "^5.1.2" - postcss-discard-duplicates "^5.1.0" - postcss-discard-empty "^5.1.1" - postcss-discard-overridden "^5.1.0" - postcss-merge-longhand "^5.1.6" - postcss-merge-rules "^5.1.2" - postcss-minify-font-values "^5.1.0" - postcss-minify-gradients "^5.1.1" - postcss-minify-params "^5.1.3" - postcss-minify-selectors "^5.2.1" - postcss-normalize-charset "^5.1.0" - postcss-normalize-display-values "^5.1.0" - postcss-normalize-positions "^5.1.1" - postcss-normalize-repeat-style "^5.1.1" - postcss-normalize-string "^5.1.0" - postcss-normalize-timing-functions "^5.1.0" - postcss-normalize-unicode "^5.1.0" - postcss-normalize-url "^5.1.0" - postcss-normalize-whitespace "^5.1.1" - postcss-ordered-values "^5.1.3" - postcss-reduce-initial "^5.1.0" - postcss-reduce-transforms "^5.1.0" - postcss-svgo "^5.1.0" - postcss-unique-selectors "^5.1.1" - -cssnano-utils@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/cssnano-utils/-/cssnano-utils-3.1.0.tgz#95684d08c91511edfc70d2636338ca37ef3a6861" - integrity sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA== - -cssnano@*, cssnano@^5.0.15: - version "5.1.13" - resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-5.1.13.tgz#83d0926e72955332dc4802a7070296e6258efc0a" - integrity sha512-S2SL2ekdEz6w6a2epXn4CmMKU4K3KpcyXLKfAYc9UQQqJRkD/2eLUG0vJ3Db/9OvO5GuAdgXw3pFbR6abqghDQ== - dependencies: - cssnano-preset-default "^5.2.12" - lilconfig "^2.0.3" - yaml "^1.10.2" - -csso@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529" - integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA== - dependencies: - css-tree "^1.1.2" - -custom-card-helpers@^1.9.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/custom-card-helpers/-/custom-card-helpers-1.9.0.tgz#dac7bb7a531101f4c83096b26a8be8ccad70d8c0" - integrity sha512-5IW4OXq3MiiCqDvqeu+MYsM1NmntKW1WfJhyJFsdP2tbzqEI4BOnqRz2qzdp08lE4QLVhYfRLwe0WAqgQVNeFg== - dependencies: - "@formatjs/intl-utils" "^3.8.4" - home-assistant-js-websocket "^6.0.1" - intl-messageformat "^9.11.1" - lit "^2.1.1" - rollup "^2.63.0" - superstruct "^0.15.3" - typescript "^4.5.4" - -date-fns-tz@^1.3.7: - version "1.3.7" - resolved "https://registry.yarnpkg.com/date-fns-tz/-/date-fns-tz-1.3.7.tgz#e8e9d2aaceba5f1cc0e677631563081fdcb0e69a" - integrity sha512-1t1b8zyJo+UI8aR+g3iqr5fkUHWpd58VBx8J/ZSQ+w7YrGlw80Ag4sA86qkfCXRBLmMc4I2US+aPMd4uKvwj5g== - -date-fns@^2.29.2: - version "2.29.2" - resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.2.tgz#0d4b3d0f3dff0f920820a070920f0d9662c51931" - integrity sha512-0VNbwmWJDS/G3ySwFSJA3ayhbURMTJLtwM2DTxf9CWondCnh6DTNlO9JgRSq6ibf4eD0lfMJNBxUdEAHHix+bA== - -debug@^2.6.9: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - -debug@^3.2.7: - version "3.2.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== - dependencies: - ms "^2.1.1" - -debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - -decode-uri-component@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" - integrity sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og== - -deep-is@^0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" - integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== - -deepmerge@^4.2.2: - version "4.2.2" - resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" - integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== - -define-lazy-prop@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" - integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== - -define-properties@^1.1.3, define-properties@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.4.tgz#0b14d7bd7fbeb2f3572c3a7eda80ea5d57fb05b1" - integrity sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA== - dependencies: - has-property-descriptors "^1.0.0" - object-keys "^1.1.1" - -dir-glob@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" - integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== - dependencies: - path-type "^4.0.0" - -doctrine@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" - integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== - dependencies: - esutils "^2.0.2" - -doctrine@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" - integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== - dependencies: - esutils "^2.0.2" - -dom-serializer@^1.0.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" - integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag== - dependencies: - domelementtype "^2.0.1" - domhandler "^4.2.0" - entities "^2.0.0" - -domelementtype@^2.0.1, domelementtype@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" - integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== - -domhandler@^4.2.0, domhandler@^4.3.1: - version "4.3.1" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" - integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ== - dependencies: - domelementtype "^2.2.0" - -domutils@^2.8.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" - integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== - dependencies: - dom-serializer "^1.0.1" - domelementtype "^2.2.0" - domhandler "^4.2.0" - -electron-to-chromium@^1.4.202: - version "1.4.247" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.247.tgz#cc93859bc5fc521f611656e65ce17eae26a0fd3d" - integrity sha512-FLs6R4FQE+1JHM0hh3sfdxnYjKvJpHZyhQDjc2qFq/xFvmmRt/TATNToZhrcGUFzpF2XjeiuozrA8lI0PZmYYw== - -embla-carousel-wheel-gestures@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/embla-carousel-wheel-gestures/-/embla-carousel-wheel-gestures-3.0.0.tgz#a68ad0f76c2bec876354939b7edac00245433e19" - integrity sha512-OxMyRkDxiSHJy1DCInYjpq93q8Xz311ajCEUp/pvWH/odDc+KODLAtVI06U5NiPNIUJWj+IlZvLSVZUtpdTBEQ== - dependencies: - wheel-gestures "^2.2.5" - -embla-carousel@^7.0.2: - version "7.0.2" - resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-7.0.2.tgz#49660d75d26604de1ca1bb0c0be13e7cae65b0ff" - integrity sha512-6SHKbLJRvcjunb0VT1Aw7YwK0Dw8ecqEa+ppT1j+kHGEg5I4WvUd8SjRkc1NAuQK0PTBD0d567UCmzEAngeAxw== - -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== - -emojis-list@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" - integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== - -entities@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" - integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== - -error-ex@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" - integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== - dependencies: - is-arrayish "^0.2.1" - -es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19.5: - version "1.20.2" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.20.2.tgz#8495a07bc56d342a3b8ea3ab01bd986700c2ccb3" - integrity sha512-XxXQuVNrySBNlEkTYJoDNFe5+s2yIOpzq80sUHEdPdQr0S5nTLz4ZPPPswNIpKseDDUS5yghX1gfLIHQZ1iNuQ== - dependencies: - call-bind "^1.0.2" - es-to-primitive "^1.2.1" - function-bind "^1.1.1" - function.prototype.name "^1.1.5" - get-intrinsic "^1.1.2" - get-symbol-description "^1.0.0" - has "^1.0.3" - has-property-descriptors "^1.0.0" - has-symbols "^1.0.3" - internal-slot "^1.0.3" - is-callable "^1.2.4" - is-negative-zero "^2.0.2" - is-regex "^1.1.4" - is-shared-array-buffer "^1.0.2" - is-string "^1.0.7" - is-weakref "^1.0.2" - object-inspect "^1.12.2" - object-keys "^1.1.1" - object.assign "^4.1.4" - regexp.prototype.flags "^1.4.3" - string.prototype.trimend "^1.0.5" - string.prototype.trimstart "^1.0.5" - unbox-primitive "^1.0.2" - -es-shim-unscopables@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz#702e632193201e3edf8713635d083d378e510241" - integrity sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w== - dependencies: - has "^1.0.3" - -es-to-primitive@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" - integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== - dependencies: - is-callable "^1.1.4" - is-date-object "^1.0.1" - is-symbol "^1.0.2" - -escalade@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" - integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== - -escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== - -escape-string-regexp@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" - integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== - -eslint-config-airbnb-base@^15.0.0: - version "15.0.0" - resolved "https://registry.yarnpkg.com/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz#6b09add90ac79c2f8d723a2580e07f3925afd236" - integrity sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig== - dependencies: - confusing-browser-globals "^1.0.10" - object.assign "^4.1.2" - object.entries "^1.1.5" - semver "^6.3.0" - -eslint-config-prettier@^8.5.0: - version "8.5.0" - resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz#5a81680ec934beca02c7b1a61cf8ca34b66feab1" - integrity sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q== - -eslint-import-resolver-node@^0.3.6: - version "0.3.6" - resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz#4048b958395da89668252001dbd9eca6b83bacbd" - integrity sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw== - dependencies: - debug "^3.2.7" - resolve "^1.20.0" - -eslint-module-utils@^2.7.3: - version "2.7.4" - resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.7.4.tgz#4f3e41116aaf13a20792261e61d3a2e7e0583974" - integrity sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA== - dependencies: - debug "^3.2.7" - -eslint-plugin-import@^2.25.4: - version "2.26.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz#f812dc47be4f2b72b478a021605a59fc6fe8b88b" - integrity sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA== - dependencies: - array-includes "^3.1.4" - array.prototype.flat "^1.2.5" - debug "^2.6.9" - doctrine "^2.1.0" - eslint-import-resolver-node "^0.3.6" - eslint-module-utils "^2.7.3" - has "^1.0.3" - is-core-module "^2.8.1" - is-glob "^4.0.3" - minimatch "^3.1.2" - object.values "^1.1.5" - resolve "^1.22.0" - tsconfig-paths "^3.14.1" - -eslint-plugin-prettier@^4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz#651cbb88b1dab98bfd42f017a12fa6b2d993f94b" - integrity sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ== - dependencies: - prettier-linter-helpers "^1.0.0" - -eslint-scope@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" - integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== - dependencies: - esrecurse "^4.3.0" - estraverse "^4.1.1" - -eslint-scope@^7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.1.tgz#fff34894c2f65e5226d3041ac480b4513a163642" - integrity sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw== - dependencies: - esrecurse "^4.3.0" - estraverse "^5.2.0" - -eslint-utils@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-3.0.0.tgz#8aebaface7345bb33559db0a1f13a1d2d48c3672" - integrity sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA== - dependencies: - eslint-visitor-keys "^2.0.0" - -eslint-visitor-keys@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" - integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== - -eslint-visitor-keys@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" - integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== - -eslint@^8.23.0: - version "8.23.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.23.0.tgz#a184918d288820179c6041bb3ddcc99ce6eea040" - integrity sha512-pBG/XOn0MsJcKcTRLr27S5HpzQo4kLr+HjLQIyK4EiCsijDl/TB+h5uEuJU6bQ8Edvwz1XWOjpaP2qgnXGpTcA== - dependencies: - "@eslint/eslintrc" "^1.3.1" - "@humanwhocodes/config-array" "^0.10.4" - "@humanwhocodes/gitignore-to-minimatch" "^1.0.2" - "@humanwhocodes/module-importer" "^1.0.1" - ajv "^6.10.0" - chalk "^4.0.0" - cross-spawn "^7.0.2" - debug "^4.3.2" - doctrine "^3.0.0" - escape-string-regexp "^4.0.0" - eslint-scope "^7.1.1" - eslint-utils "^3.0.0" - eslint-visitor-keys "^3.3.0" - espree "^9.4.0" - esquery "^1.4.0" - esutils "^2.0.2" - fast-deep-equal "^3.1.3" - file-entry-cache "^6.0.1" - find-up "^5.0.0" - functional-red-black-tree "^1.0.1" - glob-parent "^6.0.1" - globals "^13.15.0" - globby "^11.1.0" - grapheme-splitter "^1.0.4" - ignore "^5.2.0" - import-fresh "^3.0.0" - imurmurhash "^0.1.4" - is-glob "^4.0.0" - js-yaml "^4.1.0" - json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.4.1" - lodash.merge "^4.6.2" - minimatch "^3.1.2" - natural-compare "^1.4.0" - optionator "^0.9.1" - regexpp "^3.2.0" - strip-ansi "^6.0.1" - strip-json-comments "^3.1.0" - text-table "^0.2.0" - -espree@^9.4.0: - version "9.4.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.0.tgz#cd4bc3d6e9336c433265fc0aa016fc1aaf182f8a" - integrity sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw== - dependencies: - acorn "^8.8.0" - acorn-jsx "^5.3.2" - eslint-visitor-keys "^3.3.0" - -esquery@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.4.0.tgz#2148ffc38b82e8c7057dfed48425b3e61f0f24a5" - integrity sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w== - dependencies: - estraverse "^5.1.0" - -esrecurse@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" - integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== - dependencies: - estraverse "^5.2.0" - -estraverse@^4.1.1: - version "4.3.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" - integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== - -estraverse@^5.1.0, estraverse@^5.2.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" - integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== - -estree-walker@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" - integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg== - -estree-walker@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" - integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== - -esutils@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" - integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== - -eventemitter3@^4.0.4: - version "4.0.7" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" - integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== - -fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" - integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== - -fast-diff@^1.1.2: - version "1.2.0" - resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03" - integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== - -fast-glob@^3.2.7, fast-glob@^3.2.9: - version "3.2.12" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" - integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w== - dependencies: - "@nodelib/fs.stat" "^2.0.2" - "@nodelib/fs.walk" "^1.2.3" - glob-parent "^5.1.2" - merge2 "^1.3.0" - micromatch "^4.0.4" - -fast-json-stable-stringify@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" - integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== - -fast-levenshtein@^2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" - integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== - -fastq@^1.6.0: - version "1.13.0" - resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c" - integrity sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw== - dependencies: - reusify "^1.0.4" - -file-entry-cache@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" - integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== - dependencies: - flat-cache "^3.0.4" - -fill-range@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" - integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== - dependencies: - to-regex-range "^5.0.1" - -filter-obj@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b" - integrity sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ== - -find-cache-dir@^3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" - integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig== - dependencies: - commondir "^1.0.1" - make-dir "^3.0.2" - pkg-dir "^4.1.0" - -find-up@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" - integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== - dependencies: - locate-path "^5.0.0" - path-exists "^4.0.0" - -find-up@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" - integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== - dependencies: - locate-path "^6.0.0" - path-exists "^4.0.0" - -flat-cache@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" - integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg== - dependencies: - flatted "^3.1.0" - rimraf "^3.0.2" - -flatted@^3.1.0: - version "3.2.7" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" - integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== - -fs-extra@^10.0.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" - integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== - dependencies: - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^2.0.0" - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== - -fsevents@~2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" - integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== - -function-bind@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" - integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== - -function.prototype.name@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.5.tgz#cce0505fe1ffb80503e6f9e46cc64e46a12a9621" - integrity sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - es-abstract "^1.19.0" - functions-have-names "^1.2.2" - -functional-red-black-tree@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" - integrity sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g== - -functions-have-names@^1.2.2: - version "1.2.3" - resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" - integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== - -gensync@^1.0.0-beta.2: - version "1.0.0-beta.2" - resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" - integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== - -get-caller-file@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" - integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== - -get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1, get-intrinsic@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.2.tgz#336975123e05ad0b7ba41f152ee4aadbea6cf598" - integrity sha512-Jfm3OyCxHh9DJyc28qGk+JmfkpO41A4XkneDSujN9MDXrm4oDKdHvndhZ2dN94+ERNfkYJWDclW6k2L/ZGHjXA== - dependencies: - function-bind "^1.1.1" - has "^1.0.3" - has-symbols "^1.0.3" - -get-symbol-description@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6" - integrity sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw== - dependencies: - call-bind "^1.0.2" - get-intrinsic "^1.1.1" - -glob-parent@^5.1.2, glob-parent@~5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" - integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== - dependencies: - is-glob "^4.0.1" - -glob-parent@^6.0.1: - version "6.0.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" - integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== - dependencies: - is-glob "^4.0.3" - -glob@^7.1.3, glob@^7.1.6: - version "7.2.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" - integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.1.1" - once "^1.3.0" - path-is-absolute "^1.0.0" - -globals@^11.1.0: - version "11.12.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" - integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== - -globals@^13.15.0: - version "13.17.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-13.17.0.tgz#902eb1e680a41da93945adbdcb5a9f361ba69bd4" - integrity sha512-1C+6nQRb1GwGMKm2dH/E7enFAMxGTmGI7/dEdhy/DNelv85w9B72t3uc5frtMNXIbzrarJJ/lTCjcaZwbLJmyw== - dependencies: - type-fest "^0.20.2" - -globby@^11.1.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" - integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== - dependencies: - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.2.9" - ignore "^5.2.0" - merge2 "^1.4.1" - slash "^3.0.0" - -graceful-fs@^4.1.6, graceful-fs@^4.2.0: - version "4.2.10" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" - integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== - -grapheme-splitter@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" - integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== - -has-bigints@^1.0.1, has-bigints@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" - integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== - -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== - -has-flag@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" - integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== - -has-property-descriptors@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz#610708600606d36961ed04c196193b6a607fa861" - integrity sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ== - dependencies: - get-intrinsic "^1.1.1" - -has-symbols@^1.0.2, has-symbols@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" - integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== - -has-tostringtag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25" - integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ== - dependencies: - has-symbols "^1.0.2" - -has@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" - integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== - dependencies: - function-bind "^1.1.1" - -home-assistant-js-websocket@^6.0.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/home-assistant-js-websocket/-/home-assistant-js-websocket-6.1.1.tgz#87ba846753c4fb58a2e5ace6bb15a82689fd0735" - integrity sha512-TnZFzF4mn5F/v0XKUTK2GMQXrn/+eQpgaSDSELl6U0HSwSbFwRhGWLz330YT+hiKMspDflamsye//RPL+zwhDw== - -home-assistant-js-websocket@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/home-assistant-js-websocket/-/home-assistant-js-websocket-8.0.0.tgz#7111da312ac9fafb47d9bdfbcca8ee8fca4cd64d" - integrity sha512-H/JZDnlfXW3OBZK29YhaF3U/2maFcJSvxb0jKQBiN5i4XR+7ApYKALBBh2afLeiFQfy8Ys9AzSBY0kIeBW4Kzw== - -icss-utils@^5.0.0, icss-utils@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" - integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== - -ignore@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a" - integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ== - -immutable@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.1.0.tgz#f795787f0db780183307b9eb2091fcac1f6fafef" - integrity sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ== - -import-fresh@^3.0.0, import-fresh@^3.2.1: - version "3.3.0" - resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" - integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== - dependencies: - parent-module "^1.0.0" - resolve-from "^4.0.0" - -imurmurhash@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -internal-slot@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c" - integrity sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA== - dependencies: - get-intrinsic "^1.1.0" - has "^1.0.3" - side-channel "^1.0.4" - -intl-messageformat@^9.11.1: - version "9.13.0" - resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-9.13.0.tgz#97360b73bd82212e4f6005c712a4a16053165468" - integrity sha512-7sGC7QnSQGa5LZP7bXLDhVDtQOeKGeBFGHF2Y8LVBwYZoQZCgWeKoPGTa5GMG8g/TzDgeXuYJQis7Ggiw2xTOw== - dependencies: - "@formatjs/ecma402-abstract" "1.11.4" - "@formatjs/fast-memoize" "1.2.1" - "@formatjs/icu-messageformat-parser" "2.1.0" - tslib "^2.1.0" - -is-arrayish@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" - integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== - -is-bigint@^1.0.1: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" - integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg== - dependencies: - has-bigints "^1.0.1" - -is-binary-path@~2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" - integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== - dependencies: - binary-extensions "^2.0.0" - -is-boolean-object@^1.1.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719" - integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA== - dependencies: - call-bind "^1.0.2" - has-tostringtag "^1.0.0" - -is-builtin-module@^3.1.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.0.tgz#bb0310dfe881f144ca83f30100ceb10cf58835e0" - integrity sha512-phDA4oSGt7vl1n5tJvTWooWWAsXLY+2xCnxNqvKhGEzujg+A43wPlPOyDg3C8XQHN+6k/JTQWJ/j0dQh/qr+Hw== - dependencies: - builtin-modules "^3.3.0" - -is-callable@^1.1.4, is-callable@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945" - integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w== - -is-core-module@^2.8.1, is-core-module@^2.9.0: - version "2.10.0" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed" - integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg== - dependencies: - has "^1.0.3" - -is-date-object@^1.0.1: - version "1.0.5" - resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" - integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== - dependencies: - has-tostringtag "^1.0.0" - -is-docker@^2.0.0, is-docker@^2.1.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" - integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== - -is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== - -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== - -is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: - version "4.0.3" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" - integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== - dependencies: - is-extglob "^2.1.1" - -is-module@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" - integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g== - -is-negative-zero@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150" - integrity sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA== - -is-number-object@^1.0.4: - version "1.0.7" - resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc" - integrity sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ== - dependencies: - has-tostringtag "^1.0.0" - -is-number@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" - integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== - -is-reference@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7" - integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ== - dependencies: - "@types/estree" "*" - -is-regex@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" - integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== - dependencies: - call-bind "^1.0.2" - has-tostringtag "^1.0.0" - -is-shared-array-buffer@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz#8f259c573b60b6a32d4058a1a07430c0a7344c79" - integrity sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA== - dependencies: - call-bind "^1.0.2" - -is-string@^1.0.5, is-string@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" - integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg== - dependencies: - has-tostringtag "^1.0.0" - -is-symbol@^1.0.2, is-symbol@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" - integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg== - dependencies: - has-symbols "^1.0.2" - -is-weakref@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" - integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ== - dependencies: - call-bind "^1.0.2" - -is-wsl@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" - integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== - dependencies: - is-docker "^2.0.0" - -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== - -jest-worker@^26.2.1: - version "26.6.2" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed" - integrity sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ== - dependencies: - "@types/node" "*" - merge-stream "^2.0.0" - supports-color "^7.0.0" - -js-tokens@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" - integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== - -js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== - dependencies: - argparse "^2.0.1" - -jsesc@^2.5.1: - version "2.5.2" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" - integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== - -json-parse-even-better-errors@^2.3.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" - integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== - -json-schema-traverse@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" - integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== - -json-stable-stringify-without-jsonify@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" - integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== - -json5@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" - integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow== - dependencies: - minimist "^1.2.0" - -json5@^2.1.3: - version "2.2.3" - resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" - integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== - -json5@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c" - integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA== - -jsonfile@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" - integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== - dependencies: - universalify "^2.0.0" - optionalDependencies: - graceful-fs "^4.1.6" - -keycharm@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/keycharm/-/keycharm-0.4.0.tgz#8d684ea9cc01379a07fbddee33ff32d97f5ae2a7" - integrity sha512-TyQTtsabOVv3MeOpR92sIKk/br9wxS+zGj4BG7CR8YbK4jM3tyIBaF0zhzeBUMx36/Q/iQLOKKOT+3jOQtemRQ== - -levn@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" - integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== - dependencies: - prelude-ls "^1.2.1" - type-check "~0.4.0" - -lilconfig@^2.0.3: - version "2.0.6" - resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4" - integrity sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg== - -lines-and-columns@^1.1.6: - version "1.2.4" - resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" - integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== - -lit-element@^3.2.0: - version "3.2.2" - resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.2.2.tgz#d148ab6bf4c53a33f707a5168e087725499e5f2b" - integrity sha512-6ZgxBR9KNroqKb6+htkyBwD90XGRiqKDHVrW/Eh0EZ+l+iC+u+v+w3/BA5NGi4nizAVHGYvQBHUDuSmLjPp7NQ== - dependencies: - "@lit/reactive-element" "^1.3.0" - lit-html "^2.2.0" - -lit-html@^2.2.0, lit-html@^2.3.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.3.1.tgz#56f15104ea75c0a702904893e3409d0e89e2a2b9" - integrity sha512-FyKH6LTW6aBdkfNhNSHyZTnLgJSTe5hMk7HFtc/+DcN1w74C215q8B+Cfxc2OuIEpBNcEKxgF64qL8as30FDHA== - dependencies: - "@types/trusted-types" "^2.0.2" - -lit-html@^2.6.0: - version "2.6.1" - resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.6.1.tgz#eb29f0b0c2ab54ea77379db11fc011b0c71f1cda" - integrity sha512-Z3iw+E+3KKFn9t2YKNjsXNEu/LRLI98mtH/C6lnFg7kvaqPIzPn124Yd4eT/43lyqrejpc5Wb6BHq3fdv4S8Rw== - dependencies: - "@types/trusted-types" "^2.0.2" - -lit@^2.0.0: - version "2.6.1" - resolved "https://registry.yarnpkg.com/lit/-/lit-2.6.1.tgz#5951a2098b9bde5b328c73b55c15fdc0eefd96d7" - integrity sha512-DT87LD64f8acR7uVp7kZfhLRrHkfC/N4BVzAtnw9Yg8087mbBJ//qedwdwX0kzDbxgPccWRW6mFwGbRQIxy0pw== - dependencies: - "@lit/reactive-element" "^1.6.0" - lit-element "^3.2.0" - lit-html "^2.6.0" - -lit@^2.1.1, lit@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/lit/-/lit-2.3.1.tgz#2cf1c2042da1e44c7a7cc72dff2d72303fd26f48" - integrity sha512-TejktDR4mqG3qB32Y8Lm5Lye3c8SUehqz7qRsxe1PqGYL6me2Ef+jeQAEqh20BnnGncv4Yxy2njEIT0kzK1WCw== - dependencies: - "@lit/reactive-element" "^1.4.0" - lit-element "^3.2.0" - lit-html "^2.3.0" - -locate-path@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" - integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== - dependencies: - p-locate "^4.1.0" - -locate-path@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" - integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== - dependencies: - p-locate "^5.0.0" - -lodash-es@^4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" - integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== - -lodash.memoize@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" - integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag== - -lodash.merge@^4.6.2: - version "4.6.2" - resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" - integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== - -lodash.uniq@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" - integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ== - -lodash@^4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== - -lru-cache@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" - integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== - dependencies: - yallist "^4.0.0" - -magic-string@^0.25.7: - version "0.25.9" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c" - integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ== - dependencies: - sourcemap-codec "^1.4.8" - -make-dir@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" - integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== - dependencies: - semver "^6.0.0" - -mdn-data@2.0.14: - version "2.0.14" - resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" - integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== - -merge-stream@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" - integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== - -merge2@^1.3.0, merge2@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" - integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== - -micromatch@^4.0.4: - version "4.0.5" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" - integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== - dependencies: - braces "^3.0.2" - picomatch "^2.3.1" - -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.34: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -mime@>=2.4.6: - version "3.0.0" - resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" - integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== - -mini-svg-data-uri@^1.2.3: - version "1.4.4" - resolved "https://registry.yarnpkg.com/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz#8ab0aabcdf8c29ad5693ca595af19dd2ead09939" - integrity sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg== - -minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== - dependencies: - brace-expansion "^1.1.7" - -minimist@^1.2.0, minimist@^1.2.6: - version "1.2.6" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" - integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== - -mkdirp@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" - integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== - -moment@^2.29.4: - version "2.29.4" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" - integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== - -ms@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - -ms@^2.1.1: - version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - -nanoid@^3.3.4: - version "3.3.4" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" - integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== - -natural-compare@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" - integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== - -node-releases@^2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503" - integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg== - -normalize-path@^3.0.0, normalize-path@~3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" - integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== - -normalize-url@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" - integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== - -nth-check@^2.0.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" - integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== - dependencies: - boolbase "^1.0.0" - -object-inspect@^1.12.2, object-inspect@^1.9.0: - version "1.12.2" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" - integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== - -object-keys@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" - integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== - -object.assign@^4.1.2, object.assign@^4.1.4: - version "4.1.4" - resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.4.tgz#9673c7c7c351ab8c4d0b516f4343ebf4dfb7799f" - integrity sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - has-symbols "^1.0.3" - object-keys "^1.1.1" - -object.entries@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.5.tgz#e1acdd17c4de2cd96d5a08487cfb9db84d881861" - integrity sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - es-abstract "^1.19.1" - -object.values@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.5.tgz#959f63e3ce9ef108720333082131e4a459b716ac" - integrity sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - es-abstract "^1.19.1" - -once@^1.3.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== - dependencies: - wrappy "1" - -open@^8.4.0: - version "8.4.0" - resolved "https://registry.yarnpkg.com/open/-/open-8.4.0.tgz#345321ae18f8138f82565a910fdc6b39e8c244f8" - integrity sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q== - dependencies: - define-lazy-prop "^2.0.0" - is-docker "^2.1.1" - is-wsl "^2.2.0" - -opener@1: - version "1.5.2" - resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" - integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== - -optionator@^0.9.1: - version "0.9.1" - resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499" - integrity sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw== - dependencies: - deep-is "^0.1.3" - fast-levenshtein "^2.0.6" - levn "^0.4.1" - prelude-ls "^1.2.1" - type-check "^0.4.0" - word-wrap "^1.2.3" - -p-finally@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" - integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow== - -p-limit@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" - integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== - dependencies: - p-try "^2.0.0" - -p-limit@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" - integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== - dependencies: - yocto-queue "^0.1.0" - -p-locate@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" - integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== - dependencies: - p-limit "^2.2.0" - -p-locate@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" - integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== - dependencies: - p-limit "^3.0.2" - -p-queue@^6.6.2: - version "6.6.2" - resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" - integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ== - dependencies: - eventemitter3 "^4.0.4" - p-timeout "^3.2.0" - -p-timeout@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" - integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== - dependencies: - p-finally "^1.0.0" - -p-try@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" - integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== - -parent-module@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" - integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== - dependencies: - callsites "^3.0.0" - -parse-json@^5.0.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" - integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== - dependencies: - "@babel/code-frame" "^7.0.0" - error-ex "^1.3.1" - json-parse-even-better-errors "^2.3.0" - lines-and-columns "^1.1.6" - -path-browserify@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" - integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== - -path-exists@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" - integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== - -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== - -path-key@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" - integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== - -path-parse@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" - integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== - -path-type@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" - integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== - -picocolors@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" - integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== - -picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== - -pkg-dir@^4.1.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" - integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== - dependencies: - find-up "^4.0.0" - -postcss-calc@^8.2.3: - version "8.2.4" - resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-8.2.4.tgz#77b9c29bfcbe8a07ff6693dc87050828889739a5" - integrity sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q== - dependencies: - postcss-selector-parser "^6.0.9" - postcss-value-parser "^4.2.0" - -postcss-colormin@^5.3.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-5.3.0.tgz#3cee9e5ca62b2c27e84fce63affc0cfb5901956a" - integrity sha512-WdDO4gOFG2Z8n4P8TWBpshnL3JpmNmJwdnfP2gbk2qBA8PWwOYcmjmI/t3CmMeL72a7Hkd+x/Mg9O2/0rD54Pg== - dependencies: - browserslist "^4.16.6" - caniuse-api "^3.0.0" - colord "^2.9.1" - postcss-value-parser "^4.2.0" - -postcss-convert-values@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-5.1.2.tgz#31586df4e184c2e8890e8b34a0b9355313f503ab" - integrity sha512-c6Hzc4GAv95B7suy4udszX9Zy4ETyMCgFPUDtWjdFTKH1SE9eFY/jEpHSwTH1QPuwxHpWslhckUQWbNRM4ho5g== - dependencies: - browserslist "^4.20.3" - postcss-value-parser "^4.2.0" - -postcss-discard-comments@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz#8df5e81d2925af2780075840c1526f0660e53696" - integrity sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ== - -postcss-discard-duplicates@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz#9eb4fe8456706a4eebd6d3b7b777d07bad03e848" - integrity sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw== - -postcss-discard-empty@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz#e57762343ff7f503fe53fca553d18d7f0c369c6c" - integrity sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A== - -postcss-discard-overridden@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz#7e8c5b53325747e9d90131bb88635282fb4a276e" - integrity sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw== - -postcss-merge-longhand@^5.1.6: - version "5.1.6" - resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-5.1.6.tgz#f378a8a7e55766b7b644f48e5d8c789ed7ed51ce" - integrity sha512-6C/UGF/3T5OE2CEbOuX7iNO63dnvqhGZeUnKkDeifebY0XqkkvrctYSZurpNE902LDf2yKwwPFgotnfSoPhQiw== - dependencies: - postcss-value-parser "^4.2.0" - stylehacks "^5.1.0" - -postcss-merge-rules@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-5.1.2.tgz#7049a14d4211045412116d79b751def4484473a5" - integrity sha512-zKMUlnw+zYCWoPN6yhPjtcEdlJaMUZ0WyVcxTAmw3lkkN/NDMRkOkiuctQEoWAOvH7twaxUUdvBWl0d4+hifRQ== - dependencies: - browserslist "^4.16.6" - caniuse-api "^3.0.0" - cssnano-utils "^3.1.0" - postcss-selector-parser "^6.0.5" - -postcss-minify-font-values@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz#f1df0014a726083d260d3bd85d7385fb89d1f01b" - integrity sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-minify-gradients@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz#f1fe1b4f498134a5068240c2f25d46fcd236ba2c" - integrity sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw== - dependencies: - colord "^2.9.1" - cssnano-utils "^3.1.0" - postcss-value-parser "^4.2.0" - -postcss-minify-params@^5.1.3: - version "5.1.3" - resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-5.1.3.tgz#ac41a6465be2db735099bbd1798d85079a6dc1f9" - integrity sha512-bkzpWcjykkqIujNL+EVEPOlLYi/eZ050oImVtHU7b4lFS82jPnsCb44gvC6pxaNt38Els3jWYDHTjHKf0koTgg== - dependencies: - browserslist "^4.16.6" - cssnano-utils "^3.1.0" - postcss-value-parser "^4.2.0" - -postcss-minify-selectors@^5.2.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz#d4e7e6b46147b8117ea9325a915a801d5fe656c6" - integrity sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg== - dependencies: - postcss-selector-parser "^6.0.5" - -postcss-modules-extract-imports@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz#cda1f047c0ae80c97dbe28c3e76a43b88025741d" - integrity sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw== - -postcss-modules-local-by-default@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz#ebbb54fae1598eecfdf691a02b3ff3b390a5a51c" - integrity sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ== - dependencies: - icss-utils "^5.0.0" - postcss-selector-parser "^6.0.2" - postcss-value-parser "^4.1.0" - -postcss-modules-scope@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz#9ef3151456d3bbfa120ca44898dfca6f2fa01f06" - integrity sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg== - dependencies: - postcss-selector-parser "^6.0.4" - -postcss-modules-values@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz#d7c5e7e68c3bb3c9b27cbf48ca0bb3ffb4602c9c" - integrity sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ== - dependencies: - icss-utils "^5.0.0" - -postcss-normalize-charset@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz#9302de0b29094b52c259e9b2cf8dc0879879f0ed" - integrity sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg== - -postcss-normalize-display-values@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz#72abbae58081960e9edd7200fcf21ab8325c3da8" - integrity sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-normalize-positions@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz#ef97279d894087b59325b45c47f1e863daefbb92" - integrity sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-normalize-repeat-style@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz#e9eb96805204f4766df66fd09ed2e13545420fb2" - integrity sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-normalize-string@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz#411961169e07308c82c1f8c55f3e8a337757e228" - integrity sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-normalize-timing-functions@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz#d5614410f8f0b2388e9f240aa6011ba6f52dafbb" - integrity sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-normalize-unicode@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.0.tgz#3d23aede35e160089a285e27bf715de11dc9db75" - integrity sha512-J6M3MizAAZ2dOdSjy2caayJLQT8E8K9XjLce8AUQMwOrCvjCHv24aLC/Lps1R1ylOfol5VIDMaM/Lo9NGlk1SQ== - dependencies: - browserslist "^4.16.6" - postcss-value-parser "^4.2.0" - -postcss-normalize-url@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz#ed9d88ca82e21abef99f743457d3729a042adcdc" - integrity sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew== - dependencies: - normalize-url "^6.0.1" - postcss-value-parser "^4.2.0" - -postcss-normalize-whitespace@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz#08a1a0d1ffa17a7cc6efe1e6c9da969cc4493cfa" - integrity sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-ordered-values@^5.1.3: - version "5.1.3" - resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz#b6fd2bd10f937b23d86bc829c69e7732ce76ea38" - integrity sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ== - dependencies: - cssnano-utils "^3.1.0" - postcss-value-parser "^4.2.0" - -postcss-reduce-initial@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-5.1.0.tgz#fc31659ea6e85c492fb2a7b545370c215822c5d6" - integrity sha512-5OgTUviz0aeH6MtBjHfbr57tml13PuedK/Ecg8szzd4XRMbYxH4572JFG067z+FqBIf6Zp/d+0581glkvvWMFw== - dependencies: - browserslist "^4.16.6" - caniuse-api "^3.0.0" - -postcss-reduce-transforms@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz#333b70e7758b802f3dd0ddfe98bb1ccfef96b6e9" - integrity sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.0.9: - version "6.0.10" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d" - integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w== - dependencies: - cssesc "^3.0.0" - util-deprecate "^1.0.2" - -postcss-svgo@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-5.1.0.tgz#0a317400ced789f233a28826e77523f15857d80d" - integrity sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA== - dependencies: - postcss-value-parser "^4.2.0" - svgo "^2.7.0" - -postcss-unique-selectors@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz#a9f273d1eacd09e9aa6088f4b0507b18b1b541b6" - integrity sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA== - dependencies: - postcss-selector-parser "^6.0.5" - -postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" - integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== - -postcss@^8.4.5: - version "8.4.16" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.16.tgz#33a1d675fac39941f5f445db0de4db2b6e01d43c" - integrity sha512-ipHE1XBvKzm5xI7hiHCZJCSugxvsdq2mPnsq5+UF+VHCjiBvtDrlxJfMBToWaP9D5XlgNmcFGqoHmUn0EYEaRQ== - dependencies: - nanoid "^3.3.4" - picocolors "^1.0.0" - source-map-js "^1.0.2" - -prelude-ls@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" - integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== - -prettier-linter-helpers@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b" - integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w== - dependencies: - fast-diff "^1.1.2" - -prettier@^2.6.0: - version "2.7.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.7.1.tgz#e235806850d057f97bb08368a4f7d899f7760c64" - integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g== - -propagating-hammerjs@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/propagating-hammerjs/-/propagating-hammerjs-2.0.1.tgz#2ba5d491d8734944cc0ace9ad2ae9660232cde5d" - integrity sha512-PH3zG5whbSxMocphXJzVtvKr+vWAgfkqVvtuwjSJ/apmEACUoiw6auBAT5HYXpZOR0eGcTAfYG5Yl8h91O5Elg== - -punycode@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" - integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== - -query-string@^7.1.0: - version "7.1.1" - resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.1.tgz#754620669db978625a90f635f12617c271a088e1" - integrity sha512-MplouLRDHBZSG9z7fpuAAcI7aAYjDLhtsiVZsevsfaHWDS2IDdORKbSd1kWUA+V4zyva/HZoSfpwnYMMQDhb0w== - dependencies: - decode-uri-component "^0.2.0" - filter-obj "^1.1.0" - split-on-first "^1.0.0" - strict-uri-encode "^2.0.0" - -queue-microtask@^1.2.2: - version "1.2.3" - resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" - integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== - -quick-lru@^6.1.0: - version "6.1.1" - resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-6.1.1.tgz#f8e5bf9010376c126c80c1a62827a526c0e60adf" - integrity sha512-S27GBT+F0NTRiehtbrgaSE1idUAJ5bX8dPAQTdylEyNlrdcH5X4Lz7Edz3DYzecbsCluD5zO8ZNEe04z3D3u6Q== - -randombytes@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" - integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== - dependencies: - safe-buffer "^5.1.0" - -readdirp@~3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" - integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== - dependencies: - picomatch "^2.2.1" - -regexp.prototype.flags@^1.4.3: - version "1.4.3" - resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac" - integrity sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - functions-have-names "^1.2.2" - -regexpp@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" - integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== - -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== - -resolve-from@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" - integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== - -resolve@^1.17.0, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.21.0, resolve@^1.22.0: - version "1.22.1" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" - integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== - dependencies: - is-core-module "^2.9.0" - path-parse "^1.0.7" - supports-preserve-symlinks-flag "^1.0.0" - -reusify@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" - integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== - -rimraf@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - -rollup-plugin-git-info@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/rollup-plugin-git-info/-/rollup-plugin-git-info-1.0.0.tgz#f6d9f474718d9453eeb5049772a24ca4b9ac310e" - integrity sha512-AL2jzfDvOLze+Sakz3yuQjq9wY/wIj3j49yYnhuXtr6qP4qKzD039haPFa8uaLb0rzDBj2P5S8fWvJyYeIjOVw== - dependencies: - "@rollup/plugin-json" "^4.1.0" - -rollup-plugin-serve@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/rollup-plugin-serve/-/rollup-plugin-serve-2.0.1.tgz#79d2813baeae48888ebbc608418afc68f938eab8" - integrity sha512-/9lnzylTMwbXNQUwWAO0DIxW145wl+rmikwFXgujLJ5N9bRfWcjP+qd1XM5wxSiw8ZlBAlFy/n2zRvzcG21bBw== - dependencies: - mime ">=2.4.6" - opener "1" - -rollup-plugin-styles@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/rollup-plugin-styles/-/rollup-plugin-styles-4.0.0.tgz#472f104d33edda27fb089b63a315e31531611826" - integrity sha512-A2K2sao84OsTmDxXG83JTCdXWrmgvQkkI38XDat46rdtpGMRm9tSYqeCdlwwGDJF4kKIafhV1mUidqu8MxUGig== - dependencies: - "@rollup/pluginutils" "^4.1.2" - "@types/cssnano" "^5.0.0" - cosmiconfig "^7.0.1" - cssnano "^5.0.15" - fs-extra "^10.0.0" - icss-utils "^5.1.0" - mime-types "^2.1.34" - p-queue "^6.6.2" - postcss "^8.4.5" - postcss-modules-extract-imports "^3.0.0" - postcss-modules-local-by-default "^4.0.0" - postcss-modules-scope "^3.0.0" - postcss-modules-values "^4.0.0" - postcss-value-parser "^4.2.0" - query-string "^7.1.0" - resolve "^1.21.0" - source-map-js "^1.0.1" - tslib "^2.3.1" - -rollup-plugin-terser@^7.0.2: - version "7.0.2" - resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz#e8fbba4869981b2dc35ae7e8a502d5c6c04d324d" - integrity sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ== - dependencies: - "@babel/code-frame" "^7.10.4" - jest-worker "^26.2.1" - serialize-javascript "^4.0.0" - terser "^5.0.0" - -rollup-plugin-typescript2@^0.33.0: - version "0.33.0" - resolved "https://registry.yarnpkg.com/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.33.0.tgz#2bb943f83e9d3a4c61a19aed5bee5bcff28d16a3" - integrity sha512-7ZXoZeX93kNb4/ICzOi2AlperVV6cAsNz8THqrbz+KNvpn47P2F/nFdK/BGhkoOsOwuYDuY57vccdZZrcd8/dA== - dependencies: - "@rollup/pluginutils" "^4.1.2" - find-cache-dir "^3.3.2" - fs-extra "^10.0.0" - tslib "^2.4.0" - -rollup-plugin-visualizer@^5.8.2: - version "5.8.2" - resolved "https://registry.yarnpkg.com/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.8.2.tgz#25a4cc6431aba2eba8ecb7c99d8c3623d493e4bd" - integrity sha512-Fh7KoAa7FVVOojmyyX9ro7fBSR7mPG2cgfDbA877HM4IeJJtSZO+I/R3h/u6TB8wVP5J4pXPpTaRMSREyqCS3g== - dependencies: - open "^8.4.0" - source-map "^0.7.4" - yargs "^17.5.1" - -rollup@^2.63.0, rollup@^2.79.0: - version "2.79.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.0.tgz#9177992c9f09eb58c5e56cbfa641607a12b57ce2" - integrity sha512-x4KsrCgwQ7ZJPcFA/SUu6QVcYlO7uRLfLAy0DSA4NS2eG8japdbpM50ToH7z4iObodRYOJ0soneF0iaQRJ6zhA== - optionalDependencies: - fsevents "~2.3.2" - -run-parallel@^1.1.9: - version "1.2.0" - resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" - integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== - dependencies: - queue-microtask "^1.2.2" - -safe-buffer@^5.1.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -safe-buffer@~5.1.1: - version "5.1.2" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" - integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== - -sass@^1.54.9: - version "1.54.9" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.54.9.tgz#b05f14ed572869218d1a76961de60cd647221762" - integrity sha512-xb1hjASzEH+0L0WI9oFjqhRi51t/gagWnxLiwUNMltA0Ab6jIDkAacgKiGYKM9Jhy109osM7woEEai6SXeJo5Q== - dependencies: - chokidar ">=3.0.0 <4.0.0" - immutable "^4.0.0" - source-map-js ">=0.6.2 <2.0.0" - -screenfull@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-6.0.2.tgz#3dbe4b8c4f8f49fb8e33caa8f69d0bca730ab238" - integrity sha512-AQdy8s4WhNvUZ6P8F6PB21tSPIYKniic+Ogx0AacBMjKP1GUHN2E9URxQHtCusiwxudnCKkdy4GrHXPPJSkCCw== - -semver@^6.0.0, semver@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" - integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== - -semver@^7.3.7: - version "7.3.7" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" - integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== - dependencies: - lru-cache "^6.0.0" - -serialize-javascript@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" - integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw== - dependencies: - randombytes "^2.1.0" - -shebang-command@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" - integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== - dependencies: - shebang-regex "^3.0.0" - -shebang-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" - integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== - -side-channel@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" - integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== - dependencies: - call-bind "^1.0.0" - get-intrinsic "^1.0.2" - object-inspect "^1.9.0" - -side-drawer@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/side-drawer/-/side-drawer-3.1.0.tgz#8542c5379c1dd82cb2f59fd27de6fa8f778c55ba" - integrity sha512-muiYSSJehoKOKuYXXGUKFrUZTM4ndUzHEdWK1C9ClIMCjWtp7b5oxMACEr1DLYTr8MABlD71u2UheR4Ru8o2vQ== - -slash@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" - integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== - -"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.1, source-map-js@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" - integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== - -source-map-support@~0.5.20: - version "0.5.21" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" - integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== - dependencies: - buffer-from "^1.0.0" - source-map "^0.6.0" - -source-map@^0.6.0, source-map@^0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - -source-map@^0.7.4: - version "0.7.4" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656" - integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA== - -sourcemap-codec@^1.4.8: - version "1.4.8" - resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" - integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== - -split-on-first@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f" - integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw== - -stable@^0.1.8: - version "0.1.8" - resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" - integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== - -strict-uri-encode@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" - integrity sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ== - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string.prototype.trimend@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.5.tgz#914a65baaab25fbdd4ee291ca7dde57e869cb8d0" - integrity sha512-I7RGvmjV4pJ7O3kdf+LXFpVfdNOxtCW/2C8f6jNiW4+PQchwxkCDzlk1/7p+Wl4bqFIZeF47qAHXLuHHWKAxog== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.19.5" - -string.prototype.trimstart@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.5.tgz#5466d93ba58cfa2134839f81d7f42437e8c01fef" - integrity sha512-THx16TJCGlsN0o6dl2o6ncWUsdgnLRSA23rRE5pyGBw/mLr3Ej/R2LaqCtgP8VNMGZsvMWnf9ooZPyY2bHvUFg== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.19.5" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-bom@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" - integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== - -strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" - integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== - -stylehacks@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-5.1.0.tgz#a40066490ca0caca04e96c6b02153ddc39913520" - integrity sha512-SzLmvHQTrIWfSgljkQCw2++C9+Ne91d/6Sp92I8c5uHTcy/PgeHamwITIbBW9wnFTY/3ZfSXR9HIL6Ikqmcu6Q== - dependencies: - browserslist "^4.16.6" - postcss-selector-parser "^6.0.4" - -superstruct@^0.15.3: - version "0.15.5" - resolved "https://registry.yarnpkg.com/superstruct/-/superstruct-0.15.5.tgz#0f0a8d3ce31313f0d84c6096cd4fa1bfdedc9dab" - integrity sha512-4AOeU+P5UuE/4nOUkmcQdW5y7i9ndt1cQd/3iUe+LTz3RxESf/W/5lg4B74HbDMMv8PHnPnGCQFH45kBcrQYoQ== - -supports-color@^5.3.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== - dependencies: - has-flag "^3.0.0" - -supports-color@^7.0.0, supports-color@^7.1.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" - integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== - dependencies: - has-flag "^4.0.0" - -supports-preserve-symlinks-flag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" - integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== - -svgo@^2.7.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/svgo/-/svgo-2.8.0.tgz#4ff80cce6710dc2795f0c7c74101e6764cfccd24" - integrity sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg== - dependencies: - "@trysound/sax" "0.2.0" - commander "^7.2.0" - css-select "^4.1.3" - css-tree "^1.1.3" - csso "^4.2.0" - picocolors "^1.0.0" - stable "^0.1.8" - -terser@^5.0.0: - version "5.15.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.15.0.tgz#e16967894eeba6e1091509ec83f0c60e179f2425" - integrity sha512-L1BJiXVmheAQQy+as0oF3Pwtlo4s3Wi1X2zNZ2NxOB4wx9bdS9Vk67XQENLFdLYGCK/Z2di53mTj/hBafR+dTA== - dependencies: - "@jridgewell/source-map" "^0.3.2" - acorn "^8.5.0" - commander "^2.20.0" - source-map-support "~0.5.20" - -text-table@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== - -to-fast-properties@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" - integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== - -to-regex-range@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" - integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== - dependencies: - is-number "^7.0.0" - -"true-myth@^4.1.0": - version "4.1.1" - resolved "https://registry.yarnpkg.com/true-myth/-/true-myth-4.1.1.tgz#ff4ac9d5130276e34aa338757e2416ec19248ba2" - integrity sha512-rqy30BSpxPznbbTcAcci90oZ1YR4DqvKcNXNerG5gQBU2v4jk0cygheiul5J6ExIMrgDVuanv/MkGfqZbKrNNg== - -ts-morph@^13.0.1: - version "13.0.3" - resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-13.0.3.tgz#c0c51d1273ae2edb46d76f65161eb9d763444c1d" - integrity sha512-pSOfUMx8Ld/WUreoSzvMFQG5i9uEiWIsBYjpU9+TTASOeUa89j5HykomeqVULm1oqWtBdleI3KEFRLrlA3zGIw== - dependencies: - "@ts-morph/common" "~0.12.3" - code-block-writer "^11.0.0" - -ts-prune@^0.10.3: - version "0.10.3" - resolved "https://registry.yarnpkg.com/ts-prune/-/ts-prune-0.10.3.tgz#b6c71a525543b38dcf947a7d3adfb7f9e8b91f38" - integrity sha512-iS47YTbdIcvN8Nh/1BFyziyUqmjXz7GVzWu02RaZXqb+e/3Qe1B7IQ4860krOeCGUeJmterAlaM2FRH0Ue0hjw== - dependencies: - commander "^6.2.1" - cosmiconfig "^7.0.1" - json5 "^2.1.3" - lodash "^4.17.21" - "true-myth" "^4.1.0" - ts-morph "^13.0.1" - -ts-toolbelt@^9.6.0: - version "9.6.0" - resolved "https://registry.yarnpkg.com/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz#50a25426cfed500d4a09bd1b3afb6f28879edfd5" - integrity sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w== - -tsconfig-paths@^3.14.1: - version "3.14.1" - resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a" - integrity sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ== - dependencies: - "@types/json5" "^0.0.29" - json5 "^1.0.1" - minimist "^1.2.6" - strip-bom "^3.0.0" - -tslib@^1.8.1: - version "1.14.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" - integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== - -tslib@^2.1.0, tslib@^2.3.1, tslib@^2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" - integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== - -tsutils@^3.21.0: - version "3.21.0" - resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" - integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== - dependencies: - tslib "^1.8.1" - -type-check@^0.4.0, type-check@~0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" - integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== - dependencies: - prelude-ls "^1.2.1" - -type-fest@^0.20.2: - version "0.20.2" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" - integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== - -typescript@^4.5.4, typescript@^4.8.3: - version "4.8.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.3.tgz#d59344522c4bc464a65a730ac695007fdb66dd88" - integrity sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig== - -unbox-primitive@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" - integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw== - dependencies: - call-bind "^1.0.2" - has-bigints "^1.0.2" - has-symbols "^1.0.3" - which-boxed-primitive "^1.0.2" - -universalify@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" - integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== - -update-browserslist-db@^1.0.5: - version "1.0.7" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.7.tgz#16279639cff1d0f800b14792de43d97df2d11b7d" - integrity sha512-iN/XYesmZ2RmmWAiI4Z5rq0YqSiv0brj9Ce9CfhNE4xIW2h+MFxcgkxIzZ+ShkFPUkjU3gQ+3oypadD3RAMtrg== - dependencies: - escalade "^3.1.1" - picocolors "^1.0.0" - -uri-js@^4.2.2: - version "4.4.1" - resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" - integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== - dependencies: - punycode "^2.1.0" - -util-deprecate@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== - -uuid@^8.3.2: - version "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.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== - -vis-timeline@^7.7.0: - version "7.7.0" - resolved "https://registry.yarnpkg.com/vis-timeline/-/vis-timeline-7.7.0.tgz#50c13072b77d7b8a48029fccb3077be7119c614b" - integrity sha512-et5xobQTEp7i8lqcEjgMWoGE4s4qn+2VtEJ35uRZiL5Y3qRzi84bCTkUKAjOM/HTzVFiLTWET+DZZi1iHYriuA== - -vis-util@^5.0.2: - version "5.0.3" - resolved "https://registry.yarnpkg.com/vis-util/-/vis-util-5.0.3.tgz#6a9d67c6ed92f2bfa67e50c20166bb5f59ac07bf" - integrity sha512-Wf9STUcFrDzK4/Zr7B6epW2Kvm3ORNWF+WiwEz2dpf5RdWkLUXFSbLcuB88n1W6tCdFwVN+v3V4/Xmn9PeL39g== - -wheel-gestures@^2.2.5: - version "2.2.5" - resolved "https://registry.yarnpkg.com/wheel-gestures/-/wheel-gestures-2.2.5.tgz#d4a7cdcd402500de26f65aabc8553b657515fb7d" - integrity sha512-01BlXRjBVt5Gy0VH2rRqMeccHruTGq2Gd1fx0UqCPN8KDeDCmlTXsRGc/hXJk6W+wnO9CNJp2xfntMNRArBxKQ== - -which-boxed-primitive@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" - integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== - dependencies: - is-bigint "^1.0.1" - is-boolean-object "^1.1.0" - is-number-object "^1.0.4" - is-string "^1.0.5" - is-symbol "^1.0.3" - -which@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" - integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== - dependencies: - isexe "^2.0.0" - -word-wrap@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" - integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== - -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== - -xss@^1.0.14: - version "1.0.14" - resolved "https://registry.yarnpkg.com/xss/-/xss-1.0.14.tgz#4f3efbde75ad0d82e9921cc3c95e6590dd336694" - integrity sha512-og7TEJhXvn1a7kzZGQ7ETjdQVS2UfZyTlsEdDOqvQF7GoxNfY+0YLCzBy1kPdsDDx4QuNAonQPddpsn6Xl/7sw== - dependencies: - commander "^2.20.3" - cssfilter "0.0.10" - -y18n@^5.0.5: - version "5.0.8" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" - integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== - -yallist@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" - integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== - -yaml@^1.10.0, yaml@^1.10.2: - version "1.10.2" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" - integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== - -yargs-parser@^21.0.0: - version "21.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" - integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== - -yargs@^17.5.1: - version "17.5.1" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.5.1.tgz#e109900cab6fcb7fd44b1d8249166feb0b36e58e" - integrity sha512-t6YAJcxDkNX7NFYiVtKvWUz8l+PaKTLiL63mJYWR2GnHq2gjEWISzsLp9wg3aY36dY1j+gfIEL3pIF+XlJJfbA== - dependencies: - cliui "^7.0.2" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.3" - y18n "^5.0.5" - yargs-parser "^21.0.0" - -yocto-queue@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" - integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== - -zod@^3.19.0: - version "3.19.0" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.19.0.tgz#a9303fcaed0950a3dc57bf35cc11f6282d4f52f2" - integrity sha512-Yw0qvUsCNGBe5YacikdMt5gVYeUuaEFVDIHKMfElrGSaBhwR3CQK6vOzgfAJOjTdGIhEeoaj8GtT+NDZrepZbw== +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 6 + cacheKey: 8 + +"@ampproject/remapping@npm:^2.1.0": + version: 2.2.0 + resolution: "@ampproject/remapping@npm:2.2.0" + dependencies: + "@jridgewell/gen-mapping": ^0.1.0 + "@jridgewell/trace-mapping": ^0.3.9 + checksum: d74d170d06468913921d72430259424b7e4c826b5a7d39ff839a29d547efb97dc577caa8ba3fb5cf023624e9af9d09651afc3d4112a45e2050328abc9b3a2292 + languageName: node + linkType: hard + +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.18.6": + version: 7.18.6 + resolution: "@babel/code-frame@npm:7.18.6" + dependencies: + "@babel/highlight": ^7.18.6 + checksum: 195e2be3172d7684bf95cff69ae3b7a15a9841ea9d27d3c843662d50cdd7d6470fd9c8e64be84d031117e4a4083486effba39f9aef6bbb2c89f7f21bcfba33ba + languageName: node + linkType: hard + +"@babel/compat-data@npm:^7.20.5": + version: 7.20.14 + resolution: "@babel/compat-data@npm:7.20.14" + checksum: 6c9efe36232094e4ad0b70d165587f21ca718e5d011f7a52a77a18502a7524e90e2855aa5a2e086395bcfd21bd2c7c99128dcd8d9fdffe94316b72acf5c66f2c + languageName: node + linkType: hard + +"@babel/core@npm:^7.19.0": + version: 7.20.12 + resolution: "@babel/core@npm:7.20.12" + dependencies: + "@ampproject/remapping": ^2.1.0 + "@babel/code-frame": ^7.18.6 + "@babel/generator": ^7.20.7 + "@babel/helper-compilation-targets": ^7.20.7 + "@babel/helper-module-transforms": ^7.20.11 + "@babel/helpers": ^7.20.7 + "@babel/parser": ^7.20.7 + "@babel/template": ^7.20.7 + "@babel/traverse": ^7.20.12 + "@babel/types": ^7.20.7 + convert-source-map: ^1.7.0 + debug: ^4.1.0 + gensync: ^1.0.0-beta.2 + json5: ^2.2.2 + semver: ^6.3.0 + checksum: 62e6c3e2149a70b5c9729ef5f0d3e2e97e9dcde89fc039c8d8e3463d5d7ba9b29ee84d10faf79b61532ac1645aa62f2bd42338320617e6e3a8a4d8e2a27076e7 + languageName: node + linkType: hard + +"@babel/generator@npm:^7.20.7": + version: 7.20.14 + resolution: "@babel/generator@npm:7.20.14" + dependencies: + "@babel/types": ^7.20.7 + "@jridgewell/gen-mapping": ^0.3.2 + jsesc: ^2.5.1 + checksum: 5f6aa2d86af26e76d276923a5c34191124a119b16ee9ccc34aef654a7dec84fbd7d2daed2e6458a6a06bf87f3661deb77c9fea59b8f67faff5c90793c96d76d6 + languageName: node + linkType: hard + +"@babel/helper-annotate-as-pure@npm:^7.18.6": + version: 7.18.6 + resolution: "@babel/helper-annotate-as-pure@npm:7.18.6" + dependencies: + "@babel/types": ^7.18.6 + checksum: 88ccd15ced475ef2243fdd3b2916a29ea54c5db3cd0cfabf9d1d29ff6e63b7f7cd1c27264137d7a40ac2e978b9b9a542c332e78f40eb72abe737a7400788fc1b + languageName: node + linkType: hard + +"@babel/helper-compilation-targets@npm:^7.20.7": + version: 7.20.7 + resolution: "@babel/helper-compilation-targets@npm:7.20.7" + dependencies: + "@babel/compat-data": ^7.20.5 + "@babel/helper-validator-option": ^7.18.6 + browserslist: ^4.21.3 + lru-cache: ^5.1.1 + semver: ^6.3.0 + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 8c32c873ba86e2e1805b30e0807abd07188acbe00ebb97576f0b09061cc65007f1312b589eccb4349c5a8c7f8bb9f2ab199d41da7030bf103d9f347dcd3a3cf4 + languageName: node + linkType: hard + +"@babel/helper-create-class-features-plugin@npm:^7.18.6, @babel/helper-create-class-features-plugin@npm:^7.20.12": + version: 7.20.12 + resolution: "@babel/helper-create-class-features-plugin@npm:7.20.12" + dependencies: + "@babel/helper-annotate-as-pure": ^7.18.6 + "@babel/helper-environment-visitor": ^7.18.9 + "@babel/helper-function-name": ^7.19.0 + "@babel/helper-member-expression-to-functions": ^7.20.7 + "@babel/helper-optimise-call-expression": ^7.18.6 + "@babel/helper-replace-supers": ^7.20.7 + "@babel/helper-skip-transparent-expression-wrappers": ^7.20.0 + "@babel/helper-split-export-declaration": ^7.18.6 + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 1e9ed4243b75278fa24deb40dc62bf537b79307987223a2d2d2ae5abf7ba6dc8435d6e3bb55d52ceb30d3e1eba88e7eb6a1885a8bb519e5cfc3e9dedb97d43e6 + languageName: node + linkType: hard + +"@babel/helper-environment-visitor@npm:^7.18.9": + version: 7.18.9 + resolution: "@babel/helper-environment-visitor@npm:7.18.9" + checksum: b25101f6162ddca2d12da73942c08ad203d7668e06663df685634a8fde54a98bc015f6f62938e8554457a592a024108d45b8f3e651fd6dcdb877275b73cc4420 + languageName: node + linkType: hard + +"@babel/helper-function-name@npm:^7.19.0": + version: 7.19.0 + resolution: "@babel/helper-function-name@npm:7.19.0" + dependencies: + "@babel/template": ^7.18.10 + "@babel/types": ^7.19.0 + checksum: eac1f5db428ba546270c2b8d750c24eb528b8fcfe50c81de2e0bdebf0e20f24bec688d4331533b782e4a907fad435244621ca2193cfcf80a86731299840e0f6e + languageName: node + linkType: hard + +"@babel/helper-hoist-variables@npm:^7.18.6": + version: 7.18.6 + resolution: "@babel/helper-hoist-variables@npm:7.18.6" + dependencies: + "@babel/types": ^7.18.6 + checksum: fd9c35bb435fda802bf9ff7b6f2df06308a21277c6dec2120a35b09f9de68f68a33972e2c15505c1a1a04b36ec64c9ace97d4a9e26d6097b76b4396b7c5fa20f + languageName: node + linkType: hard + +"@babel/helper-member-expression-to-functions@npm:^7.20.7": + version: 7.20.7 + resolution: "@babel/helper-member-expression-to-functions@npm:7.20.7" + dependencies: + "@babel/types": ^7.20.7 + checksum: cec17aab7e964830b0146e575bd141127032319f26ed864a65b35abd75ad618d264d3e11449b9b4e29cfd95bb1a7e774afddd4884fdcc29c36ac9cbd2b66359f + languageName: node + linkType: hard + +"@babel/helper-module-imports@npm:^7.10.4, @babel/helper-module-imports@npm:^7.18.6": + version: 7.18.6 + resolution: "@babel/helper-module-imports@npm:7.18.6" + dependencies: + "@babel/types": ^7.18.6 + checksum: f393f8a3b3304b1b7a288a38c10989de754f01d29caf62ce7c4e5835daf0a27b81f3ac687d9d2780d39685aae7b55267324b512150e7b2be967b0c493b6a1def + languageName: node + linkType: hard + +"@babel/helper-module-transforms@npm:^7.20.11": + version: 7.20.11 + resolution: "@babel/helper-module-transforms@npm:7.20.11" + dependencies: + "@babel/helper-environment-visitor": ^7.18.9 + "@babel/helper-module-imports": ^7.18.6 + "@babel/helper-simple-access": ^7.20.2 + "@babel/helper-split-export-declaration": ^7.18.6 + "@babel/helper-validator-identifier": ^7.19.1 + "@babel/template": ^7.20.7 + "@babel/traverse": ^7.20.10 + "@babel/types": ^7.20.7 + checksum: 29319ebafa693d48756c6ba0d871677bb0037e0da084fbe221a17c38d57093fc8aa38543c07d76e788266a937976e37ab4901971ca7f237c5ab45f524b9ecca0 + languageName: node + linkType: hard + +"@babel/helper-optimise-call-expression@npm:^7.18.6": + version: 7.18.6 + resolution: "@babel/helper-optimise-call-expression@npm:7.18.6" + dependencies: + "@babel/types": ^7.18.6 + checksum: e518fe8418571405e21644cfb39cf694f30b6c47b10b006609a92469ae8b8775cbff56f0b19732343e2ea910641091c5a2dc73b56ceba04e116a33b0f8bd2fbd + languageName: node + linkType: hard + +"@babel/helper-plugin-utils@npm:^7.18.6, @babel/helper-plugin-utils@npm:^7.19.0, @babel/helper-plugin-utils@npm:^7.20.2": + version: 7.20.2 + resolution: "@babel/helper-plugin-utils@npm:7.20.2" + checksum: f6cae53b7fdb1bf3abd50fa61b10b4470985b400cc794d92635da1e7077bb19729f626adc0741b69403d9b6e411cddddb9c0157a709cc7c4eeb41e663be5d74b + languageName: node + linkType: hard + +"@babel/helper-replace-supers@npm:^7.20.7": + version: 7.20.7 + resolution: "@babel/helper-replace-supers@npm:7.20.7" + dependencies: + "@babel/helper-environment-visitor": ^7.18.9 + "@babel/helper-member-expression-to-functions": ^7.20.7 + "@babel/helper-optimise-call-expression": ^7.18.6 + "@babel/template": ^7.20.7 + "@babel/traverse": ^7.20.7 + "@babel/types": ^7.20.7 + checksum: b8e0087c9b0c1446e3c6f3f72b73b7e03559c6b570e2cfbe62c738676d9ebd8c369a708cf1a564ef88113b4330750a50232ee1131d303d478b7a5e65e46fbc7c + languageName: node + linkType: hard + +"@babel/helper-simple-access@npm:^7.20.2": + version: 7.20.2 + resolution: "@babel/helper-simple-access@npm:7.20.2" + dependencies: + "@babel/types": ^7.20.2 + checksum: ad1e96ee2e5f654ffee2369a586e5e8d2722bf2d8b028a121b4c33ebae47253f64d420157b9f0a8927aea3a9e0f18c0103e74fdd531815cf3650a0a4adca11a1 + languageName: node + linkType: hard + +"@babel/helper-skip-transparent-expression-wrappers@npm:^7.20.0": + version: 7.20.0 + resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.20.0" + dependencies: + "@babel/types": ^7.20.0 + checksum: 34da8c832d1c8a546e45d5c1d59755459ffe43629436707079989599b91e8c19e50e73af7a4bd09c95402d389266731b0d9c5f69e372d8ebd3a709c05c80d7dd + languageName: node + linkType: hard + +"@babel/helper-split-export-declaration@npm:^7.18.6": + version: 7.18.6 + resolution: "@babel/helper-split-export-declaration@npm:7.18.6" + dependencies: + "@babel/types": ^7.18.6 + checksum: c6d3dede53878f6be1d869e03e9ffbbb36f4897c7cc1527dc96c56d127d834ffe4520a6f7e467f5b6f3c2843ea0e81a7819d66ae02f707f6ac057f3d57943a2b + languageName: node + linkType: hard + +"@babel/helper-string-parser@npm:^7.19.4": + version: 7.19.4 + resolution: "@babel/helper-string-parser@npm:7.19.4" + checksum: b2f8a3920b30dfac81ec282ac4ad9598ea170648f8254b10f475abe6d944808fb006aab325d3eb5a8ad3bea8dfa888cfa6ef471050dae5748497c110ec060943 + languageName: node + linkType: hard + +"@babel/helper-validator-identifier@npm:^7.18.6, @babel/helper-validator-identifier@npm:^7.19.1": + version: 7.19.1 + resolution: "@babel/helper-validator-identifier@npm:7.19.1" + checksum: 0eca5e86a729162af569b46c6c41a63e18b43dbe09fda1d2a3c8924f7d617116af39cac5e4cd5d431bb760b4dca3c0970e0c444789b1db42bcf1fa41fbad0a3a + languageName: node + linkType: hard + +"@babel/helper-validator-option@npm:^7.18.6": + version: 7.18.6 + resolution: "@babel/helper-validator-option@npm:7.18.6" + checksum: f9cc6eb7cc5d759c5abf006402180f8d5e4251e9198197428a97e05d65eb2f8ae5a0ce73b1dfd2d35af41d0eb780627a64edf98a4e71f064eeeacef8de58f2cf + languageName: node + linkType: hard + +"@babel/helpers@npm:^7.20.7": + version: 7.20.13 + resolution: "@babel/helpers@npm:7.20.13" + dependencies: + "@babel/template": ^7.20.7 + "@babel/traverse": ^7.20.13 + "@babel/types": ^7.20.7 + checksum: d62076fa834f342798f8c3fd7aec0870cc1725d273d99e540cbaa8d6c3ed10258228dd14601c8e66bfeabbb9424c3b31090ecc467fe855f7bd72c4734df7fb09 + languageName: node + linkType: hard + +"@babel/highlight@npm:^7.18.6": + version: 7.18.6 + resolution: "@babel/highlight@npm:7.18.6" + dependencies: + "@babel/helper-validator-identifier": ^7.18.6 + chalk: ^2.0.0 + js-tokens: ^4.0.0 + checksum: 92d8ee61549de5ff5120e945e774728e5ccd57fd3b2ed6eace020ec744823d4a98e242be1453d21764a30a14769ecd62170fba28539b211799bbaf232bbb2789 + languageName: node + linkType: hard + +"@babel/parser@npm:^7.20.13, @babel/parser@npm:^7.20.7": + version: 7.20.15 + resolution: "@babel/parser@npm:7.20.15" + bin: + parser: ./bin/babel-parser.js + checksum: 1d0f47ca67ff2652f1c0ff1570bed8deccbc4b53509e7cd73476af9cc7ed23480c99f1179bd6d0be01612368b92b39e206d330ad6054009d699934848a89298b + languageName: node + linkType: hard + +"@babel/plugin-proposal-class-properties@npm:^7.18.6": + version: 7.18.6 + resolution: "@babel/plugin-proposal-class-properties@npm:7.18.6" + dependencies: + "@babel/helper-create-class-features-plugin": ^7.18.6 + "@babel/helper-plugin-utils": ^7.18.6 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 49a78a2773ec0db56e915d9797e44fd079ab8a9b2e1716e0df07c92532f2c65d76aeda9543883916b8e0ff13606afeffa67c5b93d05b607bc87653ad18a91422 + languageName: node + linkType: hard + +"@babel/plugin-proposal-decorators@npm:^7.19.0": + version: 7.20.13 + resolution: "@babel/plugin-proposal-decorators@npm:7.20.13" + dependencies: + "@babel/helper-create-class-features-plugin": ^7.20.12 + "@babel/helper-plugin-utils": ^7.20.2 + "@babel/helper-replace-supers": ^7.20.7 + "@babel/helper-split-export-declaration": ^7.18.6 + "@babel/plugin-syntax-decorators": ^7.19.0 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 445723b410627d52ab2d195589eb9fe5fbd66a00ebfc9bedcf63b6cbfdfc42e163d77ac391f8738ab9f632779e6f2aa427fe468fbbd6661177ef0cdca735a7d5 + languageName: node + linkType: hard + +"@babel/plugin-syntax-decorators@npm:^7.19.0": + version: 7.19.0 + resolution: "@babel/plugin-syntax-decorators@npm:7.19.0" + dependencies: + "@babel/helper-plugin-utils": ^7.19.0 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 105a13d581a8643ba145d4d0d31f34a492b352defa5b155e785702da6ce9c3ff0c1843ba9bee176e35f6e38afa19dc7bd12c120220af0495de4b128f1dd27f6e + languageName: node + linkType: hard + +"@babel/template@npm:^7.18.10, @babel/template@npm:^7.20.7": + version: 7.20.7 + resolution: "@babel/template@npm:7.20.7" + dependencies: + "@babel/code-frame": ^7.18.6 + "@babel/parser": ^7.20.7 + "@babel/types": ^7.20.7 + checksum: 2eb1a0ab8d415078776bceb3473d07ab746e6bb4c2f6ca46ee70efb284d75c4a32bb0cd6f4f4946dec9711f9c0780e8e5d64b743208deac6f8e9858afadc349e + languageName: node + linkType: hard + +"@babel/traverse@npm:^7.20.10, @babel/traverse@npm:^7.20.12, @babel/traverse@npm:^7.20.13, @babel/traverse@npm:^7.20.7": + version: 7.20.13 + resolution: "@babel/traverse@npm:7.20.13" + dependencies: + "@babel/code-frame": ^7.18.6 + "@babel/generator": ^7.20.7 + "@babel/helper-environment-visitor": ^7.18.9 + "@babel/helper-function-name": ^7.19.0 + "@babel/helper-hoist-variables": ^7.18.6 + "@babel/helper-split-export-declaration": ^7.18.6 + "@babel/parser": ^7.20.13 + "@babel/types": ^7.20.7 + debug: ^4.1.0 + globals: ^11.1.0 + checksum: 30ca6e0bd18233fda48fa09315efd14dfc61dcf5b8fa3712b343bfc61b32bc63b5e85ea1773cc9576c9b293b96f46b4589aaeb0a52e1f3eeac4edc076d049fc7 + languageName: node + linkType: hard + +"@babel/types@npm:^7.18.6, @babel/types@npm:^7.19.0, @babel/types@npm:^7.20.0, @babel/types@npm:^7.20.2, @babel/types@npm:^7.20.7, @babel/types@npm:^7.8.3": + version: 7.20.7 + resolution: "@babel/types@npm:7.20.7" + dependencies: + "@babel/helper-string-parser": ^7.19.4 + "@babel/helper-validator-identifier": ^7.19.1 + to-fast-properties: ^2.0.0 + checksum: b39af241f0b72bba67fd6d0d23914f6faec8c0eba8015c181cbd5ea92e59fc91a52a1ab490d3520c7dbd19ddb9ebb76c476308f6388764f16d8201e37fae6811 + languageName: node + linkType: hard + +"@cycjimmy/jsmpeg-player@npm:^6.0.4": + version: 6.0.5 + resolution: "@cycjimmy/jsmpeg-player@npm:6.0.5" + checksum: c83c4cc2060f75d3933c889c92e6261fe207bb79e3af41538be3809a33188875363266b87cb7f70c34f3aaaf475615813ec0520b70c6eae0503f526857b4b4c0 + languageName: node + linkType: hard + +"@duetds/date-picker@npm:^1.4.0": + version: 1.4.0 + resolution: "@duetds/date-picker@npm:1.4.0" + dependencies: + "@stencil/core": ^2.3.0 + checksum: a7b82bbf207768b6f1ad1e38893ec21ef360d48f155d5c29863cc83414adaf2537f279dec2c146631967e9ea4761eb026d78e209aa1054d3a42e31deaad3a4fc + languageName: node + linkType: hard + +"@egjs/hammerjs@npm:^2.0.17": + version: 2.0.17 + resolution: "@egjs/hammerjs@npm:2.0.17" + dependencies: + "@types/hammerjs": ^2.0.36 + checksum: 8945137cec5837edd70af3f2e0ea621543eb0aa3b667e6269ec6485350f4d120c2434b37c7c30b1cf42a65275dd61c1f24626749c616696d3956ac0c008c4766 + languageName: node + linkType: hard + +"@eslint/eslintrc@npm:^1.4.1": + version: 1.4.1 + resolution: "@eslint/eslintrc@npm:1.4.1" + dependencies: + ajv: ^6.12.4 + debug: ^4.3.2 + espree: ^9.4.0 + globals: ^13.19.0 + ignore: ^5.2.0 + import-fresh: ^3.2.1 + js-yaml: ^4.1.0 + minimatch: ^3.1.2 + strip-json-comments: ^3.1.1 + checksum: cd3e5a8683db604739938b1c1c8b77927dc04fce3e28e0c88e7f2cd4900b89466baf83dfbad76b2b9e4d2746abdd00dd3f9da544d3e311633d8693f327d04cd7 + languageName: node + linkType: hard + +"@formatjs/ecma402-abstract@npm:1.11.4": + version: 1.11.4 + resolution: "@formatjs/ecma402-abstract@npm:1.11.4" + dependencies: + "@formatjs/intl-localematcher": 0.2.25 + tslib: ^2.1.0 + checksum: 05dbe1c6457acfe9cdc0fc770940260e19fa588be6c655b1ff1697506348dac1eee9b249b64e6544531174d07a5a74a9e75f68430947cfdc074ebe8e3c86f86f + languageName: node + linkType: hard + +"@formatjs/fast-memoize@npm:1.2.1": + version: 1.2.1 + resolution: "@formatjs/fast-memoize@npm:1.2.1" + dependencies: + tslib: ^2.1.0 + checksum: 7df9e941142be16e5862afe7387926cec44ec136d2c2f9a7e1598cb6c8c23a65e420ed90251ec9b48df083f5473b10d6fbbee2e9fc7233d5bf1f27efffba59a7 + languageName: node + linkType: hard + +"@formatjs/icu-messageformat-parser@npm:2.1.0": + version: 2.1.0 + resolution: "@formatjs/icu-messageformat-parser@npm:2.1.0" + dependencies: + "@formatjs/ecma402-abstract": 1.11.4 + "@formatjs/icu-skeleton-parser": 1.3.6 + tslib: ^2.1.0 + checksum: 8dab4d102b334dd7ab25b85817b074f1b54845d02f9ef9fa5a1fa6a9723176ad28a59d845fdc0eeedb868b891e61a4c530384b123db29f5b14e1f3f8b207373f + languageName: node + linkType: hard + +"@formatjs/icu-skeleton-parser@npm:1.3.6": + version: 1.3.6 + resolution: "@formatjs/icu-skeleton-parser@npm:1.3.6" + dependencies: + "@formatjs/ecma402-abstract": 1.11.4 + tslib: ^2.1.0 + checksum: cce2d8bea54f0096c557dc03920bfe4785893e60962313fab9eeee41f0b411d38b9d45852882b19f261417d730362c8685bea6ba5ac1e2dee141f030cda624e9 + languageName: node + linkType: hard + +"@formatjs/intl-localematcher@npm:0.2.25": + version: 0.2.25 + resolution: "@formatjs/intl-localematcher@npm:0.2.25" + dependencies: + tslib: ^2.1.0 + checksum: ee00ddc23317dc47a58831aaca5112e101d8bb1f38adc0ecfe1a9d7e008d0bb1091519f07e1d7d805b0c1e28f2c3e75f697ae479e22423445814412c7669284c + languageName: node + linkType: hard + +"@formatjs/intl-utils@npm:^3.8.4": + version: 3.8.4 + resolution: "@formatjs/intl-utils@npm:3.8.4" + dependencies: + emojis-list: ^3.0.0 + checksum: e38a98d391ec68d8abc4bb1824426bd804dfb022ce61be004459faec69f7843ddb3733bae8d0dce4aafbfafad59cb10e67fc21d22d62f68d98251ce0bce8e704 + languageName: node + linkType: hard + +"@gar/promisify@npm:^1.1.3": + version: 1.1.3 + resolution: "@gar/promisify@npm:1.1.3" + checksum: 4059f790e2d07bf3c3ff3e0fec0daa8144fe35c1f6e0111c9921bd32106adaa97a4ab096ad7dab1e28ee6a9060083c4d1a4ada42a7f5f3f7a96b8812e2b757c1 + languageName: node + linkType: hard + +"@graphiteds/core@npm:^1.9.6": + version: 1.9.7 + resolution: "@graphiteds/core@npm:1.9.7" + dependencies: + "@duetds/date-picker": ^1.4.0 + "@popperjs/core": ^2.11.5 + "@stencil/core": ^2.20.0 + checksum: d9fa01a5e63fa4b26010893f85324ddfc1961c08ba1a934da5371941d648582e722a87f5af682084be24f28aa5b3aec3bda6a8ff8759b2f7246e5db13f528391 + languageName: node + linkType: hard + +"@humanwhocodes/config-array@npm:^0.11.8": + version: 0.11.8 + resolution: "@humanwhocodes/config-array@npm:0.11.8" + dependencies: + "@humanwhocodes/object-schema": ^1.2.1 + debug: ^4.1.1 + minimatch: ^3.0.5 + checksum: 0fd6b3c54f1674ce0a224df09b9c2f9846d20b9e54fabae1281ecfc04f2e6ad69bf19e1d6af6a28f88e8aa3990168b6cb9e1ef755868c3256a630605ec2cb1d3 + languageName: node + linkType: hard + +"@humanwhocodes/module-importer@npm:^1.0.1": + version: 1.0.1 + resolution: "@humanwhocodes/module-importer@npm:1.0.1" + checksum: 0fd22007db8034a2cdf2c764b140d37d9020bbfce8a49d3ec5c05290e77d4b0263b1b972b752df8c89e5eaa94073408f2b7d977aed131faf6cf396ebb5d7fb61 + languageName: node + linkType: hard + +"@humanwhocodes/object-schema@npm:^1.2.1": + version: 1.2.1 + resolution: "@humanwhocodes/object-schema@npm:1.2.1" + checksum: a824a1ec31591231e4bad5787641f59e9633827d0a2eaae131a288d33c9ef0290bd16fda8da6f7c0fcb014147865d12118df10db57f27f41e20da92369fcb3f1 + languageName: node + linkType: hard + +"@jridgewell/gen-mapping@npm:^0.1.0": + version: 0.1.1 + resolution: "@jridgewell/gen-mapping@npm:0.1.1" + dependencies: + "@jridgewell/set-array": ^1.0.0 + "@jridgewell/sourcemap-codec": ^1.4.10 + checksum: 3bcc21fe786de6ffbf35c399a174faab05eb23ce6a03e8769569de28abbf4facc2db36a9ddb0150545ae23a8d35a7cf7237b2aa9e9356a7c626fb4698287d5cc + languageName: node + linkType: hard + +"@jridgewell/gen-mapping@npm:^0.3.0, @jridgewell/gen-mapping@npm:^0.3.2": + version: 0.3.2 + resolution: "@jridgewell/gen-mapping@npm:0.3.2" + dependencies: + "@jridgewell/set-array": ^1.0.1 + "@jridgewell/sourcemap-codec": ^1.4.10 + "@jridgewell/trace-mapping": ^0.3.9 + checksum: 1832707a1c476afebe4d0fbbd4b9434fdb51a4c3e009ab1e9938648e21b7a97049fa6009393bdf05cab7504108413441df26d8a3c12193996e65493a4efb6882 + languageName: node + linkType: hard + +"@jridgewell/resolve-uri@npm:3.1.0": + version: 3.1.0 + resolution: "@jridgewell/resolve-uri@npm:3.1.0" + checksum: b5ceaaf9a110fcb2780d1d8f8d4a0bfd216702f31c988d8042e5f8fbe353c55d9b0f55a1733afdc64806f8e79c485d2464680ac48a0d9fcadb9548ee6b81d267 + languageName: node + linkType: hard + +"@jridgewell/set-array@npm:^1.0.0, @jridgewell/set-array@npm:^1.0.1": + version: 1.1.2 + resolution: "@jridgewell/set-array@npm:1.1.2" + checksum: 69a84d5980385f396ff60a175f7177af0b8da4ddb81824cb7016a9ef914eee9806c72b6b65942003c63f7983d4f39a5c6c27185bbca88eb4690b62075602e28e + languageName: node + linkType: hard + +"@jridgewell/source-map@npm:^0.3.2": + version: 0.3.2 + resolution: "@jridgewell/source-map@npm:0.3.2" + dependencies: + "@jridgewell/gen-mapping": ^0.3.0 + "@jridgewell/trace-mapping": ^0.3.9 + checksum: 1b83f0eb944e77b70559a394d5d3b3f98a81fcc186946aceb3ef42d036762b52ef71493c6c0a3b7c1d2f08785f53ba2df1277fe629a06e6109588ff4cdcf7482 + languageName: node + linkType: hard + +"@jridgewell/sourcemap-codec@npm:1.4.14, @jridgewell/sourcemap-codec@npm:^1.4.10": + version: 1.4.14 + resolution: "@jridgewell/sourcemap-codec@npm:1.4.14" + checksum: 61100637b6d173d3ba786a5dff019e1a74b1f394f323c1fee337ff390239f053b87266c7a948777f4b1ee68c01a8ad0ab61e5ff4abb5a012a0b091bec391ab97 + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:^0.3.9": + version: 0.3.17 + resolution: "@jridgewell/trace-mapping@npm:0.3.17" + dependencies: + "@jridgewell/resolve-uri": 3.1.0 + "@jridgewell/sourcemap-codec": 1.4.14 + checksum: 9d703b859cff5cd83b7308fd457a431387db5db96bd781a63bf48e183418dd9d3d44e76b9e4ae13237f6abeeb25d739ec9215c1d5bfdd08f66f750a50074a339 + languageName: node + linkType: hard + +"@lit-labs/scoped-registry-mixin@npm:^1.0.1": + version: 1.0.1 + resolution: "@lit-labs/scoped-registry-mixin@npm:1.0.1" + dependencies: + "@lit/reactive-element": ^1.0.0 + lit: ^2.0.0 + checksum: 94a86c8329d27b0f7e3b6ec1928c0eb7efc64f6dc2ed2d5cce09184650e465ce63e77ca4fec95d508583ea0d793fe0495b75a7b0830f5c1f951caa28d7d51842 + languageName: node + linkType: hard + +"@lit-labs/ssr-dom-shim@npm:^1.0.0": + version: 1.0.0 + resolution: "@lit-labs/ssr-dom-shim@npm:1.0.0" + checksum: ccba6675ad631c6f7360af4e12e328e88d877a52ec992ae9254a1be9902ef699b7916d851b073803dd91244d0f709de2c0539a7db643889cfe341ff6e1a74eb5 + languageName: node + linkType: hard + +"@lit-labs/task@npm:^1.1.3": + version: 1.1.3 + resolution: "@lit-labs/task@npm:1.1.3" + dependencies: + "@lit/reactive-element": ^1.1.0 + checksum: 82b3680a8b6d5c447364857a0beb8e28237c05fdffe06456183196521000b4fcd696da374f17d28020248042bda2af7d4ddf9adbe40309c32e6672afa6e58935 + languageName: node + linkType: hard + +"@lit/reactive-element@npm:^1.0.0, @lit/reactive-element@npm:^1.1.0, @lit/reactive-element@npm:^1.3.0, @lit/reactive-element@npm:^1.6.0": + version: 1.6.1 + resolution: "@lit/reactive-element@npm:1.6.1" + dependencies: + "@lit-labs/ssr-dom-shim": ^1.0.0 + checksum: fab0bcfdade9c26af2ad5115fb564bcf8ba0732a3a8be86c157851c2771e3fdc65ab38f2cd60fa121946058bf6e487461fd217f87b01f96e88ee7a95d5d866ba + languageName: node + linkType: hard + +"@nodelib/fs.scandir@npm:2.1.5": + version: 2.1.5 + resolution: "@nodelib/fs.scandir@npm:2.1.5" + dependencies: + "@nodelib/fs.stat": 2.0.5 + run-parallel: ^1.1.9 + checksum: a970d595bd23c66c880e0ef1817791432dbb7acbb8d44b7e7d0e7a22f4521260d4a83f7f9fd61d44fda4610105577f8f58a60718105fb38352baed612fd79e59 + languageName: node + linkType: hard + +"@nodelib/fs.stat@npm:2.0.5, @nodelib/fs.stat@npm:^2.0.2": + version: 2.0.5 + resolution: "@nodelib/fs.stat@npm:2.0.5" + checksum: 012480b5ca9d97bff9261571dbbec7bbc6033f69cc92908bc1ecfad0792361a5a1994bc48674b9ef76419d056a03efadfce5a6cf6dbc0a36559571a7a483f6f0 + languageName: node + linkType: hard + +"@nodelib/fs.walk@npm:^1.2.3, @nodelib/fs.walk@npm:^1.2.8": + version: 1.2.8 + resolution: "@nodelib/fs.walk@npm:1.2.8" + dependencies: + "@nodelib/fs.scandir": 2.1.5 + fastq: ^1.6.0 + checksum: 190c643f156d8f8f277bf2a6078af1ffde1fd43f498f187c2db24d35b4b4b5785c02c7dc52e356497b9a1b65b13edc996de08de0b961c32844364da02986dc53 + languageName: node + linkType: hard + +"@npmcli/fs@npm:^2.1.0": + version: 2.1.2 + resolution: "@npmcli/fs@npm:2.1.2" + dependencies: + "@gar/promisify": ^1.1.3 + semver: ^7.3.5 + checksum: 405074965e72d4c9d728931b64d2d38e6ea12066d4fad651ac253d175e413c06fe4350970c783db0d749181da8fe49c42d3880bd1cbc12cd68e3a7964d820225 + languageName: node + linkType: hard + +"@npmcli/move-file@npm:^2.0.0": + version: 2.0.1 + resolution: "@npmcli/move-file@npm:2.0.1" + dependencies: + mkdirp: ^1.0.4 + rimraf: ^3.0.2 + checksum: 52dc02259d98da517fae4cb3a0a3850227bdae4939dda1980b788a7670636ca2b4a01b58df03dd5f65c1e3cb70c50fa8ce5762b582b3f499ec30ee5ce1fd9380 + languageName: node + linkType: hard + +"@popperjs/core@npm:^2.11.5": + version: 2.11.6 + resolution: "@popperjs/core@npm:2.11.6" + checksum: 47fb328cec1924559d759b48235c78574f2d71a8a6c4c03edb6de5d7074078371633b91e39bbf3f901b32aa8af9b9d8f82834856d2f5737a23475036b16817f0 + languageName: node + linkType: hard + +"@rollup/plugin-babel@npm:^5.3.1": + version: 5.3.1 + resolution: "@rollup/plugin-babel@npm:5.3.1" + dependencies: + "@babel/helper-module-imports": ^7.10.4 + "@rollup/pluginutils": ^3.1.0 + peerDependencies: + "@babel/core": ^7.0.0 + "@types/babel__core": ^7.1.9 + rollup: ^1.20.0||^2.0.0 + peerDependenciesMeta: + "@types/babel__core": + optional: true + checksum: 220d71e4647330f252ef33d5f29700aef2e8284a0b61acfcceb47617a7f96208aa1ed16eae75619424bf08811ede5241e271a6d031f07026dee6b3a2bdcdc638 + languageName: node + linkType: hard + +"@rollup/plugin-commonjs@npm:^22.0.2": + version: 22.0.2 + resolution: "@rollup/plugin-commonjs@npm:22.0.2" + dependencies: + "@rollup/pluginutils": ^3.1.0 + commondir: ^1.0.1 + estree-walker: ^2.0.1 + glob: ^7.1.6 + is-reference: ^1.2.1 + magic-string: ^0.25.7 + resolve: ^1.17.0 + peerDependencies: + rollup: ^2.68.0 + checksum: 70098a4b91afe3f164f5d27cba65edf148c5ed146ee0e07a964b66940681553ac77391083114cdcf9427e7f2706bf0d61eab310b3a2caeab83b7452c0292fcae + languageName: node + linkType: hard + +"@rollup/plugin-image@npm:^2.1.1": + version: 2.1.1 + resolution: "@rollup/plugin-image@npm:2.1.1" + dependencies: + "@rollup/pluginutils": ^3.1.0 + mini-svg-data-uri: ^1.2.3 + peerDependencies: + rollup: ^1.20.0 || ^2.0.0 + checksum: a629c8f22233ca159c23655fdbc3449dab3c939372178ed4462fc9c525cc4ecd8b11fae359eb94be4f769d26f48b85fb18eb16ce1fbc33ed16b6a7c1f84391f6 + languageName: node + linkType: hard + +"@rollup/plugin-json@npm:^4.1.0": + version: 4.1.0 + resolution: "@rollup/plugin-json@npm:4.1.0" + dependencies: + "@rollup/pluginutils": ^3.0.8 + peerDependencies: + rollup: ^1.20.0 || ^2.0.0 + checksum: 867bc9339b4ccf0b9ff3b2617a95b3b8920115163f86c8e3b1f068a14ca25949472d3c05b09a5ac38ca0fe2185756e34617eaeb219d4a2b6e2307c501c7d4552 + languageName: node + linkType: hard + +"@rollup/plugin-node-resolve@npm:^13.3.0": + version: 13.3.0 + resolution: "@rollup/plugin-node-resolve@npm:13.3.0" + dependencies: + "@rollup/pluginutils": ^3.1.0 + "@types/resolve": 1.17.1 + deepmerge: ^4.2.2 + is-builtin-module: ^3.1.0 + is-module: ^1.0.0 + resolve: ^1.19.0 + peerDependencies: + rollup: ^2.42.0 + checksum: ec5418e6b3c23a9e30683056b3010e9d325316dcfae93fbc673ae64dad8e56a2ce761c15c48f5e2dcfe0c822fdc4a4905ee6346e3dcf90603ba2260afef5a5e6 + languageName: node + linkType: hard + +"@rollup/plugin-replace@npm:^4.0.0": + version: 4.0.0 + resolution: "@rollup/plugin-replace@npm:4.0.0" + dependencies: + "@rollup/pluginutils": ^3.1.0 + magic-string: ^0.25.7 + peerDependencies: + rollup: ^1.20.0 || ^2.0.0 + checksum: b61701e612661a46da06ca88f390c3839b586708abc0329a476411b90aa8ac59fe94437ede4c3fe53fea268b3054ea9d7d4ee851d7bd0cb7a5f06906002067cb + languageName: node + linkType: hard + +"@rollup/pluginutils@npm:^3.0.8, @rollup/pluginutils@npm:^3.1.0": + version: 3.1.0 + resolution: "@rollup/pluginutils@npm:3.1.0" + dependencies: + "@types/estree": 0.0.39 + estree-walker: ^1.0.1 + picomatch: ^2.2.2 + peerDependencies: + rollup: ^1.20.0||^2.0.0 + checksum: 8be16e27863c219edbb25a4e6ec2fe0e1e451d9e917b6a43cf2ae5bc025a6b8faaa40f82a6e53b66d0de37b58ff472c6c3d57a83037ae635041f8df959d6d9aa + languageName: node + linkType: hard + +"@rollup/pluginutils@npm:^4.1.2": + version: 4.2.1 + resolution: "@rollup/pluginutils@npm:4.2.1" + dependencies: + estree-walker: ^2.0.1 + picomatch: ^2.2.2 + checksum: 6bc41f22b1a0f1efec3043899e4d3b6b1497b3dea4d94292d8f83b4cf07a1073ecbaedd562a22d11913ff7659f459677b01b09e9598a98936e746780ecc93a12 + languageName: node + linkType: hard + +"@stencil/core@npm:^2.20.0, @stencil/core@npm:^2.3.0": + version: 2.22.2 + resolution: "@stencil/core@npm:2.22.2" + bin: + stencil: bin/stencil + checksum: 139a7b63b81c7eaa5026e76e1c947a402f58d0a6d1d353a34883995ea7e480fa6991e71c0c3d812f7046d395b99d0f7f7b11af9277f04a3c62aba96cda18dd5d + languageName: node + linkType: hard + +"@tootallnate/once@npm:2": + version: 2.0.0 + resolution: "@tootallnate/once@npm:2.0.0" + checksum: ad87447820dd3f24825d2d947ebc03072b20a42bfc96cbafec16bff8bbda6c1a81fcb0be56d5b21968560c5359a0af4038a68ba150c3e1694fe4c109a063bed8 + languageName: node + linkType: hard + +"@trysound/sax@npm:0.2.0": + version: 0.2.0 + resolution: "@trysound/sax@npm:0.2.0" + checksum: 11226c39b52b391719a2a92e10183e4260d9651f86edced166da1d95f39a0a1eaa470e44d14ac685ccd6d3df7e2002433782872c0feeb260d61e80f21250e65c + languageName: node + linkType: hard + +"@ts-morph/common@npm:~0.12.3": + version: 0.12.3 + resolution: "@ts-morph/common@npm:0.12.3" + dependencies: + fast-glob: ^3.2.7 + minimatch: ^3.0.4 + mkdirp: ^1.0.4 + path-browserify: ^1.0.1 + checksum: d96ea9805d4f0300cc05c47daa9454438903b86ffb7116f5181a1eba71e881012a1adc2a867b3afbe4429ef29e3e0d6204175cbaf33ecdd7a7d09b5d8a37f12d + languageName: node + linkType: hard + +"@types/bluebird@npm:^3.5.36": + version: 3.5.38 + resolution: "@types/bluebird@npm:3.5.38" + checksum: 8d1b04261e8e58816ec84ffa616c9e641c9416d0ab10c915ddb9cd8a0e7070af16df4f2eec243a3809cbed8ecee2fb3f45600ae43b3ab0ceea563aa18ceb6ab1 + languageName: node + linkType: hard + +"@types/cssnano@npm:^5.0.0": + version: 5.1.0 + resolution: "@types/cssnano@npm:5.1.0" + dependencies: + cssnano: "*" + checksum: 4cce9e5d041a6276cd2e4bc3fce3e9ef5cd53531f86cedee2e9abdd12b050fb927e7a68a87043dbfa222f74ad3eced377d677a113b4772ec7297c737cdab0d74 + languageName: node + linkType: hard + +"@types/estree@npm:*": + version: 1.0.0 + resolution: "@types/estree@npm:1.0.0" + checksum: 910d97fb7092c6738d30a7430ae4786a38542023c6302b95d46f49420b797f21619cdde11fa92b338366268795884111c2eb10356e4bd2c8ad5b92941e9e6443 + languageName: node + linkType: hard + +"@types/estree@npm:0.0.39": + version: 0.0.39 + resolution: "@types/estree@npm:0.0.39" + checksum: 412fb5b9868f2c418126451821833414189b75cc6bf84361156feed733e3d92ec220b9d74a89e52722e03d5e241b2932732711b7497374a404fad49087adc248 + languageName: node + linkType: hard + +"@types/hammerjs@npm:^2.0.36": + version: 2.0.41 + resolution: "@types/hammerjs@npm:2.0.41" + checksum: d16fbd688fc9b18cc270abe8dea8d4c50ef7bd8375e593d92c233d299387933a6b003c8db69819344833052458bc5f9ef1b472001277a49f095928d184356006 + languageName: node + linkType: hard + +"@types/json-schema@npm:^7.0.9": + version: 7.0.11 + resolution: "@types/json-schema@npm:7.0.11" + checksum: 527bddfe62db9012fccd7627794bd4c71beb77601861055d87e3ee464f2217c85fca7a4b56ae677478367bbd248dbde13553312b7d4dbc702a2f2bbf60c4018d + languageName: node + linkType: hard + +"@types/json5@npm:^0.0.29": + version: 0.0.29 + resolution: "@types/json5@npm:0.0.29" + checksum: e60b153664572116dfea673c5bda7778dbff150498f44f998e34b5886d8afc47f16799280e4b6e241c0472aef1bc36add771c569c68fc5125fc2ae519a3eb9ac + languageName: node + linkType: hard + +"@types/lodash-es@npm:^4.17.5": + version: 4.17.6 + resolution: "@types/lodash-es@npm:4.17.6" + dependencies: + "@types/lodash": "*" + checksum: 9bd239dd525086e278821949ce12fbdd4f100a060fed9323fc7ad5661113e1641f28a7ebab617230ed3474680d8f4de705c1928b48252bb684be6ec9eed715db + languageName: node + linkType: hard + +"@types/lodash@npm:*": + version: 4.14.191 + resolution: "@types/lodash@npm:4.14.191" + checksum: ba0d5434e10690869f32d5ea49095250157cae502f10d57de0a723fd72229ce6c6a4979576f0f13e0aa9fbe3ce2457bfb9fa7d4ec3d6daba56730a51906d1491 + languageName: node + linkType: hard + +"@types/node@npm:*": + version: 18.11.19 + resolution: "@types/node@npm:18.11.19" + checksum: d7cd19fcfc59cbdd3f9ba0b4072cb7adc21bd575bd8eb7d7e698975e63564aaa83f03434f32b12331f84f73d0b369d9cbe2371e359d9d7f5c3361f4987f4f7da + languageName: node + linkType: hard + +"@types/parse-json@npm:^4.0.0": + version: 4.0.0 + resolution: "@types/parse-json@npm:4.0.0" + checksum: fd6bce2b674b6efc3db4c7c3d336bd70c90838e8439de639b909ce22f3720d21344f52427f1d9e57b265fcb7f6c018699b99e5e0c208a1a4823014269a6bf35b + languageName: node + linkType: hard + +"@types/resolve@npm:1.17.1": + version: 1.17.1 + resolution: "@types/resolve@npm:1.17.1" + dependencies: + "@types/node": "*" + checksum: dc6a6df507656004e242dcb02c784479deca516d5f4b58a1707e708022b269ae147e1da0521f3e8ad0d63638869d87e0adc023f0bd5454aa6f72ac66c7525cf5 + languageName: node + linkType: hard + +"@types/semver@npm:^7.3.12": + version: 7.3.13 + resolution: "@types/semver@npm:7.3.13" + checksum: 00c0724d54757c2f4bc60b5032fe91cda6410e48689633d5f35ece8a0a66445e3e57fa1d6e07eb780f792e82ac542948ec4d0b76eb3484297b79bd18b8cf1cb0 + languageName: node + linkType: hard + +"@types/trusted-types@npm:^2.0.2": + version: 2.0.2 + resolution: "@types/trusted-types@npm:2.0.2" + checksum: 3371eef5f1c50e1c3c07a127c1207b262ba65b83dd167a1c460fc1b135a3fb0c97b9f508efebd383f239cc5dd5b7169093686a692a501fde9c3f7208657d9b0d + languageName: node + linkType: hard + +"@typescript-eslint/eslint-plugin@npm:^5.36.2": + version: 5.51.0 + resolution: "@typescript-eslint/eslint-plugin@npm:5.51.0" + dependencies: + "@typescript-eslint/scope-manager": 5.51.0 + "@typescript-eslint/type-utils": 5.51.0 + "@typescript-eslint/utils": 5.51.0 + debug: ^4.3.4 + grapheme-splitter: ^1.0.4 + ignore: ^5.2.0 + natural-compare-lite: ^1.4.0 + regexpp: ^3.2.0 + semver: ^7.3.7 + tsutils: ^3.21.0 + peerDependencies: + "@typescript-eslint/parser": ^5.0.0 + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 5351d8cec13bd9867ce4aaf7052aa31c9ca867fc89c620fc0fe5718ac2cbc165903275db59974324d98e45df0d33a73a4367d236668772912731031a672cfdcd + languageName: node + linkType: hard + +"@typescript-eslint/parser@npm:^5.36.2": + version: 5.51.0 + resolution: "@typescript-eslint/parser@npm:5.51.0" + dependencies: + "@typescript-eslint/scope-manager": 5.51.0 + "@typescript-eslint/types": 5.51.0 + "@typescript-eslint/typescript-estree": 5.51.0 + debug: ^4.3.4 + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 096ec819132839febd4f390c4bbf31687e06191092c244dbd189a64cd7383fbaba728f2765e8809cd9834c0069163ab38b0e5f0f6360157d831647d4c295f8cd + languageName: node + linkType: hard + +"@typescript-eslint/scope-manager@npm:5.51.0": + version: 5.51.0 + resolution: "@typescript-eslint/scope-manager@npm:5.51.0" + dependencies: + "@typescript-eslint/types": 5.51.0 + "@typescript-eslint/visitor-keys": 5.51.0 + checksum: b3c9f48b6b7a7ae2ebcad4745ef91e4727776b2cf56d31be6456b1aa063aa649539e20f9fffa83cad9ccaaa9c492f2354a1c15526a2b789e235ec58b3a82d22c + languageName: node + linkType: hard + +"@typescript-eslint/type-utils@npm:5.51.0": + version: 5.51.0 + resolution: "@typescript-eslint/type-utils@npm:5.51.0" + dependencies: + "@typescript-eslint/typescript-estree": 5.51.0 + "@typescript-eslint/utils": 5.51.0 + debug: ^4.3.4 + tsutils: ^3.21.0 + peerDependencies: + eslint: "*" + peerDependenciesMeta: + typescript: + optional: true + checksum: ab9747b0c629cfaaab903eed8ce1e39d34d69a402ce5faf2f1fff2bbb461bdbe034044b1368ba67ba8e5c1c512172e07d83c8a563635d8de811bf148d95c7dec + languageName: node + linkType: hard + +"@typescript-eslint/types@npm:5.51.0": + version: 5.51.0 + resolution: "@typescript-eslint/types@npm:5.51.0" + checksum: b31021a0866f41ba5d71b6c4c7e20cc9b99d49c93bb7db63b55b2e51542fb75b4e27662ee86350da3c1318029e278a5a807facaf4cb5aeea724be8b0e021e836 + languageName: node + linkType: hard + +"@typescript-eslint/typescript-estree@npm:5.51.0": + version: 5.51.0 + resolution: "@typescript-eslint/typescript-estree@npm:5.51.0" + dependencies: + "@typescript-eslint/types": 5.51.0 + "@typescript-eslint/visitor-keys": 5.51.0 + debug: ^4.3.4 + globby: ^11.1.0 + is-glob: ^4.0.3 + semver: ^7.3.7 + tsutils: ^3.21.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: aec23e5cab48ee72fefa6d1ac266639ebabf6cebec1e0207ad47011d3a48186ac9a632c8e34c3bac896155f54895a497230c11d789fd81263b08eb267d7113ce + languageName: node + linkType: hard + +"@typescript-eslint/utils@npm:5.51.0": + version: 5.51.0 + resolution: "@typescript-eslint/utils@npm:5.51.0" + dependencies: + "@types/json-schema": ^7.0.9 + "@types/semver": ^7.3.12 + "@typescript-eslint/scope-manager": 5.51.0 + "@typescript-eslint/types": 5.51.0 + "@typescript-eslint/typescript-estree": 5.51.0 + eslint-scope: ^5.1.1 + eslint-utils: ^3.0.0 + semver: ^7.3.7 + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + checksum: c6e28c942fbac5500f0e8ed67ef304b484ba296486e55306f78fb090dc9d5bb1f25a0bedc065e14680041eadce5e95fa10aab618cb0c316599ec987e6ea72442 + languageName: node + linkType: hard + +"@typescript-eslint/visitor-keys@npm:5.51.0": + version: 5.51.0 + resolution: "@typescript-eslint/visitor-keys@npm:5.51.0" + dependencies: + "@typescript-eslint/types": 5.51.0 + eslint-visitor-keys: ^3.3.0 + checksum: b49710f3c6b3b62a846a163afffd81be5eb2b1f44e25bec51ff3c9f4c3b579d74aa4cbd3753b4fc09ea3dbc64a7062f9c658c08d22bb2740a599cb703d876220 + languageName: node + linkType: hard + +"abbrev@npm:^1.0.0": + version: 1.1.1 + resolution: "abbrev@npm:1.1.1" + checksum: a4a97ec07d7ea112c517036882b2ac22f3109b7b19077dc656316d07d308438aac28e4d9746dc4d84bf6b1e75b4a7b0a5f3cb30592419f128ca9a8cee3bcfa17 + languageName: node + linkType: hard + +"acorn-jsx@npm:^5.3.2": + version: 5.3.2 + resolution: "acorn-jsx@npm:5.3.2" + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + checksum: c3d3b2a89c9a056b205b69530a37b972b404ee46ec8e5b341666f9513d3163e2a4f214a71f4dfc7370f5a9c07472d2fd1c11c91c3f03d093e37637d95da98950 + languageName: node + linkType: hard + +"acorn@npm:^8.5.0, acorn@npm:^8.8.0": + version: 8.8.2 + resolution: "acorn@npm:8.8.2" + bin: + acorn: bin/acorn + checksum: f790b99a1bf63ef160c967e23c46feea7787e531292bb827126334612c234ed489a0dc2c7ba33156416f0ffa8d25bf2b0fdb7f35c2ba60eb3e960572bece4001 + languageName: node + linkType: hard + +"agent-base@npm:6, agent-base@npm:^6.0.2": + version: 6.0.2 + resolution: "agent-base@npm:6.0.2" + dependencies: + debug: 4 + checksum: f52b6872cc96fd5f622071b71ef200e01c7c4c454ee68bc9accca90c98cfb39f2810e3e9aa330435835eedc8c23f4f8a15267f67c6e245d2b33757575bdac49d + languageName: node + linkType: hard + +"agentkeepalive@npm:^4.2.1": + version: 4.2.1 + resolution: "agentkeepalive@npm:4.2.1" + dependencies: + debug: ^4.1.0 + depd: ^1.1.2 + humanize-ms: ^1.2.1 + checksum: 39cb49ed8cf217fd6da058a92828a0a84e0b74c35550f82ee0a10e1ee403c4b78ade7948be2279b188b7a7303f5d396ea2738b134731e464bf28de00a4f72a18 + languageName: node + linkType: hard + +"aggregate-error@npm:^3.0.0": + version: 3.1.0 + resolution: "aggregate-error@npm:3.1.0" + dependencies: + clean-stack: ^2.0.0 + indent-string: ^4.0.0 + checksum: 1101a33f21baa27a2fa8e04b698271e64616b886795fd43c31068c07533c7b3facfcaf4e9e0cab3624bd88f729a592f1c901a1a229c9e490eafce411a8644b79 + languageName: node + linkType: hard + +"ajv@npm:^6.10.0, ajv@npm:^6.12.4": + version: 6.12.6 + resolution: "ajv@npm:6.12.6" + dependencies: + fast-deep-equal: ^3.1.1 + fast-json-stable-stringify: ^2.0.0 + json-schema-traverse: ^0.4.1 + uri-js: ^4.2.2 + checksum: 874972efe5c4202ab0a68379481fbd3d1b5d0a7bd6d3cc21d40d3536ebff3352a2a1fabb632d4fd2cc7fe4cbdcd5ed6782084c9bbf7f32a1536d18f9da5007d4 + languageName: node + linkType: hard + +"ansi-regex@npm:^5.0.1": + version: 5.0.1 + resolution: "ansi-regex@npm:5.0.1" + checksum: 2aa4bb54caf2d622f1afdad09441695af2a83aa3fe8b8afa581d205e57ed4261c183c4d3877cee25794443fde5876417d859c108078ab788d6af7e4fe52eb66b + languageName: node + linkType: hard + +"ansi-styles@npm:^3.2.1": + version: 3.2.1 + resolution: "ansi-styles@npm:3.2.1" + dependencies: + color-convert: ^1.9.0 + checksum: d85ade01c10e5dd77b6c89f34ed7531da5830d2cb5882c645f330079975b716438cd7ebb81d0d6e6b4f9c577f19ae41ab55f07f19786b02f9dfd9e0377395665 + languageName: node + linkType: hard + +"ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": + version: 4.3.0 + resolution: "ansi-styles@npm:4.3.0" + dependencies: + color-convert: ^2.0.1 + checksum: 513b44c3b2105dd14cc42a19271e80f386466c4be574bccf60b627432f9198571ebf4ab1e4c3ba17347658f4ee1711c163d574248c0c1cdc2d5917a0ad582ec4 + languageName: node + linkType: hard + +"anymatch@npm:~3.1.2": + version: 3.1.3 + resolution: "anymatch@npm:3.1.3" + dependencies: + normalize-path: ^3.0.0 + picomatch: ^2.0.4 + checksum: 3e044fd6d1d26545f235a9fe4d7a534e2029d8e59fa7fd9f2a6eb21230f6b5380ea1eaf55136e60cbf8e613544b3b766e7a6fa2102e2a3a117505466e3025dc2 + languageName: node + linkType: hard + +"aproba@npm:^1.0.3 || ^2.0.0": + version: 2.0.0 + resolution: "aproba@npm:2.0.0" + checksum: 5615cadcfb45289eea63f8afd064ab656006361020e1735112e346593856f87435e02d8dcc7ff0d11928bc7d425f27bc7c2a84f6c0b35ab0ff659c814c138a24 + languageName: node + linkType: hard + +"are-we-there-yet@npm:^3.0.0": + version: 3.0.1 + resolution: "are-we-there-yet@npm:3.0.1" + dependencies: + delegates: ^1.0.0 + readable-stream: ^3.6.0 + checksum: 52590c24860fa7173bedeb69a4c05fb573473e860197f618b9a28432ee4379049336727ae3a1f9c4cb083114601c1140cee578376164d0e651217a9843f9fe83 + languageName: node + linkType: hard + +"argparse@npm:^2.0.1": + version: 2.0.1 + resolution: "argparse@npm:2.0.1" + checksum: 83644b56493e89a254bae05702abf3a1101b4fa4d0ca31df1c9985275a5a5bd47b3c27b7fa0b71098d41114d8ca000e6ed90cad764b306f8a503665e4d517ced + languageName: node + linkType: hard + +"array-includes@npm:^3.1.6": + version: 3.1.6 + resolution: "array-includes@npm:3.1.6" + dependencies: + call-bind: ^1.0.2 + define-properties: ^1.1.4 + es-abstract: ^1.20.4 + get-intrinsic: ^1.1.3 + is-string: ^1.0.7 + checksum: f22f8cd8ba8a6448d91eebdc69f04e4e55085d09232b5216ee2d476dab3ef59984e8d1889e662c6a0ed939dcb1b57fd05b2c0209c3370942fc41b752c82a2ca5 + languageName: node + linkType: hard + +"array-union@npm:^2.1.0": + version: 2.1.0 + resolution: "array-union@npm:2.1.0" + checksum: 5bee12395cba82da674931df6d0fea23c4aa4660cb3b338ced9f828782a65caa232573e6bf3968f23e0c5eb301764a382cef2f128b170a9dc59de0e36c39f98d + languageName: node + linkType: hard + +"array.prototype.flat@npm:^1.3.1": + version: 1.3.1 + resolution: "array.prototype.flat@npm:1.3.1" + dependencies: + call-bind: ^1.0.2 + define-properties: ^1.1.4 + es-abstract: ^1.20.4 + es-shim-unscopables: ^1.0.0 + checksum: 5a8415949df79bf6e01afd7e8839bbde5a3581300e8ad5d8449dea52639e9e59b26a467665622783697917b43bf39940a6e621877c7dd9b3d1c1f97484b9b88b + languageName: node + linkType: hard + +"array.prototype.flatmap@npm:^1.3.1": + version: 1.3.1 + resolution: "array.prototype.flatmap@npm:1.3.1" + dependencies: + call-bind: ^1.0.2 + define-properties: ^1.1.4 + es-abstract: ^1.20.4 + es-shim-unscopables: ^1.0.0 + checksum: 8c1c43a4995f12cf12523436da28515184c753807b3f0bc2ca6c075f71c470b099e2090cc67dba8e5280958fea401c1d0c59e1db0143272aef6cd1103921a987 + languageName: node + linkType: hard + +"available-typed-arrays@npm:^1.0.5": + version: 1.0.5 + resolution: "available-typed-arrays@npm:1.0.5" + checksum: 20eb47b3cefd7db027b9bbb993c658abd36d4edd3fe1060e83699a03ee275b0c9b216cc076ff3f2db29073225fb70e7613987af14269ac1fe2a19803ccc97f1a + languageName: node + linkType: hard + +"balanced-match@npm:^1.0.0": + version: 1.0.2 + resolution: "balanced-match@npm:1.0.2" + checksum: 9706c088a283058a8a99e0bf91b0a2f75497f185980d9ffa8b304de1d9e58ebda7c72c07ebf01dadedaac5b2907b2c6f566f660d62bd336c3468e960403b9d65 + languageName: node + linkType: hard + +"binary-extensions@npm:^2.0.0": + version: 2.2.0 + resolution: "binary-extensions@npm:2.2.0" + checksum: ccd267956c58d2315f5d3ea6757cf09863c5fc703e50fbeb13a7dc849b812ef76e3cf9ca8f35a0c48498776a7478d7b4a0418e1e2b8cb9cb9731f2922aaad7f8 + languageName: node + linkType: hard + +"boolbase@npm:^1.0.0": + version: 1.0.0 + resolution: "boolbase@npm:1.0.0" + checksum: 3e25c80ef626c3a3487c73dbfc70ac322ec830666c9ad915d11b701142fab25ec1e63eff2c450c74347acfd2de854ccde865cd79ef4db1683f7c7b046ea43bb0 + languageName: node + linkType: hard + +"brace-expansion@npm:^1.1.7": + version: 1.1.11 + resolution: "brace-expansion@npm:1.1.11" + dependencies: + balanced-match: ^1.0.0 + concat-map: 0.0.1 + checksum: faf34a7bb0c3fcf4b59c7808bc5d2a96a40988addf2e7e09dfbb67a2251800e0d14cd2bfc1aa79174f2f5095c54ff27f46fb1289fe2d77dac755b5eb3434cc07 + languageName: node + linkType: hard + +"brace-expansion@npm:^2.0.1": + version: 2.0.1 + resolution: "brace-expansion@npm:2.0.1" + dependencies: + balanced-match: ^1.0.0 + checksum: a61e7cd2e8a8505e9f0036b3b6108ba5e926b4b55089eeb5550cd04a471fe216c96d4fe7e4c7f995c728c554ae20ddfc4244cad10aef255e72b62930afd233d1 + languageName: node + linkType: hard + +"braces@npm:^3.0.2, braces@npm:~3.0.2": + version: 3.0.2 + resolution: "braces@npm:3.0.2" + dependencies: + fill-range: ^7.0.1 + checksum: e2a8e769a863f3d4ee887b5fe21f63193a891c68b612ddb4b68d82d1b5f3ff9073af066c343e9867a393fe4c2555dcb33e89b937195feb9c1613d259edfcd459 + languageName: node + linkType: hard + +"browserslist@npm:^4.0.0, browserslist@npm:^4.16.6, browserslist@npm:^4.21.3, browserslist@npm:^4.21.4": + version: 4.21.5 + resolution: "browserslist@npm:4.21.5" + dependencies: + caniuse-lite: ^1.0.30001449 + electron-to-chromium: ^1.4.284 + node-releases: ^2.0.8 + update-browserslist-db: ^1.0.10 + bin: + browserslist: cli.js + checksum: 9755986b22e73a6a1497fd8797aedd88e04270be33ce66ed5d85a1c8a798292a65e222b0f251bafa1c2522261e237d73b08b58689d4920a607e5a53d56dc4706 + languageName: node + linkType: hard + +"buffer-from@npm:^1.0.0": + version: 1.1.2 + resolution: "buffer-from@npm:1.1.2" + checksum: 0448524a562b37d4d7ed9efd91685a5b77a50672c556ea254ac9a6d30e3403a517d8981f10e565db24e8339413b43c97ca2951f10e399c6125a0d8911f5679bb + languageName: node + linkType: hard + +"builtin-modules@npm:^3.3.0": + version: 3.3.0 + resolution: "builtin-modules@npm:3.3.0" + checksum: db021755d7ed8be048f25668fe2117620861ef6703ea2c65ed2779c9e3636d5c3b82325bd912244293959ff3ae303afa3471f6a15bf5060c103e4cc3a839749d + languageName: node + linkType: hard + +"cacache@npm:^16.1.0": + version: 16.1.3 + resolution: "cacache@npm:16.1.3" + dependencies: + "@npmcli/fs": ^2.1.0 + "@npmcli/move-file": ^2.0.0 + chownr: ^2.0.0 + fs-minipass: ^2.1.0 + glob: ^8.0.1 + infer-owner: ^1.0.4 + lru-cache: ^7.7.1 + minipass: ^3.1.6 + minipass-collect: ^1.0.2 + minipass-flush: ^1.0.5 + minipass-pipeline: ^1.2.4 + mkdirp: ^1.0.4 + p-map: ^4.0.0 + promise-inflight: ^1.0.1 + rimraf: ^3.0.2 + ssri: ^9.0.0 + tar: ^6.1.11 + unique-filename: ^2.0.0 + checksum: d91409e6e57d7d9a3a25e5dcc589c84e75b178ae8ea7de05cbf6b783f77a5fae938f6e8fda6f5257ed70000be27a681e1e44829251bfffe4c10216002f8f14e6 + languageName: node + linkType: hard + +"call-bind@npm:^1.0.0, call-bind@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind@npm:1.0.2" + dependencies: + function-bind: ^1.1.1 + get-intrinsic: ^1.0.2 + checksum: f8e31de9d19988a4b80f3e704788c4a2d6b6f3d17cfec4f57dc29ced450c53a49270dc66bf0fbd693329ee948dd33e6c90a329519aef17474a4d961e8d6426b0 + languageName: node + linkType: hard + +"callsites@npm:^3.0.0": + version: 3.1.0 + resolution: "callsites@npm:3.1.0" + checksum: 072d17b6abb459c2ba96598918b55868af677154bec7e73d222ef95a8fdb9bbf7dae96a8421085cdad8cd190d86653b5b6dc55a4484f2e5b2e27d5e0c3fc15b3 + languageName: node + linkType: hard + +"caniuse-api@npm:^3.0.0": + version: 3.0.0 + resolution: "caniuse-api@npm:3.0.0" + dependencies: + browserslist: ^4.0.0 + caniuse-lite: ^1.0.0 + lodash.memoize: ^4.1.2 + lodash.uniq: ^4.5.0 + checksum: db2a229383b20d0529b6b589dde99d7b6cb56ba371366f58cbbfa2929c9f42c01f873e2b6ef641d4eda9f0b4118de77dbb2805814670bdad4234bf08e720b0b4 + languageName: node + linkType: hard + +"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001449": + version: 1.0.30001450 + resolution: "caniuse-lite@npm:1.0.30001450" + checksum: 511b360bfc907b2e437699364cf96b83507bc45043926450056642332bcd6f65a1e72540c828534ae15e0ac906e3e9af46cb2bb84458dd580bc31478e9dce282 + languageName: node + linkType: hard + +"chalk@npm:^2.0.0": + version: 2.4.2 + resolution: "chalk@npm:2.4.2" + dependencies: + ansi-styles: ^3.2.1 + escape-string-regexp: ^1.0.5 + supports-color: ^5.3.0 + checksum: ec3661d38fe77f681200f878edbd9448821924e0f93a9cefc0e26a33b145f1027a2084bf19967160d11e1f03bfe4eaffcabf5493b89098b2782c3fe0b03d80c2 + languageName: node + linkType: hard + +"chalk@npm:^4.0.0": + version: 4.1.2 + resolution: "chalk@npm:4.1.2" + dependencies: + ansi-styles: ^4.1.0 + supports-color: ^7.1.0 + checksum: fe75c9d5c76a7a98d45495b91b2172fa3b7a09e0cc9370e5c8feb1c567b85c4288e2b3fded7cfdd7359ac28d6b3844feb8b82b8686842e93d23c827c417e83fc + languageName: node + linkType: hard + +"chokidar@npm:>=3.0.0 <4.0.0": + version: 3.5.3 + resolution: "chokidar@npm:3.5.3" + dependencies: + anymatch: ~3.1.2 + braces: ~3.0.2 + fsevents: ~2.3.2 + glob-parent: ~5.1.2 + is-binary-path: ~2.1.0 + is-glob: ~4.0.1 + normalize-path: ~3.0.0 + readdirp: ~3.6.0 + dependenciesMeta: + fsevents: + optional: true + checksum: b49fcde40176ba007ff361b198a2d35df60d9bb2a5aab228279eb810feae9294a6b4649ab15981304447afe1e6ffbf4788ad5db77235dc770ab777c6e771980c + languageName: node + linkType: hard + +"chownr@npm:^2.0.0": + version: 2.0.0 + resolution: "chownr@npm:2.0.0" + checksum: c57cf9dd0791e2f18a5ee9c1a299ae6e801ff58fee96dc8bfd0dcb4738a6ce58dd252a3605b1c93c6418fe4f9d5093b28ffbf4d66648cb2a9c67eaef9679be2f + languageName: node + linkType: hard + +"clean-stack@npm:^2.0.0": + version: 2.2.0 + resolution: "clean-stack@npm:2.2.0" + checksum: 2ac8cd2b2f5ec986a3c743935ec85b07bc174d5421a5efc8017e1f146a1cf5f781ae962618f416352103b32c9cd7e203276e8c28241bbe946160cab16149fb68 + languageName: node + linkType: hard + +"cliui@npm:^8.0.1": + version: 8.0.1 + resolution: "cliui@npm:8.0.1" + dependencies: + string-width: ^4.2.0 + strip-ansi: ^6.0.1 + wrap-ansi: ^7.0.0 + checksum: 79648b3b0045f2e285b76fb2e24e207c6db44323581e421c3acbd0e86454cba1b37aea976ab50195a49e7384b871e6dfb2247ad7dec53c02454ac6497394cb56 + languageName: node + linkType: hard + +"code-block-writer@npm:^11.0.0": + version: 11.0.3 + resolution: "code-block-writer@npm:11.0.3" + checksum: f0a2605f19963d7087267c9b0fd0b05a6638a50e7b29b70f97aa01a514f59475b0626f8aa092188df853ee6d96745426dfa132d6a677795df462c6ce32c21639 + languageName: node + linkType: hard + +"color-convert@npm:^1.9.0": + version: 1.9.3 + resolution: "color-convert@npm:1.9.3" + dependencies: + color-name: 1.1.3 + checksum: fd7a64a17cde98fb923b1dd05c5f2e6f7aefda1b60d67e8d449f9328b4e53b228a428fd38bfeaeb2db2ff6b6503a776a996150b80cdf224062af08a5c8a3a203 + languageName: node + linkType: hard + +"color-convert@npm:^2.0.1": + version: 2.0.1 + resolution: "color-convert@npm:2.0.1" + dependencies: + color-name: ~1.1.4 + checksum: 79e6bdb9fd479a205c71d89574fccfb22bd9053bd98c6c4d870d65c132e5e904e6034978e55b43d69fcaa7433af2016ee203ce76eeba9cfa554b373e7f7db336 + languageName: node + linkType: hard + +"color-name@npm:1.1.3": + version: 1.1.3 + resolution: "color-name@npm:1.1.3" + checksum: 09c5d3e33d2105850153b14466501f2bfb30324a2f76568a408763a3b7433b0e50e5b4ab1947868e65cb101bb7cb75029553f2c333b6d4b8138a73fcc133d69d + languageName: node + linkType: hard + +"color-name@npm:~1.1.4": + version: 1.1.4 + resolution: "color-name@npm:1.1.4" + checksum: b0445859521eb4021cd0fb0cc1a75cecf67fceecae89b63f62b201cca8d345baf8b952c966862a9d9a2632987d4f6581f0ec8d957dfacece86f0a7919316f610 + languageName: node + linkType: hard + +"color-support@npm:^1.1.3": + version: 1.1.3 + resolution: "color-support@npm:1.1.3" + bin: + color-support: bin.js + checksum: 9b7356817670b9a13a26ca5af1c21615463b500783b739b7634a0c2047c16cef4b2865d7576875c31c3cddf9dd621fa19285e628f20198b233a5cfdda6d0793b + languageName: node + linkType: hard + +"colord@npm:^2.9.1": + version: 2.9.3 + resolution: "colord@npm:2.9.3" + checksum: 95d909bfbcfd8d5605cbb5af56f2d1ce2b323990258fd7c0d2eb0e6d3bb177254d7fb8213758db56bb4ede708964f78c6b992b326615f81a18a6aaf11d64c650 + languageName: node + linkType: hard + +"commander@npm:^2.20.0, commander@npm:^2.20.3": + version: 2.20.3 + resolution: "commander@npm:2.20.3" + checksum: ab8c07884e42c3a8dbc5dd9592c606176c7eb5c1ca5ff274bcf907039b2c41de3626f684ea75ccf4d361ba004bbaff1f577d5384c155f3871e456bdf27becf9e + languageName: node + linkType: hard + +"commander@npm:^6.2.1": + version: 6.2.1 + resolution: "commander@npm:6.2.1" + checksum: d7090410c0de6bc5c67d3ca41c41760d6d268f3c799e530aafb73b7437d1826bbf0d2a3edac33f8b57cc9887b4a986dce307fa5557e109be40eadb7c43b21742 + languageName: node + linkType: hard + +"commander@npm:^7.2.0": + version: 7.2.0 + resolution: "commander@npm:7.2.0" + checksum: 53501cbeee61d5157546c0bef0fedb6cdfc763a882136284bed9a07225f09a14b82d2a84e7637edfd1a679fb35ed9502fd58ef1d091e6287f60d790147f68ddc + languageName: node + linkType: hard + +"commondir@npm:^1.0.1": + version: 1.0.1 + resolution: "commondir@npm:1.0.1" + checksum: 59715f2fc456a73f68826285718503340b9f0dd89bfffc42749906c5cf3d4277ef11ef1cca0350d0e79204f00f1f6d83851ececc9095dc88512a697ac0b9bdcb + languageName: node + linkType: hard + +"component-emitter@npm:^1.3.0": + version: 1.3.0 + resolution: "component-emitter@npm:1.3.0" + checksum: b3c46de38ffd35c57d1c02488355be9f218e582aec72d72d1b8bbec95a3ac1b38c96cd6e03ff015577e68f550fbb361a3bfdbd9bb248be9390b7b3745691be6b + languageName: node + linkType: hard + +"concat-map@npm:0.0.1": + version: 0.0.1 + resolution: "concat-map@npm:0.0.1" + checksum: 902a9f5d8967a3e2faf138d5cb784b9979bad2e6db5357c5b21c568df4ebe62bcb15108af1b2253744844eb964fc023fbd9afbbbb6ddd0bcc204c6fb5b7bf3af + languageName: node + linkType: hard + +"confusing-browser-globals@npm:^1.0.10": + version: 1.0.11 + resolution: "confusing-browser-globals@npm:1.0.11" + checksum: 3afc635abd37e566477f610e7978b15753f0e84025c25d49236f1f14d480117185516bdd40d2a2167e6bed8048641a9854964b9c067e3dcdfa6b5d0ad3c3a5ef + languageName: node + linkType: hard + +"console-control-strings@npm:^1.1.0": + version: 1.1.0 + resolution: "console-control-strings@npm:1.1.0" + checksum: 8755d76787f94e6cf79ce4666f0c5519906d7f5b02d4b884cf41e11dcd759ed69c57da0670afd9236d229a46e0f9cf519db0cd829c6dca820bb5a5c3def584ed + languageName: node + linkType: hard + +"convert-source-map@npm:^1.7.0": + version: 1.9.0 + resolution: "convert-source-map@npm:1.9.0" + checksum: dc55a1f28ddd0e9485ef13565f8f756b342f9a46c4ae18b843fe3c30c675d058d6a4823eff86d472f187b176f0adf51ea7b69ea38be34be4a63cbbf91b0593c8 + languageName: node + linkType: hard + +"cosmiconfig@npm:^7.0.1": + version: 7.1.0 + resolution: "cosmiconfig@npm:7.1.0" + dependencies: + "@types/parse-json": ^4.0.0 + import-fresh: ^3.2.1 + parse-json: ^5.0.0 + path-type: ^4.0.0 + yaml: ^1.10.0 + checksum: c53bf7befc1591b2651a22414a5e786cd5f2eeaa87f3678a3d49d6069835a9d8d1aef223728e98aa8fec9a95bf831120d245096db12abe019fecb51f5696c96f + languageName: node + linkType: hard + +"cross-spawn@npm:^7.0.2": + version: 7.0.3 + resolution: "cross-spawn@npm:7.0.3" + dependencies: + path-key: ^3.1.0 + shebang-command: ^2.0.0 + which: ^2.0.1 + checksum: 671cc7c7288c3a8406f3c69a3ae2fc85555c04169e9d611def9a675635472614f1c0ed0ef80955d5b6d4e724f6ced67f0ad1bb006c2ea643488fcfef994d7f52 + languageName: node + linkType: hard + +"crypto@npm:^1.0.1": + version: 1.0.1 + resolution: "crypto@npm:1.0.1" + checksum: 087fe3165bd94c333a49e6ed66a0193911f63eac38a24f379b3001a5fe260a59c413646e53a0f67875ba13902b2686d81dc703cb2c147a4ec727dcdc04e5645e + languageName: node + linkType: hard + +"css-declaration-sorter@npm:^6.3.1": + version: 6.3.1 + resolution: "css-declaration-sorter@npm:6.3.1" + peerDependencies: + postcss: ^8.0.9 + checksum: ff0d9989ee21ec4c42430b9bb86c43f973ed5024d68f30edc1e3fb07a22828ce3c3e5b922019f2ccbff606722e43c407c5c76e3cddac523ac4afcb31e4b2601c + languageName: node + linkType: hard + +"css-select@npm:^4.1.3": + version: 4.3.0 + resolution: "css-select@npm:4.3.0" + dependencies: + boolbase: ^1.0.0 + css-what: ^6.0.1 + domhandler: ^4.3.1 + domutils: ^2.8.0 + nth-check: ^2.0.1 + checksum: d6202736839194dd7f910320032e7cfc40372f025e4bf21ca5bf6eb0a33264f322f50ba9c0adc35dadd342d3d6fae5ca244779a4873afbfa76561e343f2058e0 + languageName: node + linkType: hard + +"css-tree@npm:^1.1.2, css-tree@npm:^1.1.3": + version: 1.1.3 + resolution: "css-tree@npm:1.1.3" + dependencies: + mdn-data: 2.0.14 + source-map: ^0.6.1 + checksum: 79f9b81803991b6977b7fcb1588799270438274d89066ce08f117f5cdb5e20019b446d766c61506dd772c839df84caa16042d6076f20c97187f5abe3b50e7d1f + languageName: node + linkType: hard + +"css-what@npm:^6.0.1": + version: 6.1.0 + resolution: "css-what@npm:6.1.0" + checksum: b975e547e1e90b79625918f84e67db5d33d896e6de846c9b584094e529f0c63e2ab85ee33b9daffd05bff3a146a1916bec664e18bb76dd5f66cbff9fc13b2bbe + languageName: node + linkType: hard + +"cssesc@npm:^3.0.0": + version: 3.0.0 + resolution: "cssesc@npm:3.0.0" + bin: + cssesc: bin/cssesc + checksum: f8c4ababffbc5e2ddf2fa9957dda1ee4af6048e22aeda1869d0d00843223c1b13ad3f5d88b51caa46c994225eacb636b764eb807a8883e2fb6f99b4f4e8c48b2 + languageName: node + linkType: hard + +"cssfilter@npm:0.0.10": + version: 0.0.10 + resolution: "cssfilter@npm:0.0.10" + checksum: bc2c52bbb3426c3f2e4832edb6f8573e6cfa65b40b540932762d1e018f0f0157725e2991b77344bbc8266c6bbf4daa2803b0707cfb1bd0877505bf83a68e4b04 + languageName: node + linkType: hard + +"cssnano-preset-default@npm:^5.2.13": + version: 5.2.13 + resolution: "cssnano-preset-default@npm:5.2.13" + dependencies: + css-declaration-sorter: ^6.3.1 + cssnano-utils: ^3.1.0 + postcss-calc: ^8.2.3 + postcss-colormin: ^5.3.0 + postcss-convert-values: ^5.1.3 + postcss-discard-comments: ^5.1.2 + postcss-discard-duplicates: ^5.1.0 + postcss-discard-empty: ^5.1.1 + postcss-discard-overridden: ^5.1.0 + postcss-merge-longhand: ^5.1.7 + postcss-merge-rules: ^5.1.3 + postcss-minify-font-values: ^5.1.0 + postcss-minify-gradients: ^5.1.1 + postcss-minify-params: ^5.1.4 + postcss-minify-selectors: ^5.2.1 + postcss-normalize-charset: ^5.1.0 + postcss-normalize-display-values: ^5.1.0 + postcss-normalize-positions: ^5.1.1 + postcss-normalize-repeat-style: ^5.1.1 + postcss-normalize-string: ^5.1.0 + postcss-normalize-timing-functions: ^5.1.0 + postcss-normalize-unicode: ^5.1.1 + postcss-normalize-url: ^5.1.0 + postcss-normalize-whitespace: ^5.1.1 + postcss-ordered-values: ^5.1.3 + postcss-reduce-initial: ^5.1.1 + postcss-reduce-transforms: ^5.1.0 + postcss-svgo: ^5.1.0 + postcss-unique-selectors: ^5.1.1 + peerDependencies: + postcss: ^8.2.15 + checksum: f773de44f67f71e7301e1f4b4664b894c3a48bba4dadc16c559acd0b14ceafed228bdc76fe19d500b0ded9394732377069daadff2184465fa369f8dfd72d47e2 + languageName: node + linkType: hard + +"cssnano-utils@npm:^3.1.0": + version: 3.1.0 + resolution: "cssnano-utils@npm:3.1.0" + peerDependencies: + postcss: ^8.2.15 + checksum: 975c84ce9174cf23bb1da1e9faed8421954607e9ea76440cd3bb0c1bea7e17e490d800fca5ae2812d1d9e9d5524eef23ede0a3f52497d7ccc628e5d7321536f2 + languageName: node + linkType: hard + +"cssnano@npm:*, cssnano@npm:^5.0.15": + version: 5.1.14 + resolution: "cssnano@npm:5.1.14" + dependencies: + cssnano-preset-default: ^5.2.13 + lilconfig: ^2.0.3 + yaml: ^1.10.2 + peerDependencies: + postcss: ^8.2.15 + checksum: 73463c723c5e598b37b8b4d2f014145bd72133e6581349a1b154904e0830e58de17afb1e801ed3ea3b18e386883964ce4d0299e43d4dc37d339214a956c6697f + languageName: node + linkType: hard + +"csso@npm:^4.2.0": + version: 4.2.0 + resolution: "csso@npm:4.2.0" + dependencies: + css-tree: ^1.1.2 + checksum: 380ba9663da3bcea58dee358a0d8c4468bb6539be3c439dc266ac41c047217f52fd698fb7e4b6b6ccdfb8cf53ef4ceed8cc8ceccb8dfca2aa628319826b5b998 + languageName: node + linkType: hard + +"custom-card-helpers@npm:^1.9.0": + version: 1.9.0 + resolution: "custom-card-helpers@npm:1.9.0" + dependencies: + "@formatjs/intl-utils": ^3.8.4 + home-assistant-js-websocket: ^6.0.1 + intl-messageformat: ^9.11.1 + lit: ^2.1.1 + rollup: ^2.63.0 + superstruct: ^0.15.3 + typescript: ^4.5.4 + checksum: 5cefdad2487c1d9e6a190e1837637817c8f7ac86c970c2117fac9b57493f2b235d99af301a7bafcf0a53d1c42b7e75b2accda63de6381607d4beb6d175aa07f4 + languageName: node + linkType: hard + +"date-fns-tz@npm:^1.3.7": + version: 1.3.8 + resolution: "date-fns-tz@npm:1.3.8" + peerDependencies: + date-fns: ">=2.0.0" + checksum: dbf17cb88de7c7b71a9f86b815ac62ebdbf725c030e78d2421309456b5001a85284a5b6763f4af0ba660183f12e876c165e725a0723f642edb7846407db03133 + languageName: node + linkType: hard + +"date-fns@npm:^2.29.2": + version: 2.29.3 + resolution: "date-fns@npm:2.29.3" + checksum: e01cf5b62af04e05dfff921bb9c9933310ed0e1ae9a81eb8653452e64dc841acf7f6e01e1a5ae5644d0337e9a7f936175fd2cb6819dc122fdd9c5e86c56be484 + languageName: node + linkType: hard + +"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": + version: 4.3.4 + resolution: "debug@npm:4.3.4" + dependencies: + ms: 2.1.2 + peerDependenciesMeta: + supports-color: + optional: true + checksum: 3dbad3f94ea64f34431a9cbf0bafb61853eda57bff2880036153438f50fb5a84f27683ba0d8e5426bf41a8c6ff03879488120cf5b3a761e77953169c0600a708 + languageName: node + linkType: hard + +"debug@npm:^3.2.7": + version: 3.2.7 + resolution: "debug@npm:3.2.7" + dependencies: + ms: ^2.1.1 + checksum: b3d8c5940799914d30314b7c3304a43305fd0715581a919dacb8b3176d024a782062368405b47491516d2091d6462d4d11f2f4974a405048094f8bfebfa3071c + languageName: node + linkType: hard + +"decode-uri-component@npm:^0.2.2": + version: 0.2.2 + resolution: "decode-uri-component@npm:0.2.2" + checksum: 95476a7d28f267292ce745eac3524a9079058bbb35767b76e3ee87d42e34cd0275d2eb19d9d08c3e167f97556e8a2872747f5e65cbebcac8b0c98d83e285f139 + languageName: node + linkType: hard + +"deep-is@npm:^0.1.3": + version: 0.1.4 + resolution: "deep-is@npm:0.1.4" + checksum: edb65dd0d7d1b9c40b2f50219aef30e116cedd6fc79290e740972c132c09106d2e80aa0bc8826673dd5a00222d4179c84b36a790eef63a4c4bca75a37ef90804 + languageName: node + linkType: hard + +"deepmerge@npm:^4.2.2": + version: 4.3.0 + resolution: "deepmerge@npm:4.3.0" + checksum: c7980eb5c5be040b371f1df0d566473875cfabed9f672ccc177b81ba8eee5686ce2478de2f1d0076391621cbe729e5eacda397179a59ef0f68901849647db126 + languageName: node + linkType: hard + +"define-lazy-prop@npm:^2.0.0": + version: 2.0.0 + resolution: "define-lazy-prop@npm:2.0.0" + checksum: 0115fdb065e0490918ba271d7339c42453d209d4cb619dfe635870d906731eff3e1ade8028bb461ea27ce8264ec5e22c6980612d332895977e89c1bbc80fcee2 + languageName: node + linkType: hard + +"define-properties@npm:^1.1.3, define-properties@npm:^1.1.4": + version: 1.1.4 + resolution: "define-properties@npm:1.1.4" + dependencies: + has-property-descriptors: ^1.0.0 + object-keys: ^1.1.1 + checksum: ce0aef3f9eb193562b5cfb79b2d2c86b6a109dfc9fdcb5f45d680631a1a908c06824ddcdb72b7573b54e26ace07f0a23420aaba0d5c627b34d2c1de8ef527e2b + languageName: node + linkType: hard + +"delegates@npm:^1.0.0": + version: 1.0.0 + resolution: "delegates@npm:1.0.0" + checksum: a51744d9b53c164ba9c0492471a1a2ffa0b6727451bdc89e31627fdf4adda9d51277cfcbfb20f0a6f08ccb3c436f341df3e92631a3440226d93a8971724771fd + languageName: node + linkType: hard + +"depd@npm:^1.1.2": + version: 1.1.2 + resolution: "depd@npm:1.1.2" + checksum: 6b406620d269619852885ce15965272b829df6f409724415e0002c8632ab6a8c0a08ec1f0bd2add05dc7bd7507606f7e2cc034fa24224ab829580040b835ecd9 + languageName: node + linkType: hard + +"dir-glob@npm:^3.0.1": + version: 3.0.1 + resolution: "dir-glob@npm:3.0.1" + dependencies: + path-type: ^4.0.0 + checksum: fa05e18324510d7283f55862f3161c6759a3f2f8dbce491a2fc14c8324c498286c54282c1f0e933cb930da8419b30679389499b919122952a4f8592362ef4615 + languageName: node + linkType: hard + +"doctrine@npm:^2.1.0": + version: 2.1.0 + resolution: "doctrine@npm:2.1.0" + dependencies: + esutils: ^2.0.2 + checksum: a45e277f7feaed309fe658ace1ff286c6e2002ac515af0aaf37145b8baa96e49899638c7cd47dccf84c3d32abfc113246625b3ac8f552d1046072adee13b0dc8 + languageName: node + linkType: hard + +"doctrine@npm:^3.0.0": + version: 3.0.0 + resolution: "doctrine@npm:3.0.0" + dependencies: + esutils: ^2.0.2 + checksum: fd7673ca77fe26cd5cba38d816bc72d641f500f1f9b25b83e8ce28827fe2da7ad583a8da26ab6af85f834138cf8dae9f69b0cd6ab925f52ddab1754db44d99ce + languageName: node + linkType: hard + +"dom-serializer@npm:^1.0.1": + version: 1.4.1 + resolution: "dom-serializer@npm:1.4.1" + dependencies: + domelementtype: ^2.0.1 + domhandler: ^4.2.0 + entities: ^2.0.0 + checksum: fbb0b01f87a8a2d18e6e5a388ad0f7ec4a5c05c06d219377da1abc7bb0f674d804f4a8a94e3f71ff15f6cb7dcfc75704a54b261db672b9b3ab03da6b758b0b22 + languageName: node + linkType: hard + +"domelementtype@npm:^2.0.1, domelementtype@npm:^2.2.0": + version: 2.3.0 + resolution: "domelementtype@npm:2.3.0" + checksum: ee837a318ff702622f383409d1f5b25dd1024b692ef64d3096ff702e26339f8e345820f29a68bcdcea8cfee3531776b3382651232fbeae95612d6f0a75efb4f6 + languageName: node + linkType: hard + +"domhandler@npm:^4.2.0, domhandler@npm:^4.3.1": + version: 4.3.1 + resolution: "domhandler@npm:4.3.1" + dependencies: + domelementtype: ^2.2.0 + checksum: 4c665ceed016e1911bf7d1dadc09dc888090b64dee7851cccd2fcf5442747ec39c647bb1cb8c8919f8bbdd0f0c625a6bafeeed4b2d656bbecdbae893f43ffaaa + languageName: node + linkType: hard + +"domutils@npm:^2.8.0": + version: 2.8.0 + resolution: "domutils@npm:2.8.0" + dependencies: + dom-serializer: ^1.0.1 + domelementtype: ^2.2.0 + domhandler: ^4.2.0 + checksum: abf7434315283e9aadc2a24bac0e00eab07ae4313b40cc239f89d84d7315ebdfd2fb1b5bf750a96bc1b4403d7237c7b2ebf60459be394d625ead4ca89b934391 + languageName: node + linkType: hard + +"electron-to-chromium@npm:^1.4.284": + version: 1.4.288 + resolution: "electron-to-chromium@npm:1.4.288" + checksum: 312e1210f62c6bb593d1f57a15a1c64729e6a5edeacecc5d28dfb21f1a78ed3fac11805a0749a4e68f1fe1536229bf9d7bca36e1f34303369248a749a78aec1f + languageName: node + linkType: hard + +"embla-carousel-wheel-gestures@npm:^3.0.0": + version: 3.0.0 + resolution: "embla-carousel-wheel-gestures@npm:3.0.0" + dependencies: + wheel-gestures: ^2.2.5 + peerDependencies: + embla-carousel: ^7.0.0 + checksum: 109c1d10fa6f892fe4c627212ee5d24ae2d2d0c28210af16a9770901c2b65faf32eb1a5c95e87451aec9407ec8e3248e49789d6998b7079c83fd13b50532431d + languageName: node + linkType: hard + +"embla-carousel@npm:^7.0.2": + version: 7.0.9 + resolution: "embla-carousel@npm:7.0.9" + checksum: dabb371a44c2d67f21aa7a0ff3f452a5c2678b79e80cb251e0a83b707c4b684fbdbe010927142cdcae3d1c9711a269e05655bd05c39608337fd1b6f5d2c20371 + languageName: node + linkType: hard + +"emoji-regex@npm:^8.0.0": + version: 8.0.0 + resolution: "emoji-regex@npm:8.0.0" + checksum: d4c5c39d5a9868b5fa152f00cada8a936868fd3367f33f71be515ecee4c803132d11b31a6222b2571b1e5f7e13890156a94880345594d0ce7e3c9895f560f192 + languageName: node + linkType: hard + +"emojis-list@npm:^3.0.0": + version: 3.0.0 + resolution: "emojis-list@npm:3.0.0" + checksum: ddaaa02542e1e9436c03970eeed445f4ed29a5337dfba0fe0c38dfdd2af5da2429c2a0821304e8a8d1cadf27fdd5b22ff793571fa803ae16852a6975c65e8e70 + languageName: node + linkType: hard + +"encoding@npm:^0.1.13": + version: 0.1.13 + resolution: "encoding@npm:0.1.13" + dependencies: + iconv-lite: ^0.6.2 + checksum: bb98632f8ffa823996e508ce6a58ffcf5856330fde839ae42c9e1f436cc3b5cc651d4aeae72222916545428e54fd0f6aa8862fd8d25bdbcc4589f1e3f3715e7f + languageName: node + linkType: hard + +"entities@npm:^2.0.0": + version: 2.2.0 + resolution: "entities@npm:2.2.0" + checksum: 19010dacaf0912c895ea262b4f6128574f9ccf8d4b3b65c7e8334ad0079b3706376360e28d8843ff50a78aabcb8f08f0a32dbfacdc77e47ed77ca08b713669b3 + languageName: node + linkType: hard + +"env-paths@npm:^2.2.0": + version: 2.2.1 + resolution: "env-paths@npm:2.2.1" + checksum: 65b5df55a8bab92229ab2b40dad3b387fad24613263d103a97f91c9fe43ceb21965cd3392b1ccb5d77088021e525c4e0481adb309625d0cb94ade1d1fb8dc17e + languageName: node + linkType: hard + +"err-code@npm:^2.0.2": + version: 2.0.3 + resolution: "err-code@npm:2.0.3" + checksum: 8b7b1be20d2de12d2255c0bc2ca638b7af5171142693299416e6a9339bd7d88fc8d7707d913d78e0993176005405a236b066b45666b27b797252c771156ace54 + languageName: node + linkType: hard + +"error-ex@npm:^1.3.1": + version: 1.3.2 + resolution: "error-ex@npm:1.3.2" + dependencies: + is-arrayish: ^0.2.1 + checksum: c1c2b8b65f9c91b0f9d75f0debaa7ec5b35c266c2cac5de412c1a6de86d4cbae04ae44e510378cb14d032d0645a36925d0186f8bb7367bcc629db256b743a001 + languageName: node + linkType: hard + +"es-abstract@npm:^1.19.0, es-abstract@npm:^1.20.4": + version: 1.21.1 + resolution: "es-abstract@npm:1.21.1" + dependencies: + available-typed-arrays: ^1.0.5 + call-bind: ^1.0.2 + es-set-tostringtag: ^2.0.1 + es-to-primitive: ^1.2.1 + function-bind: ^1.1.1 + function.prototype.name: ^1.1.5 + get-intrinsic: ^1.1.3 + get-symbol-description: ^1.0.0 + globalthis: ^1.0.3 + gopd: ^1.0.1 + has: ^1.0.3 + has-property-descriptors: ^1.0.0 + has-proto: ^1.0.1 + has-symbols: ^1.0.3 + internal-slot: ^1.0.4 + is-array-buffer: ^3.0.1 + is-callable: ^1.2.7 + is-negative-zero: ^2.0.2 + is-regex: ^1.1.4 + is-shared-array-buffer: ^1.0.2 + is-string: ^1.0.7 + is-typed-array: ^1.1.10 + is-weakref: ^1.0.2 + object-inspect: ^1.12.2 + object-keys: ^1.1.1 + object.assign: ^4.1.4 + regexp.prototype.flags: ^1.4.3 + safe-regex-test: ^1.0.0 + string.prototype.trimend: ^1.0.6 + string.prototype.trimstart: ^1.0.6 + typed-array-length: ^1.0.4 + unbox-primitive: ^1.0.2 + which-typed-array: ^1.1.9 + checksum: 23ff60d42d17a55d150e7bcedbdb065d4077a8b98c436e0e2e1ef4dd532a6d78a56028673de0bd8ed464a43c46ba781c50d9af429b6a17e44dbd14c7d7fb7926 + languageName: node + linkType: hard + +"es-set-tostringtag@npm:^2.0.1": + version: 2.0.1 + resolution: "es-set-tostringtag@npm:2.0.1" + dependencies: + get-intrinsic: ^1.1.3 + has: ^1.0.3 + has-tostringtag: ^1.0.0 + checksum: ec416a12948cefb4b2a5932e62093a7cf36ddc3efd58d6c58ca7ae7064475ace556434b869b0bbeb0c365f1032a8ccd577211101234b69837ad83ad204fff884 + languageName: node + linkType: hard + +"es-shim-unscopables@npm:^1.0.0": + version: 1.0.0 + resolution: "es-shim-unscopables@npm:1.0.0" + dependencies: + has: ^1.0.3 + checksum: 83e95cadbb6ee44d3644dfad60dcad7929edbc42c85e66c3e99aefd68a3a5c5665f2686885cddb47dfeabfd77bd5ea5a7060f2092a955a729bbd8834f0d86fa1 + languageName: node + linkType: hard + +"es-to-primitive@npm:^1.2.1": + version: 1.2.1 + resolution: "es-to-primitive@npm:1.2.1" + dependencies: + is-callable: ^1.1.4 + is-date-object: ^1.0.1 + is-symbol: ^1.0.2 + checksum: 4ead6671a2c1402619bdd77f3503991232ca15e17e46222b0a41a5d81aebc8740a77822f5b3c965008e631153e9ef0580540007744521e72de8e33599fca2eed + languageName: node + linkType: hard + +"escalade@npm:^3.1.1": + version: 3.1.1 + resolution: "escalade@npm:3.1.1" + checksum: a3e2a99f07acb74b3ad4989c48ca0c3140f69f923e56d0cba0526240ee470b91010f9d39001f2a4a313841d237ede70a729e92125191ba5d21e74b106800b133 + languageName: node + linkType: hard + +"escape-string-regexp@npm:^1.0.5": + version: 1.0.5 + resolution: "escape-string-regexp@npm:1.0.5" + checksum: 6092fda75c63b110c706b6a9bfde8a612ad595b628f0bd2147eea1d3406723020810e591effc7db1da91d80a71a737a313567c5abb3813e8d9c71f4aa595b410 + languageName: node + linkType: hard + +"escape-string-regexp@npm:^4.0.0": + version: 4.0.0 + resolution: "escape-string-regexp@npm:4.0.0" + checksum: 98b48897d93060f2322108bf29db0feba7dd774be96cd069458d1453347b25ce8682ecc39859d4bca2203cc0ab19c237bcc71755eff49a0f8d90beadeeba5cc5 + languageName: node + linkType: hard + +"eslint-config-airbnb-base@npm:^15.0.0": + version: 15.0.0 + resolution: "eslint-config-airbnb-base@npm:15.0.0" + dependencies: + confusing-browser-globals: ^1.0.10 + object.assign: ^4.1.2 + object.entries: ^1.1.5 + semver: ^6.3.0 + peerDependencies: + eslint: ^7.32.0 || ^8.2.0 + eslint-plugin-import: ^2.25.2 + checksum: 38626bad2ce2859fccac86b30cd2b86c9b7d8d71d458331860861dc05290a5b198bded2f4fb89efcb9046ec48f8ab4c4fb00365ba8916f27b172671da28b93ea + languageName: node + linkType: hard + +"eslint-config-prettier@npm:^8.5.0": + version: 8.6.0 + resolution: "eslint-config-prettier@npm:8.6.0" + peerDependencies: + eslint: ">=7.0.0" + bin: + eslint-config-prettier: bin/cli.js + checksum: ff0d0dfc839a556355422293428637e8d35693de58dabf8638bf0b6529131a109d0b2ade77521aa6e54573bb842d7d9d322e465dd73dd61c7590fa3834c3fa81 + languageName: node + linkType: hard + +"eslint-import-resolver-node@npm:^0.3.7": + version: 0.3.7 + resolution: "eslint-import-resolver-node@npm:0.3.7" + dependencies: + debug: ^3.2.7 + is-core-module: ^2.11.0 + resolve: ^1.22.1 + checksum: 3379aacf1d2c6952c1b9666c6fa5982c3023df695430b0d391c0029f6403a7775414873d90f397e98ba6245372b6c8960e16e74d9e4a3b0c0a4582f3bdbe3d6e + languageName: node + linkType: hard + +"eslint-module-utils@npm:^2.7.4": + version: 2.7.4 + resolution: "eslint-module-utils@npm:2.7.4" + dependencies: + debug: ^3.2.7 + peerDependenciesMeta: + eslint: + optional: true + checksum: 5da13645daff145a5c922896b258f8bba560722c3767254e458d894ff5fbb505d6dfd945bffa932a5b0ae06714da2379bd41011c4c20d2d59cc83e23895360f7 + languageName: node + linkType: hard + +"eslint-plugin-import@npm:^2.25.4": + version: 2.27.5 + resolution: "eslint-plugin-import@npm:2.27.5" + dependencies: + array-includes: ^3.1.6 + array.prototype.flat: ^1.3.1 + array.prototype.flatmap: ^1.3.1 + debug: ^3.2.7 + doctrine: ^2.1.0 + eslint-import-resolver-node: ^0.3.7 + eslint-module-utils: ^2.7.4 + has: ^1.0.3 + is-core-module: ^2.11.0 + is-glob: ^4.0.3 + minimatch: ^3.1.2 + object.values: ^1.1.6 + resolve: ^1.22.1 + semver: ^6.3.0 + tsconfig-paths: ^3.14.1 + peerDependencies: + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + checksum: f500571a380167e25d72a4d925ef9a7aae8899eada57653e5f3051ec3d3c16d08271fcefe41a30a9a2f4fefc232f066253673ee4ea77b30dba65ae173dade85d + languageName: node + linkType: hard + +"eslint-plugin-prettier@npm:^4.2.1": + version: 4.2.1 + resolution: "eslint-plugin-prettier@npm:4.2.1" + dependencies: + prettier-linter-helpers: ^1.0.0 + peerDependencies: + eslint: ">=7.28.0" + prettier: ">=2.0.0" + peerDependenciesMeta: + eslint-config-prettier: + optional: true + checksum: b9e839d2334ad8ec7a5589c5cb0f219bded260839a857d7a486997f9870e95106aa59b8756ff3f37202085ebab658de382b0267cae44c3a7f0eb0bcc03a4f6d6 + languageName: node + linkType: hard + +"eslint-scope@npm:^5.1.1": + version: 5.1.1 + resolution: "eslint-scope@npm:5.1.1" + dependencies: + esrecurse: ^4.3.0 + estraverse: ^4.1.1 + checksum: 47e4b6a3f0cc29c7feedee6c67b225a2da7e155802c6ea13bbef4ac6b9e10c66cd2dcb987867ef176292bf4e64eccc680a49e35e9e9c669f4a02bac17e86abdb + languageName: node + linkType: hard + +"eslint-scope@npm:^7.1.1": + version: 7.1.1 + resolution: "eslint-scope@npm:7.1.1" + dependencies: + esrecurse: ^4.3.0 + estraverse: ^5.2.0 + checksum: 9f6e974ab2db641ca8ab13508c405b7b859e72afe9f254e8131ff154d2f40c99ad4545ce326fd9fde3212ff29707102562a4834f1c48617b35d98c71a97fbf3e + languageName: node + linkType: hard + +"eslint-utils@npm:^3.0.0": + version: 3.0.0 + resolution: "eslint-utils@npm:3.0.0" + dependencies: + eslint-visitor-keys: ^2.0.0 + peerDependencies: + eslint: ">=5" + checksum: 0668fe02f5adab2e5a367eee5089f4c39033af20499df88fe4e6aba2015c20720404d8c3d6349b6f716b08fdf91b9da4e5d5481f265049278099c4c836ccb619 + languageName: node + linkType: hard + +"eslint-visitor-keys@npm:^2.0.0": + version: 2.1.0 + resolution: "eslint-visitor-keys@npm:2.1.0" + checksum: e3081d7dd2611a35f0388bbdc2f5da60b3a3c5b8b6e928daffff7391146b434d691577aa95064c8b7faad0b8a680266bcda0a42439c18c717b80e6718d7e267d + languageName: node + linkType: hard + +"eslint-visitor-keys@npm:^3.3.0": + version: 3.3.0 + resolution: "eslint-visitor-keys@npm:3.3.0" + checksum: d59e68a7c5a6d0146526b0eec16ce87fbf97fe46b8281e0d41384224375c4e52f5ffb9e16d48f4ea50785cde93f766b0c898e31ab89978d88b0e1720fbfb7808 + languageName: node + linkType: hard + +"eslint@npm:^8.23.0": + version: 8.33.0 + resolution: "eslint@npm:8.33.0" + dependencies: + "@eslint/eslintrc": ^1.4.1 + "@humanwhocodes/config-array": ^0.11.8 + "@humanwhocodes/module-importer": ^1.0.1 + "@nodelib/fs.walk": ^1.2.8 + ajv: ^6.10.0 + chalk: ^4.0.0 + cross-spawn: ^7.0.2 + debug: ^4.3.2 + doctrine: ^3.0.0 + escape-string-regexp: ^4.0.0 + eslint-scope: ^7.1.1 + eslint-utils: ^3.0.0 + eslint-visitor-keys: ^3.3.0 + espree: ^9.4.0 + esquery: ^1.4.0 + esutils: ^2.0.2 + fast-deep-equal: ^3.1.3 + file-entry-cache: ^6.0.1 + find-up: ^5.0.0 + glob-parent: ^6.0.2 + globals: ^13.19.0 + grapheme-splitter: ^1.0.4 + ignore: ^5.2.0 + import-fresh: ^3.0.0 + imurmurhash: ^0.1.4 + is-glob: ^4.0.0 + is-path-inside: ^3.0.3 + js-sdsl: ^4.1.4 + js-yaml: ^4.1.0 + json-stable-stringify-without-jsonify: ^1.0.1 + levn: ^0.4.1 + lodash.merge: ^4.6.2 + minimatch: ^3.1.2 + natural-compare: ^1.4.0 + optionator: ^0.9.1 + regexpp: ^3.2.0 + strip-ansi: ^6.0.1 + strip-json-comments: ^3.1.0 + text-table: ^0.2.0 + bin: + eslint: bin/eslint.js + checksum: 727e63ab8b7acf281442323c5971f6afdd5b656fbcebc4476cf54e35af51b2f180617433fc5e1952f0449ca3f43a905527f9407ea4b8a7ea7562fc9c3f278d4c + languageName: node + linkType: hard + +"espree@npm:^9.4.0": + version: 9.4.1 + resolution: "espree@npm:9.4.1" + dependencies: + acorn: ^8.8.0 + acorn-jsx: ^5.3.2 + eslint-visitor-keys: ^3.3.0 + checksum: 4d266b0cf81c7dfe69e542c7df0f246e78d29f5b04dda36e514eb4c7af117ee6cfbd3280e560571ed82ff6c9c3f0003c05b82583fc7a94006db7497c4fe4270e + languageName: node + linkType: hard + +"esquery@npm:^1.4.0": + version: 1.4.0 + resolution: "esquery@npm:1.4.0" + dependencies: + estraverse: ^5.1.0 + checksum: a0807e17abd7fbe5fbd4fab673038d6d8a50675cdae6b04fbaa520c34581be0c5fa24582990e8acd8854f671dd291c78bb2efb9e0ed5b62f33bac4f9cf820210 + languageName: node + linkType: hard + +"esrecurse@npm:^4.3.0": + version: 4.3.0 + resolution: "esrecurse@npm:4.3.0" + dependencies: + estraverse: ^5.2.0 + checksum: ebc17b1a33c51cef46fdc28b958994b1dc43cd2e86237515cbc3b4e5d2be6a811b2315d0a1a4d9d340b6d2308b15322f5c8291059521cc5f4802f65e7ec32837 + languageName: node + linkType: hard + +"estraverse@npm:^4.1.1": + version: 4.3.0 + resolution: "estraverse@npm:4.3.0" + checksum: a6299491f9940bb246124a8d44b7b7a413a8336f5436f9837aaa9330209bd9ee8af7e91a654a3545aee9c54b3308e78ee360cef1d777d37cfef77d2fa33b5827 + languageName: node + linkType: hard + +"estraverse@npm:^5.1.0, estraverse@npm:^5.2.0": + version: 5.3.0 + resolution: "estraverse@npm:5.3.0" + checksum: 072780882dc8416ad144f8fe199628d2b3e7bbc9989d9ed43795d2c90309a2047e6bc5979d7e2322a341163d22cfad9e21f4110597fe487519697389497e4e2b + languageName: node + linkType: hard + +"estree-walker@npm:^1.0.1": + version: 1.0.1 + resolution: "estree-walker@npm:1.0.1" + checksum: 7e70da539691f6db03a08e7ce94f394ce2eef4180e136d251af299d41f92fb2d28ebcd9a6e393e3728d7970aeb5358705ddf7209d52fbcb2dd4693f95dcf925f + languageName: node + linkType: hard + +"estree-walker@npm:^2.0.1": + version: 2.0.2 + resolution: "estree-walker@npm:2.0.2" + checksum: 6151e6f9828abe2259e57f5fd3761335bb0d2ebd76dc1a01048ccee22fabcfef3c0859300f6d83ff0d1927849368775ec5a6d265dde2f6de5a1be1721cd94efc + languageName: node + linkType: hard + +"esutils@npm:^2.0.2": + version: 2.0.3 + resolution: "esutils@npm:2.0.3" + checksum: 22b5b08f74737379a840b8ed2036a5fb35826c709ab000683b092d9054e5c2a82c27818f12604bfc2a9a76b90b6834ef081edbc1c7ae30d1627012e067c6ec87 + languageName: node + linkType: hard + +"eventemitter3@npm:^4.0.4": + version: 4.0.7 + resolution: "eventemitter3@npm:4.0.7" + checksum: 1875311c42fcfe9c707b2712c32664a245629b42bb0a5a84439762dd0fd637fc54d078155ea83c2af9e0323c9ac13687e03cfba79b03af9f40c89b4960099374 + languageName: node + linkType: hard + +"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": + version: 3.1.3 + resolution: "fast-deep-equal@npm:3.1.3" + checksum: e21a9d8d84f53493b6aa15efc9cfd53dd5b714a1f23f67fb5dc8f574af80df889b3bce25dc081887c6d25457cce704e636395333abad896ccdec03abaf1f3f9d + languageName: node + linkType: hard + +"fast-diff@npm:^1.1.2": + version: 1.2.0 + resolution: "fast-diff@npm:1.2.0" + checksum: 1b5306eaa9e826564d9e5ffcd6ebd881eb5f770b3f977fcbf38f05c824e42172b53c79920e8429c54eb742ce15a0caf268b0fdd5b38f6de52234c4a8368131ae + languageName: node + linkType: hard + +"fast-glob@npm:^3.2.7, fast-glob@npm:^3.2.9": + version: 3.2.12 + resolution: "fast-glob@npm:3.2.12" + dependencies: + "@nodelib/fs.stat": ^2.0.2 + "@nodelib/fs.walk": ^1.2.3 + glob-parent: ^5.1.2 + merge2: ^1.3.0 + micromatch: ^4.0.4 + checksum: 0b1990f6ce831c7e28c4d505edcdaad8e27e88ab9fa65eedadb730438cfc7cde4910d6c975d6b7b8dc8a73da4773702ebcfcd6e3518e73938bb1383badfe01c2 + languageName: node + linkType: hard + +"fast-json-stable-stringify@npm:^2.0.0": + version: 2.1.0 + resolution: "fast-json-stable-stringify@npm:2.1.0" + checksum: b191531e36c607977e5b1c47811158733c34ccb3bfde92c44798929e9b4154884378536d26ad90dfecd32e1ffc09c545d23535ad91b3161a27ddbb8ebe0cbecb + languageName: node + linkType: hard + +"fast-levenshtein@npm:^2.0.6": + version: 2.0.6 + resolution: "fast-levenshtein@npm:2.0.6" + checksum: 92cfec0a8dfafd9c7a15fba8f2cc29cd0b62b85f056d99ce448bbcd9f708e18ab2764bda4dd5158364f4145a7c72788538994f0d1787b956ef0d1062b0f7c24c + languageName: node + linkType: hard + +"fastq@npm:^1.6.0": + version: 1.15.0 + resolution: "fastq@npm:1.15.0" + dependencies: + reusify: ^1.0.4 + checksum: 0170e6bfcd5d57a70412440b8ef600da6de3b2a6c5966aeaf0a852d542daff506a0ee92d6de7679d1de82e644bce69d7a574a6c93f0b03964b5337eed75ada1a + languageName: node + linkType: hard + +"file-entry-cache@npm:^6.0.1": + version: 6.0.1 + resolution: "file-entry-cache@npm:6.0.1" + dependencies: + flat-cache: ^3.0.4 + checksum: f49701feaa6314c8127c3c2f6173cfefff17612f5ed2daaafc6da13b5c91fd43e3b2a58fd0d63f9f94478a501b167615931e7200e31485e320f74a33885a9c74 + languageName: node + linkType: hard + +"fill-range@npm:^7.0.1": + version: 7.0.1 + resolution: "fill-range@npm:7.0.1" + dependencies: + to-regex-range: ^5.0.1 + checksum: cc283f4e65b504259e64fd969bcf4def4eb08d85565e906b7d36516e87819db52029a76b6363d0f02d0d532f0033c9603b9e2d943d56ee3b0d4f7ad3328ff917 + languageName: node + linkType: hard + +"filter-obj@npm:^1.1.0": + version: 1.1.0 + resolution: "filter-obj@npm:1.1.0" + checksum: cf2104a7c45ff48e7f505b78a3991c8f7f30f28bd8106ef582721f321f1c6277f7751aacd5d83026cb079d9d5091082f588d14a72e7c5d720ece79118fa61e10 + languageName: node + linkType: hard + +"find-cache-dir@npm:^3.3.2": + version: 3.3.2 + resolution: "find-cache-dir@npm:3.3.2" + dependencies: + commondir: ^1.0.1 + make-dir: ^3.0.2 + pkg-dir: ^4.1.0 + checksum: 1e61c2e64f5c0b1c535bd85939ae73b0e5773142713273818cc0b393ee3555fb0fd44e1a5b161b8b6c3e03e98c2fcc9c227d784850a13a90a8ab576869576817 + languageName: node + linkType: hard + +"find-up@npm:^4.0.0": + version: 4.1.0 + resolution: "find-up@npm:4.1.0" + dependencies: + locate-path: ^5.0.0 + path-exists: ^4.0.0 + checksum: 4c172680e8f8c1f78839486e14a43ef82e9decd0e74145f40707cc42e7420506d5ec92d9a11c22bd2c48fb0c384ea05dd30e10dd152fefeec6f2f75282a8b844 + languageName: node + linkType: hard + +"find-up@npm:^5.0.0": + version: 5.0.0 + resolution: "find-up@npm:5.0.0" + dependencies: + locate-path: ^6.0.0 + path-exists: ^4.0.0 + checksum: 07955e357348f34660bde7920783204ff5a26ac2cafcaa28bace494027158a97b9f56faaf2d89a6106211a8174db650dd9f503f9c0d526b1202d5554a00b9095 + languageName: node + linkType: hard + +"flat-cache@npm:^3.0.4": + version: 3.0.4 + resolution: "flat-cache@npm:3.0.4" + dependencies: + flatted: ^3.1.0 + rimraf: ^3.0.2 + checksum: 4fdd10ecbcbf7d520f9040dd1340eb5dfe951e6f0ecf2252edeec03ee68d989ec8b9a20f4434270e71bcfd57800dc09b3344fca3966b2eb8f613072c7d9a2365 + languageName: node + linkType: hard + +"flatted@npm:^3.1.0": + version: 3.2.7 + resolution: "flatted@npm:3.2.7" + checksum: 427633049d55bdb80201c68f7eb1cbd533e03eac541f97d3aecab8c5526f12a20ccecaeede08b57503e772c769e7f8680b37e8d482d1e5f8d7e2194687f9ea35 + languageName: node + linkType: hard + +"for-each@npm:^0.3.3": + version: 0.3.3 + resolution: "for-each@npm:0.3.3" + dependencies: + is-callable: ^1.1.3 + checksum: 6c48ff2bc63362319c65e2edca4a8e1e3483a2fabc72fbe7feaf8c73db94fc7861bd53bc02c8a66a0c1dd709da6b04eec42e0abdd6b40ce47305ae92a25e5d28 + languageName: node + linkType: hard + +"frigate-hass-card@workspace:.": + version: 0.0.0-use.local + resolution: "frigate-hass-card@workspace:." + dependencies: + "@babel/core": ^7.19.0 + "@babel/plugin-proposal-class-properties": ^7.18.6 + "@babel/plugin-proposal-decorators": ^7.19.0 + "@cycjimmy/jsmpeg-player": ^6.0.4 + "@egjs/hammerjs": ^2.0.17 + "@graphiteds/core": ^1.9.6 + "@lit-labs/scoped-registry-mixin": ^1.0.1 + "@lit-labs/task": ^1.1.3 + "@rollup/plugin-babel": ^5.3.1 + "@rollup/plugin-commonjs": ^22.0.2 + "@rollup/plugin-image": ^2.1.1 + "@rollup/plugin-json": ^4.1.0 + "@rollup/plugin-node-resolve": ^13.3.0 + "@rollup/plugin-replace": ^4.0.0 + "@types/bluebird": ^3.5.36 + "@types/lodash-es": ^4.17.5 + "@typescript-eslint/eslint-plugin": ^5.36.2 + "@typescript-eslint/parser": ^5.36.2 + component-emitter: ^1.3.0 + crypto: ^1.0.1 + custom-card-helpers: ^1.9.0 + date-fns: ^2.29.2 + date-fns-tz: ^1.3.7 + embla-carousel: ^7.0.2 + embla-carousel-wheel-gestures: ^3.0.0 + eslint: ^8.23.0 + eslint-config-airbnb-base: ^15.0.0 + eslint-config-prettier: ^8.5.0 + eslint-plugin-import: ^2.25.4 + eslint-plugin-prettier: ^4.2.1 + home-assistant-js-websocket: ^8.0.0 + keycharm: ^0.4.0 + lit: ^2.3.1 + lodash-es: ^4.17.21 + moment: ^2.29.4 + prettier: ^2.6.0 + propagating-hammerjs: ^2.0.1 + quick-lru: ^6.1.0 + rollup: ^2.79.0 + rollup-plugin-git-info: ^1.0.0 + rollup-plugin-serve: ^2.0.1 + rollup-plugin-styles: ^4.0.0 + rollup-plugin-terser: ^7.0.2 + rollup-plugin-typescript2: ^0.33.0 + rollup-plugin-visualizer: ^5.8.2 + sass: ^1.54.9 + screenfull: ^6.0.2 + side-drawer: ^3.1.0 + ts-prune: ^0.10.3 + ts-toolbelt: ^9.6.0 + typescript: ^4.8.3 + uuid: ^8.3.2 + vis-data: ^7.1.4 + vis-timeline: ^7.7.0 + vis-util: ^5.0.2 + xss: ^1.0.14 + zod: ^3.20.2 + languageName: unknown + linkType: soft + +"fs-extra@npm:^10.0.0": + version: 10.1.0 + resolution: "fs-extra@npm:10.1.0" + dependencies: + graceful-fs: ^4.2.0 + jsonfile: ^6.0.1 + universalify: ^2.0.0 + checksum: dc94ab37096f813cc3ca12f0f1b5ad6744dfed9ed21e953d72530d103cea193c2f81584a39e9dee1bea36de5ee66805678c0dddc048e8af1427ac19c00fffc50 + languageName: node + linkType: hard + +"fs-minipass@npm:^2.0.0, fs-minipass@npm:^2.1.0": + version: 2.1.0 + resolution: "fs-minipass@npm:2.1.0" + dependencies: + minipass: ^3.0.0 + checksum: 1b8d128dae2ac6cc94230cc5ead341ba3e0efaef82dab46a33d171c044caaa6ca001364178d42069b2809c35a1c3c35079a32107c770e9ffab3901b59af8c8b1 + languageName: node + linkType: hard + +"fs.realpath@npm:^1.0.0": + version: 1.0.0 + resolution: "fs.realpath@npm:1.0.0" + checksum: 99ddea01a7e75aa276c250a04eedeffe5662bce66c65c07164ad6264f9de18fb21be9433ead460e54cff20e31721c811f4fb5d70591799df5f85dce6d6746fd0 + languageName: node + linkType: hard + +"fsevents@npm:~2.3.2": + version: 2.3.2 + resolution: "fsevents@npm:2.3.2" + dependencies: + node-gyp: latest + checksum: 97ade64e75091afee5265e6956cb72ba34db7819b4c3e94c431d4be2b19b8bb7a2d4116da417950c3425f17c8fe693d25e20212cac583ac1521ad066b77ae31f + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@patch:fsevents@~2.3.2#~builtin": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#~builtin::version=2.3.2&hash=df0bf1" + dependencies: + node-gyp: latest + conditions: os=darwin + languageName: node + linkType: hard + +"function-bind@npm:^1.1.1": + version: 1.1.1 + resolution: "function-bind@npm:1.1.1" + checksum: b32fbaebb3f8ec4969f033073b43f5c8befbb58f1a79e12f1d7490358150359ebd92f49e72ff0144f65f2c48ea2a605bff2d07965f548f6474fd8efd95bf361a + languageName: node + linkType: hard + +"function.prototype.name@npm:^1.1.5": + version: 1.1.5 + resolution: "function.prototype.name@npm:1.1.5" + dependencies: + call-bind: ^1.0.2 + define-properties: ^1.1.3 + es-abstract: ^1.19.0 + functions-have-names: ^1.2.2 + checksum: acd21d733a9b649c2c442f067567743214af5fa248dbeee69d8278ce7df3329ea5abac572be9f7470b4ec1cd4d8f1040e3c5caccf98ebf2bf861a0deab735c27 + languageName: node + linkType: hard + +"functions-have-names@npm:^1.2.2": + version: 1.2.3 + resolution: "functions-have-names@npm:1.2.3" + checksum: c3f1f5ba20f4e962efb71344ce0a40722163e85bee2101ce25f88214e78182d2d2476aa85ef37950c579eb6cf6ee811c17b3101bb84004bb75655f3e33f3fdb5 + languageName: node + linkType: hard + +"gauge@npm:^4.0.3": + version: 4.0.4 + resolution: "gauge@npm:4.0.4" + dependencies: + aproba: ^1.0.3 || ^2.0.0 + color-support: ^1.1.3 + console-control-strings: ^1.1.0 + has-unicode: ^2.0.1 + signal-exit: ^3.0.7 + string-width: ^4.2.3 + strip-ansi: ^6.0.1 + wide-align: ^1.1.5 + checksum: 788b6bfe52f1dd8e263cda800c26ac0ca2ff6de0b6eee2fe0d9e3abf15e149b651bd27bf5226be10e6e3edb5c4e5d5985a5a1a98137e7a892f75eff76467ad2d + languageName: node + linkType: hard + +"gensync@npm:^1.0.0-beta.2": + version: 1.0.0-beta.2 + resolution: "gensync@npm:1.0.0-beta.2" + checksum: a7437e58c6be12aa6c90f7730eac7fa9833dc78872b4ad2963d2031b00a3367a93f98aec75f9aaac7220848e4026d67a8655e870b24f20a543d103c0d65952ec + languageName: node + linkType: hard + +"get-caller-file@npm:^2.0.5": + version: 2.0.5 + resolution: "get-caller-file@npm:2.0.5" + checksum: b9769a836d2a98c3ee734a88ba712e62703f1df31b94b784762c433c27a386dd6029ff55c2a920c392e33657d80191edbf18c61487e198844844516f843496b9 + languageName: node + linkType: hard + +"get-intrinsic@npm:^1.0.2, get-intrinsic@npm:^1.1.1, get-intrinsic@npm:^1.1.3": + version: 1.2.0 + resolution: "get-intrinsic@npm:1.2.0" + dependencies: + function-bind: ^1.1.1 + has: ^1.0.3 + has-symbols: ^1.0.3 + checksum: 78fc0487b783f5c58cf2dccafc3ae656ee8d2d8062a8831ce4a95e7057af4587a1d4882246c033aca0a7b4965276f4802b45cc300338d1b77a73d3e3e3f4877d + languageName: node + linkType: hard + +"get-symbol-description@npm:^1.0.0": + version: 1.0.0 + resolution: "get-symbol-description@npm:1.0.0" + dependencies: + call-bind: ^1.0.2 + get-intrinsic: ^1.1.1 + checksum: 9ceff8fe968f9270a37a1f73bf3f1f7bda69ca80f4f80850670e0e7b9444ff99323f7ac52f96567f8b5f5fbe7ac717a0d81d3407c7313e82810c6199446a5247 + languageName: node + linkType: hard + +"glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": + version: 5.1.2 + resolution: "glob-parent@npm:5.1.2" + dependencies: + is-glob: ^4.0.1 + checksum: f4f2bfe2425296e8a47e36864e4f42be38a996db40420fe434565e4480e3322f18eb37589617a98640c5dc8fdec1a387007ee18dbb1f3f5553409c34d17f425e + languageName: node + linkType: hard + +"glob-parent@npm:^6.0.2": + version: 6.0.2 + resolution: "glob-parent@npm:6.0.2" + dependencies: + is-glob: ^4.0.3 + checksum: c13ee97978bef4f55106b71e66428eb1512e71a7466ba49025fc2aec59a5bfb0954d5abd58fc5ee6c9b076eef4e1f6d3375c2e964b88466ca390da4419a786a8 + languageName: node + linkType: hard + +"glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6": + version: 7.2.3 + resolution: "glob@npm:7.2.3" + dependencies: + fs.realpath: ^1.0.0 + inflight: ^1.0.4 + inherits: 2 + minimatch: ^3.1.1 + once: ^1.3.0 + path-is-absolute: ^1.0.0 + checksum: 29452e97b38fa704dabb1d1045350fb2467cf0277e155aa9ff7077e90ad81d1ea9d53d3ee63bd37c05b09a065e90f16aec4a65f5b8de401d1dac40bc5605d133 + languageName: node + linkType: hard + +"glob@npm:^8.0.1": + version: 8.1.0 + resolution: "glob@npm:8.1.0" + dependencies: + fs.realpath: ^1.0.0 + inflight: ^1.0.4 + inherits: 2 + minimatch: ^5.0.1 + once: ^1.3.0 + checksum: 92fbea3221a7d12075f26f0227abac435de868dd0736a17170663783296d0dd8d3d532a5672b4488a439bf5d7fb85cdd07c11185d6cd39184f0385cbdfb86a47 + languageName: node + linkType: hard + +"globals@npm:^11.1.0": + version: 11.12.0 + resolution: "globals@npm:11.12.0" + checksum: 67051a45eca3db904aee189dfc7cd53c20c7d881679c93f6146ddd4c9f4ab2268e68a919df740d39c71f4445d2b38ee360fc234428baea1dbdfe68bbcb46979e + languageName: node + linkType: hard + +"globals@npm:^13.19.0": + version: 13.20.0 + resolution: "globals@npm:13.20.0" + dependencies: + type-fest: ^0.20.2 + checksum: ad1ecf914bd051325faad281d02ea2c0b1df5d01bd94d368dcc5513340eac41d14b3c61af325768e3c7f8d44576e72780ec0b6f2d366121f8eec6e03c3a3b97a + languageName: node + linkType: hard + +"globalthis@npm:^1.0.3": + version: 1.0.3 + resolution: "globalthis@npm:1.0.3" + dependencies: + define-properties: ^1.1.3 + checksum: fbd7d760dc464c886d0196166d92e5ffb4c84d0730846d6621a39fbbc068aeeb9c8d1421ad330e94b7bca4bb4ea092f5f21f3d36077812af5d098b4dc006c998 + languageName: node + linkType: hard + +"globby@npm:^11.1.0": + version: 11.1.0 + resolution: "globby@npm:11.1.0" + dependencies: + array-union: ^2.1.0 + dir-glob: ^3.0.1 + fast-glob: ^3.2.9 + ignore: ^5.2.0 + merge2: ^1.4.1 + slash: ^3.0.0 + checksum: b4be8885e0cfa018fc783792942d53926c35c50b3aefd3fdcfb9d22c627639dc26bd2327a40a0b74b074100ce95bb7187bfeae2f236856aa3de183af7a02aea6 + languageName: node + linkType: hard + +"gopd@npm:^1.0.1": + version: 1.0.1 + resolution: "gopd@npm:1.0.1" + dependencies: + get-intrinsic: ^1.1.3 + checksum: a5ccfb8806e0917a94e0b3de2af2ea4979c1da920bc381667c260e00e7cafdbe844e2cb9c5bcfef4e5412e8bf73bab837285bc35c7ba73aaaf0134d4583393a6 + languageName: node + linkType: hard + +"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6": + version: 4.2.10 + resolution: "graceful-fs@npm:4.2.10" + checksum: 3f109d70ae123951905d85032ebeae3c2a5a7a997430df00ea30df0e3a6c60cf6689b109654d6fdacd28810a053348c4d14642da1d075049e6be1ba5216218da + languageName: node + linkType: hard + +"grapheme-splitter@npm:^1.0.4": + version: 1.0.4 + resolution: "grapheme-splitter@npm:1.0.4" + checksum: 0c22ec54dee1b05cd480f78cf14f732cb5b108edc073572c4ec205df4cd63f30f8db8025afc5debc8835a8ddeacf648a1c7992fe3dcd6ad38f9a476d84906620 + languageName: node + linkType: hard + +"has-bigints@npm:^1.0.1, has-bigints@npm:^1.0.2": + version: 1.0.2 + resolution: "has-bigints@npm:1.0.2" + checksum: 390e31e7be7e5c6fe68b81babb73dfc35d413604d7ee5f56da101417027a4b4ce6a27e46eff97ad040c835b5d228676eae99a9b5c3bc0e23c8e81a49241ff45b + languageName: node + linkType: hard + +"has-flag@npm:^3.0.0": + version: 3.0.0 + resolution: "has-flag@npm:3.0.0" + checksum: 4a15638b454bf086c8148979aae044dd6e39d63904cd452d970374fa6a87623423da485dfb814e7be882e05c096a7ccf1ebd48e7e7501d0208d8384ff4dea73b + languageName: node + linkType: hard + +"has-flag@npm:^4.0.0": + version: 4.0.0 + resolution: "has-flag@npm:4.0.0" + checksum: 261a1357037ead75e338156b1f9452c016a37dcd3283a972a30d9e4a87441ba372c8b81f818cd0fbcd9c0354b4ae7e18b9e1afa1971164aef6d18c2b6095a8ad + languageName: node + linkType: hard + +"has-property-descriptors@npm:^1.0.0": + version: 1.0.0 + resolution: "has-property-descriptors@npm:1.0.0" + dependencies: + get-intrinsic: ^1.1.1 + checksum: a6d3f0a266d0294d972e354782e872e2fe1b6495b321e6ef678c9b7a06a40408a6891817350c62e752adced73a94ac903c54734fee05bf65b1905ee1368194bb + languageName: node + linkType: hard + +"has-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "has-proto@npm:1.0.1" + checksum: febc5b5b531de8022806ad7407935e2135f1cc9e64636c3916c6842bd7995994ca3b29871ecd7954bd35f9e2986c17b3b227880484d22259e2f8e6ce63fd383e + languageName: node + linkType: hard + +"has-symbols@npm:^1.0.2, has-symbols@npm:^1.0.3": + version: 1.0.3 + resolution: "has-symbols@npm:1.0.3" + checksum: a054c40c631c0d5741a8285010a0777ea0c068f99ed43e5d6eb12972da223f8af553a455132fdb0801bdcfa0e0f443c0c03a68d8555aa529b3144b446c3f2410 + languageName: node + linkType: hard + +"has-tostringtag@npm:^1.0.0": + version: 1.0.0 + resolution: "has-tostringtag@npm:1.0.0" + dependencies: + has-symbols: ^1.0.2 + checksum: cc12eb28cb6ae22369ebaad3a8ab0799ed61270991be88f208d508076a1e99abe4198c965935ce85ea90b60c94ddda73693b0920b58e7ead048b4a391b502c1c + languageName: node + linkType: hard + +"has-unicode@npm:^2.0.1": + version: 2.0.1 + resolution: "has-unicode@npm:2.0.1" + checksum: 1eab07a7436512db0be40a710b29b5dc21fa04880b7f63c9980b706683127e3c1b57cb80ea96d47991bdae2dfe479604f6a1ba410106ee1046a41d1bd0814400 + languageName: node + linkType: hard + +"has@npm:^1.0.3": + version: 1.0.3 + resolution: "has@npm:1.0.3" + dependencies: + function-bind: ^1.1.1 + checksum: b9ad53d53be4af90ce5d1c38331e712522417d017d5ef1ebd0507e07c2fbad8686fffb8e12ddecd4c39ca9b9b47431afbb975b8abf7f3c3b82c98e9aad052792 + languageName: node + linkType: hard + +"home-assistant-js-websocket@npm:^6.0.1": + version: 6.1.1 + resolution: "home-assistant-js-websocket@npm:6.1.1" + checksum: b46dd44ac1b393ecfdda152743fe44dcd6534463d1df631c448a71e71166c04d4fea2a0827dbb2e55c85fcd8d9ccdea4f168713bf18e832c7f688b9b2fdc8d72 + languageName: node + linkType: hard + +"home-assistant-js-websocket@npm:^8.0.0": + version: 8.0.1 + resolution: "home-assistant-js-websocket@npm:8.0.1" + checksum: e8b2204d58b2b1fbdf26ca1ad196fcc02ec5d18e6d867179f27246a9f2d4fe5f91de9dbbe7b82806c19dcb0af0e2b77fb48d393668b5c8c0844c201a16832023 + languageName: node + linkType: hard + +"http-cache-semantics@npm:^4.1.0": + version: 4.1.1 + resolution: "http-cache-semantics@npm:4.1.1" + checksum: 83ac0bc60b17a3a36f9953e7be55e5c8f41acc61b22583060e8dedc9dd5e3607c823a88d0926f9150e571f90946835c7fe150732801010845c72cd8bbff1a236 + languageName: node + linkType: hard + +"http-proxy-agent@npm:^5.0.0": + version: 5.0.0 + resolution: "http-proxy-agent@npm:5.0.0" + dependencies: + "@tootallnate/once": 2 + agent-base: 6 + debug: 4 + checksum: e2ee1ff1656a131953839b2a19cd1f3a52d97c25ba87bd2559af6ae87114abf60971e498021f9b73f9fd78aea8876d1fb0d4656aac8a03c6caa9fc175f22b786 + languageName: node + linkType: hard + +"https-proxy-agent@npm:^5.0.0": + version: 5.0.1 + resolution: "https-proxy-agent@npm:5.0.1" + dependencies: + agent-base: 6 + debug: 4 + checksum: 571fccdf38184f05943e12d37d6ce38197becdd69e58d03f43637f7fa1269cf303a7d228aa27e5b27bbd3af8f09fd938e1c91dcfefff2df7ba77c20ed8dfc765 + languageName: node + linkType: hard + +"humanize-ms@npm:^1.2.1": + version: 1.2.1 + resolution: "humanize-ms@npm:1.2.1" + dependencies: + ms: ^2.0.0 + checksum: 9c7a74a2827f9294c009266c82031030eae811ca87b0da3dceb8d6071b9bde22c9f3daef0469c3c533cc67a97d8a167cd9fc0389350e5f415f61a79b171ded16 + languageName: node + linkType: hard + +"iconv-lite@npm:^0.6.2": + version: 0.6.3 + resolution: "iconv-lite@npm:0.6.3" + dependencies: + safer-buffer: ">= 2.1.2 < 3.0.0" + checksum: 3f60d47a5c8fc3313317edfd29a00a692cc87a19cac0159e2ce711d0ebc9019064108323b5e493625e25594f11c6236647d8e256fbe7a58f4a3b33b89e6d30bf + languageName: node + linkType: hard + +"icss-utils@npm:^5.0.0, icss-utils@npm:^5.1.0": + version: 5.1.0 + resolution: "icss-utils@npm:5.1.0" + peerDependencies: + postcss: ^8.1.0 + checksum: 5c324d283552b1269cfc13a503aaaa172a280f914e5b81544f3803bc6f06a3b585fb79f66f7c771a2c052db7982c18bf92d001e3b47282e3abbbb4c4cc488d68 + languageName: node + linkType: hard + +"ignore@npm:^5.2.0": + version: 5.2.4 + resolution: "ignore@npm:5.2.4" + checksum: 3d4c309c6006e2621659311783eaea7ebcd41fe4ca1d78c91c473157ad6666a57a2df790fe0d07a12300d9aac2888204d7be8d59f9aaf665b1c7fcdb432517ef + languageName: node + linkType: hard + +"immutable@npm:^4.0.0": + version: 4.2.4 + resolution: "immutable@npm:4.2.4" + checksum: 3be84eded37b05e65cad57bfba630bc1bf170c498b7472144bc02d2650cc9baef79daf03574a9c2e41d195ebb55a1c12c9b312f41ee324b653927b24ad8bcaa7 + languageName: node + linkType: hard + +"import-fresh@npm:^3.0.0, import-fresh@npm:^3.2.1": + version: 3.3.0 + resolution: "import-fresh@npm:3.3.0" + dependencies: + parent-module: ^1.0.0 + resolve-from: ^4.0.0 + checksum: 2cacfad06e652b1edc50be650f7ec3be08c5e5a6f6d12d035c440a42a8cc028e60a5b99ca08a77ab4d6b1346da7d971915828f33cdab730d3d42f08242d09baa + languageName: node + linkType: hard + +"imurmurhash@npm:^0.1.4": + version: 0.1.4 + resolution: "imurmurhash@npm:0.1.4" + checksum: 7cae75c8cd9a50f57dadd77482359f659eaebac0319dd9368bcd1714f55e65badd6929ca58569da2b6494ef13fdd5598cd700b1eba23f8b79c5f19d195a3ecf7 + languageName: node + linkType: hard + +"indent-string@npm:^4.0.0": + version: 4.0.0 + resolution: "indent-string@npm:4.0.0" + checksum: 824cfb9929d031dabf059bebfe08cf3137365e112019086ed3dcff6a0a7b698cb80cf67ccccde0e25b9e2d7527aa6cc1fed1ac490c752162496caba3e6699612 + languageName: node + linkType: hard + +"infer-owner@npm:^1.0.4": + version: 1.0.4 + resolution: "infer-owner@npm:1.0.4" + checksum: 181e732764e4a0611576466b4b87dac338972b839920b2a8cde43642e4ed6bd54dc1fb0b40874728f2a2df9a1b097b8ff83b56d5f8f8e3927f837fdcb47d8a89 + languageName: node + linkType: hard + +"inflight@npm:^1.0.4": + version: 1.0.6 + resolution: "inflight@npm:1.0.6" + dependencies: + once: ^1.3.0 + wrappy: 1 + checksum: f4f76aa072ce19fae87ce1ef7d221e709afb59d445e05d47fba710e85470923a75de35bfae47da6de1b18afc3ce83d70facf44cfb0aff89f0a3f45c0a0244dfd + languageName: node + linkType: hard + +"inherits@npm:2, inherits@npm:^2.0.3": + version: 2.0.4 + resolution: "inherits@npm:2.0.4" + checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1 + languageName: node + linkType: hard + +"internal-slot@npm:^1.0.4": + version: 1.0.4 + resolution: "internal-slot@npm:1.0.4" + dependencies: + get-intrinsic: ^1.1.3 + has: ^1.0.3 + side-channel: ^1.0.4 + checksum: 8974588d06bab4f675573a3b52975370facf6486df51bc0567a982c7024fa29495f10b76c0d4dc742dd951d1b72024fdc1e31bb0bedf1678dc7aacacaf5a4f73 + languageName: node + linkType: hard + +"intl-messageformat@npm:^9.11.1": + version: 9.13.0 + resolution: "intl-messageformat@npm:9.13.0" + dependencies: + "@formatjs/ecma402-abstract": 1.11.4 + "@formatjs/fast-memoize": 1.2.1 + "@formatjs/icu-messageformat-parser": 2.1.0 + tslib: ^2.1.0 + checksum: effb840ae6e213adceab9951dbb2d18d1a354c82e35dbd213011daf23bcc79e2a18b6ef79157ae3b69c1e6d898fe66b54dd184ac180ee0646e26a70276e60038 + languageName: node + linkType: hard + +"ip@npm:^2.0.0": + version: 2.0.0 + resolution: "ip@npm:2.0.0" + checksum: cfcfac6b873b701996d71ec82a7dd27ba92450afdb421e356f44044ed688df04567344c36cbacea7d01b1c39a4c732dc012570ebe9bebfb06f27314bca625349 + languageName: node + linkType: hard + +"is-array-buffer@npm:^3.0.1": + version: 3.0.1 + resolution: "is-array-buffer@npm:3.0.1" + dependencies: + call-bind: ^1.0.2 + get-intrinsic: ^1.1.3 + is-typed-array: ^1.1.10 + checksum: f26ab87448e698285daf707e52a533920449f7abf63714140ffab9d5571aa5a71ac2fa2677e8b793ad0d5d3e40078d4d2c8a0ab39c957e3cfc6513bb6c9dfdc9 + languageName: node + linkType: hard + +"is-arrayish@npm:^0.2.1": + version: 0.2.1 + resolution: "is-arrayish@npm:0.2.1" + checksum: eef4417e3c10e60e2c810b6084942b3ead455af16c4509959a27e490e7aee87cfb3f38e01bbde92220b528a0ee1a18d52b787e1458ee86174d8c7f0e58cd488f + languageName: node + linkType: hard + +"is-bigint@npm:^1.0.1": + version: 1.0.4 + resolution: "is-bigint@npm:1.0.4" + dependencies: + has-bigints: ^1.0.1 + checksum: c56edfe09b1154f8668e53ebe8252b6f185ee852a50f9b41e8d921cb2bed425652049fbe438723f6cb48a63ca1aa051e948e7e401e093477c99c84eba244f666 + languageName: node + linkType: hard + +"is-binary-path@npm:~2.1.0": + version: 2.1.0 + resolution: "is-binary-path@npm:2.1.0" + dependencies: + binary-extensions: ^2.0.0 + checksum: 84192eb88cff70d320426f35ecd63c3d6d495da9d805b19bc65b518984b7c0760280e57dbf119b7e9be6b161784a5a673ab2c6abe83abb5198a432232ad5b35c + languageName: node + linkType: hard + +"is-boolean-object@npm:^1.1.0": + version: 1.1.2 + resolution: "is-boolean-object@npm:1.1.2" + dependencies: + call-bind: ^1.0.2 + has-tostringtag: ^1.0.0 + checksum: c03b23dbaacadc18940defb12c1c0e3aaece7553ef58b162a0f6bba0c2a7e1551b59f365b91e00d2dbac0522392d576ef322628cb1d036a0fe51eb466db67222 + languageName: node + linkType: hard + +"is-builtin-module@npm:^3.1.0": + version: 3.2.1 + resolution: "is-builtin-module@npm:3.2.1" + dependencies: + builtin-modules: ^3.3.0 + checksum: e8f0ffc19a98240bda9c7ada84d846486365af88d14616e737d280d378695c8c448a621dcafc8332dbf0fcd0a17b0763b845400709963fa9151ddffece90ae88 + languageName: node + linkType: hard + +"is-callable@npm:^1.1.3, is-callable@npm:^1.1.4, is-callable@npm:^1.2.7": + version: 1.2.7 + resolution: "is-callable@npm:1.2.7" + checksum: 61fd57d03b0d984e2ed3720fb1c7a897827ea174bd44402878e059542ea8c4aeedee0ea0985998aa5cc2736b2fa6e271c08587addb5b3959ac52cf665173d1ac + languageName: node + linkType: hard + +"is-core-module@npm:^2.11.0, is-core-module@npm:^2.9.0": + version: 2.11.0 + resolution: "is-core-module@npm:2.11.0" + dependencies: + has: ^1.0.3 + checksum: f96fd490c6b48eb4f6d10ba815c6ef13f410b0ba6f7eb8577af51697de523e5f2cd9de1c441b51d27251bf0e4aebc936545e33a5d26d5d51f28d25698d4a8bab + languageName: node + linkType: hard + +"is-date-object@npm:^1.0.1": + version: 1.0.5 + resolution: "is-date-object@npm:1.0.5" + dependencies: + has-tostringtag: ^1.0.0 + checksum: baa9077cdf15eb7b58c79398604ca57379b2fc4cf9aa7a9b9e295278648f628c9b201400c01c5e0f7afae56507d741185730307cbe7cad3b9f90a77e5ee342fc + languageName: node + linkType: hard + +"is-docker@npm:^2.0.0, is-docker@npm:^2.1.1": + version: 2.2.1 + resolution: "is-docker@npm:2.2.1" + bin: + is-docker: cli.js + checksum: 3fef7ddbf0be25958e8991ad941901bf5922ab2753c46980b60b05c1bf9c9c2402d35e6dc32e4380b980ef5e1970a5d9d5e5aa2e02d77727c3b6b5e918474c56 + languageName: node + linkType: hard + +"is-extglob@npm:^2.1.1": + version: 2.1.1 + resolution: "is-extglob@npm:2.1.1" + checksum: df033653d06d0eb567461e58a7a8c9f940bd8c22274b94bf7671ab36df5719791aae15eef6d83bbb5e23283967f2f984b8914559d4449efda578c775c4be6f85 + languageName: node + linkType: hard + +"is-fullwidth-code-point@npm:^3.0.0": + version: 3.0.0 + resolution: "is-fullwidth-code-point@npm:3.0.0" + checksum: 44a30c29457c7fb8f00297bce733f0a64cd22eca270f83e58c105e0d015e45c019491a4ab2faef91ab51d4738c670daff901c799f6a700e27f7314029e99e348 + languageName: node + linkType: hard + +"is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3, is-glob@npm:~4.0.1": + version: 4.0.3 + resolution: "is-glob@npm:4.0.3" + dependencies: + is-extglob: ^2.1.1 + checksum: d381c1319fcb69d341cc6e6c7cd588e17cd94722d9a32dbd60660b993c4fb7d0f19438674e68dfec686d09b7c73139c9166b47597f846af387450224a8101ab4 + languageName: node + linkType: hard + +"is-lambda@npm:^1.0.1": + version: 1.0.1 + resolution: "is-lambda@npm:1.0.1" + checksum: 93a32f01940220532e5948538699ad610d5924ac86093fcee83022252b363eb0cc99ba53ab084a04e4fb62bf7b5731f55496257a4c38adf87af9c4d352c71c35 + languageName: node + linkType: hard + +"is-module@npm:^1.0.0": + version: 1.0.0 + resolution: "is-module@npm:1.0.0" + checksum: 8cd5390730c7976fb4e8546dd0b38865ee6f7bacfa08dfbb2cc07219606755f0b01709d9361e01f13009bbbd8099fa2927a8ed665118a6105d66e40f1b838c3f + languageName: node + linkType: hard + +"is-negative-zero@npm:^2.0.2": + version: 2.0.2 + resolution: "is-negative-zero@npm:2.0.2" + checksum: f3232194c47a549da60c3d509c9a09be442507616b69454716692e37ae9f37c4dea264fb208ad0c9f3efd15a796a46b79df07c7e53c6227c32170608b809149a + languageName: node + linkType: hard + +"is-number-object@npm:^1.0.4": + version: 1.0.7 + resolution: "is-number-object@npm:1.0.7" + dependencies: + has-tostringtag: ^1.0.0 + checksum: d1e8d01bb0a7134c74649c4e62da0c6118a0bfc6771ea3c560914d52a627873e6920dd0fd0ebc0e12ad2ff4687eac4c308f7e80320b973b2c8a2c8f97a7524f7 + languageName: node + linkType: hard + +"is-number@npm:^7.0.0": + version: 7.0.0 + resolution: "is-number@npm:7.0.0" + checksum: 456ac6f8e0f3111ed34668a624e45315201dff921e5ac181f8ec24923b99e9f32ca1a194912dc79d539c97d33dba17dc635202ff0b2cf98326f608323276d27a + languageName: node + linkType: hard + +"is-path-inside@npm:^3.0.3": + version: 3.0.3 + resolution: "is-path-inside@npm:3.0.3" + checksum: abd50f06186a052b349c15e55b182326f1936c89a78bf6c8f2b707412517c097ce04bc49a0ca221787bc44e1049f51f09a2ffb63d22899051988d3a618ba13e9 + languageName: node + linkType: hard + +"is-reference@npm:^1.2.1": + version: 1.2.1 + resolution: "is-reference@npm:1.2.1" + dependencies: + "@types/estree": "*" + checksum: e7b48149f8abda2c10849ea51965904d6a714193d68942ad74e30522231045acf06cbfae5a4be2702fede5d232e61bf50b3183acdc056e6e3afe07fcf4f4b2bc + languageName: node + linkType: hard + +"is-regex@npm:^1.1.4": + version: 1.1.4 + resolution: "is-regex@npm:1.1.4" + dependencies: + call-bind: ^1.0.2 + has-tostringtag: ^1.0.0 + checksum: 362399b33535bc8f386d96c45c9feb04cf7f8b41c182f54174c1a45c9abbbe5e31290bbad09a458583ff6bf3b2048672cdb1881b13289569a7c548370856a652 + languageName: node + linkType: hard + +"is-shared-array-buffer@npm:^1.0.2": + version: 1.0.2 + resolution: "is-shared-array-buffer@npm:1.0.2" + dependencies: + call-bind: ^1.0.2 + checksum: 9508929cf14fdc1afc9d61d723c6e8d34f5e117f0bffda4d97e7a5d88c3a8681f633a74f8e3ad1fe92d5113f9b921dc5ca44356492079612f9a247efbce7032a + languageName: node + linkType: hard + +"is-string@npm:^1.0.5, is-string@npm:^1.0.7": + version: 1.0.7 + resolution: "is-string@npm:1.0.7" + dependencies: + has-tostringtag: ^1.0.0 + checksum: 323b3d04622f78d45077cf89aab783b2f49d24dc641aa89b5ad1a72114cfeff2585efc8c12ef42466dff32bde93d839ad321b26884cf75e5a7892a938b089989 + languageName: node + linkType: hard + +"is-symbol@npm:^1.0.2, is-symbol@npm:^1.0.3": + version: 1.0.4 + resolution: "is-symbol@npm:1.0.4" + dependencies: + has-symbols: ^1.0.2 + checksum: 92805812ef590738d9de49d677cd17dfd486794773fb6fa0032d16452af46e9b91bb43ffe82c983570f015b37136f4b53b28b8523bfb10b0ece7a66c31a54510 + languageName: node + linkType: hard + +"is-typed-array@npm:^1.1.10, is-typed-array@npm:^1.1.9": + version: 1.1.10 + resolution: "is-typed-array@npm:1.1.10" + dependencies: + available-typed-arrays: ^1.0.5 + call-bind: ^1.0.2 + for-each: ^0.3.3 + gopd: ^1.0.1 + has-tostringtag: ^1.0.0 + checksum: aac6ecb59d4c56a1cdeb69b1f129154ef462bbffe434cb8a8235ca89b42f258b7ae94073c41b3cb7bce37f6a1733ad4499f07882d5d5093a7ba84dfc4ebb8017 + languageName: node + linkType: hard + +"is-weakref@npm:^1.0.2": + version: 1.0.2 + resolution: "is-weakref@npm:1.0.2" + dependencies: + call-bind: ^1.0.2 + checksum: 95bd9a57cdcb58c63b1c401c60a474b0f45b94719c30f548c891860f051bc2231575c290a6b420c6bc6e7ed99459d424c652bd5bf9a1d5259505dc35b4bf83de + languageName: node + linkType: hard + +"is-wsl@npm:^2.2.0": + version: 2.2.0 + resolution: "is-wsl@npm:2.2.0" + dependencies: + is-docker: ^2.0.0 + checksum: 20849846ae414997d290b75e16868e5261e86ff5047f104027026fd61d8b5a9b0b3ade16239f35e1a067b3c7cc02f70183cb661010ed16f4b6c7c93dad1b19d8 + languageName: node + linkType: hard + +"isexe@npm:^2.0.0": + version: 2.0.0 + resolution: "isexe@npm:2.0.0" + checksum: 26bf6c5480dda5161c820c5b5c751ae1e766c587b1f951ea3fcfc973bafb7831ae5b54a31a69bd670220e42e99ec154475025a468eae58ea262f813fdc8d1c62 + languageName: node + linkType: hard + +"jest-worker@npm:^26.2.1": + version: 26.6.2 + resolution: "jest-worker@npm:26.6.2" + dependencies: + "@types/node": "*" + merge-stream: ^2.0.0 + supports-color: ^7.0.0 + checksum: f9afa3b88e3f12027901e4964ba3ff048285b5783b5225cab28fac25b4058cea8ad54001e9a1577ee2bed125fac3ccf5c80dc507b120300cc1bbcb368796533e + languageName: node + linkType: hard + +"js-sdsl@npm:^4.1.4": + version: 4.3.0 + resolution: "js-sdsl@npm:4.3.0" + checksum: ce908257cf6909e213af580af3a691a736f5ee8b16315454768f917a682a4ea0c11bde1b241bbfaecedc0eb67b72101b2c2df2ffaed32aed5d539fca816f054e + languageName: node + linkType: hard + +"js-tokens@npm:^4.0.0": + version: 4.0.0 + resolution: "js-tokens@npm:4.0.0" + checksum: 8a95213a5a77deb6cbe94d86340e8d9ace2b93bc367790b260101d2f36a2eaf4e4e22d9fa9cf459b38af3a32fb4190e638024cf82ec95ef708680e405ea7cc78 + languageName: node + linkType: hard + +"js-yaml@npm:^4.1.0": + version: 4.1.0 + resolution: "js-yaml@npm:4.1.0" + dependencies: + argparse: ^2.0.1 + bin: + js-yaml: bin/js-yaml.js + checksum: c7830dfd456c3ef2c6e355cc5a92e6700ceafa1d14bba54497b34a99f0376cecbb3e9ac14d3e5849b426d5a5140709a66237a8c991c675431271c4ce5504151a + languageName: node + linkType: hard + +"jsesc@npm:^2.5.1": + version: 2.5.2 + resolution: "jsesc@npm:2.5.2" + bin: + jsesc: bin/jsesc + checksum: 4dc190771129e12023f729ce20e1e0bfceac84d73a85bc3119f7f938843fe25a4aeccb54b6494dce26fcf263d815f5f31acdefac7cc9329efb8422a4f4d9fa9d + languageName: node + linkType: hard + +"json-parse-even-better-errors@npm:^2.3.0": + version: 2.3.1 + resolution: "json-parse-even-better-errors@npm:2.3.1" + checksum: 798ed4cf3354a2d9ccd78e86d2169515a0097a5c133337807cdf7f1fc32e1391d207ccfc276518cc1d7d8d4db93288b8a50ba4293d212ad1336e52a8ec0a941f + languageName: node + linkType: hard + +"json-schema-traverse@npm:^0.4.1": + version: 0.4.1 + resolution: "json-schema-traverse@npm:0.4.1" + checksum: 7486074d3ba247769fda17d5181b345c9fb7d12e0da98b22d1d71a5db9698d8b4bd900a3ec1a4ffdd60846fc2556274a5c894d0c48795f14cb03aeae7b55260b + languageName: node + linkType: hard + +"json-stable-stringify-without-jsonify@npm:^1.0.1": + version: 1.0.1 + resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" + checksum: cff44156ddce9c67c44386ad5cddf91925fe06b1d217f2da9c4910d01f358c6e3989c4d5a02683c7a5667f9727ff05831f7aa8ae66c8ff691c556f0884d49215 + languageName: node + linkType: hard + +"json5@npm:^1.0.1": + version: 1.0.2 + resolution: "json5@npm:1.0.2" + dependencies: + minimist: ^1.2.0 + bin: + json5: lib/cli.js + checksum: 866458a8c58a95a49bef3adba929c625e82532bcff1fe93f01d29cb02cac7c3fe1f4b79951b7792c2da9de0b32871a8401a6e3c5b36778ad852bf5b8a61165d7 + languageName: node + linkType: hard + +"json5@npm:^2.1.3, json5@npm:^2.2.2": + version: 2.2.3 + resolution: "json5@npm:2.2.3" + bin: + json5: lib/cli.js + checksum: 2a7436a93393830bce797d4626275152e37e877b265e94ca69c99e3d20c2b9dab021279146a39cdb700e71b2dd32a4cebd1514cd57cee102b1af906ce5040349 + languageName: node + linkType: hard + +"jsonfile@npm:^6.0.1": + version: 6.1.0 + resolution: "jsonfile@npm:6.1.0" + dependencies: + graceful-fs: ^4.1.6 + universalify: ^2.0.0 + dependenciesMeta: + graceful-fs: + optional: true + checksum: 7af3b8e1ac8fe7f1eccc6263c6ca14e1966fcbc74b618d3c78a0a2075579487547b94f72b7a1114e844a1e15bb00d440e5d1720bfc4612d790a6f285d5ea8354 + languageName: node + linkType: hard + +"keycharm@npm:^0.4.0": + version: 0.4.0 + resolution: "keycharm@npm:0.4.0" + checksum: b6a601e04d7354af4f94facb4174bc46d0788b30b7d19ab8af37dacffb43f6f7ae4ab1f2a2a635de28b4da107b2f1b7219efc789887f7897ed53f209c97cce85 + languageName: node + linkType: hard + +"levn@npm:^0.4.1": + version: 0.4.1 + resolution: "levn@npm:0.4.1" + dependencies: + prelude-ls: ^1.2.1 + type-check: ~0.4.0 + checksum: 12c5021c859bd0f5248561bf139121f0358285ec545ebf48bb3d346820d5c61a4309535c7f387ed7d84361cf821e124ce346c6b7cef8ee09a67c1473b46d0fc4 + languageName: node + linkType: hard + +"lilconfig@npm:^2.0.3": + version: 2.0.6 + resolution: "lilconfig@npm:2.0.6" + checksum: 40a3cd72f103b1be5975f2ac1850810b61d4053e20ab09be8d3aeddfe042187e1ba70b4651a7e70f95efa1642e7dc8b2ae395b317b7d7753b241b43cef7c0f7d + languageName: node + linkType: hard + +"lines-and-columns@npm:^1.1.6": + version: 1.2.4 + resolution: "lines-and-columns@npm:1.2.4" + checksum: 0c37f9f7fa212b38912b7145e1cd16a5f3cd34d782441c3e6ca653485d326f58b3caccda66efce1c5812bde4961bbde3374fae4b0d11bf1226152337f3894aa5 + languageName: node + linkType: hard + +"lit-element@npm:^3.2.0": + version: 3.2.2 + resolution: "lit-element@npm:3.2.2" + dependencies: + "@lit/reactive-element": ^1.3.0 + lit-html: ^2.2.0 + checksum: e1df57c02ad5ae4c42b49a5066b3b623bccf7fa6610bbc3f65bc22cbbe1546ecd6a7f717b1f01863929262f416c4b8b7a39ff166114215f93cc22f4e5b3825c3 + languageName: node + linkType: hard + +"lit-html@npm:^2.2.0, lit-html@npm:^2.6.0": + version: 2.6.1 + resolution: "lit-html@npm:2.6.1" + dependencies: + "@types/trusted-types": ^2.0.2 + checksum: 32291eb5bcaa7fcc1d433522a9a62c3c3d240e49bb8c57ccea5c10014823d94f62f17e2d5361781cb6ee0b3c9078ef839de6164d5374371d650471904515e48e + languageName: node + linkType: hard + +"lit@npm:^2.0.0, lit@npm:^2.1.1, lit@npm:^2.3.1": + version: 2.6.1 + resolution: "lit@npm:2.6.1" + dependencies: + "@lit/reactive-element": ^1.6.0 + lit-element: ^3.2.0 + lit-html: ^2.6.0 + checksum: 74f2813152a48f195784f01b6b4ff7e4e7d8fa90ac272b725d1953fa3e1d6c9dca60b77da435f34144fcdd38705480889a8ea986835676932a8559c897cea517 + languageName: node + linkType: hard + +"locate-path@npm:^5.0.0": + version: 5.0.0 + resolution: "locate-path@npm:5.0.0" + dependencies: + p-locate: ^4.1.0 + checksum: 83e51725e67517287d73e1ded92b28602e3ae5580b301fe54bfb76c0c723e3f285b19252e375712316774cf52006cb236aed5704692c32db0d5d089b69696e30 + languageName: node + linkType: hard + +"locate-path@npm:^6.0.0": + version: 6.0.0 + resolution: "locate-path@npm:6.0.0" + dependencies: + p-locate: ^5.0.0 + checksum: 72eb661788a0368c099a184c59d2fee760b3831c9c1c33955e8a19ae4a21b4116e53fa736dc086cdeb9fce9f7cc508f2f92d2d3aae516f133e16a2bb59a39f5a + languageName: node + linkType: hard + +"lodash-es@npm:^4.17.21": + version: 4.17.21 + resolution: "lodash-es@npm:4.17.21" + checksum: 05cbffad6e2adbb331a4e16fbd826e7faee403a1a04873b82b42c0f22090f280839f85b95393f487c1303c8a3d2a010048bf06151a6cbe03eee4d388fb0a12d2 + languageName: node + linkType: hard + +"lodash.memoize@npm:^4.1.2": + version: 4.1.2 + resolution: "lodash.memoize@npm:4.1.2" + checksum: 9ff3942feeccffa4f1fafa88d32f0d24fdc62fd15ded5a74a5f950ff5f0c6f61916157246744c620173dddf38d37095a92327d5fd3861e2063e736a5c207d089 + languageName: node + linkType: hard + +"lodash.merge@npm:^4.6.2": + version: 4.6.2 + resolution: "lodash.merge@npm:4.6.2" + checksum: ad580b4bdbb7ca1f7abf7e1bce63a9a0b98e370cf40194b03380a46b4ed799c9573029599caebc1b14e3f24b111aef72b96674a56cfa105e0f5ac70546cdc005 + languageName: node + linkType: hard + +"lodash.uniq@npm:^4.5.0": + version: 4.5.0 + resolution: "lodash.uniq@npm:4.5.0" + checksum: a4779b57a8d0f3c441af13d9afe7ecff22dd1b8ce1129849f71d9bbc8e8ee4e46dfb4b7c28f7ad3d67481edd6e51126e4e2a6ee276e25906d10f7140187c392d + languageName: node + linkType: hard + +"lodash@npm:^4.17.21": + version: 4.17.21 + resolution: "lodash@npm:4.17.21" + checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7 + languageName: node + linkType: hard + +"lru-cache@npm:^5.1.1": + version: 5.1.1 + resolution: "lru-cache@npm:5.1.1" + dependencies: + yallist: ^3.0.2 + checksum: c154ae1cbb0c2206d1501a0e94df349653c92c8cbb25236d7e85190bcaf4567a03ac6eb43166fabfa36fd35623694da7233e88d9601fbf411a9a481d85dbd2cb + languageName: node + linkType: hard + +"lru-cache@npm:^6.0.0": + version: 6.0.0 + resolution: "lru-cache@npm:6.0.0" + dependencies: + yallist: ^4.0.0 + checksum: f97f499f898f23e4585742138a22f22526254fdba6d75d41a1c2526b3b6cc5747ef59c5612ba7375f42aca4f8461950e925ba08c991ead0651b4918b7c978297 + languageName: node + linkType: hard + +"lru-cache@npm:^7.7.1": + version: 7.14.1 + resolution: "lru-cache@npm:7.14.1" + checksum: d72c6713c6a6d86836a7a6523b3f1ac6764768cca47ec99341c3e76db06aacd4764620e5e2cda719a36848785a52a70e531822dc2b33fb071fa709683746c104 + languageName: node + linkType: hard + +"magic-string@npm:^0.25.7": + version: 0.25.9 + resolution: "magic-string@npm:0.25.9" + dependencies: + sourcemap-codec: ^1.4.8 + checksum: 9a0e55a15c7303fc360f9572a71cffba1f61451bc92c5602b1206c9d17f492403bf96f946dfce7483e66822d6b74607262e24392e87b0ac27b786e69a40e9b1a + languageName: node + linkType: hard + +"make-dir@npm:^3.0.2": + version: 3.1.0 + resolution: "make-dir@npm:3.1.0" + dependencies: + semver: ^6.0.0 + checksum: 484200020ab5a1fdf12f393fe5f385fc8e4378824c940fba1729dcd198ae4ff24867bc7a5646331e50cead8abff5d9270c456314386e629acec6dff4b8016b78 + languageName: node + linkType: hard + +"make-fetch-happen@npm:^10.0.3": + version: 10.2.1 + resolution: "make-fetch-happen@npm:10.2.1" + dependencies: + agentkeepalive: ^4.2.1 + cacache: ^16.1.0 + http-cache-semantics: ^4.1.0 + http-proxy-agent: ^5.0.0 + https-proxy-agent: ^5.0.0 + is-lambda: ^1.0.1 + lru-cache: ^7.7.1 + minipass: ^3.1.6 + minipass-collect: ^1.0.2 + minipass-fetch: ^2.0.3 + minipass-flush: ^1.0.5 + minipass-pipeline: ^1.2.4 + negotiator: ^0.6.3 + promise-retry: ^2.0.1 + socks-proxy-agent: ^7.0.0 + ssri: ^9.0.0 + checksum: 2332eb9a8ec96f1ffeeea56ccefabcb4193693597b132cd110734d50f2928842e22b84cfa1508e921b8385cdfd06dda9ad68645fed62b50fff629a580f5fb72c + languageName: node + linkType: hard + +"mdn-data@npm:2.0.14": + version: 2.0.14 + resolution: "mdn-data@npm:2.0.14" + checksum: 9d0128ed425a89f4cba8f787dca27ad9408b5cb1b220af2d938e2a0629d17d879a34d2cb19318bdb26c3f14c77dd5dfbae67211f5caaf07b61b1f2c5c8c7dc16 + languageName: node + linkType: hard + +"merge-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-stream@npm:2.0.0" + checksum: 6fa4dcc8d86629705cea944a4b88ef4cb0e07656ebf223fa287443256414283dd25d91c1cd84c77987f2aec5927af1a9db6085757cb43d90eb170ebf4b47f4f4 + languageName: node + linkType: hard + +"merge2@npm:^1.3.0, merge2@npm:^1.4.1": + version: 1.4.1 + resolution: "merge2@npm:1.4.1" + checksum: 7268db63ed5169466540b6fb947aec313200bcf6d40c5ab722c22e242f651994619bcd85601602972d3c85bd2cc45a358a4c61937e9f11a061919a1da569b0c2 + languageName: node + linkType: hard + +"micromatch@npm:^4.0.4": + version: 4.0.5 + resolution: "micromatch@npm:4.0.5" + dependencies: + braces: ^3.0.2 + picomatch: ^2.3.1 + checksum: 02a17b671c06e8fefeeb6ef996119c1e597c942e632a21ef589154f23898c9c6a9858526246abb14f8bca6e77734aa9dcf65476fca47cedfb80d9577d52843fc + languageName: node + linkType: hard + +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 0d99a03585f8b39d68182803b12ac601d9c01abfa28ec56204fa330bc9f3d1c5e14beb049bafadb3dbdf646dfb94b87e24d4ec7b31b7279ef906a8ea9b6a513f + languageName: node + linkType: hard + +"mime-types@npm:^2.1.34": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: 1.52.0 + checksum: 89a5b7f1def9f3af5dad6496c5ed50191ae4331cc5389d7c521c8ad28d5fdad2d06fd81baf38fed813dc4e46bb55c8145bb0ff406330818c9cf712fb2e9b3836 + languageName: node + linkType: hard + +"mime@npm:>=2.4.6": + version: 3.0.0 + resolution: "mime@npm:3.0.0" + bin: + mime: cli.js + checksum: f43f9b7bfa64534e6b05bd6062961681aeb406a5b53673b53b683f27fcc4e739989941836a355eef831f4478923651ecc739f4a5f6e20a76487b432bfd4db928 + languageName: node + linkType: hard + +"mini-svg-data-uri@npm:^1.2.3": + version: 1.4.4 + resolution: "mini-svg-data-uri@npm:1.4.4" + bin: + mini-svg-data-uri: cli.js + checksum: 997f1fbd8d59a70f03761e18626d335197a3479cb9d1ff75678e4b64b864d32a0b8fc18115eabde035e5299b8b4a354a78e57dd6ac10f9d604162a6170898d09 + languageName: node + linkType: hard + +"minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": + version: 3.1.2 + resolution: "minimatch@npm:3.1.2" + dependencies: + brace-expansion: ^1.1.7 + checksum: c154e566406683e7bcb746e000b84d74465b3a832c45d59912b9b55cd50dee66e5c4b1e5566dba26154040e51672f9aa450a9aef0c97cfc7336b78b7afb9540a + languageName: node + linkType: hard + +"minimatch@npm:^5.0.1": + version: 5.1.6 + resolution: "minimatch@npm:5.1.6" + dependencies: + brace-expansion: ^2.0.1 + checksum: 7564208ef81d7065a370f788d337cd80a689e981042cb9a1d0e6580b6c6a8c9279eba80010516e258835a988363f99f54a6f711a315089b8b42694f5da9d0d77 + languageName: node + linkType: hard + +"minimist@npm:^1.2.0, minimist@npm:^1.2.6": + version: 1.2.7 + resolution: "minimist@npm:1.2.7" + checksum: 7346574a1038ca23c32e02252f603801f09384dd1d78b69a943a4e8c2c28730b80e96193882d3d3b22a063445f460e48316b29b8a25addca2d7e5e8f75478bec + languageName: node + linkType: hard + +"minipass-collect@npm:^1.0.2": + version: 1.0.2 + resolution: "minipass-collect@npm:1.0.2" + dependencies: + minipass: ^3.0.0 + checksum: 14df761028f3e47293aee72888f2657695ec66bd7d09cae7ad558da30415fdc4752bbfee66287dcc6fd5e6a2fa3466d6c484dc1cbd986525d9393b9523d97f10 + languageName: node + linkType: hard + +"minipass-fetch@npm:^2.0.3": + version: 2.1.2 + resolution: "minipass-fetch@npm:2.1.2" + dependencies: + encoding: ^0.1.13 + minipass: ^3.1.6 + minipass-sized: ^1.0.3 + minizlib: ^2.1.2 + dependenciesMeta: + encoding: + optional: true + checksum: 3f216be79164e915fc91210cea1850e488793c740534985da017a4cbc7a5ff50506956d0f73bb0cb60e4fe91be08b6b61ef35101706d3ef5da2c8709b5f08f91 + languageName: node + linkType: hard + +"minipass-flush@npm:^1.0.5": + version: 1.0.5 + resolution: "minipass-flush@npm:1.0.5" + dependencies: + minipass: ^3.0.0 + checksum: 56269a0b22bad756a08a94b1ffc36b7c9c5de0735a4dd1ab2b06c066d795cfd1f0ac44a0fcae13eece5589b908ecddc867f04c745c7009be0b566421ea0944cf + languageName: node + linkType: hard + +"minipass-pipeline@npm:^1.2.4": + version: 1.2.4 + resolution: "minipass-pipeline@npm:1.2.4" + dependencies: + minipass: ^3.0.0 + checksum: b14240dac0d29823c3d5911c286069e36d0b81173d7bdf07a7e4a91ecdef92cdff4baaf31ea3746f1c61e0957f652e641223970870e2353593f382112257971b + languageName: node + linkType: hard + +"minipass-sized@npm:^1.0.3": + version: 1.0.3 + resolution: "minipass-sized@npm:1.0.3" + dependencies: + minipass: ^3.0.0 + checksum: 79076749fcacf21b5d16dd596d32c3b6bf4d6e62abb43868fac21674078505c8b15eaca4e47ed844985a4514854f917d78f588fcd029693709417d8f98b2bd60 + languageName: node + linkType: hard + +"minipass@npm:^3.0.0, minipass@npm:^3.1.1, minipass@npm:^3.1.6": + version: 3.3.6 + resolution: "minipass@npm:3.3.6" + dependencies: + yallist: ^4.0.0 + checksum: a30d083c8054cee83cdcdc97f97e4641a3f58ae743970457b1489ce38ee1167b3aaf7d815cd39ec7a99b9c40397fd4f686e83750e73e652b21cb516f6d845e48 + languageName: node + linkType: hard + +"minipass@npm:^4.0.0": + version: 4.0.2 + resolution: "minipass@npm:4.0.2" + checksum: 2e4f4caaf85a45c01c6042adc43b5111a6113f62e300acf1db3b193ac3ec6e955bc893d6d8a08635598771a55a0f60487dcac59a1ac39557eb524295d4778c9e + languageName: node + linkType: hard + +"minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": + version: 2.1.2 + resolution: "minizlib@npm:2.1.2" + dependencies: + minipass: ^3.0.0 + yallist: ^4.0.0 + checksum: f1fdeac0b07cf8f30fcf12f4b586795b97be856edea22b5e9072707be51fc95d41487faec3f265b42973a304fe3a64acd91a44a3826a963e37b37bafde0212c3 + languageName: node + linkType: hard + +"mkdirp@npm:^1.0.3, mkdirp@npm:^1.0.4": + version: 1.0.4 + resolution: "mkdirp@npm:1.0.4" + bin: + mkdirp: bin/cmd.js + checksum: a96865108c6c3b1b8e1d5e9f11843de1e077e57737602de1b82030815f311be11f96f09cce59bd5b903d0b29834733e5313f9301e3ed6d6f6fba2eae0df4298f + languageName: node + linkType: hard + +"moment@npm:^2.29.4": + version: 2.29.4 + resolution: "moment@npm:2.29.4" + checksum: 0ec3f9c2bcba38dc2451b1daed5daded747f17610b92427bebe1d08d48d8b7bdd8d9197500b072d14e326dd0ccf3e326b9e3d07c5895d3d49e39b6803b76e80e + languageName: node + linkType: hard + +"ms@npm:2.1.2": + version: 2.1.2 + resolution: "ms@npm:2.1.2" + checksum: 673cdb2c3133eb050c745908d8ce632ed2c02d85640e2edb3ace856a2266a813b30c613569bf3354fdf4ea7d1a1494add3bfa95e2713baa27d0c2c71fc44f58f + languageName: node + linkType: hard + +"ms@npm:^2.0.0, ms@npm:^2.1.1": + version: 2.1.3 + resolution: "ms@npm:2.1.3" + checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d + languageName: node + linkType: hard + +"nanoid@npm:^3.3.4": + version: 3.3.4 + resolution: "nanoid@npm:3.3.4" + bin: + nanoid: bin/nanoid.cjs + checksum: 2fddd6dee994b7676f008d3ffa4ab16035a754f4bb586c61df5a22cf8c8c94017aadd360368f47d653829e0569a92b129979152ff97af23a558331e47e37cd9c + languageName: node + linkType: hard + +"natural-compare-lite@npm:^1.4.0": + version: 1.4.0 + resolution: "natural-compare-lite@npm:1.4.0" + checksum: 5222ac3986a2b78dd6069ac62cbb52a7bf8ffc90d972ab76dfe7b01892485d229530ed20d0c62e79a6b363a663b273db3bde195a1358ce9e5f779d4453887225 + languageName: node + linkType: hard + +"natural-compare@npm:^1.4.0": + version: 1.4.0 + resolution: "natural-compare@npm:1.4.0" + checksum: 23ad088b08f898fc9b53011d7bb78ec48e79de7627e01ab5518e806033861bef68d5b0cd0e2205c2f36690ac9571ff6bcb05eb777ced2eeda8d4ac5b44592c3d + languageName: node + linkType: hard + +"negotiator@npm:^0.6.3": + version: 0.6.3 + resolution: "negotiator@npm:0.6.3" + checksum: b8ffeb1e262eff7968fc90a2b6767b04cfd9842582a9d0ece0af7049537266e7b2506dfb1d107a32f06dd849ab2aea834d5830f7f4d0e5cb7d36e1ae55d021d9 + languageName: node + linkType: hard + +"node-gyp@npm:latest": + version: 9.3.1 + resolution: "node-gyp@npm:9.3.1" + dependencies: + env-paths: ^2.2.0 + glob: ^7.1.4 + graceful-fs: ^4.2.6 + make-fetch-happen: ^10.0.3 + nopt: ^6.0.0 + npmlog: ^6.0.0 + rimraf: ^3.0.2 + semver: ^7.3.5 + tar: ^6.1.2 + which: ^2.0.2 + bin: + node-gyp: bin/node-gyp.js + checksum: b860e9976fa645ca0789c69e25387401b4396b93c8375489b5151a6c55cf2640a3b6183c212b38625ef7c508994930b72198338e3d09b9d7ade5acc4aaf51ea7 + languageName: node + linkType: hard + +"node-releases@npm:^2.0.8": + version: 2.0.10 + resolution: "node-releases@npm:2.0.10" + checksum: d784ecde25696a15d449c4433077f5cce620ed30a1656c4abf31282bfc691a70d9618bae6868d247a67914d1be5cc4fde22f65a05f4398cdfb92e0fc83cadfbc + languageName: node + linkType: hard + +"nopt@npm:^6.0.0": + version: 6.0.0 + resolution: "nopt@npm:6.0.0" + dependencies: + abbrev: ^1.0.0 + bin: + nopt: bin/nopt.js + checksum: 82149371f8be0c4b9ec2f863cc6509a7fd0fa729929c009f3a58e4eb0c9e4cae9920e8f1f8eb46e7d032fec8fb01bede7f0f41a67eb3553b7b8e14fa53de1dac + languageName: node + linkType: hard + +"normalize-path@npm:^3.0.0, normalize-path@npm:~3.0.0": + version: 3.0.0 + resolution: "normalize-path@npm:3.0.0" + checksum: 88eeb4da891e10b1318c4b2476b6e2ecbeb5ff97d946815ffea7794c31a89017c70d7f34b3c2ebf23ef4e9fc9fb99f7dffe36da22011b5b5c6ffa34f4873ec20 + languageName: node + linkType: hard + +"normalize-url@npm:^6.0.1": + version: 6.1.0 + resolution: "normalize-url@npm:6.1.0" + checksum: 4a4944631173e7d521d6b80e4c85ccaeceb2870f315584fa30121f505a6dfd86439c5e3fdd8cd9e0e291290c41d0c3599f0cb12ab356722ed242584c30348e50 + languageName: node + linkType: hard + +"npmlog@npm:^6.0.0": + version: 6.0.2 + resolution: "npmlog@npm:6.0.2" + dependencies: + are-we-there-yet: ^3.0.0 + console-control-strings: ^1.1.0 + gauge: ^4.0.3 + set-blocking: ^2.0.0 + checksum: ae238cd264a1c3f22091cdd9e2b106f684297d3c184f1146984ecbe18aaa86343953f26b9520dedd1b1372bc0316905b736c1932d778dbeb1fcf5a1001390e2a + languageName: node + linkType: hard + +"nth-check@npm:^2.0.1": + version: 2.1.1 + resolution: "nth-check@npm:2.1.1" + dependencies: + boolbase: ^1.0.0 + checksum: 5afc3dafcd1573b08877ca8e6148c52abd565f1d06b1eb08caf982e3fa289a82f2cae697ffb55b5021e146d60443f1590a5d6b944844e944714a5b549675bcd3 + languageName: node + linkType: hard + +"object-inspect@npm:^1.12.2, object-inspect@npm:^1.9.0": + version: 1.12.3 + resolution: "object-inspect@npm:1.12.3" + checksum: dabfd824d97a5f407e6d5d24810d888859f6be394d8b733a77442b277e0808860555176719c5905e765e3743a7cada6b8b0a3b85e5331c530fd418cc8ae991db + languageName: node + linkType: hard + +"object-keys@npm:^1.1.1": + version: 1.1.1 + resolution: "object-keys@npm:1.1.1" + checksum: b363c5e7644b1e1b04aa507e88dcb8e3a2f52b6ffd0ea801e4c7a62d5aa559affe21c55a07fd4b1fd55fc03a33c610d73426664b20032405d7b92a1414c34d6a + languageName: node + linkType: hard + +"object.assign@npm:^4.1.2, object.assign@npm:^4.1.4": + version: 4.1.4 + resolution: "object.assign@npm:4.1.4" + dependencies: + call-bind: ^1.0.2 + define-properties: ^1.1.4 + has-symbols: ^1.0.3 + object-keys: ^1.1.1 + checksum: 76cab513a5999acbfe0ff355f15a6a125e71805fcf53de4e9d4e082e1989bdb81d1e329291e1e4e0ae7719f0e4ef80e88fb2d367ae60500d79d25a6224ac8864 + languageName: node + linkType: hard + +"object.entries@npm:^1.1.5": + version: 1.1.6 + resolution: "object.entries@npm:1.1.6" + dependencies: + call-bind: ^1.0.2 + define-properties: ^1.1.4 + es-abstract: ^1.20.4 + checksum: 0f8c47517e6a9a980241eafe3b73de11e59511883173c2b93d67424a008e47e11b77c80e431ad1d8a806f6108b225a1cab9223e53e555776c612a24297117d28 + languageName: node + linkType: hard + +"object.values@npm:^1.1.6": + version: 1.1.6 + resolution: "object.values@npm:1.1.6" + dependencies: + call-bind: ^1.0.2 + define-properties: ^1.1.4 + es-abstract: ^1.20.4 + checksum: f6fff9fd817c24cfd8107f50fb33061d81cd11bacc4e3dbb3852e9ff7692fde4dbce823d4333ea27cd9637ef1b6690df5fbb61f1ed314fa2959598dc3ae23d8e + languageName: node + linkType: hard + +"once@npm:^1.3.0": + version: 1.4.0 + resolution: "once@npm:1.4.0" + dependencies: + wrappy: 1 + checksum: cd0a88501333edd640d95f0d2700fbde6bff20b3d4d9bdc521bdd31af0656b5706570d6c6afe532045a20bb8dc0849f8332d6f2a416e0ba6d3d3b98806c7db68 + languageName: node + linkType: hard + +"open@npm:^8.4.0": + version: 8.4.0 + resolution: "open@npm:8.4.0" + dependencies: + define-lazy-prop: ^2.0.0 + is-docker: ^2.1.1 + is-wsl: ^2.2.0 + checksum: e9545bec64cdbf30a0c35c1bdc310344adf8428a117f7d8df3c0af0a0a24c513b304916a6d9b11db0190ff7225c2d578885080b761ed46a3d5f6f1eebb98b63c + languageName: node + linkType: hard + +"opener@npm:1": + version: 1.5.2 + resolution: "opener@npm:1.5.2" + bin: + opener: bin/opener-bin.js + checksum: 33b620c0d53d5b883f2abc6687dd1c5fd394d270dbe33a6356f2d71e0a2ec85b100d5bac94694198ccf5c30d592da863b2292c5539009c715a9c80c697b4f6cc + languageName: node + linkType: hard + +"optionator@npm:^0.9.1": + version: 0.9.1 + resolution: "optionator@npm:0.9.1" + dependencies: + deep-is: ^0.1.3 + fast-levenshtein: ^2.0.6 + levn: ^0.4.1 + prelude-ls: ^1.2.1 + type-check: ^0.4.0 + word-wrap: ^1.2.3 + checksum: dbc6fa065604b24ea57d734261914e697bd73b69eff7f18e967e8912aa2a40a19a9f599a507fa805be6c13c24c4eae8c71306c239d517d42d4c041c942f508a0 + languageName: node + linkType: hard + +"p-finally@npm:^1.0.0": + version: 1.0.0 + resolution: "p-finally@npm:1.0.0" + checksum: 93a654c53dc805dd5b5891bab16eb0ea46db8f66c4bfd99336ae929323b1af2b70a8b0654f8f1eae924b2b73d037031366d645f1fd18b3d30cbd15950cc4b1d4 + languageName: node + linkType: hard + +"p-limit@npm:^2.2.0": + version: 2.3.0 + resolution: "p-limit@npm:2.3.0" + dependencies: + p-try: ^2.0.0 + checksum: 84ff17f1a38126c3314e91ecfe56aecbf36430940e2873dadaa773ffe072dc23b7af8e46d4b6485d302a11673fe94c6b67ca2cfbb60c989848b02100d0594ac1 + languageName: node + linkType: hard + +"p-limit@npm:^3.0.2": + version: 3.1.0 + resolution: "p-limit@npm:3.1.0" + dependencies: + yocto-queue: ^0.1.0 + checksum: 7c3690c4dbf62ef625671e20b7bdf1cbc9534e83352a2780f165b0d3ceba21907e77ad63401708145ca4e25bfc51636588d89a8c0aeb715e6c37d1c066430360 + languageName: node + linkType: hard + +"p-locate@npm:^4.1.0": + version: 4.1.0 + resolution: "p-locate@npm:4.1.0" + dependencies: + p-limit: ^2.2.0 + checksum: 513bd14a455f5da4ebfcb819ef706c54adb09097703de6aeaa5d26fe5ea16df92b48d1ac45e01e3944ce1e6aa2a66f7f8894742b8c9d6e276e16cd2049a2b870 + languageName: node + linkType: hard + +"p-locate@npm:^5.0.0": + version: 5.0.0 + resolution: "p-locate@npm:5.0.0" + dependencies: + p-limit: ^3.0.2 + checksum: 1623088f36cf1cbca58e9b61c4e62bf0c60a07af5ae1ca99a720837356b5b6c5ba3eb1b2127e47a06865fee59dd0453cad7cc844cda9d5a62ac1a5a51b7c86d3 + languageName: node + linkType: hard + +"p-map@npm:^4.0.0": + version: 4.0.0 + resolution: "p-map@npm:4.0.0" + dependencies: + aggregate-error: ^3.0.0 + checksum: cb0ab21ec0f32ddffd31dfc250e3afa61e103ef43d957cc45497afe37513634589316de4eb88abdfd969fe6410c22c0b93ab24328833b8eb1ccc087fc0442a1c + languageName: node + linkType: hard + +"p-queue@npm:^6.6.2": + version: 6.6.2 + resolution: "p-queue@npm:6.6.2" + dependencies: + eventemitter3: ^4.0.4 + p-timeout: ^3.2.0 + checksum: 832642fcc4ab6477b43e6d7c30209ab10952969ed211c6d6f2931be8a4f9935e3578c72e8cce053dc34f2eb6941a408a2c516a54904e989851a1a209cf19761c + languageName: node + linkType: hard + +"p-timeout@npm:^3.2.0": + version: 3.2.0 + resolution: "p-timeout@npm:3.2.0" + dependencies: + p-finally: ^1.0.0 + checksum: 3dd0eaa048780a6f23e5855df3dd45c7beacff1f820476c1d0d1bcd6648e3298752ba2c877aa1c92f6453c7dd23faaf13d9f5149fc14c0598a142e2c5e8d649c + languageName: node + linkType: hard + +"p-try@npm:^2.0.0": + version: 2.2.0 + resolution: "p-try@npm:2.2.0" + checksum: f8a8e9a7693659383f06aec604ad5ead237c7a261c18048a6e1b5b85a5f8a067e469aa24f5bc009b991ea3b058a87f5065ef4176793a200d4917349881216cae + languageName: node + linkType: hard + +"parent-module@npm:^1.0.0": + version: 1.0.1 + resolution: "parent-module@npm:1.0.1" + dependencies: + callsites: ^3.0.0 + checksum: 6ba8b255145cae9470cf5551eb74be2d22281587af787a2626683a6c20fbb464978784661478dd2a3f1dad74d1e802d403e1b03c1a31fab310259eec8ac560ff + languageName: node + linkType: hard + +"parse-json@npm:^5.0.0": + version: 5.2.0 + resolution: "parse-json@npm:5.2.0" + dependencies: + "@babel/code-frame": ^7.0.0 + error-ex: ^1.3.1 + json-parse-even-better-errors: ^2.3.0 + lines-and-columns: ^1.1.6 + checksum: 62085b17d64da57f40f6afc2ac1f4d95def18c4323577e1eced571db75d9ab59b297d1d10582920f84b15985cbfc6b6d450ccbf317644cfa176f3ed982ad87e2 + languageName: node + linkType: hard + +"path-browserify@npm:^1.0.1": + version: 1.0.1 + resolution: "path-browserify@npm:1.0.1" + checksum: c6d7fa376423fe35b95b2d67990060c3ee304fc815ff0a2dc1c6c3cfaff2bd0d572ee67e18f19d0ea3bbe32e8add2a05021132ac40509416459fffee35200699 + languageName: node + linkType: hard + +"path-exists@npm:^4.0.0": + version: 4.0.0 + resolution: "path-exists@npm:4.0.0" + checksum: 505807199dfb7c50737b057dd8d351b82c033029ab94cb10a657609e00c1bc53b951cfdbccab8de04c5584d5eff31128ce6afd3db79281874a5ef2adbba55ed1 + languageName: node + linkType: hard + +"path-is-absolute@npm:^1.0.0": + version: 1.0.1 + resolution: "path-is-absolute@npm:1.0.1" + checksum: 060840f92cf8effa293bcc1bea81281bd7d363731d214cbe5c227df207c34cd727430f70c6037b5159c8a870b9157cba65e775446b0ab06fd5ecc7e54615a3b8 + languageName: node + linkType: hard + +"path-key@npm:^3.1.0": + version: 3.1.1 + resolution: "path-key@npm:3.1.1" + checksum: 55cd7a9dd4b343412a8386a743f9c746ef196e57c823d90ca3ab917f90ab9f13dd0ded27252ba49dbdfcab2b091d998bc446f6220cd3cea65db407502a740020 + languageName: node + linkType: hard + +"path-parse@npm:^1.0.7": + version: 1.0.7 + resolution: "path-parse@npm:1.0.7" + checksum: 49abf3d81115642938a8700ec580da6e830dde670be21893c62f4e10bd7dd4c3742ddc603fe24f898cba7eb0c6bc1777f8d9ac14185d34540c6d4d80cd9cae8a + languageName: node + linkType: hard + +"path-type@npm:^4.0.0": + version: 4.0.0 + resolution: "path-type@npm:4.0.0" + checksum: 5b1e2daa247062061325b8fdbfd1fb56dde0a448fb1455453276ea18c60685bdad23a445dc148cf87bc216be1573357509b7d4060494a6fd768c7efad833ee45 + languageName: node + linkType: hard + +"picocolors@npm:^1.0.0": + version: 1.0.0 + resolution: "picocolors@npm:1.0.0" + checksum: a2e8092dd86c8396bdba9f2b5481032848525b3dc295ce9b57896f931e63fc16f79805144321f72976383fc249584672a75cc18d6777c6b757603f372f745981 + languageName: node + linkType: hard + +"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.2, picomatch@npm:^2.3.1": + version: 2.3.1 + resolution: "picomatch@npm:2.3.1" + checksum: 050c865ce81119c4822c45d3c84f1ced46f93a0126febae20737bd05ca20589c564d6e9226977df859ed5e03dc73f02584a2b0faad36e896936238238b0446cf + languageName: node + linkType: hard + +"pkg-dir@npm:^4.1.0": + version: 4.2.0 + resolution: "pkg-dir@npm:4.2.0" + dependencies: + find-up: ^4.0.0 + checksum: 9863e3f35132bf99ae1636d31ff1e1e3501251d480336edb1c211133c8d58906bed80f154a1d723652df1fda91e01c7442c2eeaf9dc83157c7ae89087e43c8d6 + languageName: node + linkType: hard + +"postcss-calc@npm:^8.2.3": + version: 8.2.4 + resolution: "postcss-calc@npm:8.2.4" + dependencies: + postcss-selector-parser: ^6.0.9 + postcss-value-parser: ^4.2.0 + peerDependencies: + postcss: ^8.2.2 + checksum: 314b4cebb0c4ed0cf8356b4bce71eca78f5a7842e6a3942a3bba49db168d5296b2bd93c3f735ae1c616f2651d94719ade33becc03c73d2d79c7394fb7f73eabb + languageName: node + linkType: hard + +"postcss-colormin@npm:^5.3.0": + version: 5.3.0 + resolution: "postcss-colormin@npm:5.3.0" + dependencies: + browserslist: ^4.16.6 + caniuse-api: ^3.0.0 + colord: ^2.9.1 + postcss-value-parser: ^4.2.0 + peerDependencies: + postcss: ^8.2.15 + checksum: 3d3e3cc25071407fb73d68541ca1039ebd154fceb649041461a8a3cab0400cc89b42dbb34a4eeaf573be4ba2370ce23af5e01aff5e03a8d72275f40605577212 + languageName: node + linkType: hard + +"postcss-convert-values@npm:^5.1.3": + version: 5.1.3 + resolution: "postcss-convert-values@npm:5.1.3" + dependencies: + browserslist: ^4.21.4 + postcss-value-parser: ^4.2.0 + peerDependencies: + postcss: ^8.2.15 + checksum: df48cdaffabf9737f9cfdc58a3dc2841cf282506a7a944f6c70236cff295d3a69f63de6e0935eeb8a9d3f504324e5b4e240abc29e21df9e35a02585d3060aeb5 + languageName: node + linkType: hard + +"postcss-discard-comments@npm:^5.1.2": + version: 5.1.2 + resolution: "postcss-discard-comments@npm:5.1.2" + peerDependencies: + postcss: ^8.2.15 + checksum: abfd064ebc27aeaf5037643dd51ffaff74d1fa4db56b0523d073ace4248cbb64ffd9787bd6924b0983a9d0bd0e9bf9f10d73b120e50391dc236e0d26c812fa2a + languageName: node + linkType: hard + +"postcss-discard-duplicates@npm:^5.1.0": + version: 5.1.0 + resolution: "postcss-discard-duplicates@npm:5.1.0" + peerDependencies: + postcss: ^8.2.15 + checksum: 88d6964201b1f4ed6bf7a32cefe68e86258bb6e42316ca01d9b32bdb18e7887d02594f89f4a2711d01b51ea6e3fcca8c54be18a59770fe5f4521c61d3eb6ca35 + languageName: node + linkType: hard + +"postcss-discard-empty@npm:^5.1.1": + version: 5.1.1 + resolution: "postcss-discard-empty@npm:5.1.1" + peerDependencies: + postcss: ^8.2.15 + checksum: 970adb12fae5c214c0768236ad9a821552626e77dedbf24a8213d19cc2c4a531a757cd3b8cdd3fc22fb1742471b8692a1db5efe436a71236dec12b1318ee8ff4 + languageName: node + linkType: hard + +"postcss-discard-overridden@npm:^5.1.0": + version: 5.1.0 + resolution: "postcss-discard-overridden@npm:5.1.0" + peerDependencies: + postcss: ^8.2.15 + checksum: d64d4a545aa2c81b22542895cfcddc787d24119f294d35d29b0599a1c818b3cc51f4ee80b80f5a0a09db282453dd5ac49f104c2117cc09112d0ac9b40b499a41 + languageName: node + linkType: hard + +"postcss-merge-longhand@npm:^5.1.7": + version: 5.1.7 + resolution: "postcss-merge-longhand@npm:5.1.7" + dependencies: + postcss-value-parser: ^4.2.0 + stylehacks: ^5.1.1 + peerDependencies: + postcss: ^8.2.15 + checksum: 81c3fc809f001b9b71a940148e242bdd6e2d77713d1bfffa15eb25c1f06f6648d5e57cb21645746d020a2a55ff31e1740d2b27900442913a9d53d8a01fb37e1b + languageName: node + linkType: hard + +"postcss-merge-rules@npm:^5.1.3": + version: 5.1.3 + resolution: "postcss-merge-rules@npm:5.1.3" + dependencies: + browserslist: ^4.21.4 + caniuse-api: ^3.0.0 + cssnano-utils: ^3.1.0 + postcss-selector-parser: ^6.0.5 + peerDependencies: + postcss: ^8.2.15 + checksum: 0ddaddff98cd7f3fac2b0e716c641f529a61a8668be6d5b48d60770d0a1246126088e1d606f309b9748ff598a3794f3fd6dd5b8c3d79112f84744cab5375d4d9 + languageName: node + linkType: hard + +"postcss-minify-font-values@npm:^5.1.0": + version: 5.1.0 + resolution: "postcss-minify-font-values@npm:5.1.0" + dependencies: + postcss-value-parser: ^4.2.0 + peerDependencies: + postcss: ^8.2.15 + checksum: 35e858fa41efa05acdeb28f1c76579c409fdc7eabb1744c3bd76e895bb9fea341a016746362a67609688ab2471f587202b9a3e14ea28ad677754d663a2777ece + languageName: node + linkType: hard + +"postcss-minify-gradients@npm:^5.1.1": + version: 5.1.1 + resolution: "postcss-minify-gradients@npm:5.1.1" + dependencies: + colord: ^2.9.1 + cssnano-utils: ^3.1.0 + postcss-value-parser: ^4.2.0 + peerDependencies: + postcss: ^8.2.15 + checksum: 27354072a07c5e6dab36731103b94ca2354d4ed3c5bc6aacfdf2ede5a55fa324679d8fee5450800bc50888dbb5e9ed67569c0012040c2be128143d0cebb36d67 + languageName: node + linkType: hard + +"postcss-minify-params@npm:^5.1.4": + version: 5.1.4 + resolution: "postcss-minify-params@npm:5.1.4" + dependencies: + browserslist: ^4.21.4 + cssnano-utils: ^3.1.0 + postcss-value-parser: ^4.2.0 + peerDependencies: + postcss: ^8.2.15 + checksum: bd63e2cc89edcf357bb5c2a16035f6d02ef676b8cede4213b2bddd42626b3d428403849188f95576fc9f03e43ebd73a29bf61d33a581be9a510b13b7f7f100d5 + languageName: node + linkType: hard + +"postcss-minify-selectors@npm:^5.2.1": + version: 5.2.1 + resolution: "postcss-minify-selectors@npm:5.2.1" + dependencies: + postcss-selector-parser: ^6.0.5 + peerDependencies: + postcss: ^8.2.15 + checksum: 6fdbc84f99a60d56b43df8930707da397775e4c36062a106aea2fd2ac81b5e24e584a1892f4baa4469fa495cb87d1422560eaa8f6c9d500f9f0b691a5f95bab5 + languageName: node + linkType: hard + +"postcss-modules-extract-imports@npm:^3.0.0": + version: 3.0.0 + resolution: "postcss-modules-extract-imports@npm:3.0.0" + peerDependencies: + postcss: ^8.1.0 + checksum: 4b65f2f1382d89c4bc3c0a1bdc5942f52f3cb19c110c57bd591ffab3a5fee03fcf831604168205b0c1b631a3dce2255c70b61aaae3ef39d69cd7eb450c2552d2 + languageName: node + linkType: hard + +"postcss-modules-local-by-default@npm:^4.0.0": + version: 4.0.0 + resolution: "postcss-modules-local-by-default@npm:4.0.0" + dependencies: + icss-utils: ^5.0.0 + postcss-selector-parser: ^6.0.2 + postcss-value-parser: ^4.1.0 + peerDependencies: + postcss: ^8.1.0 + checksum: 6cf570badc7bc26c265e073f3ff9596b69bb954bc6ac9c5c1b8cba2995b80834226b60e0a3cbb87d5f399dbb52e6466bba8aa1d244f6218f99d834aec431a69d + languageName: node + linkType: hard + +"postcss-modules-scope@npm:^3.0.0": + version: 3.0.0 + resolution: "postcss-modules-scope@npm:3.0.0" + dependencies: + postcss-selector-parser: ^6.0.4 + peerDependencies: + postcss: ^8.1.0 + checksum: 330b9398dbd44c992c92b0dc612c0626135e2cc840fee41841eb61247a6cfed95af2bd6f67ead9dd9d0bb41f5b0367129d93c6e434fa3e9c58ade391d9a5a138 + languageName: node + linkType: hard + +"postcss-modules-values@npm:^4.0.0": + version: 4.0.0 + resolution: "postcss-modules-values@npm:4.0.0" + dependencies: + icss-utils: ^5.0.0 + peerDependencies: + postcss: ^8.1.0 + checksum: f7f2cdf14a575b60e919ad5ea52fed48da46fe80db2733318d71d523fc87db66c835814940d7d05b5746b0426e44661c707f09bdb83592c16aea06e859409db6 + languageName: node + linkType: hard + +"postcss-normalize-charset@npm:^5.1.0": + version: 5.1.0 + resolution: "postcss-normalize-charset@npm:5.1.0" + peerDependencies: + postcss: ^8.2.15 + checksum: e79d92971fc05b8b3c9b72f3535a574e077d13c69bef68156a0965f397fdf157de670da72b797f57b0e3bac8f38155b5dd1735ecab143b9cc4032d72138193b4 + languageName: node + linkType: hard + +"postcss-normalize-display-values@npm:^5.1.0": + version: 5.1.0 + resolution: "postcss-normalize-display-values@npm:5.1.0" + dependencies: + postcss-value-parser: ^4.2.0 + peerDependencies: + postcss: ^8.2.15 + checksum: b6eb7b9b02c3bdd62bbc54e01e2b59733d73a1c156905d238e178762962efe0c6f5104544da39f32cade8a4fb40f10ff54b63a8ebfbdff51e8780afb9fbdcf86 + languageName: node + linkType: hard + +"postcss-normalize-positions@npm:^5.1.1": + version: 5.1.1 + resolution: "postcss-normalize-positions@npm:5.1.1" + dependencies: + postcss-value-parser: ^4.2.0 + peerDependencies: + postcss: ^8.2.15 + checksum: d9afc233729c496463c7b1cdd06732469f401deb387484c3a2422125b46ec10b4af794c101f8c023af56f01970b72b535e88373b9058ecccbbf88db81662b3c4 + languageName: node + linkType: hard + +"postcss-normalize-repeat-style@npm:^5.1.1": + version: 5.1.1 + resolution: "postcss-normalize-repeat-style@npm:5.1.1" + dependencies: + postcss-value-parser: ^4.2.0 + peerDependencies: + postcss: ^8.2.15 + checksum: 2c6ad2b0ae10a1fda156b948c34f78c8f1e185513593de4d7e2480973586675520edfec427645fa168c337b0a6b3ceca26f92b96149741ca98a9806dad30d534 + languageName: node + linkType: hard + +"postcss-normalize-string@npm:^5.1.0": + version: 5.1.0 + resolution: "postcss-normalize-string@npm:5.1.0" + dependencies: + postcss-value-parser: ^4.2.0 + peerDependencies: + postcss: ^8.2.15 + checksum: 6e549c6e5b2831e34c7bdd46d8419e2278f6af1d5eef6d26884a37c162844e60339340c57e5e06058cdbe32f27fc6258eef233e811ed2f71168ef2229c236ada + languageName: node + linkType: hard + +"postcss-normalize-timing-functions@npm:^5.1.0": + version: 5.1.0 + resolution: "postcss-normalize-timing-functions@npm:5.1.0" + dependencies: + postcss-value-parser: ^4.2.0 + peerDependencies: + postcss: ^8.2.15 + checksum: da550f50e90b0b23e17b67449a7d1efd1aa68288e66d4aa7614ca6f5cc012896be1972b7168eee673d27da36504faccf7b9f835c0f7e81243f966a42c8c030aa + languageName: node + linkType: hard + +"postcss-normalize-unicode@npm:^5.1.1": + version: 5.1.1 + resolution: "postcss-normalize-unicode@npm:5.1.1" + dependencies: + browserslist: ^4.21.4 + postcss-value-parser: ^4.2.0 + peerDependencies: + postcss: ^8.2.15 + checksum: 4c24d26cc9f4b19a9397db4e71dd600dab690f1de8e14a3809e2aa1452dbc3791c208c38a6316bbc142f29e934fdf02858e68c94038c06174d78a4937e0f273c + languageName: node + linkType: hard + +"postcss-normalize-url@npm:^5.1.0": + version: 5.1.0 + resolution: "postcss-normalize-url@npm:5.1.0" + dependencies: + normalize-url: ^6.0.1 + postcss-value-parser: ^4.2.0 + peerDependencies: + postcss: ^8.2.15 + checksum: 3bd4b3246d6600230bc827d1760b24cb3101827ec97570e3016cbe04dc0dd28f4dbe763245d1b9d476e182c843008fbea80823061f1d2219b96f0d5c724a24c0 + languageName: node + linkType: hard + +"postcss-normalize-whitespace@npm:^5.1.1": + version: 5.1.1 + resolution: "postcss-normalize-whitespace@npm:5.1.1" + dependencies: + postcss-value-parser: ^4.2.0 + peerDependencies: + postcss: ^8.2.15 + checksum: 12d8fb6d1c1cba208cc08c1830959b7d7ad447c3f5581873f7e185f99a9a4230c43d3af21ca12c818e4690a5085a95b01635b762ad4a7bef69d642609b4c0e19 + languageName: node + linkType: hard + +"postcss-ordered-values@npm:^5.1.3": + version: 5.1.3 + resolution: "postcss-ordered-values@npm:5.1.3" + dependencies: + cssnano-utils: ^3.1.0 + postcss-value-parser: ^4.2.0 + peerDependencies: + postcss: ^8.2.15 + checksum: 6f3ca85b6ceffc68aadaf319d9ee4c5ac16d93195bf8cba2d1559b631555ad61941461cda6d3909faab86e52389846b2b36345cff8f0c3f4eb345b1b8efadcf9 + languageName: node + linkType: hard + +"postcss-reduce-initial@npm:^5.1.1": + version: 5.1.1 + resolution: "postcss-reduce-initial@npm:5.1.1" + dependencies: + browserslist: ^4.21.4 + caniuse-api: ^3.0.0 + peerDependencies: + postcss: ^8.2.15 + checksum: 1b704aba8c38103cbb5a75c6201dbf58ec2f3a978013c7f7e8957fd3bf3282f992050dec5a01bc050d031bad836e187dd6622b922ca78ab92bcd0afd21fb0b98 + languageName: node + linkType: hard + +"postcss-reduce-transforms@npm:^5.1.0": + version: 5.1.0 + resolution: "postcss-reduce-transforms@npm:5.1.0" + dependencies: + postcss-value-parser: ^4.2.0 + peerDependencies: + postcss: ^8.2.15 + checksum: 0c6af2cba20e3ff63eb9ad045e634ddfb9c3e5c0e614c020db2a02f3aa20632318c4ede9e0c995f9225d9a101e673de91c0a6e10bb2fa5da6d6c75d15a55882f + languageName: node + linkType: hard + +"postcss-selector-parser@npm:^6.0.2, postcss-selector-parser@npm:^6.0.4, postcss-selector-parser@npm:^6.0.5, postcss-selector-parser@npm:^6.0.9": + version: 6.0.11 + resolution: "postcss-selector-parser@npm:6.0.11" + dependencies: + cssesc: ^3.0.0 + util-deprecate: ^1.0.2 + checksum: 0b01aa9c2d2c8dbeb51e9b204796b678284be9823abc8d6d40a8b16d4149514e922c264a8ed4deb4d6dbced564b9be390f5942c058582d8656351516d6c49cde + languageName: node + linkType: hard + +"postcss-svgo@npm:^5.1.0": + version: 5.1.0 + resolution: "postcss-svgo@npm:5.1.0" + dependencies: + postcss-value-parser: ^4.2.0 + svgo: ^2.7.0 + peerDependencies: + postcss: ^8.2.15 + checksum: d86eb5213d9f700cf5efe3073799b485fb7cacae0c731db3d7749c9c2b1c9bc85e95e0baeca439d699ff32ea24815fc916c4071b08f67ed8219df229ce1129bd + languageName: node + linkType: hard + +"postcss-unique-selectors@npm:^5.1.1": + version: 5.1.1 + resolution: "postcss-unique-selectors@npm:5.1.1" + dependencies: + postcss-selector-parser: ^6.0.5 + peerDependencies: + postcss: ^8.2.15 + checksum: 637e7b786e8558265775c30400c54b6b3b24d4748923f4a39f16a65fd0e394f564ccc9f0a1d3c0e770618a7637a7502ea1d0d79f731d429cb202255253c23278 + languageName: node + linkType: hard + +"postcss-value-parser@npm:^4.1.0, postcss-value-parser@npm:^4.2.0": + version: 4.2.0 + resolution: "postcss-value-parser@npm:4.2.0" + checksum: 819ffab0c9d51cf0acbabf8996dffbfafbafa57afc0e4c98db88b67f2094cb44488758f06e5da95d7036f19556a4a732525e84289a425f4f6fd8e412a9d7442f + languageName: node + linkType: hard + +"postcss@npm:^8.4.5": + version: 8.4.21 + resolution: "postcss@npm:8.4.21" + dependencies: + nanoid: ^3.3.4 + picocolors: ^1.0.0 + source-map-js: ^1.0.2 + checksum: e39ac60ccd1542d4f9d93d894048aac0d686b3bb38e927d8386005718e6793dbbb46930f0a523fe382f1bbd843c6d980aaea791252bf5e176180e5a4336d9679 + languageName: node + linkType: hard + +"prelude-ls@npm:^1.2.1": + version: 1.2.1 + resolution: "prelude-ls@npm:1.2.1" + checksum: cd192ec0d0a8e4c6da3bb80e4f62afe336df3f76271ac6deb0e6a36187133b6073a19e9727a1ff108cd8b9982e4768850d413baa71214dd80c7979617dca827a + languageName: node + linkType: hard + +"prettier-linter-helpers@npm:^1.0.0": + version: 1.0.0 + resolution: "prettier-linter-helpers@npm:1.0.0" + dependencies: + fast-diff: ^1.1.2 + checksum: 00ce8011cf6430158d27f9c92cfea0a7699405633f7f1d4a45f07e21bf78e99895911cbcdc3853db3a824201a7c745bd49bfea8abd5fb9883e765a90f74f8392 + languageName: node + linkType: hard + +"prettier@npm:^2.6.0": + version: 2.8.3 + resolution: "prettier@npm:2.8.3" + bin: + prettier: bin-prettier.js + checksum: 92f2ceb522d454370e02082aa74ad27388672f7cee8975028b59517c069fe643bdc73e322675c8faf2ff173d7a626d1a6389f26b474000308e793aa25fff46e5 + languageName: node + linkType: hard + +"promise-inflight@npm:^1.0.1": + version: 1.0.1 + resolution: "promise-inflight@npm:1.0.1" + checksum: 22749483091d2c594261517f4f80e05226d4d5ecc1fc917e1886929da56e22b5718b7f2a75f3807e7a7d471bc3be2907fe92e6e8f373ddf5c64bae35b5af3981 + languageName: node + linkType: hard + +"promise-retry@npm:^2.0.1": + version: 2.0.1 + resolution: "promise-retry@npm:2.0.1" + dependencies: + err-code: ^2.0.2 + retry: ^0.12.0 + checksum: f96a3f6d90b92b568a26f71e966cbbc0f63ab85ea6ff6c81284dc869b41510e6cdef99b6b65f9030f0db422bf7c96652a3fff9f2e8fb4a0f069d8f4430359429 + languageName: node + linkType: hard + +"propagating-hammerjs@npm:^2.0.1": + version: 2.0.1 + resolution: "propagating-hammerjs@npm:2.0.1" + peerDependencies: + "@egjs/hammerjs": ^2.0.17 + checksum: 7177010beae4536c681f0d44c5f7208fb46d4c90cd79f18d7e96e2b4e0f2a3386b79b291e74813a3ccc155ad5dd40bf87796ec63efda84f4e6bdd82007f4270a + languageName: node + linkType: hard + +"punycode@npm:^2.1.0": + version: 2.3.0 + resolution: "punycode@npm:2.3.0" + checksum: 39f760e09a2a3bbfe8f5287cf733ecdad69d6af2fe6f97ca95f24b8921858b91e9ea3c9eeec6e08cede96181b3bb33f95c6ffd8c77e63986508aa2e8159fa200 + languageName: node + linkType: hard + +"query-string@npm:^7.1.0": + version: 7.1.3 + resolution: "query-string@npm:7.1.3" + dependencies: + decode-uri-component: ^0.2.2 + filter-obj: ^1.1.0 + split-on-first: ^1.0.0 + strict-uri-encode: ^2.0.0 + checksum: 91af02dcd9cc9227a052841d5c2eecb80a0d6489d05625df506a097ef1c59037cfb5e907f39b84643cbfd535c955abec3e553d0130a7b510120c37d06e0f4346 + languageName: node + linkType: hard + +"queue-microtask@npm:^1.2.2": + version: 1.2.3 + resolution: "queue-microtask@npm:1.2.3" + checksum: b676f8c040cdc5b12723ad2f91414d267605b26419d5c821ff03befa817ddd10e238d22b25d604920340fd73efd8ba795465a0377c4adf45a4a41e4234e42dc4 + languageName: node + linkType: hard + +"quick-lru@npm:^6.1.0": + version: 6.1.1 + resolution: "quick-lru@npm:6.1.1" + checksum: a9c75bf1d208c1d207590e10403a75d037f9faf7857f2e0d38c294d92b40d8b400776bcb5d4dd516baa84e6f5e3baf2a16cf405e6fedc18107922f46aeae83ee + languageName: node + linkType: hard + +"randombytes@npm:^2.1.0": + version: 2.1.0 + resolution: "randombytes@npm:2.1.0" + dependencies: + safe-buffer: ^5.1.0 + checksum: d779499376bd4cbb435ef3ab9a957006c8682f343f14089ed5f27764e4645114196e75b7f6abf1cbd84fd247c0cb0651698444df8c9bf30e62120fbbc52269d6 + languageName: node + linkType: hard + +"readable-stream@npm:^3.6.0": + version: 3.6.0 + resolution: "readable-stream@npm:3.6.0" + dependencies: + inherits: ^2.0.3 + string_decoder: ^1.1.1 + util-deprecate: ^1.0.1 + checksum: d4ea81502d3799439bb955a3a5d1d808592cf3133350ed352aeaa499647858b27b1c4013984900238b0873ec8d0d8defce72469fb7a83e61d53f5ad61cb80dc8 + languageName: node + linkType: hard + +"readdirp@npm:~3.6.0": + version: 3.6.0 + resolution: "readdirp@npm:3.6.0" + dependencies: + picomatch: ^2.2.1 + checksum: 1ced032e6e45670b6d7352d71d21ce7edf7b9b928494dcaba6f11fba63180d9da6cd7061ebc34175ffda6ff529f481818c962952004d273178acd70f7059b320 + languageName: node + linkType: hard + +"regexp.prototype.flags@npm:^1.4.3": + version: 1.4.3 + resolution: "regexp.prototype.flags@npm:1.4.3" + dependencies: + call-bind: ^1.0.2 + define-properties: ^1.1.3 + functions-have-names: ^1.2.2 + checksum: 51228bae732592adb3ededd5e15426be25f289e9c4ef15212f4da73f4ec3919b6140806374b8894036a86020d054a8d2657d3fee6bb9b4d35d8939c20030b7a6 + languageName: node + linkType: hard + +"regexpp@npm:^3.2.0": + version: 3.2.0 + resolution: "regexpp@npm:3.2.0" + checksum: a78dc5c7158ad9ddcfe01aa9144f46e192ddbfa7b263895a70a5c6c73edd9ce85faf7c0430e59ac38839e1734e275b9c3de5c57ee3ab6edc0e0b1bdebefccef8 + languageName: node + linkType: hard + +"require-directory@npm:^2.1.1": + version: 2.1.1 + resolution: "require-directory@npm:2.1.1" + checksum: fb47e70bf0001fdeabdc0429d431863e9475e7e43ea5f94ad86503d918423c1543361cc5166d713eaa7029dd7a3d34775af04764bebff99ef413111a5af18c80 + languageName: node + linkType: hard + +"resolve-from@npm:^4.0.0": + version: 4.0.0 + resolution: "resolve-from@npm:4.0.0" + checksum: f4ba0b8494846a5066328ad33ef8ac173801a51739eb4d63408c847da9a2e1c1de1e6cbbf72699211f3d13f8fc1325648b169bd15eb7da35688e30a5fb0e4a7f + languageName: node + linkType: hard + +"resolve@npm:^1.17.0, resolve@npm:^1.19.0, resolve@npm:^1.21.0, resolve@npm:^1.22.1": + version: 1.22.1 + resolution: "resolve@npm:1.22.1" + dependencies: + is-core-module: ^2.9.0 + path-parse: ^1.0.7 + supports-preserve-symlinks-flag: ^1.0.0 + bin: + resolve: bin/resolve + checksum: 07af5fc1e81aa1d866cbc9e9460fbb67318a10fa3c4deadc35c3ad8a898ee9a71a86a65e4755ac3195e0ea0cfbe201eb323ebe655ce90526fd61917313a34e4e + languageName: node + linkType: hard + +"resolve@patch:resolve@^1.17.0#~builtin, resolve@patch:resolve@^1.19.0#~builtin, resolve@patch:resolve@^1.21.0#~builtin, resolve@patch:resolve@^1.22.1#~builtin": + version: 1.22.1 + resolution: "resolve@patch:resolve@npm%3A1.22.1#~builtin::version=1.22.1&hash=c3c19d" + dependencies: + is-core-module: ^2.9.0 + path-parse: ^1.0.7 + supports-preserve-symlinks-flag: ^1.0.0 + bin: + resolve: bin/resolve + checksum: 5656f4d0bedcf8eb52685c1abdf8fbe73a1603bb1160a24d716e27a57f6cecbe2432ff9c89c2bd57542c3a7b9d14b1882b73bfe2e9d7849c9a4c0b8b39f02b8b + languageName: node + linkType: hard + +"retry@npm:^0.12.0": + version: 0.12.0 + resolution: "retry@npm:0.12.0" + checksum: 623bd7d2e5119467ba66202d733ec3c2e2e26568074923bc0585b6b99db14f357e79bdedb63cab56cec47491c4a0da7e6021a7465ca6dc4f481d3898fdd3158c + languageName: node + linkType: hard + +"reusify@npm:^1.0.4": + version: 1.0.4 + resolution: "reusify@npm:1.0.4" + checksum: c3076ebcc22a6bc252cb0b9c77561795256c22b757f40c0d8110b1300723f15ec0fc8685e8d4ea6d7666f36c79ccc793b1939c748bf36f18f542744a4e379fcc + languageName: node + linkType: hard + +"rimraf@npm:^3.0.2": + version: 3.0.2 + resolution: "rimraf@npm:3.0.2" + dependencies: + glob: ^7.1.3 + bin: + rimraf: bin.js + checksum: 87f4164e396f0171b0a3386cc1877a817f572148ee13a7e113b238e48e8a9f2f31d009a92ec38a591ff1567d9662c6b67fd8818a2dbbaed74bc26a87a2a4a9a0 + languageName: node + linkType: hard + +"rollup-plugin-git-info@npm:^1.0.0": + version: 1.0.0 + resolution: "rollup-plugin-git-info@npm:1.0.0" + dependencies: + "@rollup/plugin-json": ^4.1.0 + checksum: b54133235f102fa392edfaae2da2c4568cd6c8ac59dfd0cdf5b003f6af0ce0dc423b3f4686ac671d1302af80bc752df04d10c76152a76b9e25b1d58f1c337751 + languageName: node + linkType: hard + +"rollup-plugin-serve@npm:^2.0.1": + version: 2.0.2 + resolution: "rollup-plugin-serve@npm:2.0.2" + dependencies: + mime: ">=2.4.6" + opener: 1 + checksum: de74616bc197360d282cabb26aa2f77780b4864364f62562d1d6cb3fd3ba74f956e1ce1bd881edf122e11e234b96132857a760e0668f22aebbd54a0fc55d1bec + languageName: node + linkType: hard + +"rollup-plugin-styles@npm:^4.0.0": + version: 4.0.0 + resolution: "rollup-plugin-styles@npm:4.0.0" + dependencies: + "@rollup/pluginutils": ^4.1.2 + "@types/cssnano": ^5.0.0 + cosmiconfig: ^7.0.1 + cssnano: ^5.0.15 + fs-extra: ^10.0.0 + icss-utils: ^5.1.0 + mime-types: ^2.1.34 + p-queue: ^6.6.2 + postcss: ^8.4.5 + postcss-modules-extract-imports: ^3.0.0 + postcss-modules-local-by-default: ^4.0.0 + postcss-modules-scope: ^3.0.0 + postcss-modules-values: ^4.0.0 + postcss-value-parser: ^4.2.0 + query-string: ^7.1.0 + resolve: ^1.21.0 + source-map-js: ^1.0.1 + tslib: ^2.3.1 + peerDependencies: + rollup: ^2.63.0 + checksum: 857e5fd6820aedf2f751878d2ef4fa40210094fb548d1baaa1dafbcdd7f7ea9775bac1c899b305bd27a23026d0694940651692a39901ac2ba2f8ce9f2bd10eb1 + languageName: node + linkType: hard + +"rollup-plugin-terser@npm:^7.0.2": + version: 7.0.2 + resolution: "rollup-plugin-terser@npm:7.0.2" + dependencies: + "@babel/code-frame": ^7.10.4 + jest-worker: ^26.2.1 + serialize-javascript: ^4.0.0 + terser: ^5.0.0 + peerDependencies: + rollup: ^2.0.0 + checksum: af84bb7a7a894cd00852b6486528dfb8653cf94df4c126f95f389a346f401d054b08c46bee519a2ab6a22b33804d1d6ac6d8c90b1b2bf8fffb097eed73fc3c72 + languageName: node + linkType: hard + +"rollup-plugin-typescript2@npm:^0.33.0": + version: 0.33.0 + resolution: "rollup-plugin-typescript2@npm:0.33.0" + dependencies: + "@rollup/pluginutils": ^4.1.2 + find-cache-dir: ^3.3.2 + fs-extra: ^10.0.0 + tslib: ^2.4.0 + peerDependencies: + rollup: ">=1.26.3" + typescript: ">=2.4.0" + checksum: 03e3077c5197f512222f11ebb8b7682c97ddbd6332189ea06884aa3e778a04107483d340f3be94dbc471595e8908687dd841b81976855485486097b6bb095f65 + languageName: node + linkType: hard + +"rollup-plugin-visualizer@npm:^5.8.2": + version: 5.9.0 + resolution: "rollup-plugin-visualizer@npm:5.9.0" + dependencies: + open: ^8.4.0 + picomatch: ^2.3.1 + source-map: ^0.7.4 + yargs: ^17.5.1 + peerDependencies: + rollup: 2.x || 3.x + peerDependenciesMeta: + rollup: + optional: true + bin: + rollup-plugin-visualizer: dist/bin/cli.js + checksum: 362d4fac0295c14bd205dbc85c20c31f4b6c47604868da21d9565ed47e0333759f08b9fe0acb82f78221f5173ea01e4eb70d47351eb6012216afe71b5492ed5f + languageName: node + linkType: hard + +"rollup@npm:^2.63.0, rollup@npm:^2.79.0": + version: 2.79.1 + resolution: "rollup@npm:2.79.1" + dependencies: + fsevents: ~2.3.2 + dependenciesMeta: + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: 6a2bf167b3587d4df709b37d149ad0300692cc5deb510f89ac7bdc77c8738c9546ae3de9322b0968e1ed2b0e984571f5f55aae28fa7de4cfcb1bc5402a4e2be6 + languageName: node + linkType: hard + +"run-parallel@npm:^1.1.9": + version: 1.2.0 + resolution: "run-parallel@npm:1.2.0" + dependencies: + queue-microtask: ^1.2.2 + checksum: cb4f97ad25a75ebc11a8ef4e33bb962f8af8516bb2001082ceabd8902e15b98f4b84b4f8a9b222e5d57fc3bd1379c483886ed4619367a7680dad65316993021d + languageName: node + linkType: hard + +"safe-buffer@npm:^5.1.0, safe-buffer@npm:~5.2.0": + version: 5.2.1 + resolution: "safe-buffer@npm:5.2.1" + checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491 + languageName: node + linkType: hard + +"safe-regex-test@npm:^1.0.0": + version: 1.0.0 + resolution: "safe-regex-test@npm:1.0.0" + dependencies: + call-bind: ^1.0.2 + get-intrinsic: ^1.1.3 + is-regex: ^1.1.4 + checksum: bc566d8beb8b43c01b94e67de3f070fd2781685e835959bbbaaec91cc53381145ca91f69bd837ce6ec244817afa0a5e974fc4e40a2957f0aca68ac3add1ddd34 + languageName: node + linkType: hard + +"safer-buffer@npm:>= 2.1.2 < 3.0.0": + version: 2.1.2 + resolution: "safer-buffer@npm:2.1.2" + checksum: cab8f25ae6f1434abee8d80023d7e72b598cf1327164ddab31003c51215526801e40b66c5e65d658a0af1e9d6478cadcb4c745f4bd6751f97d8644786c0978b0 + languageName: node + linkType: hard + +"sass@npm:^1.54.9": + version: 1.58.0 + resolution: "sass@npm:1.58.0" + dependencies: + chokidar: ">=3.0.0 <4.0.0" + immutable: ^4.0.0 + source-map-js: ">=0.6.2 <2.0.0" + bin: + sass: sass.js + checksum: a7219634881d2de6441fb619787fb1a02e3fa0333fb715be26aa335ba49d6bdb4f1105d9df70a80a67200893022b08346745783dc49046095d94fc6e044492d6 + languageName: node + linkType: hard + +"screenfull@npm:^6.0.2": + version: 6.0.2 + resolution: "screenfull@npm:6.0.2" + checksum: 56a541da09241ae7c97115cc31245623c09230bcbebb93636871e955b545f26c7dcdb2299795234a0a21bff5d5401b7329b22609becf4c381a9e22347acf87ba + languageName: node + linkType: hard + +"semver@npm:^6.0.0, semver@npm:^6.3.0": + version: 6.3.0 + resolution: "semver@npm:6.3.0" + bin: + semver: ./bin/semver.js + checksum: 1b26ecf6db9e8292dd90df4e781d91875c0dcc1b1909e70f5d12959a23c7eebb8f01ea581c00783bbee72ceeaad9505797c381756326073850dc36ed284b21b9 + languageName: node + linkType: hard + +"semver@npm:^7.3.5, semver@npm:^7.3.7": + version: 7.3.8 + resolution: "semver@npm:7.3.8" + dependencies: + lru-cache: ^6.0.0 + bin: + semver: bin/semver.js + checksum: ba9c7cbbf2b7884696523450a61fee1a09930d888b7a8d7579025ad93d459b2d1949ee5bbfeb188b2be5f4ac163544c5e98491ad6152df34154feebc2cc337c1 + languageName: node + linkType: hard + +"serialize-javascript@npm:^4.0.0": + version: 4.0.0 + resolution: "serialize-javascript@npm:4.0.0" + dependencies: + randombytes: ^2.1.0 + checksum: 3273b3394b951671fcf388726e9577021870dfbf85e742a1183fb2e91273e6101bdccea81ff230724f6659a7ee4cef924b0ff9baca32b79d9384ec37caf07302 + languageName: node + linkType: hard + +"set-blocking@npm:^2.0.0": + version: 2.0.0 + resolution: "set-blocking@npm:2.0.0" + checksum: 6e65a05f7cf7ebdf8b7c75b101e18c0b7e3dff4940d480efed8aad3a36a4005140b660fa1d804cb8bce911cac290441dc728084a30504d3516ac2ff7ad607b02 + languageName: node + linkType: hard + +"shebang-command@npm:^2.0.0": + version: 2.0.0 + resolution: "shebang-command@npm:2.0.0" + dependencies: + shebang-regex: ^3.0.0 + checksum: 6b52fe87271c12968f6a054e60f6bde5f0f3d2db483a1e5c3e12d657c488a15474121a1d55cd958f6df026a54374ec38a4a963988c213b7570e1d51575cea7fa + languageName: node + linkType: hard + +"shebang-regex@npm:^3.0.0": + version: 3.0.0 + resolution: "shebang-regex@npm:3.0.0" + checksum: 1a2bcae50de99034fcd92ad4212d8e01eedf52c7ec7830eedcf886622804fe36884278f2be8be0ea5fde3fd1c23911643a4e0f726c8685b61871c8908af01222 + languageName: node + linkType: hard + +"side-channel@npm:^1.0.4": + version: 1.0.4 + resolution: "side-channel@npm:1.0.4" + dependencies: + call-bind: ^1.0.0 + get-intrinsic: ^1.0.2 + object-inspect: ^1.9.0 + checksum: 351e41b947079c10bd0858364f32bb3a7379514c399edb64ab3dce683933483fc63fb5e4efe0a15a2e8a7e3c436b6a91736ddb8d8c6591b0460a24bb4a1ee245 + languageName: node + linkType: hard + +"side-drawer@npm:^3.1.0": + version: 3.1.0 + resolution: "side-drawer@npm:3.1.0" + checksum: aa6825741986bc67bac74729f377889f71af96769c4d28cc4f566a1caca828fee61d9b6c79a599914f160af3532942906ff740b385ef95535b45a596a402063e + languageName: node + linkType: hard + +"signal-exit@npm:^3.0.7": + version: 3.0.7 + resolution: "signal-exit@npm:3.0.7" + checksum: a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 + languageName: node + linkType: hard + +"slash@npm:^3.0.0": + version: 3.0.0 + resolution: "slash@npm:3.0.0" + checksum: 94a93fff615f25a999ad4b83c9d5e257a7280c90a32a7cb8b4a87996e4babf322e469c42b7f649fd5796edd8687652f3fb452a86dc97a816f01113183393f11c + languageName: node + linkType: hard + +"smart-buffer@npm:^4.2.0": + version: 4.2.0 + resolution: "smart-buffer@npm:4.2.0" + checksum: b5167a7142c1da704c0e3af85c402002b597081dd9575031a90b4f229ca5678e9a36e8a374f1814c8156a725d17008ae3bde63b92f9cfd132526379e580bec8b + languageName: node + linkType: hard + +"socks-proxy-agent@npm:^7.0.0": + version: 7.0.0 + resolution: "socks-proxy-agent@npm:7.0.0" + dependencies: + agent-base: ^6.0.2 + debug: ^4.3.3 + socks: ^2.6.2 + checksum: 720554370154cbc979e2e9ce6a6ec6ced205d02757d8f5d93fe95adae454fc187a5cbfc6b022afab850a5ce9b4c7d73e0f98e381879cf45f66317a4895953846 + languageName: node + linkType: hard + +"socks@npm:^2.6.2": + version: 2.7.1 + resolution: "socks@npm:2.7.1" + dependencies: + ip: ^2.0.0 + smart-buffer: ^4.2.0 + checksum: 259d9e3e8e1c9809a7f5c32238c3d4d2a36b39b83851d0f573bfde5f21c4b1288417ce1af06af1452569cd1eb0841169afd4998f0e04ba04656f6b7f0e46d748 + languageName: node + linkType: hard + +"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.1, source-map-js@npm:^1.0.2": + version: 1.0.2 + resolution: "source-map-js@npm:1.0.2" + checksum: c049a7fc4deb9a7e9b481ae3d424cc793cb4845daa690bc5a05d428bf41bf231ced49b4cf0c9e77f9d42fdb3d20d6187619fc586605f5eabe995a316da8d377c + languageName: node + linkType: hard + +"source-map-support@npm:~0.5.20": + version: 0.5.21 + resolution: "source-map-support@npm:0.5.21" + dependencies: + buffer-from: ^1.0.0 + source-map: ^0.6.0 + checksum: 43e98d700d79af1d36f859bdb7318e601dfc918c7ba2e98456118ebc4c4872b327773e5a1df09b0524e9e5063bb18f0934538eace60cca2710d1fa687645d137 + languageName: node + linkType: hard + +"source-map@npm:^0.6.0, source-map@npm:^0.6.1": + version: 0.6.1 + resolution: "source-map@npm:0.6.1" + checksum: 59ce8640cf3f3124f64ac289012c2b8bd377c238e316fb323ea22fbfe83da07d81e000071d7242cad7a23cd91c7de98e4df8830ec3f133cb6133a5f6e9f67bc2 + languageName: node + linkType: hard + +"source-map@npm:^0.7.4": + version: 0.7.4 + resolution: "source-map@npm:0.7.4" + checksum: 01cc5a74b1f0e1d626a58d36ad6898ea820567e87f18dfc9d24a9843a351aaa2ec09b87422589906d6ff1deed29693e176194dc88bcae7c9a852dc74b311dbf5 + languageName: node + linkType: hard + +"sourcemap-codec@npm:^1.4.8": + version: 1.4.8 + resolution: "sourcemap-codec@npm:1.4.8" + checksum: b57981c05611afef31605732b598ccf65124a9fcb03b833532659ac4d29ac0f7bfacbc0d6c5a28a03e84c7510e7e556d758d0bb57786e214660016fb94279316 + languageName: node + linkType: hard + +"split-on-first@npm:^1.0.0": + version: 1.1.0 + resolution: "split-on-first@npm:1.1.0" + checksum: 16ff85b54ddcf17f9147210a4022529b343edbcbea4ce977c8f30e38408b8d6e0f25f92cd35b86a524d4797f455e29ab89eb8db787f3c10708e0b47ebf528d30 + languageName: node + linkType: hard + +"ssri@npm:^9.0.0": + version: 9.0.1 + resolution: "ssri@npm:9.0.1" + dependencies: + minipass: ^3.1.1 + checksum: fb58f5e46b6923ae67b87ad5ef1c5ab6d427a17db0bead84570c2df3cd50b4ceb880ebdba2d60726588272890bae842a744e1ecce5bd2a2a582fccd5068309eb + languageName: node + linkType: hard + +"stable@npm:^0.1.8": + version: 0.1.8 + resolution: "stable@npm:0.1.8" + checksum: 2ff482bb100285d16dd75cd8f7c60ab652570e8952c0bfa91828a2b5f646a0ff533f14596ea4eabd48bb7f4aeea408dce8f8515812b975d958a4cc4fa6b9dfeb + languageName: node + linkType: hard + +"strict-uri-encode@npm:^2.0.0": + version: 2.0.0 + resolution: "strict-uri-encode@npm:2.0.0" + checksum: eaac4cf978b6fbd480f1092cab8b233c9b949bcabfc9b598dd79a758f7243c28765ef7639c876fa72940dac687181b35486ea01ff7df3e65ce3848c64822c581 + languageName: node + linkType: hard + +"string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": + version: 4.2.3 + resolution: "string-width@npm:4.2.3" + dependencies: + emoji-regex: ^8.0.0 + is-fullwidth-code-point: ^3.0.0 + strip-ansi: ^6.0.1 + checksum: e52c10dc3fbfcd6c3a15f159f54a90024241d0f149cf8aed2982a2d801d2e64df0bf1dc351cf8e95c3319323f9f220c16e740b06faecd53e2462df1d2b5443fb + languageName: node + linkType: hard + +"string.prototype.trimend@npm:^1.0.6": + version: 1.0.6 + resolution: "string.prototype.trimend@npm:1.0.6" + dependencies: + call-bind: ^1.0.2 + define-properties: ^1.1.4 + es-abstract: ^1.20.4 + checksum: 0fdc34645a639bd35179b5a08227a353b88dc089adf438f46be8a7c197fc3f22f8514c1c9be4629b3cd29c281582730a8cbbad6466c60f76b5f99cf2addb132e + languageName: node + linkType: hard + +"string.prototype.trimstart@npm:^1.0.6": + version: 1.0.6 + resolution: "string.prototype.trimstart@npm:1.0.6" + dependencies: + call-bind: ^1.0.2 + define-properties: ^1.1.4 + es-abstract: ^1.20.4 + checksum: 89080feef416621e6ef1279588994305477a7a91648d9436490d56010a1f7adc39167cddac7ce0b9884b8cdbef086987c4dcb2960209f2af8bac0d23ceff4f41 + languageName: node + linkType: hard + +"string_decoder@npm:^1.1.1": + version: 1.3.0 + resolution: "string_decoder@npm:1.3.0" + dependencies: + safe-buffer: ~5.2.0 + checksum: 8417646695a66e73aefc4420eb3b84cc9ffd89572861fe004e6aeb13c7bc00e2f616247505d2dbbef24247c372f70268f594af7126f43548565c68c117bdeb56 + languageName: node + linkType: hard + +"strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": + version: 6.0.1 + resolution: "strip-ansi@npm:6.0.1" + dependencies: + ansi-regex: ^5.0.1 + checksum: f3cd25890aef3ba6e1a74e20896c21a46f482e93df4a06567cebf2b57edabb15133f1f94e57434e0a958d61186087b1008e89c94875d019910a213181a14fc8c + languageName: node + linkType: hard + +"strip-bom@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-bom@npm:3.0.0" + checksum: 8d50ff27b7ebe5ecc78f1fe1e00fcdff7af014e73cf724b46fb81ef889eeb1015fc5184b64e81a2efe002180f3ba431bdd77e300da5c6685d702780fbf0c8d5b + languageName: node + linkType: hard + +"strip-json-comments@npm:^3.1.0, strip-json-comments@npm:^3.1.1": + version: 3.1.1 + resolution: "strip-json-comments@npm:3.1.1" + checksum: 492f73e27268f9b1c122733f28ecb0e7e8d8a531a6662efbd08e22cccb3f9475e90a1b82cab06a392f6afae6d2de636f977e231296400d0ec5304ba70f166443 + languageName: node + linkType: hard + +"stylehacks@npm:^5.1.1": + version: 5.1.1 + resolution: "stylehacks@npm:5.1.1" + dependencies: + browserslist: ^4.21.4 + postcss-selector-parser: ^6.0.4 + peerDependencies: + postcss: ^8.2.15 + checksum: 11175366ef52de65bf06cefba0ddc9db286dc3a1451fd2989e74c6ea47091a02329a4bf6ce10b1a36950056927b6bbbe47c5ab3a1f4c7032df932d010fbde5a2 + languageName: node + linkType: hard + +"superstruct@npm:^0.15.3": + version: 0.15.5 + resolution: "superstruct@npm:0.15.5" + checksum: 6d1f5249fee789424b7178fa0a1ffb2ace629c5480c39505885bd8c0046a4ff8b267569a3442fa53b8c560a7ba6599cf3f8af94225aebeb2cf6023f7dd911050 + languageName: node + linkType: hard + +"supports-color@npm:^5.3.0": + version: 5.5.0 + resolution: "supports-color@npm:5.5.0" + dependencies: + has-flag: ^3.0.0 + checksum: 95f6f4ba5afdf92f495b5a912d4abee8dcba766ae719b975c56c084f5004845f6f5a5f7769f52d53f40e21952a6d87411bafe34af4a01e65f9926002e38e1dac + languageName: node + linkType: hard + +"supports-color@npm:^7.0.0, supports-color@npm:^7.1.0": + version: 7.2.0 + resolution: "supports-color@npm:7.2.0" + dependencies: + has-flag: ^4.0.0 + checksum: 3dda818de06ebbe5b9653e07842d9479f3555ebc77e9a0280caf5a14fb877ffee9ed57007c3b78f5a6324b8dbeec648d9e97a24e2ed9fdb81ddc69ea07100f4a + languageName: node + linkType: hard + +"supports-preserve-symlinks-flag@npm:^1.0.0": + version: 1.0.0 + resolution: "supports-preserve-symlinks-flag@npm:1.0.0" + checksum: 53b1e247e68e05db7b3808b99b892bd36fb096e6fba213a06da7fab22045e97597db425c724f2bbd6c99a3c295e1e73f3e4de78592289f38431049e1277ca0ae + languageName: node + linkType: hard + +"svgo@npm:^2.7.0": + version: 2.8.0 + resolution: "svgo@npm:2.8.0" + dependencies: + "@trysound/sax": 0.2.0 + commander: ^7.2.0 + css-select: ^4.1.3 + css-tree: ^1.1.3 + csso: ^4.2.0 + picocolors: ^1.0.0 + stable: ^0.1.8 + bin: + svgo: bin/svgo + checksum: b92f71a8541468ffd0b81b8cdb36b1e242eea320bf3c1a9b2c8809945853e9d8c80c19744267eb91cabf06ae9d5fff3592d677df85a31be4ed59ff78534fa420 + languageName: node + linkType: hard + +"tar@npm:^6.1.11, tar@npm:^6.1.2": + version: 6.1.13 + resolution: "tar@npm:6.1.13" + dependencies: + chownr: ^2.0.0 + fs-minipass: ^2.0.0 + minipass: ^4.0.0 + minizlib: ^2.1.1 + mkdirp: ^1.0.3 + yallist: ^4.0.0 + checksum: 8a278bed123aa9f53549b256a36b719e317c8b96fe86a63406f3c62887f78267cea9b22dc6f7007009738509800d4a4dccc444abd71d762287c90f35b002eb1c + languageName: node + linkType: hard + +"terser@npm:^5.0.0": + version: 5.16.3 + resolution: "terser@npm:5.16.3" + dependencies: + "@jridgewell/source-map": ^0.3.2 + acorn: ^8.5.0 + commander: ^2.20.0 + source-map-support: ~0.5.20 + bin: + terser: bin/terser + checksum: d3c2ac1c2723c37b698b25b68d76fd315a1277fddde113983d5783d1f2a01dd7b8ed83ba3f54e5e65f0b59dd971ed7be2fdf8d4be94ec694b2d27832d2e7561f + languageName: node + linkType: hard + +"text-table@npm:^0.2.0": + version: 0.2.0 + resolution: "text-table@npm:0.2.0" + checksum: b6937a38c80c7f84d9c11dd75e49d5c44f71d95e810a3250bd1f1797fc7117c57698204adf676b71497acc205d769d65c16ae8fa10afad832ae1322630aef10a + languageName: node + linkType: hard + +"to-fast-properties@npm:^2.0.0": + version: 2.0.0 + resolution: "to-fast-properties@npm:2.0.0" + checksum: be2de62fe58ead94e3e592680052683b1ec986c72d589e7b21e5697f8744cdbf48c266fa72f6c15932894c10187b5f54573a3bcf7da0bfd964d5caf23d436168 + languageName: node + linkType: hard + +"to-regex-range@npm:^5.0.1": + version: 5.0.1 + resolution: "to-regex-range@npm:5.0.1" + dependencies: + is-number: ^7.0.0 + checksum: f76fa01b3d5be85db6a2a143e24df9f60dd047d151062d0ba3df62953f2f697b16fe5dad9b0ac6191c7efc7b1d9dcaa4b768174b7b29da89d4428e64bc0a20ed + languageName: node + linkType: hard + +"true-myth@npm:^4.1.0": + version: 4.1.1 + resolution: "true-myth@npm:4.1.1" + checksum: 0b937bb215b78d0ccff8637ac1c49be7f08c04f32a7b755c45a1566a1d522f50b0cadac93ad869b6bea9b843f60479160d3382c2c91d2381ef3559915564ff82 + languageName: node + linkType: hard + +"ts-morph@npm:^13.0.1": + version: 13.0.3 + resolution: "ts-morph@npm:13.0.3" + dependencies: + "@ts-morph/common": ~0.12.3 + code-block-writer: ^11.0.0 + checksum: 0983a7c4e0e1063e3900186bf2e85c5080621e31b754434136d2130a82bda6556985fdf012b766a87b9ae47d6f63732a562bb80854c34644f9352bea9a35b8ee + languageName: node + linkType: hard + +"ts-prune@npm:^0.10.3": + version: 0.10.3 + resolution: "ts-prune@npm:0.10.3" + dependencies: + commander: ^6.2.1 + cosmiconfig: ^7.0.1 + json5: ^2.1.3 + lodash: ^4.17.21 + true-myth: ^4.1.0 + ts-morph: ^13.0.1 + bin: + ts-prune: lib/index.js + checksum: 2cbc4d64a2e50637d06691e7f962b913d87aba2419ba772a3a1cb6e4b7542412cd77af592ba7b376ed273f5e37d9be180613b479602b9f92693e4d72f0f5a373 + languageName: node + linkType: hard + +"ts-toolbelt@npm:^9.6.0": + version: 9.6.0 + resolution: "ts-toolbelt@npm:9.6.0" + checksum: 9f35fd95d895a5d32ea9fd2e532a695b0bae6cbff6832b77292efa188a0ed1ed6e54f63f74a8920390f3d909a7a3adb20a144686372a8e78b420246a9bd3d58a + languageName: node + linkType: hard + +"tsconfig-paths@npm:^3.14.1": + version: 3.14.1 + resolution: "tsconfig-paths@npm:3.14.1" + dependencies: + "@types/json5": ^0.0.29 + json5: ^1.0.1 + minimist: ^1.2.6 + strip-bom: ^3.0.0 + checksum: 8afa01c673ebb4782ba53d3a12df97fa837ce524f8ad38ee4e2b2fd57f5ac79abc21c574e9e9eb014d93efe7fe8214001b96233b5c6ea75bd1ea82afe17a4c6d + languageName: node + linkType: hard + +"tslib@npm:^1.8.1": + version: 1.14.1 + resolution: "tslib@npm:1.14.1" + checksum: dbe628ef87f66691d5d2959b3e41b9ca0045c3ee3c7c7b906cc1e328b39f199bb1ad9e671c39025bd56122ac57dfbf7385a94843b1cc07c60a4db74795829acd + languageName: node + linkType: hard + +"tslib@npm:^2.1.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0": + version: 2.5.0 + resolution: "tslib@npm:2.5.0" + checksum: ae3ed5f9ce29932d049908ebfdf21b3a003a85653a9a140d614da6b767a93ef94f460e52c3d787f0e4f383546981713f165037dc2274df212ea9f8a4541004e1 + languageName: node + linkType: hard + +"tsutils@npm:^3.21.0": + version: 3.21.0 + resolution: "tsutils@npm:3.21.0" + dependencies: + tslib: ^1.8.1 + peerDependencies: + typescript: ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + checksum: 1843f4c1b2e0f975e08c4c21caa4af4f7f65a12ac1b81b3b8489366826259323feb3fc7a243123453d2d1a02314205a7634e048d4a8009921da19f99755cdc48 + languageName: node + linkType: hard + +"type-check@npm:^0.4.0, type-check@npm:~0.4.0": + version: 0.4.0 + resolution: "type-check@npm:0.4.0" + dependencies: + prelude-ls: ^1.2.1 + checksum: ec688ebfc9c45d0c30412e41ca9c0cdbd704580eb3a9ccf07b9b576094d7b86a012baebc95681999dd38f4f444afd28504cb3a89f2ef16b31d4ab61a0739025a + languageName: node + linkType: hard + +"type-fest@npm:^0.20.2": + version: 0.20.2 + resolution: "type-fest@npm:0.20.2" + checksum: 4fb3272df21ad1c552486f8a2f8e115c09a521ad7a8db3d56d53718d0c907b62c6e9141ba5f584af3f6830d0872c521357e512381f24f7c44acae583ad517d73 + languageName: node + linkType: hard + +"typed-array-length@npm:^1.0.4": + version: 1.0.4 + resolution: "typed-array-length@npm:1.0.4" + dependencies: + call-bind: ^1.0.2 + for-each: ^0.3.3 + is-typed-array: ^1.1.9 + checksum: 2228febc93c7feff142b8c96a58d4a0d7623ecde6c7a24b2b98eb3170e99f7c7eff8c114f9b283085cd59dcd2bd43aadf20e25bba4b034a53c5bb292f71f8956 + languageName: node + linkType: hard + +"typescript@npm:^4.5.4, typescript@npm:^4.8.3": + version: 4.9.5 + resolution: "typescript@npm:4.9.5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: ee000bc26848147ad423b581bd250075662a354d84f0e06eb76d3b892328d8d4440b7487b5a83e851b12b255f55d71835b008a66cbf8f255a11e4400159237db + languageName: node + linkType: hard + +"typescript@patch:typescript@^4.5.4#~builtin, typescript@patch:typescript@^4.8.3#~builtin": + version: 4.9.5 + resolution: "typescript@patch:typescript@npm%3A4.9.5#~builtin::version=4.9.5&hash=23ec76" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: ab417a2f398380c90a6cf5a5f74badd17866adf57f1165617d6a551f059c3ba0a3e4da0d147b3ac5681db9ac76a303c5876394b13b3de75fdd5b1eaa06181c9d + languageName: node + linkType: hard + +"unbox-primitive@npm:^1.0.2": + version: 1.0.2 + resolution: "unbox-primitive@npm:1.0.2" + dependencies: + call-bind: ^1.0.2 + has-bigints: ^1.0.2 + has-symbols: ^1.0.3 + which-boxed-primitive: ^1.0.2 + checksum: b7a1cf5862b5e4b5deb091672ffa579aa274f648410009c81cca63fed3b62b610c4f3b773f912ce545bb4e31edc3138975b5bc777fc6e4817dca51affb6380e9 + languageName: node + linkType: hard + +"unique-filename@npm:^2.0.0": + version: 2.0.1 + resolution: "unique-filename@npm:2.0.1" + dependencies: + unique-slug: ^3.0.0 + checksum: 807acf3381aff319086b64dc7125a9a37c09c44af7620bd4f7f3247fcd5565660ac12d8b80534dcbfd067e6fe88a67e621386dd796a8af828d1337a8420a255f + languageName: node + linkType: hard + +"unique-slug@npm:^3.0.0": + version: 3.0.0 + resolution: "unique-slug@npm:3.0.0" + dependencies: + imurmurhash: ^0.1.4 + checksum: 49f8d915ba7f0101801b922062ee46b7953256c93ceca74303bd8e6413ae10aa7e8216556b54dc5382895e8221d04f1efaf75f945c2e4a515b4139f77aa6640c + languageName: node + linkType: hard + +"universalify@npm:^2.0.0": + version: 2.0.0 + resolution: "universalify@npm:2.0.0" + checksum: 2406a4edf4a8830aa6813278bab1f953a8e40f2f63a37873ffa9a3bc8f9745d06cc8e88f3572cb899b7e509013f7f6fcc3e37e8a6d914167a5381d8440518c44 + languageName: node + linkType: hard + +"update-browserslist-db@npm:^1.0.10": + version: 1.0.10 + resolution: "update-browserslist-db@npm:1.0.10" + dependencies: + escalade: ^3.1.1 + picocolors: ^1.0.0 + peerDependencies: + browserslist: ">= 4.21.0" + bin: + browserslist-lint: cli.js + checksum: 12db73b4f63029ac407b153732e7cd69a1ea8206c9100b482b7d12859cd3cd0bc59c602d7ae31e652706189f1acb90d42c53ab24a5ba563ed13aebdddc5561a0 + languageName: node + linkType: hard + +"uri-js@npm:^4.2.2": + version: 4.4.1 + resolution: "uri-js@npm:4.4.1" + dependencies: + punycode: ^2.1.0 + checksum: 7167432de6817fe8e9e0c9684f1d2de2bb688c94388f7569f7dbdb1587c9f4ca2a77962f134ec90be0cc4d004c939ff0d05acc9f34a0db39a3c797dada262633 + languageName: node + linkType: hard + +"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2": + version: 1.0.2 + resolution: "util-deprecate@npm:1.0.2" + checksum: 474acf1146cb2701fe3b074892217553dfcf9a031280919ba1b8d651a068c9b15d863b7303cb15bd00a862b498e6cf4ad7b4a08fb134edd5a6f7641681cb54a2 + languageName: node + linkType: hard + +"uuid@npm:^8.3.2": + version: 8.3.2 + resolution: "uuid@npm:8.3.2" + bin: + uuid: dist/bin/uuid + checksum: 5575a8a75c13120e2f10e6ddc801b2c7ed7d8f3c8ac22c7ed0c7b2ba6383ec0abda88c905085d630e251719e0777045ae3236f04c812184b7c765f63a70e58df + languageName: node + linkType: hard + +"vis-data@npm:^7.1.4": + version: 7.1.4 + resolution: "vis-data@npm:7.1.4" + peerDependencies: + uuid: ^7.0.0 || ^8.0.0 + vis-util: ^4.0.0 || ^5.0.0 + checksum: a1266eb678b7f75c422c070ebd579a99565de9b23deba2e75862afb9c15ef5fa67e34f002c8c7013751100c75d6fb76b25bdb9f1a309d9f0cda1bec0935b26ff + languageName: node + linkType: hard + +"vis-timeline@npm:^7.7.0": + version: 7.7.0 + resolution: "vis-timeline@npm:7.7.0" + peerDependencies: + "@egjs/hammerjs": ^2.0.0 + component-emitter: ^1.3.0 + keycharm: ^0.3.0 || ^0.4.0 + moment: ^2.24.0 + propagating-hammerjs: ^1.4.0 || ^2.0.0 + uuid: ^3.4.0 || ^7.0.0 || ^8.0.0 + vis-data: ^6.3.0 || ^7.0.0 + vis-util: ^3.0.0 || ^4.0.0 || ^5.0.0 + xss: ^1.0.0 + checksum: 07d8618b7cf63ea64870c1404fb852d7b6e58df1b9fa429564225bd7a8f7f6ca047c109cee13c1501ee514abfed176699434be3f4fcf620731ca94aee525f8f8 + languageName: node + linkType: hard + +"vis-util@npm:^5.0.2": + version: 5.0.3 + resolution: "vis-util@npm:5.0.3" + peerDependencies: + "@egjs/hammerjs": ^2.0.0 + component-emitter: ^1.3.0 + checksum: a6a809cca36fdebd2c9b06038c30980b21c346e10eba8b30b4cfac1495751adee8f7f5a10ca1842b352b3c1eb3051abc669dc8cf204ad391c52f8165b0ca274b + languageName: node + linkType: hard + +"wheel-gestures@npm:^2.2.5": + version: 2.2.5 + resolution: "wheel-gestures@npm:2.2.5" + checksum: d382de6d4e49ad753dfb22f49000923b30dba1b622ec80821435d1097af63cdf6a1904e2ab1963a3f580d7522d7ba6de02976233eeb99e387d9cfdbeb8a0f9bf + languageName: node + linkType: hard + +"which-boxed-primitive@npm:^1.0.2": + version: 1.0.2 + resolution: "which-boxed-primitive@npm:1.0.2" + dependencies: + is-bigint: ^1.0.1 + is-boolean-object: ^1.1.0 + is-number-object: ^1.0.4 + is-string: ^1.0.5 + is-symbol: ^1.0.3 + checksum: 53ce774c7379071729533922adcca47220228405e1895f26673bbd71bdf7fb09bee38c1d6399395927c6289476b5ae0629863427fd151491b71c4b6cb04f3a5e + languageName: node + linkType: hard + +"which-typed-array@npm:^1.1.9": + version: 1.1.9 + resolution: "which-typed-array@npm:1.1.9" + dependencies: + available-typed-arrays: ^1.0.5 + call-bind: ^1.0.2 + for-each: ^0.3.3 + gopd: ^1.0.1 + has-tostringtag: ^1.0.0 + is-typed-array: ^1.1.10 + checksum: fe0178ca44c57699ca2c0e657b64eaa8d2db2372a4e2851184f568f98c478ae3dc3fdb5f7e46c384487046b0cf9e23241423242b277e03e8ba3dabc7c84c98ef + languageName: node + linkType: hard + +"which@npm:^2.0.1, which@npm:^2.0.2": + version: 2.0.2 + resolution: "which@npm:2.0.2" + dependencies: + isexe: ^2.0.0 + bin: + node-which: ./bin/node-which + checksum: 1a5c563d3c1b52d5f893c8b61afe11abc3bab4afac492e8da5bde69d550de701cf9806235f20a47b5c8fa8a1d6a9135841de2596535e998027a54589000e66d1 + languageName: node + linkType: hard + +"wide-align@npm:^1.1.5": + version: 1.1.5 + resolution: "wide-align@npm:1.1.5" + dependencies: + string-width: ^1.0.2 || 2 || 3 || 4 + checksum: d5fc37cd561f9daee3c80e03b92ed3e84d80dde3365a8767263d03dacfc8fa06b065ffe1df00d8c2a09f731482fcacae745abfbb478d4af36d0a891fad4834d3 + languageName: node + linkType: hard + +"word-wrap@npm:^1.2.3": + version: 1.2.3 + resolution: "word-wrap@npm:1.2.3" + checksum: 30b48f91fcf12106ed3186ae4fa86a6a1842416df425be7b60485de14bec665a54a68e4b5156647dec3a70f25e84d270ca8bc8cd23182ed095f5c7206a938c1f + languageName: node + linkType: hard + +"wrap-ansi@npm:^7.0.0": + version: 7.0.0 + resolution: "wrap-ansi@npm:7.0.0" + dependencies: + ansi-styles: ^4.0.0 + string-width: ^4.1.0 + strip-ansi: ^6.0.0 + checksum: a790b846fd4505de962ba728a21aaeda189b8ee1c7568ca5e817d85930e06ef8d1689d49dbf0e881e8ef84436af3a88bc49115c2e2788d841ff1b8b5b51a608b + languageName: node + linkType: hard + +"wrappy@npm:1": + version: 1.0.2 + resolution: "wrappy@npm:1.0.2" + checksum: 159da4805f7e84a3d003d8841557196034155008f817172d4e986bd591f74aa82aa7db55929a54222309e01079a65a92a9e6414da5a6aa4b01ee44a511ac3ee5 + languageName: node + linkType: hard + +"xss@npm:^1.0.14": + version: 1.0.14 + resolution: "xss@npm:1.0.14" + dependencies: + commander: ^2.20.3 + cssfilter: 0.0.10 + bin: + xss: bin/xss + checksum: 77c6a60a5f96490098ef837c3b49321c0f31963f323c748cb0a01bc02fc6e465a6accbe4c54cad62f8d6ffe647d984be173879433af861883f6213d9416d3f53 + languageName: node + linkType: hard + +"y18n@npm:^5.0.5": + version: 5.0.8 + resolution: "y18n@npm:5.0.8" + checksum: 54f0fb95621ee60898a38c572c515659e51cc9d9f787fb109cef6fde4befbe1c4602dc999d30110feee37456ad0f1660fa2edcfde6a9a740f86a290999550d30 + languageName: node + linkType: hard + +"yallist@npm:^3.0.2": + version: 3.1.1 + resolution: "yallist@npm:3.1.1" + checksum: 48f7bb00dc19fc635a13a39fe547f527b10c9290e7b3e836b9a8f1ca04d4d342e85714416b3c2ab74949c9c66f9cebb0473e6bc353b79035356103b47641285d + languageName: node + linkType: hard + +"yallist@npm:^4.0.0": + version: 4.0.0 + resolution: "yallist@npm:4.0.0" + checksum: 343617202af32df2a15a3be36a5a8c0c8545208f3d3dfbc6bb7c3e3b7e8c6f8e7485432e4f3b88da3031a6e20afa7c711eded32ddfb122896ac5d914e75848d5 + languageName: node + linkType: hard + +"yaml@npm:^1.10.0, yaml@npm:^1.10.2": + version: 1.10.2 + resolution: "yaml@npm:1.10.2" + checksum: ce4ada136e8a78a0b08dc10b4b900936912d15de59905b2bf415b4d33c63df1d555d23acb2a41b23cf9fb5da41c256441afca3d6509de7247daa062fd2c5ea5f + languageName: node + linkType: hard + +"yargs-parser@npm:^21.1.1": + version: 21.1.1 + resolution: "yargs-parser@npm:21.1.1" + checksum: ed2d96a616a9e3e1cc7d204c62ecc61f7aaab633dcbfab2c6df50f7f87b393993fe6640d017759fe112d0cb1e0119f2b4150a87305cc873fd90831c6a58ccf1c + languageName: node + linkType: hard + +"yargs@npm:^17.5.1": + version: 17.6.2 + resolution: "yargs@npm:17.6.2" + dependencies: + cliui: ^8.0.1 + escalade: ^3.1.1 + get-caller-file: ^2.0.5 + require-directory: ^2.1.1 + string-width: ^4.2.3 + y18n: ^5.0.5 + yargs-parser: ^21.1.1 + checksum: 47da1b0d854fa16d45a3ded57b716b013b2179022352a5f7467409da5a04a1eef5b3b3d97a2dfc13e8bbe5f2ffc0afe3bc6a4a72f8254e60f5a4bd7947138643 + languageName: node + linkType: hard + +"yocto-queue@npm:^0.1.0": + version: 0.1.0 + resolution: "yocto-queue@npm:0.1.0" + checksum: f77b3d8d00310def622123df93d4ee654fc6a0096182af8bd60679ddcdfb3474c56c6c7190817c84a2785648cdee9d721c0154eb45698c62176c322fb46fc700 + languageName: node + linkType: hard + +"zod@npm:^3.20.2": + version: 3.20.2 + resolution: "zod@npm:3.20.2" + checksum: 04172f7e9350372684ccd298d4716908edc9113751295b6c4e1b3ea84e2af8997e504b33ba36f4741417bb2a5dc90bfd40501f6b0e7389df10e42a63d6d8366c + languageName: node + linkType: hard From 3d799d6fa64f39ba79fcaa83acf9ed63eb59b5c4 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 6 Feb 2023 20:35:56 -0800 Subject: [PATCH 74/74] Fix minor timeline bug for recordings. --- src/components/timeline-core.ts | 2 +- src/view/view.ts | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 3e75ae9e..ed584496 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -706,7 +706,7 @@ export class FrigateCardTimelineCore extends LitElement { protected _getPerfectWindowFromMedia(media: ViewMedia): TimelineWindow | null { const startTime = media.getStartTime(); - const endTime = media.getStartTime(); + const endTime = media.getEndTime(); if (ViewMediaClassifier.isEvent(media)) { const windowSeconds = this._getConfiguredWindowSeconds(); diff --git a/src/view/view.ts b/src/view/view.ts index d6896995..2dc4705f 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,6 +1,3 @@ -// Gallery: -// - TODO: View a recording from the gallery => timeline should span the hour. - import { ViewContext } from 'view'; import { FrigateCardUserSpecifiedView,