feat(share): library deep links (psysonic2) with paste + toolbar actions (#261)

* feat(share): library deep links (psysonic2) with paste and toolbar actions

Add base64url-encoded payloads for track, album, artist, and ordered queue.
Handle paste outside inputs to switch server, validate entities, navigate or
play. Reuse clipboard helper for context menu, queue panel, album and artist
headers. Tests distinguish server invite strings from library shares.

* fix(share): play partial queue when some shared tracks are missing

Skip unavailable song IDs when pasting a queue share while preserving order.
Toast when some tracks are missing; error only if none resolve. Add
openedQueuePartial and queueAllUnavailable i18n; remove queueTracksMissing.

* feat(settings): paste server invites globally and scroll to add form

Handle psysonic1- magic strings outside inputs: navigate to settings or login
with prefilled add-server fields. Decode invites embedded in surrounding text.
Scroll the add-server block into view when opening from a paste so long server
lists stay oriented.

---------

Co-authored-by: Maxim Isaev <im@friclub.ru>
This commit is contained in:
Frank Stellmacher
2026-04-22 11:21:00 +02:00
committed by GitHub
parent 21d00889aa
commit 7c9a300022
22 changed files with 779 additions and 18 deletions
+113
View File
@@ -0,0 +1,113 @@
import type { NavigateFunction } from 'react-router-dom';
import type { TFunction } from 'i18next';
import { getAlbum, getArtist, getSong, type SubsonicSong } from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
import { songToTrack, usePlayerStore } from '../store/playerStore';
import { findServerIdForShareUrl, type SharePayloadV1 } from './shareLink';
import { showToast } from './toast';
const RESOLVE_QUEUE_CHUNK = 12;
/**
* 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: SharePayloadV1,
navigate: NavigateFunction,
t: TFunction,
): 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') {
try {
await getAlbum(payload.id);
} catch {
showToast(t('sharePaste.albumUnavailable'), 5000, 'error');
return;
}
navigate(`/album/${payload.id}`);
showToast(t('sharePaste.openedAlbum'), 3000, 'info');
return;
}
if (payload.k === 'artist') {
try {
await getArtist(payload.id);
} catch {
showToast(t('sharePaste.artistUnavailable'), 5000, 'error');
return;
}
navigate(`/artist/${payload.id}`);
showToast(t('sharePaste.openedArtist'), 3000, 'info');
return;
}
if (payload.k === 'queue') {
const { ids } = payload;
if (ids.length === 0) {
showToast(t('sharePaste.genericError'), 5000, 'error');
return;
}
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 (let j = 0; j < songs.length; j++) {
const s = songs[j];
if (s) resolved.push(s);
}
}
const skipped = ids.length - resolved.length;
if (resolved.length === 0) {
showToast(t('sharePaste.queueAllUnavailable'), 6000, 'error');
return;
}
const tracks = resolved.map(songToTrack);
usePlayerStore.getState().clearQueue();
usePlayerStore.getState().playTrack(tracks[0]!, tracks);
if (skipped > 0) {
showToast(
t('sharePaste.openedQueuePartial', { played: tracks.length, total: ids.length, skipped }),
5000,
'info',
);
} else {
showToast(t('sharePaste.openedQueue', { count: tracks.length }), 3000, 'info');
}
return;
}
} catch (e) {
console.error('[psysonic] share paste failed', e);
showToast(t('sharePaste.genericError'), 5000, 'error');
}
}
+10
View File
@@ -0,0 +1,10 @@
import { useAuthStore } from '../store/authStore';
import { encodeSharePayload, type EntityShareKind } from './shareLink';
import { copyTextToClipboard } from './serverMagicString';
/** Copies a track / album / artist share link (`psysonic2-`) to the clipboard. */
export async function copyEntityShareLink(kind: EntityShareKind, id: string): Promise<boolean> {
const srv = useAuthStore.getState().getBaseUrl();
if (!srv || !id.trim()) return false;
return copyTextToClipboard(encodeSharePayload({ srv, k: kind, id: id.trim() }));
}
+12
View File
@@ -3,6 +3,7 @@ import {
SERVER_MAGIC_STRING_PREFIX,
DECODED_PASSWORD_VISUAL_MASK,
decodeServerMagicString,
decodeServerMagicStringFromText,
encodeServerMagicString,
} from './serverMagicString';
@@ -40,4 +41,15 @@ describe('serverMagicString', () => {
expect(decodeServerMagicString('nope')).toBeNull();
expect(decodeServerMagicString(`${SERVER_MAGIC_STRING_PREFIX}%%%`)).toBeNull();
});
it('decodes invite embedded in surrounding text', () => {
const original = {
url: 'https://music.example.com',
username: 'alice',
password: 'pw',
};
const line = encodeServerMagicString(original);
expect(decodeServerMagicStringFromText(`Copy:\n${line}\nThanks`)).toEqual(original);
expect(decodeServerMagicStringFromText('no token')).toBeNull();
});
});
+17 -1
View File
@@ -1,4 +1,7 @@
/** Prefix for server share strings generated by Psysonic (Subsonic credentials bundle). */
/**
* Prefix for server invite strings (Subsonic credentials). Same family as library
* shares in `shareLink.ts` (`psysonic2-` + payload).
*/
export const SERVER_MAGIC_STRING_PREFIX = 'psysonic1-';
/** Fixed-length placeholder so a password field does not reveal the real password length after decode. */
@@ -45,6 +48,19 @@ export function encodeServerMagicString(p: ServerMagicPayload): string {
* Decode a magic string from {@link encodeServerMagicString}.
* Accepts optional surrounding whitespace.
*/
/**
* Finds a server invite (`psysonic1-` + base64url payload) inside arbitrary pasted
* text (e.g. a sentence with the token embedded).
*/
export function decodeServerMagicStringFromText(text: string): ServerMagicPayload | null {
const idx = text.indexOf(SERVER_MAGIC_STRING_PREFIX);
if (idx < 0) return null;
const afterPrefix = text.slice(idx + SERVER_MAGIC_STRING_PREFIX.length);
const token = afterPrefix.match(/^([A-Za-z0-9_-]+)/)?.[1];
if (!token) return null;
return decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + token);
}
export function decodeServerMagicString(raw: string): ServerMagicPayload | null {
const s = raw.trim();
if (!s.startsWith(SERVER_MAGIC_STRING_PREFIX)) return null;
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import {
decodeSharePayloadFromText,
encodeSharePayload,
PSYSONIC_SHARE_PREFIX,
} from './shareLink';
import { decodeServerMagicString, encodeServerMagicString, SERVER_MAGIC_STRING_PREFIX } from './serverMagicString';
describe('shareLink vs serverMagicString', () => {
it('uses the same psysonic* prefix family as server invites (distinct digit)', () => {
expect(SERVER_MAGIC_STRING_PREFIX).toBe('psysonic1-');
expect(PSYSONIC_SHARE_PREFIX).toBe('psysonic2-');
expect(SERVER_MAGIC_STRING_PREFIX.slice(0, 8)).toBe(PSYSONIC_SHARE_PREFIX.slice(0, 8));
expect(SERVER_MAGIC_STRING_PREFIX).not.toBe(PSYSONIC_SHARE_PREFIX);
});
it('does not decode a server magic string as an entity share', () => {
const serverLine = encodeServerMagicString({
url: 'https://music.example.com',
username: 'u',
password: 'p',
});
expect(decodeSharePayloadFromText(serverLine)).toBeNull();
expect(decodeSharePayloadFromText(`intro ${serverLine}`)).toBeNull();
});
it('does not decode an entity share as server magic', () => {
const share = encodeSharePayload({
srv: 'https://music.example.com',
k: 'track',
id: 'tr-1',
});
expect(share.startsWith(PSYSONIC_SHARE_PREFIX)).toBe(true);
expect(decodeServerMagicString(share)).toBeNull();
});
it('round-trips entity payload embedded in surrounding text', () => {
const encoded = encodeSharePayload({
srv: 'https://nd.example/rest',
k: 'album',
id: 'al-99',
});
const pasted = `Check this:\n${encoded}\n`;
expect(decodeSharePayloadFromText(pasted)).toEqual({
srv: 'https://nd.example/rest',
k: 'album',
id: 'al-99',
});
});
it('round-trips queue payload in order', () => {
const ids = ['a', 'b', 'c'];
const encoded = encodeSharePayload({
srv: 'https://x.example',
k: 'queue',
ids,
});
expect(decodeSharePayloadFromText(encoded)).toEqual({
srv: 'https://x.example',
k: 'queue',
ids: ['a', 'b', 'c'],
});
expect(decodeServerMagicString(encoded)).toBeNull();
});
});
+89
View File
@@ -0,0 +1,89 @@
import type { ServerProfile } from '../store/authStore';
/** Library share (track / album / artist / queue). Same naming family as `psysonic1-` server invites. */
export const PSYSONIC_SHARE_PREFIX = 'psysonic2-';
export type EntityShareKind = 'track' | 'album' | 'artist';
export type SharePayloadV1 =
| { srv: string; k: EntityShareKind; id: string }
| { srv: string; k: 'queue'; ids: string[] };
export function normalizeShareServerUrl(url: string): string {
const t = url.trim();
if (!t) return '';
const withScheme = t.startsWith('http') ? t : `http://${t}`;
return withScheme.replace(/\/$/, '');
}
function utf8ToBase64Url(s: string): string {
const bytes = new TextEncoder().encode(s);
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function base64UrlToUtf8(s: string): string {
let b64 = s.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new TextDecoder().decode(bytes);
}
function isEntityKind(k: unknown): k is EntityShareKind {
return k === 'track' || k === 'album' || k === 'artist';
}
export function encodeSharePayload(payload: SharePayloadV1): string {
const srvNorm = normalizeShareServerUrl(payload.srv);
const body =
payload.k === 'queue'
? JSON.stringify({
v: 1,
srv: srvNorm,
k: 'queue',
ids: payload.ids.map(id => String(id).trim()).filter(Boolean),
})
: JSON.stringify({
v: 1,
srv: srvNorm,
k: payload.k,
id: String(payload.id).trim(),
});
return PSYSONIC_SHARE_PREFIX + utf8ToBase64Url(body);
}
export function decodeSharePayloadFromText(text: string): SharePayloadV1 | null {
const idx = text.indexOf(PSYSONIC_SHARE_PREFIX);
if (idx < 0) return null;
const after = text.slice(idx + PSYSONIC_SHARE_PREFIX.length);
const token = after.match(/^([A-Za-z0-9_-]+)/)?.[1];
if (!token) return null;
try {
const raw = JSON.parse(base64UrlToUtf8(token)) as Record<string, unknown>;
if (raw.v !== 1) return null;
const srv = typeof raw.srv === 'string' ? normalizeShareServerUrl(raw.srv) : '';
if (!srv) return null;
const k = raw.k;
if (k === 'queue') {
const idsRaw = raw.ids;
if (!Array.isArray(idsRaw) || idsRaw.length === 0) return null;
const ids = idsRaw.map(x => (typeof x === 'string' ? x.trim() : '')).filter(Boolean);
if (ids.length === 0) return null;
return { srv, k: 'queue', ids };
}
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
if (!id || !isEntityKind(k)) return null;
return { srv, k, id };
} catch {
return null;
}
}
export function findServerIdForShareUrl(servers: ServerProfile[], shareSrv: string): string | null {
const norm = normalizeShareServerUrl(shareSrv);
const hit = servers.find(s => normalizeShareServerUrl(s.url) === norm);
return hit?.id ?? null;
}