1use crate::iterator::{ApiRecentTracksIterator, AsyncPaginatedIterator};
2use crate::types::{
3 ClientEvent, ClientEventReceiver, RequestInfo, SharedEventBroadcaster, Track, TrackPage,
4};
5use crate::Result;
6use async_trait::async_trait;
7use http_client::{HttpClient, Request};
8use http_types::{Method, Url};
9use serde::Deserialize;
10use std::sync::Arc;
11
12use crate::types::LastFmError;
13
14#[async_trait(?Send)]
19pub trait LastFmApiClient: Clone {
20 async fn api_get_recent_tracks_page(&self, page: u32) -> Result<TrackPage> {
25 self.api_get_recent_tracks_page_in_range(page, None, None)
26 .await
27 }
28
29 async fn api_get_recent_tracks_page_in_range(
39 &self,
40 page: u32,
41 from: Option<u64>,
42 to: Option<u64>,
43 ) -> Result<TrackPage>;
44}
45
46#[derive(Clone)]
47pub struct LastFmApiClientImpl {
48 client: Arc<dyn HttpClient + Send + Sync>,
49 username: String,
50 api_key: String,
51 broadcaster: Arc<SharedEventBroadcaster>,
52}
53
54impl LastFmApiClientImpl {
55 pub fn new(
56 client: Box<dyn HttpClient + Send + Sync>,
57 username: String,
58 api_key: String,
59 ) -> Self {
60 Self {
61 client: Arc::from(client),
62 username,
63 api_key,
64 broadcaster: Arc::new(SharedEventBroadcaster::new()),
65 }
66 }
67
68 pub fn subscribe(&self) -> ClientEventReceiver {
69 self.broadcaster.subscribe()
70 }
71
72 pub fn latest_event(&self) -> Option<ClientEvent> {
73 self.broadcaster.latest_event()
74 }
75
76 pub fn rate_limit_state(&self) -> crate::types::RateLimitState {
78 self.broadcaster.rate_limit_state()
79 }
80
81 pub fn watch_rate_limit_state(&self) -> crate::types::RateLimitStateWatcher {
83 self.broadcaster.watch_rate_limit_state()
84 }
85
86 pub fn username(&self) -> &str {
87 &self.username
88 }
89
90 pub fn recent_tracks(&self) -> Box<dyn AsyncPaginatedIterator<Track>> {
91 Box::new(ApiRecentTracksIterator::new(self.clone()))
92 }
93
94 pub fn recent_tracks_from_page(
95 &self,
96 starting_page: u32,
97 ) -> Box<dyn AsyncPaginatedIterator<Track>> {
98 Box::new(ApiRecentTracksIterator::with_starting_page(
99 self.clone(),
100 starting_page,
101 ))
102 }
103
104 pub fn recent_tracks_in_range(
111 &self,
112 from: Option<u64>,
113 to: Option<u64>,
114 ) -> Box<dyn AsyncPaginatedIterator<Track>> {
115 Box::new(ApiRecentTracksIterator::with_range(self.clone(), from, to))
116 }
117}
118
119pub(crate) fn build_recent_tracks_url(
121 username: &str,
122 api_key: &str,
123 page: u32,
124 from: Option<u64>,
125 to: Option<u64>,
126) -> String {
127 let mut url = format!(
128 "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user={}&api_key={}&format=json&page={}&limit=200",
129 urlencoding::encode(username),
130 urlencoding::encode(api_key),
131 page
132 );
133
134 if let Some(from) = from {
135 url.push_str(&format!("&from={from}"));
136 }
137 if let Some(to) = to {
138 url.push_str(&format!("&to={to}"));
139 }
140
141 url
142}
143
144pub(crate) async fn fetch_recent_tracks_page(
151 client: &Arc<dyn HttpClient + Send + Sync>,
152 broadcaster: &SharedEventBroadcaster,
153 username: &str,
154 api_key: &str,
155 page: u32,
156 from: Option<u64>,
157 to: Option<u64>,
158) -> Result<TrackPage> {
159 let url = build_recent_tracks_url(username, api_key, page, from, to);
160
161 let request_info = RequestInfo::from_url_and_method(&url, "GET");
162 let request_start = std::time::Instant::now();
163
164 broadcaster.broadcast_event(ClientEvent::RequestStarted {
165 request: request_info.clone(),
166 });
167
168 let request = Request::new(Method::Get, url.parse::<Url>().unwrap());
169 let mut response = client
170 .send(request)
171 .await
172 .map_err(|e| LastFmError::Http(e.to_string()))?;
173
174 broadcaster.broadcast_event(ClientEvent::RequestCompleted {
175 request: request_info,
176 status_code: response.status().into(),
177 duration_ms: request_start.elapsed().as_millis() as u64,
178 });
179
180 let body = response
181 .body_string()
182 .await
183 .map_err(|e| LastFmError::Http(e.to_string()))?;
184
185 parse_api_recent_tracks_response(&body)
186}
187
188#[async_trait(?Send)]
189impl LastFmApiClient for LastFmApiClientImpl {
190 async fn api_get_recent_tracks_page_in_range(
191 &self,
192 page: u32,
193 from: Option<u64>,
194 to: Option<u64>,
195 ) -> Result<TrackPage> {
196 fetch_recent_tracks_page(
197 &self.client,
198 &self.broadcaster,
199 &self.username,
200 &self.api_key,
201 page,
202 from,
203 to,
204 )
205 .await
206 }
207}
208
209#[derive(Deserialize)]
210pub struct ApiRecentTracksResponse {
211 pub recenttracks: ApiRecentTracks,
212}
213
214#[derive(Deserialize)]
215pub struct ApiRecentTracks {
216 #[serde(default, deserialize_with = "deserialize_one_or_many")]
219 pub track: Vec<ApiTrack>,
220 #[serde(rename = "@attr")]
221 pub attr: ApiPaginationAttr,
222}
223
224fn deserialize_one_or_many<'de, D>(deserializer: D) -> std::result::Result<Vec<ApiTrack>, D::Error>
225where
226 D: serde::Deserializer<'de>,
227{
228 #[derive(Deserialize)]
229 #[serde(untagged)]
230 enum OneOrMany {
231 Many(Vec<ApiTrack>),
232 One(Box<ApiTrack>),
233 }
234 Ok(match Option::<OneOrMany>::deserialize(deserializer)? {
235 None => Vec::new(),
236 Some(OneOrMany::Many(tracks)) => tracks,
237 Some(OneOrMany::One(track)) => vec![*track],
238 })
239}
240
241#[derive(Deserialize)]
243struct ApiErrorResponse {
244 error: i64,
245 message: String,
246}
247
248#[derive(Deserialize)]
249pub struct ApiTrack {
250 pub name: String,
251 pub artist: ApiTextField,
252 pub album: ApiTextField,
253 pub date: Option<ApiDate>,
254 #[serde(rename = "@attr")]
255 pub attr: Option<ApiTrackAttr>,
256}
257
258#[derive(Deserialize)]
259pub struct ApiTextField {
260 #[serde(rename = "#text")]
261 pub text: String,
262}
263
264#[derive(Deserialize)]
265pub struct ApiDate {
266 pub uts: String,
267}
268
269#[derive(Deserialize)]
270pub struct ApiTrackAttr {
271 pub nowplaying: Option<String>,
272}
273
274#[derive(Deserialize)]
275pub struct ApiPaginationAttr {
276 pub page: String,
277 #[serde(rename = "totalPages")]
278 pub total_pages: String,
279}
280
281pub fn parse_api_recent_tracks_response(json: &str) -> Result<TrackPage> {
282 let response: ApiRecentTracksResponse = serde_json::from_str(json).map_err(|e| {
283 if let Ok(api_error) = serde_json::from_str::<ApiErrorResponse>(json) {
285 crate::types::LastFmError::Http(format!(
286 "last.fm API error {}: {}",
287 api_error.error, api_error.message
288 ))
289 } else {
290 crate::types::LastFmError::Parse(e.to_string())
291 }
292 })?;
293
294 let current_page: u32 = response.recenttracks.attr.page.parse().unwrap_or(1);
295 let total_pages: u32 = response.recenttracks.attr.total_pages.parse().unwrap_or(1);
296
297 let tracks: Vec<Track> = response
298 .recenttracks
299 .track
300 .into_iter()
301 .filter(|t| {
302 if let Some(ref attr) = t.attr {
304 if attr.nowplaying.as_deref() == Some("true") {
305 return false;
306 }
307 }
308 true
309 })
310 .filter_map(|t| {
311 let timestamp: u64 = t.date.as_ref()?.uts.parse().ok()?;
312 Some(Track {
313 name: t.name,
314 artist: t.artist.text,
315 playcount: 1,
316 timestamp: Some(timestamp),
317 album: Some(t.album.text),
318 album_artist: None,
322 })
323 })
324 .collect();
325
326 let has_next_page = current_page < total_pages;
327
328 Ok(TrackPage {
329 tracks,
330 page_number: current_page,
331 has_next_page,
332 total_pages: Some(total_pages),
333 })
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn test_parse_api_recent_tracks() {
342 let json = r##"{
343 "recenttracks": {
344 "track": [
345 {
346 "name": "Test Track",
347 "artist": {"#text": "Test Artist"},
348 "album": {"#text": "Test Album"},
349 "date": {"uts": "1700000000"}
350 },
351 {
352 "name": "Now Playing",
353 "artist": {"#text": "Some Artist"},
354 "album": {"#text": "Some Album"},
355 "@attr": {"nowplaying": "true"}
356 }
357 ],
358 "@attr": {
359 "page": "1",
360 "totalPages": "5"
361 }
362 }
363 }"##;
364
365 let page = parse_api_recent_tracks_response(json).unwrap();
366 assert_eq!(page.tracks.len(), 1);
367 assert_eq!(page.tracks[0].name, "Test Track");
368 assert_eq!(page.tracks[0].artist, "Test Artist");
369 assert_eq!(page.tracks[0].album.as_deref(), Some("Test Album"));
370 assert_eq!(page.tracks[0].album_artist, None);
372 assert_eq!(page.tracks[0].timestamp, Some(1700000000));
373 assert_eq!(page.tracks[0].playcount, 1);
374 assert_eq!(page.page_number, 1);
375 assert!(page.has_next_page);
376 assert_eq!(page.total_pages, Some(5));
377 }
378
379 #[test]
380 fn test_parse_single_track_as_object() {
381 let json = r##"{
383 "recenttracks": {
384 "track": {
385 "name": "Solo",
386 "artist": {"#text": "Artist"},
387 "album": {"#text": "Album"},
388 "date": {"uts": "1700000000"}
389 },
390 "@attr": {"page": "1", "totalPages": "1"}
391 }
392 }"##;
393 let page = parse_api_recent_tracks_response(json).unwrap();
394 assert_eq!(page.tracks.len(), 1);
395 assert_eq!(page.tracks[0].name, "Solo");
396 }
397
398 #[test]
399 fn test_parse_empty_page_without_track_field() {
400 let json = r##"{
401 "recenttracks": {
402 "@attr": {"page": "1", "totalPages": "0"}
403 }
404 }"##;
405 let page = parse_api_recent_tracks_response(json).unwrap();
406 assert!(page.tracks.is_empty());
407 }
408
409 #[test]
410 fn test_api_error_body_is_surfaced() {
411 let json = r##"{"error":6,"message":"User not found"}"##;
412 let err = parse_api_recent_tracks_response(json).unwrap_err();
413 assert!(err.to_string().contains("User not found"), "{err}");
414 }
415
416 #[test]
417 fn test_parse_api_last_page() {
418 let json = r##"{
419 "recenttracks": {
420 "track": [
421 {
422 "name": "Track",
423 "artist": {"#text": "Artist"},
424 "album": {"#text": "Album"},
425 "date": {"uts": "1700000000"}
426 }
427 ],
428 "@attr": {
429 "page": "3",
430 "totalPages": "3"
431 }
432 }
433 }"##;
434
435 let page = parse_api_recent_tracks_response(json).unwrap();
436 assert!(!page.has_next_page);
437 assert_eq!(page.page_number, 3);
438 }
439
440 #[test]
441 fn test_build_recent_tracks_url_without_range() {
442 let url = build_recent_tracks_url("someuser", "apikey123", 2, None, None);
443 assert_eq!(
444 url,
445 "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=someuser&api_key=apikey123&format=json&page=2&limit=200"
446 );
447 assert!(!url.contains("&from="));
448 assert!(!url.contains("&to="));
449 }
450
451 #[test]
452 fn test_build_recent_tracks_url_with_from_and_to() {
453 let url = build_recent_tracks_url(
454 "someuser",
455 "apikey123",
456 1,
457 Some(1700000000),
458 Some(1700086400),
459 );
460 assert_eq!(
461 url,
462 "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=someuser&api_key=apikey123&format=json&page=1&limit=200&from=1700000000&to=1700086400"
463 );
464 }
465
466 #[test]
467 fn test_build_recent_tracks_url_with_only_from() {
468 let url = build_recent_tracks_url("someuser", "apikey123", 1, Some(1700000000), None);
469 assert!(url.ends_with("&limit=200&from=1700000000"));
470 assert!(!url.contains("&to="));
471 }
472
473 #[test]
474 fn test_build_recent_tracks_url_with_only_to() {
475 let url = build_recent_tracks_url("someuser", "apikey123", 1, None, Some(1700086400));
476 assert!(url.ends_with("&limit=200&to=1700086400"));
477 assert!(!url.contains("&from="));
478 }
479
480 #[test]
481 fn test_build_recent_tracks_url_encodes_username() {
482 let url = build_recent_tracks_url("some user&x", "key", 1, None, None);
483 assert!(url.contains("user=some%20user%26x"));
484 }
485}