fix(settings): surface a specific reason when adding a server fails

Adding a server could fail silently: the form closed before the connect-ping
ran, and a failed ping only set an invisible temp status — no toast, no log. A
reachable-but-rejected server (wrong credentials, protocol version, …) or a
network/TLS problem both vanished with no feedback.

pingWithCredentials now returns a structured failure (auth / version / network
/ server, with the server's own error code + message). The add flow keeps the
form open on failure and shows a specific toast: the server's Subsonic message
for a rejection, or an actionable 'couldn't reach the server — check address,
network, and TLS certificate' for a network/TLS error. Both paths also log.
This commit is contained in:
Psychotoxical
2026-06-10 00:01:40 +02:00
parent a8bed6cc91
commit f133492e5c
13 changed files with 94 additions and 9 deletions
+26 -5
View File
@@ -16,11 +16,20 @@ import {
import { neededProbeIds } from '../serverCapabilities/resolve';
import {
SUBSONIC_CLIENT,
SUBSONIC_API_VERSION,
api,
apiWithCredentials,
secureRandomSalt,
} from './subsonicClient';
import type { PingWithCredentialsResult, SubsonicSong } from './subsonicTypes';
import type { PingFailure, PingWithCredentialsResult, SubsonicSong } from './subsonicTypes';
/** Map a Subsonic error code to a coarse failure category for the UI. */
function classifyPingError(code: number | undefined, message: string | undefined): PingFailure {
let reason: PingFailure['reason'] = 'server';
if (code === 40 || code === 41 || code === 50) reason = 'auth';
else if (code === 20 || code === 30) reason = 'version';
return { reason, code, message };
}
export async function ping(): Promise<boolean> {
try {
@@ -43,21 +52,33 @@ export async function pingWithCredentials(
const salt = secureRandomSalt();
const token = md5(password + salt);
const resp = await axios.get(`${base}/rest/ping.view`, {
params: { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' },
params: { u: username, t: token, s: salt, v: SUBSONIC_API_VERSION, c: SUBSONIC_CLIENT, f: 'json' },
paramsSerializer: { indexes: null },
timeout: 15000,
});
const data = resp.data?.['subsonic-response'];
const ok = data?.status === 'ok';
return {
ok,
const identity = {
type: typeof data?.type === 'string' ? data.type : undefined,
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
openSubsonic: data?.openSubsonic === true,
};
if (ok) return { ok: true, ...identity };
// Reachable server that rejected the ping — keep the Subsonic reason so the
// UI can show a specific message (code 30 = protocol too high, 40 = bad
// credentials, …) instead of an opaque failure.
const code = typeof data?.error?.code === 'number' ? data.error.code : undefined;
const message = typeof data?.error?.message === 'string' ? data.error.message : undefined;
console.warn('[psysonic] ping rejected by server:', serverUrl, 'sentVersion=', SUBSONIC_API_VERSION, data?.error ?? data);
return { ok: false, failure: classifyPingError(code, message), ...identity };
} catch (err) {
// Never reached the server (DNS, refused, timeout, TLS-cert not trusted, or
// a blocked cross-origin request). The WebView hides the exact cause, so
// pass the raw detail through for the toast + log.
const detail =
(err as { message?: string })?.message || (err as { code?: string })?.code || 'network error';
console.warn('[psysonic] pingWithCredentials failed:', serverUrl, err);
return { ok: false };
return { ok: false, failure: { reason: 'network', message: String(detail) } };
}
}
+14 -1
View File
@@ -201,7 +201,20 @@ export interface SubsonicDirectory {
child: SubsonicDirectoryEntry[];
}
export type PingWithCredentialsResult = SubsonicServerIdentity & { ok: boolean };
export interface PingFailure {
/** Coarse category that drives the user-facing message. */
reason: 'auth' | 'version' | 'network' | 'server';
/** Subsonic error code, when the server returned a structured error. */
code?: number;
/** Server's error message, or the network/TLS error detail. */
message?: string;
}
export type PingWithCredentialsResult = SubsonicServerIdentity & {
ok: boolean;
/** Populated when `ok` is false — why the ping was rejected. */
failure?: PingFailure;
};
export interface RandomSongsFilters {
size?: number;
+14 -3
View File
@@ -12,6 +12,7 @@ import { useLibraryIndexSync } from '../../hooks/useLibraryIndexSync';
import ServerLibraryIndexControls from './ServerLibraryIndexControls';
import type { ServerProfile } from '../../store/authStoreTypes';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic';
import { pingFailureMessage } from '../../utils/server/pingFailureMessage';
import { useDragDrop } from '../../contexts/DragDropContext';
import { type ServerMagicPayload } from '../../utils/server/serverMagicString';
import { ensureConnectUrlResolved, invalidateReachableEndpointCache } from '../../utils/server/serverEndpoint';
@@ -226,8 +227,6 @@ export function ServersTab({
};
const handleAddServer = async (data: Omit<ServerProfile, 'id'>) => {
setShowAddForm(false);
setPastedServerInvite(null);
const tempId = '_new';
setConnStatus(s => ({ ...s, [tempId]: 'testing' }));
try {
@@ -241,6 +240,7 @@ export function ServersTab({
data.password,
);
if (!announceVerifyResult(verify)) {
// announceVerifyResult already toasts the reason; keep the form open.
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
return;
}
@@ -258,11 +258,22 @@ export function ServersTab({
setConnStatus(s => ({ ...s, [id]: 'ok' }));
const added = useAuthStore.getState().servers.find(s => s.id === id);
if (added) void bootstrapIndexedServer(added);
// Close the form only once the server actually landed.
setShowAddForm(false);
setPastedServerInvite(null);
} else {
// Ping failed (reachable server that rejected us, or a network/TLS
// problem). Don't drop the add silently: show a specific reason and
// keep the form open so the entered details aren't lost.
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
const { key, vars } = pingFailureMessage(ping.failure);
console.warn('[servers] add aborted:', ping.failure?.reason ?? 'unknown', data.url, ping.failure);
showToast(t(key, vars), 6000, 'error');
}
} catch {
} catch (e) {
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
console.error('[servers] add failed:', data.url, e);
showToast(t('settings.serverFailed'), 4000, 'error');
}
};
+2
View File
@@ -52,6 +52,8 @@ export const settings = {
serverConnecting: 'Verbinde…',
serverConnected: 'Verbunden!',
serverFailed: 'Verbindung fehlgeschlagen.',
serverAddUnreachable: 'Server nicht erreichbar — Adresse, Netzwerk und das TLS-Zertifikat des Servers prüfen. ({{detail}})',
serverAddRejected: 'Der Server hat die Verbindung abgelehnt: {{message}}',
testBtn: 'Verbindung testen',
testingBtn: 'Teste…',
serverCompatible: 'Für Navidrome entwickelt. Andere Subsonic-kompatible Server (Gonic, Airsonic, …) funktionieren ggf. eingeschränkt, weil Psysonic viele Navidrome-spezifische API-Endpunkte nutzt.',
+2
View File
@@ -52,6 +52,8 @@ export const settings = {
serverConnecting: 'Connecting…',
serverConnected: 'Connected!',
serverFailed: 'Connection failed.',
serverAddUnreachable: "Couldn't reach the server — check the address, your network, and the server's TLS certificate. ({{detail}})",
serverAddRejected: 'The server rejected the connection: {{message}}',
testBtn: 'Test Connection',
testingBtn: 'Testing…',
serverCompatible: 'Built for Navidrome. Other Subsonic-compatible servers (Gonic, Airsonic, …) may work with reduced functionality, since Psysonic uses many Navidrome-specific API endpoints.',
+2
View File
@@ -51,6 +51,8 @@ export const settings = {
serverConnecting: 'Conectando…',
serverConnected: '¡Conectado!',
serverFailed: 'Conexión fallida.',
serverAddUnreachable: 'No se pudo conectar con el servidor — comprueba la dirección, tu red y el certificado TLS del servidor. ({{detail}})',
serverAddRejected: 'El servidor rechazó la conexión: {{message}}',
testBtn: 'Probar Conexión',
testingBtn: 'Probando…',
serverCompatible: 'Diseñado para Navidrome. Otros servidores compatibles con Subsonic (Gonic, Airsonic, …) pueden funcionar con funcionalidad reducida, ya que Psysonic utiliza muchos endpoints específicos de la API de Navidrome.',
+2
View File
@@ -51,6 +51,8 @@ export const settings = {
serverConnecting: 'Connexion…',
serverConnected: 'Connecté !',
serverFailed: 'Connexion échouée.',
serverAddUnreachable: 'Serveur injoignable — vérifiez ladresse, votre réseau et le certificat TLS du serveur. ({{detail}})',
serverAddRejected: 'Le serveur a refusé la connexion : {{message}}',
testBtn: 'Tester la connexion',
testingBtn: 'Test en cours…',
serverCompatible: 'Conçu pour Navidrome. Les autres serveurs compatibles Subsonic (Gonic, Airsonic, …) peuvent fonctionner avec des fonctionnalités réduites, car Psysonic utilise de nombreux endpoints dAPI spécifiques à Navidrome.',
+2
View File
@@ -51,6 +51,8 @@ export const settings = {
serverConnecting: 'Kobler til…',
serverConnected: 'Tilkoblet!',
serverFailed: 'Tilkobling mislyktes.',
serverAddUnreachable: 'Kunne ikke nå serveren — sjekk adressen, nettverket ditt og serverens TLS-sertifikat. ({{detail}})',
serverAddRejected: 'Serveren avviste tilkoblingen: {{message}}',
testBtn: 'Test tilkobling',
testingBtn: 'Tester…',
serverCompatible: 'Laget for Navidrome. Andre Subsonic-kompatible servere (Gonic, Airsonic, …) kan fungere med begrenset funksjonalitet, fordi Psysonic bruker mange Navidrome-spesifikke API-endepunkter.',
+2
View File
@@ -51,6 +51,8 @@ export const settings = {
serverConnecting: 'Verbinden…',
serverConnected: 'Verbonden!',
serverFailed: 'Verbinding mislukt.',
serverAddUnreachable: 'Server niet bereikbaar — controleer het adres, je netwerk en het TLS-certificaat van de server. ({{detail}})',
serverAddRejected: 'De server weigerde de verbinding: {{message}}',
testBtn: 'Verbinding testen',
testingBtn: 'Testen…',
serverCompatible: 'Gemaakt voor Navidrome. Andere Subsonic-compatibele servers (Gonic, Airsonic, …) kunnen met beperkte functionaliteit werken, omdat Psysonic veel Navidrome-specifieke API-endpoints gebruikt.',
+2
View File
@@ -51,6 +51,8 @@ export const settings = {
serverConnecting: 'Se conectează…',
serverConnected: 'Conectat!',
serverFailed: 'Conexiune eșuată.',
serverAddUnreachable: 'Serverul nu poate fi accesat — verifică adresa, rețeaua ta și certificatul TLS al serverului. ({{detail}})',
serverAddRejected: 'Serverul a refuzat conexiunea: {{message}}',
testBtn: 'Testează Conexiunea',
testingBtn: 'Se testează…',
serverCompatible: 'Făcut pentru Navidrome. Alte servere compatibile cu Subsonic (Gonic, Airsonic, …) pot rula cu funcționalitate redusă, deoarece Psysonic folosește multe endpointuri API specifice Navidrome.',
+2
View File
@@ -51,6 +51,8 @@ export const settings = {
serverConnecting: 'Подключение…',
serverConnected: 'Подключено!',
serverFailed: 'Ошибка подключения.',
serverAddUnreachable: 'Не удалось подключиться к серверу — проверьте адрес, сеть и TLS-сертификат сервера. ({{detail}})',
serverAddRejected: 'Сервер отклонил подключение: {{message}}',
testBtn: 'Проверить',
testingBtn: 'Проверка…',
serverCompatible: 'Разработано для Navidrome. Другие Subsonic-совместимые серверы (Gonic, Airsonic, …) могут работать с ограничениями, так как Psysonic использует много специфичных для Navidrome API-эндпоинтов.',
+2
View File
@@ -51,6 +51,8 @@ export const settings = {
serverConnecting: '正在连接…',
serverConnected: '已连接!',
serverFailed: '连接失败。',
serverAddUnreachable: '无法连接服务器 — 请检查地址、网络以及服务器的 TLS 证书。({{detail}})',
serverAddRejected: '服务器拒绝了连接:{{message}}',
testBtn: '测试连接',
testingBtn: '正在测试…',
serverCompatible: '专为 Navidrome 构建。其他兼容 Subsonic 的服务器(Gonic、Airsonic 等)可能功能受限,因为 Psysonic 使用了许多 Navidrome 特有的 API 端点。',
+22
View File
@@ -0,0 +1,22 @@
import type { PingFailure } from '../../api/subsonicTypes';
/**
* Map a ping failure to an i18n key (+ interpolation vars) for the connection
* error toast, so the user sees a specific reason instead of a generic
* "Connection failed":
* - network/TLS actionable "couldn't reach the server" message with detail
* - any reachable-but-rejected case (auth, version, other) the server's own
* Subsonic error message, which is already human-readable.
*/
export function pingFailureMessage(failure: PingFailure | undefined): {
key: string;
vars?: Record<string, string | number>;
} {
if (failure?.reason === 'network') {
return { key: 'settings.serverAddUnreachable', vars: { detail: failure.message ?? '' } };
}
if (failure?.message) {
return { key: 'settings.serverAddRejected', vars: { message: failure.message } };
}
return { key: 'settings.serverFailed' };
}