import { add } from 'date-fns'; import { chunk } from 'lodash-es'; import { allPromises } from '../../utils/basic'; import { HomeAssistant } from '../types'; import { homeAssistantWSRequest } from '../ws-request'; import { BROWSE_MEDIA_CACHE_SECONDS, BrowseMedia, BrowseMediaCache, browseMediaSchema, RichBrowseMedia, } from './types'; type RichMetadataGenerator = ( media: BrowseMedia, parent?: RichBrowseMedia, ) => M | null; export type BrowseMediaTarget = string | RichBrowseMedia; type RichBrowseMediaPredicate = (media: RichBrowseMedia) => boolean; export interface BrowseMediaStep { // The targets to start the media walk from. targets: BrowseMediaTarget[]; // How many children to process concurrently. Default is infinite. concurrency?: number; // All children of the target have the metadata generator applied to them // first. metadataGenerator?: RichMetadataGenerator; // If those children pass this matcher, then they will be included in the // output. matcher?: RichBrowseMediaPredicate; // Children (once past the matcher) will be sorted before the next step. sorter?: (media: RichBrowseMedia[]) => RichBrowseMedia[]; // Whether to exit the walk early with the given output. earlyExit?: (media: RichBrowseMedia[]) => boolean; // advance will be called to generate a next step (or null if the child should // just be included straight through to the output with no further steps). advance?: BrowseMediaStepAdvancer; } type BrowseMediaStepAdvancer = (media: RichBrowseMedia[]) => BrowseMediaStep[]; export class BrowseMediaWalker { // Walk down a browse media tree according to instructions included in `steps`. public async walk( hass: HomeAssistant, steps: BrowseMediaStep[] | null, options?: { cache?: BrowseMediaCache; }, ): Promise[]> { if (!steps || !steps.length) { return []; } return ( await allPromises( steps, async (step) => await this._walkBrowseMedia(hass, step, options), ) ).flat(); } protected async _walkBrowseMedia( hass: HomeAssistant, step: BrowseMediaStep, options?: { cache?: BrowseMediaCache; }, ): Promise[]> { let output: RichBrowseMedia[] = []; for (const targetChunk of chunk(step.targets, step.concurrency ?? Infinity)) { const mediaChunk = await allPromises( targetChunk, async (target) => await this._browseMedia(hass, target, { cache: options?.cache, metadataGenerator: step.metadataGenerator, }), ); for (const parent of mediaChunk) { for (const child of parent.children ?? []) { if (!step.matcher || step.matcher(child)) { output.push(child); } } } if (step.sorter) { output = step.sorter(output); } if (step.earlyExit && step.earlyExit(output)) { break; } } const nextSteps = step.advance ? step.advance(output) : null; if (!nextSteps?.length) { return output; } return await this.walk(hass, nextSteps, options); } protected async _browseMedia( hass: HomeAssistant, target: string | RichBrowseMedia, options?: { cache?: BrowseMediaCache; metadataGenerator?: RichMetadataGenerator; }, ): Promise> { const mediaContentID = typeof target === 'object' ? target.media_content_id : target; const cachedResult = options?.cache ? options.cache.get(mediaContentID) : null; if (cachedResult) { return cachedResult; } const request = { type: 'media_source/browse_media', media_content_id: mediaContentID, }; const browseMedia = await homeAssistantWSRequest>( hass, browseMediaSchema, request, ); if (options?.metadataGenerator) { for (const child of browseMedia.children ?? []) { child._metadata = options.metadataGenerator( child, typeof target === 'object' ? target : undefined, ) ?? undefined; } } if (options?.cache) { options.cache.set( mediaContentID, browseMedia, add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }), ); } return browseMedia; } }