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    /// Fetch a single page of the user's recent tracks via the JSON API.
21    ///
22    /// Equivalent to [`api_get_recent_tracks_page_in_range`](Self::api_get_recent_tracks_page_in_range)
23    /// with no time window.
24    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    /// Fetch a single page of the user's recent tracks restricted to a unix-timestamp window.
30    ///
31    /// `from` and `to` are passed through to the `user.getRecentTracks` API endpoint's
32    /// optional `from`/`to` query parameters. Observed live (see the
33    /// `api_recent_tracks_in_range` VCR test): `from` is **inclusive** and `to` is
34    /// **exclusive** — a native half-open `[from, to)` window, despite the API docs'
35    /// "strictly after"/"strictly before" wording. Callers that must be robust to a
36    /// server-side behavior change can widen `from` by one second and dedupe by
37    /// timestamp.
38    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    /// Get the current rate-limit state snapshot.
77    pub fn rate_limit_state(&self) -> crate::types::RateLimitState {
78        self.broadcaster.rate_limit_state()
79    }
80
81    /// Get a watch receiver tracking rate-limit state transitions.
82    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    /// Iterate over recent tracks restricted to a unix-timestamp window.
105    ///
106    /// `from` and `to` are forwarded to the `user.getRecentTracks` endpoint's optional
107    /// query parameters. Observed live (see the `api_recent_tracks_in_range` VCR test):
108    /// `from` is **inclusive** and `to` is **exclusive** — a native half-open
109    /// `[from, to)` window.
110    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
119/// Build the `user.getRecentTracks` request URL, appending `from`/`to` only when present.
120pub(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
144/// Shared implementation of a single `user.getRecentTracks` page fetch.
145///
146/// Builds the request URL (including the optional `from`/`to` unix-timestamp window),
147/// broadcasts `RequestStarted`/`RequestCompleted` events, and parses the JSON response.
148/// Used by both [`LastFmApiClientImpl`] and `LastFmEditClientImpl` so the request logic
149/// exists in exactly one place.
150pub(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    /// The API serializes a single-track page as a bare object rather than a one-element
217    /// array, and omits the field entirely for empty pages — accept all three shapes.
218    #[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/// Error body shape returned by the API (e.g. `{"error":6,"message":"User not found"}`).
242#[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        // Prefer surfacing the API's own error message when the body is an error payload.
284        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            // Skip "now playing" tracks (they have no timestamp)
303            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                // The recent-tracks API response carries no album-artist field; report that
319                // honestly instead of guessing. Scraped edit-form values are the authoritative
320                // way to obtain it.
321                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        // The API provides no album-artist information; it must not be fabricated.
371        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        // The API returns a bare object (not a one-element array) for single-track pages.
382        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}