refactor: extract psysonic-integration crate (M5/7)

Moves all outbound external-service bridges out of the top crate into a
new psysonic-integration crate.

  crates/psysonic-integration/
    src/discord.rs                     Rich Presence + iTunes artwork
    src/navidrome/{client,covers,...}  Native REST API admin (5 modules)
    src/remote.rs                      radio-browser, last.fm, ICY meta,
                                       generic CORS proxy (fetch_url_bytes
                                       / fetch_json_url / resolve_stream_url)
    src/bandsintown.rs                 events for an artist
    src/lib.rs                         module declarations + macro re-exports

Top crate keeps `crate::discord` working via
`pub use psysonic_integration::discord;`.

invoke_handler! in lib.rs uses full deepest paths
(`psysonic_integration::navidrome::users::nd_list_users`, etc.) so
Tauri's `__cmd__*` magic macros resolve at the right module — same
pattern audio + syncfs already use.

Cross-crate refs migrated:
  crate::subsonic_wire_user_agent → psysonic_core::user_agent::*
  pub(crate) → pub                  (cross-crate visibility)

Behaviour preserving. Cargo check + clippy --workspace clean.
This commit is contained in:
Psychotoxical
2026-05-09 13:51:28 +02:00
parent 9417d522f3
commit 98d8ea6353
17 changed files with 145 additions and 117 deletions
+6 -15
View File
@@ -1,11 +1,9 @@
mod analysis;
mod cli_bridge;
mod core;
mod navidrome;
mod integration;
mod perf;
mod platform;
mod remote;
mod integration;
mod analysis;
// Tauri commands re-exported for the lib.rs invoke_handler.
pub(crate) use cli_bridge::{
@@ -16,19 +14,8 @@ pub(crate) use core::{
exit_app, export_runtime_logs, frontend_debug_log, greet, set_logging_mode,
set_subsonic_wire_user_agent,
};
pub(crate) use navidrome::{
delete_radio_cover, navidrome_login, nd_create_playlist, nd_create_user, nd_delete_playlist,
nd_delete_user, nd_get_playlist, nd_get_song_path, nd_list_albums_by_artist_role,
nd_list_artists_by_role, nd_list_libraries, nd_list_playlists, nd_list_songs, nd_list_users,
nd_set_user_libraries, nd_update_playlist, nd_update_user, upload_artist_image,
upload_playlist_cover, upload_radio_cover,
};
pub(crate) use perf::performance_cpu_snapshot;
pub(crate) use platform::{set_linux_webkit_smooth_scrolling, set_window_decorations};
pub(crate) use remote::{
fetch_icy_metadata, fetch_json_url, fetch_url_bytes, get_top_radio_stations, lastfm_request,
resolve_stream_url, search_radio_browser,
};
pub(crate) use integration::{
check_dir_accessible, mpris_set_metadata, mpris_set_playback, register_global_shortcut,
unregister_global_shortcut,
@@ -38,3 +25,7 @@ pub(crate) use analysis::{
analysis_enqueue_seed_from_url, analysis_get_loudness_for_track, analysis_get_waveform,
analysis_get_waveform_for_track, analysis_prune_pending_to_track_ids,
};
// Discord, Navidrome admin, last.fm + radio-browser + CORS proxy, bandsintown
// now live in `psysonic_integration`. invoke_handler! in lib.rs registers
// them with their full paths so Tauri's `__cmd__*` macros resolve correctly.
@@ -1,106 +0,0 @@
//! 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<String, String> {
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<F, Fut>(mut build_and_send: F) -> Result<reqwest::Response, String>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
{
// 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<reqwest::Error> = 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())
}
@@ -1,107 +0,0 @@
//! 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<u8>,
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<u8>,
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<u8>,
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(())
}
@@ -1,28 +0,0 @@
//! 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,
};
@@ -1,120 +0,0 @@
//! 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<bool>,
) -> Result<serde_json::Value, String> {
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::<serde_json::Value>().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<serde_json::Value, String> {
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<serde_json::Value, String> {
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<serde_json::Value, String> {
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(())
}
@@ -1,207 +0,0 @@
//! 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<serde_json::Value, String> {
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::<serde_json::Value>().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<String, serde_json::Value>, 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::<i64>()
.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":"<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<String>,
) -> Result<serde_json::Value, String> {
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::<serde_json::Value>().await.map_err(nd_err)
}
/// GET `/api/album?_filters={"role_<role>_id":"<artistId>"}&_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_<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<String>,
) -> Result<serde_json::Value, String> {
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::<serde_json::Value>().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<serde_json::Value, 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::<serde_json::Value>().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<i64>,
) -> 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<Option<String>, 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()))
}
@@ -1,138 +0,0 @@
//! 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<NdLoginResult, 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(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<serde_json::Value, 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::<serde_json::Value>().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<serde_json::Value, String> {
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<serde_json::Value, String> {
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(())
}
@@ -1,344 +0,0 @@
use crate::subsonic_wire_user_agent;
pub(crate) const RADIO_PAGE_SIZE: u32 = 25;
/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView).
#[tauri::command]
pub(crate) async fn search_radio_browser(query: String, offset: u32) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new();
let limit_s = RADIO_PAGE_SIZE.to_string();
let offset_s = offset.to_string();
let resp = client
.get("https://de1.api.radio-browser.info/json/stations/search")
.header("User-Agent", subsonic_wire_user_agent())
.query(&[
("name", query.as_str()),
("hidebroken", "true"),
("limit", limit_s.as_str()),
("offset", offset_s.as_str()),
])
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
resp.json::<Vec<serde_json::Value>>().await.map_err(|e| e.to_string())
}
/// Fetch top-voted stations from radio-browser.info for initial suggestions.
#[tauri::command]
pub(crate) async fn get_top_radio_stations(offset: u32) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new();
let limit_s = RADIO_PAGE_SIZE.to_string();
let offset_s = offset.to_string();
let resp = client
.get("https://de1.api.radio-browser.info/json/stations/topvote")
.header("User-Agent", subsonic_wire_user_agent())
.query(&[("limit", limit_s.as_str()), ("offset", offset_s.as_str())])
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
resp.json::<Vec<serde_json::Value>>().await.map_err(|e| e.to_string())
}
/// Fetch arbitrary URL bytes (e.g. radio station favicon) through Rust to bypass CORS.
/// Returns (bytes, content_type).
#[tauri::command]
pub(crate) async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
let client = reqwest::Client::builder()
.user_agent(subsonic_wire_user_agent())
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get(&url)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("image/jpeg")
.split(';')
.next()
.unwrap_or("image/jpeg")
.trim()
.to_string();
let bytes = resp.bytes().await.map_err(|e| e.to_string())?;
Ok((bytes.to_vec(), content_type))
}
/// Fetch a JSON API endpoint through Rust to bypass CORS/WebView networking restrictions.
/// Returns the response body as a UTF-8 string for parsing on the JS side.
#[tauri::command]
pub(crate) async fn fetch_json_url(url: String) -> Result<String, String> {
let client = reqwest::Client::builder()
.user_agent(subsonic_wire_user_agent())
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get(&url)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let text = resp.text().await.map_err(|e| e.to_string())?;
Ok(text)
}
/// ICY metadata response returned to the frontend.
#[derive(serde::Serialize)]
pub(crate) struct IcyMetadata {
/// The `StreamTitle` from the inline ICY metadata block in the stream (e.g. `"Artist - Title"`).
stream_title: Option<String>,
/// Value of the `icy-name` response header.
icy_name: Option<String>,
/// Value of the `icy-genre` response header.
icy_genre: Option<String>,
/// Value of the `icy-url` response header.
icy_url: Option<String>,
/// Value of the `icy-description` response header.
icy_description: Option<String>,
}
/// Extract the first `File1=` stream URL from a PLS playlist file.
pub(crate) fn parse_pls_stream_url(content: &str) -> Option<String> {
content.lines()
.map(str::trim)
.find(|l| l.to_lowercase().starts_with("file1="))
.and_then(|l| {
let url = l[6..].trim();
(url.starts_with("http://") || url.starts_with("https://"))
.then(|| url.to_string())
})
}
/// Extract the first non-comment HTTP URL from an M3U/M3U8 playlist file.
pub(crate) fn parse_m3u_stream_url(content: &str) -> Option<String> {
content.lines()
.map(str::trim)
.find(|l| !l.is_empty() && !l.starts_with('#')
&& (l.starts_with("http://") || l.starts_with("https://")))
.map(str::to_string)
}
/// If `url` points to a PLS or M3U playlist, fetch it and return the first
/// stream URL it contains. Returns `None` for direct stream URLs.
pub(crate) async fn resolve_playlist_url(client: &reqwest::Client, url: &str) -> Option<String> {
let path = url.split('?').next().unwrap_or(url).to_lowercase();
let is_pls = path.ends_with(".pls");
let is_m3u = path.ends_with(".m3u") || path.ends_with(".m3u8");
if !is_pls && !is_m3u {
return None;
}
let resp = client
.get(url)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.ok()?;
let ct = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_lowercase();
let text = resp.text().await.ok()?;
if is_pls || ct.contains("scpls") || ct.contains("pls+xml") {
parse_pls_stream_url(&text)
} else {
parse_m3u_stream_url(&text)
}
}
/// Fetch ICY in-stream metadata from a radio stream URL.
///
/// Sends a GET request with `Icy-MetaData: 1` and reads just enough bytes
/// (up to `icy-metaint` audio bytes plus the following metadata block) to
/// extract the `StreamTitle`. The connection is dropped as soon as the
/// first metadata chunk has been parsed, so bandwidth usage is minimal.
///
/// If `url` is a PLS or M3U playlist file it is resolved to the first direct
/// stream URL before the ICY request is made.
#[tauri::command]
pub(crate) async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
use futures_util::StreamExt;
let client = reqwest::Client::builder()
.user_agent(subsonic_wire_user_agent())
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| e.to_string())?;
// Resolve PLS/M3U playlist files to their first direct stream URL.
let url = resolve_playlist_url(&client, &url).await.unwrap_or(url);
let resp = client
.get(&url)
.header("Icy-MetaData", "1")
.send()
.await
.map_err(|e| e.to_string())?;
// Harvest ICY headers before consuming the body.
let headers = resp.headers();
let icy_name = headers.get("icy-name").and_then(|v| v.to_str().ok()).map(str::to_string);
let icy_genre = headers.get("icy-genre").and_then(|v| v.to_str().ok()).map(str::to_string);
let icy_url = headers.get("icy-url").and_then(|v| v.to_str().ok()).map(str::to_string);
let icy_description = headers.get("icy-description").and_then(|v| v.to_str().ok()).map(str::to_string);
let metaint: Option<usize> = headers
.get("icy-metaint")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok());
// If the server doesn't advertise a metaint we can still return header info.
let Some(metaint) = metaint else {
return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description });
};
// Cap metaint at 64 KiB to avoid reading unreasonably large audio chunks.
let metaint = metaint.min(65_536);
let needed = metaint + 1; // +1 for the metadata-length byte
let mut buf: Vec<u8> = Vec::with_capacity(needed + 256);
let mut stream = resp.bytes_stream();
while buf.len() < needed {
let Some(chunk) = stream.next().await else { break };
let chunk = chunk.map_err(|e| e.to_string())?;
buf.extend_from_slice(&chunk);
}
if buf.len() < needed {
// Stream ended before we reached the metadata block.
return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description });
}
// The byte immediately after `metaint` audio bytes encodes metadata length:
// actual_bytes = length_byte * 16
let meta_len = buf[metaint] as usize * 16;
if meta_len == 0 {
return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description });
}
// We may need to read a few more chunks to get the full metadata block.
let total_needed = needed + meta_len;
while buf.len() < total_needed {
let Some(chunk) = stream.next().await else { break };
let chunk = chunk.map_err(|e| e.to_string())?;
buf.extend_from_slice(&chunk);
}
let meta_start = needed; // index of first metadata byte
let meta_end = (meta_start + meta_len).min(buf.len());
let meta_bytes = &buf[meta_start..meta_end];
// ICY metadata is Latin-1 encoded; convert to a Rust String lossily.
let meta_str: String = meta_bytes
.iter()
.map(|&b| if b == 0 { '\0' } else { b as char })
.collect::<String>();
// Parse StreamTitle='...' — value ends at the next unescaped single-quote.
let stream_title = meta_str
.split("StreamTitle='")
.nth(1)
.and_then(|s| {
// Find closing quote that is NOT preceded by a backslash.
let mut prev = '\0';
let mut end = s.len();
for (i, c) in s.char_indices() {
if c == '\'' && prev != '\\' {
end = i;
break;
}
prev = c;
}
let title = s[..end].trim().to_string();
if title.is_empty() { None } else { Some(title) }
});
Ok(IcyMetadata { stream_title, icy_name, icy_genre, icy_url, icy_description })
}
/// Resolve a PLS or M3U playlist URL to its first direct stream URL.
/// Returns the original URL unchanged if it is not a recognised playlist format
/// or if the playlist cannot be fetched/parsed.
#[tauri::command]
pub(crate) async fn resolve_stream_url(url: String) -> String {
let Ok(client) = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
else {
return url;
};
resolve_playlist_url(&client, &url).await.unwrap_or(url)
}
/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions.
/// `params` is a list of [key, value] pairs (method must be included).
/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made.
#[tauri::command]
pub(crate) async fn lastfm_request(
params: Vec<[String; 2]>,
sign: bool,
get: bool,
api_key: String,
api_secret: String,
) -> Result<serde_json::Value, String> {
use std::collections::HashMap;
let mut map: HashMap<String, String> = params.into_iter().map(|[k, v]| (k, v)).collect();
map.insert("api_key".into(), api_key.clone());
if sign {
let mut keys: Vec<String> = map.keys().cloned().collect();
keys.sort();
let sig_str: String = keys.iter()
.filter(|k| k.as_str() != "format" && k.as_str() != "callback")
.map(|k| format!("{}{}", k, map[k]))
.collect::<String>();
let sig_input = format!("{}{}", sig_str, api_secret);
let digest = md5::compute(sig_input.as_bytes());
map.insert("api_sig".into(), format!("{:x}", digest));
}
map.insert("format".into(), "json".into());
let client = reqwest::Client::new();
let resp = if get {
client
.get("https://ws.audioscrobbler.com/2.0/")
.query(&map)
.header("User-Agent", subsonic_wire_user_agent())
.send()
.await
} else {
client
.post("https://ws.audioscrobbler.com/2.0/")
.form(&map)
.header("User-Agent", subsonic_wire_user_agent())
.send()
.await
}.map_err(|e| e.to_string())?;
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
if let Some(err) = json.get("error") {
return Err(format!("Last.fm {} {}", err, json.get("message").and_then(|m| m.as_str()).unwrap_or("")));
}
Ok(json)
}