feat(search): queue pasted share links from Live Search and mobile search

Implement share-link detection in search (track, queue, album, artist,
composer): enqueue tracks/queues without interrupting playback; preview
album/artist/composer without switching the active server; queue preview
modal with scrollable track list. Based on community PR #551.

Co-authored-by: Daniel Wagner <daniel.iuser@icloud.com>
This commit is contained in:
Maxim Isaev
2026-05-15 13:38:35 +03:00
parent 02fd828049
commit e7431b94b8
30 changed files with 2323 additions and 16 deletions
@@ -0,0 +1,259 @@
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('../../api/subsonicLibrary', () => ({
getAlbum: mocks.getAlbum,
getSong: mocks.getSong,
}));
vi.mock('../../api/subsonicArtists', () => ({
getArtist: mocks.getArtist,
}));
vi.mock('../../api/subsonicEntityWithCredentials', () => ({
getAlbumWithCredentials: mocks.getAlbumWithCredentials,
getArtistWithCredentials: mocks.getArtistWithCredentials,
getSongWithCredentials: mocks.getSongWithCredentials,
}));
vi.mock('../../store/authStore', () => ({
useAuthStore: {
getState: () => mocks.authState.current,
},
}));
vi.mock('../../store/playerStore', () => ({
usePlayerStore: {
getState: () => ({ enqueue: mocks.enqueue }),
},
}));
vi.mock('../playback/songToTrack', () => ({
songToTrack: mocks.songToTrack,
}));
vi.mock('../orbitBulkGuard', () => ({
orbitBulkGuard: mocks.orbitBulkGuard,
}));
vi.mock('../ui/toast', () => ({
showToast: mocks.showToast,
}));
import {
activateShareSearchServer,
enqueueShareSearchPayload,
resolveShareSearchAlbum,
resolveShareSearchArtist,
resolveShareSearchPayload,
} from './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',
);
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',
);
expect(mocks.getArtistWithCredentials).toHaveBeenCalledWith(
sharedServer.url,
sharedServer.username,
sharedServer.password,
'artist-1',
);
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',
);
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,246 @@
import type { TFunction } from 'i18next';
import {
getAlbumWithCredentials,
getArtistWithCredentials,
getSongWithCredentials,
} from '../../api/subsonicEntityWithCredentials';
import { getAlbum, getSong } from '../../api/subsonicLibrary';
import { getArtist } from '../../api/subsonicArtists';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
import { useAuthStore } from '../../store/authStore';
import type { ServerProfile } from '../../store/authStoreTypes';
import { usePlayerStore } from '../../store/playerStore';
import { songToTrack } from '../playback/songToTrack';
import type { Track } from '../../store/playerStoreTypes';
import { orbitBulkGuard } from '../orbitBulkGuard';
import { findServerIdForShareUrl } from './shareLink';
import type {
AlbumShareSearchPayload,
ArtistShareSearchPayload,
ComposerShareSearchPayload,
QueueableShareSearchPayload,
} from './shareSearch';
import { showToast } from '../ui/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) : 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(lookup.server.url, lookup.server.username, lookup.server.password, id);
}
async function getAlbumAfterActivation(
id: string,
serverId: string,
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
activateShareServer(serverId);
return getAlbum(id);
}
async function getArtistAfterActivation(
id: string,
serverId: string,
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
activateShareServer(serverId);
return getArtist(id);
}
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(lookup.server.url, lookup.server.username, lookup.server.password, payload.id);
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(lookup.server.url, lookup.server.username, lookup.server.password, payload.id);
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;
}
}
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import { encodeServerMagicString } from '../server/serverMagicString';
import { encodeSharePayload } from './shareLink';
import { parseShareSearchText, sharePayloadTotal } from './shareSearch';
describe('share search parsing', () => {
it('detects track share links as queueable', () => {
const link = encodeSharePayload({
srv: 'https://music.example.com',
k: 'track',
id: 'song-1',
});
expect(parseShareSearchText(link)).toEqual({
type: 'queueable',
payload: {
srv: 'https://music.example.com',
k: 'track',
id: 'song-1',
},
});
});
it('detects queue share links as queueable and counts their tracks', () => {
const link = encodeSharePayload({
srv: 'https://music.example.com',
k: 'queue',
ids: ['a', 'b', 'c'],
});
const match = parseShareSearchText(`try this ${link}`);
expect(match).toEqual({
type: 'queueable',
payload: {
srv: 'https://music.example.com',
k: 'queue',
ids: ['a', 'b', 'c'],
},
});
if (match?.type === 'queueable') {
expect(sharePayloadTotal(match.payload)).toBe(3);
}
});
it('does not treat server magic strings as share-search links', () => {
const invite = encodeServerMagicString({
url: 'https://music.example.com',
username: 'user',
password: 'pass',
});
expect(parseShareSearchText(invite)).toBeNull();
});
it('detects album share links as album search results', () => {
const album = encodeSharePayload({
srv: 'https://music.example.com',
k: 'album',
id: 'album-1',
});
expect(parseShareSearchText(album)).toEqual({
type: 'album',
payload: {
srv: 'https://music.example.com',
k: 'album',
id: 'album-1',
},
});
});
it('detects artist share links as artist search results', () => {
const artist = encodeSharePayload({
srv: 'https://music.example.com',
k: 'artist',
id: 'artist-1',
});
expect(parseShareSearchText(artist)).toEqual({
type: 'artist',
payload: {
srv: 'https://music.example.com',
k: 'artist',
id: 'artist-1',
},
});
});
it('detects composer share links as composer search results', () => {
const composer = encodeSharePayload({
srv: 'https://music.example.com',
k: 'composer',
id: 'composer-1',
});
expect(parseShareSearchText(composer)).toEqual({
type: 'composer',
payload: {
srv: 'https://music.example.com',
k: 'composer',
id: 'composer-1',
},
});
});
it('returns unsupported for invalid psysonic2 payloads', () => {
expect(parseShareSearchText('psysonic2-not-valid-base64!!!')).toEqual({ type: 'unsupported' });
});
it('counts a single track in sharePayloadTotal', () => {
expect(
sharePayloadTotal({ srv: 'https://music.example.com', k: 'track', id: 'song-1' }),
).toBe(1);
});
it('marks orbit invites as unsupported in search', () => {
const orbit = encodeSharePayload({
srv: 'https://music.example.com',
k: 'orbit',
sid: '1234abcd',
});
expect(parseShareSearchText(orbit)).toEqual({ type: 'unsupported' });
});
});
+63
View File
@@ -0,0 +1,63 @@
import {
decodeSharePayloadFromText,
PSYSONIC_SHARE_PREFIX,
} from './shareLink';
export type QueueableShareSearchPayload =
| { srv: string; k: 'track'; id: string }
| { srv: string; k: 'queue'; ids: string[] };
export type AlbumShareSearchPayload = { srv: string; k: 'album'; id: string };
export type ArtistShareSearchPayload = { srv: string; k: 'artist'; id: string };
export type ComposerShareSearchPayload = { srv: string; k: 'composer'; id: string };
export type ShareSearchMatch =
| { type: 'queueable'; payload: QueueableShareSearchPayload }
| { type: 'album'; payload: AlbumShareSearchPayload }
| { type: 'artist'; payload: ArtistShareSearchPayload }
| { type: 'composer'; payload: ComposerShareSearchPayload }
| { type: 'unsupported' };
export function parseShareSearchText(text: string): ShareSearchMatch | null {
const trimmed = text.trim();
if (!trimmed.includes(PSYSONIC_SHARE_PREFIX)) return null;
const payload = decodeSharePayloadFromText(trimmed);
if (!payload) return { type: 'unsupported' };
if (payload.k === 'track') {
return {
type: 'queueable',
payload: { srv: payload.srv, k: 'track', id: payload.id },
};
}
if (payload.k === 'queue') {
return {
type: 'queueable',
payload: { srv: payload.srv, k: 'queue', ids: payload.ids },
};
}
if (payload.k === 'album') {
return {
type: 'album',
payload: { srv: payload.srv, k: 'album', id: payload.id },
};
}
if (payload.k === 'artist') {
return {
type: 'artist',
payload: { srv: payload.srv, k: 'artist', id: payload.id },
};
}
if (payload.k === 'composer') {
return {
type: 'composer',
payload: { srv: payload.srv, k: 'composer', id: payload.id },
};
}
return { type: 'unsupported' };
}
export function sharePayloadTotal(payload: QueueableShareSearchPayload): number {
return payload.k === 'track' ? 1 : payload.ids.length;
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { encodeSharePayload } from './shareLink';
import { parseShareSearchText } from './shareSearch';
import { shareServerOriginLabel } from './shareServerOriginLabel';
const home = {
id: 'home',
name: 'Home NAS',
url: 'https://music.home.example',
username: 'u1',
password: 'p1',
};
const office = {
id: 'office',
name: 'Office',
url: 'https://music.office.example',
username: 'u2',
password: 'p2',
};
describe('shareServerOriginLabel', () => {
it('returns null when share targets the active server', () => {
const match = parseShareSearchText(
encodeSharePayload({ srv: home.url, k: 'track', id: 't-1' }),
);
expect(shareServerOriginLabel(match, [home, office], 'home')).toBeNull();
});
it('returns the saved server display name when share is from another saved server', () => {
const match = parseShareSearchText(
encodeSharePayload({ srv: office.url, k: 'track', id: 't-1' }),
);
expect(shareServerOriginLabel(match, [home, office], 'home')).toBe('Office');
});
it('returns null when the share server is not in saved profiles', () => {
const match = parseShareSearchText(
encodeSharePayload({ srv: 'https://unknown.example', k: 'track', id: 't-1' }),
);
expect(shareServerOriginLabel(match, [home], 'home')).toBeNull();
});
it('returns null for unsupported share payloads', () => {
expect(shareServerOriginLabel({ type: 'unsupported' }, [home], 'home')).toBeNull();
});
});
+25
View File
@@ -0,0 +1,25 @@
import type { ServerProfile } from '../../store/authStoreTypes';
import { serverListDisplayLabel } from '../server/serverDisplayName';
import { findServerIdForShareUrl } from './shareLink';
import type { ShareSearchMatch } from './shareSearch';
/**
* Display name for the share link's origin server when it differs from the
* active server. Returns null when the link targets the active server, is
* unsupported, or does not match any saved server profile.
*/
export function shareServerOriginLabel(
shareMatch: ShareSearchMatch | null,
servers: ServerProfile[],
activeServerId: string | null,
): string | null {
if (!shareMatch || shareMatch.type === 'unsupported') return null;
const shareServerId = findServerIdForShareUrl(servers, shareMatch.payload.srv);
if (!shareServerId || shareServerId === activeServerId) return null;
const server = servers.find(s => s.id === shareServerId);
if (!server) return null;
return serverListDisplayLabel(server, servers);
}