@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user