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,