fix(library): preserve multi-server ownership

This commit is contained in:
cucadmuh
2026-07-18 00:59:41 +03:00
parent e53d5fb350
commit 15e9fc498d
31 changed files with 439 additions and 224 deletions
@@ -5,10 +5,6 @@ const libraryScopeAlbumDetailMock = vi.fn();
vi.mock('@/lib/api/library/scopeReads', () => ({
libraryScopeAlbumDetail: (...args: unknown[]) => libraryScopeAlbumDetailMock(...args),
scopePairsFromLibrarySelection: (serverId: string) => [
{ serverId: `${serverId}-idx`, libraryId: 'lib-a' },
{ serverId: `${serverId}-idx`, libraryId: 'lib-b' },
],
}));
import { tryLoadAlbumDetailMultiScope } from './loadAlbumDetailMultiScope';
@@ -53,12 +49,16 @@ describe('tryLoadAlbumDetailMultiScope', () => {
tracks: [trackDto(), trackDto({ id: 'trk-2', title: 'Track Two', trackNumber: 2 })],
});
const result = await tryLoadAlbumDetailMultiScope('srv-1', 'alb-1');
const scopes = [
{ serverId: 'srv-1', libraryId: 'lib-a' },
{ serverId: 'srv-2', libraryId: 'lib-b' },
];
const result = await tryLoadAlbumDetailMultiScope(scopes, 'srv-1', 'alb-1');
expect(libraryScopeAlbumDetailMock).toHaveBeenCalledWith('srv-1', {
scopes: [
{ serverId: 'srv-1-idx', libraryId: 'lib-a' },
{ serverId: 'srv-1-idx', libraryId: 'lib-b' },
{ serverId: 'srv-1', libraryId: 'lib-a' },
{ serverId: 'srv-2', libraryId: 'lib-b' },
],
albumId: 'alb-1',
serverId: 'srv-1',
@@ -74,12 +74,12 @@ describe('tryLoadAlbumDetailMultiScope', () => {
tracks: [],
});
await expect(tryLoadAlbumDetailMultiScope('srv-1', 'alb-1')).resolves.toBeNull();
await expect(tryLoadAlbumDetailMultiScope([], 'srv-1', 'alb-1')).resolves.toBeNull();
});
it('returns null when the scope command throws', async () => {
libraryScopeAlbumDetailMock.mockRejectedValue(new Error('ipc fail'));
await expect(tryLoadAlbumDetailMultiScope('srv-1', 'alb-1')).resolves.toBeNull();
await expect(tryLoadAlbumDetailMultiScope([], 'srv-1', 'alb-1')).resolves.toBeNull();
});
});
@@ -1,6 +1,6 @@
import {
libraryScopeAlbumDetail,
scopePairsFromLibrarySelection,
type LibraryScopePair,
} from '@/lib/api/library/scopeReads';
import { albumToAlbum, trackToSong } from '@/lib/library/advancedSearchLocal';
import type { ResolvedAlbum } from '@/features/offline';
@@ -10,12 +10,13 @@ import type { ResolvedAlbum } from '@/features/offline';
* (one or more). Returns null on IPC failure or when the merged album anchor is missing.
*/
export async function tryLoadAlbumDetailMultiScope(
scopes: LibraryScopePair[],
serverId: string,
albumId: string,
): Promise<ResolvedAlbum | null> {
try {
const response = await libraryScopeAlbumDetail(serverId, {
scopes: scopePairsFromLibrarySelection(serverId),
scopes,
albumId,
serverId,
});
@@ -6,20 +6,11 @@ import { useAuthStore } from '@/store/authStore';
const tryLoadAlbumDetailMultiScopeMock = vi.fn();
const resolveAlbumMock = vi.fn();
const librarySelectionForServerMock = vi.fn();
vi.mock('@/features/album/hooks/loadAlbumDetailMultiScope', () => ({
tryLoadAlbumDetailMultiScope: (...args: unknown[]) => tryLoadAlbumDetailMultiScopeMock(...args),
}));
vi.mock('@/lib/api/subsonicClient', async importOriginal => {
const actual = await importOriginal<typeof import('@/lib/api/subsonicClient')>();
return {
...actual,
librarySelectionForServer: (...args: unknown[]) => librarySelectionForServerMock(...args),
};
});
vi.mock('@/features/offline', () => ({
resolveAlbum: (...args: unknown[]) => resolveAlbumMock(...args),
resolveArtist: vi.fn().mockResolvedValue(null),
@@ -48,13 +39,20 @@ describe('useAlbumDetailData — multi-library selection', () => {
beforeEach(() => {
tryLoadAlbumDetailMultiScopeMock.mockReset();
resolveAlbumMock.mockReset();
librarySelectionForServerMock.mockReset();
useAuthStore.setState({
activeServerId: 'srv-1',
servers: [{ id: 'srv-1', name: 'S', url: 'https://s.test', username: 'u', password: 'p' }],
servers: [
{ id: 'srv-1', name: 'S1', url: 'https://s1.test', username: 'u', password: 'p' },
{ id: 'srv-2', name: 'S2', url: 'https://s2.test', username: 'u', password: 'p' },
],
favoritesOfflineEnabled: false,
musicLibrarySelectionByServer: { 'srv-1': ['lib-a', 'lib-b'] },
musicLibraryFilterVersion: 0,
musicFoldersByServer: {
'srv-1': [{ id: 'lib-a', name: 'A' }],
'srv-2': [{ id: 'lib-b', name: 'B' }],
},
libraryBrowseServerIds: ['srv-1', 'srv-2'],
libraryBrowseSelectionByServer: { 'srv-1': ['lib-a'], 'srv-2': ['lib-b'] },
libraryBrowseScopeVersion: 0,
});
});
@@ -62,39 +60,44 @@ describe('useAlbumDetailData — multi-library selection', () => {
vi.clearAllMocks();
});
it('loads via tryLoadAlbumDetailMultiScope when more than one library is selected', async () => {
it('loads via the authoritative cross-server browse scope', async () => {
tryLoadAlbumDetailMultiScopeMock.mockResolvedValue({
album: { id: 'alb-1', name: 'Merged', artistId: 'art-1', songs: [] },
songs: [{ id: 'trk-1', title: 'One' }],
});
librarySelectionForServerMock.mockReturnValue(['lib-a', 'lib-b']);
const { result } = renderHook(() => useAlbumDetailData('alb-1'), { wrapper: routerWrapper });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(tryLoadAlbumDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'alb-1');
expect(tryLoadAlbumDetailMultiScopeMock).toHaveBeenCalledWith([
{ serverId: 'srv-1', libraryId: 'lib-a' },
{ serverId: 'srv-2', libraryId: 'lib-b' },
], 'srv-1', 'alb-1');
expect(resolveAlbumMock).not.toHaveBeenCalled();
expect(result.current.album?.album).toMatchObject({ id: 'alb-1', name: 'Merged' });
expect(result.current.album?.songs).toHaveLength(1);
});
it('loads via tryLoadAlbumDetailMultiScope when one library is selected', async () => {
it('loads via the authoritative scope when one folder is selected', async () => {
useAuthStore.setState({
libraryBrowseServerIds: ['srv-1'],
libraryBrowseSelectionByServer: { 'srv-1': ['lib-a'] },
});
tryLoadAlbumDetailMultiScopeMock.mockResolvedValue({
album: { id: 'alb-1', name: 'Scoped', artistId: 'art-1', songs: [] },
songs: [{ id: 'trk-1', title: 'One' }],
});
librarySelectionForServerMock.mockReturnValue(['sampler']);
const { result } = renderHook(() => useAlbumDetailData('alb-1'), { wrapper: routerWrapper });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(tryLoadAlbumDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'alb-1');
expect(tryLoadAlbumDetailMultiScopeMock).toHaveBeenCalledWith([
{ serverId: 'srv-1', libraryId: 'lib-a' },
], 'srv-1', 'alb-1');
expect(resolveAlbumMock).not.toHaveBeenCalled();
expect(result.current.album?.album).toMatchObject({ name: 'Scoped' });
});
it('does not call tryLoadAlbumDetailMultiScope when all libraries are selected', async () => {
librarySelectionForServerMock.mockReturnValue([]);
it('uses the direct resolver when no concrete browse scope is configured', async () => {
useAuthStore.setState({ musicFoldersByServer: {}, libraryBrowseServerIds: [] });
resolveAlbumMock.mockResolvedValue({
album: { id: 'alb-1', name: 'Single' },
songs: [],
@@ -108,8 +111,7 @@ describe('useAlbumDetailData — multi-library selection', () => {
expect(result.current.album?.album).toMatchObject({ id: 'alb-1', name: 'Single' });
});
it('falls through to resolveAlbum when multi-scope load returns null', async () => {
librarySelectionForServerMock.mockReturnValue(['lib-a', 'lib-b']);
it('does not escape the authoritative scope when the scoped lookup misses', async () => {
tryLoadAlbumDetailMultiScopeMock.mockResolvedValue(null);
resolveAlbumMock.mockResolvedValue({
album: { id: 'alb-1', name: 'Fallback' },
@@ -120,7 +122,7 @@ describe('useAlbumDetailData — multi-library selection', () => {
await waitFor(() => expect(result.current.loading).toBe(false));
expect(tryLoadAlbumDetailMultiScopeMock).toHaveBeenCalled();
expect(resolveAlbumMock).toHaveBeenCalled();
expect(result.current.album?.album).toMatchObject({ name: 'Fallback' });
expect(resolveAlbumMock).not.toHaveBeenCalled();
expect(result.current.album).toBeNull();
});
});
+23 -8
View File
@@ -22,8 +22,10 @@ import {
shouldAttemptSubsonicForActiveServer,
shouldAttemptSubsonicForServer,
} from '@/lib/network/subsonicNetworkGuard';
import { librarySelectionForServer } from '@/lib/api/subsonicClient';
import { tryLoadAlbumDetailMultiScope } from '@/features/album/hooks/loadAlbumDetailMultiScope';
import { tryLoadArtistDetailMultiScope } from '@/lib/library/loadArtistDetailMultiScope';
import { getLibraryBrowseScope } from '@/lib/library/libraryBrowseScope';
import type { LibraryScopePair } from '@/lib/api/library/scopeReads';
type AlbumPayload = ResolvedAlbum;
@@ -48,8 +50,7 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const activeServerId = useAuthStore(s => s.activeServerId);
const musicLibrarySelectionByServer = useAuthStore(s => s.musicLibrarySelectionByServer);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const libraryBrowseScopeVersion = useAuthStore(s => s.libraryBrowseScopeVersion);
const [searchParams] = useSearchParams();
const detailServerId = readDetailServerId(searchParams, activeServerId);
const offlineBrowseActive = useOfflineBrowseContext().active && !!detailServerId;
@@ -74,9 +75,15 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
artistId: string | undefined,
useLocalArtist: boolean,
localBytesOnly: boolean,
scopes?: LibraryScopePair[],
) => {
if (!artistId) return;
try {
if (serverId && scopes?.length) {
const scoped = await tryLoadArtistDetailMultiScope(scopes, serverId, artistId);
if (scoped) setRelatedAlbums(scoped.albums.filter(a => a.id !== id));
return;
}
if (useLocalArtist && serverId) {
const artistLocal = localBytesOnly
? await loadArtistFromLocalPlayback(serverId, artistId)
@@ -98,6 +105,7 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
};
void (async () => {
const browseScope = getLibraryBrowseScope();
if (offlineBrowseActive && detailServerId) {
const local = await resolveAlbum(detailServerId, id);
if (local) {
@@ -114,13 +122,21 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
return;
}
if (detailServerId && librarySelectionForServer(detailServerId).length > 0) {
const multi = await tryLoadAlbumDetailMultiScope(detailServerId, id);
if (detailServerId && browseScope.pairs.length > 0) {
const multi = await tryLoadAlbumDetailMultiScope(browseScope.pairs, detailServerId, id);
if (multi) {
applyAlbumPayload(multi);
await loadRelatedAlbums(detailServerId, multi.album.artistId, true, false);
await loadRelatedAlbums(
multi.album.serverId ?? detailServerId,
multi.album.artistId,
true,
false,
browseScope.pairs,
);
return;
}
setLoading(false);
return;
}
// Index-first when the local SQLite index is ready, not only when the
@@ -191,8 +207,7 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
detailServerId,
favoritesOfflineEnabled,
id,
musicLibraryFilterVersion,
musicLibrarySelectionByServer,
libraryBrowseScopeVersion,
offlineBrowseActive,
searchParams,
]);
+15 -14
View File
@@ -1,4 +1,4 @@
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
import { buildDownloadUrlForServer } from '@/lib/api/subsonicStreamUrl';
import { resolveAlbum } from '@/features/offline';
import { songToTrack } from '@/lib/media/songToTrack';
import { useState, useEffect, useLayoutEffect, useRef, useMemo, useSyncExternalStore } from 'react';
@@ -63,13 +63,10 @@ import {
} from '@/lib/library/albumBrowseDebug';
import { librarySelectionForServer } from '@/lib/api/subsonicClient';
import { usePsyLabDebugTraces } from '@/lib/perf/psyLabDebugTraces';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
type SortType = AlbumBrowseSort;
function albumEntityKey(album: { id: string; serverId?: string }): string {
return album.serverId ? `${album.serverId}:${album.id}` : album.id;
}
function sanitizeFilename(name: string): string {
// eslint-disable-next-line no-control-regex -- intentional: strip control chars for safe download filenames
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
@@ -284,7 +281,10 @@ export default function Albums() {
// `displayAlbums` — visible grid slice (local index) or loaded SQL pages (network).
const [selectionMode, setSelectionMode] = useState(false);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(displayAlbums);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(
displayAlbums,
ownedEntityKey,
);
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
@@ -296,7 +296,7 @@ export default function Albums() {
resetSelection();
};
const selectedAlbums = displayAlbums.filter(a => selectedIds.has(albumEntityKey(a)));
const selectedAlbums = displayAlbums.filter(a => selectedIds.has(ownedEntityKey(a)));
const enqueue = usePlayerStore(state => state.enqueue);
const handleEnqueueSelected = async () => {
@@ -304,7 +304,7 @@ export default function Albums() {
try {
// Parallel album resolves — Navidrome handles concurrent requests fine.
const results = await Promise.all(
selectedAlbums.map(a => resolveAlbum(serverId, a.id).catch(() => null)),
selectedAlbums.map(a => resolveAlbum(a.serverId ?? serverId, a.id).catch(() => null)),
);
const tracks = results.flatMap(r => r ? r.songs.map(songToTrack) : []);
if (tracks.length > 0) {
@@ -330,7 +330,7 @@ export default function Albums() {
const downloadId = crypto.randomUUID();
const filename = `${sanitizeFilename(album.name)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(album.id);
const url = buildDownloadUrlForServer(album.serverId ?? serverId, album.id);
start(downloadId, filename);
try {
await downloadZip({ id: downloadId, url, destPath });
@@ -348,9 +348,10 @@ export default function Albums() {
let queued = 0;
for (const album of selectedAlbums) {
try {
const detail = await resolveAlbum(serverId, album.id);
const ownerServerId = album.serverId ?? serverId;
const detail = await resolveAlbum(ownerServerId, album.id);
if (!detail) throw new Error('album unavailable');
downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId);
downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, ownerServerId);
queued++;
} catch {
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
@@ -606,7 +607,7 @@ export default function Albums() {
<div ref={gridMeasureRef}>
<VirtualCardGrid
items={displayAlbums}
itemKey={(a, _i) => albumEntityKey(a)}
itemKey={(a, _i) => ownedEntityKey(a)}
rowVariant="album"
disableVirtualization={albumBrowsePlainLayout}
layoutSignal={displayAlbums.length}
@@ -622,8 +623,8 @@ export default function Albums() {
observeScrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
linkQuery={losslessOnly ? LOSSLESS_MODE_QUERY : undefined}
selectionMode={selectionMode}
selected={selectedIds.has(albumEntityKey(a))}
onToggleSelect={(_id, opts) => toggleSelect(albumEntityKey(a), opts)}
selected={selectedIds.has(ownedEntityKey(a))}
onToggleSelect={(_id, opts) => toggleSelect(ownedEntityKey(a), opts)}
selectedAlbums={selectedAlbums}
/>
)}
+9 -5
View File
@@ -16,6 +16,7 @@ import { join } from '@tauri-apps/api/path';
import { showToast } from '@/lib/dom/toast';
import { useZipDownloadStore } from '@/features/offline';
import { useRangeSelection } from '@/lib/hooks/useRangeSelection';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
import { useMainstageInpageHeaderTight } from '@/lib/hooks/useMainstageInpageHeaderTight';
import { albumGridWarmCovers } from '@/cover/layoutSizes';
@@ -151,11 +152,14 @@ export default function NewReleases() {
selectedGenres,
]);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(displayAlbums);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(
displayAlbums,
ownedEntityKey,
);
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
const clearSelection = () => { setSelectionMode(false); resetSelection(); };
const selectedAlbums = displayAlbums.filter(a => selectedIds.has(a.id));
const selectedAlbums = displayAlbums.filter(a => selectedIds.has(ownedEntityKey(a)));
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
@@ -336,7 +340,7 @@ export default function NewReleases() {
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
<VirtualCardGrid
items={displayAlbums}
itemKey={(a, _i) => a.id}
itemKey={(a, _i) => ownedEntityKey(a)}
rowVariant="album"
disableVirtualization={albumBrowsePlainLayout}
layoutSignal={displayAlbums.length}
@@ -347,8 +351,8 @@ export default function NewReleases() {
album={a}
observeScrollRootId={NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selected={selectedIds.has(ownedEntityKey(a))}
onToggleSelect={(_id, opts) => toggleSelect(ownedEntityKey(a), opts)}
selectedAlbums={selectedAlbums}
/>
)}
@@ -14,13 +14,21 @@ function album(id: string, created: string, serverId = 's1'): SubsonicAlbum {
describe('hot New Releases overlay', () => {
beforeEach(() => getAlbumListForServer.mockReset());
it('merges by album id and orders by catalog creation time', () => {
it('merges only equal owner identities and orders by catalog creation time', () => {
const merged = mergeHotNewReleases(
[album('local', '2026-01-01T00:00:00Z'), album('same', '2026-01-01T00:00:00Z')],
[album('hot', '2026-01-03T00:00:00Z', 's2'), album('same', '2026-01-02T00:00:00Z', 's2')],
[
album('hot', '2026-01-03T00:00:00Z', 's2'),
album('same', '2026-01-02T00:00:00Z', 's2'),
album('same', '2026-01-04T00:00:00Z', 's1'),
],
);
expect(merged.map(item => item.id)).toEqual(['hot', 'same', 'local']);
expect(merged.find(item => item.id === 'same')?.serverId).toBe('s2');
expect(merged.map(item => `${item.serverId}:${item.id}`)).toEqual([
's1:same',
's2:hot',
's2:same',
's1:local',
]);
});
it('requests each selected library and keeps only recent valid dates', async () => {
+5 -3
View File
@@ -1,6 +1,7 @@
import { getAlbumListForServer } from '@/lib/api/subsonicLibrary';
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
import type { LibraryScopePair } from '@/lib/api/library/scopeReads';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
export const HOT_NEW_RELEASE_WINDOW_MS = 2 * 24 * 60 * 60 * 1000;
const HOT_NEW_RELEASE_SAMPLE_SIZE = 24;
@@ -16,10 +17,11 @@ export function mergeHotNewReleases(
hot: SubsonicAlbum[],
): SubsonicAlbum[] {
const byId = new Map<string, SubsonicAlbum>();
for (const album of local) byId.set(album.id, album);
for (const album of local) byId.set(ownedEntityKey(album), album);
for (const album of hot) {
const prior = byId.get(album.id);
byId.set(album.id, prior ? { ...prior, ...album } : album);
const key = ownedEntityKey(album);
const prior = byId.get(key);
byId.set(key, prior ? { ...prior, ...album } : album);
}
return [...byId.values()].sort((left, right) => (
(createdAtMs(right) ?? -Infinity) - (createdAtMs(left) ?? -Infinity)
@@ -5,20 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/store/authStore';
const tryLoadArtistDetailMultiScopeMock = vi.fn();
const librarySelectionForServerMock = vi.fn();
vi.mock('@/features/artist/hooks/loadArtistDetailMultiScope', () => ({
vi.mock('@/lib/library/loadArtistDetailMultiScope', () => ({
tryLoadArtistDetailMultiScope: (...args: unknown[]) => tryLoadArtistDetailMultiScopeMock(...args),
}));
vi.mock('@/lib/api/subsonicClient', async importOriginal => {
const actual = await importOriginal<typeof import('@/lib/api/subsonicClient')>();
return {
...actual,
librarySelectionForServer: (...args: unknown[]) => librarySelectionForServerMock(...args),
};
});
vi.mock('@/lib/api/subsonicArtists');
vi.mock('@/lib/api/subsonicSearch');
@@ -44,16 +35,23 @@ function routerWrapper({ children }: { children: React.ReactNode }) {
describe('useArtistDetailData — multi-library selection', () => {
beforeEach(() => {
tryLoadArtistDetailMultiScopeMock.mockReset();
librarySelectionForServerMock.mockReset();
vi.mocked(getTopSongs).mockResolvedValue([]);
vi.mocked(getArtistInfo).mockResolvedValue({} as Awaited<ReturnType<typeof getArtistInfo>>);
vi.mocked(search).mockResolvedValue({ songs: [], albums: [], artists: [] });
useAuthStore.setState({
activeServerId: 'srv-1',
servers: [{ id: 'srv-1', name: 'S', url: 'https://s.test', username: 'u', password: 'p' }],
servers: [
{ id: 'srv-1', name: 'S1', url: 'https://s1.test', username: 'u', password: 'p' },
{ id: 'srv-2', name: 'S2', url: 'https://s2.test', username: 'u', password: 'p' },
],
favoritesOfflineEnabled: false,
musicLibrarySelectionByServer: { 'srv-1': ['lib-a', 'lib-b'] },
musicLibraryFilterVersion: 0,
musicFoldersByServer: {
'srv-1': [{ id: 'lib-a', name: 'A' }],
'srv-2': [{ id: 'lib-b', name: 'B' }],
},
libraryBrowseServerIds: ['srv-1', 'srv-2'],
libraryBrowseSelectionByServer: { 'srv-1': ['lib-a'], 'srv-2': ['lib-b'] },
libraryBrowseScopeVersion: 0,
audiomuseNavidromeByServer: {},
});
});
@@ -62,8 +60,7 @@ describe('useArtistDetailData — multi-library selection', () => {
vi.clearAllMocks();
});
it('loads via tryLoadArtistDetailMultiScope when more than one library is selected', async () => {
librarySelectionForServerMock.mockReturnValue(['lib-a', 'lib-b']);
it('loads via the authoritative cross-server browse scope', async () => {
tryLoadArtistDetailMultiScopeMock.mockResolvedValue({
artist: { id: 'art-1', name: 'Merged' },
albums: [{ id: 'alb-1', name: 'Album' }],
@@ -73,7 +70,10 @@ describe('useArtistDetailData — multi-library selection', () => {
const { result } = renderHook(() => useArtistDetailData('art-1'), { wrapper: routerWrapper });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(tryLoadArtistDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'art-1');
expect(tryLoadArtistDetailMultiScopeMock).toHaveBeenCalledWith([
{ serverId: 'srv-1', libraryId: 'lib-a' },
{ serverId: 'srv-2', libraryId: 'lib-b' },
], 'srv-1', 'art-1');
expect(getArtistForServer).not.toHaveBeenCalled();
expect(getArtist).not.toHaveBeenCalled();
expect(result.current.artist).toMatchObject({ id: 'art-1', name: 'Merged' });
@@ -81,8 +81,11 @@ describe('useArtistDetailData — multi-library selection', () => {
expect(result.current.topSongs.map(s => s.id)).toEqual(['trk-high', 'trk-low']);
});
it('loads via tryLoadArtistDetailMultiScope when one library is selected', async () => {
librarySelectionForServerMock.mockReturnValue(['sampler']);
it('loads via the authoritative scope when one folder is selected', async () => {
useAuthStore.setState({
libraryBrowseServerIds: ['srv-1'],
libraryBrowseSelectionByServer: { 'srv-1': ['lib-a'] },
});
tryLoadArtistDetailMultiScopeMock.mockResolvedValue({
artist: { id: 'art-1', name: 'Scoped' },
albums: [{ id: 'alb-1', name: 'Sampler Album' }],
@@ -92,13 +95,15 @@ describe('useArtistDetailData — multi-library selection', () => {
const { result } = renderHook(() => useArtistDetailData('art-1'), { wrapper: routerWrapper });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(tryLoadArtistDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'art-1');
expect(tryLoadArtistDetailMultiScopeMock).toHaveBeenCalledWith([
{ serverId: 'srv-1', libraryId: 'lib-a' },
], 'srv-1', 'art-1');
expect(getArtistForServer).not.toHaveBeenCalled();
expect(result.current.albums).toHaveLength(1);
});
it('does not call tryLoadArtistDetailMultiScope when all libraries are selected', async () => {
librarySelectionForServerMock.mockReturnValue([]);
it('uses the direct resolver when no concrete browse scope is configured', async () => {
useAuthStore.setState({ musicFoldersByServer: {}, libraryBrowseServerIds: [] });
vi.mocked(getArtistForServer).mockResolvedValue({
artist: { id: 'art-1', name: 'Network' },
albums: [{ id: 'alb-1', name: 'Album', artist: 'Network', artistId: 'art-1', songCount: 1, duration: 100 }],
@@ -117,7 +122,7 @@ describe('useArtistDetailData — multi-library selection', () => {
// Random Albums links an album-artist id that `getArtist` 404s on, but the
// artist row exists in the local index the album came from → resolve there
// instead of showing "Artist not found".
librarySelectionForServerMock.mockReturnValue([]);
useAuthStore.setState({ musicFoldersByServer: {}, libraryBrowseServerIds: [] });
vi.mocked(getArtistForServer).mockRejectedValue(new Error('artist not found'));
vi.mocked(loadArtistFromLibraryIndex).mockResolvedValue({
artist: { id: 'art-x', name: 'Album Artist', albumCount: 1, serverId: 'srv-1' },
@@ -134,7 +139,7 @@ describe('useArtistDetailData — multi-library selection', () => {
});
it('shows nothing to resolve when both network and local index miss', async () => {
librarySelectionForServerMock.mockReturnValue([]);
useAuthStore.setState({ musicFoldersByServer: {}, libraryBrowseServerIds: [] });
vi.mocked(getArtistForServer).mockRejectedValue(new Error('artist not found'));
vi.mocked(loadArtistFromLibraryIndex).mockResolvedValue(null);
@@ -145,8 +150,7 @@ describe('useArtistDetailData — multi-library selection', () => {
expect(result.current.artist).toBeNull();
});
it('falls through to getArtist when multi-scope load returns null', async () => {
librarySelectionForServerMock.mockReturnValue(['lib-a', 'lib-b']);
it('does not escape the authoritative scope when the scoped lookup misses', async () => {
tryLoadArtistDetailMultiScopeMock.mockResolvedValue(null);
vi.mocked(getArtistForServer).mockResolvedValue({
artist: { id: 'art-1', name: 'Fallback' },
@@ -157,7 +161,7 @@ describe('useArtistDetailData — multi-library selection', () => {
await waitFor(() => expect(result.current.loading).toBe(false));
expect(tryLoadArtistDetailMultiScopeMock).toHaveBeenCalled();
expect(getArtistForServer).toHaveBeenCalled();
expect(result.current.artist).toMatchObject({ name: 'Fallback' });
expect(getArtistForServer).not.toHaveBeenCalled();
expect(result.current.artist).toBeNull();
});
});
@@ -13,8 +13,8 @@ import { loadArtistFromLocalPlayback, offlineLocalBrowseEnabled } from '@/featur
import { readDetailServerId } from '@/lib/navigation/detailServerScope';
import { runLocalArtistLosslessBrowse } from '@/lib/library/browseTextSearch';
import { isLosslessSuffix } from '@/lib/library/losslessFormats';
import { librarySelectionForServer } from '@/lib/api/subsonicClient';
import { tryLoadArtistDetailMultiScope } from '@/features/artist/hooks/loadArtistDetailMultiScope';
import { tryLoadArtistDetailMultiScope } from '@/lib/library/loadArtistDetailMultiScope';
import { getLibraryBrowseScope } from '@/lib/library/libraryBrowseScope';
export interface UseArtistDetailDataOptions {
/** When true, albums and top tracks are limited to lossless containers (local index preferred). */
@@ -61,8 +61,8 @@ export function useArtistDetailData(
const audiomuseNavidromeEnabled = useAuthStore(
s => !!(serverId && s.audiomuseNavidromeByServer[serverId]),
);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const musicLibrarySelectionByServer = useAuthStore(s => s.musicLibrarySelectionByServer);
const libraryBrowseScopeVersion = useAuthStore(s => s.libraryBrowseScopeVersion);
const browseScope = getLibraryBrowseScope();
const offlineBrowseActive = useOfflineBrowseContext().active && !!serverId;
const preferLocalBytesOnly = offlineBrowseActive && offlineLocalBrowseEnabled(serverId);
const preferLocalArtist = preferLocalBytesOnly
@@ -90,12 +90,13 @@ export function useArtistDetailData(
(async () => {
try {
const currentBrowseScope = getLibraryBrowseScope();
if (offlineBrowseActive && !preferLocalBytesOnly) {
setLoading(false);
return;
}
if (serverId && librarySelectionForServer(serverId).length > 0) {
const multi = await tryLoadArtistDetailMultiScope(serverId, id);
if (serverId && currentBrowseScope.pairs.length > 0) {
const multi = await tryLoadArtistDetailMultiScope(currentBrowseScope.pairs, serverId, id);
if (cancelled) return;
if (multi) {
setArtist(multi.artist);
@@ -105,6 +106,8 @@ export function useArtistDetailData(
setLoading(false);
return;
}
setLoading(false);
return;
}
if (preferLocalArtist && serverId && id) {
const local = preferLocalBytesOnly
@@ -193,9 +196,8 @@ export function useArtistDetailData(
return () => { cancelled = true; };
}, [
id,
libraryBrowseScopeVersion,
losslessOnly,
musicLibraryFilterVersion,
musicLibrarySelectionByServer,
offlineBrowseActive,
preferLocalArtist,
preferLocalBytesOnly,
@@ -204,7 +206,7 @@ export function useArtistDetailData(
]);
useEffect(() => {
if (!id || preferLocalArtist) return;
if (!id || preferLocalArtist || browseScope.multiServer) return;
let cancelled = 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
@@ -220,10 +222,10 @@ export function useArtistDetailData(
if (!cancelled) setArtistInfoLoading(false);
});
return () => { cancelled = true; };
}, [id, audiomuseNavidromeEnabled, preferLocalArtist]);
}, [id, audiomuseNavidromeEnabled, preferLocalArtist, browseScope.multiServer]);
useEffect(() => {
if (!id || !artist || preferLocalArtist) return;
if (!id || !artist || preferLocalArtist || browseScope.multiServer) return;
const ownAlbumIds = new Set(albums.map(a => a.id));
// 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
@@ -265,7 +267,7 @@ export function useArtistDetailData(
setFeaturedLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artist?.id, musicLibraryFilterVersion, losslessOnly, albums, preferLocalArtist]);
}, [artist?.id, libraryBrowseScopeVersion, losslessOnly, albums, preferLocalArtist, browseScope.multiServer]);
const info = infoEntry && infoEntry.id === id ? infoEntry.value : null;
@@ -17,7 +17,6 @@ import {
fetchOfflineLocalStarredArtists,
offlineLocalBrowseEnabled,
} from '@/features/offline';
import { librarySelectionForServer } from '@/lib/api/subsonicClient';
import { scheduleAlbumBrowseBackgroundWork } from '@/lib/library/albumBrowseBackground';
import {
artistBrowseTimed,
@@ -34,6 +33,7 @@ import {
readArtistBrowseCatalogCache,
storeArtistBrowseCatalogCache,
} from '@/lib/library/artistBrowseInflight';
import type { LibraryScopePair } from '@/lib/api/library/scopeReads';
/** Local-index artist catalog buffer grows by this many rows per background SQL chunk. */
export const ARTIST_CATALOG_CHUNK_SIZE = 200;
@@ -48,6 +48,8 @@ export type UseArtistsBrowseCatalogArgs = {
letterFilter: string;
musicLibraryFilterVersion: number;
libraryScopeKey: string;
libraryScopes: LibraryScopePair[];
multiServer: boolean;
/** Server `ignoredArticles` for offline letter buckets (Navidrome parity). */
ignoredArticles?: string | null;
};
@@ -60,6 +62,8 @@ export function useArtistsBrowseCatalog({
letterFilter,
musicLibraryFilterVersion,
libraryScopeKey,
libraryScopes,
multiServer,
ignoredArticles,
}: UseArtistsBrowseCatalogArgs) {
const offlineBrowseActive = useOfflineBrowseContext().active;
@@ -78,6 +82,10 @@ export function useArtistsBrowseCatalog({
const loadGenerationRef = useRef(0);
const catalogOffsetRef = useRef(0);
const catalogLoadingRef = useRef(false);
const libraryScopesRef = useRef(libraryScopes);
useEffect(() => {
libraryScopesRef.current = libraryScopes;
}, [libraryScopes, libraryScopeKey]);
const catalogLoadKey = useMemo(() => {
if (!serverId) return '';
@@ -168,6 +176,7 @@ export function useArtistsBrowseCatalog({
ARTIST_CATALOG_CHUNK_SIZE,
creditMode,
letterFilter,
{ libraryScopes: libraryScopesRef.current },
),
{ append, offset: catalogOffsetRef.current, creditMode, letterFilter },
);
@@ -240,7 +249,7 @@ export function useArtistsBrowseCatalog({
serverId,
indexEnabled,
libraryFilterVersion: musicLibraryFilterVersion,
libraryScopeCount: serverId ? librarySelectionForServer(serverId).length : 0,
libraryScopeCount: libraryScopesRef.current.length,
offlineBrowseActive,
starredOnly,
creditMode,
@@ -290,12 +299,24 @@ export function useArtistsBrowseCatalog({
if (starredOnly) {
emitArtistsBrowseDebug('load_branch', { mode: 'starred' });
if (!cancelled && generation === loadGenerationRef.current) {
const starred = await artistBrowseTimed(
'starred_catalog',
() => fetchStarredArtistsForBrowse(creditMode, serverId, indexEnabled),
);
const starred = multiServer && indexEnabled && serverId
? (await artistBrowseTimed(
'starred_catalog_local',
() => fetchLocalArtistCatalogChunk(
serverId,
0,
10_000,
creditMode,
undefined,
{ libraryScopes: libraryScopesRef.current, starredOnly: true },
),
))?.artists ?? []
: await artistBrowseTimed(
'starred_catalog',
() => fetchStarredArtistsForBrowse(creditMode, serverId, indexEnabled),
);
setCatalogArtists(starred);
setBrowseMode('network');
setBrowseMode(multiServer ? 'slice' : 'network');
setCatalogHasMore(false);
emitArtistsBrowseDebug('load_effect_done', {
browseMode: 'network',
@@ -318,6 +339,7 @@ export function useArtistsBrowseCatalog({
ARTIST_BROWSE_BOOTSTRAP_CHUNK,
creditMode,
letterFilter,
{ libraryScopes: libraryScopesRef.current },
),
{ creditMode, letterFilter, chunkSize: ARTIST_BROWSE_BOOTSTRAP_CHUNK },
),
@@ -357,6 +379,7 @@ export function useArtistsBrowseCatalog({
tailSize,
creditMode,
letterFilter,
{ libraryScopes: libraryScopesRef.current },
),
{ creditMode, letterFilter, chunkSize: tailSize, offset: tailOffset },
),
@@ -393,6 +416,7 @@ export function useArtistsBrowseCatalog({
ARTIST_CATALOG_CHUNK_SIZE,
creditMode,
letterFilter,
{ libraryScopes: libraryScopesRef.current },
),
{ creditMode, letterFilter, chunkSize: ARTIST_CATALOG_CHUNK_SIZE },
),
@@ -413,7 +437,7 @@ export function useArtistsBrowseCatalog({
}
emitArtistsBrowseDebug('slice_fallback', { reason: 'local_chunk_null' });
}
if (!cancelled && generation === loadGenerationRef.current && !indexEnabled) {
if (!cancelled && generation === loadGenerationRef.current && !indexEnabled && !multiServer) {
emitArtistsBrowseDebug('load_branch', { mode: 'network' });
const network = await artistBrowseTimed(
'network_catalog',
@@ -455,7 +479,7 @@ export function useArtistsBrowseCatalog({
return () => {
cancelled = true;
};
}, [catalogLoadKey, creditMode, ignoredArticles, letterFilter, musicLibraryFilterVersion, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, starredOnly]);
}, [catalogLoadKey, creditMode, ignoredArticles, letterFilter, musicLibraryFilterVersion, indexEnabled, multiServer, offlineBrowseActive, offlineBrowseReloadTs, serverId, starredOnly]);
return {
catalogArtists,
+9 -19
View File
@@ -41,7 +41,6 @@ import { readArtistBrowseRestore } from '@/lib/navigation/albumDetailNavigation'
import { useScopedBrowseSearchQuery } from '@/store/liveSearchScopeStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
import {
beginArtistsBrowseTrace,
emitArtistsBrowseDebug,
@@ -52,10 +51,7 @@ import {
import { appendServerQuery } from '@/lib/navigation/detailServerScope';
import { usePsyLabDebugTraces } from '@/lib/perf/psyLabDebugTraces';
import { getLibraryBrowseScope } from '@/lib/library/libraryBrowseScope';
function artistEntityKey(artist: { id: string; serverId?: string }): string {
return artist.serverId ? `${artist.serverId}:${artist.id}` : artist.id;
}
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
export default function Artists() {
const perfFlags = usePerfProbeFlags();
@@ -69,18 +65,10 @@ export default function Artists() {
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const libraryBrowseScopeVersion = useAuthStore(s => s.libraryBrowseScopeVersion);
const libraryBrowseVersion = musicLibraryFilterVersion + libraryBrowseScopeVersion;
const serverId = useAuthStore(s => s.activeServerId ?? '');
const libraryScopeKey = useAuthStore(s => {
if (!serverId) return 'all';
const resolved = resolveServerIdForIndexKey(serverId);
const selection = s.musicLibrarySelectionByServer[resolved];
if (selection !== undefined) {
return selection.length === 0 ? 'all' : selection.join(',');
}
const legacy = s.musicLibraryFilterByServer[resolved];
if (legacy === undefined || legacy === 'all') return 'all';
return legacy;
});
const activeServerId = useAuthStore(s => s.activeServerId ?? '');
const browseScope = getLibraryBrowseScope();
const serverId = browseScope.anchorServerId ?? activeServerId;
const libraryScopeKey = browseScope.fingerprint || 'all';
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const scrollSnapshotRef = useRef<ArtistBrowseScrollSnapshot>({ scrollTop: 0, visibleCount: 0 });
@@ -148,7 +136,9 @@ export default function Artists() {
creditMode,
letterFilter,
musicLibraryFilterVersion: libraryBrowseVersion,
libraryScopeKey: `${libraryScopeKey}:${libraryBrowseScopeVersion}`,
libraryScopeKey,
libraryScopes: browseScope.pairs,
multiServer: browseScope.multiServer,
ignoredArticles,
});
@@ -198,7 +188,7 @@ export default function Artists() {
});
}, []);
const selectedArtists = artists.filter(a => selectedIds.has(artistEntityKey(a)));
const selectedArtists = artists.filter(a => selectedIds.has(ownedEntityKey(a)));
const {
filtered, visible, hasMore, groups, letters, artistListFlatRows,
@@ -20,8 +20,8 @@ export interface FavoriteSongRowCallbacks {
toggleSelect: (songId: string, index: number, shift: boolean) => void;
play: (index: number) => void;
startPreview: (song: SubsonicSong) => void;
rate: (songId: string, rating: number) => void;
remove: (songId: string) => void;
rate: (song: SubsonicSong, rating: number) => void;
remove: (song: SubsonicSong) => void;
navArtist: (artistId: string, serverId?: string) => void;
navAlbum: (albumId: string, serverId?: string) => void;
}
@@ -128,7 +128,7 @@ function FavoriteSongRow({
{(song.suffix || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>}
</div>
);
case 'rating': return <StarRating key="rating" value={ratingValue} onChange={r => cb.rate(song.id, r)} />;
case 'rating': return <StarRating key="rating" value={ratingValue} onChange={r => cb.rate(song, r)} />;
case 'duration': return <div key="duration" className="track-duration">{formatTrackTime(song.duration)}</div>;
case 'playCount': return (
<div key="playCount" className="track-duration">{song.playCount ?? '—'}</div>
@@ -141,7 +141,7 @@ function FavoriteSongRow({
);
case 'remove': return (
<div key="remove" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<button className="btn-icon fav-remove-btn" data-tooltip={t('favorites.removeSong')} onClick={e => { e.stopPropagation(); cb.remove(song.id); }} aria-label={t('favorites.removeSong')}>
<button className="btn-icon fav-remove-btn" data-tooltip={t('favorites.removeSong')} onClick={e => { e.stopPropagation(); cb.remove(song); }} aria-label={t('favorites.removeSong')}>
<X size={14} />
</button>
</div>
@@ -7,6 +7,7 @@ import { useSelectionStore } from '@/store/selectionStore';
import { songToTrack } from '@/lib/media/songToTrack';
import { AddToPlaylistSubmenu } from '@/features/contextMenu/components/ContextMenu';
import GenreFilterBar from '@/ui/GenreFilterBar';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
interface Props {
visibleSongs: SubsonicSong[];
@@ -45,7 +46,7 @@ export default function FavoritesSongsSectionHeader({
const targetSongs = useMemo(() => {
if (!inSelectMode) return visibleSongs;
return visibleSongs.filter(s => selectedIds.has(s.id));
return visibleSongs.filter(s => selectedIds.has(ownedEntityKey(s)));
}, [inSelectMode, visibleSongs, selectedIds]);
// Snapshot selection when the picker opens so add-to-playlist still sees every
@@ -60,8 +61,8 @@ export default function FavoritesSongsSectionHeader({
{(selectedArtist || selectedGenres.length > 0 || yearRange[0] !== minYear || yearRange[1] !== currentYear) && (
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', fontStyle: 'italic' }}>
{selectedArtist
? t('favorites.showingFiltered', { filtered: visibleSongs.length, total: songs.filter(s => starredOverrides[s.id] !== false).length, artist: selectedArtistName ?? selectedArtist })
: t('favorites.showingCount', { filtered: visibleSongs.length, total: songs.filter(s => starredOverrides[s.id] !== false).length })}
? t('favorites.showingFiltered', { filtered: visibleSongs.length, total: songs.filter(s => (starredOverrides[ownedEntityKey(s)] ?? starredOverrides[s.id]) !== false).length, artist: selectedArtistName ?? selectedArtist })
: t('favorites.showingCount', { filtered: visibleSongs.length, total: songs.filter(s => (starredOverrides[ownedEntityKey(s)] ?? starredOverrides[s.id]) !== false).length })}
</span>
)}
</div>
@@ -20,6 +20,7 @@ import { SORTABLE_COLUMNS } from '@/features/favorites/hooks/useFavoritesSongFil
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 {
visibleSongs: SubsonicSong[];
@@ -42,8 +43,8 @@ interface Props {
handleSortClick: (key: string) => void;
getSortIndicator: (key: string) => React.ReactNode;
ratings: Record<string, number>;
handleRate: (songId: string, rating: number) => void;
removeSong: (id: string) => void;
handleRate: (song: SubsonicSong, rating: number) => void;
removeSong: (song: SubsonicSong) => void;
hasFilters: boolean;
}
@@ -62,6 +63,7 @@ export default function FavoritesSongsTracklist({
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const previewingId = usePreviewStore(s => s.previewingId);
const previewingTrack = usePreviewStore(s => s.previewingTrack);
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
const showBitrate = useThemeStore(s => s.showBitrate);
const trackListCoversOn = useTrackListCoverArtEnabled('pages');
@@ -82,10 +84,11 @@ export default function FavoritesSongsTracklist({
activate: (song, index, e) => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
const L = latest.current;
if (e.ctrlKey || e.metaKey) L.toggleSelect(song.id, index, false);
else if (L.inSelectMode) L.toggleSelect(song.id, index, e.shiftKey);
const key = ownedEntityKey(song);
if (e.ctrlKey || e.metaKey) L.toggleSelect(key, index, false);
else if (L.inSelectMode) L.toggleSelect(key, index, e.shiftKey);
else if (L.orbitActive) L.queueHint();
else L.playTrack(L.visibleTracks[index], L.visibleTracks);
else L.playTrack(L.visibleTracks[index], L.visibleTracks, true, false, index);
},
dblOrbit: (songId, e) => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
@@ -109,8 +112,8 @@ export default function FavoritesSongsTracklist({
document.removeEventListener('mouseup', onUp);
const L = latest.current;
const { selectedIds: selIds } = useSelectionStore.getState();
if (selIds.has(song.id) && selIds.size > 1) {
const bulkTracks = L.visibleSongs.filter(s => selIds.has(s.id)).map(songToTrack);
if (selIds.has(ownedEntityKey(song)) && selIds.size > 1) {
const bulkTracks = L.visibleSongs.filter(s => selIds.has(ownedEntityKey(s))).map(songToTrack);
L.psyDrag.startDrag({ data: JSON.stringify({ type: 'songs', tracks: bulkTracks }), label: `${bulkTracks.length} Songs` }, me.clientX, me.clientY);
} else {
L.psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
@@ -125,14 +128,14 @@ export default function FavoritesSongsTracklist({
play: (index) => {
const L = latest.current;
if (L.orbitActive) { L.queueHint(); return; }
L.playTrack(L.visibleTracks[index], L.visibleTracks);
L.playTrack(L.visibleTracks[index], L.visibleTracks, true, false, index);
},
startPreview: (song) => usePreviewStore.getState().startPreview(
previewInputFromSong(song),
'favorites',
),
rate: (songId, r) => latest.current.handleRate(songId, r),
remove: (songId) => latest.current.removeSong(songId),
rate: (song, r) => latest.current.handleRate(song, r),
remove: (song) => latest.current.removeSong(song),
navArtist: (artistId, serverId) => {
const query = appendServerQuery(undefined, serverId);
latest.current.navigate(query ? `/artist/${artistId}?${query}` : `/artist/${artistId}`);
@@ -177,7 +180,7 @@ export default function FavoritesSongsTracklist({
estimateSize: () => 48,
overscan: Math.max(8, Math.ceil(viewportH / 48)),
scrollMargin,
getItemKey: i => `${visibleSongs[i].id}:${i}`,
getItemKey: i => ownedEntityKey(visibleSongs[i]),
});
const virtualItems = rowVirtualizer.getVirtualItems();
@@ -227,7 +230,7 @@ export default function FavoritesSongsTracklist({
if (allSelected) {
useSelectionStore.getState().clearAll();
} else {
useSelectionStore.getState().setSelectedIds(() => new Set(visibleSongs.map(s => s.id)));
useSelectionStore.getState().setSelectedIds(() => new Set(visibleSongs.map(ownedEntityKey)));
}
}}
/>
@@ -318,13 +321,13 @@ export default function FavoritesSongsTracklist({
visibleCols={visibleCols}
gridStyle={gridStyle}
showBitrate={showBitrate}
isActive={currentTrack?.id === song.id}
showEq={currentTrack?.id === song.id && isPlaying}
isSelected={selectedIds.has(song.id)}
isActive={currentTrack ? ownedEntityKey(currentTrack) === ownedEntityKey(song) : false}
showEq={currentTrack ? ownedEntityKey(currentTrack) === ownedEntityKey(song) && isPlaying : false}
isSelected={selectedIds.has(ownedEntityKey(song))}
inSelectMode={inSelectMode}
ratingValue={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
isPreviewing={previewingId === song.id}
previewStarted={previewingId === song.id && previewAudioStarted}
ratingValue={ratings[ownedEntityKey(song)] ?? userRatingOverrides[ownedEntityKey(song)] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
isPreviewing={previewingId === song.id && previewingTrack?.serverId === song.serverId}
previewStarted={previewingId === song.id && previewingTrack?.serverId === song.serverId && previewAudioStarted}
orbitActive={orbitActive}
cb={cb}
/>
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef } from 'react';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { useSelectionStore } from '@/store/selectionStore';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
export interface FavoritesSelectionResult {
toggleSelect: (id: string, idx: number, shift: boolean) => void;
@@ -41,7 +42,8 @@ export function useFavoritesSelection(
const from = Math.min(lastSelectedIdxRef.current, idx);
const to = Math.max(lastSelectedIdxRef.current, idx);
for (let j = from; j <= to; j++) {
const sid = visibleSongs[j]?.id;
const song = visibleSongs[j];
const sid = song ? ownedEntityKey(song) : null;
if (sid) next.add(sid);
}
} else {
@@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
import { ArrowDown, ArrowUp } from 'lucide-react';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
const CURRENT_YEAR = new Date().getFullYear();
const MIN_YEAR = 1950;
@@ -76,7 +77,7 @@ export function useFavoritesSongFiltering(deps: FavoritesSongFilteringDeps): Fav
const filteredSongs = useMemo(() => {
return songs.filter(s => {
// Remove unfavorited
if (starredOverrides[s.id] === false) return false;
if ((starredOverrides[ownedEntityKey(s)] ?? starredOverrides[s.id]) === false) return false;
// Artist filter (composite key when favorites span servers)
if (selectedArtist) {
@@ -126,8 +127,8 @@ export function useFavoritesSongFiltering(deps: FavoritesSongFilteringDeps): Fav
case 'album':
return multiplier * ((a.album || '').localeCompare(b.album || ''));
case 'rating': {
const ratingA = ratings[a.id] ?? userRatingOverrides[a.id] ?? a.userRating ?? 0;
const ratingB = ratings[b.id] ?? userRatingOverrides[b.id] ?? b.userRating ?? 0;
const ratingA = ratings[ownedEntityKey(a)] ?? userRatingOverrides[ownedEntityKey(a)] ?? userRatingOverrides[a.id] ?? a.userRating ?? 0;
const ratingB = ratings[ownedEntityKey(b)] ?? userRatingOverrides[ownedEntityKey(b)] ?? userRatingOverrides[b.id] ?? b.userRating ?? 0;
return multiplier * (ratingA - ratingB);
}
case 'duration':
+10 -7
View File
@@ -23,6 +23,8 @@ import {
} from '@/lib/library/favoritesBrowseDebug';
import { usePsyLabDebugTraces } from '@/lib/perf/psyLabDebugTraces';
import { getLibraryBrowseScope } from '@/lib/library/libraryBrowseScope';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
const FAV_COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
@@ -92,17 +94,18 @@ export default function Favorites() {
const isPlaying = usePlayerStore(s => s.isPlaying);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const handleRate = (songId: string, rating: number) => {
setRatings(r => ({ ...r, [songId]: rating }));
const handleRate = (song: SubsonicSong, rating: number) => {
const key = ownedEntityKey(song);
setRatings(r => ({ ...r, [key]: rating }));
// F4: optimistic override + retried server sync via the central helper.
queueSongRating(songId, rating);
queueSongRating(song.id, rating, song.serverId, { scopedOverride: true });
};
function removeSong(id: string) {
function removeSong(song: SubsonicSong) {
// F4: optimistic un-star + retried server sync via the central helper.
const song = songs.find(s => s.id === id);
queueSongStar(id, false, song?.serverId);
setSongs(prev => prev.filter(s => s.id !== id));
const key = ownedEntityKey(song);
queueSongStar(song.id, false, song.serverId, { scopedOverride: true });
setSongs(prev => prev.filter(candidate => ownedEntityKey(candidate) !== key));
}
const { visibleSongs, handleSortClick, getSortIndicator } = useFavoritesSongFiltering({
@@ -100,6 +100,18 @@ describe('pendingStarSync', () => {
expect(starMock).toHaveBeenCalledWith('t1', 'song', { serverId: 'srv-b' });
});
it('isolates scoped favorite overrides that share a raw id', async () => {
queueSongStar('shared', false, 'srv-b', { scopedOverride: true });
expect(usePlayerStore.getState().starredOverrides).toMatchObject({
'srv-b:shared': false,
});
expect(usePlayerStore.getState().starredOverrides.shared).toBeUndefined();
await vi.runAllTimersAsync();
expect(unstarMock).toHaveBeenCalledWith('shared', 'song', { serverId: 'srv-b' });
});
it('latest toggle wins when re-queued before sync', async () => {
queueSongStar('t1', true);
queueSongStar('t1', false); // user toggled back off
@@ -122,4 +134,15 @@ describe('pendingStarSync', () => {
expect('t1' in s.userRatingOverrides).toBe(false); // cleared
expect(s.currentTrack?.userRating).toBe(4); // track stays patched
});
it('routes scoped ratings to the owner and clears only its composite override', async () => {
queueSongRating('shared', 5, 'srv-b', { scopedOverride: true });
expect(usePlayerStore.getState().userRatingOverrides['srv-b:shared']).toBe(5);
expect(usePlayerStore.getState().userRatingOverrides.shared).toBeUndefined();
await vi.runAllTimersAsync();
expect(setRatingMock).toHaveBeenCalledWith('shared', 5, { serverId: 'srv-b' });
expect(usePlayerStore.getState().userRatingOverrides['srv-b:shared']).toBeUndefined();
});
});
+38 -21
View File
@@ -2,6 +2,7 @@ import { setRating, star, unstar } from '@/lib/api/subsonicStarRating';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { patchCachedTrack } from '@/features/playback/store/queueTrackResolver';
import { onActiveServerBecameReachable } from '@/lib/network/activeServerReachability';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
/**
* F4 pending-sync for **song** star + rating (spec §6.5 / R7-18).
@@ -26,8 +27,8 @@ import { onActiveServerBecameReachable } from '@/lib/network/activeServerReachab
*/
type Task =
| { kind: 'star'; id: string; starred: boolean; serverId?: string }
| { kind: 'rating'; id: string; rating: number };
| { kind: 'star'; id: string; starred: boolean; serverId?: string; overrideKey: string }
| { kind: 'rating'; id: string; rating: number; serverId?: string; overrideKey: string };
const pending = new Map<string, Task>(); // key `${kind}:${id}` — latest wins
const timers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -36,7 +37,7 @@ const MAX_BACKOFF_MS = 30_000;
let listenersArmed = false;
const keyOf = (t: Task) =>
t.kind === 'star' ? `star:${t.serverId ?? ''}:${t.id}` : `${t.kind}:${t.id}`;
`${t.kind}:${t.serverId ?? ''}:${t.id}`;
function armListeners(): void {
if (listenersArmed || typeof window === 'undefined') return;
@@ -68,10 +69,11 @@ async function run(k: string): Promise<void> {
const meta = task.serverId ? { serverId: task.serverId } : undefined;
if (task.starred) await star(task.id, 'song', meta);
else await unstar(task.id, 'song', meta);
onStarSuccess(task.id, task.starred);
onStarSuccess(task);
} else {
await setRating(task.id, task.rating);
onRatingSuccess(task.id);
if (task.serverId) await setRating(task.id, task.rating, { serverId: task.serverId });
else await setRating(task.id, task.rating);
onRatingSuccess(task);
}
// Only retire the entry if a newer toggle hasn't superseded it mid-flight.
if (pending.get(k) === task) {
@@ -86,36 +88,45 @@ async function run(k: string): Promise<void> {
}
}
function onStarSuccess(id: string, starred: boolean): void {
const starredVal = starred ? new Date().toISOString() : undefined;
function onStarSuccess(task: Extract<Task, { kind: 'star' }>): void {
const starredVal = task.starred ? new Date().toISOString() : undefined;
// Keep the override — list views merge it (step 3 atop this file).
usePlayerStore.setState(s => ({
currentTrack:
s.currentTrack?.id === id ? { ...s.currentTrack, starred: starredVal } : s.currentTrack,
s.currentTrack?.id === task.id
&& (!task.serverId || !s.currentTrack.serverId || s.currentTrack.serverId === task.serverId)
? { ...s.currentTrack, starred: starredVal }
: s.currentTrack,
}));
// Thin-state: the queue's copy lives in the resolver cache. Patch it in place
// to the synced value rather than dropping it — a dropped entry would blank the
// visible queue row to a "…" placeholder until the next window re-resolve.
patchCachedTrack(id, { starred: starredVal });
patchCachedTrack(task.id, { starred: starredVal }, task.serverId);
}
function onRatingSuccess(id: string): void {
const rating = usePlayerStore.getState().userRatingOverrides[id];
function onRatingSuccess(task: Extract<Task, { kind: 'rating' }>): void {
const rating = usePlayerStore.getState().userRatingOverrides[task.overrideKey];
usePlayerStore.setState(s => {
if (!(id in s.userRatingOverrides)) return {};
if (!(task.overrideKey in s.userRatingOverrides)) return {};
const next = { ...s.userRatingOverrides };
delete next[id];
delete next[task.overrideKey];
return { userRatingOverrides: next };
});
// Patch the cached queue track in place (see onStarSuccess) so the row keeps
// its title and shows the synced rating without flashing a placeholder.
if (rating !== undefined) patchCachedTrack(id, { userRating: rating });
if (rating !== undefined) patchCachedTrack(task.id, { userRating: rating }, task.serverId);
}
/** Optimistically (un)star a song and sync it to the server with retry. */
export function queueSongStar(id: string, starred: boolean, serverId?: string): void {
usePlayerStore.getState().setStarredOverride(id, starred);
const t: Task = { kind: 'star', id, starred, serverId };
export function queueSongStar(
id: string,
starred: boolean,
serverId?: string,
options?: { scopedOverride?: boolean },
): void {
const overrideKey = options?.scopedOverride ? ownedEntityKey({ id, serverId }) : id;
usePlayerStore.getState().setStarredOverride(overrideKey, starred);
const t: Task = { kind: 'star', id, starred, serverId, overrideKey };
const k = keyOf(t);
pending.set(k, t);
attempts.delete(k);
@@ -124,9 +135,15 @@ export function queueSongStar(id: string, starred: boolean, serverId?: string):
}
/** Optimistically rate a song and sync it to the server with retry. */
export function queueSongRating(id: string, rating: number): void {
usePlayerStore.getState().setUserRatingOverride(id, rating);
const t: Task = { kind: 'rating', id, rating };
export function queueSongRating(
id: string,
rating: number,
serverId?: string,
options?: { scopedOverride?: boolean },
): void {
const overrideKey = options?.scopedOverride ? ownedEntityKey({ id, serverId }) : id;
usePlayerStore.getState().setUserRatingOverride(overrideKey, rating);
const t: Task = { kind: 'rating', id, rating, serverId, overrideKey };
const k = keyOf(t);
pending.set(k, t);
attempts.delete(k);
@@ -273,6 +273,27 @@ describe('previewStore — startPreview', () => {
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_play', expect.anything());
});
it('treats the same raw id on another server as a different preview', async () => {
useAuthStore.setState({
servers: [
{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
{ id: 'srv-b', name: 'B', url: 'https://b.test', username: 'u', password: 'p' },
],
});
usePreviewStore.setState({
previewingId: 'shared',
previewingTrack: { id: 'shared', title: 'A', artist: 'Artist', serverId: 'srv-a' },
});
await usePreviewStore.getState().startPreview({ ...song('shared'), serverId: 'srv-b' }, 'suggestions');
expect(invokeMock).toHaveBeenCalledWith('audio_preview_play', expect.objectContaining({
id: 'shared',
url: expect.stringContaining('b.test'),
}));
expect(usePreviewStore.getState().previewingTrack?.serverId).toBe('srv-b');
});
it('rolls back optimistic state when the engine invoke rejects', async () => {
usePreviewStore.setState({
previewingId: 'older',
+12 -5
View File
@@ -1,4 +1,4 @@
import { buildStreamUrl } from '@/lib/api/subsonicStreamUrl';
import { buildStreamUrl, buildStreamUrlForServer } from '@/lib/api/subsonicStreamUrl';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import type { TrackPreviewLocation } from '@/store/authStoreTypes';
import { create } from 'zustand';
@@ -13,6 +13,7 @@ export interface PreviewingTrack {
title: string;
artist: string;
coverArt?: string;
serverId?: string;
}
export interface PreviewSongInput {
@@ -22,11 +23,12 @@ export interface PreviewSongInput {
coverArt?: string;
duration?: number;
suffix?: string;
serverId?: string;
}
/** Map a browse/playlist song row into preview input (keeps Subsonic suffix for format hints). */
export function previewInputFromSong(
song: Pick<SubsonicSong, 'id' | 'title' | 'artist' | 'coverArt' | 'duration' | 'suffix'>,
song: Pick<SubsonicSong, 'id' | 'title' | 'artist' | 'coverArt' | 'duration' | 'suffix' | 'serverId'>,
): PreviewSongInput {
return {
id: song.id,
@@ -35,6 +37,7 @@ export function previewInputFromSong(
coverArt: song.coverArt,
duration: song.duration,
suffix: song.suffix,
serverId: song.serverId,
};
}
@@ -108,15 +111,18 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
// this guards keyboard shortcuts / programmatic callers.
if (isOrbitPlaybackSyncActive()) return;
const current = get().previewingId;
if (current === song.id) {
const currentId = get().previewingId;
const current = get().previewingTrack;
if (currentId === song.id && (!current || current.serverId === song.serverId)) {
await get().stopPreview();
return;
}
const previewDuration = auth.trackPreviewDurationSec;
const startRatio = auth.trackPreviewStartRatio;
const url = buildStreamUrl(song.id);
const url = song.serverId
? buildStreamUrlForServer(song.serverId, song.id)
: buildStreamUrl(song.id);
const trackDuration = Math.max(song.duration ?? 0, 0);
const startSec = trackDuration > previewDuration * 1.5
? trackDuration * startRatio
@@ -129,6 +135,7 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
title: song.title,
artist: song.artist,
coverArt: song.coverArt,
serverId: song.serverId,
},
elapsed: 0,
duration: previewDuration,
@@ -290,10 +290,11 @@ export function invalidateQueueResolver(trackId: string): void {
* succeeds). Unlike {@link invalidateQueueResolver}, this keeps the entry so a
* visible queue row never blanks to a placeholder the row stays resolved and
* just reflects the synced value. No-op for refs not currently cached. */
export function patchCachedTrack(trackId: string, patch: Partial<Track>): void {
export function patchCachedTrack(trackId: string, patch: Partial<Track>, serverId?: string): void {
let changed = false;
const scopedKey = serverId ? `${canonicalQueueServerKey(serverId)}:${trackId}` : null;
for (const [key, track] of cache) {
if (key.endsWith(`:${trackId}`)) {
if ((scopedKey && key === scopedKey) || (!scopedKey && key.endsWith(`:${trackId}`))) {
cache.set(key, { ...track, ...patch });
changed = true;
}
+24
View File
@@ -0,0 +1,24 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useRangeSelection } from './useRangeSelection';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
describe('useRangeSelection', () => {
it('uses the supplied owner-aware key for toggles and ranges', () => {
const items = [
{ id: 'shared', serverId: 'server-a' },
{ id: 'shared', serverId: 'server-b' },
{ id: 'third', serverId: 'server-b' },
];
const { result } = renderHook(() => useRangeSelection(items, ownedEntityKey));
act(() => result.current.toggleSelect('server-a:shared'));
act(() => result.current.toggleSelect('server-b:third', { shiftKey: true }));
expect([...result.current.selectedIds]).toEqual([
'server-a:shared',
'server-b:shared',
'server-b:third',
]);
});
});
+10 -5
View File
@@ -14,12 +14,17 @@ import { useCallback, useEffect, useRef, useState } from 'react';
*
* The anchor is a ref, not state moving it does not trigger re-renders.
*/
export function useRangeSelection<T extends { id: string }>(items: T[]) {
export function useRangeSelection<T extends { id: string }>(
items: T[],
keyOf: (item: T) => string = item => item.id,
) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const itemsRef = useRef(items);
const keyOfRef = useRef(keyOf);
useEffect(() => {
itemsRef.current = items;
}, [items]);
keyOfRef.current = keyOf;
}, [items, keyOf]);
const anchorRef = useRef<string | null>(null);
const toggleSelect = useCallback((id: string, opts?: { shiftKey?: boolean }) => {
@@ -33,13 +38,13 @@ export function useRangeSelection<T extends { id: string }>(items: T[]) {
const list = itemsRef.current;
if (opts?.shiftKey && anchorAtCallTime && anchorAtCallTime !== id) {
const startIdx = list.findIndex(x => x.id === anchorAtCallTime);
const endIdx = list.findIndex(x => x.id === id);
const startIdx = list.findIndex(x => keyOfRef.current(x) === anchorAtCallTime);
const endIdx = list.findIndex(x => keyOfRef.current(x) === id);
if (startIdx >= 0 && endIdx >= 0) {
const lo = Math.min(startIdx, endIdx);
const hi = Math.max(startIdx, endIdx);
for (let i = lo; i <= hi; i++) {
next.add(list[i].id);
next.add(keyOfRef.current(list[i]));
}
return next;
}
+43 -1
View File
@@ -1,11 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const libraryListRandomArtists = vi.fn();
const libraryAdvancedSearch = vi.fn();
const librarySelectionForServer = vi.fn();
const libraryIsReady = vi.fn();
const waitForLibraryBrowseReady = vi.fn();
vi.mock('@/lib/api/library', () => ({
libraryListRandomArtists: (...args: unknown[]) => libraryListRandomArtists(...args),
libraryAdvancedSearch: (...args: unknown[]) => libraryAdvancedSearch(...args),
}));
vi.mock('@/lib/api/subsonicClient', () => ({
libraryScopeForServer: vi.fn(),
@@ -14,11 +17,12 @@ vi.mock('@/lib/api/subsonicClient', () => ({
}));
vi.mock('./libraryReady', () => ({
libraryIsReady: (...args: unknown[]) => libraryIsReady(...args),
waitForLibraryBrowseReady: vi.fn(),
waitForLibraryBrowseReady: (...args: unknown[]) => waitForLibraryBrowseReady(...args),
}));
import {
browseRaceCountsArtists,
fetchLocalArtistCatalogChunk,
filterBrowseArtistsByNameQuery,
raceBrowseWithLocalFallback,
runLocalRandomArtists,
@@ -32,6 +36,44 @@ describe('filterBrowseArtistsByNameQuery', () => {
});
});
describe('fetchLocalArtistCatalogChunk', () => {
beforeEach(() => {
libraryAdvancedSearch.mockReset();
waitForLibraryBrowseReady.mockReset();
});
it('forwards authoritative cross-server scopes without a legacy single-server scope', async () => {
waitForLibraryBrowseReady.mockResolvedValue({ ready: true, waitedMs: 0 });
libraryAdvancedSearch.mockResolvedValue({
source: 'local',
artists: [{ serverId: 'server-b', id: 'artist-b', name: 'Only on B', rawJson: {} }],
});
const scopes = [
{ serverId: 'server-a', libraryId: 'library-a' },
{ serverId: 'server-b', libraryId: 'library-b' },
];
await expect(fetchLocalArtistCatalogChunk(
'server-a',
0,
200,
'album',
undefined,
{ libraryScopes: scopes },
)).resolves.toEqual({
artists: [expect.objectContaining({ serverId: 'server-b', id: 'artist-b' })],
hasMore: false,
});
expect(libraryAdvancedSearch).toHaveBeenCalledWith(expect.objectContaining({
serverId: 'server-a',
libraryScope: undefined,
libraryScopes: scopes,
entityTypes: ['artist'],
}));
});
});
describe('raceBrowseWithLocalFallback', () => {
it('returns local when network throws and local has data', async () => {
const outcome = await raceBrowseWithLocalFallback(
+9 -2
View File
@@ -40,6 +40,7 @@ import {
import { libraryIsReady, waitForLibraryBrowseReady } from './libraryReady';
import { artistBrowseTimed, emitArtistsBrowseDebug } from './artistBrowseDebug';
import { raceSearchSources, type SearchRaceWinner } from './searchRace';
import type { LibraryScopePair } from '@/lib/api/library/scopeReads';
export type { LibrarySearchSurface };
@@ -622,6 +623,7 @@ export async function fetchLocalArtistCatalogChunk(
chunkSize: number,
creditMode: ArtistCreditMode = 'album',
letterBucket?: string | null,
options?: { libraryScopes?: LibraryScopePair[]; starredOnly?: boolean },
): Promise<ArtistCatalogChunkResult | null> {
if (!serverId) return null;
const { ready, waitedMs } = await artistBrowseTimed(
@@ -639,10 +641,15 @@ export async function fetchLocalArtistCatalogChunk(
'rust_advanced_search',
() => libraryAdvancedSearch({
serverId,
libraryScope: libraryScopeForServer(serverId) ?? undefined,
libraryScopes: libraryScopePairsForServer(serverId),
libraryScope: options?.libraryScopes?.length
? undefined
: libraryScopeForServer(serverId) ?? undefined,
libraryScopes: options?.libraryScopes?.length
? options.libraryScopes
: libraryScopePairsForServer(serverId),
entityTypes: ['artist'],
artistCreditMode: creditMode,
starredOnly: options?.starredOnly,
...(bucket ? { artistLetterBucket: bucket } : {}),
sort: [{ field: 'name', dir: 'asc' }],
limit: chunkSize,
@@ -5,10 +5,6 @@ const libraryScopeArtistDetailMock = vi.fn();
vi.mock('@/lib/api/library/scopeReads', () => ({
libraryScopeArtistDetail: (...args: unknown[]) => libraryScopeArtistDetailMock(...args),
scopePairsFromLibrarySelection: (serverId: string) => [
{ serverId: `${serverId}-idx`, libraryId: 'lib-a' },
{ serverId: `${serverId}-idx`, libraryId: 'lib-b' },
],
}));
import { tryLoadArtistDetailMultiScope } from './loadArtistDetailMultiScope';
@@ -71,12 +67,16 @@ describe('tryLoadArtistDetailMultiScope', () => {
],
});
const result = await tryLoadArtistDetailMultiScope('srv-1', 'art-1');
const scopes = [
{ serverId: 'srv-1', libraryId: 'lib-a' },
{ serverId: 'srv-2', libraryId: 'lib-b' },
];
const result = await tryLoadArtistDetailMultiScope(scopes, 'srv-1', 'art-1');
expect(libraryScopeArtistDetailMock).toHaveBeenCalledWith('srv-1', {
scopes: [
{ serverId: 'srv-1-idx', libraryId: 'lib-a' },
{ serverId: 'srv-1-idx', libraryId: 'lib-b' },
{ serverId: 'srv-1', libraryId: 'lib-a' },
{ serverId: 'srv-2', libraryId: 'lib-b' },
],
artistId: 'art-1',
serverId: 'srv-1',
@@ -93,12 +93,12 @@ describe('tryLoadArtistDetailMultiScope', () => {
tracks: [],
});
await expect(tryLoadArtistDetailMultiScope('srv-1', 'art-1')).resolves.toBeNull();
await expect(tryLoadArtistDetailMultiScope([], 'srv-1', 'art-1')).resolves.toBeNull();
});
it('returns null when the scope command throws', async () => {
libraryScopeArtistDetailMock.mockRejectedValue(new Error('ipc fail'));
await expect(tryLoadArtistDetailMultiScope('srv-1', 'art-1')).resolves.toBeNull();
await expect(tryLoadArtistDetailMultiScope([], 'srv-1', 'art-1')).resolves.toBeNull();
});
});
@@ -1,6 +1,6 @@
import {
libraryScopeArtistDetail,
scopePairsFromLibrarySelection,
type LibraryScopePair,
} from '@/lib/api/library/scopeReads';
import { albumToAlbum, artistToArtist, trackToSong } from '@/lib/library/advancedSearchLocal';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
@@ -16,12 +16,13 @@ export interface ArtistDetailMultiScopePayload {
* (one or more). Returns null on IPC failure or when the merged artist anchor is missing.
*/
export async function tryLoadArtistDetailMultiScope(
scopes: LibraryScopePair[],
serverId: string,
artistId: string,
): Promise<ArtistDetailMultiScopePayload | null> {
try {
const response = await libraryScopeArtistDetail(serverId, {
scopes: scopePairsFromLibrarySelection(serverId),
scopes,
artistId,
serverId,
});
+2 -1
View File
@@ -7,10 +7,11 @@ export function dedupeById<T extends { id: string; serverId?: string }>(items: T
const seen = new Set<string>();
const out: T[] = [];
for (const item of items) {
const key = item.serverId ? `${item.serverId}:${item.id}` : item.id;
const key = ownedEntityKey(item);
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
}
import { ownedEntityKey } from './ownedEntityKey';
+3
View File
@@ -0,0 +1,3 @@
export function ownedEntityKey(entity: { id: string; serverId?: string | null }): string {
return entity.serverId ? `${entity.serverId}:${entity.id}` : entity.id;
}