fix(browse): layered album filters and multi-library scope

Introduce albumBrowseExecution with explicit filter layers (library scope,
AND attributes, OR genre union, finalize) so multi-genre and cluster paths
cannot leak albums from other libraries. Wire multi-select sidebar picker
scope through local index, cluster merge reads, and REST allowlist fallback.
This commit is contained in:
Maxim Isaev
2026-06-05 19:54:23 +03:00
parent 558f42dba6
commit 496b39a4da
47 changed files with 1393 additions and 388 deletions
+15 -9
View File
@@ -202,6 +202,8 @@ export interface LibrarySortClause {
export interface LibraryAdvancedSearchRequest {
serverId: string;
libraryScope?: string | null;
/** Multiple music-folder ids (OR). Preferred over `libraryScope` when length > 1. */
libraryScopeIds?: string[] | null;
query?: string | null; // shorthand → fts clause on text fields
entityTypes: LibraryEntityType[];
filters?: LibraryFilterClause[];
@@ -482,12 +484,12 @@ function mapServersOrderedToIndexKeys(serverIds: string[]): string[] {
}
function mapClusterLibraryScopesToIndexKeys(
scopes: Record<string, string> | undefined,
): Record<string, string> | undefined {
scopes: Record<string, string[]> | undefined,
): Record<string, string[]> | undefined {
if (!scopes) return undefined;
const out: Record<string, string> = {};
for (const [sid, scope] of Object.entries(scopes)) {
out[serverIndexKeyForId(sid)] = scope;
const out: Record<string, string[]> = {};
for (const [sid, scopeIds] of Object.entries(scopes)) {
if (scopeIds.length > 0) out[serverIndexKeyForId(sid)] = scopeIds;
}
return Object.keys(out).length > 0 ? out : undefined;
}
@@ -497,7 +499,7 @@ export function libraryClusterListTracks(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
libraryScopes?: Record<string, string>;
libraryScopes?: Record<string, string[]>;
}): Promise<LibraryTracksEnvelope> {
return invoke<LibraryTracksEnvelope>('library_cluster_list_tracks', {
request: {
@@ -526,7 +528,7 @@ export function libraryClusterListAlbums(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
libraryScopes?: Record<string, string>;
libraryScopes?: Record<string, string[]>;
}): Promise<LibraryClusterAlbumsResponse> {
return invoke<LibraryClusterAlbumsResponse>('library_cluster_list_albums', {
request: {
@@ -545,7 +547,7 @@ export function libraryClusterListArtists(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
libraryScopes?: Record<string, string>;
libraryScopes?: Record<string, string[]>;
}): Promise<LibraryClusterArtistsResponse> {
return invoke<LibraryClusterArtistsResponse>('library_cluster_list_artists', {
request: {
@@ -713,7 +715,7 @@ export interface LibraryClusterAdvancedSearchRequest {
limit: number;
offset?: number;
skipTotals?: boolean;
libraryScopes?: Record<string, string>;
libraryScopes?: Record<string, string[]>;
}
export function libraryClusterAdvancedSearch(
@@ -1042,11 +1044,13 @@ export function libraryGetCatalogYearBounds(args: { serverId: string }): Promise
export function libraryGetGenreAlbumCounts(args: {
serverId: string;
libraryScope?: string;
libraryScopeIds?: string[];
}): Promise<GenreAlbumCountRow[]> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<GenreAlbumCountRow[]>('library_get_genre_album_counts', {
serverId: indexKey,
libraryScope: args.libraryScope,
libraryScopeIds: args.libraryScopeIds,
});
}
@@ -1054,6 +1058,7 @@ export type LibraryGenreAlbumsRequest = {
serverId: string;
genre: string;
libraryScope?: string | null;
libraryScopeIds?: string[] | null;
sort?: LibrarySortClause[];
limit?: number;
offset?: number;
@@ -1077,6 +1082,7 @@ export function libraryListAlbumsByGenre(
serverId: indexKey,
genre: request.genre,
libraryScope: request.libraryScope ?? undefined,
libraryScopeIds: request.libraryScopeIds ?? undefined,
sort: request.sort ?? [],
limit: request.limit ?? 50,
offset: request.offset ?? 0,
+3 -3
View File
@@ -1,6 +1,7 @@
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { libraryScopeForServer } from './subsonicClient';
import { ndLogin } from './navidromeAdmin';
/** Server-keyed Bearer token cache. Cheap to keep — Navidrome tokens are long-lived. */
let cachedToken: { serverUrl: string; token: string } | null = null;
@@ -24,10 +25,9 @@ function asString(v: unknown, fallback = ''): string {
* Mirrors the Subsonic `musicFolderId` we pipe through `libraryFilterParams()` — Navidrome
* uses the same id space, so the same value is valid for the native API's `library_id` filter. */
function currentLibraryId(): string | null {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) return null;
const f = musicLibraryFilterByServer[activeServerId];
return !f || f === 'all' ? null : f;
return libraryScopeForServer(activeServerId) ?? null;
}
function asNumber(v: unknown): number | undefined {
+2 -2
View File
@@ -72,7 +72,7 @@ describe('libraryFilterParams', () => {
it('returns { musicFolderId } when the active server has a specific filter', () => {
const serverId = setUpServer();
useAuthStore.setState({
musicLibraryFilterByServer: { [serverId]: 'mf-7' },
musicLibraryFilterByServer: { [serverId]: ['mf-7'] },
});
expect(libraryFilterParams()).toEqual({ musicFolderId: 'mf-7' });
});
@@ -91,7 +91,7 @@ describe('libraryScopeForServer', () => {
it('returns the folder id when scoped', () => {
const serverId = setUpServer();
useAuthStore.setState({
musicLibraryFilterByServer: { [serverId]: 'mf-7' },
musicLibraryFilterByServer: { [serverId]: ['mf-7'] },
});
expect(libraryScopeForServer(serverId)).toBe('mf-7');
});
+2 -1
View File
@@ -8,6 +8,7 @@ import type {
SubsonicArtistInfo,
SubsonicSong,
} from './subsonicTypes';
import { isAllLibrariesFilter, normalizeMusicLibraryFilter } from '../utils/musicLibraryFilter';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { resolveClusterBrowseMembers } from '../utils/serverCluster/clusterBrowse';
import { libraryClusterResolveCandidates } from './library';
@@ -75,7 +76,7 @@ export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
export async function getTopSongsForServer(serverId: string, artist: string): Promise<SubsonicSong[]> {
try {
const { musicLibraryFilterByServer } = useAuthStore.getState();
const scoped = musicLibraryFilterByServer[serverId] && musicLibraryFilterByServer[serverId] !== 'all';
const scoped = !isAllLibrariesFilter(normalizeMusicLibraryFilter(musicLibraryFilterByServer[serverId]));
const topCount = scoped ? 20 : 5;
const data = await apiForServer<{ topSongs: { song: SubsonicSong[] } }>(
serverId,
+8 -8
View File
@@ -4,6 +4,7 @@ import { version } from '../../package.json';
import { useAuthStore } from '../store/authStore';
import type { ServerProfile } from '../store/authStoreTypes';
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
import { libraryScopeForServer as scopeForServer } from '../utils/musicLibraryFilter';
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup';
export const SUBSONIC_CLIENT = `psysonic/${version}`;
@@ -106,17 +107,16 @@ export function libraryFilterParams(): Record<string, string | number> {
return activeServerId ? libraryFilterParamsForServer(activeServerId) : {};
}
/** Navidrome/Subsonic music folder id for the local library index, or undefined for all libraries. */
export function libraryScopeForServer(serverId: string): string | undefined {
const resolved = resolveServerIdForIndexKey(serverId);
const f = useAuthStore.getState().musicLibraryFilterByServer[resolved];
if (f === undefined || f === 'all') return undefined;
return f;
}
export {
libraryScopeForServer,
libraryScopeIdsForServer,
libraryScopeInvokeArgs,
musicLibraryFilterForServer,
} from '../utils/musicLibraryFilter';
/** Library folder filter for an explicit saved server (e.g. Now Playing while browsing another). */
export function libraryFilterParamsForServer(serverId: string): Record<string, string | number> {
const scope = libraryScopeForServer(serverId);
const scope = scopeForServer(serverId);
if (!scope) return {};
return { musicFolderId: scope };
}
+31 -16
View File
@@ -1,3 +1,8 @@
import {
isAllLibrariesFilter,
musicLibraryFilterForServer,
musicLibraryFilterStorageKey,
} from '../utils/musicLibraryFilter';
import { useAuthStore } from '../store/authStore';
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
import type {
@@ -94,36 +99,46 @@ let scopedLibraryAlbumIdCache: {
ids: Set<string>;
} | null = null;
async function albumIdsInLibraryScope(serverId: string): Promise<Set<string> | null> {
const { musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState();
/** Union of album ids across selected music folders (network scope filter). */
export async function albumIdsInLibraryScope(serverId: string): Promise<Set<string> | null> {
const { musicLibraryFilterVersion } = useAuthStore.getState();
if (!serverId) return null;
const folder = musicLibraryFilterByServer[serverId];
if (folder === undefined || folder === 'all') {
const filter = musicLibraryFilterForServer(serverId);
if (filter === 'all') {
scopedLibraryAlbumIdCache = null;
return null;
}
const cacheKey = musicLibraryFilterStorageKey(serverId);
const hit = scopedLibraryAlbumIdCache;
if (
hit &&
hit.serverId === serverId &&
hit.folderId === folder &&
hit.folderId === cacheKey &&
hit.filterVersion === musicLibraryFilterVersion
) {
return hit.ids;
}
const ids = new Set<string>();
const pageSize = 500;
let offset = 0;
for (;;) {
const albums = await getAlbumListForServer(serverId, 'alphabeticalByName', pageSize, offset);
for (const a of albums) ids.add(a.id);
if (albums.length < pageSize) break;
offset += pageSize;
if (offset > 500_000) break;
for (const folderId of filter) {
let offset = 0;
for (;;) {
const albums = await getAlbumListForServer(
serverId,
'alphabeticalByName',
pageSize,
offset,
{ musicFolderId: folderId },
);
for (const a of albums) ids.add(a.id);
if (albums.length < pageSize) break;
offset += pageSize;
if (offset > 500_000) break;
}
}
scopedLibraryAlbumIdCache = {
serverId,
folderId: folder,
folderId: cacheKey,
filterVersion: musicLibraryFilterVersion,
ids,
};
@@ -175,9 +190,9 @@ export async function filterAlbumsToActiveLibrary(albums: SubsonicAlbum[]): Prom
/** When scoped to one library, ask the server for more similar tracks — many will be filtered out client-side. */
export function similarSongsRequestCount(desired: number): number {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
const f = activeServerId ? musicLibraryFilterByServer[activeServerId] : undefined;
if (f === undefined || f === 'all') return desired;
const { activeServerId } = useAuthStore.getState();
const f = activeServerId ? musicLibraryFilterForServer(activeServerId) : 'all';
if (isAllLibrariesFilter(f)) return desired;
return Math.min(300, Math.max(desired, desired * 4));
}
+46 -26
View File
@@ -30,12 +30,21 @@ import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal';
import SidebarNavBody from './sidebar/SidebarNavBody';
import { getActiveClusterMemberIds, isClusterMode } from '../utils/serverCluster/clusterScope';
import {
clusterLibraryFilterStorageKey,
clusterLibraryPickerEntryId,
clusterLibraryScopeSubtitle,
clusterPickerFilterId,
parseClusterLibraryPickerEntryId,
isClusterAllLibrariesSelected,
isClusterLibraryFolderSelected,
} from '../utils/serverCluster/clusterLibraryScopes';
import { getCachedMusicFolders } from '../utils/musicFoldersCache';
import {
isAllLibrariesFilter,
isLibraryFolderSelected,
libraryScopeSubtitleFromFolders,
musicLibraryFilterForServer,
musicLibraryFilterStorageKey,
normalizeMusicLibraryFilter,
} from '../utils/musicLibraryFilter';
export default function Sidebar({
@@ -64,6 +73,7 @@ export default function Sidebar({
const musicFolders = useAuthStore(s => s.musicFolders);
const musicLibraryFilterByServer = useAuthStore(s => s.musicLibraryFilterByServer);
const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter);
const toggleMusicLibraryFolder = useAuthStore(s => s.toggleMusicLibraryFolder);
const hotCacheEnabled = useAuthStore(s => s.hotCacheEnabled);
const setHotCacheEnabled = useAuthStore(s => s.setHotCacheEnabled);
const normalizationEngine = useAuthStore(s => s.normalizationEngine);
@@ -100,22 +110,37 @@ export default function Sidebar({
? clusterMusicFolders.map(e => ({
id: clusterLibraryPickerEntryId(e.serverId, e.folderId),
name: `${e.serverLabel}${e.folderName}`,
serverId: e.serverId,
folderId: e.folderId,
}))
: effectiveMusicFolders;
: effectiveMusicFolders.map(f => ({
id: f.id,
name: f.name,
serverId,
folderId: f.id,
}));
const showLibraryPicker = !isCollapsed && isLoggedIn && (
clusterMode ? clusterMusicFolders.length > 0 : effectiveMusicFolders.length > 1
);
const filterId = clusterMode
? clusterPickerFilterId(clusterMemberIds, clusterMusicFolders)
const allLibrariesSelected = clusterMode
? isClusterAllLibrariesSelected(clusterMemberIds)
: serverId
? (musicLibraryFilterByServer[serverId] ?? 'all')
? isAllLibrariesFilter(normalizeMusicLibraryFilter(musicLibraryFilterByServer[serverId]))
: true;
const filterStorageKey = clusterMode
? clusterLibraryFilterStorageKey(clusterMemberIds)
: serverId
? musicLibraryFilterStorageKey(serverId)
: 'all';
const multiLibrariesLabel = (count: number) => t('sidebar.librariesCount', { count });
const selectedFolderName = clusterMode
? clusterLibraryScopeSubtitle(clusterMemberIds, clusterMusicFolders)
: filterId === 'all'
? null
: effectiveMusicFolders.find(f => f.id === filterId)?.name ?? null;
? clusterLibraryScopeSubtitle(clusterMemberIds, clusterMusicFolders, multiLibrariesLabel)
: libraryScopeSubtitleFromFolders(
effectiveMusicFolders,
serverId ? musicLibraryFilterForServer(serverId) : 'all',
multiLibrariesLabel,
);
const libraryItemsForReorder = useMemo(
() => getLibraryItemsForReorder(sidebarItems, randomNavMode),
@@ -158,7 +183,7 @@ export default function Sidebar({
});
const newReleasesUnreadCount = useSidebarNewReleasesUnread({
serverId,
filterId,
filterId: filterStorageKey,
isLoggedIn,
pathname: location.pathname,
});
@@ -168,18 +193,10 @@ export default function Sidebar({
const pickLibrary = (id: 'all' | string) => {
if (clusterMode && id !== 'all') {
const parsed = parseClusterLibraryPickerEntryId(id);
if (parsed) {
setMusicLibraryFilter(parsed.folderId, parsed.serverId);
setLibraryDropdownOpen(false);
return;
}
}
setMusicLibraryFilter(id);
setLibraryDropdownOpen(false);
};
const isFolderSelected = (sid: string, folderId: string) =>
clusterMode
? isClusterLibraryFolderSelected(sid, folderId)
: isLibraryFolderSelected(sid, folderId);
// Fetch playlists when expanded
useEffect(() => {
@@ -232,7 +249,7 @@ export default function Sidebar({
playlists.length,
isLoggedIn,
randomNavMode,
filterId,
filterStorageKey,
hasOfflineContent,
activeJobs.length,
isSyncing,
@@ -243,14 +260,17 @@ export default function Sidebar({
<SidebarNavBody
isCollapsed={isCollapsed}
showLibraryPicker={showLibraryPicker}
filterId={filterId}
allLibrariesSelected={allLibrariesSelected}
selectedFolderName={selectedFolderName}
libraryDropdownOpen={libraryDropdownOpen}
setLibraryDropdownOpen={setLibraryDropdownOpen}
dropdownRect={dropdownRect}
libraryTriggerRef={libraryTriggerRef}
musicFolders={pickerFolders}
pickLibrary={pickLibrary}
isFolderSelected={isFolderSelected}
onSelectAll={() => setMusicLibraryFilter('all')}
onExclusiveSelect={(sid, folderId) => setMusicLibraryFilter(folderId, sid)}
onToggleFolder={(sid, folderId) => toggleMusicLibraryFolder(folderId, sid)}
visibleLibraryConfigs={visibleLibraryConfigs}
libraryItemsForReorder={libraryItemsForReorder}
visibleSystemConfigs={visibleSystemConfigs}
+78 -24
View File
@@ -3,25 +3,42 @@ import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Check, ChevronDown, Music2 } from 'lucide-react';
interface MusicFolder { id: string; name: string }
export interface LibraryPickerFolder {
id: string;
name: string;
serverId: string;
folderId: string;
}
interface Props {
filterId: string;
allLibrariesSelected: boolean;
selectedFolderName: string | null;
libraryDropdownOpen: boolean;
setLibraryDropdownOpen: (open: boolean) => void;
dropdownRect: { top: number; left: number; width: number };
libraryTriggerRef: React.RefObject<HTMLButtonElement | null>;
musicFolders: MusicFolder[];
pickLibrary: (id: 'all' | string) => void;
musicFolders: LibraryPickerFolder[];
isFolderSelected: (serverId: string, folderId: string) => boolean;
onSelectAll: () => void;
onExclusiveSelect: (serverId: string, folderId: string) => void;
onToggleFolder: (serverId: string, folderId: string) => void;
}
export default function SidebarLibraryPicker({
filterId, selectedFolderName, libraryDropdownOpen, setLibraryDropdownOpen,
dropdownRect, libraryTriggerRef, musicFolders, pickLibrary,
allLibrariesSelected,
selectedFolderName,
libraryDropdownOpen,
setLibraryDropdownOpen,
dropdownRect,
libraryTriggerRef,
musicFolders,
isFolderSelected,
onSelectAll,
onExclusiveSelect,
onToggleFolder,
}: Props) {
const { t } = useTranslation();
const libraryTriggerPlain = filterId === 'all';
const libraryTriggerPlain = allLibrariesSelected;
const panelRef = useRef<HTMLDivElement>(null);
const [panelWidth, setPanelWidth] = useState<number | null>(null);
const allLibrariesLabel = t('sidebar.allLibraries');
@@ -98,29 +115,66 @@ export default function SidebarLibraryPicker({
boxSizing: 'border-box',
}}
>
<button
type="button"
<div
role="option"
aria-selected={filterId === 'all'}
className={`nav-library-dropdown-item ${filterId === 'all' ? 'nav-library-dropdown-item--selected' : ''}`}
onClick={() => pickLibrary('all')}
aria-selected={allLibrariesSelected}
className={`nav-library-dropdown-item ${allLibrariesSelected ? 'nav-library-dropdown-item--selected' : ''}`}
>
<span className="nav-library-dropdown-item-label">{t('sidebar.allLibraries')}</span>
{filterId === 'all' ? <Check size={16} className="nav-library-dropdown-check" strokeWidth={2.5} /> : <span className="nav-library-dropdown-check-spacer" />}
</button>
{musicFolders.map(f => (
<button
key={f.id}
type="button"
role="option"
aria-selected={filterId === f.id}
className={`nav-library-dropdown-item ${filterId === f.id ? 'nav-library-dropdown-item--selected' : ''}`}
onClick={() => pickLibrary(f.id)}
className="nav-library-dropdown-item-main"
onClick={() => {
onSelectAll();
setLibraryDropdownOpen(false);
}}
>
<span className="nav-library-dropdown-item-label">{f.name}</span>
{filterId === f.id ? <Check size={16} className="nav-library-dropdown-check" strokeWidth={2.5} /> : <span className="nav-library-dropdown-check-spacer" />}
<span className="nav-library-dropdown-item-label">{allLibrariesLabel}</span>
</button>
))}
<span
className={`nav-library-dropdown-item-toggle ${allLibrariesSelected ? 'nav-library-dropdown-item-toggle--on' : 'nav-library-dropdown-item-toggle--align-only'}`}
aria-hidden
>
{allLibrariesSelected ? <Check size={16} strokeWidth={2.5} /> : null}
</span>
</div>
{musicFolders.map(f => {
const selected = isFolderSelected(f.serverId, f.folderId);
return (
<div
key={f.id}
role="option"
aria-selected={selected}
className={`nav-library-dropdown-item ${selected ? 'nav-library-dropdown-item--selected' : ''}`}
>
<button
type="button"
className="nav-library-dropdown-item-main"
onClick={() => {
onExclusiveSelect(f.serverId, f.folderId);
setLibraryDropdownOpen(false);
}}
>
<span className="nav-library-dropdown-item-label">{f.name}</span>
</button>
<button
type="button"
className={`nav-library-dropdown-item-toggle ${selected ? 'nav-library-dropdown-item-toggle--on' : ''}`}
aria-label={selected ? t('sidebar.libraryDeselect', { name: f.name }) : t('sidebar.librarySelect', { name: f.name })}
aria-pressed={selected}
onClick={e => {
e.stopPropagation();
onToggleFolder(f.serverId, f.folderId);
}}
>
{selected ? (
<Check size={16} strokeWidth={2.5} />
) : (
<span className="nav-library-dropdown-item-toggle-box" aria-hidden />
)}
</button>
</div>
);
})}
</div>,
document.body
)}
+14 -10
View File
@@ -6,7 +6,7 @@ import type { SidebarItemConfig } from '../../store/sidebarStore';
import { ALL_NAV_ITEMS } from '../../config/navItems';
import WhatsNewBanner from '../WhatsNewBanner';
import { displayPlaylistName, isSmartPlaylistName } from '../../utils/componentHelpers/sidebarHelpers';
import SidebarLibraryPicker from './SidebarLibraryPicker';
import SidebarLibraryPicker, { type LibraryPickerFolder } from './SidebarLibraryPicker';
import SidebarActiveJobs from './SidebarActiveJobs';
interface NavDndState {
@@ -14,19 +14,20 @@ interface NavDndState {
fromIdx: number;
}
interface MusicFolder { id: string; name: string }
interface Props {
isCollapsed: boolean;
showLibraryPicker: boolean;
filterId: string;
allLibrariesSelected: boolean;
selectedFolderName: string | null;
libraryDropdownOpen: boolean;
setLibraryDropdownOpen: (open: boolean) => void;
dropdownRect: { top: number; left: number; width: number };
libraryTriggerRef: React.RefObject<HTMLButtonElement | null>;
musicFolders: MusicFolder[];
pickLibrary: (id: 'all' | string) => void;
musicFolders: LibraryPickerFolder[];
isFolderSelected: (serverId: string, folderId: string) => boolean;
onSelectAll: () => void;
onExclusiveSelect: (serverId: string, folderId: string) => void;
onToggleFolder: (serverId: string, folderId: string) => void;
visibleLibraryConfigs: SidebarItemConfig[];
libraryItemsForReorder: SidebarItemConfig[];
visibleSystemConfigs: SidebarItemConfig[];
@@ -54,9 +55,9 @@ interface Props {
export default function SidebarNavBody(props: Props) {
const {
isCollapsed, showLibraryPicker, filterId, selectedFolderName,
isCollapsed, showLibraryPicker, allLibrariesSelected, selectedFolderName,
libraryDropdownOpen, setLibraryDropdownOpen, dropdownRect, libraryTriggerRef,
musicFolders, pickLibrary,
musicFolders, isFolderSelected, onSelectAll, onExclusiveSelect, onToggleFolder,
visibleLibraryConfigs, libraryItemsForReorder,
visibleSystemConfigs, systemItemsForReorder,
playlistsExpanded, setPlaylistsExpanded, playlists, playlistsLoading,
@@ -89,14 +90,17 @@ export default function SidebarNavBody(props: Props) {
{nowPlayingAtTop && nowPlayingLink}
{!isCollapsed && (showLibraryPicker ? (
<SidebarLibraryPicker
filterId={filterId}
allLibrariesSelected={allLibrariesSelected}
selectedFolderName={selectedFolderName}
libraryDropdownOpen={libraryDropdownOpen}
setLibraryDropdownOpen={setLibraryDropdownOpen}
dropdownRect={dropdownRect}
libraryTriggerRef={libraryTriggerRef}
musicFolders={musicFolders}
pickLibrary={pickLibrary}
isFolderSelected={isFolderSelected}
onSelectAll={onSelectAll}
onExclusiveSelect={onExclusiveSelect}
onToggleFolder={onToggleFolder}
/>
) : (
<span className="nav-section-label">{t('sidebar.library')}</span>
+15 -5
View File
@@ -18,6 +18,8 @@ import type { AlbumCompFilter } from '../utils/library/albumCompilation';
import {
albumBrowseHasGenreFilter,
albumBrowseHasServerFilters,
albumBrowseMultiGenreBrowse,
albumBrowseUseSliceCatalog,
fetchAlbumBrowseGenreOptions,
fetchAlbumBrowsePage,
fetchLocalAlbumCatalogChunk,
@@ -26,7 +28,7 @@ import {
type AlbumBrowseQuery,
type GenreFilterOption,
} from '../utils/library/albumBrowseLoad';
import { libraryScopeForServer } from '../api/subsonicClient';
import { libraryScopeIdsForServer } from '../api/subsonicClient';
import {
ALBUM_YEAR_FILTER_DEBOUNCE_MS,
resolveAlbumYearBounds,
@@ -167,8 +169,9 @@ export function useAlbumBrowseData({
}, [browseMode, visibleAlbums, visibleCount]);
const genreFiltered = albumBrowseHasGenreFilter(browseQuery);
const multiGenreBrowse = albumBrowseMultiGenreBrowse(browseQuery);
const serverFilterActive = albumBrowseHasServerFilters(browseQuery);
const libraryScopeActive = libraryScopeForServer(serverId) != null;
const libraryScopeActive = libraryScopeIdsForServer(serverId) != null;
const narrowGenreList = yearFilterActive || losslessOnly || starredOnly || compFilterActive;
/** When true, GenreFilterBar uses `genreCatalogOptions` instead of server `getGenres()`. */
const genreCatalogActive = narrowGenreList || (indexEnabled && libraryScopeActive);
@@ -184,7 +187,7 @@ export function useAlbumBrowseData({
const gridHasMore = browseMode === 'slice'
? visibleCount < visibleAlbums.length || catalogHasMore
: hasMore && !genreFiltered;
: hasMore && !multiGenreBrowse;
const gridLoadingMore = browseMode === 'slice'
? sliceLoadingMore || catalogLoadingMore
@@ -333,6 +336,13 @@ export function useAlbumBrowseData({
);
if (cancelled || generation !== loadGenerationRef.current) return;
if (first != null) {
if (!albumBrowseUseSliceCatalog(browseQuery)) {
setBrowseMode('page');
setAlbums(first.albums);
setHasMore(first.hasMore);
setLoading(false);
return;
}
setBrowseMode('slice');
setAlbums(first.albums);
catalogOffsetRef.current = first.albums.length;
@@ -376,7 +386,7 @@ export function useAlbumBrowseData({
]);
const loadMorePage = useCallback(() => {
if (loadingRef.current || loadPendingRef.current || !hasMore || genreFiltered) return;
if (loadingRef.current || loadPendingRef.current || !hasMore || multiGenreBrowse) return;
if (coverEnsureQueueBacklog() > LOAD_MORE_COVER_BACKLOG_MAX) return;
if (compFilterClientOnly && visibleAlbums.length === 0
&& albumBrowseCompScanComplete(albums, compFilter, hasMore)) {
@@ -390,7 +400,7 @@ export function useAlbumBrowseData({
hasMore,
browseQuery,
loadBrowse,
genreFiltered,
multiGenreBrowse,
compFilterClientOnly,
visibleAlbums.length,
albums,
+2 -1
View File
@@ -18,6 +18,7 @@ export function useBrowseAlbumTextSearch(
indexEnabled: boolean,
serverId: string | null | undefined,
losslessOnly = false,
musicLibraryFilterVersion = 0,
) {
const [debouncedFilter, setDebouncedFilter] = useState('');
const [textSearchAlbums, setTextSearchAlbums] = useState<SubsonicAlbum[] | null>(null);
@@ -66,7 +67,7 @@ export function useBrowseAlbumTextSearch(
setTextSearchAlbums(outcome?.result ?? null);
setTextSearchLoading(false);
})();
}, [debouncedFilter, indexEnabled, serverId, losslessOnly]);
}, [debouncedFilter, indexEnabled, serverId, losslessOnly, musicLibraryFilterVersion]);
const effectiveFilter = textSearchAlbums != null ? '' : filter;
return { textSearchAlbums, textSearchLoading, effectiveFilter };
+3
View File
@@ -32,6 +32,9 @@ export const sidebar = {
deviceSync: 'Device Sync',
libraryScope: 'Library scope',
allLibraries: 'All libraries',
librariesCount: '{{count}} libraries',
librarySelect: 'Include {{name}}',
libraryDeselect: 'Exclude {{name}}',
expandPlaylists: 'Expand playlists',
collapsePlaylists: 'Collapse playlists',
more: 'More',
+3
View File
@@ -31,6 +31,9 @@ export const sidebar = {
deviceSync: 'Синхронизация устройства',
libraryScope: 'Область медиатеки',
allLibraries: 'Все библиотеки',
librariesCount: '{{count}} библиотек',
librarySelect: 'Включить {{name}}',
libraryDeselect: 'Исключить {{name}}',
expandPlaylists: 'Развернуть плейлисты',
collapsePlaylists: 'Свернуть плейлисты',
more: 'Ещё',
+1
View File
@@ -97,6 +97,7 @@ export default function Albums() {
indexEnabled,
serverId,
losslessOnly,
musicLibraryFilterVersion,
);
const {
+63 -25
View File
@@ -1,3 +1,5 @@
import type { MusicLibraryFilter } from '../utils/musicLibraryFilter';
import { normalizeMusicLibraryFilter } from '../utils/musicLibraryFilter';
import type { AuthState } from './authStoreTypes';
type SetState = (
@@ -5,50 +7,60 @@ type SetState = (
) => void;
type GetState = () => AuthState;
function bumpFilter(
set: SetState,
next: Record<string, MusicLibraryFilter>,
): void {
set(s => ({
musicLibraryFilterByServer: next,
musicLibraryFilterVersion: s.musicLibraryFilterVersion + 1,
}));
}
function resolveTargetServerId(get: GetState, targetServerId?: string): string | null {
const { activeClusterId, clusters, activeServerId } = get();
if (targetServerId) return targetServerId;
if (activeClusterId) return null;
return activeServerId;
}
/**
* Per-server music-folder selection. `setMusicFolders` is called
* after login / server change with the fresh Subsonic folder list;
* if the currently-persisted filter for that server points at a
* folder that no longer exists on the server, it falls back to
* `'all'` so the page doesn't end up filtering by a stale id.
*
* `setMusicLibraryFilter` writes the new filter and bumps
* `musicLibraryFilterVersion` so subscribed pages refetch their
* catalog data.
* if persisted filters point at folders that no longer exist,
* they fall back to `'all'`.
*/
export function createMusicLibraryActions(set: SetState, get: GetState): Pick<
AuthState,
'setMusicFolders' | 'setMusicLibraryFilter'
'setMusicFolders' | 'setMusicLibraryFilter' | 'toggleMusicLibraryFolder'
> {
return {
setMusicFolders: (folders) => {
const sid = get().activeServerId;
set(s => {
const f = sid ? s.musicLibraryFilterByServer[sid] : undefined;
const invalidFilter = f && f !== 'all' && !folders.some(x => x.id === f);
if (!sid) return { musicFolders: folders };
const f = normalizeMusicLibraryFilter(s.musicLibraryFilterByServer[sid]);
const folderIds = new Set(folders.map(x => x.id));
const invalidFilter =
f !== 'all' && f.some(id => !folderIds.has(id));
return {
musicFolders: folders,
...(sid && invalidFilter
...(invalidFilter
? { musicLibraryFilterByServer: { ...s.musicLibraryFilterByServer, [sid]: 'all' } }
: {}),
};
});
},
/** Exclusive select: one folder, or all libraries. */
setMusicLibraryFilter: (folderId, targetServerId) => {
const { activeClusterId, clusters, activeServerId } = get();
if (activeClusterId) {
const { activeClusterId, clusters } = get();
if (folderId === 'all' && activeClusterId && !targetServerId) {
const cluster = clusters.find(c => c.id === activeClusterId);
if (!cluster) return;
set(s => {
const next = { ...s.musicLibraryFilterByServer };
if (folderId === 'all' && !targetServerId) {
for (const sid of cluster.serverIds) next[sid] = 'all';
} else if (targetServerId) {
next[targetServerId] = folderId;
} else {
return s;
}
for (const sid of cluster.serverIds) next[sid] = 'all';
return {
musicLibraryFilterByServer: next,
musicLibraryFilterVersion: s.musicLibraryFilterVersion + 1,
@@ -56,12 +68,38 @@ export function createMusicLibraryActions(set: SetState, get: GetState): Pick<
});
return;
}
const sid = activeServerId;
const sid = resolveTargetServerId(get, targetServerId);
if (!sid) return;
set(s => ({
musicLibraryFilterByServer: { ...s.musicLibraryFilterByServer, [sid]: folderId },
musicLibraryFilterVersion: s.musicLibraryFilterVersion + 1,
}));
set(s => {
const next = { ...s.musicLibraryFilterByServer };
next[sid] = folderId === 'all' ? 'all' : [folderId];
return {
musicLibraryFilterByServer: next,
musicLibraryFilterVersion: s.musicLibraryFilterVersion + 1,
};
});
},
/** Toggle one folder in a multi-select set (checkbox). */
toggleMusicLibraryFolder: (folderId, targetServerId) => {
const sid = resolveTargetServerId(get, targetServerId);
if (!sid) return;
set(s => {
const next = { ...s.musicLibraryFilterByServer };
const current = normalizeMusicLibraryFilter(next[sid]);
if (current === 'all') {
next[sid] = [folderId];
} else if (current.includes(folderId)) {
const rest = current.filter(id => id !== folderId);
next[sid] = rest.length > 0 ? rest : 'all';
} else {
next[sid] = [...current, folderId];
}
return {
musicLibraryFilterByServer: next,
musicLibraryFilterVersion: s.musicLibraryFilterVersion + 1,
};
});
},
};
}
+9
View File
@@ -12,6 +12,7 @@ import {
sanitizeLoudnessPreAnalysisFromStorage,
sanitizeSkipStarCounts,
} from './authStoreHelpers';
import { normalizeMusicLibraryFilter } from '../utils/musicLibraryFilter';
import type {
AuthState,
DiscordCoverSource,
@@ -152,7 +153,15 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
discordCoverSourceMigrated = { discordCoverSource: 'apple' };
}
const musicLibraryFilterByServer: AuthState['musicLibraryFilterByServer'] = {};
for (const [sid, raw] of Object.entries(state.musicLibraryFilterByServer ?? {})) {
musicLibraryFilterByServer[sid] = normalizeMusicLibraryFilter(
raw as Parameters<typeof normalizeMusicLibraryFilter>[0],
);
}
return {
musicLibraryFilterByServer,
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
+3 -5
View File
@@ -239,11 +239,8 @@ export interface AuthState {
/** Subsonic music folders for the active server (not persisted; refetched on login / server change). */
musicFolders: Array<{ id: string; name: string }>;
/**
* Per server: `all` = no musicFolderId param; otherwise a single folder id.
* Only one library or all — no multi-folder merge.
*/
musicLibraryFilterByServer: Record<string, 'all' | string>;
/** Per server: `all` or selected music-folder ids (multi-select). */
musicLibraryFilterByServer: Record<string, import('../utils/musicLibraryFilter').MusicLibraryFilter>;
/** Bumps when `setMusicLibraryFilter` runs so pages refetch catalog data. */
musicLibraryFilterVersion: number;
@@ -372,6 +369,7 @@ export interface AuthState {
setShowLuckyMixMenu: (v: boolean) => void;
setMusicFolders: (folders: Array<{ id: string; name: string }>) => void;
setMusicLibraryFilter: (folderId: 'all' | string, serverId?: string) => void;
toggleMusicLibraryFolder: (folderId: string, serverId?: string) => void;
/** Navigation style for Mix pages: single hub ('hub') or separate sidebar entries ('separate'). */
randomNavMode: 'hub' | 'separate';
+63 -3
View File
@@ -179,10 +179,10 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
gap: var(--space-2);
width: 100%;
margin: 0;
padding: var(--space-2) var(--space-3);
padding: var(--space-1) var(--space-1) var(--space-1) var(--space-2);
border: none;
border-radius: var(--radius-sm);
background: transparent;
@@ -190,10 +190,70 @@
font-size: 13px;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background var(--transition-fast);
}
.nav-library-dropdown-item-main {
flex: 1;
min-width: 0;
margin: 0;
padding: var(--space-1) var(--space-2);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: inherit;
font: inherit;
font-weight: inherit;
text-align: left;
cursor: pointer;
}
.nav-library-dropdown-item-main:hover {
background: var(--bg-hover);
}
.nav-library-dropdown-item-toggle {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin: 0;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--accent);
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast), background var(--transition-fast);
}
.nav-library-dropdown-item:hover .nav-library-dropdown-item-toggle,
.nav-library-dropdown-item-toggle--on {
opacity: 1;
}
/* «Все библиотеки»: пустой слот той же ширины, без hover-чекбокса */
.nav-library-dropdown-item-toggle--align-only {
opacity: 0;
pointer-events: none;
}
.nav-library-dropdown-item-toggle:hover {
background: var(--bg-hover);
}
.nav-library-dropdown-item-toggle-box {
display: block;
width: 14px;
height: 14px;
border: 1.5px solid var(--text-muted);
border-radius: 3px;
box-sizing: border-box;
}
.nav-library-dropdown-item:hover {
background: var(--bg-hover);
}
@@ -53,7 +53,7 @@ describe('runLocalAdvancedSearch', () => {
});
it('passes libraryScope from the sidebar music library filter', async () => {
useAuthStore.setState({ musicLibraryFilterByServer: { s1: 'lib7' } });
useAuthStore.setState({ musicLibraryFilterByServer: { s1: ['lib7'] } });
ready();
let captured: unknown;
onInvoke('library_advanced_search', (args) => {
+3 -4
View File
@@ -22,7 +22,7 @@ import {
} from '../../api/library';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
import { search } from '../../api/subsonicSearch';
import { libraryScopeForServer } from '../../api/subsonicClient';
import { libraryScopeInvokeArgs } from '../musicLibraryFilter';
import { fetchAlbumBrowseNetwork } from './albumBrowseNetwork';
import type { AlbumBrowseQuery } from './albumBrowseTypes';
import { resolveAlbumYearBounds } from './albumYearFilter';
@@ -140,10 +140,9 @@ function buildRequest(
skipTotals = false,
): LibraryAdvancedSearchRequest {
const q = opts.query.trim();
const libraryScope = libraryScopeForServer(serverId);
return {
serverId,
libraryScope: libraryScope ?? undefined,
...libraryScopeInvokeArgs(serverId),
query: q || undefined,
entityTypes,
filters: buildFilters(opts),
@@ -403,7 +402,7 @@ export async function runLocalSongBrowse(
try {
const req: LibraryAdvancedSearchRequest = {
serverId,
libraryScope: libraryScopeForServer(serverId),
...libraryScopeInvokeArgs(serverId),
query: undefined,
entityTypes: ['track'],
limit: pageSize,
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
const mockAdvancedSearch = vi.fn();
const mockListByGenre = vi.fn();
const mockFilterScope = vi.fn();
const mockResolveRestrict = vi.fn();
const mockScopeArgs = vi.fn();
vi.mock('../../api/library', () => ({
libraryAdvancedSearch: (...args: unknown[]) => mockAdvancedSearch(...args),
libraryListAlbumsByGenre: (...args: unknown[]) => mockListByGenre(...args),
}));
vi.mock('../musicLibraryFilter', () => ({
libraryScopeInvokeArgs: (...args: unknown[]) => mockScopeArgs(...args),
}));
vi.mock('./albumBrowseLibraryScope', () => ({
resolveScopedAlbumRestrictIds: (...args: unknown[]) => mockResolveRestrict(...args),
intersectAlbumRestrictIds: (
primary: string[] | undefined,
scope: string[] | undefined,
) => {
if (!scope?.length) return primary;
if (!primary?.length) return scope;
const allowed = new Set(scope);
return primary.filter(id => allowed.has(id));
},
filterAlbumsToServerLibraryScope: (
_serverId: string,
albums: SubsonicAlbum[],
) => mockFilterScope(albums),
}));
import { searchSingleServerAlbumBrowse } from './albumBrowseExecution';
const album = (id: string, genre?: string): SubsonicAlbum => ({
id,
name: id,
artist: 'X',
artistId: 'x',
songCount: 1,
duration: 1,
genre,
});
const baseQuery = {
sort: 'alphabeticalByName' as const,
genres: [] as string[],
losslessOnly: false,
starredOnly: false,
compFilter: 'all' as const,
};
beforeEach(() => {
vi.clearAllMocks();
mockScopeArgs.mockReturnValue({ libraryScopeIds: ['lib-1'] });
mockResolveRestrict.mockResolvedValue(['scoped-a', 'scoped-b']);
mockFilterScope.mockImplementation(async (albums: SubsonicAlbum[]) =>
albums.filter(a => a.id.startsWith('scoped')),
);
});
describe('searchSingleServerAlbumBrowse', () => {
it('multi-genre union always runs library scope finalize', async () => {
mockAdvancedSearch
.mockResolvedValueOnce({
source: 'local',
albums: [{ id: 'scoped-a', name: 'A', serverId: 's1', genre: 'Rock' }],
})
.mockResolvedValueOnce({
source: 'local',
albums: [
{ id: 'scoped-b', name: 'B', serverId: 's1', genre: 'Jazz' },
{ id: 'leak', name: 'L', serverId: 's1', genre: 'Jazz' },
],
});
const result = await searchSingleServerAlbumBrowse(
'srv-1',
{ ...baseQuery, genres: ['Rock', 'Jazz'] },
0,
30,
);
expect(mockAdvancedSearch).toHaveBeenCalledTimes(2);
expect(mockFilterScope).toHaveBeenCalled();
expect(result?.albums.map(a => a.id).sort()).toEqual(['scoped-a', 'scoped-b']);
expect(result?.hasMore).toBe(false);
});
it('single pure genre also finalizes scope', async () => {
mockListByGenre.mockResolvedValue({
source: 'local',
albums: [
{ id: 'scoped-a', name: 'A', serverId: 's1' },
{ id: 'leak', name: 'L', serverId: 's1' },
],
hasMore: false,
});
const result = await searchSingleServerAlbumBrowse(
'srv-1',
{ ...baseQuery, genres: ['Rock'] },
0,
30,
);
expect(mockListByGenre).toHaveBeenCalled();
expect(mockFilterScope).toHaveBeenCalled();
expect(result?.albums.map(a => a.id)).toEqual(['scoped-a']);
});
it('rejects offset pagination for multi-genre OR union', async () => {
const result = await searchSingleServerAlbumBrowse(
'srv-1',
{ ...baseQuery, genres: ['Rock', 'Jazz'] },
30,
30,
);
expect(result).toEqual({ albums: [], hasMore: false });
expect(mockAdvancedSearch).not.toHaveBeenCalled();
});
});
+203
View File
@@ -0,0 +1,203 @@
/**
* Album browse — filter layering (every path must follow this order):
*
* 1. **Library scope** (sidebar picker) — SQL `libraryScopeIds` and/or REST album allowlist
* 2. **Album attributes** (AND) — year, lossless, compilation, starred*
* 3. **Genre** (OR union) — one `genre = ?` query per selected genre, results merged
* 4. **Starred allowlist** — `restrictAlbumIds` when intersecting server favorites
* 5. **Finalize** — always re-apply library scope allowlist on album rows (REST fallback)
*
* *Starred uses step 4 when server favorite ids are supplied; otherwise step 2 SQL filter.
*/
import {
libraryAdvancedSearch,
libraryListAlbumsByGenre,
type LibraryFilterClause,
} from '../../api/library';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import { libraryScopeInvokeArgs } from '../musicLibraryFilter';
import {
filterAlbumsToServerLibraryScope,
filterClusterAlbumsToLibraryScope,
intersectAlbumRestrictIds,
resolveScopedAlbumRestrictIds,
} from './albumBrowseLibraryScope';
import { dedupeById } from '../dedupeById';
import { albumToAlbum } from './advancedSearchLocal';
import { sharedServerFilters } from './albumBrowseFilters';
import { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort';
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from './albumBrowseTypes';
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
export type AlbumBrowseInvokeContext = {
scopeArgs: ReturnType<typeof libraryScopeInvokeArgs>;
effectiveRestrict: string[] | undefined;
/** Passed to `libraryAdvancedSearch` / `libraryListAlbumsByGenre`. */
invokeScope:
| { restrictAlbumIds: string[] }
| ReturnType<typeof libraryScopeInvokeArgs>;
useServerStarredIds: boolean;
starredOnly: boolean | undefined;
/** Step 2 — year, lossless, compilation, starred (when not using allowlist). */
attributeFilters: LibraryFilterClause[];
};
export async function resolveAlbumBrowseInvokeContext(
serverId: string,
query: AlbumBrowseQuery,
restrictAlbumIds?: string[],
): Promise<AlbumBrowseInvokeContext> {
const scopeArgs = libraryScopeInvokeArgs(serverId);
const scopedRestrict = await resolveScopedAlbumRestrictIds(serverId);
const effectiveRestrict = intersectAlbumRestrictIds(restrictAlbumIds, scopedRestrict);
const useServerStarredIds = restrictAlbumIds != null;
const invokeScope = effectiveRestrict != null
? { restrictAlbumIds: effectiveRestrict }
: scopeArgs;
return {
scopeArgs,
effectiveRestrict,
invokeScope,
useServerStarredIds,
starredOnly: useServerStarredIds ? undefined : (query.starredOnly || undefined),
attributeFilters: sharedServerFilters(query, useServerStarredIds),
};
}
/** Step 5 — enforce sidebar library scope on every album row. */
export async function finalizeSingleServerAlbumBrowse(
serverId: string,
albums: SubsonicAlbum[],
effectiveRestrict?: string[],
): Promise<SubsonicAlbum[]> {
return filterAlbumsToServerLibraryScope(serverId, albums, effectiveRestrict);
}
/** Step 5 (cluster) — per-member scoped allowlists. */
export async function finalizeClusterAlbumBrowse(
albums: SubsonicAlbum[],
): Promise<SubsonicAlbum[]> {
return filterClusterAlbumsToLibraryScope(albums);
}
function genreEqFilter(genre: string): LibraryFilterClause {
return { field: 'genre', op: 'eq', value: genre };
}
function markServerStarredAlbums(albums: SubsonicAlbum[]): SubsonicAlbum[] {
return albums.map(a => ({ ...a, starred: a.starred ?? 'true' }));
}
/** Step 3 — OR union via parallel per-genre advanced search (offset 0 only). */
async function fetchMultiGenreAlbumUnion(
serverId: string,
query: AlbumBrowseQuery,
ctx: AlbumBrowseInvokeContext,
): Promise<SubsonicAlbum[]> {
const pages = await Promise.all(
query.genres.map(genre =>
libraryAdvancedSearch({
serverId,
...ctx.invokeScope,
entityTypes: ['album'],
filters: [genreEqFilter(genre), ...ctx.attributeFilters],
starredOnly: ctx.starredOnly,
sort: albumSortClauses(query.sort),
limit: GENRE_ALBUM_FETCH_LIMIT,
offset: 0,
skipTotals: true,
}),
),
);
if (pages.some(p => p.source !== 'local')) {
throw new Error('local index unavailable');
}
return dedupeById(pages.flatMap(p => p.albums.map(albumToAlbum)));
}
/** Single-server local index browse — one entry point for all filter combinations. */
export async function searchSingleServerAlbumBrowse(
serverId: string,
query: AlbumBrowseQuery,
offset: number,
pageSize: number,
restrictAlbumIds?: string[],
): Promise<AlbumBrowsePageResult | null> {
const ctx = await resolveAlbumBrowseInvokeContext(serverId, query, restrictAlbumIds);
const sort = albumSortClauses(query.sort);
const finish = async (
albums: SubsonicAlbum[],
hasMore: boolean,
): Promise<AlbumBrowsePageResult> => {
let out = await finalizeSingleServerAlbumBrowse(serverId, albums, ctx.effectiveRestrict);
if (ctx.useServerStarredIds) out = markServerStarredAlbums(out);
return { albums: out, hasMore };
};
if (query.genres.length > 1) {
if (offset > 0) return { albums: [], hasMore: false };
try {
const merged = await fetchMultiGenreAlbumUnion(serverId, query, ctx);
const finished = await finish(merged, false);
return {
albums: sortSubsonicAlbums(finished.albums, query.sort),
hasMore: false,
};
} catch {
return null;
}
}
if (query.genres.length === 1) {
const genre = query.genres[0];
const pureGenreQuery = ctx.attributeFilters.length === 0 && !ctx.starredOnly;
try {
if (pureGenreQuery && !ctx.useServerStarredIds) {
const resp = await libraryListAlbumsByGenre({
serverId,
genre,
...ctx.scopeArgs,
sort,
limit: pageSize,
offset,
});
if (resp.source !== 'local') return null;
return finish(resp.albums.map(albumToAlbum), resp.hasMore);
}
const resp = await libraryAdvancedSearch({
serverId,
...ctx.invokeScope,
entityTypes: ['album'],
filters: [genreEqFilter(genre), ...ctx.attributeFilters],
starredOnly: ctx.starredOnly,
sort,
limit: pageSize,
offset,
skipTotals: true,
});
if (resp.source !== 'local') return null;
return finish(resp.albums.map(albumToAlbum), resp.albums.length === pageSize);
} catch {
return null;
}
}
try {
const resp = await libraryAdvancedSearch({
serverId,
...ctx.invokeScope,
entityTypes: ['album'],
filters: ctx.attributeFilters,
starredOnly: ctx.starredOnly,
sort,
limit: pageSize,
offset,
skipTotals: true,
});
if (resp.source !== 'local') return null;
return finish(resp.albums.map(albumToAlbum), resp.albums.length === pageSize);
} catch {
return null;
}
}
+10
View File
@@ -17,6 +17,16 @@ export function albumBrowseHasServerFilters(query: AlbumBrowseQuery): boolean {
);
}
/** Multi-genre OR union is loaded in one shot — no SQL offset pagination. */
export function albumBrowseMultiGenreBrowse(query: AlbumBrowseQuery): boolean {
return query.genres.length > 1;
}
/** Lazy catalog slice mode — plain unfiltered browse (comp/year/genre/starred via server path). */
export function albumBrowseUseSliceCatalog(query: AlbumBrowseQuery): boolean {
return !albumBrowseHasServerFilters(query);
}
/** Favorites need the local index when combined with lossless or genre (AND). */
export function albumBrowseStarredNeedsLocalIntersect(
query: AlbumBrowseQuery,
@@ -3,7 +3,7 @@ import type { AlbumBrowseQuery } from './albumBrowseTypes';
const libraryGetGenreAlbumCounts = vi.fn();
const libraryIsReady = vi.fn();
const libraryScopeForServer = vi.fn();
const libraryScopeInvokeArgs = vi.fn();
const runLocalAlbumBrowse = vi.fn();
vi.mock('../../api/library', () => ({
@@ -14,8 +14,8 @@ vi.mock('./libraryReady', () => ({
libraryIsReady: (...args: unknown[]) => libraryIsReady(...args),
}));
vi.mock('../../api/subsonicClient', () => ({
libraryScopeForServer: (...args: unknown[]) => libraryScopeForServer(...args),
vi.mock('../musicLibraryFilter', () => ({
libraryScopeInvokeArgs: (...args: unknown[]) => libraryScopeInvokeArgs(...args),
}));
vi.mock('./albumBrowseLocal', () => ({
@@ -35,7 +35,10 @@ const baseQuery: AlbumBrowseQuery = {
beforeEach(() => {
vi.clearAllMocks();
libraryIsReady.mockResolvedValue(true);
libraryScopeForServer.mockReturnValue('lib-a');
libraryScopeInvokeArgs.mockReturnValue({
libraryScope: 'lib-a',
libraryScopeIds: ['lib-a'],
});
});
describe('fetchAlbumBrowseGenreOptions', () => {
@@ -53,6 +56,7 @@ describe('fetchAlbumBrowseGenreOptions', () => {
expect(libraryGetGenreAlbumCounts).toHaveBeenCalledWith({
serverId: 'srv-1',
libraryScope: 'lib-a',
libraryScopeIds: ['lib-a'],
});
expect(runLocalAlbumBrowse).not.toHaveBeenCalled();
});
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
const albumIdsInLibraryScope = vi.fn();
vi.mock('../../api/subsonicLibrary', () => ({
albumIdsInLibraryScope: (...args: unknown[]) => albumIdsInLibraryScope(...args),
}));
vi.mock('../serverCluster/clusterBrowse', () => ({
resolveClusterBrowseMembers: vi.fn(async () => ['srv-a', 'srv-b']),
}));
vi.mock('../musicLibraryFilter', () => ({
libraryScopeIdsForServer: vi.fn((sid: string) => (sid === 'srv-a' ? ['lib-1'] : undefined)),
}));
import {
filterAlbumsToServerLibraryScope,
filterClusterAlbumsToLibraryScope,
intersectAlbumRestrictIds,
} from './albumBrowseLibraryScope';
beforeEach(() => {
vi.clearAllMocks();
});
describe('intersectAlbumRestrictIds', () => {
it('returns scope restrict when primary is undefined', () => {
expect(intersectAlbumRestrictIds(undefined, ['a', 'b'])).toEqual(['a', 'b']);
});
it('intersects starred ids with scoped ids', () => {
expect(intersectAlbumRestrictIds(['a', 'c'], ['a', 'b'])).toEqual(['a']);
});
});
describe('filterAlbumsToServerLibraryScope', () => {
it('drops albums outside scoped allowlist', async () => {
albumIdsInLibraryScope.mockResolvedValue(new Set(['keep']));
const out = await filterAlbumsToServerLibraryScope('srv-a', [
{ id: 'keep', name: 'a', artist: 'X', artistId: 'x', songCount: 1, duration: 1 },
{ id: 'drop', name: 'b', artist: 'Y', artistId: 'y', songCount: 1, duration: 1 },
]);
expect(out.map(a => a.id)).toEqual(['keep']);
});
});
describe('filterClusterAlbumsToLibraryScope', () => {
const album = (id: string, clusterSeedServerId: string): SubsonicAlbum => ({
id,
name: id,
artist: 'X',
artistId: 'x',
songCount: 1,
duration: 1,
clusterSeedServerId,
});
it('keeps only albums in scoped member allowlist', async () => {
albumIdsInLibraryScope.mockResolvedValue(new Set(['in-scope']));
const out = await filterClusterAlbumsToLibraryScope([
album('in-scope', 'srv-a'),
album('other', 'srv-a'),
album('any', 'srv-b'),
]);
expect(out.map(a => a.id)).toEqual(['in-scope', 'any']);
});
});
@@ -0,0 +1,73 @@
import { albumIdsInLibraryScope } from '../../api/subsonicLibrary';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import { libraryScopeIdsForServer } from '../musicLibraryFilter';
import { resolveClusterBrowseMembers } from '../serverCluster/clusterBrowse';
/**
* Navidrome-scoped album ids from getAlbumList2 (per musicFolderId).
* Used when the local index has no reliable `library_id` on tracks SQL
* `libraryScope` alone would not narrow album browse.
*/
export async function resolveScopedAlbumRestrictIds(
serverId: string,
): Promise<string[] | undefined> {
if (!libraryScopeIdsForServer(serverId)?.length) return undefined;
try {
const ids = await albumIdsInLibraryScope(serverId);
if (!ids) return undefined;
return [...ids];
} catch {
return undefined;
}
}
/** Client-side scope filter (server getAlbumList2 ids). Idempotent after SQL restrict. */
export async function filterAlbumsToServerLibraryScope(
serverId: string,
albums: SubsonicAlbum[],
precomputedRestrict?: string[],
): Promise<SubsonicAlbum[]> {
if (!libraryScopeIdsForServer(serverId)?.length) return albums;
const restrict = precomputedRestrict ?? await resolveScopedAlbumRestrictIds(serverId);
if (!restrict) return albums;
const allowed = new Set(restrict);
return albums.filter(a => allowed.has(a.id));
}
export function intersectAlbumRestrictIds(
primary: string[] | undefined,
scopeRestrict: string[] | undefined,
): string[] | undefined {
if (!scopeRestrict?.length) return primary;
if (!primary?.length) return scopeRestrict;
const allowed = new Set(scopeRestrict);
return primary.filter(id => allowed.has(id));
}
/** Per-member scoped album ids for merged cluster browse. */
export async function filterClusterAlbumsToLibraryScope(
albums: SubsonicAlbum[],
): Promise<SubsonicAlbum[]> {
const members = await resolveClusterBrowseMembers();
if (!members?.length) return albums;
const scopedMembers = members.filter(sid => libraryScopeIdsForServer(sid)?.length);
if (scopedMembers.length === 0) return albums;
const restrictByServer = new Map<string, Set<string>>();
await Promise.all(
scopedMembers.map(async sid => {
const ids = await resolveScopedAlbumRestrictIds(sid);
if (ids?.length) restrictByServer.set(sid, new Set(ids));
}),
);
if (restrictByServer.size === 0) return albums;
return albums.filter(a => {
const seedServerId = a.clusterSeedServerId;
if (!seedServerId) return true;
const allowed = restrictByServer.get(seedServerId);
if (!allowed) return true;
return allowed.has(a.id);
});
}
+16
View File
@@ -3,7 +3,9 @@ import type { SubsonicAlbum } from '../../api/subsonicTypes';
import {
albumBrowseHasGenreFilter,
albumBrowseHasServerFilters,
albumBrowseMultiGenreBrowse,
albumBrowseStarredNeedsLocalIntersect,
albumBrowseUseSliceCatalog,
compilationFilterClauses,
countGenresFromAlbums,
filterAlbumsByNameTextQuery,
@@ -41,6 +43,20 @@ describe('albumBrowseLoad', () => {
expect(albumBrowseHasGenreFilter({ ...base, genres: ['Rock'] })).toBe(true);
});
it('slice catalog only for plain browse', () => {
expect(albumBrowseUseSliceCatalog(base)).toBe(true);
expect(albumBrowseUseSliceCatalog({ ...base, compFilter: 'only' })).toBe(true);
expect(albumBrowseUseSliceCatalog({ ...base, genres: ['Rock'] })).toBe(false);
expect(albumBrowseUseSliceCatalog({ ...base, year: { from: 1990 } })).toBe(false);
expect(albumBrowseUseSliceCatalog({ ...base, losslessOnly: true })).toBe(false);
expect(albumBrowseUseSliceCatalog({ ...base, starredOnly: true })).toBe(false);
});
it('multi-genre disables offset pagination', () => {
expect(albumBrowseMultiGenreBrowse({ ...base, genres: ['Rock'] })).toBe(false);
expect(albumBrowseMultiGenreBrowse({ ...base, genres: ['Rock', 'Jazz'] })).toBe(true);
});
it('starred + lossless uses local intersect when index is on', () => {
expect(albumBrowseStarredNeedsLocalIntersect({ ...base, starredOnly: true }, true, 's1')).toBe(
false,
+12 -4
View File
@@ -12,6 +12,8 @@ export type {
export {
albumBrowseHasGenreFilter,
albumBrowseHasServerFilters,
albumBrowseMultiGenreBrowse,
albumBrowseUseSliceCatalog,
filterAlbumsByCompilation,
filterAlbumsByStarred,
} from './albumBrowseFilters';
@@ -22,7 +24,7 @@ import { runLocalAlbumBrowse } from './albumBrowseLocal';
import { fetchAlbumBrowseNetwork } from './albumBrowseNetwork';
import { fetchStarredAlbumBrowse } from './albumBrowseStarredFetch';
import { libraryGetGenreAlbumCounts } from '../../api/library';
import { libraryScopeForServer } from '../../api/subsonicClient';
import { libraryScopeInvokeArgs } from '../musicLibraryFilter';
import { libraryIsReady } from './libraryReady';
import type {
AlbumBrowseFetchCallbacks,
@@ -58,17 +60,23 @@ export async function fetchAlbumBrowseGenreOptions(
query: AlbumBrowseQuery,
): Promise<GenreFilterOption[]> {
const withoutGenre: AlbumBrowseQuery = { ...query, genres: [] };
const scope = libraryScopeForServer(serverId);
const scopeArgs = libraryScopeInvokeArgs(serverId);
const hasCombinedFilters =
albumBrowseHasServerFilters(withoutGenre) || query.compFilter !== 'all';
// Sidebar library scope only: use the full scoped genre catalog from the local
// index instead of getGenres() (server-wide) or a 500-album sample.
if (indexEnabled && serverId && scope && !hasCombinedFilters && (await libraryIsReady(serverId))) {
if (
indexEnabled
&& serverId
&& scopeArgs.libraryScopeIds?.length
&& !hasCombinedFilters
&& (await libraryIsReady(serverId))
) {
try {
const rows = await libraryGetGenreAlbumCounts({
serverId,
libraryScope: scope,
...scopeArgs,
});
return rows.map(row => ({ genre: row.value, count: row.albumCount }));
} catch {
+80 -98
View File
@@ -1,22 +1,94 @@
import { libraryAdvancedSearch, libraryListAlbumsByGenre } from '../../api/library';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import { libraryScopeForServer } from '../../api/subsonicClient';
import { dedupeById } from '../dedupeById';
import { albumToAlbum } from './advancedSearchLocal';
import { albumBrowseHasServerFilters, sharedServerFilters } from './albumBrowseFilters';
import { sharedServerFilters } from './albumBrowseFilters';
import {
finalizeClusterAlbumBrowse,
searchSingleServerAlbumBrowse,
} from './albumBrowseExecution';
import { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort';
import { libraryIsReady } from './libraryReady';
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from './albumBrowseTypes';
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
import { canUseClusterAlbumBrowse, clusterBrowseAlbumsPage } from '../serverCluster/clusterBrowse';
import {
canUseClusterAlbumBrowse,
clusterAlbumBrowseNeedsAdvanced,
clusterBrowseAlbumsPage,
} from '../serverCluster/clusterBrowse';
import { clusterAdvancedSearchLocal } from './clusterAdvancedSearchLocal';
import { isClusterMode } from '../serverCluster/clusterScope';
import type { LibraryFilterClause } from '../../api/library';
function markServerStarredAlbums(albums: SubsonicAlbum[]) {
return albums.map(a => ({ ...a, starred: a.starred ?? 'true' }));
}
/** Local index: combined genre + year + lossless filters (AND), genres OR union. */
function genreEqFilter(genre: string): LibraryFilterClause {
return { field: 'genre', op: 'eq', value: genre };
}
async function runClusterAlbumBrowse(
query: AlbumBrowseQuery,
offset: number,
pageSize: number,
restrictAlbumIds: string[] | undefined,
): Promise<AlbumBrowsePageResult | null> {
const useServerStarredIds = restrictAlbumIds != null;
const shared = sharedServerFilters(query, useServerStarredIds);
const starredOnly = useServerStarredIds ? undefined : (query.starredOnly || undefined);
const sort = albumSortClauses(query.sort);
const finish = async (albums: SubsonicAlbum[], hasMore: boolean) => {
let out = await finalizeClusterAlbumBrowse(albums);
if (useServerStarredIds) out = markServerStarredAlbums(out);
return { albums: out, hasMore };
};
if (query.genres.length > 1) {
if (offset > 0) return { albums: [], hasMore: false };
const pages = await Promise.all(
query.genres.map(genre =>
clusterAdvancedSearchLocal({
query: undefined,
entityTypes: ['album'],
filters: [genreEqFilter(genre), ...shared],
starredOnly,
restrictAlbumIds: restrictAlbumIds ?? undefined,
sort,
limit: GENRE_ALBUM_FETCH_LIMIT,
offset: 0,
skipTotals: true,
}),
),
);
if (pages.some(p => !p)) return { albums: [], hasMore: false };
const merged = dedupeById(pages.flatMap(p => p!.albums.map(albumToAlbum)));
return {
albums: sortSubsonicAlbums(await finalizeClusterAlbumBrowse(merged), query.sort),
hasMore: false,
};
}
const genreFilters: LibraryFilterClause[] =
query.genres.length === 1
? [genreEqFilter(query.genres[0])]
: [];
const resp = await clusterAdvancedSearchLocal({
query: undefined,
entityTypes: ['album'],
filters: [...genreFilters, ...shared],
starredOnly,
restrictAlbumIds: restrictAlbumIds ?? undefined,
sort,
limit: pageSize,
offset,
skipTotals: true,
});
if (!resp) return { albums: [], hasMore: false };
return finish(resp.albums.map(albumToAlbum), resp.albums.length === pageSize);
}
/** Local index: layered filters — see `albumBrowseExecution.ts`. */
export async function runLocalAlbumBrowse(
serverId: string,
query: AlbumBrowseQuery,
@@ -24,105 +96,15 @@ export async function runLocalAlbumBrowse(
pageSize: number,
restrictAlbumIds?: string[],
): Promise<AlbumBrowsePageResult | null> {
const clusterAndFiltered = isClusterMode() && albumBrowseHasServerFilters(query);
if (canUseClusterAlbumBrowse(query, restrictAlbumIds)) {
const clusterPage = await clusterBrowseAlbumsPage(offset, pageSize);
if (clusterPage) return clusterPage;
}
if (clusterAndFiltered) {
const shared = sharedServerFilters(query, restrictAlbumIds != null);
const resp = await clusterAdvancedSearchLocal({
query: undefined,
entityTypes: ['album'],
filters: shared,
starredOnly: restrictAlbumIds != null ? undefined : (query.starredOnly || undefined),
restrictAlbumIds: restrictAlbumIds ?? undefined,
sort: albumSortClauses(query.sort),
limit: pageSize,
offset,
skipTotals: true,
});
if (!resp) return { albums: [], hasMore: false };
return {
albums: resp.albums.map(albumToAlbum),
hasMore: resp.albums.length === pageSize,
};
if (isClusterMode() && clusterAlbumBrowseNeedsAdvanced(query)) {
return runClusterAlbumBrowse(query, offset, pageSize, restrictAlbumIds);
}
if (isClusterMode()) return { albums: [], hasMore: false };
if (!serverId || !(await libraryIsReady(serverId))) return null;
const scope = libraryScopeForServer(serverId) ?? undefined;
const useServerStarredIds = restrictAlbumIds != null;
const shared = sharedServerFilters(query, useServerStarredIds);
const starredOnly = useServerStarredIds ? undefined : (query.starredOnly || undefined);
if (query.genres.length > 0) {
if (query.genres.length === 1) {
try {
const resp = await libraryListAlbumsByGenre({
serverId,
genre: query.genres[0],
libraryScope: scope,
sort: albumSortClauses(query.sort),
limit: pageSize,
offset,
});
if (resp.source !== 'local') return null;
let albums = resp.albums.map(albumToAlbum);
if (useServerStarredIds) albums = markServerStarredAlbums(albums);
return { albums, hasMore: resp.hasMore };
} catch {
return null;
}
}
if (offset > 0) return { albums: [], hasMore: false };
try {
const pages = await Promise.all(
query.genres.map(genre =>
libraryAdvancedSearch({
serverId,
libraryScope: scope,
entityTypes: ['album'],
filters: [{ field: 'genre', op: 'eq', value: genre }, ...shared],
starredOnly,
restrictAlbumIds: useServerStarredIds ? restrictAlbumIds : undefined,
sort: albumSortClauses(query.sort),
limit: GENRE_ALBUM_FETCH_LIMIT,
offset: 0,
skipTotals: true,
}),
),
);
if (pages.some(p => p.source !== 'local')) return null;
let merged = dedupeById(pages.flatMap(p => p.albums.map(albumToAlbum)));
if (useServerStarredIds) merged = markServerStarredAlbums(merged);
return {
albums: sortSubsonicAlbums(merged, query.sort),
hasMore: false,
};
} catch {
return null;
}
}
try {
const resp = await libraryAdvancedSearch({
serverId,
libraryScope: scope,
entityTypes: ['album'],
filters: shared,
starredOnly,
restrictAlbumIds: useServerStarredIds ? restrictAlbumIds : undefined,
sort: albumSortClauses(query.sort),
limit: pageSize,
offset,
skipTotals: true,
});
if (resp.source !== 'local') return null;
let albums = resp.albums.map(albumToAlbum);
if (useServerStarredIds) albums = markServerStarredAlbums(albums);
return { albums, hasMore: albums.length === pageSize };
} catch {
return null;
}
return searchSingleServerAlbumBrowse(serverId, query, offset, pageSize, restrictAlbumIds);
}
+24 -7
View File
@@ -1,4 +1,5 @@
import { getAlbumList } from '../../api/subsonicLibrary';
import { albumIdsInLibraryScope, getAlbumList } from '../../api/subsonicLibrary';
import { useAuthStore } from '../../store/authStore';
import { getAlbumsByGenre } from '../../api/subsonicGenres';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import { dedupeById } from '../dedupeById';
@@ -16,43 +17,57 @@ async function fetchByGenres(genres: string[]) {
return dedupeById(results.flat());
}
function applyNetworkPostFilters(albums: SubsonicAlbum[], query: AlbumBrowseQuery) {
async function applyNetworkPostFilters(
albums: SubsonicAlbum[],
query: AlbumBrowseQuery,
scopedAlbumIds: Set<string> | null,
) {
let out = albums;
if (scopedAlbumIds) out = out.filter(a => scopedAlbumIds.has(a.id));
if (query.year) out = filterAlbumsByYearBounds(out, query.year);
out = filterAlbumsByCompilation(out, query.compFilter);
if (query.starredOnly) out = out.filter(a => !!a.starred);
return sortSubsonicAlbums(out, query.sort);
}
async function networkScopedAlbumIds(): Promise<Set<string> | null> {
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) return null;
return albumIdsInLibraryScope(activeServerId);
}
export async function fetchAlbumBrowseNetwork(
query: AlbumBrowseQuery,
offset: number,
pageSize: number,
): Promise<AlbumBrowsePageResult> {
const scopedAlbumIds = await networkScopedAlbumIds();
if (query.genres.length > 0) {
if (query.genres.length === 1) {
const data = applyNetworkPostFilters(
const data = await applyNetworkPostFilters(
await getAlbumsByGenre(query.genres[0], pageSize, offset),
query,
scopedAlbumIds,
);
return { albums: data, hasMore: data.length === pageSize };
}
if (offset > 0) return { albums: [], hasMore: false };
const data = applyNetworkPostFilters(await fetchByGenres(query.genres), query);
const data = await applyNetworkPostFilters(await fetchByGenres(query.genres), query, scopedAlbumIds);
return { albums: data, hasMore: false };
}
if (query.starredOnly) {
const extra = query.year ? albumYearSubsonicParams(query.year) : {};
const data = applyNetworkPostFilters(
const data = await applyNetworkPostFilters(
await getAlbumList('starred', pageSize, offset, extra),
query,
scopedAlbumIds,
);
return { albums: data, hasMore: data.length === pageSize };
}
if (query.year) {
const data = applyNetworkPostFilters(
const data = await applyNetworkPostFilters(
await getAlbumList(
'byYear',
pageSize,
@@ -60,13 +75,15 @@ export async function fetchAlbumBrowseNetwork(
albumYearSubsonicParams(query.year),
),
query,
scopedAlbumIds,
);
return { albums: data, hasMore: data.length === pageSize };
}
const data = applyNetworkPostFilters(
const data = await applyNetworkPostFilters(
await getAlbumList(query.sort, pageSize, offset, {}),
query,
scopedAlbumIds,
);
return { albums: data, hasMore: data.length === pageSize };
}
+11 -8
View File
@@ -6,6 +6,7 @@ import {
filterAlbumsByCompilation,
filterAlbumsByYearBounds,
} from './albumBrowseFilters';
import { filterAlbumsToServerLibraryScope } from './albumBrowseLibraryScope';
import { runLocalAlbumBrowse } from './albumBrowseLocal';
import { sortSubsonicAlbums } from './albumBrowseSort';
import type {
@@ -18,24 +19,26 @@ function markServerStarredAlbums(albums: SubsonicAlbum[]): SubsonicAlbum[] {
return albums.map(a => ({ ...a, starred: a.starred ?? 'true' }));
}
function applyStarredNetworkPostFilters(
async function applyStarredNetworkPostFilters(
albums: SubsonicAlbum[],
query: AlbumBrowseQuery,
): SubsonicAlbum[] {
let out = albums;
serverId: string,
): Promise<SubsonicAlbum[]> {
let out = await filterAlbumsToServerLibraryScope(serverId, albums);
if (query.year) out = filterAlbumsByYearBounds(out, query.year);
out = filterAlbumsByCompilation(out, query.compFilter);
if (query.starredOnly) out = out.filter(a => !!a.starred);
return sortSubsonicAlbums(out, query.sort);
}
function paginateStarredAlbums(
async function paginateStarredAlbums(
all: SubsonicAlbum[],
query: AlbumBrowseQuery,
serverId: string,
offset: number,
pageSize: number,
): AlbumBrowsePageResult {
const filtered = applyStarredNetworkPostFilters(all, query);
): Promise<AlbumBrowsePageResult> {
const filtered = await applyStarredNetworkPostFilters(all, query, serverId);
const page = filtered.slice(offset, offset + pageSize);
return { albums: page, hasMore: offset + pageSize < filtered.length };
}
@@ -67,7 +70,7 @@ export async function fetchStarredAlbumBrowse(
);
emitPartial(fromCache);
} else {
emitPartial(paginateStarredAlbums(cached, query, 0, pageSize));
emitPartial(await paginateStarredAlbums(cached, query, serverId, 0, pageSize));
}
}
}
@@ -81,5 +84,5 @@ export async function fetchStarredAlbumBrowse(
if (query.losslessOnly) return { albums: [], hasMore: false };
}
return paginateStarredAlbums(serverAlbums, query, offset, pageSize);
return paginateStarredAlbums(serverAlbums, query, serverId, offset, pageSize);
}
+8 -8
View File
@@ -5,7 +5,7 @@ import { getStarred } from '../../api/subsonicStarRating';
import { search, searchSongsPaged } from '../../api/subsonicSearch';
import type { SearchResults, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
import { libraryAdvancedSearch, libraryGetArtistLosslessBrowse, libraryListLosslessAlbums } from '../../api/library';
import { libraryScopeForServer } from '../../api/subsonicClient';
import { libraryScopeInvokeArgs } from '../musicLibraryFilter';
import {
LIVE_SEARCH_DEBOUNCE_NETWORK_MS,
LIVE_SEARCH_DEBOUNCE_RACE_MS,
@@ -312,7 +312,7 @@ export async function runLocalBrowseSongPage(
try {
const resp = await libraryAdvancedSearch({
serverId,
libraryScope: libraryScopeForServer(serverId) ?? undefined,
...libraryScopeInvokeArgs(serverId),
query: q,
entityTypes: ['track'],
limit: pageSize,
@@ -414,7 +414,7 @@ export async function runLocalRandomSongs(
try {
const resp = await libraryAdvancedSearch({
serverId,
libraryScope: libraryScopeForServer(serverId) ?? undefined,
...libraryScopeInvokeArgs(serverId),
entityTypes: ['track'],
sort: [{ field: 'random', dir: 'asc' }],
limit,
@@ -438,7 +438,7 @@ export async function runLocalLosslessAlbums(
try {
const resp = await libraryListLosslessAlbums({
serverId,
libraryScope: libraryScopeForServer(serverId) ?? undefined,
...libraryScopeInvokeArgs(serverId),
limit,
offset,
});
@@ -462,7 +462,7 @@ export async function runLocalArtistLosslessBrowse(
const resp = await libraryGetArtistLosslessBrowse({
serverId,
artistId,
libraryScope: libraryScopeForServer(serverId) ?? undefined,
...libraryScopeInvokeArgs(serverId),
});
if (resp.source !== 'local') return null;
return {
@@ -486,7 +486,7 @@ export async function runLocalRandomAlbums(
try {
const resp = await libraryAdvancedSearch({
serverId,
libraryScope: libraryScopeForServer(serverId) ?? undefined,
...libraryScopeInvokeArgs(serverId),
entityTypes: ['album'],
sort: [{ field: 'random', dir: 'asc' }],
limit,
@@ -551,7 +551,7 @@ export async function runLocalBrowseAllArtists(
try {
const resp = await libraryAdvancedSearch({
serverId,
libraryScope: libraryScopeForServer(serverId) ?? undefined,
...libraryScopeInvokeArgs(serverId),
entityTypes: ['artist'],
limit,
offset: 0,
@@ -581,7 +581,7 @@ export async function fetchLocalArtistCatalogChunk(
try {
const resp = await libraryAdvancedSearch({
serverId,
libraryScope: libraryScopeForServer(serverId) ?? undefined,
...libraryScopeInvokeArgs(serverId),
entityTypes: ['artist'],
sort: [{ field: 'name', dir: 'asc' }],
limit: chunkSize,
+1 -1
View File
@@ -82,7 +82,7 @@ describe('runLocalLiveSearch', () => {
});
it('passes libraryScope from the sidebar music library filter', async () => {
useAuthStore.setState({ musicLibraryFilterByServer: { s1: 'lib7' } });
useAuthStore.setState({ musicLibraryFilterByServer: { s1: ['lib7'] } });
let captured: unknown;
onInvoke('library_live_search', (args) => {
captured = args;
+78
View File
@@ -0,0 +1,78 @@
import { useAuthStore } from '../store/authStore';
import { resolveServerIdForIndexKey } from './server/serverLookup';
/** Per-server Navidrome/Subsonic music-folder selection. */
export type MusicLibraryFilter = 'all' | string[];
export function normalizeMusicLibraryFilter(
raw: MusicLibraryFilter | string | undefined,
): MusicLibraryFilter {
if (raw === undefined) return 'all';
if (raw === 'all') return 'all';
if (typeof raw === 'string') return raw.trim() ? [raw] : 'all';
const ids = [...new Set(raw.map(id => String(id).trim()).filter(Boolean))];
return ids.length > 0 ? ids : 'all';
}
export function isAllLibrariesFilter(filter: MusicLibraryFilter): boolean {
return filter === 'all';
}
export function musicLibraryFilterForServer(serverId: string): MusicLibraryFilter {
const resolved = resolveServerIdForIndexKey(serverId) || serverId;
const filters = useAuthStore.getState().musicLibraryFilterByServer;
const raw = filters[resolved] ?? filters[serverId];
return normalizeMusicLibraryFilter(raw);
}
/** Folder ids when narrowed; `undefined` = all libraries on this server. */
export function libraryScopeIdsForServer(serverId: string): string[] | undefined {
const f = musicLibraryFilterForServer(serverId);
return f === 'all' ? undefined : f;
}
/** First scope id for legacy single-scope callers (REST with one musicFolderId). */
export function libraryScopeForServer(serverId: string): string | undefined {
const ids = libraryScopeIdsForServer(serverId);
return ids?.[0];
}
/** `libraryScope` + `libraryScopeIds` for local index / Tauri invoke callers. */
export function libraryScopeInvokeArgs(serverId: string): {
libraryScope?: string;
libraryScopeIds?: string[];
} {
const scopeIds = libraryScopeIdsForServer(serverId);
if (!scopeIds?.length) return {};
return {
libraryScopeIds: scopeIds,
...(scopeIds.length === 1 ? { libraryScope: scopeIds[0] } : {}),
};
}
export function isLibraryFolderSelected(serverId: string, folderId: string): boolean {
const f = musicLibraryFilterForServer(serverId);
return f === 'all' ? false : f.includes(folderId);
}
/** Stable key for sidebar unread / storage (sorted folder ids). */
export function musicLibraryFilterStorageKey(serverId: string): string {
const f = musicLibraryFilterForServer(serverId);
if (f === 'all') return 'all';
return [...f].sort().join('+');
}
export function libraryScopeSubtitleFromFolders(
folders: Array<{ id: string; name: string }>,
filter: MusicLibraryFilter,
multiLabel: (count: number) => string,
): string | null {
if (filter === 'all') return null;
const names = filter
.map(id => folders.find(f => f.id === id)?.name ?? id)
.filter(Boolean);
if (names.length === 0) return null;
if (names.length === 1) return names[0]!;
if (names.length === 2) return `${names[0]} · ${names[1]}`;
return multiLabel(names.length);
}
@@ -22,6 +22,11 @@ vi.mock('./representative', () => ({
getClusterMergeMemberIds: (...args: unknown[]) => mockGetMembers(...args),
}));
vi.mock('./clusterLibraryScopes', () => ({
buildClusterLibraryScopes: vi.fn(() => undefined),
isClusterLibraryScopeNarrowed: vi.fn(() => false),
}));
vi.mock('../library/advancedSearchLocal', () => ({
trackToSong: (t: { id: string }) => ({ id: t.id, title: t.id }),
albumToAlbum: (a: { id: string }) => ({ id: a.id, name: a.id }),
@@ -30,8 +35,10 @@ vi.mock('../library/advancedSearchLocal', () => ({
import {
canUseClusterAlbumBrowse,
clusterAlbumBrowseNeedsAdvanced,
clusterBrowseTracksPage,
} from './clusterBrowse';
import { isClusterLibraryScopeNarrowed } from './clusterLibraryScopes';
describe('clusterBrowse', () => {
beforeEach(() => {
@@ -67,4 +74,19 @@ describe('clusterBrowse', () => {
),
).toBe(false);
});
it('clusterAlbumBrowseNeedsAdvanced covers comp-only and scoped plain browse', () => {
const plain = {
genres: [] as string[],
losslessOnly: false,
starredOnly: false,
compFilter: 'all' as const,
sort: 'alphabeticalByName' as const,
};
expect(clusterAlbumBrowseNeedsAdvanced(plain)).toBe(false);
expect(clusterAlbumBrowseNeedsAdvanced({ ...plain, compFilter: 'only' })).toBe(true);
expect(clusterAlbumBrowseNeedsAdvanced({ ...plain, genres: ['Rock'] })).toBe(true);
vi.mocked(isClusterLibraryScopeNarrowed).mockReturnValueOnce(true);
expect(clusterAlbumBrowseNeedsAdvanced(plain)).toBe(true);
});
});
+11 -1
View File
@@ -15,7 +15,7 @@ import { dedupeById } from '../dedupeById';
import { albumToAlbum, artistToArtist, trackToSong } from '../library/advancedSearchLocal';
import { albumBrowseHasServerFilters } from '../library/albumBrowseFilters';
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from '../library/albumBrowseTypes';
import { buildClusterLibraryScopes } from './clusterLibraryScopes';
import { buildClusterLibraryScopes, isClusterLibraryScopeNarrowed } from './clusterLibraryScopes';
import { getActiveClusterId, isClusterMode } from './clusterScope';
import { getClusterMergeMemberIds } from './representative';
@@ -27,6 +27,14 @@ export async function resolveClusterBrowseMembers(): Promise<string[] | null> {
return ids.length > 0 ? ids : null;
}
/** Cluster merged list or per-member advanced search (not the fast `list_albums` path). */
export function clusterAlbumBrowseNeedsAdvanced(query: AlbumBrowseQuery): boolean {
if (albumBrowseHasServerFilters(query)) return true;
if (query.compFilter !== 'all') return true;
if (isClusterLibraryScopeNarrowed()) return true;
return false;
}
export function canUseClusterAlbumBrowse(
query: AlbumBrowseQuery,
restrictAlbumIds?: string[],
@@ -35,6 +43,8 @@ export function canUseClusterAlbumBrowse(
if (restrictAlbumIds != null) return false;
if (albumBrowseHasServerFilters(query)) return false;
if (query.compFilter !== 'all') return false;
// Merged list_tracks SQL ignores library_id on many indexes — use advanced search.
if (isClusterLibraryScopeNarrowed()) return false;
return true;
}
+50 -40
View File
@@ -1,71 +1,81 @@
import { libraryScopeForServer } from '../../api/subsonicClient';
import { useAuthStore } from '../../store/authStore';
import { libraryScopeIdsForServer } from '../../api/subsonicClient';
import {
isAllLibrariesFilter,
isLibraryFolderSelected,
libraryScopeSubtitleFromFolders,
musicLibraryFilterForServer,
musicLibraryFilterStorageKey,
} from '../musicLibraryFilter';
import { resolveServerIdForIndexKey } from '../server/serverLookup';
import { getActiveClusterMemberIds, isClusterMode } from './clusterScope';
/** Per-member Navidrome music-folder scopes for cluster index reads (omit = all libraries). */
export function buildClusterLibraryScopes(memberIds: string[]): Record<string, string> | undefined {
const scopes: Record<string, string> = {};
export function buildClusterLibraryScopes(
memberIds: string[],
): Record<string, string[]> | undefined {
// Per-member folder id lists for merged cluster SQL (omit member = all libraries).
const scopes: Record<string, string[]> = {};
for (const sid of memberIds) {
const scope = libraryScopeForServer(sid);
if (scope) scopes[sid] = scope;
const ids = libraryScopeIdsForServer(sid);
if (ids?.length) scopes[sid] = ids;
}
return Object.keys(scopes).length > 0 ? scopes : undefined;
}
export function isClusterAllLibrariesSelected(memberIds: string[]): boolean {
const filters = useAuthStore.getState().musicLibraryFilterByServer;
return memberIds.every(sid => {
const resolved = resolveServerIdForIndexKey(sid) || sid;
const f = filters[resolved] ?? filters[sid];
return f === undefined || f === 'all';
});
/** True when at least one cluster member narrowed the sidebar library picker. */
export function isClusterLibraryScopeNarrowed(): boolean {
if (!isClusterMode()) return false;
return buildClusterLibraryScopes(getActiveClusterMemberIds()) != null;
}
export function isClusterAllLibrariesSelected(memberIds: string[]): boolean {
return memberIds.every(sid => isAllLibrariesFilter(musicLibraryFilterForServer(sid)));
}
/** Label for the sidebar scope subtitle when one member is narrowed. */
export function clusterLibraryScopeSubtitle(
memberIds: string[],
entries: Array<{ serverId: string; serverLabel: string; folderId: string; folderName: string }>,
multiLabel: (count: number) => string,
): string | null {
if (isClusterAllLibrariesSelected(memberIds)) return null;
const filters = useAuthStore.getState().musicLibraryFilterByServer;
for (const entry of entries) {
const resolved = resolveServerIdForIndexKey(entry.serverId) || entry.serverId;
const f = filters[resolved] ?? filters[entry.serverId];
if (f && f !== 'all' && f === entry.folderId) {
return `${entry.serverLabel}${entry.folderName}`;
}
const filters = memberIds.map(sid => ({ sid, f: musicLibraryFilterForServer(sid) }));
const narrowed = filters.filter(x => x.f !== 'all');
if (narrowed.length === 0) return null;
if (narrowed.length === 1 && narrowed[0]!.f !== 'all') {
const sid = narrowed[0]!.sid;
const f = narrowed[0]!.f;
const names = f
.map(id => {
const entry = entries.find(
e => (resolveServerIdForIndexKey(e.serverId) || e.serverId) === sid && e.folderId === id,
);
return entry ? `${entry.serverLabel}${entry.folderName}` : id;
})
.filter(Boolean);
if (names.length === 1) return names[0]!;
if (names.length === 2) return `${names[0]} · ${names[1]}`;
return multiLabel(names.length);
}
return null;
const total = narrowed.reduce((n, x) => n + (x.f === 'all' ? 0 : x.f.length), 0);
return multiLabel(total);
}
export function isClusterLibraryFolderSelected(
serverId: string,
folderId: string,
): boolean {
const filters = useAuthStore.getState().musicLibraryFilterByServer;
const resolved = resolveServerIdForIndexKey(serverId) || serverId;
const f = filters[resolved] ?? filters[serverId];
return f === folderId;
return isLibraryFolderSelected(serverId, folderId);
}
export function clusterLibraryFilterStorageKey(memberIds: string[]): string {
if (isClusterAllLibrariesSelected(memberIds)) return 'all';
return memberIds.map(sid => `${sid}:${musicLibraryFilterStorageKey(sid)}`).sort().join('|');
}
export function clusterLibraryPickerEntryId(serverId: string, folderId: string): string {
return `${serverId}::${folderId}`;
}
/** Sidebar picker `filterId` in cluster mode (`all` or `serverId::folderId`). */
export function clusterPickerFilterId(
memberIds: string[],
entries: Array<{ serverId: string; folderId: string }>,
): string {
if (isClusterAllLibrariesSelected(memberIds)) return 'all';
for (const entry of entries) {
if (isClusterLibraryFolderSelected(entry.serverId, entry.folderId)) {
return clusterLibraryPickerEntryId(entry.serverId, entry.folderId);
}
}
return 'all';
}
export function parseClusterLibraryPickerEntryId(
entryId: string,
): { serverId: string; folderId: string } | null {