mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
fix(genres): aggregate selected library indexes
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user