feat(vectors): add vector builder with MB and Qobuz sources

- Add weights.rs with configurable RelationshipWeights
- Add builder.rs with ArtistVectorBuilder for constructing vectors
- Integrate MusicBrainz relationships (members, groups, collaborators)
- Integrate Qobuz similar artists API
- Add ensure_vector() with TTL for lazy building
- Add resolve_qobuz_to_mbid() for ID resolution via name search
- 21 tests passing (sparse_vector, store, weights, builder)
This commit is contained in:
vicrodh committed 2026-01-27 11:30:06 -06:00
1 parent 1d2e28c1a8
commit f3dcb6ebf2
4 files changed
+582 -3

No files matched your search

+374
View File
@@ -0,0 +1,374 @@
//! Vector builder - constructs artist vectors from MusicBrainz and Qobuz data
//!
//! This module integrates with MusicBrainz for relationship data and Qobuz
//! for similarity data to build sparse vectors for each artist.
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::api::QobuzClient;
use crate::musicbrainz::models::{
ArtistFullResponse, ArtistRelationships, Period, RelatedArtist,
};
use crate::musicbrainz::{MusicBrainzCache, MusicBrainzClient};
use super::sparse_vector::SparseVector;
use super::store::ArtistVectorStore;
use super::weights::RelationshipWeights;
/// Builder for constructing artist vectors from multiple data sources
pub struct ArtistVectorBuilder {
/// Vector store for persistence
store: Arc<Mutex<ArtistVectorStore>>,
/// MusicBrainz client for relationship data
mb_client: Arc<MusicBrainzClient>,
/// MusicBrainz cache
mb_cache: Arc<Mutex<MusicBrainzCache>>,
/// Qobuz client for similar artists
qobuz_client: Arc<Mutex<QobuzClient>>,
/// Configurable weights
weights: RelationshipWeights,
}
/// Result of building a vector
#[derive(Debug, Clone)]
pub struct BuildResult {
/// The built vector
pub vector: SparseVector,
/// Number of MusicBrainz relationships found
pub mb_relations_count: usize,
/// Number of Qobuz similar artists found
pub qobuz_similar_count: usize,
/// Sources that contributed to the vector
pub sources: Vec<String>,
}
impl ArtistVectorBuilder {
/// Create a new builder with the given dependencies
pub fn new(
store: Arc<Mutex<ArtistVectorStore>>,
mb_client: Arc<MusicBrainzClient>,
mb_cache: Arc<Mutex<MusicBrainzCache>>,
qobuz_client: Arc<Mutex<QobuzClient>>,
weights: RelationshipWeights,
) -> Self {
Self {
store,
mb_client,
mb_cache,
qobuz_client,
weights,
}
}
/// Build a vector for an artist, fetching data from all sources
///
/// This method:
/// 1. Fetches MusicBrainz relationships (members, groups, collaborators)
/// 2. Fetches Qobuz similar artists (if Qobuz ID available)
/// 3. Combines all data into a weighted sparse vector
/// 4. Persists the vector to the store
pub async fn build_vector(
&self,
artist_mbid: &str,
artist_name: Option<&str>,
qobuz_artist_id: Option<u64>,
) -> Result<BuildResult, String> {
let mut vector = SparseVector::new();
let mut sources = Vec::new();
let mut mb_relations_count = 0;
let mut qobuz_similar_count = 0;
// 1. Get or create index for this artist
{
let mut store = self.store.lock().await;
store.get_or_create_idx(artist_mbid, artist_name)?;
}
// 2. Fetch MusicBrainz relationships
match self.build_from_musicbrainz(artist_mbid).await {
Ok((mb_vec, count)) => {
vector = vector.add(&mb_vec);
mb_relations_count = count;
if count > 0 {
sources.push("musicbrainz".to_string());
}
}
Err(e) => {
log::warn!("Failed to fetch MusicBrainz relations for {}: {}", artist_mbid, e);
}
}
// 3. Fetch Qobuz similar artists (if we have the ID)
if let Some(qobuz_id) = qobuz_artist_id {
match self.build_from_qobuz(qobuz_id).await {
Ok((qobuz_vec, count)) => {
vector = vector.add(&qobuz_vec);
qobuz_similar_count = count;
if count > 0 {
sources.push("qobuz".to_string());
}
}
Err(e) => {
log::warn!("Failed to fetch Qobuz similar for {}: {}", qobuz_id, e);
}
}
}
// 4. Persist the vector
{
let mut store = self.store.lock().await;
// Store MB relationships
if mb_relations_count > 0 {
// We need to rebuild just the MB part for proper source tagging
if let Ok((mb_vec, _)) = self.build_from_musicbrainz(artist_mbid).await {
store.set_vector(artist_mbid, &mb_vec, "musicbrainz")?;
}
}
// Store Qobuz similarities
if qobuz_similar_count > 0 {
if let Some(qobuz_id) = qobuz_artist_id {
if let Ok((qobuz_vec, _)) = self.build_from_qobuz(qobuz_id).await {
store.set_vector(artist_mbid, &qobuz_vec, "qobuz")?;
}
}
}
}
Ok(BuildResult {
vector,
mb_relations_count,
qobuz_similar_count,
sources,
})
}
/// Build vector component from MusicBrainz relationships
async fn build_from_musicbrainz(&self, artist_mbid: &str) -> Result<(SparseVector, usize), String> {
// Try cache first
let cached = {
let cache = self.mb_cache.lock().await;
cache.get_artist_relations(artist_mbid)?
};
let relations = if let Some(rel) = cached {
rel
} else {
// Fetch from API
let response = self.mb_client.get_artist_with_relations(artist_mbid).await?;
// Extract relationships from raw response
let extracted = extract_relationships(&response);
// Cache it
{
let cache = self.mb_cache.lock().await;
cache.set_artist_relations(artist_mbid, &extracted)?;
}
extracted
};
let mut vector = SparseVector::new();
let mut count = 0;
// Get store for index lookups
let mut store = self.store.lock().await;
// Process members (band → person)
for member in &relations.members {
let idx = store.get_or_create_idx(&member.mbid, Some(&member.name))?;
let weight = self.weights.member_of_band;
vector.set(idx, weight);
count += 1;
}
// Process past members (slightly lower weight)
for member in &relations.past_members {
let idx = store.get_or_create_idx(&member.mbid, Some(&member.name))?;
let weight = self.weights.member_of_band * 0.8; // Past members slightly less relevant
vector.set(idx, weight);
count += 1;
}
// Process groups (person → band)
for group in &relations.groups {
let idx = store.get_or_create_idx(&group.mbid, Some(&group.name))?;
let weight = self.weights.has_member;
vector.set(idx, weight);
count += 1;
}
// Process collaborators
for collab in &relations.collaborators {
let idx = store.get_or_create_idx(&collab.mbid, Some(&collab.name))?;
let weight = self.weights.collaboration;
vector.set(idx, weight);
count += 1;
}
Ok((vector, count))
}
/// Build vector component from Qobuz similar artists
async fn build_from_qobuz(&self, qobuz_artist_id: u64) -> Result<(SparseVector, usize), String> {
let similar = {
let client = self.qobuz_client.lock().await;
client
.get_similar_artists(qobuz_artist_id, 20, 0)
.await
.map_err(|e| format!("Qobuz API error: {}", e))?
};
let mut vector = SparseVector::new();
let mut count = 0;
let mut store = self.store.lock().await;
for artist in similar.items {
// We need to resolve Qobuz artist to MBID
// For now, use a synthetic MBID based on Qobuz ID (we'll improve this later)
let synthetic_mbid = format!("qobuz:{}", artist.id);
let idx = store.get_or_create_idx(&synthetic_mbid, Some(&artist.name))?;
let weight = self.weights.qobuz_similar;
vector.set(idx, weight);
count += 1;
}
Ok((vector, count))
}
/// Ensure a vector exists and is fresh, building if necessary
///
/// Returns true if vector was built/updated, false if existing vector was used
pub async fn ensure_vector(
&self,
artist_mbid: &str,
artist_name: Option<&str>,
qobuz_artist_id: Option<u64>,
max_age_days: i64,
) -> Result<bool, String> {
let max_age_secs = max_age_days * 24 * 60 * 60;
// Check if we have a fresh vector
let has_fresh = {
let store = self.store.lock().await;
store.has_fresh_vector(artist_mbid, max_age_secs)
};
if has_fresh {
return Ok(false);
}
// Build new vector
self.build_vector(artist_mbid, artist_name, qobuz_artist_id).await?;
Ok(true)
}
}
/// Resolve a Qobuz artist ID to a MusicBrainz ID by searching
///
/// This is a best-effort match based on artist name
pub async fn resolve_qobuz_to_mbid(
qobuz_client: &Arc<Mutex<QobuzClient>>,
mb_client: &Arc<MusicBrainzClient>,
_mb_cache: &Arc<Mutex<MusicBrainzCache>>,
qobuz_artist_id: u64,
) -> Result<Option<String>, String> {
// Get artist name from Qobuz
let artist_name = {
let client = qobuz_client.lock().await;
let artist = client
.get_artist(qobuz_artist_id, false)
.await
.map_err(|e| format!("Failed to get Qobuz artist: {}", e))?;
artist.name
};
// Search MusicBrainz for this artist
let mb_result = mb_client.search_artist(&artist_name).await?;
// Return first high-confidence match (score >= 80)
if let Some(artist) = mb_result.artists.first() {
if let Some(score) = artist.score {
if score >= 80 {
return Ok(Some(artist.id.clone()));
}
}
}
Ok(None)
}
/// Extract ArtistRelationships from raw MusicBrainz response
fn extract_relationships(artist: &ArtistFullResponse) -> ArtistRelationships {
let mut members = Vec::new();
let mut past_members = Vec::new();
let mut groups = Vec::new();
let mut collaborators = Vec::new();
if let Some(relations) = &artist.relations {
for relation in relations {
let Some(related_artist) = &relation.artist else {
continue;
};
let related = RelatedArtist {
mbid: related_artist.id.clone(),
name: related_artist.name.clone(),
role: relation.attributes.as_ref().and_then(|a| a.first().cloned()),
period: Some(Period {
begin: relation.begin.clone(),
end: relation.end.clone(),
}),
ended: relation.ended.unwrap_or(false),
};
match relation.relation_type.as_str() {
"member of band" => {
if relation.direction.as_deref() == Some("backward") {
// We're viewing a BAND, the related artist is a MEMBER
if related.ended {
past_members.push(related);
} else {
members.push(related);
}
} else {
// We're viewing a PERSON, the related artist is a BAND/GROUP
groups.push(related);
}
}
"collaboration" => {
collaborators.push(related);
}
_ => {}
}
}
}
ArtistRelationships {
members,
past_members,
groups,
collaborators,
}
}
#[cfg(test)]
mod tests {
use super::*;
// Integration tests would require mocking the clients
// For now, we test the weight application logic
#[test]
fn test_weights_applied() {
let weights = RelationshipWeights::default();
assert!(weights.member_of_band > weights.collaboration);
assert!(weights.collaboration > weights.shared_tag);
}
}
+4
View File
@@ -43,8 +43,12 @@
//! ...] //! ...]
//! ``` //! ```
pub mod builder;
pub mod sparse_vector; pub mod sparse_vector;
pub mod store; pub mod store;
pub mod weights;
pub use builder::{ArtistVectorBuilder, BuildResult};
pub use sparse_vector::SparseVector; pub use sparse_vector::SparseVector;
pub use store::{ArtistVectorStore, ArtistVectorStoreState, SimilarArtist, StoreStats, VectorEntry}; pub use store::{ArtistVectorStore, ArtistVectorStoreState, SimilarArtist, StoreStats, VectorEntry};
pub use weights::RelationshipWeights;
+200
View File
@@ -0,0 +1,200 @@
//! Configurable weights for different relationship types
//!
//! These weights determine how strongly different types of relationships
//! contribute to artist similarity vectors.
use serde::{Deserialize, Serialize};
/// Weights for different relationship types when building artist vectors
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelationshipWeights {
// === MusicBrainz artist-to-artist relationships ===
/// Weight for band membership (artist was/is member of group)
/// Strongest connection - same creative unit
pub member_of_band: f32,
/// Weight for collaboration (artists worked together)
pub collaboration: f32,
/// Weight for "is person" in a group (reverse of member_of_band)
pub has_member: f32,
/// Weight for founder relationship
pub founder: f32,
// === MusicBrainz recording/release relationships ===
/// Weight for performer credit on a recording
pub performer: f32,
/// Weight for composer credit
pub composer: f32,
/// Weight for producer credit
pub producer: f32,
/// Weight for conductor credit
pub conductor: f32,
/// Weight for engineer/mixer credit
pub engineer: f32,
// === Qobuz relationships ===
/// Weight for Qobuz similar artists
pub qobuz_similar: f32,
// === Tag-based relationships ===
/// Weight for shared MusicBrainz tags (genres)
pub shared_tag: f32,
// === Behavioral relationships ===
/// Weight for user listening affinity (artists played together)
pub user_affinity: f32,
}
impl Default for RelationshipWeights {
fn default() -> Self {
Self {
// Band relationships - strongest
member_of_band: 1.0,
has_member: 0.9,
founder: 0.85,
collaboration: 0.8,
// Credit relationships - medium
performer: 0.6,
composer: 0.55,
producer: 0.5,
conductor: 0.5,
engineer: 0.3,
// Qobuz similarity - good signal
qobuz_similar: 0.7,
// Tags - weak but useful
shared_tag: 0.3,
// User behavior - medium
user_affinity: 0.5,
}
}
}
impl RelationshipWeights {
/// Create weights optimized for discovering band-related artists
pub fn band_focused() -> Self {
Self {
member_of_band: 1.0,
has_member: 1.0,
founder: 0.9,
collaboration: 0.7,
performer: 0.4,
composer: 0.3,
producer: 0.2,
conductor: 0.3,
engineer: 0.1,
qobuz_similar: 0.5,
shared_tag: 0.2,
user_affinity: 0.3,
}
}
/// Create weights optimized for sound-alike discovery
pub fn similarity_focused() -> Self {
Self {
member_of_band: 0.6,
has_member: 0.5,
founder: 0.5,
collaboration: 0.7,
performer: 0.5,
composer: 0.4,
producer: 0.3,
conductor: 0.3,
engineer: 0.2,
qobuz_similar: 1.0, // Prioritize Qobuz similarity
shared_tag: 0.5,
user_affinity: 0.6,
}
}
/// Get weight for a MusicBrainz relationship type
pub fn weight_for_mb_relation(&self, relation_type: &str) -> f32 {
match relation_type.to_lowercase().as_str() {
// Band relationships
"member of band" | "member_of_band" => self.member_of_band,
"has member" | "has_member" => self.has_member,
"founder" | "founded" => self.founder,
// Collaboration
"collaboration" | "collaborated" | "collaborator" => self.collaboration,
// Performance credits
"performer" | "vocal" | "instrument" | "performing orchestra"
| "orchestra" | "chorus master" => self.performer,
// Composition
"composer" | "writer" | "lyricist" | "librettist" | "arranger" => self.composer,
// Production
"producer" | "executive producer" | "co-producer" => self.producer,
"conductor" => self.conductor,
"engineer" | "mix" | "mixer" | "mastering" | "recording" => self.engineer,
// Unknown - use small default weight
_ => 0.2,
}
}
/// Get weight for a source type string
pub fn weight_for_source(&self, source: &str) -> f32 {
match source {
"qobuz_similar" => self.qobuz_similar,
"shared_tag" => self.shared_tag,
"user_affinity" => self.user_affinity,
s if s.starts_with("mb:") => {
let rel_type = &s[3..];
self.weight_for_mb_relation(rel_type)
}
_ => 0.2,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_weights() {
let weights = RelationshipWeights::default();
assert_eq!(weights.member_of_band, 1.0);
assert!(weights.qobuz_similar > weights.shared_tag);
assert!(weights.collaboration > weights.engineer);
}
#[test]
fn test_mb_relation_weight() {
let weights = RelationshipWeights::default();
assert_eq!(weights.weight_for_mb_relation("member of band"), 1.0);
assert_eq!(weights.weight_for_mb_relation("collaboration"), 0.8);
assert_eq!(weights.weight_for_mb_relation("producer"), 0.5);
// Unknown type gets default
assert_eq!(weights.weight_for_mb_relation("unknown_type"), 0.2);
}
#[test]
fn test_source_weight() {
let weights = RelationshipWeights::default();
assert_eq!(weights.weight_for_source("qobuz_similar"), 0.7);
assert_eq!(weights.weight_for_source("mb:member of band"), 1.0);
assert_eq!(weights.weight_for_source("mb:collaboration"), 0.8);
}
}
+4 -3
View File
@@ -22,9 +22,10 @@ pub mod smart_playlists;
pub use cache::{CacheStats, MusicBrainzCache, MusicBrainzCacheState}; pub use cache::{CacheStats, MusicBrainzCache, MusicBrainzCacheState};
pub use client::{MusicBrainzClient, MusicBrainzConfig}; pub use client::{MusicBrainzClient, MusicBrainzConfig};
pub use models::{ pub use models::{
AlbumAppearance, ArtistRelationships, ArtistType, MatchConfidence, Medium, MediumTrack, AlbumAppearance, ArtistFullResponse, ArtistRelationships, ArtistType, MatchConfidence, Medium,
MusicianAppearances, MusicianConfidence, Period, RelatedArtist, ReleaseFullResponse, MediumTrack, MusicianAppearances, MusicianConfidence, Period, RelatedArtist, Relation,
ReleaseSearchResponse, ResolvedArtist, ResolvedMusician, ResolvedRelease, ResolvedTrack, Tag, ReleaseFullResponse, ReleaseSearchResponse, ResolvedArtist, ResolvedMusician, ResolvedRelease,
ResolvedTrack, Tag,
}; };
use std::sync::Arc; use std::sync::Arc;