fix: Remove the unnecessary item limit from folder media (#2688)

- Fixed: #2573
This commit is contained in:
Dermot Duffy
2026-08-17 21:49:11 -07:00
committed by GitHub
parent 858c5d0610
commit 84b90bcc7e
12 changed files with 41 additions and 146 deletions
+3 -3
View File
@@ -313,7 +313,7 @@ folders:
id: my-folder id: my-folder
ha: ha:
url: >- url: >-
https://ha.ondu.org/media-browser/browser/app%2Cmedia-source%3A%2F%2Fmedia_source /media-browser/browser/app%2Cmedia-source%3A%2F%2Fmedia_source
path: path:
# Match everything, parse nothing. # Match everything, parse nothing.
- {} - {}
@@ -331,7 +331,7 @@ folders:
id: my-folder id: my-folder
ha: ha:
url: >- url: >-
https://ha.ondu.org/media-browser/browser/app%2Cmedia-source%3A%2F%2Fmedia_source /media-browser/browser/app%2Cmedia-source%3A%2F%2Fmedia_source
path: path:
- {} - {}
# At the final level, match everything, parse the date and time. # At the final level, match everything, parse the date and time.
@@ -350,7 +350,7 @@ folders:
id: my-folder id: my-folder
ha: ha:
url: >- url: >-
https://ha.ondu.org/media-browser/browser/app%2Cmedia-source%3A%2F%2Fmedia_source /media-browser/browser/app%2Cmedia-source%3A%2F%2Fmedia_source
path: path:
- {} - {}
# At the final level, match everything, parse the date and time. # At the final level, match everything, parse the date and time.
+7 -7
View File
@@ -22,13 +22,13 @@ performance:
# [...] # [...]
``` ```
| Option | Default | Description | | Option | Default | Description |
| ---------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `animated_progress_indicator` | `true` | Will show the animated progress indicator 'spinners' when `true`. | | `animated_progress_indicator` | `true` | Will show the animated progress indicator 'spinners' when `true`. |
| `card_loading_effects` | `true` | Will occasionally show (silly) visual effects during card loading (e.g. fireworks on New Year's Day). | | `card_loading_effects` | `true` | Will occasionally show (silly) visual effects during card loading (e.g. fireworks on New Year's Day). |
| `card_loading_indicator` | `true` | Will show the card loading indicator (spinner & version number) and effects (see `card_loading_effects` below) when `true`. | | `card_loading_indicator` | `true` | Will show the card loading indicator (spinner & version number) and effects (see `card_loading_effects` below) when `true`. |
| `media_chunk_size` | `50` | How many media items to fetch and render at a time (e.g. thumbnails under a live view, or number of snapshots to load in the media viewer). This may only make partial sense in some contexts (e.g. the 'infinite gallery' is still infinite, it just loads thumbnails this many items at a time) or not at all (e.g. the timeline will show the number of events dictated by the time span the user navigates to). | | `media_chunk_size` | `50` | How many media items to fetch and render at a time (e.g. thumbnails under a live view, or number of snapshots to load in the media viewer). This may only make partial sense in some contexts (e.g. the 'infinite gallery' is still infinite, it just loads thumbnails this many items at a time) or not at all (e.g. the timeline will show the number of events dictated by the time span the user navigates to, and [`folders`](./folders.md) always show every matching item). |
| `max_simultaneous_engine_requests` | _Infinity_ | How many camera engine requests to allow occur in parallel. Setting lower values will slow the card down since more requests will run in sequence, but it will increase the chances of positive cache hit rates and reduce the chances of overwhelming the backend. | | `max_simultaneous_engine_requests` | _Infinity_ | How many camera engine requests to allow occur in parallel. Setting lower values will slow the card down since more requests will run in sequence, but it will increase the chances of positive cache hit rates and reduce the chances of overwhelming the backend. |
### `style` ### `style`
+1 -8
View File
@@ -166,7 +166,6 @@ export class HAFoldersEngine implements FoldersEngine {
targets: BrowseMediaTarget<BrowseMediaMetadata>[], targets: BrowseMediaTarget<BrowseMediaMetadata>[],
): BrowseMediaStep<BrowseMediaMetadata>[] => { ): BrowseMediaStep<BrowseMediaMetadata>[] => {
const nextComponent = pathComponents.shift(); const nextComponent = pathComponents.shift();
const limit = query.limit ?? null;
return [ return [
{ {
@@ -174,11 +173,6 @@ export class HAFoldersEngine implements FoldersEngine {
metadataGenerator: (media, parent) => metadataGenerator: (media, parent) =>
this._metadataGenerator.generate(media, parent, nextComponent?.ha?.parsers), this._metadataGenerator.generate(media, parent, nextComponent?.ha?.parsers),
// At the final step (no nextComponent), apply limit via earlyExit.
...(limit && {
earlyExit: (media) => media.length >= limit,
}),
...(nextComponent && { ...(nextComponent && {
matcher: (media) => matcher: (media) =>
this._mediaMatcher.match(hass, media, { this._mediaMatcher.match(hass, media, {
@@ -202,11 +196,10 @@ export class HAFoldersEngine implements FoldersEngine {
}, },
); );
const results = getViewItemsFromBrowseMediaArray(browseMedia, { return getViewItemsFromBrowseMediaArray(browseMedia, {
folder: query.folder, folder: query.folder,
path: query.path, path: query.path,
}); });
return query.limit ? results.slice(0, query.limit) : results;
} }
public generateChildFolderQuery( public generateChildFolderQuery(
-1
View File
@@ -39,7 +39,6 @@ export interface FolderQuery extends BaseQuery, QueryFilters {
// A trail of paths to navigate back to the "root", with the last path being // A trail of paths to navigate back to the "root", with the last path being
// the path that this query directly refers to. // the path that this query directly refers to.
path: NonEmptyTuple<FolderPathComponent>; path: NonEmptyTuple<FolderPathComponent>;
limit?: number;
} }
// =============== // ===============
@@ -205,9 +205,7 @@ export class ViewQueryExecutor {
case 'folders': case 'folders':
viewModifiers.push( viewModifiers.push(
...(await executeQuery( ...(await executeQuery(
builder.buildDefaultFolderQuery(queryExecutorOptions?.folder, { builder.buildDefaultFolderQuery(queryExecutorOptions?.folder),
limit: this._getLimit(),
}),
)), )),
); );
break; break;
-8
View File
@@ -10,8 +10,6 @@ import type { UnifiedQueryBuilder } from '../view/unified-query-builder';
export interface FolderNavigationParamaters { export interface FolderNavigationParamaters {
viewManagerEpoch: ViewManagerEpoch; viewManagerEpoch: ViewManagerEpoch;
builder: UnifiedQueryBuilder; builder: UnifiedQueryBuilder;
limit?: number;
} }
export interface MediaNavigationParamaters { export interface MediaNavigationParamaters {
@@ -51,9 +49,6 @@ export const navigateUp = (options?: FolderNavigationParamaters | null): void =>
const query = options?.builder.buildFolderQueryWithPath( const query = options?.builder.buildFolderQueryWithPath(
folderQuery.folder, folderQuery.folder,
nonEmptyPath, nonEmptyPath,
{
limit: options?.limit,
},
); );
void options?.viewManagerEpoch.manager.setViewByParametersWithExistingQuery({ void options?.viewManagerEpoch.manager.setViewByParametersWithExistingQuery({
@@ -73,9 +68,6 @@ export const navigateToFolder = (
const query = options?.builder.buildFolderQueryWithPath( const query = options?.builder.buildFolderQueryWithPath(
item.getFolder(), item.getFolder(),
nonEmptyPath, nonEmptyPath,
{
limit: options?.limit,
},
); );
void options?.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ void options?.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
-9
View File
@@ -26,7 +26,6 @@ import {
import type { ConditionStateManagerReadonlyInterface } from '../../condition-trigger/conditions/types.js'; import type { ConditionStateManagerReadonlyInterface } from '../../condition-trigger/conditions/types.js';
import type { MediaGalleryConfig } from '../../config/schema/media-gallery.js'; import type { MediaGalleryConfig } from '../../config/schema/media-gallery.js';
import type { CardWideConfig } from '../../config/schema/types.js'; import type { CardWideConfig } from '../../config/schema/types.js';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const.js';
import type { HomeAssistant } from '../../ha/types.js'; import type { HomeAssistant } from '../../ha/types.js';
import galleryStyle from '../../scss/gallery.scss?inline'; import galleryStyle from '../../scss/gallery.scss?inline';
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
@@ -101,19 +100,11 @@ export class AdvancedCameraCardGallery extends LitElement {
} }
} }
private _getLimit(): number {
return (
this.cardWideConfig?.performance?.features?.media_chunk_size ??
MEDIA_CHUNK_SIZE_DEFAULT
);
}
private _getFolderNavigationParameters(): FolderNavigationParamaters | null { private _getFolderNavigationParameters(): FolderNavigationParamaters | null {
return this._builder && this.viewManagerEpoch return this._builder && this.viewManagerEpoch
? { ? {
builder: this._builder, builder: this._builder,
viewManagerEpoch: this.viewManagerEpoch, viewManagerEpoch: this.viewManagerEpoch,
limit: this._getLimit(),
} }
: null; : null;
} }
-9
View File
@@ -23,7 +23,6 @@ import {
} from '../components-lib/navigation.js'; } from '../components-lib/navigation.js';
import type { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js'; import type { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js';
import type { CardWideConfig } from '../config/schema/types.js'; import type { CardWideConfig } from '../config/schema/types.js';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
import type { HomeAssistant } from '../ha/types.js'; import type { HomeAssistant } from '../ha/types.js';
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss?inline'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss?inline';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
@@ -73,19 +72,11 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
private _thumbnails: TemplateResult[] = []; private _thumbnails: TemplateResult[] = [];
private _builder: UnifiedQueryBuilder | null = null; private _builder: UnifiedQueryBuilder | null = null;
private _getLimit(): number {
return (
this.cardWideConfig?.performance?.features?.media_chunk_size ??
MEDIA_CHUNK_SIZE_DEFAULT
);
}
private _getFolderNavOptions(): FolderNavigationParamaters | undefined { private _getFolderNavOptions(): FolderNavigationParamaters | undefined {
return this._builder && this.viewManagerEpoch return this._builder && this.viewManagerEpoch
? { ? {
builder: this._builder, builder: this._builder,
viewManagerEpoch: this.viewManagerEpoch, viewManagerEpoch: this.viewManagerEpoch,
limit: this._getLimit(),
} }
: undefined; : undefined;
} }
+2 -4
View File
@@ -267,14 +267,12 @@ export class UnifiedQueryBuilder {
public buildFolderQueryWithPath( public buildFolderQueryWithPath(
folder: FolderConfig, folder: FolderConfig,
path: NonEmptyTuple<FolderPathComponent>, path: NonEmptyTuple<FolderPathComponent>,
options?: QueryFiltersOptions,
): UnifiedQuery { ): UnifiedQuery {
const query = new UnifiedQuery(); const query = new UnifiedQuery();
const folderQuery: FolderQuery = { const folderQuery: FolderQuery = {
source: QuerySource.Folder, source: QuerySource.Folder,
folder, folder,
path, path,
...(options?.limit !== undefined && { limit: options.limit }),
}; };
query.addNode(folderQuery); query.addNode(folderQuery);
return query; return query;
@@ -282,7 +280,7 @@ export class UnifiedQueryBuilder {
public buildDefaultFolderQuery( public buildDefaultFolderQuery(
folderID?: string, folderID?: string,
options?: QueryFiltersOptions, options?: QueryFilters,
): UnifiedQuery | null { ): UnifiedQuery | null {
const query = new UnifiedQuery(); const query = new UnifiedQuery();
this._addNode(query, this._buildFolderQueryNode(folderID, options)); this._addNode(query, this._buildFolderQueryNode(folderID, options));
@@ -317,7 +315,7 @@ export class UnifiedQueryBuilder {
return params return params
? { ? {
...params, ...params,
...options, ...this._extractFilterOptions(options),
} }
: null; : null;
} }
@@ -238,12 +238,11 @@ describe('HAFoldersEngine', () => {
expect(await engine.expandFolder(createHASS(), query)).toBeNull(); expect(await engine.expandFolder(createHASS(), query)).toBeNull();
}); });
it('should early exit when limit is reached', async () => { it('should return every matching media item without a cap', async () => {
const query: FolderQuery = { const query: FolderQuery = {
source: QuerySource.Folder, source: QuerySource.Folder,
folder: { type: 'ha', id: 'test' }, folder: { type: 'ha', id: 'test' },
path: [{ ha: { id: 'media-source://id' } }], path: [{ ha: { id: 'media-source://id' } }],
limit: 1,
}; };
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce( vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(
@@ -266,8 +265,7 @@ describe('HAFoldersEngine', () => {
const engine = new HAFoldersEngine(templateManager); const engine = new HAFoldersEngine(templateManager);
const results = await engine.expandFolder(createHASS(), query); const results = await engine.expandFolder(createHASS(), query);
// Even though there are 2 children, the limit of 1 should trigger earlyExit. expect(results?.length).toBe(2);
expect(results?.length).toBe(1);
}); });
// See additional matcher testing in media-matcher.test.ts . // See additional matcher testing in media-matcher.test.ts .
-54
View File
@@ -157,35 +157,6 @@ describe('navigateUp', () => {
path: [{ ha: { id: 'one' } }, { ha: { id: 'two' } }], path: [{ ha: { id: 'one' } }, { ha: { id: 'two' } }],
}); });
}); });
it('should go up in the folder hierarchy with limit', () => {
const api = createCardAPI();
const folder = createFolder();
const view = createView({
query: createFolderQuery(folder, [{ ha: { id: 'one' } }, { ha: { id: 'two' } }]),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
const builder = new UnifiedQueryBuilder(
mock<CameraManager>(),
mock<FoldersManager>(),
);
const options: FolderNavigationParamaters = {
builder,
viewManagerEpoch: epoch,
limit: 50,
};
navigateUp(options);
const query = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery)
.mock.calls[0][0]?.params?.query as UnifiedQuery;
expect(query.getNodes()[0].limit).toBe(50);
});
}); });
describe('navigateToFolder', () => { describe('navigateToFolder', () => {
@@ -241,31 +212,6 @@ describe('navigateToFolder', () => {
expect(nodes?.[0]).toHaveProperty('path'); expect(nodes?.[0]).toHaveProperty('path');
expect((nodes?.[0] as { path: readonly unknown[] }).path).toHaveLength(2); expect((nodes?.[0] as { path: readonly unknown[] }).path).toHaveLength(2);
}); });
it('should navigate into folder with limit', () => {
const api = createCardAPI();
const folder = createFolder();
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
const builder = new UnifiedQueryBuilder(
mock<CameraManager>(),
mock<FoldersManager>(),
);
const options: FolderNavigationParamaters = {
builder,
viewManagerEpoch: epoch,
limit: 100,
};
const item = new ViewFolder(folder, [{ ha: { id: 'root' } }]);
navigateToFolder(item, options);
const query = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery)
.mock.calls[0][0]?.params?.query;
expect(query?.getNodes()[0].limit).toBe(100);
});
}); });
describe('getUpFolderItem', () => { describe('getUpFolderItem', () => {
+25 -36
View File
@@ -499,22 +499,7 @@ describe('UnifiedQueryBuilder', () => {
}); });
}); });
it('should apply limit option', () => { it('should not limit the number of items', () => {
const { cameraManager, foldersManager } = createMocks();
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const folder = createFolder({ id: 'folder1', title: 'Test' });
const path: [FolderPathComponent] = [{ ha: { id: 'Root' } }];
const query = builder.buildFolderQueryWithPath(folder, path, {
limit: 10,
});
expect(query.getNodes()[0]).toMatchObject({
source: QuerySource.Folder,
limit: 10,
});
});
it('should not include limit when not provided', () => {
const { cameraManager, foldersManager } = createMocks(); const { cameraManager, foldersManager } = createMocks();
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const folder = createFolder({ id: 'folder1', title: 'Test' }); const folder = createFolder({ id: 'folder1', title: 'Test' });
@@ -568,26 +553,6 @@ describe('UnifiedQueryBuilder', () => {
expect(query).toBeNull(); expect(query).toBeNull();
}); });
it('should apply limit option', () => {
const { cameraManager, foldersManager } = createMocks();
const folder = createFolder({ id: 'folder1', title: 'Test' });
const path: [FolderPathComponent] = [{ ha: { id: 'Root' } }];
foldersManager.getFolder.mockReturnValue(folder);
foldersManager.getDefaultQueryParameters.mockReturnValue(
createFolderQueryParams(folder, path),
);
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const query = builder.buildDefaultFolderQuery('folder1', { limit: 20 });
assert(query);
expect(query.getNodes()[0]).toMatchObject({
source: QuerySource.Folder,
limit: 20,
});
});
it('should use default folder when no ID provided', () => { it('should use default folder when no ID provided', () => {
const { cameraManager, foldersManager } = createMocks(); const { cameraManager, foldersManager } = createMocks();
foldersManager.getFolder.mockReturnValue(null); foldersManager.getFolder.mockReturnValue(null);
@@ -1010,6 +975,30 @@ describe('UnifiedQueryBuilder', () => {
}); });
}); });
it('should not limit a folder query built for folder media type', () => {
const { cameraManager, foldersManager, store } = createMocks();
store.getCameraIDs.mockReturnValue(new Set(['camera.office']));
store.getCameraConfig.mockReturnValue(
createCameraConfig({ media: { type: 'folder', folders: ['folder1'] } }),
);
cameraManager.getCameraCapabilities.mockReturnValue(createCapabilities());
const folder = createFolder({ id: 'folder1', title: 'Test Folder' });
const path: [FolderPathComponent] = [{ ha: { id: 'Root' } }];
foldersManager.getFolder.mockReturnValue(folder);
foldersManager.getDefaultQueryParameters.mockReturnValue(
createFolderQueryParams(folder, path),
);
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const query = builder.buildDefaultCameraQuery(undefined, { limit: 50 });
assert(query);
expect(query.getNodes()[0]).not.toHaveProperty('limit');
});
it('should build default folder query for folder media type without specific folders', () => { it('should build default folder query for folder media type without specific folders', () => {
const { cameraManager, foldersManager, store } = createMocks(); const { cameraManager, foldersManager, store } = createMocks();
store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); store.getCameraIDs.mockReturnValue(new Set(['camera.office']));