feat(subsonic): per-server music folder filter and sidebar picker (#125)

Adds library scoping per server: users can select one Navidrome music
folder or 'All' from a new sidebar dropdown. Choice is persisted per
server ID in localStorage. A musicLibraryFilterVersion counter triggers
refetches across browsing pages when the scope changes.

- getMusicFolders() + libraryFilterParams() in subsonic.ts
- New fields in authStore: musicFolders, musicLibraryFilterByServer,
  musicLibraryFilterVersion, setMusicFolders, setMusicLibraryFilter
- Sidebar dropdown via createPortal; collapses to icon in narrow mode
- API scoping applied to: getAlbumList2, getRandomSongs, getArtists,
  getStarred2, search3
- Full i18n coverage (en, de, fr, nl, zh, nb, ru)

Review fixes applied (co-authored):
- Sidebar: title={} → data-tooltip + data-tooltip-pos='right'
- authStore: setMusicFolders merged into single set() call
- Locales: removed dead libraryScopeHint key from all 7 files
- Genres.tsx: removed musicLibraryFilterVersion dep (getGenres unscoped)

Co-authored-by: cucadmuh <cucadmuh@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-07 13:54:57 +02:00
30 changed files with 489 additions and 35 deletions
+5 -3
View File
@@ -11,6 +11,7 @@ import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import CustomSelect from '../components/CustomSelect';
import { useDragDrop } from '../contexts/DragDropContext';
import { useAuthStore } from '../store/authStore';
type ResultType = 'all' | 'artists' | 'albums' | 'songs';
@@ -31,6 +32,7 @@ interface Results {
export default function AdvancedSearch() {
const { t } = useTranslation();
const [params] = useSearchParams();
const qFromUrl = params.get('q') ?? '';
const navigate = useNavigate();
const psyDrag = useDragDrop();
const playTrack = usePlayerStore(s => s.playTrack);
@@ -46,6 +48,7 @@ export default function AdvancedSearch() {
const [loading, setLoading] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [genreNote, setGenreNote] = useState(false);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const runSearch = async (opts: SearchOpts) => {
setLoading(true);
@@ -109,9 +112,8 @@ export default function AdvancedSearch() {
getGenres().then(data =>
setGenres(data.sort((a, b) => a.value.localeCompare(b.value)))
).catch(() => {});
const q = params.get('q') ?? '';
if (q) runSearch({ query: q, genre: '', yearFrom: '', yearTo: '', resultType: 'all' });
}, []);
if (qFromUrl) runSearch({ query: qFromUrl, genre: '', yearFrom: '', yearTo: '', resultType: 'all' });
}, [musicLibraryFilterVersion, qFromUrl]);
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
+4 -2
View File
@@ -3,6 +3,7 @@ import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { X } from 'lucide-react';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
@@ -18,6 +19,7 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
export default function Albums() {
const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [sort, setSort] = useState<SortType>('alphabeticalByName');
const [loading, setLoading] = useState(true);
@@ -50,7 +52,7 @@ export default function Albums() {
} finally {
setLoading(false);
}
}, []);
}, [musicLibraryFilterVersion]);
const loadFiltered = useCallback(async (genres: string[], sortType: SortType) => {
setLoading(true);
@@ -66,7 +68,7 @@ export default function Albums() {
} finally {
setLoading(false);
}
}, []);
}, [musicLibraryFilterVersion]);
useEffect(() => {
setPage(0);
+3 -2
View File
@@ -70,6 +70,7 @@ export default function ArtistDetail() {
const isPlaying = usePlayerStore(state => state.isPlaying);
const { downloadArtist, bulkProgress } = useOfflineStore();
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => {
if (!id) return;
@@ -126,7 +127,7 @@ export default function ArtistDetail() {
setFeaturedAlbums([...albumMap.values()]);
setFeaturedLoading(false);
});
}, [artist?.id]);
}, [artist?.id, musicLibraryFilterVersion]);
useEffect(() => {
if (!artist || !lastfmIsConfigured()) return;
@@ -152,7 +153,7 @@ export default function ArtistDetail() {
setSimilarArtists(found);
setSimilarLoading(false);
}).catch(() => setSimilarLoading(false));
}, [artist?.id]);
}, [artist?.id, musicLibraryFilterVersion]);
const openLink = (url: string, key: string) => {
open(url);
+2 -1
View File
@@ -86,10 +86,11 @@ export default function Artists() {
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const showArtistImages = useAuthStore(s => s.showArtistImages);
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => {
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
}, []);
}, [musicLibraryFilterVersion]);
const loadMore = useCallback(() => {
setVisibleCount(prev => prev + 50);
+3 -1
View File
@@ -14,6 +14,7 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { unstar } from '../api/subsonic';
import { useDragDrop } from '../contexts/DragDropContext';
import { useAuthStore } from '../store/authStore';
const FAV_COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
@@ -63,6 +64,7 @@ export default function Favorites() {
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const navigate = useNavigate();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => {
const loadAll = async () => {
@@ -87,7 +89,7 @@ export default function Favorites() {
setLoading(false);
};
loadAll();
}, []);
}, [musicLibraryFilterVersion]);
if (loading) {
return (
+2
View File
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Disc3 } from 'lucide-react';
import { getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
import AlbumCard from '../components/AlbumCard';
const PAGE_SIZE = 50;
@@ -17,6 +18,7 @@ export default function GenreDetail() {
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [offset, setOffset] = useState(0);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => {
setAlbums([]);
+2 -1
View File
@@ -7,6 +7,7 @@ import {
Tags, type LucideIcon,
} from 'lucide-react';
import { getGenres, SubsonicGenre } from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
function getGenreIcon(name: string): LucideIcon {
const n = name.toLowerCase();
@@ -59,7 +60,7 @@ export default function Genres() {
setGenres(sorted);
})
.finally(() => setLoading(false));
}, []);
}, []); // getGenres is not folder-scoped — no dep on musicLibraryFilterVersion
// Restore scroll position after genres are rendered
useEffect(() => {
+3 -1
View File
@@ -6,9 +6,11 @@ import { useTranslation } from 'react-i18next';
import { NavLink, useNavigate } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';
import { useHomeStore } from '../store/homeStore';
import { useAuthStore } from '../store/authStore';
export default function Home() {
const homeSections = useHomeStore(s => s.sections);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
@@ -44,7 +46,7 @@ export default function Home() {
setRandomArtists(shuffled.slice(0, 16));
setLoading(false);
}).catch(() => setLoading(false));
}, []);
}, [musicLibraryFilterVersion, homeSections]);
const loadMore = async (
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
+3 -1
View File
@@ -4,6 +4,7 @@ import { ChevronLeft } from 'lucide-react';
import AlbumCard from '../components/AlbumCard';
import { search, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
export default function LabelAlbums() {
const { t } = useTranslation();
@@ -11,6 +12,7 @@ export default function LabelAlbums() {
const navigate = useNavigate();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => {
if (!name) return;
@@ -31,7 +33,7 @@ export default function LabelAlbums() {
})
.catch(console.error)
.finally(() => setLoading(false));
}, [name]);
}, [name, musicLibraryFilterVersion]);
return (
<div className="animate-fade-in" style={{ padding: '0 var(--space-6)' }}>
+3 -1
View File
@@ -3,6 +3,7 @@ import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
const PAGE_SIZE = 30;
@@ -15,6 +16,7 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
export default function NewReleases() {
const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
@@ -43,7 +45,7 @@ export default function NewReleases() {
} finally {
setLoading(false);
}
}, []);
}, [musicLibraryFilterVersion]);
useEffect(() => {
if (filtered) loadFiltered(selectedGenres);
+3 -1
View File
@@ -4,6 +4,7 @@ import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
const ALBUM_COUNT = 30;
@@ -21,6 +22,7 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
export default function RandomAlbums() {
const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
@@ -42,7 +44,7 @@ export default function RandomAlbums() {
loadingRef.current = false;
setLoading(false);
}
}, []);
}, [musicLibraryFilterVersion]);
useEffect(() => { load(selectedGenres); }, [selectedGenres, load]);
+2 -1
View File
@@ -36,6 +36,7 @@ export default function RandomMix() {
const psyDrag = useDragDrop();
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [addedGenre, setAddedGenre] = useState<string | null>(null);
const [addedArtist, setAddedArtist] = useState<string | null>(null);
@@ -82,7 +83,7 @@ export default function RandomMix() {
setAllAvailableGenres(available);
setDisplayedGenres(available.slice(0, 20));
}).catch(() => {});
}, []);
}, [musicLibraryFilterVersion]);
const filteredSongs = songs.filter(song => {
if (!excludeAudiobooks) return true;
+3 -1
View File
@@ -7,6 +7,7 @@ import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext';
import { useAuthStore } from '../store/authStore';
function formatDuration(s: number) {
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
@@ -21,6 +22,7 @@ export default function SearchResults() {
const playTrack = usePlayerStore(s => s.playTrack);
const currentTrack = usePlayerStore(s => s.currentTrack);
const psyDrag = useDragDrop();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => {
if (!query.trim()) { setResults(null); return; }
@@ -28,7 +30,7 @@ export default function SearchResults() {
search(query, { artistCount: 20, albumCount: 20, songCount: 50 })
.then(r => setResults(r))
.finally(() => setLoading(false));
}, [query]);
}, [query, musicLibraryFilterVersion]);
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
+4 -3
View File
@@ -33,6 +33,7 @@ const PERIODS: { key: LastfmPeriod; label: string }[] = [
export default function Statistics() {
const { t } = useTranslation();
const { lastfmSessionKey, lastfmUsername } = useAuthStore();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]);
const [highest, setHighest] = useState<SubsonicAlbum[]>([]);
@@ -73,7 +74,7 @@ export default function Statistics() {
setGenres(sorted);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
}, [musicLibraryFilterVersion]);
// Background fetch: total playtime (paginate getAlbumList up to 10 pages of 500)
useEffect(() => {
@@ -102,7 +103,7 @@ export default function Statistics() {
}
})();
return () => { cancelled = true; };
}, []);
}, [musicLibraryFilterVersion]);
// Background fetch: format distribution (sample of 500 random songs)
useEffect(() => {
@@ -121,7 +122,7 @@ export default function Statistics() {
setFormatSampleSize(songs.length);
}).catch(() => {});
return () => { cancelled = true; };
}, []);
}, [musicLibraryFilterVersion]);
useEffect(() => {
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;