mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(playback): move genreBrowsePlayback into the feature; utils/library now feature-free
genreBrowsePlayback is a "play X" queue builder -- it turns a genre seed into a Track[] for the player, the genre analogue of playArtistShuffled which already lives in features/playback/utils/playback. Consumed only by the 3 genre pages, by no utils/library sibling, so the whole-file move is clean (no split). Its Track/songToTrack edges become intra-feature; its remaining utils/library siblings (advancedSearchLocal, albumBrowseSort, genreCatalogCountsCache, genreAlbumBrowse, libraryReady) are plain infra deps. utils/library now has ZERO @/features importers (source AND tests) -- the bulk is ready to relocate to lib/library. tsc 0, lint 0, genre suite green.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
fetchGenreAlbumCount,
|
||||
fetchGenreCatalog,
|
||||
fetchGenreTracksForPlayback,
|
||||
fetchLocalGenreTracksForPlayback,
|
||||
filterGenresWithContent,
|
||||
GENRE_PLAYBACK_QUEUE_CAP,
|
||||
} from './genreBrowsePlayback';
|
||||
|
||||
vi.mock('@/lib/api/library', () => ({
|
||||
libraryAdvancedSearch: vi.fn(),
|
||||
libraryGetGenreAlbumCounts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api/subsonicGenres', () => ({
|
||||
fetchAllSongsByGenre: vi.fn(),
|
||||
getGenres: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api/subsonicClient', () => ({
|
||||
libraryScopeForServer: vi.fn(() => 'music'),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/library/libraryReady', () => ({
|
||||
libraryIsReady: vi.fn(),
|
||||
}));
|
||||
|
||||
// Spread the real leaf module so other consumers pulled in transitively (the
|
||||
// album barrel reaches this via the artist↔album edge → useGenreAlbumBrowse needs
|
||||
// GENRE_ALBUM_FIRST_PAGE); only fetchGenreAlbumTotal is stubbed here.
|
||||
vi.mock('@/utils/library/genreAlbumBrowse', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/utils/library/genreAlbumBrowse')>()),
|
||||
fetchGenreAlbumTotal: vi.fn(),
|
||||
}));
|
||||
|
||||
import { libraryAdvancedSearch, libraryGetGenreAlbumCounts } from '@/lib/api/library';
|
||||
import { fetchAllSongsByGenre, getGenres } from '@/lib/api/subsonicGenres';
|
||||
import { fetchGenreAlbumTotal } from '@/utils/library/genreAlbumBrowse';
|
||||
import { resetGenreCatalogCountsCacheForTests } from '@/utils/library/genreCatalogCountsCache';
|
||||
import { libraryIsReady } from '@/utils/library/libraryReady';
|
||||
|
||||
describe('genreBrowsePlayback', () => {
|
||||
beforeEach(() => {
|
||||
resetGenreCatalogCountsCacheForTests();
|
||||
vi.mocked(libraryIsReady).mockReset();
|
||||
vi.mocked(libraryAdvancedSearch).mockReset();
|
||||
vi.mocked(libraryGetGenreAlbumCounts).mockReset();
|
||||
vi.mocked(fetchAllSongsByGenre).mockReset();
|
||||
vi.mocked(getGenres).mockReset();
|
||||
vi.mocked(fetchGenreAlbumTotal).mockReset();
|
||||
});
|
||||
|
||||
it('requests random local tracks for shuffle', async () => {
|
||||
vi.mocked(libraryIsReady).mockResolvedValue(true);
|
||||
vi.mocked(libraryAdvancedSearch).mockResolvedValue({
|
||||
source: 'local',
|
||||
tracks: [{
|
||||
serverId: 'srv-1',
|
||||
id: 't1',
|
||||
title: 'A',
|
||||
artist: 'X',
|
||||
album: 'B',
|
||||
albumId: 'a1',
|
||||
durationSec: 1,
|
||||
coverArtId: 'c1',
|
||||
syncedAt: 0,
|
||||
rawJson: {},
|
||||
}],
|
||||
albums: [],
|
||||
artists: [],
|
||||
totals: { tracks: 1, albums: 1, artists: 1 },
|
||||
appliedFilters: ['genre'],
|
||||
});
|
||||
|
||||
await fetchLocalGenreTracksForPlayback('srv-1', 'Rock', { shuffle: true, cap: 100 });
|
||||
|
||||
expect(libraryAdvancedSearch).toHaveBeenCalledWith(expect.objectContaining({
|
||||
serverId: 'srv-1',
|
||||
entityTypes: ['track'],
|
||||
filters: [{ field: 'genre', op: 'eq', value: 'Rock' }],
|
||||
sort: [{ field: 'random', dir: 'asc' }],
|
||||
limit: 100,
|
||||
skipTotals: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('falls back to Navidrome when local index is unavailable', async () => {
|
||||
vi.mocked(libraryIsReady).mockResolvedValue(false);
|
||||
vi.mocked(fetchAllSongsByGenre).mockResolvedValue([
|
||||
{ id: 's1', title: 'Song', artist: 'A', album: 'B', albumId: 'a1', duration: 200, coverArt: 'c1' },
|
||||
]);
|
||||
|
||||
const tracks = await fetchGenreTracksForPlayback('srv-1', 'Jazz', { shuffle: false, indexEnabled: true });
|
||||
|
||||
expect(fetchAllSongsByGenre).toHaveBeenCalledWith('Jazz', GENRE_PLAYBACK_QUEUE_CAP);
|
||||
expect(tracks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reads album totals from cached genre catalog', async () => {
|
||||
vi.mocked(libraryIsReady).mockResolvedValue(true);
|
||||
vi.mocked(libraryGetGenreAlbumCounts).mockResolvedValue([
|
||||
{ value: 'Rock', albumCount: 42, songCount: 900 },
|
||||
]);
|
||||
await fetchGenreCatalog('srv-1', true);
|
||||
|
||||
await expect(fetchGenreAlbumCount('srv-1', 'Rock', true)).resolves.toBe(42);
|
||||
expect(fetchGenreAlbumTotal).not.toHaveBeenCalled();
|
||||
expect(getGenres).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to per-genre total when catalog cache is empty', async () => {
|
||||
vi.mocked(fetchGenreAlbumTotal).mockResolvedValue(42);
|
||||
|
||||
await expect(fetchGenreAlbumCount('srv-1', 'Rock', true)).resolves.toBe(42);
|
||||
});
|
||||
|
||||
it('falls back to scoped genre list album count when local index is off', async () => {
|
||||
vi.mocked(fetchGenreAlbumTotal).mockResolvedValue(null);
|
||||
vi.mocked(getGenres).mockResolvedValue([
|
||||
{ value: 'Rock', songCount: 100, albumCount: 7 },
|
||||
]);
|
||||
|
||||
await expect(fetchGenreAlbumCount('srv-1', 'Rock', false)).resolves.toBe(7);
|
||||
});
|
||||
|
||||
it('loads genre cloud from local index when ready', async () => {
|
||||
vi.mocked(libraryIsReady).mockResolvedValue(true);
|
||||
vi.mocked(libraryGetGenreAlbumCounts).mockResolvedValue([
|
||||
{ value: 'Rock', albumCount: 42, songCount: 900 },
|
||||
]);
|
||||
|
||||
await expect(fetchGenreCatalog('srv-1', true)).resolves.toEqual([
|
||||
{ value: 'Rock', albumCount: 42, songCount: 900 },
|
||||
]);
|
||||
expect(libraryGetGenreAlbumCounts).toHaveBeenCalledWith({
|
||||
serverId: 'srv-1',
|
||||
libraryScope: 'music',
|
||||
});
|
||||
expect(getGenres).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops empty genres from server fallback catalog', async () => {
|
||||
vi.mocked(libraryIsReady).mockResolvedValue(false);
|
||||
vi.mocked(getGenres).mockResolvedValue([
|
||||
{ value: 'ruspop', songCount: 0, albumCount: 0 },
|
||||
{ value: 'Rock', songCount: 10, albumCount: 3 },
|
||||
]);
|
||||
|
||||
await expect(fetchGenreCatalog('srv-1', true)).resolves.toEqual([
|
||||
{ value: 'Rock', albumCount: 3, songCount: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('filterGenresWithContent drops zero-count rows', () => {
|
||||
expect(filterGenresWithContent([
|
||||
{ value: 'Empty', albumCount: 0, songCount: 0 },
|
||||
{ value: 'SongsOnly', albumCount: 0, songCount: 2 },
|
||||
{ value: 'AlbumsOnly', albumCount: 1, songCount: 0 },
|
||||
])).toEqual([
|
||||
{ value: 'SongsOnly', albumCount: 0, songCount: 2 },
|
||||
{ value: 'AlbumsOnly', albumCount: 1, songCount: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reuses cached genre catalog without repeating SQL', async () => {
|
||||
vi.mocked(libraryIsReady).mockResolvedValue(true);
|
||||
vi.mocked(libraryGetGenreAlbumCounts).mockResolvedValue([
|
||||
{ value: 'Rock', albumCount: 42, songCount: 900 },
|
||||
]);
|
||||
|
||||
await fetchGenreCatalog('srv-1', true);
|
||||
await fetchGenreCatalog('srv-1', true);
|
||||
|
||||
expect(libraryGetGenreAlbumCounts).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Genre-detail bulk play/shuffle against the local library index.
|
||||
*/
|
||||
import { libraryAdvancedSearch, libraryGetGenreAlbumCounts, type LibrarySortClause } from '@/lib/api/library';
|
||||
import { fetchAllSongsByGenre, getGenres } from '@/lib/api/subsonicGenres';
|
||||
import type { SubsonicGenre } from '@/lib/api/subsonicTypes';
|
||||
import { libraryScopeForServer } from '@/lib/api/subsonicClient';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { shuffleArray } from '@/lib/util/shuffleArray';
|
||||
import { trackToSong } from '@/utils/library/advancedSearchLocal';
|
||||
import { type AlbumBrowseSort } from '@/utils/library/albumBrowseSort';
|
||||
import {
|
||||
genreCatalogCacheKey,
|
||||
getInflightGenreCatalog,
|
||||
lookupGenreAlbumCount,
|
||||
peekGenreCatalogCache,
|
||||
trackInflightGenreCatalog,
|
||||
writeGenreCatalogCache,
|
||||
} from '@/utils/library/genreCatalogCountsCache';
|
||||
import { fetchGenreAlbumTotal } from '@/utils/library/genreAlbumBrowse';
|
||||
import { libraryIsReady } from '@/utils/library/libraryReady';
|
||||
|
||||
/** Drop genres with no indexed albums/tracks (stale server list or orphan rows). */
|
||||
export function filterGenresWithContent(genres: SubsonicGenre[]): SubsonicGenre[] {
|
||||
return genres.filter(g => (g.albumCount ?? 0) > 0 || (g.songCount ?? 0) > 0);
|
||||
}
|
||||
|
||||
async function loadLocalGenreCatalogRows(
|
||||
serverId: string,
|
||||
libraryScope: string | undefined,
|
||||
): Promise<SubsonicGenre[]> {
|
||||
const rows = await libraryGetGenreAlbumCounts({
|
||||
serverId,
|
||||
libraryScope,
|
||||
});
|
||||
return filterGenresWithContent(rows.map(row => ({
|
||||
value: row.value,
|
||||
albumCount: row.albumCount,
|
||||
songCount: row.songCount,
|
||||
})));
|
||||
}
|
||||
|
||||
async function fetchLocalGenreCatalog(
|
||||
serverId: string,
|
||||
libraryScope: string | undefined,
|
||||
): Promise<SubsonicGenre[]> {
|
||||
const genres = await loadLocalGenreCatalogRows(serverId, libraryScope);
|
||||
writeGenreCatalogCache(serverId, libraryScope, genres);
|
||||
return genres;
|
||||
}
|
||||
|
||||
/** Matches queueTrackResolver CACHE_CAP — whole seeded queue stays warm. */
|
||||
export const GENRE_PLAYBACK_QUEUE_CAP = 500;
|
||||
|
||||
const PLAY_ORDER: LibrarySortClause[] = [
|
||||
{ field: 'title', dir: 'asc' },
|
||||
{ field: 'artist', dir: 'asc' },
|
||||
];
|
||||
|
||||
const SHUFFLE_ORDER: LibrarySortClause[] = [{ field: 'random', dir: 'asc' }];
|
||||
|
||||
export async function fetchLocalGenreTracksForPlayback(
|
||||
serverId: string | null | undefined,
|
||||
genre: string,
|
||||
options: { shuffle?: boolean; cap?: number } = {},
|
||||
): Promise<Track[] | null> {
|
||||
const cap = options.cap ?? GENRE_PLAYBACK_QUEUE_CAP;
|
||||
if (!serverId || !genre.trim() || !(await libraryIsReady(serverId))) return null;
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
entityTypes: ['track'],
|
||||
filters: [{ field: 'genre', op: 'eq', value: genre }],
|
||||
sort: options.shuffle ? SHUFFLE_ORDER : PLAY_ORDER,
|
||||
limit: cap,
|
||||
offset: 0,
|
||||
skipTotals: true,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
return resp.tracks.map(t => songToTrack(trackToSong(t)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGenreTracksForPlayback(
|
||||
serverId: string | null | undefined,
|
||||
genre: string,
|
||||
options: { shuffle?: boolean; cap?: number; indexEnabled?: boolean } = {},
|
||||
): Promise<Track[]> {
|
||||
const cap = options.cap ?? GENRE_PLAYBACK_QUEUE_CAP;
|
||||
const shuffle = !!options.shuffle;
|
||||
if (options.indexEnabled !== false) {
|
||||
const local = await fetchLocalGenreTracksForPlayback(serverId, genre, { shuffle, cap });
|
||||
if (local) return local;
|
||||
}
|
||||
const songs = await fetchAllSongsByGenre(genre, cap);
|
||||
const tracks = songs.map(songToTrack);
|
||||
return shuffle ? shuffleArray(tracks) : tracks;
|
||||
}
|
||||
|
||||
export async function fetchGenreAlbumCount(
|
||||
serverId: string | null | undefined,
|
||||
genre: string,
|
||||
indexEnabled: boolean,
|
||||
sort: AlbumBrowseSort = 'alphabeticalByName',
|
||||
): Promise<number | null> {
|
||||
if (!genre.trim()) return null;
|
||||
if (indexEnabled && serverId) {
|
||||
const scope = libraryScopeForServer(serverId);
|
||||
const cached = lookupGenreAlbumCount(serverId, genre, scope);
|
||||
if (cached != null) return cached;
|
||||
const inflight = getInflightGenreCatalog(genreCatalogCacheKey(serverId, scope));
|
||||
if (inflight) {
|
||||
const catalog = await inflight;
|
||||
const match = catalog.find(g => g.value.localeCompare(genre, undefined, { sensitivity: 'accent' }) === 0);
|
||||
if (match?.albumCount != null) return match.albumCount;
|
||||
}
|
||||
const localTotal = await fetchGenreAlbumTotal(serverId, genre, indexEnabled, sort);
|
||||
if (localTotal != null) return localTotal;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const genres = await getGenres();
|
||||
const match = genres.find(g => g.value.localeCompare(genre, undefined, { sensitivity: 'accent' }) === 0);
|
||||
return match?.albumCount ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Genres cloud + detail header: local index counts when ready, else Navidrome `getGenres`. */
|
||||
export async function fetchGenreCatalog(
|
||||
serverId: string | null | undefined,
|
||||
indexEnabled: boolean,
|
||||
): Promise<SubsonicGenre[]> {
|
||||
if (!serverId) return getGenres();
|
||||
|
||||
const scope = libraryScopeForServer(serverId);
|
||||
const cacheKey = genreCatalogCacheKey(serverId, scope);
|
||||
const fresh = peekGenreCatalogCache(serverId, scope, false);
|
||||
if (fresh) return fresh;
|
||||
|
||||
const stale = peekGenreCatalogCache(serverId, scope, true);
|
||||
const inflight = getInflightGenreCatalog(cacheKey);
|
||||
if (inflight) {
|
||||
if (stale) return stale;
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const load = async (): Promise<SubsonicGenre[]> => {
|
||||
if (indexEnabled && (await libraryIsReady(serverId))) {
|
||||
try {
|
||||
return await fetchLocalGenreCatalog(serverId, scope);
|
||||
} catch {
|
||||
/* network fallback */
|
||||
}
|
||||
}
|
||||
const genres = filterGenresWithContent(await getGenres());
|
||||
writeGenreCatalogCache(serverId, scope, genres);
|
||||
return genres;
|
||||
};
|
||||
|
||||
const promise = load();
|
||||
trackInflightGenreCatalog(cacheKey, promise);
|
||||
|
||||
if (stale) {
|
||||
void promise.catch(() => {});
|
||||
return stale;
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
Reference in New Issue
Block a user