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
+58
View File
@@ -706,6 +706,12 @@ export type CatalogYearBounds = {
maxYear: number | null;
};
export type GenreAlbumCountRow = {
value: string;
albumCount: number;
songCount: number;
};
export function libraryGetCatalogYearBounds(args: { serverId: string }): Promise<CatalogYearBounds> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<CatalogYearBounds>('library_get_catalog_year_bounds', {
@@ -713,6 +719,58 @@ export function libraryGetCatalogYearBounds(args: { serverId: string }): Promise
});
}
export function libraryGetGenreAlbumCounts(args: {
serverId: string;
libraryScope?: string;
}): Promise<GenreAlbumCountRow[]> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<GenreAlbumCountRow[]>('library_get_genre_album_counts', {
serverId: indexKey,
libraryScope: args.libraryScope,
});
}
export type LibraryGenreAlbumsRequest = {
serverId: string;
genre: string;
libraryScope?: string | null;
sort?: LibrarySortClause[];
limit?: number;
offset?: number;
includeTotal?: boolean;
};
export type LibraryGenreAlbumsResponse = {
albums: LibraryAlbumDto[];
hasMore: boolean;
total?: number | null;
source: 'local';
};
/** Paginated albums for one genre from the local track index. */
export function libraryListAlbumsByGenre(
request: LibraryGenreAlbumsRequest,
): Promise<LibraryGenreAlbumsResponse> {
const indexKey = serverIndexKeyForId(request.serverId);
return invoke<LibraryGenreAlbumsResponse>('library_list_albums_by_genre', {
request: {
serverId: indexKey,
genre: request.genre,
libraryScope: request.libraryScope ?? undefined,
sort: request.sort ?? [],
limit: request.limit ?? 50,
offset: request.offset ?? 0,
includeTotal: request.includeTotal ?? false,
},
}).then(response => ({
...response,
albums: response.albums.map(album => ({
...album,
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
})),
}));
}
export type PlaySessionRecentDay = {
date: string;
totalListenedSec: number;
+3 -1
View File
@@ -2,7 +2,9 @@ import { api, libraryFilterParams } from './subsonicClient';
import type { SubsonicAlbum, SubsonicGenre, SubsonicSong } from './subsonicTypes';
export async function getGenres(): Promise<SubsonicGenre[]> {
const data = await api<{ genres: { genre: SubsonicGenre | SubsonicGenre[] } }>('getGenres.view');
const data = await api<{ genres: { genre: SubsonicGenre | SubsonicGenre[] } }>('getGenres.view', {
...libraryFilterParams(),
});
const raw = data.genres?.genre;
if (!raw) return [];
return Array.isArray(raw) ? raw : [raw];
+1
View File
@@ -141,6 +141,7 @@ const CONTRIBUTOR_ENTRIES = [
'Analytics: Opus waveform/LUFS/enrichment decode via symphonia-adapter-libopus in the analysis pipeline (PR #883)',
'Artist page: top-track thumbnails use the same album cover path and warm batch as the albums grid (PR #886)',
'Library browse navigation: session restore on back for Artists, Search/Tracks, New Releases, Random Albums; unified SearchBrowsePage (PR #936)',
'Genres: local index genre browse with Subsonic fallback, aligned counts cache, scroll restore, hold-to-shuffle play (PR #937)',
],
},
{
+2
View File
@@ -8,6 +8,7 @@ export const NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID = 'new-releases-inpage-scrol
export const RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID = 'random-albums-inpage-scroll-viewport';
export const LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID = 'lossless-albums-inpage-scroll-viewport';
export const COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID = 'composers-inpage-scroll-viewport';
export const GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID = 'genre-detail-inpage-scroll-viewport';
export type AlbumGridInpageScrollSurface = 'albums' | 'new-releases' | 'random-albums';
@@ -39,5 +40,6 @@ export function readInpageScrollTop(viewportId: string): number {
/** Resolve in-page viewport id for the current route pathname. */
export function mainRouteInpageScrollViewportId(pathname: string): string | undefined {
const path = pathname.split('?')[0]?.replace(/\/$/, '') || pathname;
if (/^\/genres\/[^/]+$/.test(path)) return GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID;
return MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH[path];
}
+36 -6
View File
@@ -1,7 +1,9 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
import {
clearGenreDetailReturnStash,
peekAlbumBrowseScrollRestore,
peekGenreDetailScrollRestore,
type AlbumBrowseSurface,
useAlbumBrowseSessionStore,
} from '../store/albumBrowseSessionStore';
@@ -14,7 +16,10 @@ type PendingScroll = {
export type UseAlbumBrowseScrollRestoreArgs = {
serverId: string;
surface: AlbumBrowseSurface;
/** Album grid browse surface (All Albums, New Releases, Random Albums). */
surface?: AlbumBrowseSurface;
/** Genre detail page — uses genre-scoped stash instead of `surface`. */
genreName?: string;
scrollBodyEl: HTMLElement | null;
displayAlbumsLength: number;
loading: boolean;
@@ -30,12 +35,29 @@ export type UseAlbumBrowseScrollRestoreResult = {
function readPendingScrollRestore(
serverId: string,
surface: AlbumBrowseSurface,
surface: AlbumBrowseSurface | undefined,
genreName: string | undefined,
navigationType: NavigationType,
locationState: unknown,
): PendingScroll | null {
if (!shouldRestoreAlbumBrowseSession(navigationType, locationState) || !serverId) return null;
return peekAlbumBrowseScrollRestore(serverId, surface);
if (genreName) return peekGenreDetailScrollRestore(serverId, genreName);
if (surface) return peekAlbumBrowseScrollRestore(serverId, surface);
return null;
}
function clearScrollRestoreStash(
serverId: string,
surface: AlbumBrowseSurface | undefined,
genreName: string | undefined,
): void {
if (genreName) {
clearGenreDetailReturnStash(serverId, genreName);
return;
}
if (surface) {
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface);
}
}
/**
@@ -45,6 +67,7 @@ function readPendingScrollRestore(
export function useAlbumBrowseScrollRestore({
serverId,
surface,
genreName,
scrollBodyEl,
displayAlbumsLength,
loading,
@@ -60,11 +83,17 @@ export function useAlbumBrowseScrollRestore({
if (!initRef.current) {
initRef.current = true;
pendingRef.current = readPendingScrollRestore(serverId, surface, navigationType, location.state);
pendingRef.current = readPendingScrollRestore(
serverId,
surface,
genreName,
navigationType,
location.state,
);
}
const [isScrollRestorePending, setIsScrollRestorePending] = useState(
() => readPendingScrollRestore(serverId, surface, navigationType, location.state) !== null,
() => readPendingScrollRestore(serverId, surface, genreName, navigationType, location.state) !== null,
);
useLayoutEffect(() => {
@@ -84,7 +113,7 @@ export function useAlbumBrowseScrollRestore({
pendingRef.current = null;
doneRef.current = true;
setIsScrollRestorePending(false);
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface);
clearScrollRestoreStash(serverId, surface, genreName);
}, [
scrollBodyEl,
displayAlbumsLength,
@@ -94,6 +123,7 @@ export function useAlbumBrowseScrollRestore({
loadMore,
serverId,
surface,
genreName,
]);
return { isScrollRestorePending };
+179
View File
@@ -0,0 +1,179 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { dedupeById } from '../utils/dedupeById';
import type { AlbumBrowseSort } from '../utils/library/albumBrowseSort';
import {
fetchGenreAlbumPage,
GENRE_ALBUM_CATALOG_CHUNK,
GENRE_ALBUM_FIRST_PAGE,
} from '../utils/library/genreAlbumBrowse';
import { useClientSliceInfiniteScroll } from './useClientSliceInfiniteScroll';
import { useInpageScrollSentinel } from './useInpageScrollSentinel';
const CLIENT_SLICE_PAGE_SIZE = GENRE_ALBUM_FIRST_PAGE;
function initialSqlPageSize(restoreDisplayCount?: number): number {
if (restoreDisplayCount != null && restoreDisplayCount > CLIENT_SLICE_PAGE_SIZE) {
return Math.min(restoreDisplayCount, GENRE_ALBUM_CATALOG_CHUNK);
}
return CLIENT_SLICE_PAGE_SIZE;
}
export function useGenreAlbumBrowse(
serverId: string,
genre: string,
indexEnabled: boolean,
sort: AlbumBrowseSort,
musicLibraryFilterVersion: number,
getScrollRoot?: () => HTMLElement | null,
scrollRootEl?: HTMLElement | null,
restoreDisplayCount?: number,
) {
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [catalogLoadingMore, setCatalogLoadingMore] = useState(false);
const [catalogHasMore, setCatalogHasMore] = useState(false);
const catalogOffsetRef = useRef(0);
const catalogLoadingRef = useRef(false);
const loadGenerationRef = useRef(0);
const loadingRef = useRef(false);
const loadPendingRef = useRef(false);
const loadMoreRef = useRef<() => void>(() => {});
const browseSessionRef = useRef({ key: '', restoreDisplayCount: undefined as number | undefined });
const browseKey = `${serverId}:${genre}`;
if (browseSessionRef.current.key !== browseKey) {
browseSessionRef.current = {
key: browseKey,
restoreDisplayCount: restoreDisplayCount,
};
}
const sessionRestoreDisplayCount = browseSessionRef.current.restoreDisplayCount;
const {
visibleCount,
loadingMore: sliceLoadingMore,
loadMore: sliceLoadMore,
} = useClientSliceInfiniteScroll({
pageSize: CLIENT_SLICE_PAGE_SIZE,
resetDeps: [
sort,
genre,
musicLibraryFilterVersion,
serverId,
indexEnabled,
],
getScrollRoot,
scrollRootEl,
restoreDisplayCount: sessionRestoreDisplayCount,
});
const displayAlbums = useMemo(
() => albums.slice(0, visibleCount),
[albums, visibleCount],
);
const hasMore = visibleCount < albums.length || catalogHasMore;
const loadingMore = sliceLoadingMore || catalogLoadingMore;
const loadCatalogChunk = useCallback(async (
offset: number,
append: boolean,
pageSize: number = GENRE_ALBUM_CATALOG_CHUNK,
) => {
if (catalogLoadingRef.current || !genre) return;
const generation = loadGenerationRef.current;
catalogLoadingRef.current = true;
setCatalogLoadingMore(true);
try {
const chunk = await fetchGenreAlbumPage(
serverId,
genre,
indexEnabled,
offset,
pageSize,
sort,
);
if (generation !== loadGenerationRef.current) return;
if (append) {
setAlbums(prev => {
const merged = dedupeById([...prev, ...chunk.albums]);
catalogOffsetRef.current = merged.length;
return merged;
});
} else {
setAlbums(chunk.albums);
catalogOffsetRef.current = chunk.albums.length;
}
setCatalogHasMore(chunk.hasMore);
} finally {
catalogLoadingRef.current = false;
if (generation === loadGenerationRef.current) {
setCatalogLoadingMore(false);
}
}
}, [serverId, genre, indexEnabled, sort]);
useEffect(() => {
if (!genre) {
setAlbums([]);
setCatalogHasMore(false);
setLoading(false);
return;
}
let cancelled = false;
loadGenerationRef.current += 1;
const generation = loadGenerationRef.current;
catalogOffsetRef.current = 0;
catalogLoadingRef.current = false;
loadingRef.current = true;
loadPendingRef.current = true;
setLoading(true);
setCatalogLoadingMore(false);
setCatalogHasMore(false);
setAlbums([]);
const firstPageSize = initialSqlPageSize(sessionRestoreDisplayCount);
void loadCatalogChunk(0, false, firstPageSize).finally(() => {
if (cancelled || generation !== loadGenerationRef.current) return;
loadingRef.current = false;
loadPendingRef.current = false;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [serverId, genre, indexEnabled, sort, musicLibraryFilterVersion, loadCatalogChunk]);
const loadMore = useCallback(() => {
if (!genre || loadingRef.current || loadPendingRef.current) return;
if (visibleCount < albums.length) {
sliceLoadMore();
return;
}
if (catalogHasMore && !catalogLoadingRef.current) {
void loadCatalogChunk(catalogOffsetRef.current, true);
}
}, [genre, visibleCount, albums.length, catalogHasMore, sliceLoadMore, loadCatalogChunk]);
loadMoreRef.current = loadMore;
const bindLoadMoreSentinel = useInpageScrollSentinel({
active: hasMore,
getScrollRoot,
scrollRootEl,
onIntersect: () => loadMoreRef.current(),
drainSignal: loadingMore,
});
return {
albums,
displayAlbums,
loading,
loadingMore,
hasMore,
loadMore,
bindLoadMoreSentinel,
};
}
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useRef, type RefObject } from 'react';
import { useLocation, useNavigationType } from 'react-router-dom';
import { GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID, readInpageScrollTop } from '../constants/appScroll';
import {
DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
albumBrowseSortForServer,
clearGenreDetailReturnStash,
genreDetailGenreFromPath,
isAlbumDetailPath,
isGenreDetailPath,
peekGenreDetailScrollRestore,
stashGenreDetailReturnFilters,
useAlbumBrowseSessionStore,
} from '../store/albumBrowseSessionStore';
import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation';
import type { AlbumBrowseScrollSnapshot } from './useAlbumBrowseFilters';
/** Genre detail: locked genre filter + leave/restore session (same contract as All Albums). */
export function useGenreDetailBrowse(
serverId: string,
genreName: string,
scrollSnapshotRef?: RefObject<AlbumBrowseScrollSnapshot>,
) {
const navigationType = useNavigationType();
const location = useLocation();
const sort = useAlbumBrowseSessionStore(s => albumBrowseSortForServer(s.sortByServer, serverId));
const restoredFromStashRef = useRef(false);
const restoreKeyRef = useRef('');
const restoreDisplayCountRef = useRef<number | undefined>(undefined);
const restoreKey = `${serverId}:${genreName}`;
if (restoreKeyRef.current !== restoreKey) {
restoreKeyRef.current = restoreKey;
restoreDisplayCountRef.current = peekGenreDetailScrollRestore(serverId, genreName)?.displayCount;
}
useEffect(() => {
restoredFromStashRef.current = false;
}, [serverId, genreName]);
useEffect(() => {
if (!serverId || !genreName) return;
if (shouldRestoreAlbumBrowseSession(navigationType, location.state)) {
restoredFromStashRef.current = true;
return;
}
if (restoredFromStashRef.current) return;
clearGenreDetailReturnStash(serverId, genreName);
}, [serverId, genreName, navigationType, location.state]);
useEffect(() => {
return () => {
if (!serverId || !genreName) return;
const path = window.location.pathname;
if (isAlbumDetailPath(path)) {
const snapshot = scrollSnapshotRef?.current;
const scrollTop = Math.max(
readInpageScrollTop(GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID),
snapshot?.scrollTop ?? 0,
);
stashGenreDetailReturnFilters(serverId, genreName, {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
selectedGenres: [genreName],
scrollTop,
displayCount: snapshot?.displayCount,
});
} else if (!isGenreDetailPath(path) || genreDetailGenreFromPath(path) !== genreName) {
clearGenreDetailReturnStash(serverId, genreName);
}
};
}, [serverId, genreName, scrollSnapshotRef]);
return {
sort,
restoreDisplayCount: restoreDisplayCountRef.current,
};
}
+1
View File
@@ -9,6 +9,7 @@ export const genres = {
albumsEmpty: 'Keine Alben in diesem Genre gefunden.',
loadMore: 'Mehr laden',
back: 'Zurück',
playTooltip: 'Abspielen (halten zum Mischen)',
shuffle: 'Zufallswiedergabe',
addToQueue: 'Zur Warteschlange',
};
+1
View File
@@ -9,6 +9,7 @@ export const genres = {
albumsEmpty: 'No albums found for this genre.',
loadMore: 'Load more',
back: 'Back',
playTooltip: 'Play (hold to shuffle)',
shuffle: 'Shuffle',
addToQueue: 'Add to queue',
};
+1
View File
@@ -9,6 +9,7 @@ export const genres = {
albumsEmpty: 'No se encontraron álbumes para este género.',
loadMore: 'Cargar más',
back: 'Volver',
playTooltip: 'Reproducir (mantener para aleatorio)',
shuffle: 'Aleatorio',
addToQueue: 'Agregar a la cola',
};
+1
View File
@@ -9,6 +9,7 @@ export const genres = {
albumsEmpty: 'Aucun album trouvé pour ce genre.',
loadMore: 'Charger plus',
back: 'Retour',
playTooltip: 'Lire (maintenir pour mélanger)',
shuffle: 'Aléatoire',
addToQueue: 'Ajouter à la file',
};
+1
View File
@@ -9,6 +9,7 @@ export const genres = {
albumsEmpty: 'Ingen album funnet for denne sjangeren.',
loadMore: 'Last mer',
back: 'Tilbake',
playTooltip: 'Spill av (hold for tilfeldig rekkefølge)',
shuffle: 'Bland',
addToQueue: 'Legg til i kø',
};
+1
View File
@@ -9,6 +9,7 @@ export const genres = {
albumsEmpty: 'Geen albums gevonden voor dit genre.',
loadMore: 'Meer laden',
back: 'Terug',
playTooltip: 'Afspelen (ingedrukt houden om te shufflen)',
shuffle: 'Willekeurig',
addToQueue: 'Aan wachtrij toevoegen',
};
+1
View File
@@ -9,6 +9,7 @@ export const genres = {
albumsEmpty: 'Niciun album găsit pentru acest gen.',
loadMore: 'Încarcă mai mult',
back: 'Înapoi',
playTooltip: 'Redă (ține apăsat pentru amestecare)',
shuffle: 'Amestecă',
addToQueue: 'Adaugă la coadă',
};
+1
View File
@@ -11,6 +11,7 @@ export const genres = {
albumsEmpty: 'В этом жанре альбомов нет.',
loadMore: 'Ещё',
back: 'Назад',
playTooltip: 'Воспроизвести (удерживать для перемешивания)',
shuffle: 'Перемешать',
addToQueue: 'В очередь',
};
+1
View File
@@ -9,6 +9,7 @@ export const genres = {
albumsEmpty: '未找到该流派的专辑。',
loadMore: '加载更多',
back: '返回',
playTooltip: '播放(长按随机播放)',
shuffle: '随机播放',
addToQueue: '添加到队列',
};
+237 -117
View File
@@ -1,24 +1,36 @@
import { getAlbumsByGenre, fetchAllSongsByGenre } from '../api/subsonicGenres';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Disc3, Play, Shuffle, ListPlus, Loader2 } from 'lucide-react';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { ArrowLeft, Disc3, Play, ListPlus, Loader2 } from 'lucide-react';
import AlbumCard from '../components/AlbumCard';
import { songToTrack } from '../utils/playback/songToTrack';
import { runBulkPlayAll, runBulkShuffle, runBulkEnqueue } from '../utils/playback/runBulkPlay';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { albumGridWarmCovers } from '../cover/layoutSizes';
import { LongPressWaveOverlay } from '../components/LongPressWaveOverlay';
import InpageScrollSentinel from '../components/InpageScrollSentinel';
import OverlayScrollArea from '../components/OverlayScrollArea';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
const PAGE_SIZE = 50;
// Bulk play/shuffle pulls a bounded slice of the genre. The queue resolver
// (queueTrackResolver) holds a 500-entry LRU; seeding a larger queue evicts the
// earliest tracks, which then render as "…"/0:00 placeholders until lazily
// re-resolved. Keep the slice within that budget so the whole queue stays warm.
const GENRE_QUEUE_CAP = 500;
import { GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { albumGridWarmCovers } from '../cover/layoutSizes';
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters';
import { useGenreAlbumBrowse } from '../hooks/useGenreAlbumBrowse';
import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore';
import { useGenreDetailBrowse } from '../hooks/useGenreDetailBrowse';
import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
import { useLongPressAction } from '../hooks/useLongPressAction';
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { usePlayerStore } from '../store/playerStore';
import {
fetchGenreAlbumCount,
fetchGenreTracksForPlayback,
} from '../utils/library/genreBrowsePlayback';
import { lookupGenreAlbumCount } from '../utils/library/genreCatalogCountsCache';
import { libraryScopeForServer } from '../api/subsonicClient';
import {
readAlbumBrowseRestore,
readAlbumDetailReturnTo,
} from '../utils/navigation/albumDetailNavigation';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { runBulkEnqueue, runBulkPlayAll, runBulkShuffle } from '../utils/playback/runBulkPlay';
export default function GenreDetail() {
const { name } = useParams<{ name: string }>();
@@ -26,125 +38,233 @@ export default function GenreDetail() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
const navigate = useNavigate();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [offset, setOffset] = useState(0);
const [bulkLoading, setBulkLoading] = useState(false);
const location = useLocation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const fetchGenreTracks = useCallback(
() => fetchAllSongsByGenre(genre, GENRE_QUEUE_CAP).then(songs => songs.map(songToTrack)),
[genre],
const scrollSnapshotRef = useRef<AlbumBrowseScrollSnapshot>({ scrollTop: 0, displayCount: 0 });
const { sort, restoreDisplayCount } = useGenreDetailBrowse(serverId, genre, scrollSnapshotRef);
const {
scrollBodyEl,
bindScrollBody: bindGenreDetailScrollBody,
getScrollRoot,
} = useInpageScrollViewport();
const {
albums,
loading,
loadingMore,
hasMore,
displayAlbums,
bindLoadMoreSentinel,
loadMore,
} = useGenreAlbumBrowse(
serverId,
genre,
indexEnabled,
sort,
musicLibraryFilterVersion,
getScrollRoot,
scrollBodyEl,
restoreDisplayCount,
);
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, displayAlbums.length);
const { isScrollRestorePending } = useAlbumBrowseScrollRestore({
serverId,
genreName: genre,
scrollBodyEl,
displayAlbumsLength: displayAlbums.length,
loading,
loadingMore,
hasMore,
loadMore,
});
useEffect(() => {
if (isScrollRestorePending || !readAlbumBrowseRestore(location.state)) return;
navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
}, [isScrollRestorePending, location.pathname, location.search, location.hash, location.state, navigate]);
const [albumCount, setAlbumCount] = useState<number | null>(null);
const [bulkLoading, setBulkLoading] = useState(false);
useEffect(() => {
if (!genre || !serverId) return;
const cached = lookupGenreAlbumCount(serverId, genre, libraryScopeForServer(serverId));
if (cached != null) setAlbumCount(cached);
}, [serverId, genre, musicLibraryFilterVersion]);
useEffect(() => {
if (!genre || loading) return;
const cached = lookupGenreAlbumCount(serverId, genre, libraryScopeForServer(serverId));
if (cached != null) return;
let cancelled = false;
const timer = window.setTimeout(() => {
void fetchGenreAlbumCount(serverId, genre, indexEnabled, sort).then(count => {
if (!cancelled) setAlbumCount(count);
});
}, 0);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [serverId, genre, indexEnabled, sort, musicLibraryFilterVersion, loading]);
const fetchGenreTracks = useCallback(
(shuffle?: boolean) => fetchGenreTracksForPlayback(serverId, genre, {
shuffle,
indexEnabled,
}),
[serverId, genre, indexEnabled],
);
const handlePlayAll = useCallback(
() => runBulkPlayAll({ fetchTracks: fetchGenreTracks, setLoading: setBulkLoading, playTrack }),
() => runBulkPlayAll({ fetchTracks: () => fetchGenreTracks(false), setLoading: setBulkLoading, playTrack }),
[fetchGenreTracks, playTrack],
);
const handleShuffleAll = useCallback(
() => runBulkShuffle({ fetchTracks: fetchGenreTracks, setLoading: setBulkLoading, playTrack }),
() => runBulkShuffle({ fetchTracks: () => fetchGenreTracks(true), setLoading: setBulkLoading, playTrack }),
[fetchGenreTracks, playTrack],
);
const handleEnqueueAll = useCallback(
() => runBulkEnqueue({ fetchTracks: fetchGenreTracks, setLoading: setBulkLoading, enqueue }),
() => runBulkEnqueue({ fetchTracks: () => fetchGenreTracks(false), setLoading: setBulkLoading, enqueue }),
[fetchGenreTracks, enqueue],
);
useEffect(() => {
setAlbums([]);
setOffset(0);
setHasMore(true);
setLoading(true);
getAlbumsByGenre(genre, PAGE_SIZE, 0)
.then(data => {
setAlbums(data);
setHasMore(data.length === PAGE_SIZE);
setOffset(PAGE_SIZE);
})
.finally(() => setLoading(false));
}, [genre]);
const { isHolding, pressBind } = useLongPressAction({
onShortPress: handlePlayAll,
onLongPress: handleShuffleAll,
});
const loadMore = useCallback(() => {
if (loadingMore || !hasMore) return;
setLoadingMore(true);
getAlbumsByGenre(genre, PAGE_SIZE, offset)
.then(data => {
setAlbums(prev => [...prev, ...data]);
setHasMore(data.length === PAGE_SIZE);
setOffset(prev => prev + PAGE_SIZE);
})
.finally(() => setLoadingMore(false));
}, [genre, offset, loadingMore, hasMore]);
const handleBack = useCallback(() => {
navigate(readAlbumDetailReturnTo(location.state) ?? '/genres');
}, [navigate, location.state]);
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [genre, albumCount, bulkLoading]);
const headerCount = useMemo(() => {
if (!loading && !hasMore && albums.length > 0) return albums.length;
if (albumCount != null) return albumCount;
if (loading) return null;
return displayAlbums.length > 0 ? displayAlbums.length : null;
}, [loading, hasMore, albums.length, albumCount, displayAlbums.length]);
const showPlayback = !loading && (displayAlbums.length > 0 || (albumCount ?? 0) > 0);
return (
<div className="content-body animate-fade-in">
<button
className="btn btn-ghost"
onClick={() => navigate(-1)}
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
>
<ArrowLeft size={16} />
<span>{t('genres.back')}</span>
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{genre}</h1>
{!loading && albums.length > 0 && (
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--text-secondary)', fontSize: '0.9rem', fontWeight: 500 }}>
<Disc3 size={14} style={{ color: 'var(--accent)' }} />
{t('genres.albumCount', { count: albums.length })}{hasMore ? '+' : ''}
</span>
)}
{!loading && albums.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: 'auto' }}>
<button className="btn btn-primary" onClick={handlePlayAll} disabled={bulkLoading}>
{bulkLoading ? <Loader2 size={15} className="spin" /> : <Play size={15} />} {t('common.play')}
</button>
<button
className="btn btn-surface"
onClick={handleShuffleAll}
disabled={bulkLoading}
data-tooltip={t('genres.shuffle')}
>
<Shuffle size={16} />
</button>
<button
className="btn btn-surface"
onClick={handleEnqueueAll}
disabled={bulkLoading}
data-tooltip={t('genres.addToQueue')}
>
<ListPlus size={16} />
</button>
</div>
)}
<div className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}>
<div className="mainstage-inpage-toolbar">
<div className="page-sticky-header mainstage-inpage-toolbar-row">
<button
className="btn btn-ghost"
onClick={handleBack}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginRight: '0.25rem' }}
>
<ArrowLeft size={16} />
<span>{t('genres.back')}</span>
</button>
<h1 className="page-title" style={{ marginBottom: 0 }}>{genre}</h1>
{headerCount != null && headerCount > 0 && (
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--text-secondary)', fontSize: '0.9rem', fontWeight: 500 }}>
<Disc3 size={14} style={{ color: 'var(--accent)' }} />
{t('genres.albumCount', { count: headerCount })}
</span>
)}
{showPlayback && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: 'auto' }}>
<button
type="button"
className="btn btn-primary long-press-play-btn"
{...pressBind}
disabled={bulkLoading}
data-tooltip={t('genres.playTooltip')}
>
<LongPressWaveOverlay active={isHolding} size="compact" />
<span className="long-press-play-btn__icon" style={{ gap: '0.35rem' }}>
{bulkLoading ? <Loader2 size={15} className="spin" /> : <Play size={15} fill="currentColor" />}
{t('common.play')}
</span>
</button>
<button
className="btn btn-surface"
onClick={handleEnqueueAll}
disabled={bulkLoading}
data-tooltip={t('genres.addToQueue')}
>
<ListPlus size={16} />
</button>
</div>
)}
</div>
</div>
{loading && <p className="loading-text">{t('genres.albumsLoading')}</p>}
{!loading && albums.length === 0 && <p className="loading-text">{t('genres.albumsEmpty')}</p>}
{albums.length > 0 && (
<VirtualCardGrid
items={albums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={albums.length}
warmGridCovers={albumGridWarmCovers()}
renderItem={album => <AlbumCard album={album} />}
/>
)}
{hasMore && !loading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem 0' }}>
<button className="btn btn-surface" onClick={loadMore} disabled={loadingMore}>
{loadingMore ? t('common.loadingMore') : t('genres.loadMore')}
</button>
</div>
)}
<OverlayScrollArea
className="mainstage-inpage-scroll"
viewportClassName="mainstage-inpage-scroll__viewport"
viewportId={GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID}
viewportRef={bindGenreDetailScrollBody}
railInset="panel"
measureDeps={[
loading,
displayAlbums.length,
hasMore,
genre,
perfFlags.disableMainstageVirtualLists,
]}
>
{loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" />
</div>
) : !loading && displayAlbums.length === 0 ? (
<p className="loading-text" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
{t('genres.albumsEmpty')}
</p>
) : (
<div style={{ position: 'relative' }}>
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
<VirtualCardGrid
items={displayAlbums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={displayAlbums.length}
scrollRootId={GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID}
warmGridCovers={albumGridWarmCovers()}
renderItem={album => (
<AlbumCard
album={album}
observeScrollRootId={GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID}
/>
)}
/>
{hasMore && (
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loadingMore} />
)}
</div>
{isScrollRestorePending && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
justifyContent: 'center',
paddingTop: '3rem',
background: 'var(--ctp-base)',
}}
>
<div className="spinner" />
</div>
)}
</div>
)}
</OverlayScrollArea>
</div>
);
}
+33 -8
View File
@@ -1,10 +1,14 @@
import { getGenres } from '../api/subsonicGenres';
import type { SubsonicGenre } from '../api/subsonicTypes';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Tags } from 'lucide-react';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { fetchGenreCatalog } from '../utils/library/genreBrowsePlayback';
import { libraryScopeForServer } from '../api/subsonicClient';
import { peekGenreCatalogCache } from '../utils/library/genreCatalogCountsCache';
const CTP_COLORS = [
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
@@ -26,14 +30,35 @@ const FONT_MAX_REM = 1.7;
export default function Genres() {
const { t } = useTranslation();
const navigate = useNavigate();
const [rawGenres, setRawGenres] = useState<SubsonicGenre[]>([]);
const [loading, setLoading] = useState(true);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const libraryScope = libraryScopeForServer(serverId);
const cachedGenres = serverId ? peekGenreCatalogCache(serverId, libraryScope, true) : null;
const [rawGenres, setRawGenres] = useState<SubsonicGenre[]>(cachedGenres ?? []);
const [loading, setLoading] = useState(!cachedGenres);
useEffect(() => {
getGenres()
.then(data => setRawGenres(data))
.finally(() => setLoading(false));
}, []);
let cancelled = false;
const scope = libraryScopeForServer(serverId);
const cached = serverId ? peekGenreCatalogCache(serverId, scope, true) : null;
if (cached) {
setRawGenres(cached);
setLoading(false);
} else {
setLoading(true);
}
void fetchGenreCatalog(serverId, indexEnabled)
.then(data => {
if (!cancelled) setRawGenres(data);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [serverId, indexEnabled, musicLibraryFilterVersion]);
const genres = useMemo(
() => [...rawGenres].sort((a, b) => b.albumCount - a.albumCount),
@@ -62,7 +87,7 @@ export default function Genres() {
const handleGenreClick = (genreValue: string) => {
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
if (el) sessionStorage.setItem(SCROLL_KEY, String(el.scrollTop));
navigate(`/genres/${encodeURIComponent(genreValue)}`);
navigate(`/genres/${encodeURIComponent(genreValue)}`, { state: { returnTo: '/genres' } });
};
return (
+32
View File
@@ -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');
+66 -1
View File
@@ -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`). */
+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;