rustfm-scrobble 1.1.1

Last.fm Scrobble crate for Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
use crate::client::LastFm;
use crate::error::ScrobblerError;
use crate::models::metadata::{Scrobble, ScrobbleBatch};
use crate::models::responses::{
    BatchScrobbleResponse, NowPlayingResponse, ScrobbleResponse, SessionResponse,
};

use std::collections::HashMap;
use std::result;
use std::time::UNIX_EPOCH;

type Result<T> = result::Result<T, ScrobblerError>;

/// A Last.fm Scrobbler client. Submits song play information to Last.fm.
/// 
/// This is a client for the Scrobble and Now Playing endpoints on the Last.fm API. It handles API client and user 
/// auth, as well as providing Scrobble and Now Playing methods, plus support for sending batches of songs to Last.fm.
/// 
/// See the [official scrobbling API documentation](https://www.last.fm/api/scrobbling) for more information.
/// 
/// High-level example usage:
/// ```ignore
/// let username = "last-fm-username";
/// let password = "last-fm-password";
/// let api_key = "client-api-key";
/// let api_secret = "client-api-secret";
/// 
/// let mut scrobbler = Scrobbler.new(api_key, api_secret);
/// scrobbler.authenticate_with_password(username, password);
/// 
/// let song = Scrobble::new("Example Artist", "Example Song", "Example Album");
/// scrobbler.scrobble(song);
/// ```
pub struct Scrobbler {
    client: LastFm,
}

impl Scrobbler {

    /// Creates a new Scrobbler instance with the given Last.fm API Key and API Secret
    /// 
    /// # Usage
    /// ```ignore
    /// let api_secret = "xxx";
    /// let api_key = "123abc";
    /// let mut scrobbler = Scrobbler::new(api_key, api_secret);
    /// ...
    /// // Authenticate user with one of the available auth methods
    /// ```
    /// 
    /// # API Credentials
    /// All clients require the base API credentials: An API key and an API secret. These are obtained from Last.fm,
    /// and are specific to each *client*. These are credentials are totally separate from user authentication.
    /// 
    /// More information on authentication and API clients can be found in the Last.fm API documentation:
    /// 
    /// [API Authentication documentation](https://www.last.fm/api/authentication)
    /// 
    /// [API Account Registration form](https://www.last.fm/api/account/create)
    pub fn new(api_key: &str, api_secret: &str) -> Self {
        let client = LastFm::new(api_key, api_secret);

        Self { client }
    }

    /// Authenticates a Last.fm user with the given username and password. 
    /// 
    /// This authentication path is known as the 'Mobile auth flow', but is valid for any platform. This is often the
    /// simplest method of authenticating a user with the API, requiring just username & password. Other Last.fm auth
    /// flows are available and might be better suited to your application, check the official Last.fm API docs for 
    /// further information.
    /// 
    /// # Usage
    /// ```ignore
    /// let mut scrobbler = Scrobbler::new(...)
    /// let username = "last-fm-user";
    /// let password = "hunter2";
    /// let response = scrobbler.authenticate_with_password(username, password);
    /// ...
    /// ```
    /// 
    /// # Last.fm API Documentation
    /// [Last.fm Mobile Auth Flow Documentation](https://www.last.fm/api/mobileauth)
    pub fn authenticate_with_password(
        &mut self,
        username: &str,
        password: &str,
    ) -> Result<SessionResponse> {
        self.client.set_user_credentials(username, password);
        Ok(self.client.authenticate_with_password()?)
    }

    /// Authenticates a Last.fm user with an authentication token. This method supports both the 'Web' and 'Desktop'
    /// Last.fm auth flows (check the API documentation to ensure you are using the correct authentication method for
    /// your needs).
    /// 
    /// # Usage
    /// ```ignore
    /// let mut scrobbler = Scrobbler.new(...);
    /// let auth_token = "token-from-last-fm";
    /// let response = scrobbler.authenticate_with_token(auth_token);
    /// ```
    /// 
    /// # Last.fm API Documentation
    /// [Last.fm Web Auth Flow Documentation](https://www.last.fm/api/webauth)
    /// 
    /// [Last.fm Desktop Auth Flow Documentation](https://www.last.fm/api/desktopauth)
    pub fn authenticate_with_token(&mut self, token: &str) -> Result<SessionResponse> {
        self.client.set_user_token(token);
        Ok(self.client.authenticate_with_token()?)
    }

