mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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:
+15
-9
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -31,6 +31,9 @@ export const sidebar = {
|
||||
deviceSync: 'Синхронизация устройства',
|
||||
libraryScope: 'Область медиатеки',
|
||||
allLibraries: 'Все библиотеки',
|
||||
librariesCount: '{{count}} библиотек',
|
||||
librarySelect: 'Включить {{name}}',
|
||||
libraryDeselect: 'Исключить {{name}}',
|
||||
expandPlaylists: 'Развернуть плейлисты',
|
||||
collapsePlaylists: 'Свернуть плейлисты',
|
||||
more: 'Ещё',
|
||||
|
||||
@@ -97,6 +97,7 @@ export default function Albums() {
|
||||
indexEnabled,
|
||||
serverId,
|
||||
losslessOnly,
|
||||
musicLibraryFilterVersion,
|
||||
);
|
||||
|
||||
const {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user