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:
vicrodh committed 2026-03-08 15:47:17 -06:00
1 parent 501546fb55
commit cd0615350f
3 files changed
+74 -37

No files matched your search

+57 -29
View File
@@ -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)
+1 -1
View File
@@ -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,
};
+16 -7
View File
@@ -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>,
}