import { HomeAssistant } from 'custom-card-helpers'; import { differenceInHours, differenceInMinutes, differenceInSeconds, fromUnixTime } from 'date-fns'; import { homeAssistantWSRequest } from '.'; import { dispatchErrorMessageEvent, dispatchFrigateCardErrorEvent, dispatchMessageEvent } from '../../components/message.js'; import { localize } from '../../localize/localize.js'; import { BrowseMediaQueryParameters, BrowseRecordingQueryParameters, CameraConfig, FrigateBrowseMediaSource, frigateBrowseMediaSourceSchema, FrigateCardError, FrigateEvent, FrigateRecording, MEDIA_CLASS_PLAYLIST, MEDIA_CLASS_VIDEO, MEDIA_TYPE_PLAYLIST, MEDIA_TYPE_VIDEO } from '../../types.js'; import { View } from '../../view.js'; import { getCameraTitle } from '../camera.js'; /** * Return the Frigate event_id given a FrigateBrowseMediaSource object. * @param media The event to get the id from. * @returns The `event_id` or `null` if not successfully parsed. */ export const getEventID = (media: FrigateBrowseMediaSource): string | null => { return media.frigate?.event?.id ?? null; }; /** * Return the event start time given a FrigateBrowseMediaSource object. * @param browseMedia The media object to get the start time from. * @returns The start time in unix/epoch time, or null if it cannot be determined. */ export const getEventStartTime = (media: FrigateBrowseMediaSource): number | null => { return media.frigate?.event?.start_time ?? null; }; /** * Determine if a FrigateBrowseMediaSource object is truly a media item (vs a folder). * @param media The media object. * @returns `true` if it's truly a media item, `false` otherwise. */ export const isTrueMedia = (media?: FrigateBrowseMediaSource): boolean => { return !!media && !media.can_expand; }; /** * From a FrigateBrowseMediaSource item extract the first true media item from the * children (i.e. a clip/snapshot, not a folder). * @param media The media object with children. * @returns The first true media item found. */ export const getFirstTrueMediaChildIndex = ( media: FrigateBrowseMediaSource | null, ): number | null => { if (!media || !media.children) { return null; } const index = media.children.findIndex((child) => isTrueMedia(child)); return index >= 0 ? index : null; }; /** * Browse Frigate media with a media content id. May throw. * @param hass The HomeAssistant object. * @param media_content_id The media content id to browse. * @returns A FrigateBrowseMediaSource object or null on malformed. */ export const browseMedia = async ( hass: HomeAssistant, media_content_id: string, ): Promise => { const request = { type: 'media_source/browse_media', media_content_id: media_content_id, }; return await homeAssistantWSRequest(hass, frigateBrowseMediaSourceSchema, request); }; /** * Browse Frigate media with a media query. May throw. * @param hass The HomeAssistant object. * @param params The search parameters to use to search for media. * @returns A FrigateBrowseMediaSource object or null on malformed. */ export const browseMediaQuery = async ( hass: HomeAssistant, params: BrowseMediaQueryParameters, ): Promise => { return browseMedia( hass, // Defined in: // https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py [ 'media-source://frigate', params.clientId, 'event-search', params.mediaType, // If the name field ends in '.all' the integration will return up to 10K events. params.unlimited ? '.all' : '', params.after ? String(Math.floor(params.after)) : '', params.before ? String(Math.ceil(params.before)) : '', params.cameraName, params.label, params.zone, ].join('/'), ); }; /** * Browse multiple Frigate media queries. May throw. * @param hass The HomeAssistant object. * @param params An array of search parameters to use to search for media. * @returns A map of FrigateBrowseMediaSource object or null on malformed. */ export const multipleBrowseMediaQuery = async ( hass: HomeAssistant, params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[], ): Promise> => { params = Array.isArray(params) ? params : [params]; const output: Map = new Map(); await Promise.all( params.map(async (param: BrowseMediaQueryParameters): Promise => { output.set(param, await browseMediaQuery(hass, param)); }), ); return output; }; /** * Browse multiple Frigate media queries, then merged them. May throw. * @param hass The HomeAssistant object. * @param params An array of search parameters to use to search for media. * @returns A single FrigateBrowseMediaSource object or null on malformed. */ export const multipleBrowseMediaQueryMerged = async ( hass: HomeAssistant, params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[], ): Promise => { return mergeFrigateBrowseMediaSources(await multipleBrowseMediaQuery(hass, params)); }; /** * Merge multiple FrigateBrowseMediaSource into a single. Note that this may * use information from the query to differentiate results that may otherwise * be identical. * @param input A map of query -> result. * @returns A single FrigateBrowseMediaSource object. */ export const mergeFrigateBrowseMediaSources = async ( input: Map, ): Promise => { const children: FrigateBrowseMediaSource[] = []; for (const [query, result] of input.entries()) { for (const child of result.children || []) { if (isTrueMedia(child)) { children.push(child); } else { // If there are multiple inputs, separate the folder names with the // query title (if available). if (query.title && input.size > 1) { children.push({ ...child, title: `[${query.title}] ${child.title}` }); } else { children.push(child); } } } } const eventSort = ( a: FrigateBrowseMediaSource, b: FrigateBrowseMediaSource, ): number => { if ( !a.frigate?.event || (b.frigate?.event && b.frigate.event.start_time > a.frigate.event.start_time) ) { return 1; } if ( !b.frigate?.event || (a.frigate?.event && b.frigate.event.start_time < a.frigate.event.start_time) ) { return -1; } return 0; }; return createEventParentForChildren('Merged events', children.sort(eventSort)); }; /** * Get the parameters to search for media. * @returns A BrowseMediaQueryParameters object. */ export const getBrowseMediaQueryParameters = ( hass: HomeAssistant, cameraID: string, cameraConfig?: CameraConfig, overrides?: Partial, ): BrowseMediaQueryParameters | null => { if (!cameraConfig || !cameraConfig.camera_name) { return null; } return { clientId: cameraConfig.client_id, cameraName: cameraConfig.camera_name, label: cameraConfig.label, zone: cameraConfig.zone, title: getCameraTitle(hass, cameraConfig), cameraID: cameraID, ...overrides, }; }; /** * Apply overrides to multiple query parameters. * @param parameters An array of query parameters. * @param overrides The overrides to apply. * @returns The override query parameters. */ export const overrideMultiBrowseMediaQueryParameters = ( parameters: BrowseMediaQueryParameters[], overrides: Partial, ): BrowseMediaQueryParameters[] => { const output: BrowseMediaQueryParameters[] = []; parameters.forEach((param) => { output.push({ ...param, ...overrides }); }); return output; }; /** * Get BrowseMediaQueryParameters for a camera (including its dependencies). * @param hass Home Assistant object. * @param cameras Cameras map. * @param camera Name of the current camera. * @param mediaType Optional media type to include in the parameters. * @returns An array of query parameters. */ export const getFullDependentBrowseMediaQueryParameters = ( hass: HomeAssistant, cameras: Map, camera: string, mediaType?: 'clips' | 'snapshots', ): BrowseMediaQueryParameters[] | null => { const cameraIDs: Set = new Set(); const getDependentCameras = (camera: string): void => { const cameraConfig = cameras.get(camera); if (cameraConfig) { cameraIDs.add(camera); const dependentCameras: Set = new Set(); (cameraConfig.dependencies.cameras || []).forEach((item) => dependentCameras.add(item), ); if (cameraConfig.dependencies.all_cameras) { cameras.forEach((_, key) => dependentCameras.add(key)); } for (const eventCameraID of dependentCameras) { if (!cameraIDs.has(eventCameraID)) { getDependentCameras(eventCameraID); } } } }; getDependentCameras(camera); const params: BrowseMediaQueryParameters[] = []; for (const cameraID of cameraIDs) { const param = getBrowseMediaQueryParameters( hass, cameraID, cameras.get(cameraID), mediaType ? { mediaType: mediaType } : {}, ); if (param) { params.push(param); } } return params.length ? params : null; }; /** * Get BrowseMediaQueryParameters for a camera (including its dependencies) or dispatch an error. * @param element The element from which to dispatch the error. * @param hass Home Assistant object. * @param cameras Cameras map. * @param camera Name of the current camera. * @param mediaType Optional media type to include in the parameters. * @returns An array of query parameters. */ export const getFullDependentBrowseMediaQueryParametersOrDispatchError = ( element: HTMLElement, hass: HomeAssistant, cameras: Map, camera: string, mediaType?: 'clips' | 'snapshots', ): BrowseMediaQueryParameters[] | null => { const params = getFullDependentBrowseMediaQueryParameters( hass, cameras, camera, mediaType, ); if (!params) { dispatchErrorMessageEvent( element, localize('error.no_camera_name'), cameras.get(camera), ); return null; } return params; }; /** * Fetch the latest media and dispatch a change view event to reflect the * results. If no media is found a suitable message event will be triggered * instead. * @param element The HTMLElement to dispatch events from. * @param hass The Home Assistant object. * @param view The current view to evolve. * @param browseMediaQueryParameters The media parameters to query with. * @returns */ export const fetchLatestMediaAndDispatchViewChange = async ( element: HTMLElement, hass: HomeAssistant, view: Readonly, browseMediaQueryParameters: BrowseMediaQueryParameters | BrowseMediaQueryParameters[], ): Promise => { let parent: FrigateBrowseMediaSource | null; try { parent = await multipleBrowseMediaQueryMerged(hass, browseMediaQueryParameters); } catch (e) { return dispatchFrigateCardErrorEvent(element, e as FrigateCardError); } const childIndex = getFirstTrueMediaChildIndex(parent); if (!parent || !parent.children || childIndex == null) { return dispatchMessageEvent( element, view.isClipRelatedView() ? localize('common.no_clip') : localize('common.no_snapshot'), view.isClipRelatedView() ? 'mdi:filmstrip-off' : 'mdi:camera-off', ); } view .evolve({ target: parent, childIndex: childIndex, }) .dispatchChangeEvent(element); }; /** * Fetch the media of a child FrigateBrowseMediaSource object and dispatch a change * view event to reflect the results. * @param node The HTMLElement to dispatch events from. * @param hass The Home Assistant object. * @param view The current view to evolve. * @param child The FrigateBrowseMediaSource child to query for. * @returns */ export const fetchChildMediaAndDispatchViewChange = async ( element: HTMLElement, hass: HomeAssistant, view: Readonly, child: Readonly, ): Promise => { let parent: FrigateBrowseMediaSource; try { parent = await browseMedia(hass, child.media_content_id); } catch (e) { return dispatchFrigateCardErrorEvent(element, e as FrigateCardError); } view .evolve({ target: parent, }) .dispatchChangeEvent(element); }; /** * Given an array of media children, create a parent for them. * @param title The title to use for the parent. * @param children The children media items. * @returns A single parent containing the children. */ export const createEventParentForChildren = ( title: string, children: FrigateBrowseMediaSource[], ): FrigateBrowseMediaSource => { return { title: title, media_class: MEDIA_CLASS_PLAYLIST, media_content_type: MEDIA_TYPE_PLAYLIST, media_content_id: '', can_play: false, can_expand: true, children_media_class: MEDIA_CLASS_PLAYLIST, thumbnail: null, children: children, }; }; /** * Given a media video child with a given media_content_id. * @param title The title to use for the child. * @param media_con * @param children The children media items. * @returns A single parent containing the children. */ export const createVideoChild = ( title: string, mediaContentID: string, options?: { thumbnail?: string; recording?: FrigateRecording; }, ): FrigateBrowseMediaSource => { return { title: title, media_class: MEDIA_CLASS_VIDEO, media_content_type: MEDIA_TYPE_VIDEO, media_content_id: mediaContentID, can_play: true, can_expand: false, thumbnail: options?.thumbnail ?? null, children: null, ...(options?.recording && { frigate: { recording: options.recording, }, }), }; }; /** * Convenience function to convert a timestamp to hours, minutes and seconds * string. Heavily inspired by, and returning the same format as, the Frigate * UI: https://github.com/blakeblackshear/frigate/blob/master/web/src/components/RecordingPlaylist.jsx#L97 * @param event The Frigate event. * @returns A duration string. */ export function getEventDurationString(event: FrigateEvent): string { if (!event.end_time) { return localize('event.in_progress'); } const start = fromUnixTime(event.start_time); const end = fromUnixTime(event.end_time); const hours = differenceInHours(end, start); const minutes = differenceInMinutes(end, start) - hours * 60; const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60; let duration = ''; if (hours) { duration += `${hours}h `; } if (minutes) { duration += `${minutes}m `; } duration += `${seconds}s`; return duration; } /** * Generate a recording identifier. * @param hass The HomeAssistant object. * @param params The recording parameters to use in the identifer. * @returns A recording identifier. */ export const generateRecordingIdentifier = ( params: BrowseRecordingQueryParameters, ): string => { return [ 'media-source://frigate', params.clientId, 'recordings', `${params.year}-${String(params.month).padStart(2, '0')}`, String(params.day).padStart(2, '0'), String(params.hour).padStart(2, '0'), params.cameraName, ].join('/'); };