Skip to main content

lastfm_client/client/
lastfm.rs

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