diff --git a/CHANGELOG.md b/CHANGELOG.md index e4faf5f4..a9b6f060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 79d6f4ae..078dd030 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', ], }, { diff --git a/src/features/playback/utils/playback/genreBrowsePlayback.test.ts b/src/features/playback/utils/playback/genreBrowsePlayback.test.ts index 91ab32c5..e73b85b5 100644 --- a/src/features/playback/utils/playback/genreBrowsePlayback.test.ts +++ b/src/features/playback/utils/playback/genreBrowsePlayback.test.ts @@ -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([ diff --git a/src/features/playback/utils/playback/genreBrowsePlayback.ts b/src/features/playback/utils/playback/genreBrowsePlayback.ts index 16a8ab7d..723c347b 100644 --- a/src/features/playback/utils/playback/genreBrowsePlayback.ts +++ b/src/features/playback/utils/playback/genreBrowsePlayback.ts @@ -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 { 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 { - 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 { 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; } diff --git a/src/lib/library/albumBrowseGenreOptions.test.ts b/src/lib/library/albumBrowseGenreOptions.test.ts index 3e33154c..d4c458d4 100644 --- a/src/lib/library/albumBrowseGenreOptions.test.ts +++ b/src/lib/library/albumBrowseGenreOptions.test.ts @@ -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([ diff --git a/src/lib/library/albumBrowseLoad.ts b/src/lib/library/albumBrowseLoad.ts index 5bdae479..4431d3e0 100644 --- a/src/lib/library/albumBrowseLoad.ts +++ b/src/lib/library/albumBrowseLoad.ts @@ -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',