Files
advanced-camera-card/src/components/media-filter.ts
T

397 lines
12 KiB
TypeScript

import sub from 'date-fns/sub';
import endOfDay from 'date-fns/endOfDay';
import endOfYesterday from 'date-fns/endOfYesterday';
import endOfToday from 'date-fns/esm/endOfToday';
import startOfToday from 'date-fns/esm/startOfToday';
import startOfDay from 'date-fns/startOfDay';
import startOfYesterday from 'date-fns/startOfYesterday';
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
ReactiveController,
ReactiveControllerHost,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { CameraManager } from '../camera/manager';
import { DateRange } from '../camera/range';
import { CameraConfig, ExtendedHomeAssistant } from '../types';
import { getCameraTitle } from '../utils/camera';
import { View } from '../view/view';
import {
MediaFilterControls,
MediaFilterCoreFavoriteSelection,
MediaFilterCoreSelection,
MediaFilterCoreWhen,
MediaFilterCoreWhenSelection,
MediaFilterMediaType,
ValueLabel,
} from './media-filter-core';
import './surround.js';
import './timeline-core.js';
import { EventQuery, MediaMetadata, QueryType, RecordingQuery } from '../camera/types';
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
import { createViewForEvents, createViewForRecordings } from '../utils/media-to-view.js';
import { HomeAssistant } from 'custom-card-helpers';
import { errorToConsole, prettifyTitle } from '../utils/basic';
import format from 'date-fns/format';
import parse from 'date-fns/parse';
import endOfMonth from 'date-fns/endOfMonth';
import mediaFilterStyle from '../scss/media-filter.scss';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import uniqWith from 'lodash-es/uniqWith';
import isEqual from 'lodash-es/isEqual';
@customElement('frigate-card-media-filter')
export class FrigateCardMediaFilter extends LitElement {
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public cameras?: Map<string, CameraConfig>;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public view?: View;
@property({ attribute: false })
public mediaLimit?: number;
protected _cameraOptions: ValueLabel<string>[] = [];
protected _mediaMetadataController?: MediaMetadataController;
protected _convertWhenToDateRange(
value?: MediaFilterCoreWhenSelection,
): DateRange | null {
if (!value) {
return null;
}
const now = new Date();
switch (value.selection) {
case MediaFilterCoreWhen.Today:
return { start: startOfToday(), end: endOfToday() };
case MediaFilterCoreWhen.Yesterday:
return { start: startOfYesterday(), end: endOfYesterday() };
case MediaFilterCoreWhen.PastWeek:
return { start: startOfDay(sub(now, { days: 7 })), end: endOfDay(now) };
case MediaFilterCoreWhen.PastMonth:
return { start: startOfDay(sub(now, { months: 1 })), end: endOfDay(now) };
case MediaFilterCoreWhen.Custom:
if (value.custom) {
return value.custom;
}
}
return null;
}
protected _convertFavoriteToBoolean(
value?: MediaFilterCoreFavoriteSelection,
): boolean | null {
if (!value) {
return null;
}
return value === MediaFilterCoreFavoriteSelection.Favorite;
}
protected async _mediaFilterHandler(
ev: CustomEvent<MediaFilterCoreSelection>,
): Promise<void> {
const mediaFilter = ev.detail;
if (
!this.cameras ||
!this.cameraManager ||
!this.hass ||
!this.view ||
!mediaFilter.mediaType
) {
return;
}
const convertedTime = this._convertWhenToDateRange(mediaFilter.when);
const convertedFavorite = this._convertFavoriteToBoolean(mediaFilter.favorite);
const cameraIDs = mediaFilter.cameraIDs ?? new Set(this.cameras.keys());
// A note on views:
// - In the below, if the user selects a camera to view media for, the main
// view camera is also set to that value (e.g. a user browsing the
// gallery, chooses a different camera in the media filter, then
// subsequently chooses the live button -- they would expect the live view
// for that filtered camera not the prior camera).
// - Similarly, if the user chooses clips or snapshots, set the actual view
// to 'clips' or 'snapshots' in order to ensure the right icon is shown as
// selected in the menu.
if (
mediaFilter.mediaType === MediaFilterMediaType.Clips ||
mediaFilter.mediaType === MediaFilterMediaType.Snapshots
) {
const query: EventQuery = {
type: QueryType.Event,
cameraIDs: cameraIDs,
...(mediaFilter.what && { what: mediaFilter.what }),
...(mediaFilter.where && { where: mediaFilter.where }),
...(convertedFavorite !== null && { favorite: convertedFavorite }),
...(convertedTime && { start: convertedTime.start, end: convertedTime.end }),
...(this.mediaLimit && { limit: this.mediaLimit }),
...(mediaFilter.mediaType === MediaFilterMediaType.Clips && { hasClip: true }),
...(mediaFilter.mediaType === MediaFilterMediaType.Snapshots && {
hasSnapshot: true,
}),
};
(
await createViewForEvents(
this,
this.hass,
this.cameraManager,
this.cameras,
this.view,
{
query: new EventMediaQueries([query]),
// See 'A note on views' above for these two arguments.
...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }),
targetView:
mediaFilter.mediaType === MediaFilterMediaType.Clips
? 'clips'
: 'snapshots',
},
)
)?.dispatchChangeEvent(this);
} else if (mediaFilter.mediaType === MediaFilterMediaType.Recordings) {
const query: RecordingQuery = {
type: QueryType.Recording,
cameraIDs: cameraIDs,
...(convertedTime && { start: convertedTime.start, end: convertedTime.end }),
};
(
await createViewForRecordings(
this,
this.hass,
this.cameraManager,
this.cameras,
this.view,
{
query: new RecordingMediaQueries([query]),
// See 'A note on views' above for these two arguments.
...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }),
targetView: 'recordings',
},
)
)?.dispatchChangeEvent(this);
}
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('cameras') && this.cameras) {
this._cameraOptions = Array.from(this.cameras.entries()).map(
([cameraID, cameraConfig]) => ({
value: cameraID,
label: getCameraTitle(this.hass, cameraConfig),
}),
);
}
if (changedProps.has('cameraManager') && this.hass && this.cameraManager) {
this._mediaMetadataController = new MediaMetadataController(
this,
this.hass,
this.cameraManager,
);
}
}
protected _getDefaultsFromView(): MediaFilterCoreSelection | undefined {
if (!this.view) {
return undefined;
}
let mediaType: MediaFilterMediaType | undefined;
let cameraIDs: Set<string> | undefined;
let what: Set<string> | undefined;
let where: Set<string> | undefined;
let favorite: boolean | undefined;
if (MediaQueriesClassifier.areEventQueries(this.view.query)) {
const queries = this.view.query.getQueries();
if (!queries) {
return;
}
const hasClips = uniqWith(
queries.map((query) => query.hasClip),
isEqual,
);
const hasSnapshots = uniqWith(
queries.map((query) => query.hasSnapshot),
isEqual,
);
if (hasClips.length === 1 && hasSnapshots.length === 1) {
mediaType = !!hasClips[0]
? MediaFilterMediaType.Clips
: !!hasSnapshots[0]
? MediaFilterMediaType.Snapshots
: undefined;
}
const cameraIDSets = uniqWith(
queries.map((query) => query.cameraIDs),
isEqual,
);
if (cameraIDSets.length === 1) {
cameraIDs = queries[0].cameraIDs;
}
const whatSets = uniqWith(
queries.map((query) => query.what),
isEqual,
);
if (whatSets.length === 1) {
what = queries[0].what;
}
const whereSets = uniqWith(
queries.map((query) => query.where),
isEqual,
);
if (whereSets.length === 1) {
where = queries[0].where;
}
const favoriteValues = uniqWith(
queries.map((query) => query.favorite),
isEqual,
);
if (favoriteValues.length === 1) {
favorite = queries[0].favorite;
}
}
return {
...(mediaType && { mediaType: mediaType }),
...(cameraIDs && { cameraIDs: cameraIDs }),
...(what && { what: what }),
...(where && { where: where }),
...(favorite !== undefined && {
favorite: favorite
? MediaFilterCoreFavoriteSelection.Favorite
: MediaFilterCoreFavoriteSelection.NotFavorite,
}),
};
}
protected render(): TemplateResult | void {
const areEvents = !!(
this.view?.query && MediaQueriesClassifier.areEventQueries(this.view.query)
);
const areRecordings = !!(
this.view?.query && MediaQueriesClassifier.areRecordingQueries(this.view.query)
);
const managerCapabilities = this.cameraManager?.getCapabilities();
// Which media controls are shown depends on the view.
const controls: MediaFilterControls = {
what: areEvents,
where: areEvents,
favorite: areEvents
? !!managerCapabilities?.canFavoriteEvents
: areRecordings
? !!managerCapabilities?.canFavoriteRecordings
: false,
};
const defaults = this._getDefaultsFromView();
return html` <frigate-card-media-filter-core
.hass=${this.hass}
.whenOptions=${this._mediaMetadataController?.whenOptions}
.cameraOptions=${this._cameraOptions}
.whatOptions=${this._mediaMetadataController?.whatOptions}
.whereOptions=${this._mediaMetadataController?.whereOptions}
.controls=${controls}
.defaults=${defaults}
@frigate-card:media-filter-core:change=${this._mediaFilterHandler.bind(this)}
>
</frigate-card-media-filter-core>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(mediaFilterStyle);
}
}
export class MediaMetadataController implements ReactiveController {
protected _host: ReactiveControllerHost;
protected _hass: HomeAssistant;
protected _cameraManager: CameraManager;
public whenOptions: ValueLabel<MediaFilterCoreWhenSelection>[] = [];
public whatOptions: ValueLabel<string>[] = [];
public whereOptions: ValueLabel<string>[] = [];
constructor(
host: ReactiveControllerHost,
hass: HomeAssistant,
cameraManager: CameraManager,
) {
this._host = host;
this._hass = hass;
this._cameraManager = cameraManager;
host.addController(this);
}
async hostConnected() {
let metadata: MediaMetadata | null;
try {
metadata = await this._cameraManager.getMediaMetadata(this._hass);
} catch (e) {
errorToConsole(e as Error);
return;
}
if (!metadata) {
return;
}
if (metadata.what) {
this.whatOptions = [...metadata.what]
.sort()
.map((what) => ({ value: what, label: prettifyTitle(what) }));
}
if (metadata.where) {
this.whereOptions = [...metadata.where]
.sort()
.map((where) => ({ value: where, label: prettifyTitle(where) }));
}
if (metadata.days) {
const yearMonths: Set<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 = monthStarts
.sort()
.reverse()
.map((monthStart) => ({
label: format(monthStart, 'MMMM yyyy'),
value: {
selection: MediaFilterCoreWhen.Custom,
custom: { start: monthStart, end: endOfMonth(monthStart) },
},
}));
}
this._host.requestUpdate();
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-media-filter': FrigateCardMediaFilter;
}
}