mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
fix(library): preserve multi-server ownership
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
+9
-9
@@ -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();
|
||||
});
|
||||
});
|
||||
+3
-2
@@ -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,
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function ownedEntityKey(entity: { id: string; serverId?: string | null }): string {
|
||||
return entity.serverId ? `${entity.serverId}:${entity.id}` : entity.id;
|
||||
}
|
||||
Reference in New Issue
Block a user