Files
Psychotoxical-psysonic/src/utils/shareLink.ts
T
Frank Stellmacher 4d564e5016 refactor(auth): E.43 — extract authStore types + defaults + helpers (#608)
First slice of the authStore split. Pure code-move: no behaviour change.

- `authStoreTypes.ts` — `ServerProfile`, `AuthState`, plus all union
  types (`SeekbarStyle`, `LoggingMode`, `NormalizationEngine`,
  `DiscordCoverSource`, `LoudnessLufsPreset`, `LyricsSourceId`,
  `LyricsSourceConfig`, `TrackPreviewLocation`, `TrackPreviewLocations`).
- `authStoreDefaults.ts` — `LOUDNESS_LUFS_PRESETS`,
  `DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB`,
  `TRACK_PREVIEW_LOCATIONS`, `DEFAULT_TRACK_PREVIEW_LOCATIONS`,
  `DEFAULT_LYRICS_SOURCES`, `MIX_MIN_RATING_FILTER_MAX_STARS`,
  `RANDOM_MIX_SIZE_OPTIONS`.
- `authStoreHelpers.ts` — `generateId`,
  `sanitizeLoudnessLufsPreset`, `sanitizeLoudnessPreAnalysisFromStorage`,
  `clampMixFilterMinStars`, `clampRandomMixSize`,
  `clampSkipStarThreshold`, `skipStarCountStorageKey`,
  `sanitizeSkipStarCounts`.

12 external call sites migrated to direct imports from the new
modules (no re-export shims left in authStore.ts — applies the
[feedback_prevent_god_modules] rule 5: avoid re-export debt).

authStore.ts: 889 → 518 LOC (−371).
2026-05-12 23:22:28 +02:00

132 lines
4.8 KiB
TypeScript

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