Files
drift/src/lastfm.rs
Connor Johnstone d59235707d Clippy fixes
2026-03-04 23:13:40 -05:00

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)
}
}
}