From 5990d84f5a1ca8b7c78efa98089ebc703d693907 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:06:50 +0300 Subject: [PATCH] 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 --- CHANGELOG.md | 8 +++ .../randomMix/RandomMixGenrePanel.tsx | 7 +- src/locales/de/randomMix.ts | 1 + src/locales/en/randomMix.ts | 1 + src/locales/es/randomMix.ts | 1 + src/locales/fr/randomMix.ts | 1 + src/locales/nb/randomMix.ts | 1 + src/locales/nl/randomMix.ts | 1 + src/locales/ro/randomMix.ts | 1 + src/locales/ru/randomMix.ts | 1 + src/locales/zh/randomMix.ts | 1 + src/pages/RandomMix.tsx | 68 +++++++++++++------ .../componentHelpers/randomMixHelpers.test.ts | 10 +++ .../componentHelpers/randomMixHelpers.ts | 13 ++-- 14 files changed, 86 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7042c6c5..98c83206 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)** diff --git a/src/components/randomMix/RandomMixGenrePanel.tsx b/src/components/randomMix/RandomMixGenrePanel.tsx index e619fdfd..eebd5f0e 100644 --- a/src/components/randomMix/RandomMixGenrePanel.tsx +++ b/src/components/randomMix/RandomMixGenrePanel.tsx @@ -6,6 +6,7 @@ interface Props { isMobile: boolean; genreMixExpanded: boolean; setGenreMixExpanded: React.Dispatch>; + 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({

{t('randomMix.genreMixDesc')}

- {serverGenresLength === 0 ? ( + {genresLoading ? (
- ) : displayedGenres.length === 0 ? ( + ) : serverGenresLength === 0 || displayedGenres.length === 0 ? ( {t('randomMix.genreMixNoGenres')} ) : ( <> diff --git a/src/locales/de/randomMix.ts b/src/locales/de/randomMix.ts index 94eca17e..4bcb2d92 100644 --- a/src/locales/de/randomMix.ts +++ b/src/locales/de/randomMix.ts @@ -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.', diff --git a/src/locales/en/randomMix.ts b/src/locales/en/randomMix.ts index 8b0d7d9c..774838ea 100644 --- a/src/locales/en/randomMix.ts +++ b/src/locales/en/randomMix.ts @@ -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.', diff --git a/src/locales/es/randomMix.ts b/src/locales/es/randomMix.ts index 7f6ba37a..ad4495d8 100644 --- a/src/locales/es/randomMix.ts +++ b/src/locales/es/randomMix.ts @@ -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.', diff --git a/src/locales/fr/randomMix.ts b/src/locales/fr/randomMix.ts index 550a2b3f..c7ba0e4b 100644 --- a/src/locales/fr/randomMix.ts +++ b/src/locales/fr/randomMix.ts @@ -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.', diff --git a/src/locales/nb/randomMix.ts b/src/locales/nb/randomMix.ts index 9342f242..6cb4b884 100644 --- a/src/locales/nb/randomMix.ts +++ b/src/locales/nb/randomMix.ts @@ -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.', diff --git a/src/locales/nl/randomMix.ts b/src/locales/nl/randomMix.ts index 66f2846f..9ddc9b83 100644 --- a/src/locales/nl/randomMix.ts +++ b/src/locales/nl/randomMix.ts @@ -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.', diff --git a/src/locales/ro/randomMix.ts b/src/locales/ro/randomMix.ts index e962d04c..e3a78ff1 100644 --- a/src/locales/ro/randomMix.ts +++ b/src/locales/ro/randomMix.ts @@ -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.', diff --git a/src/locales/ru/randomMix.ts b/src/locales/ru/randomMix.ts index 38a91773..a4ae9a46 100644 --- a/src/locales/ru/randomMix.ts +++ b/src/locales/ru/randomMix.ts @@ -33,6 +33,7 @@ export const randomMix = { genreMixAll: 'Все треки', genreMixLoadMore: 'Ещё 10 жанров', genreMixNoGenres: 'На сервере нет данных о жанрах.', + noSongsMatchFilters: 'Нет песен, подходящих под текущие фильтры. Уберите ключевые слова из чёрного списка или измените настройки микса.', shuffleGenres: 'Другие жанры', filterPanelTitle: 'Фильтры', filterPanelDesc: diff --git a/src/locales/zh/randomMix.ts b/src/locales/zh/randomMix.ts index 007df4fd..a95e9e80 100644 --- a/src/locales/zh/randomMix.ts +++ b/src/locales/zh/randomMix.ts @@ -32,6 +32,7 @@ export const randomMix = { genreMixAll: '所有歌曲', genreMixLoadMore: '加载 10 首更多', genreMixNoGenres: '服务器上未找到流派。', + noSongsMatchFilters: '没有歌曲符合当前筛选条件。请从关键词过滤列表中移除条目或调整混音设置。', shuffleGenres: '显示其他流派', filterPanelTitle: '过滤器', filterPanelDesc: '点击下方列表中的流派标签或艺术家名称,将其从未来的混音中排除。', diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index 3475a528..91d3cdba 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -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(null); const [addedArtist, setAddedArtist] = useState(null); @@ -77,6 +80,7 @@ export default function RandomMix() { const [genreMixSongs, setGenreMixSongs] = useState([]); 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() {
{/* Genre Mix tracklist (shown when a genre is selected) */} - {(genreMixLoading || genreMixSongs.length > 0) && ( + {selectedGenre && (genreMixLoading || genreMixComplete || genreMixSongs.length > 0) && (
@@ -226,6 +244,14 @@ export default function RandomMix() {
{genreMixLoading && genreMixSongs.length === 0 ? (
+ ) : genreMixSongs.length === 0 ? ( +
+ {t('randomMix.noSongsMatchFilters')} +
+ ) : filteredGenreMixSongs.length === 0 ? ( +
+ {t('randomMix.noSongsMatchFilters')} +
) : (
@@ -236,9 +262,9 @@ export default function RandomMix() {
{t('randomMix.trackFavorite')}
{t('randomMix.trackDuration')}
- {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 (
+ ) : filteredSongs.length === 0 ? ( +
+ {t('randomMix.noSongsMatchFilters')} +
) : (
diff --git a/src/utils/componentHelpers/randomMixHelpers.test.ts b/src/utils/componentHelpers/randomMixHelpers.test.ts index 08ba5b7f..62b66162 100644 --- a/src/utils/componentHelpers/randomMixHelpers.test.ts +++ b/src/utils/componentHelpers/randomMixHelpers.test.ts @@ -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')]); + }); }); diff --git a/src/utils/componentHelpers/randomMixHelpers.ts b/src/utils/componentHelpers/randomMixHelpers.ts index 019ceb6d..e1995056 100644 --- a/src/utils/componentHelpers/randomMixHelpers.ts +++ b/src/utils/componentHelpers/randomMixHelpers.ts @@ -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; }); }