diff --git a/src/musicbrainz.rs b/src/musicbrainz.rs index 38eaa44..a36ea3b 100644 --- a/src/musicbrainz.rs +++ b/src/musicbrainz.rs @@ -150,6 +150,7 @@ impl MetadataProvider for MusicBrainzClient { let r: MbRecordingDetail = self.get_json(&url).await?; let (artist_name, artist_mbid) = extract_artist_credit(&r.artist_credit); + let secondary_artists = extract_secondary_artists(&r.artist_credit); Ok(RecordingDetails { mbid: r.id, title: r.title, @@ -173,6 +174,7 @@ impl MetadataProvider for MusicBrainzClient { .into_iter() .map(|g| g.name) .collect(), + secondary_artists, }) } async fn search_artist( @@ -275,19 +277,13 @@ impl MetadataProvider for MusicBrainzClient { } } +/// Extract the primary artist from MusicBrainz artist credits. +/// Always returns the first/primary artist only — never concatenates +/// collaborators or featured artists into compound names. fn extract_artist_credit(credits: &Option>) -> (String, Option) { match credits { Some(credits) if !credits.is_empty() => { - let name: String = credits - .iter() - .map(|c| { - let mut s = c.artist.name.clone(); - if let Some(ref join) = c.joinphrase { - s.push_str(join); - } - s - }) - .collect(); + let name = credits[0].artist.name.clone(); let mbid = Some(credits[0].artist.id.clone()); (name, mbid) } @@ -295,6 +291,30 @@ fn extract_artist_credit(credits: &Option>) -> (String, Opti } } +/// Extract non-featuring secondary artists from MusicBrainz artist credits. +/// Returns (name, mbid) pairs for collaborators that aren't "featuring" credits. +fn extract_secondary_artists(credits: &Option>) -> Vec<(String, String)> { + let Some(credits) = credits else { return vec![] }; + if credits.len() <= 1 { + return vec![]; + } + + // Walk credits after the first. Stop at any "feat"/"ft." joinphrase + // from the PREVIOUS credit (since joinphrase is on the credit BEFORE the next artist). + let mut result = Vec::new(); + for i in 0..credits.len() - 1 { + let jp = credits[i].joinphrase.as_deref().unwrap_or(""); + let lower = jp.to_lowercase(); + if lower.contains("feat") || lower.contains("ft.") { + break; + } + // The next credit is a non-featuring collaborator + let next = &credits[i + 1]; + result.push((next.artist.name.clone(), next.artist.id.clone())); + } + result +} + fn urlencoded(s: &str) -> String { s.replace(' ', "+") .replace('&', "%26") diff --git a/src/provider.rs b/src/provider.rs index a5bd0a8..b56fc45 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -45,6 +45,9 @@ pub struct RecordingDetails { pub releases: Vec, pub duration_ms: Option, pub genres: Vec, + /// Non-featuring collaborators beyond the primary artist. + #[serde(default)] + pub secondary_artists: Vec<(String, String)>, } /// An artist match from a search query. diff --git a/src/tagger.rs b/src/tagger.rs index b31ee56..33df805 100644 --- a/src/tagger.rs +++ b/src/tagger.rs @@ -105,7 +105,7 @@ pub async fn tag_track( (details, best_release) }; - // Upsert artist with MusicBrainz ID + // Upsert primary artist with MusicBrainz ID let artist_id = match &details.artist_mbid { Some(mbid) => { Some(queries::artists::upsert(conn, &details.artist, Some(mbid)).await?.id) @@ -115,6 +115,13 @@ pub async fn tag_track( } }; + // Upsert secondary collaborator artists so they exist as separate library entries + for (name, mbid) in &details.secondary_artists { + if let Err(e) = queries::artists::upsert(conn, name, Some(mbid)).await { + tracing::warn!(artist = %name, error = %e, "failed to upsert secondary artist"); + } + } + // Upsert album from best release let (album_id, album_name) = if let Some(ref release) = best_release { let album = queries::albums::upsert( diff --git a/tests/integration.rs b/tests/integration.rs index 10b5f54..6fd09cc 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -53,6 +53,7 @@ impl MetadataProvider for MockProvider { }], duration_ms: Some(413_000), genres: vec!["Progressive Rock".into()], + secondary_artists: vec![], }) } else { Err(shanty_tag::TagError::Other("not found".into()))