    /// Authenticates a Last.fm user with a session key. 
    /// 
    /// # Usage
    /// ```ignore
    /// let mut scrobbler = Scrobbler::new(...);
    /// let session_key = "securely-saved-old-session-key";
    /// let response = scrobbler.authenticate_with_session_key(session_key);
    /// ```
    /// 
    /// # Response
    /// This method has no response: the crate expects a valid session key to be provided here and has no way to
    /// indicate if an invalidated key has been used. Clients will need to manually detect any authentication issues
    /// via API call error responses.
    /// 
    /// # A Note on Session Keys
    /// When authenticating successfully with username/password or with an authentication token (
    /// [`authenticate_with_password`] or [`authenticate_with_token`]), the Last.fm API will provide a Session Key.
    /// The Session Key is used internally to authenticate all subsequent requests to the Last.fm API. 
    /// 
    /// Session keys are valid _indefinitely_. Thus, they can be stored and used for authentication at a later time.
    /// A common pattern would be to authenticate initially via a username/password (or any other authentication flow)
    /// but store ONLY the session key (avoiding difficulties of securely storing usernames/passwords that can change 
    /// etc.) and use this method to authenticate all further sessions. The current session key can be fetched for 
    /// later use via [`Scrobbler::session_key`].
    /// 
    /// [`authenticate_with_password`]: struct.Scrobbler.html#method.authenticate_with_password
    /// [`authenticate_with_token`]: struct.Scrobbler.html#method.authenticate_with_token
    /// [`Scrobbler::session_key`]: struct.Scrobbler.html#method.session_key
    pub fn authenticate_with_session_key(&mut self, session_key: &str) {
        self.client.authenticate_with_session_key(session_key)
    }

    /// Registers the given [`Scrobble`]/track as the currently authenticated user's "now playing" track.
    /// 
    /// Most scrobbling clients will set the now-playing track as soon as the user starts playing it; this makes it 
    /// appear temporarily as the 'now listening' track on the user's profile. However use of this endpoint/method
    /// is entirely *optional* and can be skipped if you want.
    /// 
    /// # Usage
    /// This method behaves largely identically to the [`Scrobbler::scrobble`] method, just pointing to a different
    /// endpoint on the Last.fm API.
    /// 
    /// ```ignore
    /// let scrobbler = Scrobbler::new(...);
    /// // Scrobbler authentication ...
    /// let now_playing_track = Scrobble::new("Example Artist", "Example Track", "Example Album");
    /// match scrobbler.now_playing(now_playing_track) {
    ///     Ok(_) => println!("Now playing succeeded!"),
    ///     Err(err) => println("Now playing failed: {}", err)
    /// };
    /// ```
    /// 
    /// # Response
    /// On success a [`NowPlayingResponse`] is returned. This can often be ignored (as in the example code), but it
    /// contains information that may be of use to some clients. 
    /// 
    /// # Last.fm API Documentation
    /// [track.updateNowPlaying API Method Documentation](https://www.last.fm/api/show/track.updateNowPlaying)
    /// 
    /// [Now Playing Request Documentation](https://www.last.fm/api/scrobbling#now-playing-requests)
    /// 
    /// [`Scrobble`]: struct.Scrobble.html
    /// [`Scrobbler::scrobble`]: struct.Scrobbler.html#method.scrobble
    /// [`NowPlayingResponse`]: responses/struct.NowPlayingResponse.html
    pub fn now_playing(&self, scrobble: &Scrobble) -> Result<NowPlayingResponse> {
        let params = scrobble.as_map();

        Ok(self.client.send_now_playing(&params)?)
    }

