Merge pull request #31 from dermotduffy/fetch-events-from-media-browser

Fetch metadata and media content via MediaBrowser instead of directly from Frigate server
This commit is contained in:
Dermot Duffy
2021-08-29 00:04:16 -07:00
committed by GitHub
6 changed files with 400 additions and 305 deletions
+9 -4
View File
@@ -54,7 +54,6 @@ lovelace:
| Option | Default | Description |
| ------------- | - | --------------------------------------------- |
| `camera_entity` | | The Frigate camera entity to use in the live camera view.|
| `frigate_url` | | The URL of the frigate server. Must be manually specified, as the URL from the underlying device is not available to Lovelace cards.|
### Optional
@@ -64,7 +63,9 @@ lovelace:
| `frigate_camera_name` | The string after the "camera." in the `camera_entity` option (above). | This parameter allows the camera name heuristic to be overriden for cases where the entity name does not cleanly map to the Frigate camera name (e.g. when the Frigate camera name is capitalized, but the entity name is lower case). This camera name is used for communicating with the Frigate backend, e.g. for fetching events. |
| `view_default` | `live` | The view to show by default. See [views](#views) below.|
| `menu_mode` | `hidden` | The menu mode to show by default. See [menu modes](#menu-modes) below.|
| `frigate_client_id` | `frigate` | The Frigate client id to use. If this Home Assistant server has multiple Frigate server backends configured, this selects which server should be used. It should be set to the MQTT client id configured for this server, see [Frigate Integration Multiple Instance Support](https://blakeblackshear.github.io/frigate/usage/home-assistant/#multiple-instance-support).|
| `view_timeout` | | A numbers of seconds of inactivity after which the card will reset to the default configured view. Inactivity is defined as lack of interaction with the Frigate menu.|
| `frigate_url` | | The URL of the frigate server. If set, this value will be (exclusively) used for a `Frigate UI` menu button. |
| `autoplay_clip` | `false` | Whether or not to autoplay clips in the 'clip' [view](#views). Clips manually chosen in the clips gallery will still autoplay.|
### Advanced
@@ -140,8 +141,8 @@ do).
### Getting from a snapshot to a clip
Clicking on a snapshot will take the user to the clip associated with the
snapshot (if any).
Clicking on a snapshot will take the user to a clip that was taken at the ~same
time as the snapshot (if any).
### Getting event details
@@ -200,7 +201,9 @@ This card supports full editing via the Lovelace card editor. Additional arbitra
## Troubleshooting
### Failed to fetch / Cannot load clips or snapshots
### Failed to fetch
**Note:** This error should no longer be possible >= v0.1.5 .
`Failed to fetch` is a generic error indicating your browser (and this card)
could not communicate with the Frigate server specified in the card
@@ -212,6 +215,8 @@ UI, the address entered is probably incorrect/inaccessible.
#### Mixed content
**Note:** This error should no longer be possible >= v0.1.5 .
If you are accessing your Home Assistant instance over `https`, you will likely
receive this error unless you have configured the card to also communicate with
Frigate via `https` (e.g. via a reverse proxy). This is because the browser is
+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 });
+5
View File
@@ -43,3 +43,8 @@ ha-icon-button.button {
/* Buttons can always be clicked */
pointer-events: auto;
}
ha-icon-button.emphasized-button {
@extend ha-icon-button, .button;
color: var(--primary-color, white);
}
+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%;
}
+307 -265
View File
@@ -9,6 +9,7 @@ import {
PropertyValues,
state,
unsafeCSS,
query,
} from 'lit-element';
import { NodePart } from 'lit-html';
@@ -27,30 +28,33 @@ 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,
MenuButton,
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(
@@ -108,59 +112,47 @@ export class FrigateCardMenu extends LitElement {
@property({ attribute: false })
protected expand = false;
@property({ attribute: false })
protected motionEntity: string | null = null;
@property({ attribute: false })
public hass: HomeAssistant | null = null;
@property({ attribute: false })
protected actionCallback: FrigateCardMenuCallback | null = null;
protected shouldUpdate(changedProps: PropertyValues): boolean {
const oldHass = changedProps.get('hass') as HomeAssistant | undefined;
if (oldHass) {
return shouldUpdateBasedOnHass(this.hass, oldHass, [this.motionEntity]);
}
return true;
}
// Render the Frigate menu button.
protected _renderFrigateButton(): TemplateResult {
return html` <ha-icon-button
class="button"
icon=${this.menuMode != 'hidden' || this.expand
? 'mdi:alpha-f-box'
: 'mdi:alpha-f-box-outline'}
data-toggle="tooltip"
title="Frigate menu"
@click=${() => {
if (this.menuMode == 'hidden') {
this.expand = !this.expand;
} else {
this._callAction('default');
}
}}
></ha-icon-button>`;
}
@property({ attribute: false })
public buttons: Map<string, MenuButton> = new Map();
// Call the callback.
protected _callAction(name: string): void {
if (name == 'frigate' && this.menuMode == 'hidden') {
this.expand = !this.expand;
return;
}
if (this.actionCallback) {
this.actionCallback(name);
}
}
// Render a menu button.
protected _renderButton(name: string, button: MenuButton): TemplateResult {
return html` <ha-icon-button
class=${button.emphasize ? "emphasized-button" : "button"}
icon=${button.icon || 'mdi:gesture-tap-button'}
data-toggle="tooltip"
title=${button.description}
@click=${() => this._callAction(name)}
></ha-icon-button>`;
}
// Render the Frigate menu button.
protected _renderFrigateButton(name: string, button: MenuButton): TemplateResult {
const icon =
this.menuMode != 'hidden' || this.expand
? 'mdi:alpha-f-box'
: 'mdi:alpha-f-box-outline';
return this._renderButton(name, Object.assign({}, button, { icon: icon }));
}
// Render the menu.
protected render(): TemplateResult | void | ((part: NodePart) => Promise<void>) {
let motionIcon: string | null = null;
if (this.motionEntity && this.hass) {
motionIcon =
this.hass.states[this.motionEntity]?.state == 'on'
? 'mdi:motion-sensor'
: 'mdi:walk';
}
let menuClass = 'frigate-card-menu-full';
if (['hidden', 'overlay'].includes(this.menuMode)) {
if (this.menuMode == 'overlay' || this.expand) {
@@ -172,63 +164,15 @@ export class FrigateCardMenu extends LitElement {
return html`
<div class=${menuClass}>
${this._renderFrigateButton()}
${this.menuMode != 'hidden' || this.expand
? html`
<ha-icon-button
class="button"
icon="mdi:cctv"
data-toggle="tooltip"
title="View live"
@click=${() => {
this.expand = false;
this._callAction('live');
}}
></ha-icon-button>
<ha-icon-button
class="button"
icon="mdi:filmstrip"
data-toggle="tooltip"
title="View clips"
@click=${() => {
this.expand = false;
this._callAction('clips');
}}
></ha-icon-button>
<ha-icon-button
class="button"
icon="mdi:camera"
data-toggle="tooltip"
title="View snapshots"
@click=${() => {
this.expand = false;
this._callAction('snapshots');
}}
></ha-icon-button>
<ha-icon-button
class="button"
icon="mdi:web"
data-toggle="tooltip"
title="View Frigate UI"
@click=${() => {
this.expand = false;
this._callAction('frigate-ui');
}}
></ha-icon-button>
${!motionIcon
? html``
: html` <ha-icon-button
data-toggle="tooltip"
title="View motion sensor"
class="button"
icon="${motionIcon}"
@click=${() => {
this.expand = false;
this._callAction('motion');
}}
></ha-icon-button>`}
`
: ``}
${Array.from(this.buttons.keys()).map((name) => {
const button = this.buttons.get(name);
if (button) {
return name === 'frigate'
? this._renderFrigateButton(name, button)
: this._renderButton(name, button);
}
return html``;
})}
</div>
`;
}
@@ -256,16 +200,7 @@ export class FrigateCard extends LitElement {
this._webrtcElement.hass = hass;
}
this._hass = hass;
// Manually set hass in the menu. This is to allow the menu to update,
// without necessarily re-rendering the entire card (re-rendering interrupts
// clip playing).
const menu = this.shadowRoot?.getElementById(
FrigateCardMenu.FRIGATE_CARD_MENU_ID,
) as FrigateCardMenu;
if (menu) {
menu.hass = hass;
}
this._updateMenu();
}
@property({ attribute: false })
@@ -282,17 +217,61 @@ 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;
@query(FrigateCardMenu.FRIGATE_CARD_MENU_ID)
_menu!: FrigateCardMenu | null;
protected _updateMenu(): void {
// Manually set hass in the menu. This is to allow the menu to update,
// without necessarily re-rendering the entire card (re-rendering interrupts
// clip playing).
if (!this._menu || !this._hass) {
return;
}
this._menu.buttons = this._getMenuButtons();
}
protected _getMenuButtons(): Map<string, MenuButton> {
const buttons: Map<string, MenuButton> = new Map();
buttons.set('frigate', { description: 'Frigate Menu' });
buttons.set('live', { icon: 'mdi:cctv', description: 'View Live' });
buttons.set('clips', { icon: 'mdi:filmstrip', description: 'View Clips' });
buttons.set('snapshots', { icon: 'mdi:camera', description: 'View Snapshots' });
if (this.config.frigate_url) {
buttons.set('frigate_ui', { icon: 'mdi:web', description: 'View Frigate UI' });
}
if (this._hass && this.config.motion_entity) {
const on = this._hass.states[this.config.motion_entity]?.state == 'on';
const motionIcon = on ? 'mdi:motion-sensor' : 'mdi:walk';
buttons.set('motion', {
icon: motionIcon,
description: 'View Motion Sensor',
emphasize: on,
});
}
return buttons;
}
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 +280,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 +317,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 +357,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 +442,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 +466,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>`;
}
@@ -565,7 +549,7 @@ export class FrigateCard extends LitElement {
protected _menuActionHandler(name: string): void {
switch (name) {
case 'default':
case 'frigate':
this._controlVideos({ stop: true, control_clip: true });
this._controlVideos({ stop: true, control_live: true });
this._changeView();
@@ -583,8 +567,12 @@ export class FrigateCard extends LitElement {
this._controlVideos({ stop: true, control_clip: true, control_live: true });
this._changeView(name);
break;
case 'frigate-ui':
window.open(this._getFrigateURLFromContext());
case 'frigate_ui':
const frigate_url = this._getFrigateURLFromContext();
if (frigate_url) {
window.open(frigate_url);
break;
}
break;
case 'motion':
if (this.config.motion_entity) {
@@ -596,46 +584,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 +663,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 +718,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);
}
});
}}
/>`;
}
@@ -775,10 +818,9 @@ export class FrigateCard extends LitElement {
return html`
<frigate-card-menu
id="${FrigateCardMenu.FRIGATE_CARD_MENU_ID}"
.motionEntity=${this.config.motion_entity}
.hass=${this._hass}
.actionCallback=${this._menuActionHandler.bind(this)}
.menuMode=${this.config.menu_mode}
.buttons=${this._getMenuButtons()}
></frigate-card-menu>
`;
}
+50 -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,60 @@ 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;
}
export interface MenuButton {
icon?: string;
description: string;
emphasize?: boolean;
}
/**
* 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>;