Use media_browser websockets.
This commit is contained in:
+16
-9
@@ -145,12 +145,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
})}
|
})}
|
||||||
</paper-listbox>
|
</paper-listbox>
|
||||||
</paper-dropdown-menu>
|
</paper-dropdown-menu>
|
||||||
<paper-input
|
|
||||||
label="Frigate URL (Required)"
|
|
||||||
.value=${this._config?.frigate_url || ''}
|
|
||||||
.configValue=${'frigate_url'}
|
|
||||||
@value-changed=${this._valueChanged}
|
|
||||||
></paper-input>
|
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ''}
|
: ''}
|
||||||
@@ -221,6 +215,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
})}
|
})}
|
||||||
</paper-listbox>
|
</paper-listbox>
|
||||||
</paper-dropdown-menu>
|
</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
|
<paper-input
|
||||||
label="View timeout (seconds)"
|
label="View timeout (seconds)"
|
||||||
prevent-invalid-input
|
prevent-invalid-input
|
||||||
@@ -231,6 +231,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
.configValue=${'view_timeout'}
|
.configValue=${'view_timeout'}
|
||||||
@value-changed=${this._valueChanged}
|
@value-changed=${this._valueChanged}
|
||||||
></paper-input>
|
></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-formfield .label=${`Autoplay latest clip`}>
|
||||||
<ha-switch
|
<ha-switch
|
||||||
.checked=${this._config?.autoplay_clip === true}
|
.checked=${this._config?.autoplay_clip === true}
|
||||||
@@ -341,6 +347,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = ev.target;
|
const target = ev.target;
|
||||||
|
const value = target.value?.trim();
|
||||||
let key: string = target.configValue;
|
let key: string = target.configValue;
|
||||||
|
|
||||||
if (!key) {
|
if (!key) {
|
||||||
@@ -361,12 +368,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
key = parts[1];
|
key = parts[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.value !== undefined && objectTarget[key] === target.value) {
|
if (value !== undefined && objectTarget[key] === value) {
|
||||||
return;
|
return;
|
||||||
} else if (target.value === '') {
|
} else if (value === '') {
|
||||||
delete objectTarget[key];
|
delete objectTarget[key];
|
||||||
} else {
|
} else {
|
||||||
objectTarget[key] = target.checked !== undefined ? target.checked : target.value;
|
objectTarget[key] = target.checked !== undefined ? target.checked : value;
|
||||||
}
|
}
|
||||||
this._config = newConfig;
|
this._config = newConfig;
|
||||||
fireEvent(this, 'config-changed', { config: this._config });
|
fireEvent(this, 'config-changed', { config: this._config });
|
||||||
|
|||||||
@@ -3,18 +3,26 @@
|
|||||||
|
|
||||||
.frigate-card-contents {
|
.frigate-card-contents {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
max-height: 277px; /* Max Lovelace 16/9 video height */
|
||||||
|
height: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.frigate-card-viewer {
|
.frigate-card-viewer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 277px; /* Max Lovelace 16/9 video height */
|
|
||||||
|
height: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.frigate-card-gallery {
|
.frigate-card-gallery {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
max-height: 277px; /* Max Lovelace 16/9 video height */
|
|
||||||
-ms-overflow-style: none; /* Hide scrollbar: IE and Edge */
|
-ms-overflow-style: none; /* Hide scrollbar: IE and Edge */
|
||||||
scrollbar-width: none; /* Hide scrollbar: Firefox */
|
scrollbar-width: none; /* Hide scrollbar: Firefox */
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
@@ -26,6 +34,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding: 0% 5% 0% 5%;
|
||||||
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+217
-144
@@ -1,3 +1,4 @@
|
|||||||
|
// TODO Don't show button if no Frigate url.
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import {
|
import {
|
||||||
LitElement,
|
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 frigate_card_menu_style from './frigate-hass-card-menu.scss';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
browseMediaSourceSchema,
|
||||||
frigateCardConfigSchema,
|
frigateCardConfigSchema,
|
||||||
frigateGetEventsResponseSchema,
|
resolvedMediaSchema,
|
||||||
FrigateMenuMode,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
import type {
|
import type {
|
||||||
|
BrowseMediaSource,
|
||||||
|
ControlVideosParameters,
|
||||||
FrigateCardView,
|
FrigateCardView,
|
||||||
FrigateCardConfig,
|
FrigateCardConfig,
|
||||||
FrigateEvent,
|
FrigateMenuMode,
|
||||||
FrigateGetEventsResponse,
|
MediaBeingShown,
|
||||||
GetEventsParameters,
|
ResolvedMedia,
|
||||||
ControlVideosParameters,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
import { CARD_VERSION } from './const';
|
import { CARD_VERSION } from './const';
|
||||||
import { localize } from './localize/localize';
|
import { localize } from './localize/localize';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import dayjs_utc from 'dayjs/plugin/utc';
|
import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat';
|
||||||
import dayjs_timezone from 'dayjs/plugin/timezone';
|
|
||||||
|
import { z, ZodSchema } from 'zod';
|
||||||
|
import { MessageBase } from 'home-assistant-js-websocket';
|
||||||
|
|
||||||
const URL_TROUBLESHOOTING =
|
const URL_TROUBLESHOOTING =
|
||||||
'https://github.com/dermotduffy/frigate-hass-card#troubleshooting';
|
'https://github.com/dermotduffy/frigate-hass-card#troubleshooting';
|
||||||
|
|
||||||
// Load dayjs plugins.
|
// Load dayjs plugin(s).
|
||||||
dayjs.extend(dayjs_timezone);
|
dayjs.extend(dayjs_custom_parse_format);
|
||||||
dayjs.extend(dayjs_utc);
|
|
||||||
|
|
||||||
/* eslint no-console: 0 */
|
/* eslint no-console: 0 */
|
||||||
console.info(
|
console.info(
|
||||||
@@ -282,17 +285,23 @@ export class FrigateCard extends LitElement {
|
|||||||
|
|
||||||
// Event specifically requested to be shown by the user.
|
// Event specifically requested to be shown by the user.
|
||||||
@property({ attribute: false })
|
@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
|
// Media (both browse item & resolved media) actually being shown to the user.
|
||||||
// _requestedEvent when no particular event is requested (e.g. most recent) --
|
// This may be different from _requestedMediaSource when no particular event is
|
||||||
// in that case the requestedEvent will be null, but _eventBeingShown will be
|
// requested (e.g. 'clip' view that views the most recent) -- in that case the
|
||||||
// the actual event shown.
|
// requestedEvent will be null, but _mediaBeingShown will be the actual event
|
||||||
protected _eventBeingShown: FrigateEvent | null = null;
|
// shown.
|
||||||
|
protected _mediaBeingShown: MediaBeingShown | null = null;
|
||||||
|
|
||||||
// Whether or not there is an active clip being played.
|
// Whether or not there is an active clip being played.
|
||||||
protected _clipPlaying = false;
|
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.
|
// Set the object configuration.
|
||||||
public setConfig(inputConfig: FrigateCardConfig): void {
|
public setConfig(inputConfig: FrigateCardConfig): void {
|
||||||
if (!inputConfig) {
|
if (!inputConfig) {
|
||||||
@@ -301,8 +310,7 @@ export class FrigateCard extends LitElement {
|
|||||||
|
|
||||||
const parseResult = frigateCardConfigSchema.safeParse(inputConfig);
|
const parseResult = frigateCardConfigSchema.safeParse(inputConfig);
|
||||||
if (!parseResult.success) {
|
if (!parseResult.success) {
|
||||||
const errors = parseResult.error.format();
|
const keys = this._getParseErrorKeys(parseResult.error);
|
||||||
const keys = Object.keys(errors).filter((v) => !v.startsWith('_'));
|
|
||||||
throw new Error(localize('common.invalid_configuration') + ': ' + keys.join(', '));
|
throw new Error(localize('common.invalid_configuration') + ': ' + keys.join(', '));
|
||||||
}
|
}
|
||||||
const config = parseResult.data;
|
const config = parseResult.data;
|
||||||
@@ -339,19 +347,19 @@ export class FrigateCard extends LitElement {
|
|||||||
|
|
||||||
protected _changeView(
|
protected _changeView(
|
||||||
view?: FrigateCardView | undefined,
|
view?: FrigateCardView | undefined,
|
||||||
event?: FrigateEvent | undefined,
|
mediaSource?: BrowseMediaSource | undefined,
|
||||||
): void {
|
): void {
|
||||||
if (view !== undefined) {
|
if (view !== undefined) {
|
||||||
this._viewMode = view;
|
this._viewMode = view;
|
||||||
} else {
|
} else {
|
||||||
this._viewMode = this.config.view_default;
|
this._viewMode = this.config.view_default;
|
||||||
if (['clip', 'snapshot'].includes(this.config.view_default)) {
|
if (['clip', 'snapshot'].includes(this.config.view_default)) {
|
||||||
this._requestedEvent = null;
|
this._requestedMediaSource = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._eventBeingShown = null;
|
this._mediaBeingShown = null;
|
||||||
if (event !== undefined) {
|
if (mediaSource !== undefined) {
|
||||||
this._requestedEvent = event;
|
this._requestedMediaSource = mediaSource;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,50 +387,72 @@ export class FrigateCard extends LitElement {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get FrigateEvents from the Frigate server API.
|
// Make a websocket request to Home Assistant.
|
||||||
protected async _getEvents({
|
protected async _makeWSRequest<T>(
|
||||||
has_clip = false,
|
schema: ZodSchema<T>,
|
||||||
has_snapshot = false,
|
request: MessageBase,
|
||||||
limit = 100,
|
): Promise<T | null> {
|
||||||
}: GetEventsParameters): Promise<FrigateGetEventsResponse> {
|
if (!this._hass) {
|
||||||
let url = `${this.config.frigate_url}/api/events?camera=${this.config.frigate_camera_name}`;
|
return null;
|
||||||
if (has_clip) {
|
|
||||||
url += `&has_clip=1`;
|
|
||||||
}
|
|
||||||
if (has_snapshot) {
|
|
||||||
url += `&has_snapshot=1`;
|
|
||||||
}
|
|
||||||
if (limit > 0) {
|
|
||||||
url += `&limit=${limit}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.config.label) {
|
const response = await this._hass.callWS<T>(request);
|
||||||
url += `&label=${this.config.label}`;
|
|
||||||
}
|
|
||||||
if (this.config.zone) {
|
|
||||||
url += `&zone=${this.config.zone}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url);
|
if (!response) {
|
||||||
if (response.ok) {
|
const error_message = `Received empty response from Home Assistant for request ${JSON.stringify(
|
||||||
let raw_json;
|
request,
|
||||||
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}`;
|
|
||||||
console.warn(error_message);
|
console.warn(error_message);
|
||||||
throw new Error(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.
|
// Render an attention grabbing icon.
|
||||||
@@ -442,41 +472,23 @@ export class FrigateCard extends LitElement {
|
|||||||
protected _renderError(error: string): TemplateResult {
|
protected _renderError(error: string): TemplateResult {
|
||||||
return this._renderAttentionIcon(
|
return this._renderAttentionIcon(
|
||||||
'mdi:alert-circle',
|
'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.
|
// Render Frigate events into a card gallery.
|
||||||
protected async _renderEvents(): Promise<TemplateResult> {
|
protected async _renderEvents(): Promise<TemplateResult> {
|
||||||
const want_clips = this._viewMode == 'clips';
|
const want_clips = this._viewMode == 'clips';
|
||||||
let events;
|
let media;
|
||||||
try {
|
try {
|
||||||
events = await this._getEvents({
|
media = await this._browseMedia(want_clips);
|
||||||
has_clip: want_clips,
|
|
||||||
has_snapshot: !want_clips,
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return this._renderError(e.message);
|
return this._renderError(e.message);
|
||||||
}
|
}
|
||||||
|
const firstMediaItem = this._getFirstTrueMediaItem(media);
|
||||||
if (!events.length) {
|
if (!firstMediaItem) {
|
||||||
return this._renderAttentionIcon(
|
return this._renderAttentionIcon(
|
||||||
want_clips ? 'mdi:filmstrip-off' : 'mdi:camera-off',
|
want_clips ? 'mdi:filmstrip-off' : 'mdi:camera-off',
|
||||||
want_clips ? 'No clips' : 'No snapshots',
|
want_clips ? 'No clips' : 'No snapshots',
|
||||||
@@ -484,16 +496,18 @@ export class FrigateCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html` <ul class="mdc-image-list frigate-card-image-list">
|
return html` <ul class="mdc-image-list frigate-card-image-list">
|
||||||
${events.map(
|
${media.children.map((mediaSource) =>
|
||||||
(event) => html` <li class="mdc-image-list__item">
|
mediaSource.can_expand
|
||||||
|
? ''
|
||||||
|
: html` <li class="mdc-image-list__item">
|
||||||
<div class="mdc-image-list__image-aspect-container">
|
<div class="mdc-image-list__image-aspect-container">
|
||||||
<img
|
<img
|
||||||
data-toggle="tooltip"
|
data-toggle="tooltip"
|
||||||
title="${this._getEventTitle(event)}"
|
title="${mediaSource.title}"
|
||||||
class="mdc-image-list__image"
|
class="mdc-image-list__image"
|
||||||
src="data:image/png;base64,${event.thumbnail}"
|
src="${mediaSource.thumbnail}"
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
this._changeView(want_clips ? 'clip' : 'snapshot', event);
|
this._changeView(want_clips ? 'clip' : 'snapshot', mediaSource);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -584,7 +598,11 @@ export class FrigateCard extends LitElement {
|
|||||||
this._changeView(name);
|
this._changeView(name);
|
||||||
break;
|
break;
|
||||||
case 'frigate-ui':
|
case 'frigate-ui':
|
||||||
window.open(this._getFrigateURLFromContext());
|
const frigate_url = this._getFrigateURLFromContext();
|
||||||
|
if (frigate_url) {
|
||||||
|
window.open(frigate_url);
|
||||||
|
break;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'motion':
|
case 'motion':
|
||||||
if (this.config.motion_entity) {
|
if (this.config.motion_entity) {
|
||||||
@@ -596,46 +614,76 @@ export class FrigateCard extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _getFrigateURLFromContext(): string {
|
// Extract the Frigate event id from the resolved media. Unfortunately, there
|
||||||
if (this._eventBeingShown) {
|
// is no way to attach metadata to BrowseMediaSource so this must suffice.
|
||||||
return `${this.config.frigate_url}/events/${this._eventBeingShown.id}`;
|
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}`;
|
return `${this.config.frigate_url}/cameras/${this.config.frigate_camera_name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _getClipURLFromEvent(event: FrigateEvent): string | null {
|
// From a BrowseMediaSource item extract the first true media item (i.e. a
|
||||||
if (!event.has_clip) {
|
// clip/snapshot, not a folder).
|
||||||
return null;
|
protected _getFirstTrueMediaItem(media: BrowseMediaSource): BrowseMediaSource | null {
|
||||||
}
|
return media.children?.find((mediaSource) => !mediaSource.can_expand) || 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`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the player for a saved clip.
|
// Render the player for a saved clip.
|
||||||
protected async _renderClipPlayer(): Promise<TemplateResult> {
|
protected async _renderClipPlayer(): Promise<TemplateResult> {
|
||||||
let event: FrigateEvent, events: FrigateGetEventsResponse;
|
let mediaSource: BrowseMediaSource;
|
||||||
let autoplay = true;
|
let autoplay = true;
|
||||||
if (this._requestedEvent) {
|
if (this._requestedMediaSource) {
|
||||||
event = this._requestedEvent;
|
mediaSource = this._requestedMediaSource;
|
||||||
} else {
|
} else {
|
||||||
|
let media;
|
||||||
try {
|
try {
|
||||||
events = await this._getEvents({
|
media = await this._browseMedia(true);
|
||||||
has_clip: true,
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return this._renderError(e.message);
|
return this._renderError(e.message);
|
||||||
}
|
}
|
||||||
if (!events.length) {
|
const firstMediaItem = this._getFirstTrueMediaItem(media);
|
||||||
return this._renderAttentionIcon('mdi:camera-off', 'No recent clip');
|
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
|
// 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
|
// 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;
|
autoplay = this.config.autoplay_clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clipURL = this._getClipURLFromEvent(event);
|
const resolvedMedia = await this._resolveMedia(mediaSource);
|
||||||
if (!clipURL) {
|
if (!resolvedMedia) {
|
||||||
// Frigate has returned an event without a clip, even though it was
|
// Home Assistant could not resolve media item.
|
||||||
// specifically asked only for events with clips.
|
return this._renderError('Could not resolve clip URL');
|
||||||
return this._renderAttentionIcon('mdi:camera-off', 'No recent clip');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this._eventBeingShown = event;
|
this._mediaBeingShown = {
|
||||||
|
browseMedia: mediaSource,
|
||||||
|
resolvedMedia: resolvedMedia,
|
||||||
|
};
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ha-hls-player
|
<ha-hls-player
|
||||||
.hass=${this._hass}
|
.hass=${this._hass}
|
||||||
.url=${clipURL}
|
.url=${resolvedMedia.url}
|
||||||
class="frigate-card-viewer"
|
class="frigate-card-viewer"
|
||||||
muted
|
muted
|
||||||
controls
|
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.
|
// Render a snapshot.
|
||||||
protected async _renderSnapshotViewer(): Promise<TemplateResult> {
|
protected async _renderSnapshotViewer(): Promise<TemplateResult> {
|
||||||
let event: FrigateEvent, events: FrigateGetEventsResponse;
|
let mediaSource: BrowseMediaSource;
|
||||||
if (this._requestedEvent) {
|
if (this._requestedMediaSource) {
|
||||||
event = this._requestedEvent;
|
mediaSource = this._requestedMediaSource;
|
||||||
} else {
|
} else {
|
||||||
|
let media;
|
||||||
try {
|
try {
|
||||||
events = await this._getEvents({
|
media = await this._browseMedia(false);
|
||||||
has_snapshot: true,
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return this._renderError(e.message);
|
return this._renderError(e.message);
|
||||||
}
|
}
|
||||||
if (!events.length) {
|
const firstMediaItem = this._getFirstTrueMediaItem(media);
|
||||||
return this._renderAttentionIcon('mdi:filmstrip-off', 'No recent snapshots');
|
if (!firstMediaItem) {
|
||||||
|
return this._renderAttentionIcon('mdi:camera-off', 'No recent snapshots');
|
||||||
}
|
}
|
||||||
event = events[0];
|
mediaSource = firstMediaItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshotURL = this._getSnapshotURLFromEvent(event);
|
const resolvedMedia = await this._resolveMedia(mediaSource);
|
||||||
if (!snapshotURL) {
|
if (!resolvedMedia) {
|
||||||
// Frigate has returned an event without a snapshot, even though it was
|
// Home Assistant could not resolve media item.
|
||||||
// specifically asked only for events with snapshots.
|
return this._renderError('Could not resolve snapshot URL');
|
||||||
return this._renderAttentionIcon('mdi:filmstrip-off', 'No recent snapshots');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this._eventBeingShown = event;
|
this._mediaBeingShown = {
|
||||||
|
browseMedia: mediaSource,
|
||||||
|
resolvedMedia: resolvedMedia,
|
||||||
|
};
|
||||||
|
|
||||||
return html` <img
|
return html` <img
|
||||||
class="frigate-card-viewer"
|
class="frigate-card-viewer"
|
||||||
src="${snapshotURL}"
|
src="${resolvedMedia.url}"
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
if (event.has_clip) {
|
// Get clips potentially related to this snapshot.
|
||||||
this._changeView('clip', event);
|
this._findRelatedClips(mediaSource).then((relatedClip) => {
|
||||||
|
if (relatedClip) {
|
||||||
|
this._changeView('clip', relatedClip);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
/>`;
|
/>`;
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-24
@@ -36,7 +36,9 @@ export type FrigateMenuMode = typeof FRIGATE_MENU_MODES[number];
|
|||||||
export const frigateCardConfigSchema = z.object({
|
export const frigateCardConfigSchema = z.object({
|
||||||
camera_entity: z.string(),
|
camera_entity: z.string(),
|
||||||
motion_entity: z.string().optional(),
|
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(),
|
frigate_camera_name: z.string().optional(),
|
||||||
view_default: z.enum(FRIGATE_CARD_VIEWS).optional().default('live'),
|
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 type FrigateCardConfig = z.infer<typeof frigateCardConfigSchema>;
|
||||||
|
|
||||||
export interface GetEventsParameters {
|
|
||||||
has_clip?: boolean;
|
|
||||||
has_snapshot?: boolean;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ControlVideosParameters {
|
export interface ControlVideosParameters {
|
||||||
stop: boolean;
|
stop: boolean;
|
||||||
control_live?: boolean;
|
control_live?: boolean;
|
||||||
control_clip?: boolean;
|
control_clip?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MediaBeingShown {
|
||||||
|
browseMedia: BrowseMediaSource;
|
||||||
|
resolvedMedia: ResolvedMedia;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Frigate API types.
|
* Media Browser API types.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const frigateEventSchema = z.object({
|
// Recursive type, cannot use type interference:
|
||||||
camera: z.string(),
|
// See: https://github.com/colinhacks/zod#recursive-types
|
||||||
end_time: z.number(),
|
//
|
||||||
false_positive: z.boolean(),
|
// Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_player/__init__.py
|
||||||
has_clip: z.boolean(),
|
export interface BrowseMediaSource {
|
||||||
has_snapshot: z.boolean(),
|
title: string;
|
||||||
id: z.string(),
|
media_class: string;
|
||||||
label: z.string(),
|
media_content_type: string;
|
||||||
start_time: z.number(),
|
media_content_id: string;
|
||||||
thumbnail: z.string(),
|
can_play: boolean;
|
||||||
top_score: z.number(),
|
can_expand: boolean;
|
||||||
zones: z.string().array(),
|
children_media_class: string | null;
|
||||||
});
|
thumbnail: string | null
|
||||||
export type FrigateEvent = z.infer<typeof frigateEventSchema>;
|
children?: BrowseMediaSource[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const frigateGetEventsResponseSchema = z.array(frigateEventSchema);
|
export const browseMediaSourceSchema: z.ZodSchema<BrowseMediaSource> = z.lazy(() =>
|
||||||
export type FrigateGetEventsResponse = z.infer<typeof frigateGetEventsResponseSchema>;
|
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>;
|
||||||
Reference in New Issue
Block a user