    /// Registers a scrobble (play) of the given [`Scrobble`]/track.
    /// 
    /// # Usage
    /// Your [`Scrobbler`] must be fully authenticated before using [`Scrobbler::scrobble`].
    /// 
    /// ```ignore
    /// let scrobbler = Scrobbler::new(...);
    /// // Scrobbler authentication ...
    /// let scrobble_track = Scrobble::new("Example Artist", "Example Track", "Example Album");
    /// match scrobbler.scrobble(scrobble_track) {
    ///     Ok(_) => println!("Scrobble succeeded!"),
    ///     Err(err) => println("Scrobble failed: {}", err)
    /// };
    /// ```
    /// 
    /// # Response
    /// On success a [`ScrobbleResponse`] is returned. This can often be ignored (as in the example code), but it
    /// contains information that may be of use to some clients. 
    /// 
    /// # Last.fm API Documentation
    /// [track.scrobble API Method Documention](https://www.last.fm/api/show/track.scrobble)
    /// [Scrobble Request Documentation](https://www.last.fm/api/scrobbling#scrobble-requests)
    /// 
    /// [`Scrobble`]: struct.Scrobble.html
    /// [`Scrobbler`]: struct.Scrobbler.html
    /// [`Scrobbler::scrobble`]: struct.Scrobbler.html#method.scrobble
    /// [`ScrobbleResponse`]: responses/struct.ScrobbleResponse.html
    pub fn scrobble(&self, scrobble: &Scrobble) -> Result<ScrobbleResponse> {
        let mut params = scrobble.as_map();
        let current_time = UNIX_EPOCH.elapsed()?;

        params
            .entry("timestamp".to_string())
            .or_insert_with(|| format!("{}", current_time.as_secs()));

        Ok(self.client.send_scrobble(&params)?)
    }

    /// Registers a scrobble (play) of a collection of tracks. 
    /// 
    /// Takes a [`ScrobbleBatch`], effectively a wrapped `Vec<Scrobble>`, containing one or more [`Scrobble`] objects
    /// which are be submitted to the Scrobble endpoint in a single batch. 
    /// 
    /// # Usage
    /// Each [`ScrobbleBatch`] must contain 50 or fewer tracks. If a [`ScrobbleBatch`] containing more than 50
    /// [`Scrobble`]s is submitted an error will be returned. An error will similarly be returned if the batch contains
    /// no [`Scrobble`]s. An example batch scrobbling client is in the `examples` directory: 
    /// `examples/example_batch.rs`.
    /// 
    /// ```ignore
    /// let tracks = vec![
    ///     ("Artist 1", "Track 1", "Album 1"),
    ///     ("Artist 2", "Track 2", "Album 2"),
    /// ];
    /// 
    /// let batch = ScrobbleBatch::from(tracks);
    /// let response = scrobbler.scrobble_batch(&batch);
    /// ```
    /// 
    /// # Response
    /// On success, returns a [`ScrobbleBatchResponse`]. This can be ignored by most clients, but contains some data
    /// that may be of interest.
    /// 
    /// # Last.fm API Documentation
    /// [track.scrobble API Method Documention](https://www.last.fm/api/show/track.scrobble)
    /// 
    /// [Scrobble Request Documentation](https://www.last.fm/api/scrobbling#scrobble-requests)
    /// 
    /// [`ScrobbleBatch`]: struct.ScrobbleBatch.html
    /// [`Scrobble`]: struct.Scrobble.html
    /// [`ScrobbleBatchResponse`]: responses/struct.ScrobbleBatchResponse.html
    pub fn scrobble_batch(&self, batch: &ScrobbleBatch) -> Result<BatchScrobbleResponse> {
        let mut params = HashMap::new();

        let batch_count = batch.len();
        if batch_count > 50 {
            return Err(ScrobblerError::new(
                "Scrobble batch too large (must be 50 or fewer scrobbles)".to_owned(),
            ));
        } else if batch_count == 0 {
            return Err(ScrobblerError::new("Scrobble batch is empty".to_owned()));
        }

        for (i, scrobble) in batch.iter().enumerate() {
            let mut scrobble_params = scrobble.as_map();
            let current_time = UNIX_EPOCH.elapsed()?;
            scrobble_params
                .entry("timestamp".to_string())
                .or_insert_with(|| format!("{}", current_time.as_secs()));

            for (key, val) in &scrobble_params {
                // batched parameters need array notation suffix ie.
                // "artist[1] = "Artist 1", "artist[2]" = "Artist 2"
                params.insert(format!("{}[{}]", key, i), val.clone());
            }
        }

        Ok(self.client.send_batch_scrobbles(&params)?)
    }

    /// Gets the session key the client is currently authenticated with. Returns `None` if not authenticated. Valid
    /// session keys can be stored and used to authenticate with [`authenticate_with_session_key`].
    /// 
    /// See [`authenticate_with_session_key`] for more information on Last.fm API Session Keys
    /// 
    /// [`authenticate_with_session_key`]: struct.Scrobbler.html#method.authenticate_with_session_key
    pub fn session_key(&self) -> Option<&str> {
        self.client.session_key()
    }
}


