1
- use super :: {
1
+ //! # Client
2
+ //!
3
+ //! The main client module for the Last.fm API.
4
+ //!
5
+ //! This module contains the [`Client`] struct and its methods.
6
+ //! It also provides a [`ClientBuilder`] to create a new [`Client`].
7
+ use crate :: {
2
8
errors:: Error ,
3
- recent_tracks_page:: RecentTracksPage ,
9
+ recent_tracks_page:: { RecentTracksPage , RecentTracksResponse } ,
10
+ retry_strategy:: { JitteredBackoff , RetryStrategy } ,
4
11
track:: { NowPlayingTrack , RecordedTrack , Track } ,
5
12
} ;
6
- use crate :: { recent_tracks_page:: RecentTracksResponse , retry_delay:: RetryDelay } ;
7
13
use async_stream:: try_stream;
8
14
use std:: {
9
15
env:: { self , VarError } ,
16
+ fmt:: Debug ,
10
17
time:: Duration ,
11
18
} ;
12
19
use tokio_stream:: Stream ;
13
20
use url:: Url ;
14
21
15
- const BASE_URL : & str = "https://ws.audioscrobbler.com/2.0/" ;
22
+ /// The default base URL for the Last.fm API.
23
+ pub const DEFAULT_BASE_URL : & str = "https://ws.audioscrobbler.com/2.0/" ;
16
24
17
25
lazy_static ! {
18
26
static ref DEFAULT_CLIENT : reqwest:: Client = reqwest:: ClientBuilder :: new( )
@@ -26,71 +34,126 @@ lazy_static! {
26
34
. expect( "Cannot initialize HTTP client" ) ;
27
35
}
28
36
37
+ /// Utility function that masks the API key by replacing all but the first 3 characters with `*`.
38
+ fn mask_api_key ( api_key : & str ) -> String {
39
+ api_key
40
+ . chars ( )
41
+ . enumerate ( )
42
+ . map ( |( i, c) | match i {
43
+ 0 | 1 | 2 => c,
44
+ _ => '*' ,
45
+ } )
46
+ . collect ( )
47
+ }
48
+
49
+ /// A builder for the [`Client`] struct.
29
50
pub struct ClientBuilder {
30
51
api_key : String ,
31
52
username : String ,
32
53
reqwest_client : Option < reqwest:: Client > ,
33
54
base_url : Option < Url > ,
55
+ retry_strategy : Option < Box < dyn RetryStrategy > > ,
34
56
}
35
57
36
58
impl ClientBuilder {
59
+ /// Creates a new [`ClientBuilder`] with the given API key and username.
37
60
pub fn new < A : AsRef < str > , U : AsRef < str > > ( api_key : A , username : U ) -> Self {
38
61
Self {
39
62
api_key : api_key. as_ref ( ) . to_string ( ) ,
40
63
username : username. as_ref ( ) . to_string ( ) ,
41
64
reqwest_client : None ,
42
65
base_url : None ,
66
+ retry_strategy : None ,
43
67
}
44
68
}
45
69
70
+ /// Creates a new [`ClientBuilder`] with the given username.
71
+ /// This is a shortcut for [`ClientBuilder::try_from_env`] that panics instead of returning an error.
72
+ ///
73
+ /// # Panics
74
+ /// This methods expects the `LASTFM_API_KEY` environment variable to be set and it would panic otherwise.
46
75
pub fn from_env < U : AsRef < str > > ( username : U ) -> Self {
47
- Self :: try_from_env ( username) . unwrap ( )
76
+ Self :: try_from_env ( username) . expect ( "Cannot read LASTFM_API_KEY from environment" )
48
77
}
49
78
79
+ /// Creates a new [`ClientBuilder`] with the given username.
80
+ /// This methods expects the `LASTFM_API_KEY` environment variable to be set and it would return an error otherwise.
50
81
pub fn try_from_env < U : AsRef < str > > ( username : U ) -> Result < Self , VarError > {
51
82
let api_key = env:: var ( "LASTFM_API_KEY" ) ?;
52
83
Ok ( ClientBuilder :: new ( api_key, username) )
53
84
}
54
85
86
+ /// Sets the [`reqwest::Client`] to use for the requests.
55
87
pub fn reqwest_client ( mut self , client : reqwest:: Client ) -> Self {
56
88
self . reqwest_client = Some ( client) ;
57
89
self
58
90
}
59
91
92
+ /// Sets the base URL for the Last.fm API.
60
93
pub fn base_url ( mut self , base_url : Url ) -> Self {
61
94
self . base_url = Some ( base_url) ;
62
95
self
63
96
}
64
97
98
+ /// Sets the retry strategy to use for the requests.
99
+ ///
100
+ /// For more details on how you can create a custom retry strategy, consult the [`crate::retry_strategy::RetryStrategy`] trait.
101
+ pub fn retry_strategy ( mut self , retry_strategy : Box < dyn RetryStrategy > ) -> Self {
102
+ self . retry_strategy = Some ( retry_strategy) ;
103
+ self
104
+ }
105
+
106
+ /// Builds the [`Client`] instance.
65
107
pub fn build ( self ) -> Client {
66
108
Client {
67
109
api_key : self . api_key ,
68
110
username : self . username ,
69
111
reqwest_client : self
70
112
. reqwest_client
71
113
. unwrap_or_else ( || DEFAULT_CLIENT . clone ( ) ) ,
72
- base_url : self . base_url . unwrap_or_else ( || BASE_URL . parse ( ) . unwrap ( ) ) ,
114
+ base_url : self
115
+ . base_url
116
+ . unwrap_or_else ( || DEFAULT_BASE_URL . parse ( ) . unwrap ( ) ) ,
117
+ retry_strategy : self
118
+ . retry_strategy
119
+ . unwrap_or_else ( || Box :: from ( JitteredBackoff :: default ( ) ) ) ,
73
120
}
74
121
}
75
122
}
76
123
77
- # [ derive ( Debug , Clone ) ]
124
+ /// A client for the Last.fm API.
78
125
pub struct Client {
79
126
api_key : String ,
80
127
username : String ,
81
128
reqwest_client : reqwest:: Client ,
82
129
base_url : Url ,
130
+ retry_strategy : Box < dyn RetryStrategy > ,
83
131
}
84
132
133
+ impl Debug for Client {
134
+ fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
135
+ f. debug_struct ( "Client" )
136
+ . field ( "api_key" , & mask_api_key ( & self . api_key ) . as_str ( ) )
137
+ . field ( "username" , & self . username )
138
+ . field ( "reqwest_client" , & self . reqwest_client )
139
+ . field ( "base_url" , & self . base_url )
140
+ . finish ( )
141
+ }
142
+ }
143
+
144
+ /// Structs that can be used to get a stream of [`RecordedTrack`]s.
145
+ #[ non_exhaustive]
85
146
pub struct RecentTracksFetcher {
86
147
api_key : String ,
87
148
username : String ,
88
149
current_page : Vec < RecordedTrack > ,
89
150
from : Option < i64 > ,
90
151
to : Option < i64 > ,
152
+ /// The total number of tracks available in the stream.
91
153
pub total_tracks : u64 ,
92
154
reqwest_client : reqwest:: Client ,
93
155
base_url : Url ,
156
+ retry_strategy : Box < dyn RetryStrategy > ,
94
157
}
95
158
96
159
impl RecentTracksFetcher {
@@ -109,6 +172,7 @@ impl RecentTracksFetcher {
109
172
self . to = to;
110
173
}
111
174
175
+ /// Converts the current instance into a stream of [`RecordedTrack`]s.
112
176
pub fn into_stream ( mut self ) -> impl Stream < Item = Result < RecordedTrack , Error > > {
113
177
let recent_tracks = try_stream ! {
114
178
loop {
@@ -117,7 +181,16 @@ impl RecentTracksFetcher {
117
181
yield t;
118
182
}
119
183
None => {
120
- let next_page = get_page( & self . reqwest_client, & self . base_url, & self . api_key, & self . username, 200 , self . from, self . to) . await ?;
184
+ let next_page = get_page( GetPageOptions {
185
+ client: & self . reqwest_client,
186
+ retry_strategy: & * self . retry_strategy,
187
+ base_url: & self . base_url,
188
+ api_key: & self . api_key,
189
+ username: & self . username,
190
+ limit: 200 ,
191
+ from: self . from,
192
+ to: self . to
193
+ } ) . await ?;
121
194
if next_page. tracks. is_empty( ) {
122
195
break ;
123
196
}
@@ -131,38 +204,43 @@ impl RecentTracksFetcher {
131
204
}
132
205
}
133
206
134
- async fn get_page < A : AsRef < str > , U : AsRef < str > > (
135
- client : & reqwest:: Client ,
136
- base_url : & Url ,
137
- api_key : A ,
138
- username : U ,
207
+ /// Configuration options used for the [`Client::get_page`] function.
208
+ struct GetPageOptions < ' a > {
209
+ client : & ' a reqwest:: Client ,
210
+ retry_strategy : & ' a dyn RetryStrategy ,
211
+ base_url : & ' a Url ,
212
+ api_key : & ' a str ,
213
+ username : & ' a str ,
139
214
limit : u32 ,
140
215
from : Option < i64 > ,
141
216
to : Option < i64 > ,
142
- ) -> Result < RecentTracksPage , Error > {
217
+ }
218
+
219
+ /// Gets a page of tracks from the Last.fm API.
220
+ async fn get_page ( options : GetPageOptions < ' _ > ) -> Result < RecentTracksPage , Error > {
143
221
let mut url_query = vec ! [
144
222
( "method" , "user.getrecenttracks" . to_string( ) ) ,
145
- ( "user" , username . as_ref ( ) . to_string( ) ) ,
223
+ ( "user" , options . username . to_string( ) ) ,
146
224
( "format" , "json" . to_string( ) ) ,
147
225
( "extended" , "1" . to_string( ) ) ,
148
- ( "limit" , limit. to_string( ) ) ,
149
- ( "api_key" , api_key . as_ref ( ) . to_string( ) ) ,
226
+ ( "limit" , options . limit. to_string( ) ) ,
227
+ ( "api_key" , options . api_key . to_string( ) ) ,
150
228
] ;
151
229
152
- if let Some ( from) = from {
230
+ if let Some ( from) = options . from {
153
231
url_query. push ( ( "from" , from. to_string ( ) ) ) ;
154
232
}
155
233
156
- if let Some ( to) = to {
234
+ if let Some ( to) = options . to {
157
235
url_query. push ( ( "to" , to. to_string ( ) ) ) ;
158
236
}
159
237
160
- let url = Url :: parse_with_params ( base_url. as_str ( ) , & url_query) . unwrap ( ) ;
238
+ let url = Url :: parse_with_params ( options . base_url . as_str ( ) , & url_query) . unwrap ( ) ;
161
239
162
- let retry = RetryDelay :: default ( ) ;
163
240
let mut errors: Vec < Error > = Vec :: new ( ) ;
164
- for sleep_time in retry {
165
- let res = client. get ( & ( url) . to_string ( ) ) . send ( ) . await ;
241
+ let mut num_retries: usize = 0 ;
242
+ while let Some ( retry_delay) = options. retry_strategy . should_retry_after ( num_retries) {
243
+ let res = options. client . get ( & ( url) . to_string ( ) ) . send ( ) . await ;
166
244
match res {
167
245
Ok ( res) => {
168
246
let page: RecentTracksResponse = res. json ( ) . await ?;
@@ -175,40 +253,52 @@ async fn get_page<A: AsRef<str>, U: AsRef<str>>(
175
253
if !e. is_retriable ( ) {
176
254
return Err ( e. into ( ) ) ;
177
255
}
178
- tokio:: time:: sleep ( sleep_time ) . await ;
256
+ tokio:: time:: sleep ( retry_delay ) . await ;
179
257
}
180
258
}
181
259
}
182
260
Err ( e) => {
183
261
tracing:: error!( "Error: {}" , e) ;
184
262
errors. push ( e. into ( ) ) ;
185
- tokio:: time:: sleep ( sleep_time ) . await ;
263
+ tokio:: time:: sleep ( retry_delay ) . await ;
186
264
}
187
265
}
266
+ num_retries += 1 ;
188
267
}
189
268
190
269
Err ( Error :: TooManyRetry ( errors) )
191
270
}
192
271
193
272
impl Client {
273
+ /// Creates a new [`Client`] with the given username.
274
+ /// The API key is read from the `LASTFM_API_KEY` environment variable.
275
+ /// This method is a shortcut for [`ClientBuilder::from_env`] but, in case of failure, it will panic rather than returning an error.
276
+ ///
277
+ /// # Panics
278
+ /// If the environment variable is not set, this function will panic.
194
279
pub fn from_env < U : AsRef < str > > ( username : U ) -> Self {
195
280
ClientBuilder :: try_from_env ( username) . unwrap ( ) . build ( )
196
281
}
197
282
283
+ /// Creates a new [`Client`] with the given username.
284
+ /// The API key is read from the `LASTFM_API_KEY` environment variable.
285
+ /// If the environment variable is not set, this function will return an error.
198
286
pub fn try_from_env < U : AsRef < str > > ( username : U ) -> Result < Self , VarError > {
199
287
Ok ( ClientBuilder :: try_from_env ( username) ?. build ( ) )
200
288
}
201
289
290
+ /// Fetches the currently playing track for the user (if any)
202
291
pub async fn now_playing ( & self ) -> Result < Option < NowPlayingTrack > , Error > {
203
- let page = get_page (
204
- & self . reqwest_client ,
205
- & self . base_url ,
206
- & self . api_key ,
207
- & self . username ,
208
- 1 ,
209
- None ,
210
- None ,
211
- )
292
+ let page = get_page ( GetPageOptions {
293
+ client : & self . reqwest_client ,
294
+ retry_strategy : & * self . retry_strategy ,
295
+ base_url : & self . base_url ,
296
+ api_key : & self . api_key ,
297
+ username : & self . username ,
298
+ limit : 1 ,
299
+ from : None ,
300
+ to : None ,
301
+ } )
212
302
. await ?;
213
303
214
304
match page. tracks . first ( ) {
@@ -217,24 +307,29 @@ impl Client {
217
307
}
218
308
}
219
309
310
+ /// Creates a new [`RecentTracksFetcher`] that can be used to fetch all of the user's recent tracks.
220
311
pub async fn all_tracks ( self ) -> Result < RecentTracksFetcher , Error > {
221
312
self . recent_tracks ( None , None ) . await
222
313
}
223
314
315
+ /// Creates a new [`RecentTracksFetcher`] that can be used to fetch the user's recent tracks in a given time range.
316
+ ///
317
+ /// The `from` and `to` parameters are Unix timestamps (in seconds).
224
318
pub async fn recent_tracks (
225
319
self ,
226
320
from : Option < i64 > ,
227
321
to : Option < i64 > ,
228
322
) -> Result < RecentTracksFetcher , Error > {
229
- let page = get_page (
230
- & self . reqwest_client ,
231
- & self . base_url ,
232
- & self . api_key ,
233
- & self . username ,
234
- 200 ,
323
+ let page = get_page ( GetPageOptions {
324
+ client : & self . reqwest_client ,
325
+ retry_strategy : & * self . retry_strategy ,
326
+ base_url : & self . base_url ,
327
+ api_key : & self . api_key ,
328
+ username : & self . username ,
329
+ limit : 200 ,
235
330
from,
236
331
to,
237
- )
332
+ } )
238
333
. await ?;
239
334
240
335
let mut fetcher = RecentTracksFetcher {
@@ -246,6 +341,7 @@ impl Client {
246
341
total_tracks : page. total_tracks ,
247
342
reqwest_client : self . reqwest_client ,
248
343
base_url : self . base_url ,
344
+ retry_strategy : self . retry_strategy ,
249
345
} ;
250
346
251
347
fetcher. update_current_page ( page) ;
0 commit comments