mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat(search): respect selected library scope
This commit is contained in:
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 effect’s 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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 effect’s 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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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']]]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user