fix(share): preserve explicit server ownership

This commit is contained in:
cucadmuh
2026-07-19 23:11:38 +03:00
parent ca1980005e
commit 68a7c212af
7 changed files with 320 additions and 174 deletions
@@ -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}
+2 -2
View File
@@ -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;
+191
View File
@@ -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();
});
});
+32 -34
View File
@@ -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,
+26 -98
View File
@@ -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);
}