diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6ee999fe..e42a14cd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -310,6 +310,79 @@ struct NdLoginResult { is_admin: bool, } +/// Flatten an error and its `source` chain into a single readable string so +/// frontend toasts can show the actual transport cause (connection refused, +/// tls handshake fail, cert expired, etc.) instead of reqwest's opaque +/// "error sending request for url (…)" wrapper. +fn nd_err(e: reqwest::Error) -> String { + let mut msg = e.to_string(); + let mut src: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e); + while let Some(s) = src { + msg.push_str(" | "); + msg.push_str(&s.to_string()); + src = s.source(); + } + msg +} + +/// Retry a request-building closure on transient transport errors +/// (connect/timeout — includes ECONNRESET, TLS handshake EOF, DNS flakes). +/// Three attempts with calm backoff: 0 → 300ms → 700ms (total worst case +/// ~1s). Retrying too aggressively (5+ attempts, short backoff) can drive +/// an already-stressed nginx upstream-probe into "offline" mode, which +/// turns a transient glitch into a visible outage. Status-level failures +/// (401/403/400 with body) return immediately — we don't retry logic +/// errors. +async fn nd_retry(mut build_and_send: F) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + const BACKOFFS_MS: [u64; 1] = [500]; + let mut last: Option = None; + for attempt in 0..=BACKOFFS_MS.len() { + if attempt > 0 { + tokio::time::sleep(std::time::Duration::from_millis(BACKOFFS_MS[attempt - 1])).await; + } + match build_and_send().await { + Ok(resp) => return Ok(resp), + Err(e) => { + if !e.is_connect() && !e.is_timeout() { + return Err(nd_err(e)); + } + last = Some(e); + } + } + } + Err(nd_err(last.expect("loop ran at least once"))) +} + +/// Build a reqwest client for Navidrome's native REST endpoints. Plain +/// `reqwest::Client::new()` defaults to HTTP/2 over ALPN with no User-Agent, +/// which some reverse-proxies (strict nginx rules, Cloudflare Tunnel, CDN +/// WAFs) abort mid-TLS-handshake. Pinning HTTP/1.1 and advertising a real +/// User-Agent makes the handshake match what browsers do for the Subsonic +/// endpoints, so `/auth/*` + `/api/*` go through the same path as `/rest/*`. +/// +/// `pool_max_idle_per_host(0)` disables connection pooling. Keeping stale +/// keep-alive connections in the pool caused intermittent "tls handshake +/// eof" errors on the second call to an admin endpoint when a server or +/// proxy had already closed the TCP connection between calls. +fn nd_http_client() -> reqwest::Client { + // TLS 1.2 only: rustls + nginx with TLS-1.3 session resumption caches + // produces intermittent ECONNRESET mid-handshake when the upstream + // starts churning keep-alive connections. Pinning TLS 1.2 matches what + // the WebKit-side Subsonic calls end up negotiating most of the time + // on these setups. + reqwest::Client::builder() + .user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION"))) + .http1_only() + .pool_max_idle_per_host(0) + .max_tls_version(reqwest::tls::Version::TLS_1_2) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + /// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin. #[tauri::command] async fn navidrome_login( @@ -317,17 +390,17 @@ async fn navidrome_login( username: String, password: String, ) -> Result { - let client = reqwest::Client::new(); - let resp = client - .post(format!("{}/auth/login", server_url)) - .json(&serde_json::json!({ "username": username, "password": password })) - .send() - .await - .map_err(|e| e.to_string())?; + let body = serde_json::json!({ "username": username, "password": password }); + let resp = nd_retry(|| { + nd_http_client() + .post(format!("{}/auth/login", server_url)) + .json(&body) + .send() + }).await?; if !resp.status().is_success() { return Err(format!("Navidrome login failed: HTTP {}", resp.status())); } - let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; + let data: serde_json::Value = resp.json().await.map_err(nd_err)?; let token = data["token"].as_str().ok_or("no token in response")?.to_string(); let user_id = data["id"].as_str().unwrap_or("").to_string(); let is_admin = data["isAdmin"].as_bool().unwrap_or(false); @@ -340,16 +413,16 @@ async fn nd_list_users( server_url: String, token: String, ) -> Result { - let resp = reqwest::Client::new() - .get(format!("{}/api/user", server_url)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .send() - .await - .map_err(|e| e.to_string())?; + let resp = nd_retry(|| { + nd_http_client() + .get(format!("{}/api/user", server_url)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .send() + }).await?; if !resp.status().is_success() { return Err(format!("HTTP {}", resp.status())); } - resp.json::().await.map_err(|e| e.to_string()) + resp.json::().await.map_err(nd_err) } /// POST `/api/user` — create a user. @@ -370,13 +443,13 @@ async fn nd_create_user( "password": password, "isAdmin": is_admin, }); - let resp = reqwest::Client::new() - .post(format!("{}/api/user", server_url)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .json(&body) - .send() - .await - .map_err(|e| e.to_string())?; + let resp = nd_retry(|| { + nd_http_client() + .post(format!("{}/api/user", server_url)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .json(&body) + .send() + }).await?; let status = resp.status(); let text = resp.text().await.unwrap_or_default(); if !status.is_success() { @@ -407,13 +480,13 @@ async fn nd_update_user( if !password.is_empty() { body["password"] = serde_json::Value::String(password); } - let resp = reqwest::Client::new() - .put(format!("{}/api/user/{}", server_url, id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .json(&body) - .send() - .await - .map_err(|e| e.to_string())?; + let resp = nd_retry(|| { + nd_http_client() + .put(format!("{}/api/user/{}", server_url, id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .json(&body) + .send() + }).await?; let status = resp.status(); let text = resp.text().await.unwrap_or_default(); if !status.is_success() { @@ -429,12 +502,12 @@ async fn nd_delete_user( token: String, id: String, ) -> Result<(), String> { - let resp = reqwest::Client::new() - .delete(format!("{}/api/user/{}", server_url, id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .send() - .await - .map_err(|e| e.to_string())?; + let resp = nd_retry(|| { + nd_http_client() + .delete(format!("{}/api/user/{}", server_url, id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .send() + }).await?; let status = resp.status(); if !status.is_success() { let text = resp.text().await.unwrap_or_default(); @@ -449,16 +522,16 @@ async fn nd_list_libraries( server_url: String, token: String, ) -> Result { - let resp = reqwest::Client::new() - .get(format!("{}/api/library", server_url)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .send() - .await - .map_err(|e| e.to_string())?; + let resp = nd_retry(|| { + nd_http_client() + .get(format!("{}/api/library", server_url)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .send() + }).await?; if !resp.status().is_success() { return Err(format!("HTTP {}", resp.status())); } - resp.json::().await.map_err(|e| e.to_string()) + resp.json::().await.map_err(nd_err) } /// PUT `/api/user/{id}/library` — assign libraries to a non-admin user. @@ -471,13 +544,13 @@ async fn nd_set_user_libraries( library_ids: Vec, ) -> Result<(), String> { let body = serde_json::json!({ "libraryIds": library_ids }); - let resp = reqwest::Client::new() - .put(format!("{}/api/user/{}/library", server_url, id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .json(&body) - .send() - .await - .map_err(|e| e.to_string())?; + let resp = nd_retry(|| { + nd_http_client() + .put(format!("{}/api/user/{}/library", server_url, id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .json(&body) + .send() + }).await?; let status = resp.status(); if !status.is_success() { let text = resp.text().await.unwrap_or_default(); diff --git a/src/locales/de.ts b/src/locales/de.ts index 9900d97a..c88f6fb3 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -481,6 +481,8 @@ export const deTranslation = { userMgmtDesc: 'Benutzer auf diesem Server verwalten. Erfordert Admin-Rechte.', userMgmtNoAdmin: 'Du brauchst Admin-Rechte, um Benutzer auf diesem Server zu verwalten.', userMgmtLoadError: 'Benutzer konnten nicht geladen werden.', + userMgmtLoadFriendly: 'Server hat nicht geantwortet — meistens ein Einzelfall.', + userMgmtRetry: 'Erneut versuchen', userMgmtEmpty: 'Keine Benutzer gefunden.', userMgmtYouBadge: 'Du', userMgmtAdminBadge: 'Admin', diff --git a/src/locales/en.ts b/src/locales/en.ts index fb45ca8e..78b2927e 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -483,6 +483,8 @@ export const enTranslation = { userMgmtDesc: 'Manage users on this server. Requires admin privileges.', userMgmtNoAdmin: 'You need admin privileges to manage users on this server.', userMgmtLoadError: 'Failed to load users.', + userMgmtLoadFriendly: 'Server didn\'t answer — this is usually a one-off.', + userMgmtRetry: 'Retry', userMgmtEmpty: 'No users found.', userMgmtYouBadge: 'You', userMgmtAdminBadge: 'Admin', diff --git a/src/locales/es.ts b/src/locales/es.ts index 3e7060e8..42743d98 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -474,6 +474,8 @@ export const esTranslation = { userMgmtDesc: 'Administra los usuarios de este servidor. Requiere privilegios de administrador.', userMgmtNoAdmin: 'Necesitas privilegios de administrador para gestionar usuarios en este servidor.', userMgmtLoadError: 'No se pudieron cargar los usuarios.', + userMgmtLoadFriendly: 'El servidor no respondió — suele ser algo puntual.', + userMgmtRetry: 'Reintentar', userMgmtEmpty: 'No se encontraron usuarios.', userMgmtYouBadge: 'Tú', userMgmtAdminBadge: 'Admin', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 72f197db..2b2d69d1 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -471,6 +471,8 @@ export const frTranslation = { userMgmtDesc: 'Gérer les utilisateurs sur ce serveur. Nécessite des privilèges administrateur.', userMgmtNoAdmin: 'Vous devez être administrateur pour gérer les utilisateurs sur ce serveur.', userMgmtLoadError: 'Impossible de charger les utilisateurs.', + userMgmtLoadFriendly: 'Le serveur n\'a pas répondu — généralement ponctuel.', + userMgmtRetry: 'Réessayer', userMgmtEmpty: 'Aucun utilisateur trouvé.', userMgmtYouBadge: 'Vous', userMgmtAdminBadge: 'Admin', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 78889790..b7d4ad20 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -471,6 +471,8 @@ export const nbTranslation = { userMgmtDesc: 'Administrer brukere på denne serveren. Krever admin-rettigheter.', userMgmtNoAdmin: 'Du trenger admin-rettigheter for å administrere brukere på denne serveren.', userMgmtLoadError: 'Kunne ikke laste brukere.', + userMgmtLoadFriendly: 'Serveren svarte ikke — som regel en engangsfeil.', + userMgmtRetry: 'Prøv igjen', userMgmtEmpty: 'Ingen brukere funnet.', userMgmtYouBadge: 'Deg', userMgmtAdminBadge: 'Admin', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 00cf5707..905e3b35 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -470,6 +470,8 @@ export const nlTranslation = { userMgmtDesc: 'Beheer gebruikers op deze server. Vereist admin-rechten.', userMgmtNoAdmin: 'Je hebt admin-rechten nodig om gebruikers op deze server te beheren.', userMgmtLoadError: 'Kon gebruikers niet laden.', + userMgmtLoadFriendly: 'Server antwoordde niet — meestal eenmalig.', + userMgmtRetry: 'Opnieuw proberen', userMgmtEmpty: 'Geen gebruikers gevonden.', userMgmtYouBadge: 'Jij', userMgmtAdminBadge: 'Admin', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 928b1abf..08e83631 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -494,6 +494,8 @@ export const ruTranslation = { userMgmtDesc: 'Управляйте пользователями этого сервера. Требуются права администратора.', userMgmtNoAdmin: 'Для управления пользователями на этом сервере нужны права администратора.', userMgmtLoadError: 'Не удалось загрузить пользователей.', + userMgmtLoadFriendly: 'Сервер не ответил — обычно разовая ошибка.', + userMgmtRetry: 'Повторить', userMgmtEmpty: 'Пользователи не найдены.', userMgmtYouBadge: 'Вы', userMgmtAdminBadge: 'Админ', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 0c520528..c3700c26 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -466,6 +466,8 @@ export const zhTranslation = { userMgmtDesc: '管理此服务器上的用户。需要管理员权限。', userMgmtNoAdmin: '需要管理员权限才能管理此服务器上的用户。', userMgmtLoadError: '加载用户失败。', + userMgmtLoadFriendly: '服务器未响应 — 通常是偶发问题。', + userMgmtRetry: '重试', userMgmtEmpty: '未找到用户。', userMgmtYouBadge: '您', userMgmtAdminBadge: '管理员', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 9e669fc4..e31b36fa 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -762,15 +762,25 @@ function UserManagementSection({ setLoading(true); setLoadError(null); try { - const [list, libs] = await Promise.all([ - ndListUsers(serverUrl, token), - ndListLibraries(serverUrl, token).catch(() => [] as NdLibrary[]), - ]); + // Sequential, not parallel: nginx setups with churning upstream + // keep-alive drop one of the two parallel TLS connections. Doing + // users first then libraries keeps us on one connection at a time + // and pairs cleanly with the nd_retry backoff on the Rust side. + const list = await ndListUsers(serverUrl, token); + const libs = await ndListLibraries(serverUrl, token).catch(() => [] as NdLibrary[]); setUsers([...list].sort((a, b) => a.userName.localeCompare(b.userName))); setLibraries([...libs].sort((a, b) => a.name.localeCompare(b.name))); } catch (e) { - const msg = (e instanceof Error && e.message) ? e.message : t('settings.userMgmtLoadError'); - setLoadError(msg); + // Tauri invoke rejects with a plain string (our Rust returns Err(String)), + // not an Error instance. Normalise so the surfaced message is the real + // cause (e.g. "tls handshake eof") rather than the generic i18n fallback. + const raw = typeof e === 'string' + ? e + : (e instanceof Error && e.message) + ? e.message + : ''; + const prefix = t('settings.userMgmtLoadError'); + setLoadError(raw ? `${prefix} ${raw}` : prefix); } finally { setLoading(false); } @@ -922,8 +932,30 @@ function UserManagementSection({ )} {!loading && loadError && ( -
- {loadError} +
+
+
{t('settings.userMgmtLoadFriendly')}
+
{loadError}
+
+
)} @@ -1009,16 +1041,14 @@ function UserManagementSection({ {t('settings.userMgmtAdminBadge')} )} - {libNames && ( - - {libNames} - - )} + + {libNames || ''} + {!u.isAdmin && ( )} {lastSeen} @@ -1347,6 +1377,14 @@ export default function Settings() { const [searchOpen, setSearchOpen] = useState(false); // -1 bedeutet: keine aktive Suche; >= 0 ist die Trefferzahl im aktuellen Tab. const [searchHits, setSearchHits] = useState(-1); + + // Server-Liste DnD + const psyDragState = useDragDrop(); + const [serverContainerEl, setServerContainerEl] = useState(null); + const [serverDropTarget, setServerDropTarget] = useState(null); + const serverDropTargetRef = useRef(null); + const serversRef = useRef(auth.servers); + serversRef.current = auth.servers; const [connStatus, setConnStatus] = useState>({}); const [showAddForm, setShowAddForm] = useState(false); const [newGenre, setNewGenre] = useState(''); @@ -1585,12 +1623,62 @@ export default function Settings() { } }; + // Clear drop target when drag ends + useEffect(() => { + if (!psyDragState.isDragging) { + serverDropTargetRef.current = null; + setServerDropTarget(null); + } + }, [psyDragState.isDragging]); + + // psy-drop listener for server reorder + useEffect(() => { + if (!serverContainerEl) return; + const onPsyDrop = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail?.data) return; + let parsed: { type?: string; index?: number }; + try { parsed = JSON.parse(detail.data as string); } catch { return; } + if (parsed.type !== 'server_reorder' || parsed.index == null) return; + + const fromIdx = parsed.index; + const target = serverDropTargetRef.current; + serverDropTargetRef.current = null; setServerDropTarget(null); + if (!target) return; + + const insertBefore = target.before ? target.idx : target.idx + 1; + if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return; + + const next = [...serversRef.current]; + const [moved] = next.splice(fromIdx, 1); + next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved); + auth.setServers(next); + }; + serverContainerEl.addEventListener('psy-drop', onPsyDrop); + return () => serverContainerEl.removeEventListener('psy-drop', onPsyDrop); + }, [serverContainerEl, auth]); + + const handleServerDragMove = (e: React.MouseEvent) => { + if (!psyDragState.isDragging || !serverContainerEl) return; + const rows = serverContainerEl.querySelectorAll('[data-server-idx]'); + let target: ServerDropTarget = null; + for (const row of rows) { + const rect = row.getBoundingClientRect(); + const idx = Number(row.dataset.serverIdx); + if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; } + target = { idx, before: false }; + } + serverDropTargetRef.current = target; + setServerDropTarget(target); + }; + const switchToServer = async (server: ServerProfile) => { setConnStatus(s => ({ ...s, [server.id]: 'testing' })); const ok = await switchActiveServer(server); if (ok) { setConnStatus(s => ({ ...s, [server.id]: 'ok' })); - navigate('/'); + // Auf der Servers-Seite bleiben, damit der User seinen Switch hier + // sofort visuell bestaetigt sieht (gruener Check, aktiv-Badge). } else { setConnStatus(s => ({ ...s, [server.id]: 'error' })); } @@ -3367,13 +3455,31 @@ export default function Settings() { {t('settings.noServers')}
) : ( -
- {auth.servers.map(srv => { +
+ {auth.servers.map((srv, srvIdx) => { const isActive = srv.id === auth.activeServerId; const status = connStatus[srv.id]; + const isBefore = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && serverDropTarget.before; + const isAfter = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && !serverDropTarget.before; return ( -
-
+
+
+ +
{serverListDisplayLabel(srv, auth.servers)} @@ -3431,6 +3537,7 @@ export default function Settings() {
+
{showAudiomuseNavidromeServerSetting( auth.subsonicServerIdentityByServer[srv.id], auth.instantMixProbeByServer[srv.id], @@ -3832,6 +3939,27 @@ const LYRICS_SOURCE_LABEL_KEYS: Record = { type LyricsDropTarget = { idx: number; before: boolean } | null; +type ServerDropTarget = { idx: number; before: boolean } | null; + +function ServerGripHandle({ idx, label }: { idx: number; label: string }) { + const { t } = useTranslation(); + const { onMouseDown } = useDragSource(() => ({ + data: JSON.stringify({ type: 'server_reorder', index: idx }), + label, + })); + return ( + e.stopPropagation()} + > + + + ); +} + function LyricsSourceGripHandle({ idx, label }: { idx: number; label: string }) { const { t } = useTranslation(); const { onMouseDown } = useDragSource(() => ({ diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 8bf49e0f..74d74ca5 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -187,6 +187,7 @@ interface AuthState { addServer: (profile: Omit) => string; updateServer: (id: string, data: Partial>) => void; removeServer: (id: string) => void; + setServers: (servers: ServerProfile[]) => void; setActiveServer: (id: string) => void; setLoggedIn: (v: boolean) => void; setConnecting: (v: boolean) => void; @@ -408,6 +409,8 @@ export const useAuthStore = create()( }); }, + setServers: (servers) => set({ servers }), + setActiveServer: (id) => set({ activeServerId: id, musicFolders: [] }), setLoggedIn: (v) => set({ isLoggedIn: v }),