fix(library): load full genre catalog for all-libraries scope (#1242)

This commit is contained in:
cucadmuh
2026-07-06 03:39:38 +03:00
committed by GitHub
parent 4e6b58967c
commit 37190775cb
6 changed files with 76 additions and 32 deletions
+6
View File
@@ -118,6 +118,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Artist name search on the Artists page no longer depends on query letter case for Cyrillic (and other non-ASCII) names when the local library index is enabled — matching uses the indexed `name_sort` key with Unicode case folding instead of SQLite `LIKE` on the display name alone.
### Genres — full catalog on large libraries with All libraries selected
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1242](https://github.com/Psychotoxical/psysonic/pull/1242)**
* The Genres page and album browse genre filter no longer miss genres on large libraries when **All libraries** is selected — both now use the indexed `track_genre` SQL aggregate instead of sampling the first page of albums (regression from multi-library scope routing in PR #1241).
## [1.49.0] - 2026-06-29
+1
View File
@@ -186,6 +186,7 @@ const CONTRIBUTOR_ENTRIES = [
'Artists browse — case-insensitive Cyrillic/non-ASCII name search when local index is enabled (PR #1237)',
'CLI — relative volume via signed `volume` argument (+/ percent delta); suppress WebKit NVIDIA stderr notes on CLI argv; faster Linux CLI forward before WebKit init (PR #1238)',
'Multi-library filter — priority-ordered multi-select scope across browse/search/detail, sargable library_id + FTS-first SQL, rebuildable library-cluster.db identity keys, locale-aware name normalization (PR #1241)',
'Genres — full catalog via indexed SQL when All libraries is selected; no longer samples first album page on large libraries (PR #1242)',
],
},
{
@@ -144,6 +144,42 @@ describe('genreBrowsePlayback', () => {
expect(getGenres).not.toHaveBeenCalled();
});
it('loads all-libraries genre cloud via unscoped SQL, not an album sample', async () => {
const { librarySelectionForServer } = await import('@/lib/api/subsonicClient');
vi.mocked(librarySelectionForServer).mockReturnValue([]);
vi.mocked(libraryIsReady).mockResolvedValue(true);
vi.mocked(libraryGetGenreAlbumCounts).mockResolvedValue([
{ value: 'Ambient', albumCount: 3, songCount: 12 },
{ value: 'Rock', albumCount: 42, songCount: 900 },
]);
await expect(fetchGenreCatalog('srv-1', true)).resolves.toEqual([
{ value: 'Ambient', albumCount: 3, songCount: 12 },
{ value: 'Rock', albumCount: 42, songCount: 900 },
]);
expect(libraryGetGenreAlbumCounts).toHaveBeenCalledWith({ serverId: 'srv-1' });
expect(getGenres).not.toHaveBeenCalled();
});
it('loads multi-library genre cloud via scoped SQL IN query', async () => {
const { librarySelectionForServer } = await import('@/lib/api/subsonicClient');
vi.mocked(librarySelectionForServer).mockReturnValue(['lib-a', 'lib-b']);
vi.mocked(libraryIsReady).mockResolvedValue(true);
vi.mocked(libraryGetGenreAlbumCounts).mockResolvedValue([
{ value: 'Pop', albumCount: 4, songCount: 12 },
{ value: 'Rock', albumCount: 15, songCount: 45 },
]);
await expect(fetchGenreCatalog('srv-1', true)).resolves.toEqual([
{ value: 'Pop', albumCount: 4, songCount: 12 },
{ value: 'Rock', albumCount: 15, songCount: 45 },
]);
expect(libraryGetGenreAlbumCounts).toHaveBeenCalledWith({
serverId: 'srv-1',
libraryScopes: ['lib-a', 'lib-b'],
});
});
it('drops empty genres from server fallback catalog', async () => {
vi.mocked(libraryIsReady).mockResolvedValue(false);
vi.mocked(getGenres).mockResolvedValue([
@@ -14,8 +14,6 @@ import type { Track } from '@/lib/media/trackTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import { shuffleArray } from '@/lib/util/shuffleArray';
import { trackToSong } from '@/lib/library/advancedSearchLocal';
import { runLocalAlbumBrowse } from '@/lib/library/albumBrowseLocal';
import { countGenresFromAlbums } from '@/lib/library/albumBrowseFilters';
import { type AlbumBrowseSort } from '@/lib/library/albumBrowseSort';
import {
genreCatalogCacheKey,
@@ -35,11 +33,11 @@ export function filterGenresWithContent(genres: SubsonicGenre[]): SubsonicGenre[
async function loadLocalGenreCatalogRows(
serverId: string,
libraryScope: string,
args: { libraryScope?: string; libraryScopes?: string[] } = {},
): Promise<SubsonicGenre[]> {
const rows = await libraryGetGenreAlbumCounts({
serverId,
libraryScope,
...args,
});
return filterGenresWithContent(rows.map(row => ({
value: row.value,
@@ -48,38 +46,17 @@ async function loadLocalGenreCatalogRows(
})));
}
/** Multi-library genre cloud from scoped album browse (no single-scope SQL fast path). */
async function loadLocalGenreCatalogRowsMulti(serverId: string): Promise<SubsonicGenre[]> {
const page = await runLocalAlbumBrowse(
serverId,
{
sort: 'alphabeticalByName',
genres: [],
losslessOnly: false,
starredOnly: false,
compFilter: 'all',
},
0,
2000,
);
if (!page) return [];
return filterGenresWithContent(
countGenresFromAlbums(page.albums).map(({ genre, count }) => ({
value: genre,
albumCount: count,
songCount: 0,
})),
);
}
async function fetchLocalGenreCatalog(
serverId: string,
scopeKey: string,
): Promise<SubsonicGenre[]> {
const selection = librarySelectionForServer(serverId);
const genres = selection.length === 1
? await loadLocalGenreCatalogRows(serverId, selection[0])
: await loadLocalGenreCatalogRowsMulti(serverId);
const genres =
selection.length === 0
? await loadLocalGenreCatalogRows(serverId)
: selection.length === 1
? await loadLocalGenreCatalogRows(serverId, { libraryScope: selection[0] })
: await loadLocalGenreCatalogRows(serverId, { libraryScopes: selection });
writeGenreCatalogCache(serverId, scopeKey, genres);
return genres;
}
@@ -60,6 +60,22 @@ describe('fetchAlbumBrowseGenreOptions', () => {
expect(runLocalAlbumBrowse).not.toHaveBeenCalled();
});
it('uses unscoped SQL for all libraries instead of an album sample', async () => {
librarySelectionForServer.mockReturnValue([]);
libraryGetGenreAlbumCounts.mockResolvedValue([
{ value: 'Ambient', albumCount: 3, songCount: 12 },
{ value: 'Rock', albumCount: 42, songCount: 900 },
]);
await expect(fetchAlbumBrowseGenreOptions('srv-1', true, baseQuery)).resolves.toEqual([
{ genre: 'Ambient', count: 3 },
{ genre: 'Rock', count: 42 },
]);
expect(libraryGetGenreAlbumCounts).toHaveBeenCalledWith({ serverId: 'srv-1' });
expect(runLocalAlbumBrowse).not.toHaveBeenCalled();
});
it('uses one scoped SQL query for a multi-library selection', async () => {
librarySelectionForServer.mockReturnValue(['lib-a', 'lib-b']);
libraryGetGenreAlbumCounts.mockResolvedValue([
+9 -1
View File
@@ -78,8 +78,16 @@ export async function fetchAlbumBrowseGenreOptions(
// multi-scope CTE sample. For multi-library selection we sum counts per library —
// cross-library album duplicates are counted once per library (a cosmetic hint),
// but the genre set stays correct and each query is an indexed GROUP BY.
if (indexEnabled && serverId && selection.length >= 1 && !hasCombinedFilters && (await libraryIsReady(serverId))) {
if (indexEnabled && serverId && !hasCombinedFilters && (await libraryIsReady(serverId))) {
try {
if (selection.length === 0) {
const rows = await albumBrowseTimed(
'genre_album_counts',
() => fetchGenreAlbumCountsDeduped({ serverId }),
{ libraryCount: 0 },
);
return rows.map(row => ({ genre: row.value, count: row.albumCount }));
}
if (selection.length === 1) {
const rows = await albumBrowseTimed(
'genre_album_counts',