mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
fix(share): preserve explicit server ownership
This commit is contained in:
@@ -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 <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<EntitySharePayloadV1, { k: 'queue' }>;
|
||||
|
||||
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<boolean> {
|
||||
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<Location, 'pathname' | 'search' | 'hash' | 'state'>,
|
||||
): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
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,
|
||||
|
||||
@@ -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<ShareServerLookupResult, { type: 'ok' }>,
|
||||
options: ShareResolveOptions,
|
||||
): Promise<SubsonicSong | null> {
|
||||
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<ShareSearchResolveResult> {
|
||||
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<ShareSearchAlbumResolveResult> {
|
||||
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<ShareSearchArtistResolveResult> {
|
||||
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<boolean> {
|
||||
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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user