fix(random-mix): keyword blocks and scoped genre list on Build a Mix (#965)

* fix(random-mix): honor keyword blocks and scope genre list to library

Keyword filter was gated behind the audiobook exclusion checkbox, so
blocked artists still appeared after Remix. Genre Mix now loads genres
via fetchGenreCatalog (scoped index / library filter) instead of raw
getGenres; show empty state when all tracks are filtered out.

* docs(changelog): credit zunoz on Psysonic Discord for PR #965
This commit is contained in:
cucadmuh
2026-06-03 23:06:50 +03:00
committed by GitHub
parent e76dac87ae
commit 5990d84f5a
14 changed files with 86 additions and 29 deletions
+49 -19
View File
@@ -1,11 +1,11 @@
import { queueSongStar } from '../store/pendingStarSync';
import { getGenres } from '../api/subsonicGenres';
import type { SubsonicSong, SubsonicGenre } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { useEffect, useMemo, useState } from 'react';
import { usePlayerStore } from '../store/playerStore';
import { usePreviewStore } from '../store/previewStore';
import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../hooks/useIsMobile';
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
@@ -13,7 +13,8 @@ import {
fetchRandomMixSongsUntilFull,
getMixMinRatingsConfigFromAuth,
} from '../utils/mix/mixRatingFilter';
import { AUDIOBOOK_GENRES, filterRandomMixSongs, formatRandomMixDuration } from '../utils/componentHelpers/randomMixHelpers';
import { fetchGenreCatalog } from '../utils/library/genreBrowsePlayback';
import { AUDIOBOOK_GENRES, filterRandomMixSongs } from '../utils/componentHelpers/randomMixHelpers';
import RandomMixHeader from '../components/randomMix/RandomMixHeader';
import RandomMixFiltersPanel from '../components/randomMix/RandomMixFiltersPanel';
import RandomMixGenrePanel from '../components/randomMix/RandomMixGenrePanel';
@@ -58,6 +59,8 @@ export default function RandomMix() {
[mixMinRatingFilterEnabled, mixMinRatingSong, mixMinRatingAlbum, mixMinRatingArtist]
);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const activeServerId = useAuthStore(s => s.activeServerId ?? '');
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(activeServerId));
const [addedGenre, setAddedGenre] = useState<string | null>(null);
const [addedArtist, setAddedArtist] = useState<string | null>(null);
@@ -77,6 +80,7 @@ export default function RandomMix() {
const [genreMixSongs, setGenreMixSongs] = useState<SubsonicSong[]>([]);
const [genreMixLoading, setGenreMixLoading] = useState(false);
const [genreMixComplete, setGenreMixComplete] = useState(false);
const [genresLoading, setGenresLoading] = useState(true);
const fetchSongs = (overrideSize?: number) => {
setLoading(true);
@@ -98,23 +102,36 @@ export default function RandomMix() {
useEffect(() => {
fetchSongs();
getGenres().then(data => {
setServerGenres(data);
const audiobookLower = AUDIOBOOK_GENRES.map(g => g.toLowerCase());
const available = data
.filter(g => g.songCount > 0 && !audiobookLower.some(ab => g.value.toLowerCase().includes(ab)))
.sort((a, b) => b.songCount - a.songCount)
.map(g => g.value);
setAllAvailableGenres(available);
setDisplayedGenres(available.slice(0, 20));
}).catch(() => {});
}, [musicLibraryFilterVersion]);
setGenresLoading(true);
void fetchGenreCatalog(activeServerId, indexEnabled)
.then(data => {
setServerGenres(data);
const audiobookLower = AUDIOBOOK_GENRES.map(g => g.toLowerCase());
const available = data
.filter(g => g.songCount > 0 && !audiobookLower.some(ab => g.value.toLowerCase().includes(ab)))
.sort((a, b) => b.songCount - a.songCount)
.map(g => g.value);
setAllAvailableGenres(available);
setDisplayedGenres(available.slice(0, 20));
})
.catch(() => {
setServerGenres([]);
setAllAvailableGenres([]);
setDisplayedGenres([]);
})
.finally(() => setGenresLoading(false));
}, [musicLibraryFilterVersion, activeServerId, indexEnabled]);
const filteredSongs = filterRandomMixSongs(songs, { excludeAudiobooks, customGenreBlacklist, mixRatingCfg });
const filteredGenreMixSongs = filterRandomMixSongs(genreMixSongs, {
excludeAudiobooks,
customGenreBlacklist,
mixRatingCfg,
});
const handlePlayAll = () => {
if (selectedGenre && genreMixSongs.length > 0) {
playTrack(songToTrack(genreMixSongs[0]), genreMixSongs.map(songToTrack));
if (selectedGenre && filteredGenreMixSongs.length > 0) {
playTrack(songToTrack(filteredGenreMixSongs[0]), filteredGenreMixSongs.map(songToTrack));
} else if (filteredSongs.length > 0) {
playTrack(songToTrack(filteredSongs[0]), filteredSongs.map(songToTrack));
}
@@ -163,7 +180,7 @@ export default function RandomMix() {
loading={loading}
genreMixLoading={genreMixLoading}
genreMixComplete={genreMixComplete}
genreMixSongsLength={genreMixSongs.length}
genreMixSongsLength={filteredGenreMixSongs.length}
filteredSongsLength={filteredSongs.length}
randomMixSize={randomMixSize}
onRefresh={selectedGenre ? () => loadGenreMix(selectedGenre) : () => fetchSongs()}
@@ -204,6 +221,7 @@ export default function RandomMix() {
isMobile={isMobile}
genreMixExpanded={genreMixExpanded}
setGenreMixExpanded={setGenreMixExpanded}
genresLoading={genresLoading}
serverGenresLength={serverGenres.length}
displayedGenres={displayedGenres}
allAvailableGenresLength={allAvailableGenres.length}
@@ -216,7 +234,7 @@ export default function RandomMix() {
</div>
{/* Genre Mix tracklist (shown when a genre is selected) */}
{(genreMixLoading || genreMixSongs.length > 0) && (
{selectedGenre && (genreMixLoading || genreMixComplete || genreMixSongs.length > 0) && (
<div style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
@@ -226,6 +244,14 @@ export default function RandomMix() {
</div>
{genreMixLoading && genreMixSongs.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}><div className="spinner" /></div>
) : genreMixSongs.length === 0 ? (
<div className="empty-state" style={{ padding: '2rem 1rem', textAlign: 'center' }}>
{t('randomMix.noSongsMatchFilters')}
</div>
) : filteredGenreMixSongs.length === 0 ? (
<div className="empty-state" style={{ padding: '2rem 1rem', textAlign: 'center' }}>
{t('randomMix.noSongsMatchFilters')}
</div>
) : (
<div className="tracklist" data-preview-loc="randomMix">
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 70px 65px' }}>
@@ -236,9 +262,9 @@ export default function RandomMix() {
<div className="col-center">{t('randomMix.trackFavorite')}</div>
<div className="col-center">{t('randomMix.trackDuration')}</div>
</div>
{genreMixSongs.map((song, idx) => {
{filteredGenreMixSongs.map((song, idx) => {
const track = songToTrack(song);
const queueSongs = genreMixSongs.map(songToTrack);
const queueSongs = filteredGenreMixSongs.map(songToTrack);
const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
return (
<RandomMixTrackRow
@@ -290,6 +316,10 @@ export default function RandomMix() {
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
) : filteredSongs.length === 0 ? (
<div className="empty-state" style={{ padding: '4rem 1rem', textAlign: 'center' }}>
{t('randomMix.noSongsMatchFilters')}
</div>
) : (
<div className="tracklist" data-preview-loc="randomMix">
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 120px 70px 65px' }}>