mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
committed by
GitHub
parent
2409a1fec8
commit
7a7a9f5e6b
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Masks Subsonic wire-auth query params so debug logs are safe to copy.
|
||||
* (`t` salt, `s` token hash, `p` password when present.)
|
||||
*/
|
||||
export function redactSubsonicUrlForLog(url: string): string {
|
||||
if (!url || !url.includes('stream.view')) return url;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
// Placeholder must stay URL-safe (no `<>` — URLSearchParams percent-encodes them).
|
||||
for (const k of ['t', 's', 'p'] as const) {
|
||||
if (u.searchParams.has(k)) u.searchParams.set(k, 'REDACTED');
|
||||
}
|
||||
return u.toString();
|
||||
} catch {
|
||||
return url.replace(/([?&])(t|s|p)=([^&]*)/gi, (_m, sep: string, key: string) => `${sep}${key}=REDACTED`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { serverListDisplayLabel, shortHostFromServerUrl } from './serverDisplayName';
|
||||
|
||||
function srv(p: Partial<ServerProfile> & Pick<ServerProfile, 'id'>): ServerProfile {
|
||||
return {
|
||||
name: '',
|
||||
url: 'https://example.com',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
...p,
|
||||
};
|
||||
}
|
||||
|
||||
describe('shortHostFromServerUrl', () => {
|
||||
it('strips https and path', () => {
|
||||
expect(shortHostFromServerUrl('https://music.one.com/v1')).toBe('music.one.com');
|
||||
});
|
||||
it('keeps port', () => {
|
||||
expect(shortHostFromServerUrl('http://127.0.0.1:4533')).toBe('127.0.0.1:4533');
|
||||
});
|
||||
});
|
||||
|
||||
describe('serverListDisplayLabel', () => {
|
||||
it('uses short host when name empty', () => {
|
||||
const a = srv({ id: '1', url: 'https://a.com', username: 'u', password: 'p', name: '' });
|
||||
expect(serverListDisplayLabel(a, [a])).toBe('a.com');
|
||||
});
|
||||
it('disambiguates duplicate names', () => {
|
||||
const a = srv({ id: '1', url: 'https://music.one.com', username: 'alice', password: 'p', name: 'Home' });
|
||||
const b = srv({ id: '2', url: 'https://other.net', username: 'bob', password: 'p', name: 'Home' });
|
||||
const all = [a, b];
|
||||
expect(serverListDisplayLabel(a, all)).toBe('alice@music.one.com');
|
||||
expect(serverListDisplayLabel(b, all)).toBe('bob@other.net');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
/** Host (+ port) from a server base URL, e.g. `https://music.one.com/foo` → `music.one.com`. */
|
||||
export function shortHostFromServerUrl(urlRaw: string): string {
|
||||
const t = urlRaw.trim();
|
||||
if (!t) return '';
|
||||
try {
|
||||
const u = new URL(t.includes('://') ? t : `https://${t}`);
|
||||
return u.host;
|
||||
} catch {
|
||||
return t
|
||||
.replace(/^https?:\/\//i, '')
|
||||
.split('/')[0]
|
||||
?.split('?')[0]
|
||||
?.trim() ?? t;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for server lists and chrome: if several servers share the same effective name,
|
||||
* show `username@host` so entries stay distinguishable.
|
||||
*/
|
||||
export function serverListDisplayLabel(server: ServerProfile, all: ServerProfile[]): string {
|
||||
const nameTrim = (server.name || '').trim();
|
||||
const shortHost = shortHostFromServerUrl(server.url);
|
||||
const key = nameTrim || shortHost;
|
||||
const collisions = all.filter(s => {
|
||||
const nt = (s.name || '').trim();
|
||||
const sh = shortHostFromServerUrl(s.url);
|
||||
return (nt || sh) === key;
|
||||
});
|
||||
if (collisions.length < 2) {
|
||||
return nameTrim || shortHost || server.url.trim();
|
||||
}
|
||||
return `${server.username}@${shortHost}`;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
SERVER_MAGIC_STRING_PREFIX,
|
||||
DECODED_PASSWORD_VISUAL_MASK,
|
||||
copyTextToClipboard,
|
||||
decodeServerMagicString,
|
||||
decodeServerMagicStringFromText,
|
||||
encodeServerMagicString,
|
||||
} from './serverMagicString';
|
||||
|
||||
describe('DECODED_PASSWORD_VISUAL_MASK', () => {
|
||||
it('has fixed length independent of real passwords', () => {
|
||||
expect(DECODED_PASSWORD_VISUAL_MASK.length).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serverMagicString', () => {
|
||||
it('round-trips url, username, password', () => {
|
||||
const original = {
|
||||
url: 'https://music.example.com',
|
||||
username: 'alice',
|
||||
password: 's3cret!',
|
||||
};
|
||||
const encoded = encodeServerMagicString(original);
|
||||
expect(encoded.startsWith(SERVER_MAGIC_STRING_PREFIX)).toBe(true);
|
||||
expect(decodeServerMagicString(encoded)).toEqual(original);
|
||||
});
|
||||
|
||||
it('round-trips optional name', () => {
|
||||
const original = {
|
||||
url: 'http://127.0.0.1:4533',
|
||||
username: 'bob',
|
||||
password: 'x',
|
||||
name: 'Home',
|
||||
};
|
||||
const encoded = encodeServerMagicString(original);
|
||||
expect(decodeServerMagicString(encoded)).toEqual(original);
|
||||
});
|
||||
|
||||
it('drops a name that becomes empty after trim', () => {
|
||||
const encoded = encodeServerMagicString({
|
||||
url: 'https://x.example',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
name: ' ',
|
||||
});
|
||||
const decoded = decodeServerMagicString(encoded);
|
||||
expect(decoded?.name).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects invalid input', () => {
|
||||
expect(decodeServerMagicString('')).toBeNull();
|
||||
expect(decodeServerMagicString('nope')).toBeNull();
|
||||
expect(decodeServerMagicString(`${SERVER_MAGIC_STRING_PREFIX}%%%`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an empty payload after the prefix', () => {
|
||||
expect(decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX)).toBeNull();
|
||||
expect(decodeServerMagicString(`${SERVER_MAGIC_STRING_PREFIX} `)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a payload that is not JSON', () => {
|
||||
// valid base64url of "not-json" → JSON.parse throws
|
||||
const garbage = `${SERVER_MAGIC_STRING_PREFIX}bm90LWpzb24`;
|
||||
expect(decodeServerMagicString(garbage)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a payload with the wrong version', () => {
|
||||
const wrongVersion = btoa(JSON.stringify({ v: 2, url: 'https://x', u: 'u', w: 'p' }))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
expect(decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + wrongVersion)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a payload missing url or username', () => {
|
||||
const noUrl = btoa(JSON.stringify({ v: 1, url: '', u: 'u', w: 'p' }))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
expect(decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + noUrl)).toBeNull();
|
||||
const noUser = btoa(JSON.stringify({ v: 1, url: 'https://x', u: '', w: 'p' }))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
expect(decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + noUser)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a payload where url/username are not strings', () => {
|
||||
const wrongTypes = btoa(JSON.stringify({ v: 1, url: 42, u: ['a'], w: 'p' }))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
expect(decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + wrongTypes)).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();
|
||||
});
|
||||
|
||||
it('rejects text that contains only the bare prefix', () => {
|
||||
expect(decodeServerMagicStringFromText(`prefix only: ${SERVER_MAGIC_STRING_PREFIX} done`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyTextToClipboard', () => {
|
||||
const originalExecCommand = document.execCommand;
|
||||
|
||||
beforeEach(() => {
|
||||
// setup.ts already installs a clipboard mock — start each test fresh.
|
||||
vi.mocked(navigator.clipboard.writeText).mockResolvedValue();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.execCommand = originalExecCommand;
|
||||
});
|
||||
|
||||
it('uses the modern clipboard API on success', async () => {
|
||||
const ok = await copyTextToClipboard('hello');
|
||||
expect(ok).toBe(true);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('hello');
|
||||
});
|
||||
|
||||
it('falls back to execCommand("copy") when clipboard API rejects', async () => {
|
||||
vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce(new Error('blocked'));
|
||||
document.execCommand = vi.fn(() => true) as unknown as typeof document.execCommand;
|
||||
const ok = await copyTextToClipboard('fallback-text');
|
||||
expect(ok).toBe(true);
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy');
|
||||
});
|
||||
|
||||
it('returns false when both clipboard API and execCommand fail', async () => {
|
||||
vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce(new Error('blocked'));
|
||||
document.execCommand = vi.fn(() => {
|
||||
throw new Error('not allowed');
|
||||
}) as unknown as typeof document.execCommand;
|
||||
const ok = await copyTextToClipboard('x');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the result of execCommand even when it returns false', async () => {
|
||||
vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce(new Error('blocked'));
|
||||
document.execCommand = vi.fn(() => false) as unknown as typeof document.execCommand;
|
||||
const ok = await copyTextToClipboard('x');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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. */
|
||||
export const DECODED_PASSWORD_VISUAL_MASK = '••••••••••';
|
||||
|
||||
export interface ServerMagicPayload {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
/** Optional display name for the saved server entry */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
function utf8ToBase64Url(json: string): string {
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
|
||||
const b64 = btoa(bin);
|
||||
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function base64UrlToUtf8(b64url: string): string {
|
||||
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4));
|
||||
const bin = atob(b64 + pad);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
/** Encode server URL + Subsonic credentials into a single pasteable string. */
|
||||
export function encodeServerMagicString(p: ServerMagicPayload): string {
|
||||
const payload = {
|
||||
v: 1 as const,
|
||||
url: p.url.trim(),
|
||||
u: p.username,
|
||||
w: p.password,
|
||||
...(p.name?.trim() ? { n: p.name.trim() } : {}),
|
||||
};
|
||||
return SERVER_MAGIC_STRING_PREFIX + utf8ToBase64Url(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
const b64 = s.slice(SERVER_MAGIC_STRING_PREFIX.length).trim();
|
||||
if (!b64) return null;
|
||||
let obj: unknown;
|
||||
try {
|
||||
obj = JSON.parse(base64UrlToUtf8(b64));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!obj || typeof obj !== 'object') return null;
|
||||
const o = obj as Record<string, unknown>;
|
||||
if (o.v !== 1) return null;
|
||||
const url = typeof o.url === 'string' ? o.url.trim() : '';
|
||||
const username = typeof o.u === 'string' ? o.u : '';
|
||||
const password = typeof o.w === 'string' ? o.w : '';
|
||||
const name = typeof o.n === 'string' && o.n.trim() ? o.n.trim() : undefined;
|
||||
if (!url || !username) return null;
|
||||
return { url, username, password, name };
|
||||
}
|
||||
|
||||
export async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Fields from Subsonic `ping` / any `subsonic-response` root (Navidrome sets type + serverVersion). */
|
||||
export type SubsonicServerIdentity = {
|
||||
type?: string;
|
||||
serverVersion?: string;
|
||||
openSubsonic?: boolean;
|
||||
};
|
||||
|
||||
/** Result of `getRandomSongs` + `getSimilarSongs` probe (Instant Mix / agent chain). */
|
||||
export type InstantMixProbeResult = 'ok' | 'empty' | 'error' | 'skipped';
|
||||
|
||||
const NAVIDROME_MIN_FOR_PLUGINS: [number, number, number] = [0, 60, 0];
|
||||
|
||||
function parseLeadingSemver(version: string | undefined): [number, number, number] | null {
|
||||
if (!version) return null;
|
||||
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version).trim());
|
||||
if (!m) return null;
|
||||
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
}
|
||||
|
||||
function semverGte(a: [number, number, number], b: [number, number, number]): boolean {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a[i] > b[i]) return true;
|
||||
if (a[i] < b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navidrome version from ping supports the plugin system (≥ 0.60). Unknown `type` stays permissive
|
||||
* until the first successful ping with metadata.
|
||||
*/
|
||||
export function isNavidromeAudiomuseSoftwareEligible(identity: SubsonicServerIdentity | undefined): boolean {
|
||||
if (!identity?.type?.trim()) return true;
|
||||
const t = identity.type.trim().toLowerCase();
|
||||
if (t !== 'navidrome') return false;
|
||||
const parsed = parseLeadingSemver(identity.serverVersion);
|
||||
if (!parsed) return true;
|
||||
return semverGte(parsed, NAVIDROME_MIN_FOR_PLUGINS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to show the per-server AudioMuse (Navidrome plugin) toggle in Settings.
|
||||
* Uses software eligibility from ping plus an optional Instant Mix probe: if the server returns no
|
||||
* similar tracks for several random songs, the row stays hidden (typical when no plugin / no agents).
|
||||
*/
|
||||
export function showAudiomuseNavidromeServerSetting(
|
||||
identity: SubsonicServerIdentity | undefined,
|
||||
instantMixProbe: InstantMixProbeResult | undefined,
|
||||
): boolean {
|
||||
if (!isNavidromeAudiomuseSoftwareEligible(identity)) return false;
|
||||
if (instantMixProbe === 'empty') return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useOrbitStore } from '../../store/orbitStore';
|
||||
import { endOrbitSession, leaveOrbitSession } from '../orbit';
|
||||
|
||||
export async function switchActiveServer(server: ServerProfile): Promise<boolean> {
|
||||
try {
|
||||
const ping = await pingWithCredentials(server.url, server.username, server.password);
|
||||
if (!ping.ok) return false;
|
||||
|
||||
// Tear down any active Orbit session before we actually switch. The
|
||||
// session's playlists live on the *old* server — once we flip the
|
||||
// active server, every API call from the orbit hooks would hit the
|
||||
// wrong backend, heartbeats would silently fail, and the next
|
||||
// app-start cleanup would prune the still-live session as stale.
|
||||
// Capped at 1.5 s so a slow network doesn't freeze the UI.
|
||||
const role = useOrbitStore.getState().role;
|
||||
if (role === 'host' || role === 'guest') {
|
||||
const teardown = role === 'host' ? endOrbitSession() : leaveOrbitSession();
|
||||
await Promise.race([
|
||||
teardown.catch(() => {}),
|
||||
new Promise<void>(r => setTimeout(r, 1500)),
|
||||
]);
|
||||
// Ensure local store is idle even if the remote call timed out.
|
||||
useOrbitStore.getState().reset();
|
||||
}
|
||||
|
||||
const identity = {
|
||||
type: ping.type,
|
||||
serverVersion: ping.serverVersion,
|
||||
openSubsonic: ping.openSubsonic,
|
||||
};
|
||||
const auth = useAuthStore.getState();
|
||||
auth.setSubsonicServerIdentity(server.id, identity);
|
||||
scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity);
|
||||
auth.setActiveServer(server.id);
|
||||
auth.setLoggedIn(true);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user