use serde::Deserialize; use tokio::sync::Mutex; use tokio::time::{Duration, Instant}; use crate::cleaning::escape_lucene; use crate::error::{TagError, TagResult}; use crate::provider::{MetadataProvider, RecordingDetails, RecordingMatch, ReleaseMatch, ReleaseRef}; const BASE_URL: &str = "https://musicbrainz.org/ws/2"; const USER_AGENT: &str = "Shanty/0.1.0 (shanty-music-app)"; const RATE_LIMIT: Duration = Duration::from_millis(1100); // slightly over 1s to be safe /// MusicBrainz API client with rate limiting. pub struct MusicBrainzClient { client: reqwest::Client, last_request: Mutex, } impl MusicBrainzClient { pub fn new() -> TagResult { let client = reqwest::Client::builder() .user_agent(USER_AGENT) .timeout(Duration::from_secs(30)) .build()?; Ok(Self { client, last_request: Mutex::new(Instant::now() - RATE_LIMIT), }) } /// Enforce rate limiting: wait if needed so we don't exceed 1 req/sec. async fn rate_limit(&self) { let mut last = self.last_request.lock().await; let elapsed = last.elapsed(); if elapsed < RATE_LIMIT { tokio::time::sleep(RATE_LIMIT - elapsed).await; } *last = Instant::now(); } async fn get_json(&self, url: &str) -> TagResult { self.rate_limit().await; tracing::debug!(url = url, "MusicBrainz request"); let resp = self.client.get(url).send().await?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); return Err(TagError::Other(format!( "MusicBrainz API error {status}: {body}" ))); } Ok(resp.json().await?) } } impl MetadataProvider for MusicBrainzClient { async fn search_recording( &self, artist: &str, title: &str, ) -> TagResult> { let query = if artist.is_empty() { format!("recording:{}", escape_lucene(title)) } else { format!( "artist:{} AND recording:{}", escape_lucene(artist), escape_lucene(title) ) }; let url = format!("{BASE_URL}/recording/?query={}&fmt=json&limit=5", urlencoded(&query)); let resp: MbRecordingSearchResponse = self.get_json(&url).await?; Ok(resp .recordings .into_iter() .map(|r| { let (artist_name, artist_mbid) = extract_artist_credit(&r.artist_credit); RecordingMatch { mbid: r.id, title: r.title, artist: artist_name, artist_mbid, releases: r .releases .unwrap_or_default() .into_iter() .map(|rel| ReleaseRef { mbid: rel.id, title: rel.title, date: rel.date, track_number: None, }) .collect(), score: r.score.unwrap_or(0), } }) .collect()) } async fn search_release( &self, artist: &str, album: &str, ) -> TagResult> { let query = format!( "artist:{} AND release:{}", escape_lucene(artist), escape_lucene(album) ); let url = format!("{BASE_URL}/release/?query={}&fmt=json&limit=5", urlencoded(&query)); let resp: MbReleaseSearchResponse = self.get_json(&url).await?; Ok(resp .releases .into_iter() .map(|r| { let (artist_name, artist_mbid) = extract_artist_credit(&r.artist_credit); ReleaseMatch { mbid: r.id, title: r.title, artist: artist_name, artist_mbid, date: r.date, track_count: r.track_count, score: r.score.unwrap_or(0), } }) .collect()) } async fn get_recording(&self, mbid: &str) -> TagResult { let url = format!( "{BASE_URL}/recording/{mbid}?inc=artists+releases+genres&fmt=json" ); let r: MbRecordingDetail = self.get_json(&url).await?; let (artist_name, artist_mbid) = extract_artist_credit(&r.artist_credit); Ok(RecordingDetails { mbid: r.id, title: r.title, artist: artist_name, artist_mbid, releases: r .releases .unwrap_or_default() .into_iter() .map(|rel| ReleaseRef { mbid: rel.id, title: rel.title, date: rel.date, track_number: None, }) .collect(), duration_ms: r.length, genres: r .genres .unwrap_or_default() .into_iter() .map(|g| g.name) .collect(), }) } } 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 mbid = Some(credits[0].artist.id.clone()); (name, mbid) } _ => ("Unknown Artist".to_string(), None), } } fn urlencoded(s: &str) -> String { s.replace(' ', "+") .replace('&', "%26") .replace('=', "%3D") .replace('#', "%23") } // --- MusicBrainz API response types --- #[derive(Deserialize)] struct MbRecordingSearchResponse { recordings: Vec, } #[derive(Deserialize)] struct MbRecordingResult { id: String, title: String, score: Option, #[serde(rename = "artist-credit")] artist_credit: Option>, releases: Option>, } #[derive(Deserialize)] struct MbReleaseSearchResponse { releases: Vec, } #[derive(Deserialize)] struct MbReleaseResult { id: String, title: String, score: Option, #[serde(rename = "artist-credit")] artist_credit: Option>, date: Option, #[serde(rename = "track-count")] track_count: Option, } #[derive(Deserialize)] struct MbRecordingDetail { id: String, title: String, #[serde(rename = "artist-credit")] artist_credit: Option>, releases: Option>, length: Option, genres: Option>, } #[derive(Deserialize)] struct MbArtistCredit { artist: MbArtist, joinphrase: Option, } #[derive(Deserialize)] struct MbArtist { id: String, name: String, } #[derive(Deserialize)] struct MbGenre { name: String, }