use serde::Deserialize; const BASE_URL: &str = "https://ws.audioscrobbler.com/2.0/"; pub struct LastfmClient { api_key: String, } pub struct SimilarArtist { pub name: String, pub mbid: Option, pub match_score: f64, } pub struct TopTrack { pub name: String, pub mbid: Option, pub playcount: u64, } // Last.fm returns {"error": N, "message": "..."} on failure. // Only used to detect error responses via serde — fields aren't read directly. #[derive(Deserialize)] struct ApiError { #[allow(dead_code)] error: u32, } // Deserialization structs for the Last.fm API responses #[derive(Deserialize)] struct SimilarArtistsResponse { similarartists: SimilarArtistsWrapper, } #[derive(Deserialize)] struct SimilarArtistsWrapper { artist: Vec, } #[derive(Deserialize)] struct ArtistEntry { name: String, mbid: Option, #[serde(rename = "match")] match_score: String, } #[derive(Deserialize)] struct TopTracksResponse { toptracks: TopTracksWrapper, } #[derive(Deserialize)] struct TopTracksWrapper { track: Vec, } #[derive(Deserialize)] struct TrackEntry { name: String, mbid: Option, playcount: String, } impl LastfmClient { pub fn new(api_key: String) -> Self { Self { api_key } } /// Fetch a URL and return the body. Returns `None` if Last.fm returns an API error. fn fetch_or_none(&self, url: &str) -> Result, Box> { let body: String = ureq::get(url).call()?.body_mut().read_to_string()?; if serde_json::from_str::(&body).is_ok() { return Ok(None); } Ok(Some(body)) } /// Normalize Unicode hyphens to ASCII and fetch by artist name. fn fetch_by_name( &self, method: &str, artist_name: Option<&str>, extra_params: &str, ) -> Result, Box> { if let Some(name) = artist_name { let name = name .replace(['\u{2010}', '\u{2011}', '\u{2012}', '\u{2013}', '\u{2014}', '\u{2015}'], "-") .replace(['\u{2018}', '\u{2019}'], "'"); let encoded = urlencoding::encode(&name); let url = format!( "{}?method={}&artist={}&api_key={}{}&format=json", BASE_URL, method, encoded, self.api_key, extra_params ); return self.fetch_or_none(&url); } Ok(None) } fn parse_similar_artists(body: &str) -> Result, Box> { let resp: SimilarArtistsResponse = serde_json::from_str(body)?; Ok(resp .similarartists .artist .into_iter() .map(|a| { let mbid = a.mbid.filter(|s| !s.is_empty()); SimilarArtist { name: a.name, mbid, match_score: a.match_score.parse().unwrap_or(0.0), } }) .collect()) } pub fn get_similar_artists( &self, artist_mbid: &str, artist_name: Option<&str>, ) -> Result, Box> { let method = "artist.getSimilar"; let extra = "&limit=500"; // Try MBID lookup let mbid_url = format!( "{}?method={}&mbid={}&api_key={}{}&format=json", BASE_URL, method, artist_mbid, self.api_key, extra ); let mbid_results = match self.fetch_or_none(&mbid_url)? { Some(body) => Self::parse_similar_artists(&body).unwrap_or_default(), None => Vec::new(), }; // Try name lookup and return whichever has more results let name_results = match self.fetch_by_name(method, artist_name, extra)? { Some(body) => Self::parse_similar_artists(&body).unwrap_or_default(), None => Vec::new(), }; if name_results.len() > mbid_results.len() { Ok(name_results) } else { Ok(mbid_results) } } fn parse_top_tracks(body: &str) -> Result, Box> { let resp: TopTracksResponse = serde_json::from_str(body)?; Ok(resp .toptracks .track .into_iter() .map(|t| TopTrack { name: t.name, mbid: t.mbid.filter(|s| !s.is_empty()), playcount: t.playcount.parse().unwrap_or(0), }) .collect()) } pub fn get_top_tracks( &self, artist_mbid: &str, artist_name: Option<&str>, ) -> Result, Box> { let method = "artist.getTopTracks"; let extra = "&limit=1000"; // Try MBID lookup let mbid_url = format!( "{}?method={}&mbid={}&api_key={}{}&format=json", BASE_URL, method, artist_mbid, self.api_key, extra ); let mbid_results = match self.fetch_or_none(&mbid_url)? { Some(body) => Self::parse_top_tracks(&body).unwrap_or_default(), None => Vec::new(), }; // Try name lookup and return whichever has more results let name_results = match self.fetch_by_name(method, artist_name, extra)? { Some(body) => Self::parse_top_tracks(&body).unwrap_or_default(), None => Vec::new(), }; if name_results.len() > mbid_results.len() { Ok(name_results) } else { Ok(mbid_results) } } }