refactor(utils): group utils/ files into topic folders (Phase L, part 1) (#689)

111 of 122 top-level src/utils/ files move into 16 topic folders (audio,
cache, cover, share, server, playback, playlist, deviceSync, waveform,
mix, format, export, changelog, ui, perf, componentHelpers). True
singletons with no cluster stay at the utils/ root.

Pure file-move: a path-aware codemod rewrote 539 relative-import
specifiers across 275 files; no logic touched. The hot-path coverage
gate list (.github/frontend-hot-path-files.txt) is updated to the new
paths for the 11 gated utils files — a mechanical consequence of the
move, not a CI change. tsc is green.
This commit is contained in:
Frank Stellmacher
2026-05-14 14:27:44 +02:00
committed by GitHub
parent 2409a1fec8
commit 7a7a9f5e6b
324 changed files with 551 additions and 551 deletions
+131
View File
@@ -0,0 +1,131 @@
import { getArtist } from '../../api/subsonicArtists';
import { getAlbum, getSong } from '../../api/subsonicLibrary';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { songToTrack } from '../playback/songToTrack';
import type { NavigateFunction } from 'react-router-dom';
import type { TFunction } from 'i18next';
import { useAuthStore } from '../../store/authStore';
import { usePlayerStore } from '../../store/playerStore';
import { findServerIdForShareUrl, type EntitySharePayloadV1 } from './shareLink';
import { showToast } from '../ui/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: EntitySharePayloadV1,
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 === 'composer') {
// Same id space as artists (Subsonic / Navidrome use one id pool for
// every participant role), so getArtist still validates the entity —
// the difference is which view we navigate to.
try {
await getArtist(payload.id);
} catch {
showToast(t('sharePaste.composerUnavailable'), 5000, 'error');
return;
}
navigate(`/composer/${payload.id}`);
showToast(t('sharePaste.openedComposer'), 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');
}
}
@@ -0,0 +1,95 @@
/**
* `copyEntityShareLink` composition tests (Phase F3).
*
* Thin wrapper around `getBaseUrl` + `encodeSharePayload` +
* `copyTextToClipboard` — tests pin its trimming + guard semantics so a
* refactor doesn't silently make empty-server / empty-id calls write
* garbage to the clipboard.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { copyEntityShareLink } from './copyEntityShareLink';
import {
decodeSharePayloadFromText,
PSYSONIC_SHARE_PREFIX,
} from './shareLink';
import { useAuthStore } from '@/store/authStore';
import { resetAuthStore } from '@/test/helpers/storeReset';
beforeEach(() => {
resetAuthStore();
// The clipboard mock from src/test/mocks/browser.ts is installed globally;
// each test starts with a clean writeText state via resetBrowserMocks.
vi.mocked(navigator.clipboard.writeText).mockResolvedValue();
});
describe('copyEntityShareLink', () => {
it('writes a psysonic2-prefixed payload that round-trips to the original entity', async () => {
const sid = useAuthStore.getState().addServer({
name: 'Home', url: 'https://music.example.com', username: 'u', password: 'p',
});
useAuthStore.getState().setActiveServer(sid);
const ok = await copyEntityShareLink('track', 'tr-1');
expect(ok).toBe(true);
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
const written = vi.mocked(navigator.clipboard.writeText).mock.calls[0]?.[0] as string;
expect(written.startsWith(PSYSONIC_SHARE_PREFIX)).toBe(true);
expect(decodeSharePayloadFromText(written)).toEqual({
srv: 'https://music.example.com',
k: 'track',
id: 'tr-1',
});
});
it('returns false when no server is active (empty base URL)', async () => {
// No addServer / setActiveServer → getBaseUrl() returns '' → bail.
const ok = await copyEntityShareLink('album', 'al-1');
expect(ok).toBe(false);
expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
});
it('returns false on empty or whitespace-only id', async () => {
const sid = useAuthStore.getState().addServer({
name: 'Home', url: 'https://x.test', username: 'u', password: 'p',
});
useAuthStore.getState().setActiveServer(sid);
expect(await copyEntityShareLink('artist', '')).toBe(false);
expect(await copyEntityShareLink('artist', ' ')).toBe(false);
expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
});
it('trims surrounding whitespace from the id before encoding', async () => {
const sid = useAuthStore.getState().addServer({
name: 'Home', url: 'https://x.test', username: 'u', password: 'p',
});
useAuthStore.getState().setActiveServer(sid);
await copyEntityShareLink('composer', ' co-9 ');
const written = vi.mocked(navigator.clipboard.writeText).mock.calls[0]?.[0] as string;
expect(decodeSharePayloadFromText(written)).toEqual({
srv: 'https://x.test',
k: 'composer',
id: 'co-9',
});
});
it('propagates a clipboard-failure return value (false)', async () => {
const sid = useAuthStore.getState().addServer({
name: 'Home', url: 'https://x.test', username: 'u', password: 'p',
});
useAuthStore.getState().setActiveServer(sid);
vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce(new Error('blocked'));
// execCommand fallback also fails.
const originalExec = document.execCommand;
document.execCommand = vi.fn(() => false) as unknown as typeof document.execCommand;
const ok = await copyEntityShareLink('album', 'al-1');
expect(ok).toBe(false);
document.execCommand = originalExec;
});
});
+10
View File
@@ -0,0 +1,10 @@
import { useAuthStore } from '../../store/authStore';
import { encodeSharePayload, type EntityShareKind } from './shareLink';
import { copyTextToClipboard } from '../server/serverMagicString';
/** Copies a track / album / artist / composer 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() }));
}
+270
View File
@@ -0,0 +1,270 @@
import { describe, expect, it } from 'vitest';
import {
PSYSONIC_SHARE_PREFIX,
decodeOrbitSharePayloadFromText,
decodeSharePayloadFromText,
encodeSharePayload,
findServerIdForShareUrl,
normalizeShareServerUrl,
} from './shareLink';
import { decodeServerMagicString, encodeServerMagicString, SERVER_MAGIC_STRING_PREFIX } from '../server/serverMagicString';
import { makeServer } from '@/test/helpers/factories';
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();
});
});
describe('normalizeShareServerUrl', () => {
it('returns empty string for whitespace input', () => {
expect(normalizeShareServerUrl(' ')).toBe('');
expect(normalizeShareServerUrl('')).toBe('');
});
it('strips trailing slashes', () => {
expect(normalizeShareServerUrl('https://x.example/')).toBe('https://x.example');
expect(normalizeShareServerUrl('https://x.example')).toBe('https://x.example');
});
it('prepends http:// when no scheme is given', () => {
expect(normalizeShareServerUrl('192.168.1.10:4533')).toBe('http://192.168.1.10:4533');
expect(normalizeShareServerUrl('music.local')).toBe('http://music.local');
});
it('leaves https URLs unchanged (modulo trailing slash)', () => {
expect(normalizeShareServerUrl('https://music.example.com')).toBe('https://music.example.com');
});
});
describe('encodeSharePayload — entity kinds', () => {
it('round-trips a track share', () => {
const encoded = encodeSharePayload({ srv: 'https://x.example', k: 'track', id: 't-1' });
expect(decodeSharePayloadFromText(encoded)).toEqual({
srv: 'https://x.example',
k: 'track',
id: 't-1',
});
});
it('round-trips an artist share', () => {
const encoded = encodeSharePayload({ srv: 'https://x.example', k: 'artist', id: 'ar-1' });
expect(decodeSharePayloadFromText(encoded)).toEqual({
srv: 'https://x.example',
k: 'artist',
id: 'ar-1',
});
});
it('round-trips a composer share', () => {
const encoded = encodeSharePayload({ srv: 'https://x.example', k: 'composer', id: 'co-1' });
expect(decodeSharePayloadFromText(encoded)).toEqual({
srv: 'https://x.example',
k: 'composer',
id: 'co-1',
});
});
it('trims whitespace in queue ids and drops empty ones', () => {
const encoded = encodeSharePayload({
srv: 'https://x.example',
k: 'queue',
ids: [' a ', '', 'b', ' '],
});
expect(decodeSharePayloadFromText(encoded)).toEqual({
srv: 'https://x.example',
k: 'queue',
ids: ['a', 'b'],
});
});
});
describe('decodeSharePayloadFromText — rejection paths', () => {
it('rejects text with no prefix', () => {
expect(decodeSharePayloadFromText('just text')).toBeNull();
});
it('rejects bare prefix with no token', () => {
expect(decodeSharePayloadFromText(`a ${PSYSONIC_SHARE_PREFIX} b`)).toBeNull();
});
it('rejects a payload with the wrong version', () => {
const body = JSON.stringify({ v: 2, srv: 'https://x.example', k: 'track', id: 't' });
const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
expect(decodeSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull();
});
it('rejects a payload with non-string srv', () => {
const body = JSON.stringify({ v: 1, srv: 42, k: 'track', id: 't' });
const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
expect(decodeSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull();
});
it('rejects an unknown entity kind', () => {
const body = JSON.stringify({ v: 1, srv: 'https://x.example', k: 'playlist', id: 'pl-1' });
const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
expect(decodeSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull();
});
it('rejects an entity payload with an empty id', () => {
const body = JSON.stringify({ v: 1, srv: 'https://x.example', k: 'track', id: ' ' });
const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
expect(decodeSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull();
});
it('rejects a queue payload with no ids', () => {
const body = JSON.stringify({ v: 1, srv: 'https://x.example', k: 'queue', ids: [] });
const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
expect(decodeSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull();
});
it('rejects a queue payload where ids is not an array', () => {
const body = JSON.stringify({ v: 1, srv: 'https://x.example', k: 'queue', ids: 'a,b' });
const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
expect(decodeSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull();
});
it('rejects a queue payload whose ids are all whitespace', () => {
const body = JSON.stringify({ v: 1, srv: 'https://x.example', k: 'queue', ids: [' ', ''] });
const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
expect(decodeSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull();
});
it('rejects malformed base64', () => {
expect(decodeSharePayloadFromText(`${PSYSONIC_SHARE_PREFIX}!!!notbase64!!!`)).toBeNull();
});
it('refuses to surface an orbit payload via the entity decoder', () => {
const orbit = encodeSharePayload({ srv: 'https://x.example', k: 'orbit', sid: 'abcd1234' });
expect(decodeSharePayloadFromText(orbit)).toBeNull();
});
});
describe('decodeOrbitSharePayloadFromText', () => {
it('round-trips an orbit invite', () => {
const encoded = encodeSharePayload({ srv: 'https://x.example', k: 'orbit', sid: 'abcd1234' });
expect(decodeOrbitSharePayloadFromText(`come to orbit: ${encoded}`)).toEqual({
srv: 'https://x.example',
k: 'orbit',
sid: 'abcd1234',
});
});
it('lowercases the session id', () => {
const body = JSON.stringify({ v: 1, srv: 'https://x.example', k: 'orbit', sid: 'ABCD1234' });
const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
expect(decodeOrbitSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toEqual({
srv: 'https://x.example',
k: 'orbit',
sid: 'abcd1234',
});
});
it('rejects text with no prefix', () => {
expect(decodeOrbitSharePayloadFromText('hello')).toBeNull();
});
it('rejects bare prefix with no token', () => {
expect(decodeOrbitSharePayloadFromText(`a ${PSYSONIC_SHARE_PREFIX} b`)).toBeNull();
});
it('rejects a non-orbit payload kind', () => {
const encoded = encodeSharePayload({ srv: 'https://x.example', k: 'track', id: 't' });
expect(decodeOrbitSharePayloadFromText(encoded)).toBeNull();
});
it('rejects an invalid version', () => {
const body = JSON.stringify({ v: 9, srv: 'https://x.example', k: 'orbit', sid: 'abcd1234' });
const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
expect(decodeOrbitSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull();
});
it('rejects an empty server', () => {
const body = JSON.stringify({ v: 1, srv: '', k: 'orbit', sid: 'abcd1234' });
const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
expect(decodeOrbitSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull();
});
it('rejects a session id that is not 8 hex characters', () => {
const sids = ['', 'abcd', 'abcd1234e', 'zzzz1234'];
for (const sid of sids) {
const body = JSON.stringify({ v: 1, srv: 'https://x.example', k: 'orbit', sid });
const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
expect(decodeOrbitSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull();
}
});
it('rejects malformed base64', () => {
expect(decodeOrbitSharePayloadFromText(`${PSYSONIC_SHARE_PREFIX}!!!`)).toBeNull();
});
});
describe('findServerIdForShareUrl', () => {
it('matches by normalized URL', () => {
const a = makeServer({ id: 'a', url: 'https://music.example.com/' });
const b = makeServer({ id: 'b', url: 'http://other.local' });
expect(findServerIdForShareUrl([a, b], 'https://music.example.com')).toBe('a');
expect(findServerIdForShareUrl([a, b], 'http://other.local/')).toBe('b');
});
it('returns null when no server matches', () => {
const a = makeServer({ id: 'a', url: 'https://music.example.com' });
expect(findServerIdForShareUrl([a], 'https://elsewhere.example')).toBeNull();
});
it('returns null on an empty server list', () => {
expect(findServerIdForShareUrl([], 'https://x.example')).toBeNull();
});
});
+131
View File
@@ -0,0 +1,131 @@
import type { ServerProfile } from '../../store/authStoreTypes';
/** 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' | 'composer';
/** 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 '';
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' || k === 'composer';
}
export function encodeSharePayload(payload: SharePayloadV1): string {
const srvNorm = normalizeShareServerUrl(payload.srv);
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);
}
/**
* 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);
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 === 'orbit') return null;
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;
}
/** 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;
}
}