Update to artist credit handling

This commit is contained in:
Connor Johnstone
2026-03-18 14:34:44 -04:00
parent 966dc6ca86
commit 4400cbc1cb
4 changed files with 42 additions and 11 deletions

View File

@@ -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<Vec<MbArtistCredit>>) -> (String, Option<String>) {
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<Vec<MbArtistCredit>>) -> (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<MbArtistCredit>>) -> 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")

View File

@@ -45,6 +45,9 @@ pub struct RecordingDetails {
pub releases: Vec<ReleaseRef>,
pub duration_ms: Option<u64>,
pub genres: Vec<String>,
/// Non-featuring collaborators beyond the primary artist.
#[serde(default)]
pub secondary_artists: Vec<(String, String)>,
}
/// An artist match from a search query.

View File

@@ -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(

View File

@@ -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()))