From f43ab94cc16db063de577ad8deab74ffa6ce9ea5 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 8 May 2026 15:59:39 +0200 Subject: [PATCH] refactor(app_api): split navidrome.rs into 5-way module directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert app_api/navidrome.rs (655 LOC monolith) into navidrome/ with five focused submodules + a thin mod.rs: - client.rs (106 LOC) — auth + retry + http client (navidrome_token, NdLoginResult, nd_err, nd_retry, nd_http_client). Internal-only, not re-exported at crate scope. - covers.rs (107 LOC) — 4 multipart image-upload commands (upload_playlist_cover / upload_radio_cover / upload_artist_image / delete_radio_cover). - users.rs (138 LOC) — login + admin user CRUD (navidrome_login + nd_list/create/update/delete_user). - queries.rs (207 LOC) — songs, role-filtered artists/albums, libraries, per-user library assignment, absolute song path. Includes the nd_build_filters helper. - playlists.rs (120 LOC) — playlist CRUD with smart-rules payload passthrough (nd_list / create / update / get / delete _playlist). mod.rs is now ~28 LOC of declarations + per-module re-exports of the Tauri commands. The cascade `app_api/mod.rs` → `pub(crate) use navidrome::*` keeps lib.rs invoke_handler registrations unchanged. Behaviour-preserving — pure file moves with `super::client::*` imports where the auth/retry helpers are needed. --- .../src/lib_commands/app_api/navidrome.rs | 655 ------------------ .../lib_commands/app_api/navidrome/client.rs | 106 +++ .../lib_commands/app_api/navidrome/covers.rs | 107 +++ .../src/lib_commands/app_api/navidrome/mod.rs | 28 + .../app_api/navidrome/playlists.rs | 120 ++++ .../lib_commands/app_api/navidrome/queries.rs | 207 ++++++ .../lib_commands/app_api/navidrome/users.rs | 138 ++++ 7 files changed, 706 insertions(+), 655 deletions(-) delete mode 100644 src-tauri/src/lib_commands/app_api/navidrome.rs create mode 100644 src-tauri/src/lib_commands/app_api/navidrome/client.rs create mode 100644 src-tauri/src/lib_commands/app_api/navidrome/covers.rs create mode 100644 src-tauri/src/lib_commands/app_api/navidrome/mod.rs create mode 100644 src-tauri/src/lib_commands/app_api/navidrome/playlists.rs create mode 100644 src-tauri/src/lib_commands/app_api/navidrome/queries.rs create mode 100644 src-tauri/src/lib_commands/app_api/navidrome/users.rs diff --git a/src-tauri/src/lib_commands/app_api/navidrome.rs b/src-tauri/src/lib_commands/app_api/navidrome.rs deleted file mode 100644 index e39f8957..00000000 --- a/src-tauri/src/lib_commands/app_api/navidrome.rs +++ /dev/null @@ -1,655 +0,0 @@ -/// Authenticate with Navidrome's own REST API and return a Bearer token. -pub(crate) async fn navidrome_token(server_url: &str, username: &str, password: &str) -> 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 data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; - data["token"] - .as_str() - .map(|s| s.to_string()) - .ok_or_else(|| "Navidrome auth: no token in response".to_string()) -} - -#[tauri::command] -pub(crate) async fn upload_playlist_cover( - server_url: String, - playlist_id: String, - username: String, - password: String, - file_bytes: Vec, - mime_type: String, -) -> Result<(), String> { - let token = navidrome_token(&server_url, &username, &password).await?; - let part = reqwest::multipart::Part::bytes(file_bytes) - .file_name("cover.jpg") - .mime_str(&mime_type) - .map_err(|e| e.to_string())?; - let form = reqwest::multipart::Form::new().part("image", part); - reqwest::Client::new() - .post(format!("{}/api/playlist/{}/image", server_url, playlist_id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .multipart(form) - .send() - .await - .map_err(|e| e.to_string())? - .error_for_status() - .map_err(|e| e.to_string())?; - Ok(()) -} - -#[tauri::command] -pub(crate) async fn upload_radio_cover( - server_url: String, - radio_id: String, - username: String, - password: String, - file_bytes: Vec, - mime_type: String, -) -> Result<(), String> { - let token = navidrome_token(&server_url, &username, &password).await?; - let part = reqwest::multipart::Part::bytes(file_bytes) - .file_name("cover.jpg") - .mime_str(&mime_type) - .map_err(|e| e.to_string())?; - let form = reqwest::multipart::Form::new().part("image", part); - reqwest::Client::new() - .post(format!("{}/api/radio/{}/image", server_url, radio_id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .multipart(form) - .send() - .await - .map_err(|e| e.to_string())? - .error_for_status() - .map_err(|e| e.to_string())?; - Ok(()) -} - -#[tauri::command] -pub(crate) async fn upload_artist_image( - server_url: String, - artist_id: String, - username: String, - password: String, - file_bytes: Vec, - mime_type: String, -) -> Result<(), String> { - let token = navidrome_token(&server_url, &username, &password).await?; - let part = reqwest::multipart::Part::bytes(file_bytes) - .file_name("cover.jpg") - .mime_str(&mime_type) - .map_err(|e| e.to_string())?; - let form = reqwest::multipart::Form::new().part("image", part); - reqwest::Client::new() - .post(format!("{}/api/artist/{}/image", server_url, artist_id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .multipart(form) - .send() - .await - .map_err(|e| e.to_string())? - .error_for_status() - .map_err(|e| e.to_string())?; - Ok(()) -} - -#[tauri::command] -pub(crate) async fn delete_radio_cover( - server_url: String, - radio_id: String, - username: String, - password: String, -) -> Result<(), String> { - let token = navidrome_token(&server_url, &username, &password).await?; - let resp = reqwest::Client::new() - .delete(format!("{}/api/radio/{}/image", server_url, radio_id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .send() - .await - .map_err(|e| e.to_string())?; - // 404/503 = no image existed — treat as success - if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE { - resp.error_for_status().map_err(|e| e.to_string())?; - } - Ok(()) -} - -/// Payload returned by Navidrome's `/auth/login`. -#[derive(serde::Serialize)] -pub(crate) struct NdLoginResult { - token: String, - #[serde(rename = "userId")] - user_id: String, - #[serde(rename = "isAdmin")] - 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. -pub(crate) 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. -pub(crate) async fn nd_retry(mut build_and_send: F) -> Result -where - F: FnMut() -> Fut, - Fut: std::future::Future>, -{ - // Reverse-proxies in front of Navidrome (Caddy/nginx + Cloudflare etc.) - // sometimes drop a TLS handshake mid-stream when their keep-alive pool - // churns. One 500 ms retry isn't always enough — exponential backoff - // across 4 attempts gives the upstream pool time to settle without - // making the user-visible wait worse for the common single-failure case. - const BACKOFFS_MS: [u64; 3] = [300, 800, 1800]; - 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. -pub(crate) 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] -pub(crate) async fn navidrome_login( - server_url: String, - username: String, - password: String, -) -> Result { - 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(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); - Ok(NdLoginResult { token, user_id, is_admin }) -} - -/// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields. -#[tauri::command] -pub(crate) async fn nd_list_users( - server_url: String, - token: String, -) -> Result { - 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(nd_err) -} - -/// POST `/api/user` — create a user. -#[tauri::command] -pub(crate) async fn nd_create_user( - server_url: String, - token: String, - user_name: String, - name: String, - email: String, - password: String, - is_admin: bool, -) -> Result { - let body = serde_json::json!({ - "userName": user_name, - "name": name, - "email": email, - "password": password, - "isAdmin": is_admin, - }); - 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() { - return Err(format!("HTTP {}: {}", status, text)); - } - serde_json::from_str(&text).map_err(|e| e.to_string()) -} - -/// PUT `/api/user/{id}` — update a user. Pass an empty `password` to leave it unchanged. -#[tauri::command] -pub(crate) async fn nd_update_user( - server_url: String, - token: String, - id: String, - user_name: String, - name: String, - email: String, - password: String, - is_admin: bool, -) -> Result { - let mut body = serde_json::json!({ - "id": id, - "userName": user_name, - "name": name, - "email": email, - "isAdmin": is_admin, - }); - if !password.is_empty() { - body["password"] = serde_json::Value::String(password); - } - 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() { - return Err(format!("HTTP {}: {}", status, text)); - } - Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null)) -} - -/// DELETE `/api/user/{id}`. -#[tauri::command] -pub(crate) async fn nd_delete_user( - server_url: String, - token: String, - id: String, -) -> Result<(), 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(); - return Err(format!("HTTP {}: {}", status, text)); - } - Ok(()) -} - -/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated song list. -/// Available to any authenticated user (no admin required). Returns raw JSON array. -#[tauri::command] -pub(crate) async fn nd_list_songs( - server_url: String, - token: String, - sort: String, - order: String, - start: u32, - end: u32, -) -> Result { - let url = format!( - "{}/api/song?_sort={}&_order={}&_start={}&_end={}", - server_url, sort, order, start, end - ); - let resp = nd_retry(|| { - nd_http_client() - .get(&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(nd_err) -} - -/// Build the `_filters` JSON for native-API list calls. Optionally narrows the -/// query to a single library — `library_id` is the same scope key the Navidrome -/// web UI sends, and it matches the Subsonic `musicFolderId` we store per server. -fn nd_build_filters(seed: serde_json::Map, library_id: Option<&str>) -> String { - let mut obj = seed; - if let Some(lib) = library_id { - // Navidrome stores library ids as i64; our state holds them as strings - // (Subsonic musicFolderId). Send numeric when parseable, fall back to - // string for safety against future non-numeric ids. - let val = lib.parse::() - .map(|n| serde_json::Value::Number(n.into())) - .unwrap_or_else(|_| serde_json::Value::String(lib.to_string())); - obj.insert("library_id".to_string(), val); - } - serde_json::Value::Object(obj).to_string() -} - -/// GET `/api/artist?_filters={"role":""}&_sort=...&_order=...&_start=...&_end=...` -/// — paginated list of artists that have at least one credit in the given role. -/// Navidrome 0.55.0+ (uses `library_artist.stats` JSON aggregate). Available to any -/// authenticated user. Returns raw JSON array. -#[tauri::command] -pub(crate) async fn nd_list_artists_by_role( - server_url: String, - token: String, - role: String, - sort: String, - order: String, - start: u32, - end: u32, - library_id: Option, -) -> Result { - let mut seed = serde_json::Map::new(); - seed.insert("role".to_string(), serde_json::Value::String(role.clone())); - let filters = nd_build_filters(seed, library_id.as_deref()); - let start_s = start.to_string(); - let end_s = end.to_string(); - let resp = nd_retry(|| { - nd_http_client() - .get(format!("{}/api/artist", server_url)) - .query(&[ - ("_filters", filters.as_str()), - ("_sort", sort.as_str()), - ("_order", order.as_str()), - ("_start", start_s.as_str()), - ("_end", end_s.as_str()), - ]) - .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(nd_err) -} - -/// GET `/api/album?_filters={"role__id":""}&_sort=...&_order=...&_start=...&_end=...` -/// — paginated list of albums in which `artist_id` holds the given participant role. -/// Subsonic `getArtist.view` only walks AlbumArtist relations, so composer-only -/// (or conductor-only, lyricist-only, …) credits are unreachable there. Navidrome -/// generates `role__id` filters dynamically from `model.AllRoles`. -#[tauri::command] -pub(crate) async fn nd_list_albums_by_artist_role( - server_url: String, - token: String, - artist_id: String, - role: String, - sort: String, - order: String, - start: u32, - end: u32, - library_id: Option, -) -> Result { - let filter_key = format!("role_{}_id", role); - let mut seed = serde_json::Map::new(); - seed.insert(filter_key, serde_json::Value::String(artist_id.clone())); - let filters = nd_build_filters(seed, library_id.as_deref()); - let start_s = start.to_string(); - let end_s = end.to_string(); - let resp = nd_retry(|| { - nd_http_client() - .get(format!("{}/api/album", server_url)) - .query(&[ - ("_filters", filters.as_str()), - ("_sort", sort.as_str()), - ("_order", order.as_str()), - ("_start", start_s.as_str()), - ("_end", end_s.as_str()), - ]) - .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(nd_err) -} - -/// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array. -#[tauri::command] -pub(crate) async fn nd_list_libraries( - server_url: String, - token: String, -) -> Result { - 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(nd_err) -} - -/// PUT `/api/user/{id}/library` — assign libraries to a non-admin user. -/// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400. -#[tauri::command] -pub(crate) async fn nd_set_user_libraries( - server_url: String, - token: String, - id: String, - library_ids: Vec, -) -> Result<(), String> { - let body = serde_json::json!({ "libraryIds": library_ids }); - 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(); - return Err(format!("HTTP {}: {}", status, text)); - } - Ok(()) -} - -/// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists. -#[tauri::command] -pub(crate) async fn nd_list_playlists( - server_url: String, - token: String, - smart: Option, -) -> Result { - let resp = nd_retry(|| { - let client = nd_http_client(); - let mut req = client - .get(format!("{}/api/playlist", server_url)) - .header("X-ND-Authorization", format!("Bearer {}", token)); - if let Some(s) = smart { - req = req.query(&[("smart", s)]); - } - req.send() - }) - .await?; - if !resp.status().is_success() { - return Err(format!("HTTP {}", resp.status())); - } - resp.json::().await.map_err(nd_err) -} - -/// POST `/api/playlist` — create playlist (supports smart rules payload). -#[tauri::command] -pub(crate) async fn nd_create_playlist( - server_url: String, - token: String, - body: serde_json::Value, -) -> Result { - let resp = nd_retry(|| { - nd_http_client() - .post(format!("{}/api/playlist", 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() { - return Err(format!("HTTP {}: {}", status, text)); - } - serde_json::from_str(&text).map_err(|e| e.to_string()) -} - -/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload). -#[tauri::command] -pub(crate) async fn nd_update_playlist( - server_url: String, - token: String, - id: String, - body: serde_json::Value, -) -> Result { - let resp = nd_retry(|| { - nd_http_client() - .put(format!("{}/api/playlist/{}", 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() { - return Err(format!("HTTP {}: {}", status, text)); - } - Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null)) -} - -/// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available). -#[tauri::command] -pub(crate) async fn nd_get_playlist( - server_url: String, - token: String, - id: String, -) -> Result { - let resp = nd_retry(|| { - nd_http_client() - .get(format!("{}/api/playlist/{}", server_url, id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .send() - }) - .await?; - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(format!("HTTP {}: {}", status, text)); - } - Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null)) -} - -/// DELETE `/api/playlist/{id}` — delete playlist. -#[tauri::command] -pub(crate) async fn nd_delete_playlist( - server_url: String, - token: String, - id: String, -) -> Result<(), String> { - let resp = nd_retry(|| { - nd_http_client() - .delete(format!("{}/api/playlist/{}", 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(); - return Err(format!("HTTP {}: {}", status, text)); - } - Ok(()) -} - -/// GET `/api/song/{id}` and return the absolute filesystem `path` field. -/// -/// Subsonic `getSong.view` returns at most a relative path (`Artist/Album/track.flac`), -/// or nothing at all on Navidrome. The Navidrome native API exposes the absolute -/// path the server stores the file at — same source Feishin and the Navidrome web -/// client use for their "show file location" feature. Logs in fresh (no token -/// cache yet); the call is occasional (Song Info modal open) so the extra -/// roundtrip is acceptable. -/// -/// Returns `Ok(None)` when the response has no `path` field — Navidrome can omit -/// it for non-admin users on some configurations. -#[tauri::command] -pub(crate) async fn nd_get_song_path( - server_url: String, - username: String, - password: String, - id: String, -) -> Result, String> { - let token = navidrome_token(&server_url, &username, &password).await?; - let url = format!("{}/api/song/{}", server_url, id); - let resp = nd_retry(|| { - nd_http_client() - .get(&url) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .send() - }) - .await?; - if !resp.status().is_success() { - return Err(format!("HTTP {}", resp.status())); - } - let data: serde_json::Value = resp.json().await.map_err(nd_err)?; - Ok(data["path"].as_str().map(|s| s.to_string()).filter(|s| !s.is_empty())) -} diff --git a/src-tauri/src/lib_commands/app_api/navidrome/client.rs b/src-tauri/src/lib_commands/app_api/navidrome/client.rs new file mode 100644 index 00000000..8a706159 --- /dev/null +++ b/src-tauri/src/lib_commands/app_api/navidrome/client.rs @@ -0,0 +1,106 @@ +//! Auth + retry + HTTP client for Navidrome's native REST API. +//! Used by every other navidrome submodule for `/auth/*` and `/api/*` calls. + +/// Authenticate with Navidrome's own REST API and return a Bearer token. +pub(crate) async fn navidrome_token(server_url: &str, username: &str, password: &str) -> 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 data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; + data["token"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "Navidrome auth: no token in response".to_string()) +} + +/// Payload returned by Navidrome's `/auth/login`. +#[derive(serde::Serialize)] +pub(crate) struct NdLoginResult { + pub(super) token: String, + #[serde(rename = "userId")] + pub(super) user_id: String, + #[serde(rename = "isAdmin")] + pub(super) 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. +pub(crate) 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. +pub(crate) async fn nd_retry(mut build_and_send: F) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + // Reverse-proxies in front of Navidrome (Caddy/nginx + Cloudflare etc.) + // sometimes drop a TLS handshake mid-stream when their keep-alive pool + // churns. One 500 ms retry isn't always enough — exponential backoff + // across 4 attempts gives the upstream pool time to settle without + // making the user-visible wait worse for the common single-failure case. + const BACKOFFS_MS: [u64; 3] = [300, 800, 1800]; + 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. +pub(crate) 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()) +} diff --git a/src-tauri/src/lib_commands/app_api/navidrome/covers.rs b/src-tauri/src/lib_commands/app_api/navidrome/covers.rs new file mode 100644 index 00000000..c061b3ce --- /dev/null +++ b/src-tauri/src/lib_commands/app_api/navidrome/covers.rs @@ -0,0 +1,107 @@ +//! Image / artwork upload + delete commands. Each command does a one-shot +//! login (via `navidrome_token`) and then a multipart POST to the relevant +//! `/api/{playlist|radio|artist}/{id}/image` endpoint. + +use super::client::navidrome_token; + +#[tauri::command] +pub(crate) async fn upload_playlist_cover( + server_url: String, + playlist_id: String, + username: String, + password: String, + file_bytes: Vec, + mime_type: String, +) -> Result<(), String> { + let token = navidrome_token(&server_url, &username, &password).await?; + let part = reqwest::multipart::Part::bytes(file_bytes) + .file_name("cover.jpg") + .mime_str(&mime_type) + .map_err(|e| e.to_string())?; + let form = reqwest::multipart::Form::new().part("image", part); + reqwest::Client::new() + .post(format!("{}/api/playlist/{}/image", server_url, playlist_id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .multipart(form) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub(crate) async fn upload_radio_cover( + server_url: String, + radio_id: String, + username: String, + password: String, + file_bytes: Vec, + mime_type: String, +) -> Result<(), String> { + let token = navidrome_token(&server_url, &username, &password).await?; + let part = reqwest::multipart::Part::bytes(file_bytes) + .file_name("cover.jpg") + .mime_str(&mime_type) + .map_err(|e| e.to_string())?; + let form = reqwest::multipart::Form::new().part("image", part); + reqwest::Client::new() + .post(format!("{}/api/radio/{}/image", server_url, radio_id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .multipart(form) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub(crate) async fn upload_artist_image( + server_url: String, + artist_id: String, + username: String, + password: String, + file_bytes: Vec, + mime_type: String, +) -> Result<(), String> { + let token = navidrome_token(&server_url, &username, &password).await?; + let part = reqwest::multipart::Part::bytes(file_bytes) + .file_name("cover.jpg") + .mime_str(&mime_type) + .map_err(|e| e.to_string())?; + let form = reqwest::multipart::Form::new().part("image", part); + reqwest::Client::new() + .post(format!("{}/api/artist/{}/image", server_url, artist_id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .multipart(form) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub(crate) async fn delete_radio_cover( + server_url: String, + radio_id: String, + username: String, + password: String, +) -> Result<(), String> { + let token = navidrome_token(&server_url, &username, &password).await?; + let resp = reqwest::Client::new() + .delete(format!("{}/api/radio/{}/image", server_url, radio_id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .send() + .await + .map_err(|e| e.to_string())?; + // 404/503 = no image existed — treat as success + if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE { + resp.error_for_status().map_err(|e| e.to_string())?; + } + Ok(()) +} diff --git a/src-tauri/src/lib_commands/app_api/navidrome/mod.rs b/src-tauri/src/lib_commands/app_api/navidrome/mod.rs new file mode 100644 index 00000000..b074f30a --- /dev/null +++ b/src-tauri/src/lib_commands/app_api/navidrome/mod.rs @@ -0,0 +1,28 @@ +//! Navidrome native REST API: split into a small client/auth/retry core +//! plus per-domain submodules (covers, users, queries, playlists). Each +//! Tauri command goes through `nd_http_client()` + `nd_retry()` so flaky +//! reverse proxies in front of the server don't surface as user-visible +//! transport errors on a single retry-able blip. + +mod client; +mod covers; +mod users; +mod queries; +mod playlists; + +// Re-export only the Tauri commands — `client` items (nd_http_client, +// nd_retry, navidrome_token, NdLoginResult, nd_err) are internal helpers +// used by the other submodules and don't need crate-wide visibility. +pub(crate) use covers::{ + delete_radio_cover, upload_artist_image, upload_playlist_cover, upload_radio_cover, +}; +pub(crate) use users::{ + navidrome_login, nd_create_user, nd_delete_user, nd_list_users, nd_update_user, +}; +pub(crate) use queries::{ + nd_get_song_path, nd_list_albums_by_artist_role, nd_list_artists_by_role, nd_list_libraries, + nd_list_songs, nd_set_user_libraries, +}; +pub(crate) use playlists::{ + nd_create_playlist, nd_delete_playlist, nd_get_playlist, nd_list_playlists, nd_update_playlist, +}; diff --git a/src-tauri/src/lib_commands/app_api/navidrome/playlists.rs b/src-tauri/src/lib_commands/app_api/navidrome/playlists.rs new file mode 100644 index 00000000..cd4d9f23 --- /dev/null +++ b/src-tauri/src/lib_commands/app_api/navidrome/playlists.rs @@ -0,0 +1,120 @@ +//! Playlist CRUD via Navidrome's native REST API. The smart-playlist rules +//! payload is forwarded as-is so the frontend can compose any rule the +//! Navidrome version supports without backend changes. + +use super::client::{nd_err, nd_http_client, nd_retry}; + +/// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists. +#[tauri::command] +pub(crate) async fn nd_list_playlists( + server_url: String, + token: String, + smart: Option, +) -> Result { + let resp = nd_retry(|| { + let client = nd_http_client(); + let mut req = client + .get(format!("{}/api/playlist", server_url)) + .header("X-ND-Authorization", format!("Bearer {}", token)); + if let Some(s) = smart { + req = req.query(&[("smart", s)]); + } + req.send() + }) + .await?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + resp.json::().await.map_err(nd_err) +} + +/// POST `/api/playlist` — create playlist (supports smart rules payload). +#[tauri::command] +pub(crate) async fn nd_create_playlist( + server_url: String, + token: String, + body: serde_json::Value, +) -> Result { + let resp = nd_retry(|| { + nd_http_client() + .post(format!("{}/api/playlist", 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() { + return Err(format!("HTTP {}: {}", status, text)); + } + serde_json::from_str(&text).map_err(|e| e.to_string()) +} + +/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload). +#[tauri::command] +pub(crate) async fn nd_update_playlist( + server_url: String, + token: String, + id: String, + body: serde_json::Value, +) -> Result { + let resp = nd_retry(|| { + nd_http_client() + .put(format!("{}/api/playlist/{}", 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() { + return Err(format!("HTTP {}: {}", status, text)); + } + Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null)) +} + +/// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available). +#[tauri::command] +pub(crate) async fn nd_get_playlist( + server_url: String, + token: String, + id: String, +) -> Result { + let resp = nd_retry(|| { + nd_http_client() + .get(format!("{}/api/playlist/{}", server_url, id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .send() + }) + .await?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!("HTTP {}: {}", status, text)); + } + Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null)) +} + +/// DELETE `/api/playlist/{id}` — delete playlist. +#[tauri::command] +pub(crate) async fn nd_delete_playlist( + server_url: String, + token: String, + id: String, +) -> Result<(), String> { + let resp = nd_retry(|| { + nd_http_client() + .delete(format!("{}/api/playlist/{}", 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(); + return Err(format!("HTTP {}: {}", status, text)); + } + Ok(()) +} diff --git a/src-tauri/src/lib_commands/app_api/navidrome/queries.rs b/src-tauri/src/lib_commands/app_api/navidrome/queries.rs new file mode 100644 index 00000000..193f3977 --- /dev/null +++ b/src-tauri/src/lib_commands/app_api/navidrome/queries.rs @@ -0,0 +1,207 @@ +//! Native-API queries that the Subsonic API doesn't cover or covers +//! incompletely: songs, role-filtered artist/album lists, libraries, +//! per-user library assignment, and absolute song path resolution. + +use super::client::{navidrome_token, nd_err, nd_http_client, nd_retry}; + +/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated song list. +/// Available to any authenticated user (no admin required). Returns raw JSON array. +#[tauri::command] +pub(crate) async fn nd_list_songs( + server_url: String, + token: String, + sort: String, + order: String, + start: u32, + end: u32, +) -> Result { + let url = format!( + "{}/api/song?_sort={}&_order={}&_start={}&_end={}", + server_url, sort, order, start, end + ); + let resp = nd_retry(|| { + nd_http_client() + .get(&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(nd_err) +} + +/// Build the `_filters` JSON for native-API list calls. Optionally narrows the +/// query to a single library — `library_id` is the same scope key the Navidrome +/// web UI sends, and it matches the Subsonic `musicFolderId` we store per server. +fn nd_build_filters(seed: serde_json::Map, library_id: Option<&str>) -> String { + let mut obj = seed; + if let Some(lib) = library_id { + // Navidrome stores library ids as i64; our state holds them as strings + // (Subsonic musicFolderId). Send numeric when parseable, fall back to + // string for safety against future non-numeric ids. + let val = lib.parse::() + .map(|n| serde_json::Value::Number(n.into())) + .unwrap_or_else(|_| serde_json::Value::String(lib.to_string())); + obj.insert("library_id".to_string(), val); + } + serde_json::Value::Object(obj).to_string() +} + +/// GET `/api/artist?_filters={"role":""}&_sort=...&_order=...&_start=...&_end=...` +/// — paginated list of artists that have at least one credit in the given role. +/// Navidrome 0.55.0+ (uses `library_artist.stats` JSON aggregate). Available to any +/// authenticated user. Returns raw JSON array. +#[tauri::command] +pub(crate) async fn nd_list_artists_by_role( + server_url: String, + token: String, + role: String, + sort: String, + order: String, + start: u32, + end: u32, + library_id: Option, +) -> Result { + let mut seed = serde_json::Map::new(); + seed.insert("role".to_string(), serde_json::Value::String(role.clone())); + let filters = nd_build_filters(seed, library_id.as_deref()); + let start_s = start.to_string(); + let end_s = end.to_string(); + let resp = nd_retry(|| { + nd_http_client() + .get(format!("{}/api/artist", server_url)) + .query(&[ + ("_filters", filters.as_str()), + ("_sort", sort.as_str()), + ("_order", order.as_str()), + ("_start", start_s.as_str()), + ("_end", end_s.as_str()), + ]) + .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(nd_err) +} + +/// GET `/api/album?_filters={"role__id":""}&_sort=...&_order=...&_start=...&_end=...` +/// — paginated list of albums in which `artist_id` holds the given participant role. +/// Subsonic `getArtist.view` only walks AlbumArtist relations, so composer-only +/// (or conductor-only, lyricist-only, …) credits are unreachable there. Navidrome +/// generates `role__id` filters dynamically from `model.AllRoles`. +#[tauri::command] +pub(crate) async fn nd_list_albums_by_artist_role( + server_url: String, + token: String, + artist_id: String, + role: String, + sort: String, + order: String, + start: u32, + end: u32, + library_id: Option, +) -> Result { + let filter_key = format!("role_{}_id", role); + let mut seed = serde_json::Map::new(); + seed.insert(filter_key, serde_json::Value::String(artist_id.clone())); + let filters = nd_build_filters(seed, library_id.as_deref()); + let start_s = start.to_string(); + let end_s = end.to_string(); + let resp = nd_retry(|| { + nd_http_client() + .get(format!("{}/api/album", server_url)) + .query(&[ + ("_filters", filters.as_str()), + ("_sort", sort.as_str()), + ("_order", order.as_str()), + ("_start", start_s.as_str()), + ("_end", end_s.as_str()), + ]) + .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(nd_err) +} + +/// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array. +#[tauri::command] +pub(crate) async fn nd_list_libraries( + server_url: String, + token: String, +) -> Result { + 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(nd_err) +} + +/// PUT `/api/user/{id}/library` — assign libraries to a non-admin user. +/// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400. +#[tauri::command] +pub(crate) async fn nd_set_user_libraries( + server_url: String, + token: String, + id: String, + library_ids: Vec, +) -> Result<(), String> { + let body = serde_json::json!({ "libraryIds": library_ids }); + 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(); + return Err(format!("HTTP {}: {}", status, text)); + } + Ok(()) +} + +/// GET `/api/song/{id}` and return the absolute filesystem `path` field. +/// +/// Subsonic `getSong.view` returns at most a relative path (`Artist/Album/track.flac`), +/// or nothing at all on Navidrome. The Navidrome native API exposes the absolute +/// path the server stores the file at — same source Feishin and the Navidrome web +/// client use for their "show file location" feature. Logs in fresh (no token +/// cache yet); the call is occasional (Song Info modal open) so the extra +/// roundtrip is acceptable. +/// +/// Returns `Ok(None)` when the response has no `path` field — Navidrome can omit +/// it for non-admin users on some configurations. +#[tauri::command] +pub(crate) async fn nd_get_song_path( + server_url: String, + username: String, + password: String, + id: String, +) -> Result, String> { + let token = navidrome_token(&server_url, &username, &password).await?; + let url = format!("{}/api/song/{}", server_url, id); + let resp = nd_retry(|| { + nd_http_client() + .get(&url) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .send() + }) + .await?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + let data: serde_json::Value = resp.json().await.map_err(nd_err)?; + Ok(data["path"].as_str().map(|s| s.to_string()).filter(|s| !s.is_empty())) +} diff --git a/src-tauri/src/lib_commands/app_api/navidrome/users.rs b/src-tauri/src/lib_commands/app_api/navidrome/users.rs new file mode 100644 index 00000000..572f574a --- /dev/null +++ b/src-tauri/src/lib_commands/app_api/navidrome/users.rs @@ -0,0 +1,138 @@ +//! Login + admin user CRUD. Each authenticated command takes a Bearer +//! `token` (obtained via `navidrome_login`); admin-only ones return 401/403 +//! when the caller is not an admin. + +use super::client::{nd_err, nd_http_client, nd_retry, NdLoginResult}; + +/// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin. +#[tauri::command] +pub(crate) async fn navidrome_login( + server_url: String, + username: String, + password: String, +) -> Result { + 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(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); + Ok(NdLoginResult { token, user_id, is_admin }) +} + +/// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields. +#[tauri::command] +pub(crate) async fn nd_list_users( + server_url: String, + token: String, +) -> Result { + 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(nd_err) +} + +/// POST `/api/user` — create a user. +#[tauri::command] +pub(crate) async fn nd_create_user( + server_url: String, + token: String, + user_name: String, + name: String, + email: String, + password: String, + is_admin: bool, +) -> Result { + let body = serde_json::json!({ + "userName": user_name, + "name": name, + "email": email, + "password": password, + "isAdmin": is_admin, + }); + 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() { + return Err(format!("HTTP {}: {}", status, text)); + } + serde_json::from_str(&text).map_err(|e| e.to_string()) +} + +/// PUT `/api/user/{id}` — update a user. Pass an empty `password` to leave it unchanged. +#[tauri::command] +pub(crate) async fn nd_update_user( + server_url: String, + token: String, + id: String, + user_name: String, + name: String, + email: String, + password: String, + is_admin: bool, +) -> Result { + let mut body = serde_json::json!({ + "id": id, + "userName": user_name, + "name": name, + "email": email, + "isAdmin": is_admin, + }); + if !password.is_empty() { + body["password"] = serde_json::Value::String(password); + } + 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() { + return Err(format!("HTTP {}: {}", status, text)); + } + Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null)) +} + +/// DELETE `/api/user/{id}`. +#[tauri::command] +pub(crate) async fn nd_delete_user( + server_url: String, + token: String, + id: String, +) -> Result<(), 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(); + return Err(format!("HTTP {}: {}", status, text)); + } + Ok(()) +}