refactor: switch discovery to ListenBrainz Labs similar-artists API
Replace lb-radio endpoint (popularity-based) with Labs similar-artists POST endpoint that returns actual similarity scores. Add 85% threshold filtering and expose similarityPercent field in DiscoveryArtist model.
This commit is contained in:
3 files changed
+74
-37
No files matched your search
@@ -11551,8 +11551,7 @@ pub async fn v2_get_discovery_artists(
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Step 2: Call ListenBrainz lb-radio API
|
||||
// Build a separate reqwest client for ListenBrainz (don't use MB rate limiter)
|
||||
// Step 2: Call ListenBrainz Labs similar-artists API
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let user_agent = format!(
|
||||
"QBZ/{} (https://github.com/vicrodh/qbz; qbz@vicrodh.dev)",
|
||||
@@ -11561,63 +11560,76 @@ pub async fn v2_get_discovery_artists(
|
||||
|
||||
let lb_client = reqwest::Client::builder()
|
||||
.user_agent(&user_agent)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build ListenBrainz HTTP client: {}", e))?;
|
||||
|
||||
let lb_url = format!(
|
||||
"https://api.listenbrainz.org/1/lb-radio/artist/{}?mode=easy&max_similar_artists=15&max_recordings_per_artist=2&pop_begin=0&pop_end=100",
|
||||
seedMbid
|
||||
);
|
||||
let lb_url = "https://labs.api.listenbrainz.org/similar-artists/json";
|
||||
let lb_body = serde_json::json!([{
|
||||
"artist_mbids": [seedMbid],
|
||||
"algorithm": "session_based_days_7500_session_300_contribution_5_threshold_10_limit_100_filter_True_skip_30"
|
||||
}]);
|
||||
|
||||
log::debug!("[Discovery] Calling ListenBrainz: {}", lb_url);
|
||||
log::debug!("[Discovery] Calling ListenBrainz Labs: {}", lb_url);
|
||||
|
||||
let lb_response = lb_client
|
||||
.get(&lb_url)
|
||||
.post(lb_url)
|
||||
.json(&lb_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("ListenBrainz request failed: {}", e))?;
|
||||
.map_err(|e| format!("ListenBrainz Labs request failed: {}", e))?;
|
||||
|
||||
if !lb_response.status().is_success() {
|
||||
let status = lb_response.status();
|
||||
let text = lb_response.text().await.unwrap_or_default();
|
||||
log::warn!(
|
||||
"[Discovery] ListenBrainz API error {}: {}",
|
||||
"[Discovery] ListenBrainz Labs API error {}: {}",
|
||||
status,
|
||||
text
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Step 3: Parse response as HashMap<String, Vec<LbRadioRecording>>
|
||||
let lb_data: HashMap<String, Vec<crate::listenbrainz::LbRadioRecording>> = lb_response
|
||||
// Step 3: Parse response - array of similar artist entries
|
||||
let lb_data: Vec<crate::listenbrainz::LbSimilarArtist> = lb_response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::warn!("[Discovery] Failed to parse ListenBrainz response: {}", e);
|
||||
format!("Failed to parse ListenBrainz response: {}", e)
|
||||
log::warn!("[Discovery] Failed to parse ListenBrainz Labs response: {}", e);
|
||||
format!("Failed to parse ListenBrainz Labs response: {}", e)
|
||||
})?;
|
||||
|
||||
log::info!(
|
||||
"[Discovery] ListenBrainz returned {} artist groups",
|
||||
"[Discovery] ListenBrainz Labs returned {} similar artists",
|
||||
lb_data.len()
|
||||
);
|
||||
|
||||
// Step 4: Extract unique artists, aggregate listen counts per artist MBID
|
||||
let mut artist_scores: HashMap<String, (String, f64)> = HashMap::new(); // mbid -> (name, total_score)
|
||||
// Step 4: Find max score for normalization, then build candidate map
|
||||
let max_score = lb_data
|
||||
.iter()
|
||||
.map(|a| a.score)
|
||||
.fold(0.0_f64, f64::max);
|
||||
|
||||
for recordings in lb_data.values() {
|
||||
for rec in recordings {
|
||||
let entry = artist_scores
|
||||
.entry(rec.similar_artist_mbid.clone())
|
||||
.or_insert_with(|| (rec.similar_artist_name.clone(), 0.0));
|
||||
entry.1 += rec.total_listen_count as f64;
|
||||
if max_score <= 0.0 {
|
||||
log::info!("[Discovery] No valid scores returned, returning empty");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Deduplicate by MBID (take highest score)
|
||||
let mut artist_scores: HashMap<String, (String, f64)> = HashMap::new();
|
||||
for artist in &lb_data {
|
||||
let entry = artist_scores
|
||||
.entry(artist.artist_mbid.clone())
|
||||
.or_insert_with(|| (artist.name.clone(), 0.0));
|
||||
if artist.score > entry.1 {
|
||||
entry.1 = artist.score;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[Discovery] Aggregated {} unique candidate artists",
|
||||
artist_scores.len()
|
||||
"[Discovery] {} unique candidate artists (max score: {:.0})",
|
||||
artist_scores.len(),
|
||||
max_score
|
||||
);
|
||||
|
||||
// Step 5: Build exclusion sets
|
||||
@@ -11661,10 +11673,24 @@ pub async fn v2_get_discovery_artists(
|
||||
}
|
||||
};
|
||||
|
||||
// Step 6: Filter candidates
|
||||
// Step 6: Filter candidates - apply 85% similarity threshold
|
||||
let similarity_threshold = 85.0;
|
||||
let mut candidates: Vec<crate::listenbrainz::DiscoveryArtist> = Vec::new();
|
||||
|
||||
for (mbid, (name, score)) in &artist_scores {
|
||||
// Calculate similarity percentage (relative to max score)
|
||||
let similarity_pct = (*score / max_score) * 100.0;
|
||||
|
||||
// Filter below threshold
|
||||
if similarity_pct < similarity_threshold {
|
||||
log::debug!(
|
||||
"[Discovery] Below threshold ({:.1}%): {}",
|
||||
similarity_pct,
|
||||
name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter seed artist
|
||||
if mbid.to_lowercase() == seed_mbid_lower {
|
||||
log::debug!("[Discovery] Excluding seed artist: {}", name);
|
||||
@@ -11690,13 +11716,15 @@ pub async fn v2_get_discovery_artists(
|
||||
name: name.clone(),
|
||||
normalized_name: normalized,
|
||||
affinity_score: *score,
|
||||
similarity_percent: (similarity_pct * 10.0).round() / 10.0, // 1 decimal place
|
||||
qobuz_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[Discovery] {} candidates after exclusion filtering",
|
||||
candidates.len()
|
||||
"[Discovery] {} candidates after exclusion + {:.0}% threshold filtering",
|
||||
candidates.len(),
|
||||
similarity_threshold
|
||||
);
|
||||
|
||||
// Sort by affinity_score DESC before Qobuz resolution (so we try the best ones first)
|
||||
|
||||
@@ -34,7 +34,7 @@ pub mod models;
|
||||
pub use cache::{ListenBrainzCache, ListenBrainzCacheState, QueueStats};
|
||||
pub use client::{ListenBrainzClient, ListenBrainzConfig};
|
||||
pub use models::{
|
||||
AdditionalInfo, DiscoveryArtist, LbRadioRecording, Listen, ListenBrainzStatus, ListenType,
|
||||
AdditionalInfo, DiscoveryArtist, LbSimilarArtist, Listen, ListenBrainzStatus, ListenType,
|
||||
QueuedListen, SubmitListensPayload, TrackMetadata, UserInfo,
|
||||
};
|
||||
|
||||
|
||||
@@ -139,13 +139,20 @@ pub struct ListenBrainzStatus {
|
||||
|
||||
// ==================== Discovery Models ====================
|
||||
|
||||
/// A recording from ListenBrainz lb-radio response
|
||||
/// A similar artist from ListenBrainz Labs similar-artists API
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LbRadioRecording {
|
||||
pub recording_mbid: String,
|
||||
pub similar_artist_mbid: String,
|
||||
pub similar_artist_name: String,
|
||||
pub total_listen_count: u64,
|
||||
pub struct LbSimilarArtist {
|
||||
pub artist_mbid: String,
|
||||
pub name: String,
|
||||
pub score: f64,
|
||||
#[serde(default)]
|
||||
pub reference_mbid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub comment: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
pub artist_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub gender: Option<String>,
|
||||
}
|
||||
|
||||
/// A discovery candidate extracted from ListenBrainz data
|
||||
@@ -158,8 +165,10 @@ pub struct DiscoveryArtist {
|
||||
pub name: String,
|
||||
/// Normalized name for dedup (lowercase, trimmed)
|
||||
pub normalized_name: String,
|
||||
/// Affinity score from ListenBrainz (sum of listen counts for this artist)
|
||||
/// Raw similarity score from ListenBrainz
|
||||
pub affinity_score: f64,
|
||||
/// Similarity percentage (0-100), normalized from raw score
|
||||
pub similarity_percent: f64,
|
||||
/// Optional Qobuz artist ID if resolved
|
||||
pub qobuz_id: Option<u64>,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user