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:
Psychotoxical
2026-06-30 17:00:57 +02:00
parent edab32d7ee
commit e5705f853e
5 changed files with 14 additions and 14 deletions
@@ -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;
}