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:
@@ -5,8 +5,14 @@ import {
|
||||
DEFAULT_ALBUM_BROWSE_SORT,
|
||||
albumBrowseSortForServer,
|
||||
albumBrowseSurfaceForPath,
|
||||
clearGenreDetailReturnStash,
|
||||
isAlbumDetailPath,
|
||||
isGenreDetailPath,
|
||||
genreDetailGenreFromPath,
|
||||
peekAlbumBrowseScrollRestore,
|
||||
peekGenreDetailReturnStash,
|
||||
peekGenreDetailScrollRestore,
|
||||
stashGenreDetailReturnFilters,
|
||||
useAlbumBrowseSessionStore,
|
||||
} from './albumBrowseSessionStore';
|
||||
|
||||
@@ -97,6 +103,22 @@ describe('albumBrowseSessionStore', () => {
|
||||
const { sortByServer } = useAlbumBrowseSessionStore.getState();
|
||||
expect(albumBrowseSortForServer(sortByServer, 'unknown')).toBe(DEFAULT_ALBUM_BROWSE_SORT);
|
||||
});
|
||||
|
||||
it('stashes genre detail leave snapshot separately from album grid surfaces', () => {
|
||||
stashGenreDetailReturnFilters('srv-a', 'Rock', {
|
||||
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
|
||||
selectedGenres: ['Rock'],
|
||||
scrollTop: 640,
|
||||
displayCount: 90,
|
||||
});
|
||||
expect(peekGenreDetailReturnStash('srv-a', 'Rock')?.scrollTop).toBe(640);
|
||||
expect(peekGenreDetailScrollRestore('srv-a', 'Rock')).toEqual({
|
||||
scrollTop: 640,
|
||||
displayCount: 90,
|
||||
});
|
||||
clearGenreDetailReturnStash('srv-a', 'Rock');
|
||||
expect(peekGenreDetailReturnStash('srv-a', 'Rock')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAlbumDetailPath', () => {
|
||||
@@ -109,6 +131,16 @@ describe('isAlbumDetailPath', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGenreDetailPath', () => {
|
||||
it('matches single genre detail routes only', () => {
|
||||
expect(isGenreDetailPath('/genres/Rock')).toBe(true);
|
||||
expect(isGenreDetailPath('/genres/Rock%20%26%20Roll')).toBe(true);
|
||||
expect(isGenreDetailPath('/genres')).toBe(false);
|
||||
expect(isGenreDetailPath('/genres/Rock/albums')).toBe(false);
|
||||
expect(genreDetailGenreFromPath('/genres/Rock%20%26%20Roll')).toBe('Rock & Roll');
|
||||
});
|
||||
});
|
||||
|
||||
describe('albumBrowseSurfaceForPath', () => {
|
||||
it('maps album grid browse routes', () => {
|
||||
expect(albumBrowseSurfaceForPath('/albums')).toBe('albums');
|
||||
|
||||
@@ -58,6 +58,10 @@ function returnStashKey(serverId: string, surface: AlbumBrowseSurface): string {
|
||||
return `${serverId}:${surface}`;
|
||||
}
|
||||
|
||||
function genreDetailStashKey(serverId: string, genreName: string): string {
|
||||
return `${serverId}:genre-detail:${genreName}`;
|
||||
}
|
||||
|
||||
function sortEntryFor(
|
||||
sortByServer: Record<string, AlbumBrowseSort>,
|
||||
serverId: string,
|
||||
@@ -132,6 +136,55 @@ export function peekAlbumBrowseScrollRestore(
|
||||
};
|
||||
}
|
||||
|
||||
/** Genre detail leave-restore (scoped per genre name). */
|
||||
export function stashGenreDetailReturnFilters(
|
||||
serverId: string,
|
||||
genreName: string,
|
||||
filters: AlbumBrowseReturnFilters,
|
||||
): void {
|
||||
if (!serverId || !genreName) return;
|
||||
const key = genreDetailStashKey(serverId, genreName);
|
||||
useAlbumBrowseSessionStore.setState((s) => ({
|
||||
returnStashByKey: {
|
||||
...s.returnStashByKey,
|
||||
[key]: cloneReturnFilters(filters),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function clearGenreDetailReturnStash(serverId: string, genreName: string): void {
|
||||
if (!serverId || !genreName) return;
|
||||
const key = genreDetailStashKey(serverId, genreName);
|
||||
useAlbumBrowseSessionStore.setState((s) => {
|
||||
const next = { ...s.returnStashByKey };
|
||||
delete next[key];
|
||||
return { returnStashByKey: next };
|
||||
});
|
||||
}
|
||||
|
||||
export function peekGenreDetailReturnStash(
|
||||
serverId: string,
|
||||
genreName: string,
|
||||
): AlbumBrowseReturnFilters | null {
|
||||
if (!serverId || !genreName) return null;
|
||||
const stash = useAlbumBrowseSessionStore.getState().returnStashByKey[genreDetailStashKey(serverId, genreName)];
|
||||
if (!stash) return null;
|
||||
return cloneReturnFilters(stash);
|
||||
}
|
||||
|
||||
export function peekGenreDetailScrollRestore(
|
||||
serverId: string,
|
||||
genreName: string,
|
||||
): { scrollTop: number; displayCount: number } | null {
|
||||
const stash = peekGenreDetailReturnStash(serverId, genreName);
|
||||
if (!stash) return null;
|
||||
if (typeof stash.scrollTop !== 'number' || typeof stash.displayCount !== 'number') return null;
|
||||
return {
|
||||
scrollTop: Math.max(0, stash.scrollTop),
|
||||
displayCount: Math.max(0, stash.displayCount),
|
||||
};
|
||||
}
|
||||
|
||||
export function albumBrowseSortForServer(
|
||||
sortByServer: Record<string, AlbumBrowseSort>,
|
||||
serverId: string,
|
||||
@@ -151,7 +204,19 @@ export function albumBrowseSurfaceForPath(pathname: string): AlbumBrowseSurface
|
||||
|
||||
/** True when pathname is a single album detail route (`/album/:id`). */
|
||||
export function isAlbumDetailPath(pathname: string): boolean {
|
||||
return /^\/album\/[^/]+\/?$/.test(pathname);
|
||||
return /^\/album\/[^/]+\/?$/.test(pathname.split('?')[0]?.replace(/\/$/, '') || pathname);
|
||||
}
|
||||
|
||||
/** Single genre detail route (`/genres/:name`), not the genre cloud (`/genres`). */
|
||||
export function isGenreDetailPath(pathname: string): boolean {
|
||||
const path = pathname.split('?')[0]?.replace(/\/$/, '') || pathname;
|
||||
return /^\/genres\/[^/]+$/.test(path);
|
||||
}
|
||||
|
||||
export function genreDetailGenreFromPath(pathname: string): string | null {
|
||||
const path = pathname.split('?')[0]?.replace(/\/$/, '') || pathname;
|
||||
const match = path.match(/^\/genres\/([^/]+)$/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
/** True when pathname is a single artist detail route (`/artist/:id`). */
|
||||
|
||||
Reference in New Issue
Block a user