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
+8
View File
@@ -457,6 +457,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* The recommendation rail picks albums from Last.fm similar artists via `getArtist`, which can ignore `musicFolderId` — picks are now filtered to the scoped library album set, and the rail cache invalidates when the sidebar library filter changes.
### Build a Mix — keyword blocks and scoped genre list
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#965](https://github.com/Psychotoxical/psysonic/pull/965)**
* Random Mix keyword filter (click-to-block artist/genre) now applies even when "Exclude audiobooks" is off — blocking the only track in a library shows an empty state after Remix instead of the excluded song.
* Genre Mix loads genres through the scoped catalog (`fetchGenreCatalog` / local index) instead of server-wide `getGenres`, matching the sidebar library filter.
### In-page browse — virtual scroll and cover-art priority
**By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)**
@@ -6,6 +6,7 @@ interface Props {
isMobile: boolean;
genreMixExpanded: boolean;
setGenreMixExpanded: React.Dispatch<React.SetStateAction<boolean>>;
genresLoading: boolean;
serverGenresLength: number;
displayedGenres: string[];
allAvailableGenresLength: number;
@@ -18,7 +19,7 @@ interface Props {
export default function RandomMixGenrePanel({
isMobile, genreMixExpanded, setGenreMixExpanded,
serverGenresLength, displayedGenres, allAvailableGenresLength,
genresLoading, serverGenresLength, displayedGenres, allAvailableGenresLength,
selectedGenre, genreMixLoading, onSelectAll, onSelectGenre, onShuffle,
}: Props) {
const { t } = useTranslation();
@@ -43,9 +44,9 @@ export default function RandomMixGenrePanel({
<div style={{ marginTop: isMobile ? '0.75rem' : 0 }}>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>{t('randomMix.genreMixDesc')}</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', alignItems: 'center' }}>
{serverGenresLength === 0 ? (
{genresLoading ? (
<div className="spinner" style={{ width: 14, height: 14 }} />
) : displayedGenres.length === 0 ? (
) : serverGenresLength === 0 || displayedGenres.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('randomMix.genreMixNoGenres')}</span>
) : (
<>
+1
View File
@@ -32,6 +32,7 @@ export const randomMix = {
genreMixAll: 'Alle Songs',
genreMixLoadMore: '10 weitere laden',
genreMixNoGenres: 'Keine Genres auf dem Server gefunden.',
noSongsMatchFilters: 'Keine Titel entsprechen den aktuellen Filtern. Entferne Schlüsselwörter aus der Sperrliste oder passe die Mix-Einstellungen an.',
shuffleGenres: 'Andere Genres anzeigen',
filterPanelTitle: 'Filter',
filterPanelDesc: 'Genre-Tag oder Künstlername in der Liste anklicken, um ihn aus zukünftigen Mixes auszuschließen.',
+1
View File
@@ -32,6 +32,7 @@ export const randomMix = {
genreMixAll: 'All Songs',
genreMixLoadMore: 'Load 10 more',
genreMixNoGenres: 'No genres found on server.',
noSongsMatchFilters: 'No songs match the current filters. Try removing keyword blocks or changing mix settings.',
shuffleGenres: 'Show different genres',
filterPanelTitle: 'Filters',
filterPanelDesc: 'Click a genre tag or artist name in the tracklist below to block it from future mixes.',
+1
View File
@@ -32,6 +32,7 @@ export const randomMix = {
genreMixAll: 'Todas las Canciones',
genreMixLoadMore: 'Cargar 10 más',
genreMixNoGenres: 'No se encontraron géneros en el servidor.',
noSongsMatchFilters: 'Ninguna canción coincide con los filtros actuales. Quita palabras clave de la lista o cambia los ajustes del mix.',
shuffleGenres: 'Mostrar géneros diferentes',
filterPanelTitle: 'Filtros',
filterPanelDesc: 'Click en una etiqueta de género o nombre de artista en la lista para bloquearlo de futuras mezclas.',
+1
View File
@@ -32,6 +32,7 @@ export const randomMix = {
genreMixAll: 'Tous les morceaux',
genreMixLoadMore: 'Charger 10 de plus',
genreMixNoGenres: 'Aucun genre trouvé sur le serveur.',
noSongsMatchFilters: 'Aucun titre ne correspond aux filtres actuels. Retirez des mots-clés de la liste ou modifiez les réglages du mix.',
shuffleGenres: 'Afficher d\'autres genres',
filterPanelTitle: 'Filtres',
filterPanelDesc: 'Cliquez sur un tag de genre ou un nom d\'artiste dans la liste pour l\'exclure des futurs mix.',
+1
View File
@@ -32,6 +32,7 @@ export const randomMix = {
genreMixAll: 'Alle sanger',
genreMixLoadMore: 'Last 10 til',
genreMixNoGenres: 'Ingen sjangre funnet på tjeneren.',
noSongsMatchFilters: 'Ingen spor matcher de aktuelle filtrene. Fjern nøkkelord fra blokkeringslisten eller endre mix-innstillingene.',
shuffleGenres: 'Vis andre sjangre',
filterPanelTitle: 'Filtre',
filterPanelDesc: 'Klikk på en sjanger-tag eller på artistnavnet i sporlisten nedenfor, for å blokkere den fra fremtidige mikser.',
+1
View File
@@ -32,6 +32,7 @@ export const randomMix = {
genreMixAll: 'Alle nummers',
genreMixLoadMore: '10 meer laden',
genreMixNoGenres: 'Geen genres gevonden op server.',
noSongsMatchFilters: 'Geen nummers komen overeen met de huidige filters. Verwijder trefwoorden uit de blokkeerlijst of pas de mix-instellingen aan.',
shuffleGenres: 'Andere genres tonen',
filterPanelTitle: 'Filters',
filterPanelDesc: 'Klik op een genre-tag of artiestennaam in de lijst om deze uit toekomstige mixes te weren.',
+1
View File
@@ -32,6 +32,7 @@ export const randomMix = {
genreMixAll: 'Toate piesele',
genreMixLoadMore: 'Încarcă încă 10',
genreMixNoGenres: 'Niciun gen găsit pe server.',
noSongsMatchFilters: 'Niciun titlu nu se potrivește filtrelor curente. Elimină cuvinte cheie din lista de blocare sau schimbă setările mixului.',
shuffleGenres: 'Arată genuri diferite',
filterPanelTitle: 'Filtre',
filterPanelDesc: 'Apasă pe un tag de gen sau nume de artist în lista de piese de mai jos pentru a o bloca din mixuri viitoare.',
+1
View File
@@ -33,6 +33,7 @@ export const randomMix = {
genreMixAll: 'Все треки',
genreMixLoadMore: 'Ещё 10 жанров',
genreMixNoGenres: 'На сервере нет данных о жанрах.',
noSongsMatchFilters: 'Нет песен, подходящих под текущие фильтры. Уберите ключевые слова из чёрного списка или измените настройки микса.',
shuffleGenres: 'Другие жанры',
filterPanelTitle: 'Фильтры',
filterPanelDesc:
+1
View File
@@ -32,6 +32,7 @@ export const randomMix = {
genreMixAll: '所有歌曲',
genreMixLoadMore: '加载 10 首更多',
genreMixNoGenres: '服务器上未找到流派。',
noSongsMatchFilters: '没有歌曲符合当前筛选条件。请从关键词过滤列表中移除条目或调整混音设置。',
shuffleGenres: '显示其他流派',
filterPanelTitle: '过滤器',
filterPanelDesc: '点击下方列表中的流派标签或艺术家名称,将其从未来的混音中排除。',
+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' }}>
@@ -32,4 +32,14 @@ describe('filterRandomMixSongs', () => {
expect(kept).toHaveLength(1);
expect(kept[0].id).toBe('2');
});
it('applies keyword blacklist even when audiobook exclusion is off', () => {
const blocked = { ...song('1'), artist: '[Unknown Artist]' };
const out = filterRandomMixSongs([blocked, song('2')], {
excludeAudiobooks: false,
customGenreBlacklist: ['[Unknown Artist]'],
mixRatingCfg: { enabled: false, minSong: 0, minAlbum: 0, minArtist: 0 },
});
expect(out).toEqual([song('2')]);
});
});
@@ -25,17 +25,16 @@ export function filterRandomMixSongs(songs: SubsonicSong[], args: FilterArgs): S
const { excludeAudiobooks, customGenreBlacklist, mixRatingCfg } = args;
return songs.filter(song => {
if (!passesMixMinRatings(song, mixRatingCfg)) return false;
if (!excludeAudiobooks) return true;
const checkText = (text: string) => {
const matchesExcludedText = (text: string) => {
const t = text.toLowerCase();
if (AUDIOBOOK_GENRES.some(ag => t.includes(ag))) return true;
if (excludeAudiobooks && AUDIOBOOK_GENRES.some(ag => t.includes(ag))) return true;
if (customGenreBlacklist.some(bg => t.includes(bg.toLowerCase()))) return true;
return false;
};
if (song.genre && checkText(song.genre)) return false;
if (song.title && checkText(song.title)) return false;
if (song.album && checkText(song.album)) return false;
if (song.artist && checkText(song.artist)) return false;
if (song.genre && matchesExcludedText(song.genre)) return false;
if (song.title && matchesExcludedText(song.title)) return false;
if (song.album && matchesExcludedText(song.album)) return false;
if (song.artist && matchesExcludedText(song.artist)) return false;
return true;
});
}