perf(share): eliminate Odesli API call, construct song.link URLs directly

ISRC/UPC paths now use a single Deezer API request instead of two
sequential requests (Deezer + Odesli). The song.link/album.link URL
is constructed directly from the Deezer ID (e.g. song.link/d/12345).

This cuts latency from ~2-5s (often timing out) to ~200ms.
The Odesli fallback is kept only for get_by_url (no ISRC/UPC).
This commit is contained in:
vicrodh committed 2026-02-19 18:47:44 -06:00
1 parent 844bd60319
commit 867b1dfbe0
1 file changed
+70 -103
+70 -103
View File
@@ -1,4 +1,7 @@
//! Odesli/song.link API client
//! song.link URL generation via Deezer ISRC/UPC resolution
//!
//! Primary path: ISRC/UPC → Deezer API (single request) → construct song.link URL directly.
//! Fallback path: URL → Odesli API (for tracks without ISRC/UPC).
use std::collections::HashMap;
use std::sync::Mutex;
@@ -10,7 +13,7 @@ use super::errors::ShareError;
use super::models::{ContentType, OdesliResponse, SongLinkResponse};
const ODESLI_API_URL: &str = "https://api.song.link/v1-alpha.1/links";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const CACHE_TTL: Duration = Duration::from_secs(3600); // 1 hour
/// Cached entry with TTL
@@ -50,155 +53,119 @@ impl SongLinkClient {
}
/// Get song.link URL for a track by ISRC
/// Qobuz isn't supported by Odesli, so we resolve ISRC → Deezer track ID → Odesli.
/// Deezer's free API supports ISRC lookup without authentication.
/// Resolves ISRC → Deezer track ID (single HTTP request) → constructs song.link URL directly.
/// No Odesli API call needed — much faster than the two-request approach.
pub async fn get_by_isrc(&self, isrc: &str) -> Result<SongLinkResponse, ShareError> {
let cache_key = format!("isrc:{}", isrc);
// Check cache first
if let Some(cached) = self.get_from_cache(&cache_key) {
log::debug!("Cache hit for ISRC: {}", isrc);
return Ok(cached);
}
// Step 1: Resolve ISRC to a Deezer track ID via Deezer's free API
let deezer_api_url = format!("https://api.deezer.com/2.0/track/isrc:{}", isrc);
log::info!("Resolving ISRC {} via Deezer API", isrc);
let deezer_response = self
.client
.get(&deezer_api_url)
.send()
.await?;
let deezer_response = self.client.get(&deezer_api_url).send().await?;
let deezer_id: Option<u64> = if deezer_response.status().is_success() {
let body: serde_json::Value = deezer_response.json().await
.map_err(|e| ShareError::OdesliError(format!("Failed to parse Deezer response: {}", e)))?;
if !deezer_response.status().is_success() {
return Err(ShareError::OdesliError(format!(
"Deezer API returned {} for ISRC {}", deezer_response.status(), isrc
)));
}
if body.get("error").is_some() {
log::warn!("Deezer ISRC lookup returned error for {}", isrc);
None
} else {
body.get("id").and_then(|v| v.as_u64())
}
} else {
log::warn!("Deezer API returned {} for ISRC {}", deezer_response.status(), isrc);
None
};
let body: serde_json::Value = deezer_response.json().await
.map_err(|e| ShareError::OdesliError(format!("Failed to parse Deezer response: {}", e)))?;
let deezer_id = deezer_id.ok_or_else(|| {
ShareError::OdesliError(format!(
if body.get("error").is_some() {
return Err(ShareError::OdesliError(format!(
"Could not find track with ISRC {} on Deezer", isrc
))
)));
}
let deezer_id = body.get("id").and_then(|v| v.as_u64()).ok_or_else(|| {
ShareError::OdesliError(format!("Could not find track with ISRC {} on Deezer", isrc))
})?;
log::info!("Resolved ISRC {} to Deezer ID: {}", isrc, deezer_id);
// Step 2: Query Odesli with platform+type+id (faster than URL resolution)
let id_str = deezer_id.to_string();
let response = self
.client
.get(ODESLI_API_URL)
.query(&[
("platform", "deezer"),
("type", "song"),
("id", &id_str),
("userCountry", "US"),
])
.send()
.await?;
// Construct song.link URL directly — no Odesli API call needed
let page_url = format!("https://song.link/d/{}", deezer_id);
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(ShareError::OdesliError(format!(
"HTTP {}: {}",
status, text
)));
}
let result = SongLinkResponse {
page_url,
title: body.get("title").and_then(|v| v.as_str()).map(String::from),
artist: body.get("artist")
.and_then(|a| a.get("name"))
.and_then(|v| v.as_str())
.map(String::from),
thumbnail_url: body.get("album")
.and_then(|a| a.get("cover_xl"))
.and_then(|v| v.as_str())
.map(String::from),
platforms: HashMap::new(),
identifier: isrc.to_string(),
content_type: ContentType::Track.as_str().to_string(),
};
let odesli: OdesliResponse = response.json().await?;
let result = self.convert_response(odesli, isrc.to_string(), ContentType::Track)?;
// Cache the result
self.store_in_cache(cache_key, result.clone());
Ok(result)
}
/// Get song.link URL for an album by UPC
/// Resolves UPC → Deezer album ID → Odesli (Odesli no longer accepts ?upc= directly).
/// Resolves UPC → Deezer album ID (single HTTP request) → constructs album.link URL directly.
/// No Odesli API call needed.
pub async fn get_by_upc(&self, upc: &str) -> Result<SongLinkResponse, ShareError> {
let cache_key = format!("upc:{}", upc);
// Check cache first
if let Some(cached) = self.get_from_cache(&cache_key) {
log::debug!("Cache hit for UPC: {}", upc);
return Ok(cached);
}
// Step 1: Resolve UPC to a Deezer album ID
let deezer_api_url = format!("https://api.deezer.com/2.0/album/upc:{}", upc);
log::info!("Resolving UPC {} via Deezer API", upc);
let deezer_response = self
.client
.get(&deezer_api_url)
.send()
.await?;
let deezer_response = self.client.get(&deezer_api_url).send().await?;
let deezer_id: Option<u64> = if deezer_response.status().is_success() {
let body: serde_json::Value = deezer_response.json().await
.map_err(|e| ShareError::OdesliError(format!("Failed to parse Deezer response: {}", e)))?;
if !deezer_response.status().is_success() {
return Err(ShareError::OdesliError(format!(
"Deezer API returned {} for UPC {}", deezer_response.status(), upc
)));
}
if body.get("error").is_some() {
log::warn!("Deezer UPC lookup returned error for {}", upc);
None
} else {
body.get("id").and_then(|v| v.as_u64())
}
} else {
log::warn!("Deezer API returned {} for UPC {}", deezer_response.status(), upc);
None
};
let body: serde_json::Value = deezer_response.json().await
.map_err(|e| ShareError::OdesliError(format!("Failed to parse Deezer response: {}", e)))?;
let deezer_id = deezer_id.ok_or_else(|| {
ShareError::OdesliError(format!(
if body.get("error").is_some() {
return Err(ShareError::OdesliError(format!(
"Could not find album with UPC {} on Deezer", upc
))
)));
}
let deezer_id = body.get("id").and_then(|v| v.as_u64()).ok_or_else(|| {
ShareError::OdesliError(format!("Could not find album with UPC {} on Deezer", upc))
})?;
log::info!("Resolved UPC {} to Deezer album ID: {}", upc, deezer_id);
// Step 2: Query Odesli with platform+type+id
let id_str = deezer_id.to_string();
let response = self
.client
.get(ODESLI_API_URL)
.query(&[
("platform", "deezer"),
("type", "album"),
("id", &id_str),
("userCountry", "US"),
])
.send()
.await?;
// Construct album.link URL directly — no Odesli API call needed
let page_url = format!("https://album.link/d/{}", deezer_id);
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(ShareError::OdesliError(format!(
"HTTP {}: {}",
status, text
)));
}
let result = SongLinkResponse {
page_url,
title: body.get("title").and_then(|v| v.as_str()).map(String::from),
artist: body.get("artist")
.and_then(|a| a.get("name"))
.and_then(|v| v.as_str())
.map(String::from),
thumbnail_url: body.get("cover_xl").and_then(|v| v.as_str()).map(String::from),
platforms: HashMap::new(),
identifier: upc.to_string(),
content_type: ContentType::Album.as_str().to_string(),
};
let odesli: OdesliResponse = response.json().await?;
let result = self.convert_response(odesli, upc.to_string(), ContentType::Album)?;
// Cache the result
self.store_in_cache(cache_key, result.clone());
Ok(result)
}