Initial motionEye commit.
This commit is contained in:
@@ -196,3 +196,7 @@ export const isSuperset = (superset: Set<unknown>, subset: Set<unknown>) => {
|
||||
export const sleep = async (seconds: number) => {
|
||||
await new Promise((r) => setTimeout(r, seconds * 1000));
|
||||
};
|
||||
|
||||
export const isValidDate = (date: Date): boolean => {
|
||||
return !isNaN(date.getTime());
|
||||
}
|
||||
+22
-13
@@ -10,23 +10,32 @@ export const downloadMedia = async (
|
||||
cameraManager: CameraManager,
|
||||
media: ViewMedia,
|
||||
): Promise<void> => {
|
||||
const path = cameraManager.getMediaDownloadPath(media);
|
||||
if (!path) {
|
||||
const download = await cameraManager.getMediaDownloadPath(hass, media);
|
||||
if (!download) {
|
||||
throw new FrigateCardError(localize('error.download_no_media'));
|
||||
}
|
||||
|
||||
let response: string | null | undefined;
|
||||
try {
|
||||
response = await homeAssistantSignPath(hass, path);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
let finalURL = download.endpoint;
|
||||
if (download.sign) {
|
||||
let response: string | null | undefined;
|
||||
try {
|
||||
response = await homeAssistantSignPath(hass, download.endpoint);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
throw new FrigateCardError(localize('error.download_sign_failed'));
|
||||
}
|
||||
finalURL = response;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
throw new FrigateCardError(localize('error.download_sign_failed'));
|
||||
}
|
||||
// The download attribute only works on the same origin.
|
||||
// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attributes
|
||||
const isSameOrigin = new URL(finalURL).origin === window.location.origin;
|
||||
|
||||
if (
|
||||
!isSameOrigin ||
|
||||
navigator.userAgent.startsWith('Home Assistant/') ||
|
||||
navigator.userAgent.startsWith('HomeAssistant/')
|
||||
) {
|
||||
@@ -36,13 +45,13 @@ export const downloadMedia = async (
|
||||
// User-agents are specified here:
|
||||
// - Android: https://github.com/home-assistant/android/blob/master/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt#L107
|
||||
// - iOS: https://github.com/home-assistant/iOS/blob/master/Sources/Shared/API/HAAPI.swift#L75
|
||||
window.open(response, '_blank');
|
||||
window.open(finalURL, '_blank');
|
||||
} else {
|
||||
// Use the HTML5 download attribute to prevent a new window from
|
||||
// temporarily opening.
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('download', '');
|
||||
link.href = response;
|
||||
link.setAttribute('download', 'download');
|
||||
link.href = finalURL;
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
+12
-13
@@ -11,23 +11,22 @@ export const getEndpointAddressOrDispatchError = async (
|
||||
endpoint: CameraEndpoint,
|
||||
expires?: number,
|
||||
): Promise<string | null> => {
|
||||
let address: string | null;
|
||||
if (!endpoint.sign) {
|
||||
address = endpoint.endpoint;
|
||||
} else {
|
||||
let response: string | null | undefined;
|
||||
try {
|
||||
response = await homeAssistantSignPath(hass, endpoint.endpoint, expires);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return null;
|
||||
}
|
||||
address = response ? response.replace(/^http/i, 'ws') : null;
|
||||
return endpoint.endpoint;
|
||||
}
|
||||
|
||||
if (!address) {
|
||||
let response: string | null | undefined;
|
||||
try {
|
||||
response = await homeAssistantSignPath(hass, endpoint.endpoint, expires);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
dispatchErrorMessageEvent(element, localize('error.failed_sign'));
|
||||
return null;
|
||||
}
|
||||
return address;
|
||||
|
||||
return response.replace(/^http/i, 'ws');
|
||||
};
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import add from 'date-fns/add';
|
||||
import { homeAssistantWSRequest } from '..';
|
||||
import { MemoryRequestCache } from '../../../camera-manager/cache';
|
||||
import { allPromises } from '../../basic';
|
||||
import {
|
||||
BrowseMedia,
|
||||
browseMediaSchema,
|
||||
BROWSE_MEDIA_CACHE_SECONDS,
|
||||
RichBrowseMedia,
|
||||
} from './types';
|
||||
|
||||
type BrowseMediaCache<M> = MemoryRequestCache<string, RichBrowseMedia<M>>;
|
||||
type RichMetadataGenerator<M> = (
|
||||
media: BrowseMedia,
|
||||
parent?: RichBrowseMedia<M>,
|
||||
) => M | null;
|
||||
|
||||
export type BrowseMediaTarget<M> = string | RichBrowseMedia<M>;
|
||||
type RichBrowseMediaPredicate<M> = (media: RichBrowseMedia<M>) => boolean;
|
||||
|
||||
export interface BrowseMediaStep<M> {
|
||||
// The targets to start the media walk from.
|
||||
targets: BrowseMediaTarget<M>[];
|
||||
|
||||
// All children of the target have the metadata generator applied to them
|
||||
// first.
|
||||
metadataGenerator?: RichMetadataGenerator<M>;
|
||||
|
||||
// If those children pass this matcher, then they will be included in the
|
||||
// output.
|
||||
matcher: RichBrowseMediaPredicate<M>;
|
||||
|
||||
// advance will be called to generate a next step (or null if the child should
|
||||
// just be included straight through to the output with no further steps).
|
||||
advance?: BrowseMediaStepAdvancer<M>;
|
||||
}
|
||||
|
||||
type BrowseMediaStepAdvancer<M> = (media: RichBrowseMedia<M>[]) => BrowseMediaStep<M>[];
|
||||
|
||||
export class BrowseMediaManager<M> {
|
||||
protected _cache: BrowseMediaCache<M>;
|
||||
|
||||
constructor(cache: BrowseMediaCache<M>) {
|
||||
this._cache = cache;
|
||||
}
|
||||
|
||||
// Walk down a browse media tree according to instructions included in `steps`.
|
||||
public async walkBrowseMedias(
|
||||
hass: HomeAssistant,
|
||||
steps: BrowseMediaStep<M>[] | null,
|
||||
options?: {
|
||||
useCache?: boolean;
|
||||
},
|
||||
): Promise<RichBrowseMedia<M>[]> {
|
||||
if (!steps || !steps.length) {
|
||||
return [];
|
||||
}
|
||||
return (
|
||||
await allPromises(
|
||||
steps,
|
||||
async (step) => await this._walkBrowseMedia(hass, step, options),
|
||||
)
|
||||
).flat();
|
||||
}
|
||||
|
||||
protected async _walkBrowseMedia(
|
||||
hass: HomeAssistant,
|
||||
step: BrowseMediaStep<M>,
|
||||
options?: {
|
||||
useCache?: boolean;
|
||||
},
|
||||
): Promise<RichBrowseMedia<M>[]> {
|
||||
const media = await allPromises(
|
||||
step.targets,
|
||||
async (target) =>
|
||||
await this._browseMedia(hass, target, {
|
||||
useCache: options?.useCache,
|
||||
metadataGenerator: step.metadataGenerator,
|
||||
}),
|
||||
);
|
||||
|
||||
const newTargets: RichBrowseMedia<M>[] = [];
|
||||
for (const parent of media) {
|
||||
for (const child of parent.children ?? []) {
|
||||
if (step.matcher(child)) {
|
||||
newTargets.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nextSteps = step.advance ? step.advance(newTargets) : null;
|
||||
if (!nextSteps || !nextSteps.length) {
|
||||
return newTargets;
|
||||
}
|
||||
|
||||
const targetsIncludedInNextSteps = new Set(
|
||||
nextSteps.map((nextStep) => nextStep.targets).flat(),
|
||||
);
|
||||
const finished: RichBrowseMedia<M>[] = [];
|
||||
|
||||
// Any new target that doesn't have a proposed 'next step' is assumed to be
|
||||
// ready to return.
|
||||
for (const target of newTargets) {
|
||||
if (!targetsIncludedInNextSteps.has(target)) {
|
||||
finished.push(target);
|
||||
}
|
||||
}
|
||||
|
||||
const downstream = await this.walkBrowseMedias(hass, nextSteps, options);
|
||||
return finished.concat(downstream);
|
||||
}
|
||||
|
||||
protected async _browseMedia(
|
||||
hass: HomeAssistant,
|
||||
target: string | RichBrowseMedia<M>,
|
||||
options?: {
|
||||
useCache?: boolean;
|
||||
metadataGenerator?: RichMetadataGenerator<M>;
|
||||
},
|
||||
): Promise<RichBrowseMedia<M>> {
|
||||
const mediaContentID = typeof target === 'object' ? target.media_content_id : target;
|
||||
const cachedResult =
|
||||
options?.useCache ?? true ? this._cache.get(mediaContentID) : null;
|
||||
if (cachedResult) {
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const request = {
|
||||
type: 'media_source/browse_media',
|
||||
media_content_id: mediaContentID,
|
||||
};
|
||||
const browseMedia = (await homeAssistantWSRequest(
|
||||
hass,
|
||||
browseMediaSchema,
|
||||
request,
|
||||
)) as RichBrowseMedia<M>;
|
||||
|
||||
if (options?.metadataGenerator) {
|
||||
for (const child of browseMedia.children ?? []) {
|
||||
child._metadata =
|
||||
options.metadataGenerator(
|
||||
child,
|
||||
typeof target === 'object' ? target : undefined,
|
||||
) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.useCache ?? true) {
|
||||
this._cache.set(
|
||||
mediaContentID,
|
||||
browseMedia,
|
||||
add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }),
|
||||
);
|
||||
}
|
||||
return browseMedia;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Recursive type, cannot use type interference:
|
||||
// See: https://github.com/colinhacks/zod#recursive-types
|
||||
//
|
||||
// Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_player/browse_media.py#L90
|
||||
export interface BrowseMedia {
|
||||
title: string;
|
||||
media_class: string;
|
||||
media_content_type: string;
|
||||
media_content_id: string;
|
||||
can_play: boolean;
|
||||
can_expand: boolean;
|
||||
children_media_class?: string | null;
|
||||
thumbnail: string | null;
|
||||
children?: BrowseMedia[] | null;
|
||||
}
|
||||
|
||||
export const browseMediaSchema: z.ZodSchema<BrowseMedia> = z.lazy(() =>
|
||||
z.object({
|
||||
title: z.string(),
|
||||
media_class: z.string(),
|
||||
media_content_type: z.string(),
|
||||
media_content_id: z.string(),
|
||||
can_play: z.boolean(),
|
||||
can_expand: z.boolean(),
|
||||
children_media_class: z.string().nullable().optional(),
|
||||
thumbnail: z.string().nullable(),
|
||||
children: z.array(browseMediaSchema).nullable().optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
export interface RichBrowseMedia<M> extends BrowseMedia {
|
||||
_metadata?: M;
|
||||
children?: RichBrowseMedia<M>[] | null;
|
||||
}
|
||||
|
||||
export const MEDIA_CLASS_VIDEO = 'video' as const;
|
||||
export const MEDIA_CLASS_IMAGE = 'image' as const;
|
||||
|
||||
export const BROWSE_MEDIA_CACHE_SECONDS = 60 as const;
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
||||
|
||||
export const entitySchema = z.object({
|
||||
config_entry_id: z.string().nullable(),
|
||||
device_id: z.string().nullable(),
|
||||
disabled_by: z.string().nullable(),
|
||||
entity_id: z.string(),
|
||||
hidden_by: z.string().nullable(),
|
||||
|
||||
@@ -354,12 +354,13 @@ export const isCardInPanel = (card: HTMLElement): boolean => {
|
||||
* location will be the Chromecast receiver, not HA).
|
||||
* @param url The media URL
|
||||
*/
|
||||
export const canonicalizeHAURL = (
|
||||
export function canonicalizeHAURL(hass: ExtendedHomeAssistant, url: string): string;
|
||||
export function canonicalizeHAURL(
|
||||
hass: ExtendedHomeAssistant,
|
||||
url?: string,
|
||||
): string | null => {
|
||||
): string | null {
|
||||
if (hass && url && url.startsWith('/')) {
|
||||
return hass.hassUrl(url);
|
||||
}
|
||||
return url ?? null;
|
||||
};
|
||||
}
|
||||
|
||||
+8
-6
@@ -31,20 +31,22 @@ export const hideMediaControlsTemporarily = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Play a piece of media, muting it if necessary.
|
||||
* @param underlyingPlayer
|
||||
*
|
||||
* @param player The Frigate Card Media Player object.
|
||||
* @param video An underlying video or media player upon which to call play.
|
||||
*/
|
||||
export const playMediaMutingIfNecessary = async (
|
||||
player?: FrigateCardMediaPlayer,
|
||||
player: FrigateCardMediaPlayer,
|
||||
video?: HTMLVideoElement | FrigateCardMediaPlayer,
|
||||
): Promise<void> => {
|
||||
// If the play call fails, and the media is not already muted, mute it first
|
||||
// and then try again. This works around some browsers that prevent
|
||||
// auto-play unless the video is muted.
|
||||
if (player?.play) {
|
||||
player.play().catch((ev) => {
|
||||
if (video?.play) {
|
||||
video.play().catch((ev) => {
|
||||
if (ev.name === 'NotAllowedError' && !player.isMuted()) {
|
||||
player.mute();
|
||||
player.play().catch();
|
||||
video.play().catch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+20
-18
@@ -2,6 +2,11 @@ import { Task } from '@lit-labs/task';
|
||||
import { ReactiveControllerHost } from '@lit/reactive-element';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
|
||||
// See: https://github.com/sindresorhus/is-absolute-url
|
||||
// Scheme: https://tools.ietf.org/html/rfc3986#section-3.1
|
||||
// Absolute URL: https://tools.ietf.org/html/rfc3986#section-4.3
|
||||
const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*?:/;
|
||||
|
||||
/**
|
||||
* Fetch a thumbnail URL and return a data URL.
|
||||
* @param hass Home Assistant object.
|
||||
@@ -12,10 +17,10 @@ const fetchThumbnail = async (
|
||||
hass: HomeAssistant,
|
||||
thumbnailURL: string,
|
||||
): Promise<string | null> => {
|
||||
if (!hass) {
|
||||
if (!hass || !thumbnailURL) {
|
||||
return null;
|
||||
}
|
||||
if (thumbnailURL?.startsWith('data:')) {
|
||||
if (thumbnailURL.startsWith('data:') || thumbnailURL.match(ABSOLUTE_URL_REGEX)) {
|
||||
return thumbnailURL;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -57,21 +62,18 @@ export const createFetchThumbnailTask = (
|
||||
getThumbnailURL: () => string | undefined,
|
||||
autoRun = true,
|
||||
): Task<FetchThumbnailTaskArgs, string | null> => {
|
||||
return new Task(
|
||||
host,
|
||||
{
|
||||
// Do not re-run the task if hass changes, unless it was previously undefined.
|
||||
args: (): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()],
|
||||
task: async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise<
|
||||
string | null
|
||||
> => {
|
||||
const hass = getHASS();
|
||||
if (!haveHASS || !hass || !thumbnailURL) {
|
||||
return null;
|
||||
}
|
||||
return fetchThumbnail(hass, thumbnailURL);
|
||||
},
|
||||
autoRun: autoRun,
|
||||
return new Task(host, {
|
||||
// Do not re-run the task if hass changes, unless it was previously undefined.
|
||||
args: (): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()],
|
||||
task: async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise<
|
||||
string | null
|
||||
> => {
|
||||
const hass = getHASS();
|
||||
if (!haveHASS || !hass || !thumbnailURL) {
|
||||
return null;
|
||||
}
|
||||
return fetchThumbnail(hass, thumbnailURL);
|
||||
},
|
||||
);
|
||||
autoRun: autoRun,
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user