feat(search): respect selected library scope

This commit is contained in:
cucadmuh
2026-07-19 22:11:42 +03:00
parent 75dfaaa241
commit fbb4546c85
48 changed files with 1828 additions and 387 deletions
+17 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest';
import { uniqueAlbumIdsFromSongs } from '@/cover/warmDiskPeek';
import {
uniqueAlbumCoverSourcesFromSongs,
uniqueAlbumIdsFromSongs,
} from '@/cover/warmDiskPeek';
describe('uniqueAlbumIdsFromSongs', () => {
it('dedupes by albumId and respects limit', () => {
@@ -19,3 +22,16 @@ describe('uniqueAlbumIdsFromSongs', () => {
expect(uniqueAlbumIdsFromSongs([{ albumId: '' }, { albumId: ' ' }, { albumId: 'x' }])).toEqual(['x']);
});
});
describe('uniqueAlbumCoverSourcesFromSongs', () => {
it('preserves equal album ids owned by different servers', () => {
expect(uniqueAlbumCoverSourcesFromSongs([
{ albumId: 'same', coverArt: 'cover-a', serverId: 'a' },
{ albumId: 'same', coverArt: 'cover-b', serverId: 'b' },
{ albumId: 'same', coverArt: 'duplicate-a', serverId: 'a' },
])).toEqual([
{ albumId: 'same', coverArt: 'cover-a', serverId: 'a' },
{ albumId: 'same', coverArt: 'cover-b', serverId: 'b' },
]);
});
});
+33 -13
View File
@@ -3,13 +3,15 @@ import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { COVER_ARTIST_TOP_TRACK_CSS_PX } from '@/cover/layoutSizes';
import { useLibraryCoverPrefetch } from '@/cover/useLibraryCoverPrefetch';
import {
uniqueAlbumIdsFromSongs,
uniqueAlbumCoverSourcesFromSongs,
warmUniqueAlbumCoversFromLibrary,
} from '@/cover/warmDiskPeek';
import { coverServerScopeForOwnerServerId } from '@/cover/serverScope';
import { COVER_SCOPE_ACTIVE, coverScopeKey, type CoverServerScope } from '@/cover/types';
const DEFAULT_LIMIT = 48;
type SongAlbumSource = Pick<SubsonicSong, 'albumId'>;
type SongAlbumSource = Pick<SubsonicSong, 'albumId' | 'coverArt' | 'serverId'>;
/**
* Standard cover pipeline warm for track-list surfaces: dedupe visible songs to
@@ -24,27 +26,45 @@ export function useWarmTrackListAlbumCovers(
const enabled = opts?.enabled ?? true;
const limit = opts?.limit ?? DEFAULT_LIMIT;
const albumIds = useMemo(
() => uniqueAlbumIdsFromSongs(songs, limit),
const albums = useMemo(
() => uniqueAlbumCoverSourcesFromSongs(songs, limit),
[songs, limit],
);
const warmKey = useMemo(() => albumIds.join('\u0001'), [albumIds]);
const prefetchAlbums = useMemo(
() => albumIds.map(id => ({ id })),
[albumIds],
const warmKey = useMemo(
() => albums.map(album => `${album.serverId ?? ''}\u0001${album.albumId}\u0001${album.coverArt}`).join('\u0002'),
[albums],
);
const prefetchBuckets = useMemo(() => {
const grouped = new Map<string, {
serverScope: CoverServerScope;
albums: Array<{ id: string; coverArt: string }>;
}>();
for (const album of albums) {
const serverScope = album.serverId
? coverServerScopeForOwnerServerId(album.serverId)
: COVER_SCOPE_ACTIVE;
const key = coverScopeKey(serverScope);
const group = grouped.get(key) ?? { serverScope, albums: [] };
group.albums.push({ id: album.albumId, coverArt: album.coverArt });
grouped.set(key, group);
}
return [...grouped.values()].map(group => ({
albums: group.albums,
limit: group.albums.length,
priority: 'high' as const,
serverScope: group.serverScope,
}));
}, [albums]);
useLibraryCoverPrefetch(
prefetchAlbums.length > 0
? [{ albums: prefetchAlbums, limit, priority: 'high' }]
: [],
prefetchBuckets,
[warmKey, enabled],
);
useEffect(() => {
if (!enabled || displayCssPx <= 0 || albumIds.length === 0) return;
if (!enabled || displayCssPx <= 0 || albums.length === 0) return;
let cancelled = false;
void warmUniqueAlbumCoversFromLibrary(albumIds, displayCssPx, 'dense').then(() => {
void warmUniqueAlbumCoversFromLibrary(albums, displayCssPx, 'dense').then(() => {
if (cancelled) return;
});
return () => {
+42 -1
View File
@@ -22,12 +22,22 @@ vi.mock('./serverScope', () => ({
password: 'secret',
}
: { kind: 'active' },
coverServerScopeForOwnerServerId: (serverId: string) => ({
kind: 'server',
serverId,
url: `https://${serverId}.test`,
username: serverId,
password: 'secret',
}),
}));
vi.mock('./resolveEntryLibrary', () => ({
resolveAlbumCoverRefFromLibrary,
}));
import { warmHomeMainstageCovers } from './warmDiskPeek';
import {
warmHomeMainstageCovers,
warmUniqueAlbumCoversFromLibrary,
} from './warmDiskPeek';
describe('warmHomeMainstageCovers', () => {
beforeEach(() => {
@@ -75,3 +85,34 @@ describe('warmHomeMainstageCovers', () => {
expect(resolveAlbumCoverRefFromLibrary).not.toHaveBeenCalled();
});
});
describe('warmUniqueAlbumCoversFromLibrary', () => {
it('resolves equal album ids in separate owner scopes', async () => {
resolveAlbumCoverRefFromLibrary.mockImplementation(
async (albumId: string, coverArt: string, serverScope: unknown) => ({
cacheKind: 'album',
cacheEntityId: albumId,
fetchCoverArtId: coverArt,
serverScope,
}),
);
await warmUniqueAlbumCoversFromLibrary([
{ albumId: 'same', coverArt: 'cover-a', serverId: 'a' },
{ albumId: 'same', coverArt: 'cover-b', serverId: 'b' },
], 64);
expect(resolveAlbumCoverRefFromLibrary).toHaveBeenNthCalledWith(
1,
'same',
'cover-a',
expect.objectContaining({ kind: 'server', serverId: 'a' }),
);
expect(resolveAlbumCoverRefFromLibrary).toHaveBeenNthCalledWith(
2,
'same',
'cover-b',
expect.objectContaining({ kind: 'server', serverId: 'b' }),
);
});
});
+50 -5
View File
@@ -3,7 +3,10 @@ import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
import { coverEnsureQueued, ensureArtistBackdropQueued } from './ensureQueue';
import { getDiskSrcForGrid, rememberGridDiskSrc } from './diskSrcLookup';
import { albumCoverRef, artistCoverRef } from './ref';
import { coverServerScopeForServerId } from './serverScope';
import {
coverServerScopeForOwnerServerId,
coverServerScopeForServerId,
} from './serverScope';
import { coverDiskUrl } from './diskSrcCache';
import { resolveAlbumCoverRefFromLibrary } from './resolveEntryLibrary';
import { coverStorageKeyFromRef } from './storageKeys';
@@ -50,7 +53,7 @@ export async function coverWarmItemFromLibrary(
const ref = await resolveAlbumCoverRefFromLibrary(
albumId,
fetchCoverArtId,
coverServerScopeForServerId(serverId),
serverId ? coverServerScopeForOwnerServerId(serverId) : coverServerScopeForServerId(serverId),
);
const tier = resolveCoverDisplayTier(displayCssPx, { surface });
return {
@@ -116,18 +119,60 @@ export function uniqueAlbumIdsFromSongs(
return out;
}
export type SongAlbumCoverSource = {
albumId?: string | null;
coverArt?: string | null;
serverId?: string;
};
export type UniqueAlbumCoverSource = {
albumId: string;
coverArt: string;
serverId?: string;
};
/** Dedupe track-list covers by owner + album identity. */
export function uniqueAlbumCoverSourcesFromSongs(
songs: ReadonlyArray<SongAlbumCoverSource>,
limit = 48,
): UniqueAlbumCoverSource[] {
const seen = new Set<string>();
const out: UniqueAlbumCoverSource[] = [];
for (const song of songs) {
const albumId = song.albumId?.trim();
if (!albumId) continue;
const serverId = song.serverId?.trim() || undefined;
const key = `${serverId ?? ''}\u0001${albumId}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({
albumId,
coverArt: song.coverArt?.trim() || albumId,
...(serverId ? { serverId } : {}),
});
if (out.length >= limit) break;
}
return out;
}
/**
* Library-resolved peek + high-priority ensure for deduped album covers referenced
* by a track list window (browse rows, playlist suggestions, virtual slice).
*/
export async function warmUniqueAlbumCoversFromLibrary(
albumIds: readonly string[],
albums: readonly UniqueAlbumCoverSource[],
displayCssPx: number,
surface: CoverSurfaceKind = 'dense',
): Promise<void> {
if (albumIds.length === 0 || displayCssPx <= 0) return;
if (albums.length === 0 || displayCssPx <= 0) return;
const items = await Promise.all(
albumIds.map(albumId => coverWarmItemFromLibrary(albumId, albumId, displayCssPx, surface)),
albums.map(album => coverWarmItemFromLibrary(
album.albumId,
album.coverArt,
displayCssPx,
surface,
album.serverId,
)),
);
const batch = dedupeWarmItems(items);
if (batch.length === 0) return;
@@ -1,13 +1,14 @@
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { navigateToAlbumDetail } from '@/lib/navigation/albumDetailNavigation';
import type { ArtistDetailPathOptions } from '@/lib/navigation/detailServerScope';
/** Navigate to album detail, remembering the current page for the back button. */
export function useNavigateToAlbum() {
const navigate = useNavigate();
const location = useLocation();
return useCallback(
(albumId: string, opts?: { search?: string }) => {
(albumId: string, opts?: ArtistDetailPathOptions) => {
navigateToAlbumDetail(navigate, location, albumId, opts);
},
[navigate, location],
@@ -158,7 +158,11 @@ export default function LiveSearch() {
setOpen(false);
setQuery('');
} }))),
...(results.albums.map(a => ({ id: a.id, action: () => { navigateToAlbum(a.id); setOpen(false); setQuery(''); } }))),
...(results.albums.map(a => ({ id: a.id, action: () => {
navigateToAlbum(a.id, { serverId: a.serverId ?? activeServerId });
setOpen(false);
setQuery('');
} }))),
...(results.songs.map(s => ({ id: s.id, action: () => {
const track = songToTrack(s);
enqueue([track]);
@@ -1,10 +1,15 @@
import { describe, expect, it, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import userEvent from '@testing-library/user-event';
import LiveSearchDropdown from '@/features/search/components/LiveSearchDropdown';
import type { useShareSearch } from '@/features/search/hooks/useShareSearch';
import type { SearchResults } from '@/lib/api/subsonicTypes';
const { navigateToAlbumMock } = vi.hoisted(() => ({
navigateToAlbumMock: vi.fn(),
}));
vi.mock('@/store/liveSearchScopeStore', () => ({
useLiveSearchScopeStore: (selector: (s: { query: string; setQuery: () => void }) => unknown) =>
selector({ query: 'beatles', setQuery: vi.fn() }),
@@ -19,7 +24,7 @@ vi.mock('react-router-dom', async importOriginal => {
});
vi.mock('@/features/album', () => ({
useNavigateToAlbum: () => vi.fn(),
useNavigateToAlbum: () => navigateToAlbumMock,
albumArtistDisplayName: (album: { artist?: string }) => album.artist ?? '',
}));
@@ -113,4 +118,38 @@ describe('LiveSearchDropdown index incomplete banner', () => {
expect(screen.queryByRole('status')).toBeNull();
});
it('preserves the album owner when opening a local result', async () => {
navigateToAlbumMock.mockReset();
const user = userEvent.setup();
renderWithProviders(
<LiveSearchDropdown
dropdownRef={{ current: null }}
results={{
artists: [],
albums: [{
serverId: 'srv-b',
id: 'album-1',
name: 'Owned Album',
artist: 'Artist',
artistId: 'artist-1',
songCount: 1,
duration: 60,
coverArt: 'cover-1',
}],
songs: [],
}}
searchSource="local"
activeIndex={-1}
loading={false}
indexIncomplete={false}
share={shareStub}
setOpen={vi.fn()}
/>,
);
await user.click(screen.getByRole('option', { name: /Owned Album/i }));
expect(navigateToAlbumMock).toHaveBeenCalledWith('album-1', { serverId: 'srv-b' });
});
});
@@ -17,6 +17,7 @@ import {
import type { useShareSearch } from '@/features/search/hooks/useShareSearch';
import { useAuthStore } from '@/store/authStore';
import { buildArtistDetailPath } from '@/lib/navigation/detailServerScope';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
export type LiveSearchSource = 'local' | 'network';
@@ -51,7 +52,10 @@ export default function LiveSearchDropdown({
const enqueue = usePlayerStore(state => state.enqueue);
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const ctxIsOpen = usePlayerStore(state => state.contextMenu.isOpen);
const ctxItemId = usePlayerStore(state => (state.contextMenu.item as { id?: string } | null)?.id);
const ctxItem = usePlayerStore(state => state.contextMenu.item as {
id?: string;
serverId?: string;
} | null);
const ctxType = usePlayerStore(state => state.contextMenu.type);
const hasResults =
@@ -138,9 +142,12 @@ export default function LiveSearchDropdown({
<div className="search-section-label"><Users size={12} /> {t('search.artists')}</div>
{results.artists.map(a => {
const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'artist' && ctxItemId === a.id;
const isCtxActive = ctxIsOpen
&& ctxType === 'artist'
&& !!ctxItem?.id
&& ownedEntityKey(a) === ownedEntityKey({ id: ctxItem.id, serverId: ctxItem.serverId });
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
<button key={ownedEntityKey(a)} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => {
navigate(buildArtistDetailPath(a.id, { serverId: a.serverId ?? activeServerId }));
setOpen(false);
@@ -166,17 +173,24 @@ export default function LiveSearchDropdown({
<div className="search-section-label"><Disc3 size={12} /> {t('search.albums')}</div>
{results.albums.map(a => {
const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'album' && ctxItemId === a.id;
const isCtxActive = ctxIsOpen
&& ctxType === 'album'
&& !!ctxItem?.id
&& ownedEntityKey(a) === ownedEntityKey({ id: ctxItem.id, serverId: ctxItem.serverId });
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigateToAlbum(a.id); setOpen(false); setQuery(''); }}
<button key={ownedEntityKey(a)} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => {
navigateToAlbum(a.id, { serverId: a.serverId ?? activeServerId });
setOpen(false);
setQuery('');
}}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'album');
}}
role="option" aria-selected={activeIndex === i}>
{a.coverArt ? (
<LiveSearchAlbumThumb albumId={a.id} coverArt={a.coverArt} />
<LiveSearchAlbumThumb albumId={a.id} coverArt={a.coverArt} serverId={a.serverId} />
) : (
<div className="search-result-icon"><Disc3 size={14} /></div>
)}
@@ -195,9 +209,12 @@ export default function LiveSearchDropdown({
<div className="search-section-label"><Music size={12} /> {t('search.songs')}</div>
{results.songs.map(s => {
const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'song' && ctxItemId === s.id;
const isCtxActive = ctxIsOpen
&& ctxType === 'song'
&& !!ctxItem?.id
&& ownedEntityKey(s) === ownedEntityKey({ id: ctxItem.id, serverId: ctxItem.serverId });
return (
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
<button key={ownedEntityKey(s)} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => {
const track = songToTrack(s);
enqueue([track]);
@@ -5,13 +5,17 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import MobileSearchOverlay from '@/features/search/components/MobileSearchOverlay';
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
const { useLiveSearchQueryMock } = vi.hoisted(() => ({
useLiveSearchQueryMock: vi.fn(() => ({ indexIncomplete: false })),
}));
// The overlay's only behaviour-bearing change in PR #1165 was renaming the
// recent-search handler `useRecent` → `applyRecentSearch` (it was a plain
// function mis-flagged as a hook). Smoke-test that the recent-search path still
// applies the term to the live-search store. Heavy collaborators are stubbed.
vi.mock('@/features/search/hooks/useShareSearch', () => ({ useShareSearch: () => ({ shareMatch: null }) }));
vi.mock('@/lib/api/subsonicSearch', () => ({
search: vi.fn(() => Promise.resolve({ artists: [], albums: [], songs: [] })),
vi.mock('@/features/search/hooks/useLiveSearchQuery', () => ({
useLiveSearchQuery: useLiveSearchQueryMock,
}));
vi.mock('@/cover/AlbumCoverArtImage', () => ({ AlbumCoverArtImage: () => null }));
vi.mock('@/cover/ArtistCoverArtImage', () => ({ ArtistCoverArtImage: () => null }));
@@ -21,6 +25,7 @@ const RECENT_KEY = 'psysonic_recent_searches';
describe('MobileSearchOverlay — recent search (applyRecentSearch, PR #1165)', () => {
beforeEach(() => {
useLiveSearchQueryMock.mockClear();
useLiveSearchScopeStore.setState({ query: '', scope: null, undoStack: [] });
localStorage.setItem(RECENT_KEY, JSON.stringify(['first query', 'second query']));
});
@@ -38,5 +43,8 @@ describe('MobileSearchOverlay — recent search (applyRecentSearch, PR #1165)',
await user.click(screen.getByText('first query'));
expect(useLiveSearchScopeStore.getState().query).toBe('first query');
expect(useLiveSearchQueryMock).toHaveBeenLastCalledWith(expect.objectContaining({
query: 'first query',
}));
});
});
@@ -1,8 +1,14 @@
import { search } from '@/lib/api/subsonicSearch';
import type { SearchResults, SubsonicArtist } from '@/lib/api/subsonicTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import React, {
useState,
useEffect,
useRef,
useMemo,
type Dispatch,
type SetStateAction,
} from 'react';
import { createPortal } from 'react-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { navigatePathWithAlbumReturnTo } from '@/lib/navigation/albumDetailNavigation';
@@ -33,7 +39,11 @@ import {
resetLiveSearchScopeBackspaceState,
resolveLiveSearchScopeGhost,
} from '@/features/search/components/liveSearchScope';
import { buildArtistDetailPath } from '@/lib/navigation/detailServerScope';
import { buildAlbumDetailPath, buildArtistDetailPath } from '@/lib/navigation/detailServerScope';
import { useLiveSearchQuery } from '@/features/search/hooks/useLiveSearchQuery';
import type { LiveSearchSource } from '@/features/search/components/LiveSearchDropdown';
import { coverServerScopeForOwnerServerId } from '@/cover/serverScope';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
const STORAGE_KEY = 'psysonic_recent_searches';
const MAX_RECENT = 6;
@@ -48,28 +58,29 @@ function saveRecent(q: string, prev: string[]): string[] {
return updated;
}
function debounce<A extends unknown[]>(
fn: (...args: A) => void,
ms: number,
): (...args: A) => void {
let timer: ReturnType<typeof setTimeout>;
return (...args: A) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
}
/** Mobile search row thumb — larger than desktop live search (32px). */
const MOBILE_SEARCH_THUMB_CSS_PX = 80;
const ignoreBooleanState: Dispatch<SetStateAction<boolean>> = () => {};
const ignoreNumberState: Dispatch<SetStateAction<number>> = () => {};
const ignoreSourceState: Dispatch<SetStateAction<LiveSearchSource | null>> = () => {};
function MobileSearchSongThumb({
song,
}: {
song: Pick<SearchResults['songs'][number], 'id' | 'albumId' | 'coverArt' | 'discNumber'>;
song: Pick<SearchResults['songs'][number], 'id' | 'albumId' | 'coverArt' | 'discNumber' | 'serverId'>;
}) {
const coverRef = useMemo(
() => (song.albumId?.trim() ? albumCoverRefForSong(song) : undefined),
() => (song.albumId?.trim()
? albumCoverRefForSong(
song,
undefined,
song.serverId ? coverServerScopeForOwnerServerId(song.serverId) : undefined,
)
: undefined),
// Keyed on song's identity fields; depending on the `song` object would
// recompute the ref on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
[song.id, song.albumId, song.coverArt, song.discNumber],
[song.id, song.albumId, song.coverArt, song.discNumber, song.serverId],
);
if (!coverRef) return null;
return (
@@ -84,11 +95,11 @@ function MobileSearchSongThumb({
);
}
function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt' | 'serverId'> }) {
const [failed, setFailed] = useState(false);
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt, artist.serverId]);
if (failed) {
return (
<div className="mobile-search-avatar mobile-search-avatar--circle">
@@ -100,6 +111,7 @@ function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id'
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArt}
serverScope={artist.serverId ? coverServerScopeForOwnerServerId(artist.serverId) : undefined}
libraryResolve={false}
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
surface="dense"
@@ -131,9 +143,21 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
const [loading, setLoading] = useState(false);
const [recentSearches, setRecentSearches] = useState<string[]>(loadRecent);
const inputRef = useRef<HTMLInputElement>(null);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const liveSearchGenRef = useRef(0);
const share = useShareSearch(query, onClose);
useLiveSearchQuery({
query,
scope,
shareMatch: share.shareMatch,
liveSearchGenRef,
setResults,
setOpen: ignoreBooleanState,
setLoading,
setSearchSource: ignoreSourceState,
setActiveIndex: ignoreNumberState,
});
useEffect(() => { inputRef.current?.focus(); }, []);
useEffect(() => {
@@ -150,39 +174,6 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
return () => { document.body.style.overflow = prev; };
}, []);
// doSearch wraps a debounce() result, so the useCallback argument is not an
// inline function and its deps can't be statically analysed. It is recreated
// only on musicLibraryFilterVersion (search() reads the active filter state).
// eslint-disable-next-line react-hooks/exhaustive-deps
const doSearch = useCallback(
// React Compiler rule: memoization shape is intentional here.
// eslint-disable-next-line react-hooks/use-memo
debounce(async (q: string) => {
if (!q.trim()) { setResults(null); setLoading(false); return; }
setLoading(true);
try {
setResults(await search(q));
} finally { setLoading(false); }
}, 300),
[musicLibraryFilterVersion],
);
useEffect(() => {
if (isLiveSearchDropdownBlocked(scope)) {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setResults(null);
setLoading(false);
return;
}
if (share.shareMatch) {
setResults(null);
setLoading(false);
return;
}
doSearch(query);
}, [query, scope, doSearch, share.shareMatch]);
const commit = (q: string) => {
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
};
@@ -285,21 +276,28 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.recentSearches')}</div>
{recentSearches.map(term => (
<button key={term} className="mobile-search-item" onClick={() => applyRecentSearch(term)}>
<div className="mobile-search-avatar">
<Clock size={18} />
</div>
<div className="mobile-search-item-info" style={{ flex: 1 }}>
<span className="mobile-search-item-title">{term}</span>
</div>
<div key={term} className="mobile-search-item">
<button
type="button"
className="mobile-search-recent-apply"
onClick={() => applyRecentSearch(term)}
>
<div className="mobile-search-avatar">
<Clock size={18} />
</div>
<div className="mobile-search-item-info" style={{ flex: 1 }}>
<span className="mobile-search-item-title">{term}</span>
</div>
</button>
<button
type="button"
className="mobile-search-recent-remove"
onClick={e => removeRecent(term, e)}
aria-label={t('search.clearLabel')}
>
<X size={14} />
</button>
</button>
</div>
))}
</div>
)}
@@ -371,7 +369,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
<div className="mobile-search-section-label">{t('search.artists')}</div>
{results!.artists.map(a => (
<button
key={a.id}
key={ownedEntityKey(a)}
className="mobile-search-item"
onClick={() => goTo(buildArtistDetailPath(a.id, {
serverId: a.serverId ?? activeServerId,
@@ -392,11 +390,18 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.albums')}</div>
{results!.albums.map(a => (
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}>
<button
key={ownedEntityKey(a)}
className="mobile-search-item"
onClick={() => goTo(buildAlbumDetailPath(a.id, {
serverId: a.serverId ?? activeServerId,
}))}
>
{a.coverArt ? (
<AlbumCoverArtImage
albumId={a.id}
coverArt={a.coverArt}
serverScope={a.serverId ? coverServerScopeForOwnerServerId(a.serverId) : undefined}
libraryResolve={false}
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
surface="dense"
@@ -423,7 +428,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.songs')}</div>
{results!.songs.map(s => (
<button key={s.id} className="mobile-search-item" onClick={() => enqueueSong(s)}>
<button key={ownedEntityKey(s)} className="mobile-search-item" onClick={() => enqueueSong(s)}>
{s.albumId && (s.coverArt ?? s.albumId) ? (
<MobileSearchSongThumb song={s} />
) : (
@@ -6,6 +6,7 @@ import InpageScrollSentinel from '@/ui/InpageScrollSentinel';
import { COVER_ARTIST_TOP_TRACK_CSS_PX } from '@/cover/layoutSizes';
import { useWarmTrackListAlbumCovers } from '@/cover/useWarmTrackListAlbumCovers';
import { useTrackListCoverArtEnabled } from '@/cover/useTrackListCoverArtSettings';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
interface Props {
songs: SubsonicSong[];
@@ -47,7 +48,7 @@ export default function PagedSongList({ songs, hasMore, loadingMore, onLoadMore,
<>
<SongListHeader showBpm={showBpm} />
{songs.map(song => (
<SongRow key={song.id} song={song} showBpm={showBpm} />
<SongRow key={ownedEntityKey(song)} song={song} showBpm={showBpm} />
))}
{hasMore && (
<InpageScrollSentinel
+11 -2
View File
@@ -14,6 +14,7 @@ import { resolveTrackArtistRefs } from '@/features/playback/utils/playback/track
import { tooltipAttrs } from '@/ui/tooltipAttrs';
import { OptionalBrowseTrackRowCoverThumb } from '@/cover/TrackRowCoverThumb';
import { useTrackListCoverArtEnabled } from '@/cover/useTrackListCoverArtSettings';
import { resolveIndexKey } from '@/lib/server/serverIndexKey';
interface Props {
song: SubsonicSong;
@@ -26,7 +27,12 @@ function SongRow({ song, showBpm }: Props) {
const { t } = useTranslation();
const enqueue = usePlayerStore(s => s.enqueue);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const isCurrent = usePlayerStore(s => s.currentTrack?.id === song.id);
const isCurrent = usePlayerStore(s => {
const current = s.currentTrack;
if (!current || current.id !== song.id) return false;
if (!current.serverId || !song.serverId) return true;
return resolveIndexKey(current.serverId) === resolveIndexKey(song.serverId);
});
const psyDrag = useDragDrop();
const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior();
const showCovers = useTrackListCoverArtEnabled('pages');
@@ -125,7 +131,10 @@ function SongRow({ song, showBpm }: Props) {
<span
className="track-artist-link"
style={{ cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); navigateToAlbum(song.albumId!); }}
onClick={(e) => {
e.stopPropagation();
navigateToAlbum(song.albumId!, { serverId: song.serverId });
}}
title={song.album}
>{song.album}</span>
) : <span title={song.album}>{song.album}</span>}
@@ -161,7 +161,9 @@ export default function TracksPageChrome({
<span
className={hero.albumId ? 'track-artist-link' : ''}
style={{ cursor: hero.albumId ? 'pointer' : 'default' }}
onClick={() => hero.albumId && navigateToAlbum(hero.albumId)}
onClick={() => hero.albumId && navigateToAlbum(hero.albumId, {
serverId: hero.serverId ?? activeServerId,
})}
>{hero.album}</span>
</>
)}
@@ -7,12 +7,22 @@ import { CoverArtImage } from '@/cover/CoverArtImage';
import { COVER_DENSE_SEARCH_CSS_PX } from '@/cover/layoutSizes';
import { albumCoverRef } from '@/cover/ref';
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '@/ui/CachedImage';
import { coverServerScopeForOwnerServerId } from '@/cover/serverScope';
export function LiveSearchAlbumThumb({ albumId, coverArt }: { albumId: string; coverArt: string }) {
export function LiveSearchAlbumThumb({
albumId,
coverArt,
serverId,
}: {
albumId: string;
coverArt: string;
serverId?: string;
}) {
return (
<AlbumCoverArtImage
albumId={albumId}
coverArt={coverArt}
serverScope={serverId ? coverServerScopeForOwnerServerId(serverId) : undefined}
libraryResolve={false}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
@@ -23,7 +33,7 @@ export function LiveSearchAlbumThumb({ albumId, coverArt }: { albumId: string; c
);
}
export function LiveSearchSongThumb({ song }: { song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'> }) {
export function LiveSearchSongThumb({ song }: { song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber' | 'serverId'> }) {
// Search results carry the per-track `mf-…` coverArt id, which the cover
// pipeline fails to resolve and the thumbnail goes blank. The album-scoped
// `al-<albumId>_0` id is what actually loads (verified in the RC1 blank-thumb
@@ -32,8 +42,14 @@ export function LiveSearchSongThumb({ song }: { song: Pick<SubsonicSong, 'id' |
// there is no album to key on.
const albumId = song.albumId?.trim();
const coverRef = React.useMemo(
() => (albumId ? albumCoverRef(albumId, `al-${albumId}_0`) : undefined),
[albumId],
() => (albumId
? albumCoverRef(
albumId,
`al-${albumId}_0`,
song.serverId ? coverServerScopeForOwnerServerId(song.serverId) : undefined,
)
: undefined),
[albumId, song.serverId],
);
if (!coverRef) return <div className="search-result-icon"><Music size={14} /></div>;
return (
@@ -48,16 +64,17 @@ export function LiveSearchSongThumb({ song }: { song: Pick<SubsonicSong, 'id' |
);
}
export function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
export function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt' | 'serverId'> }) {
const [failed, setFailed] = useState(false);
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt, artist.serverId]);
if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
return (
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArt}
serverScope={artist.serverId ? coverServerScopeForOwnerServerId(artist.serverId) : undefined}
libraryResolve={false}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
@@ -2,10 +2,23 @@ import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetAuthStore } from '@/test/helpers/storeReset';
import { useAuthStore } from '@/store/authStore';
import {
resetServerReachabilitySnapshot,
setServerReachability,
} from '@/lib/network/serverReachability';
const { readyLibraryServerKeysMock, localSearchMock } = vi.hoisted(() => ({
const {
readyLibraryServerKeysMock,
localSearchMock,
loadMoreLocalSongsMock,
networkAdvancedTextSearchMock,
networkBrowseFullSearchMock,
} = vi.hoisted(() => ({
readyLibraryServerKeysMock: vi.fn(),
localSearchMock: vi.fn(),
loadMoreLocalSongsMock: vi.fn(),
networkAdvancedTextSearchMock: vi.fn(),
networkBrowseFullSearchMock: vi.fn(),
}));
vi.mock('@/lib/library/libraryReady', () => ({
@@ -13,8 +26,8 @@ vi.mock('@/lib/library/libraryReady', () => ({
}));
vi.mock('@/lib/library/advancedSearchLocal', () => ({
loadMoreLocalSongs: vi.fn(),
runNetworkAdvancedTextSearch: vi.fn(),
loadMoreLocalSongs: loadMoreLocalSongsMock,
runNetworkAdvancedTextSearch: networkAdvancedTextSearchMock,
runNetworkAdvancedYearAlbums: vi.fn(),
tryRunLocalAdvancedSearch: localSearchMock,
}));
@@ -24,13 +37,15 @@ vi.mock('@/lib/library/browseTextSearch', () => ({
loadMoreLocalBrowseSongs: vi.fn(),
raceBrowseWithLocalFallback: vi.fn(),
runLocalBrowseFullSearch: localSearchMock,
runNetworkBrowseFullSearch: vi.fn(),
runNetworkBrowseFullSearch: networkBrowseFullSearchMock,
}));
import { useAdvancedSearchRunner } from './useAdvancedSearchRunner';
function setters() {
return {
librarySyncRevision: 0,
onResultsCommitted: vi.fn(),
setLoading: vi.fn(),
setHasSearched: vi.fn(),
setGenreNote: vi.fn(),
@@ -46,9 +61,13 @@ function setters() {
}
beforeEach(() => {
resetServerReachabilitySnapshot();
resetAuthStore();
readyLibraryServerKeysMock.mockReset().mockResolvedValue(null);
localSearchMock.mockReset();
loadMoreLocalSongsMock.mockReset();
networkAdvancedTextSearchMock.mockReset().mockResolvedValue(null);
networkBrowseFullSearchMock.mockReset().mockResolvedValue(null);
useAuthStore.setState({
activeServerId: 'a',
servers: [
@@ -65,6 +84,29 @@ beforeEach(() => {
});
describe('useAdvancedSearchRunner multi-server readiness', () => {
it('does not fall back to the active server when every selected owner is unavailable', async () => {
setServerReachability('a', 'unavailable');
setServerReachability('b', 'unavailable');
const state = setters();
const { result } = renderHook(() => useAdvancedSearchRunner({
serverId: 'a',
indexEnabled: true,
loadingMoreSongs: false,
songsHasMore: false,
activeSearch: null,
basicSearchMode: true,
localMode: false,
songsServerOffset: 0,
...state,
}));
await act(async () => { await result.current.runBasicSearch('metallica'); });
expect(localSearchMock).not.toHaveBeenCalled();
expect(networkBrowseFullSearchMock).not.toHaveBeenCalled();
expect(state.setResults).toHaveBeenLastCalledWith({ artists: [], albums: [], songs: [] });
});
it('retains existing basic-search state during a partial sync refresh', async () => {
const state = setters();
const { result } = renderHook(() => useAdvancedSearchRunner({
@@ -84,7 +126,7 @@ describe('useAdvancedSearchRunner multi-server readiness', () => {
expect(readyLibraryServerKeysMock).toHaveBeenCalledWith(['a', 'b']);
expect(localSearchMock).not.toHaveBeenCalled();
expect(state.setResults).not.toHaveBeenCalled();
expect(state.setLoading).not.toHaveBeenCalled();
expect(state.setLoading).toHaveBeenCalledWith(false);
expect(state.setLocalMode).not.toHaveBeenCalled();
expect(state.setQuery).toHaveBeenCalledWith('metallica');
expect(state.setActiveSearch).toHaveBeenCalledWith(expect.objectContaining({ query: 'metallica' }));
@@ -112,7 +154,7 @@ describe('useAdvancedSearchRunner multi-server readiness', () => {
expect(localSearchMock).not.toHaveBeenCalled();
expect(state.setResults).not.toHaveBeenCalled();
expect(state.setLoading).not.toHaveBeenCalled();
expect(state.setLoading).toHaveBeenCalledWith(false);
expect(state.setSongsHasMore).not.toHaveBeenCalled();
expect(state.setActiveSearch).toHaveBeenCalledWith(pending);
});
@@ -141,7 +183,7 @@ describe('useAdvancedSearchRunner multi-server readiness', () => {
expect(readyLibraryServerKeysMock).toHaveBeenCalledWith(['a', 'b']);
expect(localSearchMock).not.toHaveBeenCalled();
expect(state.setResults).not.toHaveBeenCalled();
expect(state.setLoading).not.toHaveBeenCalled();
expect(state.setLoading).toHaveBeenCalledWith(false);
});
it('retains advanced-search state when local readiness changes before the read', async () => {
@@ -169,7 +211,7 @@ describe('useAdvancedSearchRunner multi-server readiness', () => {
expect(localSearchMock).toHaveBeenCalledOnce();
expect(state.setResults).not.toHaveBeenCalled();
expect(state.setLoading).not.toHaveBeenCalled();
expect(state.setLoading).toHaveBeenCalledWith(false);
expect(state.setLocalMode).not.toHaveBeenCalled();
});
@@ -193,7 +235,193 @@ describe('useAdvancedSearchRunner multi-server readiness', () => {
expect(localSearchMock).toHaveBeenCalledOnce();
expect(state.setResults).not.toHaveBeenCalled();
expect(state.setLoading).not.toHaveBeenCalled();
expect(state.setLoading).toHaveBeenCalledWith(false);
expect(state.setLocalMode).not.toHaveBeenCalled();
});
it('uses the selected browse anchor when the active server is not selected', async () => {
useAuthStore.setState({ libraryBrowseServerIds: ['b'] });
localSearchMock.mockResolvedValue({
artists: [], albums: [], songs: [], songsConsumed: 0, songsTotal: 0,
});
const state = setters();
const { result } = renderHook(() => useAdvancedSearchRunner({
serverId: 'a',
indexEnabled: true,
loadingMoreSongs: false,
songsHasMore: false,
activeSearch: null,
basicSearchMode: false,
localMode: false,
songsServerOffset: 0,
...state,
}));
const search = {
query: 'metallica', genre: '', yearFrom: '', yearTo: '', bpmFrom: '', bpmTo: '',
moodGroup: '', losslessOnly: true, resultType: 'all' as const,
};
await act(async () => { await result.current.runSearch(search); });
expect(localSearchMock).toHaveBeenCalledWith(
'b',
search,
100,
false,
expect.objectContaining({ anchorServerId: 'b' }),
);
});
it('clears results from the previous scope when a newly selected owner is not ready', async () => {
useAuthStore.setState({ libraryBrowseServerIds: ['a'] });
readyLibraryServerKeysMock.mockResolvedValue(['a.test']);
localSearchMock.mockResolvedValue({
artists: [],
albums: [],
songs: [{ id: 'a-song' }],
songsConsumed: 1,
songsTotal: 1,
});
const state = setters();
const view = renderHook(() => useAdvancedSearchRunner({
serverId: 'a',
indexEnabled: true,
loadingMoreSongs: false,
songsHasMore: false,
activeSearch: null,
basicSearchMode: false,
localMode: false,
songsServerOffset: 0,
...state,
}));
const search = {
query: 'metallica', genre: '', yearFrom: '', yearTo: '', bpmFrom: '', bpmTo: '',
moodGroup: '', losslessOnly: false, resultType: 'all' as const,
};
await act(async () => { await view.result.current.runSearch(search); });
expect(state.setResults).toHaveBeenLastCalledWith(expect.objectContaining({
songs: [{ id: 'a-song' }],
}));
useAuthStore.setState({ libraryBrowseServerIds: ['a', 'b'] });
readyLibraryServerKeysMock.mockResolvedValue(null);
view.rerender();
await act(async () => { await view.result.current.runSearch(search); });
expect(state.setResults).toHaveBeenLastCalledWith({ artists: [], albums: [], songs: [] });
});
it('keeps the committed result provenance while the same scope refresh is not ready', async () => {
readyLibraryServerKeysMock.mockResolvedValue(['a.test', 'b.test']);
localSearchMock.mockResolvedValue({
artists: [], albums: [], songs: [{ id: 'current' }], songsConsumed: 1, songsTotal: 1,
});
const state = setters();
const view = renderHook(
({ librarySyncRevision }) => useAdvancedSearchRunner({
serverId: 'a',
indexEnabled: true,
loadingMoreSongs: false,
songsHasMore: false,
activeSearch: null,
basicSearchMode: false,
localMode: false,
songsServerOffset: 0,
...state,
librarySyncRevision,
}),
{ initialProps: { librarySyncRevision: 0 } },
);
const search = {
query: 'metallica', genre: '', yearFrom: '', yearTo: '', bpmFrom: '', bpmTo: '',
moodGroup: '', losslessOnly: false, resultType: 'all' as const,
};
await act(async () => { await view.result.current.runSearch(search); });
expect(state.onResultsCommitted).toHaveBeenLastCalledWith(
expect.any(String),
0,
);
readyLibraryServerKeysMock.mockResolvedValue(null);
view.rerender({ librarySyncRevision: 1 });
await act(async () => { await view.result.current.runSearch(search); });
expect(state.onResultsCommitted).toHaveBeenCalledTimes(1);
expect(state.setResults).toHaveBeenCalledTimes(1);
});
it('uses the raw network page size for filtered pagination offset', async () => {
useAuthStore.setState({ libraryBrowseServerIds: ['a'] });
localSearchMock.mockResolvedValue(null);
networkAdvancedTextSearchMock.mockResolvedValue({
artists: [],
albums: [],
songs: [{ id: 'matching-song' }],
songsConsumed: 100,
songsTotal: 1,
});
const state = setters();
const { result } = renderHook(() => useAdvancedSearchRunner({
serverId: 'a',
indexEnabled: true,
loadingMoreSongs: false,
songsHasMore: false,
activeSearch: null,
basicSearchMode: false,
localMode: false,
songsServerOffset: 0,
...state,
}));
await act(async () => {
await result.current.runSearch({
query: 'metallica', genre: 'metal', yearFrom: '', yearTo: '', bpmFrom: '', bpmTo: '',
moodGroup: '', losslessOnly: false, resultType: 'songs',
});
});
expect(state.setSongsServerOffset).toHaveBeenLastCalledWith(100);
expect(state.setSongsHasMore).toHaveBeenLastCalledWith(true);
});
it('does not append a stale local page after a newer search starts', async () => {
let resolvePage!: (songs: Array<{ id: string }>) => void;
loadMoreLocalSongsMock.mockReturnValueOnce(new Promise(resolve => {
resolvePage = resolve;
}));
readyLibraryServerKeysMock.mockResolvedValue(['a.test', 'b.test']);
localSearchMock.mockResolvedValue({
artists: [], albums: [], songs: [], songsConsumed: 0, songsTotal: 0,
});
const state = setters();
const activeSearch = {
query: 'old', genre: '', yearFrom: '', yearTo: '', bpmFrom: '', bpmTo: '',
moodGroup: '', losslessOnly: false, resultType: 'all' as const,
};
const { result } = renderHook(() => useAdvancedSearchRunner({
serverId: 'a',
indexEnabled: true,
loadingMoreSongs: false,
songsHasMore: true,
activeSearch,
basicSearchMode: false,
localMode: true,
songsServerOffset: 1,
...state,
}));
const loadMorePromise = result.current.loadMoreSongs();
await act(async () => {
await result.current.runSearch({ ...activeSearch, query: 'new' });
});
const callsAfterSearch = state.setResults.mock.calls.length;
await act(async () => {
resolvePage([{ id: 'stale' }]);
await loadMorePromise;
});
expect(state.setResults).toHaveBeenCalledTimes(callsAfterSearch);
});
});
@@ -1,7 +1,10 @@
import { useRef, useCallback, type Dispatch, type SetStateAction } from 'react';
import { getAlbumsByGenre } from '@/lib/api/subsonicGenres';
import { search, searchSongsPaged } from '@/lib/api/subsonicSearch';
import { getRandomSongs } from '@/lib/api/subsonicLibrary';
import { getAlbumsByGenreForServer } from '@/lib/api/subsonicGenres';
import {
searchForServer,
searchSongsPagedForServer,
} from '@/lib/api/subsonicSearch';
import { getRandomSongsForServer } from '@/lib/api/subsonicLibrary';
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
import {
loadMoreLocalSongs,
@@ -21,8 +24,12 @@ import {
runNetworkBrowseFullSearch,
} from '@/lib/library/browseTextSearch';
import type { SearchOpts, Results } from '@/features/search/searchBrowseTypes';
import { getLibraryBrowseScope } from '@/lib/library/libraryBrowseScope';
import {
getLibraryBrowseScope,
type LibraryBrowseScope,
} from '@/lib/library/libraryBrowseScope';
import { readyLibraryServerKeys } from '@/lib/library/libraryReady';
import { dedupeById } from '@/lib/util/dedupeById';
const MOOD_UI_ENABLED = OXIMEDIA_MOOD_SEARCH_ENABLED;
@@ -54,6 +61,7 @@ function applySongFilters(
interface UseAdvancedSearchRunnerParams {
serverId: string | null;
indexEnabled: boolean;
librarySyncRevision: number;
loadingMoreSongs: boolean;
songsHasMore: boolean;
activeSearch: SearchOpts | null;
@@ -71,6 +79,7 @@ interface UseAdvancedSearchRunnerParams {
setLocalMode: Dispatch<SetStateAction<boolean>>;
setResults: Dispatch<SetStateAction<Results | null>>;
setLoadingMoreSongs: Dispatch<SetStateAction<boolean>>;
onResultsCommitted: (scopeFingerprint: string, syncRevision: number) => void;
}
/**
@@ -79,8 +88,8 @@ interface UseAdvancedSearchRunnerParams {
* run-id staleness guard; the shell owns the result/filter state passed in via setters.
*/
export function useAdvancedSearchRunner({
serverId,
indexEnabled,
librarySyncRevision,
loadingMoreSongs,
songsHasMore,
activeSearch,
@@ -98,14 +107,28 @@ export function useAdvancedSearchRunner({
setLocalMode,
setResults,
setLoadingMoreSongs,
onResultsCommitted,
}: UseAdvancedSearchRunnerParams) {
const searchRunRef = useRef(0);
const resultScopeRef = useRef<LibraryBrowseScope | null>(getLibraryBrowseScope());
const runBasicSearch = async (rawQuery: string) => {
const q = rawQuery.trim();
const runId = ++searchRunRef.current;
const isStale = () => runId !== searchRunRef.current;
const browseScope = getLibraryBrowseScope();
const searchServerId = browseScope.anchorServerId;
const commitResults = (next: Results | null) => {
resultScopeRef.current = browseScope;
onResultsCommitted(browseScope.fingerprint, librarySyncRevision);
setResults(next);
};
const clearResultsForScopeChange = () => {
commitResults(q ? { artists: [], albums: [], songs: [] } : null);
setSongsServerOffset(0);
setSongsHasMore(false);
setLocalMode(false);
};
const pendingSearch: SearchOpts = {
query: q,
genre: '',
@@ -124,16 +147,36 @@ export function useAdvancedSearchRunner({
setBasicSearchMode(true);
setQuery(q);
setActiveSearch(pendingSearch);
setLoading(false);
setLoadingMoreSongs(false);
if (!searchServerId) {
commitResults(q ? { artists: [], albums: [], songs: [] } : null);
setSongsServerOffset(0);
setSongsHasMore(false);
setLocalMode(false);
return;
}
if (
q
&& serverId
&& searchServerId
&& indexEnabled
&& browseScope.multiServer
) {
if ((await readyLibraryServerKeys(browseScope.serverIds)) == null) return;
if ((await readyLibraryServerKeys(browseScope.serverIds)) == null) {
if (resultScopeRef.current?.fingerprint !== browseScope.fingerprint) {
clearResultsForScopeChange();
}
return;
}
try {
multiServerResult = await runLocalBrowseFullSearch(serverId, q, BASIC_SONGS_INITIAL);
multiServerResult = await runLocalBrowseFullSearch(
searchServerId,
q,
BASIC_SONGS_INITIAL,
browseScope,
);
} catch {
return;
}
@@ -147,17 +190,17 @@ export function useAdvancedSearchRunner({
setLocalMode(false);
if (!q) {
setResults(null);
commitResults(null);
setLoading(false);
return;
}
try {
if (serverId && indexEnabled) {
if (searchServerId && indexEnabled) {
if (browseScope.multiServer) {
const result = multiServerResult;
if (!result) return;
setResults(result);
commitResults(result);
setSongsServerOffset(result.songs.length);
setSongsHasMore(result.songs.length >= BASIC_SONGS_INITIAL);
setLocalMode(true);
@@ -165,8 +208,8 @@ export function useAdvancedSearchRunner({
}
const outcome = await raceBrowseWithLocalFallback(
isStale,
() => runLocalBrowseFullSearch(serverId, q, BASIC_SONGS_INITIAL),
() => runNetworkBrowseFullSearch(q, BASIC_SONGS_INITIAL),
() => runLocalBrowseFullSearch(searchServerId, q, BASIC_SONGS_INITIAL, browseScope),
() => runNetworkBrowseFullSearch(q, BASIC_SONGS_INITIAL, searchServerId),
{
surface: 'search_results',
query: q,
@@ -176,7 +219,7 @@ export function useAdvancedSearchRunner({
);
if (isStale()) return;
if (outcome) {
setResults(outcome.result);
commitResults(outcome.result);
setSongsServerOffset(outcome.result.songs.length);
setSongsHasMore(outcome.result.songs.length >= BASIC_SONGS_INITIAL);
setLocalMode(outcome.source === 'local');
@@ -184,17 +227,17 @@ export function useAdvancedSearchRunner({
}
}
const network = await runNetworkBrowseFullSearch(q, BASIC_SONGS_INITIAL);
const network = await runNetworkBrowseFullSearch(q, BASIC_SONGS_INITIAL, searchServerId);
if (isStale()) return;
if (network) {
setResults(network);
commitResults(network);
setSongsServerOffset(network.songs.length);
setSongsHasMore(network.songs.length >= BASIC_SONGS_INITIAL);
} else {
setResults({ artists: [], albums: [], songs: [] });
commitResults({ artists: [], albums: [], songs: [] });
}
} catch {
if (!isStale()) setResults(null);
if (!isStale()) commitResults(null);
} finally {
if (!isStale()) setLoading(false);
}
@@ -205,21 +248,54 @@ export function useAdvancedSearchRunner({
const isStale = () => runId !== searchRunRef.current;
const q = opts.query.trim();
const browseScope = getLibraryBrowseScope();
const searchServerId = browseScope.anchorServerId;
const commitResults = (next: Results) => {
resultScopeRef.current = browseScope;
onResultsCommitted(browseScope.fingerprint, librarySyncRevision);
setResults(next);
};
const clearResultsForScopeChange = () => {
commitResults({ artists: [], albums: [], songs: [] });
setSongsServerOffset(0);
setSongsHasMore(false);
setLocalMode(false);
};
let multiServerPage: Awaited<ReturnType<typeof tryRunLocalAdvancedSearch>> = null;
setHasSearched(true);
setGenreNote(false);
setBasicSearchMode(false);
setActiveSearch(opts);
setLoading(false);
setLoadingMoreSongs(false);
if (!searchServerId) {
commitResults({ artists: [], albums: [], songs: [] });
setSongsServerOffset(0);
setSongsHasMore(false);
setLocalMode(false);
return;
}
if (
serverId
searchServerId
&& indexEnabled
&& browseScope.multiServer
) {
if ((await readyLibraryServerKeys(browseScope.serverIds)) == null) return;
if ((await readyLibraryServerKeys(browseScope.serverIds)) == null) {
if (resultScopeRef.current?.fingerprint !== browseScope.fingerprint) {
clearResultsForScopeChange();
}
return;
}
try {
multiServerPage = await tryRunLocalAdvancedSearch(serverId, opts, SONGS_INITIAL);
multiServerPage = await tryRunLocalAdvancedSearch(
searchServerId,
opts,
SONGS_INITIAL,
false,
browseScope,
);
} catch {
return;
}
@@ -238,41 +314,47 @@ export function useAdvancedSearchRunner({
// Track-only filters (BPM dual-storage, mood) need the local index for full coverage.
// Lossless skips the race — network search3 cannot filter albums by format reliably.
if (serverId && indexEnabled && browseScope.multiServer) {
if (searchServerId && indexEnabled && browseScope.multiServer) {
const page = multiServerPage;
if (!page) return;
setResults({ artists: page.artists, albums: page.albums, songs: page.songs });
setSongsServerOffset(page.songs.length);
setSongsHasMore(page.songs.length >= SONGS_INITIAL);
commitResults({ artists: page.artists, albums: page.albums, songs: page.songs });
setSongsServerOffset(page.songsConsumed);
setSongsHasMore(page.songsConsumed >= SONGS_INITIAL);
setLocalMode(true);
setLoading(false);
return;
}
if (q && serverId && indexEnabled && !trackOnlyFilterActive && !losslessFilterActive) {
if (q && searchServerId && indexEnabled && !trackOnlyFilterActive && !losslessFilterActive) {
try {
const winner = await raceSearchSources(
[
{
source: 'local',
run: () => tryRunLocalAdvancedSearch(serverId, opts, SONGS_INITIAL, true),
run: () => tryRunLocalAdvancedSearch(
searchServerId,
opts,
SONGS_INITIAL,
true,
browseScope,
),
},
{
source: 'network',
run: () => runNetworkAdvancedTextSearch(opts, SONGS_INITIAL),
run: () => runNetworkAdvancedTextSearch(opts, SONGS_INITIAL, searchServerId),
},
],
isStale,
);
if (isStale()) return;
if (winner) {
setResults({
commitResults({
artists: winner.result.artists,
albums: winner.result.albums,
songs: winner.result.songs,
});
setSongsServerOffset(winner.result.songs.length);
setSongsHasMore(winner.result.songs.length >= SONGS_INITIAL);
setSongsServerOffset(winner.result.songsConsumed);
setSongsHasMore(winner.result.songsConsumed >= SONGS_INITIAL);
setLocalMode(winner.source === 'local');
logLibrarySearch({
at: new Date().toISOString(),
@@ -296,23 +378,29 @@ export function useAdvancedSearchRunner({
if (isStale()) return;
}
setLocalMode(false);
} else if (serverId && indexEnabled) {
const localPage = await tryRunLocalAdvancedSearch(serverId, opts, SONGS_INITIAL);
} else if (searchServerId && indexEnabled) {
const localPage = await tryRunLocalAdvancedSearch(
searchServerId,
opts,
SONGS_INITIAL,
false,
browseScope,
);
if (isStale()) return;
if (localPage) {
setResults({
commitResults({
artists: localPage.artists,
albums: localPage.albums,
songs: localPage.songs,
});
setSongsServerOffset(localPage.songs.length);
setSongsHasMore(localPage.songs.length >= SONGS_INITIAL);
setSongsServerOffset(localPage.songsConsumed);
setSongsHasMore(localPage.songsConsumed >= SONGS_INITIAL);
setLocalMode(true);
setLoading(false);
return;
}
if (trackOnlyFilterActive) {
setResults({ artists: [], albums: [], songs: [] });
commitResults({ artists: [], albums: [], songs: [] });
setLoading(false);
return;
}
@@ -322,7 +410,7 @@ export function useAdvancedSearchRunner({
}
if ((trackOnlyFilterActive || losslessFilterActive) && !indexEnabled) {
setResults({ artists: [], albums: [], songs: [] });
commitResults({ artists: [], albums: [], songs: [] });
setLoading(false);
return;
}
@@ -339,7 +427,9 @@ export function useAdvancedSearchRunner({
try {
if (q.trim()) {
const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: SONGS_INITIAL });
const searchOptions = { artistCount: 30, albumCount: 50, songCount: SONGS_INITIAL };
const r = await searchForServer(searchServerId, q.trim(), searchOptions);
if (isStale()) return;
artists = r.artists;
albums = r.albums;
songs = applySongFilters(r.songs, g, from, to, bpmLo, bpmHi, lossless);
@@ -366,9 +456,14 @@ export function useAdvancedSearchRunner({
setSongsHasMore(r.songs.length === SONGS_INITIAL);
} else if (g) {
const [albumRes, songRes] = await Promise.all([
rt === 'songs' || rt === 'artists' ? Promise.resolve([]) : getAlbumsByGenre(g, 50),
rt === 'albums' || rt === 'artists' ? Promise.resolve([]) : getRandomSongs(100, g),
rt === 'songs' || rt === 'artists'
? Promise.resolve([])
: getAlbumsByGenreForServer(searchServerId, g, 50),
rt === 'albums' || rt === 'artists'
? Promise.resolve([])
: getRandomSongsForServer(searchServerId, 100, g),
]);
if (isStale()) return;
albums = albumRes as SubsonicAlbum[];
songs = songRes as SubsonicSong[];
songs = applySongFilters(songs, g, from, to, bpmLo, bpmHi, lossless);
@@ -377,7 +472,8 @@ export function useAdvancedSearchRunner({
if (songs.length > 0) setGenreNote(true);
} else if (from !== null || to !== null) {
if (rt !== 'artists' && rt !== 'songs') {
albums = await runNetworkAdvancedYearAlbums(opts, 100);
albums = await runNetworkAdvancedYearAlbums(opts, 100, searchServerId);
if (isStale()) return;
}
}
@@ -386,7 +482,8 @@ export function useAdvancedSearchRunner({
albums: rt === 'artists' || rt === 'songs' ? [] : albums,
songs: rt === 'artists' || rt === 'albums' ? [] : songs,
};
setResults(finalResults);
if (isStale()) return;
commitResults(finalResults);
if (q.trim()) {
logLibrarySearch({
at: new Date().toISOString(),
@@ -404,46 +501,74 @@ export function useAdvancedSearchRunner({
});
}
} catch {
setResults({ artists: [], albums: [], songs: [] });
if (isStale()) return;
commitResults({ artists: [], albums: [], songs: [] });
}
setLoading(false);
if (!isStale()) setLoading(false);
};
const loadMoreSongs = useCallback(async () => {
if (loadingMoreSongs || !songsHasMore || !activeSearch) return;
const runId = searchRunRef.current;
const isStale = () => runId !== searchRunRef.current;
const browseScope = resultScopeRef.current ?? getLibraryBrowseScope();
const searchServerId = browseScope.anchorServerId;
if (!searchServerId) {
setSongsHasMore(false);
return;
}
if (basicSearchMode) {
const q = activeSearch.query.trim();
if (!q) return;
setLoadingMoreSongs(true);
try {
const page = localMode && serverId
? await loadMoreLocalBrowseSongs(serverId, q, songsServerOffset, BASIC_SONGS_PAGE_SIZE)
: await searchSongsPaged(q, BASIC_SONGS_PAGE_SIZE, songsServerOffset);
setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...page] } : prev);
const page = localMode
? await loadMoreLocalBrowseSongs(
searchServerId,
q,
songsServerOffset,
BASIC_SONGS_PAGE_SIZE,
browseScope,
)
: await searchSongsPagedForServer(
searchServerId,
q,
BASIC_SONGS_PAGE_SIZE,
songsServerOffset,
);
if (isStale()) return;
setResults(prev => prev ? { ...prev, songs: dedupeById([...prev.songs, ...page]) } : prev);
setSongsServerOffset(o => o + page.length);
if (page.length < BASIC_SONGS_PAGE_SIZE) setSongsHasMore(false);
} catch {
setSongsHasMore(false);
if (!isStale()) setSongsHasMore(false);
} finally {
setLoadingMoreSongs(false);
if (!isStale()) setLoadingMoreSongs(false);
}
return;
}
// Local mode pages every result type (genre/year too), not just free-text.
if (localMode) {
if (!serverId) return;
if (!searchServerId) return;
setLoadingMoreSongs(true);
try {
const more = await loadMoreLocalSongs(serverId, activeSearch, songsServerOffset, SONGS_PAGE_SIZE);
setResults(prev => (prev ? { ...prev, songs: [...prev.songs, ...more] } : prev));
const more = await loadMoreLocalSongs(
searchServerId,
activeSearch,
songsServerOffset,
SONGS_PAGE_SIZE,
browseScope,
);
if (isStale()) return;
setResults(prev => (prev ? { ...prev, songs: dedupeById([...prev.songs, ...more]) } : prev));
setSongsServerOffset(o => o + more.length);
if (more.length < SONGS_PAGE_SIZE) setSongsHasMore(false);
} catch {
setSongsHasMore(false);
if (!isStale()) setSongsHasMore(false);
} finally {
setLoadingMoreSongs(false);
if (!isStale()) setLoadingMoreSongs(false);
}
return;
}
@@ -457,7 +582,13 @@ export function useAdvancedSearchRunner({
const to = activeSearch.yearTo ? parseInt(activeSearch.yearTo) : null;
const bpmLo = activeSearch.bpmFrom ? parseInt(activeSearch.bpmFrom) : null;
const bpmHi = activeSearch.bpmTo ? parseInt(activeSearch.bpmTo) : null;
const page = await searchSongsPaged(q, SONGS_PAGE_SIZE, songsServerOffset);
const page = await searchSongsPagedForServer(
searchServerId,
q,
SONGS_PAGE_SIZE,
songsServerOffset,
);
if (isStale()) return;
const filtered = applySongFilters(
page,
g,
@@ -467,17 +598,20 @@ export function useAdvancedSearchRunner({
bpmHi,
activeSearch.losslessOnly,
);
setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...filtered] } : prev);
setResults(prev => prev ? {
...prev,
songs: dedupeById([...prev.songs, ...filtered]),
} : prev);
setSongsServerOffset(o => o + page.length);
// No more pages when the server returned a non-full page (regardless of how many survived filtering).
if (page.length < SONGS_PAGE_SIZE) setSongsHasMore(false);
} catch {
setSongsHasMore(false);
if (!isStale()) setSongsHasMore(false);
} finally {
setLoadingMoreSongs(false);
if (!isStale()) setLoadingMoreSongs(false);
}
}, [
loadingMoreSongs, songsHasMore, activeSearch, songsServerOffset, localMode, serverId, basicSearchMode,
loadingMoreSongs, songsHasMore, activeSearch, songsServerOffset, localMode, basicSearchMode,
setResults, setSongsServerOffset, setSongsHasMore, setLoadingMoreSongs,
]);
@@ -6,21 +6,31 @@ import type { SyncStateDto } from '@/lib/api/library/dto';
import type { SearchResults } from '@/lib/api/subsonicTypes';
import { resetAuthStore } from '@/test/helpers/storeReset';
const { libraryGetStatusMock, runLocalLiveSearchMock, showToastMock, revisionState } = vi.hoisted(() => ({
const {
libraryGetStatusMock,
runLocalLiveSearchMock,
raceLiveSearchMock,
showToastMock,
subscribeLibrarySyncProgressMock,
revisionState,
} = vi.hoisted(() => ({
libraryGetStatusMock: vi.fn(),
runLocalLiveSearchMock: vi.fn<(
serverId: string,
query: string,
context: unknown,
browseScope?: unknown,
) => Promise<SearchResults | null>>(),
raceLiveSearchMock: vi.fn(),
showToastMock: vi.fn(),
subscribeLibrarySyncProgressMock: vi.fn(async () => () => {}),
revisionState: { value: 0 },
}));
vi.mock('@/lib/api/library', () => ({
libraryGetStatus: (...args: unknown[]) => libraryGetStatusMock(...args),
subscribeLibrarySyncIdle: vi.fn(async () => () => {}),
subscribeLibrarySyncProgress: vi.fn(async () => () => {}),
subscribeLibrarySyncProgress: subscribeLibrarySyncProgressMock,
}));
vi.mock('@/lib/library/liveSearchLocal', () => ({
@@ -34,7 +44,7 @@ vi.mock('@/lib/library/liveSearchLocal', () => ({
}));
vi.mock('@/lib/library/searchRace', () => ({
raceLiveSearch: vi.fn(async () => null),
raceLiveSearch: raceLiveSearchMock,
}));
vi.mock('@/lib/library/liveSearchDebug', () => ({
@@ -93,11 +103,19 @@ function hookParams() {
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(res => { resolve = res; });
return { promise, resolve };
}
describe('useLiveSearchQuery indexIncomplete', () => {
beforeEach(() => {
libraryGetStatusMock.mockReset();
runLocalLiveSearchMock.mockReset().mockResolvedValue(null);
raceLiveSearchMock.mockReset().mockResolvedValue(null);
showToastMock.mockReset();
subscribeLibrarySyncProgressMock.mockReset().mockResolvedValue(() => {});
revisionState.value = 0;
resetAuthStore();
useAuthStore.setState({
@@ -226,22 +244,167 @@ describe('useLiveSearchQuery indexIncomplete', () => {
);
await act(async () => { await vi.advanceTimersByTimeAsync(500); });
expect(runLocalLiveSearchMock).toHaveBeenCalledWith('a', 'old', expect.anything());
expect(runLocalLiveSearchMock).toHaveBeenCalledWith(
'a',
'old',
expect.anything(),
expect.objectContaining({ anchorServerId: 'a' }),
);
bReady = false;
view.rerender({ query: 'new' });
await act(async () => { await vi.advanceTimersByTimeAsync(500); });
expect(runLocalLiveSearchMock).not.toHaveBeenCalledWith('a', 'new', expect.anything());
expect(runLocalLiveSearchMock).not.toHaveBeenCalledWith(
'a',
'new',
expect.anything(),
expect.anything(),
);
bReady = true;
revisionState.value += 1;
view.rerender({ query: 'new' });
await act(async () => { await vi.advanceTimersByTimeAsync(500); });
expect(runLocalLiveSearchMock).toHaveBeenCalledWith('a', 'new', expect.anything());
expect(runLocalLiveSearchMock).toHaveBeenCalledWith(
'a',
'new',
expect.anything(),
expect.objectContaining({ anchorServerId: 'a' }),
);
expect(params.setResults).toHaveBeenLastCalledWith(expect.objectContaining({
songs: [expect.objectContaining({ id: 'new' })],
}));
view.unmount();
vi.useRealTimers();
});
it('uses the selected browse anchor when the active server is not selected', async () => {
vi.useFakeTimers();
useAuthStore.setState({
activeServerId: 'a',
servers: [
{ id: 'a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
{ id: 'b', name: 'B', url: 'https://b.test', username: 'u', password: 'p' },
],
libraryBrowseServerIds: ['b'],
musicFoldersByServer: { b: [{ id: 'lib-b', name: 'B' }] },
libraryBrowseSelectionByServer: {},
});
libraryGetStatusMock.mockResolvedValue(readyStatus());
runLocalLiveSearchMock.mockResolvedValue({ artists: [], albums: [], songs: [] });
raceLiveSearchMock.mockImplementation(async (runLocal: () => Promise<SearchResults | null>) => {
const result = await runLocal();
return result ? { result, source: 'local', durationMs: 1 } : null;
});
const params = { ...hookParams(), query: 'metallica' };
const { unmount } = renderHook(() => useLiveSearchQuery(params));
await act(async () => { await vi.advanceTimersByTimeAsync(500); });
expect(runLocalLiveSearchMock).toHaveBeenCalledWith(
'b',
'metallica',
expect.anything(),
expect.objectContaining({ anchorServerId: 'b' }),
);
unmount();
vi.useRealTimers();
});
it('clears results from the previous scope when a newly selected owner is not ready', async () => {
vi.useFakeTimers();
useAuthStore.setState({
activeServerId: 'a',
servers: [
{ id: 'a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
{ id: 'b', name: 'B', url: 'https://b.test', username: 'u', password: 'p' },
],
libraryBrowseServerIds: ['a'],
musicFoldersByServer: {
a: [{ id: 'lib-a', name: 'A' }],
b: [{ id: 'lib-b', name: 'B' }],
},
libraryBrowseSelectionByServer: {},
libraryBrowseScopeVersion: 0,
});
libraryGetStatusMock.mockImplementation(async (serverId: string) => (
serverId === 'b.test' ? buildingStatus() : readyStatus()
));
const params = { ...hookParams(), query: 'metallica' };
const view = renderHook(() => useLiveSearchQuery(params));
useAuthStore.setState({
libraryBrowseServerIds: ['a', 'b'],
libraryBrowseScopeVersion: 1,
});
view.rerender();
await act(async () => { await vi.advanceTimersByTimeAsync(500); });
expect(params.setResults).toHaveBeenLastCalledWith({ artists: [], albums: [], songs: [] });
view.unmount();
vi.useRealTimers();
});
it('ignores a stale readiness response from the previous scope', async () => {
const aStatus = deferred<SyncStateDto>();
useAuthStore.setState({
activeServerId: 'a',
servers: [
{ id: 'a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
{ id: 'b', name: 'B', url: 'https://b.test', username: 'u', password: 'p' },
],
libraryBrowseServerIds: ['a'],
musicFoldersByServer: {
a: [{ id: 'lib-a', name: 'A' }],
b: [{ id: 'lib-b', name: 'B' }],
},
libraryBrowseSelectionByServer: {},
libraryBrowseScopeVersion: 0,
});
libraryGetStatusMock.mockImplementation((serverId: string) => (
serverId === 'a.test' ? aStatus.promise : Promise.resolve(readyStatus())
));
const params = hookParams();
const view = renderHook(() => useLiveSearchQuery(params));
useAuthStore.setState({ libraryBrowseServerIds: ['b'], libraryBrowseScopeVersion: 1 });
view.rerender();
await waitFor(() => expect(libraryGetStatusMock).toHaveBeenCalledWith('b.test'));
await waitFor(() => expect(view.result.current.indexIncomplete).toBe(false));
await act(async () => { aStatus.resolve(buildingStatus()); });
expect(view.result.current.indexIncomplete).toBe(false);
});
it('unsubscribes a progress listener whose registration resolves after cleanup', async () => {
const firstRegistration = deferred<() => void>();
const staleUnlisten = vi.fn();
subscribeLibrarySyncProgressMock
.mockReturnValueOnce(firstRegistration.promise)
.mockResolvedValueOnce(() => {});
libraryGetStatusMock.mockResolvedValue(readyStatus());
useAuthStore.setState({
activeServerId: 'a',
servers: [
{ id: 'a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
{ id: 'b', name: 'B', url: 'https://b.test', username: 'u', password: 'p' },
],
libraryBrowseServerIds: ['a'],
musicFoldersByServer: {
a: [{ id: 'lib-a', name: 'A' }],
b: [{ id: 'lib-b', name: 'B' }],
},
libraryBrowseSelectionByServer: {},
libraryBrowseScopeVersion: 0,
});
const view = renderHook(() => useLiveSearchQuery(hookParams()));
await waitFor(() => expect(subscribeLibrarySyncProgressMock).toHaveBeenCalledTimes(1));
useAuthStore.setState({ libraryBrowseServerIds: ['b'], libraryBrowseScopeVersion: 1 });
view.rerender();
await waitFor(() => expect(subscribeLibrarySyncProgressMock).toHaveBeenCalledTimes(2));
await act(async () => { firstRegistration.resolve(staleUnlisten); });
expect(staleUnlisten).toHaveBeenCalledOnce();
});
});
+49 -18
View File
@@ -63,35 +63,44 @@ export function useLiveSearchQuery({
const serverId = useAuthStore(s => s.activeServerId);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const libraryBrowseScopeVersion = useAuthStore(s => s.libraryBrowseScopeVersion);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const readinessServerIds = useMemo(() => {
const browseScopeSnapshot = useMemo(() => {
// The scope version is the invalidation token for this imperative store snapshot.
void libraryBrowseScopeVersion;
const browseScope = getLibraryBrowseScope();
return browseScope.serverIds.length > 0
? browseScope.serverIds
: (serverId ? [serverId] : []);
void serverId;
return getLibraryBrowseScope();
}, [libraryBrowseScopeVersion, serverId]);
const searchServerId = browseScopeSnapshot.anchorServerId;
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(searchServerId));
const readinessServerIds = useMemo(() => (
browseScopeSnapshot.serverIds.length > 0
? browseScopeSnapshot.serverIds
: []
), [browseScopeSnapshot]);
const readinessScopeKey = readinessServerIds.join(',');
const librarySyncRevision = useLibraryScopeSyncRevision(readinessServerIds);
const localReadyRef = useRef(false);
const readinessGenerationRef = useRef(0);
const resultScopeFingerprintRef = useRef(browseScopeSnapshot.fingerprint);
const [indexIncomplete, setIndexIncomplete] = useState(false);
const refreshIndexStatus = useCallback(async () => {
if (!serverId || !indexEnabled) {
const generation = ++readinessGenerationRef.current;
if (!searchServerId || !indexEnabled) {
localReadyRef.current = false;
setIndexIncomplete(false);
return;
}
try {
const ready = (await readyLibraryServerKeys(readinessServerIds)) != null;
if (generation !== readinessGenerationRef.current) return;
localReadyRef.current = ready;
setIndexIncomplete(!ready);
} catch {
if (generation !== readinessGenerationRef.current) return;
localReadyRef.current = false;
setIndexIncomplete(false);
}
}, [serverId, indexEnabled, readinessScopeKey]); // eslint-disable-line react-hooks/exhaustive-deps
}, [searchServerId, indexEnabled, readinessScopeKey]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
@@ -100,7 +109,8 @@ export function useLiveSearchQuery({
}, [refreshIndexStatus, musicLibraryFilterVersion, librarySyncRevision]);
useEffect(() => {
if (!indexEnabled || !serverId) return;
if (!indexEnabled || !searchServerId) return;
let disposed = false;
let unlistenProgress: (() => void) | undefined;
const indexKeys = new Set(readinessServerIds.map(resolveIndexKey));
void subscribeLibrarySyncProgress(p => {
@@ -108,12 +118,17 @@ export function useLiveSearchQuery({
void refreshIndexStatus();
}
}).then(fn => {
if (disposed) {
fn();
return;
}
unlistenProgress = fn;
});
return () => {
disposed = true;
unlistenProgress?.();
};
}, [indexEnabled, serverId, refreshIndexStatus, readinessScopeKey]); // eslint-disable-line react-hooks/exhaustive-deps
}, [indexEnabled, searchServerId, refreshIndexStatus, readinessScopeKey]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (isLiveSearchDropdownBlocked(scope)) {
@@ -153,20 +168,29 @@ export function useLiveSearchQuery({
if (isStale()) return;
const browseScope = getLibraryBrowseScope();
const raceCtx = { epoch: gen, isStale, suppressLog: indexEnabled && !!serverId };
const queryServerId = browseScope.anchorServerId;
const scopeChanged = resultScopeFingerprintRef.current !== browseScope.fingerprint;
const raceCtx = { epoch: gen, isStale, suppressLog: indexEnabled && !!queryServerId };
const queryRejected = liveSearchQueryRejected(q);
let multiServerResult: SearchResults | null = null;
if (scopeChanged) {
resultScopeFingerprintRef.current = browseScope.fingerprint;
setResults(EMPTY_SEARCH_RESULTS);
setSearchSource(null);
setOpen(true);
}
if (
!queryRejected
&& indexEnabled
&& serverId
&& queryServerId
&& browseScope.multiServer
) {
if ((await readyLibraryServerKeys(readinessServerIds)) == null) return;
if (isStale()) return;
try {
multiServerResult = await runLocalLiveSearch(serverId, q, raceCtx);
multiServerResult = await runLocalLiveSearch(queryServerId, q, raceCtx, browseScope);
} catch (err) {
if (isStale()) return;
const name = err instanceof Error ? err.name : '';
@@ -193,7 +217,14 @@ export function useLiveSearchQuery({
return;
}
if (indexEnabled && serverId) {
if (!queryServerId) {
setResults(EMPTY_SEARCH_RESULTS);
setSearchSource(null);
setOpen(true);
return;
}
if (indexEnabled && queryServerId) {
if (browseScope.multiServer) {
if (multiServerResult) {
setResults(multiServerResult);
@@ -203,8 +234,8 @@ export function useLiveSearchQuery({
}
}
const winner = await raceLiveSearch(
() => runLocalLiveSearch(serverId, q, raceCtx),
() => runNetworkLiveSearch(q, abort.signal),
() => runLocalLiveSearch(queryServerId, q, raceCtx, browseScope),
() => runNetworkLiveSearch(q, abort.signal, queryServerId),
isStale,
meta => {
emitLiveSearchDebug('race_settled', {
@@ -273,8 +304,8 @@ export function useLiveSearchQuery({
return;
}
showToast(t('search.liveSearchFailed'), 3200, 'error');
} else if (serverId) {
const network = await runNetworkLiveSearch(q, abort.signal);
} else if (queryServerId) {
const network = await runNetworkLiveSearch(q, abort.signal, queryServerId);
if (isStale()) return;
if (network) {
setResults(network);
@@ -10,7 +10,9 @@ import { resetAuthStore } from '@/test/helpers/storeReset';
const { browseScopeState, readyLibraryServerKeysMock, revisionState } = vi.hoisted(() => ({
browseScopeState: {
anchorServerId: 'srv-1' as string | null,
serverIds: ['srv-1'] as string[],
pairs: [] as Array<{ serverId: string; libraryId: string }>,
multiServer: false,
fingerprint: 'srv-1',
},
@@ -75,7 +77,12 @@ function deferred<T>() {
}
function seedMultiServerScope() {
browseScopeState.anchorServerId = 'a';
browseScopeState.serverIds = ['a', 'b'];
browseScopeState.pairs = [
{ serverId: 'a', libraryId: 'lib-a' },
{ serverId: 'b', libraryId: 'lib-b' },
];
browseScopeState.multiServer = true;
browseScopeState.fingerprint = 'a,b';
useAuthStore.setState({
@@ -98,9 +105,12 @@ describe('useSongBrowseList restore hold', () => {
resetAuthStore();
useAuthStore.setState({ activeServerId: 'srv-1' });
useLibraryIndexStore.setState({ masterEnabled: true });
vi.mocked(runLocalSongScopeBrowse).mockReset().mockResolvedValue(null);
readyLibraryServerKeysMock.mockReset().mockResolvedValue(['srv-1']);
revisionState.value = 0;
browseScopeState.serverIds = ['srv-1'];
browseScopeState.anchorServerId = 'srv-1';
browseScopeState.pairs = [];
browseScopeState.multiServer = false;
browseScopeState.fingerprint = 'srv-1';
});
@@ -111,6 +121,8 @@ describe('useSongBrowseList restore hold', () => {
enabled: true,
searchQuery,
initialRestore: {
browseScopeFingerprint: 'srv-1',
librarySyncRevision: 0,
query: 'jazz',
songs: [stashedSong],
offset: 1,
@@ -136,6 +148,68 @@ describe('useSongBrowseList restore hold', () => {
expect(result.current.songs[0]?.id).toBe('fresh');
}, { timeout: 500 });
});
it('discards a restored cursor and songs when the browse scope changed', async () => {
const { result } = renderHook(() => useSongBrowseList({
enabled: true,
searchQuery: '',
initialRestore: {
browseScopeFingerprint: 'old-scope',
librarySyncRevision: 0,
query: '',
songs: [stashedSong],
offset: 50,
hasMore: true,
browseCursor: 'old-cursor',
localSearchMode: true,
browseUnsupported: false,
hasSearched: true,
},
}));
await waitFor(() => expect(runLocalSongScopeBrowse).toHaveBeenCalled());
expect(result.current.songs).not.toContainEqual(stashedSong);
expect(result.current.browseCursor).toBeNull();
});
it('discards a valid restore when its scope or sync revision changes after mount', async () => {
vi.mocked(runLocalSongScopeBrowse).mockResolvedValue({
songs: [{ id: 'fresh', title: 'Fresh' } as SubsonicSong],
hasMore: false,
nextCursor: null,
});
const view = renderHook(() => useSongBrowseList({
enabled: true,
searchQuery: '',
initialRestore: {
browseScopeFingerprint: 'srv-1',
librarySyncRevision: 0,
query: '',
songs: [stashedSong],
offset: 50,
hasMore: true,
browseCursor: 'old-cursor',
localSearchMode: true,
browseUnsupported: false,
hasSearched: true,
},
}));
expect(view.result.current.songs).toEqual([stashedSong]);
browseScopeState.fingerprint = 'srv-1:new-library';
revisionState.value = 1;
view.rerender();
await waitFor(() => expect(view.result.current.songs.map(song => song.id)).toEqual(['fresh']));
expect(view.result.current.resultBrowseScopeFingerprint).toBe('srv-1:new-library');
expect(view.result.current.resultLibrarySyncRevision).toBe(1);
expect(runLocalSongScopeBrowse).toHaveBeenCalledWith(
'srv-1',
50,
null,
expect.objectContaining({ fingerprint: 'srv-1:new-library' }),
);
});
});
describe('useSongBrowseList scoped browse', () => {
@@ -147,10 +221,26 @@ describe('useSongBrowseList scoped browse', () => {
readyLibraryServerKeysMock.mockReset().mockResolvedValue(['srv-1']);
revisionState.value = 0;
browseScopeState.serverIds = ['srv-1'];
browseScopeState.anchorServerId = 'srv-1';
browseScopeState.pairs = [];
browseScopeState.multiServer = false;
browseScopeState.fingerprint = 'srv-1';
});
it('does not fall back to the active server when the effective scope is empty', async () => {
browseScopeState.anchorServerId = null;
browseScopeState.serverIds = [];
browseScopeState.pairs = [];
browseScopeState.fingerprint = '';
const { result } = renderHook(() => useSongBrowseList({ enabled: true, searchQuery: '' }));
await waitFor(() => expect(result.current.hasSearched).toBe(true));
expect(result.current.songs).toEqual([]);
expect(result.current.browseUnsupported).toBe(true);
expect(runLocalSongScopeBrowse).not.toHaveBeenCalled();
});
it('continues the ordinary Tracks catalogue with its opaque scoped cursor', async () => {
vi.mocked(runLocalSongScopeBrowse)
.mockResolvedValueOnce({
@@ -168,8 +258,20 @@ describe('useSongBrowseList scoped browse', () => {
await waitFor(() => expect(result.current.songs.map(song => song.id)).toEqual(['one']));
void result.current.loadMore();
await waitFor(() => expect(result.current.songs.map(song => song.id)).toEqual(['one', 'two']));
expect(runLocalSongScopeBrowse).toHaveBeenNthCalledWith(1, 'srv-1', 50, null);
expect(runLocalSongScopeBrowse).toHaveBeenNthCalledWith(2, 'srv-1', 50, 'cursor-1');
expect(runLocalSongScopeBrowse).toHaveBeenNthCalledWith(
1,
'srv-1',
50,
null,
expect.objectContaining({ anchorServerId: 'srv-1' }),
);
expect(runLocalSongScopeBrowse).toHaveBeenNthCalledWith(
2,
'srv-1',
50,
'cursor-1',
expect.objectContaining({ anchorServerId: 'srv-1' }),
);
expect(result.current.hasMore).toBe(false);
});
+144 -42
View File
@@ -1,7 +1,7 @@
import { searchSongsPaged } from '@/lib/api/subsonicSearch';
import { searchSongsPagedForServer } from '@/lib/api/subsonicSearch';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ndListSongs } from '@/lib/api/navidromeBrowse';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ndListSongsForServer } from '@/lib/api/navidromeBrowse';
import { runLocalSongBrowse, runLocalSongScopeBrowse } from '@/lib/library/advancedSearchLocal';
import {
BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
@@ -22,10 +22,14 @@ import {
useOfflineBrowseReloadToken,
} from '@/features/offline';
import { useOfflineLocalBrowseReloadKey } from '@/store/localPlaybackBrowseRevision';
import { getLibraryBrowseScope } from '@/lib/library/libraryBrowseScope';
import {
getLibraryBrowseScope,
type LibraryBrowseScope,
} from '@/lib/library/libraryBrowseScope';
import { emitTrackBrowseDebug, trackBrowseTimed } from '@/lib/library/trackBrowseDebug';
import { useLibraryScopeSyncRevision } from '@/store/offlineLocalLibrarySyncRevision';
import { readyLibraryServerKeys } from '@/lib/library/libraryReady';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
const PAGE_SIZE = 50;
const BROWSE_READINESS_CHANGED = Symbol('browse-readiness-changed');
@@ -41,12 +45,13 @@ const browseAllPageInflight = new Map<string, Promise<BrowseAllPage>>();
async function fetchBrowseAllPage(
serverId: string | null | undefined,
browseScope: LibraryBrowseScope,
offset: number,
cursor?: string | null,
syncRevision = 0,
freshness = '',
): Promise<BrowseAllPage> {
const scopeFingerprint = getLibraryBrowseScope().fingerprint;
const scopeFingerprint = browseScope.fingerprint;
const key = [
serverId ?? '',
scopeFingerprint,
@@ -58,30 +63,32 @@ async function fetchBrowseAllPage(
const existing = browseAllPageInflight.get(key);
if (existing) return existing;
const request = (async (): Promise<BrowseAllPage> => {
const browseServerId = browseScope.anchorServerId;
if (!browseServerId) throw BROWSE_READINESS_CHANGED;
const scoped = await trackBrowseTimed(
'local_scope_page',
() => runLocalSongScopeBrowse(serverId, PAGE_SIZE, cursor),
() => runLocalSongScopeBrowse(browseServerId, PAGE_SIZE, cursor, browseScope),
{ offset, cursor: cursor != null },
);
if (scoped) return { ...scoped, local: true };
if (getLibraryBrowseScope().multiServer) throw BROWSE_READINESS_CHANGED;
if (browseScope.multiServer) throw BROWSE_READINESS_CHANGED;
const local = await trackBrowseTimed(
'local_advanced_page',
() => runLocalSongBrowse(serverId, offset, PAGE_SIZE),
() => runLocalSongBrowse(browseServerId, offset, PAGE_SIZE, browseScope),
{ offset },
);
if (local) return { songs: local, hasMore: local.length === PAGE_SIZE, local: true };
try {
const songs = await trackBrowseTimed(
'network_navidrome_page',
() => ndListSongs(offset, offset + PAGE_SIZE, 'title', 'ASC'),
() => ndListSongsForServer(browseServerId, offset, offset + PAGE_SIZE, 'title', 'ASC'),
{ offset },
);
return { songs, hasMore: songs.length === PAGE_SIZE, local: false };
} catch {
const songs = await trackBrowseTimed(
'network_search_page',
() => searchSongsPaged('', PAGE_SIZE, offset),
() => searchSongsPagedForServer(browseServerId, '', PAGE_SIZE, offset),
{ offset },
);
return { songs, hasMore: songs.length === PAGE_SIZE, local: false };
@@ -95,6 +102,8 @@ async function fetchBrowseAllPage(
}
export type SongBrowseListRestore = {
browseScopeFingerprint: string;
librarySyncRevision: number;
query: string;
songs: SubsonicSong[];
offset: number;
@@ -117,43 +126,64 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
const serverId = useAuthStore(s => s.activeServerId);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const libraryBrowseScopeVersion = useAuthStore(s => s.libraryBrowseScopeVersion);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const browseScope = useMemo(() => {
void libraryBrowseScopeVersion;
void serverId;
return getLibraryBrowseScope();
}, [libraryBrowseScopeVersion, serverId]);
const browseServerId = browseScope.anchorServerId;
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(browseServerId));
const offlineBrowseActive = useOfflineBrowseContext().active;
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
const offlineLocalBrowseReloadKey = useOfflineLocalBrowseReloadKey(
serverId,
offlineBrowseActive,
);
const browseScopeServerIds = getLibraryBrowseScope().serverIds;
const browseScopeServerIds = browseScope.serverIds;
const librarySyncRevision = useLibraryScopeSyncRevision(
browseScopeServerIds.length > 0 ? browseScopeServerIds : (serverId ? [serverId] : []),
);
const validInitialRestore = initialRestore
&& initialRestore.browseScopeFingerprint === browseScope.fingerprint
&& initialRestore.librarySyncRevision === librarySyncRevision
? initialRestore
: null;
const [debouncedQuery, setDebouncedQuery] = useState(
() => initialRestore?.query.trim() ?? searchQuery.trim(),
() => validInitialRestore?.query.trim() ?? searchQuery.trim(),
);
const [songs, setSongs] = useState<SubsonicSong[]>(() => initialRestore?.songs ?? []);
const [offset, setOffset] = useState(() => initialRestore?.offset ?? 0);
const [songs, setSongs] = useState<SubsonicSong[]>(() => validInitialRestore?.songs ?? []);
const [offset, setOffset] = useState(() => validInitialRestore?.offset ?? 0);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(() => initialRestore?.hasMore ?? true);
const [hasMore, setHasMore] = useState(() => validInitialRestore?.hasMore ?? true);
const [browseCursor, setBrowseCursor] = useState<string | null>(
() => initialRestore?.browseCursor ?? null,
() => validInitialRestore?.browseCursor ?? null,
);
const [browseUnsupported, setBrowseUnsupported] = useState(
() => initialRestore?.browseUnsupported ?? false,
() => validInitialRestore?.browseUnsupported ?? false,
);
const [hasSearched, setHasSearched] = useState(() => initialRestore?.hasSearched ?? false);
const [hasSearched, setHasSearched] = useState(() => validInitialRestore?.hasSearched ?? false);
const requestSeqRef = useRef(0);
const localSearchModeRef = useRef(initialRestore?.localSearchMode ?? false);
const browseCursorRef = useRef<string | null>(initialRestore?.browseCursor ?? null);
const localSearchModeRef = useRef(validInitialRestore?.localSearchMode ?? false);
const browseCursorRef = useRef<string | null>(validInitialRestore?.browseCursor ?? null);
const loadedScopeFingerprintRef = useRef(
validInitialRestore?.browseScopeFingerprint ?? browseScope.fingerprint,
);
const loadedSyncRevisionRef = useRef(
validInitialRestore?.librarySyncRevision ?? librarySyncRevision,
);
const [resultProvenance, setResultProvenance] = useState({
browseScopeFingerprint: validInitialRestore?.browseScopeFingerprint ?? browseScope.fingerprint,
librarySyncRevision: validInitialRestore?.librarySyncRevision ?? librarySyncRevision,
});
const browsePageMetaRef = useRef<{ hasMore: boolean; local: boolean }>({ hasMore: true, local: false });
/** Keep stashed songs until the user edits the scoped query (survives fetchSongPage identity changes). */
const holdRestoredListRef = useRef(initialRestore != null);
const heldRestoredQueryRef = useRef(initialRestore?.query.trim() ?? '');
const holdRestoredListRef = useRef(validInitialRestore != null);
const heldRestoredQueryRef = useRef(validInitialRestore?.query.trim() ?? '');
const restoreQueryHoldRef = useRef(
initialRestore?.query.trim() ? initialRestore.query.trim() : null,
validInitialRestore?.query.trim() ? validInitialRestore.query.trim() : null,
);
useEffect(() => {
if (!enabled) return;
@@ -177,10 +207,12 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
}
return (await searchOfflineLocalBrowsableSongs(serverId, q, pageOffset, PAGE_SIZE)) ?? [];
}
if (!browseServerId) throw BROWSE_READINESS_CHANGED;
if (q === '') {
const page = await fetchBrowseAllPage(
serverId,
browseServerId,
browseScope,
pageOffset,
browseCursorRef.current,
librarySyncRevision,
@@ -194,9 +226,9 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
return page.songs;
}
if (pageOffset === 0 && indexEnabled && serverId) {
if (pageOffset === 0 && indexEnabled && browseServerId) {
if (getLibraryBrowseScope().multiServer) {
const local = await runLocalBrowseSongPage(serverId, q, 0, PAGE_SIZE);
const local = await runLocalBrowseSongPage(browseServerId, q, 0, PAGE_SIZE, browseScope);
if (isStale()) return [];
if (local == null) throw BROWSE_READINESS_CHANGED;
localSearchModeRef.current = true;
@@ -204,8 +236,8 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
}
const winner = await raceBrowseWithLocalFallback(
isStale,
() => runLocalBrowseSongPage(serverId, q, 0, PAGE_SIZE),
() => runNetworkBrowseSongPage(q, 0, PAGE_SIZE),
() => runLocalBrowseSongPage(browseServerId, q, 0, PAGE_SIZE, browseScope),
() => runNetworkBrowseSongPage(q, 0, PAGE_SIZE, browseServerId),
{
surface: 'tracks_browse',
query: q,
@@ -219,29 +251,44 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
return winner.result ?? [];
}
localSearchModeRef.current = false;
return (await runNetworkBrowseSongPage(q, 0, PAGE_SIZE)) ?? [];
return (await runNetworkBrowseSongPage(q, 0, PAGE_SIZE, browseServerId)) ?? [];
}
if (localSearchModeRef.current && serverId) {
if (localSearchModeRef.current && browseServerId) {
try {
return await loadMoreLocalBrowseSongs(serverId, q, pageOffset, PAGE_SIZE);
return await loadMoreLocalBrowseSongs(browseServerId, q, pageOffset, PAGE_SIZE, browseScope);
} catch {
return [];
}
}
return (await runNetworkBrowseSongPage(q, pageOffset, PAGE_SIZE)) ?? [];
return (await runNetworkBrowseSongPage(q, pageOffset, PAGE_SIZE, browseServerId)) ?? [];
},
[indexEnabled, musicLibraryFilterVersion, libraryBrowseScopeVersion, librarySyncRevision, offlineBrowseActive, serverId],
[browseScope, browseServerId, indexEnabled, musicLibraryFilterVersion, libraryBrowseScopeVersion, librarySyncRevision, offlineBrowseActive, serverId],
);
useEffect(() => {
if (!enabled) return;
let discardRestoredList = false;
if (holdRestoredListRef.current) {
const expected = heldRestoredQueryRef.current;
if (searchQuery.trim() !== expected || debouncedQuery !== expected) {
const restoreContextChanged = loadedScopeFingerprintRef.current !== browseScope.fingerprint
|| loadedSyncRevisionRef.current !== librarySyncRevision;
if (searchQuery.trim() !== expected || debouncedQuery !== expected || restoreContextChanged) {
holdRestoredListRef.current = false;
if (restoreContextChanged) {
discardRestoredList = true;
loadedScopeFingerprintRef.current = browseScope.fingerprint;
loadedSyncRevisionRef.current = librarySyncRevision;
setResultProvenance({
browseScopeFingerprint: browseScope.fingerprint,
librarySyncRevision,
});
localSearchModeRef.current = false;
browseCursorRef.current = null;
browsePageMetaRef.current = { hasMore: true, local: false };
}
} else {
return;
}
@@ -252,15 +299,57 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
const isStale = () => cancelled || seq !== requestSeqRef.current;
void (async () => {
try {
const browseScope = getLibraryBrowseScope();
if (discardRestoredList) {
setSongs([]);
setOffset(0);
setBrowseCursor(null);
setBrowseUnsupported(false);
setHasMore(true);
setHasSearched(false);
}
if (!offlineBrowseActive && !browseServerId) {
if (isStale()) return;
localSearchModeRef.current = false;
browseCursorRef.current = null;
browsePageMetaRef.current = { hasMore: false, local: false };
setSongs([]);
setOffset(0);
setBrowseCursor(null);
setBrowseUnsupported(true);
setHasMore(false);
setHasSearched(true);
setLoading(false);
loadedScopeFingerprintRef.current = browseScope.fingerprint;
loadedSyncRevisionRef.current = librarySyncRevision;
return;
}
if (
!offlineBrowseActive
&& indexEnabled
&& serverId
&& browseServerId
&& browseScope.multiServer
&& (await readyLibraryServerKeys(browseScope.serverIds)) == null
) {
if (!isStale()) setLoading(false);
if (!isStale()) {
if (loadedScopeFingerprintRef.current !== browseScope.fingerprint) {
loadedScopeFingerprintRef.current = browseScope.fingerprint;
loadedSyncRevisionRef.current = librarySyncRevision;
setResultProvenance({
browseScopeFingerprint: browseScope.fingerprint,
librarySyncRevision,
});
localSearchModeRef.current = false;
browseCursorRef.current = null;
browsePageMetaRef.current = { hasMore: false, local: false };
setSongs([]);
setOffset(0);
setBrowseCursor(null);
setBrowseUnsupported(false);
setHasMore(false);
setHasSearched(true);
}
setLoading(false);
}
return;
}
if (isStale()) return;
@@ -270,12 +359,18 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
setLoading(true);
emitTrackBrowseDebug('load_effect_start', {
queryActive: debouncedQuery !== '',
serverId,
serverId: browseServerId,
indexEnabled,
offset: 0,
});
const page = await fetchSongPage(debouncedQuery, 0, isStale);
if (isStale()) return;
loadedScopeFingerprintRef.current = browseScope.fingerprint;
loadedSyncRevisionRef.current = librarySyncRevision;
setResultProvenance({
browseScopeFingerprint: browseScope.fingerprint,
librarySyncRevision,
});
setSongs(page);
setOffset(page.length);
setBrowseCursor(browseCursorRef.current);
@@ -304,7 +399,7 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
return () => {
cancelled = true;
};
}, [debouncedQuery, searchQuery, fetchSongPage, enabled, indexEnabled, musicLibraryFilterVersion, libraryBrowseScopeVersion, librarySyncRevision, offlineBrowseReloadTs, offlineLocalBrowseReloadKey, offlineBrowseActive, serverId]);
}, [debouncedQuery, searchQuery, fetchSongPage, enabled, indexEnabled, musicLibraryFilterVersion, libraryBrowseScopeVersion, librarySyncRevision, offlineBrowseReloadTs, offlineLocalBrowseReloadKey, offlineBrowseActive, browseScope, browseServerId, serverId]);
const loadMore = useCallback(async () => {
if (!enabled || loading || !hasMore) return;
@@ -323,9 +418,14 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
setHasMore(false);
} else {
setSongs(prev => {
const seen = new Set(prev.map(s => s.id));
const seen = new Set(prev.map(ownedEntityKey));
const merged = [...prev];
for (const s of page) if (!seen.has(s.id)) merged.push(s);
for (const s of page) {
const key = ownedEntityKey(s);
if (seen.has(key)) continue;
seen.add(key);
merged.push(s);
}
return merged;
});
setOffset(o => o + page.length);
@@ -362,6 +462,8 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
localSearchMode: localSearchModeRef.current,
resultBrowseScopeFingerprint: resultProvenance.browseScopeFingerprint,
resultLibrarySyncRevision: resultProvenance.librarySyncRevision,
loadMore,
};
}
+146 -42
View File
@@ -1,4 +1,4 @@
import { getGenres } from '@/lib/api/subsonicGenres';
import { getGenresForServer } from '@/lib/api/subsonicGenres';
import type { SubsonicGenre } from '@/lib/api/subsonicTypes';
import type { ResultType, SearchOpts, Results } from '@/features/search/searchBrowseTypes';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
@@ -84,9 +84,25 @@ export default function SearchBrowsePage() {
const qFromUrl = params.get('q') ?? '';
const showTracksChrome = isTracksBrowsePath(location.pathname);
const showAdvancedPanel = isAdvancedSearchPanelPath(location.pathname);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const libraryBrowseScopeVersion = useAuthStore(s => s.libraryBrowseScopeVersion);
const serverId = useAuthStore(s => s.activeServerId);
const browseScope = useMemo(() => {
void libraryBrowseScopeVersion;
void serverId;
return getLibraryBrowseScope();
}, [libraryBrowseScopeVersion, serverId]);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(browseScope.anchorServerId));
const offlineBrowseActive = useOfflineBrowseContext().active;
const librarySyncRevision = useLibraryScopeSyncRevision(browseScope.serverIds);
const restoreStash = peekAdvancedSearchRestoreStash(navigationType, location.state);
const restoreContextMatches = restoreStash != null
&& restoreStash.browseScopeFingerprint === browseScope.fingerprint
&& restoreStash.librarySyncRevision === librarySyncRevision;
const restoreContextMismatch = restoreStash != null && !restoreContextMatches;
const hadRestoreOnMountRef = useRef(restoreStash != null);
const restoredFromStashRef = useRef(restoreStash != null);
const restoreContextMismatchRef = useRef(restoreContextMismatch);
const [query, setQuery] = useState(() => restoreStash?.query ?? qFromUrl);
const [genre, setGenre] = useState(() => restoreStash?.genre ?? '');
@@ -99,7 +115,9 @@ export default function SearchBrowsePage() {
const [resultType, setResultType] = useState<ResultType>(() => restoreStash?.resultType ?? 'all');
const [starredOnly, setStarredOnly] = useState(() => restoreStash?.starredOnly ?? false);
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
const [results, setResults] = useState<Results | null>(() => restoreStash?.results ?? null);
const [results, setResults] = useState<Results | null>(
() => restoreContextMatches ? restoreStash.results : null,
);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const filteredResults = useMemo<Results | null>(() => {
if (!results) return null;
@@ -107,33 +125,44 @@ export default function SearchBrowsePage() {
return filterStarredSearchResults(results, starredOverrides);
}, [results, starredOnly, starredOverrides]);
const [loading, setLoading] = useState(false);
const [hasSearched, setHasSearched] = useState(() => restoreStash?.hasSearched ?? false);
const [genreNote, setGenreNote] = useState(() => restoreStash?.genreNote ?? false);
const [hasSearched, setHasSearched] = useState(
() => restoreContextMatches ? restoreStash.hasSearched : false,
);
const [genreNote, setGenreNote] = useState(
() => restoreContextMatches ? restoreStash.genreNote : false,
);
// True while the current results came from the local index (drives the
// pagination branch — local pages every result type, network only free-text).
const [localMode, setLocalMode] = useState(() => restoreStash?.localMode ?? false);
const [localMode, setLocalMode] = useState(
() => restoreContextMatches ? restoreStash.localMode : false,
);
const [basicSearchMode, setBasicSearchMode] = useState(
() => restoreStash?.basicSearchMode ?? (!showAdvancedPanel && !showTracksChrome),
);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const libraryBrowseScopeVersion = useAuthStore(s => s.libraryBrowseScopeVersion);
const serverId = useAuthStore(s => s.activeServerId);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const offlineBrowseActive = useOfflineBrowseContext().active;
const librarySyncRevision = useLibraryScopeSyncRevision(
getLibraryBrowseScope().serverIds.length > 0
? getLibraryBrowseScope().serverIds
: (serverId ? [serverId] : []),
);
const searchedSyncRevisionRef = useRef(librarySyncRevision);
const searchedBrowseScopeVersionRef = useRef(libraryBrowseScopeVersion);
const [activeSearch, setActiveSearch] = useState<SearchOpts | null>(() => restoreStash?.activeSearch ?? null);
const [songsServerOffset, setSongsServerOffset] = useState(() => restoreStash?.songsServerOffset ?? 0);
const [songsHasMore, setSongsHasMore] = useState(() => restoreStash?.songsHasMore ?? false);
const [songsServerOffset, setSongsServerOffset] = useState(
() => restoreContextMatches ? restoreStash.songsServerOffset : 0,
);
const [songsHasMore, setSongsHasMore] = useState(
() => restoreContextMatches ? restoreStash.songsHasMore : false,
);
const [loadingMoreSongs, setLoadingMoreSongs] = useState(false);
const [resultProvenance, setResultProvenance] = useState({
browseScopeFingerprint: restoreContextMatches
? restoreStash.browseScopeFingerprint
: browseScope.fingerprint,
librarySyncRevision: restoreContextMatches
? restoreStash.librarySyncRevision
: librarySyncRevision,
});
const songBrowseInitialRestore: SongBrowseListRestore | null =
restoreStash && showTracksChrome
restoreStash && restoreContextMatches && showTracksChrome
? {
browseScopeFingerprint: restoreStash.browseScopeFingerprint,
librarySyncRevision: restoreStash.librarySyncRevision,
query: restoreStash.query,
songs: restoreStash.results?.songs ?? [],
offset: restoreStash.songsServerOffset,
@@ -219,21 +248,22 @@ export default function SearchBrowsePage() {
const restoringSession =
shouldRestoreAdvancedSearchSession(navigationType, location.state) || restoreStash != null;
const leaveSnapshotRef = useRef<AdvancedSearchLeaveSnapshot | null>(
restoringSession ? resolveAdvancedSearchLeaveSnapshot(restoreStash) : null,
const initialLeaveSnapshot = restoringSession
? resolveAdvancedSearchLeaveSnapshot(restoreStash)
: null;
const leaveSnapshotRef = useRef<AdvancedSearchLeaveSnapshot | null>(initialLeaveSnapshot);
const scrollTopRestoreTargetRef = useRef(
restoreContextMismatch ? 0 : (initialLeaveSnapshot?.scrollTop ?? 0),
);
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
const scrollTopRestoreTargetRef = useRef(leaveSnapshotRef.current?.scrollTop ?? 0);
const tracksSearchRestorePendingRef = useRef(
!!(songBrowseInitialRestore?.query.trim()),
);
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
const albumRowScrollLeftRestoreRef = useRef(leaveSnapshotRef.current?.albumRowScrollLeft ?? 0);
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
const artistRowScrollLeftRestoreRef = useRef(leaveSnapshotRef.current?.artistRowScrollLeft ?? 0);
const albumRowScrollLeftRestoreRef = useRef(
restoreContextMismatch ? 0 : (initialLeaveSnapshot?.albumRowScrollLeft ?? 0),
);
const artistRowScrollLeftRestoreRef = useRef(
restoreContextMismatch ? 0 : (initialLeaveSnapshot?.artistRowScrollLeft ?? 0),
);
const mainScrollTopRef = useRef(0);
const albumRowScrollLeftRef = useRef(0);
const artistRowScrollLeftRef = useRef(0);
@@ -260,6 +290,9 @@ export default function SearchBrowsePage() {
tracksSearchActive,
leaveRestorePendingWithQuery: isLeaveRestorePending
&& !!(restoreStash?.query.trim() || songBrowseInitialRestore?.query.trim()),
activeServerOwnsBrowseScope: !browseScope.multiServer
&& browseScope.anchorServerId != null
&& browseScope.anchorServerId === serverId,
});
const handleTracksChromeLayoutReady = useCallback(() => {
@@ -281,6 +314,8 @@ export default function SearchBrowsePage() {
}, []);
const sessionRef = useRef<AdvancedSearchSessionStash>({
browseScopeFingerprint: '',
librarySyncRevision: 0,
query: '',
genre: '',
yearFrom: '',
@@ -306,6 +341,12 @@ export default function SearchBrowsePage() {
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
sessionRef.current = {
browseScopeFingerprint: showTracksChrome
? songBrowse.resultBrowseScopeFingerprint
: resultProvenance.browseScopeFingerprint,
librarySyncRevision: showTracksChrome
? songBrowse.resultLibrarySyncRevision
: resultProvenance.librarySyncRevision,
query: showTracksChrome ? liveSearchQuery : query,
genre,
yearFrom,
@@ -358,6 +399,7 @@ export default function SearchBrowsePage() {
const { runBasicSearch, runSearch, loadMoreSongs } = useAdvancedSearchRunner({
serverId,
indexEnabled,
librarySyncRevision,
loadingMoreSongs,
songsHasMore,
activeSearch,
@@ -375,12 +417,22 @@ export default function SearchBrowsePage() {
setLocalMode,
setResults,
setLoadingMoreSongs,
onResultsCommitted: (browseScopeFingerprint, committedSyncRevision) => {
setResultProvenance({
browseScopeFingerprint,
librarySyncRevision: committedSyncRevision,
});
},
});
useEffect(() => {
if (searchedSyncRevisionRef.current === librarySyncRevision) return;
const syncChanged = searchedSyncRevisionRef.current !== librarySyncRevision;
const scopeChanged = searchedBrowseScopeVersionRef.current !== libraryBrowseScopeVersion;
if (!syncChanged && !scopeChanged) return;
if (isLeaveRestorePending) return;
searchedSyncRevisionRef.current = librarySyncRevision;
if (showTracksChrome || !activeSearch || isLeaveRestorePending) return;
searchedBrowseScopeVersionRef.current = libraryBrowseScopeVersion;
if (showTracksChrome || !activeSearch) return;
if (basicSearchMode) {
void runBasicSearch(activeSearch.query);
} else {
@@ -388,7 +440,7 @@ export default function SearchBrowsePage() {
}
// Search runners intentionally use the latest render state when the sync revision changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [librarySyncRevision]);
}, [librarySyncRevision, libraryBrowseScopeVersion, isLeaveRestorePending]);
useEffect(() => {
return () => {
@@ -416,8 +468,25 @@ export default function SearchBrowsePage() {
restoredFromStashRef.current = true;
const stash = useAdvancedSearchSessionStore.getState().peekReturnStash();
if (stash) {
const contextMatches = stash.browseScopeFingerprint === browseScope.fingerprint
&& stash.librarySyncRevision === librarySyncRevision;
restoreContextMismatchRef.current = !contextMatches;
if (!contextMatches) {
scrollTopRestoreTargetRef.current = 0;
albumRowScrollLeftRestoreRef.current = 0;
artistRowScrollLeftRestoreRef.current = 0;
}
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setResultProvenance(contextMatches
? {
browseScopeFingerprint: stash.browseScopeFingerprint,
librarySyncRevision: stash.librarySyncRevision,
}
: {
browseScopeFingerprint: browseScope.fingerprint,
librarySyncRevision,
});
setQuery(stash.query);
if (showTracksChrome) {
const store = useLiveSearchScopeStore.getState();
@@ -433,13 +502,13 @@ export default function SearchBrowsePage() {
setLosslessOnly(stash.losslessOnly);
setResultType(stash.resultType);
setStarredOnly(stash.starredOnly);
setResults(stash.results);
setHasSearched(stash.hasSearched);
setResults(contextMatches ? stash.results : null);
setHasSearched(contextMatches ? stash.hasSearched : false);
setActiveSearch(stash.activeSearch);
setLocalMode(stash.localMode);
setSongsServerOffset(stash.songsServerOffset);
setSongsHasMore(stash.songsHasMore);
setGenreNote(stash.genreNote);
setLocalMode(contextMatches ? stash.localMode : false);
setSongsServerOffset(contextMatches ? stash.songsServerOffset : 0);
setSongsHasMore(contextMatches ? stash.songsHasMore : false);
setGenreNote(contextMatches ? stash.genreNote : false);
setBasicSearchMode(stash.basicSearchMode);
}
if (!leaveSnapshotRef.current) {
@@ -454,6 +523,20 @@ export default function SearchBrowsePage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigationType, location.state]);
useEffect(() => {
if (!restoreContextMismatchRef.current || showTracksChrome) return;
restoreContextMismatchRef.current = false;
if (activeSearch) {
if (basicSearchMode) void runBasicSearch(activeSearch.query);
else void runSearch(activeSearch);
return;
}
if (query.trim()) void runBasicSearch(query);
// Restore mismatch is a one-shot mount/navigation repair; the runners are intentionally
// read from the render that observed the restored form state.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigationType, location.state]);
const tracksSearchRestoreSynced =
// React Compiler refs rule: ref used as a once-only init guard (checked before first assignment); not render data.
// eslint-disable-next-line react-hooks/refs
@@ -516,10 +599,31 @@ export default function SearchBrowsePage() {
}, [isLeaveRestorePending, location.pathname, location.search, location.hash, location.state, navigate]);
useEffect(() => {
getGenres().then(data =>
setGenres(data.sort((a, b) => a.value.localeCompare(b.value)))
).catch(() => {});
}, []);
let cancelled = false;
const serverIds = browseScope.serverIds;
if (serverIds.length === 0) {
void Promise.resolve().then(() => {
if (!cancelled) setGenres([]);
});
return () => {
cancelled = true;
};
}
void Promise.allSettled(serverIds.map(getGenresForServer)).then(results => {
if (cancelled) return;
const merged = new Map<string, SubsonicGenre>();
for (const result of results) {
if (result.status !== 'fulfilled') continue;
for (const genreItem of result.value) {
if (!merged.has(genreItem.value)) merged.set(genreItem.value, genreItem);
}
}
setGenres([...merged.values()].sort((a, b) => a.value.localeCompare(b.value)));
});
return () => {
cancelled = true;
};
}, [browseScope]);
useEffect(() => {
if (hadRestoreOnMountRef.current) return;
@@ -25,4 +25,13 @@ describe('tracksBrowseDiscoveryChromeHidden', () => {
leaveRestorePendingWithQuery: false,
})).toBe(true);
});
it('hides active-server discovery when the selected browse scope has another owner', () => {
expect(tracksBrowseDiscoveryChromeHidden({
offlineBrowseActive: false,
tracksSearchActive: false,
leaveRestorePendingWithQuery: false,
activeServerOwnsBrowseScope: false,
})).toBe(true);
});
});
@@ -3,8 +3,10 @@ export function tracksBrowseDiscoveryChromeHidden(args: {
offlineBrowseActive: boolean;
tracksSearchActive: boolean;
leaveRestorePendingWithQuery: boolean;
activeServerOwnsBrowseScope?: boolean;
}): boolean {
return args.offlineBrowseActive
|| args.tracksSearchActive
|| args.leaveRestorePendingWithQuery;
|| args.leaveRestorePendingWithQuery
|| args.activeServerOwnsBrowseScope === false;
}
+9 -5
View File
@@ -5,7 +5,10 @@ const { invokeMock, loginMock, getServerByIdMock, connectBaseUrlMock, authState
loginMock: vi.fn(),
getServerByIdMock: vi.fn(),
connectBaseUrlMock: vi.fn(),
authState: { activeServerId: 'srv-a' as string | null },
authState: {
activeServerId: 'srv-a' as string | null,
servers: [] as Array<{ id: string; url: string }>,
},
}));
vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }));
@@ -36,6 +39,7 @@ describe('explicit-server lossless album browsing', () => {
beforeEach(() => {
ndClearTokenCache();
authState.activeServerId = 'srv-a';
authState.servers = Object.values(servers);
invokeMock.mockReset();
loginMock.mockReset();
getServerByIdMock.mockReset();
@@ -64,8 +68,8 @@ describe('explicit-server lossless album browsing', () => {
const pageB = await ndListLosslessAlbumsPageForServer('srv-b', request);
await ndListLosslessAlbumsPageForServer('srv-a', request);
expect(firstA.entries[0]?.album.serverId).toBe('srv-a');
expect(pageB.entries[0]?.album.serverId).toBe('srv-b');
expect(firstA.entries[0]?.album.serverId).toBe('a.example');
expect(pageB.entries[0]?.album.serverId).toBe('b.example');
expect(loginMock).toHaveBeenCalledTimes(2);
expect(loginMock).toHaveBeenCalledWith('https://srv-a.connect', 'alice', 'a-pass');
expect(loginMock).toHaveBeenCalledWith('https://srv-b.connect', 'bob', 'b-pass');
@@ -98,8 +102,8 @@ describe('explicit-server lossless album browsing', () => {
'srv-b', 'composer-1', 'composer', 0, 100, 'name', 'ASC', 'lib-b',
);
expect(composers[0]).toEqual(expect.objectContaining({ id: 'composer-1', serverId: 'srv-b', albumCount: 2 }));
expect(albums[0]).toEqual(expect.objectContaining({ id: 'album-1', serverId: 'srv-b' }));
expect(composers[0]).toEqual(expect.objectContaining({ id: 'composer-1', serverId: 'b.example', albumCount: 2 }));
expect(albums[0]).toEqual(expect.objectContaining({ id: 'album-1', serverId: 'b.example' }));
expect(invokeMock).toHaveBeenNthCalledWith(1, 'nd_list_artists_by_role', expect.objectContaining({
serverUrl: 'https://srv-b.connect',
token: 'token:https://srv-b.connect',
+30 -14
View File
@@ -5,6 +5,7 @@ import { useAuthStore } from '@/store/authStore';
import { getServerById, librarySelectionForServer } from '@/lib/api/subsonicClient';
import { ndLogin } from '@/lib/api/navidromeAdmin';
import { connectBaseUrlForServer } from '@/lib/server/serverEndpoint';
import { resolveIndexKey } from '@/lib/server/serverIndexKey';
/** Server-keyed Bearer token cache. Cheap to keep — Navidrome tokens are long-lived. */
const cachedTokens = new Map<string, { serverUrl: string; token: string }>();
@@ -20,12 +21,6 @@ async function getTokenForServer(serverId: string, force = false): Promise<strin
return result.token;
}
async function getToken(force = false): Promise<string> {
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) throw new Error('No active server configured');
return getTokenForServer(activeServerId, force);
}
function asString(v: unknown, fallback = ''): string {
return typeof v === 'string' ? v : (typeof v === 'number' ? String(v) : fallback);
}
@@ -97,8 +92,22 @@ export async function ndListSongs(
order: 'ASC' | 'DESC' = 'ASC',
cacheMs?: number,
): Promise<SubsonicSong[]> {
const baseUrl = useAuthStore.getState().getBaseUrl();
if (!baseUrl) throw new Error('No server configured');
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) throw new Error('No active server configured');
return ndListSongsForServer(activeServerId, start, end, sort, order, cacheMs);
}
export async function ndListSongsForServer(
serverId: string,
start: number,
end: number,
sort: NdSongSort = 'title',
order: 'ASC' | 'DESC' = 'ASC',
cacheMs?: number,
): Promise<SubsonicSong[]> {
const server = getServerById(serverId);
if (!server) throw new Error(`Unknown server: ${serverId}`);
const baseUrl = connectBaseUrlForServer(server);
const cacheKey = (cacheMs && cacheMs > 0)
? songsCacheKey(baseUrl, start, end, sort, order)
@@ -111,7 +120,7 @@ export async function ndListSongs(
const callOnce = async (token: string): Promise<unknown> =>
invoke<unknown>('nd_list_songs', { serverUrl: baseUrl, token, sort, order, start, end });
let token = await getToken();
let token = await getTokenForServer(serverId);
let raw: unknown;
try {
raw = await callOnce(token);
@@ -119,7 +128,7 @@ export async function ndListSongs(
const msg = String(err);
// Token rejected → re-auth once and retry
if (msg.includes('401') || msg.includes('403')) {
token = await getToken(true);
token = await getTokenForServer(serverId, true);
raw = await callOnce(token);
} else {
throw err;
@@ -127,7 +136,11 @@ export async function ndListSongs(
}
if (!Array.isArray(raw)) return [];
const data = raw.map(s => mapNdSong(s as Record<string, unknown>));
const ownerServerKey = resolveIndexKey(serverId);
const data = raw.map(s => ({
...mapNdSong(s as Record<string, unknown>),
serverId: ownerServerKey,
}));
if (cacheKey && cacheMs && cacheMs > 0) {
songsCache.set(cacheKey, { data, expiresAt: Date.now() + cacheMs });
@@ -237,7 +250,8 @@ export async function ndListArtistsByRoleForServer(
}
}
if (!Array.isArray(raw)) return [];
return raw.map(a => ({ ...mapNdArtist(a as Record<string, unknown>, role), serverId }));
const ownerServerKey = resolveIndexKey(serverId);
return raw.map(a => ({ ...mapNdArtist(a as Record<string, unknown>, role), serverId: ownerServerKey }));
}
/**
@@ -294,7 +308,8 @@ export async function ndListAlbumsByArtistRoleForServer(
}
}
if (!Array.isArray(raw)) return [];
return raw.map(a => ({ ...mapNdAlbum(a as Record<string, unknown>), serverId }));
const ownerServerKey = resolveIndexKey(serverId);
return raw.map(a => ({ ...mapNdAlbum(a as Record<string, unknown>), serverId: ownerServerKey }));
}
export interface NdLosslessAlbumEntry {
@@ -369,6 +384,7 @@ export async function ndListLosslessAlbumsPageForServer(
const server = getServerById(serverId);
if (!server) throw new Error(`Unknown server: ${serverId}`);
const ownerServerKey = resolveIndexKey(serverId);
const baseUrl = connectBaseUrlForServer(server);
const fetchPage = async (start: number, end: number): Promise<unknown[]> => {
@@ -418,7 +434,7 @@ export async function ndListLosslessAlbumsPageForServer(
seen.add(albumId);
const album: SubsonicAlbum = {
serverId,
serverId: ownerServerKey,
id: albumId,
name: asString(o.album),
artist: asString(o.albumArtist) || asString(o.artist),
+29 -2
View File
@@ -1,6 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchAllSongsByGenre, getGenresForServer, getSongsByGenre } from '@/lib/api/subsonicGenres';
import {
fetchAllSongsByGenre,
getAlbumsByGenreForServer,
getGenresForServer,
getSongsByGenre,
} from '@/lib/api/subsonicGenres';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { resetAuthStore } from '@/test/helpers/storeReset';
import { useAuthStore } from '@/store/authStore';
const { apiMock, apiForServerMock } = vi.hoisted(() => ({ apiMock: vi.fn(), apiForServerMock: vi.fn() }));
@@ -30,7 +37,10 @@ describe('getSongsByGenre', () => {
});
describe('getGenresForServer', () => {
beforeEach(() => apiForServerMock.mockReset());
beforeEach(() => {
apiForServerMock.mockReset();
resetAuthStore();
});
it('uses the selected server and its library filter', async () => {
apiForServerMock.mockResolvedValue({ genres: { genre: { value: 'Jazz', songCount: 3, albumCount: 2 } } });
@@ -42,6 +52,23 @@ describe('getGenresForServer', () => {
musicFolderId: ['folder-a'],
});
});
it('stamps genre albums with the canonical owner key', async () => {
useAuthStore.setState({
servers: [{
id: 'server-b',
name: 'B',
url: 'https://b.test/rest',
username: 'u',
password: 'p',
}],
});
apiForServerMock.mockResolvedValue({ albumList2: { album: [{ id: 'album-1' }] } });
await expect(getAlbumsByGenreForServer('server-b', 'Jazz')).resolves.toEqual([
{ id: 'album-1', serverId: 'b.test/rest' },
]);
});
});
describe('fetchAllSongsByGenre', () => {
+26
View File
@@ -5,6 +5,7 @@ import {
libraryFilterParamsForServer,
} from '@/lib/api/subsonicClient';
import type { SubsonicAlbum, SubsonicGenre, SubsonicSong } from '@/lib/api/subsonicTypes';
import { resolveIndexKey } from '@/lib/server/serverIndexKey';
export async function getGenres(): Promise<SubsonicGenre[]> {
const data = await api<{ genres: { genre: SubsonicGenre | SubsonicGenre[] } }>('getGenres.view', {
@@ -40,6 +41,31 @@ export async function getAlbumsByGenre(genre: string, size = 50, offset = 0): Pr
return Array.isArray(raw) ? raw : [raw];
}
export async function getAlbumsByGenreForServer(
serverId: string,
genre: string,
size = 50,
offset = 0,
): Promise<SubsonicAlbum[]> {
const data = await apiForServer<{ albumList2: { album: SubsonicAlbum | SubsonicAlbum[] } }>(
serverId,
'getAlbumList2.view',
{
type: 'byGenre',
genre,
size,
offset,
_t: Date.now(),
...libraryFilterParamsForServer(serverId),
},
);
const raw = data.albumList2?.album;
if (!raw) return [];
const albums = Array.isArray(raw) ? raw : [raw];
const ownerServerKey = resolveIndexKey(serverId);
return albums.map(album => ({ ...album, serverId: ownerServerKey }));
}
/** Single page of songs for a genre (Subsonic `getSongsByGenre`, supported by Navidrome). */
export async function getSongsByGenre(genre: string, count = 500, offset = 0): Promise<SubsonicSong[]> {
const data = await api<{ songsByGenre: { song: SubsonicSong | SubsonicSong[] } }>('getSongsByGenre.view', {
+7 -2
View File
@@ -6,6 +6,7 @@ const { apiForServerMock, authState, guardMock } = vi.hoisted(() => ({
activeServerId: 'active',
musicLibraryFilterByServer: {} as Record<string, string>,
musicLibraryFilterVersion: 1,
servers: [] as Array<{ id: string; url: string }>,
},
guardMock: vi.fn(() => true),
}));
@@ -55,13 +56,17 @@ describe('explicit-server library wrappers', () => {
guardMock.mockReturnValue(true);
authState.activeServerId = 'active';
authState.musicLibraryFilterByServer = {};
authState.servers = [
{ id: 'srv-a', url: 'https://a.example/rest' },
{ id: 'srv-random', url: 'https://random.example' },
];
});
it('forwards album-list timeout and stamps albums', async () => {
apiForServerMock.mockResolvedValue({ albumList2: { album: [album] } });
await expect(getAlbumListForServer('srv-a', 'newest', 12, 4, { fromYear: 2020 }, 4321)).resolves.toEqual([
{ ...album, serverId: 'srv-a' },
{ ...album, serverId: 'a.example/rest' },
]);
expect(apiForServerMock).toHaveBeenCalledWith(
'srv-a',
@@ -80,7 +85,7 @@ describe('explicit-server library wrappers', () => {
});
await expect(getRandomSongsForServer('srv-random', 8, 'Rock', 2468)).resolves.toEqual([
{ ...song, serverId: 'srv-random' },
{ ...song, serverId: 'random.example' },
]);
expect(guardMock).toHaveBeenCalledWith('srv-random');
expect(apiForServerMock).toHaveBeenNthCalledWith(
+5 -2
View File
@@ -6,6 +6,7 @@ import {
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer, librarySelectionForServer } from '@/lib/api/subsonicClient';
import { getLuckyMixLibraryScopeOverride } from '@/lib/library/luckyMixScopeOverride';
import { mirrorAlbumMetadataFromServerOnUse } from '@/lib/library/patchOnUse';
import { resolveIndexKey } from '@/lib/server/serverIndexKey';
import type {
RandomSongsFilters,
SubsonicAlbum,
@@ -242,7 +243,8 @@ export async function getRandomSongsForServer(
timeout,
);
const songs = await filterSongsToServerLibrary(data.randomSongs?.song ?? [], serverId);
return songs.map(song => ({ ...song, serverId }));
const ownerServerKey = resolveIndexKey(serverId);
return songs.map(song => ({ ...song, serverId: ownerServerKey }));
}
/** Extended random song fetch with server-side year/genre filtering. */
@@ -284,7 +286,8 @@ export async function getAlbumListForServer(
},
timeout,
);
return (data.albumList2?.album ?? []).map(album => ({ ...album, serverId }));
const ownerServerKey = resolveIndexKey(serverId);
return (data.albumList2?.album ?? []).map(album => ({ ...album, serverId: ownerServerKey }));
}
export async function getSong(id: string): Promise<SubsonicSong | null> {
+15 -3
View File
@@ -1,4 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetAuthStore } from '@/test/helpers/storeReset';
import { useAuthStore } from '@/store/authStore';
const apiForServerMock = vi.hoisted(() => vi.fn());
@@ -14,6 +16,16 @@ import { searchForServer } from '@/lib/api/subsonicSearch';
describe('searchForServer', () => {
beforeEach(() => {
apiForServerMock.mockReset();
resetAuthStore();
useAuthStore.setState({
servers: [{
id: 'srv-b',
name: 'B',
url: 'https://b.test/rest',
username: 'u',
password: 'p',
}],
});
});
it('queries and stamps every result with the explicit owner server', async () => {
@@ -31,9 +43,9 @@ describe('searchForServer', () => {
songCount: 500,
timeout: 4321,
})).resolves.toEqual({
artists: [{ id: 'artist-1', name: 'Artist', serverId: 'srv-b' }],
albums: [{ id: 'album-1', name: 'Album', serverId: 'srv-b' }],
songs: [{ id: 'song-1', title: 'Song', serverId: 'srv-b' }],
artists: [{ id: 'artist-1', name: 'Artist', serverId: 'b.test/rest' }],
albums: [{ id: 'album-1', name: 'Album', serverId: 'b.test/rest' }],
songs: [{ id: 'song-1', title: 'Song', serverId: 'b.test/rest' }],
});
expect(apiForServerMock).toHaveBeenCalledWith('srv-b', 'search3.view', {
query: 'Artist',
+31 -4
View File
@@ -5,6 +5,7 @@ import {
libraryFilterParamsForServer,
} from '@/lib/api/subsonicClient';
import { searchQueryIsFtsSafe } from '@/lib/library/searchQueryFtsSafe';
import { resolveIndexKey } from '@/lib/server/serverIndexKey';
import type {
SearchResults,
SubsonicAlbum,
@@ -86,20 +87,46 @@ export async function searchForServer(
...libraryFilterParamsForServer(serverId),
}, options?.timeout ?? 15000);
const r = data.searchResult3 ?? {};
const ownerServerKey = resolveIndexKey(serverId);
return {
artists: filterSearchArtistsWithNoAlbums(r.artist ?? []).map(artist => ({ ...artist, serverId })),
albums: (r.album ?? []).map(album => ({ ...album, serverId })),
songs: (r.song ?? []).map(song => ({ ...song, serverId })),
artists: filterSearchArtistsWithNoAlbums(r.artist ?? []).map(artist => ({ ...artist, serverId: ownerServerKey })),
albums: (r.album ?? []).map(album => ({ ...album, serverId: ownerServerKey })),
songs: (r.song ?? []).map(song => ({ ...song, serverId: ownerServerKey })),
};
}
export async function searchSongsPagedForServer(
serverId: string,
query: string,
songCount: number,
songOffset: number,
): Promise<SubsonicSong[]> {
const q = query.trim();
if (q && !searchQueryIsFtsSafe(q)) return [];
const data = await apiForServer<{ searchResult3: { song?: SubsonicSong[] } }>(
serverId,
'search3.view',
{
query,
artistCount: 0,
albumCount: 0,
songCount,
songOffset,
...libraryFilterParamsForServer(serverId),
},
);
const ownerServerKey = resolveIndexKey(serverId);
return (data.searchResult3?.song ?? []).map(song => ({ ...song, serverId: ownerServerKey }));
}
/**
* Song-only paginated search3. Tolerates empty query Navidrome returns all songs
* ordered by title in that case; strict Subsonic implementations may return nothing.
* Caller handles empty results gracefully (Tracks page falls back to its random pool).
*/
export async function searchSongsPaged(query: string, songCount: number, songOffset: number): Promise<SubsonicSong[]> {
if (!searchQueryIsFtsSafe(query.trim())) return [];
const q = query.trim();
if (q && !searchQueryIsFtsSafe(q)) return [];
const data = await api<{ searchResult3: { song?: SubsonicSong[] } }>('search3.view', {
query,
artistCount: 0,
@@ -38,9 +38,18 @@ const ready = () =>
syncedAt: 0,
}));
function seedSingleServerScope() {
useAuthStore.setState({
activeServerId: 's1',
servers: [{ id: 's1', name: 'S1', url: 'https://s1', username: 'u', password: 'p' }],
libraryBrowseServerIds: [],
});
}
describe('runLocalAdvancedSearch', () => {
beforeEach(() => {
resetAuthStore();
seedSingleServerScope();
useLibraryIndexStore.setState({ masterEnabled: true });
});
@@ -312,6 +321,7 @@ describe('runLocalAdvancedSearch', () => {
describe('runLocalSongBrowse', () => {
beforeEach(() => {
resetAuthStore();
seedSingleServerScope();
useLibraryIndexStore.setState({ masterEnabled: true });
});
@@ -391,6 +401,7 @@ describe('runLocalSongBrowse', () => {
describe('tryRunLocalAdvancedSearch', () => {
beforeEach(() => {
resetAuthStore();
seedSingleServerScope();
useLibraryIndexStore.setState({ masterEnabled: true });
});
@@ -441,6 +452,7 @@ describe('runNetworkAdvancedYearAlbums', () => {
expect.objectContaining({ year: { to: 1990 } }),
0,
100,
undefined,
);
spy.mockRestore();
});
+35 -12
View File
@@ -21,7 +21,7 @@ import {
type LibraryFilterClause,
} from '@/lib/api/library';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
import { search } from '@/lib/api/subsonicSearch';
import { search, searchForServer } from '@/lib/api/subsonicSearch';
import { libraryScopeForServer, libraryScopePairsForServer } from '@/lib/api/subsonicClient';
import { fetchAlbumBrowseNetwork } from './albumBrowseNetwork';
import type { AlbumBrowseQuery } from './albumBrowseTypes';
@@ -35,7 +35,7 @@ import { isLosslessSuffix } from './losslessFormats';
import { albumIsCompilation } from './albumCompilation';
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from './trackEnrichment';
import { trackToSong } from './trackDtoMapping';
import { getLibraryBrowseScope } from './libraryBrowseScope';
import { getLibraryBrowseScope, type LibraryBrowseScope } from './libraryBrowseScope';
import { trackBrowseTimed } from './trackBrowseDebug';
export { resolveTrackCoverArtId, trackToSong } from './trackDtoMapping';
@@ -65,6 +65,8 @@ export interface LocalAdvancedSearchPage {
artists: SubsonicArtist[];
albums: SubsonicAlbum[];
songs: SubsonicSong[];
/** Raw server/index rows consumed before client-side filtering. */
songsConsumed: number;
/** Full track match count (not page size) — drives "load more". */
songsTotal: number;
}
@@ -226,16 +228,20 @@ function mergeArtistRawJson(base: SubsonicArtist, raw: Partial<SubsonicArtist>):
export async function runNetworkAdvancedTextSearch(
opts: LocalSearchOpts,
songsLimit: number,
serverId?: string | null,
): Promise<LocalAdvancedSearchPage | null> {
const q = opts.query.trim();
if (!q) return null;
const rt = opts.resultType;
const r = await search(q, {
const searchOptions = {
artistCount: 30,
albumCount: 50,
songCount: songsLimit,
});
};
const r = serverId
? await searchForServer(serverId, q, searchOptions)
: await search(q, searchOptions);
let artists = r.artists;
let albums = r.albums;
@@ -258,6 +264,7 @@ export async function runNetworkAdvancedTextSearch(
artists: rt === 'albums' || rt === 'songs' ? [] : artists,
albums: rt === 'artists' || rt === 'songs' ? [] : albums,
songs: rt === 'artists' || rt === 'albums' ? [] : songs,
songsConsumed: rt === 'artists' || rt === 'albums' ? 0 : r.songs.length,
songsTotal: rt === 'artists' || rt === 'albums' ? 0 : songs.length,
};
}
@@ -273,9 +280,13 @@ export async function runLocalAdvancedSearch(
songsLimit: number,
skipTotals = true,
suppressLog = false,
browseScope: LibraryBrowseScope = getLibraryBrowseScope(),
): Promise<LocalAdvancedSearchPage | null> {
if (!serverId) return null;
const readyScope = await resolveReadyLibraryBrowseScope(serverId, getLibraryBrowseScope());
const readyScope = await resolveReadyLibraryBrowseScope(
browseScope.anchorServerId ?? serverId,
browseScope,
);
if (!readyScope) return null;
const t0 = performance.now();
try {
@@ -293,6 +304,7 @@ export async function runLocalAdvancedSearch(
artists: resp.artists.map(artistToArtist),
albums: resp.albums.map(albumToAlbum),
songs: resp.tracks.map(trackToSong),
songsConsumed: resp.tracks.length,
songsTotal: resp.totals.tracks,
};
if (!suppressLog) {
@@ -341,15 +353,19 @@ export async function runLocalSongBrowse(
serverId: string | null | undefined,
offset: number,
pageSize: number,
browseScope: LibraryBrowseScope = getLibraryBrowseScope(),
): Promise<SubsonicSong[] | null> {
if (!serverId) return null;
const readyScope = await resolveReadyLibraryBrowseScope(serverId, getLibraryBrowseScope());
const readyScope = await resolveReadyLibraryBrowseScope(
browseScope.anchorServerId ?? serverId,
browseScope,
);
if (!readyScope) return null;
try {
const resp = await libraryAdvancedSearch({
serverId: readyScope.anchorServerKey,
libraryScope: readyScope.pairs.length > 0 ? undefined : libraryScopeForServer(serverId),
libraryScopes: readyScope.pairs.length > 0 ? readyScope.pairs : libraryScopePairsForServer(serverId),
libraryScope: readyScope.pairs.length > 0 ? undefined : libraryScopeForServer(readyScope.anchorServerKey),
libraryScopes: readyScope.pairs.length > 0 ? readyScope.pairs : libraryScopePairsForServer(readyScope.anchorServerKey),
query: undefined,
entityTypes: ['track'],
limit: pageSize,
@@ -368,12 +384,12 @@ export async function runLocalSongScopeBrowse(
serverId: string | null | undefined,
pageSize: number,
cursor?: string | null,
browseScope: LibraryBrowseScope = getLibraryBrowseScope(),
): Promise<{ songs: SubsonicSong[]; hasMore: boolean; nextCursor?: string | null } | null> {
if (!serverId) return null;
const browseScope = getLibraryBrowseScope();
const readyScope = await trackBrowseTimed(
'library_is_ready',
() => resolveReadyLibraryBrowseScope(serverId, browseScope),
() => resolveReadyLibraryBrowseScope(browseScope.anchorServerId ?? serverId, browseScope),
{ serverId },
);
if (!readyScope || readyScope.pairs.length === 0) return null;
@@ -410,8 +426,12 @@ export async function loadMoreLocalSongs(
opts: LocalSearchOpts,
offset: number,
pageSize: number,
browseScope: LibraryBrowseScope = getLibraryBrowseScope(),
): Promise<SubsonicSong[]> {
const readyScope = await resolveReadyLibraryBrowseScope(serverId, getLibraryBrowseScope());
const readyScope = await resolveReadyLibraryBrowseScope(
browseScope.anchorServerId ?? serverId,
browseScope,
);
if (!readyScope) throw new Error('local library index is not ready');
const req = buildRequest(readyScope, opts, ['track'], pageSize, offset, true);
const resp = await libraryAdvancedSearch(req);
@@ -424,6 +444,7 @@ export async function tryRunLocalAdvancedSearch(
opts: LocalSearchOpts,
songsLimit: number,
suppressLog = false,
browseScope: LibraryBrowseScope = getLibraryBrowseScope(),
): Promise<LocalAdvancedSearchPage | null> {
return runLocalAdvancedSearch(
serverId,
@@ -431,6 +452,7 @@ export async function tryRunLocalAdvancedSearch(
songsLimit,
true,
suppressLog,
browseScope,
);
}
@@ -451,9 +473,10 @@ function yearOnlyAlbumBrowseQuery(opts: LocalSearchOpts): AlbumBrowseQuery | nul
export async function runNetworkAdvancedYearAlbums(
opts: LocalSearchOpts,
pageSize = ADVANCED_SEARCH_YEAR_ALBUM_LIMIT,
serverId?: string | null,
): Promise<SubsonicAlbum[]> {
const query = yearOnlyAlbumBrowseQuery(opts);
if (!query) return [];
const page = await fetchAlbumBrowseNetwork(query, 0, pageSize);
const page = await fetchAlbumBrowseNetwork(query, 0, pageSize, serverId);
return page.albums;
}
+33 -14
View File
@@ -1,5 +1,5 @@
import { getAlbumList } from '@/lib/api/subsonicLibrary';
import { getAlbumsByGenre } from '@/lib/api/subsonicGenres';
import { getAlbumList, getAlbumListForServer } from '@/lib/api/subsonicLibrary';
import { getAlbumsByGenre, getAlbumsByGenreForServer } from '@/lib/api/subsonicGenres';
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
import { dedupeById } from '@/lib/util/dedupeById';
import {
@@ -11,8 +11,12 @@ import { albumListFetchType, sortSubsonicAlbums } from './albumBrowseSort';
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from './albumBrowseTypes';
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
async function fetchByGenres(genres: string[]) {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, GENRE_ALBUM_FETCH_LIMIT, 0)));
async function fetchByGenres(genres: string[], serverId?: string | null) {
const results = await Promise.all(genres.map(g => (
serverId
? getAlbumsByGenreForServer(serverId, g, GENRE_ALBUM_FETCH_LIMIT, 0)
: getAlbumsByGenre(g, GENRE_ALBUM_FETCH_LIMIT, 0)
)));
return dedupeById(results.flat());
}
@@ -28,24 +32,29 @@ export async function fetchAlbumBrowseNetwork(
query: AlbumBrowseQuery,
offset: number,
pageSize: number,
serverId?: string | null,
): Promise<AlbumBrowsePageResult> {
if (query.genres.length > 0) {
if (query.genres.length === 1) {
const data = applyNetworkPostFilters(
await getAlbumsByGenre(query.genres[0], pageSize, offset),
serverId
? await getAlbumsByGenreForServer(serverId, query.genres[0], pageSize, offset)
: await getAlbumsByGenre(query.genres[0], pageSize, offset),
query,
);
return { albums: data, hasMore: data.length === pageSize };
}
if (offset > 0) return { albums: [], hasMore: false };
const data = applyNetworkPostFilters(await fetchByGenres(query.genres), query);
const data = applyNetworkPostFilters(await fetchByGenres(query.genres, serverId), query);
return { albums: data, hasMore: false };
}
if (query.starredOnly) {
const extra = query.year ? albumYearSubsonicParams(query.year) : {};
const data = applyNetworkPostFilters(
await getAlbumList('starred', pageSize, offset, extra),
serverId
? await getAlbumListForServer(serverId, 'starred', pageSize, offset, extra)
: await getAlbumList('starred', pageSize, offset, extra),
query,
);
return { albums: data, hasMore: data.length === pageSize };
@@ -53,19 +62,29 @@ export async function fetchAlbumBrowseNetwork(
if (query.year) {
const data = applyNetworkPostFilters(
await getAlbumList(
'byYear',
pageSize,
offset,
albumYearSubsonicParams(query.year),
),
serverId
? await getAlbumListForServer(
serverId,
'byYear',
pageSize,
offset,
albumYearSubsonicParams(query.year),
)
: await getAlbumList(
'byYear',
pageSize,
offset,
albumYearSubsonicParams(query.year),
),
query,
);
return { albums: data, hasMore: data.length === pageSize };
}
const data = applyNetworkPostFilters(
await getAlbumList(albumListFetchType(query.sort), pageSize, offset, {}),
serverId
? await getAlbumListForServer(serverId, albumListFetchType(query.sort), pageSize, offset, {})
: await getAlbumList(albumListFetchType(query.sort), pageSize, offset, {}),
query,
);
return { albums: data, hasMore: data.length === pageSize };
+23 -8
View File
@@ -4,7 +4,11 @@
import { getStarred } from '@/lib/api/subsonicStarRating';
import { getArtists } from '@/lib/api/subsonicArtists';
import type { ArtistCreditMode } from '@/lib/api/library';
import { search, searchSongsPaged } from '@/lib/api/subsonicSearch';
import {
search,
searchSongsPaged,
searchSongsPagedForServer,
} from '@/lib/api/subsonicSearch';
import type { SearchResults, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
import {
libraryAdvancedSearch,
@@ -45,7 +49,7 @@ import {
import { artistBrowseTimed, emitArtistsBrowseDebug } from './artistBrowseDebug';
import { raceSearchSources, type SearchRaceWinner } from './searchRace';
import type { LibraryScopePair } from '@/lib/api/library/scopeReads';
import { getLibraryBrowseScope } from './libraryBrowseScope';
import { getLibraryBrowseScope, type LibraryBrowseScope } from './libraryBrowseScope';
export type { LibrarySearchSurface };
@@ -324,17 +328,21 @@ export async function runLocalBrowseSongPage(
query: string,
offset: number,
pageSize: number,
browseScope: LibraryBrowseScope = getLibraryBrowseScope(),
): Promise<SubsonicSong[] | null> {
if (!serverId) return null;
const readyScope = await resolveReadyLibraryBrowseScope(serverId, getLibraryBrowseScope());
const readyScope = await resolveReadyLibraryBrowseScope(
browseScope.anchorServerId ?? serverId,
browseScope,
);
if (!readyScope) return null;
const q = query.trim();
if (!q) return null;
try {
const resp = await libraryAdvancedSearch({
serverId: readyScope.anchorServerKey,
libraryScope: readyScope.pairs.length > 0 ? undefined : libraryScopeForServer(serverId),
libraryScopes: readyScope.pairs.length > 0 ? readyScope.pairs : libraryScopePairsForServer(serverId),
libraryScope: readyScope.pairs.length > 0 ? undefined : libraryScopeForServer(readyScope.anchorServerKey),
libraryScopes: readyScope.pairs.length > 0 ? readyScope.pairs : libraryScopePairsForServer(readyScope.anchorServerKey),
query: q,
entityTypes: ['track'],
limit: pageSize,
@@ -353,11 +361,14 @@ export async function runNetworkBrowseSongPage(
query: string,
offset: number,
pageSize: number,
serverId?: string | null,
): Promise<SubsonicSong[] | null> {
const q = query.trim();
if (!q) return null;
try {
return await searchSongsPaged(q, pageSize, offset);
return serverId
? await searchSongsPagedForServer(serverId, q, pageSize, offset)
: await searchSongsPaged(q, pageSize, offset);
} catch {
return null;
}
@@ -368,6 +379,7 @@ export async function runLocalBrowseFullSearch(
serverId: string | null | undefined,
query: string,
songsLimit: number,
browseScope: LibraryBrowseScope = getLibraryBrowseScope(),
): Promise<SearchResults | null> {
const page = await runLocalAdvancedSearch(
serverId,
@@ -375,6 +387,7 @@ export async function runLocalBrowseFullSearch(
songsLimit,
true,
true,
browseScope,
);
if (!page) return null;
return {
@@ -388,9 +401,10 @@ export async function runLocalBrowseFullSearch(
export async function runNetworkBrowseFullSearch(
query: string,
songsLimit: number,
serverId?: string | null,
): Promise<SearchResults | null> {
try {
const page = await runNetworkAdvancedTextSearch(fullSearchOpts(query), songsLimit);
const page = await runNetworkAdvancedTextSearch(fullSearchOpts(query), songsLimit, serverId);
if (!page) return null;
return {
artists: page.artists,
@@ -408,8 +422,9 @@ export async function loadMoreLocalBrowseSongs(
query: string,
offset: number,
pageSize: number,
browseScope: LibraryBrowseScope = getLibraryBrowseScope(),
): Promise<SubsonicSong[]> {
return loadMoreLocalSongs(serverId, songBrowseOpts(query), offset, pageSize);
return loadMoreLocalSongs(serverId, songBrowseOpts(query), offset, pageSize, browseScope);
}
export type { AlbumBrowseSort } from './albumBrowseSort';
+18 -1
View File
@@ -111,8 +111,25 @@ describe('getLibraryBrowseScope', () => {
anchorServerId: 'active',
serverIds: ['active'],
pairs: [],
fingerprint: '',
fingerprint: JSON.stringify([['active', ['music']]]),
multiServer: false,
});
});
it('includes fallback library selection in the scope fingerprint', () => {
const state = {
servers: [{ id: 'active' }],
activeServerId: 'active',
libraryBrowseServerIds: [],
musicFoldersByServer: { active: [{ id: 'one' }, { id: 'two' }] },
libraryBrowseSelectionByServer: { active: ['two'] },
};
expect(deriveLibraryBrowseScope(state).fingerprint)
.toBe(JSON.stringify([['active', ['two']]]));
expect(deriveLibraryBrowseScope({
...state,
libraryBrowseSelectionByServer: { active: ['one'] },
}).fingerprint).toBe(JSON.stringify([['active', ['one']]]));
});
});
+18 -11
View File
@@ -107,19 +107,26 @@ export function deriveLibraryBrowseScope(
}
}
const fallbackServerIds = orderedServerIds.length === 0
? deriveEffectiveLibraryBrowseServerIds(state, unavailableServerIds)
: [];
const scopeServerIds = effectiveServerIds.length > 0 ? effectiveServerIds : fallbackServerIds;
const fallbackFingerprintEntries = fallbackServerIds.map(serverId => {
const folders = state.musicFoldersByServer[serverId] ?? [];
const selection = state.libraryBrowseSelectionByServer[serverId] ?? [];
return [serverId, selection.length > 0 ? selection : folders.map(folder => folder.id)] as const;
});
const fingerprint = fingerprintEntries.length > 0
? JSON.stringify(fingerprintEntries)
: (fallbackFingerprintEntries.length > 0
? JSON.stringify(fallbackFingerprintEntries)
: '');
return {
anchorServerId: effectiveServerIds[0]
?? (orderedServerIds.length === 0
? deriveEffectiveLibraryBrowseServerIds(state, unavailableServerIds)[0]
: undefined)
?? null,
serverIds: effectiveServerIds.length > 0
? effectiveServerIds
: (orderedServerIds.length === 0
? deriveEffectiveLibraryBrowseServerIds(state, unavailableServerIds)
: []),
anchorServerId: scopeServerIds[0] ?? null,
serverIds: scopeServerIds,
pairs,
fingerprint: fingerprintEntries.length > 0 ? JSON.stringify(fingerprintEntries) : '',
fingerprint,
multiServer: effectiveServerIds.length > 1,
};
}
+30
View File
@@ -7,6 +7,7 @@ import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import {
libraryStatusIsReady,
readyLibraryServerKeys,
resolveReadyLibraryBrowseScope,
syncIngestDisplayCount,
} from './libraryReady';
@@ -115,6 +116,35 @@ describe('readyLibraryServerKeys', () => {
await expect(readyLibraryServerKeys(['a', 'b'])).resolves.toBeNull();
});
it('uses the selected browse anchor instead of an unrelated active server', async () => {
onInvoke('library_get_status', args => status({
serverId: (args as { serverId: string }).serverId,
syncPhase: 'ready',
}));
await expect(resolveReadyLibraryBrowseScope('a', {
anchorServerId: 'b',
serverIds: ['b'],
pairs: [{ serverId: 'b', libraryId: 'lib-b' }],
fingerprint: 'b',
multiServer: false,
})).resolves.toEqual({
anchorServerKey: 'b.test',
serverKeys: ['b.test'],
pairs: [{ serverId: 'b.test', libraryId: 'lib-b' }],
});
});
it('does not substitute the active server for an empty effective scope', async () => {
await expect(resolveReadyLibraryBrowseScope('a', {
anchorServerId: null,
serverIds: [],
pairs: [],
fingerprint: '',
multiServer: false,
})).resolves.toBeNull();
});
});
describe('syncIngestDisplayCount', () => {
+5 -3
View File
@@ -83,10 +83,12 @@ export type ReadyLibraryBrowseScope = {
/** Resolve an all-or-nothing local scope so selected servers are never silently omitted. */
export async function resolveReadyLibraryBrowseScope(
anchorServerId: string,
_anchorServerId: string,
scope: LibraryBrowseScope,
): Promise<ReadyLibraryBrowseScope | null> {
const requestedServerIds = scope.serverIds.length > 0 ? scope.serverIds : [anchorServerId];
const effectiveAnchorServerId = scope.anchorServerId;
if (!effectiveAnchorServerId) return null;
const requestedServerIds = scope.serverIds.length > 0 ? scope.serverIds : [effectiveAnchorServerId];
const serverKeys = await readyLibraryServerKeys(requestedServerIds);
if (!serverKeys) return null;
const readySet = new Set(serverKeys);
@@ -99,7 +101,7 @@ export async function resolveReadyLibraryBrowseScope(
const represented = new Set(pairs.map(pair => pair.serverId));
if (serverKeys.some(serverKey => !represented.has(serverKey))) return null;
}
const anchorServerKey = resolveIndexKey(anchorServerId);
const anchorServerKey = resolveIndexKey(effectiveAnchorServerId);
if (!readySet.has(anchorServerKey)) return null;
return { anchorServerKey, serverKeys, pairs };
}
+45
View File
@@ -165,6 +165,33 @@ describe('runLocalLiveSearch', () => {
await expect(runLocalLiveSearch('a', 'foo', neverStale)).resolves.toBeNull();
expect(invoked).toBe(false);
});
it('queries the selected owner when the active server is outside the browse scope', async () => {
useAuthStore.setState({
servers: [
{ id: 'a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
{ id: 'b', name: 'B', url: 'https://b.test', username: 'u', password: 'p' },
],
activeServerId: 'a',
libraryBrowseServerIds: ['b'],
musicFoldersByServer: { b: [{ id: 'lib-b', name: 'B' }] },
libraryBrowseSelectionByServer: {},
});
let captured: unknown;
onInvoke('library_live_search', args => {
captured = args;
return { artists: [], albums: [], tracks: [], source: 'local' };
});
await runLocalLiveSearch('a', 'foo', neverStale);
expect(captured).toMatchObject({
request: {
serverId: 'b.test',
libraryScopes: [{ serverId: 'b.test', libraryId: 'lib-b' }],
},
});
});
});
describe('liveSearchQueryRejected', () => {
@@ -201,4 +228,22 @@ describe('mergeLiveSearchResults', () => {
expect(merged.albums.map(a => a.id)).toEqual(['al1']);
expect(merged.songs.map(s => s.id)).toEqual(['s1', 's2']);
});
it('preserves equal raw ids owned by different servers', () => {
const primary: SearchResults = {
artists: [{ serverId: 'a', id: 'same', name: 'A' }],
albums: [],
songs: [],
};
const supplement: SearchResults = {
artists: [{ serverId: 'b', id: 'same', name: 'B' }],
albums: [],
songs: [],
};
expect(mergeLiveSearchResults(primary, supplement).artists).toEqual([
{ serverId: 'a', id: 'same', name: 'A' },
{ serverId: 'b', id: 'same', name: 'B' },
]);
});
});
+24 -12
View File
@@ -4,7 +4,7 @@
* Falls back to search3 when the index isn't ready (caller orchestrates).
*/
import type { SearchResults } from '@/lib/api/subsonicTypes';
import { search } from '@/lib/api/subsonicSearch';
import { search, searchForServer } from '@/lib/api/subsonicSearch';
import { libraryScopeForServer, libraryScopePairsForServer } from '@/lib/api/subsonicClient';
import { libraryLiveSearch } from '@/lib/api/library';
import { filterSearchArtistsWithNoAlbums } from '@/lib/api/subsonicSearch';
@@ -15,8 +15,9 @@ import {
} from './advancedSearchLocal';
import { logLibrarySearch, timed } from './libraryDevLog';
import { searchQueryIsFtsSafe } from './searchQueryFtsSafe';
import { getLibraryBrowseScope } from './libraryBrowseScope';
import { getLibraryBrowseScope, type LibraryBrowseScope } from './libraryBrowseScope';
import { resolveReadyLibraryBrowseScope } from './libraryReady';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
export const LIVE_SEARCH_DEBOUNCE_LOCAL_MS = 200;
export const LIVE_SEARCH_DEBOUNCE_NETWORK_MS = 300;
@@ -69,11 +70,16 @@ export async function runLocalLiveSearch(
serverId: string | null | undefined,
query: string,
ctx: LiveSearchRunContext,
browseScope: LibraryBrowseScope = getLibraryBrowseScope(),
): Promise<SearchResults | null> {
if (!serverId || ctx.isStale()) return null;
const q = query.trim();
if (liveSearchQueryRejected(q)) return null;
const readyScope = await resolveReadyLibraryBrowseScope(serverId, getLibraryBrowseScope());
if (!browseScope.anchorServerId) return null;
const readyScope = await resolveReadyLibraryBrowseScope(
browseScope.anchorServerId ?? serverId,
browseScope,
);
if (!readyScope || ctx.isStale()) return null;
const t0 = performance.now();
try {
@@ -81,8 +87,8 @@ export async function runLocalLiveSearch(
libraryLiveSearch({
serverId: readyScope.anchorServerKey,
query: q,
libraryScope: readyScope.pairs.length > 0 ? undefined : libraryScopeForServer(serverId),
libraryScopes: readyScope.pairs.length > 0 ? readyScope.pairs : libraryScopePairsForServer(serverId),
libraryScope: readyScope.pairs.length > 0 ? undefined : libraryScopeForServer(readyScope.anchorServerKey),
libraryScopes: readyScope.pairs.length > 0 ? readyScope.pairs : libraryScopePairsForServer(readyScope.anchorServerKey),
artistLimit: ARTIST_LIMIT,
albumLimit: ALBUM_LIMIT,
songLimit: SONG_LIMIT,
@@ -143,17 +149,20 @@ export const EMPTY_SEARCH_RESULTS: SearchResults = {
export async function runNetworkLiveSearch(
query: string,
signal?: AbortSignal,
serverId?: string | null,
): Promise<SearchResults | null> {
const q = query.trim();
if (liveSearchQueryRejected(q)) return null;
try {
return await search(q, {
signal,
const options = {
timeout: LIVE_SEARCH_NETWORK_TIMEOUT_MS,
artistCount: LIVE_SEARCH_LIMITS.artists,
albumCount: LIVE_SEARCH_LIMITS.albums,
songCount: LIVE_SEARCH_LIMITS.songs,
});
};
return serverId
? await searchForServer(serverId, q, options)
: await search(q, { ...options, signal });
} catch (err) {
const name = err instanceof Error ? err.name : '';
if (name === 'CanceledError' || name === 'AbortError') return null;
@@ -167,17 +176,20 @@ export function mergeLiveSearchResults(
supplement: SearchResults,
limits: { artists: number; albums: number; songs: number } = LIVE_SEARCH_LIMITS,
): SearchResults {
const mergeSlice = <T extends { id: string }>(
const mergeSlice = <T extends { id: string; serverId?: string }>(
primaryItems: T[],
extraItems: T[],
limit: number,
): T[] => {
const seen = new Set(primaryItems.map(i => i.id));
const seen = new Set(primaryItems.map(ownedEntityKey));
const seenRawIds = new Set(primaryItems.map(i => i.id));
const out = [...primaryItems];
for (const item of extraItems) {
if (out.length >= limit) break;
if (seen.has(item.id)) continue;
seen.add(item.id);
const key = ownedEntityKey(item);
if (seen.has(key) || (!item.serverId && seenRawIds.has(item.id))) continue;
seen.add(key);
seenRawIds.add(item.id);
out.push(item);
}
return out;
@@ -88,6 +88,8 @@ describe('advancedSearchScrollSnapshot', () => {
JSON.stringify({ scrollTop: 100, albumRowScrollLeft: 80, artistRowScrollLeft: 55 }),
);
expect(resolveAdvancedSearchLeaveSnapshot({
browseScopeFingerprint: 'scope-a',
librarySyncRevision: 0,
query: '',
genre: '',
yearFrom: '',
@@ -122,6 +124,8 @@ describe('advancedSearchScrollSnapshot', () => {
document.body.appendChild(viewport);
const unregister = registerAdvancedSearchSessionProvider(() => ({
browseScopeFingerprint: 'scope-a',
librarySyncRevision: 0,
query: 'rock',
genre: 'Jazz',
yearFrom: '',
@@ -38,6 +38,19 @@ describe('albumDetailNavigation', () => {
expect(navigate).toHaveBeenCalledWith('/album/alb-1', { state: { returnTo: '/artist/a' } });
});
it('preserves the owning server when navigating to an album', () => {
const navigate = vi.fn();
navigateToAlbumDetail(
navigate,
{ pathname: '/search', search: '?q=album', hash: '', state: null },
'alb-1',
{ serverId: 'srv-b' },
);
expect(navigate).toHaveBeenCalledWith('/album/alb-1?server=srv-b', {
state: { returnTo: '/search?q=album' },
});
});
it('preserves returnTo when opening a related album', () => {
const navigate = vi.fn();
navigateToAlbumDetail(
@@ -220,6 +233,8 @@ describe('albumDetailNavigation', () => {
it('skips main scroll reset when Advanced Search return stash carries scrollTop', () => {
useAdvancedSearchSessionStore.getState().stashReturnSession({
browseScopeFingerprint: 'scope-a',
librarySyncRevision: 0,
query: 'jazz',
genre: '',
yearFrom: '',
+5 -4
View File
@@ -13,6 +13,7 @@ import {
saveAdvancedSearchLeaveSnapshot,
} from '@/lib/navigation/advancedSearchScrollSnapshot';
import {
buildAlbumDetailPath,
buildArtistDetailPath,
buildComposerDetailPath,
type ArtistDetailPathOptions,
@@ -178,13 +179,13 @@ export function navigateToAlbumDetail(
navigate: NavigateFunction,
location: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>,
albumId: string,
opts?: { search?: string },
opts?: ArtistDetailPathOptions,
): void {
saveSearchLeaveIfNeeded(location);
const returnTo = buildReturnTo(location);
const raw = opts?.search ?? '';
const qs = raw ? (raw.startsWith('?') ? raw : `?${raw}`) : '';
navigate(`/album/${albumId}${qs}`, { state: { returnTo } satisfies AlbumDetailLocationState });
navigate(buildAlbumDetailPath(albumId, opts), {
state: { returnTo } satisfies AlbumDetailLocationState,
});
}
export function navigateToArtistDetail(
+3 -1
View File
@@ -30,7 +30,9 @@ export function resolveStorageServerIndexKey(serverIdOrKey: string): string | nu
}
export function resolveIndexKey(serverIdOrKey: string): string {
const server = useAuthStore.getState().servers.find(s => s.id === serverIdOrKey);
const servers = useAuthStore.getState().servers;
if (!servers) return serverIdOrKey;
const server = servers.find(s => s.id === serverIdOrKey);
if (!server) return serverIdOrKey;
return serverIndexKeyFromUrl(server.url) || serverIdOrKey;
}
+2 -1
View File
@@ -1,3 +1,5 @@
import { ownedEntityKey } from './ownedEntityKey';
/**
* Keeps the first occurrence of each `id`. Subsonic responses (and merged pages)
* occasionally repeat the same album/song id; duplicate React keys then warn and
@@ -14,4 +16,3 @@ export function dedupeById<T extends { id: string; serverId?: string }>(items: T
}
return out;
}
import { ownedEntityKey } from './ownedEntityKey';
+2
View File
@@ -27,6 +27,8 @@ export type AdvancedSearchResultsStash = {
/** Session snapshot when leaving Search → album/artist detail. */
export type AdvancedSearchSessionStash = AdvancedSearchFormStash & {
browseScopeFingerprint: string;
librarySyncRevision: number;
results: AdvancedSearchResultsStash | null;
hasSearched: boolean;
activeSearch: AdvancedSearchQueryStash | null;
+16 -2
View File
@@ -1,4 +1,17 @@
/* ─── Recent searches ─── */
.mobile-search-recent-apply {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
padding: 0;
background: none;
border: none;
cursor: pointer;
text-align: left;
}
.mobile-search-recent-remove {
display: flex;
align-items: center;
@@ -6,7 +19,9 @@
background: none;
border: none;
color: var(--text-muted);
padding: 6px;
width: 44px;
height: 44px;
padding: 0;
cursor: pointer;
flex-shrink: 0;
opacity: 0.6;
@@ -15,4 +30,3 @@
.mobile-search-recent-remove:hover {
opacity: 1;
}