From e21756887d32d3cc4353d39539c362439c92f9db Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 7 Feb 2024 22:06:21 -0800 Subject: [PATCH] Add custom date/time media filtering. --- src/camera-manager/range.ts | 1 + src/components-lib/media-filter-controller.ts | 509 ++++++++++ src/components/date-picker.ts | 37 +- src/components/media-filter.ts | 604 +++-------- src/components/select.ts | 24 +- src/components/timeline-core.ts | 4 +- src/localize/languages/en.json | 1 + src/localize/languages/it.json | 1 + src/localize/languages/pt-BR.json | 1 + src/localize/languages/pt-PT.json | 1 + src/scoped-elements/gr-select.js | 1 + src/scss/media-filter.scss | 23 + .../media-filter-controller.test.ts | 944 ++++++++++++++++++ vite.config.ts | 8 +- 14 files changed, 1667 insertions(+), 492 deletions(-) create mode 100644 src/components-lib/media-filter-controller.ts create mode 100644 tests/components-lib/media-filter-controller.test.ts diff --git a/src/camera-manager/range.ts b/src/camera-manager/range.ts index e680a5e1..789b4bbf 100644 --- a/src/camera-manager/range.ts +++ b/src/camera-manager/range.ts @@ -6,6 +6,7 @@ interface Range { } export type DateRange = Range; +export type PartialDateRange = Partial; interface MemoryRangeSetInterface { hasCoverage(range: T): boolean; diff --git a/src/components-lib/media-filter-controller.ts b/src/components-lib/media-filter-controller.ts new file mode 100644 index 00000000..ef5bfbd2 --- /dev/null +++ b/src/components-lib/media-filter-controller.ts @@ -0,0 +1,509 @@ +import endOfDay from 'date-fns/endOfDay'; +import endOfMonth from 'date-fns/endOfMonth'; +import endOfYesterday from 'date-fns/endOfYesterday'; +import endOfToday from 'date-fns/esm/endOfToday'; +import startOfToday from 'date-fns/esm/startOfToday'; +import format from 'date-fns/format'; +import parse from 'date-fns/parse'; +import startOfDay from 'date-fns/startOfDay'; +import startOfYesterday from 'date-fns/startOfYesterday'; +import sub from 'date-fns/sub'; +import { LitElement } from 'lit'; +import isEqual from 'lodash-es/isEqual'; +import orderBy from 'lodash-es/orderBy'; +import uniqWith from 'lodash-es/uniqWith'; +import { CameraManager } from '../camera-manager/manager'; +import { DateRange, PartialDateRange } from '../camera-manager/range'; +import { DataQuery, MediaMetadata, QueryType } from '../camera-manager/types'; +import { SelectOption, SelectValues } from '../components/select'; +import { CardWideConfig } from '../config/types'; +import { localize } from '../localize/localize'; +import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic'; +import { executeMediaQueryForViewWithErrorDispatching } from '../utils/media-to-view'; +import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; +import { MediaQueriesClassifier } from '../view/media-queries-classifier'; +import { View } from '../view/view'; + +interface MediaFilterControls { + events: boolean; + recordings: boolean; + favorites: boolean; +} + +export interface MediaFilterCoreDefaults { + cameraIDs?: string[]; + favorite?: MediaFilterCoreFavoriteSelection; + mediaType?: MediaFilterMediaType; + what?: string[]; + when?: string; + where?: string[]; + tags?: string[]; +} + +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 class MediaFilterController { + protected _host: LitElement; + + protected _mediaTypeOptions: SelectOption[]; + protected _cameraOptions: SelectOption[] = []; + + protected _whenOptions: SelectOption[] = []; + protected _staticWhenOptions: SelectOption[]; + protected _metaDataWhenOptions: SelectOption[] = []; + + protected _whatOptions: SelectOption[] = []; + protected _whereOptions: SelectOption[] = []; + protected _tagsOptions: SelectOption[] = []; + protected _favoriteOptions: SelectOption[]; + + protected _defaults: MediaFilterCoreDefaults | null = null; + + constructor(host: LitElement) { + this._host = host; + + 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'), + }, + ]; + this._staticWhenOptions = [ + { + 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'), + }, + { + value: MediaFilterCoreWhen.Custom, + label: localize('media_filter.whens.custom'), + }, + ]; + this._computeWhenOptions(); + } + + public getMediaTypeOptions(): SelectOption[] { + return this._mediaTypeOptions; + } + public getCameraOptions(): SelectOption[] { + return this._cameraOptions; + } + public getWhenOptions(): SelectOption[] { + return this._whenOptions; + } + public getWhatOptions(): SelectOption[] { + return this._whatOptions; + } + public getWhereOptions(): SelectOption[] { + return this._whereOptions; + } + public getTagsOptions(): SelectOption[] { + return this._tagsOptions; + } + public getFavoriteOptions(): SelectOption[] { + return this._favoriteOptions; + } + public getDefaults(): MediaFilterCoreDefaults | null { + return this._defaults; + } + + public async valueChangeHandler( + cameraManager: CameraManager, + view: View, + cardWideConfig: CardWideConfig, + values: { + camera?: string | string[]; + mediaType?: MediaFilterMediaType; + when: { + selected?: string | string[]; + from?: Date | null; + to?: Date | null; + }; + favorite?: MediaFilterCoreFavoriteSelection; + where?: string | string[]; + what?: string | string[]; + tags?: string | string[]; + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _ev?: unknown, + ): Promise { + 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 visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs(); + if (!visibleCameraIDs.size || !values.mediaType) { + return; + } + const cameraIDs = getArrayValueAsSet(values.camera) ?? visibleCameraIDs; + + const when = this._getWhen(values.when); + const favorite = values.favorite + ? values.favorite === MediaFilterCoreFavoriteSelection.Favorite + : null; + + // 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. + const limit = cardWideConfig.performance?.features.media_chunk_size; + + if ( + values.mediaType === MediaFilterMediaType.Clips || + values.mediaType === MediaFilterMediaType.Snapshots + ) { + const where = getArrayValueAsSet(values.where); + const what = getArrayValueAsSet(values.what); + const tags = getArrayValueAsSet(values.tags); + + const queries = new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: cameraIDs, + ...(tags && { tags: tags }), + ...(what && { what: what }), + ...(where && { where: where }), + ...(favorite !== null && { favorite: favorite }), + ...(when && { + ...(when.start && { start: when.start }), + ...(when.end && { end: when.end }), + }), + ...(limit && { limit: limit }), + ...(values.mediaType === MediaFilterMediaType.Clips && { hasClip: true }), + ...(values.mediaType === MediaFilterMediaType.Snapshots && { + hasSnapshot: true, + }), + }, + ]); + + ( + await executeMediaQueryForViewWithErrorDispatching( + this._host, + cameraManager, + view, + queries, + { + // See 'A note on views' above for these two arguments. + ...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }), + targetView: + values.mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots', + }, + ) + )?.dispatchChangeEvent(this._host); + } else { + const queries = new RecordingMediaQueries([ + { + type: QueryType.Recording, + cameraIDs: cameraIDs, + ...(limit && { limit: limit }), + ...(when && { + ...(when.start && { start: when.start }), + ...(when.end && { end: when.end }), + }), + ...(favorite !== null && { favorite: favorite }), + }, + ]); + + ( + await executeMediaQueryForViewWithErrorDispatching( + this._host, + cameraManager, + view, + queries, + { + // See 'A note on views' above for these two arguments. + ...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }), + targetView: 'recordings', + }, + ) + )?.dispatchChangeEvent(this._host); + } + + // Need to ensure we update the element as the date-picker selections may + // have changed, and we need to un/set the selected class. + this._host.requestUpdate(); + } + + public computeInitialDefaultsFromView(cameraManager: CameraManager, view: View): void { + const queries = view.query?.getQueries(); + const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs(); + if (!queries || !visibleCameraIDs.size) { + return; + } + + let mediaType: MediaFilterMediaType | undefined; + let cameraIDs: string[] | undefined; + let what: string[] | undefined; + let where: string[] | undefined; + let favorite: MediaFilterCoreFavoriteSelection | undefined; + let tags: string[] | undefined; + + const cameraIDSets = uniqWith( + queries.map((query: DataQuery) => query.cameraIDs), + isEqual, + ); + // Special note: If all visible cameras are selected, this is the same as no + // selector at all. + if (cameraIDSets.length === 1 && !isEqual(queries[0].cameraIDs, visibleCameraIDs)) { + 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; + } + + /* istanbul ignore else: the else path cannot be reached -- @preserve */ + if (MediaQueriesClassifier.areEventQueries(view.query)) { + const queries = view.query.getQueries(); + + /* istanbul ignore if: the if path cannot be reached -- @preserve */ + 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 whatSets = uniqWith( + queries.map((query) => query.what), + isEqual, + ); + 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 && queries[0].where?.size) { + where = [...queries[0].where]; + } + const tagsSets = uniqWith( + queries.map((query) => query.tags), + isEqual, + ); + if (tagsSets.length === 1 && queries[0].tags?.size) { + tags = [...queries[0].tags]; + } + } else if (MediaQueriesClassifier.areRecordingQueries(view.query)) { + mediaType = MediaFilterMediaType.Recordings; + } + + this._defaults = { + ...(mediaType && { mediaType: mediaType }), + ...(cameraIDs && { cameraIDs: cameraIDs }), + ...(what && { what: what }), + ...(where && { where: where }), + ...(favorite !== undefined && { favorite: favorite }), + ...(tags && { tags: tags }), + }; + } + + public computeCameraOptions(cameraManager: CameraManager): void { + const cameras = cameraManager.getStore().getVisibleCameraIDs(); + this._cameraOptions = [...cameras].map((cameraID) => ({ + value: cameraID, + label: cameraManager.getCameraMetadata(cameraID)?.title ?? cameraID, + })); + } + + public async computeMetadataOptions(cameraManager: CameraManager): Promise { + let metadata: MediaMetadata | null = null; + try { + metadata = await cameraManager.getMediaMetadata(); + } catch (e) { + errorToConsole(e as Error); + } + 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.tags) { + this._tagsOptions = [...metadata.tags] + .sort() + .map((tag) => ({ value: tag, label: prettifyTitle(tag) })); + } + 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._metaDataWhenOptions = orderBy( + monthStarts, + (date) => date.getTime(), + 'desc', + ).map((monthStart) => ({ + label: format(monthStart, 'MMMM yyyy'), + value: this._dateRangeToString({ + start: monthStart, + end: endOfMonth(monthStart), + }), + })); + this._computeWhenOptions(); + } + + this._host.requestUpdate(); + } + + public getControlsToShow( + cameraManager: CameraManager, + view: View, + ): MediaFilterControls { + const events = !!(view.query && MediaQueriesClassifier.areEventQueries(view.query)); + const recordings = !!( + view.query && MediaQueriesClassifier.areRecordingQueries(view.query) + ); + const managerCapabilities = cameraManager.getAggregateCameraCapabilities(); + + return { + events: events, + recordings: recordings, + favorites: events + ? !!managerCapabilities?.canFavoriteEvents + : recordings + ? !!managerCapabilities?.canFavoriteRecordings + : false, + }; + } + + protected _computeWhenOptions(): void { + this._whenOptions = [...this._staticWhenOptions, ...this._metaDataWhenOptions]; + } + + protected _dateRangeToString(when: DateRange): string { + return `${formatDate(when.start)},${formatDate(when.end)}`; + } + + protected _stringToDateRange(input: string): DateRange { + const dates = input.split(','); + return { + start: parse(dates[0], 'yyyy-MM-dd', new Date()), + end: endOfDay(parse(dates[1], 'yyyy-MM-dd', new Date())), + }; + } + + protected _getWhen(values: { + selected?: string | string[]; + from?: Date | null; + to?: Date | null; + }): PartialDateRange | null { + if (values.from || values.to) { + return { + ...(values.from && { start: values.from }), + ...(values.to && { end: values.to }), + }; + } + + if (!values.selected || Array.isArray(values.selected)) { + return null; + } + + const now = new Date(); + switch (values.selected) { + case MediaFilterCoreWhen.Custom: + return null; + case MediaFilterCoreWhen.Today: + return { start: startOfToday(), end: endOfToday() }; + case MediaFilterCoreWhen.Yesterday: + return { start: startOfYesterday(), end: endOfYesterday() }; + case MediaFilterCoreWhen.PastWeek: + return { start: startOfDay(sub(now, { days: 7 })), end: endOfDay(now) }; + case MediaFilterCoreWhen.PastMonth: + return { start: startOfDay(sub(now, { months: 1 })), end: endOfDay(now) }; + default: + return this._stringToDateRange(values.selected); + } + } +} diff --git a/src/components/date-picker.ts b/src/components/date-picker.ts index 42de9703..bf171ee4 100644 --- a/src/components/date-picker.ts +++ b/src/components/date-picker.ts @@ -1,5 +1,5 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; -import { customElement } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { localize } from '../localize/localize'; import datePickerStyle from '../scss/date-picker.scss'; @@ -7,32 +7,47 @@ import { stopEventFromActivatingCardWideActions } from '../utils/action'; import { dispatchFrigateCardEvent } from '../utils/basic'; export interface DatePickerEvent { - date: Date; + date: Date | null; } @customElement('frigate-card-date-picker') export class FrigateCardDatePicker extends LitElement { + @property({ attribute: false }) + public icon?: string; + protected _refInput: Ref = createRef(); + get value(): Date | null { + return this._refInput.value?.value ? new Date(this._refInput.value.value) : null; + } + + public reset(): void { + if (this._refInput.value) { + this._refInput.value.value = ''; + } + } + protected render(): TemplateResult { + const changed = () => { + const value = this._refInput.value?.value; + + dispatchFrigateCardEvent(this, 'date-picker:change', { + date: value ? new Date(value) : null, + }); + }; + return html` { - const value = this._refInput.value?.value; - if (value) { - dispatchFrigateCardEvent(this, 'date-picker:change', { - date: new Date(value), - }); - } - }} + @input=${() => changed()} + @change=${() => changed()} /> { stopEventFromActivatingCardWideActions(ev); this._refInput.value?.showPicker(); diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index e442770f..b85be987 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -1,73 +1,32 @@ -import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin'; import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; -import endOfDay from 'date-fns/endOfDay'; -import endOfMonth from 'date-fns/endOfMonth'; -import endOfYesterday from 'date-fns/endOfYesterday'; -import endOfToday from 'date-fns/esm/endOfToday'; -import startOfToday from 'date-fns/esm/startOfToday'; -import format from 'date-fns/format'; -import parse from 'date-fns/parse'; -import startOfDay from 'date-fns/startOfDay'; -import startOfYesterday from 'date-fns/startOfYesterday'; -import sub from 'date-fns/sub'; +import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin'; import { CSSResultGroup, html, LitElement, PropertyValues, - ReactiveController, - ReactiveControllerHost, TemplateResult, unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; -import isEqual from 'lodash-es/isEqual'; -import orderBy from 'lodash-es/orderBy'; -import uniqWith from 'lodash-es/uniqWith'; import { CameraManager } from '../camera-manager/manager'; -import { DateRange } from '../camera-manager/range'; -import { DataQuery, MediaMetadata, QueryType } from '../camera-manager/types'; +import { + MediaFilterController, + MediaFilterCoreFavoriteSelection, + MediaFilterCoreWhen, + MediaFilterMediaType, +} from '../components-lib/media-filter-controller'; import { CardWideConfig } from '../config/types'; import { localize } from '../localize/localize'; import mediaFilterStyle from '../scss/media-filter.scss'; -import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic'; -import { executeMediaQueryForViewWithErrorDispatching } from '../utils/media-to-view.js'; -import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; -import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { View } from '../view/view'; -import './select'; -import { FrigateCardSelect, SelectOption, SelectValues } from './select'; +import { FrigateCardDatePicker } from './date-picker'; +import './date-picker.js'; +import { FrigateCardSelect } from './select'; import './select.js'; -interface MediaFilterCoreDefaults { - cameraIDs?: string[]; - favorite?: MediaFilterCoreFavoriteSelection; - mediaType?: MediaFilterMediaType; - what?: string[]; - when?: string; - where?: string[]; - tags?: 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') class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { @property({ attribute: false }) @@ -84,429 +43,196 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { static elementDefinitions = { 'frigate-card-select': FrigateCardSelect, + 'frigate-card-date-picker': FrigateCardDatePicker, }; - protected _mediaMetadataController?: MediaMetadataController; - - protected _mediaTypeOptions: SelectOption[]; - protected _cameraOptions?: SelectOption[]; - protected _whenOptions?: SelectOption[]; - protected _favoriteOptions: SelectOption[]; - - protected _defaults: MediaFilterCoreDefaults | null = null; + protected _mediaFilterController = new MediaFilterController(this); protected _refMediaType: Ref = createRef(); protected _refCamera: Ref = createRef(); protected _refWhen: Ref = createRef(); + protected _refWhenFrom: Ref = createRef(); + protected _refWhenTo: Ref = createRef(); protected _refWhat: Ref = createRef(); protected _refWhere: Ref = createRef(); protected _refFavorite: Ref = createRef(); protected _refTags: 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) { - case MediaFilterCoreWhen.Today: - return { start: startOfToday(), end: endOfToday() }; - case MediaFilterCoreWhen.Yesterday: - return { start: startOfYesterday(), end: endOfYesterday() }; - case MediaFilterCoreWhen.PastWeek: - return { start: startOfDay(sub(now, { days: 7 })), end: endOfDay(now) }; - case MediaFilterCoreWhen.PastMonth: - return { start: startOfDay(sub(now, { months: 1 })), end: endOfDay(now) }; - default: - return this._stringToDateRange(value); - } - } - - protected async _valueChangedHandler( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _ev: CustomEvent<{ value: unknown }>, - ): Promise { - const visibleCameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); - if (!this.hass || !visibleCameraIDs || !this.cameraManager || !this.view) { - return; - } - - 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) ?? visibleCameraIDs; - 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 - // 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. - const limit = this.cardWideConfig?.performance?.features.media_chunk_size; - - if ( - mediaType === MediaFilterMediaType.Clips || - mediaType === MediaFilterMediaType.Snapshots - ) { - const where = getArrayValueAsSet(this._refWhere.value?.value); - const what = getArrayValueAsSet(this._refWhat.value?.value); - const tags = getArrayValueAsSet(this._refTags.value?.value); - - const queries = new EventMediaQueries([ - { - type: QueryType.Event, - cameraIDs: cameraIDs, - ...(tags && { tags: tags }), - ...(what && { what: what }), - ...(where && { where: where }), - ...(favorite !== null && { favorite: favorite }), - ...(when && { start: when.start, end: when.end }), - ...(limit && { limit: limit }), - ...(mediaType === MediaFilterMediaType.Clips && { hasClip: true }), - ...(mediaType === MediaFilterMediaType.Snapshots && { - hasSnapshot: true, - }), - }, - ]); - - ( - await executeMediaQueryForViewWithErrorDispatching( - this, - this.cameraManager, - this.view, - queries, - { - // See 'A note on views' above for these two arguments. - ...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }), - targetView: mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots', - }, - ) - )?.dispatchChangeEvent(this); - } else if (mediaType === MediaFilterMediaType.Recordings) { - const queries = new RecordingMediaQueries([ - { - type: QueryType.Recording, - cameraIDs: cameraIDs, - ...(limit && { limit: limit }), - ...(when && { start: when.start, end: when.end }), - }, - ]); - - ( - await executeMediaQueryForViewWithErrorDispatching( - this, - this.cameraManager, - this.view, - queries, - { - // See 'A note on views' above for these two arguments. - ...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }), - targetView: 'recordings', - }, - ) - )?.dispatchChangeEvent(this); - } - } - protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('cameraManager')) { - const cameras = this.cameraManager?.getStore().getVisibleCameraIDs(); - if (cameras) { - this._cameraOptions = [...cameras].map((cameraID) => ({ - value: cameraID, - label: this.hass - ? this.cameraManager?.getCameraMetadata(cameraID)?.title ?? '' - : '', - })); - } + if (changedProps.has('cameraManager') && this.cameraManager) { + this._mediaFilterController.computeCameraOptions(this.cameraManager); + this._mediaFilterController.computeMetadataOptions(this.cameraManager); } - - if (changedProps.has('cameraManager') && this.hass && this.cameraManager) { - this._mediaMetadataController = new MediaMetadataController( - this, + if ( + changedProps.has('view') && + !changedProps.get('view') && + this.view && + this.cameraManager + ) { + this._mediaFilterController.computeInitialDefaultsFromView( this.cameraManager, + this.view, ); } - - // 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(): MediaFilterCoreDefaults | null { - const queries = this.view?.query?.getQueries(); - const visibleCameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); - if (!this.view || !queries || !visibleCameraIDs) { - return null; - } - - let mediaType: MediaFilterMediaType | undefined; - let cameraIDs: string[] | undefined; - let what: string[] | undefined; - let where: string[] | undefined; - let favorite: MediaFilterCoreFavoriteSelection | undefined; - let tags: string[] | undefined; - - const cameraIDSets = uniqWith( - queries.map((query: DataQuery) => query.cameraIDs), - isEqual, - ); - // Special note: If all visible cameras are selected, this is the same as no - // selector at all. - if (cameraIDSets.length === 1 && !isEqual(queries[0].cameraIDs, visibleCameraIDs)) { - 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 null; - } - - 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 whatSets = uniqWith( - queries.map((query) => query.what), - isEqual, - ); - 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 && queries[0].where?.size) { - where = [...queries[0].where]; - } - const tagsSets = uniqWith( - queries.map((query) => query.tags), - isEqual, - ); - if (tagsSets.length === 1 && queries[0].tags?.size) { - tags = [...queries[0].tags]; - } - } else if (MediaQueriesClassifier.areRecordingQueries(this.view.query)) { - mediaType = MediaFilterMediaType.Recordings; - } - - return { - ...(mediaType && { mediaType: mediaType }), - ...(cameraIDs && { cameraIDs: cameraIDs }), - ...(what && { what: what }), - ...(where && { where: where }), - ...(favorite !== undefined && { favorite: favorite }), - ...(tags && { tags: tags }), - }; } protected render(): TemplateResult | void { - if (!this._mediaMetadataController) { + const valueChange = async () => { + if (!this.cameraManager || !this.view || !this.cardWideConfig) { + return; + } + await this._mediaFilterController.valueChangeHandler( + this.cameraManager, + this.view, + this.cardWideConfig, + { + camera: this._refCamera.value?.value, + mediaType: this._refMediaType.value?.value as MediaFilterMediaType | undefined, + when: { + selected: this._refWhen.value?.value, + from: this._refWhenFrom.value?.value, + to: this._refWhenTo.value?.value, + }, + favorite: this._refFavorite.value?.value as + | MediaFilterCoreFavoriteSelection + | undefined, + where: this._refWhere.value?.value, + what: this._refWhat.value?.value, + tags: this._refTags.value?.value, + }, + ); + }; + + // Ensure that the "When" selector and the custom calendar to/from selectors + // are ~mutually exclusive. + const whenChange = async (whenPriority?: 'custom' | 'selected'): Promise => { + if (whenPriority === 'custom' && this._refWhen.value) { + if (!this._refWhenFrom.value?.value && !this._refWhenTo.value?.value) { + this._refWhen.value.reset(); + } else { + this._refWhen.value.value = MediaFilterCoreWhen.Custom; + } + } else if (this._refWhen.value?.value !== MediaFilterCoreWhen.Custom) { + this._refWhenFrom.value?.reset(); + this._refWhenTo.value?.reset(); + } + await valueChange(); + }; + + if (!this.cameraManager || !this.view) { return; } - const areEvents = !!( - this.view?.query && MediaQueriesClassifier.areEventQueries(this.view.query) + const controls = this._mediaFilterController.getControlsToShow( + this.cameraManager, + this.view, ); - const areRecordings = !!( - this.view?.query && MediaQueriesClassifier.areRecordingQueries(this.view.query) - ); - const managerCapabilities = this.cameraManager?.getAggregateCameraCapabilities(); - - // Which media controls are shown depends on the view. - const showFavoriteControl = areEvents - ? !!managerCapabilities?.canFavoriteEvents - : areRecordings - ? !!managerCapabilities?.canFavoriteRecordings - : false; + const defaults = this._mediaFilterController.getDefaults(); + const whatOptions = this._mediaFilterController.getWhatOptions(); + const tagsOptions = this._mediaFilterController.getTagsOptions(); + const whereOptions = this._mediaFilterController.getWhereOptions(); return html` - - valueChange()} > +
+ whenChange('selected')} + > + + whenChange('custom')} + > + + whenChange('custom')} + > + +
valueChange()} > - ${areEvents && this._mediaMetadataController.whatOptions.length + ${controls.events && whatOptions.length ? html` valueChange()} > ` : ''} - ${areEvents && this._mediaMetadataController.tagsOptions.length + ${controls.events && tagsOptions.length ? html` valueChange()} > ` : ''} - ${areEvents && this._mediaMetadataController.whereOptions.length + ${controls.events && whereOptions.length ? html` valueChange()} > ` : ''} - ${showFavoriteControl + ${controls.favorites ? html` valueChange()} > ` @@ -518,76 +244,6 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { } } -export class MediaMetadataController implements ReactiveController { - protected _host: ReactiveControllerHost; - protected _cameraManager: CameraManager; - - public tagsOptions: SelectOption[] = []; - public whenOptions: SelectOption[] = []; - public whatOptions: SelectOption[] = []; - public whereOptions: SelectOption[] = []; - - constructor(host: ReactiveControllerHost, cameraManager: CameraManager) { - this._host = host; - this._cameraManager = cameraManager; - host.addController(this); - } - - protected _dateRangeToString(when: DateRange): string { - return `${formatDate(when.start)},${formatDate(when.end)}`; - } - - async hostConnected() { - let metadata: MediaMetadata | null; - try { - metadata = await this._cameraManager.getMediaMetadata(); - } 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.tags) { - this.tagsOptions = [...metadata.tags] - .sort() - .map((tag) => ({ value: tag, label: prettifyTitle(tag) })); - } - 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 = orderBy(monthStarts, (date) => date.getTime(), 'desc').map( - (monthStart) => ({ - label: format(monthStart, 'MMMM yyyy'), - value: this._dateRangeToString({ - start: monthStart, - end: endOfMonth(monthStart), - }), - }), - ); - } - this._host.requestUpdate(); - } -} - declare global { interface HTMLElementTagNameMap { 'frigate-card-media-filter': FrigateCardMediaFilter; diff --git a/src/components/select.ts b/src/components/select.ts index a56930a9..3a1bca79 100644 --- a/src/components/select.ts +++ b/src/components/select.ts @@ -1,4 +1,11 @@ -import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + 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'; @@ -26,6 +33,9 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) { @property({ attribute: false, hasChanged: contentsChanged }) public value?: SelectValues; + @property({ attribute: false, hasChanged: contentsChanged }) + public initialValue?: SelectValues; + @property({ attribute: true }) public label?: string; @@ -45,6 +55,10 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) { ...grSelectElements, }; + public reset(): void { + this.value = undefined; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars protected _valueChangedHandler(_ev: CustomEvent<{ value: unknown }>): void { const value: SelectValues | undefined = this._refSelect.value?.value; @@ -57,6 +71,12 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) { } } + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('initialValue') && this.initialValue && !this.value) { + this.value = this.initialValue; + } + } + protected render(): TemplateResult | void { return html` ${this.options?.map( diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 17ae1d19..a7ed952d 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -335,7 +335,9 @@ export class FrigateCardTimelineCore extends LitElement { ) => { - this._timeline?.moveTo(ev.detail.date); + if (ev.detail.date) { + this._timeline?.moveTo(ev.detail.date); + } }} > diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 241f4e4c..eed8911a 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -555,6 +555,7 @@ "what": "What", "when": "When", "whens": { + "custom": "Custom", "past_month": "Past Month", "past_week": "Past Week", "today": "Today", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 0a9b8e08..c2296b40 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -545,6 +545,7 @@ "what": "Che cosa", "when": "Quando", "whens": { + "custom": "", "past_month": "Mese scorso", "past_week": "Settimana scorso", "today": "Oggi", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 7b91adc2..99b8ebc0 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -554,6 +554,7 @@ "what": "O que", "when": "Quando", "whens": { + "custom": "", "past_month": "Mês passado", "past_week": "Semana passada", "today": "Hoje", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index c342ed09..10686bba 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -537,6 +537,7 @@ "what": "O que", "when": "Quando", "whens": { + "custom": "", "past_month": "O mes passado", "past_week": "A semana passada", "today": "Hoje", diff --git a/src/scoped-elements/gr-select.js b/src/scoped-elements/gr-select.js index ac71fe58..8285255e 100644 --- a/src/scoped-elements/gr-select.js +++ b/src/scoped-elements/gr-select.js @@ -2,6 +2,7 @@ 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: +// - Is a dropdown vs multi-select list. // - 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. diff --git a/src/scss/media-filter.scss b/src/scss/media-filter.scss index 6dc9aa2f..e70f5f69 100644 --- a/src/scss/media-filter.scss +++ b/src/scss/media-filter.scss @@ -22,3 +22,26 @@ frigate-card-select { padding: 5px; } + +div.when { + display: inline-flex; + align-items: flex-end; +} +div.when frigate-card-select { + flex: 1; +} +div.when frigate-card-date-picker { + // Visually line up the date-picker icon with the bottom of the select + // dropdown. + padding-bottom: 5px; + transition: width 0.5s ease-in-out; +} +div.when frigate-card-date-picker { + color: var(--secondary-color); +} +div.when frigate-card-date-picker.selected { + color: var(--primary-color); +} +div.when frigate-card-date-picker.hidden { + width: 0px; +} \ No newline at end of file diff --git a/tests/components-lib/media-filter-controller.test.ts b/tests/components-lib/media-filter-controller.test.ts new file mode 100644 index 00000000..3ade7447 --- /dev/null +++ b/tests/components-lib/media-filter-controller.test.ts @@ -0,0 +1,944 @@ +import endOfDay from 'date-fns/endOfDay'; +import startOfDay from 'date-fns/startOfDay'; +import sub from 'date-fns/sub'; +import { LitElement } from 'lit'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CameraManagerStore } from '../../src/camera-manager/store'; +import { QueryType } from '../../src/camera-manager/types'; +import { + MediaFilterController, + MediaFilterCoreDefaults, + MediaFilterCoreFavoriteSelection, + MediaFilterCoreWhen, + MediaFilterMediaType +} from '../../src/components-lib/media-filter-controller'; +import { executeMediaQueryForViewWithErrorDispatching } from '../../src/utils/media-to-view'; +import { + EventMediaQueries, + MediaQueries, + RecordingMediaQueries +} from '../../src/view/media-queries'; +import { + createAggregateCameraCapabilities, + createCameraConfig, + createCameraManager, + createPerformanceConfig, + createStore, + createView +} from '../test-utils'; + +vi.mock('../../src/utils/media-to-view'); + +const createHost = (): LitElement => { + const host = document.createElement('div') as unknown as LitElement; + host.requestUpdate = vi.fn(); + return host; +}; + +const createCameraStore = (): CameraManagerStore => { + return createStore([ + { + cameraID: 'camera.kitchen', + config: createCameraConfig({ + camera_entity: 'camera.kitchen', + }), + }, + ]); +}; + +// @vitest-environment jsdom +describe('MediaFilterController', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe('should have correct default options', () => { + it('media type', () => { + const controller = new MediaFilterController(createHost()); + expect(controller.getMediaTypeOptions()).toEqual([ + { + value: MediaFilterMediaType.Clips, + label: 'Clips', + }, + { + value: MediaFilterMediaType.Snapshots, + label: 'Snapshots', + }, + { + value: MediaFilterMediaType.Recordings, + label: 'Recordings', + }, + ]); + }); + + it('favorite', () => { + const controller = new MediaFilterController(createHost()); + expect(controller.getFavoriteOptions()).toEqual([ + { + value: MediaFilterCoreFavoriteSelection.Favorite, + label: 'Favorite', + }, + { + value: MediaFilterCoreFavoriteSelection.NotFavorite, + label: 'Not Favorite', + }, + ]); + }); + + it('when', () => { + const controller = new MediaFilterController(createHost()); + expect(controller.getWhenOptions()).toEqual([ + { + value: MediaFilterCoreWhen.Today, + label: 'Today', + }, + { + value: MediaFilterCoreWhen.Yesterday, + label: 'Yesterday', + }, + { + value: MediaFilterCoreWhen.PastWeek, + label: 'Past Week', + }, + { + value: MediaFilterCoreWhen.PastMonth, + label: 'Past Month', + }, + { + value: MediaFilterCoreWhen.Custom, + label: 'Custom', + }, + ]); + }); + + it('cameras', () => { + const controller = new MediaFilterController(createHost()); + expect(controller.getCameraOptions()).toEqual([]); + }); + + it('what', () => { + const controller = new MediaFilterController(createHost()); + expect(controller.getWhatOptions()).toEqual([]); + }); + + it('where', () => { + const controller = new MediaFilterController(createHost()); + expect(controller.getWhereOptions()).toEqual([]); + }); + + it('tags', () => { + const controller = new MediaFilterController(createHost()); + expect(controller.getTagsOptions()).toEqual([]); + }); + }); + + describe('should calculate correct dynamic options', () => { + describe('cameras', () => { + it('with valid camera', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({ + title: 'Kitchen Camera', + icon: 'mdi:camera', + }); + vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); + + const controller = new MediaFilterController(createHost()); + controller.computeCameraOptions(cameraManager); + expect(controller.getCameraOptions()).toEqual([ + { + label: 'Kitchen Camera', + value: 'camera.kitchen', + }, + ]); + }); + + it('without camera metadata', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getCameraMetadata).mockReturnValue(null); + vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); + + const controller = new MediaFilterController(createHost()); + controller.computeCameraOptions(cameraManager); + expect(controller.getCameraOptions()).toEqual([ + { + label: 'camera.kitchen', + value: 'camera.kitchen', + }, + ]); + }); + }); + + describe('metadata', () => { + it('with failed getMediaMetadata call', async () => { + vi.spyOn(global.console, 'warn').mockReturnValue(undefined); + + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getMediaMetadata).mockRejectedValue(new Error('error')); + + const host = createHost(); + const controller = new MediaFilterController(host); + await controller.computeMetadataOptions(cameraManager); + expect(host.requestUpdate).not.toBeCalled(); + }); + + it('with metadata for what', async () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getMediaMetadata).mockResolvedValue({ + what: new Set(['person', 'car']), + }); + + const host = createHost(); + const controller = new MediaFilterController(host); + await controller.computeMetadataOptions(cameraManager); + expect(controller.getWhatOptions()).toEqual([ + { + value: 'car', + label: 'Car', + }, + { + value: 'person', + label: 'Person', + }, + ]); + expect(host.requestUpdate).toBeCalled(); + }); + + it('with metadata for where', async () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getMediaMetadata).mockResolvedValue({ + where: new Set(['front_door', 'back_yard']), + }); + + const host = createHost(); + const controller = new MediaFilterController(host); + await controller.computeMetadataOptions(cameraManager); + expect(controller.getWhereOptions()).toEqual([ + { + value: 'back_yard', + label: 'Back Yard', + }, + { + value: 'front_door', + label: 'Front Door', + }, + ]); + expect(host.requestUpdate).toBeCalled(); + }); + + it('with metadata for tags', async () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getMediaMetadata).mockResolvedValue({ + tags: new Set(['tag-1', 'tag-2']), + }); + + const host = createHost(); + const controller = new MediaFilterController(host); + await controller.computeMetadataOptions(cameraManager); + expect(controller.getTagsOptions()).toEqual([ + { + value: 'tag-1', + label: 'Tag-1', + }, + { + value: 'tag-2', + label: 'Tag-2', + }, + ]); + expect(host.requestUpdate).toBeCalled(); + }); + + it('with metadata for days', async () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getMediaMetadata).mockResolvedValue({ + days: new Set(['2024-02-04', '2024-02-05']), + }); + + const host = createHost(); + const controller = new MediaFilterController(host); + await controller.computeMetadataOptions(cameraManager); + + expect(controller.getWhenOptions()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: '2024-02-01,2024-02-29', + label: 'February 2024', + }), + ]), + ); + expect(host.requestUpdate).toBeCalled(); + }); + }); + }); + + describe('should get correct controls to show', () => { + it('view with events', () => { + const view = createView({ query: new EventMediaQueries() }); + const cameraManager = createCameraManager(); + + const controller = new MediaFilterController(createHost()); + expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ + events: true, + recordings: false, + }); + }); + + it('view with recordings', () => { + const view = createView({ query: new RecordingMediaQueries() }); + const cameraManager = createCameraManager(); + + const controller = new MediaFilterController(createHost()); + expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ + events: false, + recordings: true, + }); + }); + + it('can favorite events', () => { + const view = createView({ query: new EventMediaQueries() }); + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( + createAggregateCameraCapabilities({ canFavoriteEvents: true }), + ); + + const controller = new MediaFilterController(createHost()); + expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ + favorites: true, + }); + }); + + it('can favorite recordings', () => { + const view = createView({ query: new RecordingMediaQueries() }); + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( + createAggregateCameraCapabilities({ canFavoriteRecordings: true }), + ); + + const controller = new MediaFilterController(createHost()); + expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ + favorites: true, + }); + }); + + it('can not favorite without a query', () => { + const view = createView(); + const cameraManager = createCameraManager(); + + const controller = new MediaFilterController(createHost()); + expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ + favorites: false, + }); + }); + }); + + describe('should handle value change', () => { + it('must have visible cameras', async () => { + const host = createHost(); + const controller = new MediaFilterController(host); + await controller.valueChangeHandler( + createCameraManager(), + createView(), + {}, + { when: {} }, + ); + + expect(host.requestUpdate).not.toBeCalled(); + }); + + describe('with events media type', () => { + it.each([['clips' as const], ['snapshots' as const]])( + '%s', + async (viewName: 'clips' | 'snapshots') => { + const eventListener = vi.fn(); + const host = createHost(); + host.addEventListener('frigate-card:view:change', eventListener); + + const controller = new MediaFilterController(host); + const cameraManager = createCameraManager(); + const view = createView(); + vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); + vi.mocked(executeMediaQueryForViewWithErrorDispatching).mockResolvedValueOnce( + view, + ); + + const from = new Date('2024-02-06T21:59'); + const to = new Date('2024-02-06T22:00'); + + await controller.valueChangeHandler( + cameraManager, + view, + { + performance: createPerformanceConfig({ + features: { + media_chunk_size: 11, + }, + }), + }, + { + mediaType: + viewName === 'clips' + ? MediaFilterMediaType.Clips + : MediaFilterMediaType.Snapshots, + when: { + to: to, + from: from, + }, + tags: ['tag-1', 'tag-2'], + what: ['what-1', 'what-2'], + where: ['where-1', 'where-2'], + favorite: MediaFilterCoreFavoriteSelection.Favorite, + }, + ); + + expect(vi.mocked(executeMediaQueryForViewWithErrorDispatching)).toBeCalledWith( + host, + cameraManager, + view, + expect.anything(), + { + targetCameraID: 'camera.kitchen', + targetView: viewName, + }, + ); + expect( + vi + .mocked(executeMediaQueryForViewWithErrorDispatching) + .mock.calls[0][3].getQueries(), + ).toEqual([ + { + cameraIDs: new Set(['camera.kitchen']), + ...(viewName === 'clips' && { hasClip: true }), + ...(viewName === 'snapshots' && { hasSnapshot: true }), + type: 'event-query', + tags: new Set(['tag-1', 'tag-2']), + what: new Set(['what-1', 'what-2']), + where: new Set(['where-1', 'where-2']), + favorite: true, + start: from, + end: to, + limit: 11, + }, + ]); + + expect(eventListener).toBeCalled(); + expect(host.requestUpdate).toBeCalled(); + }, + ); + }); + + it('with recordings media type', async () => { + const eventListener = vi.fn(); + const host = createHost(); + host.addEventListener('frigate-card:view:change', eventListener); + + const controller = new MediaFilterController(host); + const cameraManager = createCameraManager(); + const view = createView(); + vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); + vi.mocked(executeMediaQueryForViewWithErrorDispatching).mockResolvedValueOnce( + view, + ); + + const from = new Date('2024-02-06T21:59'); + const to = new Date('2024-02-06T22:00'); + + await controller.valueChangeHandler( + cameraManager, + view, + { + performance: createPerformanceConfig({ + features: { + media_chunk_size: 11, + }, + }), + }, + { + mediaType: MediaFilterMediaType.Recordings, + when: { + to: to, + from: from, + }, + favorite: MediaFilterCoreFavoriteSelection.Favorite, + }, + ); + + expect(vi.mocked(executeMediaQueryForViewWithErrorDispatching)).toBeCalledWith( + host, + cameraManager, + view, + expect.anything(), + { + targetCameraID: 'camera.kitchen', + targetView: 'recordings', + }, + ); + expect( + vi + .mocked(executeMediaQueryForViewWithErrorDispatching) + .mock.calls[0][3].getQueries(), + ).toEqual([ + { + cameraIDs: new Set(['camera.kitchen']), + type: 'recording-query', + favorite: true, + start: from, + end: to, + limit: 11, + }, + ]); + + expect(eventListener).toBeCalled(); + expect(host.requestUpdate).toBeCalled(); + }); + + it('without favorites', async () => { + const controller = new MediaFilterController(createHost()); + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); + + await controller.valueChangeHandler( + cameraManager, + createView(), + {}, + { + mediaType: MediaFilterMediaType.Recordings, + when: {}, + }, + ); + + expect( + vi + .mocked(executeMediaQueryForViewWithErrorDispatching) + .mock.calls[0][3].getQueries(), + ).toEqual([ + { + cameraIDs: new Set(['camera.kitchen']), + type: 'recording-query', + }, + ]); + }); + + describe('with fixed when selection', () => { + const date = new Date('2024-10-01T17:14'); + + beforeAll(() => { + vi.useFakeTimers(); + vi.setSystemTime(date); + }); + + afterAll(() => { + vi.useFakeTimers(); + }); + + it.each([ + [MediaFilterCoreWhen.Today, startOfDay(date), endOfDay(date)], + [ + MediaFilterCoreWhen.Yesterday, + startOfDay(sub(date, { days: 1 })), + endOfDay(sub(date, { days: 1 })), + ], + [ + MediaFilterCoreWhen.PastWeek, + startOfDay(sub(date, { days: 7 })), + endOfDay(date), + ], + [ + MediaFilterCoreWhen.PastMonth, + startOfDay(sub(date, { months: 1 })), + endOfDay(date), + ], + [ + '2024-02-01,2024-02-29', + new Date('2024-02-01T00:00:00'), + new Date('2024-02-29T23:59:59.999'), + ], + ])('%s', async (value: MediaFilterCoreWhen | string, from: Date, to: Date) => { + const controller = new MediaFilterController(createHost()); + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); + + await controller.valueChangeHandler( + cameraManager, + createView(), + {}, + { + mediaType: MediaFilterMediaType.Recordings, + when: { + selected: value, + }, + }, + ); + + expect( + vi + .mocked(executeMediaQueryForViewWithErrorDispatching) + .mock.calls[0][3].getQueries(), + ).toEqual([ + { + cameraIDs: new Set(['camera.kitchen']), + type: 'recording-query', + start: from, + end: to, + }, + ]); + }); + + it('custom without values', async () => { + const controller = new MediaFilterController(createHost()); + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); + + await controller.valueChangeHandler( + cameraManager, + createView(), + {}, + { + mediaType: MediaFilterMediaType.Recordings, + when: { + selected: MediaFilterCoreWhen.Custom, + }, + }, + ); + + expect( + vi + .mocked(executeMediaQueryForViewWithErrorDispatching) + .mock.calls[0][3].getQueries(), + ).toEqual([ + { + cameraIDs: new Set(['camera.kitchen']), + type: 'recording-query', + }, + ]); + }); + }); + }); + + describe('should calculate correct defaults', () => { + it('with no queries', () => { + const controller = new MediaFilterController(createHost()); + + controller.computeInitialDefaultsFromView(createCameraManager(), createView()); + + expect(controller.getDefaults()).toBeNull(); + }); + + it('with no cameras', () => { + const controller = new MediaFilterController(createHost()); + + controller.computeInitialDefaultsFromView( + createCameraManager(), + createView({ + query: new EventMediaQueries([ + { type: QueryType.Event, cameraIDs: new Set(['camera.kitchen']) }, + ]), + }), + ); + + expect(controller.getDefaults()).toBeNull(); + }); + + describe('for queries', () => { + it.each([ + [ + 'same cameras' as const, + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen', 'camera.living_room']), + }, + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen', 'camera.living_room']), + }, + ]), + { + cameraIDs: ['camera.kitchen', 'camera.living_room'], + }, + ], + [ + 'different cameras' as const, + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + }, + { + type: QueryType.Event, + cameraIDs: new Set(['camera.living_room']), + }, + ]), + {}, + ], + [ + 'all cameras' as const, + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + }, + ]), + {}, + ], + [ + 'different favorites ' as const, + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + favorite: true, + }, + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + favorite: undefined, + }, + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + favorite: false, + }, + ]), + {}, + ], + [ + 'all favorites' as const, + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + favorite: true, + }, + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + favorite: true, + }, + ]), + { + favorite: MediaFilterCoreFavoriteSelection.Favorite, + }, + ], + [ + 'all not favorites' as const, + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + favorite: false, + }, + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + favorite: false, + }, + ]), + { + favorite: MediaFilterCoreFavoriteSelection.NotFavorite, + }, + ], + [ + 'same hasClip' as const, + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + hasClip: true, + }, + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + hasClip: true, + }, + ]), + { + mediaType: MediaFilterMediaType.Clips, + }, + ], + [ + 'different hasClip' as const, + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + hasClip: true, + }, + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + hasClip: false, + }, + ]), + {}, + ], + [ + 'same hasSnapshot' as const, + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + hasSnapshot: true, + }, + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + hasSnapshot: true, + }, + ]), + { + mediaType: MediaFilterMediaType.Snapshots, + }, + ], + [ + 'different hasSnapshot' as const, + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + hasSnapshot: true, + }, + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + hasSnapshot: false, + }, + ]), + {}, + ], + [ + 'same what' as const, + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + what: new Set(['person']), + }, + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + what: new Set(['person']), + }, + ]), + { + what: ['person' as const], + }, + ], + [ + 'different what' as const, + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + what: new Set(['person']), + }, + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + what: new Set(['car']), + }, + ]), + {}, + ], + [ + 'same where' as const, + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + where: new Set(['front_door']), + }, + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + where: new Set(['front_door']), + }, + ]), + { + where: ['front_door' as const], + }, + ], + [ + 'different where' as const, + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + where: new Set(['front_door']), + }, + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + where: new Set(['back_steps']), + }, + ]), + {}, + ], + [ + 'same tags' as const, + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + tags: new Set(['tag-1']), + }, + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + tags: new Set(['tag-1']), + }, + ]), + { + tags: ['tag-1' as const], + }, + ], + [ + 'different tags' as const, + new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + tags: new Set(['tag-1']), + }, + { + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + tags: new Set(['tag-2']), + }, + ]), + {}, + ], + [ + 'recordings' as const, + new RecordingMediaQueries([ + { + type: QueryType.Recording, + cameraIDs: new Set(['camera.kitchen']), + }, + ]), + { + mediaType: MediaFilterMediaType.Recordings + }, + ], + ])( + '%s', + ( + _name: string, + mediaQueries: MediaQueries, + defaults: MediaFilterCoreDefaults | null, + ) => { + const controller = new MediaFilterController(createHost()); + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); + + controller.computeInitialDefaultsFromView( + cameraManager, + createView({ + query: mediaQueries, + }), + ); + + expect(controller.getDefaults()).toEqual(defaults); + }, + ); + }); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index c9e5ca8e..cd591cf8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,10 +10,10 @@ export default defineConfig({ // Thresholds will automatically be updated as coverage improves to avoid // back-sliding. thresholdAutoUpdate: true, - statements: 72.76, - branches: 61.95, - functions: 74.01, - lines: 72.66, + statements: 73.59, + branches: 63.6, + functions: 74.86, + lines: 73.49, }, }, });