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-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 });
+12 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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>;