mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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:
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "psysonic-integration"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
psysonic-core = { path = "../psysonic-core" }
|
||||
|
||||
tauri = { version = "2" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls", "blocking"] }
|
||||
futures-util = "0.3"
|
||||
discord-rich-presence = "1.1"
|
||||
url = "2"
|
||||
md5 = "0.8"
|
||||
@@ -0,0 +1,77 @@
|
||||
// ── Bandsintown ──────────────────────────────────────────────────────────────
|
||||
// Public REST API: https://rest.bandsintown.com/artists/{name}/events?app_id=…
|
||||
// Bandsintown whitelists app IDs — arbitrary strings now return 403 Forbidden.
|
||||
// `js_app_id` is the ID their own embeddable widget uses and is broadly accepted.
|
||||
pub const BANDSINTOWN_APP_ID: &str = "js_app_id";
|
||||
|
||||
#[derive(serde::Serialize, Default)]
|
||||
pub struct BandsintownEvent {
|
||||
datetime: String, // ISO 8601 (e.g. "2026-04-23T20:30:00")
|
||||
venue_name: String,
|
||||
venue_city: String,
|
||||
venue_region: String,
|
||||
venue_country: String,
|
||||
url: String,
|
||||
on_sale_datetime: String,
|
||||
lineup: Vec<String>,
|
||||
}
|
||||
|
||||
/// Fetch upcoming Bandsintown events for an artist by name.
|
||||
/// Returns an empty list on any failure (404, network, parse) — the UI
|
||||
/// just hides the section in that case.
|
||||
#[tauri::command]
|
||||
pub async fn fetch_bandsintown_events(artist_name: String) -> Result<Vec<BandsintownEvent>, String> {
|
||||
let trimmed = artist_name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
// Bandsintown expects the artist name URL-encoded; their API treats `/` as a
|
||||
// path separator (so e.g. AC/DC must be encoded as AC%252FDC).
|
||||
let encoded: String = url::form_urlencoded::byte_serialize(trimmed.as_bytes()).collect();
|
||||
let url = format!(
|
||||
"https://rest.bandsintown.com/artists/{}/events?app_id={}",
|
||||
encoded, BANDSINTOWN_APP_ID
|
||||
);
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return Ok(vec![]),
|
||||
};
|
||||
let resp = match client.get(&url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return Ok(vec![]),
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let raw: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Ok(vec![]),
|
||||
};
|
||||
let arr = match raw.as_array() {
|
||||
Some(a) => a,
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
let mut out: Vec<BandsintownEvent> = Vec::with_capacity(arr.len().min(20));
|
||||
for item in arr.iter().take(20) {
|
||||
let venue = item.get("venue").cloned().unwrap_or(serde_json::Value::Null);
|
||||
let lineup = item
|
||||
.get("lineup")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
out.push(BandsintownEvent {
|
||||
datetime: item.get("datetime").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
venue_name: venue.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
venue_city: venue.get("city").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
venue_region: venue.get("region").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
venue_country: venue.get("country").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
url: item.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
on_sale_datetime: item.get("on_sale_datetime").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
lineup,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
/// Discord Rich Presence integration.
|
||||
///
|
||||
/// Album artwork is fetched from the iTunes Search API and passed directly to
|
||||
/// Discord via the large_image URL field. This avoids the need to pre-upload
|
||||
/// assets to the Discord Developer Portal.
|
||||
///
|
||||
/// The commands silently no-op when Discord is not running or the App ID is wrong,
|
||||
/// so the app always starts cleanly regardless of Discord availability.
|
||||
|
||||
use discord_rich_presence::{
|
||||
activity::{Activity, ActivityType, Assets, Timestamps},
|
||||
DiscordIpc, DiscordIpcClient,
|
||||
};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
const DISCORD_APP_ID: &str = "1489544859718258779";
|
||||
|
||||
/// Cache entry for iTunes artwork lookup (avoids repeated API calls for same album).
|
||||
pub struct ArtworkCacheEntry {
|
||||
pub url: String,
|
||||
pub fetched_at: Instant,
|
||||
}
|
||||
|
||||
/// TTL: 1 hour — album artwork doesn't change, but we don't want to cache failures forever.
|
||||
const ARTWORK_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
|
||||
|
||||
pub struct DiscordState {
|
||||
pub client: Mutex<Option<DiscordIpcClient>>,
|
||||
/// Cache: "artist|album" -> artwork URL. Arc so it can be shared into spawn_blocking.
|
||||
pub artwork_cache: Arc<Mutex<HashMap<String, ArtworkCacheEntry>>>,
|
||||
/// HTTP client for iTunes API requests. blocking::Client is Clone (Arc-internally).
|
||||
pub http_client: Client,
|
||||
}
|
||||
|
||||
impl DiscordState {
|
||||
pub fn new() -> Self {
|
||||
DiscordState {
|
||||
client: Mutex::new(None),
|
||||
artwork_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
http_client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── iTunes Search API ───────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
struct ItunesResponse {
|
||||
results: Vec<ItunesResult>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
struct ItunesResult {
|
||||
collectionName: Option<String>,
|
||||
artistName: Option<String>,
|
||||
artworkUrl100: Option<String>,
|
||||
}
|
||||
|
||||
/// Normalize string for comparison: lowercase, trim, collapse whitespace.
|
||||
fn normalize(s: &str) -> String {
|
||||
s.to_lowercase()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Search for album artwork via iTunes Search API.
|
||||
/// Returns a higher-resolution URL (600x600) if found.
|
||||
///
|
||||
/// Takes explicit `client` and `cache` so this can be called from inside
|
||||
/// `tokio::task::spawn_blocking` without needing a reference to `DiscordState`.
|
||||
fn search_itunes_artwork(
|
||||
client: &Client,
|
||||
cache: &Mutex<HashMap<String, ArtworkCacheEntry>>,
|
||||
artist: &str,
|
||||
album: &str,
|
||||
title: &str,
|
||||
) -> Option<String> {
|
||||
let cache_key = format!("{}|{}", artist, album);
|
||||
|
||||
// Check cache first
|
||||
{
|
||||
let c = cache.lock().ok()?;
|
||||
if let Some(entry) = c.get(&cache_key) {
|
||||
if entry.fetched_at.elapsed() < ARTWORK_CACHE_TTL {
|
||||
return Some(entry.url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let norm_artist = normalize(artist);
|
||||
let norm_album = normalize(album);
|
||||
let norm_title = normalize(title);
|
||||
|
||||
// Strategy 1: exact match search — "artist" "album"
|
||||
let mut url = url::Url::parse("https://itunes.apple.com/search").ok()?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("term", &format!("\"{}\" \"{}\"", artist, album))
|
||||
.append_pair("media", "music")
|
||||
.append_pair("entity", "album")
|
||||
.append_pair("limit", "5");
|
||||
|
||||
if let Some(result) = search_with_url(client, url, &norm_artist, &norm_album) {
|
||||
cache_and_return(cache, cache_key, &result);
|
||||
return Some(result);
|
||||
}
|
||||
|
||||
// Strategy 2: relaxed search — artist album (no quotes)
|
||||
let mut url = url::Url::parse("https://itunes.apple.com/search").ok()?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("term", &format!("{} {}", artist, album))
|
||||
.append_pair("media", "music")
|
||||
.append_pair("entity", "album")
|
||||
.append_pair("limit", "10");
|
||||
|
||||
if let Some(result) = search_with_url(client, url, &norm_artist, &norm_album) {
|
||||
cache_and_return(cache, cache_key, &result);
|
||||
return Some(result);
|
||||
}
|
||||
|
||||
// Strategy 3: search by track title — artist + title (for singles/rare albums)
|
||||
if !title.is_empty() {
|
||||
let mut url = url::Url::parse("https://itunes.apple.com/search").ok()?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("term", &format!("{} {}", artist, title))
|
||||
.append_pair("media", "music")
|
||||
.append_pair("entity", "song")
|
||||
.append_pair("limit", "10");
|
||||
|
||||
if let Some(result) = search_with_url(client, url, &norm_artist, &norm_title) {
|
||||
cache_and_return(cache, cache_key, &result);
|
||||
return Some(result);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn search_with_url(
|
||||
client: &Client,
|
||||
url: url::Url,
|
||||
norm_artist: &str,
|
||||
norm_album: &str,
|
||||
) -> Option<String> {
|
||||
let resp = client.get(url).send().ok()?;
|
||||
let body: ItunesResponse = resp.json().ok()?;
|
||||
|
||||
for result in &body.results {
|
||||
let collection = normalize(result.collectionName.as_deref().unwrap_or(""));
|
||||
let result_artist = normalize(result.artistName.as_deref().unwrap_or(""));
|
||||
|
||||
// Flexible matching: check if strings contain each other
|
||||
// This handles cases like "The Beatles" vs "Beatles" or album subtitle differences
|
||||
let artist_match = norm_artist == result_artist
|
||||
|| norm_artist.contains(&result_artist)
|
||||
|| result_artist.contains(&norm_artist)
|
||||
|| words_overlap(norm_artist, &result_artist);
|
||||
|
||||
let album_match = norm_album == collection
|
||||
|| norm_album.contains(&collection)
|
||||
|| collection.contains(norm_album)
|
||||
|| words_overlap(norm_album, &collection);
|
||||
|
||||
if artist_match && album_match {
|
||||
return Some(result.artworkUrl100.as_ref()?.replace("100x100", "600x600"));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if two strings share at least 50% of their words.
|
||||
fn words_overlap(a: &str, b: &str) -> bool {
|
||||
let words_a: std::collections::HashSet<_> = a.split_whitespace().collect();
|
||||
let words_b: std::collections::HashSet<_> = b.split_whitespace().collect();
|
||||
|
||||
if words_a.is_empty() || words_b.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let common = words_a.intersection(&words_b).count();
|
||||
let min_len = words_a.len().min(words_b.len());
|
||||
|
||||
common >= min_len / 2 + min_len % 2 // At least 50% overlap
|
||||
}
|
||||
|
||||
fn cache_and_return(
|
||||
cache: &Mutex<HashMap<String, ArtworkCacheEntry>>,
|
||||
key: String,
|
||||
url: &str,
|
||||
) {
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
c.insert(
|
||||
key,
|
||||
ArtworkCacheEntry {
|
||||
url: url.to_string(),
|
||||
fetched_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to create and connect a fresh IPC client. Returns None silently on failure.
|
||||
///
|
||||
/// In debug builds (i.e. `npx tauri dev`) every step of the IPC handshake is
|
||||
/// logged so the renderer's terminal output shows exactly where the
|
||||
/// connection breaks. Release builds stay completely silent.
|
||||
fn try_connect() -> Option<DiscordIpcClient> {
|
||||
let mut client = DiscordIpcClient::new(DISCORD_APP_ID);
|
||||
if let Err(_e) = client.connect() {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] connect() failed: {} (Discord desktop running?)", _e);
|
||||
return None;
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] IPC connected (app_id={})", DISCORD_APP_ID);
|
||||
Some(client)
|
||||
}
|
||||
|
||||
/// Apply a template string, replacing placeholders with actual values.
|
||||
/// Supported placeholders: {title}, {artist}, {album}
|
||||
fn apply_template(template: &str, title: &str, artist: &str, album: Option<&str>) -> String {
|
||||
let album_text = album.unwrap_or("");
|
||||
template
|
||||
.replace("{title}", title)
|
||||
.replace("{artist}", artist)
|
||||
.replace("{album}", album_text)
|
||||
}
|
||||
|
||||
/// Update the Discord Rich Presence activity.
|
||||
///
|
||||
/// - `is_playing`: true = playing (timer shown), false = paused (no timer, state shows "Paused").
|
||||
/// - `elapsed_secs`: seconds already played. `None` when paused — no timestamp is sent so
|
||||
/// Discord stops any running timer.
|
||||
/// - `cover_art_url`: optional direct URL to album artwork.
|
||||
/// - `fetch_itunes_covers`: if true, fetch artwork from the iTunes Search API when no
|
||||
/// `cover_art_url` is provided. If false (default), fall back to the Psysonic app icon
|
||||
/// without making any external request — required for privacy opt-in.
|
||||
/// - `details_template`: template string for the "details" field. Default: "{artist} - {title}".
|
||||
/// Supported placeholders: {title}, {artist}, {album}
|
||||
/// - `state_template`: template string for the "state" field. Default: "{album}".
|
||||
/// Supported placeholders: {title}, {artist}, {album}
|
||||
/// - `large_text_template`: template string for the large image tooltip. Default: "{album}".
|
||||
/// Supported placeholders: {title}, {artist}, {album}
|
||||
#[tauri::command]
|
||||
pub async fn discord_update_presence(
|
||||
state: tauri::State<'_, DiscordState>,
|
||||
title: String,
|
||||
artist: String,
|
||||
album: Option<String>,
|
||||
is_playing: bool,
|
||||
elapsed_secs: Option<f64>,
|
||||
cover_art_url: Option<String>,
|
||||
fetch_itunes_covers: bool,
|
||||
details_template: Option<String>,
|
||||
state_template: Option<String>,
|
||||
large_text_template: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
// Resolve artwork on a dedicated blocking thread — reqwest::blocking must not
|
||||
// run on the Tokio async executor directly.
|
||||
// Only hit the iTunes API if the user has explicitly opted in.
|
||||
let artwork_url: Option<String> = if let Some(url) = cover_art_url {
|
||||
Some(url)
|
||||
} else if fetch_itunes_covers {
|
||||
if let Some(ref album_name) = album {
|
||||
let http_client = state.http_client.clone();
|
||||
let cache = Arc::clone(&state.artwork_cache);
|
||||
let artist_c = artist.clone();
|
||||
let album_c = album_name.clone();
|
||||
let title_c = title.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
search_itunes_artwork(&http_client, &cache, &artist_c, &album_c, &title_c)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut guard = state.client.lock().unwrap();
|
||||
|
||||
// (Re)connect lazily — handles the case where Discord starts after the app.
|
||||
if guard.is_none() {
|
||||
match try_connect() {
|
||||
Some(client) => *guard = Some(client),
|
||||
None => return Ok(()), // Discord not running — silently skip
|
||||
}
|
||||
}
|
||||
|
||||
let client = guard.as_mut().unwrap();
|
||||
|
||||
// Apply templates for the three configurable text fields.
|
||||
let details_str = details_template.as_deref().unwrap_or("{artist} - {title}");
|
||||
let details_text = apply_template(details_str, &title, &artist, album.as_deref());
|
||||
|
||||
let state_str = state_template.as_deref().unwrap_or("{album}");
|
||||
let state_text = apply_template(state_str, &title, &artist, album.as_deref());
|
||||
|
||||
let large_text_str = large_text_template.as_deref().unwrap_or("{album}");
|
||||
let large_text = apply_template(large_text_str, &title, &artist, album.as_deref());
|
||||
|
||||
let assets = if let Some(ref url) = artwork_url {
|
||||
Assets::new()
|
||||
.large_image(url.as_str())
|
||||
.large_text(&large_text)
|
||||
} else {
|
||||
// Fallback to default Psysonic icon
|
||||
Assets::new()
|
||||
.large_image("psysonic")
|
||||
.large_text(&large_text)
|
||||
};
|
||||
|
||||
// When paused: clear activity completely to avoid any timer issues
|
||||
// When playing: show full activity with timer
|
||||
if !is_playing {
|
||||
if let Err(_e) = client.clear_activity() {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] clear_activity (pause) failed, dropping client: {}", _e);
|
||||
*guard = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Only reach here when playing
|
||||
let activity = Activity::new()
|
||||
.activity_type(ActivityType::Listening)
|
||||
.details(&details_text)
|
||||
.state(&state_text)
|
||||
.assets(assets)
|
||||
.timestamps(if let Some(elapsed) = elapsed_secs {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let start = now - elapsed.floor() as i64;
|
||||
Timestamps::new().start(start)
|
||||
} else {
|
||||
Timestamps::new()
|
||||
});
|
||||
|
||||
if let Err(_e) = client.set_activity(activity) {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] set_activity failed, dropping client: {}", _e);
|
||||
*guard = None;
|
||||
} else {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!(
|
||||
"[discord] activity sent: \"{}\" / \"{}\"",
|
||||
details_text,
|
||||
state_text
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the Discord Rich Presence activity (e.g. playback stopped).
|
||||
#[tauri::command]
|
||||
pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> {
|
||||
let mut guard = state.client.lock().unwrap();
|
||||
if let Some(client) = guard.as_mut() {
|
||||
if let Err(_e) = client.clear_activity() {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] clear_activity failed, dropping client: {}", _e);
|
||||
*guard = None;
|
||||
} else {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] activity cleared");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! `psysonic-integration` — outbound bridges to external services that the
|
||||
//! UI surfaces but the WebView can't talk to directly (CORS, custom auth, etc.).
|
||||
//!
|
||||
//! Domains:
|
||||
//! - `discord` — Discord Rich Presence (album artwork via iTunes)
|
||||
//! - `navidrome` — Navidrome's native REST API (admin: users/playlists/covers/queries)
|
||||
//! - `remote` — radio-browser, last.fm, ICY-meta probe, generic CORS proxy
|
||||
//! - `bandsintown` — bandsintown events for an artist
|
||||
|
||||
// Re-export the logging facade so submodules keep using
|
||||
// `crate::app_eprintln!()` and `crate::app_deprintln!()`.
|
||||
pub use psysonic_core::{app_deprintln, app_eprintln, logging};
|
||||
|
||||
pub mod bandsintown;
|
||||
pub mod discord;
|
||||
pub mod navidrome;
|
||||
pub mod remote;
|
||||
@@ -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 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 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 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 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 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())
|
||||
}
|
||||
@@ -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 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 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 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 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(())
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! 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;
|
||||
pub mod covers;
|
||||
pub mod playlists;
|
||||
pub mod queries;
|
||||
pub mod users;
|
||||
@@ -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 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 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 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 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 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(())
|
||||
}
|
||||
@@ -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 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 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 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 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 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 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()))
|
||||
}
|
||||
@@ -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 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 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 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 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 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(())
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
use psysonic_core::user_agent::subsonic_wire_user_agent;
|
||||
|
||||
pub const RADIO_PAGE_SIZE: u32 = 25;
|
||||
|
||||
/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView).
|
||||
#[tauri::command]
|
||||
pub 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 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 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 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 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 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 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 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 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 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 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)
|
||||
}
|
||||
Reference in New Issue
Block a user