Skip to main content

lastfm_edit/
api.rs

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// =============================================================================
15// LastFmApiClient trait and implementation
16// =============================================================================
17
18#[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            // Skip "now playing" tracks (they have no timestamp)
171            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}