mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(genres): local index genre browse with Subsonic fallback (#937)
* feat(genres): genre detail browse via local index with aligned counts Move genre detail albums/play/shuffle onto the local library index with Albums-style in-page scroll, session restore, and genre-scoped stash. Unify genre album totals between the cloud and detail pages via library_get_genre_album_counts, and fix grouped browse totals to count distinct albums rather than matching tracks. * perf(genres): local genre browse with scoped counts cache Add dedicated Rust genre album pagination and indexes, slice-mode grid loading, library-filter-aware counts, and a long-lived in-memory catalog cache invalidated on sync so genre pages avoid repeated full-library SQL. * fix(genres): restore scroll after album back; play hold-to-shuffle Pin restore display count in refs so clearing the return stash no longer reloads the genre grid mid-restore. Load the first SQL page only (60 rows), use long-press on Play for shuffle, and add genre play tooltips. * fix(genres): fall back to Subsonic byGenre when local index unavailable Genre detail album grid now matches All Albums: try library_list_albums_by_genre first, then getAlbumsByGenre when the index is off, not ready, or errors. * docs: add CHANGELOG and credits for PR #937
This commit is contained in:
@@ -36,10 +36,15 @@ export async function fetchLocalAlbumCatalogChunk(
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
): Promise<AlbumBrowsePageResult | null> {
|
||||
const limit = query.genres.length > 0 && offset === 0 ? GENRE_ALBUM_FETCH_LIMIT : chunkSize;
|
||||
if (query.genres.length > 0 && offset > 0) {
|
||||
const singleGenre = query.genres.length === 1;
|
||||
if (query.genres.length > 1 && offset > 0) {
|
||||
return { albums: [], hasMore: false };
|
||||
}
|
||||
const limit = singleGenre
|
||||
? chunkSize
|
||||
: query.genres.length > 0 && offset === 0
|
||||
? GENRE_ALBUM_FETCH_LIMIT
|
||||
: chunkSize;
|
||||
return runLocalAlbumBrowse(serverId, query, offset, limit);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { libraryAdvancedSearch } from '../../api/library';
|
||||
import { libraryAdvancedSearch, libraryListAlbumsByGenre } from '../../api/library';
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import { libraryScopeForServer } from '../../api/subsonicClient';
|
||||
import { dedupeById } from '../dedupeById';
|
||||
@@ -29,6 +29,24 @@ export async function runLocalAlbumBrowse(
|
||||
const starredOnly = useServerStarredIds ? undefined : (query.starredOnly || undefined);
|
||||
|
||||
if (query.genres.length > 0) {
|
||||
if (query.genres.length === 1) {
|
||||
try {
|
||||
const resp = await libraryListAlbumsByGenre({
|
||||
serverId,
|
||||
genre: query.genres[0],
|
||||
libraryScope: scope,
|
||||
sort: albumSortClauses(query.sort),
|
||||
limit: pageSize,
|
||||
offset,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
let albums = resp.albums.map(albumToAlbum);
|
||||
if (useServerStarredIds) albums = markServerStarredAlbums(albums);
|
||||
return { albums, hasMore: resp.hasMore };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (offset > 0) return { albums: [], hasMore: false };
|
||||
try {
|
||||
const pages = await Promise.all(
|
||||
|
||||
@@ -30,6 +30,13 @@ export async function fetchAlbumBrowseNetwork(
|
||||
pageSize: number,
|
||||
): Promise<AlbumBrowsePageResult> {
|
||||
if (query.genres.length > 0) {
|
||||
if (query.genres.length === 1) {
|
||||
const data = applyNetworkPostFilters(
|
||||
await getAlbumsByGenre(query.genres[0], pageSize, offset),
|
||||
query,
|
||||
);
|
||||
return { albums: data, hasMore: data.length === pageSize };
|
||||
}
|
||||
if (offset > 0) return { albums: [], hasMore: false };
|
||||
const data = applyNetworkPostFilters(await fetchByGenres(query.genres), query);
|
||||
return { albums: data, hasMore: false };
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fetchGenreAlbumPage, fetchGenreAlbumTotal } from './genreAlbumBrowse';
|
||||
|
||||
vi.mock('../../api/library', () => ({
|
||||
libraryListAlbumsByGenre: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/subsonicGenres', () => ({
|
||||
getAlbumsByGenre: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/subsonicClient', () => ({
|
||||
libraryScopeForServer: vi.fn(() => 'lib-a'),
|
||||
}));
|
||||
|
||||
vi.mock('./libraryReady', () => ({
|
||||
libraryIsReady: vi.fn(),
|
||||
}));
|
||||
|
||||
import { libraryListAlbumsByGenre } from '../../api/library';
|
||||
import { getAlbumsByGenre } from '../../api/subsonicGenres';
|
||||
import { libraryIsReady } from './libraryReady';
|
||||
|
||||
describe('genreAlbumBrowse', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(libraryIsReady).mockReset();
|
||||
vi.mocked(libraryListAlbumsByGenre).mockReset();
|
||||
vi.mocked(getAlbumsByGenre).mockReset();
|
||||
});
|
||||
|
||||
it('loads albums from the local genre browse command when the index is ready', async () => {
|
||||
vi.mocked(libraryIsReady).mockResolvedValue(true);
|
||||
vi.mocked(libraryListAlbumsByGenre).mockResolvedValue({
|
||||
source: 'local',
|
||||
hasMore: true,
|
||||
albums: [{
|
||||
serverId: 'srv-1',
|
||||
id: 'al-1',
|
||||
name: 'Album',
|
||||
artist: 'Artist',
|
||||
artistId: 'ar-1',
|
||||
songCount: 8,
|
||||
durationSec: 100,
|
||||
syncedAt: 0,
|
||||
rawJson: {},
|
||||
}],
|
||||
});
|
||||
|
||||
const page = await fetchGenreAlbumPage('srv-1', 'Rock', true, 0, 60, 'alphabeticalByName');
|
||||
|
||||
expect(libraryListAlbumsByGenre).toHaveBeenCalledWith(expect.objectContaining({
|
||||
serverId: 'srv-1',
|
||||
genre: 'Rock',
|
||||
libraryScope: 'lib-a',
|
||||
offset: 0,
|
||||
limit: 60,
|
||||
}));
|
||||
expect(getAlbumsByGenre).not.toHaveBeenCalled();
|
||||
expect(page.albums).toHaveLength(1);
|
||||
expect(page.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to Subsonic byGenre when the local index is unavailable', async () => {
|
||||
vi.mocked(libraryIsReady).mockResolvedValue(false);
|
||||
vi.mocked(getAlbumsByGenre).mockResolvedValue([
|
||||
{ id: 'al-1', name: 'A', artist: 'X', artistId: 'x', songCount: 1, duration: 1 },
|
||||
]);
|
||||
|
||||
const page = await fetchGenreAlbumPage('srv-1', 'Rock', true, 0, 60, 'alphabeticalByName');
|
||||
|
||||
expect(libraryListAlbumsByGenre).not.toHaveBeenCalled();
|
||||
expect(getAlbumsByGenre).toHaveBeenCalledWith('Rock', 60, 0);
|
||||
expect(page.albums).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('uses Subsonic when the local index is disabled', async () => {
|
||||
vi.mocked(getAlbumsByGenre).mockResolvedValue([
|
||||
{ id: 'al-1', name: 'A', artist: 'X', artistId: 'x', songCount: 1, duration: 1 },
|
||||
]);
|
||||
|
||||
const page = await fetchGenreAlbumPage('srv-1', 'Rock', false, 0, 60, 'alphabeticalByName');
|
||||
|
||||
expect(libraryIsReady).not.toHaveBeenCalled();
|
||||
expect(getAlbumsByGenre).toHaveBeenCalledWith('Rock', 60, 0);
|
||||
expect(page.albums).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reads album totals from the local genre browse command when needed', async () => {
|
||||
vi.mocked(libraryIsReady).mockResolvedValue(true);
|
||||
vi.mocked(libraryListAlbumsByGenre).mockResolvedValue({
|
||||
source: 'local',
|
||||
hasMore: false,
|
||||
total: 42,
|
||||
albums: [],
|
||||
});
|
||||
|
||||
await expect(fetchGenreAlbumTotal('srv-1', 'Rock', true, 'alphabeticalByName')).resolves.toBe(42);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { getAlbumsByGenre } from '../../api/subsonicGenres';
|
||||
import { libraryListAlbumsByGenre } from '../../api/library';
|
||||
import { libraryScopeForServer } from '../../api/subsonicClient';
|
||||
import { albumToAlbum } from './advancedSearchLocal';
|
||||
import { albumSortClauses, sortSubsonicAlbums, type AlbumBrowseSort } from './albumBrowseSort';
|
||||
import type { AlbumBrowsePageResult } from './albumBrowseTypes';
|
||||
import { libraryIsReady } from './libraryReady';
|
||||
|
||||
/** First paint — one visible slice only. */
|
||||
export const GENRE_ALBUM_FIRST_PAGE = 60;
|
||||
/** Background SQL chunk when the in-memory buffer is exhausted. */
|
||||
export const GENRE_ALBUM_CATALOG_CHUNK = 200;
|
||||
|
||||
async function fetchLocalGenreAlbumPage(
|
||||
serverId: string,
|
||||
genre: string,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
sort: AlbumBrowseSort,
|
||||
): Promise<AlbumBrowsePageResult | null> {
|
||||
const scope = libraryScopeForServer(serverId) ?? undefined;
|
||||
if (!(await libraryIsReady(serverId))) return null;
|
||||
try {
|
||||
const resp = await libraryListAlbumsByGenre({
|
||||
serverId,
|
||||
genre,
|
||||
libraryScope: scope,
|
||||
sort: albumSortClauses(sort),
|
||||
limit: pageSize,
|
||||
offset,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
return {
|
||||
albums: resp.albums.map(albumToAlbum),
|
||||
hasMore: resp.hasMore,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNetworkGenreAlbumPage(
|
||||
genre: string,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
sort: AlbumBrowseSort,
|
||||
): Promise<AlbumBrowsePageResult> {
|
||||
try {
|
||||
const albums = await getAlbumsByGenre(genre, pageSize, offset);
|
||||
return {
|
||||
albums: sortSubsonicAlbums(albums, sort),
|
||||
hasMore: albums.length === pageSize,
|
||||
};
|
||||
} catch {
|
||||
return { albums: [], hasMore: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** Album grid for genre detail — local index when ready, else Subsonic `byGenre`. */
|
||||
export async function fetchGenreAlbumPage(
|
||||
serverId: string,
|
||||
genre: string,
|
||||
indexEnabled: boolean,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
sort: AlbumBrowseSort,
|
||||
): Promise<AlbumBrowsePageResult> {
|
||||
if (!serverId || !genre.trim()) {
|
||||
return { albums: [], hasMore: false };
|
||||
}
|
||||
|
||||
if (indexEnabled) {
|
||||
const local = await fetchLocalGenreAlbumPage(serverId, genre, offset, pageSize, sort);
|
||||
if (local != null) return local;
|
||||
}
|
||||
|
||||
return fetchNetworkGenreAlbumPage(genre, offset, pageSize, sort);
|
||||
}
|
||||
|
||||
export async function fetchGenreAlbumTotal(
|
||||
serverId: string,
|
||||
genre: string,
|
||||
indexEnabled: boolean,
|
||||
sort: AlbumBrowseSort,
|
||||
): Promise<number | null> {
|
||||
if (!genre.trim()) return null;
|
||||
if (indexEnabled && serverId && (await libraryIsReady(serverId))) {
|
||||
const scope = libraryScopeForServer(serverId) ?? undefined;
|
||||
try {
|
||||
const resp = await libraryListAlbumsByGenre({
|
||||
serverId,
|
||||
genre,
|
||||
libraryScope: scope,
|
||||
sort: albumSortClauses(sort),
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
includeTotal: true,
|
||||
});
|
||||
if (resp.source === 'local' && resp.total != null) return resp.total;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
fetchGenreAlbumCount,
|
||||
fetchGenreCatalog,
|
||||
fetchGenreTracksForPlayback,
|
||||
fetchLocalGenreTracksForPlayback,
|
||||
GENRE_PLAYBACK_QUEUE_CAP,
|
||||
} from './genreBrowsePlayback';
|
||||
|
||||
vi.mock('../../api/library', () => ({
|
||||
libraryAdvancedSearch: vi.fn(),
|
||||
libraryGetGenreAlbumCounts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/subsonicGenres', () => ({
|
||||
fetchAllSongsByGenre: vi.fn(),
|
||||
getGenres: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/subsonicClient', () => ({
|
||||
libraryScopeForServer: vi.fn(() => 'music'),
|
||||
}));
|
||||
|
||||
vi.mock('./libraryReady', () => ({
|
||||
libraryIsReady: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./genreAlbumBrowse', () => ({
|
||||
fetchGenreAlbumTotal: vi.fn(),
|
||||
}));
|
||||
|
||||
import { libraryAdvancedSearch, libraryGetGenreAlbumCounts } from '../../api/library';
|
||||
import { fetchAllSongsByGenre, getGenres } from '../../api/subsonicGenres';
|
||||
import { fetchGenreAlbumTotal } from './genreAlbumBrowse';
|
||||
import { resetGenreCatalogCountsCacheForTests } from './genreCatalogCountsCache';
|
||||
import { libraryIsReady } from './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('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,169 @@
|
||||
/**
|
||||
* Genre-detail bulk play/shuffle against the local library index.
|
||||
*/
|
||||
import { libraryAdvancedSearch, libraryGetGenreAlbumCounts, type LibrarySortClause } from '../../api/library';
|
||||
import { fetchAllSongsByGenre, getGenres } from '../../api/subsonicGenres';
|
||||
import type { SubsonicGenre } from '../../api/subsonicTypes';
|
||||
import { libraryScopeForServer } from '../../api/subsonicClient';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import { songToTrack } from '../playback/songToTrack';
|
||||
import { shuffleArray } from '../playback/shuffleArray';
|
||||
import { trackToSong } from './advancedSearchLocal';
|
||||
import { albumSortClauses, type AlbumBrowseSort } from './albumBrowseSort';
|
||||
import {
|
||||
genreCatalogCacheKey,
|
||||
getInflightGenreCatalog,
|
||||
lookupGenreAlbumCount,
|
||||
peekGenreCatalogCache,
|
||||
trackInflightGenreCatalog,
|
||||
writeGenreCatalogCache,
|
||||
} from './genreCatalogCountsCache';
|
||||
import { fetchGenreAlbumTotal } from './genreAlbumBrowse';
|
||||
import { libraryIsReady } from './libraryReady';
|
||||
|
||||
async function loadLocalGenreCatalogRows(
|
||||
serverId: string,
|
||||
libraryScope: string | undefined,
|
||||
): Promise<SubsonicGenre[]> {
|
||||
const rows = await libraryGetGenreAlbumCounts({
|
||||
serverId,
|
||||
libraryScope,
|
||||
});
|
||||
return 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 = await getGenres();
|
||||
writeGenreCatalogCache(serverId, scope, genres);
|
||||
return genres;
|
||||
};
|
||||
|
||||
const promise = load();
|
||||
trackInflightGenreCatalog(cacheKey, promise);
|
||||
|
||||
if (stale) {
|
||||
void promise.catch(() => {});
|
||||
return stale;
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
genreCatalogCacheKey,
|
||||
invalidateGenreCatalogCache,
|
||||
lookupGenreAlbumCount,
|
||||
peekGenreCatalogCache,
|
||||
resetGenreCatalogCountsCacheForTests,
|
||||
writeGenreCatalogCache,
|
||||
} from './genreCatalogCountsCache';
|
||||
|
||||
describe('genreCatalogCountsCache', () => {
|
||||
afterEach(() => {
|
||||
resetGenreCatalogCountsCacheForTests();
|
||||
});
|
||||
|
||||
it('keys by server and library scope', () => {
|
||||
expect(genreCatalogCacheKey('srv-1', undefined)).toBe('srv-1:all');
|
||||
expect(genreCatalogCacheKey('srv-1', 'lib-a')).toBe('srv-1:lib-a');
|
||||
});
|
||||
|
||||
it('serves fresh and stale catalog entries', () => {
|
||||
const genres = [{ value: 'Rock', albumCount: 3, songCount: 10 }];
|
||||
writeGenreCatalogCache('srv-1', 'lib-a', genres);
|
||||
expect(peekGenreCatalogCache('srv-1', 'lib-a')).toEqual(genres);
|
||||
expect(peekGenreCatalogCache('srv-1', 'lib-a', true)).toEqual(genres);
|
||||
expect(peekGenreCatalogCache('srv-1', 'lib-b')).toBeNull();
|
||||
});
|
||||
|
||||
it('looks up album counts from cached catalog', () => {
|
||||
writeGenreCatalogCache('srv-1', 'all', [
|
||||
{ value: 'Rock', albumCount: 12, songCount: 40 },
|
||||
]);
|
||||
expect(lookupGenreAlbumCount('srv-1', 'rock', 'all')).toBe(12);
|
||||
expect(lookupGenreAlbumCount('srv-1', 'Jazz', 'all')).toBeNull();
|
||||
});
|
||||
|
||||
it('invalidates per server', () => {
|
||||
writeGenreCatalogCache('srv-1', 'all', [{ value: 'A', albumCount: 1, songCount: 1 }]);
|
||||
writeGenreCatalogCache('srv-2', 'all', [{ value: 'B', albumCount: 2, songCount: 2 }]);
|
||||
invalidateGenreCatalogCache('srv-1');
|
||||
expect(peekGenreCatalogCache('srv-1', 'all')).toBeNull();
|
||||
expect(peekGenreCatalogCache('srv-2', 'all')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { SubsonicGenre } from '../../api/subsonicTypes';
|
||||
import { resolveServerIdForIndexKey } from '../server/serverLookup';
|
||||
|
||||
/** Fresh hits skip SQLite entirely. */
|
||||
const FRESH_TTL_MS = 60 * 60 * 1000;
|
||||
/** Stale entries still render while a background refresh runs. */
|
||||
const STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type CacheEntry = {
|
||||
genres: SubsonicGenre[];
|
||||
fetchedAt: number;
|
||||
};
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const inflight = new Map<string, Promise<SubsonicGenre[]>>();
|
||||
|
||||
export function genreCatalogCacheKey(serverId: string, libraryScope?: string): string {
|
||||
const resolved = resolveServerIdForIndexKey(serverId);
|
||||
const folder = libraryScope?.trim() ? libraryScope.trim() : 'all';
|
||||
return `${resolved}:${folder}`;
|
||||
}
|
||||
|
||||
function entryAge(entry: CacheEntry): number {
|
||||
return Date.now() - entry.fetchedAt;
|
||||
}
|
||||
|
||||
function findGenreInCatalog(genres: SubsonicGenre[], genre: string): SubsonicGenre | undefined {
|
||||
return genres.find(g => g.value.localeCompare(genre, undefined, { sensitivity: 'accent' }) === 0);
|
||||
}
|
||||
|
||||
export function peekGenreCatalogCache(
|
||||
serverId: string,
|
||||
libraryScope?: string,
|
||||
allowStale = false,
|
||||
): SubsonicGenre[] | null {
|
||||
const entry = cache.get(genreCatalogCacheKey(serverId, libraryScope));
|
||||
if (!entry) return null;
|
||||
const age = entryAge(entry);
|
||||
if (age <= FRESH_TTL_MS) return entry.genres;
|
||||
if (allowStale && age <= STALE_TTL_MS) return entry.genres;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function lookupGenreAlbumCount(
|
||||
serverId: string,
|
||||
genre: string,
|
||||
libraryScope?: string,
|
||||
): number | null {
|
||||
const entry = cache.get(genreCatalogCacheKey(serverId, libraryScope));
|
||||
if (!entry || entryAge(entry) > STALE_TTL_MS) return null;
|
||||
return findGenreInCatalog(entry.genres, genre)?.albumCount ?? null;
|
||||
}
|
||||
|
||||
export function writeGenreCatalogCache(
|
||||
serverId: string,
|
||||
libraryScope: string | undefined,
|
||||
genres: SubsonicGenre[],
|
||||
): void {
|
||||
cache.set(genreCatalogCacheKey(serverId, libraryScope), {
|
||||
genres,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateGenreCatalogCache(serverId?: string): void {
|
||||
if (!serverId) {
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
return;
|
||||
}
|
||||
const resolved = resolveServerIdForIndexKey(serverId);
|
||||
const prefix = `${resolved}:`;
|
||||
for (const key of [...cache.keys()]) {
|
||||
if (key.startsWith(prefix)) cache.delete(key);
|
||||
}
|
||||
for (const key of [...inflight.keys()]) {
|
||||
if (key.startsWith(prefix)) inflight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function getInflightGenreCatalog(key: string): Promise<SubsonicGenre[]> | undefined {
|
||||
return inflight.get(key);
|
||||
}
|
||||
|
||||
export function trackInflightGenreCatalog(key: string, promise: Promise<SubsonicGenre[]>): void {
|
||||
inflight.set(key, promise);
|
||||
void promise.finally(() => {
|
||||
if (inflight.get(key) === promise) inflight.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
/** Test-only reset. */
|
||||
export function resetGenreCatalogCountsCacheForTests(): void {
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../../api/library';
|
||||
import type { UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { libraryDevEnabled, logLibrarySync } from './libraryDevLog';
|
||||
import { invalidateGenreCatalogCache } from './genreCatalogCountsCache';
|
||||
|
||||
export type LibrarySyncQueueKind = 'full' | 'delta' | 'verify';
|
||||
|
||||
@@ -44,6 +45,7 @@ function ensureIdleListener(): Promise<UnlistenFn> {
|
||||
}
|
||||
|
||||
function onSyncIdle(payload: LibrarySyncIdlePayload): void {
|
||||
if (payload.ok) invalidateGenreCatalogCache(payload.serverId);
|
||||
if (!waitingForIdle || waitingForIdle.serverId !== payload.serverId) return;
|
||||
const waiter = waitingForIdle;
|
||||
waitingForIdle = null;
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('albumDetailNavigation', () => {
|
||||
it('navigates back to saved returnTo', () => {
|
||||
const navigate = vi.fn();
|
||||
navigateAlbumDetailBack(navigate, { state: { returnTo: '/genres/Rock' } });
|
||||
expect(navigate).toHaveBeenCalledWith('/genres/Rock', undefined);
|
||||
expect(navigate).toHaveBeenCalledWith('/genres/Rock', { state: { albumBrowseRestore: true } });
|
||||
});
|
||||
|
||||
it('flags All Albums return for browse restore', () => {
|
||||
|
||||
@@ -113,8 +113,14 @@ function isArtistsBrowseReturnPath(path: string): boolean {
|
||||
return path === '/artists' || path.startsWith('/artists?');
|
||||
}
|
||||
|
||||
function isGenreDetailReturnPath(path: string): boolean {
|
||||
const bare = path.split('?')[0]?.replace(/\/$/, '') || path;
|
||||
return /^\/genres\/[^/]+$/.test(bare);
|
||||
}
|
||||
|
||||
function browseReturnRestoreState(returnTo: string): AlbumsBrowseRestoreLocationState | undefined {
|
||||
if (isAlbumGridBrowseReturnPath(returnTo)) return albumBrowseRestoreNavigationState();
|
||||
if (isGenreDetailReturnPath(returnTo)) return albumBrowseRestoreNavigationState();
|
||||
if (isArtistsBrowseReturnPath(returnTo)) return artistBrowseRestoreNavigationState();
|
||||
if (isSearchReturnPath(returnTo)) return advancedSearchRestoreNavigationState();
|
||||
return undefined;
|
||||
|
||||
Reference in New Issue
Block a user