mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(lib): extract clean server + navigation infra to lib/ (decouple-knot files stay in utils/ pending seam)
This commit is contained in:
@@ -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")]`) ─
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,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}`;
|
||||
|
||||
|
||||
@@ -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,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,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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(/\/$/, '');
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user