mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
@@ -10,7 +10,14 @@
|
||||
* Network-bound endpoints (`getAlbum`, `search`, etc.) require axios
|
||||
* mocking and are not in this PR.
|
||||
*/
|
||||
import { buildCoverArtUrl, buildDownloadUrl, buildStreamUrl, coverArtCacheKey } from './subsonicStreamUrl';
|
||||
import {
|
||||
buildCoverArtUrl,
|
||||
buildCoverArtUrlForServer,
|
||||
buildDownloadUrl,
|
||||
buildStreamUrl,
|
||||
coverArtCacheKey,
|
||||
coverArtCacheKeyForServer,
|
||||
} from './subsonicStreamUrl';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { parseSubsonicEntityStarRating } from './subsonicRatings';
|
||||
import { getClient, libraryFilterParams } from './subsonicClient';
|
||||
@@ -173,6 +180,24 @@ describe('buildCoverArtUrl', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCoverArtUrlForServer', () => {
|
||||
it('builds getCoverArt URL with explicit server credentials', () => {
|
||||
const url = new URL(buildCoverArtUrlForServer('https://remote.example', 'bob', 'secret', 'art-9', 40));
|
||||
expect(url.origin).toBe('https://remote.example');
|
||||
expect(url.pathname).toBe('/rest/getCoverArt.view');
|
||||
expect(url.searchParams.get('id')).toBe('art-9');
|
||||
expect(url.searchParams.get('size')).toBe('40');
|
||||
expect(url.searchParams.get('u')).toBe('bob');
|
||||
expect(url.searchParams.get('t')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('coverArtCacheKeyForServer', () => {
|
||||
it('scopes cache keys by server id', () => {
|
||||
expect(coverArtCacheKeyForServer('srv-b', 'cover-1', 80)).toBe('srv-b:cover:cover-1:80');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDownloadUrl', () => {
|
||||
it('returns a /rest/download.view URL with id + auth params', () => {
|
||||
setUpServer({ url: 'https://music.example.com' });
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { apiWithCredentials } from './subsonicClient';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
|
||||
|
||||
export async function getSongWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
id: string,
|
||||
): Promise<SubsonicSong | null> {
|
||||
try {
|
||||
const data = await apiWithCredentials<{ song: SubsonicSong }>(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
'getSong.view',
|
||||
{ id },
|
||||
);
|
||||
return data.song ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAlbumWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
id: string,
|
||||
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
||||
const data = await apiWithCredentials<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
'getAlbum.view',
|
||||
{ id },
|
||||
);
|
||||
const { song, ...album } = data.album;
|
||||
return { album, songs: song ?? [] };
|
||||
}
|
||||
|
||||
export async function getArtistWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
id: string,
|
||||
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
|
||||
const data = await apiWithCredentials<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
'getArtist.view',
|
||||
{ id },
|
||||
);
|
||||
const { album, ...artist } = data.artist;
|
||||
return { artist, albums: album ?? [] };
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
import md5 from 'md5';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { SUBSONIC_CLIENT, secureRandomSalt } from './subsonicClient';
|
||||
import { restBaseFromUrl, SUBSONIC_CLIENT, secureRandomSalt } from './subsonicClient';
|
||||
|
||||
function coverArtQueryParams(username: string, password: string, id: string, size: number): URLSearchParams {
|
||||
const salt = secureRandomSalt();
|
||||
const token = md5(password + salt);
|
||||
return new URLSearchParams({
|
||||
id,
|
||||
size: String(size),
|
||||
u: username,
|
||||
t: token,
|
||||
s: salt,
|
||||
v: '1.16.1',
|
||||
c: SUBSONIC_CLIENT,
|
||||
f: 'json',
|
||||
});
|
||||
}
|
||||
|
||||
export function buildStreamUrl(id: string): string {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
@@ -19,23 +34,33 @@ export function buildStreamUrl(id: string): string {
|
||||
/** Stable cache key for cover art — does not include ephemeral auth params. */
|
||||
export function coverArtCacheKey(id: string, size = 256): string {
|
||||
const server = useAuthStore.getState().getActiveServer();
|
||||
return `${server?.id ?? '_'}:cover:${id}:${size}`;
|
||||
return coverArtCacheKeyForServer(server?.id ?? '_', id, size);
|
||||
}
|
||||
|
||||
export function coverArtCacheKeyForServer(serverId: string, id: string, size = 256): string {
|
||||
return `${serverId}:cover:${id}:${size}`;
|
||||
}
|
||||
|
||||
export function buildCoverArtUrl(id: string, size = 256): string {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
const salt = secureRandomSalt();
|
||||
const token = md5((server?.password ?? '') + salt);
|
||||
const p = new URLSearchParams({
|
||||
id, size: String(size),
|
||||
u: server?.username ?? '',
|
||||
t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json',
|
||||
});
|
||||
const p = coverArtQueryParams(server?.username ?? '', server?.password ?? '', id, size);
|
||||
return `${baseUrl}/rest/getCoverArt.view?${p.toString()}`;
|
||||
}
|
||||
|
||||
/** Cover art for a specific saved server (e.g. share-search preview on a non-active server). */
|
||||
export function buildCoverArtUrlForServer(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
id: string,
|
||||
size = 256,
|
||||
): string {
|
||||
const p = coverArtQueryParams(username, password, id, size);
|
||||
return `${restBaseFromUrl(serverUrl)}/getCoverArt.view?${p.toString()}`;
|
||||
}
|
||||
|
||||
export function buildDownloadUrl(id: string): string {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
|
||||
Reference in New Issue
Block a user