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
+131
View File
@@ -0,0 +1,131 @@
import { format } from 'date-fns';
import { localize } from '../../localize/localize';
import { AdvancedCameraCardError } from '../../types';
import { errorToConsole } from '../../utils/basic';
import { downloadURL } from '../../utils/download';
import { homeAssistantSignPath } from '../../ha/sign-path';
import { ViewItem } from '../../view/item';
import { ViewItemClassifier } from '../../view/item-classifier';
import { ViewItemCapabilities } from '../../view/types';
import { CardViewAPI } from '../types';
enum ViewMediaSource {
Camera = 'camera',
Folder = 'folder',
}
export class ViewItemManager {
private _api: CardViewAPI;
constructor(api: CardViewAPI) {
this._api = api;
}
public getCapabilities(item: ViewItem): ViewItemCapabilities | null {
const source = this._getMediaSource(item);
if (source === ViewMediaSource.Camera && ViewItemClassifier.isMedia(item)) {
return this._api.getCameraManager().getMediaCapabilities(item);
}
if (source === ViewMediaSource.Folder) {
return this._api.getFoldersManager().getItemCapabilities(item);
}
return null;
}
public async download(item: ViewItem): Promise<boolean> {
try {
await this._download(item);
} catch (error: unknown) {
this._api.getMessageManager().setErrorIfHigherPriority(error);
return false;
}
return true;
}
public async favorite(item: ViewItem, favorite: boolean): Promise<void> {
const source = this._getMediaSource(item);
if (source === ViewMediaSource.Camera && ViewItemClassifier.isMedia(item)) {
return await this._api.getCameraManager().favoriteMedia(item, favorite);
}
/* istanbul ignore else: this path cannot be reached -- @preserve */
if (source === ViewMediaSource.Folder) {
return this._api.getFoldersManager().favorite(item, favorite);
}
}
private _getMediaSource(item: ViewItem): ViewMediaSource | null {
if (ViewItemClassifier.isMedia(item) && item.getCameraID()) {
return ViewMediaSource.Camera;
}
if (ViewItemClassifier.isFolder(item) || item.getFolder()) {
return ViewMediaSource.Folder;
}
/* istanbul ignore next: this path cannot be reached -- @preserve */
return null;
}
private async _download(item: ViewItem): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
return;
}
const source = this._getMediaSource(item);
const endpoint =
source === ViewMediaSource.Camera && ViewItemClassifier.isMedia(item)
? await this._api.getCameraManager().getMediaDownloadPath(item)
: source === ViewMediaSource.Folder
? await this._api.getFoldersManager().getDownloadPath(item)
: null;
if (!endpoint) {
throw new AdvancedCameraCardError(localize('error.download_no_media'));
}
let finalURL = endpoint.endpoint;
if (endpoint.sign) {
let response: string | null | undefined;
try {
response = await homeAssistantSignPath(hass, endpoint.endpoint);
} catch (e) {
errorToConsole(e as Error);
}
if (!response) {
throw new AdvancedCameraCardError(localize('error.download_sign_failed'));
}
finalURL = response;
}
downloadURL(finalURL, this._generateDownloadFilename(item));
}
private _generateDownloadFilename(item: ViewItem): string {
const toFilename = (input: string): string => {
return input.toLowerCase().replaceAll(/[^a-z0-9]/gi, '-');
};
if (ViewItemClassifier.isMedia(item)) {
const cameraID = item.getCameraID();
const id = item.getID();
const startTime = item.getStartTime();
return (
(cameraID ? toFilename(cameraID) : 'media') +
(id ? `_${toFilename(id)}` : '') +
(startTime ? `_${format(startTime, `yyyy-MM-dd-HH-mm-ss`)}` : '') +
('.' + (item.getMediaType() === 'clip' ? 'mp4' : 'jpg'))
);
}
/* istanbul ignore else: this path cannot be reached -- @preserve */
if (ViewItemClassifier.isFolder(item)) {
return toFilename(item.getTitle() ?? 'media');
}
/* istanbul ignore next: this path cannot be reached -- @preserve */
return 'download';
}
}
@@ -1,16 +1,13 @@
import { MediaQueries } from '../../../view/media-queries';
import { MediaQueriesResults } from '../../../view/media-queries-results';
import { Query } from '../../../view/query';
import { QueryResults } from '../../../view/query-results';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
export class SetQueryViewModifier implements ViewModifier {
protected _query?: MediaQueries | null;
protected _queryResults?: MediaQueriesResults | null;
protected _query?: Query | null;
protected _queryResults?: QueryResults | null;
constructor(options?: {
query?: MediaQueries | null;
queryResults?: MediaQueriesResults | null;
}) {
constructor(options?: { query?: Query | null; queryResults?: QueryResults | null }) {
this._query = options?.query;
this._queryResults = options?.queryResults;
}
+71 -26
View File
@@ -1,13 +1,17 @@
import { CapabilitySearchOptions, MediaQuery } from '../../camera-manager/types';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
import { ClipsOrSnapshotsOrAll } from '../../types';
import { findBestMediaIndex } from '../../utils/find-best-media-index';
import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index';
import { ViewItem } from '../../view/item';
import {
EventMediaQueries,
EventMediaQuery,
FolderViewQuery,
MediaQueries,
RecordingMediaQueries,
} from '../../view/media-queries';
import { MediaQueriesResults } from '../../view/media-queries-results';
Query,
RecordingMediaQuery,
} from '../../view/query';
import { QueryClassifier } from '../../view/query-classifier';
import { QueryResults } from '../../view/query-results';
import { CardViewAPI } from '../types';
import { QueryExecutorOptions, QueryExecutorResult } from './types';
@@ -48,14 +52,8 @@ export class QueryExecutor {
if (!rawQueries) {
return null;
}
const queries = new EventMediaQueries(rawQueries);
const results = await this.execute(queries, options?.executorOptions);
return results
? {
query: queries,
queryResults: results,
}
: null;
const queries = new EventMediaQuery(rawQueries);
return await this.executeMediaQuery(queries, options?.executorOptions);
}
public async executeDefaultRecordingQuery(options?: {
@@ -76,16 +74,30 @@ export class QueryExecutor {
if (!rawQueries) {
return null;
}
const queries = new RecordingMediaQueries(rawQueries);
const results = await this.execute(queries, options?.executorOptions);
return results ? { query: queries, queryResults: results } : null;
const queries = new RecordingMediaQuery(rawQueries);
return await this.executeMediaQuery(queries, options?.executorOptions);
}
public async execute(
public async executeQuery(
query: Query,
executorOptions?: QueryExecutorOptions,
): Promise<QueryExecutorResult | null> {
/* istanbul ignore else: this path cannot be reached -- @preserve */
if (QueryClassifier.isMediaQuery(query)) {
return await this.executeMediaQuery(query, executorOptions);
} else if (QueryClassifier.isFolderQuery(query)) {
return await this._executeFolderQuery(query, executorOptions);
}
/* istanbul ignore next: this path cannot be reached -- @preserve */
return null;
}
public async executeMediaQuery(
query: MediaQueries,
executorOptions?: QueryExecutorOptions,
): Promise<MediaQueriesResults | null> {
const queries = query.getQueries();
): Promise<QueryExecutorResult | null> {
const queries = query.getQuery();
if (!queries) {
return null;
}
@@ -95,11 +107,17 @@ export class QueryExecutor {
.executeMediaQueries<MediaQuery>(queries, {
useCache: executorOptions?.useCache,
});
if (!mediaArray) {
return null;
}
const queryResults = mediaArray
? this._generateQueriesResults(mediaArray, executorOptions)
: null;
return queryResults ? { query, queryResults } : null;
}
const queryResults = new MediaQueriesResults({ results: mediaArray });
private _generateQueriesResults(
itemArray: ViewItem[],
executorOptions?: QueryExecutorOptions,
): QueryResults | null {
const queryResults = new QueryResults({ results: itemArray });
if (executorOptions?.rejectResults?.(queryResults)) {
return null;
}
@@ -111,9 +129,9 @@ export class QueryExecutor {
} else if (executorOptions?.selectResult?.func) {
queryResults.selectResultIfFound(executorOptions.selectResult.func);
} else if (executorOptions?.selectResult?.time) {
queryResults.selectBestResult((media) =>
findBestMediaIndex(
media,
queryResults.selectBestResult((itemArray) =>
findBestMediaTimeIndex(
itemArray,
executorOptions.selectResult?.time?.time as Date,
executorOptions.selectResult?.time?.favorCameraID,
),
@@ -122,6 +140,33 @@ export class QueryExecutor {
return queryResults;
}
public async executeDefaultFolderQuery(
executorOptions?: QueryExecutorOptions,
): Promise<QueryExecutorResult | null> {
const query = this._api.getFoldersManager().generateDefaultFolderQuery();
return query
? this._executeFolderQuery(new FolderViewQuery(query), executorOptions)
: null;
}
private async _executeFolderQuery(
query: FolderViewQuery,
executorOptions?: QueryExecutorOptions,
): Promise<QueryExecutorResult | null> {
const rawQuery = query.getQuery();
if (!rawQuery) {
return null;
}
const itemArray = await this._api
.getFoldersManager()
.expandFolder(rawQuery, { useCache: executorOptions?.useCache });
const queryResults = itemArray
? this._generateQueriesResults(itemArray, executorOptions)
: null;
return queryResults ? { query, queryResults } : null;
}
protected _getChunkLimit(): number {
const cardWideConfig = this._api.getConfigManager().getCardWideConfig();
return (
+23
View File
@@ -0,0 +1,23 @@
import { orderBy, uniqBy } from 'lodash-es';
import { ViewItem } from '../../view/item';
import { ViewItemClassifier } from '../../view/item-classifier';
export const sortItems = <T extends ViewItem>(itemArray: T[]): T[] => {
return orderBy(
// Ensure uniqueness by the ID (if specified), otherwise all elements
// are assumed to be unique.
uniqBy(itemArray, (item) => item.getID() ?? item),
[
// Pull folders to the front.
(item) => !ViewItemClassifier.isFolder(item),
// Sort by time and id.
(item) =>
ViewItemClassifier.isMedia(item)
? item.getStartTime() ?? item.getID()
: item.getID(),
],
['asc', 'asc'],
);
};
+7 -7
View File
@@ -1,9 +1,9 @@
import { ViewContext } from 'view';
import { AdvancedCameraCardView } from '../../config/schema/common/const.js';
import { AdvancedCameraCardError } from '../../types.js';
import { MediaQueriesResults } from '../../view/media-queries-results.js';
import { MediaQueries } from '../../view/media-queries.js';
import { ViewMedia } from '../../view/media.js';
import { ViewItem } from '../../view/item.js';
import { QueryResults } from '../../view/query-results.js';
import { Query } from '../../view/query.js';
import { View, ViewParameters } from '../../view/view.js';
export interface ViewModifier {
@@ -20,15 +20,15 @@ export interface QueryExecutorOptions {
favorCameraID?: string;
};
id?: string;
func?: (media: ViewMedia) => boolean;
func?: (media: ViewItem) => boolean;
};
rejectResults?: (results: MediaQueriesResults) => boolean;
rejectResults?: (results: QueryResults) => boolean;
useCache?: boolean;
}
export interface QueryExecutorResult {
query: MediaQueries;
queryResults: MediaQueriesResults;
query: Query;
queryResults: QueryResults;
}
export interface ViewFactoryOptions {
+28 -4
View File
@@ -2,9 +2,10 @@ import { ViewContext } from 'view';
import { AdvancedCameraCardView } from '../../config/schema/common/const';
import { log } from '../../utils/debug';
import { getStreamCameraID } from '../../utils/substream';
import { MediaQueriesClassifier } from '../../view/media-queries-classifier';
import { QueryClassifier } from '../../view/query-classifier';
import { View } from '../../view/view';
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
import { InitializationAspect } from '../initialization-manager';
import { CardViewAPI } from '../types';
import { ViewFactory } from './factory';
import { applyViewModifiers } from './modifiers';
@@ -103,6 +104,10 @@ export class ViewManager implements ViewManagerInterface {
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
options?: ViewFactoryOptions,
): void {
if (!this._isAllowedToSetView()) {
return;
}
let view: View | null = null;
try {
view = viewFactoryFunc({
@@ -112,7 +117,9 @@ export class ViewManager implements ViewManagerInterface {
} catch (e) {
this._api.getMessageManager().setErrorIfHigherPriority(e);
}
view && this._setView(view);
if (view) {
this._setView(view);
}
}
protected _markViewLoadingQuery(view: View, index: number): View {
@@ -122,6 +129,19 @@ export class ViewManager implements ViewManagerInterface {
return view.removeContextProperty('loading', 'query');
}
protected _isAllowedToSetView(): boolean {
// It is possible to have a race condition where the view is being set at
// the same time as the cameras being initialized. Test case: Open
// folder-based media in the media viewer carousel, then attempt to edit the
// card -- this causes the cameras to re-initialize at the same time as
// folder media is reporting observed zoom settings in the view context.
// Without this check, that will result in a "No cameras support this view"
// message.
return this._api
.getInitializationManager()
.isInitialized(InitializationAspect.CAMERAS);
}
protected async _setViewThenModifyAsync(
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
viewModifiersFunc: (
@@ -130,6 +150,10 @@ export class ViewManager implements ViewManagerInterface {
) => Promise<ViewModifier[] | null>,
options?: ViewFactoryOptions,
): Promise<void> {
if (!this._isAllowedToSetView()) {
return;
}
let initialView: View | null = null;
try {
initialView = viewFactoryFunc({
@@ -220,10 +244,10 @@ export class ViewManager implements ViewManagerInterface {
// See: https://github.com/dermotduffy/advanced-camera-card/issues/885
const switchingFromViewerToGallery =
this._view?.isViewerView() && newView?.isGalleryView();
this._view?.isViewerView() && newView?.isMediaGalleryView();
const newMediaType = newView?.getDefaultMediaType();
const alreadyHasMatchingQuery =
MediaQueriesClassifier.getMediaType(this._view?.query) === newMediaType;
QueryClassifier.getMediaType(this._view?.query) === newMediaType;
return !!switchingFromViewerToGallery && alreadyHasMatchingQuery;
}
@@ -29,7 +29,9 @@ export class ViewQueryExecutor {
return view.query
? [
new SetQueryViewModifier({
queryResults: await this._executor.execute(view.query, queryExecutorOptions),
queryResults: (
await this._executor.executeQuery(view.query, queryExecutorOptions)
)?.queryResults,
}),
]
: [];
@@ -83,6 +85,12 @@ export class ViewQueryExecutor {
return results ? [new SetQueryViewModifier(results)] : [];
};
const executeFolderQuery = async (): Promise<ViewModifier[]> => {
const results =
await this._executor.executeDefaultFolderQuery(queryExecutorOptions);
return results ? [new SetQueryViewModifier(results)] : [];
};
switch (view.view) {
case 'live':
if (config.live.controls.thumbnails.mode !== 'none') {
@@ -111,6 +119,9 @@ export class ViewQueryExecutor {
case 'recordings':
viewModifiers.push(...(await executeMediaQuery(mediaType)));
break;
case 'folder':
viewModifiers.push(...(await executeFolderQuery()));
break;
}
viewModifiers.push(...this._getTimelineWindowViewModifier(view));