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); +}