Use media_browser websockets.

This commit is contained in:
Dermot Duffy
2021-08-28 20:56:06 -07:00
parent abde9bacda
commit b1ab849981
4 changed files with 299 additions and 189 deletions
+16 -9
View File
@@ -145,12 +145,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
})}
</paper-listbox>
</paper-dropdown-menu>
<paper-input
label="Frigate URL (Required)"
.value=${this._config?.frigate_url || ''}
.configValue=${'frigate_url'}
@value-changed=${this._valueChanged}
></paper-input>
</div>
`
: ''}
@@ -221,6 +215,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
})}
</paper-listbox>
</paper-dropdown-menu>
<paper-input
label="Frigate client id (Optional, for >1 Frigate server)"
.value=${this._config?.frigate_client_id || ''}
.configValue=${'frigate_client_id'}
@value-changed=${this._valueChanged}
></paper-input>
<paper-input
label="View timeout (seconds)"
prevent-invalid-input
@@ -231,6 +231,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
.configValue=${'view_timeout'}
@value-changed=${this._valueChanged}
></paper-input>
<paper-input
label="Frigate URL (Optional, for Frigate UI button)"
.value=${this._config?.frigate_url || ''}
.configValue=${'frigate_url'}
@value-changed=${this._valueChanged}
></paper-input>
<ha-formfield .label=${`Autoplay latest clip`}>
<ha-switch
.checked=${this._config?.autoplay_clip === true}
@@ -341,6 +347,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
return;
}
const target = ev.target;
const value = target.value?.trim();
let key: string = target.configValue;
if (!key) {
@@ -361,12 +368,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
key = parts[1];
}
if (target.value !== undefined && objectTarget[key] === target.value) {
if (value !== undefined && objectTarget[key] === value) {
return;
} else if (target.value === '') {
} else if (value === '') {
delete objectTarget[key];
} else {
objectTarget[key] = target.checked !== undefined ? target.checked : target.value;
objectTarget[key] = target.checked !== undefined ? target.checked : value;
}
this._config = newConfig;
fireEvent(this, 'config-changed', { config: this._config });
+12 -2
View File
@@ -3,18 +3,26 @@
.frigate-card-contents {
width: 100%;
max-height: 277px; /* Max Lovelace 16/9 video height */
height: 100%;
aspect-ratio: 16 / 9;
}
.frigate-card-viewer {
width: 100%;
max-height: 277px; /* Max Lovelace 16/9 video height */
height: 100%;
aspect-ratio: 16 / 9;
}
.frigate-card-gallery {
overflow: auto;
max-height: 277px; /* Max Lovelace 16/9 video height */
-ms-overflow-style: none; /* Hide scrollbar: IE and Edge */
scrollbar-width: none; /* Hide scrollbar: Firefox */
height: 100%;
aspect-ratio: 16 / 9;
}
/* Hide scrollbar for Chrome, Safari and Opera */
@@ -26,6 +34,8 @@
display: flex;
justify-content: center;
align-items: center;
padding: 0% 5% 0% 5%;
height: 100%;
}
+227 -154
View File
@@ -1,3 +1,4 @@
// TODO Don't show button if no Frigate url.
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
LitElement,
@@ -27,30 +28,32 @@ import frigate_card_style from './frigate-hass-card.scss';
import frigate_card_menu_style from './frigate-hass-card-menu.scss';
import {
browseMediaSourceSchema,
frigateCardConfigSchema,
frigateGetEventsResponseSchema,
FrigateMenuMode,
resolvedMediaSchema,
} from './types';
import type {
BrowseMediaSource,
ControlVideosParameters,
FrigateCardView,
FrigateCardConfig,
FrigateEvent,
FrigateGetEventsResponse,
GetEventsParameters,
ControlVideosParameters,
FrigateMenuMode,
MediaBeingShown,
ResolvedMedia,
} from './types';
import { CARD_VERSION } from './const';
import { localize } from './localize/localize';
import dayjs from 'dayjs';
import dayjs_utc from 'dayjs/plugin/utc';
import dayjs_timezone from 'dayjs/plugin/timezone';
import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat';
import { z, ZodSchema } from 'zod';
import { MessageBase } from 'home-assistant-js-websocket';
const URL_TROUBLESHOOTING =
'https://github.com/dermotduffy/frigate-hass-card#troubleshooting';
// Load dayjs plugins.
dayjs.extend(dayjs_timezone);
dayjs.extend(dayjs_utc);
// Load dayjs plugin(s).
dayjs.extend(dayjs_custom_parse_format);
/* eslint no-console: 0 */
console.info(
@@ -282,17 +285,23 @@ export class FrigateCard extends LitElement {
// Event specifically requested to be shown by the user.
@property({ attribute: false })
protected _requestedEvent: FrigateEvent | null = null;
protected _requestedMediaSource: BrowseMediaSource | null = null;
// Event actually being shown to the user. This may be different from
// _requestedEvent when no particular event is requested (e.g. most recent) --
// in that case the requestedEvent will be null, but _eventBeingShown will be
// the actual event shown.
protected _eventBeingShown: FrigateEvent | null = null;
// Media (both browse item & resolved media) actually being shown to the user.
// This may be different from _requestedMediaSource when no particular event is
// requested (e.g. 'clip' view that views the most recent) -- in that case the
// requestedEvent will be null, but _mediaBeingShown will be the actual event
// shown.
protected _mediaBeingShown: MediaBeingShown | null = null;
// Whether or not there is an active clip being played.
protected _clipPlaying = false;
protected _getParseErrorKeys(error: z.ZodError): string[] {
const errors = error.format();
return Object.keys(errors).filter((v) => !v.startsWith('_'));
}
// Set the object configuration.
public setConfig(inputConfig: FrigateCardConfig): void {
if (!inputConfig) {
@@ -301,8 +310,7 @@ export class FrigateCard extends LitElement {
const parseResult = frigateCardConfigSchema.safeParse(inputConfig);
if (!parseResult.success) {
const errors = parseResult.error.format();
const keys = Object.keys(errors).filter((v) => !v.startsWith('_'));
const keys = this._getParseErrorKeys(parseResult.error);
throw new Error(localize('common.invalid_configuration') + ': ' + keys.join(', '));
}
const config = parseResult.data;
@@ -339,19 +347,19 @@ export class FrigateCard extends LitElement {
protected _changeView(
view?: FrigateCardView | undefined,
event?: FrigateEvent | undefined,
mediaSource?: BrowseMediaSource | undefined,
): void {
if (view !== undefined) {
this._viewMode = view;
} else {
this._viewMode = this.config.view_default;
if (['clip', 'snapshot'].includes(this.config.view_default)) {
this._requestedEvent = null;
this._requestedMediaSource = null;
}
}
this._eventBeingShown = null;
if (event !== undefined) {
this._requestedEvent = event;
this._mediaBeingShown = null;
if (mediaSource !== undefined) {
this._requestedMediaSource = mediaSource;
}
}
@@ -379,50 +387,72 @@ export class FrigateCard extends LitElement {
return true;
}
// Get FrigateEvents from the Frigate server API.
protected async _getEvents({
has_clip = false,
has_snapshot = false,
limit = 100,
}: GetEventsParameters): Promise<FrigateGetEventsResponse> {
let url = `${this.config.frigate_url}/api/events?camera=${this.config.frigate_camera_name}`;
if (has_clip) {
url += `&has_clip=1`;
}
if (has_snapshot) {
url += `&has_snapshot=1`;
}
if (limit > 0) {
url += `&limit=${limit}`;
// Make a websocket request to Home Assistant.
protected async _makeWSRequest<T>(
schema: ZodSchema<T>,
request: MessageBase,
): Promise<T | null> {
if (!this._hass) {
return null;
}
if (this.config.label) {
url += `&label=${this.config.label}`;
}
if (this.config.zone) {
url += `&zone=${this.config.zone}`;
}
const response = await this._hass.callWS<T>(request);
const response = await fetch(url);
if (response.ok) {
let raw_json;
try {
raw_json = await response.json();
} catch (e: any) {
console.warn(e);
throw new Error(`Could not JSON decode Frigate API response: ${e}`);
}
try {
return frigateGetEventsResponseSchema.parse(raw_json);
} catch (e: any) {
console.warn(e);
throw new Error(`Frigate events were malformed: ${e}`);
}
} else {
const error_message = `Frigate API request failed with status: ${response.status}`;
if (!response) {
const error_message = `Received empty response from Home Assistant for request ${JSON.stringify(
request,
)}`;
console.warn(error_message);
throw new Error(error_message);
}
const parseResult = schema.safeParse(response);
if (!parseResult.success) {
const keys = this._getParseErrorKeys(parseResult.error);
const error_message =
`Received invalid response from Home Assistant for request ${JSON.stringify(
request,
)}, ` + `invalid keys were '${keys}'`;
console.warn(error_message);
throw new Error(error_message);
}
return parseResult.data;
}
// Browse Frigate media.
protected async _browseMedia(
want_clips?: boolean,
before?: number,
after?: number,
): Promise<BrowseMediaSource | null> {
// Defined in:
// https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py
const request = {
type: 'media_source/browse_media',
media_content_id: [
'media-source://frigate',
this.config.frigate_client_id,
'event-search',
want_clips ? 'clips' : 'snapshots',
'', // Name/Title to render (not necessary here)
after ? String(after) : '',
before ? String(before) : '',
this.config.frigate_camera_name,
this.config.label,
this.config.zone,
].join('/'),
};
return this._makeWSRequest(browseMediaSourceSchema, request);
}
// Resolve Frigate media identifier to a real URL.
protected async _resolveMedia(
mediaSource: BrowseMediaSource,
): Promise<ResolvedMedia | null> {
const request = {
type: 'media_source/resolve_media',
media_content_id: mediaSource.media_content_id,
};
return this._makeWSRequest(resolvedMediaSchema, request);
}
// Render an attention grabbing icon.
@@ -442,41 +472,23 @@ export class FrigateCard extends LitElement {
protected _renderError(error: string): TemplateResult {
return this._renderAttentionIcon(
'mdi:alert-circle',
html`${error}. See <a href="${URL_TROUBLESHOOTING}">troubleshooting</a></span>.`,
html`${
error ? `${error} .` : `Unknown error`
}Check <a href="${URL_TROUBLESHOOTING}">troubleshooting</a></span>.`,
);
}
// Generate a human-readable title from an event.
// MediaBrowser title: 2021-08-12 19:20:14 [10s, Person 76%]
protected _getEventTitle(event: FrigateEvent): string {
const date = dayjs.unix(event.end_time).tz('UTC').local();
const iso_datetime = date.format('YYYY-MM-DD HH:mm:ss');
const duration = Math.trunc(
event.end_time > event.start_time ? event.end_time - event.start_time : 0,
);
const score = Math.trunc(event.top_score * 100);
// Capitalize the label.
const label = event.label.charAt(0).toUpperCase() + event.label.slice(1);
return `${iso_datetime} [${duration}s, ${label} ${score}%]`;
}
// Render Frigate events into a card gallery.
protected async _renderEvents(): Promise<TemplateResult> {
const want_clips = this._viewMode == 'clips';
let events;
let media;
try {
events = await this._getEvents({
has_clip: want_clips,
has_snapshot: !want_clips,
});
media = await this._browseMedia(want_clips);
} catch (e: any) {
return this._renderError(e.message);
}
if (!events.length) {
const firstMediaItem = this._getFirstTrueMediaItem(media);
if (!firstMediaItem) {
return this._renderAttentionIcon(
want_clips ? 'mdi:filmstrip-off' : 'mdi:camera-off',
want_clips ? 'No clips' : 'No snapshots',
@@ -484,20 +496,22 @@ export class FrigateCard extends LitElement {
}
return html` <ul class="mdc-image-list frigate-card-image-list">
${events.map(
(event) => html` <li class="mdc-image-list__item">
<div class="mdc-image-list__image-aspect-container">
<img
data-toggle="tooltip"
title="${this._getEventTitle(event)}"
class="mdc-image-list__image"
src="data:image/png;base64,${event.thumbnail}"
@click=${() => {
this._changeView(want_clips ? 'clip' : 'snapshot', event);
}}
/>
</div>
</li>`,
${media.children.map((mediaSource) =>
mediaSource.can_expand
? ''
: html` <li class="mdc-image-list__item">
<div class="mdc-image-list__image-aspect-container">
<img
data-toggle="tooltip"
title="${mediaSource.title}"
class="mdc-image-list__image"
src="${mediaSource.thumbnail}"
@click=${() => {
this._changeView(want_clips ? 'clip' : 'snapshot', mediaSource);
}}
/>
</div>
</li>`,
)}
</ul>`;
}
@@ -584,7 +598,11 @@ export class FrigateCard extends LitElement {
this._changeView(name);
break;
case 'frigate-ui':
window.open(this._getFrigateURLFromContext());
const frigate_url = this._getFrigateURLFromContext();
if (frigate_url) {
window.open(frigate_url);
break;
}
break;
case 'motion':
if (this.config.motion_entity) {
@@ -596,46 +614,76 @@ export class FrigateCard extends LitElement {
}
}
protected _getFrigateURLFromContext(): string {
if (this._eventBeingShown) {
return `${this.config.frigate_url}/events/${this._eventBeingShown.id}`;
// Extract the Frigate event id from the resolved media. Unfortunately, there
// is no way to attach metadata to BrowseMediaSource so this must suffice.
protected _extractEventIDFromResolvedMedia(
resolvedMedia: ResolvedMedia,
): string | null {
// Example: /api/frigate/frigate/clips/camera-1630123639.21596-l1y9af.jpg?authSig=[large_string]
const result = resolvedMedia.url.match(/-(?<id>[\w]+)\.(jpg|m3u8|mp4)($|\?)/i);
if (result && result.groups) {
return result.groups['id'] || null;
}
return null;
}
protected _extractEventStartTimeFromBrowseMedia(
browseMedia: BrowseMediaSource,
): number | null {
// Example: 2021-08-27 20:57:22 [10s, Person 76%]
const result = browseMedia.title.match(/^(?<iso_datetime>.+) \[/);
if (result && result.groups) {
const iso_datetime_str = result.groups['iso_datetime'];
if (iso_datetime_str) {
const iso_datetime = dayjs(iso_datetime_str, 'YYYY-MM-DD HH:mm:ss', true);
if (iso_datetime.isValid()) {
return iso_datetime.unix();
}
}
}
return null;
}
// Get the Frigate UI url.
protected _getFrigateURLFromContext(): string | null {
if (!this.config.frigate_url) {
return null;
}
if (this._mediaBeingShown) {
const eventID = this._extractEventIDFromResolvedMedia(
this._mediaBeingShown.resolvedMedia,
);
if (eventID) {
return `${this.config.frigate_url}/events/${eventID}`;
}
}
return `${this.config.frigate_url}/cameras/${this.config.frigate_camera_name}`;
}
protected _getClipURLFromEvent(event: FrigateEvent): string | null {
if (!event.has_clip) {
return null;
}
return `${this.config.frigate_url}/vod/event/${event.id}/index.m3u8`;
}
protected _getSnapshotURLFromEvent(event: FrigateEvent): string | null {
if (!event.has_snapshot) {
return null;
}
return `${this.config.frigate_url}/clips/${event.camera}-${event.id}.jpg`;
// From a BrowseMediaSource item extract the first true media item (i.e. a
// clip/snapshot, not a folder).
protected _getFirstTrueMediaItem(media: BrowseMediaSource): BrowseMediaSource | null {
return media.children?.find((mediaSource) => !mediaSource.can_expand) || null;
}
// Render the player for a saved clip.
protected async _renderClipPlayer(): Promise<TemplateResult> {
let event: FrigateEvent, events: FrigateGetEventsResponse;
let mediaSource: BrowseMediaSource;
let autoplay = true;
if (this._requestedEvent) {
event = this._requestedEvent;
if (this._requestedMediaSource) {
mediaSource = this._requestedMediaSource;
} else {
let media;
try {
events = await this._getEvents({
has_clip: true,
limit: 1,
});
media = await this._browseMedia(true);
} catch (e: any) {
return this._renderError(e.message);
}
if (!events.length) {
return this._renderAttentionIcon('mdi:camera-off', 'No recent clip');
const firstMediaItem = this._getFirstTrueMediaItem(media);
if (!firstMediaItem) {
return this._renderAttentionIcon('mdi:filmstrip-off', 'No recent clip');
}
event = events[0];
mediaSource = firstMediaItem;
// In this block, no clip has been manually selected, so this is loading
// the most recent clip on card load. In this mode, autoplay of the clip
@@ -645,19 +693,21 @@ export class FrigateCard extends LitElement {
autoplay = this.config.autoplay_clip;
}
const clipURL = this._getClipURLFromEvent(event);
if (!clipURL) {
// Frigate has returned an event without a clip, even though it was
// specifically asked only for events with clips.
return this._renderAttentionIcon('mdi:camera-off', 'No recent clip');
const resolvedMedia = await this._resolveMedia(mediaSource);
if (!resolvedMedia) {
// Home Assistant could not resolve media item.
return this._renderError('Could not resolve clip URL');
}
this._eventBeingShown = event;
this._mediaBeingShown = {
browseMedia: mediaSource,
resolvedMedia: resolvedMedia,
};
return html`
<ha-hls-player
.hass=${this._hass}
.url=${clipURL}
.url=${resolvedMedia.url}
class="frigate-card-viewer"
muted
controls
@@ -698,42 +748,65 @@ export class FrigateCard extends LitElement {
});
}
// Get a clip at the same time as a snapshot.
protected async _findRelatedClips(
snapshot: BrowseMediaSource,
): Promise<BrowseMediaSource | null> {
const startTime = this._extractEventStartTimeFromBrowseMedia(snapshot);
if (startTime) {
try {
// Fetch clips within the same second (same camera/zone/label, etc).
const clipsAtSameTime = await this._browseMedia(true, startTime + 1, startTime);
if (clipsAtSameTime) {
return this._getFirstTrueMediaItem(clipsAtSameTime);
}
} catch (e: any) {
// Pass. This is best effort.
}
}
return null;
}
// Render a snapshot.
protected async _renderSnapshotViewer(): Promise<TemplateResult> {
let event: FrigateEvent, events: FrigateGetEventsResponse;
if (this._requestedEvent) {
event = this._requestedEvent;
let mediaSource: BrowseMediaSource;
if (this._requestedMediaSource) {
mediaSource = this._requestedMediaSource;
} else {
let media;
try {
events = await this._getEvents({
has_snapshot: true,
limit: 1,
});
media = await this._browseMedia(false);
} catch (e: any) {
return this._renderError(e.message);
}
if (!events.length) {
return this._renderAttentionIcon('mdi:filmstrip-off', 'No recent snapshots');
const firstMediaItem = this._getFirstTrueMediaItem(media);
if (!firstMediaItem) {
return this._renderAttentionIcon('mdi:camera-off', 'No recent snapshots');
}
event = events[0];
mediaSource = firstMediaItem;
}
const snapshotURL = this._getSnapshotURLFromEvent(event);
if (!snapshotURL) {
// Frigate has returned an event without a snapshot, even though it was
// specifically asked only for events with snapshots.
return this._renderAttentionIcon('mdi:filmstrip-off', 'No recent snapshots');
const resolvedMedia = await this._resolveMedia(mediaSource);
if (!resolvedMedia) {
// Home Assistant could not resolve media item.
return this._renderError('Could not resolve snapshot URL');
}
this._eventBeingShown = event;
this._mediaBeingShown = {
browseMedia: mediaSource,
resolvedMedia: resolvedMedia,
};
return html` <img
class="frigate-card-viewer"
src="${snapshotURL}"
src="${resolvedMedia.url}"
@click=${() => {
if (event.has_clip) {
this._changeView('clip', event);
}
// Get clips potentially related to this snapshot.
this._findRelatedClips(mediaSource).then((relatedClip) => {
if (relatedClip) {
this._changeView('clip', relatedClip);
}
})
}}
/>`;
}
+44 -24
View File
@@ -36,7 +36,9 @@ export type FrigateMenuMode = typeof FRIGATE_MENU_MODES[number];
export const frigateCardConfigSchema = z.object({
camera_entity: z.string(),
motion_entity: z.string().optional(),
frigate_url: z.string().url(),
// No URL validation to allow relative URLs within HA (e.g. addons).
frigate_url: z.string().optional(),
frigate_client_id: z.string().optional().default("frigate"),
frigate_camera_name: z.string().optional(),
view_default: z.enum(FRIGATE_CARD_VIEWS).optional().default('live'),
@@ -64,36 +66,54 @@ export const frigateCardConfigSchema = z.object({
});
export type FrigateCardConfig = z.infer<typeof frigateCardConfigSchema>;
export interface GetEventsParameters {
has_clip?: boolean;
has_snapshot?: boolean;
limit?: number;
}
export interface ControlVideosParameters {
stop: boolean;
control_live?: boolean;
control_clip?: boolean;
}
export interface MediaBeingShown {
browseMedia: BrowseMediaSource;
resolvedMedia: ResolvedMedia;
}
/**
* Frigate API types.
* Media Browser API types.
*/
export const frigateEventSchema = z.object({
camera: z.string(),
end_time: z.number(),
false_positive: z.boolean(),
has_clip: z.boolean(),
has_snapshot: z.boolean(),
id: z.string(),
label: z.string(),
start_time: z.number(),
thumbnail: z.string(),
top_score: z.number(),
zones: z.string().array(),
});
export type FrigateEvent = z.infer<typeof frigateEventSchema>;
// 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/__init__.py
export interface BrowseMediaSource {
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?: BrowseMediaSource[] | null;
}
export const frigateGetEventsResponseSchema = z.array(frigateEventSchema);
export type FrigateGetEventsResponse = z.infer<typeof frigateGetEventsResponseSchema>;
export const browseMediaSourceSchema: z.ZodSchema<BrowseMediaSource> = 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(),
thumbnail: z.string().nullable(),
children: z.array(browseMediaSourceSchema).nullable().optional(),
})
);
// Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_source/models.py
export const resolvedMediaSchema = z.object({
url: z.string(),
mime_type: z.string(),
});
export type ResolvedMedia = z.infer<typeof resolvedMediaSchema>;