rustfm_scrobble/scrobbler.rs
1use crate::client::LastFm;
2use crate::error::ScrobblerError;
3use crate::models::metadata::{Scrobble, ScrobbleBatch};
4use crate::models::responses::{
5 BatchScrobbleResponse, NowPlayingResponse, ScrobbleResponse, SessionResponse,
6};
7
8use std::collections::HashMap;
9use std::result;
10use std::time::UNIX_EPOCH;
11
12type Result<T> = result::Result<T, ScrobblerError>;
13
14/// A Last.fm Scrobbler client. Submits song play information to Last.fm.
15///
16/// This is a client for the Scrobble and Now Playing endpoints on the Last.fm API. It handles API client and user
17/// auth, as well as providing Scrobble and Now Playing methods, plus support for sending batches of songs to Last.fm.
18///
19/// See the [official scrobbling API documentation](https://www.last.fm/api/scrobbling) for more information.
20///
21/// High-level example usage:
22/// ```ignore
23/// let username = "last-fm-username";
24/// let password = "last-fm-password";
25/// let api_key = "client-api-key";
26/// let api_secret = "client-api-secret";
27///
28/// let mut scrobbler = Scrobbler.new(api_key, api_secret);
29/// scrobbler.authenticate_with_password(username, password);
30///
31/// let song = Scrobble::new("Example Artist", "Example Song", "Example Album");
32/// scrobbler.scrobble(song);
33/// ```
34pub struct Scrobbler {
35 client: LastFm,
36}
37
38impl Scrobbler {
39
40 /// Creates a new Scrobbler instance with the given Last.fm API Key and API Secret
41 ///
42 /// # Usage
43 /// ```ignore
44 /// let api_secret = "xxx";
45 /// let api_key = "123abc";
46 /// let mut scrobbler = Scrobbler::new(api_key, api_secret);
47 /// ...
48 /// // Authenticate user with one of the available auth methods
49 /// ```
50 ///
51 /// # API Credentials
52 /// All clients require the base API credentials: An API key and an API secret. These are obtained from Last.fm,
53 /// and are specific to each *client*. These are credentials are totally separate from user authentication.
54 ///
55 /// More information on authentication and API clients can be found in the Last.fm API documentation:
56 ///
57 /// [API Authentication documentation](https://www.last.fm/api/authentication)
58 ///
59 /// [API Account Registration form](https://www.last.fm/api/account/create)
60 pub fn new(api_key: &str, api_secret: &str) -> Self {
61 let client = LastFm::new(api_key, api_secret);
62
63 Self { client }
64 }
65
66 /// Authenticates a Last.fm user with the given username and password.
67 ///
68 /// This authentication path is known as the 'Mobile auth flow', but is valid for any platform. This is often the
69 /// simplest method of authenticating a user with the API, requiring just username & password. Other Last.fm auth
70 /// flows are available and might be better suited to your application, check the official Last.fm API docs for
71 /// further information.
72 ///
73 /// # Usage
74 /// ```ignore
75 /// let mut scrobbler = Scrobbler::new(...)
76 /// let username = "last-fm-user";
77 /// let password = "hunter2";
78 /// let response = scrobbler.authenticate_with_password(username, password);
79 /// ...
80 /// ```
81 ///
82 /// # Last.fm API Documentation
83 /// [Last.fm Mobile Auth Flow Documentation](https://www.last.fm/api/mobileauth)
84 pub fn authenticate_with_password(
85 &mut self,
86 username: &str,
87 password: &str,
88 ) -> Result<SessionResponse> {
89 self.client.set_user_credentials(username, password);
90 Ok(self.client.authenticate_with_password()?)
91 }
92
93 /// Authenticates a Last.fm user with an authentication token. This method supports both the 'Web' and 'Desktop'
94 /// Last.fm auth flows (check the API documentation to ensure you are using the correct authentication method for
95 /// your needs).
96 ///
97 /// # Usage
98 /// ```ignore
99 /// let mut scrobbler = Scrobbler.new(...);
100 /// let auth_token = "token-from-last-fm";
101 /// let response = scrobbler.authenticate_with_token(auth_token);
102 /// ```
103 ///
104 /// # Last.fm API Documentation
105 /// [Last.fm Web Auth Flow Documentation](https://www.last.fm/api/webauth)
106 ///
107 /// [Last.fm Desktop Auth Flow Documentation](https://www.last.fm/api/desktopauth)
108 pub fn authenticate_with_token(&mut self, token: &str) -> Result<SessionResponse> {
109 self.client.set_user_token(token);
110 Ok(self.client.authenticate_with_token()?)
111 }
112
113 /// Authenticates a Last.fm user with a session key.
114 ///
115 /// # Usage
116 /// ```ignore
117 /// let mut scrobbler = Scrobbler::new(...);
118 /// let session_key = "securely-saved-old-session-key";
119 /// let response = scrobbler.authenticate_with_session_key(session_key);
120 /// ```
121 ///
122 /// # Response
123 /// This method has no response: the crate expects a valid session key to be provided here and has no way to
124 /// indicate if an invalidated key has been used. Clients will need to manually detect any authentication issues
125 /// via API call error responses.
126 ///
127 /// # A Note on Session Keys
128 /// When authenticating successfully with username/password or with an authentication token (
129 /// [`authenticate_with_password`] or [`authenticate_with_token`]), the Last.fm API will provide a Session Key.
130 /// The Session Key is used internally to authenticate all subsequent requests to the Last.fm API.
131 ///
132 /// Session keys are valid _indefinitely_. Thus, they can be stored and used for authentication at a later time.
133 /// A common pattern would be to authenticate initially via a username/password (or any other authentication flow)
134 /// but store ONLY the session key (avoiding difficulties of securely storing usernames/passwords that can change
135 /// etc.) and use this method to authenticate all further sessions. The current session key can be fetched for
136 /// later use via [`Scrobbler::session_key`].
137 ///
138 /// [`authenticate_with_password`]: struct.Scrobbler.html#method.authenticate_with_password
139 /// [`authenticate_with_token`]: struct.Scrobbler.html#method.authenticate_with_token
140 /// [`Scrobbler::session_key`]: struct.Scrobbler.html#method.session_key
141 pub fn authenticate_with_session_key(&mut self, session_key: &str) {
142 self.client.authenticate_with_session_key(session_key)
143 }
144
145 /// Registers the given [`Scrobble`]/track as the currently authenticated user's "now playing" track.
146 ///
147 /// Most scrobbling clients will set the now-playing track as soon as the user starts playing it; this makes it
148 /// appear temporarily as the 'now listening' track on the user's profile. However use of this endpoint/method
149 /// is entirely *optional* and can be skipped if you want.
150 ///
151 /// # Usage
152 /// This method behaves largely identically to the [`Scrobbler::scrobble`] method, just pointing to a different
153 /// endpoint on the Last.fm API.
154 ///
155 /// ```ignore
156 /// let scrobbler = Scrobbler::new(...);
157 /// // Scrobbler authentication ...
158 /// let now_playing_track = Scrobble::new("Example Artist", "Example Track", "Example Album");
159 /// match scrobbler.now_playing(now_playing_track) {
160 /// Ok(_) => println!("Now playing succeeded!"),
161 /// Err(err) => println("Now playing failed: {}", err)
162 /// };
163 /// ```
164 ///
165 /// # Response
166 /// On success a [`NowPlayingResponse`] is returned. This can often be ignored (as in the example code), but it
167 /// contains information that may be of use to some clients.
168 ///
169 /// # Last.fm API Documentation
170 /// [track.updateNowPlaying API Method Documentation](https://www.last.fm/api/show/track.updateNowPlaying)
171 ///
172 /// [Now Playing Request Documentation](https://www.last.fm/api/scrobbling#now-playing-requests)
173 ///
174 /// [`Scrobble`]: struct.Scrobble.html
175 /// [`Scrobbler::scrobble`]: struct.Scrobbler.html#method.scrobble
176 /// [`NowPlayingResponse`]: responses/struct.NowPlayingResponse.html
177 pub fn now_playing(&self, scrobble: &Scrobble) -> Result<NowPlayingResponse> {
178 let params = scrobble.as_map();
179
180 Ok(self.client.send_now_playing(¶ms)?)
181 }
182
183 /// Registers a scrobble (play) of the given [`Scrobble`]/track.
184 ///
185 /// # Usage
186 /// Your [`Scrobbler`] must be fully authenticated before using [`Scrobbler::scrobble`].
187 ///
188 /// ```ignore
189 /// let scrobbler = Scrobbler::new(...);
190 /// // Scrobbler authentication ...
191 /// let scrobble_track = Scrobble::new("Example Artist", "Example Track", "Example Album");
192 /// match scrobbler.scrobble(scrobble_track) {
193 /// Ok(_) => println!("Scrobble succeeded!"),
194 /// Err(err) => println("Scrobble failed: {}", err)
195 /// };
196 /// ```
197 ///
198 /// # Response
199 /// On success a [`ScrobbleResponse`] is returned. This can often be ignored (as in the example code), but it
200 /// contains information that may be of use to some clients.
201 ///
202 /// # Last.fm API Documentation
203 /// [track.scrobble API Method Documention](https://www.last.fm/api/show/track.scrobble)
204 /// [Scrobble Request Documentation](https://www.last.fm/api/scrobbling#scrobble-requests)
205 ///
206 /// [`Scrobble`]: struct.Scrobble.html
207 /// [`Scrobbler`]: struct.Scrobbler.html
208 /// [`Scrobbler::scrobble`]: struct.Scrobbler.html#method.scrobble
209 /// [`ScrobbleResponse`]: responses/struct.ScrobbleResponse.html
210 pub fn scrobble(&self, scrobble: &Scrobble) -> Result<ScrobbleResponse> {
211 let mut params = scrobble.as_map();
212 let current_time = UNIX_EPOCH.elapsed()?;
213
214 params
215 .entry("timestamp".to_string())
216 .or_insert_with(|| format!("{}", current_time.as_secs()));
217
218 Ok(self.client.send_scrobble(¶ms)?)
219 }
220
221 /// Registers a scrobble (play) of a collection of tracks.
222 ///
223 /// Takes a [`ScrobbleBatch`], effectively a wrapped `Vec<Scrobble>`, containing one or more [`Scrobble`] objects
224 /// which are be submitted to the Scrobble endpoint in a single batch.
225 ///
226 /// # Usage
227 /// Each [`ScrobbleBatch`] must contain 50 or fewer tracks. If a [`ScrobbleBatch`] containing more than 50
228 /// [`Scrobble`]s is submitted an error will be returned. An error will similarly be returned if the batch contains
229 /// no [`Scrobble`]s. An example batch scrobbling client is in the `examples` directory:
230 /// `examples/example_batch.rs`.
231 ///
232 /// ```ignore
233 /// let tracks = vec![
234 /// ("Artist 1", "Track 1", "Album 1"),
235 /// ("Artist 2", "Track 2", "Album 2"),
236 /// ];
237 ///
238 /// let batch = ScrobbleBatch::from(tracks);
239 /// let response = scrobbler.scrobble_batch(&batch);
240 /// ```
241 ///
242 /// # Response
243 /// On success, returns a [`ScrobbleBatchResponse`]. This can be ignored by most clients, but contains some data
244 /// that may be of interest.
245 ///
246 /// # Last.fm API Documentation
247 /// [track.scrobble API Method Documention](https://www.last.fm/api/show/track.scrobble)
248 ///
249 /// [Scrobble Request Documentation](https://www.last.fm/api/scrobbling#scrobble-requests)
250 ///
251 /// [`ScrobbleBatch`]: struct.ScrobbleBatch.html
252 /// [`Scrobble`]: struct.Scrobble.html
253 /// [`ScrobbleBatchResponse`]: responses/struct.ScrobbleBatchResponse.html
254 pub fn scrobble_batch(&self, batch: &ScrobbleBatch) -> Result<BatchScrobbleResponse> {
255 let mut params = HashMap::new();
256
257 let batch_count = batch.len();
258 if batch_count > 50 {
259 return Err(ScrobblerError::new(
260 "Scrobble batch too large (must be 50 or fewer scrobbles)".to_owned(),
261 ));
262 } else if batch_count == 0 {
263 return Err(ScrobblerError::new("Scrobble batch is empty".to_owned()));
264 }
265
266 for (i, scrobble) in batch.iter().enumerate() {
267 let mut scrobble_params = scrobble.as_map();
268 let current_time = UNIX_EPOCH.elapsed()?;
269 scrobble_params
270 .entry("timestamp".to_string())
271 .or_insert_with(|| format!("{}", current_time.as_secs()));
272
273 for (key, val) in &scrobble_params {
274 // batched parameters need array notation suffix ie.
275 // "artist[1] = "Artist 1", "artist[2]" = "Artist 2"
276 params.insert(format!("{}[{}]", key, i), val.clone());
277 }
278 }
279
280 Ok(self.client.send_batch_scrobbles(¶ms)?)
281 }
282
283 /// Gets the session key the client is currently authenticated with. Returns `None` if not authenticated. Valid
284 /// session keys can be stored and used to authenticate with [`authenticate_with_session_key`].
285 ///
286 /// See [`authenticate_with_session_key`] for more information on Last.fm API Session Keys
287 ///
288 /// [`authenticate_with_session_key`]: struct.Scrobbler.html#method.authenticate_with_session_key
289 pub fn session_key(&self) -> Option<&str> {
290 self.client.session_key()
291 }
292}
293
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use mockito::mock;
299 use std::error::Error;
300
301 #[test]
302 fn make_scrobbler_pass_auth() {
303 let _m = mock("POST", mockito::Matcher::Any).create();
304
305 let mut scrobbler = Scrobbler::new("api_key", "api_secret");
306 let resp = scrobbler.authenticate_with_password("user", "pass");
307 assert!(resp.is_err());
308
309 let _m = mock("POST", mockito::Matcher::Any)
310 .with_body(
311 r#"
312 {
313 "session": {
314 "key": "key",
315 "subscriber": 1337,
316 "name": "foo floyd"
317 }
318 }
319 "#,
320 )
321 .create();
322
323 let resp = scrobbler.authenticate_with_password("user", "pass");
324 assert!(resp.is_ok());
325 }
326
327 #[test]
328 fn make_scrobbler_token_auth() {
329 let _m = mock("POST", mockito::Matcher::Any).create();
330
331 let mut scrobbler = Scrobbler::new("api_key", "api_secret");
332 let resp = scrobbler.authenticate_with_token("some_token");
333 assert!(resp.is_err());
334
335 let _m = mock("POST", mockito::Matcher::Any)
336 .with_body(
337 r#"
338 {
339 "session": {
340 "key": "key",
341 "subscriber": 1337,
342 "name": "foo floyd"
343 }
344 }
345 "#,
346 )
347 .create();
348
349 let resp = scrobbler.authenticate_with_token("some_token");
350 assert!(resp.is_ok());
351 }
352
353 #[test]
354 fn check_scrobbler_error() {
355 let err = ScrobblerError::new("test_error".into());
356 let fmt = format!("{}", err);
357 assert_eq!("test_error", fmt);
358
359 let desc = err.to_string();
360 assert_eq!("test_error", &desc);
361
362 assert!(err.source().is_none());
363 }
364
365 #[test]
366 fn check_scrobbler_now_playing() {
367 let mut scrobbler = Scrobbler::new("api_key", "api_secret");
368
369 let _m = mock("POST", mockito::Matcher::Any)
370 .with_body(
371 r#"
372 {
373 "session": {
374 "key": "key",
375 "subscriber": 1337,
376 "name": "foo floyd"
377 }
378 }
379 "#,
380 )
381 .create();
382
383 let resp = scrobbler.authenticate_with_token("some_token");
384 assert!(resp.is_ok());
385
386 let mut scrobble = crate::models::metadata::Scrobble::new(
387 "foo floyd and the fruit flies",
388 "old bananas",
389 "old bananas",
390 );
391 scrobble.with_timestamp(1337);
392
393 let _m = mock("POST", mockito::Matcher::Any)
394 .with_body(
395 r#"
396 {
397 "nowplaying": {
398 "artist": [ "0", "foo floyd and the fruit flies" ],
399 "album": [ "1", "old bananas" ],
400 "albumArtist": [ "0", "foo floyd"],
401 "track": [ "1", "old bananas"],
402 "timestamp": "2019-10-04 13:23:40"
403 }
404 }
405 "#,
406 )
407 .create();
408
409 let resp = scrobbler.now_playing(&scrobble);
410 assert!(resp.is_ok());
411 }
412
413 #[test]
414 fn check_scrobbler_scrobble() {
415 let mut scrobbler = Scrobbler::new("api_key", "api_secret");
416
417 let _m = mock("POST", mockito::Matcher::Any)
418 .with_body(
419 r#"
420 {
421 "session": {
422 "key": "key",
423 "subscriber": 1337,
424 "name": "foo floyd"
425 }
426 }
427 "#,
428 )
429 .create();
430
431 let resp = scrobbler.authenticate_with_token("some_token");
432 assert!(resp.is_ok());
433
434 let mut scrobble = crate::models::metadata::Scrobble::new(
435 "foo floyd and the fruit flies",
436 "old bananas",
437 "old bananas",
438 );
439 scrobble.with_timestamp(1337);
440
441 let _m = mock("POST", mockito::Matcher::Any)
442 .with_body(
443 r#"
444 {
445 "scrobbles": [{
446 "artist": [ "0", "foo floyd and the fruit flies" ],
447 "album": [ "1", "old bananas" ],
448 "albumArtist": [ "0", "foo floyd"],
449 "track": [ "1", "old bananas"],
450 "timestamp": "2019-10-04 13:23:40"
451 }]
452 }
453 "#,
454 )
455 .create();
456
457 let resp = scrobbler.scrobble(&scrobble);
458 assert!(resp.is_ok());
459 }
460}