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>;
21}
22
23#[derive(Clone)]
24pub struct LastFmApiClientImpl {
25 client: Arc<dyn HttpClient + Send + Sync>,
26 username: String,
27 api_key: String,
28 broadcaster: Arc<SharedEventBroadcaster>,
29}
30
31impl LastFmApiClientImpl {
32 pub fn new(
33 client: Box<dyn HttpClient + Send + Sync>,
34 username: String,
35 api_key: String,
36 ) -> Self {
37 Self {
38 client: Arc::from(client),
39 username,
40 api_key,
41 broadcaster: Arc::new(SharedEventBroadcaster::new()),
42 }
43 }
44
45 pub fn subscribe(&self) -> ClientEventReceiver {
46 self.broadcaster.subscribe()
47 }
48
49 pub fn latest_event(&self) -> Option<ClientEvent> {
50 self.broadcaster.latest_event()
51 }
52
53 pub fn username(&self) -> &str {
54 &self.username
55 }
56
57 pub fn recent_tracks(&self) -> Box<dyn AsyncPaginatedIterator<Track>> {
58 Box::new(ApiRecentTracksIterator::new(self.clone()))
59 }
60
61 pub fn recent_tracks_from_page(
62 &self,
63 starting_page: u32,
64 ) -> Box<dyn AsyncPaginatedIterator<Track>> {
65 Box::new(ApiRecentTracksIterator::with_starting_page(
66 self.clone(),
67 starting_page,
68 ))
69 }
70}
71
72#[async_trait(?Send)]
73impl LastFmApiClient for LastFmApiClientImpl {
74 async fn api_get_recent_tracks_page(&self, page: u32) -> Result<TrackPage> {
75 let url = format!(
76 "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user={}&api_key={}&format=json&page={}&limit=200",
77 urlencoding::encode(&self.username),
78 urlencoding::encode(&self.api_key),
79 page
80 );
81
82 let request_info = RequestInfo::from_url_and_method(&url, "GET");
83 let request_start = std::time::Instant::now();
84
85 self.broadcaster
86 .broadcast_event(ClientEvent::RequestStarted {
87 request: request_info.clone(),
88 });
89
90 let request = Request::new(Method::Get, url.parse::<Url>().unwrap());
91 let mut response = self
92 .client
93 .send(request)
94 .await
95 .map_err(|e| LastFmError::Http(e.to_string()))?;
96
97 self.broadcaster
98 .broadcast_event(ClientEvent::RequestCompleted {
99 request: request_info,
100 status_code: response.status().into(),
101 duration_ms: request_start.elapsed().as_millis() as u64,
102 });
103
104 let body = response
105 .body_string()
106 .await
107 .map_err(|e| LastFmError::Http(e.to_string()))?;
108
109 parse_api_recent_tracks_response(&body)
110 }
111}
112
113#[derive(Deserialize)]
114pub struct ApiRecentTracksResponse {
115 pub recenttracks: ApiRecentTracks,
116}
117
118#[derive(Deserialize)]
119pub struct ApiRecentTracks {
120 pub track: Vec<ApiTrack>,
121 #[serde(rename = "@attr")]
122 pub attr: ApiPaginationAttr,
123}
124
125#[derive(Deserialize)]
126pub struct ApiTrack {
127 pub name: String,
128 pub artist: ApiTextField,
129 pub album: ApiTextField,
130 pub date: Option<ApiDate>,
131 #[serde(rename = "@attr")]
132 pub attr: Option<ApiTrackAttr>,
133}
134
135#[derive(Deserialize)]
136pub struct ApiTextField {
137 #[serde(rename = "#text")]
138 pub text: String,
139}
140
141#[derive(Deserialize)]
142pub struct ApiDate {
143 pub uts: String,
144}
145
146#[derive(Deserialize)]
147pub struct ApiTrackAttr {
148 pub nowplaying: Option<String>,
149}
150
151#[derive(Deserialize)]
152pub struct ApiPaginationAttr {
153 pub page: String,
154 #[serde(rename = "totalPages")]
155 pub total_pages: String,
156}
157
158pub fn parse_api_recent_tracks_response(json: &str) -> Result<TrackPage> {
159 let response: ApiRecentTracksResponse =
160 serde_json::from_str(json).map_err(|e| crate::types::LastFmError::Parse(e.to_string()))?;
161
162 let current_page: u32 = response.recenttracks.attr.page.parse().unwrap_or(1);
163 let total_pages: u32 = response.recenttracks.attr.total_pages.parse().unwrap_or(1);
164
165 let tracks: Vec<Track> = response
166 .recenttracks
167 .track
168 .into_iter()
169 .filter(|t| {
170 if let Some(ref attr) = t.attr {
172 if attr.nowplaying.as_deref() == Some("true") {
173 return false;
174 }
175 }
176 true
177 })
178 .filter_map(|t| {
179 let timestamp: u64 = t.date.as_ref()?.uts.parse().ok()?;
180 let artist = t.artist.text.clone();
181 Some(Track {
182 name: t.name,
183 artist: artist.clone(),
184 playcount: 1,
185 timestamp: Some(timestamp),
186 album: Some(t.album.text),
187 album_artist: Some(artist),
188 })
189 })
190 .collect();
191
192 let has_next_page = current_page < total_pages;
193
194 Ok(TrackPage {
195 tracks,
196 page_number: current_page,
197 has_next_page,
198 total_pages: Some(total_pages),
199 })
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn test_parse_api_recent_tracks() {
208 let json = r##"{
209 "recenttracks": {
210 "track": [
211 {
212 "name": "Test Track",
213 "artist": {"#text": "Test Artist"},
214 "album": {"#text": "Test Album"},
215 "date": {"uts": "1700000000"}
216 },
217 {
218 "name": "Now Playing",
219 "artist": {"#text": "Some Artist"},
220 "album": {"#text": "Some Album"},
221 "@attr": {"nowplaying": "true"}
222 }
223 ],
224 "@attr": {
225 "page": "1",
226 "totalPages": "5"
227 }
228 }
229 }"##;
230
231 let page = parse_api_recent_tracks_response(json).unwrap();
232 assert_eq!(page.tracks.len(), 1);
233 assert_eq!(page.tracks[0].name, "Test Track");
234 assert_eq!(page.tracks[0].artist, "Test Artist");
235 assert_eq!(page.tracks[0].album.as_deref(), Some("Test Album"));
236 assert_eq!(page.tracks[0].timestamp, Some(1700000000));
237 assert_eq!(page.tracks[0].playcount, 1);
238 assert_eq!(page.page_number, 1);
239 assert!(page.has_next_page);
240 assert_eq!(page.total_pages, Some(5));
241 }
242
243 #[test]
244 fn test_parse_api_last_page() {
245 let json = r##"{
246 "recenttracks": {
247 "track": [
248 {
249 "name": "Track",
250 "artist": {"#text": "Artist"},
251 "album": {"#text": "Album"},
252 "date": {"uts": "1700000000"}
253 }
254 ],
255 "@attr": {
256 "page": "3",
257 "totalPages": "3"
258 }
259 }
260 }"##;
261
262 let page = parse_api_recent_tracks_response(json).unwrap();
263 assert!(!page.has_next_page);
264 assert_eq!(page.page_number, 3);
265 }
266}