diff --git a/src/editor.ts b/src/editor.ts index 5c617388..d7a6b410 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -145,12 +145,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor })} - ` : ''} @@ -221,6 +215,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor })} + + !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 { - 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( + schema: ZodSchema, + request: MessageBase, + ): Promise { + 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(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 { + // 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 { + 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 troubleshooting.`, + html`${ + error ? `${error} .` : `Unknown error` + }Check troubleshooting.`, ); } - // 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 { 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` - ${events.map( - (event) => html` - - { - this._changeView(want_clips ? 'clip' : 'snapshot', event); - }} - /> - - `, + ${media.children.map((mediaSource) => + mediaSource.can_expand + ? '' + : html` + + { + this._changeView(want_clips ? 'clip' : 'snapshot', mediaSource); + }} + /> + + `, )} `; } @@ -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(/-(?[\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(/^(?.+) \[/); + 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 { - 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` { + 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 { - 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` { - 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); + } + }) }} />`; } diff --git a/src/types.ts b/src/types.ts index a2fe31dd..980386dc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; -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; +// 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; +export const browseMediaSourceSchema: z.ZodSchema = 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; \ No newline at end of file