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