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,