diff --git a/src/features/contextMenu/components/ContextMenu.test.tsx b/src/features/contextMenu/components/ContextMenu.test.tsx index 29e1601b..67d1425d 100644 --- a/src/features/contextMenu/components/ContextMenu.test.tsx +++ b/src/features/contextMenu/components/ContextMenu.test.tsx @@ -160,6 +160,19 @@ describe('ContextMenu — type=song', () => { fireEvent.click(getByText('Play Next')); expect(usePlayerStore.getState().contextMenu.isOpen).toBe(false); }); + + it('opens Song Info with the context track owner', () => { + openMenuFor('song', makeTrack({ id: 'shared', serverId: 'srv-owner' })); + const { getByText } = renderWithProviders(); + + fireEvent.click(getByText('Song Info')); + + expect(usePlayerStore.getState().songInfoModal).toEqual({ + isOpen: true, + songId: 'shared', + serverId: 'srv-owner', + }); + }); }); describe('ContextMenu — type=album', () => { diff --git a/src/features/contextMenu/components/QueueItemContextItems.tsx b/src/features/contextMenu/components/QueueItemContextItems.tsx index a80de68c..533b0bca 100644 --- a/src/features/contextMenu/components/QueueItemContextItems.tsx +++ b/src/features/contextMenu/components/QueueItemContextItems.tsx @@ -9,6 +9,7 @@ import StarRating from '@/ui/StarRating'; import { AddToPlaylistSubmenu } from '@/features/contextMenu/components/AddToPlaylistSubmenu'; import type { ContextMenuItemsProps } from '@/features/contextMenu/components/contextMenuItemTypes'; import { buildArtistDetailPath } from '@/lib/navigation/detailServerScope'; +import { buildAlbumDetailPath } from '@/lib/navigation/detailServerScope'; import { ownedEntityKey } from '@/lib/util/ownedEntityKey'; export default function QueueItemContextItems(props: ContextMenuItemsProps) { @@ -58,7 +59,9 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
{song.albumId && ( -
handleAction(() => navigateLibrary(`/album/${song.albumId}`))}> +
handleAction(() => navigateLibrary( + buildAlbumDetailPath(song.albumId!, { serverId: song.serverId }), + ))}> {t('contextMenu.openAlbum')}
)} @@ -114,10 +117,10 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) { />
-
handleAction(() => copyShareLink('track', song.id))}> +
handleAction(() => copyShareLink('track', song.id, song.serverId))}> {t('contextMenu.shareLink')}
-
handleAction(() => openSongInfo(song.id))}> +
handleAction(() => openSongInfo(song.id, song.serverId))}> {t('contextMenu.songInfo')}
diff --git a/src/features/contextMenu/components/SongContextItems.tsx b/src/features/contextMenu/components/SongContextItems.tsx index e79ccdd6..3a03f665 100644 --- a/src/features/contextMenu/components/SongContextItems.tsx +++ b/src/features/contextMenu/components/SongContextItems.tsx @@ -180,7 +180,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
handleAction(() => copyShareLink('track', song.id, song.serverId))}> {t('contextMenu.shareLink')}
-
handleAction(() => openSongInfo(song.id))}> +
handleAction(() => openSongInfo(song.id, song.serverId))}> {t('contextMenu.songInfo')}
{offlinePolicy.canEditPlaylist && playlistId && playlistSongIndex !== undefined && playlistSongRemove && ( @@ -261,7 +261,10 @@ export default function SongContextItems(props: ContextMenuItemsProps) { )}
{song.albumId && ( -
handleAction(() => navigateToAlbum(song.albumId!))}> +
handleAction(() => navigateToAlbum( + song.albumId!, + { search: appendServerQuery(undefined, song.serverId) }, + ))}> {t('contextMenu.openAlbum')}
)} @@ -314,10 +317,10 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
)}
-
handleAction(() => copyShareLink('track', song.id))}> +
handleAction(() => copyShareLink('track', song.id, song.serverId))}> {t('contextMenu.shareLink')}
-
handleAction(() => openSongInfo(song.id))}> +
handleAction(() => openSongInfo(song.id, song.serverId))}> {t('contextMenu.songInfo')}
{offlinePolicy.canFavorite && ( diff --git a/src/features/contextMenu/components/contextMenuItemTypes.ts b/src/features/contextMenu/components/contextMenuItemTypes.ts index 0914b64b..dbd93fc6 100644 --- a/src/features/contextMenu/components/contextMenuItemTypes.ts +++ b/src/features/contextMenu/components/contextMenuItemTypes.ts @@ -33,7 +33,7 @@ export interface ContextMenuItemsProps { setStarredOverride: (id: string, starred: boolean) => void; networkLovedCache: Record; setNetworkLovedForSong: (title: string, artist: string, loved: boolean) => void; - openSongInfo: (id: string) => void; + openSongInfo: (id: string, serverId?: string) => void; userRatingOverrides: Record; setKeyboardRating: React.Dispatch>; keyboardRating: KeyboardRating | null; diff --git a/src/features/miniPlayer/components/MiniContextMenu.tsx b/src/features/miniPlayer/components/MiniContextMenu.tsx index bcd886b2..4c1cc84b 100644 --- a/src/features/miniPlayer/components/MiniContextMenu.tsx +++ b/src/features/miniPlayer/components/MiniContextMenu.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { Play, Trash2, Disc3, User, Heart, Info } from 'lucide-react'; import type { MiniTrackInfo } from '@/features/miniPlayer/utils/miniPlayerBridge'; import { buildArtistDetailPath } from '@/lib/navigation/detailServerScope'; +import { buildAlbumDetailPath } from '@/lib/navigation/detailServerScope'; interface Props { x: number; @@ -94,7 +95,9 @@ export default function MiniContextMenu({ x, y, track, index, onClose }: Props) {track.albumId && (
run(() => emit('mini:navigate', { to: `/album/${track.albumId}` }))} + onClick={() => run(() => emit('mini:navigate', { + to: buildAlbumDetailPath(track.albumId!, { serverId: track.serverId }), + }))} > {t('contextMenu.openAlbum')}
@@ -116,7 +119,7 @@ export default function MiniContextMenu({ x, y, track, index, onClose }: Props)
run(() => emit('mini:song-info', { id: track.id }))} + onClick={() => run(() => emit('mini:song-info', { id: track.id, serverId: track.serverId }))} > {t('contextMenu.songInfo')}
diff --git a/src/features/miniPlayer/utils/miniPlayerBridge.ts b/src/features/miniPlayer/utils/miniPlayerBridge.ts index 5377b239..cac671d1 100644 --- a/src/features/miniPlayer/utils/miniPlayerBridge.ts +++ b/src/features/miniPlayer/utils/miniPlayerBridge.ts @@ -239,14 +239,14 @@ export function initMiniPlayerBridgeOnMain(): () => void { }); // Open the SongInfo modal in main for a given track id. - const songInfoUnlisten = listen<{ id: string }>('mini:song-info', (e) => { + const songInfoUnlisten = listen<{ id: string; serverId?: string }>('mini:song-info', (e) => { const id = e.payload?.id; if (!id) return; const w = getCurrentWindow(); w.unminimize().catch(() => {}); w.show().catch(() => {}); w.setFocus().catch(() => {}); - usePlayerStore.getState().openSongInfo(id); + usePlayerStore.getState().openSongInfo(id, e.payload?.serverId); }); return () => { diff --git a/src/features/playback/components/SongInfoModal.test.tsx b/src/features/playback/components/SongInfoModal.test.tsx new file mode 100644 index 00000000..7b4b1ec1 --- /dev/null +++ b/src/features/playback/components/SongInfoModal.test.tsx @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { renderWithProviders } from '@/test/helpers/renderWithProviders'; + +const mocks = vi.hoisted(() => ({ + getSong: vi.fn(), + getSongForServer: vi.fn(), + libraryGetFacts: vi.fn(), + ndGetSongPath: vi.fn(), +})); + +vi.mock('@/lib/api/subsonicLibrary', () => ({ + getSong: mocks.getSong, + getSongForServer: mocks.getSongForServer, +})); +vi.mock('@/lib/api/library', () => ({ libraryGetFacts: mocks.libraryGetFacts })); +vi.mock('@/lib/api/navidromeAdmin', () => ({ ndGetSongPath: mocks.ndGetSongPath })); +vi.mock('@/lib/library/libraryReady', () => ({ libraryIsReady: vi.fn(() => Promise.resolve(true)) })); +vi.mock('@/store/libraryIndexStore', () => ({ + useLibraryIndexStore: { getState: () => ({ isIndexEnabled: () => true }) }, +})); + +import SongInfoModal from './SongInfoModal'; +import { resetAuthStore, resetPlayerStore } from '@/test/helpers/storeReset'; +import { useAuthStore } from '@/store/authStore'; +import { usePlayerStore } from '@/features/playback/store/playerStore'; + +describe('SongInfoModal server ownership', () => { + beforeEach(() => { + resetAuthStore(); + resetPlayerStore(); + Object.values(mocks).forEach(mock => mock.mockReset()); + useAuthStore.setState({ + activeServerId: 'srv-active', + servers: [ + { id: 'srv-active', name: 'Active', url: 'https://active.test', username: 'a', password: 'p' }, + { id: 'srv-owner', name: 'Owner', url: 'https://owner.test', username: 'owner', password: 'secret' }, + ], + subsonicServerIdentityByServer: { + 'srv-owner': { type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, + }, + }); + mocks.getSongForServer.mockResolvedValue({ + id: 'shared', + serverId: 'srv-owner', + title: 'Owner Song', + artist: 'Owner Artist', + album: 'Owner Album', + duration: 120, + }); + mocks.libraryGetFacts.mockResolvedValue([]); + mocks.ndGetSongPath.mockResolvedValue('/owner/music/song.flac'); + }); + + it('loads metadata, local facts, and native path from the captured owner', async () => { + usePlayerStore.getState().openSongInfo('shared', 'srv-owner'); + const view = renderWithProviders(); + + expect(await view.findByText('Owner Song')).toBeInTheDocument(); + expect(mocks.getSongForServer).toHaveBeenCalledWith('srv-owner', 'shared'); + expect(mocks.getSong).not.toHaveBeenCalled(); + await waitFor(() => expect(mocks.libraryGetFacts).toHaveBeenCalledWith('srv-owner', 'shared')); + expect(mocks.ndGetSongPath).toHaveBeenCalledWith( + 'https://owner.test', + 'owner', + 'secret', + 'shared', + ); + expect(await view.findByText('/owner/music/song.flac')).toBeInTheDocument(); + }); +}); diff --git a/src/features/playback/components/SongInfoModal.tsx b/src/features/playback/components/SongInfoModal.tsx index af47f45f..36391a97 100644 --- a/src/features/playback/components/SongInfoModal.tsx +++ b/src/features/playback/components/SongInfoModal.tsx @@ -1,4 +1,4 @@ -import { getSong } from '@/lib/api/subsonicLibrary'; +import { getSong, getSongForServer } from '@/lib/api/subsonicLibrary'; import { libraryGetFacts } from '@/lib/api/library'; import type { SubsonicSong } from '@/lib/api/subsonicTypes'; import React, { useEffect, useState } from 'react'; @@ -92,8 +92,11 @@ export default function SongInfoModal() { setEnrichment(null); setAbsolutePath(null); const songId = songInfoModal.songId; + const ownerServerId = songInfoModal.serverId; void (async () => { - const s = await getSong(songId); + const s = ownerServerId + ? await getSongForServer(ownerServerId, songId) + : await getSong(songId); if (cancelled) return; setSong(s); setLoading(false); @@ -101,8 +104,7 @@ export default function SongInfoModal() { setEnrichment(null); return; } - const auth = useAuthStore.getState(); - const sid = auth.activeServerId; + const sid = ownerServerId ?? useAuthStore.getState().activeServerId; const indexEnabled = sid ? useLibraryIndexStore.getState().isIndexEnabled(sid) : false; if (sid && indexEnabled && await libraryIsReady(sid)) { try { @@ -121,7 +123,7 @@ export default function SongInfoModal() { // and we have credentials. Failures are silent — modal falls back to // whatever the Subsonic `path` field carried (typically nothing). const auth = useAuthStore.getState(); - const sid = auth.activeServerId; + const sid = ownerServerId ?? auth.activeServerId; const profile = sid ? auth.servers.find(p => p.id === sid) : null; const identity = sid ? auth.subsonicServerIdentityByServer[sid] : undefined; const isNavidrome = identity?.type?.trim().toLowerCase() === 'navidrome'; @@ -132,7 +134,7 @@ export default function SongInfoModal() { }); } return () => { cancelled = true; }; - }, [songInfoModal.isOpen, songInfoModal.songId]); + }, [songInfoModal.isOpen, songInfoModal.songId, songInfoModal.serverId]); useEffect(() => { if (!songInfoModal.isOpen) return; diff --git a/src/features/playback/store/playerStore.misc.test.ts b/src/features/playback/store/playerStore.misc.test.ts index fbca2500..35afae46 100644 --- a/src/features/playback/store/playerStore.misc.test.ts +++ b/src/features/playback/store/playerStore.misc.test.ts @@ -119,8 +119,12 @@ describe('openContextMenu / closeContextMenu', () => { describe('openSongInfo / closeSongInfo', () => { it('opens with the song id and clears on close', () => { - usePlayerStore.getState().openSongInfo('song-1'); - expect(usePlayerStore.getState().songInfoModal).toEqual({ isOpen: true, songId: 'song-1' }); + usePlayerStore.getState().openSongInfo('song-1', 'srv-owner'); + expect(usePlayerStore.getState().songInfoModal).toEqual({ + isOpen: true, + songId: 'song-1', + serverId: 'srv-owner', + }); usePlayerStore.getState().closeSongInfo(); expect(usePlayerStore.getState().songInfoModal).toEqual({ isOpen: false, songId: null }); diff --git a/src/features/playback/store/playerStoreTypes.ts b/src/features/playback/store/playerStoreTypes.ts index 8a5f84a6..e0e44717 100644 --- a/src/features/playback/store/playerStoreTypes.ts +++ b/src/features/playback/store/playerStoreTypes.ts @@ -188,7 +188,7 @@ export interface PlayerState { ) => void; closeContextMenu: () => void; - songInfoModal: { isOpen: boolean; songId: string | null }; - openSongInfo: (songId: string) => void; + songInfoModal: { isOpen: boolean; songId: string | null; serverId?: string }; + openSongInfo: (songId: string, serverId?: string) => void; closeSongInfo: () => void; } diff --git a/src/features/playback/store/uiStateActions.ts b/src/features/playback/store/uiStateActions.ts index 5b4f008b..bc512e4c 100644 --- a/src/features/playback/store/uiStateActions.ts +++ b/src/features/playback/store/uiStateActions.ts @@ -81,7 +81,7 @@ export function createUiStateActions(set: SetState): Pick< contextMenu: { ...state.contextMenu, isOpen: false }, })), - openSongInfo: (songId) => set({ songInfoModal: { isOpen: true, songId } }), + openSongInfo: (songId, serverId) => set({ songInfoModal: { isOpen: true, songId, serverId } }), closeSongInfo: () => set({ songInfoModal: { isOpen: false, songId: null } }), toggleQueue: () => diff --git a/src/lib/navigation/detailServerScope.test.ts b/src/lib/navigation/detailServerScope.test.ts index 35a162e2..98ade064 100644 --- a/src/lib/navigation/detailServerScope.test.ts +++ b/src/lib/navigation/detailServerScope.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { useAuthStore } from '@/store/authStore'; import { appendServerQuery, + buildAlbumDetailPath, buildArtistDetailPath, readDetailServerId, } from '@/lib/navigation/detailServerScope'; @@ -42,6 +43,13 @@ describe('detailServerScope', () => { })).toBe('/artist/art-1?lossless=1&server=srv-b&tab=albums'); }); + it('buildAlbumDetailPath preserves search and owning server', () => { + expect(buildAlbumDetailPath('album-1', { + serverId: 'srv-b', + search: 'lossless=1', + })).toBe('/album/album-1?lossless=1&server=srv-b'); + }); + it('buildArtistDetailPath preserves an existing server when no owner is supplied', () => { expect(buildArtistDetailPath('art-1', { search: 'server=srv-a&lossless=1' })) .toBe('/artist/art-1?server=srv-a&lossless=1'); diff --git a/src/lib/navigation/detailServerScope.ts b/src/lib/navigation/detailServerScope.ts index 15a2347e..dc6723fe 100644 --- a/src/lib/navigation/detailServerScope.ts +++ b/src/lib/navigation/detailServerScope.ts @@ -5,6 +5,17 @@ export interface ArtistDetailPathOptions { search?: string | URLSearchParams; } +/** Build an album detail path while preserving query parameters and owning server. */ +export function buildAlbumDetailPath( + albumId: string, + options: ArtistDetailPathOptions = {}, +): string { + const params = new URLSearchParams(options.search ?? ''); + if (options.serverId) params.set('server', options.serverId); + const query = params.toString(); + return `/album/${albumId}${query ? `?${query}` : ''}`; +} + /** Build an artist detail path while preserving query parameters and owning server. */ export function buildArtistDetailPath( artistId: string,