#[cfg(test)]
mod tests {
    use super::*;
    use mockito::mock;
    use std::error::Error;

    #[test]
    fn make_scrobbler_pass_auth() {
        let _m = mock("POST", mockito::Matcher::Any).create();

        let mut scrobbler = Scrobbler::new("api_key", "api_secret");
        let resp = scrobbler.authenticate_with_password("user", "pass");
        assert!(resp.is_err());

        let _m = mock("POST", mockito::Matcher::Any)
            .with_body(
                r#"
                {   
                    "session": {
                        "key": "key",
                        "subscriber": 1337,
                        "name": "foo floyd"
                    }
                }
            "#,
            )
            .create();

        let resp = scrobbler.authenticate_with_password("user", "pass");
        assert!(resp.is_ok());
    }

    #[test]
    fn make_scrobbler_token_auth() {
        let _m = mock("POST", mockito::Matcher::Any).create();

        let mut scrobbler = Scrobbler::new("api_key", "api_secret");
        let resp = scrobbler.authenticate_with_token("some_token");
        assert!(resp.is_err());

        let _m = mock("POST", mockito::Matcher::Any)
            .with_body(
                r#"
                {   
                    "session": {
                        "key": "key",
                        "subscriber": 1337,
                        "name": "foo floyd"
                    }
                }
            "#,
            )
            .create();

        let resp = scrobbler.authenticate_with_token("some_token");
        assert!(resp.is_ok());
    }

    #[test]
    fn check_scrobbler_error() {
        let err = ScrobblerError::new("test_error".into());
        let fmt = format!("{}", err);
        assert_eq!("test_error", fmt);

        let desc = err.to_string();
        assert_eq!("test_error", &desc);

        assert!(err.source().is_none());
    }

    #[test]
    fn check_scrobbler_now_playing() {
        let mut scrobbler = Scrobbler::new("api_key", "api_secret");

        let _m = mock("POST", mockito::Matcher::Any)
            .with_body(
                r#"
                {   
                    "session": {
                        "key": "key",
                        "subscriber": 1337,
                        "name": "foo floyd"
                    }
                }
            "#,
            )
            .create();

        let resp = scrobbler.authenticate_with_token("some_token");
        assert!(resp.is_ok());

        let mut scrobble = crate::models::metadata::Scrobble::new(
            "foo floyd and the fruit flies",
            "old bananas",
            "old bananas",
        );
        scrobble.with_timestamp(1337);

        let _m = mock("POST", mockito::Matcher::Any)
            .with_body(
                r#"
            { 
                "nowplaying": {
                            "artist": [ "0", "foo floyd and the fruit flies" ],
                            "album": [ "1", "old bananas" ], 
                            "albumArtist": [ "0", "foo floyd"],
                            "track": [ "1", "old bananas"], 
                            "timestamp": "2019-10-04 13:23:40" 
                        }
            }
            "#,
            )
            .create();

        let resp = scrobbler.now_playing(&scrobble);
        assert!(resp.is_ok());
    }

    #[test]
    fn check_scrobbler_scrobble() {
        let mut scrobbler = Scrobbler::new("api_key", "api_secret");

        let _m = mock("POST", mockito::Matcher::Any)
            .with_body(
                r#"
                {   
                    "session": {
                        "key": "key",
                        "subscriber": 1337,
                        "name": "foo floyd"
                    }
                }
            "#,
            )
            .create();

        let resp = scrobbler.authenticate_with_token("some_token");
        assert!(resp.is_ok());

        let mut scrobble = crate::models::metadata::Scrobble::new(
            "foo floyd and the fruit flies",
            "old bananas",
            "old bananas",
        );
        scrobble.with_timestamp(1337);

        let _m = mock("POST", mockito::Matcher::Any)
            .with_body(
                r#"
            { 
                "scrobbles": [{
                        "artist": [ "0", "foo floyd and the fruit flies" ],
                        "album": [ "1", "old bananas" ], 
                        "albumArtist": [ "0", "foo floyd"],
                        "track": [ "1", "old bananas"], 
                        "timestamp": "2019-10-04 13:23:40" 
                }]
            }
            "#,
            )
            .create();

        let resp = scrobbler.scrobble(&scrobble);
        assert!(resp.is_ok());
    }
}