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,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;
}
}