├── .gitignore ├── Cargo.toml ├── README.md ├── example_image.jpeg ├── examples ├── get_artist.rs ├── get_playing.rs ├── recently_played.rs └── refresh_file.rs └── src ├── authorization_url.rs ├── endpoints ├── albums.rs ├── artists.rs ├── browse.rs ├── episodes.rs ├── follow.rs ├── library.rs ├── mod.rs ├── personalization.rs ├── player.rs ├── playlists.rs ├── search.rs ├── shows.rs ├── tracks.rs └── users_profile.rs ├── lib.rs ├── model ├── album.rs ├── analysis.rs ├── artist.rs ├── device.rs ├── errors.rs ├── mod.rs ├── playlist.rs ├── show.rs ├── track.rs └── user.rs └── util.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | .env 5 | .refresh_token 6 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "aspotify" 3 | description = "Deprecated in favour of rspotify" 4 | version = "0.7.1" 5 | readme = "README.md" 6 | repository = "https://github.com/KaiJewson/aspotify" 7 | keywords = ["Spotify", "API", "Asynchronous"] 8 | categories = ["api-bindings", "asynchronous", "authentication", "web-programming", "web-programming::http-client"] 9 | license = "MIT OR Apache-2.0" 10 | authors = ["KaiJewson "] 11 | edition = "2018" 12 | 13 | [dependencies] 14 | reqwest = { version = "0.11.0", features = ["json"] } 15 | # reqwest doesn't re-export url::{ParseError, Position} 16 | url = "2.2.0" 17 | # For the mutex around AccessToken and the Retry-After delay 18 | tokio = { version = "1.0.1", features = ["sync", "time"] } 19 | # Serde 20 | serde = { version = "1.0.118", features = ["derive"] } 21 | serde_millis = "0.1.1" 22 | serde_json = "1.0.60" 23 | # Datatypes used in the Spotify schema 24 | chrono = { version = "0.4.19", features = ["serde"] } 25 | isocountry = "0.3.2" 26 | isolanguage-1 = { version = "0.2.0", features = ["serde"] } 27 | # For joining iterators of T: Display with "," and chunking ids into groups 28 | itertools = "0.10.0" 29 | # For managing streams 30 | futures-util = "0.3.8" 31 | 32 | # For generating random state 33 | rand = { version = "0.8.1", optional = true } 34 | # For encoding playlist cover images 35 | base64 = { version = "0.13.0", optional = true } 36 | 37 | [dev-dependencies] 38 | dotenv = "0.15.0" 39 | tokio = { version = "1.0.1", features = ["macros", "rt-multi-thread"] } 40 | 41 | [features] 42 | default = ["base64", "rand"] 43 | 44 | [[example]] 45 | name = "refresh_file" 46 | required-features = ["rand"] 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DEPRECATION NOTICE 2 | 3 | This crate is deprecated in favour of [rspotify](https://docs.rs/rspotify). Originally, this crate 4 | was created when rspotify didn't support async and had a much worse API than it does now. But since 5 | then it has much improved, and I don't have the time or energy to maintain this crate anymore. 6 | 7 | # Original README 8 | 9 | Asynchronous Rust Spotify client. 10 | 11 | ## Description 12 | 13 | Aspotify is a Rust wrapper for the Spotify API. It provides Rust structures around all of Spotify's 14 | [Object Model](https://developer.spotify.com/documentation/web-api/reference/object-model/) and 15 | functions around all their endpoints. 16 | 17 | ## Authorization 18 | 19 | All Spotify endpoints require authorization. There are two forms of authorization provided by this 20 | library; Client Credentials and Authorization Code. To use either, you first need a [Spotify 21 | Developer](https://developer.spotify.com/dashboard/applications) account, which is free. Then you 22 | can use endpoints with your Client ID and Client Secret with Client Credentials, or perform actions 23 | on behalf of a user with oauth2 and Authorization Code. 24 | 25 | ## Example 26 | 27 | ```rust 28 | use aspotify::{Client, ClientCredentials}; 29 | 30 | // This from_env function tries to read the CLIENT_ID and CLIENT_SECRET environment variables. 31 | // You can use the dotenv crate to read it from a file. 32 | let credentials = ClientCredentials::from_env() 33 | .expect("CLIENT_ID and CLIENT_SECRET not found."); 34 | 35 | // Create a Spotify client. 36 | let client = Client::new(credentials); 37 | 38 | // Gets the album "Favourite Worst Nightmare" from Spotify, with no specified market. 39 | let album = client.albums().get_album("1XkGORuUX2QGOEIL4EbJKm", None).await.unwrap(); 40 | ``` 41 | 42 | ## Features 43 | 44 | At the time of the latest release, `aspotify` supports all the features of the Spotify API. It uses 45 | `reqwest` internally, and so must run with Tokio's runtime. 46 | 47 | ## Testing 48 | 49 | In order to test, you first need to add `http://non.existant/` in your Spotify whitelisted URLs. Get 50 | your Client ID and Client Secret and put them in a `.env` file in the crate root like this: 51 | ``` 52 | CLIENT_ID=some value 53 | CLIENT_SECRET=some value 54 | ``` 55 | Then, run `cargo run --example refresh_file`. Follow the instructions shown. If everything went 56 | successfully, you should see a file called `.refresh_token` in your crate root. This file contains a 57 | refresh token that will be used to run all the tests. For more infomation about this process, see 58 | `examples/refresh_file.rs`. 59 | 60 | These tests will make temporary changes to your account, however they will all be reverted. You will 61 | also need an unrestricted non-private Spotify client open to get all the tests to run successfully, 62 | and you must not have any songs in your queue. 63 | 64 | ## Planned 65 | 66 | - Add a blocking API. 67 | - Support other HTTP clients. 68 | - Automatically send multiple requests when the limit is above Spotify's limit for functions that 69 | return `Page`/`CursorPage`. 70 | -------------------------------------------------------------------------------- /example_image.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabrinaJewson/aspotify/843f01657470e8f21962aa1332ffbc9e918ff40f/example_image.jpeg -------------------------------------------------------------------------------- /examples/get_artist.rs: -------------------------------------------------------------------------------- 1 | use aspotify::{Client, ClientCredentials}; 2 | 3 | #[tokio::main] 4 | async fn main() { 5 | // Read the client credentials from the .env file 6 | dotenv::dotenv().unwrap(); 7 | 8 | // Make the Spotify client using client credentials flow 9 | let client = Client::new(ClientCredentials::from_env().unwrap()); 10 | 11 | // Call the Spotify API to get information on an artist 12 | let artist = client 13 | .artists() 14 | .get_artist("2WX2uTcsvV5OnS0inACecP") 15 | .await 16 | .unwrap() 17 | .data; 18 | 19 | println!( 20 | "Found artist named {} with {} followers", 21 | artist.name, artist.followers.total 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /examples/get_playing.rs: -------------------------------------------------------------------------------- 1 | use aspotify::{Client, ClientCredentials, CurrentlyPlaying, PlayingType}; 2 | 3 | #[tokio::main] 4 | async fn main() { 5 | // Read the client credentials from the .env file 6 | dotenv::dotenv().unwrap(); 7 | 8 | // Make the Spotify client 9 | let client = Client::with_refresh( 10 | ClientCredentials::from_env().unwrap(), 11 | std::fs::read_to_string(".refresh_token").unwrap(), 12 | ); 13 | 14 | // Call the Spotify API to get the playing track 15 | let playing = client.player().get_playing_track(None).await.unwrap().data; 16 | 17 | // Print out the results 18 | match playing { 19 | Some(CurrentlyPlaying { 20 | item: Some(item), .. 21 | }) => { 22 | print!("Currently playing "); 23 | match item { 24 | PlayingType::Track(track) => print!("the track {}", track.name), 25 | PlayingType::Episode(ep) => print!("the episode {}", ep.name), 26 | PlayingType::Ad(ad) => print!("the advert {}", ad.name), 27 | PlayingType::Unknown(item) => print!("an unknown track {}", item.name), 28 | } 29 | println!("."); 30 | } 31 | Some(CurrentlyPlaying { item: None, .. }) => println!("Currently playing an unknown item."), 32 | None => println!("Nothing currently playing."), 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/recently_played.rs: -------------------------------------------------------------------------------- 1 | use aspotify::{Client, ClientCredentials}; 2 | 3 | #[tokio::main] 4 | async fn main() { 5 | // Read the client credentials from the .env file 6 | dotenv::dotenv().unwrap(); 7 | 8 | // Make the Spotify client 9 | let client = Client::with_refresh( 10 | ClientCredentials::from_env().unwrap(), 11 | std::fs::read_to_string(".refresh_token").unwrap(), 12 | ); 13 | 14 | let recent = client 15 | .player() 16 | .get_recently_played(50, None, None) 17 | .await 18 | .unwrap() 19 | .data; 20 | 21 | // Print the results 22 | match recent { 23 | Some(aspotify::TwoWayCursorPage { items, .. }) => { 24 | println!("Play history:"); 25 | for item in items { 26 | println!( 27 | "{}: '{}' by {}", 28 | item.played_at, 29 | item.track.name, 30 | item.track 31 | .artists 32 | .into_iter() 33 | .map(|artist| artist.name) 34 | .collect::>() 35 | .join(", ") 36 | ); 37 | } 38 | } 39 | None => { 40 | println!("Nothing in play history."); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /examples/refresh_file.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::io::{self, Write}; 3 | 4 | use aspotify::{Client, ClientCredentials, Scope}; 5 | 6 | #[tokio::main] 7 | async fn main() { 8 | // Read .env file into environment variables. 9 | dotenv::dotenv().unwrap(); 10 | 11 | // Create the Spotify client from the credentials in the env variables. 12 | let client = Client::new(ClientCredentials::from_env().unwrap()); 13 | 14 | // Get the URL to send the user to, requesting all the scopes and redirecting to a non-existant website. 15 | let (url, state) = aspotify::authorization_url( 16 | &client.credentials.id, 17 | [ 18 | Scope::UgcImageUpload, 19 | Scope::UserReadPlaybackState, 20 | Scope::UserModifyPlaybackState, 21 | Scope::UserReadCurrentlyPlaying, 22 | Scope::Streaming, 23 | Scope::AppRemoteControl, 24 | Scope::UserReadEmail, 25 | Scope::UserReadPrivate, 26 | Scope::PlaylistReadCollaborative, 27 | Scope::PlaylistModifyPublic, 28 | Scope::PlaylistReadPrivate, 29 | Scope::PlaylistModifyPrivate, 30 | Scope::UserLibraryModify, 31 | Scope::UserLibraryRead, 32 | Scope::UserTopRead, 33 | Scope::UserReadRecentlyPlayed, 34 | Scope::UserFollowRead, 35 | Scope::UserFollowModify, 36 | ] 37 | .iter() 38 | .copied(), 39 | false, 40 | "http://non.existant/", 41 | ); 42 | 43 | // Get the user to authorize our application. 44 | println!("Go to this website: {}", url); 45 | 46 | // Receive the URL that was redirected to. 47 | print!("Enter the URL that you were redirected to: "); 48 | io::stdout().flush().unwrap(); 49 | let mut redirect = String::new(); 50 | io::stdin().read_line(&mut redirect).unwrap(); 51 | 52 | // Create the refresh token from the redirected URL. 53 | client.redirected(&redirect, &state).await.unwrap(); 54 | 55 | // Put the refresh token in a file. 56 | fs::write(".refresh_token", client.refresh_token().await.unwrap()).unwrap(); 57 | } 58 | -------------------------------------------------------------------------------- /src/authorization_url.rs: -------------------------------------------------------------------------------- 1 | use itertools::Itertools; 2 | use url::Url; 3 | 4 | /// A scope that the user can grant access to. 5 | /// 6 | /// [Reference](https://developer.spotify.com/documentation/general/guides/scopes/). 7 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 8 | #[allow(missing_docs)] 9 | pub enum Scope { 10 | UgcImageUpload, 11 | UserReadPlaybackState, 12 | UserModifyPlaybackState, 13 | UserReadCurrentlyPlaying, 14 | Streaming, 15 | AppRemoteControl, 16 | UserReadEmail, 17 | UserReadPrivate, 18 | PlaylistReadCollaborative, 19 | PlaylistModifyPublic, 20 | PlaylistReadPrivate, 21 | PlaylistModifyPrivate, 22 | UserLibraryModify, 23 | UserLibraryRead, 24 | UserTopRead, 25 | UserReadRecentlyPlayed, 26 | UserReadPlaybackPosition, 27 | UserFollowRead, 28 | UserFollowModify, 29 | } 30 | 31 | impl Scope { 32 | /// Get the scope as a string (in `kebab-case` like Spotify requires). 33 | /// 34 | /// # Examples 35 | /// 36 | /// ``` 37 | /// let scope = aspotify::Sope::UserReadEmail; 38 | /// 39 | /// assert_eq!(scope.as_str(), "user-read-email"); 40 | /// ``` 41 | #[must_use] 42 | pub const fn as_str(self) -> &'static str { 43 | match self { 44 | Self::UgcImageUpload => "ugc-image-upload", 45 | Self::UserReadPlaybackState => "user-read-playback-state", 46 | Self::UserModifyPlaybackState => "user-modify-playback-state", 47 | Self::UserReadCurrentlyPlaying => "user-read-currently-playing", 48 | Self::Streaming => "streaming", 49 | Self::AppRemoteControl => "app-remote-control", 50 | Self::UserReadEmail => "user-read-email", 51 | Self::UserReadPrivate => "user-read-private", 52 | Self::PlaylistReadCollaborative => "playlist-read-collaborative", 53 | Self::PlaylistModifyPublic => "playlist-modify-public", 54 | Self::PlaylistReadPrivate => "playlist-read-private", 55 | Self::PlaylistModifyPrivate => "playlist-modify-private", 56 | Self::UserLibraryModify => "user-library-modify", 57 | Self::UserLibraryRead => "user-library-read", 58 | Self::UserTopRead => "user-top-read", 59 | Self::UserReadRecentlyPlayed => "user-read-recently-played", 60 | Self::UserReadPlaybackPosition => "user-read-playback-position", 61 | Self::UserFollowRead => "user-follow-read", 62 | Self::UserFollowModify => "user-follow-modify", 63 | } 64 | } 65 | } 66 | 67 | /// Like [`authorization_url`], but you supply your own state. 68 | /// 69 | /// It is recommended to use randomly generated state for security, so use this if you wish to use 70 | /// your own random state generator. 71 | /// 72 | /// This function, unlike [`authorization_url`] does not require features to be activated. 73 | /// 74 | /// See the docs of the other function for information about the parameters. 75 | pub fn authorization_url_with_state( 76 | client_id: &str, 77 | scopes: impl IntoIterator, 78 | force_approve: bool, 79 | redirect_uri: &str, 80 | state: &str, 81 | ) -> String { 82 | Url::parse_with_params( 83 | "https://accounts.spotify.com/authorize", 84 | &[ 85 | ("response_type", "code"), 86 | ("state", &state), 87 | ("client_id", client_id), 88 | ("scope", &scopes.into_iter().map(Scope::as_str).join(" ")), 89 | ("show_dialog", if force_approve { "true" } else { "false" }), 90 | ("redirect_uri", redirect_uri), 91 | ], 92 | ) 93 | .unwrap() 94 | .into_string() 95 | } 96 | 97 | /// Get the URL to redirect the user's browser to so that the URL can be generated for the 98 | /// [`Client::redirected`](super::Client::redirected) function. 99 | /// 100 | /// `force_approve`, if set, forces the user to approve the app again even if they already have. 101 | /// Make sure that you have whitelisted the redirect uri in your Spotify dashboard, and 102 | /// `redirect_uri` must not contain any query strings. 103 | /// 104 | /// This method returns a tuple of the generated url and the state parameter, which is randomly 105 | /// generated for security. 106 | /// 107 | /// This function is only available when the `rand` feature of this library is activated, and it is 108 | /// activated by default. 109 | /// 110 | /// [Reference](https://developer.spotify.com/documentation/general/guides/authorization-guide/#1-have-your-application-request-authorization-the-user-logs-in-and-authorizes-access). 111 | #[cfg(feature = "rand")] 112 | pub fn authorization_url( 113 | client_id: &str, 114 | scopes: impl IntoIterator, 115 | force_approve: bool, 116 | redirect_uri: &str, 117 | ) -> (String, String) { 118 | use rand::Rng as _; 119 | 120 | const STATE_LEN: usize = 16; 121 | const STATE_CHARS: &[u8] = 122 | b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"; 123 | 124 | let mut rng = rand::thread_rng(); 125 | let mut state = String::with_capacity(STATE_LEN); 126 | for _ in 0..STATE_LEN { 127 | state.push(STATE_CHARS[rng.gen_range(0..STATE_CHARS.len())].into()); 128 | } 129 | 130 | ( 131 | authorization_url_with_state(client_id, scopes, force_approve, redirect_uri, &state), 132 | state, 133 | ) 134 | } 135 | -------------------------------------------------------------------------------- /src/endpoints/albums.rs: -------------------------------------------------------------------------------- 1 | //! Endpoint functions relating to albums. 2 | 3 | use std::fmt::Display; 4 | 5 | use itertools::Itertools as _; 6 | use serde::Deserialize; 7 | 8 | use super::chunked_sequence; 9 | use crate::{Album, Client, Error, Market, Page, Response, TrackSimplified}; 10 | 11 | /// Album-related endpoints. 12 | #[derive(Debug, Clone, Copy)] 13 | pub struct Albums<'a>(pub &'a Client); 14 | 15 | impl Albums<'_> { 16 | /// Get information about an album. 17 | /// 18 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/albums/get-album/). 19 | pub async fn get_album( 20 | self, 21 | id: &str, 22 | market: Option, 23 | ) -> Result, Error> { 24 | self.0 25 | .send_json( 26 | self.0 27 | .client 28 | .get(endpoint!("/v1/albums/{}", id)) 29 | .query(&[market.map(Market::query)]), 30 | ) 31 | .await 32 | } 33 | 34 | /// Get information about several albums. 35 | /// 36 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/albums/get-several-albums/). 37 | pub async fn get_albums( 38 | self, 39 | ids: I, 40 | market: Option, 41 | ) -> Result>, Error> 42 | where 43 | I::Item: Display, 44 | { 45 | #[derive(Deserialize)] 46 | struct Albums { 47 | albums: Vec, 48 | } 49 | 50 | chunked_sequence(ids, 20, |mut ids| { 51 | let req = self 52 | .0 53 | .client 54 | .get(endpoint!("/v1/albums")) 55 | .query(&(("ids", ids.join(",")), market.map(Market::query))); 56 | async move { Ok(self.0.send_json::(req).await?.map(|res| res.albums)) } 57 | }) 58 | .await 59 | } 60 | 61 | /// Get an album's tracks. 62 | /// 63 | /// It does not return all the tracks, but a page of tracks. Limit and offset determine 64 | /// attributes of the page. Limit has a maximum of 50. 65 | /// 66 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/albums/get-albums-tracks/). 67 | pub async fn get_album_tracks( 68 | self, 69 | id: &str, 70 | limit: usize, 71 | offset: usize, 72 | market: Option, 73 | ) -> Result>, Error> { 74 | self.0 75 | .send_json( 76 | self.0 77 | .client 78 | .get(endpoint!("/v1/albums/{}/tracks", id)) 79 | .query(&( 80 | ("limit", limit), 81 | ("offset", offset), 82 | market.map(Market::query), 83 | )), 84 | ) 85 | .await 86 | } 87 | } 88 | 89 | #[cfg(test)] 90 | mod tests { 91 | use crate::endpoints::client; 92 | 93 | #[tokio::test] 94 | async fn test_get_album() { 95 | let album = client() 96 | .albums() 97 | .get_album("03JPFQvZRnHHysSZrSFmKY", None) 98 | .await 99 | .unwrap() 100 | .data; 101 | assert_eq!(album.name, "Inside In / Inside Out"); 102 | assert_eq!(album.artists.len(), 1); 103 | assert_eq!(album.artists[0].name, "The Kooks"); 104 | assert_eq!(album.tracks.total, 14); 105 | assert_eq!(album.tracks.items[0].name, "Seaside"); 106 | } 107 | 108 | #[tokio::test] 109 | async fn test_get_albums() { 110 | let albums = client() 111 | .albums() 112 | .get_albums(&["29Xikj6r9kQDSbnZWCCW2s", "0axbvqBOAejn8DgTUcJAp1"], None) 113 | .await 114 | .unwrap() 115 | .data; 116 | assert_eq!(albums.len(), 2); 117 | assert_eq!(albums[0].name, "Neotheater"); 118 | assert_eq!(albums[1].name, "Absentee"); 119 | } 120 | 121 | #[tokio::test] 122 | async fn test_get_album_tracks() { 123 | let tracks = client() 124 | .albums() 125 | .get_album_tracks("62U7xIHcID94o20Of5ea4D", 3, 1, None) 126 | .await 127 | .unwrap() 128 | .data; 129 | assert_eq!(tracks.limit, 3); 130 | assert_eq!(tracks.total, 10); 131 | assert_eq!(tracks.offset, 1); 132 | assert_eq!(tracks.items.len(), 3); 133 | assert_eq!(tracks.items[0].name, "Make Believe"); 134 | assert_eq!(tracks.items[1].name, "I Won't Hold You Back"); 135 | assert_eq!(tracks.items[2].name, "Good for You"); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/endpoints/artists.rs: -------------------------------------------------------------------------------- 1 | //! Endpoint functions relating to artists. 2 | 3 | use std::fmt::Display; 4 | 5 | use itertools::Itertools; 6 | use serde::Deserialize; 7 | 8 | use super::chunked_sequence; 9 | use crate::{AlbumGroup, Artist, ArtistsAlbum, Client, Error, Market, Page, Response, Track}; 10 | 11 | /// Artist-related endpoints. 12 | #[derive(Debug, Clone, Copy)] 13 | pub struct Artists<'a>(pub &'a Client); 14 | 15 | impl Artists<'_> { 16 | /// Get information about an artist. 17 | /// 18 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/artists/get-artist/). 19 | pub async fn get_artist(self, id: &str) -> Result, Error> { 20 | self.0 21 | .send_json(self.0.client.get(endpoint!("/v1/artists/{}", id))) 22 | .await 23 | } 24 | 25 | /// Get information about several artists. 26 | /// 27 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/artists/get-several-artists/). 28 | pub async fn get_artists(self, ids: I) -> Result>, Error> 29 | where 30 | I::Item: Display, 31 | { 32 | #[derive(Deserialize)] 33 | struct Artists { 34 | artists: Vec, 35 | } 36 | 37 | chunked_sequence(ids, 50, |mut ids| { 38 | let req = self 39 | .0 40 | .client 41 | .get(endpoint!("/v1/artists")) 42 | .query(&(("ids", ids.join(",")),)); 43 | async move { 44 | Ok(self 45 | .0 46 | .send_json::(req) 47 | .await? 48 | .map(|res| res.artists)) 49 | } 50 | }) 51 | .await 52 | } 53 | 54 | /// Get an artist's albums. 55 | /// 56 | /// The `include_groups` parameter can specify which groups to include (`album`, `single`, 57 | /// `appears_on`, `compilation`). If not specified it includes them all. Limit and offset 58 | /// control the attributes of the resulting Page. Limit has a maximum of 50. 59 | /// 60 | /// If no market is specified this function is likely to give duplicate albums, one for each 61 | /// market, so it is advised to provide a market. 62 | /// 63 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/artists/get-artists-albums/). 64 | pub async fn get_artist_albums( 65 | self, 66 | id: &str, 67 | include_groups: Option<&[AlbumGroup]>, 68 | limit: usize, 69 | offset: usize, 70 | country: Option, 71 | ) -> Result>, Error> { 72 | self.0 73 | .send_json( 74 | self.0 75 | .client 76 | .get(endpoint!("/v1/artists/{}/albums", id)) 77 | .query(&( 78 | ("limit", limit.to_string()), 79 | ("offset", offset.to_string()), 80 | include_groups.map(|groups| { 81 | ( 82 | "include_groups", 83 | groups.iter().map(|group| group.as_str()).join(","), 84 | ) 85 | }), 86 | country.map(|m| ("country", m.as_str())), 87 | )), 88 | ) 89 | .await 90 | } 91 | 92 | /// Get an artist's top tracks. 93 | /// 94 | /// Unlike most other endpoints, the country code is required. The response contains up to 10 95 | /// tracks which are the artist's top tracks. 96 | /// 97 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/artists/get-artists-top-tracks/). 98 | pub async fn get_artist_top( 99 | self, 100 | id: &str, 101 | market: Market, 102 | ) -> Result>, Error> { 103 | #[derive(Deserialize)] 104 | struct Tracks { 105 | tracks: Vec, 106 | } 107 | 108 | Ok(self 109 | .0 110 | .send_json::( 111 | self.0 112 | .client 113 | .get(endpoint!("/v1/artists/{}/top-tracks", id)) 114 | .query(&(("country", market.as_str()),)), 115 | ) 116 | .await? 117 | .map(|res| res.tracks)) 118 | } 119 | 120 | /// Get an artist's related artists. 121 | /// 122 | /// These artists are similar in style to the given artist. 123 | /// 124 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/artists/get-related-artists/). 125 | pub async fn get_related_artists(self, id: &str) -> Result>, Error> { 126 | #[derive(Deserialize)] 127 | struct Artists { 128 | artists: Vec, 129 | } 130 | 131 | Ok(self 132 | .0 133 | .send_json::( 134 | self.0 135 | .client 136 | .get(endpoint!("/v1/artists/{}/related-artists", id)), 137 | ) 138 | .await? 139 | .map(|res| res.artists)) 140 | } 141 | } 142 | 143 | #[cfg(test)] 144 | mod tests { 145 | use isocountry::CountryCode; 146 | 147 | use crate::endpoints::client; 148 | use crate::{AlbumGroup, Market}; 149 | 150 | #[tokio::test] 151 | async fn test_get_artist() { 152 | let artist = client() 153 | .artists() 154 | .get_artist("0L8ExT028jH3ddEcZwqJJ5") 155 | .await 156 | .unwrap() 157 | .data; 158 | assert_eq!(artist.id, "0L8ExT028jH3ddEcZwqJJ5"); 159 | assert_eq!(artist.name, "Red Hot Chili Peppers"); 160 | } 161 | 162 | #[tokio::test] 163 | async fn test_get_artists() { 164 | let artists = client() 165 | .artists() 166 | .get_artists(&["0L8ExT028jH3ddEcZwqJJ5", "0gxyHStUsqpMadRV0Di1Qt"]) 167 | .await 168 | .unwrap() 169 | .data; 170 | assert_eq!(artists.len(), 2); 171 | assert_eq!(artists[0].name, "Red Hot Chili Peppers"); 172 | assert_eq!(artists[1].name, "Rick Astley"); 173 | } 174 | 175 | #[tokio::test] 176 | async fn test_get_artist_albums() { 177 | let albums = client() 178 | .artists() 179 | .get_artist_albums( 180 | "0L8ExT028jH3ddEcZwqJJ5", 181 | Some(&[AlbumGroup::Single]), 182 | 2, 183 | 1, 184 | Some(Market::Country(CountryCode::GBR)), 185 | ) 186 | .await 187 | .unwrap() 188 | .data; 189 | assert_eq!(albums.limit, 2); 190 | assert_eq!(albums.offset, 1); 191 | assert_eq!(albums.items.len(), 2); 192 | assert!(albums 193 | .items 194 | .iter() 195 | .all(|album| album.album_group == AlbumGroup::Single)); 196 | assert!(albums.items.iter().all(|album| album 197 | .artists 198 | .iter() 199 | .any(|artist| artist.name == "Red Hot Chili Peppers"))); 200 | } 201 | 202 | #[tokio::test] 203 | async fn test_get_artist_top() { 204 | let top = client() 205 | .artists() 206 | .get_artist_top("0L8ExT028jH3ddEcZwqJJ5", Market::Country(CountryCode::GBR)) 207 | .await 208 | .unwrap() 209 | .data; 210 | assert!(top.iter().all(|track| track 211 | .artists 212 | .iter() 213 | .any(|artist| artist.name == "Red Hot Chili Peppers"))); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/endpoints/browse.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use chrono::{DateTime, Utc}; 4 | use isocountry::CountryCode; 5 | use isolanguage_1::LanguageCode; 6 | use itertools::Itertools; 7 | use serde::{Deserialize, Serialize}; 8 | 9 | use crate::{ 10 | AlbumSimplified, Category, Client, Error, FeaturedPlaylists, Market, Page, PlaylistSimplified, 11 | Recommendations, Response, 12 | }; 13 | 14 | /// Endpoint functions related to categories, featured playlists, recommendations, and new 15 | /// releases. 16 | #[derive(Debug, Clone, Copy)] 17 | pub struct Browse<'a>(pub &'a Client); 18 | 19 | impl Browse<'_> { 20 | /// Get information about a category. 21 | /// 22 | /// If no locale is given or Spotify does not support the given locale, then it will default to 23 | /// American English. 24 | /// 25 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/browse/get-category/). 26 | pub async fn get_category( 27 | self, 28 | name: &str, 29 | locale: Option<(LanguageCode, CountryCode)>, 30 | country: Option, 31 | ) -> Result, Error> { 32 | self.0 33 | .send_json( 34 | self.0 35 | .client 36 | .get(endpoint!("/v1/browse/categories/{}", name)) 37 | .query(&( 38 | locale.map(|locale| ("locale", format_language(locale))), 39 | country.map(|c| ("country", c.alpha2())), 40 | )), 41 | ) 42 | .await 43 | } 44 | 45 | /// Get information about several categories. 46 | /// 47 | /// You do not choose which categories to get. Limit must be in the range [1..50]. If no locale 48 | /// is given or Spotify does not support the given locale, then it will default to American 49 | /// English. 50 | /// 51 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/browse/get-list-categories/). 52 | pub async fn get_categories( 53 | self, 54 | limit: usize, 55 | offset: usize, 56 | locale: Option<(LanguageCode, CountryCode)>, 57 | country: Option, 58 | ) -> Result>, Error> { 59 | #[derive(Deserialize)] 60 | struct CategoryPage { 61 | categories: Page, 62 | } 63 | 64 | Ok(self 65 | .0 66 | .send_json::(self.0.client.get(endpoint!("/v1/browse/categories")).query( 67 | &( 68 | ("limit", limit.to_string()), 69 | ("offset", offset.to_string()), 70 | locale.map(|l| ("locale", format_language(l))), 71 | country.map(|c| ("country", c.alpha2())), 72 | ), 73 | )) 74 | .await? 75 | .map(|res| res.categories)) 76 | } 77 | 78 | /// Get a category's playlists. 79 | /// 80 | /// Limit must be in the range [1..50]. 81 | /// 82 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/browse/get-categorys-playlists/). 83 | pub async fn get_category_playlists( 84 | self, 85 | name: &str, 86 | limit: usize, 87 | offset: usize, 88 | country: Option, 89 | ) -> Result>, Error> { 90 | #[derive(Deserialize)] 91 | struct Playlists { 92 | playlists: Page, 93 | } 94 | 95 | Ok(self 96 | .0 97 | .send_json::( 98 | self.0 99 | .client 100 | .get(endpoint!("/v1/browse/categories/{}/playlists", name)) 101 | .query(&( 102 | ("limit", limit.to_string()), 103 | ("offset", offset.to_string()), 104 | country.map(|c| ("country", c.alpha2())), 105 | )), 106 | ) 107 | .await? 108 | .map(|res| res.playlists)) 109 | } 110 | 111 | /// Get featured playlists. 112 | /// 113 | /// Limit must be in the range [1..50]. The locale will default to American English and the 114 | /// timestamp will default to the current UTC time. 115 | /// 116 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/browse/get-list-featured-playlists/). 117 | pub async fn get_featured_playlists( 118 | self, 119 | limit: usize, 120 | offset: usize, 121 | locale: Option<(LanguageCode, CountryCode)>, 122 | time: Option>, 123 | country: Option, 124 | ) -> Result, Error> { 125 | self.0 126 | .send_json( 127 | self.0 128 | .client 129 | .get(endpoint!("/v1/browse/featured-playlists")) 130 | .query(&( 131 | ("limit", limit.to_string()), 132 | ("offset", offset.to_string()), 133 | locale.map(|l| ("locale", format_language(l))), 134 | time.map(|t| ("timestamp", t.to_rfc3339())), 135 | country.map(|c| ("country", c.alpha2())), 136 | )), 137 | ) 138 | .await 139 | } 140 | 141 | /// Get new releases. 142 | /// 143 | /// Limit must be in the range [1..50]. The documentation claims to also return a message string, 144 | /// but in reality the API does not. 145 | /// 146 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/browse/get-list-new-releases/). 147 | pub async fn get_new_releases( 148 | self, 149 | limit: usize, 150 | offset: usize, 151 | country: Option, 152 | ) -> Result>, Error> { 153 | #[derive(Deserialize)] 154 | struct NewReleases { 155 | albums: Page, 156 | } 157 | 158 | Ok(self 159 | .0 160 | .send_json::( 161 | self.0 162 | .client 163 | .get(endpoint!("/v1/browse/new-releases")) 164 | .query(&( 165 | ("limit", limit.to_string()), 166 | ("offset", offset.to_string()), 167 | country.map(|c| ("country", c.alpha2())), 168 | )), 169 | ) 170 | .await? 171 | .map(|res| res.albums)) 172 | } 173 | 174 | /// Get recommendations. 175 | /// 176 | /// Up to 5 seed values may be provided, that can be distributed in `seed_artists`, 177 | /// `seed_genres` and `seed_tracks` in any way. Limit must be in the range [1..100] and this 178 | /// target number of tracks may not always be met. 179 | /// 180 | /// `attributes` must serialize to a string to string map or sequence of key-value tuples. See 181 | /// the reference for more info on this. 182 | /// 183 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/browse/get-recommendations/). 184 | pub async fn get_recommendations( 185 | self, 186 | seed_artists: AI, 187 | seed_genres: GI, 188 | seed_tracks: TI, 189 | attributes: &impl Serialize, 190 | limit: usize, 191 | market: Option, 192 | ) -> Result, Error> 193 | where 194 | AI::Item: Display, 195 | GI::Item: Display, 196 | TI::Item: Display, 197 | { 198 | self.0 199 | .send_json( 200 | self.0 201 | .client 202 | .get(endpoint!("/v1/recommendations")) 203 | .query(&( 204 | ("seed_artists", seed_artists.into_iter().join(",")), 205 | ("seed_genres", seed_genres.into_iter().join(",")), 206 | ("seed_tracks", seed_tracks.into_iter().join(",")), 207 | ("limit", limit.to_string()), 208 | market.map(Market::query), 209 | )) 210 | .query(attributes), 211 | ) 212 | .await 213 | } 214 | } 215 | 216 | fn format_language(locale: (LanguageCode, CountryCode)) -> String { 217 | format!("{}_{}", locale.0.code(), locale.1.alpha2()) 218 | } 219 | 220 | #[cfg(test)] 221 | mod tests { 222 | use chrono::DateTime; 223 | use isocountry::CountryCode; 224 | use isolanguage_1::LanguageCode; 225 | 226 | use crate::endpoints::client; 227 | use crate::{Market, SeedType}; 228 | 229 | #[tokio::test] 230 | async fn test_get_category() { 231 | let category = client() 232 | .browse() 233 | .get_category( 234 | "pop", 235 | Some((LanguageCode::En, CountryCode::GBR)), 236 | Some(CountryCode::GBR), 237 | ) 238 | .await 239 | .unwrap() 240 | .data; 241 | assert_eq!(category.id, "pop"); 242 | assert_eq!(category.name, "Pop"); 243 | } 244 | 245 | #[tokio::test] 246 | async fn test_get_categories() { 247 | let categories = client() 248 | .browse() 249 | .get_categories(2, 0, None, None) 250 | .await 251 | .unwrap() 252 | .data; 253 | assert_eq!(categories.limit, 2); 254 | assert_eq!(categories.offset, 0); 255 | assert!(categories.items.len() <= 2); 256 | } 257 | 258 | #[tokio::test] 259 | async fn test_get_category_playlists() { 260 | let playlists = client() 261 | .browse() 262 | .get_category_playlists("chill", 1, 3, Some(CountryCode::GBR)) 263 | .await 264 | .unwrap() 265 | .data; 266 | assert_eq!(playlists.limit, 1); 267 | assert_eq!(playlists.offset, 3); 268 | assert!(playlists.items.len() <= 1); 269 | } 270 | 271 | #[tokio::test] 272 | async fn test_get_featured_playlists() { 273 | let playlists = client() 274 | .browse() 275 | .get_featured_playlists( 276 | 2, 277 | 0, 278 | None, 279 | Some( 280 | DateTime::parse_from_rfc3339("2015-05-02T19:25:47Z") 281 | .unwrap() 282 | .into(), 283 | ), 284 | None, 285 | ) 286 | .await 287 | .unwrap() 288 | .data 289 | .playlists; 290 | assert_eq!(playlists.limit, 2); 291 | assert_eq!(playlists.offset, 0); 292 | assert!(playlists.items.len() <= 2); 293 | } 294 | 295 | #[tokio::test] 296 | async fn test_get_new_releases() { 297 | let releases = client() 298 | .browse() 299 | .get_new_releases(1, 0, None) 300 | .await 301 | .unwrap() 302 | .data; 303 | assert_eq!(releases.limit, 1); 304 | assert_eq!(releases.offset, 0); 305 | assert!(releases.items.len() <= 1); 306 | } 307 | 308 | #[tokio::test] 309 | async fn test_get_recommendations() { 310 | let recommendations = client() 311 | .browse() 312 | .get_recommendations( 313 | &["unused"; 0], 314 | &["rock"], 315 | &["2RTkebdbPFyg4AMIzJZql1", "6fTt0CH2t0mdeB2N9XFG5r"], 316 | &[ 317 | ("max_acousticness", "0.8"), 318 | ("min_loudness", "-40"), 319 | ("target_popularity", "100"), 320 | ], 321 | 3, 322 | Some(Market::Country(CountryCode::GBR)), 323 | ) 324 | .await 325 | .unwrap() 326 | .data; 327 | assert!(recommendations.seeds.len() <= 3); 328 | assert_eq!( 329 | recommendations 330 | .seeds 331 | .iter() 332 | .filter(|seed| seed.entity_type == SeedType::Artist) 333 | .count(), 334 | 0 335 | ); 336 | assert_eq!( 337 | recommendations 338 | .seeds 339 | .iter() 340 | .filter(|seed| seed.entity_type == SeedType::Genre) 341 | .count(), 342 | 1 343 | ); 344 | assert_eq!( 345 | recommendations 346 | .seeds 347 | .iter() 348 | .filter(|seed| seed.entity_type == SeedType::Track) 349 | .count(), 350 | 2 351 | ); 352 | assert!(recommendations.tracks.len() <= 3); 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /src/endpoints/episodes.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use itertools::Itertools; 4 | use serde::Deserialize; 5 | 6 | use super::chunked_sequence; 7 | use crate::{Client, CountryCode, Episode, Error, Response}; 8 | 9 | /// Endpoint functions relating to episodes. 10 | /// 11 | /// For all the below endpoints, the market parameter must be specified if a refresh token is not 12 | /// provided. If a refresh token is provided and the market parameter is specified, the user's 13 | /// market will take precedence. 14 | #[derive(Debug, Clone, Copy)] 15 | pub struct Episodes<'a>(pub &'a Client); 16 | 17 | impl Episodes<'_> { 18 | /// Get information about an episode. 19 | /// 20 | /// Reading the user's playback points requires `user-read-playback-position`. 21 | /// 22 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/episodes/get-an-episode/). 23 | pub async fn get_episode( 24 | self, 25 | id: &str, 26 | market: Option, 27 | ) -> Result, Error> { 28 | self.0 29 | .send_json( 30 | self.0 31 | .client 32 | .get(endpoint!("/v1/episodes/{}", id)) 33 | .query(&(market.map(|c| ("market", c.alpha2())),)), 34 | ) 35 | .await 36 | } 37 | 38 | /// Get information about several episodes. 39 | /// 40 | /// Reading the user's playback points requires `user-read-playback-position`. 41 | /// 42 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/episodes/get-several-episodes/). 43 | pub async fn get_episodes( 44 | self, 45 | ids: I, 46 | market: Option, 47 | ) -> Result>>, Error> 48 | where 49 | I::Item: Display, 50 | { 51 | #[derive(Deserialize)] 52 | struct Episodes { 53 | episodes: Vec>, 54 | } 55 | 56 | chunked_sequence(ids, 50, |mut ids| { 57 | let req = self.0.client.get(endpoint!("/v1/episodes")).query(&( 58 | ("ids", ids.join(",")), 59 | market.map(|m| ("market", m.alpha2())), 60 | )); 61 | async move { 62 | Ok(self 63 | .0 64 | .send_json::(req) 65 | .await? 66 | .map(|res| res.episodes)) 67 | } 68 | }) 69 | .await 70 | } 71 | } 72 | 73 | #[cfg(test)] 74 | mod tests { 75 | use isocountry::CountryCode; 76 | 77 | use crate::endpoints::client; 78 | 79 | #[tokio::test] 80 | async fn test_get_episode() { 81 | let episode = client() 82 | .episodes() 83 | .get_episode("512ojhOuo1ktJprKbVcKyQ", Some(CountryCode::ESP)) 84 | .await 85 | .unwrap() 86 | .data; 87 | assert_eq!(episode.name, "Tredje rikets knarkande granskas"); 88 | } 89 | 90 | #[tokio::test] 91 | async fn test_get_episodes() { 92 | let episodes = client() 93 | .episodes() 94 | .get_episodes( 95 | &["77o6BIVlYM3msb4MMIL1jH", "0Q86acNRm6V9GYx55SXKwf"], 96 | Some(CountryCode::CHL), 97 | ) 98 | .await 99 | .unwrap() 100 | .data; 101 | 102 | assert_eq!(episodes.len(), 2); 103 | 104 | let mut episodes = episodes.into_iter(); 105 | assert_eq!( 106 | episodes.next().unwrap().unwrap().name, 107 | "Riddarnas vapensköldar under lupp" 108 | ); 109 | assert_eq!( 110 | episodes.next().unwrap().unwrap().name, 111 | "Okända katedralen i Dalsland" 112 | ); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/endpoints/follow.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use itertools::Itertools; 4 | use reqwest::header; 5 | use serde::Deserialize; 6 | 7 | use super::{chunked_requests, chunked_sequence}; 8 | use crate::{Artist, Client, CursorPage, Error, Response}; 9 | 10 | /// Endpoint functions relating to following and unfollowing artists, users and playlists. 11 | #[derive(Debug, Clone, Copy)] 12 | pub struct Follow<'a>(pub &'a Client); 13 | 14 | impl Follow<'_> { 15 | /// Check if the current user follows some artists. 16 | /// 17 | /// Returns vector of bools that is in the same order as the given ids. Requires 18 | /// `user-follow-read`. 19 | /// 20 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/follow/check-current-user-follows/). 21 | pub async fn user_follows_artists( 22 | self, 23 | ids: I, 24 | ) -> Result>, Error> 25 | where 26 | I::Item: Display, 27 | { 28 | chunked_sequence(ids, 50, |mut ids| { 29 | let req = self 30 | .0 31 | .client 32 | .get(endpoint!("/v1/me/following/contains")) 33 | .query(&(("type", "artist"), ("ids", ids.join(",")))); 34 | async move { self.0.send_json(req).await } 35 | }) 36 | .await 37 | } 38 | 39 | /// Check if the current user follows some users. 40 | /// 41 | /// Returns vector of bools that is in the same order as the given ids. Requires 42 | /// `user-follow-read`. 43 | /// 44 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/follow/check-current-user-follows/). 45 | pub async fn user_follows_users( 46 | self, 47 | ids: I, 48 | ) -> Result>, Error> 49 | where 50 | I::Item: Display, 51 | { 52 | chunked_sequence(ids, 50, |mut ids| { 53 | let req = self 54 | .0 55 | .client 56 | .get(endpoint!("/v1/me/following/contains")) 57 | .query(&(("type", "user"), ("ids", ids.join(",")))); 58 | async move { self.0.send_json(req).await } 59 | }) 60 | .await 61 | } 62 | 63 | /// Check if some users follow a playlist. 64 | /// 65 | /// `id` is the id of the playlist and `user_ids` is the users who you want to check. Users can 66 | /// publicly or privately follow playlists; checking whether a user privately follows a playlist 67 | /// requires `playlist-read-private`. 68 | /// 69 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/follow/check-user-following-playlist/). 70 | pub async fn users_follow_playlist( 71 | self, 72 | id: &str, 73 | user_ids: I, 74 | ) -> Result>, Error> 75 | where 76 | I::Item: Display, 77 | { 78 | chunked_sequence(user_ids, 5, |mut user_ids| { 79 | let req = self 80 | .0 81 | .client 82 | .get(endpoint!("/v1/playlists/{}/followers/contains", id)) 83 | .query(&(("ids", user_ids.join(",")),)); 84 | async move { self.0.send_json(req).await } 85 | }) 86 | .await 87 | } 88 | 89 | /// Follow artists. 90 | /// 91 | /// Requires `user-follow-modify`. 92 | /// 93 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/follow/follow-artists-users/). 94 | pub async fn follow_artists(self, ids: I) -> Result<(), Error> 95 | where 96 | I::Item: Display, 97 | { 98 | chunked_requests(ids, 50, |mut ids| { 99 | let req = self 100 | .0 101 | .client 102 | .put(endpoint!("/v1/me/following")) 103 | .query(&(("type", "artist"), ("ids", ids.join(",")))) 104 | .body("{}"); 105 | async move { self.0.send_empty(req).await } 106 | }) 107 | .await 108 | } 109 | 110 | /// Follow users. 111 | /// 112 | /// Requires `user-follow-modify`. 113 | /// 114 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/follow/follow-artists-users/). 115 | pub async fn follow_users(self, ids: I) -> Result<(), Error> 116 | where 117 | I::Item: Display, 118 | { 119 | chunked_requests(ids, 50, |mut ids| { 120 | let req = self 121 | .0 122 | .client 123 | .put(endpoint!("/v1/me/following")) 124 | .query(&(("type", "user"), ("ids", ids.join(",")))) 125 | .body("{}"); 126 | async move { self.0.send_empty(req).await } 127 | }) 128 | .await 129 | } 130 | 131 | /// Follow a playlist publicly. 132 | /// 133 | /// Requires `playlist-modify-public`. 134 | /// 135 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/follow/follow-playlist/). 136 | pub async fn follow_playlist_public(self, id: &str) -> Result<(), Error> { 137 | self.0 138 | .send_empty( 139 | self.0 140 | .client 141 | .put(endpoint!("/v1/playlists/{}/followers", id)) 142 | .header(header::CONTENT_TYPE, "application/json") 143 | .body(r#"{"public":true}"#), 144 | ) 145 | .await 146 | } 147 | 148 | /// Follow a playlist privately. 149 | /// 150 | /// Requires `playlist-modify-private`. 151 | /// 152 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/follow/follow-playlist/). 153 | pub async fn follow_playlist_private(self, id: &str) -> Result<(), Error> { 154 | self.0 155 | .send_empty( 156 | self.0 157 | .client 158 | .put(endpoint!("/v1/playlists/{}/followers", id)) 159 | .header(header::CONTENT_TYPE, "application/json") 160 | .body(r#"{"public":false}"#), 161 | ) 162 | .await 163 | } 164 | 165 | /// Get followed artists. 166 | /// 167 | /// Limit must be in the range [1..50]. `after` is the Cursor value given the previous time this 168 | /// endpoint was called. It is used to get the next page of items. 169 | /// 170 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/follow/get-followed/). 171 | pub async fn get_followed_artists( 172 | self, 173 | limit: usize, 174 | after: Option<&str>, 175 | ) -> Result>, Error> { 176 | #[derive(Deserialize)] 177 | struct Response { 178 | artists: CursorPage, 179 | } 180 | 181 | Ok(self 182 | .0 183 | .send_json::(self.0.client.get(endpoint!("/v1/me/following")).query(&( 184 | ("type", "artist"), 185 | ("limit", limit.to_string()), 186 | after.map(|after| ("after", after)), 187 | ))) 188 | .await? 189 | .map(|res| res.artists)) 190 | } 191 | 192 | /// Unfollow artists. 193 | /// 194 | /// Requires `user-follow-modify`. 195 | /// 196 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/follow/unfollow-artists-users/). 197 | pub async fn unfollow_artists(self, ids: I) -> Result<(), Error> 198 | where 199 | I::Item: Display, 200 | { 201 | chunked_requests(ids, 50, |mut ids| { 202 | let req = self 203 | .0 204 | .client 205 | .delete(endpoint!("/v1/me/following")) 206 | .query(&(("type", "artist"), ("ids", ids.join(",")))) 207 | .body("{}"); 208 | async move { self.0.send_empty(req).await } 209 | }) 210 | .await 211 | } 212 | 213 | /// Unfollow users. 214 | /// 215 | /// Requires `user-follow-modify`. 216 | /// 217 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/follow/unfollow-artists-users/). 218 | pub async fn unfollow_users(self, ids: I) -> Result<(), Error> 219 | where 220 | I::Item: Display, 221 | { 222 | chunked_requests(ids, 50, |mut ids| { 223 | let req = self 224 | .0 225 | .client 226 | .delete(endpoint!("/v1/me/following")) 227 | .query(&(("type", "users"), ("ids", ids.join(",")))) 228 | .body("{}"); 229 | async move { self.0.send_empty(req).await } 230 | }) 231 | .await 232 | } 233 | 234 | /// Unfollow a playlist. 235 | /// 236 | /// If the user follows it publicly you need `playlist-modify-public`, if the user follows it 237 | /// privately you need `playlist-modiy-private`. 238 | /// 239 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/follow/unfollow-playlist/). 240 | pub async fn unfollow_playlist(self, id: &str) -> Result<(), Error> { 241 | self.0 242 | .send_empty( 243 | self.0 244 | .client 245 | .delete(endpoint!("/v1/playlists/{}/followers", id)) 246 | .body("{}"), 247 | ) 248 | .await 249 | } 250 | } 251 | 252 | #[cfg(test)] 253 | mod tests { 254 | use crate::endpoints::client; 255 | 256 | #[tokio::test] 257 | async fn test_follow_artists() { 258 | // NOTE: This test only works if you follow < 49 artists as it only requests the first page. 259 | // You also must not follow Lemon Demon. 260 | let client = client(); 261 | let follow = client.follow(); 262 | 263 | // TOTO, Eminem and Lemon Demon 264 | let artists = &[ 265 | "0PFtn5NtBbbUNbU9EAmIWF", 266 | "7dGJo4pcD2V6oG8kP0tJRR", 267 | "4llAOeA6kEF4ytaB2fsmcW", 268 | ]; 269 | let split = 2; 270 | let (followed_artists, unfollowed_artists) = artists.split_at(split); 271 | 272 | // Store old 273 | let old = follow.user_follows_artists(artists).await.unwrap().data; 274 | 275 | // Following and unfollowing 276 | follow.follow_artists(followed_artists).await.unwrap(); 277 | follow.unfollow_artists(unfollowed_artists).await.unwrap(); 278 | 279 | // Check 280 | let check = follow.user_follows_artists(artists).await.unwrap().data; 281 | let (follow_check, unfollow_check) = check.split_at(split); 282 | assert!(follow_check.iter().all(|&followed| followed)); 283 | assert!(unfollow_check.iter().all(|&followed| !followed)); 284 | 285 | // Check by finding in list 286 | let followed = follow.get_followed_artists(50, None).await.unwrap().data; 287 | if followed.total <= 50 { 288 | for followed_artist in followed_artists { 289 | assert!(followed 290 | .items 291 | .iter() 292 | .any(|artist| artist.id == *followed_artist)); 293 | } 294 | for unfollowed_artist in unfollowed_artists { 295 | assert!(followed 296 | .items 297 | .iter() 298 | .all(|artist| artist.id != *unfollowed_artist)); 299 | } 300 | } 301 | 302 | // Restore 303 | let mut old_followed = Vec::with_capacity(artists.len()); 304 | let mut old_unfollowed = Vec::with_capacity(artists.len()); 305 | for i in 0..artists.len() { 306 | if old[i] { 307 | &mut old_followed 308 | } else { 309 | &mut old_unfollowed 310 | } 311 | .push(artists[i]); 312 | } 313 | if !old_followed.is_empty() { 314 | follow.follow_artists(&old_followed).await.unwrap(); 315 | } 316 | if !old_unfollowed.is_empty() { 317 | follow.unfollow_artists(&old_unfollowed).await.unwrap(); 318 | } 319 | } 320 | 321 | #[tokio::test] 322 | async fn test_follow_playlists() { 323 | let client = client(); 324 | let follow = client.follow(); 325 | 326 | // Follow "Sing-Along Indie Hits" playlist 327 | follow 328 | .follow_playlist_public("37i9dQZF1DWYBF1dYDPlHw") 329 | .await 330 | .unwrap(); 331 | 332 | // Check whether following playlist 333 | let id = client 334 | .users_profile() 335 | .get_current_user() 336 | .await 337 | .unwrap() 338 | .data 339 | .id; 340 | let followers = follow 341 | .users_follow_playlist("37i9dQZF1DWYBF1dYDPlHw", &["spotify", &id]) 342 | .await 343 | .unwrap() 344 | .data; 345 | assert_eq!(followers, &[false, true]); 346 | 347 | // Unfollow 348 | follow 349 | .unfollow_playlist("37i9dQZF1DWYBF1dYDPlHw") 350 | .await 351 | .unwrap(); 352 | } 353 | } 354 | -------------------------------------------------------------------------------- /src/endpoints/library.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use itertools::Itertools; 4 | 5 | use super::{chunked_requests, chunked_sequence}; 6 | use crate::{Client, Error, Market, Page, Response, SavedAlbum, SavedShow, SavedTrack}; 7 | 8 | /// Endpoints relating to saving albums and tracks. 9 | #[derive(Debug, Clone, Copy)] 10 | pub struct Library<'a>(pub &'a Client); 11 | 12 | impl Library<'_> { 13 | /// Check if the current user has saved some albums. 14 | /// 15 | /// Returns vector of bools that is in the same order as the given ids, telling whether the user 16 | /// has saved each album. Requires `user-library-read`. 17 | /// 18 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/library/check-users-saved-albums/). 19 | pub async fn user_saved_albums( 20 | self, 21 | ids: I, 22 | ) -> Result>, Error> 23 | where 24 | I::Item: Display, 25 | { 26 | chunked_sequence(ids, 50, |mut ids| { 27 | let req = self 28 | .0 29 | .client 30 | .get(endpoint!("/v1/me/albums/contains")) 31 | .query(&(("ids", ids.join(",")),)); 32 | async move { self.0.send_json(req).await } 33 | }) 34 | .await 35 | } 36 | 37 | /// Check if the current user has saved some shows. 38 | /// 39 | /// Returns vector of bools that is in the same order as the given ids, telling whether the user 40 | /// has saved each album. Requires `user-library-read`. 41 | /// 42 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/library/check-users-saved-shows/). 43 | pub async fn user_saved_shows( 44 | self, 45 | ids: I, 46 | ) -> Result>, Error> 47 | where 48 | I::Item: Display, 49 | { 50 | chunked_sequence(ids, 50, |mut ids| { 51 | let req = self 52 | .0 53 | .client 54 | .get(endpoint!("/v1/me/shows/contains")) 55 | .query(&(("ids", ids.join(",")),)); 56 | async move { self.0.send_json(req).await } 57 | }) 58 | .await 59 | } 60 | 61 | /// Check if the current user has saved some tracks. 62 | /// 63 | /// Returns vector of bools that is in the same order as the given ids, telling whether the user 64 | /// has saved each track. Requires `user-library-read`. 65 | /// 66 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/library/check-users-saved-tracks/). 67 | pub async fn user_saved_tracks( 68 | self, 69 | ids: I, 70 | ) -> Result>, Error> 71 | where 72 | I::Item: Display, 73 | { 74 | chunked_sequence(ids, 50, |mut ids| { 75 | let req = self 76 | .0 77 | .client 78 | .get(endpoint!("/v1/me/tracks/contains")) 79 | .query(&(("ids", ids.join(",")),)); 80 | async move { self.0.send_json(req).await } 81 | }) 82 | .await 83 | } 84 | 85 | /// Get the current user's saved albums. 86 | /// 87 | /// Requires `user-library-read`. Limit must be in the range [1..50]. 88 | /// 89 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/library/get-users-saved-albums/). 90 | pub async fn get_saved_albums( 91 | self, 92 | limit: usize, 93 | offset: usize, 94 | market: Option, 95 | ) -> Result>, Error> { 96 | self.0 97 | .send_json(self.0.client.get(endpoint!("/v1/me/albums")).query(&( 98 | ("limit", limit.to_string()), 99 | ("offset", offset.to_string()), 100 | market.map(Market::query), 101 | ))) 102 | .await 103 | } 104 | 105 | /// Get the current user's saved shows. 106 | /// 107 | /// Requires `user-library-read`. Limit must be in the range [1..50]. 108 | /// 109 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/library/get-users-saved-shows/). 110 | pub async fn get_saved_shows( 111 | self, 112 | limit: usize, 113 | offset: usize, 114 | ) -> Result>, Error> { 115 | self.0 116 | .send_json( 117 | self.0 118 | .client 119 | .get(endpoint!("/v1/me/shows")) 120 | .query(&(("limit", limit.to_string()), ("offset", offset.to_string()))), 121 | ) 122 | .await 123 | } 124 | 125 | /// Get the current user's saved tracks. 126 | /// 127 | /// Requires `user-library-read`. Limit must be in the range [1..50]. 128 | /// 129 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/library/get-users-saved-tracks/). 130 | pub async fn get_saved_tracks( 131 | self, 132 | limit: usize, 133 | offset: usize, 134 | market: Option, 135 | ) -> Result>, Error> { 136 | self.0 137 | .send_json(self.0.client.get(endpoint!("/v1/me/tracks")).query(&( 138 | ("limit", limit.to_string()), 139 | ("offset", offset.to_string()), 140 | market.map(Market::query), 141 | ))) 142 | .await 143 | } 144 | 145 | /// Unsave some of the current user's saved albums. 146 | /// 147 | /// Requires `user-library-modify`. 148 | /// 149 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/library/remove-albums-user/). 150 | pub async fn unsave_albums(self, ids: I) -> Result<(), Error> 151 | where 152 | I::Item: Display, 153 | { 154 | chunked_requests(ids, 50, |mut ids| { 155 | let req = self 156 | .0 157 | .client 158 | .delete(endpoint!("/v1/me/albums")) 159 | .query(&(("ids", ids.join(",")),)) 160 | .body("{}"); 161 | async move { self.0.send_empty(req).await } 162 | }) 163 | .await 164 | } 165 | 166 | /// Unsave some of the current user's saved shows. 167 | /// 168 | /// Requires `user-library-modify`. 169 | /// 170 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/library/remove-shows-user/). 171 | pub async fn unsave_shows(self, ids: I) -> Result<(), Error> 172 | where 173 | I::Item: Display, 174 | { 175 | chunked_requests(ids, 50, |mut ids| { 176 | let req = self 177 | .0 178 | .client 179 | .delete(endpoint!("/v1/me/shows")) 180 | .query(&(("ids", ids.join(",")),)) 181 | .body("{}"); 182 | async move { self.0.send_empty(req).await } 183 | }) 184 | .await 185 | } 186 | 187 | /// Unsave some of the current user's saved tracks. 188 | /// 189 | /// Requires `user-library-modify`. 190 | /// 191 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/library/remove-tracks-user/). 192 | pub async fn unsave_tracks(self, ids: I) -> Result<(), Error> 193 | where 194 | I::Item: Display, 195 | { 196 | chunked_requests(ids, 50, |mut ids| { 197 | let req = self 198 | .0 199 | .client 200 | .delete(endpoint!("/v1/me/tracks")) 201 | .query(&(("ids", ids.join(",")),)) 202 | .body("{}"); 203 | async move { self.0.send_empty(req).await } 204 | }) 205 | .await 206 | } 207 | 208 | /// Save albums for the current user. 209 | /// 210 | /// Requires `user-library-modify`. 211 | /// 212 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/library/save-albums-user/). 213 | pub async fn save_albums(self, ids: I) -> Result<(), Error> 214 | where 215 | I::Item: Display, 216 | { 217 | chunked_requests(ids, 50, |mut ids| { 218 | let req = self 219 | .0 220 | .client 221 | .put(endpoint!("/v1/me/albums")) 222 | .query(&(("ids", ids.join(",")),)) 223 | .body("{}"); 224 | async move { self.0.send_empty(req).await } 225 | }) 226 | .await 227 | } 228 | 229 | /// Save shows for the current user. 230 | /// 231 | /// Requires `user-library-modify`. 232 | /// 233 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/library/save-shows-user/). 234 | pub async fn save_shows(self, ids: I) -> Result<(), Error> 235 | where 236 | I::Item: Display, 237 | { 238 | chunked_requests(ids, 50, |mut ids| { 239 | let req = self 240 | .0 241 | .client 242 | .put(endpoint!("/v1/me/shows")) 243 | .query(&(("ids", ids.join(",")),)) 244 | .body("{}"); 245 | async move { self.0.send_empty(req).await } 246 | }) 247 | .await 248 | } 249 | 250 | /// Save tracks for the current user. 251 | /// 252 | /// Requires `user-library-modify`. 253 | /// 254 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/library/save-albums-user/). 255 | pub async fn save_tracks(self, ids: I) -> Result<(), Error> 256 | where 257 | I::Item: Display, 258 | { 259 | chunked_requests(ids, 50, |mut ids| { 260 | let req = self 261 | .0 262 | .client 263 | .put(endpoint!("/v1/me/tracks")) 264 | .query(&(("ids", ids.join(",")),)) 265 | .body("{}"); 266 | async move { self.0.send_empty(req).await } 267 | }) 268 | .await 269 | } 270 | } 271 | 272 | #[cfg(test)] 273 | mod tests { 274 | use crate::endpoints::client; 275 | 276 | #[tokio::test] 277 | async fn test_save_albums() { 278 | let client = client(); 279 | let library = client.library(); 280 | 281 | // "Wish", "The Black Parade", and "Spirit Phone" 282 | let albums = &[ 283 | "0aEL0zQ4XLuxQP0j7sLlS1", 284 | "0FZK97MXMm5mUQ8mtudjuK", 285 | "4ocal2JegUDVQdP6KN1roI", 286 | ]; 287 | let split = 2; 288 | let (saved_albums, unsaved_albums) = albums.split_at(split); 289 | 290 | // Store old saved status to restore 291 | let old = library.user_saved_albums(albums).await.unwrap().data; 292 | 293 | // Saving and unsaving 294 | library.save_albums(saved_albums).await.unwrap(); 295 | library.unsave_albums(unsaved_albums).await.unwrap(); 296 | 297 | // Check 298 | let check = library.user_saved_albums(albums).await.unwrap().data; 299 | let (save_check, unsave_check) = check.split_at(split); 300 | assert!(save_check.iter().all(|&saved| saved)); 301 | assert!(unsave_check.iter().all(|&saved| !saved)); 302 | 303 | // Check by finding in list 304 | let saved = library.get_saved_albums(50, 0, None).await.unwrap().data; 305 | if saved.total <= 50 { 306 | for saved_album in saved_albums { 307 | assert!(saved 308 | .items 309 | .iter() 310 | .any(|album| album.album.id == *saved_album)); 311 | } 312 | for unsaved_album in unsaved_albums { 313 | assert!(saved 314 | .items 315 | .iter() 316 | .all(|album| album.album.id != *unsaved_album)); 317 | } 318 | } 319 | 320 | // Restore 321 | let mut old_saved = Vec::with_capacity(albums.len()); 322 | let mut old_unsaved = Vec::with_capacity(albums.len()); 323 | for i in 0..albums.len() { 324 | if old[i] { 325 | &mut old_saved 326 | } else { 327 | &mut old_unsaved 328 | } 329 | .push(albums[i]); 330 | } 331 | if !old_saved.is_empty() { 332 | library.save_albums(&old_saved).await.unwrap(); 333 | } 334 | if !old_unsaved.is_empty() { 335 | library.unsave_albums(&old_unsaved).await.unwrap(); 336 | } 337 | } 338 | 339 | #[tokio::test] 340 | async fn test_save_shows() { 341 | let client = client(); 342 | let library = client.library(); 343 | 344 | let shows = &["5CfCWKI5pZ28U0uOzXkDHe", "6ups0LMt1G8n81XLlkbsPo"]; 345 | let split = 1; 346 | let (saved_shows, unsaved_shows) = shows.split_at(split); 347 | 348 | // Store old saved status to restore 349 | let old = library.user_saved_shows(shows).await.unwrap().data; 350 | 351 | // Saving and unsaving 352 | library.save_shows(saved_shows).await.unwrap(); 353 | library.unsave_shows(unsaved_shows).await.unwrap(); 354 | 355 | // Check 356 | let check = library.user_saved_shows(shows).await.unwrap().data; 357 | let (save_check, unsave_check) = check.split_at(split); 358 | assert!(save_check.iter().all(|&saved| saved)); 359 | assert!(unsave_check.iter().all(|&saved| !saved)); 360 | 361 | // Check by finding in list, only if it has them all 362 | let saved = library.get_saved_shows(50, 0).await.unwrap().data; 363 | if saved.total <= 50 { 364 | for saved_show in saved_shows { 365 | assert!(saved.items.iter().any(|show| show.show.id == *saved_show)); 366 | } 367 | for unsaved_show in unsaved_shows { 368 | assert!(saved.items.iter().all(|show| show.show.id != *unsaved_show)); 369 | } 370 | } 371 | 372 | // Restore 373 | let mut old_saved = Vec::with_capacity(shows.len()); 374 | let mut old_unsaved = Vec::with_capacity(shows.len()); 375 | for i in 0..shows.len() { 376 | if old[i] { 377 | &mut old_saved 378 | } else { 379 | &mut old_unsaved 380 | } 381 | .push(shows[i]); 382 | } 383 | if !old_saved.is_empty() { 384 | library.save_shows(&old_saved).await.unwrap(); 385 | } 386 | if !old_unsaved.is_empty() { 387 | library.unsave_shows(&old_unsaved).await.unwrap(); 388 | } 389 | } 390 | 391 | #[tokio::test] 392 | async fn test_save_tracks() { 393 | let client = client(); 394 | let library = client.library(); 395 | 396 | // "Friday I'm In Love" and "Spiral of Ants" 397 | let tracks = &["4QlzkaRHtU8gAdwqjWmO8n", "77hzctaLvLRLAh71LwNPE3"]; 398 | let split = 1; 399 | let (saved_tracks, unsaved_tracks) = tracks.split_at(split); 400 | 401 | // Store old saved status to restore 402 | let old = library.user_saved_tracks(tracks).await.unwrap().data; 403 | 404 | // Saving and unsaving 405 | library.save_tracks(saved_tracks).await.unwrap(); 406 | library.unsave_tracks(unsaved_tracks).await.unwrap(); 407 | 408 | // Check 409 | let check = library.user_saved_tracks(tracks).await.unwrap().data; 410 | let (save_check, unsave_check) = check.split_at(split); 411 | assert!(save_check.iter().all(|&saved| saved)); 412 | assert!(unsave_check.iter().all(|&saved| !saved)); 413 | 414 | // Check by finding in list, only if it has them all 415 | let saved = library.get_saved_tracks(50, 0, None).await.unwrap().data; 416 | if saved.total <= 50 { 417 | for saved_track in saved_tracks { 418 | assert!(saved 419 | .items 420 | .iter() 421 | .any(|track| track.track.id.as_ref().unwrap() == *saved_track)); 422 | } 423 | for unsaved_track in unsaved_tracks { 424 | assert!(saved 425 | .items 426 | .iter() 427 | .all(|track| track.track.id.as_ref().unwrap() != *unsaved_track)); 428 | } 429 | } 430 | 431 | // Restore 432 | let mut old_saved = Vec::with_capacity(tracks.len()); 433 | let mut old_unsaved = Vec::with_capacity(tracks.len()); 434 | for i in 0..tracks.len() { 435 | if old[i] { 436 | &mut old_saved 437 | } else { 438 | &mut old_unsaved 439 | } 440 | .push(tracks[i]); 441 | } 442 | if !old_saved.is_empty() { 443 | library.save_tracks(&old_saved).await.unwrap(); 444 | } 445 | if !old_unsaved.is_empty() { 446 | library.unsave_tracks(&old_unsaved).await.unwrap(); 447 | } 448 | } 449 | } 450 | -------------------------------------------------------------------------------- /src/endpoints/mod.rs: -------------------------------------------------------------------------------- 1 | //! Endpoint types. 2 | //! 3 | //! These types are transparent, short-lived wrappers around `Client`. They avoid having an 4 | //! enormous number of methods on the `Client` itself. They can be created from methods on 5 | //! `Client`, so you generally won't ever need to name them. 6 | //! 7 | //! # Common Parameters 8 | //! 9 | //! These are some common parameters used in endpoint functions. 10 | //! 11 | //! | Parameter | Use | 12 | //! | --- | --- | 13 | //! | `id(s)` | The [Spotify ID(s)](https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids) of the required resource. | 14 | //! | `country` | Limits the request to one particular country, so that resources not available in the country will not appear in the results. | 15 | //! | `market` | Limits the request to one particular country, and applies [Track Relinking](https://developer.spotify.com/documentation/general/guides/track-relinking-guide/). | 16 | //! | `locale` | The language of the response. It consists of an ISO-639 language code and an ISO-3166 country code (for, example, En and GBR is British English). | 17 | //! | `limit` | When the function returns a [`Page`](crate::Page), [`CursorPage`](crate::CursorPage) or [`TwoWayCursorPage`](crate::TwoWayCursorPage), this determines the maximum length of the page. | 18 | //! | `offset` | When the function returns a [`Page`](crate::Page), this determines what index in the larger list the page starts at. | 19 | //! | `cursor`, `before` and `after` | When the function returns a [`CursorPage`](crate::CursorPage) or [`TwoWayCursorPage`](crate::TwoWayCursorPage), this determines to give the next (`cursor` or `after`) or previous (`before`) page. | 20 | #![allow(clippy::missing_errors_doc)] 21 | 22 | use std::future::Future; 23 | use std::iter; 24 | use std::time::Instant; 25 | 26 | use futures_util::stream::{FuturesOrdered, FuturesUnordered, StreamExt, TryStreamExt}; 27 | use isocountry::CountryCode; 28 | 29 | use crate::{Client, Error, Response}; 30 | 31 | pub use albums::*; 32 | pub use artists::*; 33 | pub use browse::*; 34 | pub use episodes::*; 35 | pub use follow::*; 36 | pub use library::*; 37 | pub use personalization::*; 38 | pub use player::*; 39 | pub use playlists::*; 40 | pub use search::*; 41 | pub use shows::*; 42 | pub use tracks::*; 43 | pub use users_profile::*; 44 | 45 | macro_rules! endpoint { 46 | ($path:literal) => { 47 | concat!("https://api.spotify.com", $path) 48 | }; 49 | ($path:literal, $($fmt:tt)*) => { 50 | &format!(endpoint!($path), $($fmt)*) 51 | }; 52 | } 53 | 54 | mod albums; 55 | mod artists; 56 | mod browse; 57 | mod episodes; 58 | mod follow; 59 | mod library; 60 | mod personalization; 61 | mod player; 62 | mod playlists; 63 | mod search; 64 | mod shows; 65 | mod tracks; 66 | mod users_profile; 67 | 68 | /// Endpoint function namespaces. 69 | impl Client { 70 | /// Album-related endpoints. 71 | #[must_use] 72 | pub const fn albums(&self) -> Albums<'_> { 73 | Albums(self) 74 | } 75 | 76 | /// Artist-related endpoints. 77 | #[must_use] 78 | pub const fn artists(&self) -> Artists<'_> { 79 | Artists(self) 80 | } 81 | 82 | /// Endpoint functions related to categories, featured playlists, recommendations, and new 83 | /// releases. 84 | #[must_use] 85 | pub const fn browse(&self) -> Browse<'_> { 86 | Browse(self) 87 | } 88 | 89 | /// Episode-related endpoints. 90 | #[must_use] 91 | pub const fn episodes(&self) -> Episodes<'_> { 92 | Episodes(self) 93 | } 94 | 95 | /// Endpoint functions related to following and unfollowing artists, users and playlists. 96 | #[must_use] 97 | pub const fn follow(&self) -> Follow<'_> { 98 | Follow(self) 99 | } 100 | 101 | /// Endpoints relating to saving albums and tracks. 102 | #[must_use] 103 | pub const fn library(&self) -> Library<'_> { 104 | Library(self) 105 | } 106 | 107 | /// Endpoint functions relating to a user's top artists and tracks. 108 | #[must_use] 109 | pub const fn personalization(&self) -> Personalization<'_> { 110 | Personalization(self) 111 | } 112 | 113 | /// Endpoint functions related to controlling what is playing on the current user's Spotify 114 | /// account. (Beta) 115 | #[must_use] 116 | pub const fn player(&self) -> Player<'_> { 117 | Player(self) 118 | } 119 | 120 | /// Endpoint functions related to playlists. 121 | #[must_use] 122 | pub const fn playlists(&self) -> Playlists<'_> { 123 | Playlists(self) 124 | } 125 | 126 | /// Endpoint functions related to searches. 127 | #[must_use] 128 | pub const fn search(&self) -> Search<'_> { 129 | Search(self) 130 | } 131 | 132 | /// Endpoint functions related to shows. 133 | #[must_use] 134 | pub const fn shows(&self) -> Shows<'_> { 135 | Shows(self) 136 | } 137 | 138 | /// Endpoint functions related to tracks and audio analysis. 139 | #[must_use] 140 | pub const fn tracks(&self) -> Tracks<'_> { 141 | Tracks(self) 142 | } 143 | 144 | /// Endpoint functions related to users' profiles. 145 | #[must_use] 146 | pub const fn users_profile(&self) -> UsersProfile<'_> { 147 | UsersProfile(self) 148 | } 149 | } 150 | 151 | /// A market in which to limit the request to. 152 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 153 | pub enum Market { 154 | /// A country code. 155 | Country(CountryCode), 156 | /// Deduce the current country from the access token. Requires `user-read-private`. 157 | FromToken, 158 | } 159 | 160 | impl Market { 161 | fn as_str(self) -> &'static str { 162 | match self { 163 | Market::Country(code) => code.alpha2(), 164 | Market::FromToken => "from_token", 165 | } 166 | } 167 | fn query(self) -> (&'static str, &'static str) { 168 | ("market", self.as_str()) 169 | } 170 | } 171 | 172 | /// A time range from which to calculate the response. 173 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 174 | pub enum TimeRange { 175 | /// Use approximately the last 4 weeks of data. 176 | Short, 177 | /// Use approximately the last 6 months of data. 178 | Medium, 179 | /// Use several years of data. 180 | Long, 181 | } 182 | 183 | impl TimeRange { 184 | fn as_str(self) -> &'static str { 185 | match self { 186 | Self::Long => "long_term", 187 | Self::Medium => "medium_term", 188 | Self::Short => "short_term", 189 | } 190 | } 191 | } 192 | 193 | type Chunk<'a, I> = iter::Take<&'a mut iter::Peekable>; 194 | 195 | async fn chunked_sequence( 196 | items: I, 197 | chunk_size: usize, 198 | mut f: impl FnMut(Chunk<'_, I::IntoIter>) -> Fut, 199 | ) -> Result>, Error> 200 | where 201 | Fut: Future>, Error>>, 202 | { 203 | let mut items = items.into_iter().peekable(); 204 | let mut futures = FuturesOrdered::new(); 205 | 206 | while items.peek().is_some() { 207 | futures.push(f(items.by_ref().take(chunk_size))); 208 | } 209 | 210 | let mut response = Response { 211 | data: Vec::new(), 212 | expires: Instant::now(), 213 | }; 214 | 215 | while let Some(mut r) = futures.next().await.transpose()? { 216 | response.data.append(&mut r.data); 217 | response.expires = r.expires; 218 | } 219 | 220 | Ok(response) 221 | } 222 | 223 | async fn chunked_requests( 224 | items: I, 225 | chunk_size: usize, 226 | mut f: impl FnMut(Chunk<'_, I::IntoIter>) -> Fut, 227 | ) -> Result<(), Error> 228 | where 229 | Fut: Future>, 230 | { 231 | let mut items = items.into_iter().peekable(); 232 | let futures = FuturesUnordered::new(); 233 | 234 | while items.peek().is_some() { 235 | futures.push(f(items.by_ref().take(chunk_size))); 236 | } 237 | 238 | futures.try_collect().await 239 | } 240 | 241 | #[cfg(test)] 242 | fn client() -> crate::Client { 243 | dotenv::dotenv().unwrap(); 244 | let mut client = crate::Client::with_refresh( 245 | crate::ClientCredentials::from_env().unwrap(), 246 | std::fs::read_to_string(".refresh_token").unwrap(), 247 | ); 248 | client.debug = true; 249 | client 250 | } 251 | -------------------------------------------------------------------------------- /src/endpoints/personalization.rs: -------------------------------------------------------------------------------- 1 | use crate::{Artist, Client, Error, Page, Response, TimeRange, Track}; 2 | 3 | /// Endpoint functions relating to a user's top artists and tracks. 4 | #[derive(Debug, Clone, Copy)] 5 | pub struct Personalization<'a>(pub &'a Client); 6 | 7 | impl Personalization<'_> { 8 | /// Get a user's top artists. 9 | /// 10 | /// Requires `user-top-read`. 11 | /// 12 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/personalization/get-users-top-artists-and-tracks/). 13 | pub async fn get_top_artists( 14 | self, 15 | limit: usize, 16 | offset: usize, 17 | time_range: TimeRange, 18 | ) -> Result>, Error> { 19 | self.0 20 | .send_json(self.0.client.get(endpoint!("/v1/me/top/artists")).query(&( 21 | ("limit", limit.to_string()), 22 | ("offset", offset.to_string()), 23 | ("time_range", time_range.as_str()), 24 | ))) 25 | .await 26 | } 27 | 28 | /// Get a user's top tracks. 29 | /// 30 | /// Requires `user-top-read`. 31 | /// 32 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/personalization/get-users-top-tracks-and-tracks/). 33 | pub async fn get_top_tracks( 34 | self, 35 | limit: usize, 36 | offset: usize, 37 | time_range: TimeRange, 38 | ) -> Result>, Error> { 39 | self.0 40 | .send_json(self.0.client.get(endpoint!("/v1/me/top/tracks")).query(&( 41 | ("limit", limit.to_string()), 42 | ("offset", offset.to_string()), 43 | ("time_range", time_range.as_str()), 44 | ))) 45 | .await 46 | } 47 | } 48 | 49 | #[cfg(test)] 50 | mod tests { 51 | use crate::endpoints::client; 52 | use crate::TimeRange; 53 | 54 | #[tokio::test] 55 | async fn test() { 56 | let client = client(); 57 | let personalization = client.personalization(); 58 | 59 | let top = personalization 60 | .get_top_artists(5, 2, TimeRange::Short) 61 | .await 62 | .unwrap() 63 | .data; 64 | assert_eq!(top.limit, 5); 65 | assert_eq!(top.offset, 2); 66 | assert!(top.items.len() <= 5); 67 | 68 | let top = personalization 69 | .get_top_tracks(2, 8, TimeRange::Long) 70 | .await 71 | .unwrap() 72 | .data; 73 | assert_eq!(top.limit, 2); 74 | assert_eq!(top.offset, 8); 75 | assert!(top.items.len() <= 2); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/endpoints/player.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | use std::time::Duration; 3 | 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use crate::{ 7 | Client, CurrentPlayback, CurrentlyPlaying, Device, Error, ItemType, Market, PlayHistory, 8 | RepeatState, Response, TwoWayCursorPage, 9 | }; 10 | 11 | /// Endpoint functions related to controlling what is playing on the current user's Spotify account. 12 | /// (Beta) 13 | /// 14 | /// All endpoints in here are in Beta, and so are more likely to break. 15 | /// 16 | /// The `device_id` parameter seen in this module is the device to perform the request on. If not 17 | /// specified, it will default to the current user's currenttly active device. 18 | #[derive(Debug, Clone, Copy)] 19 | pub struct Player<'a>(pub &'a Client); 20 | 21 | impl Player<'_> { 22 | /// Get the current user's available devices (Beta). 23 | /// 24 | /// Requires `user-read-playback-state` 25 | /// 26 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/player/get-a-users-available-devices/). 27 | pub async fn get_devices(self) -> Result>, Error> { 28 | #[derive(Deserialize)] 29 | struct Devices { 30 | devices: Vec, 31 | } 32 | 33 | Ok(self 34 | .0 35 | .send_json::(self.0.client.get(endpoint!("/v1/me/player/devices"))) 36 | .await? 37 | .map(|res| res.devices)) 38 | } 39 | 40 | /// Get information about the current user's current playback (Beta). 41 | /// 42 | /// Requires `user-read-playback-state`. Returns None if nothing is currently playing. 43 | /// 44 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/player/get-information-about-the-users-current-playback/). 45 | pub async fn get_playback( 46 | self, 47 | market: Option, 48 | ) -> Result>, Error> { 49 | self.0 50 | .send_opt_json(self.0.client.get(endpoint!("/v1/me/player")).query(&( 51 | ("additional_types", "episode,track"), 52 | market.map(Market::query), 53 | ))) 54 | .await 55 | } 56 | 57 | /// Get current user's recently played tracks (Beta). 58 | /// 59 | /// Note that a track needs to be played for >30seconds to be included in the play history. 60 | /// Requires `user-read-recently-played`. Will return None if a private session is enabled. 61 | /// 62 | /// `after` and `before` are Cursor values given the previous time this endpoint was called, to 63 | /// move forward or back in time respectively. Both `after` and `before` must _not_ be Some. 64 | /// `after` is a Unix milliseconds timestamp, and will return everything played after that 65 | /// position, `before` is the same but returns everything before that position. 66 | /// 67 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/player/get-recently-played/). 68 | pub async fn get_recently_played( 69 | self, 70 | limit: usize, 71 | after: Option, 72 | before: Option, 73 | ) -> Result>>, Error> { 74 | self.0 75 | .send_opt_json( 76 | self.0 77 | .client 78 | .get(endpoint!("/v1/me/player/recently-played")) 79 | .query(&( 80 | ("limit", limit.to_string()), 81 | after.map(|after| ("after", after)), 82 | before.map(|before| ("before", before)), 83 | )), 84 | ) 85 | .await 86 | } 87 | 88 | /// Get the current user's currently playing track (Beta). 89 | /// 90 | /// Requires `user-read-currently-playing` and/or `user-read-playback-state`. Returns None if no 91 | /// available devices are found, no tracks are playing, or a private session is enabled. 92 | /// 93 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/player/get-the-users-currently-playing-track/). 94 | pub async fn get_playing_track( 95 | self, 96 | market: Option, 97 | ) -> Result>, Error> { 98 | self.0 99 | .send_opt_json( 100 | self.0 101 | .client 102 | .get(endpoint!("/v1/me/player/currently-playing")) 103 | .query(&( 104 | ("additional_types", "episode,track"), 105 | market.map(Market::query), 106 | )), 107 | ) 108 | .await 109 | } 110 | 111 | /// Pause the current user's playback (Beta). 112 | /// 113 | /// Requires `user-modify-playback-state`. This action completes asynchronously, meaning you will 114 | /// not know if it succeeded unless you check. 115 | /// 116 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/player/pause-a-users-playback/). 117 | pub async fn pause(self, device_id: Option<&str>) -> Result<(), Error> { 118 | self.0 119 | .send_empty( 120 | self.0 121 | .client 122 | .put(endpoint!("/v1/me/player/pause")) 123 | .query(&(device_id.map(device_query))) 124 | .body("{}"), 125 | ) 126 | .await 127 | } 128 | 129 | /// Seek to position in currently playing track (Beta). 130 | /// 131 | /// Requires `user-modify-playback-state`. This action completes asynchronously, meaning you will 132 | /// not know if it succeeded unless you check. 133 | /// 134 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/player/seek-to-position-in-currently-playing-track/). 135 | pub async fn seek(self, position: Duration, device_id: Option<&str>) -> Result<(), Error> { 136 | self.0 137 | .send_empty( 138 | self.0 139 | .client 140 | .put(endpoint!("/v1/me/player/seek")) 141 | .query(&( 142 | device_id.map(device_query), 143 | ("position_ms", position.as_millis().to_string()), 144 | )) 145 | .body("{}"), 146 | ) 147 | .await 148 | } 149 | 150 | /// Set repeat mode on current playback (Beta). 151 | /// 152 | /// Requires `user-modify-playback-state`. This action complete asynchronously, meaning you will 153 | /// not know if it succeeded unless you check. 154 | /// 155 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/player/set-repeat-mode-on-users-playback/). 156 | pub async fn set_repeat( 157 | self, 158 | state: RepeatState, 159 | device_id: Option<&str>, 160 | ) -> Result<(), Error> { 161 | self.0 162 | .send_empty( 163 | self.0 164 | .client 165 | .put(endpoint!("/v1/me/player/repeat")) 166 | .query(&(device_id.map(device_query), ("state", state.as_str()))) 167 | .body("{}"), 168 | ) 169 | .await 170 | } 171 | 172 | /// Set volume on current playback (Beta). 173 | /// 174 | /// Requires `user-modify-playback-state`. This action complete asynchronously, meaning you will 175 | /// not know if it succeeded unless you check. 176 | /// 177 | /// `volume_percent` is the volume as a percentage, from 0 to 100 inclusive. 178 | /// 179 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/player/set-volume-for-users-playback/). 180 | pub async fn set_volume( 181 | self, 182 | volume_percent: i32, 183 | device_id: Option<&str>, 184 | ) -> Result<(), Error> { 185 | self.0 186 | .send_empty( 187 | self.0 188 | .client 189 | .put(endpoint!("/v1/me/player/volume")) 190 | .query(&( 191 | device_id.map(device_query), 192 | ("volume_percent", volume_percent.to_string()), 193 | )) 194 | .body("{}"), 195 | ) 196 | .await 197 | } 198 | 199 | /// Skip to next track (Beta). 200 | /// 201 | /// Requires `user-modify-playback-state`. This action complete asynchronously, meaning you will 202 | /// not know if it succeeded unless you check. 203 | /// 204 | /// After a successful skip operation, playback will automatically start. 205 | /// 206 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/player/skip-users-playback-to-next-track/). 207 | pub async fn skip_next(self, device_id: Option<&str>) -> Result<(), Error> { 208 | self.0 209 | .send_empty( 210 | self.0 211 | .client 212 | .post(endpoint!("/v1/me/player/next")) 213 | .query(&(device_id.map(device_query),)) 214 | .body("{}"), 215 | ) 216 | .await 217 | } 218 | 219 | /// Skip to previous track (Beta). 220 | /// 221 | /// Requires `user-modify-playback-state`. This action complete asynchronously, meaning you will 222 | /// not know if it succeeded unless you check. 223 | /// 224 | /// After a successful skip operation, playback will automatically start. This action will always 225 | /// skip to the previous track, regardless of the current track's progress; to go to the start of 226 | /// the track, use [`seek`](Self::seek). 227 | /// 228 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/player/skip-users-playback-to-previous-track/). 229 | pub async fn skip_prev(self, device_id: Option<&str>) -> Result<(), Error> { 230 | self.0 231 | .send_empty( 232 | self.0 233 | .client 234 | .post(endpoint!("/v1/me/player/previous")) 235 | .query(&(device_id.map(device_query),)) 236 | .body("{}"), 237 | ) 238 | .await 239 | } 240 | 241 | /// Start or resume playback (Beta). 242 | /// 243 | /// Requires `user-modify-playback-state`. This action complete asynchronously, meaning you will 244 | /// not know if it succeeded unless you check. 245 | /// 246 | /// `play`, when set, controls what to play, and what offset in the context to start playing at. 247 | /// `position` controls how far into the current track to play; if it is longer than the current 248 | /// track, then the next track will play. To keep the existing content and position, use 249 | /// [`resume`](Self::resume). 250 | /// 251 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/player/start-a-users-playback/). 252 | pub async fn play( 253 | self, 254 | play: Option>, 255 | position: Option, 256 | device_id: Option<&str>, 257 | ) -> Result<(), Error> 258 | where 259 | I::Item: Display, 260 | { 261 | #[derive(Serialize)] 262 | struct Offset { 263 | position: usize, 264 | } 265 | 266 | #[derive(Serialize)] 267 | struct Body { 268 | context_uri: Option, 269 | offset: Option, 270 | uris: Option>, 271 | position_ms: Option, 272 | } 273 | 274 | let mut body = Body { 275 | context_uri: None, 276 | offset: None, 277 | uris: None, 278 | position_ms: position.map(|duration| duration.as_millis()), 279 | }; 280 | 281 | if let Some(play) = play { 282 | match play { 283 | Play::Context(context_type, id, position) => { 284 | body.context_uri = Some(format!("spotify:{}:{}", context_type.as_str(), id)); 285 | body.offset = Some(Offset { position }); 286 | } 287 | Play::Tracks(ids) => { 288 | body.uris = Some( 289 | ids.into_iter() 290 | .map(|s| format!("spotify:track:{}", s)) 291 | .collect(), 292 | ); 293 | } 294 | } 295 | } 296 | 297 | self.0 298 | .send_empty( 299 | self.0 300 | .client 301 | .put(endpoint!("/v1/me/player/play")) 302 | .query(&(device_id.map(device_query))) 303 | .body(serde_json::to_string(&body)?), 304 | ) 305 | .await 306 | } 307 | 308 | /// Resume playback (Beta). 309 | /// 310 | /// Requires `user-modify-playback-state`. This action complete asynchronously, meaning you will 311 | /// not know if it succeeded unless you check. 312 | /// 313 | /// Resumes playback where it was paused. To specify a content or offset, use 314 | /// [`play`](Self::play) instead. 315 | /// 316 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/player/start-a-users-playback/). 317 | pub async fn resume(self, device_id: Option<&str>) -> Result<(), Error> { 318 | self.0 319 | .send_empty( 320 | self.0 321 | .client 322 | .put(endpoint!("/v1/me/player/play")) 323 | .query(&(device_id.map(device_query),)) 324 | .body("{}"), 325 | ) 326 | .await 327 | } 328 | 329 | /// Enable or disable shuffle (Beta). 330 | /// 331 | /// Requires `user-modify-playback-state`. This action complete asynchronously, meaning you will 332 | /// not know if it succeeded unless you check. 333 | /// 334 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/player/toggle-shuffle-for-users-playback/). 335 | pub async fn set_shuffle(self, shuffle: bool, device_id: Option<&str>) -> Result<(), Error> { 336 | self.0 337 | .send_empty( 338 | self.0 339 | .client 340 | .put(endpoint!("/v1/me/player/shuffle")) 341 | .query(&( 342 | ("state", if shuffle { "true" } else { "false" }), 343 | device_id.map(device_query), 344 | )) 345 | .body("{}"), 346 | ) 347 | .await 348 | } 349 | 350 | /// Transfer playback to another device (Beta). 351 | /// 352 | /// Requires `user-modify-playback-state`. When `play == true`, playback will happen on the new 353 | /// device. When `play == false`, playback will continue in its current state. 354 | /// 355 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/player/transfer-a-users-playback/). 356 | pub async fn transfer(self, id: &str, play: bool) -> Result<(), Error> { 357 | self.0 358 | .send_empty( 359 | self.0 360 | .client 361 | .put(endpoint!("/v1/me/player")) 362 | .body(format!(r#"{{"device_ids":["{}"],"play":{}}}"#, id, play)), 363 | ) 364 | .await 365 | } 366 | } 367 | 368 | /// Request to play something. 369 | #[derive(Debug, Clone)] 370 | pub enum Play<'c, I> { 371 | /// Play from a context (must not be track) with a specified 0-indexed offset to start playing 372 | /// at. 373 | Context(ItemType, &'c str, usize), 374 | /// Play a list of tracks. 375 | Tracks(I), 376 | } 377 | 378 | fn device_query(device: &str) -> (&'static str, &str) { 379 | ("device_id", device) 380 | } 381 | 382 | #[cfg(test)] 383 | mod tests { 384 | use std::time::Duration; 385 | 386 | use tokio::time; 387 | 388 | use crate::endpoints::client; 389 | use crate::{ItemType, Market, Play, PlayingType, RepeatState}; 390 | 391 | #[tokio::test] 392 | async fn test() { 393 | let client = client(); 394 | let player = client.player(); 395 | 396 | let mut devices = player.get_devices().await.unwrap().data.into_iter(); 397 | let device = loop { 398 | let device = devices 399 | .next() 400 | .expect("You must have at least one usable device for this test to work."); 401 | if !device.is_restricted && device.id.is_some() && !device.is_private_session { 402 | break device; 403 | } 404 | }; 405 | let id = &device.id.as_ref().unwrap(); 406 | if !device.is_active { 407 | println!("Transferring device to {}...", device.name); 408 | player.transfer(id, false).await.unwrap(); 409 | } 410 | 411 | // Time to wait to assume that the operation has completed 412 | let wait_time = Duration::from_millis(300); 413 | 414 | // Play 10 seconds into the 3rd track from RELAXER 415 | player 416 | .play( 417 | Some(Play::<'_, &[u8]>::Context( 418 | ItemType::Album, 419 | "3lBPyXvg1hhoJ1REnw80fZ", 420 | 2, 421 | )), 422 | Some(Duration::from_secs(10)), 423 | None, 424 | ) 425 | .await 426 | .unwrap(); 427 | time::sleep(wait_time).await; 428 | 429 | let playback = player 430 | .get_playback(Some(Market::FromToken)) 431 | .await 432 | .unwrap() 433 | .data 434 | .unwrap(); 435 | assert_eq!(playback.device.id, device.id); 436 | assert_eq!(playback.device.name, device.name); 437 | assert_eq!(playback.device.device_type, device.device_type); 438 | assert_eq!(playback.device.volume_percent, device.volume_percent); 439 | let context = playback.currently_playing.context.unwrap(); 440 | assert_eq!(context.context_type, ItemType::Album); 441 | assert_eq!(context.id, "3lBPyXvg1hhoJ1REnw80fZ"); 442 | if playback.currently_playing.progress.unwrap() < Duration::from_secs(10) { 443 | panic!( 444 | "duration is {:?} (less than 10 seconds)", 445 | playback.currently_playing.progress.unwrap() 446 | ); 447 | } 448 | assert!(playback.currently_playing.is_playing); 449 | let track = match playback.currently_playing.item.unwrap() { 450 | PlayingType::Track(item) => item, 451 | _ => panic!(), 452 | }; 453 | assert_eq!(track.album.id.unwrap(), "3lBPyXvg1hhoJ1REnw80fZ"); 454 | assert_eq!(track.track_number, 3); 455 | 456 | // Play "I am a Paleontologist" and "Ten Tonne Skeleton" 457 | player 458 | .play( 459 | Some(Play::Tracks(&[ 460 | "0MSqR4unoY5KReMoOP6E2D", 461 | "0vjYxBDAcflD0358arIVZG", 462 | ])), 463 | None, 464 | None, 465 | ) 466 | .await 467 | .unwrap(); 468 | time::sleep(wait_time).await; 469 | let playing = player 470 | .get_playing_track(Some(Market::FromToken)) 471 | .await 472 | .unwrap() 473 | .data 474 | .unwrap(); 475 | assert!(playing.progress.unwrap() < Duration::from_secs(4)); 476 | assert!(playing.is_playing); 477 | let track = match playing.item.unwrap() { 478 | PlayingType::Track(item) => item, 479 | _ => panic!(), 480 | }; 481 | assert_eq!(track.id.unwrap(), "0MSqR4unoY5KReMoOP6E2D"); 482 | 483 | // Seek to 2ms before end 484 | player 485 | .seek(Duration::from_millis(152_106 - 2), None) 486 | .await 487 | .unwrap(); 488 | time::sleep(wait_time).await; 489 | let playing = player 490 | .get_playing_track(Some(Market::FromToken)) 491 | .await 492 | .unwrap() 493 | .data 494 | .unwrap(); 495 | assert_eq!( 496 | match playing.item.unwrap() { 497 | PlayingType::Track(item) => item, 498 | _ => panic!(), 499 | } 500 | .id 501 | .unwrap(), 502 | "0vjYxBDAcflD0358arIVZG" 503 | ); 504 | 505 | // Repeat, shuffle, volume 506 | player.set_repeat(RepeatState::Track, None).await.unwrap(); 507 | player.set_shuffle(true, None).await.unwrap(); 508 | player.set_volume(17, None).await.unwrap(); 509 | time::sleep(wait_time).await; 510 | let playback = player 511 | .get_playback(Some(Market::FromToken)) 512 | .await 513 | .unwrap() 514 | .data 515 | .unwrap(); 516 | assert_eq!(playback.repeat_state, RepeatState::Track); 517 | assert_eq!(playback.shuffle_state, true); 518 | assert_eq!(playback.device.volume_percent.unwrap(), 17); 519 | player.set_repeat(RepeatState::Context, None).await.unwrap(); 520 | player.set_shuffle(false, None).await.unwrap(); 521 | player.set_volume(73, None).await.unwrap(); 522 | time::sleep(wait_time).await; 523 | let playback = player 524 | .get_playback(Some(Market::FromToken)) 525 | .await 526 | .unwrap() 527 | .data 528 | .unwrap(); 529 | assert_eq!(playback.repeat_state, RepeatState::Context); 530 | assert_eq!(playback.shuffle_state, false); 531 | assert_eq!(playback.device.volume_percent.unwrap(), 73); 532 | 533 | // Skip previous 534 | player.skip_prev(None).await.unwrap(); 535 | time::sleep(wait_time).await; 536 | let playing = player 537 | .get_playing_track(Some(Market::FromToken)) 538 | .await 539 | .unwrap() 540 | .data 541 | .unwrap(); 542 | assert_eq!( 543 | match playing.item.unwrap() { 544 | PlayingType::Track(item) => item, 545 | _ => panic!(), 546 | } 547 | .id 548 | .unwrap(), 549 | "0MSqR4unoY5KReMoOP6E2D" 550 | ); 551 | 552 | // Skip next 553 | player.skip_next(None).await.unwrap(); 554 | time::sleep(wait_time).await; 555 | let playing = player 556 | .get_playing_track(Some(Market::FromToken)) 557 | .await 558 | .unwrap() 559 | .data 560 | .unwrap(); 561 | assert_eq!( 562 | match playing.item.unwrap() { 563 | PlayingType::Track(item) => item, 564 | _ => panic!(), 565 | } 566 | .id 567 | .unwrap(), 568 | "0vjYxBDAcflD0358arIVZG" 569 | ); 570 | 571 | // Play from playlist 572 | player 573 | .play( 574 | Some(Play::<'_, &[u8]>::Context( 575 | ItemType::Playlist, 576 | "37i9dQZF1DWSVtp02hITpN", 577 | 0, 578 | )), 579 | None, 580 | None, 581 | ) 582 | .await 583 | .unwrap(); 584 | time::sleep(wait_time).await; 585 | player 586 | .get_playing_track(Some(Market::FromToken)) 587 | .await 588 | .unwrap() 589 | .data 590 | .unwrap(); 591 | 592 | // Pause 593 | player.pause(None).await.unwrap(); 594 | time::sleep(wait_time).await; 595 | let playback = player 596 | .get_playback(Some(Market::FromToken)) 597 | .await 598 | .unwrap() 599 | .data 600 | .unwrap(); 601 | assert!(!playback.currently_playing.is_playing); 602 | 603 | // Resume 604 | player.resume(None).await.unwrap(); 605 | time::sleep(wait_time).await; 606 | let playback = player 607 | .get_playback(Some(Market::FromToken)) 608 | .await 609 | .unwrap() 610 | .data 611 | .unwrap(); 612 | assert!(playback.currently_playing.is_playing); 613 | 614 | // Pause again 615 | player.pause(None).await.unwrap(); 616 | } 617 | 618 | #[tokio::test] 619 | async fn test_recent() { 620 | client() 621 | .player() 622 | .get_recently_played(3, None, None) 623 | .await 624 | .unwrap(); 625 | } 626 | } 627 | -------------------------------------------------------------------------------- /src/endpoints/search.rs: -------------------------------------------------------------------------------- 1 | use itertools::Itertools; 2 | 3 | use crate::{Client, Error, ItemType, Market, Response, SearchResults}; 4 | 5 | /// Endpoint functions related to searches. 6 | #[derive(Debug, Clone, Copy)] 7 | pub struct Search<'a>(pub &'a Client); 8 | 9 | impl Search<'_> { 10 | /// Search for an item. 11 | /// 12 | /// `include_external` specifies whether to include audio content that is hosted externally. 13 | /// Playlist results are not affected by `market`. `limit` must be in the range [1..50], and is 14 | /// applied individually to each type specified in `types`, not the whole response. `offset` has a 15 | /// maximum of 10,000. 16 | /// 17 | /// Read [the Spotify documentation on how to write a 18 | /// query](https://developer.spotify.com/documentation/web-api/reference/search/search/#writing-a-query---guidelines) 19 | /// to create the `query` parameter. The only difference is that you shouldn't encode spaces as 20 | /// `%20` or `+`, as that is done by this function automatically. 21 | /// 22 | /// # Limitations 23 | /// 24 | /// - You cannot fetch sorted results. 25 | /// - You cannot search for playlists that contain a track. 26 | /// - You can only search for one genre at a time. 27 | /// - You cannot search for playlists in a user's library. 28 | /// 29 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/search/search/). 30 | pub async fn search( 31 | self, 32 | query: &str, 33 | types: impl IntoIterator, 34 | include_external: bool, 35 | limit: usize, 36 | offset: usize, 37 | market: Option, 38 | ) -> Result, Error> { 39 | let types = types.into_iter().map(ItemType::as_str).join(","); 40 | let types = if types.is_empty() { 41 | "album,artist,playlist,track,show,episode" 42 | } else { 43 | &types 44 | }; 45 | 46 | self.0 47 | .send_json(self.0.client.get(endpoint!("/v1/search")).query(&( 48 | ("q", query), 49 | ("type", types), 50 | ("limit", limit.to_string()), 51 | ("offset", offset.to_string()), 52 | if include_external { 53 | Some(("include_external", "audio")) 54 | } else { 55 | None 56 | }, 57 | market.map(Market::query), 58 | ))) 59 | .await 60 | } 61 | } 62 | 63 | #[cfg(test)] 64 | mod tests { 65 | use crate::endpoints::client; 66 | use crate::{ItemType, Market}; 67 | 68 | #[tokio::test] 69 | async fn test_search_artist() { 70 | let res = client() 71 | .search() 72 | .search( 73 | "tania bowra", 74 | [ItemType::Artist].iter().copied(), 75 | false, 76 | 1, 77 | 0, 78 | None, 79 | ) 80 | .await 81 | .unwrap() 82 | .data; 83 | assert_eq!(res.albums, None); 84 | assert_eq!(res.tracks, None); 85 | assert_eq!(res.playlists, None); 86 | let artists = res.artists.unwrap(); 87 | assert_eq!(artists.limit, 1); 88 | assert_eq!(artists.offset, 0); 89 | assert_eq!(artists.items.len(), 1); 90 | assert_eq!(artists.items[0].name, "Tania Bowra"); 91 | } 92 | 93 | #[tokio::test] 94 | async fn test_search_album_tracks() { 95 | client() 96 | .search() 97 | .search( 98 | "abba", 99 | [ItemType::Album, ItemType::Track].iter().copied(), 100 | true, 101 | 1, 102 | 0, 103 | Some(Market::FromToken), 104 | ) 105 | .await 106 | .unwrap(); 107 | } 108 | 109 | #[tokio::test] 110 | async fn test_search_playlist() { 111 | client() 112 | .search() 113 | .search( 114 | "doom metal", 115 | [ItemType::Playlist].iter().copied(), 116 | false, 117 | 1, 118 | 0, 119 | None, 120 | ) 121 | .await 122 | .unwrap(); 123 | } 124 | 125 | #[tokio::test] 126 | async fn test_search_all() { 127 | client() 128 | .search() 129 | .search("test", [].iter().copied(), false, 3, 2, None) 130 | .await 131 | .unwrap(); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/endpoints/shows.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use isocountry::CountryCode; 4 | use itertools::Itertools; 5 | use serde::Deserialize; 6 | 7 | use super::chunked_sequence; 8 | use crate::{Client, EpisodeSimplified, Error, Page, Response, Show, ShowSimplified}; 9 | 10 | /// Endpoint functions relating to shows. 11 | /// 12 | /// For all the below endpoints, the market parameter must be specified if the token is not a 13 | /// user's. If the token is a user's and the market parameter is specified, the user's token will 14 | /// take precedence. 15 | #[derive(Debug, Clone, Copy)] 16 | pub struct Shows<'a>(pub &'a Client); 17 | 18 | impl Shows<'_> { 19 | /// Get information about a show. 20 | /// 21 | /// Either the client must have a refresh token or the `market` parameter must be provided, 22 | /// otherwise this will fail. If both are provided, then the user's market will take 23 | /// precendence. 24 | /// 25 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/shows/get-a-show/). 26 | pub async fn get_show( 27 | self, 28 | id: &str, 29 | market: Option, 30 | ) -> Result, Error> { 31 | self.0 32 | .send_json( 33 | self.0 34 | .client 35 | .get(endpoint!("/v1/shows/{}", id)) 36 | .query(&(market.map(|c| ("market", c.alpha2())),)), 37 | ) 38 | .await 39 | } 40 | 41 | /// Get several shows. 42 | /// 43 | /// Either the client must have a refresh token or the `market` parameter must be provided, 44 | /// otherwise this will fail. If both are provided, then the user's market will take 45 | /// precendence. 46 | /// 47 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/shows/get-several-shows/). 48 | pub async fn get_shows( 49 | self, 50 | ids: I, 51 | market: Option, 52 | ) -> Result>, Error> 53 | where 54 | I::Item: Display, 55 | { 56 | #[derive(Deserialize)] 57 | struct Shows { 58 | shows: Vec, 59 | } 60 | 61 | chunked_sequence(ids, 50, |mut ids| { 62 | let req = self.0.client.get(endpoint!("/v1/shows")).query(&( 63 | ("ids", ids.join(",")), 64 | market.map(|c| ("market", c.alpha2())), 65 | )); 66 | async move { Ok(self.0.send_json::(req).await?.map(|res| res.shows)) } 67 | }) 68 | .await 69 | } 70 | 71 | /// Get a show's episodes. 72 | /// 73 | /// Either the client must have a refresh token or the `market` parameter must be provided, 74 | /// otherwise this will fail. If both are provided, then the user's market will take 75 | /// precendence. 76 | /// 77 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/shows/get-shows-episodes/). 78 | pub async fn get_show_episodes( 79 | self, 80 | id: &str, 81 | limit: usize, 82 | offset: usize, 83 | market: Option, 84 | ) -> Result>, Error> { 85 | self.0 86 | .send_json( 87 | self.0 88 | .client 89 | .get(endpoint!("/v1/shows/{}/episodes", id)) 90 | .query(&( 91 | ("limit", limit.to_string()), 92 | ("offset", offset.to_string()), 93 | market.map(|c| ("market", c.alpha2())), 94 | )), 95 | ) 96 | .await 97 | } 98 | } 99 | 100 | #[cfg(test)] 101 | mod tests { 102 | use isocountry::CountryCode; 103 | 104 | use crate::endpoints::client; 105 | 106 | #[tokio::test] 107 | async fn test_get_show() { 108 | let show = client() 109 | .shows() 110 | .get_show("38bS44xjbVVZ3No3ByF1dJ", Some(CountryCode::AUS)) 111 | .await 112 | .unwrap() 113 | .data; 114 | assert_eq!(show.name, "Vetenskapsradion Historia"); 115 | } 116 | 117 | #[tokio::test] 118 | async fn test_get_shows() { 119 | let shows = client() 120 | .shows() 121 | .get_shows(&["5CfCWKI5pZ28U0uOzXkDHe"], None) 122 | .await 123 | .unwrap() 124 | .data; 125 | assert_eq!(shows.len(), 1); 126 | assert_eq!(shows[0].name, "Without Fail"); 127 | } 128 | 129 | #[tokio::test] 130 | async fn test_get_show_episodes() { 131 | let episodes = client() 132 | .shows() 133 | .get_show_episodes("38bS44xjbVVZ3No3ByF1dJ", 2, 1, None) 134 | .await 135 | .unwrap() 136 | .data; 137 | assert_eq!(episodes.limit, 2); 138 | assert_eq!(episodes.offset, 1); 139 | assert_eq!(episodes.items.len(), 2); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/endpoints/tracks.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use itertools::Itertools; 4 | use serde::Deserialize; 5 | 6 | use super::chunked_sequence; 7 | use crate::{AudioAnalysis, AudioFeatures, Client, Error, Market, Response, Track}; 8 | 9 | /// Endpoint functions related to tracks and audio analysis. 10 | #[derive(Debug, Clone, Copy)] 11 | pub struct Tracks<'a>(pub &'a Client); 12 | 13 | impl Tracks<'_> { 14 | /// Get audio analysis for a track. 15 | /// 16 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/tracks/get-audio-analysis/). 17 | pub async fn get_analysis(self, id: &str) -> Result, Error> { 18 | self.0 19 | .send_json(self.0.client.get(endpoint!("/v1/audio-analysis/{}", id))) 20 | .await 21 | } 22 | 23 | /// Get audio features for a track. 24 | /// 25 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/tracks/get-audio-features/). 26 | pub async fn get_features_track(self, id: &str) -> Result, Error> { 27 | self.0 28 | .send_json(self.0.client.get(endpoint!("/v1/audio-features/{}", id))) 29 | .await 30 | } 31 | 32 | /// Get audio features for several tracks. 33 | /// 34 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/tracks/get-several-audio-features/). 35 | pub async fn get_features_tracks( 36 | self, 37 | ids: I, 38 | ) -> Result>, Error> 39 | where 40 | I::Item: Display, 41 | { 42 | #[derive(Deserialize)] 43 | struct ManyAudioFeatures { 44 | audio_features: Vec, 45 | } 46 | 47 | chunked_sequence(ids, 100, |mut ids| { 48 | let req = self 49 | .0 50 | .client 51 | .get(endpoint!("/v1/audio-features")) 52 | .query(&(("ids", ids.join(",")),)); 53 | async move { 54 | Ok(self 55 | .0 56 | .send_json::(req) 57 | .await? 58 | .map(|res| res.audio_features)) 59 | } 60 | }) 61 | .await 62 | } 63 | 64 | /// Get information about several tracks. 65 | /// 66 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/tracks/get-several-tracks/). 67 | pub async fn get_tracks( 68 | self, 69 | ids: I, 70 | market: Option, 71 | ) -> Result>, Error> 72 | where 73 | I::Item: Display, 74 | { 75 | #[derive(Deserialize)] 76 | struct Tracks { 77 | tracks: Vec, 78 | } 79 | 80 | chunked_sequence(ids, 50, |mut ids| { 81 | let req = self 82 | .0 83 | .client 84 | .get(endpoint!("/v1/tracks")) 85 | .query(&(("ids", ids.join(",")), market.map(Market::query))); 86 | async move { Ok(self.0.send_json::(req).await?.map(|res| res.tracks)) } 87 | }) 88 | .await 89 | } 90 | 91 | /// Get information about a track. 92 | /// 93 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/tracks/get-several-tracks/). 94 | pub async fn get_track( 95 | self, 96 | id: &str, 97 | market: Option, 98 | ) -> Result, Error> { 99 | self.0 100 | .send_json( 101 | self.0 102 | .client 103 | .get(endpoint!("/v1/tracks/{}", id)) 104 | .query(&(market.map(Market::query),)), 105 | ) 106 | .await 107 | } 108 | } 109 | 110 | #[cfg(test)] 111 | mod tests { 112 | use isocountry::CountryCode; 113 | 114 | use crate::endpoints::client; 115 | use crate::{Market, Mode}; 116 | 117 | #[tokio::test] 118 | async fn test_get_track() { 119 | // "Walk Like an Egyptian" 120 | let track = client() 121 | .tracks() 122 | .get_track("1Jwc3ODLQxtbnS8M9TflSP", None) 123 | .await 124 | .unwrap() 125 | .data; 126 | assert_eq!(track.id.unwrap(), "1Jwc3ODLQxtbnS8M9TflSP"); 127 | assert_eq!(track.name, "Walk Like an Egyptian"); 128 | assert_eq!(track.artists[0].name, "The Bangles"); 129 | } 130 | 131 | #[tokio::test] 132 | async fn test_get_tracks() { 133 | // "Walk Like an Egyptian", "Play that Funky Music" 134 | let tracks = client() 135 | .tracks() 136 | .get_tracks(&["1Jwc3ODLQxtbnS8M9TflSP", "5uuJruktM9fMdN9Va0DUMl"], None) 137 | .await 138 | .unwrap() 139 | .data; 140 | assert_eq!(tracks.len(), 2); 141 | assert_eq!(tracks[0].name, "Walk Like an Egyptian"); 142 | assert_eq!(tracks[1].name, "Play That Funky Music"); 143 | } 144 | 145 | #[tokio::test] 146 | async fn test_relink() { 147 | // Test track relinking with "Heaven and Hell" 148 | let relinked = client() 149 | .tracks() 150 | .get_track( 151 | "6kLCHFM39wkFjOuyPGLGeQ", 152 | Some(Market::Country(CountryCode::USA)), 153 | ) 154 | .await 155 | .unwrap() 156 | .data; 157 | assert_eq!(relinked.name, "Heaven and Hell"); 158 | assert!(relinked.is_playable.unwrap()); 159 | let from = relinked.linked_from.as_ref().unwrap(); 160 | assert_eq!(from.id, "6kLCHFM39wkFjOuyPGLGeQ"); 161 | } 162 | 163 | #[tokio::test] 164 | async fn test_analysis() { 165 | // Get analysis of "Walk Like an Egyptian" 166 | client() 167 | .tracks() 168 | .get_analysis("1Jwc3ODLQxtbnS8M9TflSP") 169 | .await 170 | .unwrap(); 171 | } 172 | 173 | #[tokio::test] 174 | async fn test_features() { 175 | // Get features of "Walk Like an Egyptian" 176 | let features = client() 177 | .tracks() 178 | .get_features_track("1Jwc3ODLQxtbnS8M9TflSP") 179 | .await 180 | .unwrap() 181 | .data; 182 | assert_eq!(features.id, "1Jwc3ODLQxtbnS8M9TflSP"); 183 | assert_eq!(features.key, 11); 184 | assert_eq!(features.mode, Mode::Major); 185 | assert_eq!(features.tempo, 103.022); 186 | } 187 | 188 | #[tokio::test] 189 | async fn test_features_tracks() { 190 | // Get features of "Walk Like an Egyptian" and "Play that Funky Music" 191 | let features = client() 192 | .tracks() 193 | .get_features_tracks(&["1Jwc3ODLQxtbnS8M9TflSP", "5uuJruktM9fMdN9Va0DUMl"]) 194 | .await 195 | .unwrap() 196 | .data; 197 | assert_eq!(features.len(), 2); 198 | assert_eq!(features[0].id, "1Jwc3ODLQxtbnS8M9TflSP"); 199 | assert_eq!(features[1].id, "5uuJruktM9fMdN9Va0DUMl"); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/endpoints/users_profile.rs: -------------------------------------------------------------------------------- 1 | use crate::{Client, Error, Response, UserPrivate, UserPublic}; 2 | 3 | /// Endpoint functions related to users' profiles. 4 | #[derive(Debug, Clone, Copy)] 5 | pub struct UsersProfile<'a>(pub &'a Client); 6 | 7 | impl UsersProfile<'_> { 8 | /// Get current user's profile. 9 | /// 10 | /// Reading the user's email requires `user-read-email`, reading their country and product 11 | /// subscription level requires `user-read-private`. 12 | /// 13 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/users-profile/get-current-users-profile/). 14 | pub async fn get_current_user(self) -> Result, Error> { 15 | self.0 16 | .send_json(self.0.client.get(endpoint!("/v1/me"))) 17 | .await 18 | } 19 | 20 | /// Get a user's profile. 21 | /// 22 | /// [Reference](https://developer.spotify.com/documentation/web-api/reference/users-profile/get-users-profile/). 23 | pub async fn get_user(self, id: &str) -> Result, Error> { 24 | self.0 25 | .send_json(self.0.client.get(endpoint!("/v1/users/{}", id))) 26 | .await 27 | } 28 | } 29 | 30 | #[cfg(test)] 31 | mod tests { 32 | use crate::endpoints::client; 33 | 34 | #[tokio::test] 35 | async fn test_get_user() { 36 | let user = client() 37 | .users_profile() 38 | .get_user("spotify") 39 | .await 40 | .unwrap() 41 | .data; 42 | assert_eq!(user.display_name.unwrap(), "Spotify"); 43 | assert_eq!(user.external_urls.len(), 1); 44 | assert_eq!( 45 | user.external_urls["spotify"], 46 | "https://open.spotify.com/user/spotify" 47 | ); 48 | assert_eq!(user.id, "spotify"); 49 | assert_eq!(user.images.len(), 1); 50 | assert_eq!( 51 | user.images[0].url, 52 | "https://i.scdn.co/image/ab6775700000ee8555c25988a6ac314394d3fbf5" 53 | ); 54 | } 55 | 56 | #[tokio::test] 57 | async fn test_get_current() { 58 | let user = client() 59 | .users_profile() 60 | .get_current_user() 61 | .await 62 | .unwrap() 63 | .data; 64 | assert_eq!(user.external_urls.len(), 1); 65 | assert_eq!( 66 | user.external_urls["spotify"], 67 | format!("https://open.spotify.com/user/{}", user.id) 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Note: This crate is deprecated in favour of [rspotify](https://docs.rs/rspotify). 2 | //! 3 | //! aspotify is an asynchronous client to the [Spotify 4 | //! API](https://developer.spotify.com/documentation/web-api/). 5 | //! 6 | //! # Examples 7 | //! ``` 8 | //! # async { 9 | //! use aspotify::{Client, ClientCredentials}; 10 | //! 11 | //! // This from_env function tries to read the CLIENT_ID and CLIENT_SECRET environment variables. 12 | //! // You can use the dotenv crate to read it from a file. 13 | //! let credentials = ClientCredentials::from_env() 14 | //! .expect("CLIENT_ID and CLIENT_SECRET not found."); 15 | //! 16 | //! // Create a Spotify client. 17 | //! let client = Client::new(credentials); 18 | //! 19 | //! // Gets the album "Favourite Worst Nightmare" from Spotify, with no specified market. 20 | //! let album = client.albums().get_album("1XkGORuUX2QGOEIL4EbJKm", None).await.unwrap(); 21 | //! # }; 22 | //! ``` 23 | //! 24 | //! # Notes 25 | //! - Spotify often imposes limits on endpoints, for example you can't get more than 50 tracks at 26 | //! once. This crate removes this limit by making multiple requests when necessary. 27 | #![forbid(unsafe_code)] 28 | #![deny(rust_2018_idioms)] 29 | #![warn(missing_docs, clippy::pedantic)] 30 | #![allow( 31 | clippy::module_name_repetitions, 32 | clippy::non_ascii_literal, 33 | clippy::items_after_statements, 34 | clippy::filter_map 35 | )] 36 | #![cfg_attr(test, allow(clippy::float_cmp))] 37 | 38 | use std::collections::HashMap; 39 | use std::env::{self, VarError}; 40 | use std::error::Error as StdError; 41 | use std::ffi::OsStr; 42 | use std::fmt::{self, Display, Formatter}; 43 | use std::time::{Duration, Instant}; 44 | 45 | use reqwest::{header, RequestBuilder, Url}; 46 | use serde::de::DeserializeOwned; 47 | use serde::{Deserialize, Serialize}; 48 | use tokio::sync::{Mutex, MutexGuard}; 49 | 50 | pub use authorization_url::*; 51 | pub use endpoints::*; 52 | /// Re-export from [`isocountry`]. 53 | pub use isocountry::CountryCode; 54 | /// Re-export from [`isolanguage_1`]. 55 | pub use isolanguage_1::LanguageCode; 56 | pub use model::*; 57 | 58 | mod authorization_url; 59 | pub mod endpoints; 60 | pub mod model; 61 | mod util; 62 | 63 | /// A client to the Spotify API. 64 | /// 65 | /// By default it will use the [client credentials 66 | /// flow](https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow) 67 | /// to send requests to the Spotify API. The [`set_refresh_token`](Client::set_refresh_token) and 68 | /// [`redirected`](Client::redirected) methods tell it to use the [authorization code 69 | /// flow](https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow) 70 | /// instead. 71 | #[derive(Debug)] 72 | pub struct Client { 73 | /// Your Spotify client credentials. 74 | pub credentials: ClientCredentials, 75 | client: reqwest::Client, 76 | cache: Mutex, 77 | debug: bool, 78 | } 79 | 80 | impl Client { 81 | /// Create a new client from your Spotify client credentials. 82 | #[must_use] 83 | pub fn new(credentials: ClientCredentials) -> Self { 84 | Self { 85 | credentials, 86 | client: reqwest::Client::new(), 87 | cache: Mutex::new(AccessToken::new(None)), 88 | debug: false, 89 | } 90 | } 91 | /// Create a new client with your Spotify client credentials and a refresh token. 92 | #[must_use] 93 | pub fn with_refresh(credentials: ClientCredentials, refresh_token: String) -> Self { 94 | Self { 95 | credentials, 96 | client: reqwest::Client::new(), 97 | cache: Mutex::new(AccessToken::new(Some(refresh_token))), 98 | debug: false, 99 | } 100 | } 101 | /// Get the client's refresh token. 102 | pub async fn refresh_token(&self) -> Option { 103 | self.cache.lock().await.refresh_token.clone() 104 | } 105 | /// Set the client's refresh token. 106 | pub async fn set_refresh_token(&self, refresh_token: Option) { 107 | self.cache.lock().await.refresh_token = refresh_token; 108 | } 109 | /// Get the client's access token values. 110 | pub async fn current_access_token(&self) -> (String, Instant) { 111 | let cache = self.cache.lock().await; 112 | (cache.token.clone(), cache.expires) 113 | } 114 | /// Explicitly override the client's access token values. Useful if you acquire the 115 | /// access token elsewhere. 116 | pub async fn set_current_access_token(&self, token: String, expires: Instant) { 117 | let mut cache = self.cache.lock().await; 118 | cache.token = token; 119 | cache.expires = expires; 120 | } 121 | 122 | async fn token_request(&self, params: TokenRequest<'_>) -> Result { 123 | let request = self 124 | .client 125 | .post("https://accounts.spotify.com/api/token") 126 | .basic_auth(&self.credentials.id, Some(&self.credentials.secret)) 127 | .form(¶ms) 128 | .build()?; 129 | 130 | if self.debug { 131 | dbg!(&request, body_str(&request)); 132 | } 133 | 134 | let response = self.client.execute(request).await?; 135 | let status = response.status(); 136 | let text = response.text().await?; 137 | if !status.is_success() { 138 | if self.debug { 139 | eprintln!( 140 | "Authentication failed ({}). Response body is '{}'", 141 | status, text 142 | ); 143 | } 144 | return Err(Error::Auth(serde_json::from_str(&text)?)); 145 | } 146 | 147 | if self.debug { 148 | dbg!(status); 149 | eprintln!("Authentication response body is '{}'", text); 150 | } 151 | 152 | Ok(serde_json::from_str(&text)?) 153 | } 154 | 155 | /// Set the refresh token from the URL the client was redirected to and the state that was used 156 | /// to send them there. 157 | /// 158 | /// Use the [`authorization_url()`] function to generate the URL to which you can send the 159 | /// client to to generate the URL here. 160 | /// 161 | /// # Errors 162 | /// 163 | /// Fails if the URL is invalid in some way, the state was incorrect for the URL or Spotify 164 | /// fails. 165 | pub async fn redirected(&self, url: &str, state: &str) -> Result<(), RedirectedError> { 166 | let url = Url::parse(url)?; 167 | 168 | let pairs: HashMap<_, _> = url.query_pairs().collect(); 169 | 170 | if pairs 171 | .get("state") 172 | .map_or(true, |url_state| url_state != state) 173 | { 174 | return Err(RedirectedError::IncorrectState); 175 | } 176 | 177 | if let Some(error) = pairs.get("error") { 178 | return Err(RedirectedError::AuthFailed(error.to_string())); 179 | } 180 | 181 | let code = pairs 182 | .get("code") 183 | .ok_or_else(|| RedirectedError::AuthFailed(String::new()))?; 184 | 185 | let token = self 186 | .token_request(TokenRequest::AuthorizationCode { 187 | code: &*code, 188 | redirect_uri: &url[..url::Position::AfterPath], 189 | }) 190 | .await?; 191 | *self.cache.lock().await = token; 192 | 193 | Ok(()) 194 | } 195 | 196 | async fn access_token(&self) -> Result, Error> { 197 | let mut cache = self.cache.lock().await; 198 | if Instant::now() >= cache.expires { 199 | *cache = match cache.refresh_token.take() { 200 | // Authorization code flow 201 | Some(refresh_token) => { 202 | let mut token = self 203 | .token_request(TokenRequest::RefreshToken { 204 | refresh_token: &refresh_token, 205 | }) 206 | .await?; 207 | token.refresh_token = Some(refresh_token); 208 | token 209 | } 210 | // Client credentials flow 211 | None => self.token_request(TokenRequest::ClientCredentials).await?, 212 | } 213 | } 214 | Ok(cache) 215 | } 216 | 217 | async fn send_text(&self, request: RequestBuilder) -> Result, Error> { 218 | let request = request 219 | .bearer_auth(&self.access_token().await?.token) 220 | .build()?; 221 | 222 | if self.debug { 223 | dbg!(&request, body_str(&request)); 224 | } 225 | 226 | let response = loop { 227 | let response = self.client.execute(request.try_clone().unwrap()).await?; 228 | if response.status() != 429 { 229 | break response; 230 | } 231 | let wait = response 232 | .headers() 233 | .get(header::RETRY_AFTER) 234 | .and_then(|val| val.to_str().ok()) 235 | .and_then(|secs| secs.parse::().ok()); 236 | // 2 seconds is default retry after time; should never be used if the Spotify API and 237 | // my code are both correct. 238 | let wait = wait.unwrap_or(2); 239 | tokio::time::sleep(std::time::Duration::from_secs(wait)).await; 240 | }; 241 | let status = response.status(); 242 | let cache_control = Duration::from_secs( 243 | response 244 | .headers() 245 | .get_all(header::CACHE_CONTROL) 246 | .iter() 247 | .filter_map(|value| value.to_str().ok()) 248 | .flat_map(|value| value.split(|c| c == ',')) 249 | .find_map(|value| { 250 | let mut parts = value.trim().splitn(2, '='); 251 | if parts.next().unwrap().eq_ignore_ascii_case("max-age") { 252 | parts.next().and_then(|max| max.parse::().ok()) 253 | } else { 254 | None 255 | } 256 | }) 257 | .unwrap_or_default(), 258 | ); 259 | 260 | let data = response.text().await?; 261 | if !status.is_success() { 262 | if self.debug { 263 | eprintln!("Failed ({}). Response body is '{}'", status, data); 264 | } 265 | return Err(Error::Endpoint(serde_json::from_str(&data)?)); 266 | } 267 | 268 | if self.debug { 269 | dbg!(status); 270 | eprintln!("Response body is '{}'", data); 271 | } 272 | 273 | Ok(Response { 274 | data, 275 | expires: Instant::now() + cache_control, 276 | }) 277 | } 278 | 279 | async fn send_empty(&self, request: RequestBuilder) -> Result<(), Error> { 280 | self.send_text(request).await?; 281 | Ok(()) 282 | } 283 | 284 | async fn send_opt_json( 285 | &self, 286 | request: RequestBuilder, 287 | ) -> Result>, Error> { 288 | let res = self.send_text(request).await?; 289 | Ok(Response { 290 | data: if res.data.is_empty() { 291 | None 292 | } else { 293 | serde_json::from_str(&res.data)? 294 | }, 295 | expires: res.expires, 296 | }) 297 | } 298 | 299 | async fn send_json( 300 | &self, 301 | request: RequestBuilder, 302 | ) -> Result, Error> { 303 | let res = self.send_text(request).await?; 304 | Ok(Response { 305 | data: serde_json::from_str(&res.data)?, 306 | expires: res.expires, 307 | }) 308 | } 309 | 310 | async fn send_snapshot_id(&self, request: RequestBuilder) -> Result { 311 | #[derive(Deserialize)] 312 | struct SnapshotId { 313 | snapshot_id: String, 314 | } 315 | Ok(self 316 | .send_json::(request) 317 | .await? 318 | .data 319 | .snapshot_id) 320 | } 321 | } 322 | 323 | /// The result of a request to a Spotify endpoint. 324 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 325 | pub struct Response { 326 | /// The data itself. 327 | pub data: T, 328 | /// When the cache expires. 329 | pub expires: Instant, 330 | } 331 | 332 | impl Response { 333 | /// Map the contained data if there is any. 334 | pub fn map(self, f: impl FnOnce(T) -> U) -> Response { 335 | Response { 336 | data: f(self.data), 337 | expires: self.expires, 338 | } 339 | } 340 | } 341 | 342 | /// An object that holds your Spotify Client ID and Client Secret. 343 | /// 344 | /// See [the Spotify guide on Spotify 345 | /// apps](https://developer.spotify.com/documentation/general/guides/app-settings/) for how to get 346 | /// these. 347 | /// 348 | /// # Examples 349 | /// 350 | /// ```no_run 351 | /// use aspotify::ClientCredentials; 352 | /// 353 | /// // Create from inside the program. 354 | /// let credentials = ClientCredentials { 355 | /// id: "your client id here".to_owned(), 356 | /// secret: "your client secret here".to_owned() 357 | /// }; 358 | /// 359 | /// // Create from CLIENT_ID and CLIENT_SECRET environment variables 360 | /// let credentials = ClientCredentials::from_env() 361 | /// .expect("CLIENT_ID or CLIENT_SECRET environment variables not set"); 362 | /// 363 | /// // Or use custom env var names 364 | /// let credentials = ClientCredentials::from_env_vars("SPOTIFY_ID", "SPOTIFY_SECRET") 365 | /// .expect("SPOTIFY_ID or SPOTIFY_SECRET environment variables not set"); 366 | /// ``` 367 | #[derive(Debug, Clone, PartialEq, Eq)] 368 | pub struct ClientCredentials { 369 | /// The Client ID. 370 | pub id: String, 371 | /// The Client Secret. 372 | pub secret: String, 373 | } 374 | 375 | impl ClientCredentials { 376 | /// Attempts to create a `ClientCredentials` by reading environment variables. 377 | /// 378 | /// # Errors 379 | /// 380 | /// Fails if the environment variables are not present or are not unicode. 381 | pub fn from_env_vars, S: AsRef>( 382 | client_id: I, 383 | client_secret: S, 384 | ) -> Result { 385 | Ok(Self { 386 | id: env::var(client_id)?, 387 | secret: env::var(client_secret)?, 388 | }) 389 | } 390 | /// Attempts to create a `ClientCredentials` by reading the `CLIENT_ID` and `CLIENT_SECRET` 391 | /// environment variables. 392 | /// 393 | /// Equivalent to `ClientCredentials::from_env_vars("CLIENT_ID", "CLIENT_SECRET")`. 394 | /// 395 | /// # Errors 396 | /// 397 | /// Fails if the environment variables are not present or are not unicode. 398 | pub fn from_env() -> Result { 399 | Self::from_env_vars("CLIENT_ID", "CLIENT_SECRET") 400 | } 401 | } 402 | 403 | /// An error caused by the [`Client::redirected`] function. 404 | #[derive(Debug)] 405 | pub enum RedirectedError { 406 | /// The URL is malformed. 407 | InvalidUrl(url::ParseError), 408 | /// The URL has no state parameter, or the state parameter was incorrect. 409 | IncorrectState, 410 | /// The user has not accepted the request or an error occured in Spotify. 411 | /// 412 | /// This contains the string returned by Spotify in the `error` parameter. 413 | AuthFailed(String), 414 | /// An error occurred getting the access token. 415 | Token(Error), 416 | } 417 | 418 | impl From for RedirectedError { 419 | fn from(error: url::ParseError) -> Self { 420 | Self::InvalidUrl(error) 421 | } 422 | } 423 | impl From for RedirectedError { 424 | fn from(error: Error) -> Self { 425 | Self::Token(error) 426 | } 427 | } 428 | 429 | impl Display for RedirectedError { 430 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 431 | match self { 432 | Self::InvalidUrl(_) => f.write_str("malformed redirect URL"), 433 | Self::IncorrectState => f.write_str("state parameter not found or is incorrect"), 434 | Self::AuthFailed(_) => f.write_str("authorization failed"), 435 | Self::Token(e) => e.fmt(f), 436 | } 437 | } 438 | } 439 | 440 | impl StdError for RedirectedError { 441 | fn source(&self) -> Option<&(dyn StdError + 'static)> { 442 | Some(match self { 443 | Self::InvalidUrl(e) => e, 444 | Self::Token(e) => e, 445 | _ => return None, 446 | }) 447 | } 448 | } 449 | 450 | #[derive(Debug, Serialize)] 451 | #[serde(tag = "grant_type", rename_all = "snake_case")] 452 | enum TokenRequest<'a> { 453 | RefreshToken { 454 | refresh_token: &'a String, 455 | }, 456 | ClientCredentials, 457 | AuthorizationCode { 458 | code: &'a str, 459 | redirect_uri: &'a str, 460 | }, 461 | } 462 | 463 | #[derive(Debug, Deserialize)] 464 | struct AccessToken { 465 | #[serde(rename = "access_token")] 466 | token: String, 467 | #[serde( 468 | rename = "expires_in", 469 | deserialize_with = "util::deserialize_instant_seconds" 470 | )] 471 | expires: Instant, 472 | #[serde(default)] 473 | refresh_token: Option, 474 | } 475 | 476 | impl AccessToken { 477 | fn new(refresh_token: Option) -> Self { 478 | Self { 479 | token: String::new(), 480 | expires: Instant::now() - Duration::from_secs(1), 481 | refresh_token, 482 | } 483 | } 484 | } 485 | 486 | /// Get the contents of a request body as a string. This is only used for debugging purposes. 487 | fn body_str(req: &reqwest::Request) -> Option<&str> { 488 | req.body().map(|body| { 489 | body.as_bytes().map_or("stream", |bytes| { 490 | std::str::from_utf8(bytes).unwrap_or("opaque bytes") 491 | }) 492 | }) 493 | } 494 | -------------------------------------------------------------------------------- /src/model/album.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use chrono::{DateTime, NaiveDate, Utc}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use crate::model::{ 7 | ArtistSimplified, Copyright, DatePrecision, Image, Page, Restrictions, TrackSimplified, 8 | TypeAlbum, 9 | }; 10 | use crate::util; 11 | 12 | macro_rules! inherit_album_simplified { 13 | ($(#[$attr:meta])* $name:ident { $($(#[$f_attr:meta])* $f_name:ident : $f_ty:ty,)* }) => { 14 | to_struct!($(#[$attr])* $name { 15 | $( 16 | $(#[$f_attr])* 17 | $f_name: $f_ty, 18 | )* 19 | /// The list of artists who made this album. 20 | artists: Vec, 21 | /// The markets in which at least 1 of the album's tracks is available. Only Some if 22 | /// the market parameter is not supplied in the request. This is an ISO 3166 2-letter 23 | /// country code. 24 | available_markets: Option>, 25 | /// Known external URLs for this album. 26 | external_urls: HashMap, 27 | /// The cover art for the album in various sizes, widest first. 28 | images: Vec, 29 | /// The name of the album; if the album has been taken down, this is an empty string. 30 | name: String, 31 | /// When [track 32 | /// relinking](https://developer.spotify.com/documentation/general/guides/track-relinking-guide/) 33 | /// is applied, the original track isn't available in the given market and Spotify didn't have 34 | /// any tracks to relink it with, then this is Some. 35 | restrictions: Option, 36 | /// The item type; `album`. 37 | #[serde(rename = "type")] 38 | item_type: TypeAlbum, 39 | }); 40 | } 41 | } 42 | 43 | inherit_album_simplified!( 44 | /// A simplified album object. 45 | AlbumSimplified { 46 | /// The type of album: album, single or compilation. This can only be not present for the 47 | /// album of a local track, which can only ever be obtained from a playlist. 48 | album_type: Option, 49 | /// The [Spotify ID](https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids) 50 | /// for this album. This can only be [`None`] for the album of a local track, which can only 51 | /// ever be obtained from a playlist. 52 | id: Option, 53 | /// When the album was released. This can only be `None` for the album of a local track, 54 | /// which can only ever be obtained from a playlist. 55 | #[serde(deserialize_with = "util::de_date_any_precision_option")] 56 | release_date: Option, 57 | /// How precise the release date is: precise to the year, month or day. This can only be 58 | /// [`None`] for the album of a local track,which can only ever be obtained from a playlist. 59 | release_date_precision: Option, 60 | } 61 | ); 62 | 63 | macro_rules! inherit_album_not_local { 64 | ($(#[$attr:meta])* $name:ident { $($(#[$f_attr:meta])* $f_name:ident : $f_ty:ty,)* }) => { 65 | inherit_album_simplified!($(#[$attr])* $name { 66 | $( 67 | $(#[$f_attr])* 68 | $f_name: $f_ty, 69 | )* 70 | /// The type of album: album, single or compilation. 71 | album_type: AlbumType, 72 | /// The [Spotify ID](https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids) 73 | /// for this album. 74 | id: String, 75 | /// When the album was released. 76 | #[serde(deserialize_with = "util::de_date_any_precision")] 77 | release_date: NaiveDate, 78 | /// How precise the release date is: precise to the year, month or day. 79 | release_date_precision: DatePrecision, 80 | }); 81 | } 82 | } 83 | 84 | inherit_album_not_local!( 85 | /// An album object. 86 | Album { 87 | /// The known copyrights of this album. 88 | copyrights: Vec, 89 | /// Known external IDs for this album. 90 | external_ids: HashMap, 91 | /// A list of the genres used to classify the album. For example: "Prog Rock", "Post-Grunge". 92 | /// If not yet classified, the array is empty. 93 | genres: Vec, 94 | /// The label of the album. 95 | label: String, 96 | /// The popularity of the album. The value will be between 0 and 100, with 100 being the most 97 | /// popular. The popularity is calculated from the popularity of the album's individual tracks. 98 | popularity: u32, 99 | /// A page of tracks in the album. 100 | tracks: Page, 101 | } 102 | ); 103 | inherit_album_not_local!( 104 | /// A simplified album object from the context of an artist. 105 | ArtistsAlbum { 106 | /// Similar to AlbumType, but also includes if the artist features on the album, and didn't 107 | /// create it as an album, single or compilation. 108 | album_group: AlbumGroup, 109 | } 110 | ); 111 | 112 | impl Album { 113 | /// Convert to an `AlbumSimplified`. 114 | #[must_use] 115 | pub fn simplify(self) -> AlbumSimplified { 116 | AlbumSimplified { 117 | album_type: Some(self.album_type), 118 | artists: self.artists, 119 | available_markets: self.available_markets, 120 | external_urls: self.external_urls, 121 | id: Some(self.id), 122 | images: self.images, 123 | name: self.name, 124 | release_date: Some(self.release_date), 125 | release_date_precision: Some(self.release_date_precision), 126 | restrictions: self.restrictions, 127 | item_type: TypeAlbum, 128 | } 129 | } 130 | } 131 | impl From for AlbumSimplified { 132 | fn from(album: Album) -> Self { 133 | album.simplify() 134 | } 135 | } 136 | impl ArtistsAlbum { 137 | /// Convert to an `AlbumSimplified`. 138 | #[must_use] 139 | pub fn simplify(self) -> AlbumSimplified { 140 | AlbumSimplified { 141 | album_type: Some(self.album_type), 142 | artists: self.artists, 143 | available_markets: self.available_markets, 144 | external_urls: self.external_urls, 145 | id: Some(self.id), 146 | images: self.images, 147 | name: self.name, 148 | release_date: Some(self.release_date), 149 | release_date_precision: Some(self.release_date_precision), 150 | restrictions: self.restrictions, 151 | item_type: TypeAlbum, 152 | } 153 | } 154 | } 155 | impl From for AlbumSimplified { 156 | fn from(album: ArtistsAlbum) -> Self { 157 | album.simplify() 158 | } 159 | } 160 | 161 | /// The type of album. 162 | #[derive(Debug, Clone, PartialEq, Eq, Copy, Hash, Serialize, Deserialize)] 163 | #[serde(rename_all = "snake_case")] 164 | pub enum AlbumType { 165 | /// An album. 166 | #[serde(alias = "ALBUM")] 167 | Album, 168 | /// A single. 169 | #[serde(alias = "SINGLE")] 170 | Single, 171 | /// A compilation album. 172 | #[serde(alias = "COMPILATION")] 173 | Compilation, 174 | } 175 | 176 | /// Similar to `AlbumType`, but with an extra variant. 177 | #[derive(Debug, Clone, PartialEq, Eq, Copy, Hash, Serialize, Deserialize)] 178 | #[serde(rename_all = "snake_case")] 179 | pub enum AlbumGroup { 180 | /// An album. 181 | Album, 182 | /// A single. 183 | Single, 184 | /// A compilation album. 185 | Compilation, 186 | /// When getting all an artist's albums, if the artist didn't release the album but instead 187 | /// appeared on it, it is this value. 188 | AppearsOn, 189 | } 190 | 191 | impl AlbumGroup { 192 | /// Get the album's type as a string. 193 | #[must_use] 194 | pub fn as_str(self) -> &'static str { 195 | match self { 196 | AlbumGroup::Album => "album", 197 | AlbumGroup::Single => "single", 198 | AlbumGroup::Compilation => "compilation", 199 | AlbumGroup::AppearsOn => "appears_on", 200 | } 201 | } 202 | } 203 | 204 | /// Information about an album that has been saved. 205 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 206 | pub struct SavedAlbum { 207 | /// When the album was saved. 208 | pub added_at: DateTime, 209 | /// Information about the album. 210 | pub album: Album, 211 | } 212 | -------------------------------------------------------------------------------- /src/model/analysis.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Formatter}; 2 | use std::time::Duration; 3 | 4 | use serde::de::{self, Deserializer, Unexpected, Visitor}; 5 | use serde::{Deserialize, Serialize, Serializer}; 6 | 7 | use crate::model::TypeAudioFeatures; 8 | 9 | /// Information and features of a track. 10 | /// 11 | /// See [the Spotify Web API 12 | /// reference](https://developer.spotify.com/documentation/web-api/reference/object-model/#audio-features-object) 13 | /// for more details on each on the items. 14 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 15 | #[allow(missing_docs)] 16 | pub struct AudioFeatures { 17 | /// The [Spotify ID](https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids) 18 | /// for the track. 19 | pub id: String, 20 | /// The length of the track. 21 | #[serde(rename = "duration_ms", with = "serde_millis")] 22 | pub duration: Duration, 23 | pub acousticness: f64, 24 | pub danceability: f64, 25 | pub energy: f64, 26 | pub instrumentalness: f64, 27 | pub key: u32, 28 | pub liveness: f64, 29 | pub loudness: f64, 30 | pub mode: Mode, 31 | pub speechiness: f64, 32 | pub tempo: f64, 33 | pub time_signature: u32, 34 | pub valence: f64, 35 | /// The item type; `audio_features`. 36 | #[serde(rename = "type")] 37 | pub item_type: TypeAudioFeatures, 38 | } 39 | 40 | /// The mode of a track (major or minor). 41 | #[derive(Debug, Clone, PartialEq, Eq, Copy, Hash)] 42 | pub enum Mode { 43 | /// The track is major. 44 | Major, 45 | /// The track is minor. 46 | Minor, 47 | } 48 | 49 | struct ModeVisitor; 50 | 51 | impl<'de> Visitor<'de> for ModeVisitor { 52 | type Value = Mode; 53 | fn expecting(&self, f: &mut Formatter<'_>) -> fmt::Result { 54 | f.write_str("a mode which is 0 (minor) or 1 (major)") 55 | } 56 | fn visit_u64(self, v: u64) -> Result { 57 | match v { 58 | 0 => Ok(Mode::Minor), 59 | 1 => Ok(Mode::Major), 60 | _ => Err(E::invalid_value(Unexpected::Unsigned(v), &self)), 61 | } 62 | } 63 | } 64 | 65 | impl<'de> Deserialize<'de> for Mode { 66 | fn deserialize(deserializer: D) -> Result 67 | where 68 | D: Deserializer<'de>, 69 | { 70 | deserializer.deserialize_u64(ModeVisitor) 71 | } 72 | } 73 | 74 | impl Serialize for Mode { 75 | fn serialize(&self, serializer: S) -> Result { 76 | match self { 77 | Self::Major => serializer.serialize_u64(1), 78 | Self::Minor => serializer.serialize_u64(0), 79 | } 80 | } 81 | } 82 | 83 | mod serde_mode_opt { 84 | use super::{Mode, ModeVisitor}; 85 | use serde::{ 86 | de::{self, Visitor}, 87 | Deserializer, Serialize, Serializer, 88 | }; 89 | use std::fmt::{self, Formatter}; 90 | use std::u64; 91 | 92 | struct ModeOptVisitor; 93 | impl<'de> Visitor<'de> for ModeOptVisitor { 94 | type Value = Option; 95 | fn expecting(&self, f: &mut Formatter<'_>) -> fmt::Result { 96 | f.write_str("-1 or a mode") 97 | } 98 | fn visit_i64(self, v: i64) -> Result { 99 | match v { 100 | -1 => Ok(None), 101 | _ => self.visit_u64(u64::MAX), 102 | } 103 | } 104 | fn visit_u64(self, v: u64) -> Result { 105 | ModeVisitor.visit_u64(v).map(Some) 106 | } 107 | } 108 | 109 | pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> 110 | where 111 | D: Deserializer<'de>, 112 | { 113 | deserializer.deserialize_i64(ModeOptVisitor) 114 | } 115 | 116 | #[allow(clippy::trivially_copy_pass_by_ref)] 117 | pub fn serialize(v: &Option, serializer: S) -> Result { 118 | match v { 119 | Some(mode) => mode.serialize(serializer), 120 | None => serializer.serialize_i64(-1), 121 | } 122 | } 123 | } 124 | 125 | mod serde_key_opt { 126 | use serde::{ 127 | de::{self, Unexpected, Visitor}, 128 | Deserializer, Serializer, 129 | }; 130 | use std::convert::TryInto; 131 | use std::fmt::{self, Formatter}; 132 | 133 | struct KeyOptVisitor; 134 | impl<'de> Visitor<'de> for KeyOptVisitor { 135 | type Value = Option; 136 | fn expecting(&self, f: &mut Formatter<'_>) -> fmt::Result { 137 | f.write_str("-1 or a key") 138 | } 139 | fn visit_i64(self, v: i64) -> Result { 140 | match v { 141 | -1 => Ok(None), 142 | _ => Err(E::invalid_value(Unexpected::Signed(v), &self)), 143 | } 144 | } 145 | fn visit_u64(self, v: u64) -> Result { 146 | match v { 147 | 0..=11 => Ok(Some(v.try_into().unwrap())), 148 | _ => Err(E::invalid_value(Unexpected::Unsigned(v), &self)), 149 | } 150 | } 151 | } 152 | 153 | pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> 154 | where 155 | D: Deserializer<'de>, 156 | { 157 | deserializer.deserialize_i64(KeyOptVisitor) 158 | } 159 | 160 | #[allow(clippy::trivially_copy_pass_by_ref)] 161 | pub fn serialize(v: &Option, serializer: S) -> Result { 162 | match v { 163 | Some(v) => serializer.serialize_u32(*v), 164 | None => serializer.serialize_i32(-1), 165 | } 166 | } 167 | } 168 | 169 | /// Audio analysis of a track. 170 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 171 | pub struct AudioAnalysis { 172 | /// The time intervals of bars throughout the track. A bar is a segment of time defined as a 173 | /// given number of beats. Bar offsets also indicate downbeats, the first beat of a bar. 174 | pub bars: Vec, 175 | /// The time intervals of beats throughout the track. A beat is the basic time unit of a piece 176 | /// of music; for example, each tick of a metronome. Beats are typically multiples of tatums. 177 | pub beats: Vec, 178 | /// A tatum represents the lowest regular pulse train that a listener intuitively infers from 179 | /// the timing of perceived musical events (segments). For more information about tatums, see 180 | /// Rhythm (below). 181 | pub tatums: Vec, 182 | /// Sections are defined by large variations in rhythm or timbre, e.g. chorus, verse, bridge, 183 | /// guitar solo, etc. Each section contains its own descriptions of tempo, key, mode, 184 | /// time_signature, and loudness. 185 | pub sections: Vec
, 186 | /// Audio segments attempts to subdivide a track into many segments, with each segment 187 | /// containing a roughly consistent sound throughout its duration. 188 | pub segments: Vec, 189 | } 190 | 191 | /// A time interval in a track. 192 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 193 | pub struct TimeInterval { 194 | /// The starting point of the time interval. 195 | #[serde(with = "crate::util::serde_duration_secs")] 196 | pub start: Duration, 197 | /// The duration of the time interval. 198 | #[serde(with = "crate::util::serde_duration_secs")] 199 | pub duration: Duration, 200 | /// The confidence, from 0 to 1, of the reliability of the interval. 201 | pub confidence: f64, 202 | } 203 | 204 | /// A section of a track. 205 | /// 206 | /// See [the Spotify docs for a section 207 | /// object](https://developer.spotify.com/documentation/web-api/reference/tracks/get-audio-analysis/#section-object) 208 | /// for more information. 209 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 210 | #[allow(missing_docs)] 211 | pub struct Section { 212 | /// The interval of the section. 213 | #[serde(flatten)] 214 | pub interval: TimeInterval, 215 | pub loudness: f64, 216 | pub tempo: f64, 217 | pub tempo_confidence: f64, 218 | #[serde(with = "serde_key_opt")] 219 | pub key: Option, 220 | pub key_confidence: f64, 221 | #[serde(with = "serde_mode_opt")] 222 | pub mode: Option, 223 | pub mode_confidence: f64, 224 | pub time_signature: u32, 225 | pub time_signature_confidence: f64, 226 | } 227 | 228 | /// A segment in a track. 229 | /// 230 | /// See [the Spotify docs for a segment 231 | /// object](https://developer.spotify.com/documentation/web-api/reference/tracks/get-audio-analysis/#segment-object) 232 | /// for more information. 233 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 234 | #[allow(missing_docs)] 235 | pub struct Segment { 236 | /// The interval of the segment. 237 | #[serde(flatten)] 238 | pub interval: TimeInterval, 239 | pub loudness_start: f64, 240 | pub loudness_max: f64, 241 | pub loudness_max_time: f64, 242 | pub pitches: Vec, 243 | pub timbre: Vec, 244 | } 245 | -------------------------------------------------------------------------------- /src/model/artist.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::model::{Followers, Image, TypeArtist}; 4 | 5 | macro_rules! inherit_artist_simplified { 6 | ($(#[$attr:meta])* $name:ident { $($(#[$f_attr:meta])* $f_name:ident : $f_ty:ty,)* }) => { 7 | to_struct!($(#[$attr])* $name { 8 | $( 9 | $(#[$f_attr])* 10 | $f_name: $f_ty, 11 | )* 12 | /// Known external URLs for this artist. 13 | external_urls: HashMap, 14 | /// The name of the artist. 15 | name: String, 16 | /// The object type; `artist`. 17 | #[serde(rename = "type")] 18 | item_type: TypeArtist, 19 | }); 20 | } 21 | } 22 | 23 | inherit_artist_simplified!( 24 | /// A simplified artist object. 25 | ArtistSimplified { 26 | /// The [Spotify ID](https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids) 27 | /// for the artist. Only `None` for local tracks on a playlist. 28 | id: Option, 29 | } 30 | ); 31 | inherit_artist_simplified!( 32 | /// An artist object. 33 | Artist { 34 | /// The [Spotify ID](https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids) 35 | /// for the artist. 36 | id: String, 37 | /// Information about the followers of this artist. 38 | followers: Followers, 39 | /// A list of the genres this artist is associated with. For example: "Prog Rock", 40 | /// "Post-Grunge". If not yet classified, the array is empty. 41 | genres: Vec, 42 | /// Images of the artist in various sizes, widest first. 43 | images: Vec, 44 | /// The popularity of the artist. The value will be between 0 and 100, with 100 being the most 45 | /// popular. The artist's popularity is calculated from the popularity of all the artist's 46 | /// tracks. 47 | popularity: u32, 48 | } 49 | ); 50 | 51 | impl Artist { 52 | /// Convert to an `ArtistSimplified`. 53 | #[must_use] 54 | pub fn simplify(self) -> ArtistSimplified { 55 | ArtistSimplified { 56 | external_urls: self.external_urls, 57 | id: Some(self.id), 58 | name: self.name, 59 | item_type: TypeArtist, 60 | } 61 | } 62 | } 63 | impl From for ArtistSimplified { 64 | fn from(artist: Artist) -> Self { 65 | artist.simplify() 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/model/device.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::time::Duration; 3 | 4 | use serde::ser::{SerializeStruct, Serializer}; 5 | use serde::{Deserialize, Serialize}; 6 | // See line 50 7 | //use chrono::serde::ts_milliseconds; 8 | 9 | use crate::model::{Episode, ItemType, Track}; 10 | use crate::util; 11 | 12 | /// A device object. 13 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 14 | pub struct Device { 15 | /// The device id. It can be [`None`], and I don't know why. 16 | pub id: Option, 17 | /// Whether this device is the currently active device. 18 | pub is_active: bool, 19 | /// Whether this device is currently in a private session. 20 | pub is_private_session: bool, 21 | /// Whether controlling this device is restricted; if set to true, no Web API commands will be 22 | /// accepted by it. 23 | pub is_restricted: bool, 24 | /// The name of the device. 25 | pub name: String, 26 | /// The type of the device. 27 | #[serde(rename = "type")] 28 | pub device_type: DeviceType, 29 | /// The current volume in percent. It can be [`None`], and I don't know why. 30 | pub volume_percent: Option, 31 | } 32 | 33 | /// A type of device. 34 | #[derive(Debug, Clone, PartialEq, Eq, Copy, Hash, Serialize, Deserialize)] 35 | #[allow(missing_docs)] 36 | pub enum DeviceType { 37 | Computer, 38 | Tablet, 39 | Smartphone, 40 | Speaker, 41 | TV, 42 | AVR, 43 | STB, 44 | AudioDongle, 45 | GameConsole, 46 | CastVideo, 47 | CastAudio, 48 | Automobile, 49 | Unknown, 50 | } 51 | 52 | /// Information about the currently playing track. 53 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 54 | pub struct CurrentlyPlaying { 55 | /// The context of the currently playing track. Is [`None`] for example if a private session is 56 | /// enabled. 57 | pub context: Option, 58 | // Spotify gave me negative timestamps for some reason so I had to disable this. 59 | // /// When data was fetched. 60 | // #[serde(with = "ts_milliseconds")] 61 | // pub timestamp: DateTime, 62 | /// Progress into the currently playing track. Is [`None`] for example if a private session is 63 | /// enabled. 64 | #[serde(rename = "progress_ms", with = "util::serde_duration_millis_option")] 65 | pub progress: Option, 66 | /// If something is currently playing. 67 | pub is_playing: bool, 68 | /// The currently playing item. Is [`None`] for example if a private session is enabled. 69 | #[serde(flatten)] 70 | pub item: Option, 71 | /// Which actions are disallowed in the current context. 72 | pub actions: Actions, 73 | } 74 | 75 | /// Information about a user's current playback state. 76 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 77 | pub struct CurrentPlayback { 78 | /// The currently active device. 79 | pub device: Device, 80 | /// The repeat state. 81 | pub repeat_state: RepeatState, 82 | /// Whether shuffle is on. 83 | pub shuffle_state: bool, 84 | /// The currently playing track. 85 | #[serde(flatten)] 86 | pub currently_playing: CurrentlyPlaying, 87 | } 88 | 89 | /// Actions that are disallowed in the current context. 90 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 91 | pub struct Actions { 92 | /// The list of disallowed actions. 93 | #[serde(with = "util::serde_disallows")] 94 | pub disallows: Vec, 95 | } 96 | 97 | /// An action that is currently not able to be performed. 98 | #[derive(Debug, Clone, PartialEq, Eq, Copy, Hash, Serialize, Deserialize)] 99 | #[serde(rename_all = "snake_case")] 100 | #[allow(missing_docs)] 101 | pub enum Disallow { 102 | InterruptingPlayback, 103 | Pausing, 104 | Resuming, 105 | Seeking, 106 | SkippingNext, 107 | SkippingPrev, 108 | TogglingRepeatContext, 109 | TogglingShuffle, 110 | TogglingRepeatTrack, 111 | TransferringPlayback, 112 | } 113 | 114 | /// The type of a currently playing item. 115 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 116 | #[serde( 117 | rename_all = "snake_case", 118 | tag = "currently_playing_type", 119 | content = "item" 120 | )] 121 | pub enum PlayingType { 122 | /// A track. 123 | Track(Track), 124 | /// An episode of a show. 125 | Episode(Episode), 126 | /// An advert. 127 | Ad(Track), 128 | /// An unknown track type. 129 | Unknown(Track), 130 | } 131 | 132 | /// The context of the current playing track. 133 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] 134 | pub struct Context { 135 | /// The type of context; album, artist, playlist, track. 136 | #[serde(rename = "type")] 137 | pub context_type: ItemType, 138 | /// External URLs for this context. 139 | pub external_urls: HashMap, 140 | /// The [Spotify 141 | /// ID](https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids) 142 | /// for the context. 143 | #[serde(rename = "uri", deserialize_with = "util::de_any_uri")] 144 | pub id: String, 145 | } 146 | 147 | impl Serialize for Context { 148 | fn serialize(&self, serializer: S) -> Result { 149 | let mut context = serializer.serialize_struct("Context", 3)?; 150 | context.serialize_field("type", &self.context_type)?; 151 | context.serialize_field("external_urls", &self.external_urls)?; 152 | context.serialize_field("uri", { 153 | struct UriSerialize<'a> { 154 | context_type: ItemType, 155 | id: &'a str, 156 | } 157 | impl Serialize for UriSerialize<'_> { 158 | fn serialize(&self, serializer: S) -> Result { 159 | serializer.serialize_str(&format!( 160 | "spotify:{}:{}", 161 | self.context_type.as_str(), 162 | self.id 163 | )) 164 | } 165 | } 166 | &UriSerialize { 167 | context_type: self.context_type, 168 | id: &self.id, 169 | } 170 | })?; 171 | context.end() 172 | } 173 | } 174 | 175 | /// Repeating the track, the context or not at all. 176 | #[derive(Debug, Clone, PartialEq, Eq, Copy, Hash, Serialize, Deserialize)] 177 | #[serde(rename_all = "snake_case")] 178 | pub enum RepeatState { 179 | /// Not repeating. 180 | Off, 181 | /// Repeating the current track. 182 | Track, 183 | /// Repeating the current context (e.g. playlist, album, etc). 184 | Context, 185 | } 186 | 187 | impl RepeatState { 188 | /// Get the state of repeating as a lowercase string. 189 | /// 190 | /// # Examples 191 | /// 192 | /// ``` 193 | /// let state = aspotify::RepeatState::Track; 194 | /// 195 | /// assert_eq!(state.as_str(), "track"); 196 | /// ``` 197 | #[must_use] 198 | pub const fn as_str(self) -> &'static str { 199 | match self { 200 | Self::Off => "off", 201 | Self::Track => "track", 202 | Self::Context => "context", 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/model/errors.rs: -------------------------------------------------------------------------------- 1 | use std::error; 2 | use std::fmt::{self, Display, Formatter}; 3 | 4 | use reqwest::StatusCode; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use crate::util; 8 | 9 | /// An error caused by one of the Web API endpoints relating to authentication. 10 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 11 | pub struct AuthError { 12 | /// A high level description of the error. 13 | pub error: String, 14 | /// A more detailed description of the error. 15 | pub error_description: String, 16 | } 17 | 18 | impl Display for AuthError { 19 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 20 | write!(f, "{}: {}", self.error, self.error_description) 21 | } 22 | } 23 | 24 | impl error::Error for AuthError {} 25 | 26 | /// A regular error object returned by endpoints of the API. 27 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 28 | #[serde(from = "EndpointErrorWrapper", into = "EndpointErrorWrapper")] 29 | pub struct EndpointError { 30 | /// The HTTP status code of the error. 31 | pub status: StatusCode, 32 | /// A short description of the error's cause. 33 | pub message: String, 34 | /// The reason for the error. Only present for player endpoints. 35 | pub reason: Option, 36 | } 37 | 38 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 39 | struct EndpointErrorWrapper { 40 | error: EndpointErrorInternal, 41 | } 42 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 43 | struct EndpointErrorInternal { 44 | #[serde(with = "util::serde_status_code")] 45 | status: StatusCode, 46 | message: String, 47 | #[serde(default)] 48 | reason: Option, 49 | } 50 | impl From for EndpointError { 51 | fn from(error: EndpointErrorWrapper) -> Self { 52 | Self { 53 | status: error.error.status, 54 | message: error.error.message, 55 | reason: error.error.reason, 56 | } 57 | } 58 | } 59 | impl From for EndpointErrorWrapper { 60 | fn from(error: EndpointError) -> Self { 61 | Self { 62 | error: EndpointErrorInternal { 63 | status: error.status, 64 | message: error.message, 65 | reason: error.reason, 66 | }, 67 | } 68 | } 69 | } 70 | 71 | impl Display for EndpointError { 72 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 73 | if let Some(reason) = self.reason { 74 | write!(f, "{}: {}", self.message, reason) 75 | } else { 76 | write!(f, "Error {}: {}", self.status, self.message) 77 | } 78 | } 79 | } 80 | 81 | impl error::Error for EndpointError {} 82 | 83 | /// An error sending a request to a Spotify endpoint. 84 | #[derive(Debug)] 85 | #[non_exhaustive] 86 | pub enum Error { 87 | /// An error caused when sending the HTTP request. 88 | Http(reqwest::Error), 89 | /// An error caused parsing the response. 90 | Parse(serde_json::error::Error), 91 | /// An error caused in authentication. 92 | Auth(AuthError), 93 | /// An error caused by a Spotify endpoint. 94 | Endpoint(EndpointError), 95 | } 96 | 97 | impl Display for Error { 98 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 99 | match self { 100 | Self::Http(e) => e.fmt(f), 101 | Self::Parse(e) => e.fmt(f), 102 | Self::Auth(e) => e.fmt(f), 103 | Self::Endpoint(e) => e.fmt(f), 104 | } 105 | } 106 | } 107 | 108 | impl error::Error for Error { 109 | fn source(&self) -> Option<&(dyn error::Error + 'static)> { 110 | Some(match self { 111 | Self::Http(e) => e, 112 | Self::Parse(e) => e, 113 | Self::Auth(e) => e, 114 | Self::Endpoint(e) => e, 115 | }) 116 | } 117 | } 118 | 119 | impl From for Error { 120 | fn from(error: reqwest::Error) -> Self { 121 | Self::Http(error) 122 | } 123 | } 124 | impl From for Error { 125 | fn from(error: serde_json::error::Error) -> Self { 126 | Self::Parse(error) 127 | } 128 | } 129 | impl From for Error { 130 | fn from(error: AuthError) -> Self { 131 | Self::Auth(error) 132 | } 133 | } 134 | impl From for Error { 135 | fn from(error: EndpointError) -> Self { 136 | Self::Endpoint(error) 137 | } 138 | } 139 | 140 | /// A reason for an error caused by the Spotify player. 141 | #[derive(Debug, Clone, PartialEq, Eq, Copy, Hash, Serialize, Deserialize)] 142 | #[serde(rename_all = "SCREAMING_SNAKE_CASE")] 143 | pub enum PlayerErrorReason { 144 | /// There is no previous track in the context. 145 | NoPrevTrack, 146 | /// There is no next track in the context. 147 | NoNextTrack, 148 | /// The requested track does not exist. 149 | NoSpecificTrack, 150 | /// Playback is paused. 151 | AlreadyPaused, 152 | /// Playback is not paused. 153 | NotPaused, 154 | /// Playback is not on the local device. 155 | NotPlayingLocally, 156 | /// No track is currently playing. 157 | NotPlayingTrack, 158 | /// No context is currently playing. 159 | NotPlayingContext, 160 | /// The current context is endless, so the shuffle command cannot be applied. 161 | EndlessContext, 162 | /// The command cannot be performed on the current context. 163 | ContextDisallow, 164 | /// The command requested a new track and context to play, but it is the same as the old one 165 | /// and there is a resume point. 166 | AlreadyPlaying, 167 | /// Too frequent track play. 168 | RateLimited, 169 | /// The context cannot be remote controlled. 170 | RemoteControlDisallow, 171 | /// It is not possible to remote control the device. 172 | DeviceNotControllable, 173 | /// It is not possible to remote control the device's volume. 174 | VolumeControlDisallow, 175 | /// The user does not have an active device. 176 | NoActiveDevice, 177 | /// The action requires premium, which the user doesn't have. 178 | PremiumRequired, 179 | /// The action is restricted due to unknown reasons. 180 | Unknown, 181 | } 182 | 183 | impl Display for PlayerErrorReason { 184 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 185 | f.write_str(match self { 186 | Self::NoPrevTrack => "There is no previous track", 187 | Self::NoNextTrack => "There is no next track", 188 | Self::NoSpecificTrack => "The requested track does not exist", 189 | Self::AlreadyPaused => "Playback is paused", 190 | Self::NotPaused => "Playback is not paused", 191 | Self::NotPlayingLocally => "Playback is not on the local device", 192 | Self::NotPlayingTrack => "No track is currently playing", 193 | Self::NotPlayingContext => "No context is currently playing", 194 | Self::EndlessContext => "The current context is endless", 195 | Self::ContextDisallow => "The action cannot be performed on the current context", 196 | Self::AlreadyPlaying => "The same track is already playing", 197 | Self::RateLimited => "Too frequent track play", 198 | Self::RemoteControlDisallow => "The context cannot be remote controlled", 199 | Self::DeviceNotControllable => "It is not possible to control the device", 200 | Self::VolumeControlDisallow => "It is not possible to control the device's volume", 201 | Self::NoActiveDevice => "The user does not have an active device", 202 | Self::PremiumRequired => "The action requires premium", 203 | Self::Unknown => "The action is restricted for unknown reasons", 204 | }) 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/model/mod.rs: -------------------------------------------------------------------------------- 1 | //! The Spotify [Object 2 | //! Model](https://developer.spotify.com/documentation/web-api/reference/object-model/), in 3 | //! deserializable Rust structures. 4 | 5 | use serde::{Deserialize, Serialize}; 6 | 7 | pub use album::*; 8 | pub use analysis::*; 9 | pub use artist::*; 10 | pub use consts::*; 11 | pub use device::*; 12 | pub use errors::*; 13 | pub use playlist::*; 14 | pub use show::*; 15 | pub use track::*; 16 | pub use user::*; 17 | 18 | macro_rules! to_struct { 19 | ($(#[$attr:meta])* $name:ident { $($(#[$f_attr:meta])* $f_name:ident : $f_ty:ty,)* }) => { 20 | $(#[$attr])* 21 | #[derive(Debug, Clone, PartialEq, Eq, ::serde::Serialize, ::serde::Deserialize)] 22 | pub struct $name { 23 | $( 24 | $(#[$f_attr])* 25 | pub $f_name: $f_ty, 26 | )* 27 | } 28 | } 29 | } 30 | 31 | mod album; 32 | mod analysis; 33 | mod artist; 34 | mod device; 35 | mod errors; 36 | mod playlist; 37 | mod show; 38 | mod track; 39 | mod user; 40 | 41 | /// Serialization and deserialization constants. 42 | pub mod consts { 43 | use std::fmt::{self, Formatter}; 44 | 45 | use serde::de::{self, Deserialize, Deserializer, Visitor}; 46 | use serde::ser::{Serialize, Serializer}; 47 | 48 | macro_rules! serde_string_consts { 49 | ($($name:ident = $value:literal $svalue:expr,)*) => { 50 | $( 51 | #[doc = "The string `"] 52 | #[doc = $svalue] 53 | #[doc = "`."] 54 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 55 | pub struct $name; 56 | 57 | impl<'de> Deserialize<'de> for $name { 58 | fn deserialize>(deserializer: D) -> Result { 59 | struct ValueVisitor; 60 | impl<'de> Visitor<'de> for ValueVisitor { 61 | type Value = $name; 62 | fn expecting(&self, f: &mut Formatter<'_>) -> fmt::Result { 63 | f.write_str(concat!("the string ", $svalue)) 64 | } 65 | fn visit_str(self, v: &str) -> Result { 66 | if v != $value { 67 | return Err(E::invalid_value(de::Unexpected::Str(v), &self)); 68 | } 69 | Ok($name) 70 | } 71 | } 72 | 73 | deserializer.deserialize_str(ValueVisitor) 74 | } 75 | } 76 | 77 | impl Serialize for $name { 78 | fn serialize(&self, serializer: S) -> Result { 79 | serializer.serialize_str($value) 80 | } 81 | } 82 | )* 83 | }; 84 | ($($name:ident = $value:literal,)*) => { 85 | serde_string_consts!($($name = $value stringify!($value),)*); 86 | }; 87 | } 88 | 89 | serde_string_consts! { 90 | TypeAlbum = "album", 91 | TypeArtist = "artist", 92 | TypeAudioFeatures = "audio_features", 93 | TypeEpisode = "episode", 94 | TypePlaylist = "playlist", 95 | TypeShow = "show", 96 | TypeTrack = "track", 97 | TypeUser = "user", 98 | } 99 | } 100 | 101 | /// A category of music, for example "Mood", "Top Lists", "Workout", et cetera. 102 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 103 | pub struct Category { 104 | /// The category icon, in various sizes, probably with widest first (although this is not 105 | /// specified by the Web API documentation). 106 | pub icons: Vec, 107 | /// The [Spotify ID](https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids) 108 | /// for the category. 109 | pub id: String, 110 | /// The name of the category. 111 | pub name: String, 112 | } 113 | 114 | /// The copyright information for a resource. 115 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 116 | pub struct Copyright { 117 | /// The copyright text. 118 | pub text: String, 119 | /// Whether the copyright is for the performance of the piece, not the piece. 120 | #[serde(rename = "type", with = "serde_is_p")] 121 | pub performance_copyright: bool, 122 | } 123 | 124 | mod serde_is_p { 125 | use serde::{ 126 | de::{self, Visitor}, 127 | Deserializer, Serializer, 128 | }; 129 | use std::fmt::{self, Formatter}; 130 | 131 | pub fn deserialize<'de, D>(deserializer: D) -> Result 132 | where 133 | D: Deserializer<'de>, 134 | { 135 | struct CopyrightType; 136 | 137 | impl<'de> Visitor<'de> for CopyrightType { 138 | type Value = bool; 139 | fn expecting(&self, f: &mut Formatter<'_>) -> fmt::Result { 140 | f.write_str("P or C") 141 | } 142 | fn visit_str(self, s: &str) -> Result { 143 | match s { 144 | "P" => Ok(true), 145 | "C" => Ok(false), 146 | _ => Err(de::Error::invalid_value(de::Unexpected::Str(s), &self)), 147 | } 148 | } 149 | } 150 | 151 | deserializer.deserialize_str(CopyrightType) 152 | } 153 | 154 | #[allow(clippy::trivially_copy_pass_by_ref)] 155 | pub fn serialize(v: &bool, serializer: S) -> Result { 156 | serializer.serialize_str(if *v { "P" } else { "C" }) 157 | } 158 | } 159 | 160 | /// Information about the followers of an item. Currently only contains the number of followers. 161 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 162 | pub struct Followers { 163 | /// The total number of followers. 164 | pub total: usize, 165 | } 166 | 167 | /// An image with a URL and an optional width and height. 168 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 169 | pub struct Image { 170 | /// The source URL of the image. 171 | pub url: String, 172 | /// The height of the image in pixels, if known. 173 | pub height: Option, 174 | /// The width of the image in pixels, if known. 175 | pub width: Option, 176 | } 177 | 178 | /// A page of items. 179 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 180 | pub struct Page { 181 | /// The items in the page. 182 | pub items: Vec, 183 | /// The maximum number of items in the page, as set by the request or a default value. 184 | pub limit: usize, 185 | /// The offset of the page in the items. 186 | pub offset: usize, 187 | /// The total number of items. 188 | pub total: usize, 189 | } 190 | 191 | /// A page of items, using a cursor to find the next page. 192 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 193 | pub struct CursorPage { 194 | /// The items in the page. 195 | pub items: Vec, 196 | /// The maximum number of items in the page, as set by the request or a default value. 197 | pub limit: usize, 198 | /// The cursor used to find the next set of items. 199 | pub cursors: Cursor, 200 | /// The total number of items. 201 | pub total: usize, 202 | } 203 | 204 | /// Object that contains the next `CursorPage`. 205 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 206 | pub struct Cursor { 207 | /// The cursor page after this one. 208 | pub after: Option, 209 | } 210 | 211 | /// A page of items, using a cursor to move backwards and forwards. 212 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 213 | pub struct TwoWayCursorPage { 214 | /// The items in the page. 215 | pub items: Vec, 216 | /// The maximum number of items in the page, as set by the request or a default value. 217 | pub limit: usize, 218 | /// The cursor used to find the next set of items. 219 | pub cursors: TwoWayCursor, 220 | } 221 | 222 | /// Object that contains the next and previous [`CursorPage`]. 223 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 224 | pub struct TwoWayCursor { 225 | /// The cursor page after this one. 226 | pub after: Option, 227 | /// The cursor page before this one. 228 | pub before: Option, 229 | } 230 | 231 | /// Recommended tracks for the user. 232 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 233 | pub struct Recommendations { 234 | /// An array of recommendation seeds. 235 | pub seeds: Vec, 236 | /// An array of simplified track objects. 237 | pub tracks: Vec, 238 | } 239 | 240 | /// How the recommendation was chosen. 241 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 242 | #[serde(rename_all = "camelCase")] 243 | pub struct RecommendationSeed { 244 | /// The number of tracks available after min_* and max_* filters have been applied. 245 | pub after_filtering_size: usize, 246 | /// The number of tracks available after relinking for regional availability. 247 | pub after_relinking_size: usize, 248 | /// The id used to select this seed, given by the user. 249 | pub id: String, 250 | /// The number of recommended tracks available for this seed. 251 | pub initial_pool_size: usize, 252 | /// The entity type of this seed; [Artist](SeedType::Artist), [Track](SeedType::Track) or 253 | /// [Genre](SeedType::Genre). 254 | #[serde(rename = "type")] 255 | pub entity_type: SeedType, 256 | } 257 | 258 | /// The context from which the recommendation was chosen; artist, track or genre. 259 | #[derive(Debug, Clone, PartialEq, Eq, Copy, Hash, Serialize, Deserialize)] 260 | #[serde(rename_all = "SCREAMING_SNAKE_CASE")] 261 | #[allow(missing_docs)] 262 | pub enum SeedType { 263 | Artist, 264 | Track, 265 | Genre, 266 | } 267 | 268 | /// How precise a date measurement is. 269 | #[derive(Debug, Clone, PartialEq, Eq, Copy, Hash, Serialize, Deserialize)] 270 | #[serde(rename_all = "snake_case")] 271 | pub enum DatePrecision { 272 | /// The measurement is precise to the nearest year. 273 | Year, 274 | /// The measurement is precise to the nearest month. 275 | Month, 276 | /// The measurement is precise to the nearest day. 277 | Day, 278 | } 279 | 280 | /// Restrictions applied to a track due to markets. 281 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 282 | pub struct Restrictions { 283 | /// Why the restriction was applied. 284 | pub reason: String, 285 | } 286 | 287 | /// A type of item in the Spotify model. 288 | #[derive(Debug, Clone, PartialEq, Eq, Copy, Hash, Serialize, Deserialize)] 289 | #[serde(rename_all = "snake_case")] 290 | #[allow(missing_docs)] 291 | pub enum ItemType { 292 | Album, 293 | Artist, 294 | Playlist, 295 | Track, 296 | Show, 297 | Episode, 298 | } 299 | 300 | impl ItemType { 301 | /// The type of item as a lowercase string. 302 | /// 303 | /// ``` 304 | /// assert_eq!(aspotify::ItemType::Episode.as_str(), "episode"); 305 | /// ``` 306 | #[must_use] 307 | pub const fn as_str(self) -> &'static str { 308 | match self { 309 | Self::Album => "album", 310 | Self::Artist => "artist", 311 | Self::Playlist => "playlist", 312 | Self::Track => "track", 313 | Self::Show => "show", 314 | Self::Episode => "episode", 315 | } 316 | } 317 | } 318 | 319 | /// The results of a search. 320 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 321 | pub struct SearchResults { 322 | /// The resulting artists of the search. 323 | pub artists: Option>, 324 | /// The resulting albums of the search. 325 | pub albums: Option>, 326 | /// The resulting tracks of the search. 327 | pub tracks: Option>, 328 | /// The resulting playlists of the search. 329 | pub playlists: Option>, 330 | /// The resulting shows of the search. 331 | pub shows: Option>, 332 | /// The resulting episodes of the search. 333 | pub episodes: Option>, 334 | } 335 | -------------------------------------------------------------------------------- /src/model/playlist.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::fmt::Display; 3 | 4 | use chrono::{DateTime, Utc}; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use crate::model::{Episode, Followers, Image, Page, Track, Tracks, TypePlaylist, UserSimplified}; 8 | 9 | macro_rules! inherit_playlist_simplified { 10 | ($(#[$attr:meta])* $name:ident { $($(#[$f_attr:meta])* $f_name:ident : $f_ty:ty,)* }) => { 11 | to_struct!($(#[$attr])* $name { 12 | $( 13 | $(#[$f_attr])* 14 | $f_name: $f_ty, 15 | )* 16 | /// Whether the owner allows other people to modify the playlist. Always is false from a search 17 | /// context. 18 | collaborative: bool, 19 | /// Known external URLs for this playlist. 20 | external_urls: HashMap, 21 | /// The [Spotify ID](https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids) 22 | /// for this playlist. 23 | id: String, 24 | /// Images for the playlist. It may be empty, or contain up to three images, in descending 25 | /// order of size. The URLs are temporary and will expire in less than a day. 26 | images: Vec, 27 | /// The name of the playlist. 28 | name: String, 29 | /// The user who owns the playlist. This is a [`UserPublic`](crate::UserPublic) 30 | /// according to the documentation, but in practice it is not. 31 | owner: UserSimplified, 32 | /// Whether the playlist is public; None if not relevant. 33 | public: Option, 34 | /// The version identifier of the playlist. 35 | snapshot_id: String, 36 | /// The item type; `playlist`. 37 | #[serde(rename = "type")] 38 | item_type: TypePlaylist, 39 | }); 40 | } 41 | } 42 | 43 | inherit_playlist_simplified!( 44 | /// A simplified playlist object. 45 | PlaylistSimplified { 46 | /// The number of tracks in the playlist. 47 | tracks: Tracks, 48 | } 49 | ); 50 | inherit_playlist_simplified!( 51 | /// A playlist object. 52 | Playlist { 53 | /// The playlist description, only for modified and verified playlists. 54 | description: Option, 55 | /// The followers of the playlist. 56 | followers: Followers, 57 | /// Information about the tracks and episodes of the playlist. 58 | tracks: Page, 59 | } 60 | ); 61 | 62 | impl Playlist { 63 | /// Convert to a `PlaylistSimplified`. 64 | #[must_use] 65 | pub fn simplify(self) -> PlaylistSimplified { 66 | PlaylistSimplified { 67 | collaborative: self.collaborative, 68 | external_urls: self.external_urls, 69 | id: self.id, 70 | images: self.images, 71 | name: self.name, 72 | owner: self.owner, 73 | public: self.public, 74 | snapshot_id: self.snapshot_id, 75 | tracks: Tracks { 76 | total: self.tracks.total, 77 | }, 78 | item_type: TypePlaylist, 79 | } 80 | } 81 | } 82 | impl From for PlaylistSimplified { 83 | fn from(playlist: Playlist) -> Self { 84 | playlist.simplify() 85 | } 86 | } 87 | 88 | /// Information about an item inside a playlist. 89 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 90 | pub struct PlaylistItem { 91 | /// The date and time that the item was added. Some very old playlists might have [`None`]. 92 | pub added_at: Option>, 93 | /// The Spotify user who added the item. Some very old playlists might have [`None`]. This is a 94 | /// [`UserPublic`](crate::UserPublic) according to the documentation, but in practice it is not. 95 | pub added_by: Option, 96 | /// Whether the item is a local file or not. 97 | pub is_local: bool, 98 | /// The item itself. Spotify API sometimes returns null for this, and I don't know why. 99 | #[serde(rename = "track")] 100 | pub item: Option>, 101 | } 102 | 103 | /// The types of item that can go in a playlist. 104 | #[derive(Debug, Clone, PartialEq, Eq, Copy, Hash, Serialize, Deserialize)] 105 | #[serde(untagged)] 106 | pub enum PlaylistItemType { 107 | /// A track. 108 | Track(T), 109 | /// An episode. 110 | Episode(E), 111 | } 112 | 113 | impl PlaylistItemType { 114 | /// Formats a Spotify URI using the [`Display`] implementations of the track and episode types. 115 | pub fn uri(&self) -> String { 116 | match self { 117 | Self::Track(track) => format!("spotify:track:{}", track), 118 | Self::Episode(episode) => format!("spotify:episode:{}", episode), 119 | } 120 | } 121 | } 122 | 123 | /// A list of featured playlists, and a message. 124 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 125 | pub struct FeaturedPlaylists { 126 | /// A message about the featured playlists. 127 | pub message: String, 128 | /// The list of featured playlists. 129 | pub playlists: Page, 130 | } 131 | -------------------------------------------------------------------------------- /src/model/show.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::time::Duration; 3 | 4 | use serde::{Deserialize, Serialize}; 5 | // See line 38+120 6 | //use isolanguage_1::LanguageCode; 7 | use chrono::{DateTime, NaiveDate, Utc}; 8 | 9 | use crate::model::{Copyright, DatePrecision, Image, Page, TypeEpisode, TypeShow}; 10 | use crate::util; 11 | 12 | macro_rules! inherit_show_simplified { 13 | ($(#[$attr:meta])* $name:ident { $($(#[$f_attr:meta])* $f_name:ident : $f_ty:ty,)* }) => { 14 | to_struct!($(#[$attr])* $name { 15 | $( 16 | $(#[$f_attr])* 17 | $f_name: $f_ty, 18 | )* 19 | /// A list of countries in which the show can be played. These are ISO 3166 2-letter 20 | /// country codes. 21 | available_markets: Vec, 22 | /// The copyright statements of the show. 23 | copyrights: Vec, 24 | /// A description of the show. 25 | description: String, 26 | /// Whether the show is explicit. 27 | explicit: bool, 28 | /// Known externals URLs for this show. 29 | external_urls: HashMap, 30 | /// The [Spotify ID](https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids) 31 | /// for this show. 32 | id: String, 33 | /// The cover art for the show in various sizes, widest first. 34 | images: Vec, 35 | /// Whether the episode is hosted outside of Spotify's CDN. Can be [`None`]. 36 | is_externally_hosted: Option, 37 | /// The list of languages used in the show. These are ISO 639 codes. 38 | // TODO: it can be en-US/en-GB 39 | languages: Vec, 40 | /// The media type of the show. 41 | media_type: String, 42 | /// The name of the show. 43 | name: String, 44 | /// The publisher of the show. 45 | publisher: String, 46 | /// The item type; `show`. 47 | #[serde(rename = "type")] 48 | item_type: TypeShow, 49 | }); 50 | } 51 | } 52 | 53 | inherit_show_simplified!( 54 | /// A simplified show object. 55 | ShowSimplified {} 56 | ); 57 | 58 | inherit_show_simplified!( 59 | /// A show object. 60 | Show { 61 | /// A list of the show's episodes. 62 | episodes: Page, 63 | } 64 | ); 65 | 66 | impl Show { 67 | /// Convert to a `ShowSimplified`. 68 | #[must_use] 69 | pub fn simplify(self) -> ShowSimplified { 70 | ShowSimplified { 71 | available_markets: self.available_markets, 72 | copyrights: self.copyrights, 73 | description: self.description, 74 | explicit: self.explicit, 75 | external_urls: self.external_urls, 76 | id: self.id, 77 | images: self.images, 78 | is_externally_hosted: self.is_externally_hosted, 79 | languages: self.languages, 80 | media_type: self.media_type, 81 | name: self.name, 82 | publisher: self.publisher, 83 | item_type: TypeShow, 84 | } 85 | } 86 | } 87 | impl From for ShowSimplified { 88 | fn from(show: Show) -> Self { 89 | show.simplify() 90 | } 91 | } 92 | 93 | /// Information about a show that has been saved. 94 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 95 | pub struct SavedShow { 96 | /// When the show was saved. 97 | pub added_at: DateTime, 98 | /// Information about the show. 99 | pub show: ShowSimplified, 100 | } 101 | 102 | macro_rules! inherit_episode_simplified { 103 | ($(#[$attr:meta])* $name:ident { $($(#[$f_attr:meta])* $f_name:ident : $f_ty:ty,)* }) => { 104 | to_struct!($(#[$attr])* $name { 105 | $( 106 | $(#[$f_attr])* 107 | $f_name: $f_ty, 108 | )* 109 | /// The URL of a 30 second MP3 preview of the episode, or None. 110 | audio_preview_url: Option, 111 | /// A description of the episode. 112 | description: String, 113 | /// The length of the episode. 114 | #[serde(rename = "duration_ms", with = "serde_millis")] 115 | duration: Duration, 116 | /// Whether the episode is explicit. 117 | explicit: bool, 118 | /// Externals URLs for this episode. 119 | external_urls: HashMap, 120 | /// The [Spotify ID](https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids) 121 | /// for this episode. 122 | id: String, 123 | /// The cover art for this episode in sizes, widest first. 124 | images: Vec, 125 | /// Whether the episode is hosted outside of Spotify's CDN. 126 | is_externally_hosted: bool, 127 | /// Whether the episode is playable in the given market. 128 | is_playable: bool, 129 | /// The list of languages used in this episode. 130 | // TODO: it can be en-US/en-GB 131 | languages: Vec, 132 | /// The name of the episode. 133 | name: String, 134 | /// When the episode was released. 135 | #[serde(deserialize_with = "util::de_date_any_precision")] 136 | release_date: NaiveDate, 137 | /// How precise the release date is: precise to the year, month or day. 138 | release_date_precision: DatePrecision, 139 | /// The user's most recent position in the episode. [`None`] if there is no user. 140 | resume_point: Option, 141 | /// The item type; `episode`. 142 | #[serde(rename = "type")] 143 | item_type: TypeEpisode, 144 | }); 145 | } 146 | } 147 | 148 | inherit_episode_simplified!( 149 | /// A simplified episode object. 150 | EpisodeSimplified {} 151 | ); 152 | 153 | inherit_episode_simplified!( 154 | /// An episode object. 155 | Episode { 156 | /// The show on which the episode belongs. 157 | show: ShowSimplified, 158 | } 159 | ); 160 | 161 | impl Episode { 162 | /// Convert to an [`EpisodeSimplified`]. 163 | #[must_use] 164 | pub fn simplify(self) -> EpisodeSimplified { 165 | EpisodeSimplified { 166 | audio_preview_url: self.audio_preview_url, 167 | description: self.description, 168 | duration: self.duration, 169 | explicit: self.explicit, 170 | external_urls: self.external_urls, 171 | id: self.id, 172 | images: self.images, 173 | is_externally_hosted: self.is_externally_hosted, 174 | is_playable: self.is_playable, 175 | languages: self.languages, 176 | name: self.name, 177 | release_date: self.release_date, 178 | release_date_precision: self.release_date_precision, 179 | resume_point: self.resume_point, 180 | item_type: TypeEpisode, 181 | } 182 | } 183 | } 184 | impl From for EpisodeSimplified { 185 | fn from(episode: Episode) -> Self { 186 | episode.simplify() 187 | } 188 | } 189 | 190 | /// A position to resume from in an object. 191 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] 192 | pub struct ResumePoint { 193 | /// Whether the user has fully played the object. 194 | pub fully_played: bool, 195 | /// The user's most recent position in the object. 196 | #[serde(rename = "resume_position_ms", with = "serde_millis")] 197 | pub resume_position: Duration, 198 | } 199 | -------------------------------------------------------------------------------- /src/model/track.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::time::Duration; 3 | 4 | use chrono::{DateTime, Utc}; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use crate::model::{AlbumSimplified, ArtistSimplified, Context, Restrictions, TypeTrack}; 8 | 9 | macro_rules! inherit_track_simplified { 10 | ($(#[$attr:meta])* $name:ident { $($(#[$f_attr:meta])* $f_name:ident : $f_ty:ty,)* }) => { 11 | to_struct!($(#[$attr])* $name { 12 | $( 13 | $(#[$f_attr])* 14 | $f_name: $f_ty, 15 | )* 16 | /// The artists who performed the track. 17 | artists: Vec, 18 | /// The markets in which this track is available. Only [`Some`] if the market parameter 19 | /// is not supplied in the request. This is an ISO-3166 2-letter country code. 20 | available_markets: Option>, 21 | /// The disc number (1 unless the album contains more than one disc). 22 | disc_number: usize, 23 | /// The track length. 24 | #[serde(rename = "duration_ms", with = "serde_millis")] 25 | duration: Duration, 26 | /// Whether the track has explicit lyrics, false if unknown. 27 | explicit: bool, 28 | /// Known external URLs for this track. 29 | external_urls: HashMap, 30 | /// The [Spotify ID](https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids) 31 | /// for this track. Only not present for a local track, which can only ever be obtained 32 | /// from a playlist. 33 | id: Option, 34 | /// When [track 35 | /// relinking](https://developer.spotify.com/documentation/general/guides/track-relinking-guide/) 36 | /// is applied, if the track is playable in the given market. 37 | is_playable: Option, 38 | /// When [track 39 | /// relinking](https://developer.spotify.com/documentation/general/guides/track-relinking-guide/) 40 | /// is applied and the requested track has been replaced by a different one. 41 | linked_from: Option, 42 | /// When [track 43 | /// relinking](https://developer.spotify.com/documentation/general/guides/track-relinking-guide/) 44 | /// is applied, the original track isn't available in the given market and Spotify didn't have 45 | /// any tracks to relink it with, then this is Some. 46 | restrictions: Option, 47 | /// The name of the track. 48 | name: String, 49 | /// Link to a 30 second MP3 preview of the track, doesn't have to be there. 50 | preview_url: Option, 51 | /// The 1-indexed number of the track in its album; if the track has several discs, 52 | /// then it the number on the specified disc. 53 | track_number: usize, 54 | /// The item type; `track`. 55 | #[serde(rename = "type")] 56 | item_type: TypeTrack, 57 | /// Whether the track is a local track. 58 | is_local: bool, 59 | }); 60 | } 61 | } 62 | 63 | inherit_track_simplified!( 64 | /// A simplified track object. 65 | TrackSimplified {} 66 | ); 67 | inherit_track_simplified!( 68 | /// A track object. 69 | Track { 70 | /// The album on which this track appears. 71 | album: AlbumSimplified, 72 | /// Known external IDs for this track. 73 | external_ids: HashMap, 74 | /// The popularity of the track. The value will be between 0 and 100, with 100 being the most 75 | /// popular. The popularity is calculated from the total number of plays and how recent they 76 | /// are. 77 | popularity: u32, 78 | } 79 | ); 80 | 81 | impl Track { 82 | /// Convert to a `TrackSimplified`. 83 | #[must_use] 84 | pub fn simplify(self) -> TrackSimplified { 85 | TrackSimplified { 86 | artists: self.artists, 87 | available_markets: self.available_markets, 88 | disc_number: self.disc_number, 89 | duration: self.duration, 90 | explicit: self.explicit, 91 | external_urls: self.external_urls, 92 | id: self.id, 93 | is_playable: self.is_playable, 94 | linked_from: self.linked_from, 95 | restrictions: self.restrictions, 96 | name: self.name, 97 | preview_url: self.preview_url, 98 | track_number: self.track_number, 99 | item_type: TypeTrack, 100 | is_local: self.is_local, 101 | } 102 | } 103 | } 104 | impl From for TrackSimplified { 105 | fn from(track: Track) -> Self { 106 | track.simplify() 107 | } 108 | } 109 | 110 | /// A link to a track. 111 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 112 | pub struct TrackLink { 113 | /// Known external URLs for this track. 114 | pub external_urls: HashMap, 115 | /// The [Spotify ID](https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids) 116 | /// for this track. 117 | pub id: String, 118 | /// The item type; `track`. 119 | #[serde(rename = "type")] 120 | pub item_type: TypeTrack, 121 | } 122 | 123 | /// When and how a track was played. 124 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 125 | pub struct PlayHistory { 126 | /// The track the user listened to. 127 | pub track: TrackSimplified, 128 | /// When the track was played. 129 | pub played_at: DateTime, 130 | /// The context from which the track was played. 131 | pub context: Option, 132 | } 133 | 134 | /// Information about a track that has been saved. 135 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 136 | pub struct SavedTrack { 137 | /// When the track was saved. 138 | pub added_at: DateTime, 139 | /// Information about the track. 140 | pub track: Track, 141 | } 142 | 143 | /// The number of tracks an object contains. 144 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 145 | pub struct Tracks { 146 | /// The number of tracks. 147 | pub total: usize, 148 | } 149 | -------------------------------------------------------------------------------- /src/model/user.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::model::{Followers, Image, TypeUser}; 6 | 7 | macro_rules! inherit_user_simplified { 8 | ($(#[$attr:meta])* $name:ident { $($(#[$f_attr:meta])* $f_name:ident : $f_ty:ty,)* }) => { 9 | to_struct!($(#[$attr])* $name { 10 | $( 11 | $(#[$f_attr])* 12 | $f_name: $f_ty, 13 | )* 14 | /// The name of the user; can be not available. 15 | display_name: Option, 16 | /// Known public external URLs for this user. 17 | external_urls: HashMap, 18 | /// The [Spotify user 19 | /// ID](https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids) for the 20 | /// user. 21 | id: String, 22 | /// The item type; `user`. 23 | #[serde(rename = "type")] 24 | item_type: TypeUser, 25 | }); 26 | } 27 | } 28 | 29 | inherit_user_simplified!( 30 | /// A user object that contains less fields than [`UserPublic`] and is not documented anywhere, 31 | /// but is returned by some endpoints. 32 | UserSimplified {} 33 | ); 34 | 35 | macro_rules! inherit_user_public { 36 | ($(#[$attr:meta])* $name:ident { $($(#[$f_attr:meta])* $f_name:ident : $f_ty:ty,)* }) => { 37 | inherit_user_simplified!($(#[$attr])* $name { 38 | $( 39 | $(#[$f_attr])* 40 | $f_name: $f_ty, 41 | )* 42 | /// Information about the followers of the user. 43 | followers: Followers, 44 | /// The user's profile image. 45 | images: Vec, 46 | }); 47 | } 48 | } 49 | 50 | inherit_user_public!( 51 | /// A user object that is accessible to everyone. 52 | UserPublic {} 53 | ); 54 | inherit_user_public!( 55 | /// A user object only accessible to the user themselves; does not work with Client Credentials 56 | /// flow. 57 | UserPrivate { 58 | /// The country of the user, as set in their account profile. Requires `user-read-private`. 59 | /// This is an ISO 3166 2-letter country code. 60 | country: Option, 61 | /// The user's email address, which is not necessarily a real email address. Requires 62 | /// `user-read-email`. 63 | email: Option, 64 | /// The user's Spotify subscription level. Requires `user-read-private`. 65 | product: Option, 66 | } 67 | ); 68 | 69 | impl UserPublic { 70 | /// Convert to a [`UserSimplified`]. 71 | #[must_use] 72 | pub fn simplify(self) -> UserSimplified { 73 | UserSimplified { 74 | display_name: self.display_name, 75 | external_urls: self.external_urls, 76 | id: self.id, 77 | item_type: TypeUser, 78 | } 79 | } 80 | } 81 | impl From for UserSimplified { 82 | fn from(user: UserPublic) -> Self { 83 | user.simplify() 84 | } 85 | } 86 | impl UserPrivate { 87 | /// Convert to a [`UserPublic`]. 88 | #[must_use] 89 | pub fn publicize(self) -> UserPublic { 90 | UserPublic { 91 | display_name: self.display_name, 92 | external_urls: self.external_urls, 93 | id: self.id, 94 | followers: self.followers, 95 | images: self.images, 96 | item_type: TypeUser, 97 | } 98 | } 99 | /// Convert to a [`UserSimplified`]. 100 | #[must_use] 101 | pub fn simplify(self) -> UserSimplified { 102 | self.publicize().simplify() 103 | } 104 | } 105 | impl From for UserSimplified { 106 | fn from(user: UserPrivate) -> Self { 107 | user.simplify() 108 | } 109 | } 110 | 111 | /// The subscription level; premium or free. 112 | #[derive(Debug, Clone, PartialEq, Eq, Copy, Hash, Serialize, Deserialize)] 113 | #[serde(rename_all = "snake_case")] 114 | pub enum Subscription { 115 | /// The user is subscribed to Spotify Premium. 116 | Premium, 117 | /// The user isn't subscribed to Spotify Premium. Also known as `open`. 118 | #[serde(alias = "open")] 119 | Free, 120 | } 121 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | //! Useful serialization and deserialization functions. 2 | 3 | use std::fmt::{self, Formatter}; 4 | use std::time::{Duration, Instant}; 5 | 6 | use chrono::NaiveDate; 7 | use serde::de::{self, Deserializer, Unexpected, Visitor}; 8 | use serde::Deserialize; 9 | 10 | pub(crate) fn deserialize_instant_seconds<'de, D>(deserializer: D) -> Result 11 | where 12 | D: Deserializer<'de>, 13 | { 14 | struct Expires; 15 | 16 | impl<'de> Visitor<'de> for Expires { 17 | type Value = Instant; 18 | fn expecting(&self, f: &mut Formatter<'_>) -> fmt::Result { 19 | f.write_str("number of seconds until the token expires") 20 | } 21 | fn visit_u64(self, v: u64) -> Result { 22 | Ok(Instant::now() + Duration::from_secs(v)) 23 | } 24 | } 25 | 26 | deserializer.deserialize_u64(Expires) 27 | } 28 | 29 | pub(crate) mod serde_duration_secs { 30 | use std::fmt::{self, Formatter}; 31 | use std::time::Duration; 32 | 33 | use serde::{ 34 | de::{self, Visitor}, 35 | Deserializer, Serializer, 36 | }; 37 | 38 | pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result 39 | where 40 | D: Deserializer<'de>, 41 | { 42 | struct Secs; 43 | 44 | impl<'de> Visitor<'de> for Secs { 45 | type Value = Duration; 46 | fn expecting(&self, f: &mut Formatter<'_>) -> fmt::Result { 47 | f.write_str("seconds") 48 | } 49 | fn visit_f64(self, v: f64) -> Result { 50 | Ok(Duration::from_secs_f64(v)) 51 | } 52 | } 53 | 54 | deserializer.deserialize_f64(Secs) 55 | } 56 | 57 | pub(crate) fn serialize(v: &Duration, serializer: S) -> Result { 58 | serializer.serialize_u64(v.as_secs()) 59 | } 60 | } 61 | 62 | pub(crate) mod serde_duration_millis { 63 | use std::fmt::{self, Formatter}; 64 | use std::time::Duration; 65 | 66 | use serde::{ 67 | de::{self, Visitor}, 68 | Deserializer, Serializer, 69 | }; 70 | 71 | pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result 72 | where 73 | D: Deserializer<'de>, 74 | { 75 | struct Millis; 76 | 77 | impl<'de> Visitor<'de> for Millis { 78 | type Value = Duration; 79 | fn expecting(&self, f: &mut Formatter<'_>) -> fmt::Result { 80 | f.write_str("milliseconds") 81 | } 82 | fn visit_u64(self, v: u64) -> Result { 83 | Ok(Duration::from_millis(v)) 84 | } 85 | } 86 | 87 | deserializer.deserialize_u64(Millis) 88 | } 89 | 90 | pub(crate) fn serialize(v: &Duration, serializer: S) -> Result { 91 | serializer.serialize_u128(v.as_millis()) 92 | } 93 | } 94 | 95 | pub(crate) mod serde_duration_millis_option { 96 | use std::time::Duration; 97 | 98 | use serde::{Deserialize, Deserializer, Serializer}; 99 | 100 | pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> 101 | where 102 | D: Deserializer<'de>, 103 | { 104 | #[derive(Deserialize)] 105 | struct Wrapper(#[serde(with = "super::serde_duration_millis")] Duration); 106 | 107 | let o = Option::deserialize(deserializer)?; 108 | Ok(o.map(|Wrapper(val)| val)) 109 | } 110 | 111 | pub(crate) fn serialize( 112 | v: &Option, 113 | serializer: S, 114 | ) -> Result { 115 | match v { 116 | Some(d) => super::serde_duration_millis::serialize(d, serializer), 117 | None => serializer.serialize_none(), 118 | } 119 | } 120 | } 121 | 122 | pub(crate) mod serde_status_code { 123 | use std::convert::TryInto; 124 | use std::fmt::{self, Formatter}; 125 | 126 | use reqwest::StatusCode; 127 | use serde::{ 128 | de::{self, Visitor}, 129 | Deserializer, Serializer, 130 | }; 131 | 132 | pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result 133 | where 134 | D: Deserializer<'de>, 135 | { 136 | struct Code; 137 | 138 | impl<'de> Visitor<'de> for Code { 139 | type Value = StatusCode; 140 | fn expecting(&self, f: &mut Formatter<'_>) -> fmt::Result { 141 | f.write_str("an HTTP Status code") 142 | } 143 | fn visit_u64(self, v: u64) -> Result { 144 | StatusCode::from_u16(v.try_into().map_err(E::custom)?).map_err(E::custom) 145 | } 146 | } 147 | 148 | deserializer.deserialize_u16(Code) 149 | } 150 | 151 | #[allow(clippy::trivially_copy_pass_by_ref)] 152 | pub(crate) fn serialize(v: &StatusCode, serializer: S) -> Result 153 | where 154 | S: Serializer, 155 | { 156 | serializer.serialize_u16(v.as_u16()) 157 | } 158 | } 159 | 160 | pub(crate) mod serde_disallows { 161 | use std::fmt::{self, Formatter}; 162 | 163 | use serde::{ 164 | de::{MapAccess, Visitor}, 165 | ser::SerializeMap, 166 | Deserializer, Serializer, 167 | }; 168 | 169 | use crate::Disallow; 170 | 171 | pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> 172 | where 173 | D: Deserializer<'de>, 174 | { 175 | struct DisallowsVisitor; 176 | 177 | impl<'de> Visitor<'de> for DisallowsVisitor { 178 | type Value = Vec; 179 | fn expecting(&self, f: &mut Formatter<'_>) -> fmt::Result { 180 | f.write_str("a disallows map") 181 | } 182 | fn visit_map>(self, mut map: A) -> Result { 183 | let mut v = Vec::with_capacity(10); 184 | 185 | while let Some((key, val)) = map.next_entry::>()? { 186 | if val == Some(true) { 187 | v.push(key); 188 | } 189 | } 190 | 191 | Ok(v) 192 | } 193 | } 194 | 195 | deserializer.deserialize_map(DisallowsVisitor) 196 | } 197 | 198 | pub(crate) fn serialize( 199 | disallows: &[Disallow], 200 | serializer: S, 201 | ) -> Result { 202 | let mut map = serializer.serialize_map(Some(disallows.len()))?; 203 | for disallow in disallows { 204 | map.serialize_entry(disallow, &true)?; 205 | } 206 | map.end() 207 | } 208 | } 209 | 210 | pub(crate) fn de_any_uri<'de, D>(deserializer: D) -> Result 211 | where 212 | D: Deserializer<'de>, 213 | { 214 | struct UriVisitor; 215 | 216 | impl<'de> Visitor<'de> for UriVisitor { 217 | type Value = String; 218 | fn expecting(&self, f: &mut Formatter<'_>) -> fmt::Result { 219 | f.write_str("a Spotify URI") 220 | } 221 | fn visit_str(self, v: &str) -> Result { 222 | let mut parts = v.split(':'); 223 | 224 | let first = parts.next().ok_or_else(|| E::missing_field("spotify"))?; 225 | if first != "spotify" { 226 | return Err(E::invalid_value(de::Unexpected::Str(first), &self)); 227 | } 228 | 229 | let item_type = parts.next().ok_or_else(|| E::missing_field("type"))?; 230 | 231 | let id = parts.next().ok_or_else(|| E::missing_field("id"))?; 232 | 233 | let id = if let Some(val) = parts.next() { 234 | if item_type != "user" || val != "playlist" { 235 | return Err(E::unknown_field(val, &[])); 236 | } 237 | // Old-style playlist ids: 238 | // spotify:user:{name}:playlist:{id}) instead of spotify:playlist:{id}. 239 | parts 240 | .next() 241 | .ok_or_else(|| E::missing_field("playlist id"))? 242 | } else { 243 | id 244 | }; 245 | 246 | Ok(id.to_owned()) 247 | } 248 | } 249 | 250 | deserializer.deserialize_str(UriVisitor) 251 | } 252 | 253 | pub(crate) fn de_date_any_precision<'de, D>(deserializer: D) -> Result 254 | where 255 | D: Deserializer<'de>, 256 | { 257 | struct DateVisitor; 258 | 259 | impl<'de> Visitor<'de> for DateVisitor { 260 | type Value = NaiveDate; 261 | fn expecting(&self, f: &mut Formatter<'_>) -> fmt::Result { 262 | f.write_str("a date") 263 | } 264 | fn visit_str(self, v: &str) -> Result { 265 | let mut parts = v.splitn(3, '-'); 266 | 267 | let year: i32 = parts.next().unwrap().parse().map_err(E::custom)?; 268 | let month: u32 = match parts.next() { 269 | Some(val) => val.parse().map_err(E::custom)?, 270 | None => 1, 271 | }; 272 | let day: u32 = match parts.next() { 273 | Some(val) => val.parse().map_err(E::custom)?, 274 | None => 1, 275 | }; 276 | 277 | Ok(NaiveDate::from_ymd_opt(year, month, day) 278 | .ok_or_else(|| E::invalid_value(Unexpected::Str(v), &self))?) 279 | } 280 | } 281 | 282 | deserializer.deserialize_str(DateVisitor) 283 | } 284 | 285 | pub(crate) fn de_date_any_precision_option<'de, D>( 286 | deserializer: D, 287 | ) -> Result, D::Error> 288 | where 289 | D: Deserializer<'de>, 290 | { 291 | #[derive(Deserialize)] 292 | struct Wrapper(#[serde(deserialize_with = "de_date_any_precision")] NaiveDate); 293 | 294 | Ok(Option::deserialize(deserializer)?.map(|Wrapper(val)| val)) 295 | } 296 | --------------------------------------------------------------------------------