fix(genres): aggregate selected library indexes

This commit is contained in:
cucadmuh
2026-07-19 03:09:48 +03:00
parent 7d54aa9dc4
commit 55d0b93268
12 changed files with 351 additions and 22 deletions
+96
View File
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from '@testing-library/react';
const hoisted = vi.hoisted(() => ({
fetchGenreCatalog: vi.fn(),
fetchScopedGenreCatalog: vi.fn(),
peekScopedGenreCatalog: vi.fn(() => null),
}));
vi.mock('@/features/playback/utils/playback/genreBrowsePlayback', () => ({
fetchGenreCatalog: hoisted.fetchGenreCatalog,
fetchScopedGenreCatalog: hoisted.fetchScopedGenreCatalog,
peekScopedGenreCatalog: hoisted.peekScopedGenreCatalog,
filterGenresWithContent: (genres: unknown[]) => genres,
}));
vi.mock('@/lib/library/genreCatalogCountsCache', () => ({
peekGenreCatalogCache: vi.fn(() => null),
}));
vi.mock('@/features/offline', () => ({
useOfflineBrowseContext: () => ({ active: false }),
offlineLocalBrowseEnabled: vi.fn(() => false),
}));
vi.mock('@/store/localPlaybackBrowseRevision', () => ({
useOfflineLocalBrowseReloadKey: vi.fn(() => 0),
}));
vi.mock('@/store/offlineLocalLibrarySyncRevision', () => ({
useLibrarySyncRevision: vi.fn(() => 0),
}));
import Genres from '@/features/genre/pages/Genres';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { resetAllStores } from '@/test/helpers/storeReset';
import { useAuthStore } from '@/store/authStore';
describe('Genres', () => {
beforeEach(() => {
resetAllStores();
hoisted.fetchGenreCatalog.mockReset();
hoisted.fetchScopedGenreCatalog.mockReset().mockResolvedValue([
{ value: 'Rock', albumCount: 4, songCount: 9 },
]);
hoisted.peekScopedGenreCatalog.mockReturnValue(null);
});
it('loads the derived selected owner while active-server switching catches up', async () => {
useAuthStore.setState({
servers: [
{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
{ id: 'srv-b', name: 'B', url: 'https://b.test', username: 'u', password: 'p' },
],
activeServerId: 'srv-a',
libraryBrowseServerIds: ['srv-b'],
libraryBrowseSelectionByServer: { 'srv-b': ['lib-b'] },
});
const view = renderWithProviders(<Genres />);
expect(await view.findByText('Rock')).toBeInTheDocument();
expect(hoisted.fetchScopedGenreCatalog).toHaveBeenCalledWith([
{ serverId: 'srv-b', libraryIds: ['lib-b'] },
]);
expect(hoisted.fetchGenreCatalog).not.toHaveBeenCalled();
});
it('does not reveal the previous scope when the replacement index read fails', async () => {
useAuthStore.setState({
servers: [
{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
{ id: 'srv-b', name: 'B', url: 'https://b.test', username: 'u', password: 'p' },
],
activeServerId: 'srv-a',
libraryBrowseServerIds: ['srv-b'],
libraryBrowseSelectionByServer: { 'srv-b': ['lib-b'] },
});
const view = renderWithProviders(<Genres />);
expect(await view.findByText('Rock')).toBeInTheDocument();
hoisted.fetchScopedGenreCatalog.mockRejectedValueOnce(new Error('index unavailable'));
act(() => {
const version = useAuthStore.getState().libraryBrowseScopeVersion;
useAuthStore.setState({
libraryBrowseServerIds: ['srv-a'],
libraryBrowseSelectionByServer: { 'srv-a': ['lib-a'] },
libraryBrowseScopeVersion: version + 1,
});
});
expect(await view.findByText('No genres found.')).toBeInTheDocument();
expect(view.queryByText('Rock')).not.toBeInTheDocument();
});
});
+39 -11
View File
@@ -6,14 +6,20 @@ import { Tags } from 'lucide-react';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { fetchGenreCatalog, filterGenresWithContent } from '@/features/playback/utils/playback/genreBrowsePlayback';
import {
fetchGenreCatalog,
fetchScopedGenreCatalog,
filterGenresWithContent,
peekScopedGenreCatalog,
} from '@/features/playback/utils/playback/genreBrowsePlayback';
import { libraryScopeCacheKeyForServer } from '@/lib/api/subsonicClient';
import { peekGenreCatalogCache } from '@/lib/library/genreCatalogCountsCache';
import { genreColor } from '@/lib/library/genreColor';
import { useOfflineBrowseContext, offlineLocalBrowseEnabled } from '@/features/offline';
import { useOfflineLocalBrowseReloadKey } from '@/store/localPlaybackBrowseRevision';
import { useOfflineLocalLibrarySyncRevision } from '@/store/offlineLocalLibrarySyncRevision';
import { useLibrarySyncRevision } from '@/store/offlineLocalLibrarySyncRevision';
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
import { deriveLibraryBrowseIndexScopes } from '@/lib/library/libraryBrowseScope';
const SCROLL_KEY = 'genres-scroll';
const FONT_MIN_REM = 0.78;
@@ -25,47 +31,69 @@ export default function Genres() {
const serverId = useAuthStore(s => s.activeServerId ?? '');
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const libraryBrowseScopeVersion = useAuthStore(s => s.libraryBrowseScopeVersion);
const selectedIndexScopes = deriveLibraryBrowseIndexScopes(useAuthStore.getState());
const libraryScopeKey = libraryScopeCacheKeyForServer(serverId);
const offlineBrowseActive = useOfflineBrowseContext().active;
const localPlaybackEntries = useLocalPlaybackStore(s => s.entries);
const librarySyncRevision = useOfflineLocalLibrarySyncRevision(serverId || null);
const librarySyncRevision = useLibrarySyncRevision();
const offlineLocalBrowseReloadKey = useOfflineLocalBrowseReloadKey(
serverId,
offlineBrowseActive,
);
const skipGenreCatalogCache = offlineBrowseActive
&& offlineLocalBrowseEnabled(serverId, localPlaybackEntries);
const cachedGenres = serverId && !skipGenreCatalogCache
? peekGenreCatalogCache(serverId, libraryScopeKey, true)
: null;
const cachedGenres = !offlineBrowseActive
? peekScopedGenreCatalog(selectedIndexScopes, true)
: serverId && !skipGenreCatalogCache
? peekGenreCatalogCache(serverId, libraryScopeKey, true)
: null;
const [rawGenres, setRawGenres] = useState<SubsonicGenre[]>(cachedGenres ?? []);
const [loading, setLoading] = useState(!cachedGenres);
useEffect(() => {
let cancelled = false;
const scopeKey = libraryScopeCacheKeyForServer(serverId);
const cached = serverId && !skipGenreCatalogCache
? peekGenreCatalogCache(serverId, scopeKey, true)
: null;
const scopes = deriveLibraryBrowseIndexScopes(useAuthStore.getState());
const useSelectedIndexCounts = scopes.length > 0 && !offlineBrowseActive;
const cached = useSelectedIndexCounts
? peekScopedGenreCatalog(scopes, true)
: serverId && !skipGenreCatalogCache
? peekGenreCatalogCache(serverId, scopeKey, true)
: null;
if (cached) {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setRawGenres(cached);
setLoading(false);
} else {
setRawGenres([]);
setLoading(true);
}
void fetchGenreCatalog(serverId, indexEnabled)
const load = useSelectedIndexCounts
? fetchScopedGenreCatalog(scopes)
: fetchGenreCatalog(serverId, indexEnabled);
void load
.then(data => {
if (!cancelled) setRawGenres(data);
})
.catch(() => {})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [serverId, indexEnabled, musicLibraryFilterVersion, offlineBrowseActive, skipGenreCatalogCache, librarySyncRevision, offlineLocalBrowseReloadKey]);
}, [
serverId,
indexEnabled,
musicLibraryFilterVersion,
libraryBrowseScopeVersion,
offlineBrowseActive,
skipGenreCatalogCache,
librarySyncRevision,
offlineLocalBrowseReloadKey,
]);
const genres = useMemo(
() => filterGenresWithContent([...rawGenres]).sort((a, b) => b.albumCount - a.albumCount),
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
fetchGenreAlbumCount,
fetchGenreCatalog,
fetchScopedGenreCatalog,
fetchGenreTracksForPlayback,
fetchLocalGenreTracksForPlayback,
filterGenresWithContent,
@@ -195,6 +196,68 @@ describe('genreBrowsePlayback', () => {
});
});
it('aggregates selected server scopes with one local count query per server', async () => {
vi.mocked(libraryGetGenreAlbumCounts).mockImplementation(async args => {
const request = args as { serverId: string };
return request.serverId === 'srv-a'
? [{ value: 'Rock', albumCount: 2, songCount: 5 }]
: [
{ value: 'rock', albumCount: 3, songCount: 7 },
{ value: 'Jazz', albumCount: 1, songCount: 2 },
];
});
const scopes = [
{ serverId: 'srv-a', libraryIds: ['lib-a'] },
{ serverId: 'srv-b', libraryIds: [] },
];
await expect(fetchScopedGenreCatalog(scopes)).resolves.toEqual([
{ value: 'Rock', albumCount: 5, songCount: 12 },
{ value: 'Jazz', albumCount: 1, songCount: 2 },
]);
await fetchScopedGenreCatalog(scopes);
expect(libraryGetGenreAlbumCounts).toHaveBeenCalledTimes(2);
expect(libraryGetGenreAlbumCounts).toHaveBeenCalledWith({
serverId: 'srv-a',
libraryScope: 'lib-a',
});
expect(libraryGetGenreAlbumCounts).toHaveBeenCalledWith({ serverId: 'srv-b' });
expect(getGenres).not.toHaveBeenCalled();
});
it('retains successful local genre counts when another selected index fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.mocked(libraryGetGenreAlbumCounts).mockImplementation(async args => {
const request = args as { serverId: string };
if (request.serverId === 'srv-b') throw new Error('index unavailable');
return [{ value: 'Ambient', albumCount: 4, songCount: 9 }];
});
await expect(fetchScopedGenreCatalog([
{ serverId: 'srv-a', libraryIds: [] },
{ serverId: 'srv-b', libraryIds: [] },
])).resolves.toEqual([
{ value: 'Ambient', albumCount: 4, songCount: 9 },
]);
expect(getGenres).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
'[genres] local index counts unavailable for selected servers',
{ failedServerIds: ['srv-b'] },
);
warn.mockRestore();
});
it('rejects when every selected index is unavailable', async () => {
vi.mocked(libraryGetGenreAlbumCounts).mockRejectedValue(new Error('index unavailable'));
await expect(fetchScopedGenreCatalog([
{ serverId: 'srv-a', libraryIds: [] },
{ serverId: 'srv-b', libraryIds: [] },
])).rejects.toThrow('genre_catalog_scope_unavailable');
expect(getGenres).not.toHaveBeenCalled();
});
it('drops empty genres from server fallback catalog', async () => {
vi.mocked(libraryIsReady).mockResolvedValue(false);
vi.mocked(getGenres).mockResolvedValue([
@@ -1,7 +1,7 @@
/**
* Genre-detail bulk play/shuffle against the local library index.
*/
import { libraryAdvancedSearch, libraryGetGenreAlbumCounts, type LibrarySortClause } from '@/lib/api/library';
import { libraryAdvancedSearch, type LibrarySortClause } from '@/lib/api/library';
import { fetchAllSongsByGenre, getGenres } from '@/lib/api/subsonicGenres';
import type { SubsonicGenre } from '@/lib/api/subsonicTypes';
import {
@@ -25,6 +25,8 @@ import {
} from '@/lib/library/genreCatalogCountsCache';
import { fetchGenreAlbumTotal } from '@/lib/library/genreAlbumBrowse';
import { libraryIsReady } from '@/lib/library/libraryReady';
import { fetchGenreAlbumCountsDeduped } from '@/lib/library/albumBrowseGenreCountsCache';
import type { LibraryBrowseIndexScope } from '@/lib/library/libraryBrowseScope';
import {
fetchOfflineLocalGenreCatalog,
isOfflineBrowseActive,
@@ -40,7 +42,7 @@ async function loadLocalGenreCatalogRows(
serverId: string,
args: { libraryScope?: string; libraryScopes?: string[] } = {},
): Promise<SubsonicGenre[]> {
const rows = await libraryGetGenreAlbumCounts({
const rows = await fetchGenreAlbumCountsDeduped({
serverId,
...args,
});
@@ -54,8 +56,8 @@ async function loadLocalGenreCatalogRows(
async function fetchLocalGenreCatalog(
serverId: string,
scopeKey: string,
selection: string[] = librarySelectionForServer(serverId),
): Promise<SubsonicGenre[]> {
const selection = librarySelectionForServer(serverId);
const genres =
selection.length === 0
? await loadLocalGenreCatalogRows(serverId)
@@ -66,6 +68,73 @@ async function fetchLocalGenreCatalog(
return genres;
}
export function mergeGenreCatalogs(catalogs: readonly SubsonicGenre[][]): SubsonicGenre[] {
const merged = new Map<string, SubsonicGenre>();
for (const catalog of catalogs) {
for (const genre of catalog) {
const value = genre.value.trim();
if (!value) continue;
const key = value.toLocaleLowerCase();
const previous = merged.get(key);
merged.set(key, {
value: previous?.value ?? value,
albumCount: (previous?.albumCount ?? 0) + (genre.albumCount ?? 0),
songCount: (previous?.songCount ?? 0) + (genre.songCount ?? 0),
});
}
}
return filterGenresWithContent([...merged.values()]).sort(
(a, b) => b.albumCount - a.albumCount || a.value.localeCompare(b.value),
);
}
function scopeCacheKey(scope: LibraryBrowseIndexScope): string {
return scope.libraryIds.length > 0 ? scope.libraryIds.join(',') : 'all';
}
export function peekScopedGenreCatalog(
scopes: readonly LibraryBrowseIndexScope[],
allowStale: boolean,
): SubsonicGenre[] | null {
if (scopes.length === 0) return null;
const catalogs: SubsonicGenre[][] = [];
for (const scope of scopes) {
const cached = peekGenreCatalogCache(scope.serverId, scopeCacheKey(scope), allowStale);
if (!cached) return null;
catalogs.push(cached);
}
return mergeGenreCatalogs(catalogs);
}
/** One local indexed GROUP BY per selected server; failures retain the other owners' counts. */
export async function fetchScopedGenreCatalog(
scopes: readonly LibraryBrowseIndexScope[],
): Promise<SubsonicGenre[]> {
const settled = await Promise.allSettled(scopes.map(async scope => {
const key = scopeCacheKey(scope);
const fresh = peekGenreCatalogCache(scope.serverId, key, false);
if (fresh) return fresh;
try {
return await fetchLocalGenreCatalog(scope.serverId, key, scope.libraryIds);
} catch (error) {
const stale = peekGenreCatalogCache(scope.serverId, key, true);
if (stale) return stale;
throw error;
}
}));
const catalogs = settled.flatMap(result =>
result.status === 'fulfilled' ? [result.value] : []);
if (catalogs.length === 0 && scopes.length > 0) {
throw new Error('genre_catalog_scope_unavailable');
}
const failedServerIds = settled.flatMap((result, index) =>
result.status === 'rejected' ? [scopes[index].serverId] : []);
if (failedServerIds.length > 0) {
console.warn('[genres] local index counts unavailable for selected servers', { failedServerIds });
}
return mergeGenreCatalogs(catalogs);
}
/** Matches queueTrackResolver CACHE_CAP — whole seeded queue stays warm. */
export const GENRE_PLAYBACK_QUEUE_CAP = 500;
+2 -6
View File
@@ -12,7 +12,7 @@ import type {
StatisticsOverviewData,
SubsonicAlbum,
} from '@/lib/api/subsonicTypes';
import { deriveEffectiveLibraryBrowseServerIds } from '@/lib/library/libraryBrowseScope';
import { deriveLibraryBrowseIndexScopes } from '@/lib/library/libraryBrowseScope';
/** Cache TTL for statistics page aggregates — same 7-minute window as
* the rating prefetch cache in subsonicRatings.ts. */
@@ -27,11 +27,7 @@ export function statisticsPageCacheKey(prefix: string): string | null {
export function statisticsIndexScopes(): LibraryStatisticsScope[] {
const state = useAuthStore.getState();
const selectedServerIds = deriveEffectiveLibraryBrowseServerIds(state);
return selectedServerIds.map(serverId => ({
serverId,
libraryIds: state.libraryBrowseSelectionByServer[serverId] ?? [],
}));
return deriveLibraryBrowseIndexScopes(state);
}
/** Ranked local-index albums for the same selected server/folder scope as Statistics. */
@@ -0,0 +1,27 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const libraryGetGenreAlbumCounts = vi.fn();
vi.mock('@/lib/api/library', () => ({
libraryGetGenreAlbumCounts: (...args: unknown[]) => libraryGetGenreAlbumCounts(...args),
}));
import { fetchGenreAlbumCountsDeduped } from './albumBrowseGenreCountsCache';
describe('fetchGenreAlbumCountsDeduped', () => {
beforeEach(() => {
libraryGetGenreAlbumCounts.mockReset();
});
it('deduplicates only concurrent reads so later sync revisions can reload counts', async () => {
libraryGetGenreAlbumCounts.mockResolvedValue([{ value: 'Rock', albumCount: 1, songCount: 2 }]);
const first = fetchGenreAlbumCountsDeduped({ serverId: 'srv-a' });
const concurrent = fetchGenreAlbumCountsDeduped({ serverId: 'srv-a' });
expect(first).toBe(concurrent);
await first;
await fetchGenreAlbumCountsDeduped({ serverId: 'srv-a' });
expect(libraryGetGenreAlbumCounts).toHaveBeenCalledTimes(2);
});
});
@@ -19,5 +19,9 @@ export function fetchGenreAlbumCountsDeduped(args: {
const promise = libraryGetGenreAlbumCounts(args);
inflight.set(key, promise);
const clear = () => {
if (inflight.get(key) === promise) inflight.delete(key);
};
void promise.then(clear, clear);
return promise;
}
+3 -2
View File
@@ -84,9 +84,10 @@ export function getInflightGenreCatalog(key: string): Promise<SubsonicGenre[]> |
export function trackInflightGenreCatalog(key: string, promise: Promise<SubsonicGenre[]>): void {
inflight.set(key, promise);
void promise.finally(() => {
const clear = () => {
if (inflight.get(key) === promise) inflight.delete(key);
});
};
void promise.then(clear, clear);
}
/** Test-only reset. */
@@ -3,6 +3,7 @@ import { useAuthStore } from '@/store/authStore';
import { resetAuthStore } from '@/test/helpers/storeReset';
import {
deriveEffectiveLibraryBrowseServerIds,
deriveLibraryBrowseIndexScopes,
deriveLibraryBrowseScope,
getLibraryBrowseScope,
} from './libraryBrowseScope';
@@ -65,6 +66,9 @@ describe('getLibraryBrowseScope', () => {
expect(deriveEffectiveLibraryBrowseServerIds(state, new Set(['primary'])))
.toEqual(['secondary']);
expect(deriveLibraryBrowseIndexScopes(state, new Set(['primary']))).toEqual([
{ serverId: 'secondary', libraryIds: [] },
]);
expect(deriveLibraryBrowseScope(state, new Set(['primary']))).toEqual({
anchorServerId: 'secondary',
pairs: [{ serverId: 'secondary', libraryId: 'secondary-music' }],
+16
View File
@@ -33,6 +33,12 @@ export interface LibraryBrowseScope {
multiServer: boolean;
}
export interface LibraryBrowseIndexScope {
serverId: string;
/** Empty means every indexed library on this server. */
libraryIds: string[];
}
type LibraryBrowseServerOrderSource = Pick<
LibraryBrowseScopeSource,
'servers' | 'activeServerId' | 'libraryBrowseServerIds'
@@ -65,6 +71,16 @@ export function deriveEffectiveLibraryBrowseServerIds(
.filter(serverId => !unavailableServerIds.has(serverId));
}
export function deriveLibraryBrowseIndexScopes(
state: LibraryBrowseScopeSource,
unavailableServerIds: ReadonlySet<string> = getUnavailableServerIds(),
): LibraryBrowseIndexScope[] {
return deriveEffectiveLibraryBrowseServerIds(state, unavailableServerIds).map(serverId => ({
serverId,
libraryIds: state.libraryBrowseSelectionByServer[serverId] ?? [],
}));
}
/** Ordered concrete source pairs used only by Library pages and search. */
export function deriveLibraryBrowseScope(
state: LibraryBrowseScopeSource,
@@ -16,6 +16,7 @@ vi.mock('@/lib/api/library/events', () => ({
}));
import {
librarySyncRevision,
offlineLocalLibrarySyncRevision,
resetOfflineLocalLibrarySyncRevisionForTests,
} from '@/store/offlineLocalLibrarySyncRevision';
@@ -41,6 +42,7 @@ describe('offlineLocalLibrarySyncRevision', () => {
});
expect(offlineLocalLibrarySyncRevision('srv-a')).toBe(1);
expect(offlineLocalLibrarySyncRevision('a.test')).toBe(1);
expect(librarySyncRevision()).toBe(1);
});
it('ignores failed sync-idle payloads', () => {
@@ -52,5 +54,6 @@ describe('offlineLocalLibrarySyncRevision', () => {
error: 'fail',
});
expect(offlineLocalLibrarySyncRevision('srv-a')).toBe(0);
expect(librarySyncRevision()).toBe(0);
});
});
@@ -6,6 +6,7 @@ import { resolveIndexKey } from '@/lib/server/serverIndexKey';
const syncRevisionByScope = new Map<string, number>();
const listeners = new Set<() => void>();
let syncHookRegistered = false;
let anySyncRevision = 0;
function notifySyncRevisionListeners(): void {
for (const listener of listeners) {
@@ -22,12 +23,32 @@ function scopeKeysForServer(serverId: string): string[] {
}
function bumpOfflineLocalLibrarySyncRevision(serverIdFromEvent: string): void {
anySyncRevision += 1;
for (const key of scopeKeysForServer(serverIdFromEvent)) {
syncRevisionByScope.set(key, (syncRevisionByScope.get(key) ?? 0) + 1);
}
notifySyncRevisionListeners();
}
/** Monotonic revision bumped after any successful library sync-idle event. */
export function librarySyncRevision(): number {
ensureOfflineLocalLibrarySyncHook();
return anySyncRevision;
}
/** Reactive revision for views that aggregate more than one server index. */
export function useLibrarySyncRevision(): number {
ensureOfflineLocalLibrarySyncHook();
return useSyncExternalStore(
onStoreChange => {
listeners.add(onStoreChange);
return () => listeners.delete(onStoreChange);
},
librarySyncRevision,
() => 0,
);
}
function ensureOfflineLocalLibrarySyncHook(): void {
if (syncHookRegistered) return;
syncHookRegistered = true;
@@ -67,6 +88,7 @@ export function useOfflineLocalLibrarySyncRevision(
/** Test-only reset. */
export function resetOfflineLocalLibrarySyncRevisionForTests(): void {
syncRevisionByScope.clear();
anySyncRevision = 0;
syncHookRegistered = false;
}