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:
Psychotoxical
2026-06-30 22:21:57 +02:00
parent 6590bd15ef
commit 92635381ea
26 changed files with 56 additions and 47 deletions
@@ -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 -1
View File
@@ -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). */
+1 -1
View File
@@ -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' }
+4 -4
View File
@@ -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;
+180
View File
@@ -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;
}
}
+9
View File
@@ -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';