mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
Generated
+3
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user