Skip to main content

lastfm_client/api/
fetch_utils.rs

1use crate::client::HttpClient;
2use crate::config::Config;
3use crate::error::Result;
4use crate::types::TrackLimit;
5use crate::url_builder::{QueryParams, Url};
6
7use futures::future::join_all;
8use serde::de::DeserializeOwned;
9use std::sync::Arc;
10
11use super::constants::{API_MAX_LIMIT, BASE_URL, CHUNK_MULTIPLIER, CHUNK_SIZE};
12
13/// Callback invoked with `(fetched, total)` after each batch of tracks is received.
14pub type ProgressCallback = Arc<dyn Fn(u32, u32) + Send + Sync>;
15
16/// Period options for Last.fm time range filters
17///
18/// These periods define the time range for calculating top tracks.
19#[derive(Debug, Clone, Copy)]
20#[non_exhaustive]
21pub enum Period {
22    /// All-time top tracks (no time limit)
23    Overall,
24    /// Top tracks from the last 7 days
25    Week,
26    /// Top tracks from the last month (30 days)
27    Month,
28    /// Top tracks from the last 3 months (90 days)
29    ThreeMonth,
30    /// Top tracks from the last 6 months (180 days)
31    SixMonth,
32    /// Top tracks from the last 12 months (365 days)
33    TwelveMonth,
34}
35
36impl Period {
37    /// Convert to the Last.fm API string representation
38    #[must_use]
39    pub const fn as_api_str(self) -> &'static str {
40        match self {
41            Self::Overall => "overall",
42            Self::Week => "7day",
43            Self::Month => "1month",
44            Self::ThreeMonth => "3month",
45            Self::SixMonth => "6month",
46            Self::TwelveMonth => "12month",
47        }
48    }
49}
50
51/// Trait for containers that hold Last.fm resources (tracks, artists, etc.)
52pub trait ResourceContainer {
53    /// The type of items contained in this resource
54    type ItemType;
55
56    /// Get the total number of items available
57    fn total(&self) -> u32;
58    /// Extract the items from this container
59    fn items(self) -> Vec<Self::ItemType>;
60}
61
62/// Generic function to fetch things with pagination
63pub(super) async fn fetch<T, R>(
64    http: Arc<dyn HttpClient>,
65    config: Arc<Config>,
66    username: String,
67    method: &str,
68    limit: TrackLimit,
69    additional_params: QueryParams,
70    on_progress: Option<&ProgressCallback>,
71) -> Result<Vec<T>>
72where
73    R: DeserializeOwned + ResourceContainer<ItemType = T>,
74{
75    let mut base_params = QueryParams::new();
76    base_params.insert("api_key".to_string(), config.api_key().to_string());
77    base_params.insert("method".to_string(), method.to_string());
78    base_params.insert("user".to_string(), username);
79    base_params.insert("format".to_string(), "json".to_string());
80    base_params.extend(additional_params);
81
82    // Make an initial request to get the total number of tracks
83    let mut initial_params = base_params.clone();
84    initial_params.insert("limit".to_string(), "1".to_string());
85    initial_params.insert("page".to_string(), "1".to_string());
86
87    let initial_response: R = fetch_json(&http, &initial_params).await?;
88    let total_tracks = initial_response.total();
89
90    let final_limit = match limit {
91        TrackLimit::Limited(l) => l.min(total_tracks),
92        TrackLimit::Unlimited => total_tracks,
93    };
94
95    if final_limit == 0 {
96        return Ok(Vec::new());
97    }
98
99    // Report initial state (total known, nothing fetched yet)
100    if let Some(cb) = on_progress {
101        cb(0, final_limit);
102    }
103
104    if final_limit <= API_MAX_LIMIT {
105        // If we need less than the API limit, just make a single request
106        let mut single_params = base_params;
107        single_params.insert("limit".to_string(), final_limit.to_string());
108        single_params.insert("page".to_string(), "1".to_string());
109
110        let response: R = fetch_json(&http, &single_params).await?;
111        let items: Vec<T> = response
112            .items()
113            .into_iter()
114            .take(final_limit as usize)
115            .collect();
116
117        if let Some(cb) = on_progress {
118            #[allow(clippy::cast_possible_truncation)]
119            cb(items.len() as u32, final_limit);
120        }
121
122        return Ok(items);
123    }
124
125    // Handle pagination with chunking
126    let chunk_nb = final_limit.div_ceil(CHUNK_SIZE);
127    let mut all_tracks = Vec::new();
128
129    // Process chunks sequentially
130    for chunk_index in 0..chunk_nb {
131        let chunk_params = base_params.clone();
132
133        // Calculate how many API calls we need for this chunk
134        let chunk_api_calls = if chunk_index == chunk_nb - 1 {
135            // Last chunk
136            (final_limit % CHUNK_SIZE).div_ceil(API_MAX_LIMIT).max(1)
137        } else {
138            CHUNK_MULTIPLIER
139        };
140
141        // Create futures for concurrent API calls within this chunk
142        let api_call_futures: Vec<_> = (0..chunk_api_calls)
143            .map(|call_index| {
144                let mut call_params = chunk_params.clone();
145                let call_limit =
146                    (final_limit - chunk_index * CHUNK_SIZE - call_index * API_MAX_LIMIT)
147                        .min(API_MAX_LIMIT);
148
149                let page = chunk_index * CHUNK_MULTIPLIER + call_index + 1;
150
151                call_params.insert("limit".to_string(), call_limit.to_string());
152                call_params.insert("page".to_string(), page.to_string());
153
154                let http = http.clone();
155                async move {
156                    let response: R = fetch_json(&http, &call_params).await?;
157                    Ok::<Vec<T>, crate::error::LastFmError>(
158                        response
159                            .items()
160                            .into_iter()
161                            .take(call_limit as usize)
162                            .collect(),
163                    )
164                }
165            })
166            .collect();
167
168        // Process all API calls in this chunk concurrently
169        let chunk_results = join_all(api_call_futures).await;
170
171        // Collect results from this chunk
172        for result in chunk_results {
173            all_tracks.extend(result?);
174        }
175
176        if let Some(cb) = on_progress {
177            #[allow(clippy::cast_possible_truncation)]
178            cb(all_tracks.len() as u32, final_limit);
179        }
180    }
181
182    Ok(all_tracks)
183}
184
185async fn fetch_json<T: DeserializeOwned>(
186    http: &Arc<dyn HttpClient>,
187    params: &QueryParams,
188) -> Result<T> {
189    let url = Url::new(BASE_URL).add_args(params.clone()).build();
190    let response = http.get(&url).await?;
191
192    match serde_json::from_value::<T>(response) {
193        Ok(parsed) => Ok(parsed),
194        Err(err) => {
195            #[cfg(debug_assertions)]
196            eprintln!("Deserialization failed: {err}\nURL: {url}");
197            Err(err.into())
198        }
199    }
200}