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; }); }