feat: album cover art in Discord Rich Presence via iTunes Search API

Fetches album artwork from the iTunes Search API and passes the URL
directly to Discord's large_image field. Subsonic cover URLs require
auth and can't be used by Discord directly.

- 3-strategy search: exact quoted → relaxed → track title fallback
- 1-hour in-memory cache per artist|album key
- iTunes search runs in tokio::task::spawn_blocking (reqwest::blocking
  must not run on the Tokio async executor)
- artwork_cache wrapped in Arc<Mutex<...>> for cross-thread sharing
- Fallback to the pre-uploaded "psysonic" asset when no match found
- Cargo.toml: blocking feature alongside rustls-tls

Co-Authored-By: kilyabin <kilyabin@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-06 19:30:22 +02:00
parent b448c2bc82
commit 1197c1f916
4 changed files with 237 additions and 16 deletions
+3
View File
@@ -1367,6 +1367,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@@ -3442,6 +3443,7 @@ dependencies = [
"tauri-plugin-updater", "tauri-plugin-updater",
"tauri-plugin-window-state", "tauri-plugin-window-state",
"tokio", "tokio",
"url",
] ]
[[package]] [[package]]
@@ -3737,6 +3739,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"http", "http",
+2 -1
View File
@@ -32,7 +32,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] } 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"] } 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" futures-util = "0.3"
md5 = "0.7" md5 = "0.7"
tokio = { version = "1", features = ["rt", "time"] } tokio = { version = "1", features = ["rt", "time"] }
@@ -43,3 +43,4 @@ tauri-plugin-updater = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] } souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
discord-rich-presence = "0.2" discord-rich-presence = "0.2"
url = "2"
+229 -15
View File
@@ -1,10 +1,8 @@
/// Discord Rich Presence integration. /// Discord Rich Presence integration.
/// ///
/// To enable this feature: /// Album artwork is fetched from the iTunes Search API and passed directly to
/// 1. Go to https://discord.com/developers/applications and create an application. /// Discord via the large_image URL field. This avoids the need to pre-upload
/// 2. Copy the Application ID and replace DISCORD_APP_ID below. /// assets to the Discord Developer Portal.
/// 3. In the "Rich Presence → Art Assets" tab, upload a PNG named "psysonic"
/// (use the app icon from public/logo.png).
/// ///
/// The commands silently no-op when Discord is not running or the App ID is wrong, /// 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. /// so the app always starts cleanly regardless of Discord availability.
@@ -13,15 +11,201 @@ use discord_rich_presence::{
activity::{Activity, ActivityType, Assets, Timestamps}, activity::{Activity, ActivityType, Assets, Timestamps},
DiscordIpc, DiscordIpcClient, 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"; const DISCORD_APP_ID: &str = "1489544859718258779";
pub struct DiscordState(pub Mutex<Option<DiscordIpcClient>>); /// 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 { impl DiscordState {
pub fn new() -> Self { 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<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(),
},
);
} }
} }
@@ -36,15 +220,38 @@ fn try_connect() -> Option<DiscordIpcClient> {
/// ///
/// - `elapsed_secs`: seconds already played. `None` when paused — Discord shows /// - `elapsed_secs`: seconds already played. `None` when paused — Discord shows
/// the song/artist without a running timer. /// 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] #[tauri::command]
pub fn discord_update_presence( pub async fn discord_update_presence(
state: tauri::State<DiscordState>, state: tauri::State<'_, DiscordState>,
title: String, title: String,
artist: String, artist: String,
album: Option<String>, album: Option<String>,
elapsed_secs: Option<f64>, elapsed_secs: Option<f64>,
cover_art_url: Option<String>,
) -> Result<(), String> { ) -> 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<String> = 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. // (Re)connect lazily — handles the case where Discord starts after the app.
if guard.is_none() { if guard.is_none() {
@@ -62,9 +269,16 @@ pub fn discord_update_presence(
// the cover art icon. // the cover art icon.
let large_text = album.as_deref().unwrap_or("Psysonic"); let large_text = album.as_deref().unwrap_or("Psysonic");
let assets = Assets::new() let assets = if let Some(ref url) = artwork_url {
.large_image("psysonic") Assets::new()
.large_text(large_text); .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() let mut activity = Activity::new()
.activity_type(ActivityType::Listening) .activity_type(ActivityType::Listening)
@@ -95,7 +309,7 @@ pub fn discord_update_presence(
/// Clear the Discord Rich Presence activity (e.g. playback stopped). /// Clear the Discord Rich Presence activity (e.g. playback stopped).
#[tauri::command] #[tauri::command]
pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> { pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> {
let mut guard = state.0.lock().unwrap(); let mut guard = state.client.lock().unwrap();
if let Some(client) = guard.as_mut() { if let Some(client) = guard.as_mut() {
if client.clear_activity().is_err() { if client.clear_activity().is_err() {
*guard = None; *guard = None;
+3
View File
@@ -522,6 +522,9 @@ export function initAudioListeners(): () => void {
// Pass elapsed when playing so Discord shows a live running timer. // Pass elapsed when playing so Discord shows a live running timer.
// Pass null when paused — Discord shows the song/artist without a timer. // Pass null when paused — Discord shows the song/artist without a timer.
elapsedSecs: isPlaying ? currentTime : null, 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(() => {}); }).catch(() => {});
} }