Files
Psychotoxical-psysonic/src/utils/library/genreBrowsePlayback.ts
T
cucadmuh ddf10ee01d 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
2026-06-01 04:20:18 +03:00

170 lines
5.6 KiB
TypeScript

/**
* 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;
}