Skip to main content

lastfm_client/client/
lastfm.rs

1use crate::api::{
2    LovedTracksClient, RecentTracksClient, TopAlbumsClient, TopArtistsClient, TopTracksClient,
3};
4use crate::client::{
5    HttpClient, RateLimitedClient, RateLimiter, ReqwestClient, RetryClient, RetryPolicy,
6};
7use crate::config::{Config, ConfigBuilder};
8use crate::error::Result;
9use std::sync::Arc;
10
11/// Main Last.fm API client
12///
13/// This is the entry point for interacting with the Last.fm API using the new v2.0 API.
14///
15/// # Example
16/// ```
17/// use lastfm_client::LastFmClient;
18/// use std::time::Duration;
19///
20/// // Create client with custom configuration
21/// let client = LastFmClient::builder()
22///     .api_key("your_api_key")
23///     .timeout(Duration::from_secs(60))
24///     .max_concurrent_requests(10)
25///     .build()
26///     .unwrap();
27///
28/// // Use client.recent_tracks() to fetch data
29/// ```
30pub struct LastFmClient {
31    http: Arc<dyn HttpClient>,
32    config: Arc<Config>,
33    recent_tracks_client: RecentTracksClient,
34    loved_tracks_client: LovedTracksClient,
35    top_tracks_client: TopTracksClient,
36    top_artists_client: TopArtistsClient,
37    top_albums_client: TopAlbumsClient,
38}
39
40impl std::fmt::Debug for LastFmClient {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("LastFmClient")
43            .field("config", &self.config)
44            .finish_non_exhaustive()
45    }
46}
47
48impl LastFmClient {
49    /// Create a new configuration builder
50    ///
51    /// This is the recommended way to create a `LastFmClient`.
52    ///
53    /// # Example
54    /// ```no_run
55    /// use lastfm_client::LastFmClient;
56    ///
57    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
58    /// let client = LastFmClient::builder()
59    ///     .api_key("your_api_key")
60    ///     .build()?;
61    /// # Ok(())
62    /// # }
63    /// ```
64    #[must_use]
65    pub fn builder() -> ConfigBuilder {
66        ConfigBuilder::new()
67    }
68
69    /// Create a new `LastFmClient` with default configuration
70    ///
71    /// This will automatically try to load the API key from the `LAST_FM_API_KEY`
72    /// environment variable. All other settings use sensible defaults.
73    ///
74    /// # Example
75    /// ```no_run
76    /// use lastfm_client::LastFmClient;
77    ///
78    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
79    /// let client = LastFmClient::new()?;
80    /// # Ok(())
81    /// # }
82    /// ```
83    ///
84    /// # Errors
85    /// Returns an error if the API key is not set and cannot be loaded from environment
86    pub fn new() -> Result<Self> {
87        let config = ConfigBuilder::build_with_defaults()?;
88        Ok(Self::from_config(config))
89    }
90
91    /// Create a new `LastFmClient` from a configuration
92    ///
93    /// This automatically sets up retry logic and rate limiting based on the configuration.
94    /// Most users should use `builder()` instead.
95    #[must_use]
96    pub fn from_config(config: Config) -> Self {
97        // Create base HTTP client
98        let base_client = ReqwestClient::new();
99
100        // Build the HTTP client with retry and rate limiting
101        let retry_policy = RetryPolicy::exponential(config.retry_attempts());
102        let http: Arc<dyn HttpClient> = if let Some(rate_limit_config) = config.rate_limit() {
103            let retry_client = RetryClient::new(base_client, retry_policy);
104
105            let limiter = Arc::new(RateLimiter::new(
106                rate_limit_config.max_requests,
107                rate_limit_config.per_duration,
108            ));
109            Arc::new(RateLimitedClient::new(retry_client, limiter))
110        } else {
111            Arc::new(RetryClient::new(base_client, retry_policy))
112        };
113
114        let config = Arc::new(config);
115        let recent_tracks_client = RecentTracksClient::new(http.clone(), config.clone());
116        let loved_tracks_client = LovedTracksClient::new(http.clone(), config.clone());
117        let top_tracks_client = TopTracksClient::new(http.clone(), config.clone());
118        let top_artists_client = TopArtistsClient::new(http.clone(), config.clone());
119        let top_albums_client = TopAlbumsClient::new(http.clone(), config.clone());
120
121        Self {
122            http,
123            config,
124            recent_tracks_client,
125            loved_tracks_client,
126            top_tracks_client,
127            top_artists_client,
128            top_albums_client,
129        }
130    }
131
132    /// Create a new `LastFmClient` with a custom HTTP client
133    ///
134    /// This is primarily useful for testing with a mock HTTP client.
135    ///
136    /// # Example
137    /// ```
138    /// use lastfm_client::{LastFmClient, Config, ConfigBuilder};
139    /// use lastfm_client::client::MockClient;
140    /// use std::sync::Arc;
141    ///
142    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
143    /// let config = ConfigBuilder::new()
144    ///     .api_key("test_key")
145    ///     .build()?;
146    ///
147    /// let mock = MockClient::new();
148    /// let client = LastFmClient::with_http(config, Arc::new(mock));
149    /// # Ok(())
150    /// # }
151    /// ```
152    pub fn with_http(config: Config, http: Arc<dyn HttpClient>) -> Self {
153        let config = Arc::new(config);
154        let recent_tracks_client = RecentTracksClient::new(http.clone(), config.clone());
155        let loved_tracks_client = LovedTracksClient::new(http.clone(), config.clone());
156        let top_tracks_client = TopTracksClient::new(http.clone(), config.clone());
157        let top_artists_client = TopArtistsClient::new(http.clone(), config.clone());
158        let top_albums_client = TopAlbumsClient::new(http.clone(), config.clone());
159
160        Self {
161            http,
162            config,
163            recent_tracks_client,
164            loved_tracks_client,
165            top_tracks_client,
166            top_artists_client,
167            top_albums_client,
168        }
169    }
170
171    /// Get a builder for recent tracks requests
172    ///
173    /// # Example
174    /// ```no_run
175    /// # use lastfm_client::LastFmClient;
176    /// # async fn example(client: LastFmClient) -> Result<(), Box<dyn std::error::Error>> {
177    /// let tracks = client
178    ///     .recent_tracks("username")
179    ///     .limit(100)
180    ///     .fetch()
181    ///     .await?;
182    /// # Ok(())
183    /// # }
184    /// ```
185    pub fn recent_tracks(
186        &self,
187        username: impl Into<String>,
188    ) -> crate::api::RecentTracksRequestBuilder {
189        self.recent_tracks_client.builder(username)
190    }
191
192    /// Get a builder for loved tracks requests
193    ///
194    /// # Example
195    /// ```no_run
196    /// # use lastfm_client::LastFmClient;
197    /// # async fn example(client: LastFmClient) -> Result<(), Box<dyn std::error::Error>> {
198    /// let tracks = client
199    ///     .loved_tracks("username")
200    ///     .limit(100)
201    ///     .fetch()
202    ///     .await?;
203    /// # Ok(())
204    /// # }
205    /// ```
206    pub fn loved_tracks(
207        &self,
208        username: impl Into<String>,
209    ) -> crate::api::LovedTracksRequestBuilder {
210        self.loved_tracks_client.builder(username)
211    }
212
213    /// Get a builder for top tracks requests
214    ///
215    /// # Example
216    /// ```no_run
217    /// # use lastfm_client::LastFmClient;
218    /// # async fn example(client: LastFmClient) -> Result<(), Box<dyn std::error::Error>> {
219    /// let tracks = client
220    ///     .top_tracks("username")
221    ///     .limit(100)
222    ///     .fetch()
223    ///     .await?;
224    /// # Ok(())
225    /// # }
226    /// ```
227    pub fn top_tracks(&self, username: impl Into<String>) -> crate::api::TopTracksRequestBuilder {
228        self.top_tracks_client.builder(username)
229    }
230
231    /// Get a builder for top artists requests
232    ///
233    /// # Example
234    /// ```no_run
235    /// # use lastfm_client::LastFmClient;
236    /// # async fn example(client: LastFmClient) -> Result<(), Box<dyn std::error::Error>> {
237    /// let artists = client
238    ///     .top_artists("username")
239    ///     .limit(100)
240    ///     .fetch()
241    ///     .await?;
242    /// # Ok(())
243    /// # }
244    /// ```
245    pub fn top_artists(&self, username: impl Into<String>) -> crate::api::TopArtistsRequestBuilder {
246        self.top_artists_client.builder(username)
247    }
248
249    /// Get a builder for top albums requests
250    ///
251    /// # Example
252    /// ```no_run
253    /// # use lastfm_client::LastFmClient;
254    /// # async fn example(client: LastFmClient) -> Result<(), Box<dyn std::error::Error>> {
255    /// let albums = client
256    ///     .top_albums("username")
257    ///     .limit(100)
258    ///     .fetch()
259    ///     .await?;
260    /// # Ok(())
261    /// # }
262    /// ```
263    pub fn top_albums(&self, username: impl Into<String>) -> crate::api::TopAlbumsRequestBuilder {
264        self.top_albums_client.builder(username)
265    }
266
267    /// Check if a Last.fm user exists
268    ///
269    /// # Arguments
270    /// * `username` - The Last.fm username to check
271    ///
272    /// # Returns
273    /// * `Ok(true)` - User exists
274    /// * `Ok(false)` - User does not exist
275    /// * `Err` - Network error or other API error
276    ///
277    /// # Example
278    /// ```no_run
279    /// # use lastfm_client::LastFmClient;
280    /// # async fn example(client: LastFmClient) -> Result<(), Box<dyn std::error::Error>> {
281    /// if client.user_exists("rj").await? {
282    ///     println!("User exists!");
283    /// } else {
284    ///     println!("User not found");
285    /// }
286    /// # Ok(())
287    /// # }
288    /// ```
289    ///
290    /// # Errors
291    /// Returns an error if the request fails due to network issues or other API errors
292    /// (not including "user not found" which returns `Ok(false)`)
293    pub async fn user_exists(&self, username: impl Into<String>) -> Result<bool> {
294        use crate::api::constants::BASE_URL;
295        use crate::error::LastFmError;
296        use crate::url_builder::{QueryParams, Url};
297
298        let username = username.into();
299        let mut params = QueryParams::new();
300        params.insert("method".to_string(), "user.getinfo".to_string());
301        params.insert("user".to_string(), username);
302        params.insert("api_key".to_string(), self.config.api_key().to_string());
303        params.insert("format".to_string(), "json".to_string());
304
305        let url = Url::new(BASE_URL).add_args(params).build();
306
307        match self.http.get(&url).await {
308            Ok(_) => Ok(true),
309            Err(LastFmError::Api { error_code, .. }) if error_code == 6 || error_code == 7 => {
310                // Error code 6: Invalid parameters (user not found)
311                // Error code 7: Invalid resource specified (user not found)
312                Ok(false)
313            }
314            Err(e) => Err(e),
315        }
316    }
317
318    /// Get a reference to the configuration
319    #[must_use]
320    pub fn config(&self) -> &Config {
321        &self.config
322    }
323}
324
325// Convenience: allow building the client directly from the ConfigBuilder
326impl ConfigBuilder {
327    /// Build a `LastFmClient` directly from this builder
328    ///
329    /// This is equivalent to calling `build().map(LastFmClient::from_config)`.
330    ///
331    /// # Errors
332    /// Returns an error if the builder is missing required fields (e.g., API key).
333    pub fn build_client(self) -> Result<LastFmClient> {
334        self.build().map(LastFmClient::from_config)
335    }
336}
337
338#[cfg(test)]
339#[allow(clippy::unwrap_used)]
340mod tests {
341    use super::*;
342    use crate::client::MockClient;
343
344    #[test]
345    fn test_client_from_config() {
346        let config = ConfigBuilder::new().api_key("test_key").build().unwrap();
347
348        let client = LastFmClient::from_config(config);
349        assert_eq!(client.config().api_key(), "test_key");
350    }
351
352    #[test]
353    fn test_client_with_mock() {
354        let config = ConfigBuilder::new().api_key("test_key").build().unwrap();
355
356        let mock = MockClient::new();
357        let http = Arc::new(mock);
358        let client = LastFmClient::with_http(config, http);
359        assert_eq!(client.config().api_key(), "test_key");
360    }
361
362    #[test]
363    fn test_builder() {
364        let client = LastFmClient::builder()
365            .api_key("test_key")
366            .build()
367            .map(LastFmClient::from_config)
368            .unwrap();
369
370        assert_eq!(client.config().api_key(), "test_key");
371    }
372
373    #[tokio::test]
374    async fn test_user_exists_returns_true() {
375        use serde_json::json;
376
377        let config = ConfigBuilder::new().api_key("test_key").build().unwrap();
378
379        let mock = MockClient::new().with_response(
380            "user.getinfo",
381            json!({
382                "user": {
383                    "name": "rj",
384                    "realname": "Richard Jones",
385                    "url": "https://www.last.fm/user/rj"
386                }
387            }),
388        );
389
390        let client = LastFmClient::with_http(config, Arc::new(mock));
391        let result = client.user_exists("rj").await;
392
393        assert!(result.is_ok());
394        assert!(result.unwrap());
395    }
396
397    #[tokio::test]
398    async fn test_user_exists_returns_false_for_error_6() {
399        use serde_json::json;
400
401        let config = ConfigBuilder::new().api_key("test_key").build().unwrap();
402
403        // Mock returns error code 6 (Invalid parameters / user not found)
404        let mock = MockClient::new().with_response(
405            "user.getinfo",
406            json!({
407                "error": 6,
408                "message": "User not found"
409            }),
410        );
411
412        let client = LastFmClient::with_http(config, Arc::new(mock));
413        let result = client.user_exists("nonexistentuser").await;
414
415        assert!(result.is_ok());
416        assert!(!result.unwrap());
417    }
418
419    #[tokio::test]
420    async fn test_user_exists_returns_false_for_error_7() {
421        use serde_json::json;
422
423        let config = ConfigBuilder::new().api_key("test_key").build().unwrap();
424
425        // Mock returns error code 7 (Invalid resource specified)
426        let mock = MockClient::new().with_response(
427            "user.getinfo",
428            json!({
429                "error": 7,
430                "message": "Invalid resource specified"
431            }),
432        );
433
434        let client = LastFmClient::with_http(config, Arc::new(mock));
435        let result = client.user_exists("invaliduser").await;
436
437        assert!(result.is_ok());
438        assert!(!result.unwrap());
439    }
440
441    #[tokio::test]
442    async fn test_user_exists_propagates_other_api_errors() {
443        use crate::error::LastFmError;
444        use serde_json::json;
445
446        let config = ConfigBuilder::new().api_key("test_key").build().unwrap();
447
448        // Mock returns error code 10 (Invalid API key)
449        let mock = MockClient::new().with_response(
450            "user.getinfo",
451            json!({
452                "error": 10,
453                "message": "Invalid API key"
454            }),
455        );
456
457        let client = LastFmClient::with_http(config, Arc::new(mock));
458        let result = client.user_exists("someuser").await;
459
460        assert!(result.is_err());
461        let err = result.unwrap_err();
462        assert!(matches!(err, LastFmError::Api { error_code: 10, .. }));
463    }
464}