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