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}