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:
cucadmuh
2026-06-01 04:20:18 +03:00
committed by GitHub
parent d3e5a6b704
commit ddf10ee01d
42 changed files with 1952 additions and 139 deletions
+7 -2
View File
@@ -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);
}
+19 -1
View File
@@ -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(
+7
View File
@@ -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);
});
});
+105
View File
@@ -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);
});
});
+169
View File
@@ -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();
}
+2
View File
@@ -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;