use serde::Deserialize;
use thiserror::Error;
const MB_BASE: &str = "https://musicbrainz.org/ws/2";
const USER_AGENT: &str = "koan/0.10.0 (https://github.com/radiosilence/koan)";
#[derive(Debug, Error)]
pub enum MusicBrainzError {
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("artist not found")]
NotFound,
#[error("rate limited")]
RateLimited,
}
#[derive(Debug, Clone)]
pub struct ArtistRelation {
pub name: String,
pub mbid: String,
pub relation_type: String,
pub category: RelationCategory,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RelationCategory {
Member,
Collaborator,
Associated,
}
impl RelationCategory {
pub fn as_str(&self) -> &'static str {
match self {
Self::Member => "member",
Self::Collaborator => "collaborator",
Self::Associated => "associated",
}
}
}
#[derive(Debug, Clone)]
pub struct ArtistSearchResult {
pub name: String,
pub mbid: String,
pub score: u32,
}
#[derive(Debug, Deserialize)]
struct ArtistSearchResponse {
#[serde(default)]
artists: Vec<MbArtist>,
}
#[derive(Debug, Deserialize)]
struct MbArtist {
#[serde(default)]
id: String,
#[serde(default)]
name: String,
#[serde(default)]
score: Option<u32>,
#[serde(default)]
relations: Vec<MbRelation>,
}
#[derive(Debug, Deserialize)]
struct MbRelation {
#[serde(rename = "type")]
relation_type: Option<String>,
#[serde(default)]
artist: Option<MbRelatedArtist>,
}
#[derive(Debug, Deserialize)]
struct MbRelatedArtist {
#[serde(default)]
id: String,
#[serde(default)]
name: String,
}
fn mb_client() -> reqwest::blocking::Client {
reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.build()
.unwrap_or_else(|_| reqwest::blocking::Client::new())
}
pub fn search_artist(
http: &reqwest::blocking::Client,
artist_name: &str,
limit: usize,
) -> Result<Vec<ArtistSearchResult>, MusicBrainzError> {
let url = format!(
"{}/artist?query=artist:{}&fmt=json&limit={}",
MB_BASE,
urlencoded(artist_name),
limit
);
let resp = http.get(&url).header("User-Agent", USER_AGENT).send()?;
if resp.status().as_u16() == 503 {
return Err(MusicBrainzError::RateLimited);
}
if !resp.status().is_success() {
return Err(MusicBrainzError::NotFound);
}
let data: ArtistSearchResponse = resp.json()?;
Ok(data
.artists
.into_iter()
.map(|a| ArtistSearchResult {
name: a.name,
mbid: a.id,
score: a.score.unwrap_or(0),
})
.collect())
}
pub fn lookup_artist_mbid(
http: &reqwest::blocking::Client,
artist_name: &str,
) -> Result<Option<String>, MusicBrainzError> {
let results = search_artist(http, artist_name, 3)?;
Ok(results.into_iter().find(|r| r.score >= 90).map(|r| r.mbid))
}
pub fn get_artist_relations(
http: &reqwest::blocking::Client,
artist_mbid: &str,
) -> Result<Vec<ArtistRelation>, MusicBrainzError> {
let url = format!(
"{}/artist/{}?inc=artist-rels&fmt=json",
MB_BASE, artist_mbid
);
let resp = http.get(&url).header("User-Agent", USER_AGENT).send()?;
if resp.status().as_u16() == 503 {
return Err(MusicBrainzError::RateLimited);
}
if resp.status().as_u16() == 404 {
return Err(MusicBrainzError::NotFound);
}
let artist: MbArtist = resp.json()?;
let mut relations = Vec::new();
for rel in artist.relations {
if let (Some(rel_type), Some(related)) = (rel.relation_type, rel.artist) {
if related.id == artist_mbid {
continue; }
let category = categorize_relation(&rel_type);
relations.push(ArtistRelation {
name: related.name,
mbid: related.id,
relation_type: rel_type,
category,
});
}
}
Ok(relations)
}
fn categorize_relation(rel_type: &str) -> RelationCategory {
let lower = rel_type.to_lowercase();
if lower.contains("member") || lower.contains("part of") {
RelationCategory::Member
} else if lower.contains("collaborat")
|| lower.contains("remix")
|| lower.contains("producer")
|| lower.contains("performing")
|| lower.contains("instrument")
|| lower.contains("vocal")
{
RelationCategory::Collaborator
} else {
RelationCategory::Associated
}
}
fn urlencoded(s: &str) -> String {
s.replace('%', "%25")
.replace(' ', "%20")
.replace('&', "%26")
.replace('=', "%3D")
.replace('+', "%2B")
.replace('#', "%23")
.replace('?', "%3F")
}
pub fn default_client() -> reqwest::blocking::Client {
mb_client()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_categorize_relation_member() {
assert_eq!(
categorize_relation("member of band"),
RelationCategory::Member
);
assert_eq!(categorize_relation("is part of"), RelationCategory::Member);
}
#[test]
fn test_categorize_relation_collaborator() {
assert_eq!(
categorize_relation("collaboration"),
RelationCategory::Collaborator
);
assert_eq!(categorize_relation("remix"), RelationCategory::Collaborator);
assert_eq!(
categorize_relation("producer"),
RelationCategory::Collaborator
);
}
#[test]
fn test_categorize_relation_associated() {
assert_eq!(categorize_relation("tribute"), RelationCategory::Associated);
assert_eq!(
categorize_relation("support act"),
RelationCategory::Associated
);
}
#[test]
fn test_urlencoded() {
assert_eq!(urlencoded("Aphex Twin"), "Aphex%20Twin");
assert_eq!(urlencoded("AC/DC"), "AC/DC");
assert_eq!(urlencoded("a&b=c"), "a%26b%3Dc");
}
#[test]
fn test_deserialize_search_response() {
let json = r#"{
"artists": [
{"id": "f22942a1-6f70-4f48-866e-238cb2308fbd", "name": "Aphex Twin", "score": 100},
{"id": "abc-123", "name": "Aphex Twin Tribute", "score": 60}
]
}"#;
let resp: ArtistSearchResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.artists.len(), 2);
assert_eq!(resp.artists[0].name, "Aphex Twin");
assert_eq!(resp.artists[0].score, Some(100));
}
#[test]
fn test_deserialize_artist_with_relations() {
let json = r#"{
"id": "f22942a1-6f70-4f48-866e-238cb2308fbd",
"name": "Aphex Twin",
"relations": [
{
"type": "collaboration",
"artist": {"id": "abc-123", "name": "µ-Ziq"}
},
{
"type": "member of band",
"artist": {"id": "def-456", "name": "Universal Indicator"}
}
]
}"#;
let artist: MbArtist = serde_json::from_str(json).unwrap();
assert_eq!(artist.relations.len(), 2);
assert_eq!(
artist.relations[0].relation_type.as_deref(),
Some("collaboration")
);
assert_eq!(artist.relations[0].artist.as_ref().unwrap().name, "µ-Ziq");
}
}