diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 71c1716e..e8137671 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1367,6 +1367,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -3442,6 +3443,7 @@ dependencies = [ "tauri-plugin-updater", "tauri-plugin-window-state", "tokio", + "url", ] [[package]] @@ -3737,6 +3739,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", "futures-util", "http", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2349c31e..45c7bd76 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,7 +32,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] } symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] } -reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "blocking"] } futures-util = "0.3" md5 = "0.7" tokio = { version = "1", features = ["rt", "time"] } @@ -43,3 +43,4 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] } discord-rich-presence = "0.2" +url = "2" diff --git a/src-tauri/src/discord.rs b/src-tauri/src/discord.rs index fc253319..1fef1247 100644 --- a/src-tauri/src/discord.rs +++ b/src-tauri/src/discord.rs @@ -1,10 +1,8 @@ /// Discord Rich Presence integration. /// -/// To enable this feature: -/// 1. Go to https://discord.com/developers/applications and create an application. -/// 2. Copy the Application ID and replace DISCORD_APP_ID below. -/// 3. In the "Rich Presence → Art Assets" tab, upload a PNG named "psysonic" -/// (use the app icon from public/logo.png). +/// 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. @@ -13,15 +11,201 @@ use discord_rich_presence::{ activity::{Activity, ActivityType, Assets, Timestamps}, DiscordIpc, DiscordIpcClient, }; -use std::sync::Mutex; +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"; -pub struct DiscordState(pub Mutex>); +/// 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>, + /// Cache: "artist|album" -> artwork URL. Arc so it can be shared into spawn_blocking. + pub artwork_cache: Arc>>, + /// HTTP client for iTunes API requests. blocking::Client is Clone (Arc-internally). + pub http_client: Client, +} impl DiscordState { pub fn new() -> Self { - DiscordState(Mutex::new(None)) + 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, +} + +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +struct ItunesResult { + collectionName: Option, + artistName: Option, + artworkUrl100: Option, +} + +/// Normalize string for comparison: lowercase, trim, collapse whitespace. +fn normalize(s: &str) -> String { + s.to_lowercase() + .split_whitespace() + .collect::>() + .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>, + artist: &str, + album: &str, + title: &str, +) -> Option { + 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 { + 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>, + key: String, + url: &str, +) { + if let Ok(mut c) = cache.lock() { + c.insert( + key, + ArtworkCacheEntry { + url: url.to_string(), + fetched_at: Instant::now(), + }, + ); } } @@ -36,15 +220,38 @@ fn try_connect() -> Option { /// /// - `elapsed_secs`: seconds already played. `None` when paused — Discord shows /// the song/artist without a running timer. +/// - `cover_art_url`: optional direct URL to album artwork. If None, tries to +/// fetch from iTunes Search API using artist + album. #[tauri::command] -pub fn discord_update_presence( - state: tauri::State, +pub async fn discord_update_presence( + state: tauri::State<'_, DiscordState>, title: String, artist: String, album: Option, elapsed_secs: Option, + cover_art_url: Option, ) -> Result<(), String> { - let mut guard = state.0.lock().unwrap(); + // Resolve artwork on a dedicated blocking thread — reqwest::blocking must not + // run on the Tokio async executor directly. + let artwork_url: Option = if let Some(url) = cover_art_url { + Some(url) + } else 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 + }; + + let mut guard = state.client.lock().unwrap(); // (Re)connect lazily — handles the case where Discord starts after the app. if guard.is_none() { @@ -62,9 +269,16 @@ pub fn discord_update_presence( // the cover art icon. let large_text = album.as_deref().unwrap_or("Psysonic"); - let assets = Assets::new() - .large_image("psysonic") - .large_text(large_text); + 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) + }; let mut activity = Activity::new() .activity_type(ActivityType::Listening) @@ -95,7 +309,7 @@ pub fn discord_update_presence( /// Clear the Discord Rich Presence activity (e.g. playback stopped). #[tauri::command] pub fn discord_clear_presence(state: tauri::State) -> Result<(), String> { - let mut guard = state.0.lock().unwrap(); + let mut guard = state.client.lock().unwrap(); if let Some(client) = guard.as_mut() { if client.clear_activity().is_err() { *guard = None; diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 4d5aa703..baeb3aa8 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -522,6 +522,9 @@ export function initAudioListeners(): () => void { // Pass elapsed when playing so Discord shows a live running timer. // Pass null when paused — Discord shows the song/artist without a timer. elapsedSecs: isPlaying ? currentTime : null, + // coverArtUrl is intentionally not passed — Subsonic URLs require auth. + // Backend will fetch artwork from iTunes Search API instead. + coverArtUrl: null, }).catch(() => {}); }