mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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:
+26
-5
@@ -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) } };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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-эндпоинтов.',
|
||||
|
||||
@@ -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 端点。',
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
Reference in New Issue
Block a user