use std::time::{Duration, Instant}; use reqwest::{Client, StatusCode}; use serde::Deserialize; use tokio::sync::Mutex; const MUSICBRAINZ_BASE_URL: &str = "https://musicbrainz.org/ws/2"; const COVER_ART_ARCHIVE_BASE_URL: &str = "https://coverartarchive.org"; const MUSICBRAINZ_REQUEST_DELAY: Duration = Duration::from_millis(1100); const MUSICBRAINZ_TAG_LIMIT: usize = 12; #[derive(Debug, Clone)] pub struct MusicBrainzTag { pub name: String, pub weight: f64, } #[derive(Debug, Clone)] pub struct MusicBrainzArtistMatch { pub mbid: String, pub score: i32, } #[derive(Debug, Clone)] pub struct MusicBrainzReleaseMatch { pub mbid: String, pub release_group_mbid: Option, pub score: i32, } #[derive(Debug, Clone)] pub struct MusicBrainzReleaseTags { pub release_group_mbid: Option, pub tags: Vec, } pub struct MusicBrainzClient { client: Client, last_musicbrainz_request: Mutex>, } pub async fn load_external_id( pool: &sqlx::PgPool, entity_kind: &str, entity_id: i64, id_kind: &str, ) -> anyhow::Result> { let value = sqlx::query_scalar::<_, String>( r#"SELECT external_id::text FROM furumusic__external_metadata_id WHERE entity_kind = $1 AND entity_id = $2 AND source = 'musicbrainz' AND id_kind = $3 LIMIT 1"#, ) .bind(entity_kind) .bind(entity_id) .bind(id_kind) .fetch_optional(pool) .await?; Ok(value) } pub async fn save_external_id( pool: &sqlx::PgPool, entity_kind: &str, entity_id: i64, id_kind: &str, external_id: &str, confidence: f64, ) -> anyhow::Result<()> { sqlx::query( r#"INSERT INTO furumusic__external_metadata_id (entity_kind, entity_id, source, id_kind, external_id, confidence, updated_at) VALUES ($1, $2, 'musicbrainz', $3, $4, $5, $6) ON CONFLICT (entity_kind, entity_id, source, id_kind) DO UPDATE SET external_id = EXCLUDED.external_id, confidence = EXCLUDED.confidence, updated_at = EXCLUDED.updated_at"#, ) .bind(entity_kind) .bind(entity_id) .bind(id_kind) .bind(external_id) .bind(confidence) .bind(now_iso()) .execute(pool) .await?; Ok(()) } pub async fn load_or_search_release_mbid( pool: &sqlx::PgPool, client: &MusicBrainzClient, release_id: i64, artist_name: &str, release_title: &str, representative_track_title: Option<&str>, ) -> anyhow::Result<(Option, Option)> { let release_mbid = load_external_id(pool, "release", release_id, "release").await?; let release_group_mbid = load_external_id(pool, "release", release_id, "release_group").await?; if release_mbid.is_some() || release_group_mbid.is_some() { return Ok((release_mbid, release_group_mbid)); } let found = match client.search_release(artist_name, release_title).await? { Some(found) => Some(found), None => { if let Some(track_title) = representative_track_title.filter(|value| !value.trim().is_empty()) { client .search_release_by_recording(artist_name, track_title) .await? } else { None } } }; let Some(found) = found else { return Ok((None, None)); }; save_external_id( pool, "release", release_id, "release", &found.mbid, found.score as f64 / 100.0, ) .await?; if let Some(group_mbid) = found.release_group_mbid.as_deref() { save_external_id( pool, "release", release_id, "release_group", group_mbid, found.score as f64 / 100.0, ) .await?; } Ok((Some(found.mbid), found.release_group_mbid)) } impl MusicBrainzClient { pub fn new(user_agent_prefix: &str) -> anyhow::Result { let client = Client::builder() .user_agent(format!( "{}/{} (musicbrainz.org/doc/MusicBrainz_API)", user_agent_prefix, env!("CARGO_PKG_VERSION") )) .timeout(Duration::from_secs(20)) .build()?; Ok(Self { client, last_musicbrainz_request: Mutex::new(None), }) } pub fn http_client(&self) -> &Client { &self.client } pub async fn search_artist( &self, name: &str, ) -> anyhow::Result> { let query = format!("artist:\"{}\"", escape_search_value(name)); let response: Option = self .get_musicbrainz_json( "artist", &[("query", query.as_str()), ("fmt", "json"), ("limit", "5")], ) .await?; let Some(response) = response else { return Ok(None); }; Ok(response .artists .into_iter() .filter(|artist| artist.id.trim().len() == 36) .max_by_key(|artist| artist.score.unwrap_or(0)) .and_then(|artist| { let score = artist.score.unwrap_or(0); (score >= 70).then_some(MusicBrainzArtistMatch { mbid: artist.id, score, }) })) } pub async fn search_release( &self, artist: &str, title: &str, ) -> anyhow::Result> { let query = format!( "release:\"{}\" AND artist:\"{}\"", escape_search_value(title), escape_search_value(artist) ); let response: Option = self .get_musicbrainz_json( "release", &[("query", query.as_str()), ("fmt", "json"), ("limit", "5")], ) .await?; let Some(response) = response else { return Ok(None); }; Ok(response .releases .into_iter() .filter(|release| release.id.trim().len() == 36) .max_by_key(|release| release.score.unwrap_or(0)) .and_then(|release| { let score = release.score.unwrap_or(0); (score >= 70).then_some(MusicBrainzReleaseMatch { mbid: release.id, release_group_mbid: release.release_group.map(|group| group.id), score, }) })) } pub async fn search_release_by_recording( &self, artist: &str, track_title: &str, ) -> anyhow::Result> { let query = format!( "recording:\"{}\" AND artist:\"{}\"", escape_search_value(track_title), escape_search_value(artist) ); let response: Option = self .get_musicbrainz_json( "recording", &[("query", query.as_str()), ("fmt", "json"), ("limit", "5")], ) .await?; let Some(response) = response else { return Ok(None); }; Ok(response .recordings .into_iter() .flat_map(|recording| { let recording_score = recording.score.unwrap_or(0); recording .releases .into_iter() .filter(move |release| release.id.trim().len() == 36) .map(move |release| (recording_score, release)) }) .max_by_key(|(score, _)| *score) .and_then(|(score, release)| { (score >= 70).then_some(MusicBrainzReleaseMatch { mbid: release.id, release_group_mbid: release.release_group.map(|group| group.id), score, }) })) } pub async fn lookup_artist_tags(&self, mbid: &str) -> anyhow::Result> { let response: Option = self .get_musicbrainz_json( &format!("artist/{mbid}"), &[("inc", "tags+genres"), ("fmt", "json")], ) .await?; Ok(response.map(tags_from_entity).unwrap_or_default()) } pub async fn lookup_release_tags(&self, mbid: &str) -> anyhow::Result { let response: Option = self .get_musicbrainz_json( &format!("release/{mbid}"), &[("inc", "tags+genres+release-groups"), ("fmt", "json")], ) .await?; let Some(response) = response else { return Ok(MusicBrainzReleaseTags { release_group_mbid: None, tags: Vec::new(), }); }; let mut tags = tags_from_parts(response.tags, response.genres); let release_group_mbid = response .release_group .as_ref() .map(|group| group.id.clone()); if let Some(group_mbid) = release_group_mbid.as_deref() { let group_response: Option = self .get_musicbrainz_json( &format!("release-group/{group_mbid}"), &[("inc", "tags+genres"), ("fmt", "json")], ) .await?; merge_tags( &mut tags, group_response.map(tags_from_entity).unwrap_or_default(), ); } tags.sort_by(|a, b| { b.weight .total_cmp(&a.weight) .then_with(|| a.name.cmp(&b.name)) }); tags.truncate(MUSICBRAINZ_TAG_LIMIT); Ok(MusicBrainzReleaseTags { release_group_mbid, tags, }) } pub async fn fetch_cover_art_front_url( &self, release_mbid: Option<&str>, release_group_mbid: Option<&str>, ) -> anyhow::Result> { if let Some(mbid) = release_mbid { if let Some(url) = self.cover_art_front_url("release", mbid).await? { return Ok(Some(url)); } } if let Some(mbid) = release_group_mbid { if let Some(url) = self.cover_art_front_url("release-group", mbid).await? { return Ok(Some(url)); } } Ok(None) } async fn get_musicbrainz_json( &self, path: &str, query: &[(&str, &str)], ) -> anyhow::Result> where T: for<'de> Deserialize<'de>, { self.wait_for_musicbrainz_slot().await; let url = format!("{MUSICBRAINZ_BASE_URL}/{}", path.trim_start_matches('/')); let response = self.client.get(url).query(query).send().await?; if response.status() == StatusCode::NOT_FOUND { return Ok(None); } if response.status() == StatusCode::TOO_MANY_REQUESTS || response.status() == StatusCode::SERVICE_UNAVAILABLE { anyhow::bail!( "MusicBrainz rate limit or service unavailable: {}", response.status() ); } let response = response.error_for_status()?; Ok(Some(response.json::().await?)) } async fn cover_art_front_url(&self, kind: &str, mbid: &str) -> anyhow::Result> { let url = format!("{COVER_ART_ARCHIVE_BASE_URL}/{kind}/{mbid}"); let response = self.client.get(url).send().await?; if response.status() == StatusCode::NOT_FOUND { return Ok(None); } if response.status() == StatusCode::TOO_MANY_REQUESTS || response.status() == StatusCode::SERVICE_UNAVAILABLE { anyhow::bail!("Cover Art Archive unavailable: {}", response.status()); } let response = response.error_for_status()?; let body = response.json::().await?; Ok(best_cover_art_url(body.images)) } async fn wait_for_musicbrainz_slot(&self) { let mut last = self.last_musicbrainz_request.lock().await; if let Some(previous) = *last { let elapsed = previous.elapsed(); if elapsed < MUSICBRAINZ_REQUEST_DELAY { tokio::time::sleep(MUSICBRAINZ_REQUEST_DELAY - elapsed).await; } } *last = Some(Instant::now()); } } fn now_iso() -> String { chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() } #[derive(Debug, Deserialize)] struct ArtistSearchResponse { #[serde(default)] artists: Vec, } #[derive(Debug, Deserialize)] struct ArtistSearchItem { id: String, score: Option, } #[derive(Debug, Deserialize)] struct ReleaseSearchResponse { #[serde(default)] releases: Vec, } #[derive(Debug, Deserialize)] struct ReleaseSearchItem { id: String, score: Option, #[serde(rename = "release-group")] release_group: Option, } #[derive(Debug, Deserialize)] struct RecordingSearchResponse { #[serde(default)] recordings: Vec, } #[derive(Debug, Deserialize)] struct RecordingSearchItem { score: Option, #[serde(default)] releases: Vec, } #[derive(Debug, Deserialize)] struct RecordingReleaseItem { id: String, #[serde(rename = "release-group")] release_group: Option, } #[derive(Debug, Deserialize)] struct ReleaseLookupResponse { #[serde(default)] tags: Vec, #[serde(default)] genres: Vec, #[serde(rename = "release-group")] release_group: Option, } #[derive(Debug, Deserialize)] struct TaggedEntityResponse { #[serde(default)] tags: Vec, #[serde(default)] genres: Vec, } #[derive(Debug, Deserialize)] struct MusicBrainzIdRef { id: String, } #[derive(Debug, Deserialize)] struct MusicBrainzTagItem { name: String, count: Option, } #[derive(Debug, Deserialize)] struct CoverArtArchiveResponse { #[serde(default)] images: Vec, } #[derive(Debug, Deserialize)] struct CoverArtArchiveImage { image: Option, front: Option, approved: Option, #[serde(default)] types: Vec, thumbnails: Option, } #[derive(Debug, Deserialize)] struct CoverArtArchiveThumbnails { #[serde(rename = "1200")] size_1200: Option, #[serde(rename = "500")] size_500: Option, large: Option, } fn escape_search_value(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } fn tags_from_entity(entity: TaggedEntityResponse) -> Vec { tags_from_parts(entity.tags, entity.genres) } fn tags_from_parts( tags: Vec, genres: Vec, ) -> Vec { let mut result = Vec::new(); merge_items(&mut result, genres, 2.0); merge_items(&mut result, tags, 1.0); result.sort_by(|a, b| { b.weight .total_cmp(&a.weight) .then_with(|| a.name.cmp(&b.name)) }); result.truncate(MUSICBRAINZ_TAG_LIMIT); result } fn merge_items(result: &mut Vec, items: Vec, multiplier: f64) { for item in items { let name = item.name.trim(); if name.is_empty() { continue; } let weight = item.count.unwrap_or(1).max(1) as f64 * multiplier; if let Some(existing) = result .iter_mut() .find(|tag| tag.name.eq_ignore_ascii_case(name)) { existing.weight = existing.weight.max(weight); } else { result.push(MusicBrainzTag { name: name.to_string(), weight, }); } } } fn merge_tags(result: &mut Vec, extra: Vec) { for tag in extra { if let Some(existing) = result .iter_mut() .find(|candidate| candidate.name.eq_ignore_ascii_case(&tag.name)) { existing.weight = existing.weight.max(tag.weight); } else { result.push(tag); } } } fn best_cover_art_url(mut images: Vec) -> Option { images.sort_by_key(|image| { let front = image.front.unwrap_or(false) || image .types .iter() .any(|value| value.eq_ignore_ascii_case("front")); let approved = image.approved.unwrap_or(false); (u8::from(front), u8::from(approved)) }); images .into_iter() .rev() .find_map(|image| { image .thumbnails .as_ref() .and_then(|thumbs| { thumbs .size_1200 .as_deref() .or(thumbs.size_500.as_deref()) .or(thumbs.large.as_deref()) }) .map(str::to_string) .or(image.image) }) .filter(|url| !url.trim().is_empty()) }