Add custom date/time media filtering.
This commit is contained in:
@@ -6,6 +6,7 @@ interface Range<T extends Date | number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DateRange = Range<Date>;
|
export type DateRange = Range<Date>;
|
||||||
|
export type PartialDateRange = Partial<DateRange>;
|
||||||
|
|
||||||
interface MemoryRangeSetInterface<T> {
|
interface MemoryRangeSetInterface<T> {
|
||||||
hasCoverage(range: T): boolean;
|
hasCoverage(range: T): boolean;
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
const getArrayValueAsSet = (val?: SelectValues): Set<string> | 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<void> {
|
||||||
|
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<string> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
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 { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import datePickerStyle from '../scss/date-picker.scss';
|
import datePickerStyle from '../scss/date-picker.scss';
|
||||||
@@ -7,32 +7,47 @@ import { stopEventFromActivatingCardWideActions } from '../utils/action';
|
|||||||
import { dispatchFrigateCardEvent } from '../utils/basic';
|
import { dispatchFrigateCardEvent } from '../utils/basic';
|
||||||
|
|
||||||
export interface DatePickerEvent {
|
export interface DatePickerEvent {
|
||||||
date: Date;
|
date: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@customElement('frigate-card-date-picker')
|
@customElement('frigate-card-date-picker')
|
||||||
export class FrigateCardDatePicker extends LitElement {
|
export class FrigateCardDatePicker extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
public icon?: string;
|
||||||
|
|
||||||
protected _refInput: Ref<HTMLInputElement> = createRef();
|
protected _refInput: Ref<HTMLInputElement> = 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 {
|
protected render(): TemplateResult {
|
||||||
|
const changed = () => {
|
||||||
|
const value = this._refInput.value?.value;
|
||||||
|
|
||||||
|
dispatchFrigateCardEvent<DatePickerEvent>(this, 'date-picker:change', {
|
||||||
|
date: value ? new Date(value) : null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return html`<input
|
return html`<input
|
||||||
aria-label="${localize('timeline.select_date')}"
|
aria-label="${localize('timeline.select_date')}"
|
||||||
title="${localize('timeline.select_date')}"
|
title="${localize('timeline.select_date')}"
|
||||||
${ref(this._refInput)}
|
${ref(this._refInput)}
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
@input=${() => {
|
@input=${() => changed()}
|
||||||
const value = this._refInput.value?.value;
|
@change=${() => changed()}
|
||||||
if (value) {
|
|
||||||
dispatchFrigateCardEvent<DatePickerEvent>(this, 'date-picker:change', {
|
|
||||||
date: new Date(value),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<ha-icon
|
<ha-icon
|
||||||
aria-label="${localize('timeline.select_date')}"
|
aria-label="${localize('timeline.select_date')}"
|
||||||
title="${localize('timeline.select_date')}"
|
title="${localize('timeline.select_date')}"
|
||||||
.icon=${`mdi:calendar-search`}
|
.icon=${this.icon ?? `mdi:calendar-search`}
|
||||||
@click=${(ev: Event) => {
|
@click=${(ev: Event) => {
|
||||||
stopEventFromActivatingCardWideActions(ev);
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
this._refInput.value?.showPicker();
|
this._refInput.value?.showPicker();
|
||||||
|
|||||||
+130
-474
@@ -1,73 +1,32 @@
|
|||||||
import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin';
|
|
||||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||||
import endOfDay from 'date-fns/endOfDay';
|
import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin';
|
||||||
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 {
|
import {
|
||||||
CSSResultGroup,
|
CSSResultGroup,
|
||||||
html,
|
html,
|
||||||
LitElement,
|
LitElement,
|
||||||
PropertyValues,
|
PropertyValues,
|
||||||
ReactiveController,
|
|
||||||
ReactiveControllerHost,
|
|
||||||
TemplateResult,
|
TemplateResult,
|
||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
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 { 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 { CameraManager } from '../camera-manager/manager';
|
||||||
import { DateRange } from '../camera-manager/range';
|
import {
|
||||||
import { DataQuery, MediaMetadata, QueryType } from '../camera-manager/types';
|
MediaFilterController,
|
||||||
|
MediaFilterCoreFavoriteSelection,
|
||||||
|
MediaFilterCoreWhen,
|
||||||
|
MediaFilterMediaType,
|
||||||
|
} from '../components-lib/media-filter-controller';
|
||||||
import { CardWideConfig } from '../config/types';
|
import { CardWideConfig } from '../config/types';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import mediaFilterStyle from '../scss/media-filter.scss';
|
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 { View } from '../view/view';
|
||||||
import './select';
|
import { FrigateCardDatePicker } from './date-picker';
|
||||||
import { FrigateCardSelect, SelectOption, SelectValues } from './select';
|
import './date-picker.js';
|
||||||
|
import { FrigateCardSelect } from './select';
|
||||||
import './select.js';
|
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')
|
@customElement('frigate-card-media-filter')
|
||||||
class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
@@ -84,429 +43,196 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
|||||||
|
|
||||||
static elementDefinitions = {
|
static elementDefinitions = {
|
||||||
'frigate-card-select': FrigateCardSelect,
|
'frigate-card-select': FrigateCardSelect,
|
||||||
|
'frigate-card-date-picker': FrigateCardDatePicker,
|
||||||
};
|
};
|
||||||
|
|
||||||
protected _mediaMetadataController?: MediaMetadataController;
|
protected _mediaFilterController = new MediaFilterController(this);
|
||||||
|
|
||||||
protected _mediaTypeOptions: SelectOption[];
|
|
||||||
protected _cameraOptions?: SelectOption[];
|
|
||||||
protected _whenOptions?: SelectOption[];
|
|
||||||
protected _favoriteOptions: SelectOption[];
|
|
||||||
|
|
||||||
protected _defaults: MediaFilterCoreDefaults | null = null;
|
|
||||||
|
|
||||||
protected _refMediaType: Ref<FrigateCardSelect> = createRef();
|
protected _refMediaType: Ref<FrigateCardSelect> = createRef();
|
||||||
protected _refCamera: Ref<FrigateCardSelect> = createRef();
|
protected _refCamera: Ref<FrigateCardSelect> = createRef();
|
||||||
protected _refWhen: Ref<FrigateCardSelect> = createRef();
|
protected _refWhen: Ref<FrigateCardSelect> = createRef();
|
||||||
|
protected _refWhenFrom: Ref<FrigateCardDatePicker> = createRef();
|
||||||
|
protected _refWhenTo: Ref<FrigateCardDatePicker> = createRef();
|
||||||
protected _refWhat: Ref<FrigateCardSelect> = createRef();
|
protected _refWhat: Ref<FrigateCardSelect> = createRef();
|
||||||
protected _refWhere: Ref<FrigateCardSelect> = createRef();
|
protected _refWhere: Ref<FrigateCardSelect> = createRef();
|
||||||
protected _refFavorite: Ref<FrigateCardSelect> = createRef();
|
protected _refFavorite: Ref<FrigateCardSelect> = createRef();
|
||||||
protected _refTags: Ref<FrigateCardSelect> = createRef();
|
protected _refTags: Ref<FrigateCardSelect> = 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<void> {
|
|
||||||
const visibleCameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
|
|
||||||
if (!this.hass || !visibleCameraIDs || !this.cameraManager || !this.view) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getArrayValueAsSet = (val?: SelectValues): Set<string> | 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 {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('cameraManager')) {
|
if (changedProps.has('cameraManager') && this.cameraManager) {
|
||||||
const cameras = this.cameraManager?.getStore().getVisibleCameraIDs();
|
this._mediaFilterController.computeCameraOptions(this.cameraManager);
|
||||||
if (cameras) {
|
this._mediaFilterController.computeMetadataOptions(this.cameraManager);
|
||||||
this._cameraOptions = [...cameras].map((cameraID) => ({
|
|
||||||
value: cameraID,
|
|
||||||
label: this.hass
|
|
||||||
? this.cameraManager?.getCameraMetadata(cameraID)?.title ?? ''
|
|
||||||
: '',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
if (changedProps.has('cameraManager') && this.hass && this.cameraManager) {
|
changedProps.has('view') &&
|
||||||
this._mediaMetadataController = new MediaMetadataController(
|
!changedProps.get('view') &&
|
||||||
this,
|
this.view &&
|
||||||
|
this.cameraManager
|
||||||
|
) {
|
||||||
|
this._mediaFilterController.computeInitialDefaultsFromView(
|
||||||
this.cameraManager,
|
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 {
|
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<void> => {
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const areEvents = !!(
|
const controls = this._mediaFilterController.getControlsToShow(
|
||||||
this.view?.query && MediaQueriesClassifier.areEventQueries(this.view.query)
|
this.cameraManager,
|
||||||
|
this.view,
|
||||||
);
|
);
|
||||||
const areRecordings = !!(
|
const defaults = this._mediaFilterController.getDefaults();
|
||||||
this.view?.query && MediaQueriesClassifier.areRecordingQueries(this.view.query)
|
const whatOptions = this._mediaFilterController.getWhatOptions();
|
||||||
);
|
const tagsOptions = this._mediaFilterController.getTagsOptions();
|
||||||
const managerCapabilities = this.cameraManager?.getAggregateCameraCapabilities();
|
const whereOptions = this._mediaFilterController.getWhereOptions();
|
||||||
|
|
||||||
// Which media controls are shown depends on the view.
|
|
||||||
const showFavoriteControl = areEvents
|
|
||||||
? !!managerCapabilities?.canFavoriteEvents
|
|
||||||
: areRecordings
|
|
||||||
? !!managerCapabilities?.canFavoriteRecordings
|
|
||||||
: false;
|
|
||||||
|
|
||||||
return html` <frigate-card-select
|
return html` <frigate-card-select
|
||||||
${ref(this._refMediaType)}
|
${ref(this._refMediaType)}
|
||||||
label=${localize('media_filter.media_type')}
|
label=${localize('media_filter.media_type')}
|
||||||
placeholder=${localize('media_filter.select_media_type')}
|
placeholder=${localize('media_filter.select_media_type')}
|
||||||
.options=${this._mediaTypeOptions}
|
.options=${this._mediaFilterController.getMediaTypeOptions()}
|
||||||
.value=${this._defaults?.mediaType}
|
.initialValue=${defaults?.mediaType}
|
||||||
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
|
@frigate-card:select:change=${() => valueChange()}
|
||||||
>
|
|
||||||
</frigate-card-select>
|
|
||||||
<frigate-card-select
|
|
||||||
${ref(this._refWhen)}
|
|
||||||
.label=${localize('media_filter.when')}
|
|
||||||
placeholder=${localize('media_filter.select_when')}
|
|
||||||
.options=${this._whenOptions}
|
|
||||||
.value=${this._defaults?.when}
|
|
||||||
clearable
|
|
||||||
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
|
|
||||||
>
|
>
|
||||||
</frigate-card-select>
|
</frigate-card-select>
|
||||||
|
<div class="when">
|
||||||
|
<frigate-card-select
|
||||||
|
${ref(this._refWhen)}
|
||||||
|
.label=${localize('media_filter.when')}
|
||||||
|
placeholder=${localize('media_filter.select_when')}
|
||||||
|
.options=${this._mediaFilterController.getWhenOptions()}
|
||||||
|
.initialValue=${defaults?.when}
|
||||||
|
clearable
|
||||||
|
@frigate-card:select:change=${() => whenChange('selected')}
|
||||||
|
>
|
||||||
|
</frigate-card-select>
|
||||||
|
<frigate-card-date-picker
|
||||||
|
class="${classMap({
|
||||||
|
selected: !!this._refWhenFrom.value?.value,
|
||||||
|
hidden: this._refWhen.value?.value !== MediaFilterCoreWhen.Custom,
|
||||||
|
})}"
|
||||||
|
${ref(this._refWhenFrom)}
|
||||||
|
.icon=${'mdi:calendar-arrow-right'}
|
||||||
|
@frigate-card:date-picker:change=${() => whenChange('custom')}
|
||||||
|
>
|
||||||
|
</frigate-card-date-picker>
|
||||||
|
<frigate-card-date-picker
|
||||||
|
class="${classMap({
|
||||||
|
selected: !!this._refWhenTo.value?.value,
|
||||||
|
hidden: this._refWhen.value?.value !== MediaFilterCoreWhen.Custom,
|
||||||
|
})}"
|
||||||
|
${ref(this._refWhenTo)}
|
||||||
|
.icon=${'mdi:calendar-arrow-left'}
|
||||||
|
@frigate-card:date-picker:change=${() => whenChange('custom')}
|
||||||
|
>
|
||||||
|
</frigate-card-date-picker>
|
||||||
|
</div>
|
||||||
<frigate-card-select
|
<frigate-card-select
|
||||||
${ref(this._refCamera)}
|
${ref(this._refCamera)}
|
||||||
.label=${localize('media_filter.camera')}
|
.label=${localize('media_filter.camera')}
|
||||||
placeholder=${localize('media_filter.select_camera')}
|
placeholder=${localize('media_filter.select_camera')}
|
||||||
.options=${this._cameraOptions}
|
.options=${this._mediaFilterController.getCameraOptions()}
|
||||||
.value=${this._defaults?.cameraIDs}
|
.initialValue=${defaults?.cameraIDs}
|
||||||
clearable
|
clearable
|
||||||
multiple
|
multiple
|
||||||
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
|
@frigate-card:select:change=${() => valueChange()}
|
||||||
>
|
>
|
||||||
</frigate-card-select>
|
</frigate-card-select>
|
||||||
${areEvents && this._mediaMetadataController.whatOptions.length
|
${controls.events && whatOptions.length
|
||||||
? html` <frigate-card-select
|
? html` <frigate-card-select
|
||||||
${ref(this._refWhat)}
|
${ref(this._refWhat)}
|
||||||
label=${localize('media_filter.what')}
|
label=${localize('media_filter.what')}
|
||||||
placeholder=${localize('media_filter.select_what')}
|
placeholder=${localize('media_filter.select_what')}
|
||||||
clearable
|
clearable
|
||||||
multiple
|
multiple
|
||||||
.options=${this._mediaMetadataController.whatOptions}
|
.options=${whatOptions}
|
||||||
.value=${this._defaults?.what}
|
.initialValue=${defaults?.what}
|
||||||
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
|
@frigate-card:select:change=${() => valueChange()}
|
||||||
>
|
>
|
||||||
</frigate-card-select>`
|
</frigate-card-select>`
|
||||||
: ''}
|
: ''}
|
||||||
${areEvents && this._mediaMetadataController.tagsOptions.length
|
${controls.events && tagsOptions.length
|
||||||
? html` <frigate-card-select
|
? html` <frigate-card-select
|
||||||
${ref(this._refTags)}
|
${ref(this._refTags)}
|
||||||
label=${localize('media_filter.tag')}
|
label=${localize('media_filter.tag')}
|
||||||
placeholder=${localize('media_filter.select_tag')}
|
placeholder=${localize('media_filter.select_tag')}
|
||||||
clearable
|
clearable
|
||||||
multiple
|
multiple
|
||||||
.options=${this._mediaMetadataController.tagsOptions}
|
.options=${tagsOptions}
|
||||||
.value=${this._defaults?.tags}
|
.initialValue=${defaults?.tags}
|
||||||
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
|
@frigate-card:select:change=${() => valueChange()}
|
||||||
>
|
>
|
||||||
</frigate-card-select>`
|
</frigate-card-select>`
|
||||||
: ''}
|
: ''}
|
||||||
${areEvents && this._mediaMetadataController.whereOptions.length
|
${controls.events && whereOptions.length
|
||||||
? html` <frigate-card-select
|
? html` <frigate-card-select
|
||||||
${ref(this._refWhere)}
|
${ref(this._refWhere)}
|
||||||
label=${localize('media_filter.where')}
|
label=${localize('media_filter.where')}
|
||||||
placeholder=${localize('media_filter.select_where')}
|
placeholder=${localize('media_filter.select_where')}
|
||||||
clearable
|
clearable
|
||||||
multiple
|
multiple
|
||||||
.options=${this._mediaMetadataController.whereOptions}
|
.options=${whereOptions}
|
||||||
.value=${this._defaults?.where}
|
.initialValue=${defaults?.where}
|
||||||
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
|
@frigate-card:select:change=${() => valueChange()}
|
||||||
>
|
>
|
||||||
</frigate-card-select>`
|
</frigate-card-select>`
|
||||||
: ''}
|
: ''}
|
||||||
${showFavoriteControl
|
${controls.favorites
|
||||||
? html`
|
? html`
|
||||||
<frigate-card-select
|
<frigate-card-select
|
||||||
${ref(this._refFavorite)}
|
${ref(this._refFavorite)}
|
||||||
label=${localize('media_filter.favorite')}
|
label=${localize('media_filter.favorite')}
|
||||||
placeholder=${localize('media_filter.select_favorite')}
|
placeholder=${localize('media_filter.select_favorite')}
|
||||||
.options=${this._favoriteOptions}
|
.options=${this._mediaFilterController.getFavoriteOptions()}
|
||||||
.value=${this._defaults?.favorite}
|
.initialValue=${defaults?.favorite}
|
||||||
clearable
|
clearable
|
||||||
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
|
@frigate-card:select:change=${() => valueChange()}
|
||||||
>
|
>
|
||||||
</frigate-card-select>
|
</frigate-card-select>
|
||||||
`
|
`
|
||||||
@@ -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<string> = 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 {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
'frigate-card-media-filter': FrigateCardMediaFilter;
|
'frigate-card-media-filter': FrigateCardMediaFilter;
|
||||||
|
|||||||
@@ -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 { property } from 'lit/decorators.js';
|
||||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
import selectStyle from '../scss/select.scss';
|
import selectStyle from '../scss/select.scss';
|
||||||
@@ -26,6 +33,9 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) {
|
|||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public value?: SelectValues;
|
public value?: SelectValues;
|
||||||
|
|
||||||
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
|
public initialValue?: SelectValues;
|
||||||
|
|
||||||
@property({ attribute: true })
|
@property({ attribute: true })
|
||||||
public label?: string;
|
public label?: string;
|
||||||
|
|
||||||
@@ -45,6 +55,10 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) {
|
|||||||
...grSelectElements,
|
...grSelectElements,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this.value = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
protected _valueChangedHandler(_ev: CustomEvent<{ value: unknown }>): void {
|
protected _valueChangedHandler(_ev: CustomEvent<{ value: unknown }>): void {
|
||||||
const value: SelectValues | undefined = this._refSelect.value?.value;
|
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 {
|
protected render(): TemplateResult | void {
|
||||||
return html` <gr-select
|
return html` <gr-select
|
||||||
${ref(this._refSelect)}
|
${ref(this._refSelect)}
|
||||||
@@ -65,7 +85,7 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) {
|
|||||||
size="small"
|
size="small"
|
||||||
?multiple=${this.multiple}
|
?multiple=${this.multiple}
|
||||||
?clearable=${this.clearable}
|
?clearable=${this.clearable}
|
||||||
.value=${this.value ?? this._refSelect.value?.value ?? []}
|
.value=${this.value ?? []}
|
||||||
@gr-change=${this._valueChangedHandler.bind(this)}
|
@gr-change=${this._valueChangedHandler.bind(this)}
|
||||||
>
|
>
|
||||||
${this.options?.map(
|
${this.options?.map(
|
||||||
|
|||||||
@@ -335,7 +335,9 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
<frigate-card-date-picker
|
<frigate-card-date-picker
|
||||||
${ref(this._refDatePicker)}
|
${ref(this._refDatePicker)}
|
||||||
@frigate-card:date-picker:change=${(ev: CustomEvent<DatePickerEvent>) => {
|
@frigate-card:date-picker:change=${(ev: CustomEvent<DatePickerEvent>) => {
|
||||||
this._timeline?.moveTo(ev.detail.date);
|
if (ev.detail.date) {
|
||||||
|
this._timeline?.moveTo(ev.detail.date);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
</frigate-card-date-picker>
|
</frigate-card-date-picker>
|
||||||
|
|||||||
@@ -555,6 +555,7 @@
|
|||||||
"what": "What",
|
"what": "What",
|
||||||
"when": "When",
|
"when": "When",
|
||||||
"whens": {
|
"whens": {
|
||||||
|
"custom": "Custom",
|
||||||
"past_month": "Past Month",
|
"past_month": "Past Month",
|
||||||
"past_week": "Past Week",
|
"past_week": "Past Week",
|
||||||
"today": "Today",
|
"today": "Today",
|
||||||
|
|||||||
@@ -545,6 +545,7 @@
|
|||||||
"what": "Che cosa",
|
"what": "Che cosa",
|
||||||
"when": "Quando",
|
"when": "Quando",
|
||||||
"whens": {
|
"whens": {
|
||||||
|
"custom": "",
|
||||||
"past_month": "Mese scorso",
|
"past_month": "Mese scorso",
|
||||||
"past_week": "Settimana scorso",
|
"past_week": "Settimana scorso",
|
||||||
"today": "Oggi",
|
"today": "Oggi",
|
||||||
|
|||||||
@@ -554,6 +554,7 @@
|
|||||||
"what": "O que",
|
"what": "O que",
|
||||||
"when": "Quando",
|
"when": "Quando",
|
||||||
"whens": {
|
"whens": {
|
||||||
|
"custom": "",
|
||||||
"past_month": "Mês passado",
|
"past_month": "Mês passado",
|
||||||
"past_week": "Semana passada",
|
"past_week": "Semana passada",
|
||||||
"today": "Hoje",
|
"today": "Hoje",
|
||||||
|
|||||||
@@ -537,6 +537,7 @@
|
|||||||
"what": "O que",
|
"what": "O que",
|
||||||
"when": "Quando",
|
"when": "Quando",
|
||||||
"whens": {
|
"whens": {
|
||||||
|
"custom": "",
|
||||||
"past_month": "O mes passado",
|
"past_month": "O mes passado",
|
||||||
"past_week": "A semana passada",
|
"past_week": "A semana passada",
|
||||||
"today": "Hoje",
|
"today": "Hoje",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { GrSelect } from '@graphiteds/core/components/gr-select';
|
|||||||
import { GrMenuItem } from '@graphiteds/core/components/gr-menu-item';
|
import { GrMenuItem } from '@graphiteds/core/components/gr-menu-item';
|
||||||
|
|
||||||
// It was difficult to find a multi-select web component that matches these criteria:
|
// It was difficult to find a multi-select web component that matches these criteria:
|
||||||
|
// - Is a dropdown vs multi-select list.
|
||||||
// - Open source.
|
// - Open source.
|
||||||
// - Supports being in a ScopedRegistry out of the box (i.e. does not auto-register with customElements).
|
// - Supports being in a ScopedRegistry out of the box (i.e. does not auto-register with customElements).
|
||||||
// - Looks attractive / compatible with mostly Material elements.
|
// - Looks attractive / compatible with mostly Material elements.
|
||||||
|
|||||||
@@ -22,3 +22,26 @@
|
|||||||
frigate-card-select {
|
frigate-card-select {
|
||||||
padding: 5px;
|
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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+4
-4
@@ -10,10 +10,10 @@ export default defineConfig({
|
|||||||
// Thresholds will automatically be updated as coverage improves to avoid
|
// Thresholds will automatically be updated as coverage improves to avoid
|
||||||
// back-sliding.
|
// back-sliding.
|
||||||
thresholdAutoUpdate: true,
|
thresholdAutoUpdate: true,
|
||||||
statements: 72.76,
|
statements: 73.59,
|
||||||
branches: 61.95,
|
branches: 63.6,
|
||||||
functions: 74.01,
|
functions: 74.86,
|
||||||
lines: 72.66,
|
lines: 73.49,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user