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(&params)?)
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(&params)?)
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(&params)?)
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}