mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
fix(library): load full genre catalog for all-libraries scope (#1242)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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<SubsonicGenre[]> {
|
||||
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<SubsonicGenre[]> {
|
||||
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<SubsonicGenre[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user