From 68a7c212af97d00ed8b8a9ab334e65fb7352c608 Mon Sep 17 00:00:00 2001
From: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
Date: Sun, 19 Jul 2026 23:11:38 +0300
Subject: [PATCH] fix(share): preserve explicit server ownership
---
.../search/components/ShareSearchResults.tsx | 17 +-
src/features/search/hooks/useShareSearch.ts | 4 +-
src/features/share/applySharePaste.test.ts | 191 ++++++++++++++++++
src/features/share/applySharePaste.ts | 66 +++---
.../share/enqueueShareSearchPayload.test.ts | 68 ++++---
.../share/enqueueShareSearchPayload.ts | 124 +++---------
src/features/share/shareServerResolution.ts | 24 +++
7 files changed, 320 insertions(+), 174 deletions(-)
create mode 100644 src/features/share/applySharePaste.test.ts
create mode 100644 src/features/share/shareServerResolution.ts
diff --git a/src/features/search/components/ShareSearchResults.tsx b/src/features/search/components/ShareSearchResults.tsx
index 0624b945..7ca41349 100644
--- a/src/features/search/components/ShareSearchResults.tsx
+++ b/src/features/search/components/ShareSearchResults.tsx
@@ -5,7 +5,6 @@ import type { TFunction } from 'i18next';
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
import type { ServerProfile } from '@/store/authStoreTypes';
import { songToTrack } from '@/lib/media/songToTrack';
-import { activateShareSearchServer } from '@/features/share/enqueueShareSearchPayload';
import { sharePayloadTotal, type ShareSearchMatch } from '@/lib/share/shareSearch';
import type { ShareSearchPreviewState } from '@/features/search/hooks/useShareSearchPreview';
import type { NavidromePublicSharePreviewState } from '@/features/search/hooks/useNavidromePublicSharePreview';
@@ -126,16 +125,6 @@ function StaticIcon({ className, children }: { className: string; children: Reac
return
{children}
;
}
-function withShareServer(
- shareMatch: ShareSearchMatch,
- t: TFunction,
- fn: () => void,
-): void {
- if (shareMatch.type === 'unsupported' || shareMatch.type === 'navidrome-public') return;
- if (!activateShareSearchServer(shareMatch.payload.srv, t)) return;
- fn();
-}
-
function shareSubLine(primary: string, serverLabel: string | null | undefined, t: TFunction): string {
if (!serverLabel) return primary;
const hint = t('search.shareFromServer', { server: serverLabel });
@@ -333,7 +322,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
onClick={onOpenArtist}
onContextMenu={e => {
e.preventDefault();
- withShareServer(shareMatch, t, () => onContextMenu?.(e, shareArtist, 'artist'));
+ onContextMenu?.(e, shareArtist, 'artist');
}}
role={desktop ? 'option' : undefined}
aria-selected={desktop ? activeIndex === 0 : undefined}
@@ -421,7 +410,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
onClick={onOpenAlbum}
onContextMenu={e => {
e.preventDefault();
- withShareServer(shareMatch, t, () => onContextMenu?.(e, shareAlbum, 'album'));
+ onContextMenu?.(e, shareAlbum, 'album');
}}
role={desktop ? 'option' : undefined}
aria-selected={desktop ? activeIndex === 0 : undefined}
@@ -471,7 +460,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
onClick={onEnqueue}
onContextMenu={e => {
e.preventDefault();
- withShareServer(shareMatch, t, () => onContextMenu?.(e, songToTrack(shareTrackSong), 'song'));
+ onContextMenu?.(e, songToTrack(shareTrackSong), 'song');
}}
disabled={shareQueueBusy}
role={desktop ? 'option' : undefined}
diff --git a/src/features/search/hooks/useShareSearch.ts b/src/features/search/hooks/useShareSearch.ts
index f12cd3a0..8ff6c9dc 100644
--- a/src/features/search/hooks/useShareSearch.ts
+++ b/src/features/search/hooks/useShareSearch.ts
@@ -78,9 +78,9 @@ export function useShareSearch(query: string, onSuccess?: () => void) {
const openShareAlbum = useCallback(() => {
if (shareMatch?.type !== 'album' || !preview.shareAlbum) return;
if (!activateShareSearchServer(shareMatch.payload.srv, t)) return;
- navigateToAlbum(preview.shareAlbum.id);
+ navigateToAlbum(preview.shareAlbum.id, { serverId: shareServerId });
onSuccess?.();
- }, [shareMatch, preview.shareAlbum, navigateToAlbum, t, onSuccess]);
+ }, [shareMatch, preview.shareAlbum, navigateToAlbum, shareServerId, t, onSuccess]);
const openShareArtist = useCallback(() => {
if (shareMatch?.type !== 'artist' || !preview.shareArtist) return;
diff --git a/src/features/share/applySharePaste.test.ts b/src/features/share/applySharePaste.test.ts
new file mode 100644
index 00000000..770afaed
--- /dev/null
+++ b/src/features/share/applySharePaste.test.ts
@@ -0,0 +1,191 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { TFunction } from 'i18next';
+
+const mocks = vi.hoisted(() => ({
+ authState: {
+ current: {
+ servers: [] as Array<{
+ id: string;
+ name: string;
+ url: string;
+ username: string;
+ password: string;
+ }>,
+ isLoggedIn: true,
+ activeServerId: 'active',
+ setActiveServer: vi.fn(),
+ },
+ },
+ clearQueue: vi.fn(),
+ getSongForServer: vi.fn(),
+ navigateToAlbumDetail: vi.fn(),
+ playTrack: vi.fn(),
+ resolveAlbum: vi.fn(),
+ resolveArtist: vi.fn(),
+ showToast: vi.fn(),
+ songToTrack: vi.fn(),
+}));
+
+vi.mock('@/store/authStore', () => ({
+ useAuthStore: {
+ getState: () => mocks.authState.current,
+ },
+}));
+
+vi.mock('@/lib/api/subsonicLibrary', () => ({
+ getSongForServer: mocks.getSongForServer,
+}));
+
+vi.mock('@/features/offline', () => ({
+ resolveAlbum: mocks.resolveAlbum,
+ resolveArtist: mocks.resolveArtist,
+}));
+
+vi.mock('@/features/playback/store/playerStore', () => ({
+ usePlayerStore: {
+ getState: () => ({
+ clearQueue: mocks.clearQueue,
+ playTrack: mocks.playTrack,
+ }),
+ },
+}));
+
+vi.mock('@/lib/media/songToTrack', () => ({
+ songToTrack: mocks.songToTrack,
+}));
+
+vi.mock('@/lib/navigation/albumDetailNavigation', () => ({
+ navigateToAlbumDetail: mocks.navigateToAlbumDetail,
+}));
+
+vi.mock('@/lib/dom/toast', () => ({
+ showToast: mocks.showToast,
+}));
+
+import {
+ applySharePastePayload,
+ applySharePasteQueue,
+} from '@/features/share/applySharePaste';
+
+const t = ((key: string) => key) as TFunction;
+const activeServer = {
+ id: 'active',
+ name: 'Active',
+ url: 'https://active.example.com',
+ username: 'active-user',
+ password: 'active-pass',
+};
+const sharedServer = {
+ id: 'shared',
+ name: 'Shared',
+ url: 'https://shared.example.com',
+ username: 'shared-user',
+ password: 'shared-pass',
+};
+const sharedSong = {
+ id: 'song-1',
+ title: 'Shared Song',
+ artist: 'Shared Artist',
+ album: 'Shared Album',
+ albumId: 'album-1',
+ duration: 180,
+ serverId: 'shared',
+};
+
+describe('share paste resolution', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.authState.current = {
+ servers: [activeServer, sharedServer],
+ isLoggedIn: true,
+ activeServerId: 'active',
+ setActiveServer: vi.fn(),
+ };
+ mocks.getSongForServer.mockResolvedValue(sharedSong);
+ mocks.resolveAlbum.mockResolvedValue({
+ album: { id: 'album-1', name: 'Shared Album', serverId: 'shared' },
+ songs: [sharedSong],
+ });
+ mocks.resolveArtist.mockResolvedValue({
+ artist: { id: 'artist-1', name: 'Shared Artist', serverId: 'shared' },
+ albums: [],
+ });
+ mocks.songToTrack.mockImplementation(song => ({
+ id: song.id,
+ title: song.title,
+ serverId: song.serverId,
+ }));
+ });
+
+ it('fetches and maps a track through its explicit share server before activation', async () => {
+ const order: string[] = [];
+ mocks.getSongForServer.mockImplementation(async () => {
+ order.push('fetch');
+ return sharedSong;
+ });
+ mocks.authState.current.setActiveServer.mockImplementation(() => order.push('activate'));
+ mocks.playTrack.mockImplementation(() => order.push('play'));
+
+ await applySharePastePayload(
+ { srv: sharedServer.url, k: 'track', id: sharedSong.id },
+ vi.fn(),
+ t,
+ );
+
+ expect(mocks.getSongForServer).toHaveBeenCalledWith('shared', 'song-1');
+ expect(mocks.songToTrack).toHaveBeenCalledWith(expect.objectContaining({ serverId: 'shared' }));
+ expect(mocks.playTrack).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'song-1', serverId: 'shared' }),
+ [expect.objectContaining({ id: 'song-1', serverId: 'shared' })],
+ );
+ expect(order).toEqual(['fetch', 'activate', 'play']);
+ });
+
+ it('keeps queue tracks owner-qualified and activates only after resolution succeeds', async () => {
+ const ok = await applySharePasteQueue(
+ { srv: sharedServer.url, k: 'queue', ids: ['song-1'] },
+ t,
+ );
+
+ expect(ok).toBe(true);
+ expect(mocks.getSongForServer).toHaveBeenCalledWith('shared', 'song-1');
+ expect(mocks.authState.current.setActiveServer).toHaveBeenCalledWith('shared');
+ expect(mocks.playTrack).toHaveBeenCalledWith(
+ expect.objectContaining({ serverId: 'shared' }),
+ [expect.objectContaining({ serverId: 'shared' })],
+ );
+ });
+
+ it('preserves the owning server in album navigation', async () => {
+ const navigate = vi.fn();
+ const location = { pathname: '/search', search: '', hash: '', state: null };
+
+ await applySharePastePayload(
+ { srv: sharedServer.url, k: 'album', id: 'album-1' },
+ navigate,
+ t,
+ location,
+ );
+
+ expect(mocks.resolveAlbum).toHaveBeenCalledWith('shared', 'album-1');
+ expect(mocks.navigateToAlbumDetail).toHaveBeenCalledWith(
+ navigate,
+ location,
+ 'album-1',
+ { serverId: 'shared' },
+ );
+ });
+
+ it('does not switch servers when the shared entity cannot be resolved', async () => {
+ mocks.getSongForServer.mockResolvedValue(null);
+
+ await applySharePastePayload(
+ { srv: sharedServer.url, k: 'track', id: 'missing' },
+ vi.fn(),
+ t,
+ );
+
+ expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
+ expect(mocks.playTrack).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/features/share/applySharePaste.ts b/src/features/share/applySharePaste.ts
index 08f88b8a..19f1b3a6 100644
--- a/src/features/share/applySharePaste.ts
+++ b/src/features/share/applySharePaste.ts
@@ -1,21 +1,26 @@
-import { getSong } from '@/lib/api/subsonicLibrary';
+import { getSongForServer } from '@/lib/api/subsonicLibrary';
import { resolveAlbum, resolveArtist } from '@/features/offline';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import type { Location, NavigateFunction } from 'react-router-dom';
import type { TFunction } from 'i18next';
-import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { navigateToAlbumDetail } from '@/lib/navigation/albumDetailNavigation';
-import { findServerIdForShareUrl, type EntitySharePayloadV1 } from '@/lib/share/shareLink';
+import type { EntitySharePayloadV1 } from '@/lib/share/shareLink';
import { showToast } from '@/lib/dom/toast';
-import { buildArtistDetailPath, buildComposerDetailPath } from '@/lib/navigation/detailServerScope';
+import {
+ buildAlbumDetailPath,
+ buildArtistDetailPath,
+ buildComposerDetailPath,
+} from '@/lib/navigation/detailServerScope';
+import { activateShareServer, lookupShareServer } from '@/features/share/shareServerResolution';
const RESOLVE_QUEUE_CHUNK = 12;
type SharePasteQueuePayload = Extract;
async function resolveSharePasteQueueSongs(
+ serverId: string,
ids: string[],
): Promise<{ songs: SubsonicSong[]; skipped: number } | null> {
if (ids.length === 0) return null;
@@ -23,7 +28,7 @@ async function resolveSharePasteQueueSongs(
const resolved: SubsonicSong[] = [];
for (let i = 0; i < ids.length; i += RESOLVE_QUEUE_CHUNK) {
const chunk = ids.slice(i, i + RESOLVE_QUEUE_CHUNK);
- const songs = await Promise.all(chunk.map(id => getSong(id)));
+ const songs = await Promise.all(chunk.map(id => getSongForServer(serverId, id)));
for (const s of songs) {
if (s) resolved.push(s);
}
@@ -42,30 +47,25 @@ export async function applySharePasteQueue(
payload: SharePasteQueuePayload,
t: TFunction,
): Promise {
- const { servers, isLoggedIn, setActiveServer } = useAuthStore.getState();
- if (!isLoggedIn) {
+ const lookup = lookupShareServer(payload.srv);
+ if (lookup.type === 'not-logged-in') {
showToast(t('sharePaste.notLoggedIn'), 4000, 'info');
return false;
}
-
- const serverId = findServerIdForShareUrl(servers, payload.srv);
- if (!serverId) {
- showToast(t('sharePaste.noMatchingServer', { url: payload.srv }), 6000, 'error');
+ if (lookup.type === 'no-matching-server') {
+ showToast(t('sharePaste.noMatchingServer', { url: lookup.url }), 6000, 'error');
return false;
}
- if (useAuthStore.getState().activeServerId !== serverId) {
- setActiveServer(serverId);
- }
-
try {
- const result = await resolveSharePasteQueueSongs(payload.ids);
+ const result = await resolveSharePasteQueueSongs(lookup.serverId, payload.ids);
if (!result) {
showToast(t('sharePaste.queueAllUnavailable'), 6000, 'error');
return false;
}
const tracks = result.songs.map(songToTrack);
+ activateShareServer(lookup.serverId);
usePlayerStore.getState().clearQueue();
usePlayerStore.getState().playTrack(tracks[0]!, tracks);
if (result.skipped > 0) {
@@ -100,30 +100,25 @@ export async function applySharePastePayload(
t: TFunction,
location?: Pick,
): Promise {
- const { servers, isLoggedIn, setActiveServer } = useAuthStore.getState();
- if (!isLoggedIn) {
+ const lookup = lookupShareServer(payload.srv);
+ if (lookup.type === 'not-logged-in') {
showToast(t('sharePaste.notLoggedIn'), 4000, 'info');
return;
}
-
- const serverId = findServerIdForShareUrl(servers, payload.srv);
- if (!serverId) {
- showToast(t('sharePaste.noMatchingServer', { url: payload.srv }), 6000, 'error');
+ if (lookup.type === 'no-matching-server') {
+ showToast(t('sharePaste.noMatchingServer', { url: lookup.url }), 6000, 'error');
return;
}
- if (useAuthStore.getState().activeServerId !== serverId) {
- setActiveServer(serverId);
- }
-
try {
if (payload.k === 'track') {
- const song = await getSong(payload.id);
+ const song = await getSongForServer(lookup.serverId, payload.id);
if (!song) {
showToast(t('sharePaste.trackUnavailable'), 5000, 'error');
return;
}
const track = songToTrack(song);
+ activateShareServer(lookup.serverId);
usePlayerStore.getState().clearQueue();
usePlayerStore.getState().playTrack(track, [track]);
showToast(t('sharePaste.openedTrack'), 3000, 'info');
@@ -131,27 +126,29 @@ export async function applySharePastePayload(
}
if (payload.k === 'album') {
- const albumResult = await resolveAlbum(serverId, payload.id);
+ const albumResult = await resolveAlbum(lookup.serverId, payload.id);
if (!albumResult) {
showToast(t('sharePaste.albumUnavailable'), 5000, 'error');
return;
}
+ activateShareServer(lookup.serverId);
if (location) {
- navigateToAlbumDetail(navigate, location, payload.id);
+ navigateToAlbumDetail(navigate, location, payload.id, { serverId: lookup.serverId });
} else {
- navigate(`/album/${payload.id}`);
+ navigate(buildAlbumDetailPath(payload.id, { serverId: lookup.serverId }));
}
showToast(t('sharePaste.openedAlbum'), 3000, 'info');
return;
}
if (payload.k === 'artist') {
- const artistResult = await resolveArtist(serverId, payload.id);
+ const artistResult = await resolveArtist(lookup.serverId, payload.id);
if (!artistResult) {
showToast(t('sharePaste.artistUnavailable'), 5000, 'error');
return;
}
- navigate(buildArtistDetailPath(payload.id, { serverId }));
+ activateShareServer(lookup.serverId);
+ navigate(buildArtistDetailPath(payload.id, { serverId: lookup.serverId }));
showToast(t('sharePaste.openedArtist'), 3000, 'info');
return;
}
@@ -160,12 +157,13 @@ export async function applySharePastePayload(
// Same id space as artists (Subsonic / Navidrome use one id pool for
// every participant role), so resolveArtist still validates the entity —
// the difference is which view we navigate to.
- const composerResult = await resolveArtist(serverId, payload.id);
+ const composerResult = await resolveArtist(lookup.serverId, payload.id);
if (!composerResult) {
showToast(t('sharePaste.composerUnavailable'), 5000, 'error');
return;
}
- navigate(buildComposerDetailPath(payload.id, { serverId }));
+ activateShareServer(lookup.serverId);
+ navigate(buildComposerDetailPath(payload.id, { serverId: lookup.serverId }));
showToast(t('sharePaste.openedComposer'), 3000, 'info');
return;
}
diff --git a/src/features/share/enqueueShareSearchPayload.test.ts b/src/features/share/enqueueShareSearchPayload.test.ts
index ed144d6f..16ec0525 100644
--- a/src/features/share/enqueueShareSearchPayload.test.ts
+++ b/src/features/share/enqueueShareSearchPayload.test.ts
@@ -15,8 +15,7 @@ const mocks = vi.hoisted(() => ({
getAlbumWithCredentials: vi.fn(),
getArtist: vi.fn(),
getArtistWithCredentials: vi.fn(),
- getSong: vi.fn(),
- getSongWithCredentials: vi.fn(),
+ getSongForServer: vi.fn(),
orbitBulkGuard: vi.fn(),
showToast: vi.fn(),
songToTrack: vi.fn(),
@@ -24,7 +23,7 @@ const mocks = vi.hoisted(() => ({
vi.mock('@/lib/api/subsonicLibrary', () => ({
getAlbum: mocks.getAlbum,
- getSong: mocks.getSong,
+ getSongForServer: mocks.getSongForServer,
}));
vi.mock('@/lib/api/subsonicArtists', () => ({
@@ -34,7 +33,6 @@ vi.mock('@/lib/api/subsonicArtists', () => ({
vi.mock('@/lib/api/subsonicEntityWithCredentials', () => ({
getAlbumWithCredentials: mocks.getAlbumWithCredentials,
getArtistWithCredentials: mocks.getArtistWithCredentials,
- getSongWithCredentials: mocks.getSongWithCredentials,
}));
vi.mock('@/store/authStore', () => ({
@@ -106,7 +104,7 @@ describe('share search payload resolution', () => {
activeServerId: 'active',
setActiveServer: vi.fn(),
};
- mocks.getSongWithCredentials.mockResolvedValue(sharedSong);
+ mocks.getSongForServer.mockResolvedValue({ ...sharedSong, serverId: 'shared' });
mocks.getAlbumWithCredentials.mockResolvedValue({
album: { id: 'album-1', name: 'Shared Album', artist: 'Shared Artist' },
songs: [],
@@ -115,33 +113,42 @@ describe('share search payload resolution', () => {
artist: { id: 'artist-1', name: 'Shared Artist' },
albums: [],
});
- mocks.getSong.mockResolvedValue(sharedSong);
- mocks.songToTrack.mockImplementation(song => ({ id: song.id, title: song.title }));
+ mocks.songToTrack.mockImplementation(song => ({
+ id: song.id,
+ title: song.title,
+ serverId: song.serverId,
+ }));
mocks.orbitBulkGuard.mockResolvedValue(true);
});
- it('resolves a shared track preview with explicit credentials without switching active server', async () => {
+ it('resolves a shared track preview through its explicit server without switching active server', async () => {
const result = await resolveShareSearchPayload({
srv: 'https://shared.example.com',
k: 'track',
id: 'song-1',
});
- expect(result).toEqual({ type: 'ok', songs: [sharedSong], total: 1, skipped: 0 });
- expect(mocks.getSongWithCredentials).toHaveBeenCalledWith(
- sharedServer.url,
- sharedServer.username,
- sharedServer.password,
- 'song-1',
- sharedServer,
- );
- expect(mocks.getSong).not.toHaveBeenCalled();
+ expect(result).toEqual({
+ type: 'ok',
+ songs: [{ ...sharedSong, serverId: 'shared' }],
+ total: 1,
+ skipped: 0,
+ });
+ expect(mocks.getSongForServer).toHaveBeenCalledWith('shared', 'song-1');
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
});
it('resolves album and artist previews without switching active server', async () => {
- await resolveShareSearchAlbum({ srv: 'https://shared.example.com', k: 'album', id: 'album-1' });
- await resolveShareSearchArtist({ srv: 'https://shared.example.com', k: 'artist', id: 'artist-1' });
+ const albumResult = await resolveShareSearchAlbum({
+ srv: 'https://shared.example.com',
+ k: 'album',
+ id: 'album-1',
+ });
+ const artistResult = await resolveShareSearchArtist({
+ srv: 'https://shared.example.com',
+ k: 'artist',
+ id: 'artist-1',
+ });
expect(mocks.getAlbumWithCredentials).toHaveBeenCalledWith(
sharedServer.url,
@@ -160,6 +167,8 @@ describe('share search payload resolution', () => {
expect(mocks.getAlbum).not.toHaveBeenCalled();
expect(mocks.getArtist).not.toHaveBeenCalled();
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
+ expect(albumResult).toMatchObject({ type: 'ok', album: { serverId: 'shared' } });
+ expect(artistResult).toMatchObject({ type: 'ok', artist: { serverId: 'shared' } });
});
it('resolves composer previews via artist credentials without switching active server', async () => {
@@ -190,7 +199,7 @@ describe('share search payload resolution', () => {
});
expect(result).toEqual({ type: 'not-logged-in' });
- expect(mocks.getSongWithCredentials).not.toHaveBeenCalled();
+ expect(mocks.getSongForServer).not.toHaveBeenCalled();
});
it('activates the share server for confirmed enqueue actions', async () => {
@@ -203,9 +212,11 @@ describe('share search payload resolution', () => {
expect(ok).toBe(true);
expect(mocks.authState.current.setActiveServer).toHaveBeenCalledWith('shared');
- expect(mocks.getSong).toHaveBeenCalledWith('song-1');
- expect(mocks.getSongWithCredentials).not.toHaveBeenCalled();
- expect(mocks.enqueue).toHaveBeenCalledWith([{ id: 'song-1', title: 'Shared Song' }], true);
+ expect(mocks.getSongForServer).toHaveBeenCalledWith('shared', 'song-1');
+ expect(mocks.songToTrack.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ serverId: 'shared' }));
+ expect(mocks.enqueue).toHaveBeenCalledWith([
+ { id: 'song-1', title: 'Shared Song', serverId: 'shared' },
+ ], true);
});
it('aborts enqueue when orbitBulkGuard rejects the bulk add', async () => {
@@ -220,11 +231,14 @@ describe('share search payload resolution', () => {
expect(ok).toBe(false);
expect(mocks.enqueue).not.toHaveBeenCalled();
+ expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
});
it('reports partial queue enqueue with a partial toast', async () => {
- mocks.getSong.mockImplementation((id: string) =>
- id === 'song-1' ? Promise.resolve(sharedSong) : Promise.resolve(null),
+ mocks.getSongForServer.mockImplementation((_serverId: string, id: string) =>
+ id === 'song-1'
+ ? Promise.resolve({ ...sharedSong, serverId: 'shared' })
+ : Promise.resolve(null),
);
const t = ((key: string, opts?: Record) =>
opts ? `${key}:${JSON.stringify(opts)}` : key) as TFunction;
@@ -236,7 +250,9 @@ describe('share search payload resolution', () => {
}, t);
expect(ok).toBe(true);
- expect(mocks.enqueue).toHaveBeenCalledWith([{ id: 'song-1', title: 'Shared Song' }], true);
+ expect(mocks.enqueue).toHaveBeenCalledWith([
+ { id: 'song-1', title: 'Shared Song', serverId: 'shared' },
+ ], true);
expect(mocks.showToast).toHaveBeenCalledWith(
expect.stringContaining('search.shareQueuedPartial'),
5000,
diff --git a/src/features/share/enqueueShareSearchPayload.ts b/src/features/share/enqueueShareSearchPayload.ts
index 4a9fe9b5..2198d7cc 100644
--- a/src/features/share/enqueueShareSearchPayload.ts
+++ b/src/features/share/enqueueShareSearchPayload.ts
@@ -2,20 +2,14 @@ import type { TFunction } from 'i18next';
import {
getAlbumWithCredentials,
getArtistWithCredentials,
- getSongWithCredentials,
} from '@/lib/api/subsonicEntityWithCredentials';
-import { getSong } from '@/lib/api/subsonicLibrary';
-import { resolveAlbum, resolveArtist } from '@/features/offline';
+import { getSongForServer } from '@/lib/api/subsonicLibrary';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
-import { useAuthStore } from '@/store/authStore';
-import type { ServerProfile } from '@/store/authStoreTypes';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { songToTrack } from '@/lib/media/songToTrack';
import type { Track } from '@/lib/media/trackTypes';
import { orbitBulkGuard } from '@/features/orbit';
-import { findServerIdForShareUrl } from '@/lib/share/shareLink';
import { connectBaseUrlForServer } from '@/lib/server/serverEndpoint';
-import { serverIndexKeyFromUrl } from '@/lib/server/serverIndexKey';
import type {
AlbumShareSearchPayload,
ArtistShareSearchPayload,
@@ -23,18 +17,14 @@ import type {
QueueableShareSearchPayload,
} from '@/lib/share/shareSearch';
import { showToast } from '@/lib/dom/toast';
+import {
+ activateShareServer,
+ lookupShareServer,
+ type ShareServerLookupResult,
+} from '@/features/share/shareServerResolution';
const RESOLVE_QUEUE_CHUNK = 12;
-type ShareServerLookupResult =
- | { type: 'ok'; serverId: string; server: ServerProfile }
- | { type: 'not-logged-in' }
- | { type: 'no-matching-server'; url: string };
-
-type ShareResolveOptions = {
- activateServer?: boolean;
-};
-
export type ShareSearchResolveResult =
| { type: 'ok'; songs: SubsonicSong[]; total: number; skipped: number }
| { type: 'not-logged-in' }
@@ -56,31 +46,6 @@ export type ShareSearchArtistResolveResult =
| { type: 'unavailable' }
| { type: 'error' };
-function lookupShareServer(shareSrv: string): ShareServerLookupResult {
- const { servers, isLoggedIn } = useAuthStore.getState();
- if (!isLoggedIn) {
- return { type: 'not-logged-in' };
- }
-
- const serverId = findServerIdForShareUrl(servers, shareSrv);
- const server = serverId
- ? servers.find(s => s.id === serverId)
- ?? servers.find(s => serverIndexKeyFromUrl(s.url) === serverId)
- : undefined;
- if (!serverId || !server) {
- return { type: 'no-matching-server', url: shareSrv };
- }
-
- return { type: 'ok', serverId, server };
-}
-
-function activateShareServer(serverId: string): void {
- const { activeServerId, setActiveServer } = useAuthStore.getState();
- if (activeServerId !== serverId) {
- setActiveServer(serverId);
- }
-}
-
export function activateShareSearchServer(shareSrv: string, t: TFunction): boolean {
const lookup = lookupShareServer(shareSrv);
if (lookup.type === 'not-logged-in') {
@@ -99,44 +64,12 @@ export function activateShareSearchServer(shareSrv: string, t: TFunction): boole
async function resolveSharedSong(
id: string,
lookup: Extract,
- options: ShareResolveOptions,
): Promise {
- if (options.activateServer) {
- activateShareServer(lookup.serverId);
- return getSong(id);
- }
- return getSongWithCredentials(
- connectBaseUrlForServer(lookup.server),
- lookup.server.username,
- lookup.server.password,
- id,
- lookup.server,
- );
-}
-
-async function getAlbumAfterActivation(
- id: string,
- serverId: string,
-): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
- activateShareServer(serverId);
- const result = await resolveAlbum(serverId, id);
- if (!result) throw new Error('album unavailable');
- return result;
-}
-
-async function getArtistAfterActivation(
- id: string,
- serverId: string,
-): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
- activateShareServer(serverId);
- const result = await resolveArtist(serverId, id);
- if (!result) throw new Error('artist unavailable');
- return result;
+ return getSongForServer(lookup.serverId, id);
}
export async function resolveShareSearchPayload(
payload: QueueableShareSearchPayload,
- options: ShareResolveOptions = {},
): Promise {
const lookup = lookupShareServer(payload.srv);
if (lookup.type === 'not-logged-in') {
@@ -151,7 +84,7 @@ export async function resolveShareSearchPayload(
const resolved: SubsonicSong[] = [];
for (let i = 0; i < ids.length; i += RESOLVE_QUEUE_CHUNK) {
const chunk = ids.slice(i, i + RESOLVE_QUEUE_CHUNK);
- const songs = await Promise.all(chunk.map(id => resolveSharedSong(id, lookup, options)));
+ const songs = await Promise.all(chunk.map(id => resolveSharedSong(id, lookup)));
for (const song of songs) {
if (song) resolved.push(song);
}
@@ -170,7 +103,6 @@ export async function resolveShareSearchPayload(
export async function resolveShareSearchAlbum(
payload: AlbumShareSearchPayload,
- options: ShareResolveOptions = {},
): Promise {
const lookup = lookupShareServer(payload.srv);
if (lookup.type === 'not-logged-in') {
@@ -181,16 +113,14 @@ export async function resolveShareSearchAlbum(
}
try {
- const { album } = options.activateServer
- ? await getAlbumAfterActivation(payload.id, lookup.serverId)
- : await getAlbumWithCredentials(
- connectBaseUrlForServer(lookup.server),
- lookup.server.username,
- lookup.server.password,
- payload.id,
- lookup.server,
- );
- return { type: 'ok', album };
+ const { album } = await getAlbumWithCredentials(
+ connectBaseUrlForServer(lookup.server),
+ lookup.server.username,
+ lookup.server.password,
+ payload.id,
+ lookup.server,
+ );
+ return { type: 'ok', album: { ...album, serverId: lookup.serverId } };
} catch {
return { type: 'unavailable' };
}
@@ -198,7 +128,6 @@ export async function resolveShareSearchAlbum(
export async function resolveShareSearchArtist(
payload: ArtistShareSearchPayload | ComposerShareSearchPayload,
- options: ShareResolveOptions = {},
): Promise {
const lookup = lookupShareServer(payload.srv);
if (lookup.type === 'not-logged-in') {
@@ -209,16 +138,14 @@ export async function resolveShareSearchArtist(
}
try {
- const { artist } = options.activateServer
- ? await getArtistAfterActivation(payload.id, lookup.serverId)
- : await getArtistWithCredentials(
- connectBaseUrlForServer(lookup.server),
- lookup.server.username,
- lookup.server.password,
- payload.id,
- lookup.server,
- );
- return { type: 'ok', artist };
+ const { artist } = await getArtistWithCredentials(
+ connectBaseUrlForServer(lookup.server),
+ lookup.server.username,
+ lookup.server.password,
+ payload.id,
+ lookup.server,
+ );
+ return { type: 'ok', artist: { ...artist, serverId: lookup.serverId } };
} catch {
return { type: 'unavailable' };
}
@@ -228,7 +155,7 @@ export async function enqueueShareSearchPayload(
payload: QueueableShareSearchPayload,
t: TFunction,
): Promise {
- const resolved = await resolveShareSearchPayload(payload, { activateServer: true });
+ const resolved = await resolveShareSearchPayload(payload);
if (resolved.type === 'not-logged-in') {
showToast(t('sharePaste.notLoggedIn'), 4000, 'info');
return false;
@@ -254,6 +181,7 @@ export async function enqueueShareSearchPayload(
const tracks: Track[] = resolved.songs.map(songToTrack);
const okToEnqueue = await orbitBulkGuard(tracks.length);
if (!okToEnqueue) return false;
+ if (!activateShareSearchServer(payload.srv, t)) return false;
usePlayerStore.getState().enqueue(tracks, true);
if (resolved.skipped > 0) {
showToast(
diff --git a/src/features/share/shareServerResolution.ts b/src/features/share/shareServerResolution.ts
new file mode 100644
index 00000000..af9b75e1
--- /dev/null
+++ b/src/features/share/shareServerResolution.ts
@@ -0,0 +1,24 @@
+import { findServerIdForShareUrl } from '@/lib/share/shareLink';
+import { useAuthStore } from '@/store/authStore';
+import type { ServerProfile } from '@/store/authStoreTypes';
+
+export type ShareServerLookupResult =
+ | { type: 'ok'; serverId: string; server: ServerProfile }
+ | { type: 'not-logged-in' }
+ | { type: 'no-matching-server'; url: string };
+
+export function lookupShareServer(shareSrv: string): ShareServerLookupResult {
+ const { servers, isLoggedIn } = useAuthStore.getState();
+ if (!isLoggedIn) return { type: 'not-logged-in' };
+
+ const serverId = findServerIdForShareUrl(servers, shareSrv);
+ const server = serverId ? servers.find(candidate => candidate.id === serverId) : undefined;
+ if (!serverId || !server) return { type: 'no-matching-server', url: shareSrv };
+
+ return { type: 'ok', serverId, server };
+}
+
+export function activateShareServer(serverId: string): void {
+ const { activeServerId, setActiveServer } = useAuthStore.getState();
+ if (activeServerId !== serverId) setActiveServer(serverId);
+}