mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
455aec4def
* feat(discord): override activity name in member list with track title (configurable template)
The Discord member list and the collapsed Rich Presence card display the
activity's `name` field next to the music icon. Without an override
Discord falls back to the registered application name ("Psysonic"), so
the line reads "Psysonic" while every track plays instead of the actual
track title that comparable players show.
Add a fourth user-configurable template `discordTemplateName` (Settings ->
Integrations -> Discord Rich Presence). The Rust side passes it through
`Activity::new().name(...)` when the rendered template is non-empty; an
empty template falls back to Discord's default application name. Default
template is "{title}".
Tauri boundary: additive optional `nameTemplate` field on the existing
`discord_update_presence` command. Existing call sites that omit it keep
working -- the Rust handler applies the same "{title}" fallback.
* i18n(settings): discord name template label in all locales
Adds the discordTemplateName label across en, de, fr, es, nl, nb, ro,
zh, ru -- shown next to the new "User list line (name)" template input
under Discord Rich Presence.
* docs(release): CHANGELOG and credits for discord name template (PR #885)
920 lines
32 KiB
Rust
920 lines
32 KiB
Rust
//! 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()),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for DiscordState {
|
||
fn default() -> Self {
|
||
Self::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`.
|
||
/// 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<HashMap<String, ArtworkCacheEntry>>,
|
||
artist: &str,
|
||
album: &str,
|
||
title: &str,
|
||
) -> Option<String> {
|
||
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<HashMap<String, ArtworkCacheEntry>>,
|
||
artist: &str,
|
||
album: &str,
|
||
title: &str,
|
||
base_url: &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(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<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)
|
||
}
|
||
|
||
/// 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<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>,
|
||
name_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();
|
||
|
||
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<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(())
|
||
}
|
||
|
||
#[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<HashMap<String, ArtworkCacheEntry>> = 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<HashMap<String, ArtworkCacheEntry>> = 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<HashMap<String, ArtworkCacheEntry>> = 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<HashMap<String, ArtworkCacheEntry>> = 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<Mutex<HashMap<String, ArtworkCacheEntry>>> =
|
||
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()));
|
||
}
|
||
}
|