feat: Implement basic general folder support (#2051)

- Related: #1748
This commit is contained in:
Dermot Duffy
2025-05-21 19:59:21 -07:00
committed by GitHub
parent 2eb0d9e35e
commit c6a4c8aea2
350 changed files with 12837 additions and 4509 deletions
+74
View File
@@ -0,0 +1,74 @@
import { FolderConfig, FolderType, folderTypeSchema } from '../../config/schema/folders';
import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types';
import { ViewItem } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types';
import { sortItems } from '../view/sort';
import { HAFoldersEngine } from './ha/engine';
import { DownloadHelpers, EngineOptions, FolderQuery, FoldersEngine } from './types';
export class FoldersExecutor {
private _ha: FoldersEngine;
constructor(engines?: { ha?: HAFoldersEngine }) {
this._ha = engines?.ha ?? new HAFoldersEngine();
}
public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null {
return (
this._getFolderEngine(folder.type)?.generateDefaultFolderQuery(folder) ?? null
);
}
public async expandFolder(
hass: HomeAssistant,
query: FolderQuery,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> {
const results =
(await this._getFolderEngine(query.folder.type)?.expandFolder(
hass,
query,
engineOptions,
)) ?? null;
return results ? sortItems(results) : null;
}
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
return (
this._getFolderEngine(item.getFolder()?.type)?.getItemCapabilities(item) ?? null
);
}
public async getDownloadPath(
hass: HomeAssistant | null,
item: ViewItem,
helpers?: DownloadHelpers,
): Promise<Endpoint | null> {
return await (this._getFolderEngine(item.getFolder()?.type)?.getDownloadPath(
hass,
item,
helpers,
) ?? null);
}
public async favorite(
hass: HomeAssistant | null,
item: ViewItem,
favorite: boolean,
): Promise<void> {
return await this._getFolderEngine(item.getFolder()?.type)?.favorite(
hass,
item,
favorite,
);
}
private _getFolderEngine(type?: FolderType): FoldersEngine | null {
switch (type) {
case folderTypeSchema.enum.ha:
return this._ha;
}
return null;
}
}
+180
View File
@@ -0,0 +1,180 @@
import { NonEmptyTuple } from 'type-fest';
import {
FolderConfig,
folderTypeSchema,
HA_MEDIA_SOURCE_ROOT,
HAFolderConfig,
HAFolderPathComponent,
} from '../../../config/schema/folders';
import { getViewItemsFromBrowseMediaArray } from '../../../ha/browse-media/browse-media-to-view-media';
import { BrowseMedia, BrowseMediaCache } from '../../../ha/browse-media/types';
import {
BrowseMediaStep,
BrowseMediaTarget,
BrowseMediaWalker,
} from '../../../ha/browse-media/walker';
import { getMediaDownloadPath } from '../../../ha/download';
import { HomeAssistant } from '../../../ha/types';
import { Endpoint } from '../../../types';
import { ViewItem } from '../../../view/item';
import { ViewItemClassifier } from '../../../view/item-classifier';
import { ViewItemCapabilities } from '../../../view/types';
import {
DownloadHelpers,
EngineOptions,
FolderPathComponent,
FolderQuery,
FoldersEngine,
} from '../types';
export class HAFoldersEngine implements FoldersEngine {
private _browseMediaManager: BrowseMediaWalker;
private _cache = new BrowseMediaCache();
public constructor(browseMediaManager?: BrowseMediaWalker) {
this._browseMediaManager = browseMediaManager ?? new BrowseMediaWalker();
}
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
return {
canFavorite: false,
canDownload: !ViewItemClassifier.isFolder(item),
};
}
public async getDownloadPath(
hass: HomeAssistant,
item: ViewItem,
helpers?: DownloadHelpers,
): Promise<Endpoint | null> {
if (!ViewItemClassifier.isMedia(item)) {
return null;
}
return getMediaDownloadPath(hass, item.getContentID(), helpers?.resolvedMediaCache);
}
public async favorite(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_hass: HomeAssistant,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_item: ViewItem,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_favorite: boolean,
): Promise<void> {
return;
}
public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null {
if (folder.type !== folderTypeSchema.enum.ha) {
return null;
}
return {
folder,
path: this.getDefaultFolderPathComponents(folder.ha),
};
}
public async expandFolder(
hass: HomeAssistant,
query: FolderQuery,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> {
if (query.folder.type !== folderTypeSchema.enum.ha) {
return null;
}
const pathComponents = [...query.path];
// Search through the path components from the start to find the last
// component with a precise media source id, which is where the queries
// start (and may drill down from).
let start: string | null = null;
while (pathComponents.length > 0) {
const id = pathComponents[0]?.id;
if (id) {
start = id;
pathComponents.shift();
} else {
break;
}
}
// If no media source id is found, return null, as there is no "starting
// query".
if (start === null) {
return null;
}
// This matcher matches a browse media against a given path component.
const componentMatcher = (
media: BrowseMedia,
component?: FolderPathComponent,
): boolean => {
return (
!component ||
(media.can_expand &&
(component.ha?.title === media.title ||
(component.ha?.title_re &&
new RegExp(component.ha.title_re).test(media.title)) ||
component.id === media.media_content_id))
);
};
// Generate a walk step, optionally matching against the next path component
// (if any), otherwise just returning all the media at this level.
const generateStep = (targets: BrowseMediaTarget[]): BrowseMediaStep[] => {
const nextComponent = pathComponents.shift();
return [
{
targets,
...(nextComponent && {
matcher: (media: BrowseMedia) => componentMatcher(media, nextComponent),
advance: (targets) => generateStep(targets),
}),
},
];
};
const browseMedia = await this._browseMediaManager.walk(
hass,
generateStep([start]),
{
...((engineOptions?.useCache ?? true) && { cache: this._cache }),
},
);
return getViewItemsFromBrowseMediaArray(browseMedia, {
folder: query.folder,
});
}
private getDefaultFolderPathComponents(
haFolderConfig?: HAFolderConfig,
): NonEmptyTuple<FolderPathComponent> {
const shouldAddDefaultRoot = !haFolderConfig?.url && !haFolderConfig?.path?.[0]?.id;
const defaultPath = [
...(shouldAddDefaultRoot ? [{ id: HA_MEDIA_SOURCE_ROOT }] : []),
...(haFolderConfig?.url ?? []),
...(haFolderConfig?.path ?? []),
];
return defaultPath.map((component) =>
this._convertHAPathComponentToFolderPathComponent(component),
) as [FolderPathComponent, ...FolderPathComponent[]];
}
// Convert from the HA folder path component config schema to the general,
// which pulls `path` to the top level.
private _convertHAPathComponentToFolderPathComponent(
component: HAFolderPathComponent,
): FolderPathComponent {
return {
id: component.id,
ha: {
...component,
},
};
}
}
+90
View File
@@ -0,0 +1,90 @@
import { cloneDeep } from 'lodash-es';
import { FolderConfig } from '../../config/schema/folders';
import { localize } from '../../localize/localize';
import { Endpoint } from '../../types';
import { ViewItem } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types';
import { CardFoldersAPI } from '../types';
import { FoldersExecutor } from './executor';
import { EngineOptions, FolderInitializationError, FolderQuery } from './types';
export class FoldersManager {
private _api: CardFoldersAPI;
private _executor: FoldersExecutor;
private _folders: Map<string, FolderConfig> = new Map();
constructor(api: CardFoldersAPI, executor?: FoldersExecutor) {
this._api = api;
this._executor = executor ?? new FoldersExecutor();
}
public deleteFolders(): void {
this._folders.clear();
}
public addFolders(folders: FolderConfig[]): void {
for (const folder of folders) {
const folderNumber = this._folders.size;
const id = folder.id ?? `folder/${folderNumber.toString()}`;
if (this._folders.has(id)) {
throw new FolderInitializationError(
localize('error.duplicate_folder_id'),
folder,
);
}
this._folders.set(id, {
title: `${localize('common.folder')} ${folderNumber}`,
...cloneDeep(folder),
id,
});
}
}
public getFolderCount(): number {
return this._folders.size;
}
public getFolders(): MapIterator<[string, FolderConfig]> {
return this._folders.entries();
}
public getFolder(id?: string): FolderConfig | null {
return id
? this._folders.get(id) ?? null
: this._folders.values().next().value ?? null;
}
public generateDefaultFolderQuery(folder?: FolderConfig): FolderQuery | null {
const _folder = folder ?? this.getFolder();
return _folder ? this._executor.generateDefaultFolderQuery(_folder) : null;
}
public async expandFolder(
query: FolderQuery,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> {
const hass = this._api.getHASSManager().getHASS();
return hass ? this._executor.expandFolder(hass, query, engineOptions) : null;
}
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
return this._executor.getItemCapabilities(item);
}
public async getDownloadPath(item: ViewItem): Promise<Endpoint | null> {
return await this._executor.getDownloadPath(
this._api.getHASSManager().getHASS(),
item,
{
resolvedMediaCache: this._api.getResolvedMediaCache(),
},
);
}
public async favorite(item: ViewItem, favorite: boolean): Promise<void> {
return await this._executor.favorite(
this._api.getHASSManager().getHASS(),
item,
favorite,
);
}
}
+60
View File
@@ -0,0 +1,60 @@
import { NonEmptyTuple } from 'type-fest';
import { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders';
import { ResolvedMediaCache } from '../../ha/resolved-media';
import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types';
import { AdvancedCameraCardError } from '../../types.js';
import { ViewItem } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types';
// ====
// Base
// ====
export interface EngineOptions {
useCache?: boolean;
}
export class FolderInitializationError extends AdvancedCameraCardError {}
// ============
// Folder Query
// ============
export type FolderPathComponent = {
id?: string;
ha?: Omit<HAFolderPathComponent, 'id'>;
};
export interface FolderQuery {
folder: FolderConfig;
// A trail of paths to navigate back to the "root", with the last path being
// the path that this query directly refers to.
path: NonEmptyTuple<FolderPathComponent>;
}
// ===============
// Folders Engines
// ===============
export interface DownloadHelpers {
resolvedMediaCache?: ResolvedMediaCache | null;
}
export interface FoldersEngine {
generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null;
expandFolder(
hass: HomeAssistant,
query: FolderQuery,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null>;
getItemCapabilities(item: ViewItem): ViewItemCapabilities | null;
getDownloadPath(
hass: HomeAssistant | null,
item: ViewItem,
options?: DownloadHelpers,
): Promise<Endpoint | null>;
favorite(hass: HomeAssistant | null, item: ViewItem, favorite: boolean): Promise<void>;
}