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()); return Ok(Vec::new());
} }
// Step 2: Call ListenBrainz lb-radio API // Step 2: Call ListenBrainz Labs similar-artists API
// Build a separate reqwest client for ListenBrainz (don't use MB rate limiter)
let version = env!("CARGO_PKG_VERSION"); let version = env!("CARGO_PKG_VERSION");
let user_agent = format!( let user_agent = format!(
"QBZ/{} (https://github.com/vicrodh/qbz; qbz@vicrodh.dev)", "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() let lb_client = reqwest::Client::builder()
.user_agent(&user_agent) .user_agent(&user_agent)
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(15))
.build() .build()
.map_err(|e| format!("Failed to build ListenBrainz HTTP client: {}", e))?; .map_err(|e| format!("Failed to build ListenBrainz HTTP client: {}", e))?;
let lb_url = format!( let lb_url = "https://labs.api.listenbrainz.org/similar-artists/json";
"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", let lb_body = serde_json::json!([{
seedMbid "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 let lb_response = lb_client
.get(&lb_url) .post(lb_url)
.json(&lb_body)
.send() .send()
.await .await
.map_err(|e| format!("ListenBrainz request failed: {}", e))?; .map_err(|e| format!("ListenBrainz Labs request failed: {}", e))?;
if !lb_response.status().is_success() { if !lb_response.status().is_success() {
let status = lb_response.status(); let status = lb_response.status();
let text = lb_response.text().await.unwrap_or_default(); let text = lb_response.text().await.unwrap_or_default();
log::warn!( log::warn!(
"[Discovery] ListenBrainz API error {}: {}", "[Discovery] ListenBrainz Labs API error {}: {}",
status, status,
text text
); );
return Ok(Vec::new()); return Ok(Vec::new());
} }
// Step 3: Parse response as HashMap<String, Vec<LbRadioRecording>> // Step 3: Parse response - array of similar artist entries
let lb_data: HashMap<String, Vec<crate::listenbrainz::LbRadioRecording>> = lb_response let lb_data: Vec<crate::listenbrainz::LbSimilarArtist> = lb_response
.json() .json()
.await .await
.map_err(|e| { .map_err(|e| {
log::warn!("[Discovery] Failed to parse ListenBrainz response: {}", e); log::warn!("[Discovery] Failed to parse ListenBrainz Labs response: {}", e);
format!("Failed to parse ListenBrainz response: {}", e) format!("Failed to parse ListenBrainz Labs response: {}", e)
})?; })?;
log::info!( log::info!(
"[Discovery] ListenBrainz returned {} artist groups", "[Discovery] ListenBrainz Labs returned {} similar artists",
lb_data.len() lb_data.len()
); );
// Step 4: Extract unique artists, aggregate listen counts per artist MBID // Step 4: Find max score for normalization, then build candidate map
let mut artist_scores: HashMap<String, (String, f64)> = HashMap::new(); // mbid -> (name, total_score) let max_score = lb_data
.iter()
.map(|a| a.score)
.fold(0.0_f64, f64::max);
for recordings in lb_data.values() { if max_score <= 0.0 {
for rec in recordings { log::info!("[Discovery] No valid scores returned, returning empty");
let entry = artist_scores return Ok(Vec::new());
.entry(rec.similar_artist_mbid.clone()) }
.or_insert_with(|| (rec.similar_artist_name.clone(), 0.0));
entry.1 += rec.total_listen_count as f64; // 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!( log::info!(
"[Discovery] Aggregated {} unique candidate artists", "[Discovery] {} unique candidate artists (max score: {:.0})",
artist_scores.len() artist_scores.len(),
max_score
); );
// Step 5: Build exclusion sets // 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(); let mut candidates: Vec<crate::listenbrainz::DiscoveryArtist> = Vec::new();
for (mbid, (name, score)) in &artist_scores { 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 // Filter seed artist
if mbid.to_lowercase() == seed_mbid_lower { if mbid.to_lowercase() == seed_mbid_lower {
log::debug!("[Discovery] Excluding seed artist: {}", name); log::debug!("[Discovery] Excluding seed artist: {}", name);
@@ -11690,13 +11716,15 @@ pub async fn v2_get_discovery_artists(
name: name.clone(), name: name.clone(),
normalized_name: normalized, normalized_name: normalized,
affinity_score: *score, affinity_score: *score,
similarity_percent: (similarity_pct * 10.0).round() / 10.0, // 1 decimal place
qobuz_id: None, qobuz_id: None,
}); });
} }
log::info!( log::info!(
"[Discovery] {} candidates after exclusion filtering", "[Discovery] {} candidates after exclusion + {:.0}% threshold filtering",
candidates.len() candidates.len(),
similarity_threshold
); );
// Sort by affinity_score DESC before Qobuz resolution (so we try the best ones first) // 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 cache::{ListenBrainzCache, ListenBrainzCacheState, QueueStats};
pub use client::{ListenBrainzClient, ListenBrainzConfig}; pub use client::{ListenBrainzClient, ListenBrainzConfig};
pub use models::{ pub use models::{
AdditionalInfo, DiscoveryArtist, LbRadioRecording, Listen, ListenBrainzStatus, ListenType, AdditionalInfo, DiscoveryArtist, LbSimilarArtist, Listen, ListenBrainzStatus, ListenType,
QueuedListen, SubmitListensPayload, TrackMetadata, UserInfo, QueuedListen, SubmitListensPayload, TrackMetadata, UserInfo,
}; };
+16 -7
View File
@@ -139,13 +139,20 @@ pub struct ListenBrainzStatus {
// ==================== Discovery Models ==================== // ==================== Discovery Models ====================
/// A recording from ListenBrainz lb-radio response /// A similar artist from ListenBrainz Labs similar-artists API
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct LbRadioRecording { pub struct LbSimilarArtist {
pub recording_mbid: String, pub artist_mbid: String,
pub similar_artist_mbid: String, pub name: String,
pub similar_artist_name: String, pub score: f64,
pub total_listen_count: u64, #[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 /// A discovery candidate extracted from ListenBrainz data
@@ -158,8 +165,10 @@ pub struct DiscoveryArtist {
pub name: String, pub name: String,
/// Normalized name for dedup (lowercase, trimmed) /// Normalized name for dedup (lowercase, trimmed)
pub normalized_name: String, pub normalized_name: String,
/// Affinity score from ListenBrainz (sum of listen counts for this artist) /// Raw similarity score from ListenBrainz
pub affinity_score: f64, pub affinity_score: f64,
/// Similarity percentage (0-100), normalized from raw score
pub similarity_percent: f64,
/// Optional Qobuz artist ID if resolved /// Optional Qobuz artist ID if resolved
pub qobuz_id: Option<u64>, pub qobuz_id: Option<u64>,
} }