mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(share): split share cluster into lib/share + features/share
a11y-branch hold lifted. Pure share machinery (no @/features runtime imports) → lib/share: shareLink, shareSearch, shareServerOriginLabel, copyEntityShareLink (+tests). The two orchestrators that runtime-import offline/playback/orbit → new features/share: applySharePaste, enqueueShareSearchPayload (+test); doc-only barrel, consumers use deep paths. No low-layer consumes the orchestrators, so no seam is needed — pure move, tests pass unmodified. Drains utils/share. Only import lines changed in the PasteClipboardHandler consumer (logic untouched).
This commit is contained in:
@@ -11,7 +11,7 @@ import { useIsMobile } from '@/lib/hooks/useIsMobile';
|
||||
import { useAlbumDetailBack } from '@/features/album/hooks/useAlbumDetailBack';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import StarRating from '@/ui/StarRating';
|
||||
import { copyEntityShareLink } from '@/utils/share/copyEntityShareLink';
|
||||
import { copyEntityShareLink } from '@/lib/share/copyEntityShareLink';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
import { isAlbumRecentlyAdded } from '@/features/album/utils/albumRecency';
|
||||
import { formatLongDuration } from '@/lib/format/formatDuration';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { uploadArtistImage } from '@/lib/api/subsonicArtists';
|
||||
import { setRating, star, unstar } from '@/lib/api/subsonicStarRating';
|
||||
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { copyEntityShareLink } from '@/utils/share/copyEntityShareLink';
|
||||
import { copyEntityShareLink } from '@/lib/share/copyEntityShareLink';
|
||||
import { invalidateCoverArt } from '@/utils/imageCache';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { copyEntityShareLink } from '@/utils/share/copyEntityShareLink';
|
||||
import { copyEntityShareLink } from '@/lib/share/copyEntityShareLink';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
import { sanitizeHtml } from '@/lib/util/sanitizeHtml';
|
||||
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { EntityShareKind } from '@/utils/share/shareLink';
|
||||
import type { EntityShareKind } from '@/lib/share/shareLink';
|
||||
import { AddToPlaylistSubmenu } from '@/features/contextMenu/components/AddToPlaylistSubmenu';
|
||||
import {
|
||||
copyShareLink as copyShareLinkAction,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type React from 'react';
|
||||
import type { SubsonicAlbum, SubsonicArtist } from '@/lib/api/subsonicTypes';
|
||||
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
|
||||
import type { EntityShareKind } from '@/utils/share/shareLink';
|
||||
import type { EntityShareKind } from '@/lib/share/shareLink';
|
||||
import type { OfflineActionPolicy } from '@/features/offline';
|
||||
|
||||
export type RatingKind = 'song' | 'album' | 'artist';
|
||||
|
||||
@@ -9,8 +9,8 @@ import type { Track } from '@/lib/media/trackTypes';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import { useZipDownloadStore } from '@/features/offline';
|
||||
import { useDownloadModalStore } from '@/features/offline';
|
||||
import type { EntityShareKind } from '@/utils/share/shareLink';
|
||||
import { copyEntityShareLink } from '@/utils/share/copyEntityShareLink';
|
||||
import type { EntityShareKind } from '@/lib/share/shareLink';
|
||||
import { copyEntityShareLink } from '@/lib/share/copyEntityShareLink';
|
||||
import { sanitizeFilename, shuffleArray } from '@/features/contextMenu/utils/contextMenuHelpers';
|
||||
import { songToTrack } from '@/lib/media/songToTrack';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { decodeOrbitSharePayloadFromText, encodeSharePayload } from '@/utils/share/shareLink';
|
||||
import { decodeOrbitSharePayloadFromText, encodeSharePayload } from '@/lib/share/shareLink';
|
||||
|
||||
export interface OrbitShareLink {
|
||||
/** Base URL of the Navidrome server (decoded). */
|
||||
|
||||
@@ -12,7 +12,7 @@ import { usePlaylistStore } from '@/features/playlist';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlaybackLibraryNavigate } from '@/features/playback/hooks/usePlaybackLibraryNavigate';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { encodeSharePayload } from '@/utils/share/shareLink';
|
||||
import { encodeSharePayload } from '@/lib/share/shareLink';
|
||||
import { serverShareBaseUrl } from '@/lib/server/serverEndpoint';
|
||||
import { copyTextToClipboard } from '@/lib/server/serverMagicString';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import type { ServerProfile } from '@/store/authStoreTypes';
|
||||
import { formatTrackTime } from '@/lib/format/formatDuration';
|
||||
import type { ShareQueuePreviewState } from '@/features/search/hooks/useShareQueuePreview';
|
||||
import { sharePayloadTotal, type QueueableShareSearchPayload } from '@/utils/share/shareSearch';
|
||||
import { sharePayloadTotal, type QueueableShareSearchPayload } from '@/lib/share/shareSearch';
|
||||
import OverlayScrollArea from '@/ui/OverlayScrollArea';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { COVER_DENSE_SEARCH_CSS_PX } from '@/cover/layoutSizes';
|
||||
|
||||
@@ -5,8 +5,8 @@ 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 '@/utils/share/enqueueShareSearchPayload';
|
||||
import { sharePayloadTotal, type ShareSearchMatch } from '@/utils/share/shareSearch';
|
||||
import { activateShareSearchServer } from '@/features/share/enqueueShareSearchPayload';
|
||||
import { sharePayloadTotal, type ShareSearchMatch } from '@/lib/share/shareSearch';
|
||||
import type { ShareSearchPreviewState } from '@/features/search/hooks/useShareSearchPreview';
|
||||
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '@/ui/CachedImage';
|
||||
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import {
|
||||
resolveShareSearchPayload,
|
||||
type ShareSearchResolveResult,
|
||||
} from '@/utils/share/enqueueShareSearchPayload';
|
||||
import type { QueueableShareSearchPayload } from '@/utils/share/shareSearch';
|
||||
} from '@/features/share/enqueueShareSearchPayload';
|
||||
import type { QueueableShareSearchPayload } from '@/lib/share/shareSearch';
|
||||
|
||||
export type ShareQueuePreviewState =
|
||||
| { status: 'idle' }
|
||||
|
||||
@@ -6,11 +6,11 @@ import { useAuthStore } from '@/store/authStore';
|
||||
import {
|
||||
activateShareSearchServer,
|
||||
enqueueShareSearchPayload,
|
||||
} from '@/utils/share/enqueueShareSearchPayload';
|
||||
} from '@/features/share/enqueueShareSearchPayload';
|
||||
import type { ServerProfile } from '@/store/authStoreTypes';
|
||||
import { findServerIdForShareUrl } from '@/utils/share/shareLink';
|
||||
import { shareServerOriginLabel } from '@/utils/share/shareServerOriginLabel';
|
||||
import { parseShareSearchText } from '@/utils/share/shareSearch';
|
||||
import { findServerIdForShareUrl } from '@/lib/share/shareLink';
|
||||
import { shareServerOriginLabel } from '@/lib/share/shareServerOriginLabel';
|
||||
import { parseShareSearchText } from '@/lib/share/shareSearch';
|
||||
import { serverIndexKeyFromUrl } from '@/lib/server/serverIndexKey';
|
||||
import { useShareSearchPreview } from '@/features/search/hooks/useShareSearchPreview';
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
resolveShareSearchAlbum,
|
||||
resolveShareSearchArtist,
|
||||
resolveShareSearchPayload,
|
||||
} from '@/utils/share/enqueueShareSearchPayload';
|
||||
import type { ShareSearchMatch } from '@/utils/share/shareSearch';
|
||||
} from '@/features/share/enqueueShareSearchPayload';
|
||||
import type { ShareSearchMatch } from '@/lib/share/shareSearch';
|
||||
|
||||
export interface ShareSearchPreviewState {
|
||||
shareTrackSong: SubsonicSong | null;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { getSong } 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 { showToast } from '@/lib/dom/toast';
|
||||
|
||||
const RESOLVE_QUEUE_CHUNK = 12;
|
||||
|
||||
type SharePasteQueuePayload = Extract<EntitySharePayloadV1, { k: 'queue' }>;
|
||||
|
||||
async function resolveSharePasteQueueSongs(
|
||||
ids: string[],
|
||||
): Promise<{ songs: SubsonicSong[]; skipped: number } | null> {
|
||||
if (ids.length === 0) return null;
|
||||
|
||||
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)));
|
||||
for (const s of songs) {
|
||||
if (s) resolved.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
const skipped = ids.length - resolved.length;
|
||||
if (resolved.length === 0) return null;
|
||||
return { songs: resolved, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to the matching server, resolves queue track ids, then replaces the
|
||||
* queue and starts playback. Used after the queue preview modal on global paste.
|
||||
*/
|
||||
export async function applySharePasteQueue(
|
||||
payload: SharePasteQueuePayload,
|
||||
t: TFunction,
|
||||
): Promise<boolean> {
|
||||
const { servers, isLoggedIn, setActiveServer } = useAuthStore.getState();
|
||||
if (!isLoggedIn) {
|
||||
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');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (useAuthStore.getState().activeServerId !== serverId) {
|
||||
setActiveServer(serverId);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await resolveSharePasteQueueSongs(payload.ids);
|
||||
if (!result) {
|
||||
showToast(t('sharePaste.queueAllUnavailable'), 6000, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
const tracks = result.songs.map(songToTrack);
|
||||
usePlayerStore.getState().clearQueue();
|
||||
usePlayerStore.getState().playTrack(tracks[0]!, tracks);
|
||||
if (result.skipped > 0) {
|
||||
showToast(
|
||||
t('sharePaste.openedQueuePartial', {
|
||||
played: tracks.length,
|
||||
total: payload.ids.length,
|
||||
skipped: result.skipped,
|
||||
}),
|
||||
5000,
|
||||
'info',
|
||||
);
|
||||
} else {
|
||||
showToast(t('sharePaste.openedQueue', { count: tracks.length }), 3000, 'info');
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[psysonic] share paste queue failed', e);
|
||||
showToast(t('sharePaste.genericError'), 5000, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to the matching server, validates the entity on the server, then
|
||||
* plays or navigates. Caller should `preventDefault` on the paste event when
|
||||
* the payload was already decoded successfully.
|
||||
*/
|
||||
export async function applySharePastePayload(
|
||||
payload: EntitySharePayloadV1,
|
||||
navigate: NavigateFunction,
|
||||
t: TFunction,
|
||||
location?: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>,
|
||||
): Promise<void> {
|
||||
const { servers, isLoggedIn, setActiveServer } = useAuthStore.getState();
|
||||
if (!isLoggedIn) {
|
||||
showToast(t('sharePaste.notLoggedIn'), 4000, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const serverId = findServerIdForShareUrl(servers, payload.srv);
|
||||
if (!serverId) {
|
||||
showToast(t('sharePaste.noMatchingServer', { url: payload.srv }), 6000, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (useAuthStore.getState().activeServerId !== serverId) {
|
||||
setActiveServer(serverId);
|
||||
}
|
||||
|
||||
try {
|
||||
if (payload.k === 'track') {
|
||||
const song = await getSong(payload.id);
|
||||
if (!song) {
|
||||
showToast(t('sharePaste.trackUnavailable'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
const track = songToTrack(song);
|
||||
usePlayerStore.getState().clearQueue();
|
||||
usePlayerStore.getState().playTrack(track, [track]);
|
||||
showToast(t('sharePaste.openedTrack'), 3000, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.k === 'album') {
|
||||
const albumResult = await resolveAlbum(serverId, payload.id);
|
||||
if (!albumResult) {
|
||||
showToast(t('sharePaste.albumUnavailable'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
if (location) {
|
||||
navigateToAlbumDetail(navigate, location, payload.id);
|
||||
} else {
|
||||
navigate(`/album/${payload.id}`);
|
||||
}
|
||||
showToast(t('sharePaste.openedAlbum'), 3000, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.k === 'artist') {
|
||||
const artistResult = await resolveArtist(serverId, payload.id);
|
||||
if (!artistResult) {
|
||||
showToast(t('sharePaste.artistUnavailable'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
navigate(`/artist/${payload.id}`);
|
||||
showToast(t('sharePaste.openedArtist'), 3000, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.k === 'composer') {
|
||||
// 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);
|
||||
if (!composerResult) {
|
||||
showToast(t('sharePaste.composerUnavailable'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
navigate(`/composer/${payload.id}`);
|
||||
showToast(t('sharePaste.openedComposer'), 3000, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.k === 'queue') {
|
||||
await applySharePasteQueue(payload, t);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[psysonic] share paste failed', e);
|
||||
showToast(t('sharePaste.genericError'), 5000, 'error');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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(),
|
||||
},
|
||||
},
|
||||
enqueue: vi.fn(),
|
||||
getAlbum: vi.fn(),
|
||||
getAlbumWithCredentials: vi.fn(),
|
||||
getArtist: vi.fn(),
|
||||
getArtistWithCredentials: vi.fn(),
|
||||
getSong: vi.fn(),
|
||||
getSongWithCredentials: vi.fn(),
|
||||
orbitBulkGuard: vi.fn(),
|
||||
showToast: vi.fn(),
|
||||
songToTrack: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api/subsonicLibrary', () => ({
|
||||
getAlbum: mocks.getAlbum,
|
||||
getSong: mocks.getSong,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api/subsonicArtists', () => ({
|
||||
getArtist: mocks.getArtist,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api/subsonicEntityWithCredentials', () => ({
|
||||
getAlbumWithCredentials: mocks.getAlbumWithCredentials,
|
||||
getArtistWithCredentials: mocks.getArtistWithCredentials,
|
||||
getSongWithCredentials: mocks.getSongWithCredentials,
|
||||
}));
|
||||
|
||||
vi.mock('@/store/authStore', () => ({
|
||||
useAuthStore: {
|
||||
getState: () => mocks.authState.current,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/playback/store/playerStore', () => ({
|
||||
usePlayerStore: {
|
||||
getState: () => ({ enqueue: mocks.enqueue }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/media/songToTrack', () => ({
|
||||
songToTrack: mocks.songToTrack,
|
||||
}));
|
||||
|
||||
vi.mock('@/features/orbit', () => ({
|
||||
orbitBulkGuard: mocks.orbitBulkGuard,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/dom/toast', () => ({
|
||||
showToast: mocks.showToast,
|
||||
}));
|
||||
|
||||
import {
|
||||
activateShareSearchServer,
|
||||
enqueueShareSearchPayload,
|
||||
resolveShareSearchAlbum,
|
||||
resolveShareSearchArtist,
|
||||
resolveShareSearchPayload,
|
||||
} from '@/features/share/enqueueShareSearchPayload';
|
||||
|
||||
const sharedServer = {
|
||||
id: 'shared',
|
||||
name: 'Shared',
|
||||
url: 'https://shared.example.com',
|
||||
username: 'shared-user',
|
||||
password: 'shared-pass',
|
||||
};
|
||||
|
||||
const activeServer = {
|
||||
id: 'active',
|
||||
name: 'Active',
|
||||
url: 'https://active.example.com',
|
||||
username: 'active-user',
|
||||
password: 'active-pass',
|
||||
};
|
||||
|
||||
const sharedSong = {
|
||||
id: 'song-1',
|
||||
title: 'Shared Song',
|
||||
artist: 'Shared Artist',
|
||||
album: 'Shared Album',
|
||||
albumId: 'album-1',
|
||||
duration: 180,
|
||||
minutesAgo: 0,
|
||||
playerId: 0,
|
||||
playerName: '',
|
||||
};
|
||||
|
||||
describe('share search payload resolution', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.authState.current = {
|
||||
servers: [activeServer, sharedServer],
|
||||
isLoggedIn: true,
|
||||
activeServerId: 'active',
|
||||
setActiveServer: vi.fn(),
|
||||
};
|
||||
mocks.getSongWithCredentials.mockResolvedValue(sharedSong);
|
||||
mocks.getAlbumWithCredentials.mockResolvedValue({
|
||||
album: { id: 'album-1', name: 'Shared Album', artist: 'Shared Artist' },
|
||||
songs: [],
|
||||
});
|
||||
mocks.getArtistWithCredentials.mockResolvedValue({
|
||||
artist: { id: 'artist-1', name: 'Shared Artist' },
|
||||
albums: [],
|
||||
});
|
||||
mocks.getSong.mockResolvedValue(sharedSong);
|
||||
mocks.songToTrack.mockImplementation(song => ({ id: song.id, title: song.title }));
|
||||
mocks.orbitBulkGuard.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('resolves a shared track preview with explicit credentials 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(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' });
|
||||
|
||||
expect(mocks.getAlbumWithCredentials).toHaveBeenCalledWith(
|
||||
sharedServer.url,
|
||||
sharedServer.username,
|
||||
sharedServer.password,
|
||||
'album-1',
|
||||
sharedServer,
|
||||
);
|
||||
expect(mocks.getArtistWithCredentials).toHaveBeenCalledWith(
|
||||
sharedServer.url,
|
||||
sharedServer.username,
|
||||
sharedServer.password,
|
||||
'artist-1',
|
||||
sharedServer,
|
||||
);
|
||||
expect(mocks.getAlbum).not.toHaveBeenCalled();
|
||||
expect(mocks.getArtist).not.toHaveBeenCalled();
|
||||
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves composer previews via artist credentials without switching active server', async () => {
|
||||
const result = await resolveShareSearchArtist({
|
||||
srv: 'https://shared.example.com',
|
||||
k: 'composer',
|
||||
id: 'composer-1',
|
||||
});
|
||||
|
||||
expect(result.type).toBe('ok');
|
||||
expect(mocks.getArtistWithCredentials).toHaveBeenCalledWith(
|
||||
sharedServer.url,
|
||||
sharedServer.username,
|
||||
sharedServer.password,
|
||||
'composer-1',
|
||||
sharedServer,
|
||||
);
|
||||
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns not-logged-in without calling the API', async () => {
|
||||
mocks.authState.current.isLoggedIn = false;
|
||||
|
||||
const result = await resolveShareSearchPayload({
|
||||
srv: 'https://shared.example.com',
|
||||
k: 'track',
|
||||
id: 'song-1',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ type: 'not-logged-in' });
|
||||
expect(mocks.getSongWithCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('activates the share server for confirmed enqueue actions', async () => {
|
||||
const t = ((key: string) => key) as TFunction;
|
||||
const ok = await enqueueShareSearchPayload({
|
||||
srv: 'https://shared.example.com',
|
||||
k: 'track',
|
||||
id: 'song-1',
|
||||
}, t);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('aborts enqueue when orbitBulkGuard rejects the bulk add', async () => {
|
||||
mocks.orbitBulkGuard.mockResolvedValue(false);
|
||||
const t = ((key: string) => key) as TFunction;
|
||||
|
||||
const ok = await enqueueShareSearchPayload({
|
||||
srv: 'https://shared.example.com',
|
||||
k: 'track',
|
||||
id: 'song-1',
|
||||
}, t);
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(mocks.enqueue).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),
|
||||
);
|
||||
const t = ((key: string, opts?: Record<string, unknown>) =>
|
||||
opts ? `${key}:${JSON.stringify(opts)}` : key) as TFunction;
|
||||
|
||||
const ok = await enqueueShareSearchPayload({
|
||||
srv: 'https://shared.example.com',
|
||||
k: 'queue',
|
||||
ids: ['song-1', 'missing'],
|
||||
}, t);
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(mocks.enqueue).toHaveBeenCalledWith([{ id: 'song-1', title: 'Shared Song' }], true);
|
||||
expect(mocks.showToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining('search.shareQueuedPartial'),
|
||||
5000,
|
||||
'info',
|
||||
);
|
||||
});
|
||||
|
||||
it('activateShareSearchServer switches server when lookup succeeds', () => {
|
||||
const t = ((key: string) => key) as TFunction;
|
||||
expect(activateShareSearchServer('https://shared.example.com', t)).toBe(true);
|
||||
expect(mocks.authState.current.setActiveServer).toHaveBeenCalledWith('shared');
|
||||
expect(mocks.showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('activateShareSearchServer toasts when no matching server exists', () => {
|
||||
const t = ((key: string) => key) as TFunction;
|
||||
expect(activateShareSearchServer('https://unknown.example.com', t)).toBe(false);
|
||||
expect(mocks.showToast).toHaveBeenCalledWith(
|
||||
'sharePaste.noMatchingServer',
|
||||
6000,
|
||||
'error',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
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 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,
|
||||
ComposerShareSearchPayload,
|
||||
QueueableShareSearchPayload,
|
||||
} from '@/lib/share/shareSearch';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
|
||||
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' }
|
||||
| { type: 'no-matching-server'; url: string }
|
||||
| { type: 'all-unavailable' }
|
||||
| { type: 'error' };
|
||||
|
||||
export type ShareSearchAlbumResolveResult =
|
||||
| { type: 'ok'; album: SubsonicAlbum }
|
||||
| { type: 'not-logged-in' }
|
||||
| { type: 'no-matching-server'; url: string }
|
||||
| { type: 'unavailable' }
|
||||
| { type: 'error' };
|
||||
|
||||
export type ShareSearchArtistResolveResult =
|
||||
| { type: 'ok'; artist: SubsonicArtist }
|
||||
| { type: 'not-logged-in' }
|
||||
| { type: 'no-matching-server'; url: string }
|
||||
| { 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') {
|
||||
showToast(t('sharePaste.notLoggedIn'), 4000, 'info');
|
||||
return false;
|
||||
}
|
||||
if (lookup.type === 'no-matching-server') {
|
||||
showToast(t('sharePaste.noMatchingServer', { url: lookup.url }), 6000, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
activateShareServer(lookup.serverId);
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function resolveShareSearchPayload(
|
||||
payload: QueueableShareSearchPayload,
|
||||
options: ShareResolveOptions = {},
|
||||
): Promise<ShareSearchResolveResult> {
|
||||
const lookup = lookupShareServer(payload.srv);
|
||||
if (lookup.type === 'not-logged-in') {
|
||||
return { type: 'not-logged-in' };
|
||||
}
|
||||
if (lookup.type === 'no-matching-server') {
|
||||
return { type: 'no-matching-server', url: lookup.url };
|
||||
}
|
||||
|
||||
try {
|
||||
const ids = payload.k === 'track' ? [payload.id] : payload.ids;
|
||||
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)));
|
||||
for (const song of songs) {
|
||||
if (song) resolved.push(song);
|
||||
}
|
||||
}
|
||||
|
||||
const skipped = ids.length - resolved.length;
|
||||
if (resolved.length === 0) {
|
||||
return { type: 'all-unavailable' };
|
||||
}
|
||||
|
||||
return { type: 'ok', songs: resolved, total: ids.length, skipped };
|
||||
} catch {
|
||||
return { type: 'error' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveShareSearchAlbum(
|
||||
payload: AlbumShareSearchPayload,
|
||||
options: ShareResolveOptions = {},
|
||||
): Promise<ShareSearchAlbumResolveResult> {
|
||||
const lookup = lookupShareServer(payload.srv);
|
||||
if (lookup.type === 'not-logged-in') {
|
||||
return { type: 'not-logged-in' };
|
||||
}
|
||||
if (lookup.type === 'no-matching-server') {
|
||||
return { type: 'no-matching-server', url: lookup.url };
|
||||
}
|
||||
|
||||
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 };
|
||||
} catch {
|
||||
return { type: 'unavailable' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveShareSearchArtist(
|
||||
payload: ArtistShareSearchPayload | ComposerShareSearchPayload,
|
||||
options: ShareResolveOptions = {},
|
||||
): Promise<ShareSearchArtistResolveResult> {
|
||||
const lookup = lookupShareServer(payload.srv);
|
||||
if (lookup.type === 'not-logged-in') {
|
||||
return { type: 'not-logged-in' };
|
||||
}
|
||||
if (lookup.type === 'no-matching-server') {
|
||||
return { type: 'no-matching-server', url: lookup.url };
|
||||
}
|
||||
|
||||
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 };
|
||||
} catch {
|
||||
return { type: 'unavailable' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function enqueueShareSearchPayload(
|
||||
payload: QueueableShareSearchPayload,
|
||||
t: TFunction,
|
||||
): Promise<boolean> {
|
||||
const resolved = await resolveShareSearchPayload(payload, { activateServer: true });
|
||||
if (resolved.type === 'not-logged-in') {
|
||||
showToast(t('sharePaste.notLoggedIn'), 4000, 'info');
|
||||
return false;
|
||||
}
|
||||
if (resolved.type === 'no-matching-server') {
|
||||
showToast(t('sharePaste.noMatchingServer', { url: resolved.url }), 6000, 'error');
|
||||
return false;
|
||||
}
|
||||
if (resolved.type === 'all-unavailable') {
|
||||
showToast(
|
||||
payload.k === 'track' ? t('sharePaste.trackUnavailable') : t('sharePaste.queueAllUnavailable'),
|
||||
payload.k === 'track' ? 5000 : 6000,
|
||||
'error',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (resolved.type === 'error') {
|
||||
showToast(t('sharePaste.genericError'), 5000, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const tracks: Track[] = resolved.songs.map(songToTrack);
|
||||
const okToEnqueue = await orbitBulkGuard(tracks.length);
|
||||
if (!okToEnqueue) return false;
|
||||
usePlayerStore.getState().enqueue(tracks, true);
|
||||
if (resolved.skipped > 0) {
|
||||
showToast(
|
||||
t('search.shareQueuedPartial', { queued: tracks.length, total: resolved.total, skipped: resolved.skipped }),
|
||||
5000,
|
||||
'info',
|
||||
);
|
||||
} else {
|
||||
showToast(t('search.shareQueued', { count: tracks.length }), 3000, 'info');
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[psysonic] share search enqueue failed', e);
|
||||
showToast(t('sharePaste.genericError'), 5000, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Share feature — applying pasted entity/queue shares (server switch + resolve +
|
||||
* play/navigate) and enqueueing share-search results. The pure encode/decode/parse
|
||||
* and origin-label helpers live in `@/lib/share` (feature-free); these orchestrators
|
||||
* runtime-import offline/playback/orbit, so they are feature-scoped. Consumers import
|
||||
* deep paths (`@/features/share/...`); this barrel is documentation.
|
||||
*/
|
||||
export * from './applySharePaste';
|
||||
export * from './enqueueShareSearchPayload';
|
||||
Reference in New Issue
Block a user