//! 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>, /// 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 { 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()), } } } impl Default for DiscordState { fn default() -> Self { Self::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`. /// iTunes Search API endpoint. Lifted to a constant so [`search_itunes_artwork_with_base`] /// can be redirected at a wiremock instance in tests. const ITUNES_SEARCH_URL: &str = "https://itunes.apple.com/search"; fn search_itunes_artwork( client: &Client, cache: &Mutex>, artist: &str, album: &str, title: &str, ) -> Option { search_itunes_artwork_with_base(client, cache, artist, album, title, ITUNES_SEARCH_URL) } /// Test-friendly variant of [`search_itunes_artwork`] that takes the search /// endpoint as a parameter. Production calls always go through the wrapper /// above, which pins the iTunes URL. fn search_itunes_artwork_with_base( client: &Client, cache: &Mutex>, artist: &str, album: &str, title: &str, base_url: &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(base_url).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(base_url).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(base_url).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(), }, ); } } /// 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 { 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) } /// Bundled output of [`compute_discord_text_fields`]. pub(crate) struct DiscordTextFields { pub name: String, pub details: String, pub state: String, pub large_text: String, } /// Pure helper: resolve all four configurable Discord text fields, applying /// the supplied templates (or falling back to documented defaults). pub(crate) fn compute_discord_text_fields( title: &str, artist: &str, album: Option<&str>, details_template: Option<&str>, state_template: Option<&str>, large_text_template: Option<&str>, name_template: Option<&str>, ) -> DiscordTextFields { let name = apply_template(name_template.unwrap_or("{title}"), title, artist, album); let details = apply_template( details_template.unwrap_or("{artist} - {title}"), title, artist, album, ); let state = apply_template(state_template.unwrap_or("{album}"), title, artist, album); let large_text = apply_template( large_text_template.unwrap_or("{album}"), title, artist, album, ); DiscordTextFields { name, details, state, large_text, } } /// Pure helper: compute the Unix-timestamp `start` field that Discord uses /// to show "X minutes elapsed" when `elapsed_secs` is supplied. pub(crate) fn compute_discord_start_timestamp(elapsed_secs: f64, now_unix_secs: i64) -> i64 { now_unix_secs - elapsed_secs.floor() as i64 } /// 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} /// - `name_template`: template string overriding Discord's default application name in the /// user list (e.g. "🎵 Bohemian Rhapsody" instead of "🎵 Psysonic"). Default: "{title}". /// Empty string falls back to the registered Discord application name. /// Supported placeholders: {title}, {artist}, {album} #[tauri::command] #[allow(clippy::too_many_arguments)] pub async fn discord_update_presence( state: tauri::State<'_, DiscordState>, title: String, artist: String, album: Option, is_playing: bool, elapsed_secs: Option, cover_art_url: Option, fetch_itunes_covers: bool, details_template: Option, state_template: Option, large_text_template: Option, name_template: Option, ) -> 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 = 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(); let texts = compute_discord_text_fields( &title, &artist, album.as_deref(), details_template.as_deref(), state_template.as_deref(), large_text_template.as_deref(), name_template.as_deref(), ); let assets = if let Some(ref url) = artwork_url { Assets::new() .large_image(url.as_str()) .large_text(&texts.large_text) } else { // Fallback to default Psysonic icon Assets::new() .large_image("psysonic") .large_text(&texts.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 mut activity = Activity::new().activity_type(ActivityType::Listening); if !texts.name.is_empty() { activity = activity.name(texts.name.as_str()); } let activity = activity .details(&texts.details) .state(&texts.state) .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; Timestamps::new().start(compute_discord_start_timestamp(elapsed, now)) } 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: \"{}\" / \"{}\"", texts.details, texts.state ); } Ok(()) } /// 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.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(()) } #[cfg(test)] mod tests { use super::*; use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; // ── normalize ───────────────────────────────────────────────────────────── #[test] fn normalize_lowercases_and_collapses_whitespace() { assert_eq!(normalize(" Pink FLOYD "), "pink floyd"); assert_eq!(normalize("The\tBeatles\n"), "the beatles"); } #[test] fn normalize_returns_empty_for_pure_whitespace() { assert_eq!(normalize(""), ""); assert_eq!(normalize(" "), ""); } #[test] fn normalize_preserves_unicode_letters() { assert_eq!(normalize("Sigur Rós"), "sigur rós"); assert_eq!(normalize("Mötley Crüe"), "mötley crüe"); } // ── words_overlap ───────────────────────────────────────────────────────── #[test] fn words_overlap_returns_false_for_empty_inputs() { assert!(!words_overlap("", "anything")); assert!(!words_overlap("anything", "")); assert!(!words_overlap("", "")); } #[test] fn words_overlap_returns_true_for_full_match() { assert!(words_overlap("a b c", "a b c")); } #[test] fn words_overlap_meets_50_percent_threshold() { // "a b" vs "a c" — 1 of 2 words overlap → 50% (just meets ceil-half). assert!(words_overlap("a b", "a c")); } #[test] fn words_overlap_below_threshold_returns_false() { // 1 of 4 words overlap = 25%. assert!(!words_overlap("a b c d", "a x y z")); } #[test] fn words_overlap_handles_asymmetric_lengths() { // "the beatles" (2 words) vs "the beatles greatest hits" (4 words): // 2 common, min_len = 2 → threshold = 1+0 = 1, so true. assert!(words_overlap("the beatles", "the beatles greatest hits")); } // ── apply_template ──────────────────────────────────────────────────────── #[test] fn apply_template_replaces_all_placeholders() { let out = apply_template( "{artist} - {title} ({album})", "Comfortably Numb", "Pink Floyd", Some("The Wall"), ); assert_eq!(out, "Pink Floyd - Comfortably Numb (The Wall)"); } #[test] fn apply_template_substitutes_empty_for_missing_album() { let out = apply_template("{album}", "t", "a", None); assert_eq!(out, ""); } #[test] fn apply_template_leaves_unknown_placeholders_untouched() { // Only {title}, {artist}, {album} are supported — {year} stays literal. let out = apply_template("{title} ({year})", "t", "a", None); assert_eq!(out, "t ({year})"); } #[test] fn apply_template_repeats_replacement_for_repeated_placeholder() { let out = apply_template("{artist} / {artist}", "t", "AC/DC", None); assert_eq!(out, "AC/DC / AC/DC"); } // ── compute_discord_text_fields ────────────────────────────────────────── #[test] fn text_fields_use_documented_defaults_when_templates_are_none() { let f = compute_discord_text_fields("Song", "Artist", Some("Album"), None, None, None, None); assert_eq!(f.name, "Song"); assert_eq!(f.details, "Artist - Song"); assert_eq!(f.state, "Album"); assert_eq!(f.large_text, "Album"); } #[test] fn text_fields_apply_supplied_templates_overriding_defaults() { let f = compute_discord_text_fields( "Song", "Artist", Some("Album"), Some("{title} | {album}"), Some("by {artist}"), Some("{album} ({artist})"), Some("{title} ({artist})"), ); assert_eq!(f.name, "Song (Artist)"); assert_eq!(f.details, "Song | Album"); assert_eq!(f.state, "by Artist"); assert_eq!(f.large_text, "Album (Artist)"); } #[test] fn text_fields_substitute_empty_for_missing_album() { let f = compute_discord_text_fields("Song", "Artist", None, None, None, None, None); // {album} placeholder → empty, but the surrounding template stays. assert_eq!(f.name, "Song"); assert_eq!(f.details, "Artist - Song"); assert_eq!(f.state, ""); assert_eq!(f.large_text, ""); } #[test] fn text_fields_handle_unicode_and_special_characters() { let f = compute_discord_text_fields( "Bohemian Rhapsody", "Queen", Some("A Night at the Opera"), Some("{artist} – {title}"), None, None, None, ); assert_eq!(f.name, "Bohemian Rhapsody"); assert_eq!(f.details, "Queen – Bohemian Rhapsody"); } // ── compute_discord_start_timestamp ────────────────────────────────────── #[test] fn start_timestamp_subtracts_floor_of_elapsed() { // elapsed=42.7 → floor=42; start = now - 42 assert_eq!(compute_discord_start_timestamp(42.7, 1_700_000_000), 1_699_999_958); } #[test] fn start_timestamp_for_zero_elapsed_equals_now() { assert_eq!(compute_discord_start_timestamp(0.0, 1_700_000_000), 1_700_000_000); } #[test] fn start_timestamp_handles_fractional_seconds_via_floor() { // 0.999 → floor 0 (same as just-started) assert_eq!(compute_discord_start_timestamp(0.999, 1_700_000_000), 1_700_000_000); // 1.0001 → floor 1 assert_eq!(compute_discord_start_timestamp(1.0001, 1_700_000_000), 1_699_999_999); } // ── cache_and_return ────────────────────────────────────────────────────── #[test] fn cache_and_return_inserts_entry_with_url() { let cache: Mutex> = Mutex::new(HashMap::new()); cache_and_return(&cache, "key".to_string(), "https://example/600x600.jpg"); let g = cache.lock().unwrap(); let entry = g.get("key").expect("entry inserted"); assert_eq!(entry.url, "https://example/600x600.jpg"); // fetched_at is set to now() — sanity-check it's recent. assert!(entry.fetched_at.elapsed() < std::time::Duration::from_secs(1)); } // ── search_with_url against wiremock ────────────────────────────────────── fn itunes_blocking_client() -> Client { // Mirror the production builder used by DiscordState. Client::builder() .timeout(std::time::Duration::from_secs(5)) .build() .unwrap() } #[tokio::test(flavor = "multi_thread")] async fn search_with_url_returns_600x600_when_artist_and_album_match() { let server = MockServer::start().await; Mock::given(method("GET")) .and(wm_path("/search")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "results": [ { "collectionName": "The Wall", "artistName": "Pink Floyd", "artworkUrl100": "https://is1-ssl.mzstatic.com/100x100bb.jpg" } ] }))) .mount(&server) .await; let server_uri = server.uri(); let result = tokio::task::spawn_blocking(move || { let url = url::Url::parse(&format!("{server_uri}/search")).unwrap(); search_with_url(&itunes_blocking_client(), url, "pink floyd", "the wall") }) .await .unwrap(); assert_eq!( result, Some("https://is1-ssl.mzstatic.com/600x600bb.jpg".to_string()), "100x100 must be replaced with 600x600" ); } #[tokio::test(flavor = "multi_thread")] async fn search_with_url_returns_none_when_no_results_match() { let server = MockServer::start().await; Mock::given(method("GET")) .and(wm_path("/search")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "results": [ { "collectionName": "Some Other Album", "artistName": "Different Artist", "artworkUrl100": "https://x/100x100.jpg" } ] }))) .mount(&server) .await; let server_uri = server.uri(); let result = tokio::task::spawn_blocking(move || { let url = url::Url::parse(&format!("{server_uri}/search")).unwrap(); search_with_url(&itunes_blocking_client(), url, "pink floyd", "the wall") }) .await .unwrap(); assert!(result.is_none()); } #[tokio::test(flavor = "multi_thread")] async fn search_with_url_returns_none_for_empty_results() { let server = MockServer::start().await; Mock::given(method("GET")) .and(wm_path("/search")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "results": [] }))) .mount(&server) .await; let server_uri = server.uri(); let result = tokio::task::spawn_blocking(move || { let url = url::Url::parse(&format!("{server_uri}/search")).unwrap(); search_with_url(&itunes_blocking_client(), url, "x", "y") }) .await .unwrap(); assert!(result.is_none()); } // ── search_itunes_artwork_with_base — full strategy ladder + cache ────── #[tokio::test(flavor = "multi_thread")] async fn artwork_with_base_returns_cached_url_without_network() { // No mock — if the function tries to hit the network it'll fail with // a transport error rather than the cached value. let server = MockServer::start().await; let cache: Mutex> = Mutex::new(HashMap::new()); cache.lock().unwrap().insert( "Pink Floyd|The Wall".to_string(), ArtworkCacheEntry { url: "https://cached/600x600.jpg".to_string(), fetched_at: Instant::now(), }, ); let server_uri = server.uri(); let result = tokio::task::spawn_blocking(move || { let url = format!("{server_uri}/search"); search_itunes_artwork_with_base( &itunes_blocking_client(), &cache, "Pink Floyd", "The Wall", "Comfortably Numb", &url, ) }) .await .unwrap(); assert_eq!(result, Some("https://cached/600x600.jpg".to_string())); } #[tokio::test(flavor = "multi_thread")] async fn artwork_with_base_uses_strategy_1_when_exact_match_succeeds() { let server = MockServer::start().await; Mock::given(method("GET")) .and(wm_path("/search")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "results": [ { "collectionName": "The Wall", "artistName": "Pink Floyd", "artworkUrl100": "https://itunes/strategy1/100x100.jpg" } ] }))) .mount(&server) .await; let server_uri = server.uri(); let cache: Mutex> = Mutex::new(HashMap::new()); let result = tokio::task::spawn_blocking(move || { let url = format!("{server_uri}/search"); search_itunes_artwork_with_base( &itunes_blocking_client(), &cache, "Pink Floyd", "The Wall", "Comfortably Numb", &url, ) }) .await .unwrap(); assert_eq!( result, Some("https://itunes/strategy1/600x600.jpg".to_string()), "first matching strategy returns immediately + caches" ); } #[tokio::test(flavor = "multi_thread")] async fn artwork_with_base_returns_none_when_no_strategy_matches() { let server = MockServer::start().await; // Server always returns empty results — every strategy misses. Mock::given(method("GET")) .and(wm_path("/search")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "results": [] }))) .mount(&server) .await; let server_uri = server.uri(); let cache: Mutex> = Mutex::new(HashMap::new()); let result = tokio::task::spawn_blocking(move || { let url = format!("{server_uri}/search"); search_itunes_artwork_with_base( &itunes_blocking_client(), &cache, "Unknown", "Album", "Title", &url, ) }) .await .unwrap(); assert!(result.is_none()); } #[tokio::test(flavor = "multi_thread")] async fn artwork_with_base_caches_successful_lookup_for_subsequent_calls() { let server = MockServer::start().await; Mock::given(method("GET")) .and(wm_path("/search")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "results": [ { "collectionName": "Album", "artistName": "Artist", "artworkUrl100": "https://itunes/cached/100x100.jpg" } ] }))) .mount(&server) .await; let server_uri = server.uri(); let cache: Arc>> = Arc::new(Mutex::new(HashMap::new())); let cache_clone = Arc::clone(&cache); let _first = tokio::task::spawn_blocking(move || { let url = format!("{server_uri}/search"); search_itunes_artwork_with_base( &itunes_blocking_client(), &cache_clone, "Artist", "Album", "T", &url, ) }) .await .unwrap(); // After first lookup, cache must hold the resolved URL. let entry_url = cache.lock().unwrap().get("Artist|Album").map(|e| e.url.clone()); assert_eq!( entry_url, Some("https://itunes/cached/600x600.jpg".to_string()), "successful lookup must populate the artwork cache", ); } #[tokio::test(flavor = "multi_thread")] async fn search_with_url_uses_words_overlap_for_fuzzy_artist_match() { // Server returns "The Beatles" but our normalised query is just "beatles" — // contains() catches it, but this exercises the words_overlap branch by // using artist names where neither contains the other and only word overlap // matches. let server = MockServer::start().await; Mock::given(method("GET")) .and(wm_path("/search")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "results": [ { "collectionName": "Help", "artistName": "The Fab Four Beatles", "artworkUrl100": "https://x/100x100.jpg" } ] }))) .mount(&server) .await; let server_uri = server.uri(); let result = tokio::task::spawn_blocking(move || { let url = url::Url::parse(&format!("{server_uri}/search")).unwrap(); // "fab beatles" vs "the fab four beatles" — word overlap = 2 of 2, // 50% threshold met, contains() also catches "beatles". search_with_url(&itunes_blocking_client(), url, "fab beatles", "help") }) .await .unwrap(); assert_eq!(result, Some("https://x/600x600.jpg".to_string())); } }