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
+498 -253

No files matched your search

+84 -1
View File
@@ -4,7 +4,7 @@ use reqwest::Client;
use serde_json::json;
use crate::error::{IntegrationError, IntegrationResult};
use super::models::{AuthGetSessionResponse, AuthGetTokenResponse, LastFmResponse, LastFmSession};
use super::models::{AuthGetSessionResponse, AuthGetTokenResponse, LastFmResponse, LastFmSession, LastFmSimilarArtist};
/// Cloudflare Workers proxy URL - handles API credentials and signature generation
const LASTFM_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/lastfm";
@@ -214,4 +214,87 @@ impl LastFmClient {
)))
}
}
/// Get similar artists for a given artist name
///
/// Uses Last.fm's artist.getSimilar which returns genre-accurate similarity.
/// Requires authentication (user must have Last.fm connected).
pub async fn get_similar_artists(
&self,
artist: &str,
limit: u32,
) -> IntegrationResult<Vec<LastFmSimilarArtist>> {
// artist.getSimilar is a public read endpoint - no session key needed.
// The proxy handles the API key.
let url = format!("{}/artist.getSimilar", LASTFM_PROXY_URL);
let response = self
.client
.post(&url)
.json(&json!({
"artist": artist,
"limit": limit,
}))
.send()
.await?;
if !response.status().is_success() {
let text = response.text().await.unwrap_or_default();
return Err(IntegrationError::internal(format!(
"Last.fm artist.getSimilar failed: {}",
text
)));
}
let text = response.text().await?;
let data: serde_json::Value = serde_json::from_str(&text)?;
// 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(IntegrationError::api(
error.as_u64().unwrap_or(0) as u32,
message.to_string(),
));
}
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();
// Last.fm returns match as string "0" to "1"
let match_score: f64 = item
.get("match")
.and_then(|m| {
m.as_str()
.and_then(|s| s.parse().ok())
.or_else(|| m.as_f64())
})
.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)
}
}
+1 -1
View File
@@ -31,4 +31,4 @@ mod client;
mod models;
pub use client::LastFmClient;
pub use models::LastFmSession;
pub use models::{LastFmSession, LastFmSimilarArtist};
@@ -48,3 +48,15 @@ pub(crate) enum LastFmResponse<T> {
Success(T),
Error { error: u32, message: String },
}
/// A similar artist from Last.fm's artist.getSimilar
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LastFmSimilarArtist {
/// Artist name
pub name: String,
/// Similarity score from 0.0 to 1.0
pub match_score: f64,
/// MusicBrainz ID if available
pub mbid: Option<String>,
}
+204 -222
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
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)
{
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;
Some((artist.id, artist.name.clone()))
} else {
None
}
} else {
None
}
}
Err(_) => None,
};
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
);
} else {
log::debug!(
"[Discovery] Name mismatch for {}: Qobuz returned '{}'",
candidate.name,
artist.name
);
}
} else {
log::debug!(
"[Discovery] Not found on Qobuz: {}",
candidate.name
);
}
}
Err(err) => {
log::warn!(
"[Discovery] Qobuz search failed for {}: {}",
candidate.name,
err
);
}
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());
}
// 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] {} candidates resolved on Qobuz",
resolved.len()
"[Discovery] Only {} results, trying secondary tag: '{}'",
results.len(),
secondary_tag
);
// 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)
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 {
true
None
}
});
} else {
None
}
}
Err(_) => None,
};
if before_blacklist > resolved.len() {
log::debug!(
"[Discovery] Filtered {} blacklisted artists",
before_blacklist - resolved.len()
);
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
@@ -888,9 +888,9 @@
}
}
// Load "You may also like" discovery artists from ListenBrainz
// Load "Listeners also enjoy" discovery artists from ListenBrainz + MB genre filter
async function loadDiscoveryArtists() {
if (!mbArtistMbid || !mbAvailable) return;
if (!mbArtistMbid) return;
discoveryLoading = true;
discoveryArtists = [];
@@ -899,6 +899,7 @@
const similarNames = similarArtists.map(sa => sa.name);
const results = await invoke<DiscoveryArtist[]>('v2_get_discovery_artists', {
seedMbid: mbArtistMbid,
seedArtistName: artist.name,
similarArtistNames: similarNames
});
discoveryArtists = results || [];
@@ -2670,7 +2671,7 @@
</section>
{/if}
<!-- You may also like (ListenBrainz discovery) -->
<!-- Listeners also enjoy (ListenBrainz discovery) -->
{#if mbAvailable && (discoveryLoading || discoveryArtists.length > 0)}
<section class="sidebar-section">
<h4 class="section-label">{$t('artist.youMayAlsoLike')}</h4>
@@ -2680,13 +2681,12 @@
{:else}
{#each discoveryArtists as disc}
<button
class="sidebar-artist-link discovery-link"
class="sidebar-artist-link"
onclick={() => disc.qobuzId && onTrackGoToArtist?.(disc.qobuzId)}
title="{disc.name} — {disc.similarityPercent}%"
title={disc.name}
>
<User size={12} />
<span class="discovery-name">{disc.name}</span>
<span class="discovery-score">{disc.similarityPercent}%</span>
{disc.name}
</button>
{/each}
{/if}
@@ -2923,26 +2923,6 @@
color: var(--text-primary);
}
.sidebar-artist-link.discovery-link {
justify-content: flex-start;
}
.discovery-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.discovery-score {
font-size: 10px;
color: var(--accent-primary);
font-weight: 600;
flex-shrink: 0;
opacity: 0.8;
}
.relationship-group {
display: flex;
flex-direction: column;
+1 -1
View File
@@ -295,7 +295,7 @@
"customImageRemoved": "Imagen personalizada eliminada",
"customImageError": "Error al actualizar imagen del artista",
"imageSaved": "Imagen del artista guardada",
"youMayAlsoLike": "TAMBIEN TE PUEDE GUSTAR"
"youMayAlsoLike": "TAMBIÉN TE PUEDE GUSTAR"
},
"sort": {
"nameAZ": "Nombre (A-Z)",