refactor(lib): extract clean server + navigation infra to lib/ (decouple-knot files stay in utils/ pending seam)

This commit is contained in:
Psychotoxical
2026-06-30 20:44:34 +02:00
parent 7d98e649da
commit 13142e6ae3
123 changed files with 191 additions and 191 deletions
+2 -2
View File
@@ -9,8 +9,8 @@
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { useAuthStore } from '@/store/authStore';
import { serverIndexKeyFromUrl } from '@/utils/server/serverIndexKey';
import { resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
import { serverIndexKeyFromUrl } from '@/lib/server/serverIndexKey';
import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
// ── DTO mirrors (camelCase, matching the Rust `#[serde(rename_all = "camelCase")]`) ─
+1 -1
View File
@@ -7,7 +7,7 @@ vi.mock('@/lib/api/subsonicOpenSubsonic', () => ({
import { fetchOpenSubsonicExtensionsWithCredentials } from '@/lib/api/subsonicOpenSubsonic';
import { scheduleInstantMixProbeForServer } from '@/lib/api/subsonic';
import { useAuthStore } from '@/store/authStore';
import type { SubsonicServerIdentity } from '@/utils/server/subsonicServerIdentity';
import type { SubsonicServerIdentity } from '@/lib/server/subsonicServerIdentity';
const fetchMock = vi.mocked(fetchOpenSubsonicExtensionsWithCredentials);
const SID = 'srv-probe';
+3 -3
View File
@@ -2,12 +2,12 @@ import axios from 'axios';
import md5 from 'md5';
import { useAuthStore } from '@/store/authStore';
import type { ServerProfile } from '@/store/authStoreTypes';
import { headersForServerRequest } from '@/utils/server/serverHttpHeaders';
import { findServerByIdOrIndexKey } from '@/utils/server/serverLookup';
import { headersForServerRequest } from '@/lib/server/serverHttpHeaders';
import { findServerByIdOrIndexKey } from '@/lib/server/serverLookup';
import {
type InstantMixProbeResult,
type SubsonicServerIdentity,
} from '@/utils/server/subsonicServerIdentity';
} from '@/lib/server/subsonicServerIdentity';
import { fetchOpenSubsonicExtensionsWithCredentials } from '@/lib/api/subsonicOpenSubsonic';
import { buildCapabilityContext } from '@/serverCapabilities/context';
import {
+3 -3
View File
@@ -3,9 +3,9 @@ import md5 from 'md5';
import { version } from '@/../package.json';
import { useAuthStore } from '@/store/authStore';
import type { ServerProfile } from '@/store/authStoreTypes';
import { connectBaseUrlForServer } from '@/utils/server/serverEndpoint';
import { headersForServerRequest } from '@/utils/server/serverHttpHeaders';
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
import { connectBaseUrlForServer } from '@/lib/server/serverEndpoint';
import { headersForServerRequest } from '@/lib/server/serverHttpHeaders';
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
export const SUBSONIC_CLIENT = `psysonic/${version}`;
+2 -2
View File
@@ -3,8 +3,8 @@ import { coverStorageKeyFromRef } from '@/cover/storageKeys';
import { coverEntryToRef, resolveAlbumCoverEntry } from '@/cover/resolveEntry';
import type { CoverArtTier } from '@/cover/types';
import { useAuthStore } from '@/store/authStore';
import { connectBaseUrlForServer } from '@/utils/server/serverEndpoint';
import { findServerByIdOrIndexKey } from '@/utils/server/serverLookup';
import { connectBaseUrlForServer } from '@/lib/server/serverEndpoint';
import { findServerByIdOrIndexKey } from '@/lib/server/serverLookup';
import { restBaseFromUrl, SUBSONIC_CLIENT, secureRandomSalt } from '@/lib/api/subsonicClient';
function coverArtQueryParams(username: string, password: string, id: string, size: number): URLSearchParams {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { SubsonicServerIdentity } from '@/utils/server/subsonicServerIdentity';
import type { SubsonicServerIdentity } from '@/lib/server/subsonicServerIdentity';
/** OpenSubsonic `ItemGenre` on songs/albums (atomic genres from the server). */
export interface SubsonicItemGenre {
+1 -1
View File
@@ -1,5 +1,5 @@
import type { SubsonicGenre } from '@/lib/api/subsonicTypes';
import { resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
/** Fresh hits skip SQLite entirely. */
const FRESH_TTL_MS = 60 * 60 * 1000;
@@ -7,7 +7,7 @@ import { librarySqlServerId } from '@/api/coverCache';
import { useAuthStore } from '@/store/authStore';
import { useAnalysisStrategyStore } from '@/store/analysisStrategyStore';
import { DEFAULT_ADVANCED_PARALLELISM } from '@/lib/library/analysisStrategy';
import { serverIndexKeyForProfile } from '@/utils/server/serverIndexKey';
import { serverIndexKeyForProfile } from '@/lib/server/serverIndexKey';
const DISABLED_CONFIGURE = {
enabled: false,
+1 -1
View File
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { showToast } from '@/lib/dom/toast';
import { resolveIndexKey, serverIndexKeyForProfile } from '@/utils/server/serverIndexKey';
import { resolveIndexKey, serverIndexKeyForProfile } from '@/lib/server/serverIndexKey';
import {
libraryGetStatus,
librarySyncCancel,
+3 -3
View File
@@ -6,9 +6,9 @@ import { enqueueLibrarySync, queueInitialSyncIfNeeded } from './librarySyncQueue
import type { ServerProfile } from '@/store/authStoreTypes';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { ensureConnectUrlResolved } from '@/utils/server/serverEndpoint';
import { serverIndexKeyForProfile } from '@/utils/server/serverIndexKey';
import { syncServerHttpContextForProfile } from '@/utils/server/syncServerHttpContext';
import { ensureConnectUrlResolved } from '@/lib/server/serverEndpoint';
import { serverIndexKeyForProfile } from '@/lib/server/serverIndexKey';
import { syncServerHttpContextForProfile } from '@/lib/server/syncServerHttpContext';
import { libraryDevEnabled, logLibraryStatus, logLibrarySync, timed } from './libraryDevLog';
export type BindServerResult = 'bound' | 'offline' | 'error';
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Track, QueueItemRef } from '@/lib/media/trackTypes';
import { useAuthStore } from '@/store/authStore';
import { canonicalQueueServerKey } from '@/utils/server/serverIndexKey';
import { resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
import { canonicalQueueServerKey } from '@/lib/server/serverIndexKey';
import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
/**
* Pure server-scope helpers for the shared media model: stamp the owning server
@@ -0,0 +1,151 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
import {
clearAdvancedSearchLeaveSnapshots,
peekPersistedAdvancedSearchLeaveSnapshot,
readAdvancedSearchLeaveSnapshot,
registerAdvancedSearchLeaveScrollProvider,
registerAdvancedSearchSessionProvider,
resolveAdvancedSearchLeaveSnapshot,
saveAdvancedSearchLeaveSnapshot,
} from '@/lib/navigation/advancedSearchScrollSnapshot';
import { useAdvancedSearchSessionStore } from '@/store/advancedSearchSessionStore';
describe('advancedSearchScrollSnapshot', () => {
afterEach(() => {
clearAdvancedSearchLeaveSnapshots();
useAdvancedSearchSessionStore.getState().clearReturnStash();
sessionStorage.clear();
document.body.innerHTML = '';
});
it('persists and peeks leave snapshot in sessionStorage', () => {
const viewport = document.createElement('div');
viewport.id = APP_MAIN_SCROLL_VIEWPORT_ID;
Object.defineProperty(viewport, 'scrollTop', { value: 640, writable: true });
document.body.appendChild(viewport);
saveAdvancedSearchLeaveSnapshot();
expect(peekPersistedAdvancedSearchLeaveSnapshot()).toEqual({
scrollTop: 640,
albumRowScrollLeft: 0,
artistRowScrollLeft: 0,
});
});
it('reads leave snapshot from provider merged with DOM', () => {
const viewport = document.createElement('div');
viewport.id = APP_MAIN_SCROLL_VIEWPORT_ID;
Object.defineProperty(viewport, 'scrollTop', { value: 512, writable: true });
document.body.appendChild(viewport);
const albumGrid = document.createElement('div');
albumGrid.className = 'album-grid';
Object.defineProperty(albumGrid, 'scrollLeft', { value: 80, writable: true });
const row = document.createElement('div');
row.setAttribute('data-advanced-search-album-row', '');
row.appendChild(albumGrid);
document.body.appendChild(row);
const unregister = registerAdvancedSearchLeaveScrollProvider(() => ({
scrollTop: 100,
albumRowScrollLeft: 45,
artistRowScrollLeft: 10,
}));
expect(readAdvancedSearchLeaveSnapshot()).toEqual({
scrollTop: 512,
albumRowScrollLeft: 80,
artistRowScrollLeft: 10,
});
unregister();
});
it('reads artist row scroll from DOM', () => {
const artistGrid = document.createElement('div');
artistGrid.className = 'album-grid';
Object.defineProperty(artistGrid, 'scrollLeft', { value: 120, writable: true });
const row = document.createElement('div');
row.setAttribute('data-advanced-search-artist-row', '');
row.appendChild(artistGrid);
document.body.appendChild(row);
expect(readAdvancedSearchLeaveSnapshot()).toEqual({
scrollTop: 0,
albumRowScrollLeft: 0,
artistRowScrollLeft: 120,
});
});
it('merges leave snapshot, sessionStorage, and stash scroll fields', () => {
useAdvancedSearchSessionStore.getState().setLeaveScrollSnapshot({
scrollTop: 300,
albumRowScrollLeft: 0,
artistRowScrollLeft: 0,
});
sessionStorage.setItem(
'psysonic:advanced-search-leave-v1',
JSON.stringify({ scrollTop: 100, albumRowScrollLeft: 80, artistRowScrollLeft: 55 }),
);
expect(resolveAdvancedSearchLeaveSnapshot({
query: '',
genre: '',
yearFrom: '',
yearTo: '',
bpmFrom: '',
bpmTo: '',
moodGroup: '',
losslessOnly: false,
resultType: 'all',
starredOnly: false,
results: null,
hasSearched: false,
activeSearch: null,
localMode: false,
songsServerOffset: 0,
songsHasMore: false,
genreNote: false,
basicSearchMode: false,
tracksBrowseMode: false,
tracksBrowseUnsupported: false,
scrollTop: 50,
albumRowScrollLeft: 20,
artistRowScrollLeft: 15,
})).toEqual({ scrollTop: 300, albumRowScrollLeft: 80, artistRowScrollLeft: 55 });
});
it('saves session stash together with leave snapshot on navigate away', () => {
const viewport = document.createElement('div');
viewport.id = APP_MAIN_SCROLL_VIEWPORT_ID;
Object.defineProperty(viewport, 'scrollTop', { value: 640, writable: true });
document.body.appendChild(viewport);
const unregister = registerAdvancedSearchSessionProvider(() => ({
query: 'rock',
genre: 'Jazz',
yearFrom: '',
yearTo: '',
bpmFrom: '',
bpmTo: '',
moodGroup: '',
losslessOnly: false,
resultType: 'all',
starredOnly: false,
results: null,
hasSearched: true,
activeSearch: null,
localMode: false,
songsServerOffset: 0,
songsHasMore: false,
genreNote: false,
basicSearchMode: false,
tracksBrowseMode: false,
}));
saveAdvancedSearchLeaveSnapshot();
expect(useAdvancedSearchSessionStore.getState().peekReturnStash()?.query).toBe('rock');
expect(useAdvancedSearchSessionStore.getState().peekReturnStash()?.genre).toBe('Jazz');
expect(useAdvancedSearchSessionStore.getState().peekReturnStash()?.scrollTop).toBe(640);
unregister();
});
});
@@ -0,0 +1,167 @@
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
import {
useAdvancedSearchSessionStore,
type AdvancedSearchSessionStash,
} from '@/store/advancedSearchSessionStore';
export type AdvancedSearchLeaveSnapshot = {
scrollTop: number;
albumRowScrollLeft: number;
artistRowScrollLeft: number;
};
const STORAGE_KEY = 'psysonic:advanced-search-leave-v1';
type LeaveScrollProvider = () => AdvancedSearchLeaveSnapshot;
type SessionProvider = () => AdvancedSearchSessionStash;
let leaveScrollProvider: LeaveScrollProvider | null = null;
let sessionProvider: SessionProvider | null = null;
let leavingAdvancedSearchForDetail = false;
export function registerAdvancedSearchLeaveScrollProvider(
provider: LeaveScrollProvider,
): () => void {
leaveScrollProvider = provider;
return () => {
if (leaveScrollProvider === provider) leaveScrollProvider = null;
};
}
export function registerAdvancedSearchSessionProvider(
provider: SessionProvider,
): () => void {
sessionProvider = provider;
return () => {
if (sessionProvider === provider) sessionProvider = null;
};
}
export function markAdvancedSearchLeavingForDetail(): void {
leavingAdvancedSearchForDetail = true;
}
export function consumeAdvancedSearchLeavingForDetail(): boolean {
const value = leavingAdvancedSearchForDetail;
leavingAdvancedSearchForDetail = false;
return value;
}
function readAlbumRowScrollLeftFromDom(): number {
const albumGrid = document.querySelector<HTMLElement>('[data-advanced-search-album-row] .album-grid');
return albumGrid?.scrollLeft ?? 0;
}
function readArtistRowScrollLeftFromDom(): number {
const artistGrid = document.querySelector<HTMLElement>('[data-advanced-search-artist-row] .album-grid');
return artistGrid?.scrollLeft ?? 0;
}
function readMainScrollTopFromDom(): number {
return document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTop ?? 0;
}
function readMainScrollTopForLeave(providerSnap?: AdvancedSearchLeaveSnapshot): number {
const fromDom = readMainScrollTopFromDom();
const fromProvider = providerSnap?.scrollTop ?? 0;
// After route commit the DOM viewport may already be the destination page (scrollTop 0).
return Math.max(fromDom, fromProvider);
}
export function readAdvancedSearchLeaveSnapshot(): AdvancedSearchLeaveSnapshot {
const providerSnap = leaveScrollProvider?.();
return {
scrollTop: readMainScrollTopForLeave(providerSnap),
albumRowScrollLeft: Math.max(
readAlbumRowScrollLeftFromDom(),
providerSnap?.albumRowScrollLeft ?? 0,
),
artistRowScrollLeft: Math.max(
readArtistRowScrollLeftFromDom(),
providerSnap?.artistRowScrollLeft ?? 0,
),
};
}
function persistLeaveSnapshot(snapshot: AdvancedSearchLeaveSnapshot): void {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
/* quota / private mode */
}
}
export function peekPersistedAdvancedSearchLeaveSnapshot(): AdvancedSearchLeaveSnapshot | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<AdvancedSearchLeaveSnapshot>;
const scrollTop = typeof parsed.scrollTop === 'number' ? Math.max(0, parsed.scrollTop) : 0;
const albumRowScrollLeft = typeof parsed.albumRowScrollLeft === 'number'
? Math.max(0, parsed.albumRowScrollLeft)
: 0;
const artistRowScrollLeft = typeof parsed.artistRowScrollLeft === 'number'
? Math.max(0, parsed.artistRowScrollLeft)
: 0;
if (scrollTop <= 0 && albumRowScrollLeft <= 0 && artistRowScrollLeft <= 0) return null;
return { scrollTop, albumRowScrollLeft, artistRowScrollLeft };
} catch {
return null;
}
}
export function clearPersistedAdvancedSearchLeaveSnapshot(): void {
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch {
/* ignore */
}
}
export function saveAdvancedSearchLeaveSnapshot(): AdvancedSearchLeaveSnapshot {
const snapshot = readAdvancedSearchLeaveSnapshot();
persistLeaveSnapshot(snapshot);
const store = useAdvancedSearchSessionStore.getState();
store.setLeaveScrollSnapshot(snapshot);
const session = sessionProvider?.();
if (session) {
store.stashReturnSession({
...session,
scrollTop: snapshot.scrollTop,
albumRowScrollLeft: snapshot.albumRowScrollLeft,
artistRowScrollLeft: snapshot.artistRowScrollLeft,
});
}
markAdvancedSearchLeavingForDetail();
return snapshot;
}
export function clearAdvancedSearchLeaveSnapshots(): void {
clearPersistedAdvancedSearchLeaveSnapshot();
useAdvancedSearchSessionStore.getState().clearLeaveScrollSnapshot();
}
/** Merge zustand leave snapshot, sessionStorage, and session stash scroll fields. */
export function resolveAdvancedSearchLeaveSnapshot(
stash: AdvancedSearchSessionStash | null,
): AdvancedSearchLeaveSnapshot | null {
const leave = useAdvancedSearchSessionStore.getState().peekLeaveScrollSnapshot();
const persisted = peekPersistedAdvancedSearchLeaveSnapshot();
const scrollTop = Math.max(
leave?.scrollTop ?? 0,
persisted?.scrollTop ?? 0,
stash?.scrollTop ?? 0,
);
const albumRowScrollLeft = Math.max(
leave?.albumRowScrollLeft ?? 0,
persisted?.albumRowScrollLeft ?? 0,
stash?.albumRowScrollLeft ?? 0,
);
const artistRowScrollLeft = Math.max(
leave?.artistRowScrollLeft ?? 0,
persisted?.artistRowScrollLeft ?? 0,
stash?.artistRowScrollLeft ?? 0,
);
if (scrollTop <= 0 && albumRowScrollLeft <= 0 && artistRowScrollLeft <= 0) return null;
return { scrollTop, albumRowScrollLeft, artistRowScrollLeft };
}
@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from '@/store/authStore';
import { appendServerQuery, readDetailServerId } from '@/lib/navigation/detailServerScope';
describe('detailServerScope', () => {
beforeEach(() => {
useAuthStore.setState({
activeServerId: 'srv-active',
servers: [
{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
{ id: 'srv-b', name: 'B', url: 'https://b.test', username: 'u', password: 'p' },
],
});
});
it('readDetailServerId prefers valid ?server= over fallback', () => {
const params = new URLSearchParams('server=srv-b&lossless=1');
expect(readDetailServerId(params, 'srv-active')).toBe('srv-b');
});
it('readDetailServerId falls back when server param is unknown', () => {
const params = new URLSearchParams('server=missing');
expect(readDetailServerId(params, 'srv-active')).toBe('srv-active');
});
it('appendServerQuery merges with existing query parts', () => {
expect(appendServerQuery('lossless=1', 'srv-a')).toBe('lossless=1&server=srv-a');
expect(appendServerQuery(undefined, 'srv-a')).toBe('server=srv-a');
});
});
+25
View File
@@ -0,0 +1,25 @@
import { useAuthStore } from '@/store/authStore';
/** Resolve `?server=` on album/artist detail routes; falls back when absent or unknown. */
export function readDetailServerId(
searchParams: URLSearchParams,
fallback: string | null | undefined,
): string | null {
const raw = searchParams.get('server');
if (!raw) return fallback ?? null;
const servers = useAuthStore.getState().servers;
if (servers.some(s => s.id === raw)) return raw;
return fallback ?? null;
}
/** Append or merge `server=` into an existing album/artist link query string. */
export function appendServerQuery(
base: string | undefined,
serverId: string | undefined,
): string | undefined {
if (!serverId) return base;
const serverPart = `server=${encodeURIComponent(serverId)}`;
if (!base) return serverPart;
const normalized = base.startsWith('?') ? base.slice(1) : base;
return `${normalized}&${serverPart}`;
}
@@ -0,0 +1,62 @@
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
const SAFETY_TIMEOUT_MS = 3000;
function clampScrollTop(el: HTMLElement, scrollTop: number): number {
const maxScroll = Math.max(0, el.scrollHeight - el.clientHeight);
return Math.min(Math.max(0, scrollTop), maxScroll);
}
function scrollRestoreMatches(el: HTMLElement, targetScrollTop: number): boolean {
const maxScroll = Math.max(0, el.scrollHeight - el.clientHeight);
if (targetScrollTop > maxScroll + 1) return false;
const desired = clampScrollTop(el, targetScrollTop);
return Math.abs(el.scrollTop - desired) <= 1;
}
/** Apply main viewport scroll after route content is ready; retry until layout can reach target. */
export function restoreMainViewportScroll(
targetScrollTop: number,
onComplete: () => void,
): () => void {
let cancelled = false;
let ro: ResizeObserver | null = null;
let safetyTimeoutId = 0;
const finish = () => {
if (cancelled) return;
cancelled = true;
ro?.disconnect();
ro = null;
if (safetyTimeoutId) window.clearTimeout(safetyTimeoutId);
onComplete();
};
const apply = () => {
if (cancelled) return;
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
if (!el) return;
const desired = clampScrollTop(el, targetScrollTop);
el.scrollTop = desired;
el.dispatchEvent(new Event('scroll', { bubbles: false }));
if (scrollRestoreMatches(el, targetScrollTop)) finish();
};
const scheduleApply = () => {
requestAnimationFrame(apply);
};
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
if (el && typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(scheduleApply);
ro.observe(el);
}
apply();
scheduleApply();
safetyTimeoutId = window.setTimeout(finish, SAFETY_TIMEOUT_MS);
return finish;
}
+17
View File
@@ -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) 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`);
}
}
+8
View File
@@ -0,0 +1,8 @@
import type { ServerProfile } from '@/store/authStoreTypes';
/** Normalized Subsonic root URL for a server profile (same shape as `getBaseUrl`). */
export function serverProfileBaseUrl(server: Pick<ServerProfile, 'url'>): string {
if (!server.url) return '';
const base = server.url.startsWith('http') ? server.url : `http://${server.url}`;
return base.replace(/\/$/, '');
}
+58
View File
@@ -0,0 +1,58 @@
import type { ServerProfile } from '@/store/authStoreTypes';
import { describe, expect, it } from 'vitest';
import { serverIdentityLabel, serverListDisplayLabel, serverSettingsEntryTitle, shortHostFromServerUrl } from '@/lib/server/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('serverIdentityLabel', () => {
it('formats username@host', () => {
const a = srv({ id: '1', url: 'https://music.shstk.ru', username: 'cucadmuh', password: 'p', name: 'Home' });
expect(serverIdentityLabel(a)).toBe('cucadmuh@music.shstk.ru');
});
it('keeps port in host', () => {
const a = srv({ id: '1', url: 'http://127.0.0.1:4533', username: 'admin', password: 'p' });
expect(serverIdentityLabel(a)).toBe('admin@127.0.0.1:4533');
});
});
describe('serverSettingsEntryTitle', () => {
it('prefers custom entry name', () => {
const a = srv({ id: '1', url: 'https://music.shstk.ru', username: 'u', password: 'p', name: 'Home NAS' });
expect(serverSettingsEntryTitle(a)).toBe('Home NAS');
});
it('falls back to short host when name empty', () => {
const a = srv({ id: '1', url: 'https://music.shstk.ru', username: 'u', password: 'p', name: '' });
expect(serverSettingsEntryTitle(a)).toBe('music.shstk.ru');
});
});
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');
});
});
+50
View File
@@ -0,0 +1,50 @@
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 | null): string {
const t = typeof urlRaw === 'string' ? 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;
}
}
/** Settings server card primary line: `username@host`. */
export function serverIdentityLabel(server: ServerProfile): string {
const shortHost = shortHostFromServerUrl(server.url);
const host = shortHost || (typeof server.url === 'string' ? server.url.trim() : '');
return `${server.username}@${host}`;
}
/** Settings server card title: custom entry name, or short host when unset. */
export function serverSettingsEntryTitle(server: ServerProfile): string {
const nameTrim = (server.name || '').trim();
if (nameTrim) return nameTrim;
return shortHostFromServerUrl(server.url) || (typeof server.url === 'string' ? server.url.trim() : '');
}
/**
* 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 safeUrl = typeof server.url === 'string' ? server.url : '';
const shortHost = shortHostFromServerUrl(safeUrl);
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 || safeUrl.trim();
}
return `${server.username}@${shortHost}`;
}
+559
View File
@@ -0,0 +1,559 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/api/subsonic', () => ({
pingWithCredentials: vi.fn(),
pingWithCredentialsForProfile: vi.fn(),
}));
import { pingWithCredentialsForProfile } from '@/lib/api/subsonic';
import {
allNormalizedAddresses,
ensureConnectUrlResolved,
getCachedConnectBaseUrl,
invalidateReachableEndpointCache,
isLanUrl,
normalizeServerBaseUrl,
pickReachableBaseUrl,
serverAddressEndpoints,
serverShareBaseUrl,
subscribeConnectCache,
} from '@/lib/server/serverEndpoint';
import type { ServerProfile } from '@/store/authStoreTypes';
function makeProfile(overrides: Partial<ServerProfile> = {}): ServerProfile {
return {
id: 'profile-1',
name: 'Test',
url: 'https://music.example.com',
username: 'u',
password: 'p',
...overrides,
};
}
function pingOk(overrides: Partial<{ type: string; serverVersion: string; openSubsonic: boolean }> = {}) {
return {
ok: true as const,
type: 'navidrome',
serverVersion: '0.55.0',
openSubsonic: true,
...overrides,
};
}
function pingFail() {
return { ok: false as const };
}
/** Initial connect ping + 2 retries (`serverEndpoint.ts`). */
const CONNECT_PROBE_ATTEMPTS = 3;
function mockDualAddressLanFailPublicOk() {
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url: string) => {
if (url === 'http://192.168.0.10') return pingFail();
return pingOk();
});
}
describe('normalizeServerBaseUrl', () => {
it('strips a single trailing slash', () => {
expect(normalizeServerBaseUrl('https://music.example.com/')).toBe(
'https://music.example.com',
);
});
it('prefixes http:// for a bare host', () => {
expect(normalizeServerBaseUrl('music.example.com')).toBe('http://music.example.com');
});
it('returns empty for empty input', () => {
expect(normalizeServerBaseUrl('')).toBe('');
});
});
describe('isLanUrl — IPv4', () => {
it.each([
'http://localhost',
'http://localhost:4533',
'http://musicbox.local',
'http://127.0.0.1',
'http://127.5.6.7',
'http://10.0.0.5',
'http://192.168.1.10',
'http://172.16.0.1',
'http://172.31.255.255',
])('classifies %s as LAN', url => {
expect(isLanUrl(url)).toBe(true);
});
it.each([
'http://172.15.0.1',
'http://172.32.0.1',
'https://example.com',
'https://music.example.com',
'http://8.8.8.8',
])('classifies %s as public', url => {
expect(isLanUrl(url)).toBe(false);
});
});
describe('isLanUrl — IPv6', () => {
it.each([
'http://[::1]',
'http://[::1]:4533',
'http://[fe80::1]',
'http://[fe80::abcd:1]',
'http://[fc00::1]',
'http://[fd12:3456:789a::1]',
'http://[::ffff:127.0.0.1]',
'http://[::ffff:192.168.0.1]',
])('classifies %s as LAN', url => {
expect(isLanUrl(url)).toBe(true);
});
it.each([
'http://[2001:db8::1]',
'http://[::ffff:8.8.8.8]',
'http://[2606:4700:4700::1111]',
])('classifies %s as public', url => {
expect(isLanUrl(url)).toBe(false);
});
});
describe('isLanUrl — edge cases', () => {
it('handles bare hosts without scheme', () => {
expect(isLanUrl('192.168.0.1')).toBe(true);
expect(isLanUrl('example.com')).toBe(false);
});
it('returns false on empty / malformed', () => {
expect(isLanUrl('')).toBe(false);
expect(isLanUrl('not a url at all ')).toBe(false);
});
});
describe('allNormalizedAddresses', () => {
it('returns single entry for profile with only url', () => {
expect(
allNormalizedAddresses({ url: 'https://music.example.com' }),
).toEqual(['https://music.example.com']);
});
it('returns both addresses preserving order', () => {
expect(
allNormalizedAddresses({
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10:4533',
}),
).toEqual(['https://music.example.com', 'http://192.168.0.10:4533']);
});
it('dedupes identical normalized addresses', () => {
expect(
allNormalizedAddresses({
url: 'https://music.example.com/',
alternateUrl: 'https://music.example.com',
}),
).toEqual(['https://music.example.com']);
});
it('drops empty alternateUrl', () => {
expect(
allNormalizedAddresses({
url: 'https://music.example.com',
alternateUrl: '',
}),
).toEqual(['https://music.example.com']);
});
});
describe('serverAddressEndpoints', () => {
it('returns a single local endpoint for a LAN-only profile', () => {
expect(
serverAddressEndpoints({ url: 'http://192.168.0.10' }),
).toEqual([{ url: 'http://192.168.0.10', kind: 'local' }]);
});
it('puts LAN before public when public is primary', () => {
expect(
serverAddressEndpoints({
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10',
}),
).toEqual([
{ url: 'http://192.168.0.10', kind: 'local' },
{ url: 'https://music.example.com', kind: 'public' },
]);
});
it('keeps LAN-first when LAN is already primary', () => {
expect(
serverAddressEndpoints({
url: 'http://192.168.0.10',
alternateUrl: 'https://music.example.com',
}),
).toEqual([
{ url: 'http://192.168.0.10', kind: 'local' },
{ url: 'https://music.example.com', kind: 'public' },
]);
});
it('preserves original order among endpoints of the same kind', () => {
expect(
serverAddressEndpoints({
url: 'http://10.0.0.5',
alternateUrl: 'http://192.168.0.10',
}),
).toEqual([
{ url: 'http://10.0.0.5', kind: 'local' },
{ url: 'http://192.168.0.10', kind: 'local' },
]);
});
});
describe('pickReachableBaseUrl', () => {
beforeEach(() => {
invalidateReachableEndpointCache();
vi.mocked(pingWithCredentialsForProfile).mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('returns the single endpoint when it pings ok and caches it', async () => {
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingOk());
const result = await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.baseUrl).toBe('https://music.example.com');
expect(result.endpoint).toEqual({ url: 'https://music.example.com', kind: 'public' });
expect(result.ping.ok).toBe(true);
expect(result.ping.type).toBe('navidrome');
}
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
});
it('prefers the LAN endpoint even when alternateUrl is the LAN one', async () => {
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingOk());
const result = await pickReachableBaseUrl(
makeProfile({
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10',
}),
);
expect(result.ok).toBe(true);
if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10');
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls[0]![1]).toBe('http://192.168.0.10');
});
it('falls through to the public endpoint when LAN ping fails', async () => {
vi.useFakeTimers();
mockDualAddressLanFailPublicOk();
const promise = pickReachableBaseUrl(
makeProfile({
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10',
}),
);
await vi.runAllTimersAsync();
const result = await promise;
expect(result.ok).toBe(true);
if (result.ok) expect(result.baseUrl).toBe('https://music.example.com');
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS + 1);
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
});
it('retries a flaky endpoint before declaring it unreachable', async () => {
vi.useFakeTimers();
vi.mocked(pingWithCredentialsForProfile)
.mockResolvedValueOnce(pingFail())
.mockResolvedValueOnce(pingOk());
const promise = pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
await vi.runAllTimersAsync();
const result = await promise;
expect(result.ok).toBe(true);
if (result.ok) expect(result.baseUrl).toBe('https://music.example.com');
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
it('returns unreachable and clears cache when every endpoint fails', async () => {
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingFail());
// Seed a stale cache entry first.
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
vi.mocked(pingWithCredentialsForProfile).mockReset();
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingFail());
vi.useFakeTimers();
const unreachablePromise = pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
await vi.runAllTimersAsync();
const result = await unreachablePromise;
expect(result).toEqual({ ok: false, reason: 'unreachable' });
expect(getCachedConnectBaseUrl('profile-1')).toBeNull();
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS);
});
it('returns unreachable when the profile has no usable url', async () => {
const result = await pickReachableBaseUrl(makeProfile({ url: '' }));
expect(result).toEqual({ ok: false, reason: 'unreachable' });
expect(pingWithCredentialsForProfile).not.toHaveBeenCalled();
});
it('tries the cached endpoint first on subsequent calls (sticky)', async () => {
const profile = makeProfile({
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10',
});
// First call: LAN responds ok, becomes cached.
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile);
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
// Second call: cached URL is tried first; sole ping happens against it.
vi.mocked(pingWithCredentialsForProfile).mockClear();
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
const result = await pickReachableBaseUrl(profile);
expect(result.ok).toBe(true);
if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10');
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls[0]![1]).toBe('http://192.168.0.10');
});
it('dedupes concurrent calls for the same profile (single shared probe)', async () => {
// Two callers in the same tick must observe the same promise — without
// the in-flight map both would ping every endpoint and race on the
// cache write, with last-write-wins potentially clobbering the correct
// LAN sticky a millisecond after it was set.
let resolvePing: ((v: ReturnType<typeof pingOk>) => void) | null = null;
vi.mocked(pingWithCredentialsForProfile).mockReturnValueOnce(
new Promise(r => {
resolvePing = r;
}),
);
const profile = makeProfile({ url: 'http://192.168.0.10' });
const p1 = pickReachableBaseUrl(profile);
const p2 = pickReachableBaseUrl(profile);
// Both calls saw a pending probe — only one ping should have been fired.
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
resolvePing!(pingOk());
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1.ok).toBe(true);
expect(r2.ok).toBe(true);
if (r1.ok && r2.ok) {
expect(r1.baseUrl).toBe(r2.baseUrl);
}
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
});
it('starts a fresh probe after the in-flight one settles', async () => {
// Once the previous probe resolves, the in-flight slot is freed and
// the next call hits the network again (subject to the sticky cache).
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' }));
vi.mocked(pingWithCredentialsForProfile).mockClear();
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' }));
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
});
it('falls back to the natural order if the cached endpoint stops answering', async () => {
const profile = makeProfile({
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10',
});
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile);
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
// LAN now fails; public answers.
vi.mocked(pingWithCredentialsForProfile).mockClear();
vi.useFakeTimers();
mockDualAddressLanFailPublicOk();
const fallbackPromise = pickReachableBaseUrl(profile);
await vi.runAllTimersAsync();
const result = await fallbackPromise;
expect(result.ok).toBe(true);
if (result.ok) expect(result.baseUrl).toBe('https://music.example.com');
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
});
});
describe('invalidateReachableEndpointCache', () => {
beforeEach(() => {
invalidateReachableEndpointCache();
vi.mocked(pingWithCredentialsForProfile).mockReset();
});
it('clears a specific profile', async () => {
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await ensureConnectUrlResolved(makeProfile({ id: 'a' }));
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await ensureConnectUrlResolved(makeProfile({ id: 'b' }));
expect(getCachedConnectBaseUrl('a')).not.toBeNull();
expect(getCachedConnectBaseUrl('b')).not.toBeNull();
invalidateReachableEndpointCache('a');
expect(getCachedConnectBaseUrl('a')).toBeNull();
expect(getCachedConnectBaseUrl('b')).not.toBeNull();
});
it('clears everything when called with no argument', async () => {
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await ensureConnectUrlResolved(makeProfile({ id: 'a' }));
invalidateReachableEndpointCache();
expect(getCachedConnectBaseUrl('a')).toBeNull();
});
});
describe('subscribeConnectCache — connect-URL flip notifications', () => {
beforeEach(() => {
invalidateReachableEndpointCache();
vi.mocked(pingWithCredentialsForProfile).mockReset();
});
it('notifies when a probe resolves a new endpoint and on a later flip', async () => {
const listener = vi.fn();
const unsubscribe = subscribeConnectCache(listener);
const profile = makeProfile({
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10',
});
// First probe: LAN answers → cache set → one notification.
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile);
expect(listener).toHaveBeenCalledTimes(1);
// LAN drops, public answers → cached URL flips → another notification.
vi.useFakeTimers();
mockDualAddressLanFailPublicOk();
const flipPromise = pickReachableBaseUrl(profile);
await vi.runAllTimersAsync();
await flipPromise;
expect(listener).toHaveBeenCalledTimes(2);
unsubscribe();
});
it('does not notify when the sticky endpoint is unchanged', async () => {
const profile = makeProfile({ url: 'http://192.168.0.10' });
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile);
const listener = vi.fn();
const unsubscribe = subscribeConnectCache(listener);
// Re-probe, same endpoint answers → cache value identical → no notification.
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile);
expect(listener).not.toHaveBeenCalled();
unsubscribe();
});
it('notifies on explicit cache invalidation when an entry existed', async () => {
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(makeProfile({ id: 'a' }));
const listener = vi.fn();
const unsubscribe = subscribeConnectCache(listener);
invalidateReachableEndpointCache('a');
expect(listener).toHaveBeenCalledTimes(1);
// No-op invalidation (nothing cached) must stay silent.
invalidateReachableEndpointCache('a');
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
});
});
describe('serverShareBaseUrl', () => {
it('returns the single address for a single-URL profile', () => {
expect(serverShareBaseUrl({ url: 'https://music.example.com' })).toBe(
'https://music.example.com',
);
});
it('falls back to a normalized url even when empty', () => {
// Defensive — never throws; downstream consumers tolerate the empty string.
expect(serverShareBaseUrl({ url: '' })).toBe('');
});
it('prefers the public address by default when both are set', () => {
expect(
serverShareBaseUrl({
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10',
}),
).toBe('https://music.example.com');
});
it('still prefers public when the LAN address is the primary', () => {
expect(
serverShareBaseUrl({
url: 'http://192.168.0.10',
alternateUrl: 'https://music.example.com',
}),
).toBe('https://music.example.com');
});
it('returns the LAN address when shareUsesLocalUrl is true', () => {
expect(
serverShareBaseUrl({
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10',
shareUsesLocalUrl: true,
}),
).toBe('http://192.168.0.10');
});
it('falls back to the first endpoint when no LAN exists and flag is set', () => {
expect(
serverShareBaseUrl({
url: 'https://music.example.com',
alternateUrl: 'https://music-alt.example.com',
shareUsesLocalUrl: true,
}),
).toBe('https://music.example.com');
});
it('falls back to the first endpoint when both are LAN and flag is off', () => {
expect(
serverShareBaseUrl({
url: 'http://10.0.0.5',
alternateUrl: 'http://192.168.0.10',
}),
).toBe('http://10.0.0.5');
});
it('returns the first LAN endpoint when both are LAN and flag is on', () => {
// Two LAN addresses + flag set: spec §5.1 says "local ?? endpoints[0]".
// `find(isLanUrl)` returns the first LAN, which is endpoints[0] either
// way — pin the test so future refactors don't accidentally drift.
expect(
serverShareBaseUrl({
url: 'http://10.0.0.5',
alternateUrl: 'http://192.168.0.10',
shareUsesLocalUrl: true,
}),
).toBe('http://10.0.0.5');
});
it('returns the first endpoint when both are public and flag is off', () => {
// Reverse case: two publics, no LAN exists, flag default → publicEndpoint
// matches the first one. Identity, but locks the rule down.
expect(
serverShareBaseUrl({
url: 'https://music.example.com',
alternateUrl: 'https://backup.example.com',
}),
).toBe('https://music.example.com');
});
});
+344
View File
@@ -0,0 +1,344 @@
import { pingWithCredentialsForProfile } from '@/lib/api/subsonic';
import type { PingWithCredentialsResult } from '@/lib/api/subsonicTypes';
import type { ServerProfile } from '@/store/authStoreTypes';
import { serverProfileBaseUrl } from '@/lib/server/serverBaseUrl';
export type ServerEndpointKind = 'local' | 'public';
export type ServerEndpoint = {
/** Normalized base URL, no trailing slash. */
url: string;
kind: ServerEndpointKind;
};
export type PickReachableResult =
| {
ok: true;
baseUrl: string;
endpoint: ServerEndpoint;
/**
* The successful ping response exposed so callers like
* `switchActiveServer` don't need to issue a second `pingWithCredentials`
* just to read `type` / `serverVersion` / `openSubsonic`.
*/
ping: PingWithCredentialsResult;
}
| { ok: false; reason: 'unreachable' };
/**
* Aligned with `serverProfileBaseUrl` so connect / share / index helpers all
* agree on the canonical form of an address (`http://` default, no trailing
* slash). Exposed separately so non-profile-shaped callers can normalize a
* raw string.
*/
export function normalizeServerBaseUrl(raw: string): string {
return serverProfileBaseUrl({ url: raw });
}
function isIpv4LanLiteral(ip: string): boolean {
return (
/^127\./.test(ip) ||
/^10\./.test(ip) ||
/^192\.168\./.test(ip) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(ip)
);
}
function isIpv6LanHostname(hostname: string): boolean {
if (hostname === '::1') return true;
// fe80::/10 — link-local (first 10 bits 1111 1110 10..)
if (/^fe[89ab][0-9a-f]:/.test(hostname)) return true;
// fc00::/7 — ULA (includes fd00::/8)
if (/^f[cd][0-9a-f]{2}:/.test(hostname)) return true;
// IPv4-mapped IPv6 — accept dot-decimal (`::ffff:1.2.3.4`, raw user input)
// and the URL-API-normalized hex form (`::ffff:HHHH:HHHH`, which `new URL`
// produces from any dot-decimal input).
const dotted = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(hostname);
if (dotted) return isIpv4LanLiteral(dotted[1]!);
const hexMapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(hostname);
if (hexMapped) {
const v1 = parseInt(hexMapped[1]!, 16);
const v2 = parseInt(hexMapped[2]!, 16);
const ipv4 = `${(v1 >> 8) & 0xff}.${v1 & 0xff}.${(v2 >> 8) & 0xff}.${v2 & 0xff}`;
return isIpv4LanLiteral(ipv4);
}
return false;
}
/**
* True when `url`'s hostname falls in a private / link-local range, or is a
* loopback / `.local` / `localhost`. IPv4 + IPv6 (incl. IPv4-mapped). Empty /
* malformed inputs return `false`.
*
* Mirrors the prior `isLanUrl` in `useConnectionStatus.ts` for IPv4 the
* additions are the IPv6 cases. UI hints, endpoint ordering, and the
* share-link LAN warning all read this.
*/
export function isLanUrl(url: string): boolean {
if (!url) return false;
try {
const parsed = new URL(url.startsWith('http') ? url : `http://${url}`);
const raw = parsed.hostname;
// `URL().hostname` keeps IPv6 brackets — strip before pattern matches.
const hostname = raw.replace(/^\[|\]$/g, '').toLowerCase();
if (!hostname) return false;
if (hostname === 'localhost' || hostname.endsWith('.local')) return true;
if (hostname.includes(':')) return isIpv6LanHostname(hostname);
return isIpv4LanLiteral(hostname);
} catch {
return false;
}
}
/**
* Deduped normalized addresses for a profile (`url` plus optional
* `alternateUrl`). Both fields are passed through `normalizeServerBaseUrl`
* before dedupe so `https://x.example/` and `https://x.example` collapse.
* Order is preserved (`url` first); empty entries are dropped.
*/
export function allNormalizedAddresses(
profile: Pick<ServerProfile, 'url' | 'alternateUrl'>,
): string[] {
const result: string[] = [];
const seen = new Set<string>();
for (const raw of [profile.url, profile.alternateUrl]) {
if (!raw) continue;
const normalized = normalizeServerBaseUrl(raw);
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
result.push(normalized);
}
return result;
}
/**
* Endpoint list for connect probing LAN-first, stable within each class.
* Single-address profiles return one entry; dual-address returns up to two.
*/
export function serverAddressEndpoints(
profile: Pick<ServerProfile, 'url' | 'alternateUrl'>,
): ServerEndpoint[] {
const endpoints: ServerEndpoint[] = allNormalizedAddresses(profile).map(url => ({
url,
kind: isLanUrl(url) ? 'local' : 'public',
}));
return [
...endpoints.filter(e => e.kind === 'local'),
...endpoints.filter(e => e.kind === 'public'),
];
}
/**
* URL to embed in **shares** (Orbit invites, entity / queue share payloads,
* magic strings). Different from the connect URL: a guest opening the share
* link is not on the host's LAN, so the public address is the right default
* when both are configured. `shareUsesLocalUrl` flips that for the rare
* "share into a LAN-only group" case (spec §5).
*
* Single-address profiles return their one normalized address; empty
* profiles still return a normalized form of `url` (possibly empty).
*/
export function serverShareBaseUrl(
profile: Pick<ServerProfile, 'url' | 'alternateUrl' | 'shareUsesLocalUrl'>,
): string {
const endpoints = allNormalizedAddresses(profile);
if (endpoints.length === 0) return normalizeServerBaseUrl(profile.url);
if (endpoints.length === 1) return endpoints[0]!;
const local = endpoints.find(isLanUrl);
const publicEndpoint = endpoints.find(u => !isLanUrl(u));
if (profile.shareUsesLocalUrl) return local ?? endpoints[0]!;
return publicEndpoint ?? endpoints[0]!;
}
// ─────────────────────────────────────────────────────────────────────────────
// Connect cache (in-memory, per-session)
//
// `pickReachableBaseUrl` probes the LAN-first endpoint list with the existing
// `pingWithCredentials`, sequentially (not parallel) so LAN wins over public
// without racing. The first OK URL is cached against the profile id so the
// next sync `getBaseUrl()` lookup is instant. Cache is **session only** —
// never persisted; cleared on profile edit / credentials change / online
// event / manual retry via `invalidateReachableEndpointCache`.
// ─────────────────────────────────────────────────────────────────────────────
const connectCache = new Map<string, string>();
// ── Connect-cache change notifications ───────────────────────────────────────
// The sticky connect URL flips silently (120-s probe tick / online event /
// switch). Long-lived consumers that snapshot the URL once — notably the native
// **library cover backfill**, which is configured with a fixed `rest_base_url`
// — need to react when a laptop moves off the LAN, or they keep hammering the
// now-unreachable local address. UI/playback rebuild the URL per request and
// don't need this. Listeners are notified only when a profile's cached URL
// actually changes value (set to a different endpoint, dropped, or cleared).
const connectCacheListeners = new Set<() => void>();
let connectCacheVersion = 0;
function notifyConnectCacheChanged(): void {
connectCacheVersion += 1;
connectCacheListeners.forEach(cb => cb());
}
/** Subscribe to connect-URL flips (any profile). Returns an unsubscribe fn. */
export function subscribeConnectCache(cb: () => void): () => void {
connectCacheListeners.add(cb);
return () => connectCacheListeners.delete(cb);
}
/** Monotonic version, bumped on every effective connect-cache change. */
export function getConnectCacheVersion(): number {
return connectCacheVersion;
}
/**
* In-flight probes keyed by `profile.id`. Three call sites (useConnectionStatus
* 120-s tick, switchActiveServer, bindIndexedServer, plus retry / online
* handlers) can fire near-simultaneously; without this map two probes would
* each see an empty cache, both ping every endpoint, and race to set the
* sticky URL the loser's `connectCache.set` would stomp the winner.
* Returning the existing promise dedupes them so every caller gets the
* same result.
*/
const inFlightProbes = new Map<string, Promise<PickReachableResult>>();
/**
* Last resolved connect URL for the profile, if a probe has succeeded in this
* session. `null` means "no probe yet" sync `getBaseUrl()` callers should
* fall back to the normalized primary `url`.
*/
export function getCachedConnectBaseUrl(profileId: string): string | null {
return connectCache.get(profileId) ?? null;
}
/**
* Synchronous connect URL for any saved profile (active or not). Reads the
* cached probe result; falls back to the normalized primary `url` when no
* probe has run yet for that profile. **Use this** everywhere HTTP traffic
* is built against an explicit `server.url` never read the raw `url`
* straight for HTTP.
*/
export function connectBaseUrlForServer(
server: Pick<ServerProfile, 'id' | 'url'>,
): string {
const cached = connectCache.get(server.id);
if (cached) return cached;
return serverProfileBaseUrl({ url: server.url });
}
/**
* Drop one or all cached connect URLs. Call when:
* - profile was edited (url / alternateUrl / credentials changed)
* - network went online (re-check sticky)
* - user explicitly retried the connection
*/
export function invalidateReachableEndpointCache(profileId?: string): void {
if (profileId === undefined) {
// Don't clear in-flight slots — they're already racing against the
// network, letting their own `finally` clean up keeps the dedup
// invariant. Their results will still write to the (now empty) cache,
// which is the right behaviour: the freshest probe wins.
if (connectCache.size > 0) {
connectCache.clear();
notifyConnectCacheChanged();
}
return;
}
if (connectCache.delete(profileId)) notifyConnectCacheChanged();
}
/** Retries after a failed connect ping before trying the next endpoint / unreachable. */
const CONNECT_PING_RETRIES = 2;
const CONNECT_PING_RETRY_DELAY_MS = 2000;
function sleepMs(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* `pingWithCredentials` for connect probing retries flaky links (packet loss,
* proxy TLS flakes) before the connection indicator marks the server down.
*/
async function pingWithConnectRetries(
profile: ServerProfile,
endpointUrl: string,
): Promise<PingWithCredentialsResult> {
let ping = await pingWithCredentialsForProfile(profile, endpointUrl);
if (ping.ok) return ping;
for (let retry = 0; retry < CONNECT_PING_RETRIES; retry++) {
await sleepMs(CONNECT_PING_RETRY_DELAY_MS);
ping = await pingWithCredentialsForProfile(profile, endpointUrl);
if (ping.ok) return ping;
}
return ping;
}
/**
* Sequentially ping the profile's endpoints (LAN-first), return the first one
* that answers OK. Sticky: if a cached endpoint exists and is still in the
* list, it's tried first; on failure, the cache entry is cleared and the full
* sequence runs.
*
* Each endpoint is probed with {@link pingWithConnectRetries} (initial ping +
* {@link CONNECT_PING_RETRIES} retries, {@link CONNECT_PING_RETRY_DELAY_MS} apart).
*
* Single-address profiles: one endpoint sequence, identical intent to legacy
* behavior aside from the retry cushion.
*/
export async function pickReachableBaseUrl(
profile: ServerProfile,
): Promise<PickReachableResult> {
// Dedupe concurrent calls for the same profile — see `inFlightProbes`.
const existing = inFlightProbes.get(profile.id);
if (existing) return existing;
const promise = (async (): Promise<PickReachableResult> => {
const ordered = serverAddressEndpoints(profile);
if (ordered.length === 0) return { ok: false, reason: 'unreachable' };
// Apply sticky: move the cached endpoint (if still in the list) to the front.
const cached = connectCache.get(profile.id);
const endpoints =
cached && ordered.some(e => e.url === cached)
? [
ordered.find(e => e.url === cached)!,
...ordered.filter(e => e.url !== cached),
]
: ordered;
for (const endpoint of endpoints) {
const ping = await pingWithConnectRetries(profile, endpoint.url);
if (ping.ok) {
const prev = connectCache.get(profile.id);
connectCache.set(profile.id, endpoint.url);
if (prev !== endpoint.url) notifyConnectCacheChanged();
return { ok: true, baseUrl: endpoint.url, endpoint, ping };
}
}
// Every endpoint failed — drop any stale cache entry so the next probe
// starts from the natural LAN-first order.
if (connectCache.delete(profile.id)) notifyConnectCacheChanged();
return { ok: false, reason: 'unreachable' };
})();
inFlightProbes.set(profile.id, promise);
try {
return await promise;
} finally {
// Always clear the in-flight slot when this promise settles — the next
// call (after a real boundary in time) starts a fresh probe.
inFlightProbes.delete(profile.id);
}
}
/**
* Boot / switch / online-event entry point: same mechanism as
* `pickReachableBaseUrl` but named for intent at the call site.
*/
export async function ensureConnectUrlResolved(
profile: ServerProfile,
): Promise<PickReachableResult> {
return pickReachableBaseUrl(profile);
}
+352
View File
@@ -0,0 +1,352 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock the apiWithCredentials surface — fetchServerFingerprint pulls
// folders / user / license / indexes through it.
vi.mock('@/lib/api/subsonicClient', async importOriginal => {
const original = await importOriginal<typeof import('@/lib/api/subsonicClient')>();
return {
...original,
apiWithCredentials: vi.fn(),
};
});
import { apiWithCredentials } from '@/lib/api/subsonicClient';
import {
compareFingerprints,
fetchServerFingerprint,
verifySameServerEndpoints,
type ServerFingerprint,
} from '@/lib/server/serverFingerprint';
function makeFingerprint(overrides: Partial<ServerFingerprint> = {}): ServerFingerprint {
return {
ping: {
type: 'navidrome',
serverVersion: '0.55.0',
openSubsonic: true,
apiVersion: '1.16.1',
},
musicFolders: [{ id: '1', name: 'Music' }],
userId: 'tester',
licenseKey: 'tester@example.com',
indexesDigest: 'letters:26|a1,a2',
...overrides,
};
}
function navidromePingResponse() {
return {
'subsonic-response': {
status: 'ok',
type: 'navidrome',
serverVersion: '0.55.0',
openSubsonic: true,
version: '1.16.1',
},
};
}
function ampachePingResponse() {
// Minimal Subsonic-shape — ampache doesn't always advertise openSubsonic etc.
return {
'subsonic-response': {
status: 'ok',
type: 'ampache',
serverVersion: '6.0.0',
version: '1.13.0',
},
};
}
/** Build a fetch-API-shaped mock response without depending on the global `Response` polyfill. */
function jsonResponse(body: unknown, ok = true, status = 200) {
return {
ok,
status,
json: async () => body,
} as unknown as Response;
}
describe('compareFingerprints', () => {
it('matches two identical fingerprints', () => {
const a = makeFingerprint();
const b = makeFingerprint();
expect(compareFingerprints(a, b)).toBe('match');
});
it('mismatches when type differs (case-insensitive)', () => {
const a = makeFingerprint({ ping: { ...makeFingerprint().ping, type: 'navidrome' } });
const b = makeFingerprint({ ping: { ...makeFingerprint().ping, type: 'subsonic' } });
expect(compareFingerprints(a, b)).toBe('mismatch');
});
it('mismatches when serverVersion differs', () => {
const a = makeFingerprint({ ping: { ...makeFingerprint().ping, serverVersion: '0.55.0' } });
const b = makeFingerprint({ ping: { ...makeFingerprint().ping, serverVersion: '0.54.9' } });
expect(compareFingerprints(a, b)).toBe('mismatch');
});
it('mismatches when openSubsonic differs', () => {
const a = makeFingerprint({ ping: { ...makeFingerprint().ping, openSubsonic: true } });
const b = makeFingerprint({ ping: { ...makeFingerprint().ping, openSubsonic: false } });
expect(compareFingerprints(a, b)).toBe('mismatch');
});
it('treats envelope apiVersion as informational (no mismatch on its own)', () => {
const a = makeFingerprint({ ping: { ...makeFingerprint().ping, apiVersion: '1.16.1' } });
const b = makeFingerprint({ ping: { ...makeFingerprint().ping, apiVersion: '1.13.0' } });
expect(compareFingerprints(a, b)).toBe('match');
});
it('mismatches when musicFolders differ', () => {
const a = makeFingerprint({ musicFolders: [{ id: '1', name: 'Music' }] });
const b = makeFingerprint({ musicFolders: [{ id: '1', name: 'Music' }, { id: '2', name: 'Audiobooks' }] });
expect(compareFingerprints(a, b)).toBe('mismatch');
});
it('treats empty musicFolders on both sides as a matching signal', () => {
const a = makeFingerprint({ musicFolders: [], userId: null, licenseKey: null, indexesDigest: null });
const b = makeFingerprint({ musicFolders: [], userId: null, licenseKey: null, indexesDigest: null });
expect(compareFingerprints(a, b)).toBe('match');
});
it('mismatches when userId differs', () => {
const a = makeFingerprint({ userId: 'tester' });
const b = makeFingerprint({ userId: 'maria' });
expect(compareFingerprints(a, b)).toBe('mismatch');
});
it('mismatches when licenseKey differs', () => {
const a = makeFingerprint({ licenseKey: 'a@example.com' });
const b = makeFingerprint({ licenseKey: 'b@example.com' });
expect(compareFingerprints(a, b)).toBe('mismatch');
});
it('mismatches when indexesDigest differs', () => {
const a = makeFingerprint({ indexesDigest: 'letters:5|x,y' });
const b = makeFingerprint({ indexesDigest: 'letters:5|x,z' });
expect(compareFingerprints(a, b)).toBe('mismatch');
});
it('ignores a body signal that is null on one side', () => {
// userId null on a — only license + folders + indexes contribute; they match.
const a = makeFingerprint({ userId: null });
const b = makeFingerprint({ userId: 'tester' });
expect(compareFingerprints(a, b)).toBe('match');
});
it('returns insufficient when ping matches but every body signal is null on at least one side', () => {
const a = makeFingerprint({
musicFolders: null,
userId: null,
licenseKey: null,
indexesDigest: null,
});
const b = makeFingerprint();
expect(compareFingerprints(a, b)).toBe('insufficient');
});
});
describe('fetchServerFingerprint', () => {
beforeEach(() => {
vi.mocked(apiWithCredentials).mockReset();
vi.stubGlobal('fetch', vi.fn());
});
it('returns a populated fingerprint for a Navidrome-shaped server', async () => {
vi.mocked(fetch).mockResolvedValue(
jsonResponse(navidromePingResponse()),
);
vi.mocked(apiWithCredentials)
.mockResolvedValueOnce({ musicFolders: { musicFolder: [{ id: '1', name: 'Music' }] } })
.mockResolvedValueOnce({ user: { id: 'tester', username: 'tester' } })
.mockResolvedValueOnce({ license: { email: 'tester@example.com' } })
.mockResolvedValueOnce({
indexes: {
index: [
{ name: 'A', artist: [{ id: 'a1' }, { id: 'a2' }] },
{ name: 'B', artist: [{ id: 'b1' }] },
],
},
});
const fp = await fetchServerFingerprint('https://music.example.com', 'tester', 'pw');
expect(fp.ping.type).toBe('navidrome');
expect(fp.ping.serverVersion).toBe('0.55.0');
expect(fp.ping.openSubsonic).toBe(true);
expect(fp.ping.apiVersion).toBe('1.16.1');
expect(fp.musicFolders).toEqual([{ id: '1', name: 'Music' }]);
expect(fp.userId).toBe('tester');
expect(fp.licenseKey).toBe('tester@example.com');
expect(fp.indexesDigest).toMatch(/^letters:2\|/);
});
it('extracts userId only when the server supplies an explicit id (no username fallback)', async () => {
// Two servers return the same authenticated user but only one of them
// surfaces a `user.id` — the username-only side must extract null so the
// comparator skips the signal instead of comparing two unrelated strings.
vi.mocked(fetch).mockResolvedValue(jsonResponse(navidromePingResponse()));
vi.mocked(apiWithCredentials)
.mockResolvedValueOnce({ musicFolders: { musicFolder: [] } })
.mockResolvedValueOnce({ user: { username: 'tester' } }) // no `id` field
.mockResolvedValueOnce({})
.mockResolvedValueOnce({});
const fp = await fetchServerFingerprint('https://x.example.com', 'tester', 'pw');
expect(fp.userId).toBeNull();
});
it('handles a partial-fail mix — some optional calls ok, others rejected', async () => {
// Real-world: a server answers getMusicFolders + getUser but rejects
// getLicense / getIndexes (subset of OpenSubsonic supported). The
// fingerprint must surface what succeeded and leave the rest null —
// guards against a Promise.all-vs-allSettled regression that would
// collapse the whole fingerprint when one call throws.
vi.mocked(fetch).mockResolvedValue(jsonResponse(navidromePingResponse()));
vi.mocked(apiWithCredentials)
.mockResolvedValueOnce({ musicFolders: { musicFolder: [{ id: '1', name: 'Music' }] } })
.mockResolvedValueOnce({ user: { id: 'tester' } })
.mockRejectedValueOnce(new Error('license not implemented'))
.mockRejectedValueOnce(new Error('indexes not implemented'));
const fp = await fetchServerFingerprint('https://music.example.com', 'tester', 'pw');
expect(fp.musicFolders).toEqual([{ id: '1', name: 'Music' }]);
expect(fp.userId).toBe('tester');
expect(fp.licenseKey).toBeNull();
expect(fp.indexesDigest).toBeNull();
});
it('soft-fails optional calls — minimal Subsonic-shape', async () => {
vi.mocked(fetch).mockResolvedValue(
jsonResponse(ampachePingResponse()),
);
// All four optional calls fail (server doesn't support them).
vi.mocked(apiWithCredentials).mockRejectedValue(new Error('Not implemented'));
const fp = await fetchServerFingerprint('https://ampache.example.com', 'u', 'p');
expect(fp.ping.type).toBe('ampache');
expect(fp.ping.serverVersion).toBe('6.0.0');
expect(fp.ping.openSubsonic).toBe(false);
expect(fp.musicFolders).toBeNull();
expect(fp.userId).toBeNull();
expect(fp.licenseKey).toBeNull();
expect(fp.indexesDigest).toBeNull();
});
it('returns a null-bodied fingerprint when ping itself fails', async () => {
vi.mocked(fetch).mockResolvedValue(jsonResponse(null, false, 500));
const fp = await fetchServerFingerprint('https://broken.example.com', 'u', 'p');
expect(fp.ping.type).toBeNull();
expect(fp.ping.serverVersion).toBeNull();
expect(fp.musicFolders).toBeNull();
expect(fp.userId).toBeNull();
// Optional calls are skipped entirely once ping fails.
expect(apiWithCredentials).not.toHaveBeenCalled();
});
});
describe('verifySameServerEndpoints', () => {
beforeEach(() => {
vi.mocked(apiWithCredentials).mockReset();
vi.stubGlobal('fetch', vi.fn());
});
it('short-circuits to ok:true for a single-address profile', async () => {
const result = await verifySameServerEndpoints(
{ url: 'https://music.example.com' },
'u',
'p',
);
expect(result).toEqual({ ok: true });
expect(fetch).not.toHaveBeenCalled();
});
it('returns ok:true when both endpoints fingerprint the same server', async () => {
// Both pings succeed; both return identical Navidrome payloads.
vi.mocked(fetch).mockResolvedValue(
jsonResponse(navidromePingResponse()),
);
vi.mocked(apiWithCredentials).mockResolvedValue({
musicFolders: { musicFolder: [{ id: '1', name: 'Music' }] },
});
const result = await verifySameServerEndpoints(
{
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10',
},
'u',
'p',
);
expect(result).toEqual({ ok: true });
});
it('returns unreachable when one endpoint ping fails', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(jsonResponse(navidromePingResponse()))
.mockResolvedValueOnce(jsonResponse(null, false, 500));
vi.mocked(apiWithCredentials).mockResolvedValue({});
const result = await verifySameServerEndpoints(
{
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10',
},
'u',
'p',
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('unreachable');
expect(result.unreachableHost).toBe('http://192.168.0.10');
}
});
it('returns mismatch when fingerprints disagree on a body signal', async () => {
vi.mocked(fetch).mockResolvedValue(
jsonResponse(navidromePingResponse()),
);
// Two different folder lists.
vi.mocked(apiWithCredentials)
.mockResolvedValueOnce({ musicFolders: { musicFolder: [{ id: '1', name: 'Music' }] } })
.mockResolvedValueOnce({ user: { id: 'tester' } })
.mockResolvedValueOnce({ license: { email: 'a@e.com' } })
.mockResolvedValueOnce({
indexes: { index: [{ name: 'A', artist: [{ id: 'a1' }] }] },
})
.mockResolvedValueOnce({ musicFolders: { musicFolder: [{ id: '99', name: 'Other' }] } })
.mockResolvedValueOnce({ user: { id: 'tester' } })
.mockResolvedValueOnce({ license: { email: 'a@e.com' } })
.mockResolvedValueOnce({
indexes: { index: [{ name: 'A', artist: [{ id: 'a1' }] }] },
});
const result = await verifySameServerEndpoints(
{
url: 'https://a.example.com',
alternateUrl: 'https://b.example.com',
},
'u',
'p',
);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe('mismatch');
});
it('returns insufficient when pings agree but no body signal is comparable', async () => {
vi.mocked(fetch).mockResolvedValue(
jsonResponse(ampachePingResponse()),
);
// Both servers don't respond to any optional call.
vi.mocked(apiWithCredentials).mockRejectedValue(new Error('Not implemented'));
const result = await verifySameServerEndpoints(
{
url: 'https://ampache-1.example.com',
alternateUrl: 'https://ampache-2.example.com',
},
'u',
'p',
);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe('insufficient');
});
});
+385
View File
@@ -0,0 +1,385 @@
/**
* Same-server verification for dual-address profiles.
*
* When a user enters both a LAN and a public address for "their" server, the
* UI must refuse to save the pair if the two addresses are actually different
* boxes (e.g. typo, copy-paste mishap, two different Navidromes). We probe
* each address for an **idempotent fingerprint** a small read-only snapshot
* of server identity and compare them.
*
* Subsonic-generic by design: no branch on `type === 'navidrome'`. Whatever
* the server reports for `ping.view` plus the optional folders / user /
* license / indexes calls defines the fingerprint. Servers that only respond
* to `ping.view` produce an "insufficient" result, which blocks save in v1
* (no "save anyway" escape hatch see spec §7.3).
*/
import md5 from 'md5';
import {
apiWithCredentials,
restBaseFromUrl,
secureRandomSalt,
SUBSONIC_CLIENT,
type ServerHttpHeaderProfile,
} from '@/lib/api/subsonicClient';
import type { ServerProfile } from '@/store/authStoreTypes';
import { allNormalizedAddresses } from '@/lib/server/serverEndpoint';
import { headersForServerRequest } from '@/lib/server/serverHttpHeaders';
export type ServerFingerprint = {
ping: {
type: string | null;
serverVersion: string | null;
openSubsonic: boolean;
apiVersion: string | null;
};
musicFolders: Array<{ id: string; name: string }> | null;
userId: string | null;
/** Normalized lowercased email if present, else null. */
licenseKey: string | null;
indexesDigest: string | null;
};
export type FingerprintCompareResult = 'match' | 'mismatch' | 'insufficient';
export type VerifySameServerResult =
| { ok: true }
| {
ok: false;
reason: 'mismatch' | 'insufficient' | 'unreachable';
unreachableHost?: string;
};
// ─── ping (with envelope `version`) ──────────────────────────────────────────
//
// `pingWithCredentials` in api/subsonic.ts drops the envelope `version`. We
// need it for the fingerprint (informational, per spec §7.3) so we do the
// ping call here against the same Subsonic shape. Failure → throws.
type PingFingerprint = ServerFingerprint['ping'] & { ok: boolean };
async function fetchPingFingerprint(
baseUrl: string,
username: string,
password: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<PingFingerprint> {
// Mirrors pingWithCredentials but also extracts envelope `version`.
// Using fetch (the bundled axios pulls in extra noise here; one call is fine).
const salt = secureRandomSalt();
const token = md5(password + salt);
const params = new URLSearchParams({
u: username,
t: token,
s: salt,
v: '1.16.1',
c: SUBSONIC_CLIENT,
f: 'json',
});
const url = `${restBaseFromUrl(baseUrl)}/ping.view?${params.toString()}`;
const profileForHeaders: ServerHttpHeaderProfile = headerProfile ?? {
url: baseUrl,
alternateUrl: undefined,
customHeaders: undefined,
customHeadersApplyTo: undefined,
};
let body: Record<string, unknown> | null;
try {
const resp = await fetch(url, {
method: 'GET',
headers: headersForServerRequest(profileForHeaders, baseUrl),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const json = (await resp.json()) as Record<string, unknown>;
body = (json?.['subsonic-response'] as Record<string, unknown>) ?? null;
} catch {
return {
ok: false,
type: null,
serverVersion: null,
openSubsonic: false,
apiVersion: null,
};
}
if (!body) {
return {
ok: false,
type: null,
serverVersion: null,
openSubsonic: false,
apiVersion: null,
};
}
const ok = body.status === 'ok';
return {
ok,
type: typeof body.type === 'string' ? body.type : null,
serverVersion: typeof body.serverVersion === 'string' ? body.serverVersion : null,
openSubsonic: body.openSubsonic === true,
apiVersion: typeof body.version === 'string' ? body.version : null,
};
}
// ─── optional fingerprint calls (failures soft-fail to null) ─────────────────
function extractMusicFolders(data: unknown): Array<{ id: string; name: string }> | null {
const folders = (data as Record<string, unknown> | null)?.['musicFolders'] as
| { musicFolder?: Array<{ id: unknown; name: unknown }> }
| undefined;
const list = folders?.musicFolder;
if (!Array.isArray(list)) return null;
const normalized = list
.map(f => ({ id: String(f.id ?? ''), name: typeof f.name === 'string' ? f.name : '' }))
.filter(f => f.id !== '');
// Sort by id so order differences from the server don't trip the compare.
normalized.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
return normalized;
}
function extractUserId(data: unknown): string | null {
const user = (data as Record<string, unknown> | null)?.['user'] as
| Record<string, unknown>
| undefined;
if (!user) return null;
// Only count the server-supplied id as a signal. A username-only fallback
// (spec §7.2 footnote) would create false `mismatch` results when one
// endpoint surfaces an explicit id and the other only returns the
// username we already authenticated with — both sides would carry a
// value and the comparator would compare unrelated strings.
const explicit = typeof user.id === 'string' ? user.id.trim() : '';
return explicit || null;
}
function extractLicenseEmail(data: unknown): string | null {
const license = (data as Record<string, unknown> | null)?.['license'] as
| Record<string, unknown>
| undefined;
if (!license) return null;
const email = typeof license.email === 'string' ? license.email.trim().toLowerCase() : '';
return email || null;
}
function extractIndexesDigest(data: unknown): string | null {
const indexes = (data as Record<string, unknown> | null)?.['indexes'] as
| Record<string, unknown>
| undefined;
if (!indexes) return null;
const letters = Array.isArray(indexes.index) ? (indexes.index as Array<Record<string, unknown>>) : [];
if (letters.length === 0) return null;
const letterCount = letters.length;
const artistIds: string[] = [];
for (const letter of letters) {
const artists = Array.isArray(letter.artist) ? (letter.artist as Array<Record<string, unknown>>) : [];
for (const artist of artists) {
const id = typeof artist.id === 'string' ? artist.id : null;
if (id) artistIds.push(id);
if (artistIds.length >= 20) break;
}
if (artistIds.length >= 20) break;
}
if (artistIds.length === 0) return `letters:${letterCount}|`;
artistIds.sort();
return `letters:${letterCount}|${artistIds.slice(0, 20).join(',')}`;
}
// ─── fetchServerFingerprint ──────────────────────────────────────────────────
export async function fetchServerFingerprint(
baseUrl: string,
username: string,
password: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<ServerFingerprint> {
// Ping is required — but we still build a (mostly-null) fingerprint when
// it fails so callers can tell the difference between "server unreachable"
// (verify reports `unreachable`) and "server reachable but disagrees"
// (verify reports `mismatch` / `insufficient`).
const ping = await fetchPingFingerprint(baseUrl, username, password, headerProfile);
// The optional calls only make sense once ping succeeded — without that,
// any subsequent call against the same URL is just wasted bandwidth.
if (!ping.ok) {
return {
ping: {
type: ping.type,
serverVersion: ping.serverVersion,
openSubsonic: ping.openSubsonic,
apiVersion: ping.apiVersion,
},
musicFolders: null,
userId: null,
licenseKey: null,
indexesDigest: null,
};
}
const settled = await Promise.allSettled([
apiWithCredentials<Record<string, unknown>>(
baseUrl,
username,
password,
'getMusicFolders.view',
{},
15000,
headerProfile,
),
apiWithCredentials<Record<string, unknown>>(
baseUrl,
username,
password,
'getUser.view',
{ username },
15000,
headerProfile,
),
apiWithCredentials<Record<string, unknown>>(
baseUrl,
username,
password,
'getLicense.view',
{},
15000,
headerProfile,
),
apiWithCredentials<Record<string, unknown>>(
baseUrl,
username,
password,
'getIndexes.view',
{},
15000,
headerProfile,
),
]);
const [foldersResult, userResult, licenseResult, indexesResult] = settled;
const value = <T>(r: PromiseSettledResult<T>): T | null =>
r.status === 'fulfilled' ? r.value : null;
return {
ping: {
type: ping.type,
serverVersion: ping.serverVersion,
openSubsonic: ping.openSubsonic,
apiVersion: ping.apiVersion,
},
musicFolders: extractMusicFolders(value(foldersResult)),
userId: extractUserId(value(userResult)),
licenseKey: extractLicenseEmail(value(licenseResult)),
indexesDigest: extractIndexesDigest(value(indexesResult)),
};
}
// ─── compareFingerprints ─────────────────────────────────────────────────────
/**
* Strict comparison rule for the ping triple, plus a "common-signals" rule
* for the body. Envelope `apiVersion` is informational only and never causes
* mismatch on its own (spec §7.3).
*
* `match` every common signal agrees + at least one common body signal
* `mismatch` any common signal differs (ping or body)
* `insufficient` pings ok but no body signal is present on both sides
*/
export function compareFingerprints(
a: ServerFingerprint,
b: ServerFingerprint,
): FingerprintCompareResult {
// Ping strictness — these MUST agree once both pings succeeded. (Callers
// upstream of compareFingerprints handle the "ping failed" case as
// `unreachable`.)
const aType = a.ping.type?.trim().toLowerCase() ?? null;
const bType = b.ping.type?.trim().toLowerCase() ?? null;
if (aType !== bType) return 'mismatch';
const aVersion = a.ping.serverVersion ?? null;
const bVersion = b.ping.serverVersion ?? null;
if (aVersion !== bVersion) return 'mismatch';
if (a.ping.openSubsonic !== b.ping.openSubsonic) return 'mismatch';
// Body signals — only count when both sides have a value. Empty array on
// both sides for musicFolders is itself a matching signal (spec §7.3).
let common = 0;
// musicFolders — array equality after sort (extract already sorts).
if (a.musicFolders !== null && b.musicFolders !== null) {
common += 1;
if (!musicFoldersEqual(a.musicFolders, b.musicFolders)) return 'mismatch';
}
if (a.userId !== null && b.userId !== null) {
common += 1;
if (a.userId !== b.userId) return 'mismatch';
}
if (a.licenseKey !== null && b.licenseKey !== null) {
common += 1;
if (a.licenseKey !== b.licenseKey) return 'mismatch';
}
if (a.indexesDigest !== null && b.indexesDigest !== null) {
common += 1;
if (a.indexesDigest !== b.indexesDigest) return 'mismatch';
}
if (common === 0) return 'insufficient';
return 'match';
}
function musicFoldersEqual(
a: Array<{ id: string; name: string }>,
b: Array<{ id: string; name: string }>,
): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const ai = a[i]!;
const bi = b[i]!;
if (ai.id !== bi.id || ai.name !== bi.name) return false;
}
return true;
}
// ─── verifySameServerEndpoints ───────────────────────────────────────────────
/**
* Top-level orchestrator: probe each configured address in parallel, then
* compare the fingerprints pairwise. Single-address profiles short-circuit to
* `ok: true` nothing to verify.
*/
export async function verifySameServerEndpoints(
profile: Pick<
ServerProfile,
'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
>,
username: string,
password: string,
): Promise<VerifySameServerResult> {
const endpoints = allNormalizedAddresses(profile);
if (endpoints.length <= 1) return { ok: true };
const fingerprints = await Promise.all(
endpoints.map(baseUrl => fetchServerFingerprint(baseUrl, username, password, profile)),
);
// If any ping failed → unreachable (with the offending host for the UI).
for (let i = 0; i < endpoints.length; i++) {
if (!fingerprints[i]!.ping.type && !fingerprints[i]!.ping.serverVersion) {
// ping.type/serverVersion are null only when the ping itself failed
// (fetchPingFingerprint zeroes everything on failure).
return { ok: false, reason: 'unreachable', unreachableHost: endpoints[i]! };
}
}
// Compare every pair. N=2 in v1 (spec §6), but the loop generalises.
for (let i = 0; i < fingerprints.length; i++) {
for (let j = i + 1; j < fingerprints.length; j++) {
const result = compareFingerprints(fingerprints[i]!, fingerprints[j]!);
if (result === 'mismatch') return { ok: false, reason: 'mismatch' };
if (result === 'insufficient') return { ok: false, reason: 'insufficient' };
}
}
return { ok: true };
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import {
headersForServerRequest,
requestBaseUrlFromHttpUrl,
serverCustomHeadersFromForm,
validateCustomHeaders,
} from '@/lib/server/serverHttpHeaders';
describe('requestBaseUrlFromHttpUrl', () => {
it('strips /rest/ path and query from stream URLs', () => {
expect(
requestBaseUrlFromHttpUrl(
'https://music.example.com/rest/stream.view?id=1&u=x&t=y&s=z',
),
).toBe('https://music.example.com');
});
it('strips /api/ Navidrome paths', () => {
expect(requestBaseUrlFromHttpUrl('https://nd.local/api/album')).toBe('https://nd.local');
});
});
describe('headersForServerRequest', () => {
const profile = {
url: 'https://music.example.com',
alternateUrl: 'http://192.168.1.10:4533',
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'secret' }],
customHeadersApplyTo: 'public' as const,
};
it('applies headers on public endpoint only when applyTo is public', () => {
expect(headersForServerRequest(profile, 'https://music.example.com')).toEqual({
'CF-Access-Client-Secret': 'secret',
});
expect(headersForServerRequest(profile, 'http://192.168.1.10:4533')).toEqual({});
});
it('returns empty for foreign base URL', () => {
expect(headersForServerRequest(profile, 'https://other.example.com')).toEqual({});
});
});
describe('validateCustomHeaders', () => {
it('rejects blocked header names', () => {
const result = validateCustomHeaders([{ name: 'Host', value: 'x' }]);
expect(result.ok).toBe(false);
});
it('accepts valid rows', () => {
expect(
validateCustomHeaders([{ name: 'X-Custom', value: 'ok' }]),
).toEqual({ ok: true });
});
});
describe('serverCustomHeadersFromForm', () => {
it('returns empty object when all rows are blank', () => {
expect(serverCustomHeadersFromForm([{ name: '', value: '' }], 'public')).toEqual({});
});
it('trims and returns profile fields for non-empty rows', () => {
expect(
serverCustomHeadersFromForm([{ name: ' X-Gate ', value: 'secret' }], 'public'),
).toEqual({
customHeaders: [{ name: 'X-Gate', value: 'secret' }],
customHeadersApplyTo: 'public',
});
});
});
+164
View File
@@ -0,0 +1,164 @@
import type {
CustomHeaderEntry,
CustomHeadersApplyTo,
CustomHeadersFieldError,
CustomHeadersValidationResult,
ServerProfile,
} from '@/store/authStoreTypes';
import { serverIndexKeyForProfile } from '@/lib/server/serverIndexKey';
import { normalizeServerBaseUrl, serverAddressEndpoints, type ServerEndpointKind } from '@/lib/server/serverEndpoint';
export const DEFAULT_CUSTOM_HEADERS_APPLY_TO: CustomHeadersApplyTo = 'public';
export const CUSTOM_HEADER_NAME_BLOCKLIST = new Set([
'host',
'content-length',
'transfer-encoding',
'connection',
'cookie',
]);
const MAX_CUSTOM_HEADERS = 16;
const MAX_HEADER_NAME_LEN = 256;
const MAX_HEADER_VALUE_LEN = 8192;
export function normalizeHeaderName(name: string): string {
return name.trim();
}
export function requestBaseUrlFromHttpUrl(rawUrl: string): string {
const trimmed = rawUrl.trim();
if (!trimmed) return '';
try {
const parsed = new URL(trimmed.startsWith('http') ? trimmed : `http://${trimmed}`);
parsed.search = '';
parsed.hash = '';
let path = parsed.pathname;
const restIdx = path.indexOf('/rest/');
if (restIdx >= 0 || path.endsWith('/rest')) {
path = path.slice(0, restIdx >= 0 ? restIdx : path.length - '/rest'.length);
} else {
for (const seg of ['/api/', '/auth/'] as const) {
const idx = path.indexOf(seg);
if (idx >= 0) {
path = path.slice(0, idx);
break;
}
}
}
if (path.endsWith('/') && path.length > 1) path = path.replace(/\/+$/, '');
parsed.pathname = path || '/';
const origin = `${parsed.protocol}//${parsed.host}${path === '/' ? '' : path}`;
return normalizeServerBaseUrl(origin);
} catch {
return normalizeServerBaseUrl(trimmed);
}
}
export function validateCustomHeaders(
headers: CustomHeaderEntry[] | undefined,
): CustomHeadersValidationResult {
if (!headers?.length) return { ok: true };
if (headers.length > MAX_CUSTOM_HEADERS) {
return {
ok: false,
fieldErrors: [{ index: 0, field: 'name', messageKey: 'settings.customHeadersValidation.tooMany' }],
};
}
const fieldErrors: CustomHeadersFieldError[] = [];
const seen = new Set<string>();
headers.forEach((row, index) => {
const name = normalizeHeaderName(row.name);
const value = row.value;
if (!name) {
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.nameRequired' });
return;
}
if (name.length > MAX_HEADER_NAME_LEN) {
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.nameTooLong' });
}
if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) {
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.crlf' });
}
if (CUSTOM_HEADER_NAME_BLOCKLIST.has(name.toLowerCase())) {
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.blocked' });
}
const key = name.toLowerCase();
if (seen.has(key)) {
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.duplicate' });
}
seen.add(key);
if (value.length > MAX_HEADER_VALUE_LEN) {
fieldErrors.push({ index, field: 'value', messageKey: 'settings.customHeadersValidation.valueTooLong' });
}
});
if (fieldErrors.length) return { ok: false, fieldErrors };
return { ok: true };
}
/** Non-empty custom header rows from a form editor → profile fields (or omit when empty). */
export function serverCustomHeadersFromForm(
headers: CustomHeaderEntry[],
applyTo: CustomHeadersApplyTo,
): Pick<ServerProfile, 'customHeaders' | 'customHeadersApplyTo'> | Record<string, never> {
const rows = headers
.map(h => ({ name: h.name.trim(), value: h.value }))
.filter(h => h.name || h.value);
if (!rows.length) return {};
return { customHeaders: rows, customHeadersApplyTo: applyTo };
}
function headersRecord(entries: CustomHeaderEntry[]): Record<string, string> {
const out: Record<string, string> = {};
for (const row of entries) {
const name = normalizeHeaderName(row.name);
if (!name) continue;
out[name] = row.value;
}
return out;
}
export function headersForServerRequest(
profile: Pick<
ServerProfile,
'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
>,
requestBaseUrl: string,
): Record<string, string> {
if (!profile.customHeaders?.length) return {};
const normalized = normalizeServerBaseUrl(requestBaseUrl);
const endpoint = serverAddressEndpoints(profile).find(e => e.url === normalized);
if (!endpoint) return {};
const apply = profile.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO;
if (apply === 'both' || apply === endpoint.kind) {
return headersRecord(profile.customHeaders);
}
return {};
}
export type ServerHttpEndpointWire = {
url: string;
kind: ServerEndpointKind;
};
/** Payload for Rust registry sync — endpoint kinds from TS dual-address layer. */
export function serverHttpContextWireForProfile(
server: Pick<
ServerProfile,
'id' | 'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
>,
): {
serverId: string;
appServerId: string;
endpoints: ServerHttpEndpointWire[];
customHeaders: CustomHeaderEntry[];
customHeadersApplyTo: CustomHeadersApplyTo;
} {
return {
serverId: serverIndexKeyForProfile(server),
appServerId: server.id,
endpoints: serverAddressEndpoints(server).map(e => ({ url: e.url, kind: e.kind })),
customHeaders: server.customHeaders ?? [],
customHeadersApplyTo: server.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO,
};
}
+41
View File
@@ -0,0 +1,41 @@
import type { ServerProfile } from '@/store/authStoreTypes';
import { useAuthStore } from '@/store/authStore';
import { serverProfileBaseUrl } from '@/lib/server/serverBaseUrl';
/** Stable index key derived from a server URL (host + optional path, no scheme). */
export function serverIndexKeyFromUrl(urlRaw: string): string {
const base = serverProfileBaseUrl({ url: urlRaw });
return base.replace(/^https?:\/\//, '');
}
export function serverIndexKeyForProfile(server: Pick<ServerProfile, 'url'>): string {
return serverIndexKeyFromUrl(server.url);
}
export function resolveIndexKey(serverIdOrKey: string): string {
const server = useAuthStore.getState().servers.find(s => s.id === serverIdOrKey);
if (!server) return serverIdOrKey;
return serverIndexKeyFromUrl(server.url) || serverIdOrKey;
}
/**
* Canonical key for queue-thin-state writers: returns the URL-derived index key
* for any known server (whether the caller passed the UUID or the index key),
* and leaves unknown / already-canonical values untouched. Idempotent.
*
* Use this on every write path that lands in `QueueItemRef.serverId` or
* `PlayerState.queueServerId`. Reading sides may still receive legacy UUID
* values from persisted blobs; `serverLookup` helpers accept both shapes.
*/
export function canonicalQueueServerKey(serverIdOrKey: string): string {
if (!serverIdOrKey) return serverIdOrKey;
// Defensive: tests sometimes stub `useAuthStore` without seeding `servers`.
// Treat a missing list as "unknown server" rather than crashing the read.
const servers = useAuthStore.getState().servers;
if (!servers) return serverIdOrKey;
const server = servers.find(s => s.id === serverIdOrKey);
if (server) {
return serverIndexKeyFromUrl(server.url) || serverIdOrKey;
}
return serverIdOrKey;
}
+10
View File
@@ -0,0 +1,10 @@
import { useAuthStore } from '@/store/authStore';
import { rewriteFrontendStoreKeys } from '@/utils/server/rewriteFrontendStoreKeys';
/**
* Legacy compatibility shim. The migration lifecycle now runs through
* `useMigrationOrchestrator` + blocking gate.
*/
export async function migrateServerIndexKeysIfNeeded(): Promise<void> {
await rewriteFrontendStoreKeys(useAuthStore.getState().servers);
}
+22
View File
@@ -0,0 +1,22 @@
import { useAuthStore } from '@/store/authStore';
import type { ServerProfile } from '@/store/authStoreTypes';
import { serverIndexKeyForProfile, serverIndexKeyFromUrl } from '@/lib/server/serverIndexKey';
export function findServerByIdOrIndexKey(serverIdOrKey: string): ServerProfile | undefined {
const servers = useAuthStore.getState().servers;
const direct = servers.find(s => s.id === serverIdOrKey);
if (direct) return direct;
return servers.find(s => serverIndexKeyForProfile(s) === serverIdOrKey);
}
export function resolveServerIdForIndexKey(serverIdOrKey: string): string {
const { servers, activeServerId } = useAuthStore.getState();
const direct = servers.find(s => s.id === serverIdOrKey);
if (direct) return direct.id;
const active = servers.find(
s => s.id === activeServerId && serverIndexKeyFromUrl(s.url) === serverIdOrKey,
);
if (active) return active.id;
const fallback = servers.find(s => serverIndexKeyFromUrl(s.url) === serverIdOrKey);
return fallback?.id ?? serverIdOrKey;
}
+251
View File
@@ -0,0 +1,251 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
SERVER_MAGIC_STRING_PREFIX,
DECODED_PASSWORD_VISUAL_MASK,
copyTextToClipboard,
decodeServerMagicString,
decodeServerMagicStringFromText,
encodeServerMagicString,
} from '@/lib/server/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 an unknown version', () => {
// v: 3 is out of range; v1 + v2 are both accepted.
const wrongVersion = btoa(JSON.stringify({ v: 3, 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();
});
// ─── v2 (dual-address) ──────────────────────────────────────────────────
it('emits v1 for a single-address invite (backward-compatible)', () => {
// No alternateUrl, no shareUsesLocalUrl → v1 wire format. Verified by
// round-tripping through a v1-decode of the inner JSON.
const encoded = encodeServerMagicString({
url: 'https://music.example.com',
username: 'alice',
password: 'pw',
});
const b64 = encoded.slice(SERVER_MAGIC_STRING_PREFIX.length);
const b64Std = b64.replace(/-/g, '+').replace(/_/g, '/');
const padded = b64Std + '='.repeat((4 - (b64Std.length % 4)) % 4);
const inner = JSON.parse(atob(padded));
expect(inner.v).toBe(1);
expect(inner.alt).toBeUndefined();
expect(inner.shareLocal).toBeUndefined();
});
it('emits v2 when alternateUrl is set', () => {
const encoded = encodeServerMagicString({
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10:4533',
username: 'alice',
password: 'pw',
});
const b64 = encoded.slice(SERVER_MAGIC_STRING_PREFIX.length);
const b64Std = b64.replace(/-/g, '+').replace(/_/g, '/');
const padded = b64Std + '='.repeat((4 - (b64Std.length % 4)) % 4);
const inner = JSON.parse(atob(padded));
expect(inner.v).toBe(2);
expect(inner.url).toBe('https://music.example.com');
expect(inner.alt).toBe('http://192.168.0.10:4533');
expect(inner.shareLocal).toBeUndefined();
});
it('emits v2 when only shareUsesLocalUrl is set (no alt)', () => {
// Edge: the host has dropped the second address but kept the share flag
// on. We still emit v2 so the receiver picks up the preference.
const encoded = encodeServerMagicString({
url: 'https://music.example.com',
username: 'alice',
password: 'pw',
shareUsesLocalUrl: true,
});
const b64 = encoded.slice(SERVER_MAGIC_STRING_PREFIX.length);
const b64Std = b64.replace(/-/g, '+').replace(/_/g, '/');
const padded = b64Std + '='.repeat((4 - (b64Std.length % 4)) % 4);
const inner = JSON.parse(atob(padded));
expect(inner.v).toBe(2);
expect(inner.alt).toBeUndefined();
expect(inner.shareLocal).toBe(true);
});
it('round-trips v2 with alternateUrl + shareUsesLocalUrl + name', () => {
const original = {
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10:4533',
shareUsesLocalUrl: true,
username: 'alice',
password: 'pw',
name: 'Home',
};
const encoded = encodeServerMagicString(original);
expect(decodeServerMagicString(encoded)).toEqual(original);
});
it('round-trips v2 with alternateUrl only (default share flag absent)', () => {
const original = {
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10:4533',
username: 'alice',
password: 'pw',
};
const decoded = decodeServerMagicString(encodeServerMagicString(original));
expect(decoded).toEqual(original);
// shareUsesLocalUrl must NOT be set on the decoded payload when the
// host didn't flip the flag — kept absent rather than `false` so
// existing zustand persist diffs stay clean.
expect(decoded?.shareUsesLocalUrl).toBeUndefined();
});
it('decodes a v1 invite without alternateUrl / shareUsesLocalUrl', () => {
// Hand-built v1 payload → verify backward-compat decode path leaves the
// v2-only fields undefined.
const v1 = btoa(JSON.stringify({ v: 1, url: 'https://x.example', u: 'u', w: 'p' }))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const decoded = decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + v1);
expect(decoded?.url).toBe('https://x.example');
expect(decoded?.alternateUrl).toBeUndefined();
expect(decoded?.shareUsesLocalUrl).toBeUndefined();
});
it('treats an empty alt field on v2 as absent (no alternateUrl)', () => {
// Defensive — a misformed v2 invite with `alt: ''` should decode as if
// no alternate were set, not as an empty-string alternateUrl that
// would later fail isLanUrl / normalize checks.
const v2 = btoa(JSON.stringify({
v: 2, url: 'https://x.example', alt: ' ', u: 'u', w: 'p',
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const decoded = decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + v2);
expect(decoded?.alternateUrl).toBeUndefined();
});
});
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);
});
});
+196
View File
@@ -0,0 +1,196 @@
import type { ServerProfile } from '@/store/authStoreTypes';
import { normalizeServerBaseUrl, serverShareBaseUrl } from '@/lib/server/serverEndpoint';
/**
* 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 {
/**
* **v1:** the host's primary URL (also the connect URL for single-address
* profiles).
* **v2:** the host's **share URL** public by default when both
* addresses are set, or the local address if `shareUsesLocalUrl` flips it.
* The receiver takes whichever they got and treats it as the primary URL
* of the new profile (their own index key).
*/
url: string;
/**
* v2 only the host's alternate address. Empty / absent when the host
* has a single-address profile or chose to share a v1 invite.
*/
alternateUrl?: string;
/**
* v2 only the host's preference for which address goes into future
* share links from the receiver's side. Mirrors the source profile's
* checkbox so a guest's onward shares behave the same way.
*/
shareUsesLocalUrl?: boolean;
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.
*
* Emits **v2** when the payload carries an `alternateUrl` or
* `shareUsesLocalUrl=true` (the dual-address shape spec §10). Falls back
* to **v1** otherwise, byte-identical with the pre-dual-address format so
* older receivers keep working.
*/
export function encodeServerMagicString(p: ServerMagicPayload): string {
const alt = p.alternateUrl?.trim() ?? '';
const useV2 = alt.length > 0 || p.shareUsesLocalUrl === true;
const base = {
url: p.url.trim(),
u: p.username,
w: p.password,
...(p.name?.trim() ? { n: p.name.trim() } : {}),
};
if (useV2) {
const v2 = {
v: 2 as const,
...base,
...(alt ? { alt } : {}),
...(p.shareUsesLocalUrl ? { shareLocal: true as const } : {}),
};
return SERVER_MAGIC_STRING_PREFIX + utf8ToBase64Url(JSON.stringify(v2));
}
const v1 = { v: 1 as const, ...base };
return SERVER_MAGIC_STRING_PREFIX + utf8ToBase64Url(JSON.stringify(v1));
}
/**
* 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);
}
/**
* Decode either a v1 or v2 invite. v2 surfaces `alternateUrl` + the share
* preference; v1 leaves those fields undefined so single-address profiles
* keep the same persisted shape.
*/
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 && o.v !== 2) 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;
if (o.v === 2) {
const alt = typeof o.alt === 'string' ? o.alt.trim() : '';
return {
url,
...(alt ? { alternateUrl: alt } : {}),
...(o.shareLocal === true ? { shareUsesLocalUrl: true } : {}),
username,
password,
name,
};
}
return { url, username, password, name };
}
/**
* Pick out the dual-address fields for the saved profile whose primary URL
* or `alternateUrl` matches the given `serverUrl`. Returns the share URL +
* the alternate + the share flag when those exist; falls back to just the
* raw `serverUrl` for legacy / single-address profiles (v1 wire shape).
*
* Shared by `MagicStringModal` and `UserForm` (the two places we encode
* server invites from). The lookup is normalize-aware so it tolerates
* trailing-slash / scheme differences between the input URL and the saved
* profile's URL.
*/
export function magicPayloadAddressFields(
serverUrl: string,
servers: ServerProfile[],
): {
url: string;
alternateUrl?: string;
shareUsesLocalUrl?: boolean;
} {
const normalized = normalizeServerBaseUrl(serverUrl);
const match = servers.find(
s =>
normalizeServerBaseUrl(s.url) === normalized ||
(s.alternateUrl != null && normalizeServerBaseUrl(s.alternateUrl) === normalized),
);
if (!match) return { url: serverUrl };
return {
url: serverShareBaseUrl(match),
...(match.alternateUrl ? { alternateUrl: match.alternateUrl } : {}),
...(match.shareUsesLocalUrl ? { shareUsesLocalUrl: true } : {}),
};
}
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;
}
}
}
+155
View File
@@ -0,0 +1,155 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock the Tauri core invoke surface — the orchestrator calls
// `migration_inspect`, `migration_run`, and `cover_cache_rename_server_bucket`
// through this single entry point.
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
}));
import { invoke } from '@tauri-apps/api/core';
import {
indexKeyRemapForUrlChange,
runIndexKeyRemigration,
} from '@/lib/server/serverUrlRemigration';
function inspectStub(needs = true) {
return {
needsMigration: needs,
hasSkippedUnknownServerRows: false,
canRun: true,
warnings: [],
unmappedEmptyBucket: false,
library: { totalLegacyRows: 100, skippedUnknownServerRows: 0, tables: {} },
analysis: { totalLegacyRows: 50, skippedUnknownServerRows: 0, tables: {} },
mappings: [{ legacyId: 'old.example.com', indexKey: 'new.example.com' }],
};
}
function runStub() {
return {
library: { importedRows: 100, sourceRows: 100, skippedUnknownServerRows: 0 },
analysis: { importedRows: 50, sourceRows: 50, skippedUnknownServerRows: 0 },
hasSkippedUnknownServerRows: false,
switched: true,
backupRemoved: true,
};
}
describe('indexKeyRemapForUrlChange', () => {
it('returns null when both urls collapse to the same index key', () => {
expect(
indexKeyRemapForUrlChange({ url: 'https://music.example.com' }, { url: 'http://music.example.com/' }),
).toBeNull();
});
it('returns null when only the scheme changes', () => {
expect(
indexKeyRemapForUrlChange({ url: 'http://music.example.com' }, { url: 'https://music.example.com' }),
).toBeNull();
});
it('returns null for missing urls', () => {
expect(indexKeyRemapForUrlChange({ url: '' }, { url: 'https://x.example' })).toBeNull();
expect(indexKeyRemapForUrlChange({ url: 'https://x.example' }, { url: '' })).toBeNull();
});
it('returns the remap when the host changes', () => {
expect(
indexKeyRemapForUrlChange(
{ url: 'https://old.example.com' },
{ url: 'https://new.example.com' },
),
).toEqual({ oldKey: 'old.example.com', newKey: 'new.example.com' });
});
it('returns the remap when the path part changes', () => {
expect(
indexKeyRemapForUrlChange(
{ url: 'https://music.example.com' },
{ url: 'https://music.example.com/navidrome' },
),
).toEqual({ oldKey: 'music.example.com', newKey: 'music.example.com/navidrome' });
});
});
describe('runIndexKeyRemigration', () => {
beforeEach(() => {
vi.mocked(invoke).mockReset();
});
it('runs the full pipeline on the happy path', async () => {
vi.mocked(invoke)
.mockResolvedValueOnce(inspectStub()) // migration_inspect
.mockResolvedValueOnce(runStub()) // migration_run
.mockResolvedValueOnce(undefined); // cover_cache_rename_server_bucket
const result = await runIndexKeyRemigration({ oldKey: 'old', newKey: 'new' });
expect(result.ok).toBe(true);
expect(invoke).toHaveBeenCalledTimes(3);
expect(vi.mocked(invoke).mock.calls[0]![0]).toBe('migration_inspect');
expect(vi.mocked(invoke).mock.calls[1]![0]).toBe('migration_run');
expect(vi.mocked(invoke).mock.calls[2]![0]).toBe('cover_cache_rename_server_bucket');
expect(vi.mocked(invoke).mock.calls[2]![1]).toEqual({ oldKey: 'old', newKey: 'new' });
});
it('hands the same { legacyId, indexKey } mapping to inspect + run', async () => {
vi.mocked(invoke)
.mockResolvedValueOnce(inspectStub())
.mockResolvedValueOnce(runStub())
.mockResolvedValueOnce(undefined);
await runIndexKeyRemigration({ oldKey: 'old.example.com', newKey: 'new.example.com' });
const inspectCall = vi.mocked(invoke).mock.calls[0]!;
const runCall = vi.mocked(invoke).mock.calls[1]!;
expect(inspectCall[1]).toEqual({
mappings: [{ legacyId: 'old.example.com', indexKey: 'new.example.com' }],
});
expect(runCall[1]).toEqual({
mappings: [{ legacyId: 'old.example.com', indexKey: 'new.example.com' }],
});
});
it('reports inspect failure and stops without running the destructive step', async () => {
vi.mocked(invoke).mockRejectedValueOnce(new Error('locked')); // inspect throws
const result = await runIndexKeyRemigration({ oldKey: 'old', newKey: 'new' });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.failure.stage).toBe('inspect');
expect(result.failure.error).toContain('locked');
}
expect(invoke).toHaveBeenCalledTimes(1); // only inspect ran
});
it('reports run failure and does not attempt the cover rename', async () => {
vi.mocked(invoke)
.mockResolvedValueOnce(inspectStub())
.mockRejectedValueOnce(new Error('disk full'));
const result = await runIndexKeyRemigration({ oldKey: 'old', newKey: 'new' });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.failure.stage).toBe('run');
expect(invoke).toHaveBeenCalledTimes(2); // inspect + failed run
});
it('reports cover-rename failure (DB rows already moved — recoverable)', async () => {
vi.mocked(invoke)
.mockResolvedValueOnce(inspectStub())
.mockResolvedValueOnce(runStub())
.mockRejectedValueOnce(new Error('rename: permission denied'));
const result = await runIndexKeyRemigration({ oldKey: 'old', newKey: 'new' });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.failure.stage).toBe('cover-rename');
expect(result.failure.error).toContain('permission denied');
}
expect(invoke).toHaveBeenCalledTimes(3); // all three were attempted
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* Primary-URL change remigration moves library / analysis SQLite rows and
* the cover-cache disk bucket from one index key to another when the user
* edits a server profile's primary `url` in a way that changes the derived
* `serverIndexKeyFromUrl(url)`.
*
* Spec §8 ("URL change remigration"). The pipeline:
*
* 1. detect `indexKeyRemapForUrlChange(prev, next)` returns null when the
* index key is unchanged (scheme-only flip, alternateUrl-only edit,
* trailing-slash edit, etc.) so callers can short-circuit without doing
* any work.
* 2. inspect `migration_inspect` confirms how many rows would move (also
* surfaces warnings before the destructive step runs).
* 3. run `migration_run` re-tags the SQLite rows (library + analysis).
* 4. front-end rewrite `rewriteFrontendStoreKeysForRemap` repoints
* offlineStore / hotCacheStore / analysisStrategyStore /
* playerStore.queueServerId.
* 5. cover bucket `cover_cache_rename_server_bucket` moves
* `{cover_root}/{oldKey}/` to `{newKey}/`.
*
* Failures abort early; the caller blocks the profile save and shows the
* user a retry. We never partially commit: the SQLite step is the latest
* destructive step before the front-end rewrite and disk rename, so the
* worst case (DB ok, cover rename fails) leaves the index key consistent
* but covers under the new key may need to be re-downloaded handled by
* the existing cover backfill on next access.
*/
import { invoke } from '@tauri-apps/api/core';
import type { ServerProfile } from '@/store/authStoreTypes';
import {
migrationInspect,
migrationRun,
type MigrationInspectReport,
type MigrationRunResult,
} from '@/api/migration';
import { rewriteFrontendStoreKeysForRemap } from '@/utils/server/rewriteFrontendStoreKeys';
import { serverIndexKeyFromUrl } from '@/lib/server/serverIndexKey';
export type IndexKeyRemap = { oldKey: string; newKey: string };
export type IndexKeyRemigrationFailure =
| { stage: 'inspect'; error: string }
| { stage: 'run'; error: string }
| { stage: 'cover-rename'; error: string };
export type IndexKeyRemigrationResult =
| { ok: true; inspect: MigrationInspectReport; run: MigrationRunResult }
| { ok: false; failure: IndexKeyRemigrationFailure };
/**
* Detect whether a profile edit changes the **index key** derived from the
* primary url. Returns `null` when both sides normalize to the same key
* scheme-only edits (`http://x` `https://x`), trailing-slash differences,
* and any change that only touches `alternateUrl` / credentials / name all
* fall through.
*
* Empty / missing urls return null (no remigration to do).
*/
export function indexKeyRemapForUrlChange(
previous: Pick<ServerProfile, 'url'>,
next: Pick<ServerProfile, 'url'>,
): IndexKeyRemap | null {
const oldKey = serverIndexKeyFromUrl(previous.url ?? '').trim();
const newKey = serverIndexKeyFromUrl(next.url ?? '').trim();
if (!oldKey || !newKey) return null;
if (oldKey === newKey) return null;
return { oldKey, newKey };
}
/**
* Run the four-stage pipeline for a single oldKey newKey remap.
*
* Returns a structured result the caller can branch on UI surfaces the
* failure stage so the user knows whether to retry the DB step, fix
* network, or report a cover-rename bug.
*/
export async function runIndexKeyRemigration(
remap: IndexKeyRemap,
): Promise<IndexKeyRemigrationResult> {
const mappings = [{ legacyId: remap.oldKey, indexKey: remap.newKey }];
let inspect: MigrationInspectReport;
try {
inspect = await migrationInspect(mappings);
} catch (e) {
return { ok: false, failure: { stage: 'inspect', error: String(e) } };
}
let run: MigrationRunResult;
try {
run = await migrationRun(mappings);
} catch (e) {
return { ok: false, failure: { stage: 'run', error: String(e) } };
}
// Frontend stores — this is in-memory only; if zustand throws (it
// shouldn't), the user is left with a DB tagged on newKey and stores on
// oldKey. That recovers on next app start via the existing rehydration
// path, so we don't treat it as a failure here.
try {
await rewriteFrontendStoreKeysForRemap([remap]);
} catch {
/* in-memory rewrite is best-effort; the persisted state catches up at
the next zustand persist tick. */
}
try {
await invoke('cover_cache_rename_server_bucket', {
oldKey: remap.oldKey,
newKey: remap.newKey,
});
} catch (e) {
// Cover rename is the latest step and the most recoverable failure:
// the disk bucket is still under oldKey, library + analysis already
// point at newKey, so covers will look "missing" until the user re-
// triggers a sync or the backfill catches them. Surface the failure
// but don't undo the DB step — that would be far more destructive.
return { ok: false, failure: { stage: 'cover-rename', error: String(e) } };
}
return { ok: true, inspect, run };
}
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import {
formatServerSoftware,
isNavidromeAudiomuseSoftwareEligible,
isNavidromeServer,
parseLeadingSemver,
} from '@/lib/server/subsonicServerIdentity';
describe('parseLeadingSemver', () => {
it('parses Navidrome-style version strings', () => {
expect(parseLeadingSemver('0.62.0 (2026-06-08)')).toEqual([0, 62, 0]);
expect(parseLeadingSemver('v0.61.2')).toEqual([0, 61, 2]);
});
it('returns null for unparseable input', () => {
expect(parseLeadingSemver(undefined)).toBeNull();
expect(parseLeadingSemver('unknown')).toBeNull();
});
});
describe('isNavidromeServer', () => {
it('matches the navidrome type case-insensitively', () => {
expect(isNavidromeServer({ type: 'navidrome' })).toBe(true);
expect(isNavidromeServer({ type: 'Navidrome' })).toBe(true);
expect(isNavidromeServer({ type: 'gonic' })).toBe(false);
expect(isNavidromeServer(undefined)).toBe(false);
});
});
describe('formatServerSoftware', () => {
it('capitalises the type and appends the version', () => {
expect(formatServerSoftware({ type: 'navidrome', serverVersion: '0.62.0' })).toBe('Navidrome 0.62.0');
expect(formatServerSoftware({ type: 'gonic', serverVersion: '0.16.4' })).toBe('Gonic 0.16.4');
});
it('keeps only the leading version token, dropping a build hash', () => {
expect(formatServerSoftware({ type: 'navidrome', serverVersion: '0.62.0 (1b46b977)' })).toBe('Navidrome 0.62.0');
});
it('shows the type alone when no version is reported', () => {
expect(formatServerSoftware({ type: 'subsonic' })).toBe('Subsonic');
});
it('returns null when the server reports no type', () => {
expect(formatServerSoftware(undefined)).toBeNull();
expect(formatServerSoftware({})).toBeNull();
expect(formatServerSoftware({ type: ' ' })).toBeNull();
});
});
describe('isNavidromeAudiomuseSoftwareEligible', () => {
it('is permissive until a typed ping arrives', () => {
expect(isNavidromeAudiomuseSoftwareEligible(undefined)).toBe(true);
expect(isNavidromeAudiomuseSoftwareEligible({})).toBe(true);
});
it('requires Navidrome ≥ 0.60 once metadata is known', () => {
expect(isNavidromeAudiomuseSoftwareEligible({ type: 'navidrome', serverVersion: '0.60.0' })).toBe(true);
expect(isNavidromeAudiomuseSoftwareEligible({ type: 'navidrome', serverVersion: '0.59.9' })).toBe(false);
expect(isNavidromeAudiomuseSoftwareEligible({ type: 'gonic', serverVersion: '1.0.0' })).toBe(false);
});
});
+70
View File
@@ -0,0 +1,70 @@
/** 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';
/**
* Navidrome 0.62 exposes the OpenSubsonic `sonicSimilarity` extension when an audio-similarity
* plugin (e.g. AudioMuse-AI) is active the first reliable plugin signal.
*/
export type AudiomusePluginProbeResult =
| 'probing'
| 'present'
| 'absent'
| 'error';
const NAVIDROME_MIN_FOR_PLUGINS: [number, number, number] = [0, 60, 0];
export 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;
}
export function isNavidromeServer(identity: SubsonicServerIdentity | undefined): boolean {
if (!identity?.type?.trim()) return false;
return identity.type.trim().toLowerCase() === 'navidrome';
}
/**
* Human-facing server software label from a ping identity e.g. `Navidrome 0.62.0`.
* Capitalises the leading word of `type` (OpenSubsonic reports it lower-case) and
* appends the leading version token. Navidrome reports `serverVersion` with a build
* hash (`0.62.0 (1b46b977)`); only the version up to the first space/paren is kept.
* Returns `null` when the server reported no `type` (e.g. plain Subsonic without
* OpenSubsonic), so callers can omit the line.
*/
export function formatServerSoftware(identity: SubsonicServerIdentity | undefined): string | null {
const type = identity?.type?.trim();
if (!type) return null;
const label = type.charAt(0).toUpperCase() + type.slice(1);
const rawVersion = identity?.serverVersion?.trim();
const version = rawVersion ? rawVersion.split(/[\s(]/)[0] : undefined;
return version ? `${label} ${version}` : label;
}
/**
* 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;
if (!isNavidromeServer(identity)) return false;
const parsed = parseLeadingSemver(identity.serverVersion);
if (!parsed) return true;
return semverGte(parsed, NAVIDROME_MIN_FOR_PLUGINS);
}
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const invokeMock = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }));
import { syncServerHttpContextForProfile } from '@/lib/server/syncServerHttpContext';
const server = {
id: 'app-uuid-1',
name: 'Gated',
url: 'https://music.example.com',
username: 'u',
password: 'p',
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'secret' }],
customHeadersApplyTo: 'public' as const,
};
describe('syncServerHttpContextForProfile', () => {
beforeEach(() => {
invokeMock.mockClear();
});
it('passes the wire payload under the wire key for Tauri struct args', async () => {
await syncServerHttpContextForProfile(server);
expect(invokeMock).toHaveBeenCalledTimes(1);
expect(invokeMock).toHaveBeenCalledWith('server_http_context_sync', {
wire: expect.objectContaining({
serverId: expect.any(String),
appServerId: 'app-uuid-1',
customHeaders: server.customHeaders,
customHeadersApplyTo: 'public',
}),
});
});
});
+21
View File
@@ -0,0 +1,21 @@
import { invoke } from '@tauri-apps/api/core';
import type { ServerProfile } from '@/store/authStoreTypes';
import { serverHttpContextWireForProfile } from '@/lib/server/serverHttpHeaders';
import { serverIndexKeyForProfile } from '@/lib/server/serverIndexKey';
export async function syncServerHttpContextForProfile(server: ServerProfile): Promise<void> {
const wire = serverHttpContextWireForProfile(server);
await invoke('server_http_context_sync', { wire });
}
export async function syncAllServerHttpContexts(servers: ServerProfile[]): Promise<void> {
if (servers.length === 0) return;
await invoke('server_http_context_sync_all', {
entries: servers.map(s => serverHttpContextWireForProfile(s)),
});
}
export async function clearServerHttpContext(server: Pick<ServerProfile, 'id' | 'url'>): Promise<void> {
const indexKey = serverIndexKeyForProfile(server);
await invoke('server_http_context_clear', { serverId: indexKey, appServerId: server.id });
}