feat: add ListenBrainz discovery pipeline for Artist Network sidebar

Add v2_get_discovery_artists command that uses ListenBrainz lb-radio API
to find novel artist recommendations. Pipeline: fetch candidates from
ListenBrainz, filter out seed/similar/local-history artists, resolve on
Qobuz, filter blacklisted, return top 8 by affinity score.
This commit is contained in:
vicrodh committed 2026-03-08 14:51:30 -06:00
1 parent a2eabf08f1
commit 501546fb55
5 files changed
+363 -2

No files matched your search

+304
View File
@@ -11507,3 +11507,307 @@ pub async fn v2_clear_image_cache(
Ok(freed)
}
// ==================== ListenBrainz Discovery ====================
/// Normalize an artist name for dedup: trim, lowercase, collapse whitespace
fn normalize_artist_name(name: &str) -> String {
name.trim()
.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
/// Discover new artists via ListenBrainz lb-radio API.
///
/// 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
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_discovery_artists(
seedMbid: 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: {}",
seedMbid
);
// Step 1: Check MB is enabled
if !musicbrainz.client.is_enabled().await {
log::warn!("[Discovery] MusicBrainz is disabled, returning empty");
return Ok(Vec::new());
}
// Step 2: Call ListenBrainz lb-radio API
// Build a separate reqwest client for ListenBrainz (don't use MB rate limiter)
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(10))
.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
);
log::debug!("[Discovery] Calling ListenBrainz: {}", lb_url);
let lb_response = lb_client
.get(&lb_url)
.send()
.await
.map_err(|e| format!("ListenBrainz 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 {}: {}",
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
.json()
.await
.map_err(|e| {
log::warn!("[Discovery] Failed to parse ListenBrainz response: {}", e);
format!("Failed to parse ListenBrainz response: {}", e)
})?;
log::info!(
"[Discovery] ListenBrainz returned {} artist groups",
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)
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;
}
}
log::info!(
"[Discovery] Aggregated {} unique candidate artists",
artist_scores.len()
);
// 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>) = {
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();
// Get cached artist names from reco_artist_meta
let known_artists = db.get_known_artist_names(500).unwrap_or_default();
let names: HashSet<String> = known_artists
.iter()
.map(|(_, name)| normalize_artist_name(name))
.collect();
log::debug!(
"[Discovery] Exclusion: {} local Qobuz IDs, {} known artist names",
qobuz_ids.len(),
names.len()
);
(qobuz_ids, names)
} else {
(HashSet::new(), HashSet::new())
}
};
// Step 6: Filter candidates
let mut candidates: Vec<crate::listenbrainz::DiscoveryArtist> = Vec::new();
for (mbid, (name, score)) in &artist_scores {
// 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)
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);
continue;
}
candidates.push(crate::listenbrainz::DiscoveryArtist {
mbid: mbid.clone(),
name: name.clone(),
normalized_name: normalized,
affinity_score: *score,
qobuz_id: None,
});
}
log::info!(
"[Discovery] {} candidates after exclusion filtering",
candidates.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
let bridge_guard = bridge.try_get().await;
let mut resolved: Vec<crate::listenbrainz::DiscoveryArtist> = Vec::new();
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)
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
);
} 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
);
}
}
}
} else {
log::warn!("[Discovery] CoreBridge not available, cannot resolve on Qobuz");
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 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)
}
+1
View File
@@ -1540,6 +1540,7 @@ pub fn run() {
commands_v2::v2_remote_metadata_search,
commands_v2::v2_remote_metadata_get_album,
commands_v2::v2_musicbrainz_get_artist_relationships,
commands_v2::v2_get_discovery_artists,
commands_v2::v2_lastfm_get_auth_url,
commands_v2::v2_lastfm_complete_auth,
commands_v2::v2_lastfm_is_authenticated,
+2 -2
View File
@@ -34,8 +34,8 @@ pub mod models;
pub use cache::{ListenBrainzCache, ListenBrainzCacheState, QueueStats};
pub use client::{ListenBrainzClient, ListenBrainzConfig};
pub use models::{
AdditionalInfo, Listen, ListenBrainzStatus, ListenType, QueuedListen, SubmitListensPayload,
TrackMetadata, UserInfo,
AdditionalInfo, DiscoveryArtist, LbRadioRecording, Listen, ListenBrainzStatus, ListenType,
QueuedListen, SubmitListensPayload, TrackMetadata, UserInfo,
};
use std::path::Path;
+27
View File
@@ -137,6 +137,33 @@ pub struct ListenBrainzStatus {
pub enabled: bool,
}
// ==================== Discovery Models ====================
/// A recording from ListenBrainz lb-radio response
#[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,
}
/// A discovery candidate extracted from ListenBrainz data
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DiscoveryArtist {
/// MusicBrainz artist ID
pub mbid: String,
/// Display name
pub name: String,
/// Normalized name for dedup (lowercase, trimmed)
pub normalized_name: String,
/// Affinity score from ListenBrainz (sum of listen counts for this artist)
pub affinity_score: f64,
/// Optional Qobuz artist ID if resolved
pub qobuz_id: Option<u64>,
}
/// Queued listen for offline submission
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
+29
View File
@@ -854,6 +854,35 @@ impl RecoStoreDb {
Ok(())
}
/// Get known artist names from the metadata cache.
/// Returns (qobuz_artist_id, name) pairs.
pub fn get_known_artist_names(&self, limit: u32) -> Result<Vec<(u64, String)>, String> {
let mut stmt = self
.conn
.prepare(
r#"
SELECT artist_id, name
FROM reco_artist_meta
ORDER BY updated_at DESC
LIMIT ?
"#,
)
.map_err(|e| format!("Failed to prepare known artist names query: {}", e))?;
let rows = stmt
.query_map(params![limit], |row| {
Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?))
})
.map_err(|e| format!("Failed to query known artist names: {}", e))?;
let mut artists = Vec::new();
for row in rows {
artists
.push(row.map_err(|e| format!("Failed to read known artist name row: {}", e))?);
}
Ok(artists)
}
/// Clear all meta caches so entries re-resolve with fresh image URLs.
pub fn clear_meta_caches(&self) -> Result<(), String> {
self.conn