Refactor to avoid use of "all-static" classes.

This commit is contained in:
Dermot Duffy
2022-05-12 21:06:27 -07:00
parent 6b30487229
commit 441162f052
9 changed files with 454 additions and 462 deletions
+2 -2
View File
@@ -84,7 +84,7 @@ import {
shouldUpdateBasedOnHass, shouldUpdateBasedOnHass,
sideLoadHomeAssistantElements sideLoadHomeAssistantElements
} from './utils/ha'; } from './utils/ha';
import { BrowseMediaUtil } from './utils/ha/browse-media.js'; import { getEventID } from './utils/ha/browse-media.js';
import { supportsFeature } from './utils/ha/update.js'; import { supportsFeature } from './utils/ha/update.js';
import { isValidMediaShowInfo } from './utils/media-info.js'; import { isValidMediaShowInfo } from './utils/media-info.js';
import { ResolvedMediaCache } from './utils/resolved-media.js'; import { ResolvedMediaCache } from './utils/resolved-media.js';
@@ -915,7 +915,7 @@ export class FrigateCard extends LitElement {
}); });
return; return;
} }
const event_id = BrowseMediaUtil.getEventID(this._view.media); const event_id = getEventID(this._view.media);
if (!event_id) { if (!event_id) {
this._setMessageAndUpdate({ this._setMessageAndUpdate({
message: localize('error.download_no_event_id'), message: localize('error.download_no_event_id'),
+8 -4
View File
@@ -13,7 +13,11 @@ import { customElement, property } from 'lit/decorators.js';
import galleryStyle from '../scss/gallery.scss'; import galleryStyle from '../scss/gallery.scss';
import { CameraConfig, frigateCardConfigDefaults, GalleryConfig } from '../types.js'; import { CameraConfig, frigateCardConfigDefaults, GalleryConfig } from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { BrowseMediaUtil } from '../utils/ha/browse-media.js'; import {
fetchChildMediaAndDispatchViewChange,
fetchLatestMediaAndDispatchViewChange,
getFullDependentBrowseMediaQueryParametersOrDispatchError
} from '../utils/ha/browse-media';
import { View } from '../view.js'; import { View } from '../view.js';
import { renderProgressIndicator } from './message.js'; import { renderProgressIndicator } from './message.js';
import './thumbnail.js'; import './thumbnail.js';
@@ -51,7 +55,7 @@ export class FrigateCardGallery extends LitElement {
if (!this.view.target) { if (!this.view.target) {
const browseMediaQueryParameters = const browseMediaQueryParameters =
BrowseMediaUtil.getFullDependentBrowseMediaQueryParametersOrDispatchError( getFullDependentBrowseMediaQueryParametersOrDispatchError(
this, this,
this.hass, this.hass,
this.cameras, this.cameras,
@@ -63,7 +67,7 @@ export class FrigateCardGallery extends LitElement {
return; return;
} }
BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange( fetchLatestMediaAndDispatchViewChange(
this, this,
this.hass, this.hass,
this.view, this.view,
@@ -216,7 +220,7 @@ export class FrigateCardGalleryCore extends LitElement {
<ha-card <ha-card
@click=${(ev) => { @click=${(ev) => {
if (this.hass && this.view) { if (this.hass && this.view) {
BrowseMediaUtil.fetchChildMediaAndDispatchViewChange( fetchChildMediaAndDispatchViewChange(
this, this,
this.hass, this.hass,
this.view, this.view,
+2 -2
View File
@@ -38,7 +38,7 @@ import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { contentsChanged } from '../utils/basic.js'; import { contentsChanged } from '../utils/basic.js';
import { getCameraIcon, getCameraTitle } from '../utils/camera.js'; import { getCameraIcon, getCameraTitle } from '../utils/camera.js';
import { homeAssistantSignPath } from '../utils/ha'; import { homeAssistantSignPath } from '../utils/ha';
import { BrowseMediaUtil } from '../utils/ha/browse-media.js'; import { getFullDependentBrowseMediaQueryParameters } from '../utils/ha/browse-media.js';
import { import {
dispatchExistingMediaShowInfoAsEvent, dispatchExistingMediaShowInfoAsEvent,
dispatchMediaShowEvent dispatchMediaShowEvent
@@ -125,7 +125,7 @@ export class FrigateCardLive extends LitElement {
// Does not use getFullDependentBrowseMediaQueryParametersOrDispatchError to // Does not use getFullDependentBrowseMediaQueryParametersOrDispatchError to
// ensure that non-Frigate cameras will work in live view (they will not // ensure that non-Frigate cameras will work in live view (they will not
// have a Frigate camera name). // have a Frigate camera name).
const browseMediaParams = BrowseMediaUtil.getFullDependentBrowseMediaQueryParameters( const browseMediaParams = getFullDependentBrowseMediaQueryParameters(
this.hass, this.hass,
this.cameras, this.cameras,
this.view.camera, this.view.camera,
+6 -6
View File
@@ -10,7 +10,10 @@ import {
ThumbnailsControlConfig ThumbnailsControlConfig
} from '../types.js'; } from '../types.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { BrowseMediaUtil } from '../utils/ha/browse-media.js'; import {
getFirstTrueMediaChildIndex,
multipleBrowseMediaQueryMerged
} from '../utils/ha/browse-media';
import { View } from '../view.js'; import { View } from '../view.js';
import { dispatchErrorMessageEvent } from './message.js'; import { dispatchErrorMessageEvent } from './message.js';
import './surround.js'; import './surround.js';
@@ -68,14 +71,11 @@ export class FrigateCardSurround extends LitElement {
} }
let parent: FrigateBrowseMediaSource | null; let parent: FrigateBrowseMediaSource | null;
try { try {
parent = await BrowseMediaUtil.multipleBrowseMediaQueryMerged( parent = await multipleBrowseMediaQueryMerged(this.hass, browseMediaParams);
this.hass,
browseMediaParams,
);
} catch (e) { } catch (e) {
return dispatchErrorMessageEvent(this, (e as Error).message); return dispatchErrorMessageEvent(this, (e as Error).message);
} }
if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) !== null) { if (getFirstTrueMediaChildIndex(parent) !== null) {
this.view this.view
?.evolve({ ?.evolve({
...(this.targetView && { view: this.targetView }), ...(this.targetView && { view: this.targetView }),
+2 -2
View File
@@ -7,7 +7,7 @@ import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
import type { FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js'; import type { FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
import { BrowseMediaUtil } from '../utils/ha/browse-media.js'; import { isTrueMedia } from '../utils/ha/browse-media';
import { View } from '../view.js'; import { View } from '../view.js';
import { FrigateCardCarousel } from './carousel.js'; import { FrigateCardCarousel } from './carousel.js';
import './thumbnail.js'; import './thumbnail.js';
@@ -154,7 +154,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
if ( if (
!parent.children || !parent.children ||
!parent.children.length || !parent.children.length ||
!BrowseMediaUtil.isTrueMedia(parent.children[childIndex]) !isTrueMedia(parent.children[childIndex])
) { ) {
return; return;
} }
+18 -21
View File
@@ -37,7 +37,12 @@ import {
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { getCameraTitle } from '../utils/camera.js'; import { getCameraTitle } from '../utils/camera.js';
import { BrowseMediaUtil } from '../utils/ha/browse-media.js'; import {
createEventParentForChildren,
getBrowseMediaQueryParameters,
isTrueMedia,
multipleBrowseMediaQuery
} from '../utils/ha/browse-media';
import { View, ViewContext } from '../view'; import { View, ViewContext } from '../view';
import { dispatchErrorMessageEvent, dispatchMessageEvent } from './message.js'; import { dispatchErrorMessageEvent, dispatchMessageEvent } from './message.js';
import './surround-thumbnails.js'; import './surround-thumbnails.js';
@@ -137,7 +142,7 @@ class TimelineEventManager {
const event = child.frigate?.event; const event = child.frigate?.event;
if ( if (
event && event &&
BrowseMediaUtil.isTrueMedia(child) && isTrueMedia(child) &&
['video', 'image'].includes(child.media_content_type) ['video', 'image'].includes(child.media_content_type)
) { ) {
let item = this._dataset.get(event.id); let item = this._dataset.get(event.id);
@@ -282,20 +287,15 @@ class TimelineEventManager {
this._dateStart && this._dateStart &&
cameraConfig.camera_name !== CAMERA_BIRDSEYE cameraConfig.camera_name !== CAMERA_BIRDSEYE
) { ) {
const param = BrowseMediaUtil.getBrowseMediaQueryParameters( const param = getBrowseMediaQueryParameters(hass, cameraID, cameraConfig, {
hass, // Events are always fetched for the maximum extent of the managed
cameraID, // range. This is because events may change at any point in time
cameraConfig, // (e.g. a long-running event that ends).
{ before: this._dateEnd.getTime() / 1000,
// Events are always fetched for the maximum extent of the managed after: this._dateStart.getTime() / 1000,
// range. This is because events may change at any point in time unlimited: true,
// (e.g. a long-running event that ends). mediaType: mediaType as 'clips' | 'snapshots',
before: this._dateEnd.getTime() / 1000, });
after: this._dateStart.getTime() / 1000,
unlimited: true,
mediaType: mediaType as 'clips' | 'snapshots',
},
);
if (param) { if (param) {
params.push(param); params.push(param);
} }
@@ -309,7 +309,7 @@ class TimelineEventManager {
let results: Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource>; let results: Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource>;
try { try {
results = await BrowseMediaUtil.multipleBrowseMediaQuery(hass, params); results = await multipleBrowseMediaQuery(hass, params);
} catch (e) { } catch (e) {
return dispatchErrorMessageEvent(element, (e as Error).message); return dispatchErrorMessageEvent(element, (e as Error).message);
} }
@@ -572,10 +572,7 @@ export class FrigateCardTimelineCore extends LitElement {
return null; return null;
} }
const target = BrowseMediaUtil.createEventParentForChildren( const target = createEventParentForChildren('Timeline events', children);
'Timeline events',
children,
);
return { return {
target: target, target: target,
childIndex: childIndex < 0 ? null : childIndex, childIndex: childIndex < 0 ? null : childIndex,
+28 -22
View File
@@ -28,9 +28,16 @@ import type {
} from '../types.js'; } from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { contentsChanged } from '../utils/basic.js'; import { contentsChanged } from '../utils/basic.js';
import { BrowseMediaUtil } from '../utils/ha/browse-media.js'; import {
fetchLatestMediaAndDispatchViewChange,
getEventStartTime,
getFullDependentBrowseMediaQueryParametersOrDispatchError,
isTrueMedia,
multipleBrowseMediaQueryMerged,
overrideMultiBrowseMediaQueryParameters
} from '../utils/ha/browse-media.js';
import { createMediaShowInfo } from '../utils/media-info.js'; import { createMediaShowInfo } from '../utils/media-info.js';
import { ResolvedMediaCache, ResolvedMediaUtil } from '../utils/resolved-media.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/resolved-media.js';
import { View } from '../view.js'; import { View } from '../view.js';
import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js';
import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js'; import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js';
@@ -66,7 +73,7 @@ export class FrigateCardViewer extends LitElement {
} }
const browseMediaQueryParameters = const browseMediaQueryParameters =
BrowseMediaUtil.getFullDependentBrowseMediaQueryParametersOrDispatchError( getFullDependentBrowseMediaQueryParametersOrDispatchError(
this, this,
this.hass, this.hass,
this.cameras, this.cameras,
@@ -83,14 +90,13 @@ export class FrigateCardViewer extends LitElement {
return; return;
} }
BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange( fetchLatestMediaAndDispatchViewChange(
this, this,
this.hass, this.hass,
this.view, this.view,
BrowseMediaUtil.overrideMultiBrowseMediaQueryParameters( overrideMultiBrowseMediaQueryParameters(browseMediaQueryParameters, {
browseMediaQueryParameters, mediaType: mediaType,
{ mediaType: mediaType }, }),
),
); );
return renderProgressIndicator(); return renderProgressIndicator();
} }
@@ -161,8 +167,8 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
i < (target.children || []).length; i < (target.children || []).length;
++i ++i
) { ) {
if (BrowseMediaUtil.isTrueMedia(target.children[i])) { if (isTrueMedia(target.children[i])) {
await ResolvedMediaUtil.resolveMedia( await resolveMedia(
this.hass, this.hass,
target.children[i], target.children[i],
this.resolvedMediaCache, this.resolvedMediaCache,
@@ -321,7 +327,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
let prevIndex: number | null = null; let prevIndex: number | null = null;
for (let i = this.view.childIndex - 1; i >= 0; i--) { for (let i = this.view.childIndex - 1; i >= 0; i--) {
const media = this.view.target.children[i]; const media = this.view.target.children[i];
if (media && BrowseMediaUtil.isTrueMedia(media)) { if (media && isTrueMedia(media)) {
prevIndex = i; prevIndex = i;
break; break;
} }
@@ -331,7 +337,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
let nextIndex: number | null = null; let nextIndex: number | null = null;
for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) { for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) {
const media = this.view.target.children[i]; const media = this.view.target.children[i];
if (media && BrowseMediaUtil.isTrueMedia(media)) { if (media && isTrueMedia(media)) {
nextIndex = i; nextIndex = i;
break; break;
} }
@@ -365,7 +371,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
return null; return null;
} }
const snapshotStartTime = BrowseMediaUtil.getEventStartTime(snapshot); const snapshotStartTime = getEventStartTime(snapshot);
if (!snapshotStartTime) { if (!snapshotStartTime) {
return null; return null;
} }
@@ -383,10 +389,10 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
let latest: number | null = null; let latest: number | null = null;
for (let i = 0; i < this.view.target.children.length; i++) { for (let i = 0; i < this.view.target.children.length; i++) {
const child = this.view.target.children[i]; const child = this.view.target.children[i];
if (!BrowseMediaUtil.isTrueMedia(child)) { if (!isTrueMedia(child)) {
continue; continue;
} }
const startTime = BrowseMediaUtil.getEventStartTime(child); const startTime = getEventStartTime(child);
if (startTime && (earliest === null || startTime < earliest)) { if (startTime && (earliest === null || startTime < earliest)) {
earliest = startTime; earliest = startTime;
@@ -401,7 +407,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
let clips: FrigateBrowseMediaSource | null; let clips: FrigateBrowseMediaSource | null;
const params = BrowseMediaUtil.overrideMultiBrowseMediaQueryParameters( const params = overrideMultiBrowseMediaQueryParameters(
this.browseMediaQueryParameters, this.browseMediaQueryParameters,
{ {
mediaType: 'clips', mediaType: 'clips',
@@ -411,7 +417,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
); );
try { try {
clips = await BrowseMediaUtil.multipleBrowseMediaQueryMerged(this.hass, params); clips = await multipleBrowseMediaQueryMerged(this.hass, params);
} catch (e) { } catch (e) {
// This is best effort. // This is best effort.
return null; return null;
@@ -423,10 +429,10 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
for (let i = 0; i < clips.children.length; i++) { for (let i = 0; i < clips.children.length; i++) {
const child = clips.children[i]; const child = clips.children[i];
if (!BrowseMediaUtil.isTrueMedia(child)) { if (!isTrueMedia(child)) {
continue; continue;
} }
const clipStartTime = BrowseMediaUtil.getEventStartTime(child); const clipStartTime = getEventStartTime(child);
if (clipStartTime && clipStartTime === snapshotStartTime) { if (clipStartTime && clipStartTime === snapshotStartTime) {
return this.view.evolve({ return this.view.evolve({
view: 'clip', view: 'clip',
@@ -487,12 +493,12 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
!this.view || !this.view ||
!this.view.target || !this.view.target ||
!this.view.target.children || !this.view.target.children ||
!BrowseMediaUtil.isTrueMedia(this.view.target.children[childIndex]) !isTrueMedia(this.view.target.children[childIndex])
) { ) {
return; return;
} }
ResolvedMediaUtil.resolveMedia( resolveMedia(
this.hass, this.hass,
this.view.target.children[childIndex], this.view.target.children[childIndex],
this.resolvedMediaCache, this.resolvedMediaCache,
@@ -664,7 +670,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
!this.hass || !this.hass ||
!this.view || !this.view ||
!this.viewerConfig || !this.viewerConfig ||
!BrowseMediaUtil.isTrueMedia(mediaToRender) || !isTrueMedia(mediaToRender) ||
!['video', 'image'].includes(mediaToRender.media_content_type) !['video', 'image'].includes(mediaToRender.media_content_type)
) { ) {
return; return;
+364 -373
View File
@@ -1,414 +1,405 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { homeAssistantWSRequest } from '.'; import { homeAssistantWSRequest } from '.';
import { import {
dispatchErrorMessageEvent, dispatchErrorMessageEvent,
dispatchMessageEvent dispatchMessageEvent
} from '../../components/message.js'; } from '../../components/message.js';
import { localize } from '../../localize/localize.js'; import { localize } from '../../localize/localize.js';
import { import {
BrowseMediaQueryParameters, BrowseMediaQueryParameters,
CameraConfig, CameraConfig,
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
frigateBrowseMediaSourceSchema, frigateBrowseMediaSourceSchema,
MEDIA_CLASS_PLAYLIST, MEDIA_CLASS_PLAYLIST,
MEDIA_TYPE_PLAYLIST MEDIA_TYPE_PLAYLIST
} from '../../types.js'; } from '../../types.js';
import { View } from '../../view.js'; import { View } from '../../view.js';
import { getCameraTitle } from '../camera.js'; import { getCameraTitle } from '../camera.js';
export class BrowseMediaUtil { /**
/** * Return the Frigate event_id given a FrigateBrowseMediaSource object.
* Return the Frigate event_id given a FrigateBrowseMediaSource object. * @param media The event to get the id from.
* @param media The event to get the id from. * @returns The `event_id` or `null` if not successfully parsed.
* @returns The `event_id` or `null` if not successfully parsed. */
*/ export const getEventID = (media: FrigateBrowseMediaSource): string | null => {
static getEventID(media: FrigateBrowseMediaSource): string | null { return media.frigate?.event.id ?? null;
return media.frigate?.event.id ?? null; };
/**
* Return the event start time given a FrigateBrowseMediaSource object.
* @param browseMedia The media object to get the start time from.
* @returns The start time in unix/epoch time, or null if it cannot be determined.
*/
export const getEventStartTime = (media: FrigateBrowseMediaSource): number | null => {
return media.frigate?.event.start_time ?? null;
};
/**
* Determine if a FrigateBrowseMediaSource object is truly a media item (vs a folder).
* @param media The media object.
* @returns `true` if it's truly a media item, `false` otherwise.
*/
export const isTrueMedia = (media?: FrigateBrowseMediaSource): boolean => {
return !!media && !media.can_expand;
};
/**
* From a FrigateBrowseMediaSource item extract the first true media item from the
* children (i.e. a clip/snapshot, not a folder).
* @param media The media object with children.
* @returns The first true media item found.
*/
export const getFirstTrueMediaChildIndex = (
media: FrigateBrowseMediaSource | null,
): number | null => {
if (!media || !media.children) {
return null;
} }
const index = media.children.findIndex((child) => isTrueMedia(child));
return index >= 0 ? index : null;
};
/** /**
* Return the event start time given a FrigateBrowseMediaSource object. * Browse Frigate media with a media content id. May throw.
* @param browseMedia The media object to get the start time from. * @param hass The HomeAssistant object.
* @returns The start time in unix/epoch time, or null if it cannot be determined. * @param media_content_id The media content id to browse.
*/ * @returns A FrigateBrowseMediaSource object or null on malformed.
static getEventStartTime(media: FrigateBrowseMediaSource): number | null { */
return media.frigate?.event.start_time ?? null; export const browseMedia = async (
} hass: HomeAssistant,
media_content_id: string,
): Promise<FrigateBrowseMediaSource> => {
const request = {
type: 'media_source/browse_media',
media_content_id: media_content_id,
};
return await homeAssistantWSRequest(hass, frigateBrowseMediaSourceSchema, request);
};
/** /**
* Determine if a FrigateBrowseMediaSource object is truly a media item (vs a folder). * Browse Frigate media with a media query. May throw.
* @param media The media object. * @param hass The HomeAssistant object.
* @returns `true` if it's truly a media item, `false` otherwise. * @param params The search parameters to use to search for media.
*/ * @returns A FrigateBrowseMediaSource object or null on malformed.
static isTrueMedia(media?: FrigateBrowseMediaSource): boolean { */
return !!media && !media.can_expand; export const browseMediaQuery = async (
} hass: HomeAssistant,
params: BrowseMediaQueryParameters,
): Promise<FrigateBrowseMediaSource> => {
return browseMedia(
hass,
// Defined in:
// https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py
[
'media-source://frigate',
params.clientId,
'event-search',
params.mediaType,
/** // If the name field ends in '.all' the integration will return up to 10K events.
* From a FrigateBrowseMediaSource item extract the first true media item from the params.unlimited ? '.all' : '',
* children (i.e. a clip/snapshot, not a folder). params.after ? String(Math.floor(params.after)) : '',
* @param media The media object with children. params.before ? String(Math.ceil(params.before)) : '',
* @returns The first true media item found. params.cameraName,
*/ params.label,
static getFirstTrueMediaChildIndex( params.zone,
media: FrigateBrowseMediaSource | null, ].join('/'),
): number | null { );
if (!media || !media.children) { };
return null;
}
const index = media.children.findIndex((child) => this.isTrueMedia(child));
return index >= 0 ? index : null;
}
/** /**
* Browse Frigate media with a media content id. May throw. * Browse multiple Frigate media queries. May throw.
* @param hass The HomeAssistant object. * @param hass The HomeAssistant object.
* @param media_content_id The media content id to browse. * @param params An array of search parameters to use to search for media.
* @returns A FrigateBrowseMediaSource object or null on malformed. * @returns A map of FrigateBrowseMediaSource object or null on malformed.
*/ */
static async browseMedia( export const multipleBrowseMediaQuery = async (
hass: HomeAssistant, hass: HomeAssistant,
media_content_id: string, params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[],
): Promise<FrigateBrowseMediaSource> { ): Promise<Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource>> => {
const request = { params = Array.isArray(params) ? params : [params];
type: 'media_source/browse_media', const output: Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource> = new Map();
media_content_id: media_content_id, await Promise.all(
}; params.map(async (param: BrowseMediaQueryParameters): Promise<void> => {
return await homeAssistantWSRequest(hass, frigateBrowseMediaSourceSchema, request); output.set(param, await browseMediaQuery(hass, param));
} }),
);
return output;
};
/** /**
* Browse Frigate media with a media query. May throw. * Browse multiple Frigate media queries, then merged them. May throw.
* @param hass The HomeAssistant object. * @param hass The HomeAssistant object.
* @param params The search parameters to use to search for media. * @param params An array of search parameters to use to search for media.
* @returns A FrigateBrowseMediaSource object or null on malformed. * @returns A single FrigateBrowseMediaSource object or null on malformed.
*/ */
static async browseMediaQuery( export const multipleBrowseMediaQueryMerged = async (
hass: HomeAssistant, hass: HomeAssistant,
params: BrowseMediaQueryParameters, params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[],
): Promise<FrigateBrowseMediaSource> { ): Promise<FrigateBrowseMediaSource> => {
return this.browseMedia( return mergeFrigateBrowseMediaSources(await multipleBrowseMediaQuery(hass, params));
hass, };
// Defined in:
// https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py
[
'media-source://frigate',
params.clientId,
'event-search',
params.mediaType,
// If the name field ends in '.all' the integration will return up to 10K events. /**
params.unlimited ? '.all' : '', * Merge multiple FrigateBrowseMediaSource into a single. Note that this may
params.after ? String(Math.floor(params.after)) : '', * use information from the query to differentiate results that may otherwise
params.before ? String(Math.ceil(params.before)) : '', * be identical.
params.cameraName, * @param input A map of query -> result.
params.label, * @returns A single FrigateBrowseMediaSource object.
params.zone, */
].join('/'), export const mergeFrigateBrowseMediaSources = async (
); input: Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource>,
} ): Promise<FrigateBrowseMediaSource> => {
const children: FrigateBrowseMediaSource[] = [];
/** for (const [query, result] of input.entries()) {
* Browse multiple Frigate media queries. May throw. for (const child of result.children || []) {
* @param hass The HomeAssistant object. if (isTrueMedia(child)) {
* @param params An array of search parameters to use to search for media. children.push(child);
* @returns A map of FrigateBrowseMediaSource object or null on malformed. } else {
*/ // If there are multiple inputs, separate the folder names with the
static async multipleBrowseMediaQuery( // query title (if available).
hass: HomeAssistant, if (query.title && input.size > 1) {
params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[], children.push({ ...child, title: `[${query.title}] ${child.title}` });
): Promise<Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource>> {
params = Array.isArray(params) ? params : [params];
const output: Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource> = new Map();
await Promise.all(
params.map(async (param: BrowseMediaQueryParameters): Promise<void> => {
output.set(param, await this.browseMediaQuery(hass, param));
}),
);
return output;
}
/**
* Browse multiple Frigate media queries, then merged them. May throw.
* @param hass The HomeAssistant object.
* @param params An array of search parameters to use to search for media.
* @returns A single FrigateBrowseMediaSource object or null on malformed.
*/
static async multipleBrowseMediaQueryMerged(
hass: HomeAssistant,
params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[],
): Promise<FrigateBrowseMediaSource> {
return this.mergeFrigateBrowseMediaSources(
await this.multipleBrowseMediaQuery(hass, params),
);
}
/**
* Merge multiple FrigateBrowseMediaSource into a single. Note that this may
* use information from the query to differentiate results that may otherwise
* be identical.
* @param input A map of query -> result.
* @returns A single FrigateBrowseMediaSource object.
*/
static mergeFrigateBrowseMediaSources(
input: Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource>,
): FrigateBrowseMediaSource {
const children: FrigateBrowseMediaSource[] = [];
for (const [query, result] of input.entries()) {
for (const child of result.children || []) {
if (this.isTrueMedia(child)) {
children.push(child);
} else { } else {
// If there are multiple inputs, separate the folder names with the children.push(child);
// query title (if available).
if (query.title && input.size > 1) {
children.push({ ...child, title: `[${query.title}] ${child.title}` });
} else {
children.push(child);
}
} }
} }
} }
const eventSort = (
a: FrigateBrowseMediaSource,
b: FrigateBrowseMediaSource,
): number => {
if (
!a.frigate?.event ||
(b.frigate?.event && b.frigate.event.start_time > a.frigate.event.start_time)
) {
return 1;
}
if (
!b.frigate?.event ||
(a.frigate?.event && b.frigate.event.start_time < a.frigate.event.start_time)
) {
return -1;
}
return 0;
};
return this.createEventParentForChildren('Merged events', children.sort(eventSort));
} }
/** const eventSort = (
* Get the parameters to search for media. a: FrigateBrowseMediaSource,
* @returns A BrowseMediaQueryParameters object. b: FrigateBrowseMediaSource,
*/ ): number => {
static getBrowseMediaQueryParameters( if (
hass: HomeAssistant, !a.frigate?.event ||
cameraID: string, (b.frigate?.event && b.frigate.event.start_time > a.frigate.event.start_time)
cameraConfig?: CameraConfig, ) {
overrides?: Partial<BrowseMediaQueryParameters>, return 1;
): BrowseMediaQueryParameters | null {
if (!cameraConfig || !cameraConfig.camera_name) {
return null;
} }
return {
clientId: cameraConfig.client_id,
cameraName: cameraConfig.camera_name,
label: cameraConfig.label,
zone: cameraConfig.zone,
title: getCameraTitle(hass, cameraConfig),
cameraID: cameraID,
...overrides,
};
}
/** if (
* Apply overrides to multiple query parameters. !b.frigate?.event ||
* @param parameters An array of query parameters. (a.frigate?.event && b.frigate.event.start_time < a.frigate.event.start_time)
* @param overrides The overrides to apply. ) {
* @returns The override query parameters. return -1;
*/ }
static overrideMultiBrowseMediaQueryParameters( return 0;
parameters: BrowseMediaQueryParameters[], };
overrides: Partial<BrowseMediaQueryParameters>,
): BrowseMediaQueryParameters[] {
const output: BrowseMediaQueryParameters[] = [];
parameters.forEach((param) => {
output.push({ ...param, ...overrides });
});
return output;
}
/** return createEventParentForChildren('Merged events', children.sort(eventSort));
* Get BrowseMediaQueryParameters for a camera (including its dependencies). };
* @param hass Home Assistant object.
* @param cameras Cameras map. /**
* @param camera Name of the current camera. * Get the parameters to search for media.
* @param mediaType Optional media type to include in the parameters. * @returns A BrowseMediaQueryParameters object.
* @returns An array of query parameters. */
*/ export const getBrowseMediaQueryParameters = (
static getFullDependentBrowseMediaQueryParameters( hass: HomeAssistant,
hass: HomeAssistant, cameraID: string,
cameras: Map<string, CameraConfig>, cameraConfig?: CameraConfig,
camera: string, overrides?: Partial<BrowseMediaQueryParameters>,
mediaType?: 'clips' | 'snapshots', ): BrowseMediaQueryParameters | null => {
): BrowseMediaQueryParameters[] | null { if (!cameraConfig || !cameraConfig.camera_name) {
const cameraIDs: Set<string> = new Set(); return null;
const getDependentCameras = (camera: string): void => { }
const cameraConfig = cameras.get(camera); return {
if (cameraConfig) { clientId: cameraConfig.client_id,
cameraIDs.add(camera); cameraName: cameraConfig.camera_name,
for (const eventCameraID of cameraConfig.dependent_cameras || []) { label: cameraConfig.label,
if (!cameraIDs.has(eventCameraID)) { zone: cameraConfig.zone,
getDependentCameras(eventCameraID); title: getCameraTitle(hass, cameraConfig),
} cameraID: cameraID,
...overrides,
};
};
/**
* Apply overrides to multiple query parameters.
* @param parameters An array of query parameters.
* @param overrides The overrides to apply.
* @returns The override query parameters.
*/
export const overrideMultiBrowseMediaQueryParameters = (
parameters: BrowseMediaQueryParameters[],
overrides: Partial<BrowseMediaQueryParameters>,
): BrowseMediaQueryParameters[] => {
const output: BrowseMediaQueryParameters[] = [];
parameters.forEach((param) => {
output.push({ ...param, ...overrides });
});
return output;
};
/**
* Get BrowseMediaQueryParameters for a camera (including its dependencies).
* @param hass Home Assistant object.
* @param cameras Cameras map.
* @param camera Name of the current camera.
* @param mediaType Optional media type to include in the parameters.
* @returns An array of query parameters.
*/
export const getFullDependentBrowseMediaQueryParameters = (
hass: HomeAssistant,
cameras: Map<string, CameraConfig>,
camera: string,
mediaType?: 'clips' | 'snapshots',
): BrowseMediaQueryParameters[] | null => {
const cameraIDs: Set<string> = new Set();
const getDependentCameras = (camera: string): void => {
const cameraConfig = cameras.get(camera);
if (cameraConfig) {
cameraIDs.add(camera);
for (const eventCameraID of cameraConfig.dependent_cameras || []) {
if (!cameraIDs.has(eventCameraID)) {
getDependentCameras(eventCameraID);
} }
} }
};
getDependentCameras(camera);
const params: BrowseMediaQueryParameters[] = [];
for (const cameraID of cameraIDs) {
const param = BrowseMediaUtil.getBrowseMediaQueryParameters(
hass,
cameraID,
cameras.get(cameraID),
mediaType ? { mediaType: mediaType } : {},
);
// Fail on a single bad camera, as it's almost certainly a user error that
// should be fixed rather than hidden.
if (!param) {
return null;
}
params.push(param);
} }
return params.length ? params : null; };
} getDependentCameras(camera);
/** const params: BrowseMediaQueryParameters[] = [];
* Get BrowseMediaQueryParameters for a camera (including its dependencies) or dispatch an error. for (const cameraID of cameraIDs) {
* @param element The element from which to dispatch the error. const param = getBrowseMediaQueryParameters(
* @param hass Home Assistant object.
* @param cameras Cameras map.
* @param camera Name of the current camera.
* @param mediaType Optional media type to include in the parameters.
* @returns An array of query parameters.
*/
static getFullDependentBrowseMediaQueryParametersOrDispatchError(
element: HTMLElement,
hass: HomeAssistant,
cameras: Map<string, CameraConfig>,
camera: string,
mediaType?: 'clips' | 'snapshots',
): BrowseMediaQueryParameters[] | null {
const params = this.getFullDependentBrowseMediaQueryParameters(
hass, hass,
cameras, cameraID,
camera, cameras.get(cameraID),
mediaType, mediaType ? { mediaType: mediaType } : {},
); );
if (!params) { // Fail on a single bad camera, as it's almost certainly a user error that
dispatchErrorMessageEvent( // should be fixed rather than hidden.
element, if (!param) {
localize('error.no_camera_name'),
cameras.get(camera),
);
return null; return null;
} }
return params; params.push(param);
}
return params.length ? params : null;
};
/**
* Get BrowseMediaQueryParameters for a camera (including its dependencies) or dispatch an error.
* @param element The element from which to dispatch the error.
* @param hass Home Assistant object.
* @param cameras Cameras map.
* @param camera Name of the current camera.
* @param mediaType Optional media type to include in the parameters.
* @returns An array of query parameters.
*/
export const getFullDependentBrowseMediaQueryParametersOrDispatchError = (
element: HTMLElement,
hass: HomeAssistant,
cameras: Map<string, CameraConfig>,
camera: string,
mediaType?: 'clips' | 'snapshots',
): BrowseMediaQueryParameters[] | null => {
const params = getFullDependentBrowseMediaQueryParameters(
hass,
cameras,
camera,
mediaType,
);
if (!params) {
dispatchErrorMessageEvent(
element,
localize('error.no_camera_name'),
cameras.get(camera),
);
return null;
}
return params;
};
/**
* Fetch the latest media and dispatch a change view event to reflect the
* results. If no media is found a suitable message event will be triggered
* instead.
* @param element The HTMLElement to dispatch events from.
* @param hass The Home Assistant object.
* @param view The current view to evolve.
* @param browseMediaQueryParameters The media parameters to query with.
* @returns
*/
export const fetchLatestMediaAndDispatchViewChange = async (
element: HTMLElement,
hass: HomeAssistant,
view: Readonly<View>,
browseMediaQueryParameters: BrowseMediaQueryParameters | BrowseMediaQueryParameters[],
): Promise<void> => {
let parent: FrigateBrowseMediaSource | null;
try {
parent = await multipleBrowseMediaQueryMerged(hass, browseMediaQueryParameters);
} catch (e) {
return dispatchErrorMessageEvent(element, (e as Error).message);
}
const childIndex = getFirstTrueMediaChildIndex(parent);
if (!parent || !parent.children || childIndex == null) {
return dispatchMessageEvent(
element,
view.isClipRelatedView()
? localize('common.no_clip')
: localize('common.no_snapshot'),
view.isClipRelatedView() ? 'mdi:filmstrip-off' : 'mdi:camera-off',
);
} }
/** view
* Fetch the latest media and dispatch a change view event to reflect the .evolve({
* results. If no media is found a suitable message event will be triggered target: parent,
* instead. childIndex: childIndex,
* @param element The HTMLElement to dispatch events from. })
* @param hass The Home Assistant object. .dispatchChangeEvent(element);
* @param view The current view to evolve. };
* @param browseMediaQueryParameters The media parameters to query with.
* @returns
*/
static async fetchLatestMediaAndDispatchViewChange(
element: HTMLElement,
hass: HomeAssistant,
view: Readonly<View>,
browseMediaQueryParameters:
| BrowseMediaQueryParameters
| BrowseMediaQueryParameters[],
): Promise<void> {
let parent: FrigateBrowseMediaSource | null;
try {
parent = await BrowseMediaUtil.multipleBrowseMediaQueryMerged(
hass,
browseMediaQueryParameters,
);
} catch (e) {
return dispatchErrorMessageEvent(element, (e as Error).message);
}
const childIndex = BrowseMediaUtil.getFirstTrueMediaChildIndex(parent);
if (!parent || !parent.children || childIndex == null) {
return dispatchMessageEvent(
element,
view.isClipRelatedView()
? localize('common.no_clip')
: localize('common.no_snapshot'),
view.isClipRelatedView() ? 'mdi:filmstrip-off' : 'mdi:camera-off',
);
}
view /**
.evolve({ * Fetch the media of a child FrigateBrowseMediaSource object and dispatch a change
target: parent, * view event to reflect the results.
childIndex: childIndex, * @param node The HTMLElement to dispatch events from.
}) * @param hass The Home Assistant object.
.dispatchChangeEvent(element); * @param view The current view to evolve.
* @param child The FrigateBrowseMediaSource child to query for.
* @returns
*/
export const fetchChildMediaAndDispatchViewChange = async (
element: HTMLElement,
hass: HomeAssistant,
view: Readonly<View>,
child: Readonly<FrigateBrowseMediaSource>,
): Promise<void> => {
let parent: FrigateBrowseMediaSource;
try {
parent = await browseMedia(hass, child.media_content_id);
} catch (e) {
return dispatchErrorMessageEvent(element, (e as Error).message);
} }
/** view
* Fetch the media of a child FrigateBrowseMediaSource object and dispatch a change .evolve({
* view event to reflect the results. target: parent,
* @param node The HTMLElement to dispatch events from. })
* @param hass The Home Assistant object. .dispatchChangeEvent(element);
* @param view The current view to evolve. };
* @param child The FrigateBrowseMediaSource child to query for.
* @returns
*/
static async fetchChildMediaAndDispatchViewChange(
element: HTMLElement,
hass: HomeAssistant,
view: Readonly<View>,
child: Readonly<FrigateBrowseMediaSource>,
): Promise<void> {
let parent: FrigateBrowseMediaSource;
try {
parent = await BrowseMediaUtil.browseMedia(hass, child.media_content_id);
} catch (e) {
return dispatchErrorMessageEvent(element, (e as Error).message);
}
view /**
.evolve({ * Given an array of media children, create a parent for them.
target: parent, * @param title The title to use for the parent.
}) * @param children The children media items.
.dispatchChangeEvent(element); * @returns A single parent containing the children.
} */
export const createEventParentForChildren = (
/** title: string,
* Given an array of media children, create a parent for them. children: FrigateBrowseMediaSource[],
* @param title The title to use for the parent. ): FrigateBrowseMediaSource => {
* @param children The children media items. return {
* @returns A single parent containing the children. title: title,
*/ media_class: MEDIA_CLASS_PLAYLIST,
static createEventParentForChildren( media_content_type: MEDIA_TYPE_PLAYLIST,
title: string, media_content_id: '',
children: FrigateBrowseMediaSource[], can_play: false,
): FrigateBrowseMediaSource { can_expand: true,
return { children_media_class: MEDIA_CLASS_PLAYLIST,
title: title, thumbnail: null,
media_class: MEDIA_CLASS_PLAYLIST, children: children,
media_content_type: MEDIA_TYPE_PLAYLIST, };
media_content_id: '', };
can_play: false,
can_expand: true,
children_media_class: MEDIA_CLASS_PLAYLIST,
thumbnail: null,
children: children,
};
}
}
+24 -30
View File
@@ -1,9 +1,9 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import QuickLRU from 'quick-lru'; import QuickLRU from 'quick-lru';
import { import {
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
ResolvedMedia, ResolvedMedia,
resolvedMediaSchema resolvedMediaSchema
} from '../types.js'; } from '../types.js';
import { homeAssistantWSRequest } from './ha'; import { homeAssistantWSRequest } from './ha';
@@ -34,31 +34,25 @@ export class ResolvedMediaCache {
} }
} }
export class ResolvedMediaUtil { export const resolveMedia = async (
static async resolveMedia( hass: HomeAssistant,
hass: HomeAssistant, mediaSource?: FrigateBrowseMediaSource,
mediaSource?: FrigateBrowseMediaSource, cache?: ResolvedMediaCache,
cache?: ResolvedMediaCache, ): Promise<ResolvedMedia | null> => {
): Promise<ResolvedMedia | null> { if (!mediaSource) {
if (!mediaSource) { return null;
return null;
}
const cachedValue = cache ? cache.get(mediaSource.media_content_id) : undefined;
if (cachedValue) {
return cachedValue;
}
const request = {
type: 'media_source/resolve_media',
media_content_id: mediaSource.media_content_id,
};
const resolvedMedia = await homeAssistantWSRequest(
hass,
resolvedMediaSchema,
request,
);
if (cache && resolvedMedia) {
cache.set(mediaSource.media_content_id, resolvedMedia);
}
return resolvedMedia;
} }
} const cachedValue = cache ? cache.get(mediaSource.media_content_id) : undefined;
if (cachedValue) {
return cachedValue;
}
const request = {
type: 'media_source/resolve_media',
media_content_id: mediaSource.media_content_id,
};
const resolvedMedia = await homeAssistantWSRequest(hass, resolvedMediaSchema, request);
if (cache && resolvedMedia) {
cache.set(mediaSource.media_content_id, resolvedMedia);
}
return resolvedMedia;
};