From f133492e5c7abcec1adae3e2800fa81ac8b77407 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 10 Jun 2026 00:01:40 +0200 Subject: [PATCH] fix(settings): surface a specific reason when adding a server fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/api/subsonic.ts | 31 +++++++++++++++++++++----- src/api/subsonicTypes.ts | 15 ++++++++++++- src/components/settings/ServersTab.tsx | 17 +++++++++++--- src/locales/de/settings.ts | 2 ++ src/locales/en/settings.ts | 2 ++ src/locales/es/settings.ts | 2 ++ src/locales/fr/settings.ts | 2 ++ src/locales/nb/settings.ts | 2 ++ src/locales/nl/settings.ts | 2 ++ src/locales/ro/settings.ts | 2 ++ src/locales/ru/settings.ts | 2 ++ src/locales/zh/settings.ts | 2 ++ src/utils/server/pingFailureMessage.ts | 22 ++++++++++++++++++ 13 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 src/utils/server/pingFailureMessage.ts diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 1229559e..f04a423d 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -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 { 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) } }; } } diff --git a/src/api/subsonicTypes.ts b/src/api/subsonicTypes.ts index 011424f4..fc26e070 100644 --- a/src/api/subsonicTypes.ts +++ b/src/api/subsonicTypes.ts @@ -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; diff --git a/src/components/settings/ServersTab.tsx b/src/components/settings/ServersTab.tsx index bf2a4e72..f88b1417 100644 --- a/src/components/settings/ServersTab.tsx +++ b/src/components/settings/ServersTab.tsx @@ -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) => { - 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'); } }; diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index 55c3fd40..6daa4a1d 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -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.', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index 4d7c6906..da4e2c46 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -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.', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 78da2ad8..a615256f 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -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.', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index a80a6769..3648fb11 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -51,6 +51,8 @@ export const settings = { serverConnecting: 'Connexion…', serverConnected: 'Connecté !', serverFailed: 'Connexion échouée.', + serverAddUnreachable: 'Serveur injoignable — vérifiez l’adresse, 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 d’API spécifiques à Navidrome.', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index fb6cc2bc..e6a92a6d 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -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.', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index f5e902fb..4f161bc7 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -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.', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index 5af19467..be46b20e 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -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.', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 57b558c7..e63932d3 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -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-эндпоинтов.', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index ea9bab71..60e4b4bd 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -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 端点。', diff --git a/src/utils/server/pingFailureMessage.ts b/src/utils/server/pingFailureMessage.ts new file mode 100644 index 00000000..79315b76 --- /dev/null +++ b/src/utils/server/pingFailureMessage.ts @@ -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; +} { + 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' }; +}