diff --git a/src/cover/useWarmTrackListAlbumCovers.test.ts b/src/cover/useWarmTrackListAlbumCovers.test.ts index 8710b1e2..5bc89383 100644 --- a/src/cover/useWarmTrackListAlbumCovers.test.ts +++ b/src/cover/useWarmTrackListAlbumCovers.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { uniqueAlbumIdsFromSongs } from '@/cover/warmDiskPeek'; +import { + uniqueAlbumCoverSourcesFromSongs, + uniqueAlbumIdsFromSongs, +} from '@/cover/warmDiskPeek'; describe('uniqueAlbumIdsFromSongs', () => { it('dedupes by albumId and respects limit', () => { @@ -19,3 +22,16 @@ describe('uniqueAlbumIdsFromSongs', () => { expect(uniqueAlbumIdsFromSongs([{ albumId: '' }, { albumId: ' ' }, { albumId: 'x' }])).toEqual(['x']); }); }); + +describe('uniqueAlbumCoverSourcesFromSongs', () => { + it('preserves equal album ids owned by different servers', () => { + expect(uniqueAlbumCoverSourcesFromSongs([ + { albumId: 'same', coverArt: 'cover-a', serverId: 'a' }, + { albumId: 'same', coverArt: 'cover-b', serverId: 'b' }, + { albumId: 'same', coverArt: 'duplicate-a', serverId: 'a' }, + ])).toEqual([ + { albumId: 'same', coverArt: 'cover-a', serverId: 'a' }, + { albumId: 'same', coverArt: 'cover-b', serverId: 'b' }, + ]); + }); +}); diff --git a/src/cover/useWarmTrackListAlbumCovers.ts b/src/cover/useWarmTrackListAlbumCovers.ts index 7b6b151a..4abc11b1 100644 --- a/src/cover/useWarmTrackListAlbumCovers.ts +++ b/src/cover/useWarmTrackListAlbumCovers.ts @@ -3,13 +3,15 @@ import type { SubsonicSong } from '@/lib/api/subsonicTypes'; import { COVER_ARTIST_TOP_TRACK_CSS_PX } from '@/cover/layoutSizes'; import { useLibraryCoverPrefetch } from '@/cover/useLibraryCoverPrefetch'; import { - uniqueAlbumIdsFromSongs, + uniqueAlbumCoverSourcesFromSongs, warmUniqueAlbumCoversFromLibrary, } from '@/cover/warmDiskPeek'; +import { coverServerScopeForOwnerServerId } from '@/cover/serverScope'; +import { COVER_SCOPE_ACTIVE, coverScopeKey, type CoverServerScope } from '@/cover/types'; const DEFAULT_LIMIT = 48; -type SongAlbumSource = Pick; +type SongAlbumSource = Pick; /** * Standard cover pipeline warm for track-list surfaces: dedupe visible songs to @@ -24,27 +26,45 @@ export function useWarmTrackListAlbumCovers( const enabled = opts?.enabled ?? true; const limit = opts?.limit ?? DEFAULT_LIMIT; - const albumIds = useMemo( - () => uniqueAlbumIdsFromSongs(songs, limit), + const albums = useMemo( + () => uniqueAlbumCoverSourcesFromSongs(songs, limit), [songs, limit], ); - const warmKey = useMemo(() => albumIds.join('\u0001'), [albumIds]); - const prefetchAlbums = useMemo( - () => albumIds.map(id => ({ id })), - [albumIds], + const warmKey = useMemo( + () => albums.map(album => `${album.serverId ?? ''}\u0001${album.albumId}\u0001${album.coverArt}`).join('\u0002'), + [albums], ); + const prefetchBuckets = useMemo(() => { + const grouped = new Map; + }>(); + for (const album of albums) { + const serverScope = album.serverId + ? coverServerScopeForOwnerServerId(album.serverId) + : COVER_SCOPE_ACTIVE; + const key = coverScopeKey(serverScope); + const group = grouped.get(key) ?? { serverScope, albums: [] }; + group.albums.push({ id: album.albumId, coverArt: album.coverArt }); + grouped.set(key, group); + } + return [...grouped.values()].map(group => ({ + albums: group.albums, + limit: group.albums.length, + priority: 'high' as const, + serverScope: group.serverScope, + })); + }, [albums]); useLibraryCoverPrefetch( - prefetchAlbums.length > 0 - ? [{ albums: prefetchAlbums, limit, priority: 'high' }] - : [], + prefetchBuckets, [warmKey, enabled], ); useEffect(() => { - if (!enabled || displayCssPx <= 0 || albumIds.length === 0) return; + if (!enabled || displayCssPx <= 0 || albums.length === 0) return; let cancelled = false; - void warmUniqueAlbumCoversFromLibrary(albumIds, displayCssPx, 'dense').then(() => { + void warmUniqueAlbumCoversFromLibrary(albums, displayCssPx, 'dense').then(() => { if (cancelled) return; }); return () => { diff --git a/src/cover/warmDiskPeek.test.ts b/src/cover/warmDiskPeek.test.ts index 30dbc50c..22abb164 100644 --- a/src/cover/warmDiskPeek.test.ts +++ b/src/cover/warmDiskPeek.test.ts @@ -22,12 +22,22 @@ vi.mock('./serverScope', () => ({ password: 'secret', } : { kind: 'active' }, + coverServerScopeForOwnerServerId: (serverId: string) => ({ + kind: 'server', + serverId, + url: `https://${serverId}.test`, + username: serverId, + password: 'secret', + }), })); vi.mock('./resolveEntryLibrary', () => ({ resolveAlbumCoverRefFromLibrary, })); -import { warmHomeMainstageCovers } from './warmDiskPeek'; +import { + warmHomeMainstageCovers, + warmUniqueAlbumCoversFromLibrary, +} from './warmDiskPeek'; describe('warmHomeMainstageCovers', () => { beforeEach(() => { @@ -75,3 +85,34 @@ describe('warmHomeMainstageCovers', () => { expect(resolveAlbumCoverRefFromLibrary).not.toHaveBeenCalled(); }); }); + +describe('warmUniqueAlbumCoversFromLibrary', () => { + it('resolves equal album ids in separate owner scopes', async () => { + resolveAlbumCoverRefFromLibrary.mockImplementation( + async (albumId: string, coverArt: string, serverScope: unknown) => ({ + cacheKind: 'album', + cacheEntityId: albumId, + fetchCoverArtId: coverArt, + serverScope, + }), + ); + + await warmUniqueAlbumCoversFromLibrary([ + { albumId: 'same', coverArt: 'cover-a', serverId: 'a' }, + { albumId: 'same', coverArt: 'cover-b', serverId: 'b' }, + ], 64); + + expect(resolveAlbumCoverRefFromLibrary).toHaveBeenNthCalledWith( + 1, + 'same', + 'cover-a', + expect.objectContaining({ kind: 'server', serverId: 'a' }), + ); + expect(resolveAlbumCoverRefFromLibrary).toHaveBeenNthCalledWith( + 2, + 'same', + 'cover-b', + expect.objectContaining({ kind: 'server', serverId: 'b' }), + ); + }); +}); diff --git a/src/cover/warmDiskPeek.ts b/src/cover/warmDiskPeek.ts index 063a10b8..66c6b6ac 100644 --- a/src/cover/warmDiskPeek.ts +++ b/src/cover/warmDiskPeek.ts @@ -3,7 +3,10 @@ import type { SubsonicAlbum } from '@/lib/api/subsonicTypes'; import { coverEnsureQueued, ensureArtistBackdropQueued } from './ensureQueue'; import { getDiskSrcForGrid, rememberGridDiskSrc } from './diskSrcLookup'; import { albumCoverRef, artistCoverRef } from './ref'; -import { coverServerScopeForServerId } from './serverScope'; +import { + coverServerScopeForOwnerServerId, + coverServerScopeForServerId, +} from './serverScope'; import { coverDiskUrl } from './diskSrcCache'; import { resolveAlbumCoverRefFromLibrary } from './resolveEntryLibrary'; import { coverStorageKeyFromRef } from './storageKeys'; @@ -50,7 +53,7 @@ export async function coverWarmItemFromLibrary( const ref = await resolveAlbumCoverRefFromLibrary( albumId, fetchCoverArtId, - coverServerScopeForServerId(serverId), + serverId ? coverServerScopeForOwnerServerId(serverId) : coverServerScopeForServerId(serverId), ); const tier = resolveCoverDisplayTier(displayCssPx, { surface }); return { @@ -116,18 +119,60 @@ export function uniqueAlbumIdsFromSongs( return out; } +export type SongAlbumCoverSource = { + albumId?: string | null; + coverArt?: string | null; + serverId?: string; +}; + +export type UniqueAlbumCoverSource = { + albumId: string; + coverArt: string; + serverId?: string; +}; + +/** Dedupe track-list covers by owner + album identity. */ +export function uniqueAlbumCoverSourcesFromSongs( + songs: ReadonlyArray, + limit = 48, +): UniqueAlbumCoverSource[] { + const seen = new Set(); + const out: UniqueAlbumCoverSource[] = []; + for (const song of songs) { + const albumId = song.albumId?.trim(); + if (!albumId) continue; + const serverId = song.serverId?.trim() || undefined; + const key = `${serverId ?? ''}\u0001${albumId}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ + albumId, + coverArt: song.coverArt?.trim() || albumId, + ...(serverId ? { serverId } : {}), + }); + if (out.length >= limit) break; + } + return out; +} + /** * Library-resolved peek + high-priority ensure for deduped album covers referenced * by a track list window (browse rows, playlist suggestions, virtual slice). */ export async function warmUniqueAlbumCoversFromLibrary( - albumIds: readonly string[], + albums: readonly UniqueAlbumCoverSource[], displayCssPx: number, surface: CoverSurfaceKind = 'dense', ): Promise { - if (albumIds.length === 0 || displayCssPx <= 0) return; + if (albums.length === 0 || displayCssPx <= 0) return; const items = await Promise.all( - albumIds.map(albumId => coverWarmItemFromLibrary(albumId, albumId, displayCssPx, surface)), + albums.map(album => coverWarmItemFromLibrary( + album.albumId, + album.coverArt, + displayCssPx, + surface, + album.serverId, + )), ); const batch = dedupeWarmItems(items); if (batch.length === 0) return; diff --git a/src/features/album/hooks/useNavigateToAlbum.ts b/src/features/album/hooks/useNavigateToAlbum.ts index 73c408b1..df3a8543 100644 --- a/src/features/album/hooks/useNavigateToAlbum.ts +++ b/src/features/album/hooks/useNavigateToAlbum.ts @@ -1,13 +1,14 @@ import { useCallback } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { navigateToAlbumDetail } from '@/lib/navigation/albumDetailNavigation'; +import type { ArtistDetailPathOptions } from '@/lib/navigation/detailServerScope'; /** Navigate to album detail, remembering the current page for the back button. */ export function useNavigateToAlbum() { const navigate = useNavigate(); const location = useLocation(); return useCallback( - (albumId: string, opts?: { search?: string }) => { + (albumId: string, opts?: ArtistDetailPathOptions) => { navigateToAlbumDetail(navigate, location, albumId, opts); }, [navigate, location], diff --git a/src/features/search/components/LiveSearch.tsx b/src/features/search/components/LiveSearch.tsx index da22ccbb..219be00f 100644 --- a/src/features/search/components/LiveSearch.tsx +++ b/src/features/search/components/LiveSearch.tsx @@ -158,7 +158,11 @@ export default function LiveSearch() { setOpen(false); setQuery(''); } }))), - ...(results.albums.map(a => ({ id: a.id, action: () => { navigateToAlbum(a.id); setOpen(false); setQuery(''); } }))), + ...(results.albums.map(a => ({ id: a.id, action: () => { + navigateToAlbum(a.id, { serverId: a.serverId ?? activeServerId }); + setOpen(false); + setQuery(''); + } }))), ...(results.songs.map(s => ({ id: s.id, action: () => { const track = songToTrack(s); enqueue([track]); diff --git a/src/features/search/components/LiveSearchDropdown.test.tsx b/src/features/search/components/LiveSearchDropdown.test.tsx index 67bda62e..85949688 100644 --- a/src/features/search/components/LiveSearchDropdown.test.tsx +++ b/src/features/search/components/LiveSearchDropdown.test.tsx @@ -1,10 +1,15 @@ import { describe, expect, it, vi } from 'vitest'; import { screen } from '@testing-library/react'; import { renderWithProviders } from '@/test/helpers/renderWithProviders'; +import userEvent from '@testing-library/user-event'; import LiveSearchDropdown from '@/features/search/components/LiveSearchDropdown'; import type { useShareSearch } from '@/features/search/hooks/useShareSearch'; import type { SearchResults } from '@/lib/api/subsonicTypes'; +const { navigateToAlbumMock } = vi.hoisted(() => ({ + navigateToAlbumMock: vi.fn(), +})); + vi.mock('@/store/liveSearchScopeStore', () => ({ useLiveSearchScopeStore: (selector: (s: { query: string; setQuery: () => void }) => unknown) => selector({ query: 'beatles', setQuery: vi.fn() }), @@ -19,7 +24,7 @@ vi.mock('react-router-dom', async importOriginal => { }); vi.mock('@/features/album', () => ({ - useNavigateToAlbum: () => vi.fn(), + useNavigateToAlbum: () => navigateToAlbumMock, albumArtistDisplayName: (album: { artist?: string }) => album.artist ?? '', })); @@ -113,4 +118,38 @@ describe('LiveSearchDropdown index incomplete banner', () => { expect(screen.queryByRole('status')).toBeNull(); }); + + it('preserves the album owner when opening a local result', async () => { + navigateToAlbumMock.mockReset(); + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole('option', { name: /Owned Album/i })); + + expect(navigateToAlbumMock).toHaveBeenCalledWith('album-1', { serverId: 'srv-b' }); + }); }); diff --git a/src/features/search/components/LiveSearchDropdown.tsx b/src/features/search/components/LiveSearchDropdown.tsx index f21d2b74..bf423bcb 100644 --- a/src/features/search/components/LiveSearchDropdown.tsx +++ b/src/features/search/components/LiveSearchDropdown.tsx @@ -17,6 +17,7 @@ import { import type { useShareSearch } from '@/features/search/hooks/useShareSearch'; import { useAuthStore } from '@/store/authStore'; import { buildArtistDetailPath } from '@/lib/navigation/detailServerScope'; +import { ownedEntityKey } from '@/lib/util/ownedEntityKey'; export type LiveSearchSource = 'local' | 'network'; @@ -51,7 +52,10 @@ export default function LiveSearchDropdown({ const enqueue = usePlayerStore(state => state.enqueue); const openContextMenu = usePlayerStore(state => state.openContextMenu); const ctxIsOpen = usePlayerStore(state => state.contextMenu.isOpen); - const ctxItemId = usePlayerStore(state => (state.contextMenu.item as { id?: string } | null)?.id); + const ctxItem = usePlayerStore(state => state.contextMenu.item as { + id?: string; + serverId?: string; + } | null); const ctxType = usePlayerStore(state => state.contextMenu.type); const hasResults = @@ -138,9 +142,12 @@ export default function LiveSearchDropdown({
{t('search.artists')}
{results.artists.map(a => { const i = idx++; - const isCtxActive = ctxIsOpen && ctxType === 'artist' && ctxItemId === a.id; + const isCtxActive = ctxIsOpen + && ctxType === 'artist' + && !!ctxItem?.id + && ownedEntityKey(a) === ownedEntityKey({ id: ctxItem.id, serverId: ctxItem.serverId }); return ( - + - + ))} )} @@ -371,7 +369,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
{t('search.artists')}
{results!.artists.map(a => (