diff --git a/CHANGELOG.md b/CHANGELOG.md index 632a067d..6690ebc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -414,6 +414,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Startup no longer overwrites saved prefs before Zustand rehydration finishes; persisted volume is pushed to the Rust engine on boot. +### All Albums — genre filter respects sidebar library scope + +**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#959](https://github.com/Psychotoxical/psysonic/pull/959)** + +* With multiple music libraries, narrowing the sidebar to one library no longer leaves the Genre filter showing server-wide genres — options now come from the scoped local index catalog (same scope as the album grid). + ### In-page browse — virtual scroll and cover-art priority diff --git a/src/hooks/useAlbumBrowseData.ts b/src/hooks/useAlbumBrowseData.ts index 90380afc..a72bc225 100644 --- a/src/hooks/useAlbumBrowseData.ts +++ b/src/hooks/useAlbumBrowseData.ts @@ -26,6 +26,7 @@ import { type AlbumBrowseQuery, type GenreFilterOption, } from '../utils/library/albumBrowseLoad'; +import { libraryScopeForServer } from '../api/subsonicClient'; import { ALBUM_YEAR_FILTER_DEBOUNCE_MS, resolveAlbumYearBounds, @@ -167,7 +168,10 @@ export function useAlbumBrowseData({ const genreFiltered = albumBrowseHasGenreFilter(browseQuery); const serverFilterActive = albumBrowseHasServerFilters(browseQuery); + const libraryScopeActive = libraryScopeForServer(serverId) != null; const narrowGenreList = yearFilterActive || losslessOnly || starredOnly || compFilterActive; + /** When true, GenreFilterBar uses `genreCatalogOptions` instead of server `getGenres()`. */ + const genreCatalogActive = narrowGenreList || (indexEnabled && libraryScopeActive); const compScanExhausted = useMemo( () => compFilterClientOnly && !genreFiltered @@ -352,7 +356,7 @@ export function useAlbumBrowseData({ }, [browseQuery, indexEnabled, serverId, loadBrowse, musicLibraryFilterVersion]); useEffect(() => { - if (!narrowGenreList) { + if (!genreCatalogActive) { setGenreCatalogOptions(null); return; } @@ -364,7 +368,7 @@ export function useAlbumBrowseData({ cancelled = true; }; }, [ - narrowGenreList, + genreCatalogActive, serverId, indexEnabled, browseQueryWithoutGenre, @@ -466,6 +470,7 @@ export function useAlbumBrowseData({ genreFiltered, serverFilterActive, narrowGenreList, + genreCatalogActive, genreCatalogOptions, yearFilterActive, debouncedYearFields, diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index d4aed6e5..6c84c3b6 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -163,8 +163,6 @@ export default function Albums() { const serverFilterActive = textSearchActive ? selectedGenres.length > 0 || textSearchYearBounds.active || losslessOnly || starredOnly : browseData.serverFilterActive; - const narrowGenreList = browseData.narrowGenreList; - const genreCatalogOptions = browseData.genreCatalogOptions; const yearFilterActive = browseData.yearFilterActive; const debouncedYearFields = browseData.debouncedYearFields; const compFilterActive = browseData.compFilterActive; @@ -398,7 +396,7 @@ export default function Albums() { diff --git a/src/utils/library/albumBrowseGenreOptions.test.ts b/src/utils/library/albumBrowseGenreOptions.test.ts new file mode 100644 index 00000000..29e95481 --- /dev/null +++ b/src/utils/library/albumBrowseGenreOptions.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AlbumBrowseQuery } from './albumBrowseTypes'; + +const libraryGetGenreAlbumCounts = vi.fn(); +const libraryIsReady = vi.fn(); +const libraryScopeForServer = vi.fn(); +const runLocalAlbumBrowse = vi.fn(); + +vi.mock('../../api/library', () => ({ + libraryGetGenreAlbumCounts: (...args: unknown[]) => libraryGetGenreAlbumCounts(...args), +})); + +vi.mock('./libraryReady', () => ({ + libraryIsReady: (...args: unknown[]) => libraryIsReady(...args), +})); + +vi.mock('../../api/subsonicClient', () => ({ + libraryScopeForServer: (...args: unknown[]) => libraryScopeForServer(...args), +})); + +vi.mock('./albumBrowseLocal', () => ({ + runLocalAlbumBrowse: (...args: unknown[]) => runLocalAlbumBrowse(...args), +})); + +import { fetchAlbumBrowseGenreOptions } from './albumBrowseLoad'; + +const baseQuery: AlbumBrowseQuery = { + sort: 'alphabeticalByName', + genres: [], + losslessOnly: false, + starredOnly: false, + compFilter: 'all', +}; + +beforeEach(() => { + vi.clearAllMocks(); + libraryIsReady.mockResolvedValue(true); + libraryScopeForServer.mockReturnValue('lib-a'); +}); + +describe('fetchAlbumBrowseGenreOptions', () => { + it('uses scoped local genre counts when only the sidebar library is narrowed', async () => { + libraryGetGenreAlbumCounts.mockResolvedValue([ + { value: 'Rock', albumCount: 12, songCount: 40 }, + { value: 'Jazz', albumCount: 3, songCount: 9 }, + ]); + + await expect(fetchAlbumBrowseGenreOptions('srv-1', true, baseQuery)).resolves.toEqual([ + { genre: 'Rock', count: 12 }, + { genre: 'Jazz', count: 3 }, + ]); + + expect(libraryGetGenreAlbumCounts).toHaveBeenCalledWith({ + serverId: 'srv-1', + libraryScope: 'lib-a', + }); + expect(runLocalAlbumBrowse).not.toHaveBeenCalled(); + }); + + it('derives genres from filtered albums when combined filters are active', async () => { + runLocalAlbumBrowse.mockResolvedValue({ + albums: [ + { id: '1', name: 'A', artist: 'X', artistId: 'x', songCount: 1, duration: 1, genre: 'Rock' }, + { id: '2', name: 'B', artist: 'Y', artistId: 'y', songCount: 1, duration: 1, genre: 'Jazz' }, + ], + hasMore: false, + }); + + await expect( + fetchAlbumBrowseGenreOptions('srv-1', true, { ...baseQuery, year: { from: 1990 } }), + ).resolves.toEqual([ + { genre: 'Jazz', count: 1 }, + { genre: 'Rock', count: 1 }, + ]); + + expect(libraryGetGenreAlbumCounts).not.toHaveBeenCalled(); + expect(runLocalAlbumBrowse).toHaveBeenCalled(); + }); +}); diff --git a/src/utils/library/albumBrowseLoad.ts b/src/utils/library/albumBrowseLoad.ts index 3a97f0e4..779ad6db 100644 --- a/src/utils/library/albumBrowseLoad.ts +++ b/src/utils/library/albumBrowseLoad.ts @@ -17,10 +17,13 @@ export { } from './albumBrowseFilters'; export { runLocalAlbumBrowse } from './albumBrowseLocal'; -import { countGenresFromAlbums, filterAlbumsByCompilation } from './albumBrowseFilters'; +import { albumBrowseHasServerFilters, countGenresFromAlbums, filterAlbumsByCompilation } from './albumBrowseFilters'; import { runLocalAlbumBrowse } from './albumBrowseLocal'; import { fetchAlbumBrowseNetwork } from './albumBrowseNetwork'; import { fetchStarredAlbumBrowse } from './albumBrowseStarredFetch'; +import { libraryGetGenreAlbumCounts } from '../../api/library'; +import { libraryScopeForServer } from '../../api/subsonicClient'; +import { libraryIsReady } from './libraryReady'; import type { AlbumBrowseFetchCallbacks, AlbumBrowsePageResult, @@ -55,6 +58,24 @@ export async function fetchAlbumBrowseGenreOptions( query: AlbumBrowseQuery, ): Promise { const withoutGenre: AlbumBrowseQuery = { ...query, genres: [] }; + const scope = libraryScopeForServer(serverId); + const hasCombinedFilters = + albumBrowseHasServerFilters(withoutGenre) || query.compFilter !== 'all'; + + // Sidebar library scope only: use the full scoped genre catalog from the local + // index instead of getGenres() (server-wide) or a 500-album sample. + if (indexEnabled && serverId && scope && !hasCombinedFilters && (await libraryIsReady(serverId))) { + try { + const rows = await libraryGetGenreAlbumCounts({ + serverId, + libraryScope: scope, + }); + return rows.map(row => ({ genre: row.value, count: row.albumCount })); + } catch { + /* fall through to album-derived options */ + } + } + const page = await fetchAlbumBrowsePage( serverId, indexEnabled,