mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user