feat: replace ListenBrainz co-listening with MusicBrainz tag-based discovery

ListenBrainz similar-artists returned mainstream crossover artists (SOAD,
Led Zeppelin for Metallica) instead of genre-accurate results. Now uses
MusicBrainz tag search to find artists sharing the seed's primary genre tag
(e.g. "thrash metal" returns Megadeth, Slayer, Kreator, etc).

- Add search_artists_by_tag to MusicBrainz client
- Deterministic shuffle using seed MBID hash for variety across artists
- Secondary tag fallback when primary yields fewer than 5 results
- Rename label to "You May Also Like", limit results to 6
- Add Last.fm get_similar_artists method (unused, for future use)
- Add tags field to ArtistFullResponse model
- Tighten local history exclusion to play_count > 2
This commit is contained in:
vicrodh committed 2026-03-08 21:30:01 -06:00
1 parent a029f30fa5
commit 572d23b935
9 files changed
+501 -256

No files matched your search

+207 -225
View File
@@ -11519,29 +11519,35 @@ fn normalize_artist_name(name: &str) -> String {
.join(" ")
}
/// Discover new artists via ListenBrainz lb-radio API.
/// Discover new artists via MusicBrainz tag-based search.
///
/// Pipeline: "Listeners also enjoy"
///
/// Uses MusicBrainz tag search to find artists that share the seed artist's
/// primary genre tag. This gives genre-accurate results (e.g., searching
/// "thrash metal" for Metallica returns Megadeth, Slayer, Anthrax — not
/// mainstream crossover like Led Zeppelin).
///
/// Pipeline:
/// 1. Call ListenBrainz lb-radio for the seed artist MBID
/// 2. Aggregate listen counts per similar artist
/// 3. Filter: seed artist, already-known similar artists, local listening history
/// 4. Resolve surviving candidates on Qobuz (skip if not found)
/// 5. Filter blacklisted artists
/// 6. Return top 8 by affinity score
/// 1. Fetch seed artist's tags from MusicBrainz (sorted by vote count)
/// 2. Search MB for artists tagged with the primary genre tag
/// 3. Filter: seed artist, known similar artists, local listening history
/// 4. Resolve on Qobuz (verify exact name match to avoid homonyms)
/// 5. Return top 6, minimum 5
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_discovery_artists(
seedMbid: String,
seedArtistName: String,
similarArtistNames: Vec<String>,
musicbrainz: State<'_, MusicBrainzSharedState>,
reco_state: State<'_, RecoState>,
bridge: State<'_, CoreBridgeState>,
blacklist_state: State<'_, BlacklistState>,
) -> Result<Vec<crate::listenbrainz::DiscoveryArtist>, String> {
use std::collections::HashMap;
log::info!(
"[Discovery] Starting pipeline for seed MBID: {}",
"[Discovery] Starting pipeline for {} (MBID: {})",
seedArtistName,
seedMbid
);
@@ -11551,120 +11557,75 @@ pub async fn v2_get_discovery_artists(
return Ok(Vec::new());
}
// 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)",
version
);
let lb_client = reqwest::Client::builder()
.user_agent(&user_agent)
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("Failed to build ListenBrainz HTTP client: {}", e))?;
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 Labs: {}", lb_url);
let lb_response = lb_client
.post(lb_url)
.json(&lb_body)
.send()
// Step 2: Get seed artist's primary genre tag
let seed_tags = musicbrainz
.client
.get_artist_tags(&seedMbid)
.await
.map_err(|e| format!("ListenBrainz Labs request failed: {}", e))?;
.unwrap_or_default();
if !lb_response.status().is_success() {
let status = lb_response.status();
let text = lb_response.text().await.unwrap_or_default();
log::warn!(
"[Discovery] ListenBrainz Labs API error {}: {}",
status,
text
);
if seed_tags.is_empty() {
log::warn!("[Discovery] No tags found for seed artist, returning empty");
return Ok(Vec::new());
}
// 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 Labs response: {}", e);
format!("Failed to parse ListenBrainz Labs response: {}", e)
})?;
let primary_tag = &seed_tags[0];
log::info!(
"[Discovery] ListenBrainz Labs returned {} similar artists",
lb_data.len()
"[Discovery] Seed primary tag: '{}' (from {} tags total)",
primary_tag,
seed_tags.len()
);
// 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);
// Step 3: Search MB for artists with the same primary tag
// Request more than we need to account for filtering
let mb_results = musicbrainz
.client
.search_artists_by_tag(primary_tag, 50)
.await
.map_err(|e| format!("Tag search failed: {}", e))?;
if max_score <= 0.0 {
log::info!("[Discovery] No valid scores returned, returning empty");
log::info!(
"[Discovery] MB tag search returned {} artists for '{}'",
mb_results.artists.len(),
primary_tag
);
if mb_results.artists.is_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;
}
}
// Step 4: Build exclusion sets
let seed_name_normalized = normalize_artist_name(&seedArtistName);
log::info!(
"[Discovery] {} unique candidate artists (max score: {:.0})",
artist_scores.len(),
max_score
);
// Step 5: Build exclusion sets
let seed_mbid_lower = seedMbid.to_lowercase();
// 5a: Similar artist names (from frontend, normalized)
let similar_names_set: HashSet<String> = similarArtistNames
.iter()
.map(|name| normalize_artist_name(name))
.collect();
log::debug!(
"[Discovery] Exclusion: {} similar artist names from frontend",
similar_names_set.len()
);
// 5b: Local history from reco_store
let (local_qobuz_ids, local_names): (HashSet<u64>, HashSet<String>) = {
// Exclude any artist listened more than 2 times (user already knows them)
let listen_threshold: u32 = 2;
let (local_known_qobuz_ids, local_known_names): (HashSet<u64>, HashSet<String>) = {
let guard = reco_state.db.lock().await;
if let Some(db) = guard.as_ref() {
// Get top artist IDs (Qobuz IDs)
let top_artists = db.get_top_artist_ids(200).unwrap_or_default();
let qobuz_ids: HashSet<u64> = top_artists.iter().map(|a| a.artist_id).collect();
let top_artists = db.get_top_artist_ids(500).unwrap_or_default();
let qobuz_ids: HashSet<u64> = top_artists
.iter()
.filter(|a| a.play_count > listen_threshold)
.map(|a| a.artist_id)
.collect();
// Get cached artist names from reco_artist_meta
let known_artists = db.get_known_artist_names(500).unwrap_or_default();
let known_artists = db.get_known_artist_names(1000).unwrap_or_default();
let known_ids: HashSet<u64> = qobuz_ids.clone();
let names: HashSet<String> = known_artists
.iter()
.filter(|(id, _)| known_ids.contains(id))
.map(|(_, name)| normalize_artist_name(name))
.collect();
log::debug!(
"[Discovery] Exclusion: {} local Qobuz IDs, {} known artist names",
"[Discovery] Exclusion: {} known artists (>{} plays)",
qobuz_ids.len(),
names.len()
listen_threshold
);
(qobuz_ids, names)
@@ -11673,169 +11634,190 @@ pub async fn v2_get_discovery_artists(
}
};
// Step 6: Filter candidates - apply 85% similarity threshold
let similarity_threshold = 85.0;
let mut candidates: Vec<crate::listenbrainz::DiscoveryArtist> = Vec::new();
// Step 5: Filter MB results
let mut candidates: Vec<(String, String)> = Vec::new(); // (mbid, name)
for (mbid, (name, score)) in &artist_scores {
// Calculate similarity percentage (relative to max score)
let similarity_pct = (*score / max_score) * 100.0;
for artist in &mb_results.artists {
let normalized = normalize_artist_name(&artist.name);
// Filter below threshold
if similarity_pct < similarity_threshold {
log::debug!(
"[Discovery] Below threshold ({:.1}%): {}",
similarity_pct,
name
);
// Skip seed artist
if normalized == seed_name_normalized || artist.id.to_lowercase() == seedMbid.to_lowercase()
{
continue;
}
// Filter seed artist
if mbid.to_lowercase() == seed_mbid_lower {
log::debug!("[Discovery] Excluding seed artist: {}", name);
continue;
}
let normalized = normalize_artist_name(name);
// Filter already-known similar artists (by name)
// Skip artists already shown in the similar section
if similar_names_set.contains(&normalized) {
log::debug!("[Discovery] Excluding similar artist: {}", name);
continue;
}
// Filter local history (by normalized name)
if local_names.contains(&normalized) {
log::debug!("[Discovery] Excluding local history artist: {}", name);
// Skip locally known artists
if local_known_names.contains(&normalized) {
continue;
}
candidates.push((artist.id.clone(), artist.name.clone()));
}
candidates.push(crate::listenbrainz::DiscoveryArtist {
mbid: mbid.clone(),
name: name.clone(),
normalized_name: normalized,
affinity_score: *score,
similarity_percent: (similarity_pct * 10.0).round() / 10.0, // 1 decimal place
qobuz_id: None,
});
// Step 6: Shuffle deterministically using seed MBID
// This ensures: same artist page = same results, different artist = different results
{
use rand::seq::SliceRandom;
use rand::SeedableRng;
use std::hash::{Hash, Hasher};
use std::collections::hash_map::DefaultHasher;
let mut hasher = DefaultHasher::new();
seedMbid.hash(&mut hasher);
let hash = hasher.finish();
let mut rng = rand::rngs::StdRng::seed_from_u64(hash);
candidates.shuffle(&mut rng);
}
log::info!(
"[Discovery] {} candidates after exclusion + {:.0}% threshold filtering",
"[Discovery] {} candidates after filtering + shuffle (from {} MB results)",
candidates.len(),
similarity_threshold
mb_results.artists.len()
);
// Sort by affinity_score DESC before Qobuz resolution (so we try the best ones first)
candidates.sort_by(|a, b| {
b.affinity_score
.partial_cmp(&a.affinity_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
// Step 7: Resolve on Qobuz - try to find each candidate, skip if not found
// Step 7: Resolve on Qobuz
let bridge_guard = bridge.try_get().await;
let mut resolved: Vec<crate::listenbrainz::DiscoveryArtist> = Vec::new();
let mut results: Vec<crate::listenbrainz::DiscoveryArtist> = Vec::new();
let min_results = 5;
let max_results = 6;
if let Some(ref core_bridge) = bridge_guard {
for candidate in &candidates {
if resolved.len() >= 12 {
// Enough resolved candidates (will trim to 8 after blacklist)
for (mbid, name) in &candidates {
if results.len() >= max_results {
break;
}
match core_bridge
.search_artists(&candidate.name, 1, 0, None)
.await
{
Ok(results) => {
if let Some(artist) = results.items.first() {
// Verify the name roughly matches (avoid false positives)
let qobuz_normalized = normalize_artist_name(&artist.name);
if qobuz_normalized == candidate.normalized_name {
// Also check against local Qobuz IDs
if local_qobuz_ids.contains(&artist.id) {
log::debug!(
"[Discovery] Excluding {} - found in local history by Qobuz ID {}",
candidate.name,
artist.id
);
continue;
}
let mut resolved_candidate = candidate.clone();
resolved_candidate.qobuz_id = Some(artist.id);
resolved.push(resolved_candidate);
log::debug!(
"[Discovery] Resolved: {} -> Qobuz ID {}",
candidate.name,
artist.id
);
let qobuz_artist = match core_bridge.search_artists(name, 1, 0, None).await {
Ok(search_results) => {
if let Some(artist) = search_results.items.first() {
let qobuz_norm = normalize_artist_name(&artist.name);
let cand_norm = normalize_artist_name(name);
if qobuz_norm == cand_norm
&& !local_known_qobuz_ids.contains(&artist.id)
&& !blacklist_state.is_blacklisted(artist.id)
{
Some((artist.id, artist.name.clone()))
} else {
log::debug!(
"[Discovery] Name mismatch for {}: Qobuz returned '{}'",
candidate.name,
artist.name
);
None
}
} else {
log::debug!(
"[Discovery] Not found on Qobuz: {}",
candidate.name
);
None
}
}
Err(err) => {
log::warn!(
"[Discovery] Qobuz search failed for {}: {}",
candidate.name,
err
);
}
Err(_) => None,
};
if let Some((qobuz_id, qobuz_name)) = qobuz_artist {
results.push(crate::listenbrainz::DiscoveryArtist {
mbid: mbid.to_string(),
name: qobuz_name.clone(),
normalized_name: normalize_artist_name(&qobuz_name),
affinity_score: 0.0,
similarity_percent: 0.0,
qobuz_id: Some(qobuz_id),
});
}
}
} else {
log::warn!("[Discovery] CoreBridge not available, cannot resolve on Qobuz");
log::warn!("[Discovery] CoreBridge not available");
return Ok(Vec::new());
}
log::info!(
"[Discovery] {} candidates resolved on Qobuz",
resolved.len()
);
// Step 8: Filter blacklisted artists
let before_blacklist = resolved.len();
resolved.retain(|candidate| {
if let Some(qid) = candidate.qobuz_id {
!blacklist_state.is_blacklisted(qid)
} else {
true
}
});
if before_blacklist > resolved.len() {
log::debug!(
"[Discovery] Filtered {} blacklisted artists",
before_blacklist - resolved.len()
// Step 7: If not enough results with primary tag, try secondary tag
if results.len() < min_results && seed_tags.len() > 1 {
let secondary_tag = &seed_tags[1];
log::info!(
"[Discovery] Only {} results, trying secondary tag: '{}'",
results.len(),
secondary_tag
);
if let Ok(secondary_results) = musicbrainz
.client
.search_artists_by_tag(secondary_tag, 30)
.await
{
let existing_mbids: HashSet<String> =
results.iter().map(|r| r.mbid.clone()).collect();
// Filter and shuffle secondary candidates too
let mut secondary_candidates: Vec<(String, String)> = Vec::new();
for artist in &secondary_results.artists {
let normalized = normalize_artist_name(&artist.name);
if normalized == seed_name_normalized
|| artist.id.to_lowercase() == seedMbid.to_lowercase()
{
continue;
}
if similar_names_set.contains(&normalized)
|| local_known_names.contains(&normalized)
{
continue;
}
if existing_mbids.contains(&artist.id) {
continue;
}
secondary_candidates.push((artist.id.clone(), artist.name.clone()));
}
{
use rand::seq::SliceRandom;
use rand::SeedableRng;
use std::hash::{Hash, Hasher};
use std::collections::hash_map::DefaultHasher;
let mut hasher = DefaultHasher::new();
seedMbid.hash(&mut hasher);
secondary_tag.hash(&mut hasher);
let hash = hasher.finish();
let mut rng = rand::rngs::StdRng::seed_from_u64(hash);
secondary_candidates.shuffle(&mut rng);
}
if let Some(ref core_bridge) = bridge_guard {
for (mbid, name) in &secondary_candidates {
if results.len() >= max_results {
break;
}
let qobuz_artist =
match core_bridge.search_artists(name, 1, 0, None).await {
Ok(sr) => {
if let Some(qa) = sr.items.first() {
let qobuz_norm = normalize_artist_name(&qa.name);
let cand_norm = normalize_artist_name(name);
if qobuz_norm == cand_norm
&& !local_known_qobuz_ids.contains(&qa.id)
&& !blacklist_state.is_blacklisted(qa.id)
{
Some((qa.id, qa.name.clone()))
} else {
None
}
} else {
None
}
}
Err(_) => None,
};
if let Some((qobuz_id, qobuz_name)) = qobuz_artist {
results.push(crate::listenbrainz::DiscoveryArtist {
mbid: mbid.clone(),
name: qobuz_name.clone(),
normalized_name: normalize_artist_name(&qobuz_name),
affinity_score: 0.0,
similarity_percent: 0.0,
qobuz_id: Some(qobuz_id),
});
}
}
}
}
}
// Step 9: Sort by affinity_score DESC (already sorted but re-sort after filtering)
resolved.sort_by(|a, b| {
b.affinity_score
.partial_cmp(&a.affinity_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
// Step 10: Return top 8
resolved.truncate(8);
log::info!(
"[Discovery] Returning {} discovery artists",
resolved.len()
);
Ok(resolved)
log::info!("[Discovery] Returning {} discovery artists", results.len());
Ok(results)
}
+96
View File
@@ -254,4 +254,100 @@ impl LastFmClient {
Err(format!("Update now playing failed: {}", text))
}
}
/// Get similar artists for a given artist name
/// Returns a list of (artist_name, match_score) where match_score is 0.0 to 1.0
pub async fn get_similar_artists(
&self,
artist: &str,
limit: u32,
) -> Result<Vec<LastFmSimilarArtist>, String> {
// Gate on authentication - user must have Last.fm connected
if !self.is_authenticated() {
return Err("Not authenticated with Last.fm".to_string());
}
let url = format!("{}/artist.getSimilar", LASTFM_PROXY_URL);
let response = self
.client
.post(&url)
.json(&json!({
"artist": artist,
"limit": limit,
}))
.send()
.await
.map_err(|e| format!("Failed to get similar artists: {}", e))?;
if !response.status().is_success() {
let text = response.text().await.unwrap_or_default();
return Err(format!("Last.fm API error: {}", text));
}
let text = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
// Parse the Last.fm response format
let data: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| format!("Failed to parse response: {} - Raw: {}", e, &text[..text.len().min(200)]))?;
// Handle Last.fm error responses
if let Some(error) = data.get("error") {
let message = data
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error");
return Err(format!("Last.fm error {}: {}", error, message));
}
let artists = data
.get("similarartists")
.and_then(|sa| sa.get("artist"))
.and_then(|a| a.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| {
let name = item.get("name")?.as_str()?.to_string();
let match_score: f64 = item
.get("match")
.and_then(|m| m.as_str().or_else(|| m.as_f64().map(|_| "")).and_then(|s| {
if s.is_empty() {
item.get("match").and_then(|m| m.as_f64())
} else {
s.parse().ok()
}
}))
.unwrap_or(0.0);
let mbid = item
.get("mbid")
.and_then(|m| m.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
Some(LastFmSimilarArtist {
name,
match_score,
mbid,
})
})
.collect()
})
.unwrap_or_default();
Ok(artists)
}
}
/// A similar artist from Last.fm's artist.getSimilar
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LastFmSimilarArtist {
pub name: String,
/// Similarity score from 0.0 to 1.0
pub match_score: f64,
/// MusicBrainz ID if available
pub mbid: Option<String>,
}
+90 -1
View File
@@ -272,7 +272,7 @@ impl MusicBrainzClient {
self.rate_limiter.wait().await;
let base_url = self.base_url().await;
let url = format!("{}/artist/{}?inc=artist-rels&fmt=json", base_url, mbid);
let url = format!("{}/artist/{}?inc=artist-rels+tags&fmt=json", base_url, mbid);
log::debug!("MusicBrainz artist lookup with relations: {}", mbid);
@@ -295,6 +295,95 @@ impl MusicBrainzClient {
.map_err(|e| format!("Failed to parse MusicBrainz response: {}", e))
}
/// Fetch artist tags only (lightweight, no relations)
pub async fn get_artist_tags(
&self,
mbid: &str,
) -> Result<Vec<String>, String> {
if !self.is_enabled().await {
return Err("MusicBrainz integration is disabled".to_string());
}
self.rate_limiter.wait().await;
let base_url = self.base_url().await;
let url = format!("{}/artist/{}?inc=tags&fmt=json", base_url, mbid);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| format!("MusicBrainz request failed: {}", e))?;
if !response.status().is_success() {
return Ok(Vec::new());
}
let artist: ArtistFullResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse MusicBrainz response: {}", e))?;
let mut tags: Vec<_> = artist
.tags
.unwrap_or_default()
.into_iter()
.filter(|tag| tag.count.unwrap_or(0) > 0)
.collect();
// Sort by vote count descending — highest voted tag = primary genre
tags.sort_by(|a, b| b.count.unwrap_or(0).cmp(&a.count.unwrap_or(0)));
Ok(tags.into_iter().map(|tag| tag.name.to_lowercase()).collect())
}
/// Search artists by tag (genre)
///
/// Returns artists tagged with the given genre on MusicBrainz,
/// sorted by search relevance. Used for "Listeners also enjoy" discovery.
pub async fn search_artists_by_tag(
&self,
tag: &str,
limit: usize,
) -> Result<ArtistSearchResponse, String> {
if !self.is_enabled().await {
return Err("MusicBrainz integration is disabled".to_string());
}
self.rate_limiter.wait().await;
let base_url = self.base_url().await;
let limit = limit.min(100).max(1);
let query = format!("tag:\"{}\"", Self::escape_query(tag));
let url = format!(
"{}/artist?query={}&fmt=json&limit={}",
base_url,
urlencoding::encode(&query),
limit
);
log::debug!("MusicBrainz artist search by tag '{}' (limit {})", tag, limit);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| format!("MusicBrainz request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(format!("MusicBrainz API error {}: {}", status, text));
}
response
.json::<ArtistSearchResponse>()
.await
.map_err(|e| format!("Failed to parse MusicBrainz response: {}", e))
}
/// Search releases by barcode (UPC/EAN)
pub async fn search_release_by_barcode(
&self,
+3
View File
@@ -180,6 +180,9 @@ pub struct ArtistFullResponse {
#[serde(rename = "life-span")]
pub life_span: Option<LifeSpan>,
pub relations: Option<Vec<Relation>>,
/// Genre/style tags from MusicBrainz community
#[serde(default)]
pub tags: Option<Vec<Tag>>,
}
/// Relation between entities