refactor(orbit): switch share link to psysonic2- magic string format

Aligns orbit invites with the existing magic-string family (psysonic1- for
server invites, psysonic2- for library shares) by folding orbit into the
psysonic2- payload as a new k:'orbit' variant. The JSON body is
intentionally extendable so future layers (passwords, permissions,
invite expiry) can be added without a format migration.

- SharePayloadV1 split into EntitySharePayloadV1 + OrbitSharePayloadV1
- decodeSharePayloadFromText filters orbit out; orbit has its own decoder
- applySharePastePayload param narrowed to EntitySharePayloadV1
- buildOrbitShareLink / parseOrbitShareLink kept as thin wrappers
- slug parameter dropped (magic string is opaque, so the slug was
  cosmetic-only in the old URL form); slugifyOrbitName removed as dead code
- joinModalLinkPlaceholder updated in all 8 locales

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-24 23:59:37 +02:00
parent 682fe99bba
commit 0d18d5dfa9
12 changed files with 84 additions and 79 deletions
+2 -2
View File
@@ -3,7 +3,7 @@ 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 { findServerIdForShareUrl, type EntitySharePayloadV1 } from './shareLink';
import { showToast } from './toast';
const RESOLVE_QUEUE_CHUNK = 12;
@@ -14,7 +14,7 @@ const RESOLVE_QUEUE_CHUNK = 12;
* the payload was already decoded successfully.
*/
export async function applySharePastePayload(
payload: SharePayloadV1,
payload: EntitySharePayloadV1,
navigate: NavigateFunction,
t: TFunction,
): Promise<void> {
+13 -50
View File
@@ -10,6 +10,7 @@ import {
import { useAuthStore } from '../store/authStore';
import { useOrbitStore } from '../store/orbitStore';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { encodeSharePayload, decodeOrbitSharePayloadFromText } from './shareLink';
import {
makeInitialOrbitState,
orbitOutboxPlaylistName,
@@ -44,21 +45,6 @@ export function generateSessionId(): string {
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
}
/**
* Turn a human session name into a URL-safe slug. Ignores non-ASCII
* characters so the output is stable across locales and safe in a
* `psysonic2://` link. Returns an empty string for names that slugify
* to nothing — callers should fall back to a slug-less link in that case.
*/
export function slugifyOrbitName(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
}
// ── Serialisation ───────────────────────────────────────────────────────
/**
@@ -307,8 +293,6 @@ export async function updateOrbitSettings(patch: Partial<import('../api/orbit').
// ── Share link ──────────────────────────────────────────────────────────
export const ORBIT_SHARE_SCHEME = 'psysonic2://orbit/';
export interface OrbitShareLink {
/** Base URL of the Navidrome server (decoded). */
serverBase: string;
@@ -317,42 +301,21 @@ export interface OrbitShareLink {
}
/**
* Parse a `psysonic2://orbit/<server-b64>/<sid>` link. Returns null on any
* shape mismatch — the caller decides what to do (show error toast etc.).
* Accepts both the `psysonic2://` prefix and a bare string if the OS-level
* handler has already stripped the scheme.
* Parse an orbit invite from pasted text. Accepts the magic-string format
* `psysonic2-<base64url-json>` (same prefix family as library shares and
* server invites). The caller decides what to do on null (show toast, etc.).
*/
export function parseOrbitShareLink(url: string): OrbitShareLink | null {
if (!url) return null;
const stripped = url.startsWith(ORBIT_SHARE_SCHEME)
? url.slice(ORBIT_SHARE_SCHEME.length)
: url.startsWith('orbit/') ? url.slice('orbit/'.length) : null;
if (stripped == null) return null;
const slash = stripped.indexOf('/');
if (slash <= 0) return null;
const serverB64 = stripped.slice(0, slash);
const tail = stripped.slice(slash + 1).replace(/\/+$/, '');
// Tail is either `<sid>` or `<slug>-<sid>` — the SID is always the
// terminal 8-hex group. The slug is purely cosmetic for the sender.
const m = tail.match(/(?:^|-)([0-9a-f]{8})$/i);
if (!m) return null;
const sid = m[1].toLowerCase();
let serverBase: string;
try {
serverBase = atob(serverB64);
} catch { return null; }
try { new URL(serverBase); } catch { return null; }
return { serverBase, sid };
export function parseOrbitShareLink(text: string): OrbitShareLink | null {
if (!text) return null;
const payload = decodeOrbitSharePayloadFromText(text);
if (!payload) return null;
try { new URL(payload.srv); } catch { return null; }
return { serverBase: payload.srv, sid: payload.sid };
}
/**
* Build a share link for a live session. When `slug` is provided (and
* non-empty) it is prepended to the SID for a friendlier-looking URL
* — the parser strips it on the receiving side.
*/
export function buildOrbitShareLink(serverBase: string, sid: string, slug?: string): string {
const tail = slug && slug.length > 0 ? `${slug}-${sid}` : sid;
return `${ORBIT_SHARE_SCHEME}${btoa(serverBase)}/${tail}`;
/** Build an orbit invite magic string for a live session. */
export function buildOrbitShareLink(serverBase: string, sid: string): string {
return encodeSharePayload({ srv: serverBase, k: 'orbit', sid });
}
// ── Playlist lookup ─────────────────────────────────────────────────────
+59 -16
View File
@@ -5,10 +5,17 @@ export const PSYSONIC_SHARE_PREFIX = 'psysonic2-';
export type EntityShareKind = 'track' | 'album' | 'artist';
export type SharePayloadV1 =
/** Entity / queue shares — what {@link applySharePastePayload} dispatches on. */
export type EntitySharePayloadV1 =
| { srv: string; k: EntityShareKind; id: string }
| { srv: string; k: 'queue'; ids: string[] };
/** Orbit invite — session id + originating server. Decoded separately so that
* entity-share consumers can't accidentally receive an orbit payload. */
export type OrbitSharePayloadV1 = { srv: string; k: 'orbit'; sid: string };
export type SharePayloadV1 = EntitySharePayloadV1 | OrbitSharePayloadV1;
export function normalizeShareServerUrl(url: string): string {
const t = url.trim();
if (!t) return '';
@@ -38,24 +45,38 @@ function isEntityKind(k: unknown): k is EntityShareKind {
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(),
});
let body: string;
if (payload.k === 'queue') {
body = JSON.stringify({
v: 1,
srv: srvNorm,
k: 'queue',
ids: payload.ids.map(id => String(id).trim()).filter(Boolean),
});
} else if (payload.k === 'orbit') {
body = JSON.stringify({
v: 1,
srv: srvNorm,
k: 'orbit',
sid: String(payload.sid).trim(),
});
} else {
body = 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 {
/**
* Decode an entity / queue share from pasted text. Returns null for orbit
* payloads (use {@link decodeOrbitSharePayloadFromText}) — so entity-share
* consumers can't be fed an orbit invite by accident.
*/
export function decodeSharePayloadFromText(text: string): EntitySharePayloadV1 | null {
const idx = text.indexOf(PSYSONIC_SHARE_PREFIX);
if (idx < 0) return null;
const after = text.slice(idx + PSYSONIC_SHARE_PREFIX.length);
@@ -67,6 +88,7 @@ export function decodeSharePayloadFromText(text: string): SharePayloadV1 | null
const srv = typeof raw.srv === 'string' ? normalizeShareServerUrl(raw.srv) : '';
if (!srv) return null;
const k = raw.k;
if (k === 'orbit') return null;
if (k === 'queue') {
const idsRaw = raw.ids;
if (!Array.isArray(idsRaw) || idsRaw.length === 0) return null;
@@ -87,3 +109,24 @@ export function findServerIdForShareUrl(servers: ServerProfile[], shareSrv: stri
const hit = servers.find(s => normalizeShareServerUrl(s.url) === norm);
return hit?.id ?? null;
}
/** Decode an orbit invite from pasted text. Returns null for entity / queue shares. */
export function decodeOrbitSharePayloadFromText(text: string): OrbitSharePayloadV1 | 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;
if (raw.k !== 'orbit') return null;
const srv = typeof raw.srv === 'string' ? normalizeShareServerUrl(raw.srv) : '';
if (!srv) return null;
const sid = typeof raw.sid === 'string' ? raw.sid.trim().toLowerCase() : '';
if (!/^[0-9a-f]{8}$/.test(sid)) return null;
return { srv, k: 'orbit', sid };
} catch {
return null;
}
}