194 lines
5.5 KiB
Rust
194 lines
5.5 KiB
Rust
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<String>,
|
|
pub match_score: f64,
|
|
}
|
|
|
|
pub struct TopTrack {
|
|
pub name: String,
|
|
pub mbid: Option<String>,
|
|
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<ArtistEntry>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ArtistEntry {
|
|
name: String,
|
|
mbid: Option<String>,
|
|
#[serde(rename = "match")]
|
|
match_score: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TopTracksResponse {
|
|
toptracks: TopTracksWrapper,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TopTracksWrapper {
|
|
track: Vec<TrackEntry>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TrackEntry {
|
|
name: String,
|
|
mbid: Option<String>,
|
|
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<Option<String>, Box<dyn std::error::Error>> {
|
|
let body: String = ureq::get(url).call()?.body_mut().read_to_string()?;
|
|
if serde_json::from_str::<ApiError>(&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<Option<String>, Box<dyn std::error::Error>> {
|
|
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<Vec<SimilarArtist>, Box<dyn std::error::Error>> {
|
|
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<Vec<SimilarArtist>, Box<dyn std::error::Error>> {
|
|
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<Vec<TopTrack>, Box<dyn std::error::Error>> {
|
|
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<Vec<TopTrack>, Box<dyn std::error::Error>> {
|
|
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)
|
|
}
|
|
}
|
|
}
|