diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee044e91..5f308752 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,8 +6,8 @@ on: name: Continuous Integration jobs: - check: - name: Check + formatting: + name: Formatting runs-on: ubuntu-latest steps: - name: Checkout sources @@ -19,84 +19,51 @@ jobs: profile: minimal toolchain: stable override: true + components: rustfmt - - name: Run cargo check + - name: Run cargo fmt uses: actions-rs/cargo@v1 with: - command: check - args: --all-targets --features=cli,env-file + command: fmt + args: --all -- --check - cross-compile: - name: Cross Compile + docs: + name: Check Docs runs-on: ubuntu-latest - env: - CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc - CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc - CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc - strategy: - matrix: - target: - - aarch64-unknown-linux-gnu - - arm-unknown-linux-gnueabihf - - armv7-unknown-linux-gnueabihf - client: - - client-ureq,ureq-rustls-tls - - client-reqwest,reqwest-rustls-tls steps: - name: Checkout sources uses: actions/checkout@v2 - - name: Install gcc for armhf - run: sudo apt-get update && sudo apt-get install gcc-arm-linux-gnueabihf gcc-aarch64-linux-gnu - - name: Install stable toolchain uses: actions-rs/toolchain@v1 with: profile: minimal toolchain: stable override: true - target: ${{ matrix.target }} - - name: Run cargo build - uses: actions-rs/cargo@v1 - with: - command: build - args: --target ${{ matrix.target }} --no-default-features --features=cli,env-file,${{ matrix.client }} + - name: Install cargo-deadlinks + run: cargo install cargo-deadlinks + - name: Build and Check + run: RUSTDOCFLAGS='-D warnings' cargo doc --features=cli,env-file - test: - name: Test Suite - runs-on: ubuntu-latest - strategy: - matrix: - client: - - client-ureq,ureq-rustls-tls - - client-reqwest,reqwest-rustls-tls - steps: - - name: Checkout sources - uses: actions/checkout@v2 + - name: Look for Dead Links + run: | + cd target + for crate in doc/rspotify*; do + echo ">> Checking in $crate" + cargo deadlinks --check-http --dir "$crate" + done + continue-on-error: true - - name: Install stable toolchain - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - override: true - - - name: Run cargo test - uses: actions-rs/cargo@v1 - with: - command: test - args: --no-default-features --features=env-file,${{ matrix.client }} - - lints: - name: Lints + test: + name: Test and Lint for each Client runs-on: ubuntu-latest strategy: matrix: - client: - - client-ureq,ureq-rustls-tls - - client-reqwest,reqwest-rustls-tls + features: + - rspotify/cli,rspotify/env-file,rspotify/client-ureq,rspotify/ureq-rustls-tls,rspotify-http/client-ureq,rspotify-http/ureq-rustls-tls + - rspotify/cli,rspotify/env-file,rspotify/client-reqwest,rspotify/reqwest-rustls-tls,rspotify-http/client-reqwest,rspotify-http/reqwest-rustls-tls steps: - name: Checkout sources uses: actions/checkout@v2 @@ -107,16 +74,16 @@ jobs: profile: minimal toolchain: stable override: true - components: rustfmt, clippy + components: clippy - - name: Run cargo fmt + - name: Run cargo clippy uses: actions-rs/cargo@v1 with: - command: fmt - args: --all -- --check + command: clippy + args: -p rspotify -p rspotify-http -p rspotify-model -p rspotify-macros --no-default-features --features=${{ matrix.features }} -- -D warnings - - name: Run cargo clippy + - name: Run cargo test uses: actions-rs/cargo@v1 with: - command: clippy - args: --no-default-features --features=cli,env-file,${{ matrix.client }} -- -D warnings + command: test + args: -p rspotify -p rspotify-http -p rspotify-model -p rspotify-macros --no-default-features --features=${{ matrix.features }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index e3a90395..00000000 --- a/.travis.yml +++ /dev/null @@ -1,25 +0,0 @@ -language: rust -rust: - - stable - - beta - - nightly -matrix: - allow_failures: - - rust: nightly - -script: - - cargo test - - cargo run --example artists_albums - - cargo run --example track - - cargo run --example artist_related_artists - - cargo run --example tracks - - cargo run --example artist_top_tracks - - cargo run --example user - - cargo run --example artists - - cargo run --example albums - - cargo run --example audios_features - - cargo run --example audio_analysis - - cargo run --example album_tracks - - cargo run --example audio_features - - cargo run --example artist - - cargo run --example album diff --git a/CHANGELOG.md b/CHANGELOG.md index 77eb8a1a..d71db7f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,94 @@ ## 0.11 (unreleased) -This release contains *lots* of breaking changes. These were necessary to continue Rspotify's development, and no more versions like this should happen again. Lots of internal code was rewritten to make Rspotify more flexible, performant and easier to use. Sorry for the inconvenience! + +This release contains *lots* of breaking changes. These were necessary to continue Rspotify's development, and this shouldn't happen again. From now on we'll work on getting closer to the first stable release. Lots of internal code was rewritten to make Rspotify more flexible, performant and easier to use. Sorry for the inconvenience! If we missed any change or there's something you'd like to discuss about this version, please open a new issue and let us know. +### Upgrade guide + +This guide should make it easier to upgrade your code, rather than checking out the changelog line by line. The most important changes are: + +* Support for **multiple HTTP clients**. Instead of using `rspotify::blocking` for synchronous access, you just need to configure the `ureq-client` feature and its TLS (learn more in the docs). +* No need for the builder pattern anymore: `Spotify` has been split up into **multiple clients depending on the authentication process** you want to follow. This means that you'll be required to `use rspotify::prelude::*` in order to access the traits with the endpoints. + * [Client Credentials Flow](https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow): see `ClientCredsSpotify`. + * [Authorization Code Flow](https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow): see `AuthCodeSpotify`. + * [Authorization Code Flow with Proof Key for Code Exchange (PKCE)](https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce): see `AuthCodePkceSpotify`. This is new! You might be interested in using PKCE for your app. + * [Implicit Grant Flow](https://developer.spotify.com/documentation/general/guides/authorization-guide/#implicit-grant-flow): unimplemented, as Rspotify has not been tested on a browser yet. If you'd like support for it, let us know in an issue! +* There's now support for (both sync and async) **automatic pagination** as well! Make sure you upgrade to these after checking out the [`pagination_async.rs`](https://github.com/ramsayleung/rspotify/blob/auth-rewrite-part4/examples/pagination_async.rs) and [`pagination_sync.rs`](https://github.com/ramsayleung/rspotify/blob/auth-rewrite-part4/examples/pagination_sync.rs) examples. You can use the `_manual`-suffixed endpoints for the previous pagination style. +* We've **renamed** a few structs and endpoints. The new names are quite similar, so the Rust compiler should suggest you what to change after an error. The only one you might not notice is the **environmental variables**: they're now `RSPOTIFY_CLIENT_ID`, `RSPOTIFY_CLIENT_SECRET` and `RSPOTIFY_REDIRECT_URI` to avoid collisions with other libraries. +* We always use **`Option`** for optional parameters now. This means that you might have to add `Some(...)` to some of your parameters. We were using both `Into>` and `Option` but decided that either of these would be best as long as it's *consistent*. `Option` has less magic, so we went for that one. +* The core library has been split up with **features**. If you need `dotenv` just activate `env-file`, and if you need CLI functionality (`prompt_for_token` and similars), activate `cli`. +* We use **custom errors** now instead of the `failure` crate. + +Now to a quick example: here's how you *used to* query the current user saved tracks: + +```rust +extern crate rspotify; + +use rspotify::blocking::client::Spotify; +use rspotify::blocking::oauth2::{SpotifyClientCredentials, SpotifyOAuth}; +use rspotify::blocking::util::get_token; + +fn main() { + let mut oauth = SpotifyOAuth::default().scope("user-library-read").build(); // Turns out this reads from the environment variables! + let token_info = get_token(&mut oauth).unwrap(); // How does it get the token? Why is it not in the client if it makes a request? + + let client_credential = SpotifyClientCredentials::default() // This also accesses the environment variables with no warning. + .token_info(token_info) + .build(); // So verbose... + + let spotify = Spotify::default() // So verbose and easy to mess up... What auth flow is this again? + .client_credentials_manager(client_credential) + .build(); + let tracks = spotify.current_user_saved_tracks(10, 0); // Iterating is hard + println!("{:?}", tracks); +} +``` + +And here's how you do it now: + +```rust +use rspotify::{prelude::*, scopes, AuthCodeSpotify, Credentials, OAuth}; + +fn main() { + let oauth = OAuth::from_env(scopes!("user-library-read")).unwrap(); // Concise & explicit with `from_env` + let creds = Credentials::from_env().unwrap(); // Same, concise & explicit + + let mut spotify = AuthCodeSpotify::new(creds, oauth); // Simpler initialization + + let url = spotify.get_authorize_url(false).unwrap(); // More flexible, lets us implement PKCE + spotify.prompt_for_token(&url).unwrap(); // Explicit: the token is obtained by interacting with the user + + let stream = spotify.current_user_saved_tracks(); + println!("Items:"); + for item in stream { // Easy iteration instead of manual pagination + println!("* {}", item.unwrap().track.name); + } +} +``` + +Hopefully this will convince you that the new breaking changes are good; you'll find the new interface easier to read, more intuitive and less error prone. + +Here are a few examples of upgrades: + +| Name | Old | New | +|----------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------| +| [Sync] device | [`examples/blocking/device.rs`](https://github.com/ramsayleung/rspotify/blob/22a995a061dffbce9f5069fd603e266d7ed3a252/examples/blocking/device.rs) | [`examples/ureq/device.rs`](https://github.com/ramsayleung/rspotify/blob/master/examples/ureq/device.rs) | +| [Sync] me | [`examples/blocking/me.rs`](https://github.com/ramsayleung/rspotify/blob/22a995a061dffbce9f5069fd603e266d7ed3a252/examples/blocking/me.rs) | [`examples/ureq/me.rs`](https://github.com/ramsayleung/rspotify/blob/master/examples/ureq/me.rs) | +| [Sync] search | [`examples/blocking/search_track.rs`](https://github.com/ramsayleung/rspotify/blob/22a995a061dffbce9f5069fd603e266d7ed3a252/examples/blocking/search_track.rs) | [`examples/ureq/search.rs`](https://github.com/ramsayleung/rspotify/blob/master/examples/ureq/search.rs) | +| [Sync] seek_track | [`examples/blocking/seek_track.rs`](https://github.com/ramsayleung/rspotify/blob/22a995a061dffbce9f5069fd603e266d7ed3a252/examples/blocking/seek_track.rs) | [`examples/ureq/seek_track.rs`](https://github.com/ramsayleung/rspotify/blob/master/examples/ureq/seek_track.rs) | +| [Sync] current_user_saved_tracks | [`examples/blocking/current_user_saved_tracks.rs`](https://github.com/ramsayleung/rspotify/blob/22a995a061dffbce9f5069fd603e266d7ed3a252/examples/blocking/current_user_saved_tracks.rs) | [`examples/pagination_sync.rs`](https://github.com/ramsayleung/rspotify/blob/master/examples/pagination_sync.rs) | +| [Async] current_user_saved_tracks | [`examples/current_user_saved_tracks.rs`](https://github.com/ramsayleung/rspotify/blob/22a995a061dffbce9f5069fd603e266d7ed3a252/examples/current_user_saved_tracks.rs) | [`examples/pagination_async.rs`](https://github.com/ramsayleung/rspotify/blob/master/examples/pagination_async.rs) | +| [Async] current_user_saved_tracks (manually) | [`examples/current_user_saved_tracks.rs`](https://github.com/ramsayleung/rspotify/blob/22a995a061dffbce9f5069fd603e266d7ed3a252/examples/current_user_saved_tracks.rs) | [`examples/pagination_manual.rs`](https://github.com/ramsayleung/rspotify/blob/master/examples/pagination_manual.rs) | +| [Async] current_playing | [`examples/current_playing.rs`](https://github.com/ramsayleung/rspotify/blob/22a995a061dffbce9f5069fd603e266d7ed3a252/examples/current_playing.rs) | [`examples/auth_code.rs`](https://github.com/ramsayleung/rspotify/blob/master/examples/auth_code.rs) | +| [Async] current_playback | [`examples/current_playback.rs`](https://github.com/ramsayleung/rspotify/blob/22a995a061dffbce9f5069fd603e266d7ed3a252/examples/current_playback.rs) | [`examples/auth_code_pkce.rs`](https://github.com/ramsayleung/rspotify/blob/master/examples/auth_code_pkce.rs) | +| [Async] album | [`examples/album.rs`](https://github.com/ramsayleung/rspotify/blob/22a995a061dffbce9f5069fd603e266d7ed3a252/examples/album.rs) | [`examples/client_creds.rs`](https://github.com/ramsayleung/rspotify/blob/master/examples/client_creds.rs) | +| [Async] webapp with Rocket | [`examples/webapp`](https://github.com/ramsayleung/rspotify/tree/4c1c3366630a8b2b37668a17878b746108c93fd0/examples/webapp) | [`examples/webapp`](https://github.com/ramsayleung/rspotify/tree/master/examples/webapp) | + +More in the [`examples` directory](https://github.com/ramsayleung/rspotify/tree/master/examples)! + +### Full changelog + - Rewritten documentation in hopes that it's easier to get started with Rspotify. - Reduced the number of examples. Instead of having an example for each endpoint, which is repetitive and unhelpful for newcomers, some real-life examples are now included. If you'd like to add your own example, please do! ([#113](https://github.com/ramsayleung/rspotify/pull/113)) - Rspotify now uses macros internally to make the endpoints as concise as possible and nice to read. @@ -82,7 +168,9 @@ If we missed any change or there's something you'd like to discuss about this ve + `TrackRestriction` + Fix broken model links refering to Spotify documentation - ([#188](https://github.com/ramsayleung/rspotify/pull/188)) Replace html links with intra-documentation links -- ([#189](https://github.com/ramsayleung/rspotify/pull/189)) Add `scopes!` macro to generate scope for `Token` from string literal +- ([#189](https://github.com/ramsayleung/rspotify/pull/189)) Add `scopes!` macro to generate scopes for `Token` from string literal +- Rspotify has now been split up into independent crates, so that it can be used without the client. See `rspotify-macros` and `rspotify-model`. +- ([#128](https://github.com/ramsayleung/rspotify/pull/128)) Reexport `model` module to allow user to write `rspotify::model::FullAlbum` instead of `rspotify::model::album::FullAlbum`. **Breaking changes:** - ([#202](https://github.com/ramsayleung/rspotify/pull/202)) Rspotify now consistently uses `Option` for optional parameters. Those generic over `Into>` have been changed, which makes calling endpoints a bit ugiler but more consistent and simpler. @@ -92,23 +180,10 @@ If we missed any change or there's something you'd like to discuss about this ve - `TokenBuilder` and `OAuthBuilder` will only read from environment variables when `from_env` is used, instead of `default`. - `dotenv` support is now optional. You can enable it with the `env-file` feature to have the same behavior as before ([#108](https://github.com/ramsayleung/rspotify/issues/108)). It may be used with `from_env` as well. - Renamed environmental variables to `RSPOTIFY_CLIENT_ID`, `RSPOTIFY_CLIENT_SECRET` and `RSPOTIFY_REDIRECT_URI` to avoid name collisions with other libraries that use OAuth2 ([#118](https://github.com/ramsayleung/rspotify/issues/118)). -- All fallible calls in the client return a `ClientResult` rather than using `failure`, which is equivalent to a `Result`. -- A real builder pattern is used now. For example, `Token` is constructed now with `TokenBuilder::default().access_token("...").build().unwrap()`. This has been applied to `Spotify`, `OAuth`, `Token` and `Credentials` ([#129](https://github.com/ramsayleung/rspotify/pull/129)). - The `blocking` module has been removed, since Rspotify is able to use multiple HTTP clients now. `reqwest` and `ureq` are currently supported, meaning that you can still use blocking code by enabling the `client-ureq` feature and a TLS like `ureq-rustls-tls`. Read the docs for more information ([#129](https://github.com/ramsayleung/rspotify/pull/129)). -- The authentication process has been completely rewritten in order to make it more performant and robust. Please read the docs to learn more about how that works now ([#129](https://github.com/ramsayleung/rspotify/pull/129)). These are the main changes: - + `TokenInfo::get_cached_token` is now `TokenBuilder::from_cache` and `Spotify::read_token_cache` (using the internal cache path). Instead of panicking, the resulting `TokenBuilder` may be empty (and `build` will fail). - + `Spotify::save_token_info` is now `Token::write_cache` and `Spotify::write_token_cache`. The latter uses the client's set cache path for the write. These functions also now return `ClientResult` instead of panicking. - + `Spotify::is_token_expired` is now `Token::is_expired`. - + `SpotifyOAuth2::get_authorize_url` is now `Spotify::get_authorize_url`, and it returns `ClientResult` instead of panicking. - + `SpotifyOAuth2::refresh_access_token[_without_cache]` are now `Spotify::refresh_user_token[_with_cache]`. It returns `ClientResult<()>`, and the resulting token will be saved internally instead of returned. - + `SpotifyOAuth2::request_client_token[_without_cache]` are now `Spotify::request_client_token[_with_cache]`. It returns `ClientResult<()>`, and the resulting token will be saved internally instead of returned. - + `SpotifyOAuth2::get_access_token[_without_cache]` are now `Spotify::request_user_token[_with_cache]`. It returns `ClientResult<()>`, and the resulting token will be saved internally instead of returned. - + `get_token[_without_cache]` is now `Spotify::prompt_for_user_token[_without_cache]`. It returns `ClientResult<()>`, and the resulting token will be saved internally instead of returned. -- CLI-exclusive functions and are now optional under the `cli` feature: - + `Spotify::prompt_for_user_token[_without_cache]` - + The `ClientError::Cli` variant, for whenever user interaction goes wrong +- The `Spotify` client has been split up by authorization flows (`ClientCredsSpotify`, `AuthCodeSpotify`, `AuthCodePkceSpotify`), which allows us to remove the builder pattern. The authentication process has been rewritten. ([#216](https://github.com/ramsayleung/rspotify/pull/216)). - Fix typo in `user_playlist_remove_specific_occurrenes_of_tracks`, now it's `user_playlist_remove_specific_occurrences_of_tracks`. -- ([#123](https://github.com/ramsayleung/rspotify/pull/123))All fallible calls in the client return a `ClientError` rather than using `failure`. +- ([#123](https://github.com/ramsayleung/rspotify/pull/123)) All fallible calls in the client return a `ClientError` rather than using `failure`. - ([#161](https://github.com/ramsayleung/rspotify/pull/161)) Endpoints taking `Vec/&[String]` as parameter have changed to `impl IntoIterator>`. + The endpoints which changes parameter from `Vec` to `impl IntoIterator>`: - `albums` @@ -144,12 +219,11 @@ If we missed any change or there's something you'd like to discuss about this ve + `audio_analysis` -> `track_analysis` + `audio_features` -> `track_features` + `audios_features` -> `tracks_features` -- ([#128](https://github.com/ramsayleung/rspotify/pull/128)) Reexport `model` module to allow user to write `rspotify::model::FullAlbum` instead of `rspotify::model::album::FullAlbum`. - ([#128](https://github.com/ramsayleung/rspotify/pull/128)) Split single `senum.rs` file into a separate module named `enums` (which is more appropriate compared with `senum`) with three files `country.rs`, `types.rs`, `misc.rs`, and move `enums` module into `model` module, which should be part of the `model` module, check [enums mod.rs file](src/model/enums/mod.rs) for details. - ([#128](https://github.com/ramsayleung/rspotify/pull/128)) Refactor all enum files with `strum`, reduced boilerplate code. - + All enums don't have a method named `as_str()` anymore, by leveraging `strum`, it's easy to convert strings to enum variants based on their name, with method `to_string()`. + + All enums don't have a method named `as_str()` anymore, by leveraging `strum`, it's easy to convert strings to enum variants based on their name, with method `as_ref()`. - Fix typo in `transfer_playback`: `device_id` to `device_ids`. -- ([#145](https://github.com/ramsayleung/rspotify/pull/145))Refactor models to make it easier to use: +- ([#145](https://github.com/ramsayleung/rspotify/pull/145)) Refactor models to make it easier to use: + Changed type of `track` in `PlayHistory` to `FullTrack` ([#139](https://github.com/ramsayleung/rspotify/pull/139)). + Rename model `CurrentlyPlaybackContext` to `CurrentPlaybackContext` + Change `copyrights` from `Vec>` to `Vec` @@ -169,7 +243,7 @@ If we missed any change or there's something you'd like to discuss about this ve + Replace `Actions::disallows` with a `Vec` by removing all entires whose value is false, which will result in a simpler API + Replace `{FullAlbum, SimplifiedEpisode, FullEpisode}::release_date_precision` from `String` to `DatePrecision` enum, makes it easier to use. + Id and URI parameters are type-safe now everywhere, `Id` and `IdBuf` types for ids/URIs added (non-owning and owning structs). -- ([#157](https://github.com/ramsayleung/rspotify/pull/157))Keep polishing models to make it easier to use: +- ([#157](https://github.com/ramsayleung/rspotify/pull/157)) Keep polishing models to make it easier to use: + Constrain visibility of `FullArtists` struct with `pub (in crate)`, make `artists` and `artist_related_artists` endpoints return a `Vec` instead. + Constrain visibility of `FullTracks` struct with `pub (in crate)`, make `tracks` and `artist_top_tracks` endpoints return a `Vec` instead. + Constrain visibility of `AudioFeaturesPayload` struct with `pub (in crate)`, make `tracks_features` endpoints return a `Vec` instead. @@ -197,11 +271,11 @@ If we missed any change or there's something you'd like to discuss about this ve - ([#185](https://github.com/ramsayleung/rspotify/pull/185)) Polish the `Token.expires_at`, `Token.expires_in` fields + Change `Token.expires_in` from u32 to `chrono::Duration` + Change `Token.expires_at` from i64 to `chrono::DateTime` - + Change `Token.scope` from `String` to `HashSet`. - + Change `OAuth.scope` from `String` to `HashSet`. + + Change `Token.scopes` from `String` to `HashSet`. + + Change `OAuth.scopes` from `String` to `HashSet`. + Change `SimplifiedPlaylist::tracks` from `HashMap` to `PlaylistTracksRef` - ([#194](https://github.com/ramsayleung/rspotify/pull/194)) Rename `PlayingItem` to `PlayableItem`, `PlaylistItem::track` type changed to `Option`, so playlists can contain episodes as well -- ([#197](https://github.com/ramsayleung/rspotify/pull/197)) Makeing acronym lowercase +- ([#197](https://github.com/ramsayleung/rspotify/pull/197)) Making acronyms lowercase: + Rename `ClientError::ParseJSON` to `ClientError::ParseJson` + Rename `ClientError::ParseURL` to `ClientError::ParseUrl` + Rename `ClientError::IO` to `ClientError::Io` diff --git a/Cargo.toml b/Cargo.toml index 7546a416..c0a32e3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,8 @@ [package] -authors = ["Ramsay Leung "] +authors = [ + "Ramsay Leung ", + "Mario Ortiz Manero " +] name = "rspotify" version = "0.10.0" license = "MIT" @@ -7,30 +10,49 @@ readme = "README.md" description = "Spotify API wrapper" homepage = "https://github.com/ramsayleung/rspotify" repository = "https://github.com/ramsayleung/rspotify" -keywords = ["spotify","api"] +keywords = ["spotify", "api"] edition = "2018" +[workspace] +members = [ + "rspotify-macros", + "rspotify-model", + "rspotify-http" +] +exclude = [ + "examples/webapp" +] +# For advanced features usage in the CLI, see: +# https://doc.rust-lang.org/nightly/cargo/reference/features.html#resolver-version-2-command-line-flags +resolver = "2" + [dependencies] -# Temporary until https://github.com/rust-lang/rfcs/issues/2739, for -# `maybe_async`. -async-trait = { version = "0.1.48", optional = true } +rspotify-macros = { path = "rspotify-macros", version = "0.10.0" } +rspotify-model = { path = "rspotify-model", version = "0.10.0" } +rspotify-http = { path = "rspotify-http", version = "0.10.0", default-features = false } + +### Client ### async-stream = { version = "0.3.0", optional = true } +async-trait = { version = "0.1.48", optional = true } base64 = "0.13.0" chrono = { version = "0.4.13", features = ["serde", "rustc-serialize"] } -derive_builder = "0.10.0" dotenv = { version = "0.15.0", optional = true } futures = { version = "0.3.8", optional = true } +futures-util = "0.3.8" # TODO getrandom = "0.2.0" log = "0.4.11" maybe-async = "0.2.1" -reqwest = { version = "0.11.0", default-features = false, features = ["json", "socks"], optional = true } serde = { version = "1.0.115", features = ["derive"] } serde_json = "1.0.57" thiserror = "1.0.20" -ureq = { version = "2.0", default-features = false, features = ["json", "cookies"], optional = true } -url = "2.1.1" +url = "2.2.2" webbrowser = { version = "0.5.5", optional = true } -strum = { version = "0.20", features = ["derive"] } + +### Auth ### +# chrono = { version = "0.4.13", features = ["serde", "rustc-serialize"] } +# log = "0.4.11" +# maybe-async = "0.2.1" +# thiserror = "1.0.20" [dev-dependencies] env_logger = "0.8.1" @@ -39,54 +61,52 @@ futures-util = "0.3.8" [features] default = ["client-reqwest", "reqwest-default-tls"] + +### Client ### cli = ["webbrowser"] env-file = ["dotenv"] +### HTTP ### # Available clients. By default they don't include a TLS so that it can be # configured. -client-ureq = ["ureq", "__sync"] -client-reqwest = ["reqwest", "__async"] +client-ureq = ["rspotify-http/client-ureq", "__sync"] +client-reqwest = ["rspotify-http/client-reqwest", "__async"] # Passing the TLS features to reqwest. -reqwest-default-tls = ["reqwest/default-tls"] -reqwest-rustls-tls = ["reqwest/rustls-tls"] -reqwest-native-tls = ["reqwest/native-tls"] -reqwest-native-tls-vendored = ["reqwest/native-tls-vendored"] +reqwest-default-tls = ["rspotify-http/reqwest-default-tls"] +reqwest-rustls-tls = ["rspotify-http/reqwest-rustls-tls"] +reqwest-native-tls = ["rspotify-http/reqwest-native-tls"] +reqwest-native-tls-vendored = ["rspotify-http/reqwest-native-tls-vendored"] # Same for ureq. -ureq-rustls-tls = ["ureq/tls"] +ureq-rustls-tls = ["rspotify-http/ureq-rustls-tls"] # Internal features for checking async or sync compilation -__async = ["async-trait", "async-stream", "futures"] +__async = ["futures", "async-stream", "async-trait"] __sync = ["maybe-async/is_sync"] [package.metadata.docs.rs] -# Also documenting the CLI methods -features = ["cli"] +# Documenting the CLI methods, and working links for `dotenv` +features = ["cli", "env-file"] [[example]] -name = "album" +name = "client_creds" required-features = ["env-file", "cli", "client-reqwest"] -path = "examples/album.rs" +path = "examples/client_creds.rs" [[example]] -name = "current_user_recently_played" +name = "auth_code" required-features = ["env-file", "cli", "client-reqwest"] -path = "examples/current_user_recently_played.rs" +path = "examples/auth_code.rs" [[example]] -name = "oauth_tokens" +name = "auth_code_pkce" required-features = ["env-file", "cli", "client-reqwest"] -path = "examples/oauth_tokens.rs" - -[[example]] -name = "track" -required-features = ["env-file", "cli", "client-reqwest"] -path = "examples/track.rs" +path = "examples/auth_code_pkce.rs" [[example]] -name = "tracks" +name = "oauth_tokens" required-features = ["env-file", "cli", "client-reqwest"] -path = "examples/tracks.rs" +path = "examples/oauth_tokens.rs" [[example]] name = "with_refresh_token" diff --git a/examples/auth_code.rs b/examples/auth_code.rs new file mode 100644 index 00000000..64796f99 --- /dev/null +++ b/examples/auth_code.rs @@ -0,0 +1,55 @@ +use rspotify::{ + model::{AdditionalType, Country, Market}, + prelude::*, + scopes, AuthCodeSpotify, Credentials, OAuth, +}; + +#[tokio::main] +async fn main() { + // You can use any logger for debugging. + env_logger::init(); + + // Set RSPOTIFY_CLIENT_ID, RSPOTIFY_CLIENT_SECRET and + // RSPOTIFY_REDIRECT_URI in an .env file or export them manually: + // + // export RSPOTIFY_CLIENT_ID="your client_id" + // export RSPOTIFY_CLIENT_SECRET="secret" + // + // These will then be read with `from_env`. + // + // Otherwise, set client_id and client_secret explictly: + // + // ``` + // let creds = Credentials { + // id: "this-is-my-client-id".to_string(), + // secret: "this-is-my-client-secret".to_string() + // }; + // ``` + let creds = Credentials::from_env().unwrap(); + + // Or set the redirect_uri explictly: + // + // ``` + // let oauth = OAuth { + // redirect_uri: "http://localhost:8888/callback".to_string(), + // scopes: scopes!("user-read-recently-played"), + // ..Default::default(), + // }; + // ``` + let oauth = OAuth::from_env(scopes!("user-read-currently-playing")).unwrap(); + + let mut spotify = AuthCodeSpotify::new(creds, oauth); + + // Obtaining the access token + let url = spotify.get_authorize_url(false).unwrap(); + spotify.prompt_for_token(&url).await.unwrap(); + + // Running the requests + let market = Market::Country(Country::Spain); + let additional_types = [AdditionalType::Episode]; + let artists = spotify + .current_playing(Some(&market), Some(&additional_types)) + .await; + + println!("Response: {:?}", artists); +} diff --git a/examples/auth_code_pkce.rs b/examples/auth_code_pkce.rs new file mode 100644 index 00000000..5a35e4a6 --- /dev/null +++ b/examples/auth_code_pkce.rs @@ -0,0 +1,47 @@ +use rspotify::{prelude::*, scopes, AuthCodePkceSpotify, Credentials, OAuth}; + +#[tokio::main] +async fn main() { + // You can use any logger for debugging. + env_logger::init(); + + // Set RSPOTIFY_CLIENT_ID, RSPOTIFY_CLIENT_SECRET and + // RSPOTIFY_REDIRECT_URI in an .env file or export them manually: + // + // export RSPOTIFY_CLIENT_ID="your client_id" + // export RSPOTIFY_CLIENT_SECRET="secret" + // + // These will then be read with `from_env`. + // + // Otherwise, set client_id and client_secret explictly: + // + // ``` + // let creds = Credentials { + // id: "this-is-my-client-id".to_string(), + // secret: "this-is-my-client-secret".to_string() + // }; + // ``` + let creds = Credentials::from_env().unwrap(); + + // Or set the redirect_uri explictly: + // + // ``` + // let oauth = OAuth { + // redirect_uri: "http://localhost:8888/callback".to_string(), + // scopes: scopes!("user-read-recently-played"), + // ..Default::default(), + // }; + // ``` + let oauth = OAuth::from_env(scopes!("user-read-recently-played")).unwrap(); + + let mut spotify = AuthCodePkceSpotify::new(creds, oauth); + + // Obtaining the access token + let url = spotify.get_authorize_url().unwrap(); + spotify.prompt_for_token(&url).await.unwrap(); + + // Running the requests + let history = spotify.current_playback(None, None::>).await; + + println!("Response: {:?}", history); +} diff --git a/examples/album.rs b/examples/client_creds.rs similarity index 62% rename from examples/album.rs rename to examples/client_creds.rs index f2213185..d54faaac 100644 --- a/examples/album.rs +++ b/examples/client_creds.rs @@ -1,6 +1,4 @@ -use rspotify::client::SpotifyBuilder; -use rspotify::model::Id; -use rspotify::oauth2::CredentialsBuilder; +use rspotify::{model::Id, prelude::*, ClientCredsSpotify, Credentials}; #[tokio::main] async fn main() { @@ -17,22 +15,20 @@ async fn main() { // // Otherwise, set client_id and client_secret explictly: // - // let creds = CredentialsBuilder::default() - // .client_id("this-is-my-client-id") - // .client_secret("this-is-my-client-secret") - // .build() - // .unwrap(); - let creds = CredentialsBuilder::from_env().build().unwrap(); + // ``` + // let creds = Credentials { + // id: "this-is-my-client-id".to_string(), + // secret: "this-is-my-client-secret".to_string() + // }; + // ``` + let creds = Credentials::from_env().unwrap(); - let mut spotify = SpotifyBuilder::default() - .credentials(creds) - .build() - .unwrap(); + let mut spotify = ClientCredsSpotify::new(creds); // Obtaining the access token. Requires to be mutable because the internal // token will be modified. We don't need OAuth for this specific endpoint, // so `...` is used instead of `prompt_for_user_token`. - spotify.request_client_token().await.unwrap(); + spotify.request_token().await.unwrap(); // Running the requests let birdy_uri = Id::from_uri("spotify:album:0sNOF9WDwhWunNAHPD3Baj").unwrap(); diff --git a/examples/current_user_recently_played.rs b/examples/current_user_recently_played.rs deleted file mode 100644 index d68b0eb1..00000000 --- a/examples/current_user_recently_played.rs +++ /dev/null @@ -1,51 +0,0 @@ -use rspotify::client::SpotifyBuilder; -use rspotify::oauth2::{CredentialsBuilder, OAuthBuilder}; -use rspotify::scopes; - -#[tokio::main] -async fn main() { - // You can use any logger for debugging. - env_logger::init(); - - // Set RSPOTIFY_CLIENT_ID, RSPOTIFY_CLIENT_SECRET and - // RSPOTIFY_REDIRECT_URI in an .env file or export them manually: - // - // export RSPOTIFY_CLIENT_ID="your client_id" - // export RSPOTIFY_CLIENT_SECRET="secret" - // - // These will then be read with `from_env`. - // - // Otherwise, set client_id and client_secret explictly: - // - // let creds = CredentialsBuilder::default() - // .client_id("this-is-my-client-id") - // .client_secret("this-is-my-client-secret") - // .build() - // .unwrap(); - let creds = CredentialsBuilder::from_env().build().unwrap(); - - // Or set the redirect_uri explictly: - // - // let oauth = OAuthBuilder::default() - // .redirect_uri("http://localhost:8888/callback") - // .build() - // .unwrap(); - let oauth = OAuthBuilder::from_env() - .scope(scopes!("user-read-recently-played")) - .build() - .unwrap(); - - let mut spotify = SpotifyBuilder::default() - .credentials(creds) - .oauth(oauth) - .build() - .unwrap(); - - // Obtaining the access token - spotify.prompt_for_user_token().await.unwrap(); - - // Running the requests - let history = spotify.current_user_recently_played(Some(10)).await; - - println!("Response: {:?}", history); -} diff --git a/examples/oauth_tokens.rs b/examples/oauth_tokens.rs index de127938..6a1baea3 100644 --- a/examples/oauth_tokens.rs +++ b/examples/oauth_tokens.rs @@ -5,9 +5,7 @@ //! an .env file or export them manually as environmental variables for this to //! work. -use rspotify::client::SpotifyBuilder; -use rspotify::oauth2::{CredentialsBuilder, OAuthBuilder}; -use rspotify::scopes; +use rspotify::{prelude::*, scopes, AuthCodeSpotify, Credentials, OAuth}; #[tokio::main] async fn main() { @@ -16,10 +14,10 @@ async fn main() { // The credentials must be available in the environment. Enable // `env-file` in order to read them from an `.env` file. - let creds = CredentialsBuilder::from_env().build().unwrap(); + let creds = Credentials::from_env().unwrap(); // Using every possible scope - let scope = scopes!( + let scopes = scopes!( "user-read-email", "user-read-private", "user-top-read", @@ -38,15 +36,12 @@ async fn main() { "playlist-modify-private", "ugc-image-upload" ); - let oauth = OAuthBuilder::from_env().scope(scope).build().unwrap(); + let oauth = OAuth::from_env(scopes).unwrap(); - let mut spotify = SpotifyBuilder::default() - .credentials(creds) - .oauth(oauth) - .build() - .unwrap(); + let mut spotify = AuthCodeSpotify::new(creds, oauth); - spotify.prompt_for_user_token().await.unwrap(); + let url = spotify.get_authorize_url(false).unwrap(); + spotify.prompt_for_token(&url).await.unwrap(); let token = spotify.token.as_ref().unwrap(); println!("Access token: {}", &token.access_token); diff --git a/examples/pagination_async.rs b/examples/pagination_async.rs index 874ce60c..ac01bbbe 100644 --- a/examples/pagination_async.rs +++ b/examples/pagination_async.rs @@ -8,51 +8,21 @@ use futures::stream::TryStreamExt; use futures_util::pin_mut; -use rspotify::client::SpotifyBuilder; -use rspotify::oauth2::{CredentialsBuilder, OAuthBuilder}; -use rspotify::scopes; +use rspotify::{prelude::*, scopes, AuthCodeSpotify, Credentials, OAuth}; #[tokio::main] async fn main() { // You can use any logger for debugging. env_logger::init(); - // Set RSPOTIFY_CLIENT_ID, RSPOTIFY_CLIENT_SECRET and - // RSPOTIFY_REDIRECT_URI in an .env file or export them manually: - // - // export RSPOTIFY_CLIENT_ID="your client_id" - // export RSPOTIFY_CLIENT_SECRET="secret" - // - // These will then be read with `from_env`. - // - // Otherwise, set client_id and client_secret explictly: - // - // let creds = CredentialsBuilder::default() - // .client_id("this-is-my-client-id") - // .client_secret("this-is-my-client-secret") - // .build() - // .unwrap(); - let creds = CredentialsBuilder::from_env().build().unwrap(); + let creds = Credentials::from_env().unwrap(); + let oauth = OAuth::from_env(scopes!("user-library-read")).unwrap(); - // Or set the redirect_uri explictly: - // - // let oauth = OAuthBuilder::default() - // .redirect_uri("http://localhost:8888/callback") - // .build() - // .unwrap(); - let oauth = OAuthBuilder::from_env() - .scope(scopes!("user-library-read")) - .build() - .unwrap(); - - let mut spotify = SpotifyBuilder::default() - .credentials(creds) - .oauth(oauth) - .build() - .unwrap(); + let mut spotify = AuthCodeSpotify::new(creds, oauth); // Obtaining the access token - spotify.prompt_for_user_token().await.unwrap(); + let url = spotify.get_authorize_url(false).unwrap(); + spotify.prompt_for_token(&url).await.unwrap(); // Executing the futures sequentially let stream = spotify.current_user_saved_tracks(); diff --git a/examples/pagination_manual.rs b/examples/pagination_manual.rs index 80b338fd..a8c6ab0d 100644 --- a/examples/pagination_manual.rs +++ b/examples/pagination_manual.rs @@ -1,51 +1,21 @@ //! This example shows how manual pagination works. It's what the raw API //! returns, but harder to use than an iterator or stream. -use rspotify::client::SpotifyBuilder; -use rspotify::oauth2::{CredentialsBuilder, OAuthBuilder}; -use rspotify::scopes; +use rspotify::{prelude::*, scopes, AuthCodeSpotify, Credentials, OAuth}; #[tokio::main] async fn main() { // You can use any logger for debugging. env_logger::init(); - // Set RSPOTIFY_CLIENT_ID, RSPOTIFY_CLIENT_SECRET and - // RSPOTIFY_REDIRECT_URI in an .env file or export them manually: - // - // export RSPOTIFY_CLIENT_ID="your client_id" - // export RSPOTIFY_CLIENT_SECRET="secret" - // - // These will then be read with `from_env`. - // - // Otherwise, set client_id and client_secret explictly: - // - // let creds = CredentialsBuilder::default() - // .client_id("this-is-my-client-id") - // .client_secret("this-is-my-client-secret") - // .build() - // .unwrap(); - let creds = CredentialsBuilder::from_env().build().unwrap(); + let creds = Credentials::from_env().unwrap(); + let oauth = OAuth::from_env(scopes!("user-library-read")).unwrap(); - // Or set the redirect_uri explictly: - // - // let oauth = OAuthBuilder::default() - // .redirect_uri("http://localhost:8888/callback") - // .build() - // .unwrap(); - let oauth = OAuthBuilder::from_env() - .scope(scopes!("user-library-read")) - .build() - .unwrap(); - - let mut spotify = SpotifyBuilder::default() - .credentials(creds) - .oauth(oauth) - .build() - .unwrap(); + let mut spotify = AuthCodeSpotify::new(creds, oauth); // Obtaining the access token - spotify.prompt_for_user_token().await.unwrap(); + let url = spotify.get_authorize_url(false).unwrap(); + spotify.prompt_for_token(&url).await.unwrap(); // Manual pagination. You may choose the number of items returned per // iteration. diff --git a/examples/pagination_sync.rs b/examples/pagination_sync.rs index eb127588..2dd8600e 100644 --- a/examples/pagination_sync.rs +++ b/examples/pagination_sync.rs @@ -9,50 +9,20 @@ //! } //! ``` -use rspotify::client::SpotifyBuilder; -use rspotify::oauth2::{CredentialsBuilder, OAuthBuilder}; -use rspotify::scopes; +use rspotify::{prelude::*, scopes, AuthCodeSpotify, Credentials, OAuth}; fn main() { // You can use any logger for debugging. env_logger::init(); - // Set RSPOTIFY_CLIENT_ID, RSPOTIFY_CLIENT_SECRET and - // RSPOTIFY_REDIRECT_URI in an .env file or export them manually: - // - // export RSPOTIFY_CLIENT_ID="your client_id" - // export RSPOTIFY_CLIENT_SECRET="secret" - // - // These will then be read with `from_env`. - // - // Otherwise, set client_id and client_secret explictly: - // - // let creds = CredentialsBuilder::default() - // .client_id("this-is-my-client-id") - // .client_secret("this-is-my-client-secret") - // .build() - // .unwrap(); - let creds = CredentialsBuilder::from_env().build().unwrap(); + let creds = Credentials::from_env().unwrap(); + let oauth = OAuth::from_env(scopes!("user-library-read")).unwrap(); - // Or set the redirect_uri explictly: - // - // let oauth = OAuthBuilder::default() - // .redirect_uri("http://localhost:8888/callback") - // .build() - // .unwrap(); - let oauth = OAuthBuilder::from_env() - .scope(scopes!("user-library-read")) - .build() - .unwrap(); - - let mut spotify = SpotifyBuilder::default() - .credentials(creds) - .oauth(oauth) - .build() - .unwrap(); + let mut spotify = AuthCodeSpotify::new(creds, oauth); // Obtaining the access token - spotify.prompt_for_user_token().unwrap(); + let url = spotify.get_authorize_url(false).unwrap(); + spotify.prompt_for_token(&url).unwrap(); // Typical iteration, no extra boilerplate needed. let stream = spotify.current_user_saved_tracks(); diff --git a/examples/track.rs b/examples/track.rs deleted file mode 100644 index 66446771..00000000 --- a/examples/track.rs +++ /dev/null @@ -1,42 +0,0 @@ -use rspotify::client::SpotifyBuilder; -use rspotify::model::Id; -use rspotify::oauth2::CredentialsBuilder; - -#[tokio::main] -async fn main() { - // You can use any logger for debugging. - env_logger::init(); - - // Set RSPOTIFY_CLIENT_ID, RSPOTIFY_CLIENT_SECRET and - // RSPOTIFY_REDIRECT_URI in an .env file or export them manually: - // - // export RSPOTIFY_CLIENT_ID="your client_id" - // export RSPOTIFY_CLIENT_SECRET="secret" - // - // These will then be read with `from_env`. - // - // Otherwise, set client_id and client_secret explictly: - // - // let creds = CredentialsBuilder::default() - // .client_id("this-is-my-client-id") - // .client_secret("this-is-my-client-secret") - // .build() - // .unwrap(); - let creds = CredentialsBuilder::from_env().build().unwrap(); - - let mut spotify = SpotifyBuilder::default() - .credentials(creds) - .build() - .unwrap(); - - // Obtaining the access token. Requires to be mutable because the internal - // token will be modified. We don't need OAuth for this specific endpoint, - // so `...` is used instead of `prompt_for_user_token`. - spotify.request_client_token().await.unwrap(); - - // Running the requests - let birdy_uri = Id::from_uri("spotify:track:6rqhFgbbKwnb9MLmUQDhG6").unwrap(); - let track = spotify.track(birdy_uri).await; - - println!("Response: {:#?}", track); -} diff --git a/examples/tracks.rs b/examples/tracks.rs deleted file mode 100644 index 7549ecd7..00000000 --- a/examples/tracks.rs +++ /dev/null @@ -1,42 +0,0 @@ -use rspotify::client::SpotifyBuilder; -use rspotify::model::Id; -use rspotify::oauth2::CredentialsBuilder; - -#[tokio::main] -async fn main() { - // You can use any logger for debugging. - env_logger::init(); - - // Set RSPOTIFY_CLIENT_ID, RSPOTIFY_CLIENT_SECRET and - // RSPOTIFY_REDIRECT_URI in an .env file or export them manually: - // - // export RSPOTIFY_CLIENT_ID="your client_id" - // export RSPOTIFY_CLIENT_SECRET="secret" - // - // These will then be read with `from_env`. - // - // Otherwise, set client_id and client_secret explictly: - // - // let creds = CredentialsBuilder::default() - // .client_id("this-is-my-client-id") - // .client_secret("this-is-my-client-secret") - // .build() - // .unwrap(); - let creds = CredentialsBuilder::from_env().build().unwrap(); - - let mut spotify = SpotifyBuilder::default() - .credentials(creds) - .build() - .unwrap(); - - // Obtaining the access token. Requires to be mutable because the internal - // token will be modified. We don't need OAuth for this specific endpoint, - // so `...` is used instead of `prompt_for_user_token`. - spotify.request_client_token().await.unwrap(); - - let birdy_uri1 = Id::from_uri("spotify:track:3n3Ppam7vgaVa1iaRUc9Lp").unwrap(); - let birdy_uri2 = Id::from_uri("spotify:track:3twNvmDtFQtAd5gMKedhLD").unwrap(); - let track_uris = vec![birdy_uri1, birdy_uri2]; - let tracks = spotify.tracks(track_uris, None).await; - println!("Response: {:?}", tracks); -} diff --git a/examples/ureq/device.rs b/examples/ureq/device.rs index 4154394f..f4c66dd0 100644 --- a/examples/ureq/device.rs +++ b/examples/ureq/device.rs @@ -1,47 +1,17 @@ -use rspotify::client::SpotifyBuilder; -use rspotify::oauth2::{CredentialsBuilder, OAuthBuilder}; -use rspotify::scopes; +use rspotify::{prelude::*, scopes, AuthCodeSpotify, Credentials, OAuth}; fn main() { // You can use any logger for debugging. env_logger::init(); - // Set RSPOTIFY_CLIENT_ID, RSPOTIFY_CLIENT_SECRET and - // RSPOTIFY_REDIRECT_URI in an .env file or export them manually: - // - // export RSPOTIFY_CLIENT_ID="your client_id" - // export RSPOTIFY_CLIENT_SECRET="secret" - // - // These will then be read with `from_env`. - // - // Otherwise, set client_id and client_secret explictly: - // - // let creds = CredentialsBuilder::default() - // .client_id("this-is-my-client-id") - // .client_secret("this-is-my-client-secret") - // .build() - // .unwrap(); - let creds = CredentialsBuilder::from_env().build().unwrap(); + let creds = Credentials::from_env().unwrap(); + let oauth = OAuth::from_env(scopes!("user-read-playback-state")).unwrap(); - // Or set the redirect_uri explictly: - // - // let oauth = OAuthBuilder::default() - // .redirect_uri("http://localhost:8888/callback") - // .build() - // .unwrap(); - let oauth = OAuthBuilder::from_env() - .scope(scopes!("user-read-playback-state")) - .build() - .unwrap(); - - let mut spotify = SpotifyBuilder::default() - .credentials(creds) - .oauth(oauth) - .build() - .unwrap(); + let mut spotify = AuthCodeSpotify::new(creds, oauth); // Obtaining the access token - spotify.prompt_for_user_token().unwrap(); + let url = spotify.get_authorize_url(false).unwrap(); + spotify.prompt_for_token(&url).unwrap(); let devices = spotify.device(); diff --git a/examples/ureq/me.rs b/examples/ureq/me.rs index e089c2fd..9258589b 100644 --- a/examples/ureq/me.rs +++ b/examples/ureq/me.rs @@ -1,47 +1,17 @@ -use rspotify::client::SpotifyBuilder; -use rspotify::oauth2::{CredentialsBuilder, OAuthBuilder}; -use rspotify::scopes; +use rspotify::{prelude::*, scopes, AuthCodeSpotify, Credentials, OAuth}; fn main() { // You can use any logger for debugging. env_logger::init(); - // Set RSPOTIFY_CLIENT_ID, RSPOTIFY_CLIENT_SECRET and - // RSPOTIFY_REDIRECT_URI in an .env file or export them manually: - // - // export RSPOTIFY_CLIENT_ID="your client_id" - // export RSPOTIFY_CLIENT_SECRET="secret" - // - // These will then be read with `from_env`. - // - // Otherwise, set client_id and client_secret explictly: - // - // let creds = CredentialsBuilder::default() - // .client_id("this-is-my-client-id") - // .client_secret("this-is-my-client-secret") - // .build() - // .unwrap(); - let creds = CredentialsBuilder::from_env().build().unwrap(); + let creds = Credentials::from_env().unwrap(); + let oauth = OAuth::from_env(scopes!("user-read-playback-state")).unwrap(); - // Or set the redirect_uri explictly: - // - // let oauth = OAuthBuilder::default() - // .redirect_uri("http://localhost:8888/callback") - // .build() - // .unwrap(); - let oauth = OAuthBuilder::from_env() - .scope(scopes!("user-read-playback-state")) - .build() - .unwrap(); - - let mut spotify = SpotifyBuilder::default() - .credentials(creds) - .oauth(oauth) - .build() - .unwrap(); + let mut spotify = AuthCodeSpotify::new(creds, oauth); // Obtaining the access token - spotify.prompt_for_user_token().unwrap(); + let url = spotify.get_authorize_url(false).unwrap(); + spotify.prompt_for_token(&url).unwrap(); let user = spotify.me(); println!("Request: {:?}", user); diff --git a/examples/ureq/search.rs b/examples/ureq/search.rs index 2a601877..caab1fae 100644 --- a/examples/ureq/search.rs +++ b/examples/ureq/search.rs @@ -1,51 +1,21 @@ -use rspotify::client::SpotifyBuilder; -use rspotify::model::{Country, Market, SearchType}; -use rspotify::oauth2::{CredentialsBuilder, OAuthBuilder}; -use rspotify::scopes; +use rspotify::{ + model::{Country, Market, SearchType}, + prelude::*, + ClientCredsSpotify, Credentials, +}; fn main() { // You can use any logger for debugging. env_logger::init(); - // Set RSPOTIFY_CLIENT_ID, RSPOTIFY_CLIENT_SECRET and - // RSPOTIFY_REDIRECT_URI in an .env file or export them manually: - // - // export RSPOTIFY_CLIENT_ID="your client_id" - // export RSPOTIFY_CLIENT_SECRET="secret" - // - // These will then be read with `from_env`. - // - // Otherwise, set client_id and client_secret explictly: - // - // let creds = CredentialsBuilder::default() - // .client_id("this-is-my-client-id") - // .client_secret("this-is-my-client-secret") - // .build() - // .unwrap(); - let creds = CredentialsBuilder::from_env().build().unwrap(); - - // Or set the redirect_uri explictly: - // - // let oauth = OAuthBuilder::default() - // .redirect_uri("http://localhost:8888/callback") - // .build() - // .unwrap(); - let oauth = OAuthBuilder::from_env() - .scope(scopes!("user-read-playback-state")) - .build() - .unwrap(); - - let mut spotify = SpotifyBuilder::default() - .credentials(creds) - .oauth(oauth) - .build() - .unwrap(); + let creds = Credentials::from_env().unwrap(); + let mut spotify = ClientCredsSpotify::new(creds); // Obtaining the access token - spotify.request_client_token().unwrap(); + spotify.request_token().unwrap(); let album_query = "album:arrival artist:abba"; - let result = spotify.search(album_query, SearchType::Album, None, None, Some(10), None); + let result = spotify.search(album_query, &SearchType::Album, None, None, Some(10), None); match result { Ok(album) => println!("searched album:{:?}", album), Err(err) => println!("search error!{:?}", err), @@ -54,8 +24,8 @@ fn main() { let artist_query = "tania bowra"; let result = spotify.search( artist_query, - SearchType::Artist, - Some(Market::Country(Country::UnitedStates)), + &SearchType::Artist, + Some(&Market::Country(Country::UnitedStates)), None, Some(10), None, @@ -68,8 +38,8 @@ fn main() { let playlist_query = "\"doom metal\""; let result = spotify.search( playlist_query, - SearchType::Playlist, - Some(Market::Country(Country::UnitedStates)), + &SearchType::Playlist, + Some(&Market::Country(Country::UnitedStates)), None, Some(10), None, @@ -82,8 +52,8 @@ fn main() { let track_query = "abba"; let result = spotify.search( track_query, - SearchType::Track, - Some(Market::Country(Country::UnitedStates)), + &SearchType::Track, + Some(&Market::Country(Country::UnitedStates)), None, Some(10), None, @@ -94,7 +64,7 @@ fn main() { } let show_query = "love"; - let result = spotify.search(show_query, SearchType::Show, None, None, Some(10), None); + let result = spotify.search(show_query, &SearchType::Show, None, None, Some(10), None); match result { Ok(show) => println!("searched show:{:?}", show), Err(err) => println!("search error!{:?}", err), @@ -103,7 +73,7 @@ fn main() { let episode_query = "love"; let result = spotify.search( episode_query, - SearchType::Episode, + &SearchType::Episode, None, None, Some(10), diff --git a/examples/ureq/seek_track.rs b/examples/ureq/seek_track.rs index 8dacf63e..a4a98ae1 100644 --- a/examples/ureq/seek_track.rs +++ b/examples/ureq/seek_track.rs @@ -1,49 +1,17 @@ -use rspotify::client::SpotifyBuilder; -use rspotify::oauth2::{CredentialsBuilder, OAuthBuilder}; -use rspotify::scope; - -use std::collections::HashSet; +use rspotify::{prelude::*, scopes, AuthCodeSpotify, Credentials, OAuth}; fn main() { // You can use any logger for debugging. env_logger::init(); - // Set RSPOTIFY_CLIENT_ID, RSPOTIFY_CLIENT_SECRET and - // RSPOTIFY_REDIRECT_URI in an .env file or export them manually: - // - // export RSPOTIFY_CLIENT_ID="your client_id" - // export RSPOTIFY_CLIENT_SECRET="secret" - // - // These will then be read with `from_env`. - // - // Otherwise, set client_id and client_secret explictly: - // - // let creds = CredentialsBuilder::default() - // .client_id("this-is-my-client-id") - // .client_secret("this-is-my-client-secret") - // .build() - // .unwrap(); - let creds = CredentialsBuilder::from_env().build().unwrap(); - - // Or set the redirect_uri explictly: - // - // let oauth = OAuthBuilder::default() - // .redirect_uri("http://localhost:8888/callback") - // .build() - // .unwrap(); - let oauth = OAuthBuilder::from_env() - .scope(scopes!("user-read-playback-state")) - .build() - .unwrap(); + let creds = Credentials::from_env().unwrap(); + let oauth = OAuth::from_env(scopes!("user-read-playback-state")).unwrap(); - let mut spotify = SpotifyBuilder::default() - .credentials(creds) - .oauth(oauth) - .build() - .unwrap(); + let mut spotify = AuthCodeSpotify::new(creds, oauth); // Obtaining the access token - spotify.prompt_for_user_token().unwrap(); + let url = spotify.get_authorize_url(false).unwrap(); + spotify.prompt_for_token(&url).unwrap(); match spotify.seek_track(25000, None) { Ok(_) => println!("change to previous playback successful"), diff --git a/examples/webapp/src/main.rs b/examples/webapp/src/main.rs index 14525b82..e4011504 100644 --- a/examples/webapp/src/main.rs +++ b/examples/webapp/src/main.rs @@ -1,7 +1,7 @@ //! In this example, the token is saved into a cache file. If you are building a //! real-world web app, you should store it in a database instead. In that case -//! you can use `Spotify::request_user_token_without_cache` and -//! `Spotify::refresh_user_token_without_cache` to avoid creating cache files. +//! you can disable `token_cached` in the `Config` struct passed to the client +//! when initializing it to avoid using cache files. #![feature(proc_macro_hygiene, decl_macro)] @@ -14,13 +14,11 @@ use rocket::response::Redirect; use rocket_contrib::json; use rocket_contrib::json::JsonValue; use rocket_contrib::templates::Template; -use rspotify::client::{ClientError, SpotifyBuilder}; -use rspotify::oauth2::{CredentialsBuilder, OAuthBuilder, TokenBuilder}; -use rspotify::scope; +use rspotify::{scopes, AuthCodeSpotify, OAuth, Credentials, Config, prelude::*, Token}; use std::fs; use std::{ - collections::{HashMap, HashSet}, + collections::HashMap, env, path::PathBuf, }; @@ -47,9 +45,18 @@ fn generate_random_uuid(length: usize) -> String { .collect() } +fn get_cache_path(cookies: &Cookies) -> PathBuf { + let project_dir_path = env::current_dir().unwrap(); + let mut cache_path = project_dir_path; + cache_path.push(CACHE_PATH); + cache_path.push(cookies.get("uuid").unwrap().value()); + + cache_path +} + fn create_cache_path_if_absent(cookies: &Cookies) -> PathBuf { - let (exist, cache_path) = check_cache_path_exists(cookies); - if !exist { + let cache_path = get_cache_path(cookies); + if !cache_path.exists() { let mut path = cache_path.clone(); path.pop(); fs::create_dir_all(path).unwrap(); @@ -58,54 +65,49 @@ fn create_cache_path_if_absent(cookies: &Cookies) -> PathBuf { } fn remove_cache_path(mut cookies: Cookies) { - let (exist, cache_path) = check_cache_path_exists(&cookies); - if exist { + let cache_path = get_cache_path(&cookies); + if cache_path.exists() { fs::remove_file(cache_path).unwrap() } cookies.remove(Cookie::named("uuid")) } -fn check_cache_path_exists(cookies: &Cookies) -> (bool, PathBuf) { - let project_dir_path = env::current_dir().unwrap(); - let mut cache_path = project_dir_path; - cache_path.push(CACHE_PATH); - cache_path.push(cookies.get("uuid").unwrap().value()); - (cache_path.exists(), cache_path) +fn check_cache_path_exists(cookies: &Cookies) -> bool { + let cache_path = get_cache_path(cookies); + cache_path.exists() } -fn init_spotify() -> SpotifyBuilder { +fn init_spotify(cookies: &Cookies) -> AuthCodeSpotify { + let config = Config { + token_cached: true, + cache_path: create_cache_path_if_absent(cookies), + ..Default::default() + }; + // Please notice that protocol of redirect_uri, make sure it's http // (or https). It will fail if you mix them up. - let scope = scopes!("user-read-currently-playing", "playlist-modify-private"); - let oauth = OAuthBuilder::default() - .redirect_uri("http://localhost:8000/callback") - .scope(scope) - .build() - .unwrap(); + let oauth = OAuth { + scopes: scopes!("user-read-currently-playing", "playlist-modify-private"), + redirect_uri: "http://localhost:8000/callback".to_owned(), + ..Default::default() + }; // Replacing client_id and client_secret with yours. - let creds = CredentialsBuilder::default() - .id("e1dce60f1e274e20861ce5d96142a4d3") - .secret("0e4e03b9be8d465d87fc32857a4b5aa3") - .build() - .unwrap(); - - SpotifyBuilder::default() - .credentials(creds) - .oauth(oauth) - .clone() + let creds = Credentials::new( + "e1dce60f1e274e20861ce5d96142a4d3", + "0e4e03b9be8d465d87fc32857a4b5aa3" + ); + + AuthCodeSpotify::with_config(creds, oauth, config) } #[get("/callback?")] fn callback(cookies: Cookies, code: String) -> AppResponse { - let mut spotify = init_spotify(); - let mut spotify = spotify - .cache_path(create_cache_path_if_absent(&cookies)) - .build() - .unwrap(); - return match spotify.request_user_token(code.as_str()) { + let mut spotify = init_spotify(&cookies); + + match spotify.request_token(&code) { Ok(_) => { - println!("request user token successful"); + println!("Request user token successful"); AppResponse::Redirect(Redirect::to("/")) } Err(err) => { @@ -114,33 +116,28 @@ fn callback(cookies: Cookies, code: String) -> AppResponse { context.insert("err_msg", "Failed to get token!"); AppResponse::Template(Template::render("error", context)) } - }; + } } #[get("/")] fn index(mut cookies: Cookies) -> AppResponse { - let mut spotify_builder = init_spotify(); - let check_exists = |c| { - let (exists, _) = check_cache_path_exists(c); - exists - }; + let mut context = HashMap::new(); // The user is authenticated if their cookie is set and a cache exists for // them. - let authenticated = cookies.get("uuid").is_some() && check_exists(&cookies); - let spotify = if authenticated { - let (_, cache_path) = check_cache_path_exists(&cookies); - let token = TokenBuilder::from_cache(cache_path).build().unwrap(); - spotify_builder.token(token).build().unwrap() - } else { + let authenticated = cookies.get("uuid").is_some() && check_cache_path_exists(&cookies); + if !authenticated { cookies.add(Cookie::new("uuid", generate_random_uuid(64))); - spotify_builder - .cache_path(create_cache_path_if_absent(&cookies)) - .build() - .unwrap() - }; - let mut context = HashMap::new(); + let spotify = init_spotify(&cookies); + let auth_url = spotify.get_authorize_url(true).unwrap(); + context.insert("auth_url", auth_url); + return AppResponse::Template(Template::render("authorize", context)); + } + + let cache_path = get_cache_path(&cookies); + let token = Token::from_cache(cache_path).unwrap(); + let spotify = AuthCodeSpotify::from_token(token); match spotify.me() { Ok(user_info) => { context.insert( @@ -151,14 +148,7 @@ fn index(mut cookies: Cookies) -> AppResponse { ); AppResponse::Template(Template::render("index", context.clone())) } - Err(ClientError::InvalidAuth(msg)) => { - println!("InvalidAuth msg {:?}", msg); - let auth_url = spotify.get_authorize_url(true).unwrap(); - context.insert("auth_url", auth_url); - AppResponse::Template(Template::render("authorize", context)) - } Err(err) => { - let mut context = HashMap::new(); context.insert("err_msg", format!("Failed for {}!", err)); AppResponse::Template(Template::render("error", context)) } @@ -173,30 +163,33 @@ fn sign_out(cookies: Cookies) -> AppResponse { #[get("/playlists")] fn playlist(cookies: Cookies) -> AppResponse { - let mut spotify = init_spotify(); - let (exist, cache_path) = check_cache_path_exists(&cookies); - if !exist { + let mut spotify = init_spotify(&cookies); + if !spotify.config.cache_path.exists() { return AppResponse::Redirect(Redirect::to("/")); } - let token = TokenBuilder::from_cache(cache_path).build().unwrap(); - let spotify = spotify.token(token).build().unwrap(); - match spotify.current_user_playlists(Some(20), Some(0)) { - Ok(playlists) => AppResponse::Json(json!(playlists)), - Err(_) => AppResponse::Redirect(Redirect::to("/")), + let token = spotify.read_token_cache().unwrap(); + spotify.token = Some(token); + let playlists = spotify.current_user_playlists() + .take(50) + .filter_map(Result::ok) + .collect::>(); + + if playlists.is_empty() { + return AppResponse::Redirect(Redirect::to("/")); } + + AppResponse::Json(json!(playlists)) } #[get("/me")] fn me(cookies: Cookies) -> AppResponse { - let mut spotify = init_spotify(); - let (exist, cache_path) = check_cache_path_exists(&cookies); - if !exist { + let mut spotify = init_spotify(&cookies); + if !spotify.config.cache_path.exists() { return AppResponse::Redirect(Redirect::to("/")); } - let token = TokenBuilder::from_cache(cache_path).build().unwrap(); - let spotify = spotify.token(token).build().unwrap(); + spotify.token = Some(spotify.read_token_cache().unwrap()); match spotify.me() { Ok(user_info) => AppResponse::Json(json!(user_info)), Err(_) => AppResponse::Redirect(Redirect::to("/")), diff --git a/examples/with_refresh_token.rs b/examples/with_refresh_token.rs index 98e45e81..0218cd21 100644 --- a/examples/with_refresh_token.rs +++ b/examples/with_refresh_token.rs @@ -2,7 +2,7 @@ //! authentication method without the need for user's interaction for oauth //! requests. You still need to authenticate the usual way at least once to //! obtain the refresh token, and you may need to obtain a new one if you change -//! the required scope. +//! the required scopes. //! //! The cache generated by `get_token` uses the refresh token under the hood to //! automatically authenticate the user. This example shows how it's done @@ -15,14 +15,11 @@ //! tokens](https://github.com/felix-hilden/tekore/issues/86), so in the case of //! Spotify it doesn't seem to revoke them at all. -use rspotify::client::{Spotify, SpotifyBuilder}; -use rspotify::model::Id; -use rspotify::oauth2::{CredentialsBuilder, OAuthBuilder}; -use rspotify::scopes; +use rspotify::{model::Id, prelude::*, scopes, AuthCodeSpotify, Credentials, OAuth}; // Sample request that will follow some artists, print the user's // followed artists, and then unfollow the artists. -async fn do_things(spotify: Spotify) { +async fn do_things(spotify: AuthCodeSpotify) { let artists = vec![ Id::from_id("3RGLhK1IP9jnYFH4BRFJBS").unwrap(), // The Clash Id::from_id("0yNLKJebCb8Aueb54LYya3").unwrap(), // New Order @@ -57,20 +54,16 @@ async fn main() { env_logger::init(); // The default credentials from the `.env` file will be used by default. - let creds = CredentialsBuilder::from_env().build().unwrap(); - let scope = scopes!("user-follow-read user-follow-modify"); - let oauth = OAuthBuilder::from_env().scope(scope).build().unwrap(); - let mut spotify = SpotifyBuilder::default() - .credentials(creds.clone()) - .oauth(oauth.clone()) - .build() - .unwrap(); + let creds = Credentials::from_env().unwrap(); + let oauth = OAuth::from_env(scopes!("user-follow-read user-follow-modify")).unwrap(); + let mut spotify = AuthCodeSpotify::new(creds.clone(), oauth.clone()); // In the first session of the application we authenticate and obtain the // refresh token. We can also do some requests here. println!(">>> Session one, obtaining refresh token and running some requests:"); + let url = spotify.get_authorize_url(false).unwrap(); spotify - .prompt_for_user_token_without_cache() + .prompt_for_token(&url) .await .expect("couldn't authenticate successfully"); let refresh_token = spotify @@ -86,14 +79,10 @@ async fn main() { // At a different time, the refresh token can be used to refresh an access // token directly and run requests: println!(">>> Session two, running some requests:"); - let mut spotify = SpotifyBuilder::default() - .credentials(creds.clone()) - .oauth(oauth.clone()) - .build() - .unwrap(); + let mut spotify = AuthCodeSpotify::new(creds.clone(), oauth.clone()); // No `prompt_for_user_token_without_cache` needed. spotify - .refresh_user_token(&refresh_token) + .refresh_token(&refresh_token) .await .expect("couldn't refresh user token"); do_things(spotify).await; @@ -101,13 +90,9 @@ async fn main() { // This process can now be repeated multiple times by using only the // refresh token that was obtained at the beginning. println!(">>> Session three, running some requests:"); - let mut spotify = SpotifyBuilder::default() - .credentials(creds.clone()) - .oauth(oauth.clone()) - .build() - .unwrap(); + let mut spotify = AuthCodeSpotify::new(creds, oauth); spotify - .refresh_user_token(&refresh_token) + .refresh_token(&refresh_token) .await .expect("couldn't refresh user token"); do_things(spotify).await; diff --git a/rspotify-http/Cargo.toml b/rspotify-http/Cargo.toml new file mode 100644 index 00000000..dafb313a --- /dev/null +++ b/rspotify-http/Cargo.toml @@ -0,0 +1,54 @@ +[package] +authors = [ + "Ramsay Leung ", + "Mario Ortiz Manero " +] +name = "rspotify-http" +version = "0.10.0" +license = "MIT" +readme = "README.md" +description = "HTTP compatibility layer for Rspotify" +homepage = "https://github.com/ramsayleung/rspotify" +repository = "https://github.com/ramsayleung/rspotify" +keywords = ["spotify", "api"] +edition = "2018" + +[dependencies] +rspotify-model = { path = "../rspotify-model", version = "0.10.0" } + +# Temporary until https://github.com/rust-lang/rfcs/issues/2739, for +# `maybe_async`. +async-trait = { version = "0.1.48", optional = true } +base64 = "0.13.0" +futures = { version = "0.3.8", optional = true } +log = "0.4.11" +maybe-async = "0.2.4" +reqwest = { version = "0.11.0", default-features = false, features = ["json", "socks"], optional = true } +serde_json = "1.0.57" +thiserror = "1.0.20" +ureq = { version = "2.0", default-features = false, features = ["json", "cookies"], optional = true } +url = "2.2.2" + +[dev-dependencies] +env_logger = "0.8.1" +tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] } + +[features] +default = ["client-reqwest", "reqwest-default-tls"] + +# Available clients. By default they don't include a TLS so that it can be +# configured. +client-ureq = ["ureq", "__sync"] +client-reqwest = ["reqwest", "__async"] + +# Passing the TLS features to reqwest. +reqwest-default-tls = ["reqwest/default-tls"] +reqwest-rustls-tls = ["reqwest/rustls-tls"] +reqwest-native-tls = ["reqwest/native-tls"] +reqwest-native-tls-vendored = ["reqwest/native-tls-vendored"] +# Same for ureq. +ureq-rustls-tls = ["ureq/tls"] + +# Internal features for checking async or sync compilation +__async = ["async-trait", "futures"] +__sync = ["maybe-async/is_sync"] diff --git a/rspotify-http/src/common.rs b/rspotify-http/src/common.rs new file mode 100644 index 00000000..9734bb36 --- /dev/null +++ b/rspotify-http/src/common.rs @@ -0,0 +1,82 @@ +use std::collections::HashMap; +use std::fmt; + +use maybe_async::maybe_async; +use rspotify_model::ApiError; +use serde_json::Value; + +pub type Headers = HashMap; +pub type Query<'a> = HashMap<&'a str, &'a str>; +pub type Form<'a> = HashMap<&'a str, &'a str>; + +#[derive(thiserror::Error, Debug)] +pub enum HttpError { + #[error("request unauthorized")] + Unauthorized, + + #[error("exceeded request limit")] + RateLimited(Option), + + #[error("request error: {0}")] + Request(String), + + #[error("status code {0}: {1}")] + StatusCode(u16, String), + + #[error("spotify error: {0}")] + Api(#[from] ApiError), + + #[error("input/output error: {0}")] + Io(#[from] std::io::Error), +} + +pub type HttpResult = Result; + +/// This trait represents the interface to be implemented for an HTTP client, +/// which is kept separate from the Spotify client for cleaner code. Thus, it +/// also requires other basic traits that are needed for the Spotify client. +/// +/// When a request doesn't need to pass parameters, the empty or default value +/// of the payload type should be passed, like `json!({})` or `Query::new()`. +/// This avoids using `Option` because `Value` itself may be null in other +/// different ways (`Value::Null`, an empty `Value::Object`...), so this removes +/// redundancy and edge cases (a `Some(Value::Null), for example, doesn't make +/// much sense). +#[maybe_async] +pub trait BaseHttpClient: Send + Default + Clone + fmt::Debug { + // This internal function should always be given an object value in JSON. + async fn get( + &self, + url: &str, + headers: Option<&Headers>, + payload: &Query, + ) -> HttpResult; + + async fn post( + &self, + url: &str, + headers: Option<&Headers>, + payload: &Value, + ) -> HttpResult; + + async fn post_form<'a>( + &self, + url: &str, + headers: Option<&Headers>, + payload: &Form<'a>, + ) -> HttpResult; + + async fn put( + &self, + url: &str, + headers: Option<&Headers>, + payload: &Value, + ) -> HttpResult; + + async fn delete( + &self, + url: &str, + headers: Option<&Headers>, + payload: &Value, + ) -> HttpResult; +} diff --git a/rspotify-http/src/lib.rs b/rspotify-http/src/lib.rs new file mode 100644 index 00000000..ced8acdd --- /dev/null +++ b/rspotify-http/src/lib.rs @@ -0,0 +1,43 @@ +//! The HTTP client may vary depending on which one the user configures. This +//! module contains the required logic to use different clients interchangeably. + +// Disable all modules when both client features are enabled or when none are. +// This way only the compile error below gets shown instead of a whole list of +// confusing errors.. + +#[cfg(feature = "client-reqwest")] +#[cfg(not(all(feature = "client-reqwest", feature = "client-ureq")))] +mod reqwest; + +#[cfg(feature = "client-ureq")] +#[cfg(not(all(feature = "client-reqwest", feature = "client-ureq")))] +mod ureq; + +#[cfg(any(feature = "client-reqwest", feature = "client-ureq"))] +#[cfg(not(all(feature = "client-reqwest", feature = "client-ureq")))] +mod common; + +#[cfg(feature = "client-reqwest")] +#[cfg(not(all(feature = "client-reqwest", feature = "client-ureq")))] +pub use self::reqwest::ReqwestClient as HttpClient; + +#[cfg(feature = "client-ureq")] +#[cfg(not(all(feature = "client-reqwest", feature = "client-ureq")))] +pub use self::ureq::UreqClient as HttpClient; + +#[cfg(any(feature = "client-reqwest", feature = "client-ureq"))] +#[cfg(not(all(feature = "client-reqwest", feature = "client-ureq")))] +pub use common::{BaseHttpClient, Form, Headers, HttpError, HttpResult, Query}; + +#[cfg(all(feature = "client-reqwest", feature = "client-ureq"))] +compile_error!( + "`client-reqwest` and `client-ureq` features cannot both be enabled at \ + the same time, if you want to use `client-ureq` you need to set \ + `default-features = false`" +); + +#[cfg(not(any(feature = "client-reqwest", feature = "client-ureq")))] +compile_error!( + "You have to enable at least one of the available clients with the \ + `client-reqwest` or `client-ureq` features." +); diff --git a/src/http/reqwest.rs b/rspotify-http/src/reqwest.rs similarity index 89% rename from src/http/reqwest.rs rename to rspotify-http/src/reqwest.rs index cef391ae..a08e893f 100644 --- a/src/http/reqwest.rs +++ b/rspotify-http/src/reqwest.rs @@ -1,16 +1,16 @@ //! The client implementation for the reqwest HTTP client, which is async by //! default. -use maybe_async::async_impl; -use reqwest::{Method, RequestBuilder, StatusCode}; -use serde_json::Value; +use super::{BaseHttpClient, Form, Headers, HttpError, HttpResult, Query}; use std::convert::TryInto; -use super::{BaseHttpClient, Form, Headers, Query}; -use crate::client::{ApiError, ClientError, ClientResult}; +use maybe_async::async_impl; +use reqwest::{Method, RequestBuilder, StatusCode}; +use rspotify_model::ApiError; +use serde_json::Value; -impl ClientError { +impl HttpError { pub async fn from_response(response: reqwest::Response) -> Self { match response.status() { StatusCode::UNAUTHORIZED => Self::Unauthorized, @@ -31,13 +31,13 @@ impl ClientError { } } -impl From for ClientError { +impl From for HttpError { fn from(err: reqwest::Error) -> Self { Self::Request(err.to_string()) } } -impl From for ClientError { +impl From for HttpError { fn from(code: reqwest::StatusCode) -> Self { Self::StatusCode( code.as_u16(), @@ -59,7 +59,7 @@ impl ReqwestClient { url: &str, headers: Option<&Headers>, add_data: D, - ) -> ClientResult + ) -> HttpResult where D: Fn(RequestBuilder) -> RequestBuilder, { @@ -88,7 +88,7 @@ impl ReqwestClient { if response.status().is_success() { response.text().await.map_err(Into::into) } else { - Err(ClientError::from_response(response).await) + Err(HttpError::from_response(response).await) } } } @@ -101,7 +101,7 @@ impl BaseHttpClient for ReqwestClient { url: &str, headers: Option<&Headers>, payload: &Query, - ) -> ClientResult { + ) -> HttpResult { self.request(Method::GET, url, headers, |req| req.query(payload)) .await } @@ -112,7 +112,7 @@ impl BaseHttpClient for ReqwestClient { url: &str, headers: Option<&Headers>, payload: &Value, - ) -> ClientResult { + ) -> HttpResult { self.request(Method::POST, url, headers, |req| req.json(payload)) .await } @@ -123,7 +123,7 @@ impl BaseHttpClient for ReqwestClient { url: &str, headers: Option<&Headers>, payload: &Form<'a>, - ) -> ClientResult { + ) -> HttpResult { self.request(Method::POST, url, headers, |req| req.form(payload)) .await } @@ -134,7 +134,7 @@ impl BaseHttpClient for ReqwestClient { url: &str, headers: Option<&Headers>, payload: &Value, - ) -> ClientResult { + ) -> HttpResult { self.request(Method::PUT, url, headers, |req| req.json(payload)) .await } @@ -145,7 +145,7 @@ impl BaseHttpClient for ReqwestClient { url: &str, headers: Option<&Headers>, payload: &Value, - ) -> ClientResult { + ) -> HttpResult { self.request(Method::DELETE, url, headers, |req| req.json(payload)) .await } diff --git a/src/http/ureq.rs b/rspotify-http/src/ureq.rs similarity index 81% rename from src/http/ureq.rs rename to rspotify-http/src/ureq.rs index e5da4b36..fdb79f90 100644 --- a/src/http/ureq.rs +++ b/rspotify-http/src/ureq.rs @@ -1,15 +1,14 @@ //! The client implementation for the ureq HTTP client, which is blocking. -use super::{BaseHttpClient, Form, Headers, Query}; -use crate::client::{ClientError, ClientResult}; +use super::{BaseHttpClient, Form, Headers, HttpError, HttpResult, Query}; use maybe_async::sync_impl; use serde_json::Value; use ureq::{Request, Response}; -impl ClientError { +impl HttpError { pub fn from_response(r: ureq::Response) -> Self { - ClientError::StatusCode(r.status(), r.status_text().to_string()) + HttpError::StatusCode(r.status(), r.status_text().to_string()) } } @@ -30,7 +29,7 @@ impl UreqClient { mut request: Request, headers: Option<&Headers>, send_request: D, - ) -> ClientResult + ) -> HttpResult where D: Fn(Request) -> Result, { @@ -46,9 +45,9 @@ impl UreqClient { // Successful request Ok(response) => response.into_string().map_err(Into::into), // HTTP status error - Err(ureq::Error::Status(_, response)) => Err(ClientError::from_response(response)), + Err(ureq::Error::Status(_, response)) => Err(HttpError::from_response(response)), // Some kind of IO/transport error - Err(err) => Err(ClientError::Request(err.to_string())), + Err(err) => Err(HttpError::Request(err.to_string())), } } } @@ -56,7 +55,7 @@ impl UreqClient { #[sync_impl] impl BaseHttpClient for UreqClient { #[inline] - fn get(&self, url: &str, headers: Option<&Headers>, payload: &Query) -> ClientResult { + fn get(&self, url: &str, headers: Option<&Headers>, payload: &Query) -> HttpResult { let request = ureq::get(url); let sender = |mut req: Request| { for (key, val) in payload.iter() { @@ -68,7 +67,7 @@ impl BaseHttpClient for UreqClient { } #[inline] - fn post(&self, url: &str, headers: Option<&Headers>, payload: &Value) -> ClientResult { + fn post(&self, url: &str, headers: Option<&Headers>, payload: &Value) -> HttpResult { let request = ureq::post(url); let sender = |req: Request| req.send_json(payload.clone()); self.request(request, headers, sender) @@ -80,7 +79,7 @@ impl BaseHttpClient for UreqClient { url: &str, headers: Option<&Headers>, payload: &Form<'a>, - ) -> ClientResult { + ) -> HttpResult { let request = ureq::post(url); let sender = |req: Request| { let payload = payload @@ -95,19 +94,14 @@ impl BaseHttpClient for UreqClient { } #[inline] - fn put(&self, url: &str, headers: Option<&Headers>, payload: &Value) -> ClientResult { + fn put(&self, url: &str, headers: Option<&Headers>, payload: &Value) -> HttpResult { let request = ureq::put(url); let sender = |req: Request| req.send_json(payload.clone()); self.request(request, headers, sender) } #[inline] - fn delete( - &self, - url: &str, - headers: Option<&Headers>, - payload: &Value, - ) -> ClientResult { + fn delete(&self, url: &str, headers: Option<&Headers>, payload: &Value) -> HttpResult { let request = ureq::delete(url); let sender = |req: Request| req.send_json(payload.clone()); self.request(request, headers, sender) diff --git a/rspotify-macros/Cargo.toml b/rspotify-macros/Cargo.toml new file mode 100644 index 00000000..2bd68245 --- /dev/null +++ b/rspotify-macros/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "rspotify-macros" +authors = [ + "Ramsay Leung ", + "Mario Ortiz Manero " +] +version = "0.10.0" +license = "MIT" +readme = "README.md" +description = "Macros for Rspotify" +homepage = "https://github.com/ramsayleung/rspotify" +repository = "https://github.com/ramsayleung/rspotify" +keywords = ["spotify", "api", "rspotify"] +edition = "2018" + +[dev-dependencies] +serde_json = "1.0.57" diff --git a/src/macros.rs b/rspotify-macros/src/lib.rs similarity index 76% rename from src/macros.rs rename to rspotify-macros/src/lib.rs index 2e855f0d..7bc81c5e 100644 --- a/src/macros.rs +++ b/rspotify-macros/src/lib.rs @@ -1,26 +1,17 @@ -/// Create a [`HashSet`](std::collections::HashSet) from a list of `&str` (which -/// will be converted to String internally), to easily create scopes for -/// [`Token`](crate::oauth2::Token) or -/// [`OAuthBuilder`](crate::oauth2::OAuthBuilder). +/// Create a [`HashSet`](std::collections::HashSet) from a list of `&str` to +/// easily create scopes for `Token` or `OAuth`. /// /// Example: /// /// ``` -/// use rspotify::oauth2::TokenBuilder; -/// use rspotify::scopes; +/// use rspotify_macros::scopes; /// use std::collections::HashSet; -/// use chrono::prelude::*; -/// use chrono::Duration; /// -/// let scope = scopes!("playlist-read-private", "playlist-read-collaborative"); -/// let tok = TokenBuilder::default() -/// .access_token("test-access_token") -/// .expires_in(Duration::seconds(1)) -/// .expires_at(Utc::now()) -/// .scope(scope) -/// .refresh_token("...") -/// .build() -/// .unwrap(); +/// let with_macro = scopes!("playlist-read-private", "playlist-read-collaborative"); +/// let mut manually = HashSet::new(); +/// manually.insert("playlist-read-private".to_owned()); +/// manually.insert("playlist-read-collaborative".to_owned()); +/// assert_eq!(with_macro, manually); /// ``` #[macro_export] macro_rules! scopes { @@ -84,7 +75,7 @@ macro_rules! build_map { $( $kind:ident )? $key:literal : $val:expr ),+ $(,)? ) => {{ - let mut params = $crate::http::Query::with_capacity( + let mut params = ::std::collections::HashMap::<&str, &str>::with_capacity( $crate::count_items!($( $key ),*) ); $( @@ -138,19 +129,18 @@ macro_rules! build_json { #[cfg(test)] mod test { - use crate::http::Query; - use crate::model::Market; use crate::{build_json, build_map, scopes}; use serde_json::{json, Map, Value}; + use std::collections::HashMap; #[test] fn test_hashset() { - let scope = scopes!("hello", "world", "foo", "bar"); - assert_eq!(scope.len(), 4); - assert!(scope.contains("hello")); - assert!(scope.contains("world")); - assert!(scope.contains("foo")); - assert!(scope.contains("bar")); + let scopes = scopes!("hello", "world", "foo", "bar"); + assert_eq!(scopes.len(), 4); + assert!(scopes.contains("hello")); + assert!(scopes.contains("world")); + assert!(scopes.contains("foo")); + assert!(scopes.contains("bar")); } #[test] @@ -158,23 +148,25 @@ mod test { // Passed as parameters, for example. let id = "Pink Lemonade"; let artist = Some("The Wombats"); - let market: Option<&Market> = None; + let market: Option = None; + let market_str = market.clone().map(|x| x.to_string()); let with_macro = build_map! { // Mandatory (not an `Option`) "id": id, // Can be used directly optional "artist": artist, // `Modality` needs to be converted to &str - optional "market": market.map(|x| x.as_ref()), + optional "market": market_str.as_deref(), }; - let mut manually = Query::with_capacity(3); + let mut manually = HashMap::<&str, &str>::with_capacity(3); manually.insert("id", id); + let market_str = market.map(|x| x.to_string()); if let Some(val) = artist { manually.insert("artist", val); } - if let Some(val) = market.map(|x| x.as_ref()) { + if let Some(val) = market_str.as_deref() { manually.insert("market", val); } @@ -186,12 +178,12 @@ mod test { // Passed as parameters, for example. let id = "Pink Lemonade"; let artist = Some("The Wombats"); - let market: Option<&Market> = None; + let market: Option = None; let with_macro = build_json! { "id": id, optional "artist": artist, - optional "market": market.map(|x| x.as_ref()), + optional "market": market.map(|x| x.to_string()), }; let mut manually = Map::with_capacity(3); @@ -199,7 +191,7 @@ mod test { if let Some(val) = artist.map(|x| json!(x)) { manually.insert("artist".to_string(), val); } - if let Some(val) = market.map(|x| x.as_ref()).map(|x| json!(x)) { + if let Some(val) = market.map(|x| x.to_string()).map(|x| json!(x)) { manually.insert("market".to_string(), val); } diff --git a/rspotify-model/Cargo.toml b/rspotify-model/Cargo.toml new file mode 100644 index 00000000..5f0690a6 --- /dev/null +++ b/rspotify-model/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "rspotify-model" +version = "0.10.0" +authors = [ + "Ramsay Leung ", + "Mario Ortiz Manero " +] +license = "MIT" +readme = "README.md" +description = "Model for Rspotify" +homepage = "https://github.com/ramsayleung/rspotify" +repository = "https://github.com/ramsayleung/rspotify" +keywords = ["spotify", "api", "rspotify"] +edition = "2018" + +[dependencies] +chrono = { version = "0.4.13", features = ["serde", "rustc-serialize"] } +serde = { version = "1.0.115", features = ["derive"] } +strum = { version = "0.20", features = ["derive"] } +thiserror = "1.0.20" + +[dev-dependencies] +serde_json = "1.0.57" diff --git a/src/model/album.rs b/rspotify-model/src/album.rs similarity index 95% rename from src/model/album.rs rename to rspotify-model/src/album.rs index 0eb6eb20..1e45432b 100644 --- a/src/model/album.rs +++ b/rspotify-model/src/album.rs @@ -10,7 +10,7 @@ use super::image::Image; use super::page::Page; use super::track::SimplifiedTrack; use super::Restriction; -use crate::model::{AlbumType, Copyright, DatePrecision, Type}; +use crate::{AlbumType, Copyright, DatePrecision, Type}; /// Simplified Album Object /// @@ -68,7 +68,7 @@ pub struct FullAlbum { /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-albums) #[derive(Deserialize)] -pub(in crate) struct FullAlbums { +pub struct FullAlbums { pub albums: Vec, } @@ -76,7 +76,7 @@ pub(in crate) struct FullAlbums { /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-new-releases) #[derive(Deserialize)] -pub(in crate) struct PageSimpliedAlbums { +pub struct PageSimpliedAlbums { pub albums: Page, } diff --git a/src/model/artist.rs b/rspotify-model/src/artist.rs similarity index 93% rename from src/model/artist.rs rename to rspotify-model/src/artist.rs index fb5e2ef0..2e4aa034 100644 --- a/src/model/artist.rs +++ b/rspotify-model/src/artist.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use super::image::Image; use super::page::CursorBasedPage; -use crate::model::{Followers, Type}; +use crate::{Followers, Type}; use std::collections::HashMap; /// Simplified Artist Object @@ -43,7 +43,7 @@ pub struct FullArtist { /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-artists) #[derive(Deserialize)] -pub(in crate) struct FullArtists { +pub struct FullArtists { pub artists: Vec, } @@ -51,6 +51,6 @@ pub(in crate) struct FullArtists { /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-followed) #[derive(Deserialize)] -pub(in crate) struct CursorPageFullArtists { +pub struct CursorPageFullArtists { pub artists: CursorBasedPage, } diff --git a/src/model/audio.rs b/rspotify-model/src/audio.rs similarity index 97% rename from src/model/audio.rs rename to rspotify-model/src/audio.rs index 13e5790c..850771ce 100644 --- a/src/model/audio.rs +++ b/rspotify-model/src/audio.rs @@ -1,6 +1,6 @@ //! All objects related to audio defined by Spotify API -use crate::model::{duration_ms, enums::Modality, modality}; +use crate::{duration_ms, enums::Modality, modality}; use serde::{Deserialize, Serialize}; use std::time::Duration; @@ -36,7 +36,7 @@ pub struct AudioFeatures { /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-several-audio-features) #[derive(Deserialize)] -pub(in crate) struct AudioFeaturesPayload { +pub struct AudioFeaturesPayload { pub audio_features: Vec, } diff --git a/src/model/category.rs b/rspotify-model/src/category.rs similarity index 94% rename from src/model/category.rs rename to rspotify-model/src/category.rs index ab144c41..ec80e187 100644 --- a/src/model/category.rs +++ b/rspotify-model/src/category.rs @@ -19,6 +19,6 @@ pub struct Category { /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-categories) #[derive(Deserialize)] -pub(in crate) struct PageCategory { +pub struct PageCategory { pub categories: Page, } diff --git a/src/model/context.rs b/rspotify-model/src/context.rs similarity index 99% rename from src/model/context.rs rename to rspotify-model/src/context.rs index 0c1bdbce..fa7fcb2f 100644 --- a/src/model/context.rs +++ b/rspotify-model/src/context.rs @@ -2,7 +2,7 @@ use super::device::Device; use super::PlayableItem; -use crate::model::{ +use crate::{ millisecond_timestamp, option_duration_ms, CurrentlyPlayingType, DisallowKey, RepeatState, Type, }; use chrono::{DateTime, Utc}; diff --git a/src/model/device.rs b/rspotify-model/src/device.rs similarity index 94% rename from src/model/device.rs rename to rspotify-model/src/device.rs index e53c57c8..a92b47a3 100644 --- a/src/model/device.rs +++ b/rspotify-model/src/device.rs @@ -1,4 +1,4 @@ -use crate::model::DeviceType; +use crate::DeviceType; use serde::{Deserialize, Serialize}; /// Device object @@ -20,7 +20,7 @@ pub struct Device { /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-a-users-available-devices) #[derive(Deserialize)] -pub(in crate) struct DevicePayload { +pub struct DevicePayload { pub devices: Vec, } diff --git a/src/model/enums/country.rs b/rspotify-model/src/enums/country.rs similarity index 100% rename from src/model/enums/country.rs rename to rspotify-model/src/enums/country.rs diff --git a/src/model/enums/misc.rs b/rspotify-model/src/enums/misc.rs similarity index 90% rename from src/model/enums/misc.rs rename to rspotify-model/src/enums/misc.rs index cb7e1211..9688c6f2 100644 --- a/src/model/enums/misc.rs +++ b/rspotify-model/src/enums/misc.rs @@ -7,7 +7,7 @@ use super::Country; /// `skipping_next`, `skipping_prev`, `toggling_repeat_context`, /// `toggling_shuffle`, `toggling_repeat_track`, `transferring_playback`. /// -/// [Reference](https://developer.spotify.com/documentation/web-api/reference/object-model/#disallows-object) +/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#object-disallowsobject) #[derive(Clone, Serialize, Deserialize, Copy, PartialEq, Eq, Debug, Hash, AsRefStr)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] @@ -50,7 +50,7 @@ pub enum RepeatState { /// Type for include_external: `audio`. /// -/// [Reference](https://developer.spotify.com/documentation/web-api/reference/search/search/) +/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-search) #[derive(Clone, Serialize, Deserialize, Copy, PartialEq, Eq, Debug, AsRefStr)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] @@ -72,7 +72,7 @@ pub enum DatePrecision { /// The reason for the restriction: `market`, `product`, `explicit` /// -/// [Reference](https://developer.spotify.com/documentation/web-api/reference/object-model/#track-restriction-object) +/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#object-albumrestrictionobject) #[derive(Clone, Serialize, Deserialize, Copy, PartialEq, Eq, Debug, AsRefStr)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] @@ -82,11 +82,12 @@ pub enum RestrictionReason { Explict, } -/// Indicates the modality (major or minor) of a track -/// This field will contain a 0 for `minor`, a 1 for `major` or -/// a -1 for `no result` +/// Indicates the modality (major or minor) of a track. /// -/// [Reference](https://developer.spotify.com/documentation/web-api/reference/tracks/get-audio-analysis/#section-object) +/// This field will contain a 0 for `minor`, a 1 for `major` or a -1 for `no +/// result` +/// +/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#objects-index) #[derive(Clone, Serialize, Deserialize, Copy, PartialEq, Eq, Debug, AsRefStr)] pub enum Modality { Minor = 0, diff --git a/src/model/enums/mod.rs b/rspotify-model/src/enums/mod.rs similarity index 100% rename from src/model/enums/mod.rs rename to rspotify-model/src/enums/mod.rs diff --git a/src/model/enums/types.rs b/rspotify-model/src/enums/types.rs similarity index 93% rename from src/model/enums/types.rs rename to rspotify-model/src/enums/types.rs index 2115e408..df97d39a 100644 --- a/src/model/enums/types.rs +++ b/rspotify-model/src/enums/types.rs @@ -4,7 +4,7 @@ use strum::{AsRefStr, Display, EnumString}; /// Copyright type: `C` = the copyright, `P` = the sound recording (performance) /// copyright. /// -/// [Reference](https://developer.spotify.com/documentation/web-api/reference/object-model/#copyright-object) +/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#object-copyrightobject) #[derive(Clone, Serialize, Deserialize, Copy, PartialEq, Eq, Debug, AsRefStr)] pub enum CopyrightType { #[strum(serialize = "P")] @@ -17,7 +17,7 @@ pub enum CopyrightType { /// Album type: `album`, `single`, `appears_on`, `compilation` /// -/// [Reference](https://developer.spotify.com/documentation/web-api/reference/object-model/#album-object-full) +/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#objects-index) #[derive(Clone, Serialize, Deserialize, Copy, PartialEq, Eq, Debug, AsRefStr)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] @@ -89,7 +89,7 @@ pub enum SearchType { /// /// (The subscription level "open" can be considered the same as "free".) /// -/// [Reference](https://developer.spotify.com/documentation/web-api/reference/users-profile/get-current-users-profile/) +/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-current-users-profile) #[derive(Clone, Serialize, Deserialize, Copy, PartialEq, Eq, Debug, AsRefStr)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] @@ -101,7 +101,7 @@ pub enum SubscriptionLevel { /// Device Type: `computer`, `smartphone`, `speaker`, `TV` /// -/// [Reference](https://developer.spotify.com/documentation/web-api/reference/player/get-a-users-available-devices/#device-types) +/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#object-deviceobject) #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, AsRefStr)] #[strum(serialize_all = "snake_case")] pub enum DeviceType { @@ -122,7 +122,7 @@ pub enum DeviceType { /// Recommendations seed type /// -/// [Reference](https://developer.spotify.com/documentation/web-api/reference/object-model/#recommendations-seed-object) +/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#object-recommendationseedobject) #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, AsRefStr)] #[serde(rename_all = "snake_case")] pub enum RecommendationsSeedType { diff --git a/rspotify-model/src/error.rs b/rspotify-model/src/error.rs new file mode 100644 index 00000000..d64175bc --- /dev/null +++ b/rspotify-model/src/error.rs @@ -0,0 +1,20 @@ +use serde::Deserialize; + +/// Matches errors that are returned from the Spotfiy +/// API as part of the JSON response object. +#[derive(Debug, thiserror::Error, Deserialize)] +pub enum ApiError { + /// See [Error Object](https://developer.spotify.com/documentation/web-api/reference/#object-errorobject) + #[error("{status}: {message}")] + #[serde(alias = "error")] + Regular { status: u16, message: String }, + + /// See [Play Error Object](https://developer.spotify.com/documentation/web-api/reference/#object-playererrorobject) + #[error("{status} ({reason}): {message}")] + #[serde(alias = "error")] + Player { + status: u16, + message: String, + reason: String, + }, +} diff --git a/src/model/idtypes.rs b/rspotify-model/src/idtypes.rs similarity index 93% rename from src/model/idtypes.rs rename to rspotify-model/src/idtypes.rs index 527ae45e..a2f0622c 100644 --- a/src/model/idtypes.rs +++ b/rspotify-model/src/idtypes.rs @@ -1,4 +1,4 @@ -use crate::model::Type; +use crate::Type; use serde::{Deserialize, Serialize}; use std::borrow::Borrow; use std::marker::PhantomData; @@ -61,10 +61,10 @@ pub type UserIdBuf = IdBuf; pub type ShowIdBuf = IdBuf; pub type EpisodeIdBuf = IdBuf; -/// A Spotify object id of given [type](crate::model::enums::types::Type) +/// A Spotify object id of given [type](crate::enums::types::Type). /// -/// This is a not-owning type, it stores a `&str` only. -/// See [IdBuf](crate::model::idtypes::IdBuf) for owned version of the type. +/// This is a not-owning type, it stores a `&str` only. See +/// [IdBuf](crate::idtypes::IdBuf) for owned version of the type. #[derive(Debug, PartialEq, Eq, Serialize)] pub struct Id { #[serde(default)] @@ -73,10 +73,10 @@ pub struct Id { id: str, } -/// A Spotify object id of given [type](crate::model::enums::types::Type) +/// A Spotify object id of given [type](crate::enums::types::Type) /// -/// This is an owning type, it stores a `String`. -/// See [Id](crate::model::idtypes::Id) for light-weight non-owning type. +/// This is an owning type, it stores a `String`. See [Id](crate::idtypes::Id) +/// for light-weight non-owning type. /// /// Use `Id::from_id(val).to_owned()`, `Id::from_uri(val).to_owned()` or /// `Id::from_id_or_uri(val).to_owned()` to construct an instance of this type. @@ -111,7 +111,7 @@ impl Deref for IdBuf { } impl IdBuf { - /// Get a [`Type`](crate::model::enums::types::Type) of the id + /// Get a [`Type`](crate::enums::types::Type) of the id pub fn _type(&self) -> Type { T::TYPE } @@ -134,7 +134,7 @@ impl IdBuf { /// Spotify id or URI parsing error /// -/// See also [`Id`](crate::model::idtypes::Id) for details. +/// See also [`Id`](crate::idtypes::Id) for details. #[derive(Debug, PartialEq, Eq, Clone, Copy, Display, Error)] pub enum IdError { /// Spotify URI prefix is not `spotify:` or `spotify/` @@ -176,7 +176,7 @@ impl std::str::FromStr for IdBuf { } impl Id { - /// Owned version of the id [`IdBuf`](crate::model::idtypes::IdBuf) + /// Owned version of the id [`IdBuf`](crate::idtypes::IdBuf). pub fn to_owned(&self) -> IdBuf { IdBuf { _type: PhantomData, diff --git a/src/model/image.rs b/rspotify-model/src/image.rs similarity index 100% rename from src/model/image.rs rename to rspotify-model/src/image.rs diff --git a/src/model/mod.rs b/rspotify-model/src/lib.rs similarity index 98% rename from src/model/mod.rs rename to rspotify-model/src/lib.rs index a131fa46..c0f0838a 100644 --- a/src/model/mod.rs +++ b/rspotify-model/src/lib.rs @@ -9,6 +9,7 @@ pub mod category; pub mod context; pub mod device; pub mod enums; +pub mod error; pub mod idtypes; pub mod image; pub mod offset; @@ -238,15 +239,14 @@ pub use idtypes::{ UserIdBuf, }; pub use { - album::*, artist::*, audio::*, category::*, context::*, device::*, enums::*, image::*, - offset::*, page::*, playing::*, playlist::*, recommend::*, search::*, show::*, track::*, - user::*, + album::*, artist::*, audio::*, category::*, context::*, device::*, enums::*, error::*, + image::*, offset::*, page::*, playing::*, playlist::*, recommend::*, search::*, show::*, + track::*, user::*, }; #[cfg(test)] mod tests { use super::*; - use crate::model::{Id, IdError}; #[test] fn test_get_id() { diff --git a/src/model/offset.rs b/rspotify-model/src/offset.rs similarity index 91% rename from src/model/offset.rs rename to rspotify-model/src/offset.rs index 6e1ad8c1..3d21f30c 100644 --- a/src/model/offset.rs +++ b/rspotify-model/src/offset.rs @@ -1,6 +1,6 @@ //! Offset object -use crate::model::{Id, IdBuf, PlayableIdType}; +use crate::{Id, IdBuf, PlayableIdType}; /// Offset object /// diff --git a/src/model/page.rs b/rspotify-model/src/page.rs similarity index 100% rename from src/model/page.rs rename to rspotify-model/src/page.rs diff --git a/src/model/playing.rs b/rspotify-model/src/playing.rs similarity index 100% rename from src/model/playing.rs rename to rspotify-model/src/playing.rs diff --git a/src/model/playlist.rs b/rspotify-model/src/playlist.rs similarity index 96% rename from src/model/playlist.rs rename to rspotify-model/src/playlist.rs index 01954340..0de6acf4 100644 --- a/src/model/playlist.rs +++ b/rspotify-model/src/playlist.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use super::image::Image; use super::page::Page; use super::user::PublicUser; -use crate::model::{Followers, PlayableItem, Type}; +use crate::{Followers, PlayableItem, Type}; /// Playlist result object /// @@ -90,6 +90,6 @@ pub struct FeaturedPlaylists { /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-a-categories-playlists) #[derive(Deserialize)] -pub(in crate) struct CategoryPlaylists { +pub struct CategoryPlaylists { pub playlists: Page, } diff --git a/src/model/recommend.rs b/rspotify-model/src/recommend.rs similarity index 95% rename from src/model/recommend.rs rename to rspotify-model/src/recommend.rs index e848c090..45cb306f 100644 --- a/src/model/recommend.rs +++ b/rspotify-model/src/recommend.rs @@ -1,7 +1,7 @@ //! All objects related to recommendation use super::track::SimplifiedTrack; -use crate::model::RecommendationsSeedType; +use crate::RecommendationsSeedType; use serde::{Deserialize, Serialize}; /// Recommendations object diff --git a/src/model/search.rs b/rspotify-model/src/search.rs similarity index 97% rename from src/model/search.rs rename to rspotify-model/src/search.rs index d75d45cb..497a8d65 100644 --- a/src/model/search.rs +++ b/rspotify-model/src/search.rs @@ -54,9 +54,9 @@ pub struct SearchEpisodes { pub episodes: Page, } -/// Search result +/// Search result of any kind /// -/// [Reference](https://developer.spotify.com/documentation/web-api/reference/search/search/) +/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#category-search) #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum SearchResult { #[serde(rename = "playlists")] diff --git a/src/model/show.rs b/rspotify-model/src/show.rs similarity index 97% rename from src/model/show.rs rename to rspotify-model/src/show.rs index 24f1e967..1889c3ce 100644 --- a/src/model/show.rs +++ b/rspotify-model/src/show.rs @@ -1,6 +1,6 @@ use super::image::Image; use super::page::Page; -use crate::model::{duration_ms, CopyrightType, DatePrecision}; +use crate::{duration_ms, CopyrightType, DatePrecision}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::Duration; @@ -42,7 +42,7 @@ pub struct SimplifiedShow { /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-shows) #[derive(Deserialize)] -pub(in crate) struct SeversalSimplifiedShows { +pub struct SeversalSimplifiedShows { pub shows: Vec, } @@ -143,7 +143,7 @@ pub struct FullEpisode { /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-episodes) #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub(in crate) struct EpisodesPayload { +pub struct EpisodesPayload { pub episodes: Vec, } diff --git a/src/model/track.rs b/rspotify-model/src/track.rs similarity index 97% rename from src/model/track.rs rename to rspotify-model/src/track.rs index e391ff2b..ee87bef8 100644 --- a/src/model/track.rs +++ b/rspotify-model/src/track.rs @@ -8,7 +8,7 @@ use std::{collections::HashMap, time::Duration}; use super::album::SimplifiedAlbum; use super::artist::SimplifiedArtist; use super::Restriction; -use crate::model::{duration_ms, TrackId, Type}; +use crate::{duration_ms, TrackId, Type}; /// Full track object /// @@ -60,7 +60,7 @@ pub struct TrackLink { /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-several-tracks) #[derive(Deserialize)] -pub(in crate) struct FullTracks { +pub struct FullTracks { pub tracks: Vec, } diff --git a/src/model/user.rs b/rspotify-model/src/user.rs similarity index 96% rename from src/model/user.rs rename to rspotify-model/src/user.rs index 1e364ef4..2e066bcb 100644 --- a/src/model/user.rs +++ b/rspotify-model/src/user.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use super::image::Image; -use crate::model::{Country, Followers, SubscriptionLevel, Type}; +use crate::{Country, Followers, SubscriptionLevel, Type}; /// Public user object /// diff --git a/src/auth_code.rs b/src/auth_code.rs new file mode 100644 index 00000000..119c43b1 --- /dev/null +++ b/src/auth_code.rs @@ -0,0 +1,199 @@ +use crate::{ + auth_urls, + clients::{BaseClient, OAuthClient}, + headers, + http::{Form, HttpClient}, + ClientResult, Config, Credentials, OAuth, Token, +}; + +use std::collections::HashMap; + +use maybe_async::maybe_async; +use url::Url; + +/// The [Authorization Code Flow](reference) client for the Spotify API. +/// +/// This includes user authorization, and thus has access to endpoints related +/// to user private data, unlike the [Client Credentials +/// Flow](crate::ClientCredsSpotify) client. See [`BaseClient`] and +/// [`OAuthClient`] for the available endpoints. +/// +/// If you're developing a CLI application, you might be interested in the `cli` +/// feature. This brings the [`Self::prompt_for_token`] utility to automatically +/// follow the flow steps via user interaction. +/// +/// Otherwise, these are the steps to be followed to authenticate your app: +/// +/// 0. Generate a request URL with [`Self::get_authorize_url`]. +/// 1. The user logs in with the request URL. They will be redirected to the +/// given redirect URI, including a code in the URL parameters. This happens +/// on your side. +/// 2. The code obtained in the previous step is parsed with +/// [`Self::parse_response_code`]. +/// 3. The code is sent to Spotify in order to obtain an access token with +/// [`Self::request_token`]. +/// 4. Finally, this access token can be used internally for the requests. +/// It may expire relatively soon, so it can be refreshed with the refresh +/// token (obtained in the previous step as well) using +/// [`Self::refresh_token`]. Otherwise, a new access token may be generated +/// from scratch by repeating these steps, but the advantage of refreshing it +/// is that this doesn't require the user to log in, and that it's a simpler +/// procedure. +/// +/// See [this related example][example-refresh-token] to learn more about +/// refreshing tokens. +/// +/// There's a [webapp example][example-webapp] for more details on how you can +/// implement it for something like a web server, or [this one][example-main] +/// for a CLI use case. +/// +/// An example of the CLI authentication: +/// +/// ![demo](https://raw.githubusercontent.com/ramsayleung/rspotify/master/doc/images/rspotify.gif) +/// +/// Note: even if your script does not have an accessible URL, you will have to +/// specify a redirect URI. It doesn't need to work, you can use +/// `http://localhost:8888/callback` for example, which will also have the code +/// appended like so: `http://localhost/?code=...`. +/// +/// [reference]: https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow +/// [example-main]: https://github.com/ramsayleung/rspotify/blob/master/examples/auth_code.rs +/// [example-webapp]: https://github.com/ramsayleung/rspotify/tree/master/examples/webapp +/// [example-refresh-token]: https://github.com/ramsayleung/rspotify/blob/master/examples/with_refresh_token.rs +#[derive(Clone, Debug, Default)] +pub struct AuthCodeSpotify { + pub creds: Credentials, + pub oauth: OAuth, + pub config: Config, + pub token: Option, + pub(in crate) http: HttpClient, +} + +/// This client has access to the base methods. +#[maybe_async(?Send)] +impl BaseClient for AuthCodeSpotify { + fn get_http(&self) -> &HttpClient { + &self.http + } + + fn get_token(&self) -> Option<&Token> { + self.token.as_ref() + } + + fn get_token_mut(&mut self) -> Option<&mut Token> { + self.token.as_mut() + } + + fn get_creds(&self) -> &Credentials { + &self.creds + } + + fn get_config(&self) -> &Config { + &self.config + } +} + +/// This client includes user authorization, so it has access to the user +/// private endpoints in [`OAuthClient`]. +#[maybe_async(?Send)] +impl OAuthClient for AuthCodeSpotify { + fn get_oauth(&self) -> &OAuth { + &self.oauth + } + + /// Obtains a user access token given a code, as part of the OAuth + /// authentication. The access token will be saved internally. + async fn request_token(&mut self, code: &str) -> ClientResult<()> { + let mut data = Form::new(); + let oauth = self.get_oauth(); + let scopes = oauth + .scopes + .clone() + .into_iter() + .collect::>() + .join(" "); + data.insert(headers::GRANT_TYPE, headers::GRANT_AUTH_CODE); + data.insert(headers::REDIRECT_URI, oauth.redirect_uri.as_ref()); + data.insert(headers::CODE, code); + data.insert(headers::SCOPE, scopes.as_ref()); + data.insert(headers::STATE, oauth.state.as_ref()); + + let token = self.fetch_access_token(&data).await?; + self.token = Some(token); + + self.write_token_cache() + } + + /// Refreshes the current access token given a refresh token. + /// + /// The obtained token will be saved internally. + async fn refresh_token(&mut self, refresh_token: &str) -> ClientResult<()> { + let mut data = Form::new(); + data.insert(headers::REFRESH_TOKEN, refresh_token); + data.insert(headers::GRANT_TYPE, headers::GRANT_REFRESH_TOKEN); + + let mut token = self.fetch_access_token(&data).await?; + token.refresh_token = Some(refresh_token.to_string()); + self.token = Some(token); + + self.write_token_cache() + } +} + +impl AuthCodeSpotify { + /// Builds a new [`AuthCodeSpotify`] given a pair of client credentials and + /// OAuth information. + pub fn new(creds: Credentials, oauth: OAuth) -> Self { + AuthCodeSpotify { + creds, + oauth, + ..Default::default() + } + } + + /// Build a new [`AuthCodeSpotify`] from an already generated token. Note + /// that once the token expires this will fail to make requests, as the + /// client credentials aren't known. + pub fn from_token(token: Token) -> Self { + AuthCodeSpotify { + token: Some(token), + ..Default::default() + } + } + + /// Same as [`Self::new`] but with an extra parameter to configure the + /// client. + pub fn with_config(creds: Credentials, oauth: OAuth, config: Config) -> Self { + AuthCodeSpotify { + creds, + oauth, + config, + ..Default::default() + } + } + + /// Returns the URL needed to authorize the current client as the first step + /// in the authorization flow. + pub fn get_authorize_url(&self, show_dialog: bool) -> ClientResult { + let mut payload: HashMap<&str, &str> = HashMap::new(); + let oauth = self.get_oauth(); + let scopes = oauth + .scopes + .clone() + .into_iter() + .collect::>() + .join(" "); + payload.insert(headers::CLIENT_ID, &self.get_creds().id); + payload.insert(headers::RESPONSE_TYPE, headers::RESPONSE_CODE); + payload.insert(headers::REDIRECT_URI, &oauth.redirect_uri); + payload.insert(headers::SCOPE, &scopes); + payload.insert(headers::STATE, &oauth.state); + + if show_dialog { + payload.insert(headers::SHOW_DIALOG, "true"); + } + + let parsed = Url::parse_with_params(auth_urls::AUTHORIZE, payload)?; + Ok(parsed.into()) + } +} diff --git a/src/auth_code_pkce.rs b/src/auth_code_pkce.rs new file mode 100644 index 00000000..fca31d87 --- /dev/null +++ b/src/auth_code_pkce.rs @@ -0,0 +1,158 @@ +use crate::{ + auth_urls, + clients::{BaseClient, OAuthClient}, + headers, + http::{Form, HttpClient}, + ClientResult, Config, Credentials, OAuth, Token, +}; + +use std::collections::HashMap; + +use maybe_async::maybe_async; +use url::Url; + +/// The [Authorization Code Flow with Proof Key for Code Exchange +/// (PKCE)][reference] client for the Spotify API. +/// +/// This flow is very similar to the regular Authorization Code Flow, so please +/// read [`AuthCodeSpotify`](crate::AuthCodeSpotify) for more information about +/// it. The main difference in this case is that you can avoid storing your +/// client secret by generating a *code verifier* and a *code challenge*. +/// +/// There's an [example][example-main] available to learn how to use this +/// client. +/// +/// [reference]: https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce +/// [example-main]: https://github.com/ramsayleung/rspotify/blob/master/examples/auth_code_pkce.rs +#[derive(Clone, Debug, Default)] +pub struct AuthCodePkceSpotify { + pub creds: Credentials, + pub oauth: OAuth, + pub config: Config, + pub token: Option, + pub(in crate) http: HttpClient, +} + +/// This client has access to the base methods. +impl BaseClient for AuthCodePkceSpotify { + fn get_http(&self) -> &HttpClient { + &self.http + } + + fn get_token(&self) -> Option<&Token> { + self.token.as_ref() + } + + fn get_token_mut(&mut self) -> Option<&mut Token> { + self.token.as_mut() + } + + fn get_creds(&self) -> &Credentials { + &self.creds + } + + fn get_config(&self) -> &Config { + &self.config + } +} + +/// This client includes user authorization, so it has access to the user +/// private endpoints in [`OAuthClient`]. +#[maybe_async(?Send)] +impl OAuthClient for AuthCodePkceSpotify { + fn get_oauth(&self) -> &OAuth { + &self.oauth + } + + async fn request_token(&mut self, code: &str) -> ClientResult<()> { + // TODO + let mut data = Form::new(); + let oauth = self.get_oauth(); + let scopes = oauth + .scopes + .clone() + .into_iter() + .collect::>() + .join(" "); + data.insert(headers::GRANT_TYPE, headers::GRANT_AUTH_CODE); + data.insert(headers::REDIRECT_URI, oauth.redirect_uri.as_ref()); + data.insert(headers::CODE, code); + data.insert(headers::SCOPE, scopes.as_ref()); + data.insert(headers::STATE, oauth.state.as_ref()); + + let token = self.fetch_access_token(&data).await?; + self.token = Some(token); + + self.write_token_cache() + } + + async fn refresh_token(&mut self, refresh_token: &str) -> ClientResult<()> { + // TODO + let mut data = Form::new(); + data.insert(headers::REFRESH_TOKEN, refresh_token); + data.insert(headers::GRANT_TYPE, headers::GRANT_REFRESH_TOKEN); + + let mut token = self.fetch_access_token(&data).await?; + token.refresh_token = Some(refresh_token.to_string()); + self.token = Some(token); + + self.write_token_cache() + } +} + +impl AuthCodePkceSpotify { + /// Builds a new [`AuthCodePkceSpotify`] given a pair of client credentials + /// and OAuth information. + pub fn new(creds: Credentials, oauth: OAuth) -> Self { + AuthCodePkceSpotify { + creds, + oauth, + ..Default::default() + } + } + + /// Build a new [`AuthCodePkceSpotify`] from an already generated token. + /// Note that once the token expires this will fail to make requests, as the + /// client credentials aren't known. + pub fn from_token(token: Token) -> Self { + AuthCodePkceSpotify { + token: Some(token), + ..Default::default() + } + } + + /// Same as [`Self::new`] but with an extra parameter to configure the + /// client. + pub fn with_config(creds: Credentials, oauth: OAuth, config: Config) -> Self { + AuthCodePkceSpotify { + creds, + oauth, + config, + ..Default::default() + } + } + + /// Returns the URL needed to authorize the current client as the first step + /// in the authorization flow. + pub fn get_authorize_url(&self) -> ClientResult { + // TODO + let mut payload: HashMap<&str, &str> = HashMap::new(); + let oauth = self.get_oauth(); + let scopes = oauth + .scopes + .clone() + .into_iter() + .collect::>() + .join(" "); + payload.insert(headers::CLIENT_ID, &self.get_creds().id); + payload.insert(headers::RESPONSE_TYPE, headers::RESPONSE_CODE); + payload.insert(headers::REDIRECT_URI, &oauth.redirect_uri); + payload.insert(headers::SCOPE, &scopes); + payload.insert(headers::STATE, &oauth.state); + // payload.insert(headers::CODE_CHALLENGE, todo!()); + // payload.insert(headers::CODE_CHALLENGE_METHOD, "S256"); + + let parsed = Url::parse_with_params(auth_urls::AUTHORIZE, payload)?; + Ok(parsed.into()) + } +} diff --git a/src/client.rs b/src/client.rs deleted file mode 100644 index 3adee6bc..00000000 --- a/src/client.rs +++ /dev/null @@ -1,2267 +0,0 @@ -//! Client to Spotify API endpoint - -use chrono::prelude::*; -use derive_builder::Builder; -use log::error; -use maybe_async::maybe_async; -use serde::Deserialize; -use serde_json::map::Map; -use serde_json::{json, Value}; -use thiserror::Error; - -use std::path::PathBuf; - -use super::http::{HTTPClient, Query}; -use super::model::*; -use super::oauth2::{Credentials, OAuth, Token}; -use super::pagination::{paginate, Paginator}; -use super::{build_json, build_map}; -use crate::model::idtypes::{IdType, PlayContextIdType}; -use std::collections::HashMap; - -/// Possible errors returned from the `rspotify` client. -#[derive(Debug, Error)] -pub enum ClientError { - /// Raised when the authentication isn't configured properly. - #[error("invalid client authentication: {0}")] - InvalidAuth(String), - - #[error("request unauthorized")] - Unauthorized, - - #[error("exceeded request limit")] - RateLimited(Option), - - #[error("request error: {0}")] - Request(String), - - #[error("status code {0}: {1}")] - StatusCode(u16, String), - - #[error("spotify error: {0}")] - Api(#[from] ApiError), - - #[error("json parse error: {0}")] - ParseJson(#[from] serde_json::Error), - - #[error("url parse error: {0}")] - ParseUrl(#[from] url::ParseError), - - #[error("input/output error: {0}")] - Io(#[from] std::io::Error), - - #[cfg(feature = "cli")] - #[error("cli error: {0}")] - Cli(String), - - #[error("cache file error: {0}")] - CacheFile(String), -} - -pub type ClientResult = Result; - -/// Matches errors that are returned from the Spotfiy -/// API as part of the JSON response object. -#[derive(Debug, Error, Deserialize)] -pub enum ApiError { - /// See [Error Object](https://developer.spotify.com/documentation/web-api/reference/#object-errorobject) - #[error("{status}: {message}")] - #[serde(alias = "error")] - Regular { status: u16, message: String }, - - /// See [Play Error Object](https://developer.spotify.com/documentation/web-api/reference/#object-playererrorobject) - #[error("{status} ({reason}): {message}")] - #[serde(alias = "error")] - Player { - status: u16, - message: String, - reason: String, - }, -} - -pub const DEFAULT_API_PREFIX: &str = "https://api.spotify.com/v1/"; -pub const DEFAULT_CACHE_PATH: &str = ".spotify_token_cache.json"; -pub const DEFAULT_PAGINATION_CHUNKS: u32 = 50; - -/// Spotify API object -#[derive(Builder, Debug, Clone)] -pub struct Spotify { - /// Internal member to perform requests to the Spotify API. - #[builder(setter(skip))] - pub(in crate) http: HTTPClient, - - /// The access token information required for requests to the Spotify API. - #[builder(setter(strip_option), default)] - pub token: Option, - - /// The credentials needed for obtaining a new access token, for requests. - /// without OAuth authentication. - #[builder(setter(strip_option), default)] - pub credentials: Option, - - /// The OAuth information required for obtaining a new access token, for - /// requests with OAuth authentication. `credentials` also needs to be - /// set up. - #[builder(setter(strip_option), default)] - pub oauth: Option, - - /// The Spotify API prefix, [`DEFAULT_API_PREFIX`] by default. - #[builder(setter(into), default = "String::from(DEFAULT_API_PREFIX)")] - pub prefix: String, - - /// The cache file path, in case it's used. By default it's - /// [`DEFAULT_CACHE_PATH`] - #[builder(default = "PathBuf::from(DEFAULT_CACHE_PATH)")] - pub cache_path: PathBuf, - - /// The pagination chunk size used when performing automatically paginated - /// requests, like [`Spotify::artist_albums`]. This means that a request - /// will be performed every `pagination_chunks` items. By default this is - /// [`DEFAULT_PAGINATION_CHUNKS`]. - /// - /// Note that most endpoints set a maximum to the number of items per - /// request, which most times is 50. - #[builder(default = "DEFAULT_PAGINATION_CHUNKS")] - pub pagination_chunks: u32, -} - -// Endpoint-related methods for the client. -impl Spotify { - /// Returns the access token, or an error in case it's not configured. - pub(in crate) fn get_token(&self) -> ClientResult<&Token> { - self.token - .as_ref() - .ok_or_else(|| ClientError::InvalidAuth("no access token configured".to_string())) - } - - /// Returns the credentials, or an error in case it's not configured. - pub(in crate) fn get_creds(&self) -> ClientResult<&Credentials> { - self.credentials - .as_ref() - .ok_or_else(|| ClientError::InvalidAuth("no credentials configured".to_string())) - } - - /// Returns the oauth information, or an error in case it's not configured. - pub(in crate) fn get_oauth(&self) -> ClientResult<&OAuth> { - self.oauth - .as_ref() - .ok_or_else(|| ClientError::InvalidAuth("no oauth configured".to_string())) - } - - /// Converts a JSON response from Spotify into its model. - fn convert_result<'a, T: Deserialize<'a>>(&self, input: &'a str) -> ClientResult { - serde_json::from_str::(input).map_err(Into::into) - } - - /// Append device ID to an API path. - fn append_device_id(&self, path: &str, device_id: Option<&str>) -> String { - let mut new_path = path.to_string(); - if let Some(_device_id) = device_id { - if path.contains('?') { - new_path.push_str(&format!("&device_id={}", _device_id)); - } else { - new_path.push_str(&format!("?device_id={}", _device_id)); - } - } - new_path - } - - /// Returns a single track given the track's ID, URI or URL. - /// - /// Parameters: - /// - track_id - a spotify URI, URL or ID - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-track) - #[maybe_async] - pub async fn track(&self, track_id: &TrackId) -> ClientResult { - let url = format!("tracks/{}", track_id.id()); - let result = self.endpoint_get(&url, &Query::new()).await?; - self.convert_result(&result) - } - - /// Returns a list of tracks given a list of track IDs, URIs, or URLs. - /// - /// Parameters: - /// - track_ids - a list of spotify URIs, URLs or IDs - /// - market - an ISO 3166-1 alpha-2 country code or the string from_token. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-several-tracks) - #[maybe_async] - pub async fn tracks<'a>( - &self, - track_ids: impl IntoIterator, - market: Option<&Market>, - ) -> ClientResult> { - let ids = join_ids(track_ids); - let params = build_map! { - optional "market": market.map(|x| x.as_ref()), - }; - - let url = format!("tracks/?ids={}", ids); - let result = self.endpoint_get(&url, ¶ms).await?; - self.convert_result::(&result).map(|x| x.tracks) - } - - /// Returns a single artist given the artist's ID, URI or URL. - /// - /// Parameters: - /// - artist_id - an artist ID, URI or URL - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-an-artist) - #[maybe_async] - pub async fn artist(&self, artist_id: &ArtistId) -> ClientResult { - let url = format!("artists/{}", artist_id.id()); - let result = self.endpoint_get(&url, &Query::new()).await?; - self.convert_result(&result) - } - - /// Returns a list of artists given the artist IDs, URIs, or URLs. - /// - /// Parameters: - /// - artist_ids - a list of artist IDs, URIs or URLs - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-artists) - #[maybe_async] - pub async fn artists<'a>( - &self, - artist_ids: impl IntoIterator, - ) -> ClientResult> { - let ids = join_ids(artist_ids); - let url = format!("artists/?ids={}", ids); - let result = self.endpoint_get(&url, &Query::new()).await?; - - self.convert_result::(&result) - .map(|x| x.artists) - } - - /// Get Spotify catalog information about an artist's albums. - /// - /// Parameters: - /// - artist_id - the artist ID, URI or URL - /// - album_type - 'album', 'single', 'appears_on', 'compilation' - /// - market - limit the response to one particular country. - /// - limit - the number of albums to return - /// - offset - the index of the first album to return - /// - /// See [`Spotify::artist_albums_manual`] for a manually paginated version - /// of this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-an-artists-albums) - pub fn artist_albums<'a>( - &'a self, - artist_id: &'a ArtistId, - album_type: Option<&'a AlbumType>, - market: Option<&'a Market>, - ) -> impl Paginator> + 'a { - paginate( - move |limit, offset| { - self.artist_albums_manual(artist_id, album_type, market, Some(limit), Some(offset)) - }, - self.pagination_chunks, - ) - } - - /// The manually paginated version of [`Spotify::artist_albums`]. - #[maybe_async] - pub async fn artist_albums_manual( - &self, - artist_id: &ArtistId, - album_type: Option<&AlbumType>, - market: Option<&Market>, - limit: Option, - offset: Option, - ) -> ClientResult> { - let limit = limit.map(|x| x.to_string()); - let offset = offset.map(|x| x.to_string()); - let params = build_map! { - optional "album_type": album_type.map(|x| x.as_ref()), - optional "market": market.map(|x| x.as_ref()), - optional "limit": limit.as_deref(), - optional "offset": offset.as_deref(), - }; - - let url = format!("artists/{}/albums", artist_id.id()); - let result = self.endpoint_get(&url, ¶ms).await?; - self.convert_result(&result) - } - - /// Get Spotify catalog information about an artist's top 10 tracks by - /// country. - /// - /// Parameters: - /// - artist_id - the artist ID, URI or URL - /// - market - limit the response to one particular country. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-an-artists-top-tracks) - #[maybe_async] - pub async fn artist_top_tracks( - &self, - artist_id: &ArtistId, - market: &Market, - ) -> ClientResult> { - let params = build_map! { - "market": market.as_ref() - }; - - let url = format!("artists/{}/top-tracks", artist_id.id()); - let result = self.endpoint_get(&url, ¶ms).await?; - self.convert_result::(&result).map(|x| x.tracks) - } - - /// Get Spotify catalog information about artists similar to an identified - /// artist. Similarity is based on analysis of the Spotify community's - /// listening history. - /// - /// Parameters: - /// - artist_id - the artist ID, URI or URL - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-an-artists-related-artists) - #[maybe_async] - pub async fn artist_related_artists( - &self, - artist_id: &ArtistId, - ) -> ClientResult> { - let url = format!("artists/{}/related-artists", artist_id.id()); - let result = self.endpoint_get(&url, &Query::new()).await?; - self.convert_result::(&result) - .map(|x| x.artists) - } - - /// Returns a single album given the album's ID, URIs or URL. - /// - /// Parameters: - /// - album_id - the album ID, URI or URL - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-an-album) - #[maybe_async] - pub async fn album(&self, album_id: &AlbumId) -> ClientResult { - let url = format!("albums/{}", album_id.id()); - - let result = self.endpoint_get(&url, &Query::new()).await?; - self.convert_result(&result) - } - - /// Returns a list of albums given the album IDs, URIs, or URLs. - /// - /// Parameters: - /// - albums_ids - a list of album IDs, URIs or URLs - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-albums) - #[maybe_async] - pub async fn albums<'a>( - &self, - album_ids: impl IntoIterator, - ) -> ClientResult> { - let ids = join_ids(album_ids); - let url = format!("albums/?ids={}", ids); - let result = self.endpoint_get(&url, &Query::new()).await?; - self.convert_result::(&result).map(|x| x.albums) - } - - /// Search for an Item. Get Spotify catalog information about artists, - /// albums, tracks or playlists that match a keyword string. - /// - /// Parameters: - /// - q - the search query - /// - limit - the number of items to return - /// - offset - the index of the first item to return - /// - type - the type of item to return. One of 'artist', 'album', 'track', - /// 'playlist', 'show' or 'episode' - /// - market - An ISO 3166-1 alpha-2 country code or the string from_token. - /// - include_external: Optional.Possible values: audio. If - /// include_external=audio is specified the response will include any - /// relevant audio content that is hosted externally. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#category-search) - #[maybe_async] - pub async fn search( - &self, - q: &str, - _type: &SearchType, - market: Option<&Market>, - include_external: Option<&IncludeExternal>, - limit: Option, - offset: Option, - ) -> ClientResult { - let limit = limit.map(|s| s.to_string()); - let offset = offset.map(|s| s.to_string()); - let params = build_map! { - "q": q, - "type": _type.as_ref(), - optional "market": market.map(|x| x.as_ref()), - optional "include_external": include_external.map(|x| x.as_ref()), - optional "limit": limit.as_deref(), - optional "offset": offset.as_deref(), - }; - - let result = self.endpoint_get("search", ¶ms).await?; - self.convert_result(&result) - } - - /// Get Spotify catalog information about an album's tracks. - /// - /// Parameters: - /// - album_id - the album ID, URI or URL - /// - limit - the number of items to return - /// - offset - the index of the first item to return - /// - /// See [`Spotify::album_track_manual`] for a manually paginated version of - /// this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-an-albums-tracks) - pub fn album_track<'a>( - &'a self, - album_id: &'a AlbumId, - ) -> impl Paginator> + 'a { - paginate( - move |limit, offset| self.album_track_manual(album_id, Some(limit), Some(offset)), - self.pagination_chunks, - ) - } - - /// The manually paginated version of [`Spotify::album_track`]. - #[maybe_async] - pub async fn album_track_manual( - &self, - album_id: &AlbumId, - limit: Option, - offset: Option, - ) -> ClientResult> { - let limit = limit.map(|s| s.to_string()); - let offset = offset.map(|s| s.to_string()); - let params = build_map! { - optional "limit": limit.as_deref(), - optional "offset": offset.as_deref(), - }; - - let url = format!("albums/{}/tracks", album_id.id()); - let result = self.endpoint_get(&url, ¶ms).await?; - self.convert_result(&result) - } - - /// Gets basic profile information about a Spotify User. - /// - /// Parameters: - /// - user - the id of the usr - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-users-profile) - #[maybe_async] - pub async fn user(&self, user_id: &UserId) -> ClientResult { - let url = format!("users/{}", user_id.id()); - let result = self.endpoint_get(&url, &Query::new()).await?; - self.convert_result(&result) - } - - /// Get full details about Spotify playlist. - /// - /// Parameters: - /// - playlist_id - the id of the playlist - /// - market - an ISO 3166-1 alpha-2 country code or the string from_token. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-playlist) - #[maybe_async] - pub async fn playlist( - &self, - playlist_id: &PlaylistId, - fields: Option<&str>, - market: Option<&Market>, - ) -> ClientResult { - let params = build_map! { - optional "fields": fields, - optional "market": market.map(|x| x.as_ref()), - }; - - let url = format!("playlists/{}", playlist_id.id()); - let result = self.endpoint_get(&url, ¶ms).await?; - self.convert_result(&result) - } - - /// Get current user playlists without required getting his profile. - /// - /// Parameters: - /// - limit - the number of items to return - /// - offset - the index of the first item to return - /// - /// See [`Spotify::current_user_playlists_manual`] for a manually paginated - /// version of this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-a-list-of-current-users-playlists) - pub fn current_user_playlists(&self) -> impl Paginator> + '_ { - paginate( - move |limit, offset| self.current_user_playlists_manual(Some(limit), Some(offset)), - self.pagination_chunks, - ) - } - - /// The manually paginated version of [`Spotify::current_user_playlists`]. - #[maybe_async] - pub async fn current_user_playlists_manual( - &self, - limit: Option, - offset: Option, - ) -> ClientResult> { - let limit = limit.map(|s| s.to_string()); - let offset = offset.map(|s| s.to_string()); - let params = build_map! { - optional "limit": limit.as_deref(), - optional "offset": offset.as_deref(), - }; - - let result = self.endpoint_get("me/playlists", ¶ms).await?; - self.convert_result(&result) - } - - /// Gets playlists of a user. - /// - /// Parameters: - /// - user_id - the id of the usr - /// - limit - the number of items to return - /// - offset - the index of the first item to return - /// - /// See [`Spotify::user_playlists_manual`] for a manually paginated version - /// of this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-list-users-playlists) - pub fn user_playlists<'a>( - &'a self, - user_id: &'a UserId, - ) -> impl Paginator> + 'a { - paginate( - move |limit, offset| self.user_playlists_manual(user_id, Some(limit), Some(offset)), - self.pagination_chunks, - ) - } - - /// The manually paginated version of [`Spotify::user_playlists`]. - #[maybe_async] - pub async fn user_playlists_manual( - &self, - user_id: &UserId, - limit: Option, - offset: Option, - ) -> ClientResult> { - let limit = limit.map(|s| s.to_string()); - let offset = offset.map(|s| s.to_string()); - let params = build_map! { - optional "limit": limit.as_deref(), - optional "offset": offset.as_deref(), - }; - - let url = format!("users/{}/playlists", user_id.id()); - let result = self.endpoint_get(&url, ¶ms).await?; - self.convert_result(&result) - } - - /// Gets playlist of a user. - /// - /// Parameters: - /// - user_id - the id of the user - /// - playlist_id - the id of the playlist - /// - fields - which fields to return - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-list-users-playlists) - #[maybe_async] - pub async fn user_playlist( - &self, - user_id: &UserId, - playlist_id: Option<&PlaylistId>, - fields: Option<&str>, - ) -> ClientResult { - let params = build_map! { - optional "fields": fields, - }; - - let url = match playlist_id { - Some(playlist_id) => format!("users/{}/playlists/{}", user_id.id(), playlist_id.id()), - None => format!("users/{}/starred", user_id.id()), - }; - let result = self.endpoint_get(&url, ¶ms).await?; - self.convert_result(&result) - } - - /// Get full details of the tracks of a playlist owned by a user. - /// - /// Parameters: - /// - playlist_id - the id of the playlist - /// - fields - which fields to return - /// - limit - the maximum number of tracks to return - /// - offset - the index of the first track to return - /// - market - an ISO 3166-1 alpha-2 country code or the string from_token. - /// - /// See [`Spotify::playlist_tracks`] for a manually paginated version of - /// this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-playlists-tracks) - pub fn playlist_tracks<'a>( - &'a self, - playlist_id: &'a PlaylistId, - fields: Option<&'a str>, - market: Option<&'a Market>, - ) -> impl Paginator> + 'a { - paginate( - move |limit, offset| { - self.playlist_tracks_manual(playlist_id, fields, market, Some(limit), Some(offset)) - }, - self.pagination_chunks, - ) - } - - /// The manually paginated version of [`Spotify::playlist_tracks`]. - #[maybe_async] - pub async fn playlist_tracks_manual( - &self, - playlist_id: &PlaylistId, - fields: Option<&str>, - market: Option<&Market>, - limit: Option, - offset: Option, - ) -> ClientResult> { - let limit = limit.map(|s| s.to_string()); - let offset = offset.map(|s| s.to_string()); - let params = build_map! { - optional "fields": fields, - optional "market": market.map(|x| x.as_ref()), - optional "limit": limit.as_deref(), - optional "offset": offset.as_deref(), - }; - - let url = format!("playlists/{}/tracks", playlist_id.id()); - let result = self.endpoint_get(&url, ¶ms).await?; - self.convert_result(&result) - } - - /// Creates a playlist for a user. - /// - /// Parameters: - /// - user_id - the id of the user - /// - name - the name of the playlist - /// - public - is the created playlist public - /// - description - the description of the playlist - /// - collaborative - if the playlist will be collaborative - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-create-playlist) - #[maybe_async] - pub async fn user_playlist_create( - &self, - user_id: &UserId, - name: &str, - public: Option, - collaborative: Option, - description: Option<&str>, - ) -> ClientResult { - let params = build_json! { - "name": name, - optional "public": public, - optional "collaborative": collaborative, - optional "description": description, - }; - - let url = format!("users/{}/playlists", user_id.id()); - let result = self.endpoint_post(&url, ¶ms).await?; - self.convert_result(&result) - } - - /// Changes a playlist's name and/or public/private state. - /// - /// Parameters: - /// - playlist_id - the id of the playlist - /// - name - optional name of the playlist - /// - public - optional is the playlist public - /// - collaborative - optional is the playlist collaborative - /// - description - optional description of the playlist - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-change-playlist-details) - #[maybe_async] - pub async fn playlist_change_detail( - &self, - playlist_id: &str, - name: Option<&str>, - public: Option, - description: Option<&str>, - collaborative: Option, - ) -> ClientResult { - let params = build_json! { - optional "name": name, - optional "public": public, - optional "collaborative": collaborative, - optional "description": description, - }; - - let url = format!("playlists/{}", playlist_id); - self.endpoint_put(&url, ¶ms).await - } - - /// Unfollows (deletes) a playlist for a user. - /// - /// Parameters: - /// - playlist_id - the id of the playlist - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-unfollow-playlist) - #[maybe_async] - pub async fn playlist_unfollow(&self, playlist_id: &str) -> ClientResult { - let url = format!("playlists/{}/followers", playlist_id); - self.endpoint_delete(&url, &json!({})).await - } - - /// Adds tracks to a playlist. - /// - /// Parameters: - /// - playlist_id - the id of the playlist - /// - track_ids - a list of track URIs, URLs or IDs - /// - position - the position to add the tracks - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-add-tracks-to-playlist) - #[maybe_async] - pub async fn playlist_add_tracks<'a>( - &self, - playlist_id: &PlaylistId, - track_ids: impl IntoIterator, - position: Option, - ) -> ClientResult { - let uris = track_ids.into_iter().map(|id| id.uri()).collect::>(); - let params = build_json! { - "uris": uris, - optional "position": position, - }; - - let url = format!("playlists/{}/tracks", playlist_id.id()); - let result = self.endpoint_post(&url, ¶ms).await?; - self.convert_result(&result) - } - - /// Replace all tracks in a playlist - /// - /// Parameters: - /// - user - the id of the user - /// - playlist_id - the id of the playlist - /// - tracks - the list of track ids to add to the playlist - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-reorder-or-replace-playlists-tracks) - #[maybe_async] - pub async fn playlist_replace_tracks<'a>( - &self, - playlist_id: &PlaylistId, - track_ids: impl IntoIterator, - ) -> ClientResult<()> { - let uris = track_ids.into_iter().map(|id| id.uri()).collect::>(); - let params = build_json! { - "uris": uris - }; - - let url = format!("playlists/{}/tracks", playlist_id.id()); - self.endpoint_put(&url, ¶ms).await?; - - Ok(()) - } - - /// Reorder tracks in a playlist. - /// - /// Parameters: - /// - playlist_id - the id of the playlist - /// - uris - a list of Spotify URIs to replace or clear - /// - range_start - the position of the first track to be reordered - /// - insert_before - the position where the tracks should be inserted - /// - range_length - optional the number of tracks to be reordered (default: - /// 1) - /// - snapshot_id - optional playlist's snapshot ID - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-reorder-or-replace-playlists-tracks) - #[maybe_async] - pub async fn playlist_reorder_tracks<'a, T: PlayableIdType + 'a>( - &self, - playlist_id: &PlaylistId, - uris: Option>>, - range_start: Option, - insert_before: Option, - range_length: Option, - snapshot_id: Option<&str>, - ) -> ClientResult { - let uris = uris.map(|u| u.into_iter().map(|id| id.uri()).collect::>()); - let params = build_json! { - optional "uris": uris, - optional "range_start": range_start, - optional "insert_before": insert_before, - optional "range_length": range_length, - optional "snapshot_id": snapshot_id, - }; - - let url = format!("playlists/{}/tracks", playlist_id.id()); - let result = self.endpoint_put(&url, ¶ms).await?; - self.convert_result(&result) - } - - /// Removes all occurrences of the given tracks from the given playlist. - /// - /// Parameters: - /// - playlist_id - the id of the playlist - /// - track_ids - the list of track ids to add to the playlist - /// - snapshot_id - optional id of the playlist snapshot - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-remove-tracks-playlist) - #[maybe_async] - pub async fn playlist_remove_all_occurrences_of_tracks<'a>( - &self, - playlist_id: &PlaylistId, - track_ids: impl IntoIterator, - snapshot_id: Option<&str>, - ) -> ClientResult { - let tracks = track_ids - .into_iter() - .map(|id| { - let mut map = Map::with_capacity(1); - map.insert("uri".to_owned(), id.uri().into()); - map - }) - .collect::>(); - - let params = build_json! { - "tracks": tracks, - optional "snapshot_id": snapshot_id, - }; - - let url = format!("playlists/{}/tracks", playlist_id.id()); - let result = self.endpoint_delete(&url, ¶ms).await?; - self.convert_result(&result) - } - - /// Removes specfic occurrences of the given tracks from the given playlist. - /// - /// Parameters: - /// - playlist_id: the id of the playlist - /// - tracks: an array of map containing Spotify URIs of the tracks to - /// remove with their current positions in the playlist. For example: - /// - /// ```json - /// { - /// "tracks":[ - /// { - /// "uri":"spotify:track:4iV5W9uYEdYUVa79Axb7Rh", - /// "positions":[ - /// 0, - /// 3 - /// ] - /// }, - /// { - /// "uri":"spotify:track:1301WleyT98MSxVHPZCA6M", - /// "positions":[ - /// 7 - /// ] - /// } - /// ] - /// } - /// ``` - /// - snapshot_id: optional id of the playlist snapshot - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-remove-tracks-playlist) - #[maybe_async] - pub async fn playlist_remove_specific_occurrences_of_tracks<'a>( - &self, - playlist_id: &PlaylistId, - tracks: impl IntoIterator>, - snapshot_id: Option<&str>, - ) -> ClientResult { - let tracks = tracks - .into_iter() - .map(|track| { - let mut map = Map::new(); - map.insert("uri".to_owned(), track.id.uri().into()); - map.insert("positions".to_owned(), json!(track.positions)); - map - }) - .collect::>(); - - let params = build_json! { - "tracks": tracks, - optional "snapshot_id": snapshot_id, - }; - - let url = format!("playlists/{}/tracks", playlist_id.id()); - let result = self.endpoint_delete(&url, ¶ms).await?; - self.convert_result(&result) - } - - /// Add the current authenticated user as a follower of a playlist. - /// - /// Parameters: - /// - playlist_id - the id of the playlist - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-follow-playlist) - #[maybe_async] - pub async fn playlist_follow( - &self, - playlist_id: &PlaylistId, - public: Option, - ) -> ClientResult<()> { - let url = format!("playlists/{}/followers", playlist_id.id()); - - let params = build_json! { - optional "public": public, - }; - - self.endpoint_put(&url, ¶ms).await?; - - Ok(()) - } - - /// Check to see if the given users are following the given playlist. - /// - /// Parameters: - /// - playlist_id - the id of the playlist - /// - user_ids - the ids of the users that you want to - /// check to see if they follow the playlist. Maximum: 5 ids. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-check-if-user-follows-playlist) - #[maybe_async] - pub async fn playlist_check_follow( - &self, - playlist_id: &PlaylistId, - user_ids: &[&UserId], - ) -> ClientResult> { - if user_ids.len() > 5 { - error!("The maximum length of user ids is limited to 5 :-)"); - } - let url = format!( - "playlists/{}/followers/contains?ids={}", - playlist_id.id(), - user_ids - .iter() - .map(|id| id.id()) - .collect::>() - .join(","), - ); - let result = self.endpoint_get(&url, &Query::new()).await?; - self.convert_result(&result) - } - - /// Get detailed profile information about the current user. - /// An alias for the 'current_user' method. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-current-users-profile) - #[maybe_async] - pub async fn me(&self) -> ClientResult { - let result = self.endpoint_get("me/", &Query::new()).await?; - self.convert_result(&result) - } - - /// Get detailed profile information about the current user. - /// An alias for the 'me' method. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-current-users-profile) - #[maybe_async] - pub async fn current_user(&self) -> ClientResult { - self.me().await - } - - /// Get information about the current users currently playing track. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-recently-played) - #[maybe_async] - pub async fn current_user_playing_track( - &self, - ) -> ClientResult> { - let result = self - .get("me/player/currently-playing", None, &Query::new()) - .await?; - if result.is_empty() { - Ok(None) - } else { - self.convert_result(&result) - } - } - - /// Gets a list of the albums saved in the current authorized user's - /// "Your Music" library - /// - /// Parameters: - /// - limit - the number of albums to return - /// - offset - the index of the first album to return - /// - market - Provide this parameter if you want to apply Track Relinking. - /// - /// See [`Spotify::current_user_saved_albums`] for a manually paginated - /// version of this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-users-saved-albums) - pub fn current_user_saved_albums(&self) -> impl Paginator> + '_ { - paginate( - move |limit, offset| self.current_user_saved_albums_manual(Some(limit), Some(offset)), - self.pagination_chunks, - ) - } - - /// The manually paginated version of - /// [`Spotify::current_user_saved_albums`]. - #[maybe_async] - pub async fn current_user_saved_albums_manual( - &self, - limit: Option, - offset: Option, - ) -> ClientResult> { - let limit = limit.map(|s| s.to_string()); - let offset = offset.map(|s| s.to_string()); - let params = build_map! { - optional "limit": limit.as_deref(), - optional "offset": offset.as_deref(), - }; - - let result = self.endpoint_get("me/albums", ¶ms).await?; - self.convert_result(&result) - } - - /// Get a list of the songs saved in the current Spotify user's "Your Music" - /// library. - /// - /// Parameters: - /// - limit - the number of tracks to return - /// - offset - the index of the first track to return - /// - market - Provide this parameter if you want to apply Track Relinking. - /// - /// See [`Spotify::current_user_saved_tracks_manual`] for a manually - /// paginated version of this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-users-saved-tracks) - pub fn current_user_saved_tracks(&self) -> impl Paginator> + '_ { - paginate( - move |limit, offset| self.current_user_saved_tracks_manual(Some(limit), Some(offset)), - self.pagination_chunks, - ) - } - - /// The manually paginated version of - /// [`Spotify::current_user_saved_tracks`]. - #[maybe_async] - pub async fn current_user_saved_tracks_manual( - &self, - limit: Option, - offset: Option, - ) -> ClientResult> { - let limit = limit.map(|s| s.to_string()); - let offset = offset.map(|s| s.to_string()); - let params = build_map! { - optional "limit": limit.as_deref(), - optional "offset": offset.as_deref(), - }; - - let result = self.endpoint_get("me/tracks", ¶ms).await?; - self.convert_result(&result) - } - - /// Gets a list of the artists followed by the current authorized user. - /// - /// Parameters: - /// - after - the last artist ID retrieved from the previous request - /// - limit - the number of tracks to return - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-followed) - #[maybe_async] - pub async fn current_user_followed_artists( - &self, - after: Option<&str>, - limit: Option, - ) -> ClientResult> { - let limit = limit.map(|s| s.to_string()); - let params = build_map! { - "type": Type::Artist.as_ref(), - optional "after": after, - optional "limit": limit.as_deref(), - }; - - let result = self.endpoint_get("me/following", ¶ms).await?; - self.convert_result::(&result) - .map(|x| x.artists) - } - - /// Remove one or more tracks from the current user's "Your Music" library. - /// - /// Parameters: - /// - track_ids - a list of track URIs, URLs or IDs - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-remove-tracks-user) - #[maybe_async] - pub async fn current_user_saved_tracks_delete<'a>( - &self, - track_ids: impl IntoIterator, - ) -> ClientResult<()> { - let url = format!("me/tracks/?ids={}", join_ids(track_ids)); - self.endpoint_delete(&url, &json!({})).await?; - - Ok(()) - } - - /// Check if one or more tracks is already saved in the current Spotify - /// user’s "Your Music" library. - /// - /// Parameters: - /// - track_ids - a list of track URIs, URLs or IDs - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-check-users-saved-tracks) - #[maybe_async] - pub async fn current_user_saved_tracks_contains<'a>( - &self, - track_ids: impl IntoIterator, - ) -> ClientResult> { - let url = format!("me/tracks/contains/?ids={}", join_ids(track_ids)); - let result = self.endpoint_get(&url, &Query::new()).await?; - self.convert_result(&result) - } - - /// Save one or more tracks to the current user's "Your Music" library. - /// - /// Parameters: - /// - track_ids - a list of track URIs, URLs or IDs - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-save-tracks-user) - #[maybe_async] - pub async fn current_user_saved_tracks_add<'a>( - &self, - track_ids: impl IntoIterator, - ) -> ClientResult<()> { - let url = format!("me/tracks/?ids={}", join_ids(track_ids)); - self.endpoint_put(&url, &json!({})).await?; - - Ok(()) - } - - /// Get the current user's top artists. - /// - /// Parameters: - /// - limit - the number of entities to return - /// - offset - the index of the first entity to return - /// - time_range - Over what time frame are the affinities computed - /// - /// See [`Spotify::current_user_top_artists_manual`] for a manually - /// paginated version of this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-users-top-artists-and-tracks) - pub fn current_user_top_artists<'a>( - &'a self, - time_range: Option<&'a TimeRange>, - ) -> impl Paginator> + 'a { - paginate( - move |limit, offset| { - self.current_user_top_artists_manual(time_range, Some(limit), Some(offset)) - }, - self.pagination_chunks, - ) - } - - /// The manually paginated version of [`Spotify::current_user_top_artists`]. - #[maybe_async] - pub async fn current_user_top_artists_manual( - &self, - time_range: Option<&TimeRange>, - limit: Option, - offset: Option, - ) -> ClientResult> { - let limit = limit.map(|s| s.to_string()); - let offset = offset.map(|s| s.to_string()); - let params = build_map! { - optional "time_range": time_range.map(|x| x.as_ref()), - optional "limit": limit.as_deref(), - optional "offset": offset.as_deref(), - }; - - let result = self.endpoint_get(&"me/top/artists", ¶ms).await?; - self.convert_result(&result) - } - - /// Get the current user's top tracks. - /// - /// Parameters: - /// - limit - the number of entities to return - /// - offset - the index of the first entity to return - /// - time_range - Over what time frame are the affinities computed - /// - /// See [`Spotify::current_user_top_tracks_manual`] for a manually paginated - /// version of this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-users-top-artists-and-tracks) - pub fn current_user_top_tracks<'a>( - &'a self, - time_range: Option<&'a TimeRange>, - ) -> impl Paginator> + 'a { - paginate( - move |limit, offset| { - self.current_user_top_tracks_manual(time_range, Some(limit), Some(offset)) - }, - self.pagination_chunks, - ) - } - - /// The manually paginated version of [`Spotify::current_user_top_tracks`]. - #[maybe_async] - pub async fn current_user_top_tracks_manual( - &self, - time_range: Option<&TimeRange>, - limit: Option, - offset: Option, - ) -> ClientResult> { - let limit = limit.map(|x| x.to_string()); - let offset = offset.map(|x| x.to_string()); - let params = build_map! { - optional "time_range": time_range.map(|x| x.as_ref()), - optional "limit": limit.as_deref(), - optional "offset": offset.as_deref(), - }; - - let result = self.endpoint_get("me/top/tracks", ¶ms).await?; - self.convert_result(&result) - } - - /// Get the current user's recently played tracks. - /// - /// Parameters: - /// - limit - the number of entities to return - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-the-users-currently-playing-track) - #[maybe_async] - pub async fn current_user_recently_played( - &self, - limit: Option, - ) -> ClientResult> { - let limit = limit.map(|x| x.to_string()); - let params = build_map! { - optional "limit": limit.as_deref(), - }; - - let result = self - .endpoint_get("me/player/recently-played", ¶ms) - .await?; - self.convert_result(&result) - } - - /// Add one or more albums to the current user's "Your Music" library. - /// - /// Parameters: - /// - album_ids - a list of album URIs, URLs or IDs - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-save-albums-user) - #[maybe_async] - pub async fn current_user_saved_albums_add<'a>( - &self, - album_ids: impl IntoIterator, - ) -> ClientResult<()> { - let url = format!("me/albums/?ids={}", join_ids(album_ids)); - self.endpoint_put(&url, &json!({})).await?; - - Ok(()) - } - - /// Remove one or more albums from the current user's "Your Music" library. - /// - /// Parameters: - /// - album_ids - a list of album URIs, URLs or IDs - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-remove-albums-user) - #[maybe_async] - pub async fn current_user_saved_albums_delete<'a>( - &self, - album_ids: impl IntoIterator, - ) -> ClientResult<()> { - let url = format!("me/albums/?ids={}", join_ids(album_ids)); - self.endpoint_delete(&url, &json!({})).await?; - - Ok(()) - } - - /// Check if one or more albums is already saved in the current Spotify - /// user’s "Your Music” library. - /// - /// Parameters: - /// - album_ids - a list of album URIs, URLs or IDs - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-check-users-saved-albums) - #[maybe_async] - pub async fn current_user_saved_albums_contains<'a>( - &self, - album_ids: impl IntoIterator, - ) -> ClientResult> { - let url = format!("me/albums/contains/?ids={}", join_ids(album_ids)); - let result = self.endpoint_get(&url, &Query::new()).await?; - self.convert_result(&result) - } - - /// Follow one or more artists. - /// - /// Parameters: - /// - artist_ids - a list of artist IDs - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-follow-artists-users) - #[maybe_async] - pub async fn user_follow_artists<'a>( - &self, - artist_ids: impl IntoIterator, - ) -> ClientResult<()> { - let url = format!("me/following?type=artist&ids={}", join_ids(artist_ids)); - self.endpoint_put(&url, &json!({})).await?; - - Ok(()) - } - - /// Unfollow one or more artists. - /// - /// Parameters: - /// - artist_ids - a list of artist IDs - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-unfollow-artists-users) - #[maybe_async] - pub async fn user_unfollow_artists<'a>( - &self, - artist_ids: impl IntoIterator, - ) -> ClientResult<()> { - let url = format!("me/following?type=artist&ids={}", join_ids(artist_ids)); - self.endpoint_delete(&url, &json!({})).await?; - - Ok(()) - } - - /// Check to see if the current user is following one or more artists or - /// other Spotify users. - /// - /// Parameters: - /// - artist_ids - the ids of the users that you want to - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-check-current-user-follows) - #[maybe_async] - pub async fn user_artist_check_follow<'a>( - &self, - artist_ids: impl IntoIterator, - ) -> ClientResult> { - let url = format!( - "me/following/contains?type=artist&ids={}", - join_ids(artist_ids) - ); - let result = self.endpoint_get(&url, &Query::new()).await?; - self.convert_result(&result) - } - - /// Follow one or more users. - /// - /// Parameters: - /// - user_ids - a list of artist IDs - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-follow-artists-users) - #[maybe_async] - pub async fn user_follow_users<'a>( - &self, - user_ids: impl IntoIterator, - ) -> ClientResult<()> { - let url = format!("me/following?type=user&ids={}", join_ids(user_ids)); - self.endpoint_put(&url, &json!({})).await?; - - Ok(()) - } - - /// Unfollow one or more users. - /// - /// Parameters: - /// - user_ids - a list of artist IDs - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-unfollow-artists-users) - #[maybe_async] - pub async fn user_unfollow_users<'a>( - &self, - user_ids: impl IntoIterator, - ) -> ClientResult<()> { - let url = format!("me/following?type=user&ids={}", join_ids(user_ids)); - self.endpoint_delete(&url, &json!({})).await?; - - Ok(()) - } - - /// Get a list of Spotify featured playlists. - /// - /// Parameters: - /// - locale - The desired language, consisting of a lowercase ISO 639 - /// language code and an uppercase ISO 3166-1 alpha-2 country code, - /// joined by an underscore. - /// - country - An ISO 3166-1 alpha-2 country code or the string from_token. - /// - timestamp - A timestamp in ISO 8601 format: yyyy-MM-ddTHH:mm:ss. Use - /// this parameter to specify the user's local time to get results - /// tailored for that specific date and time in the day - /// - limit - The maximum number of items to return. Default: 20. - /// Minimum: 1. Maximum: 50 - /// - offset - The index of the first item to return. Default: 0 - /// (the first object). Use with limit to get the next set of - /// items. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-featured-playlists) - #[maybe_async] - pub async fn featured_playlists( - &self, - locale: Option<&str>, - country: Option<&Market>, - timestamp: Option<&DateTime>, - limit: Option, - offset: Option, - ) -> ClientResult { - let limit = limit.map(|x| x.to_string()); - let offset = offset.map(|x| x.to_string()); - let timestamp = timestamp.map(|x| x.to_rfc3339()); - let params = build_map! { - optional "locale": locale, - optional "country": country.map(|x| x.as_ref()), - optional "timestamp": timestamp.as_deref(), - optional "limit": limit.as_deref(), - optional "offset": offset.as_deref(), - }; - - let result = self - .endpoint_get("browse/featured-playlists", ¶ms) - .await?; - self.convert_result(&result) - } - - /// Get a list of new album releases featured in Spotify. - /// - /// Parameters: - /// - country - An ISO 3166-1 alpha-2 country code or string from_token. - /// - limit - The maximum number of items to return. Default: 20. - /// Minimum: 1. Maximum: 50 - /// - offset - The index of the first item to return. Default: 0 (the first - /// object). Use with limit to get the next set of items. - /// - /// See [`Spotify::new_releases_manual`] for a manually paginated version of - /// this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-new-releases) - pub fn new_releases<'a>( - &'a self, - country: Option<&'a Market>, - ) -> impl Paginator> + 'a { - paginate( - move |limit, offset| self.new_releases_manual(country, Some(limit), Some(offset)), - self.pagination_chunks, - ) - } - - /// The manually paginated version of [`Spotify::new_releases`]. - #[maybe_async] - pub async fn new_releases_manual( - &self, - country: Option<&Market>, - limit: Option, - offset: Option, - ) -> ClientResult> { - let limit = limit.map(|x| x.to_string()); - let offset = offset.map(|x| x.to_string()); - let params = build_map! { - optional "country": country.map(|x| x.as_ref()), - optional "limit": limit.as_deref(), - optional "offset": offset.as_deref(), - }; - - let result = self.endpoint_get("browse/new-releases", ¶ms).await?; - self.convert_result::(&result) - .map(|x| x.albums) - } - - /// Get a list of new album releases featured in Spotify - /// - /// Parameters: - /// - country - An ISO 3166-1 alpha-2 country code or string from_token. - /// - locale - The desired language, consisting of an ISO 639 language code - /// and an ISO 3166-1 alpha-2 country code, joined by an underscore. - /// - limit - The maximum number of items to return. Default: 20. - /// Minimum: 1. Maximum: 50 - /// - offset - The index of the first item to return. Default: 0 (the first - /// object). Use with limit to get the next set of items. - /// - /// See [`Spotify::categories_manual`] for a manually paginated version of - /// this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-categories) - pub fn categories<'a>( - &'a self, - locale: Option<&'a str>, - country: Option<&'a Market>, - ) -> impl Paginator> + 'a { - paginate( - move |limit, offset| self.categories_manual(locale, country, Some(limit), Some(offset)), - self.pagination_chunks, - ) - } - - /// The manually paginated version of [`Spotify::categories`]. - #[maybe_async] - pub async fn categories_manual( - &self, - locale: Option<&str>, - country: Option<&Market>, - limit: Option, - offset: Option, - ) -> ClientResult> { - let limit = limit.map(|x| x.to_string()); - let offset = offset.map(|x| x.to_string()); - let params = build_map! { - optional "locale": locale, - optional "country": country.map(|x| x.as_ref()), - optional "limit": limit.as_deref(), - optional "offset": offset.as_deref(), - }; - let result = self.endpoint_get("browse/categories", ¶ms).await?; - self.convert_result::(&result) - .map(|x| x.categories) - } - - /// Get a list of playlists in a category in Spotify - /// - /// Parameters: - /// - category_id - The category id to get playlists from. - /// - country - An ISO 3166-1 alpha-2 country code or the string from_token. - /// - limit - The maximum number of items to return. Default: 20. - /// Minimum: 1. Maximum: 50 - /// - offset - The index of the first item to return. Default: 0 (the first - /// object). Use with limit to get the next set of items. - /// - /// See [`Spotify::category_playlists_manual`] for a manually paginated - /// version of this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-a-categories-playlists) - pub fn category_playlists<'a>( - &'a self, - category_id: &'a str, - country: Option<&'a Market>, - ) -> impl Paginator> + 'a { - paginate( - move |limit, offset| { - self.category_playlists_manual(category_id, country, Some(limit), Some(offset)) - }, - self.pagination_chunks, - ) - } - - /// The manually paginated version of [`Spotify::category_playlists`]. - #[maybe_async] - pub async fn category_playlists_manual( - &self, - category_id: &str, - country: Option<&Market>, - limit: Option, - offset: Option, - ) -> ClientResult> { - let limit = limit.map(|x| x.to_string()); - let offset = offset.map(|x| x.to_string()); - let params = build_map! { - optional "country": country.map(|x| x.as_ref()), - optional "limit": limit.as_deref(), - optional "offset": offset.as_deref(), - }; - - let url = format!("browse/categories/{}/playlists", category_id); - let result = self.endpoint_get(&url, ¶ms).await?; - self.convert_result::(&result) - .map(|x| x.playlists) - } - - /// Get Recommendations Based on Seeds - /// - /// Parameters: - /// - seed_artists - a list of artist IDs, URIs or URLs - /// - seed_tracks - a list of artist IDs, URIs or URLs - /// - seed_genres - a list of genre names. Available genres for - /// - market - An ISO 3166-1 alpha-2 country code or the string from_token. If provided, all - /// results will be playable in this country. - /// - limit - The maximum number of items to return. Default: 20. - /// Minimum: 1. Maximum: 100 - /// - min/max/target_ - For the tuneable track attributes listed - /// in the documentation, these values provide filters and targeting on - /// results. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-recommendations) - #[maybe_async] - pub async fn recommendations<'a>( - &self, - payload: &Map, - seed_artists: Option>, - seed_genres: Option>, - seed_tracks: Option>, - market: Option<&Market>, - limit: Option, - ) -> ClientResult { - let seed_artists = seed_artists.map(join_ids); - let seed_genres = seed_genres.map(|x| x.into_iter().collect::>().join(",")); - let seed_tracks = seed_tracks.map(join_ids); - let limit = limit.map(|x| x.to_string()); - let mut params = build_map! { - optional "seed_artists": seed_artists.as_ref(), - optional "seed_genres": seed_genres.as_ref(), - optional "seed_tracks": seed_tracks.as_ref(), - optional "market": market.map(|x| x.as_ref()), - optional "limit": limit.as_ref(), - }; - - let attributes = [ - "acousticness", - "danceability", - "duration_ms", - "energy", - "instrumentalness", - "key", - "liveness", - "loudness", - "mode", - "popularity", - "speechiness", - "tempo", - "time_signature", - "valence", - ]; - let prefixes = ["min", "max", "target"]; - - // This map is used to store the intermediate data which lives long enough - // to be borrowed into the `params` - let map_to_hold_owned_value = attributes - .iter() - // create cartesian product for attributes and prefixes - .flat_map(|attribute| { - prefixes - .iter() - .map(move |prefix| format!("{}_{}", prefix, attribute)) - }) - .filter_map( - // TODO: not sure if this `to_string` is what we want. It - // might add quotes to the strings. - |param| payload.get(¶m).map(|value| (param, value.to_string())), - ) - .collect::>(); - - for (ref key, ref value) in &map_to_hold_owned_value { - params.insert(key, value); - } - - let result = self.endpoint_get("recommendations", ¶ms).await?; - self.convert_result(&result) - } - - /// Get audio features for a track - /// - /// Parameters: - /// - track - track URI, URL or ID - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-audio-features) - #[maybe_async] - pub async fn track_features(&self, track_id: &TrackId) -> ClientResult { - let url = format!("audio-features/{}", track_id.id()); - let result = self.endpoint_get(&url, &Query::new()).await?; - self.convert_result(&result) - } - - /// Get Audio Features for Several Tracks - /// - /// Parameters: - /// - tracks a list of track URIs, URLs or IDs - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-several-audio-features) - #[maybe_async] - pub async fn tracks_features<'a>( - &self, - track_ids: impl IntoIterator, - ) -> ClientResult>> { - let url = format!("audio-features/?ids={}", join_ids(track_ids)); - - let result = self.endpoint_get(&url, &Query::new()).await?; - if result.is_empty() { - Ok(None) - } else { - self.convert_result::>(&result) - .map(|option_payload| option_payload.map(|x| x.audio_features)) - } - } - - /// Get Audio Analysis for a Track - /// - /// Parameters: - /// - track_id - a track URI, URL or ID - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-audio-analysis) - #[maybe_async] - pub async fn track_analysis(&self, track_id: &TrackId) -> ClientResult { - let url = format!("audio-analysis/{}", track_id.id()); - let result = self.endpoint_get(&url, &Query::new()).await?; - self.convert_result(&result) - } - - /// Get a User’s Available Devices - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-a-users-available-devices) - #[maybe_async] - pub async fn device(&self) -> ClientResult> { - let result = self - .endpoint_get("me/player/devices", &Query::new()) - .await?; - self.convert_result::(&result) - .map(|x| x.devices) - } - - /// Get Information About The User’s Current Playback - /// - /// Parameters: - /// - market: Optional. an ISO 3166-1 alpha-2 country code or the string from_token. - /// - additional_types: Optional. A comma-separated list of item types that - /// your client supports besides the default track type. Valid types are: - /// `track` and `episode`. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-information-about-the-users-current-playback) - #[maybe_async] - pub async fn current_playback<'a, A: IntoIterator>( - &self, - country: Option<&Market>, - additional_types: Option, - ) -> ClientResult> { - let additional_types = additional_types.map(|x| { - x.into_iter() - .map(|x| x.as_ref()) - .collect::>() - .join(",") - }); - let params = build_map! { - optional "country": country.map(|x| x.as_ref()), - optional "additional_types": additional_types.as_deref(), - }; - - let result = self.endpoint_get("me/player", ¶ms).await?; - if result.is_empty() { - Ok(None) - } else { - self.convert_result(&result) - } - } - - /// Get the User’s Currently Playing Track - /// - /// Parameters: - /// - market: Optional. an ISO 3166-1 alpha-2 country code or the string from_token. - /// - additional_types: Optional. A comma-separated list of item types that - /// your client supports besides the default track type. Valid types are: - /// `track` and `episode`. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-recently-played) - #[maybe_async] - pub async fn current_playing<'a>( - &self, - market: Option<&'a Market>, - additional_types: Option>, - ) -> ClientResult> { - let additional_types = additional_types.map(|x| { - x.into_iter() - .map(|x| x.as_ref()) - .collect::>() - .join(",") - }); - let params = build_map! { - optional "market": market.map(|x| x.as_ref()), - optional "additional_types": additional_types.as_ref(), - }; - - let result = self - .get("me/player/currently-playing", None, ¶ms) - .await?; - if result.is_empty() { - Ok(None) - } else { - self.convert_result(&result) - } - } - - /// Transfer a User’s Playback. - /// - /// Note: Although an array is accepted, only a single device_id is - /// currently supported. Supplying more than one will return 400 Bad Request - /// - /// Parameters: - /// - device_id - transfer playback to this device - /// - force_play - true: after transfer, play. false: keep current state. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-transfer-a-users-playback) - #[maybe_async] - pub async fn transfer_playback(&self, device_id: &str, play: Option) -> ClientResult<()> { - let params = build_json! { - "device_ids": [device_id], - optional "play": play, - }; - - self.endpoint_put("me/player", ¶ms).await?; - Ok(()) - } - - /// Start/Resume a User’s Playback. - /// - /// Provide a `context_uri` to start playback or a album, artist, or - /// playlist. Provide a `uris` list to start playback of one or more tracks. - /// Provide `offset` as {"position": } or {"uri": ""} to - /// start playback at a particular offset. - /// - /// Parameters: - /// - device_id - device target for playback - /// - context_uri - spotify context uri to play - /// - uris - spotify track uris - /// - offset - offset into context by index or track - /// - position_ms - Indicates from what position to start playback. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-start-a-users-playback) - #[maybe_async] - pub async fn start_context_playback( - &self, - context_uri: &Id, - device_id: Option<&str>, - offset: Option>, - position_ms: Option, - ) -> ClientResult<()> { - use super::model::Offset; - - let params = build_json! { - "context_uri": context_uri.uri(), - optional "offset": offset.map(|x| match x { - Offset::Position(position) => json!({ "position": position }), - Offset::Uri(uri) => json!({ "uri": uri.uri() }), - }), - optional "position_ms": position_ms, - - }; - - let url = self.append_device_id("me/player/play", device_id); - self.put(&url, None, ¶ms).await?; - - Ok(()) - } - - #[maybe_async] - pub async fn start_uris_playback<'a, T: PlayableIdType + 'a>( - &self, - uris: impl IntoIterator>, - device_id: Option<&str>, - offset: Option>, - position_ms: Option, - ) -> ClientResult<()> { - use super::model::Offset; - - let params = build_json! { - "uris": uris.into_iter().map(|id| id.uri()).collect::>(), - optional "position_ms": position_ms, - optional "offset": offset.map(|x| match x { - Offset::Position(position) => json!({ "position": position }), - Offset::Uri(uri) => json!({ "uri": uri.uri() }), - }), - }; - - let url = self.append_device_id("me/player/play", device_id); - self.endpoint_put(&url, ¶ms).await?; - - Ok(()) - } - - /// Pause a User’s Playback. - /// - /// Parameters: - /// - device_id - device target for playback - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-pause-a-users-playback) - #[maybe_async] - pub async fn pause_playback(&self, device_id: Option<&str>) -> ClientResult<()> { - let url = self.append_device_id("me/player/pause", device_id); - self.endpoint_put(&url, &json!({})).await?; - - Ok(()) - } - - /// Skip User’s Playback To Next Track. - /// - /// Parameters: - /// - device_id - device target for playback - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-skip-users-playback-to-next-track) - #[maybe_async] - pub async fn next_track(&self, device_id: Option<&str>) -> ClientResult<()> { - let url = self.append_device_id("me/player/next", device_id); - self.endpoint_post(&url, &json!({})).await?; - - Ok(()) - } - - /// Skip User’s Playback To Previous Track. - /// - /// Parameters: - /// - device_id - device target for playback - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-skip-users-playback-to-previous-track) - #[maybe_async] - pub async fn previous_track(&self, device_id: Option<&str>) -> ClientResult<()> { - let url = self.append_device_id("me/player/previous", device_id); - self.endpoint_post(&url, &json!({})).await?; - - Ok(()) - } - - /// Seek To Position In Currently Playing Track. - /// - /// Parameters: - /// - position_ms - position in milliseconds to seek to - /// - device_id - device target for playback - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-seek-to-position-in-currently-playing-track) - #[maybe_async] - pub async fn seek_track(&self, position_ms: u32, device_id: Option<&str>) -> ClientResult<()> { - let url = self.append_device_id( - &format!("me/player/seek?position_ms={}", position_ms), - device_id, - ); - self.endpoint_put(&url, &json!({})).await?; - - Ok(()) - } - - /// Set Repeat Mode On User’s Playback. - /// - /// Parameters: - /// - state - `track`, `context`, or `off` - /// - device_id - device target for playback - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-set-repeat-mode-on-users-playback) - #[maybe_async] - pub async fn repeat(&self, state: &RepeatState, device_id: Option<&str>) -> ClientResult<()> { - let url = self.append_device_id( - &format!("me/player/repeat?state={}", state.as_ref()), - device_id, - ); - self.endpoint_put(&url, &json!({})).await?; - - Ok(()) - } - - /// Set Volume For User’s Playback. - /// - /// Parameters: - /// - volume_percent - volume between 0 and 100 - /// - device_id - device target for playback - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-set-volume-for-users-playback) - #[maybe_async] - pub async fn volume(&self, volume_percent: u8, device_id: Option<&str>) -> ClientResult<()> { - if volume_percent > 100u8 { - error!("volume must be between 0 and 100, inclusive"); - } - let url = self.append_device_id( - &format!("me/player/volume?volume_percent={}", volume_percent), - device_id, - ); - self.endpoint_put(&url, &json!({})).await?; - - Ok(()) - } - - /// Toggle Shuffle For User’s Playback. - /// - /// Parameters: - /// - state - true or false - /// - device_id - device target for playback - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-toggle-shuffle-for-users-playback) - #[maybe_async] - pub async fn shuffle(&self, state: bool, device_id: Option<&str>) -> ClientResult<()> { - let url = self.append_device_id(&format!("me/player/shuffle?state={}", state), device_id); - self.endpoint_put(&url, &json!({})).await?; - - Ok(()) - } - - /// Add an item to the end of the user's playback queue. - /// - /// Parameters: - /// - uri - The uri of the item to add, Track or Episode - /// - device id - The id of the device targeting - /// - If no device ID provided the user's currently active device is - /// targeted - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-add-to-queue) - #[maybe_async] - pub async fn add_item_to_queue( - &self, - item: &Id, - device_id: Option<&str>, - ) -> ClientResult<()> { - let url = self.append_device_id(&format!("me/player/queue?uri={}", item), device_id); - self.endpoint_post(&url, &json!({})).await?; - - Ok(()) - } - - /// Add a show or a list of shows to a user’s library. - /// - /// Parameters: - /// - ids(Required) A comma-separated list of Spotify IDs for the shows to - /// be added to the user’s library. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-save-shows-user) - #[maybe_async] - pub async fn save_shows<'a>( - &self, - show_ids: impl IntoIterator, - ) -> ClientResult<()> { - let url = format!("me/shows/?ids={}", join_ids(show_ids)); - self.endpoint_put(&url, &json!({})).await?; - - Ok(()) - } - - /// Get a list of shows saved in the current Spotify user’s library. - /// Optional parameters can be used to limit the number of shows returned. - /// - /// Parameters: - /// - limit(Optional). The maximum number of shows to return. Default: 20. - /// Minimum: 1. Maximum: 50. - /// - offset(Optional). The index of the first show to return. Default: 0 - /// (the first object). Use with limit to get the next set of shows. - /// - /// See [`Spotify::get_saved_show_manual`] for a manually paginated version - /// of this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-users-saved-shows) - pub fn get_saved_show(&self) -> impl Paginator> + '_ { - paginate( - move |limit, offset| self.get_saved_show_manual(Some(limit), Some(offset)), - self.pagination_chunks, - ) - } - - /// The manually paginated version of [`Spotify::get_saved_show`]. - #[maybe_async] - pub async fn get_saved_show_manual( - &self, - limit: Option, - offset: Option, - ) -> ClientResult> { - let limit = limit.map(|x| x.to_string()); - let offset = offset.map(|x| x.to_string()); - let params = build_map! { - optional "limit": limit.as_ref(), - optional "offset": offset.as_ref(), - }; - - let result = self.endpoint_get("me/shows", ¶ms).await?; - self.convert_result(&result) - } - - /// Get Spotify catalog information for a single show identified by its unique Spotify ID. - /// - /// Path Parameters: - /// - id: The Spotify ID for the show. - /// - /// Query Parameters - /// - market(Optional): An ISO 3166-1 alpha-2 country code or the string from_token. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-a-show) - #[maybe_async] - pub async fn get_a_show(&self, id: &ShowId, market: Option<&Market>) -> ClientResult { - let params = build_map! { - optional "market": market.map(|x| x.as_ref()), - }; - - let url = format!("shows/{}", id.id()); - let result = self.endpoint_get(&url, ¶ms).await?; - self.convert_result(&result) - } - - /// Get Spotify catalog information for multiple shows based on their - /// Spotify IDs. - /// - /// Query Parameters - /// - ids(Required) A comma-separated list of the Spotify IDs for the shows. Maximum: 50 IDs. - /// - market(Optional) An ISO 3166-1 alpha-2 country code or the string from_token. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-shows) - #[maybe_async] - pub async fn get_several_shows<'a>( - &self, - ids: impl IntoIterator, - market: Option<&Market>, - ) -> ClientResult> { - let ids = join_ids(ids); - let params = build_map! { - "ids": &ids, - optional "market": market.map(|x| x.as_ref()), - }; - - let result = self.endpoint_get("shows", ¶ms).await?; - self.convert_result::(&result) - .map(|x| x.shows) - } - - /// Get Spotify catalog information about an show’s episodes. Optional - /// parameters can be used to limit the number of episodes returned. - /// - /// Path Parameters - /// - id: The Spotify ID for the show. - /// - /// Query Parameters - /// - limit: Optional. The maximum number of episodes to return. Default: 20. Minimum: 1. Maximum: 50. - /// - offset: Optional. The index of the first episode to return. Default: 0 (the first object). Use with limit to get the next set of episodes. - /// - market: Optional. An ISO 3166-1 alpha-2 country code or the string from_token. - /// - /// See [`Spotify::get_shows_episodes_manual`] for a manually paginated - /// version of this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-a-shows-episodes) - pub fn get_shows_episodes<'a>( - &'a self, - id: &'a ShowId, - market: Option<&'a Market>, - ) -> impl Paginator> + 'a { - paginate( - move |limit, offset| { - self.get_shows_episodes_manual(id, market, Some(limit), Some(offset)) - }, - self.pagination_chunks, - ) - } - - /// The manually paginated version of [`Spotify::get_shows_episodes`]. - #[maybe_async] - pub async fn get_shows_episodes_manual( - &self, - id: &ShowId, - market: Option<&Market>, - limit: Option, - offset: Option, - ) -> ClientResult> { - let limit = limit.map(|x| x.to_string()); - let offset = offset.map(|x| x.to_string()); - let params = build_map! { - optional "market": market.map(|x| x.as_ref()), - optional "limit": limit.as_ref(), - optional "offset": offset.as_ref(), - }; - - let url = format!("shows/{}/episodes", id.id()); - let result = self.endpoint_get(&url, ¶ms).await?; - self.convert_result(&result) - } - - /// Get Spotify catalog information for a single episode identified by its unique Spotify ID. - /// - /// Path Parameters - /// - id: The Spotify ID for the episode. - /// - /// Query Parameters - /// - market: Optional. An ISO 3166-1 alpha-2 country code or the string from_token. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-an-episode) - #[maybe_async] - pub async fn get_an_episode( - &self, - id: &EpisodeId, - market: Option<&Market>, - ) -> ClientResult { - let url = format!("episodes/{}", id.id()); - let params = build_map! { - optional "market": market.map(|x| x.as_ref()), - }; - - let result = self.endpoint_get(&url, ¶ms).await?; - self.convert_result(&result) - } - - /// Get Spotify catalog information for multiple episodes based on their Spotify IDs. - /// - /// Query Parameters - /// - ids: Required. A comma-separated list of the Spotify IDs for the episodes. Maximum: 50 IDs. - /// - market: Optional. An ISO 3166-1 alpha-2 country code or the string from_token. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-episodes) - #[maybe_async] - pub async fn get_several_episodes<'a>( - &self, - ids: impl IntoIterator, - market: Option<&Market>, - ) -> ClientResult> { - let ids = join_ids(ids); - let params = build_map! { - "ids": &ids, - optional "market": market.map(|x| x.as_ref()), - }; - - let result = self.endpoint_get("episodes", ¶ms).await?; - self.convert_result::(&result) - .map(|x| x.episodes) - } - - /// Check if one or more shows is already saved in the current Spotify user’s library. - /// - /// Query Parameters - /// - ids: Required. A comma-separated list of the Spotify IDs for the shows. Maximum: 50 IDs. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-check-users-saved-shows) - #[maybe_async] - pub async fn check_users_saved_shows<'a>( - &self, - ids: impl IntoIterator, - ) -> ClientResult> { - let ids = join_ids(ids); - let params = build_map! { - "ids": &ids, - }; - let result = self.endpoint_get("me/shows/contains", ¶ms).await?; - self.convert_result(&result) - } - - /// Delete one or more shows from current Spotify user's library. - /// Changes to a user's saved shows may not be visible in other Spotify applications immediately. - /// - /// Query Parameters - /// - ids: Required. A comma-separated list of Spotify IDs for the shows to be deleted from the user’s library. - /// - market: Optional. An ISO 3166-1 alpha-2 country code or the string from_token. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-remove-shows-user) - #[maybe_async] - pub async fn remove_users_saved_shows<'a>( - &self, - show_ids: impl IntoIterator, - country: Option<&Market>, - ) -> ClientResult<()> { - let url = format!("me/shows?ids={}", join_ids(show_ids)); - let params = build_json! { - optional "country": country.map(|x| x.as_ref()) - }; - self.endpoint_delete(&url, ¶ms).await?; - - Ok(()) - } -} - -#[inline] -fn join_ids<'a, T: 'a + IdType>(ids: impl IntoIterator>) -> String { - ids.into_iter().collect::>().join(",") -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_parse_response_code() { - let url = "http://localhost:8888/callback?code=AQD0yXvFEOvw&state=sN#_=_"; - let spotify = SpotifyBuilder::default().build().unwrap(); - let code = spotify.parse_response_code(url).unwrap(); - assert_eq!(code, "AQD0yXvFEOvw"); - } - - #[test] - fn test_append_device_id_without_question_mark() { - let path = "me/player/play"; - let device_id = Some("fdafdsadfa"); - let spotify = SpotifyBuilder::default().build().unwrap(); - let new_path = spotify.append_device_id(path, device_id); - assert_eq!(new_path, "me/player/play?device_id=fdafdsadfa"); - } - - #[test] - fn test_append_device_id_with_question_mark() { - let path = "me/player/shuffle?state=true"; - let device_id = Some("fdafdsadfa"); - let spotify = SpotifyBuilder::default().build().unwrap(); - let new_path = spotify.append_device_id(path, device_id); - assert_eq!( - new_path, - "me/player/shuffle?state=true&device_id=fdafdsadfa" - ); - } -} diff --git a/src/client_creds.rs b/src/client_creds.rs new file mode 100644 index 00000000..816dc04d --- /dev/null +++ b/src/client_creds.rs @@ -0,0 +1,114 @@ +use crate::{ + clients::BaseClient, + headers, + http::{Form, HttpClient}, + ClientResult, Config, Credentials, Token, +}; + +use maybe_async::maybe_async; + +/// The [Client Credentials Flow][reference] client for the Spotify API. +/// +/// This is the most basic flow. It requests a token to Spotify given some +/// client credentials, without user authorization. The only step to take is to +/// call [`Self::request_token`]. See [this example][example-main]. +/// +/// Note: This flow does not include authorization and therefore cannot be used +/// to access or to manage the endpoints related to user private data in +/// [`OAuthClient`](crate::clients::OAuthClient). +/// +/// [reference]: https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow +/// [example-main]: https://github.com/ramsayleung/rspotify/blob/master/examples/client_creds.rs +#[derive(Clone, Debug, Default)] +pub struct ClientCredsSpotify { + pub config: Config, + pub creds: Credentials, + pub token: Option, + pub(in crate) http: HttpClient, +} + +/// This client has access to the base methods. +impl BaseClient for ClientCredsSpotify { + fn get_http(&self) -> &HttpClient { + &self.http + } + + fn get_token(&self) -> Option<&Token> { + self.token.as_ref() + } + + fn get_token_mut(&mut self) -> Option<&mut Token> { + self.token.as_mut() + } + + fn get_creds(&self) -> &Credentials { + &self.creds + } + + fn get_config(&self) -> &Config { + &self.config + } +} + +impl ClientCredsSpotify { + /// Builds a new [`ClientCredsSpotify`] given a pair of client credentials + /// and OAuth information. + pub fn new(creds: Credentials) -> Self { + ClientCredsSpotify { + creds, + ..Default::default() + } + } + + /// Build a new [`ClientCredsSpotify`] from an already generated token. Note + /// that once the token expires this will fail to make requests, + /// as the client credentials aren't known. + pub fn from_token(token: Token) -> Self { + ClientCredsSpotify { + token: Some(token), + ..Default::default() + } + } + + /// Same as [`Self::new`] but with an extra parameter to configure the + /// client. + pub fn with_config(creds: Credentials, config: Config) -> Self { + ClientCredsSpotify { + config, + creds, + ..Default::default() + } + } + + /// Tries to read the cache file's token, which may not exist. + /// + /// Similarly to [`Self::write_token_cache`], this will already check if the + /// cached token is enabled and return `None` in case it isn't. + #[maybe_async] + pub async fn read_token_cache(&self) -> Option { + if !self.get_config().token_cached { + return None; + } + + let token = Token::from_cache(&self.get_config().cache_path)?; + if token.is_expired() { + // Invalid token, since it doesn't have at least the currently + // required scopes or it's expired. + None + } else { + Some(token) + } + } + + /// Obtains the client access token for the app. The resulting token will be + /// saved internally. + #[maybe_async] + pub async fn request_token(&mut self) -> ClientResult<()> { + let mut data = Form::new(); + data.insert(headers::GRANT_TYPE, headers::GRANT_CLIENT_CREDS); + + self.token = Some(self.fetch_access_token(&data).await?); + + self.write_token_cache() + } +} diff --git a/src/clients/base.rs b/src/clients/base.rs new file mode 100644 index 00000000..d711358c --- /dev/null +++ b/src/clients/base.rs @@ -0,0 +1,1054 @@ +use crate::{ + auth_urls, + clients::{ + basic_auth, bearer_auth, convert_result, join_ids, + pagination::{paginate, Paginator}, + }, + http::{BaseHttpClient, Form, Headers, HttpClient, Query}, + macros::build_map, + model::*, + ClientResult, Config, Credentials, Token, +}; + +use std::{collections::HashMap, fmt}; + +use chrono::Utc; +use maybe_async::maybe_async; +use serde_json::{Map, Value}; + +/// This trait implements the basic endpoints from the Spotify API that may be +/// accessed without user authorization, including parts of the authentication +/// flow that are shared, and the endpoints. +#[maybe_async(?Send)] +pub trait BaseClient +where + Self: Default + Clone + fmt::Debug, +{ + fn get_config(&self) -> &Config; + fn get_http(&self) -> &HttpClient; + fn get_token(&self) -> Option<&Token>; + fn get_token_mut(&mut self) -> Option<&mut Token>; + fn get_creds(&self) -> &Credentials; + + /// If it's a relative URL like "me", the prefix is appended to it. + /// Otherwise, the same URL is returned. + fn endpoint_url(&self, url: &str) -> String { + // Using the client's prefix in case it's a relative route. + if !url.starts_with("http") { + self.get_config().prefix.clone() + url + } else { + url.to_string() + } + } + + /// The headers required for authenticated requests to the API + fn auth_headers(&self) -> ClientResult { + let mut auth = Headers::new(); + let (key, val) = bearer_auth(self.get_token().expect("Rspotify not authenticated")); + auth.insert(key, val); + + Ok(auth) + } + + // HTTP-related methods for the Spotify client. It wraps the basic HTTP + // client with features needed of higher level. + // + // The Spotify client has two different wrappers to perform requests: + // + // * Basic wrappers: `get`, `post`, `put`, `delete`, `post_form`. These only + // append the configured Spotify API URL to the relative URL provided so + // that it's not forgotten. They're used in the authentication process to + // request an access token and similars. + // * Endpoint wrappers: `endpoint_get`, `endpoint_post`, `endpoint_put`, + // `endpoint_delete`. These append the authentication headers for endpoint + // requests to reduce the code needed for endpoints and make them as + // concise as possible. + + #[inline] + async fn get( + &self, + url: &str, + headers: Option<&Headers>, + payload: &Query<'_>, + ) -> ClientResult { + let url = self.endpoint_url(url); + Ok(self.get_http().get(&url, headers, payload).await?) + } + + #[inline] + async fn post( + &self, + url: &str, + headers: Option<&Headers>, + payload: &Value, + ) -> ClientResult { + let url = self.endpoint_url(url); + Ok(self.get_http().post(&url, headers, payload).await?) + } + + #[inline] + async fn post_form( + &self, + url: &str, + headers: Option<&Headers>, + payload: &Form<'_>, + ) -> ClientResult { + let url = self.endpoint_url(url); + Ok(self.get_http().post_form(&url, headers, payload).await?) + } + + #[inline] + async fn put( + &self, + url: &str, + headers: Option<&Headers>, + payload: &Value, + ) -> ClientResult { + let url = self.endpoint_url(url); + Ok(self.get_http().put(&url, headers, payload).await?) + } + + #[inline] + async fn delete( + &self, + url: &str, + headers: Option<&Headers>, + payload: &Value, + ) -> ClientResult { + let url = self.endpoint_url(url); + Ok(self.get_http().delete(&url, headers, payload).await?) + } + + /// The wrapper for the endpoints, which also includes the required + /// autentication. + #[inline] + async fn endpoint_get(&self, url: &str, payload: &Query<'_>) -> ClientResult { + let headers = self.auth_headers()?; + self.get(url, Some(&headers), payload).await + } + + #[inline] + async fn endpoint_post(&self, url: &str, payload: &Value) -> ClientResult { + let headers = self.auth_headers()?; + self.post(url, Some(&headers), payload).await + } + + #[inline] + async fn endpoint_put(&self, url: &str, payload: &Value) -> ClientResult { + let headers = self.auth_headers()?; + self.put(url, Some(&headers), payload).await + } + + #[inline] + async fn endpoint_delete(&self, url: &str, payload: &Value) -> ClientResult { + let headers = self.auth_headers()?; + self.delete(url, Some(&headers), payload).await + } + + /// Updates the cache file at the internal cache path. + /// + /// This should be used whenever it's possible to, even if the cached token + /// isn't configured, because this will already check `Config::token_cached` + /// and do nothing in that case already. + fn write_token_cache(&self) -> ClientResult<()> { + if !self.get_config().token_cached { + return Ok(()); + } + + if let Some(tok) = self.get_token().as_ref() { + tok.write_cache(&self.get_config().cache_path)?; + } + + Ok(()) + } + + /// Sends a request to Spotify for an access token. + async fn fetch_access_token(&self, payload: &Form<'_>) -> ClientResult { + // This request uses a specific content type, and the client ID/secret + // as the authentication, since the access token isn't available yet. + let mut head = Headers::new(); + let (key, val) = basic_auth(&self.get_creds().id, &self.get_creds().secret); + head.insert(key, val); + + let response = self + .post_form(auth_urls::TOKEN, Some(&head), payload) + .await?; + let mut tok = serde_json::from_str::(&response)?; + tok.expires_at = Utc::now().checked_add_signed(tok.expires_in); + Ok(tok) + } + + /// Returns a single track given the track's ID, URI or URL. + /// + /// Parameters: + /// - track_id - a spotify URI, URL or ID + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-track) + async fn track(&self, track_id: &TrackId) -> ClientResult { + let url = format!("tracks/{}", track_id.id()); + let result = self.endpoint_get(&url, &Query::new()).await?; + convert_result(&result) + } + + /// Returns a list of tracks given a list of track IDs, URIs, or URLs. + /// + /// Parameters: + /// - track_ids - a list of spotify URIs, URLs or IDs + /// - market - an ISO 3166-1 alpha-2 country code or the string from_token. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-several-tracks) + async fn tracks<'a>( + &self, + track_ids: impl IntoIterator + 'a, + market: Option<&Market>, + ) -> ClientResult> { + let ids = join_ids(track_ids); + let params = build_map! { + optional "market": market.map(|x| x.as_ref()), + }; + + let url = format!("tracks/?ids={}", ids); + let result = self.endpoint_get(&url, ¶ms).await?; + convert_result::(&result).map(|x| x.tracks) + } + + /// Returns a single artist given the artist's ID, URI or URL. + /// + /// Parameters: + /// - artist_id - an artist ID, URI or URL + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-an-artist) + async fn artist(&self, artist_id: &ArtistId) -> ClientResult { + let url = format!("artists/{}", artist_id.id()); + let result = self.endpoint_get(&url, &Query::new()).await?; + convert_result(&result) + } + + /// Returns a list of artists given the artist IDs, URIs, or URLs. + /// + /// Parameters: + /// - artist_ids - a list of artist IDs, URIs or URLs + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-artists) + async fn artists<'a, Artists: IntoIterator>( + &self, + artist_ids: Artists, + ) -> ClientResult> { + let ids = join_ids(artist_ids); + let url = format!("artists/?ids={}", ids); + let result = self.endpoint_get(&url, &Query::new()).await?; + + convert_result::(&result).map(|x| x.artists) + } + + /// Get Spotify catalog information about an artist's albums. + /// + /// Parameters: + /// - artist_id - the artist ID, URI or URL + /// - album_type - 'album', 'single', 'appears_on', 'compilation' + /// - market - limit the response to one particular country. + /// - limit - the number of albums to return + /// - offset - the index of the first album to return + /// + /// See [`Self::artist_albums_manual`] for a manually paginated version of + /// this. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-an-artists-albums) + fn artist_albums<'a>( + &'a self, + artist_id: &'a ArtistId, + album_type: Option<&'a AlbumType>, + market: Option<&'a Market>, + ) -> Paginator<'_, ClientResult> { + paginate( + move |limit, offset| { + self.artist_albums_manual(artist_id, album_type, market, Some(limit), Some(offset)) + }, + self.get_config().pagination_chunks, + ) + } + + /// The manually paginated version of [`Self::artist_albums`]. + async fn artist_albums_manual( + &self, + artist_id: &ArtistId, + album_type: Option<&AlbumType>, + market: Option<&Market>, + limit: Option, + offset: Option, + ) -> ClientResult> { + let limit = limit.map(|x| x.to_string()); + let offset = offset.map(|x| x.to_string()); + let params = build_map! { + optional "album_type": album_type.map(|x| x.as_ref()), + optional "market": market.map(|x| x.as_ref()), + optional "limit": limit.as_deref(), + optional "offset": offset.as_deref(), + }; + + let url = format!("artists/{}/albums", artist_id.id()); + let result = self.endpoint_get(&url, ¶ms).await?; + convert_result(&result) + } + + /// Get Spotify catalog information about an artist's top 10 tracks by + /// country. + /// + /// Parameters: + /// - artist_id - the artist ID, URI or URL + /// - market - limit the response to one particular country. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-an-artists-top-tracks) + async fn artist_top_tracks( + &self, + artist_id: &ArtistId, + market: &Market, + ) -> ClientResult> { + let params = build_map! { + "market": market.as_ref() + }; + + let url = format!("artists/{}/top-tracks", artist_id.id()); + let result = self.endpoint_get(&url, ¶ms).await?; + convert_result::(&result).map(|x| x.tracks) + } + + /// Get Spotify catalog information about artists similar to an identified + /// artist. Similarity is based on analysis of the Spotify community's + /// listening history. + /// + /// Parameters: + /// - artist_id - the artist ID, URI or URL + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-an-artists-related-artists) + async fn artist_related_artists(&self, artist_id: &ArtistId) -> ClientResult> { + let url = format!("artists/{}/related-artists", artist_id.id()); + let result = self.endpoint_get(&url, &Query::new()).await?; + convert_result::(&result).map(|x| x.artists) + } + + /// Returns a single album given the album's ID, URIs or URL. + /// + /// Parameters: + /// - album_id - the album ID, URI or URL + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-an-album) + async fn album(&self, album_id: &AlbumId) -> ClientResult { + let url = format!("albums/{}", album_id.id()); + + let result = self.endpoint_get(&url, &Query::new()).await?; + convert_result(&result) + } + + /// Returns a list of albums given the album IDs, URIs, or URLs. + /// + /// Parameters: + /// - albums_ids - a list of album IDs, URIs or URLs + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-albums) + async fn albums<'a, Albums: IntoIterator>( + &self, + album_ids: Albums, + ) -> ClientResult> { + let ids = join_ids(album_ids); + let url = format!("albums/?ids={}", ids); + let result = self.endpoint_get(&url, &Query::new()).await?; + convert_result::(&result).map(|x| x.albums) + } + + /// Search for an Item. Get Spotify catalog information about artists, + /// albums, tracks or playlists that match a keyword string. + /// + /// Parameters: + /// - q - the search query + /// - limit - the number of items to return + /// - offset - the index of the first item to return + /// - type - the type of item to return. One of 'artist', 'album', 'track', + /// 'playlist', 'show' or 'episode' + /// - market - An ISO 3166-1 alpha-2 country code or the string from_token. + /// - include_external: Optional.Possible values: audio. If + /// include_external=audio is specified the response will include any + /// relevant audio content that is hosted externally. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#category-search) + async fn search( + &self, + q: &str, + _type: &SearchType, + market: Option<&Market>, + include_external: Option<&IncludeExternal>, + limit: Option, + offset: Option, + ) -> ClientResult { + let limit = limit.map(|s| s.to_string()); + let offset = offset.map(|s| s.to_string()); + let params = build_map! { + "q": q, + "type": _type.as_ref(), + optional "market": market.map(|x| x.as_ref()), + optional "include_external": include_external.map(|x| x.as_ref()), + optional "limit": limit.as_deref(), + optional "offset": offset.as_deref(), + }; + + let result = self.endpoint_get("search", ¶ms).await?; + convert_result(&result) + } + + /// Get Spotify catalog information about an album's tracks. + /// + /// Parameters: + /// - album_id - the album ID, URI or URL + /// - limit - the number of items to return + /// - offset - the index of the first item to return + /// + /// See [`Self::album_track_manual`] for a manually paginated version of + /// this. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-an-albums-tracks) + fn album_track<'a>( + &'a self, + album_id: &'a AlbumId, + ) -> Paginator<'_, ClientResult> { + paginate( + move |limit, offset| self.album_track_manual(album_id, Some(limit), Some(offset)), + self.get_config().pagination_chunks, + ) + } + + /// The manually paginated version of [`Self::album_track`]. + async fn album_track_manual( + &self, + album_id: &AlbumId, + limit: Option, + offset: Option, + ) -> ClientResult> { + let limit = limit.map(|s| s.to_string()); + let offset = offset.map(|s| s.to_string()); + let params = build_map! { + optional "limit": limit.as_deref(), + optional "offset": offset.as_deref(), + }; + + let url = format!("albums/{}/tracks", album_id.id()); + let result = self.endpoint_get(&url, ¶ms).await?; + convert_result(&result) + } + + /// Gets basic profile information about a Spotify User. + /// + /// Parameters: + /// - user - the id of the usr + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-users-profile) + async fn user(&self, user_id: &UserId) -> ClientResult { + let url = format!("users/{}", user_id.id()); + let result = self.endpoint_get(&url, &Query::new()).await?; + convert_result(&result) + } + + /// Get full details about Spotify playlist. + /// + /// Parameters: + /// - playlist_id - the id of the playlist + /// - market - an ISO 3166-1 alpha-2 country code or the string from_token. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-playlist) + async fn playlist( + &self, + playlist_id: &PlaylistId, + fields: Option<&str>, + market: Option<&Market>, + ) -> ClientResult { + let params = build_map! { + optional "fields": fields, + optional "market": market.map(|x| x.as_ref()), + }; + + let url = format!("playlists/{}", playlist_id.id()); + let result = self.endpoint_get(&url, ¶ms).await?; + convert_result(&result) + } + + /// Gets playlist of a user. + /// + /// Parameters: + /// - user_id - the id of the user + /// - playlist_id - the id of the playlist + /// - fields - which fields to return + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-list-users-playlists) + async fn user_playlist( + &self, + user_id: &UserId, + playlist_id: Option<&PlaylistId>, + fields: Option<&str>, + ) -> ClientResult { + let params = build_map! { + optional "fields": fields, + }; + + let url = match playlist_id { + Some(playlist_id) => format!("users/{}/playlists/{}", user_id.id(), playlist_id.id()), + None => format!("users/{}/starred", user_id.id()), + }; + let result = self.endpoint_get(&url, ¶ms).await?; + convert_result(&result) + } + + /// Check to see if the given users are following the given playlist. + /// + /// Parameters: + /// - playlist_id - the id of the playlist + /// - user_ids - the ids of the users that you want to + /// check to see if they follow the playlist. Maximum: 5 ids. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-check-if-user-follows-playlist) + async fn playlist_check_follow( + &self, + playlist_id: &PlaylistId, + user_ids: &[&UserId], + ) -> ClientResult> { + if user_ids.len() > 5 { + log::error!("The maximum length of user ids is limited to 5 :-)"); + } + let url = format!( + "playlists/{}/followers/contains?ids={}", + playlist_id.id(), + user_ids + .iter() + .map(|id| id.id()) + .collect::>() + .join(","), + ); + let result = self.endpoint_get(&url, &Query::new()).await?; + convert_result(&result) + } + + /// Get Spotify catalog information for a single show identified by its unique Spotify ID. + /// + /// Path Parameters: + /// - id: The Spotify ID for the show. + /// + /// Query Parameters + /// - market(Optional): An ISO 3166-1 alpha-2 country code or the string from_token. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-a-show) + async fn get_a_show(&self, id: &ShowId, market: Option<&Market>) -> ClientResult { + let params = build_map! { + optional "market": market.map(|x| x.as_ref()), + }; + + let url = format!("shows/{}", id.id()); + let result = self.endpoint_get(&url, ¶ms).await?; + convert_result(&result) + } + + /// Get Spotify catalog information for multiple shows based on their + /// Spotify IDs. + /// + /// Query Parameters + /// - ids(Required) A comma-separated list of the Spotify IDs for the shows. Maximum: 50 IDs. + /// - market(Optional) An ISO 3166-1 alpha-2 country code or the string from_token. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-shows) + async fn get_several_shows<'a>( + &self, + ids: impl IntoIterator + 'a, + market: Option<&Market>, + ) -> ClientResult> { + let ids = join_ids(ids); + let params = build_map! { + "ids": &ids, + optional "market": market.map(|x| x.as_ref()), + }; + + let result = self.endpoint_get("shows", ¶ms).await?; + convert_result::(&result).map(|x| x.shows) + } + + /// Get Spotify catalog information about an show’s episodes. Optional + /// parameters can be used to limit the number of episodes returned. + /// + /// Path Parameters + /// - id: The Spotify ID for the show. + /// + /// Query Parameters + /// - limit: Optional. The maximum number of episodes to return. Default: 20. Minimum: 1. Maximum: 50. + /// - offset: Optional. The index of the first episode to return. Default: 0 (the first object). Use with limit to get the next set of episodes. + /// - market: Optional. An ISO 3166-1 alpha-2 country code or the string from_token. + /// + /// See [`Self::get_shows_episodes_manual`] for a manually paginated version + /// of this. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-a-shows-episodes) + fn get_shows_episodes<'a>( + &'a self, + id: &'a ShowId, + market: Option<&'a Market>, + ) -> Paginator<'_, ClientResult> { + paginate( + move |limit, offset| { + self.get_shows_episodes_manual(id, market, Some(limit), Some(offset)) + }, + self.get_config().pagination_chunks, + ) + } + + /// The manually paginated version of [`Self::get_shows_episodes`]. + async fn get_shows_episodes_manual( + &self, + id: &ShowId, + market: Option<&Market>, + limit: Option, + offset: Option, + ) -> ClientResult> { + let limit = limit.map(|x| x.to_string()); + let offset = offset.map(|x| x.to_string()); + let params = build_map! { + optional "market": market.map(|x| x.as_ref()), + optional "limit": limit.as_ref(), + optional "offset": offset.as_ref(), + }; + + let url = format!("shows/{}/episodes", id.id()); + let result = self.endpoint_get(&url, ¶ms).await?; + convert_result(&result) + } + + /// Get Spotify catalog information for a single episode identified by its unique Spotify ID. + /// + /// Path Parameters + /// - id: The Spotify ID for the episode. + /// + /// Query Parameters + /// - market: Optional. An ISO 3166-1 alpha-2 country code or the string from_token. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-an-episode) + async fn get_an_episode( + &self, + id: &EpisodeId, + market: Option<&Market>, + ) -> ClientResult { + let url = format!("episodes/{}", id.id()); + let params = build_map! { + optional "market": market.map(|x| x.as_ref()), + }; + + let result = self.endpoint_get(&url, ¶ms).await?; + convert_result(&result) + } + + /// Get Spotify catalog information for multiple episodes based on their Spotify IDs. + /// + /// Query Parameters + /// - ids: Required. A comma-separated list of the Spotify IDs for the episodes. Maximum: 50 IDs. + /// - market: Optional. An ISO 3166-1 alpha-2 country code or the string from_token. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-episodes) + async fn get_several_episodes<'a>( + &self, + ids: impl IntoIterator + 'a, + market: Option<&Market>, + ) -> ClientResult> { + let ids = join_ids(ids); + let params = build_map! { + "ids": &ids, + optional "market": market.map(|x| x.as_ref()), + }; + + let result = self.endpoint_get("episodes", ¶ms).await?; + convert_result::(&result).map(|x| x.episodes) + } + + /// Get audio features for a track + /// + /// Parameters: + /// - track - track URI, URL or ID + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-audio-features) + async fn track_features(&self, track_id: &TrackId) -> ClientResult { + let url = format!("audio-features/{}", track_id.id()); + let result = self.endpoint_get(&url, &Query::new()).await?; + convert_result(&result) + } + + /// Get Audio Features for Several Tracks + /// + /// Parameters: + /// - tracks a list of track URIs, URLs or IDs + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-several-audio-features) + async fn tracks_features<'a>( + &self, + track_ids: impl IntoIterator + 'a, + ) -> ClientResult>> { + let url = format!("audio-features/?ids={}", join_ids(track_ids)); + + let result = self.endpoint_get(&url, &Query::new()).await?; + if result.is_empty() { + Ok(None) + } else { + convert_result::>(&result) + .map(|option_payload| option_payload.map(|x| x.audio_features)) + } + } + + /// Get Audio Analysis for a Track + /// + /// Parameters: + /// - track_id - a track URI, URL or ID + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-audio-analysis) + async fn track_analysis(&self, track_id: &TrackId) -> ClientResult { + let url = format!("audio-analysis/{}", track_id.id()); + let result = self.endpoint_get(&url, &Query::new()).await?; + convert_result(&result) + } + + /// Get a list of new album releases featured in Spotify + /// + /// Parameters: + /// - country - An ISO 3166-1 alpha-2 country code or string from_token. + /// - locale - The desired language, consisting of an ISO 639 language code + /// and an ISO 3166-1 alpha-2 country code, joined by an underscore. + /// - limit - The maximum number of items to return. Default: 20. + /// Minimum: 1. Maximum: 50 + /// - offset - The index of the first item to return. Default: 0 (the first + /// object). Use with limit to get the next set of items. + /// + /// See [`Self::categories_manual`] for a manually paginated version of + /// this. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-categories) + fn categories<'a>( + &'a self, + locale: Option<&'a str>, + country: Option<&'a Market>, + ) -> Paginator<'_, ClientResult> { + paginate( + move |limit, offset| self.categories_manual(locale, country, Some(limit), Some(offset)), + self.get_config().pagination_chunks, + ) + } + + /// The manually paginated version of [`Self::categories`]. + async fn categories_manual( + &self, + locale: Option<&str>, + country: Option<&Market>, + limit: Option, + offset: Option, + ) -> ClientResult> { + let limit = limit.map(|x| x.to_string()); + let offset = offset.map(|x| x.to_string()); + let params = build_map! { + optional "locale": locale, + optional "country": country.map(|x| x.as_ref()), + optional "limit": limit.as_deref(), + optional "offset": offset.as_deref(), + }; + let result = self.endpoint_get("browse/categories", ¶ms).await?; + convert_result::(&result).map(|x| x.categories) + } + + /// Get a list of playlists in a category in Spotify + /// + /// Parameters: + /// - category_id - The category id to get playlists from. + /// - country - An ISO 3166-1 alpha-2 country code or the string from_token. + /// - limit - The maximum number of items to return. Default: 20. + /// Minimum: 1. Maximum: 50 + /// - offset - The index of the first item to return. Default: 0 (the first + /// object). Use with limit to get the next set of items. + /// + /// See [`Self::category_playlists_manual`] for a manually paginated version + /// of this. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-a-categories-playlists) + fn category_playlists<'a>( + &'a self, + category_id: &'a str, + country: Option<&'a Market>, + ) -> Paginator<'_, ClientResult> { + paginate( + move |limit, offset| { + self.category_playlists_manual(category_id, country, Some(limit), Some(offset)) + }, + self.get_config().pagination_chunks, + ) + } + + /// The manually paginated version of [`Self::category_playlists`]. + async fn category_playlists_manual( + &self, + category_id: &str, + country: Option<&Market>, + limit: Option, + offset: Option, + ) -> ClientResult> { + let limit = limit.map(|x| x.to_string()); + let offset = offset.map(|x| x.to_string()); + let params = build_map! { + optional "country": country.map(|x| x.as_ref()), + optional "limit": limit.as_deref(), + optional "offset": offset.as_deref(), + }; + + let url = format!("browse/categories/{}/playlists", category_id); + let result = self.endpoint_get(&url, ¶ms).await?; + convert_result::(&result).map(|x| x.playlists) + } + + /// Get a list of Spotify featured playlists. + /// + /// Parameters: + /// - locale - The desired language, consisting of a lowercase ISO 639 + /// language code and an uppercase ISO 3166-1 alpha-2 country code, + /// joined by an underscore. + /// - country - An ISO 3166-1 alpha-2 country code or the string from_token. + /// - timestamp - A timestamp in ISO 8601 format: yyyy-MM-ddTHH:mm:ss. Use + /// this parameter to specify the user's local time to get results + /// tailored for that specific date and time in the day + /// - limit - The maximum number of items to return. Default: 20. + /// Minimum: 1. Maximum: 50 + /// - offset - The index of the first item to return. Default: 0 + /// (the first object). Use with limit to get the next set of + /// items. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-featured-playlists) + async fn featured_playlists( + &self, + locale: Option<&str>, + country: Option<&Market>, + timestamp: Option<&chrono::DateTime>, + limit: Option, + offset: Option, + ) -> ClientResult { + let limit = limit.map(|x| x.to_string()); + let offset = offset.map(|x| x.to_string()); + let timestamp = timestamp.map(|x| x.to_rfc3339()); + let params = build_map! { + optional "locale": locale, + optional "country": country.map(|x| x.as_ref()), + optional "timestamp": timestamp.as_deref(), + optional "limit": limit.as_deref(), + optional "offset": offset.as_deref(), + }; + + let result = self + .endpoint_get("browse/featured-playlists", ¶ms) + .await?; + convert_result(&result) + } + + /// Get a list of new album releases featured in Spotify. + /// + /// Parameters: + /// - country - An ISO 3166-1 alpha-2 country code or string from_token. + /// - limit - The maximum number of items to return. Default: 20. + /// Minimum: 1. Maximum: 50 + /// - offset - The index of the first item to return. Default: 0 (the first + /// object). Use with limit to get the next set of items. + /// + /// See [`Self::new_releases_manual`] for a manually paginated version of + /// this. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-new-releases) + fn new_releases<'a>( + &'a self, + country: Option<&'a Market>, + ) -> Paginator<'_, ClientResult> { + paginate( + move |limit, offset| self.new_releases_manual(country, Some(limit), Some(offset)), + self.get_config().pagination_chunks, + ) + } + + /// The manually paginated version of [`Self::new_releases`]. + async fn new_releases_manual( + &self, + country: Option<&Market>, + limit: Option, + offset: Option, + ) -> ClientResult> { + let limit = limit.map(|x| x.to_string()); + let offset = offset.map(|x| x.to_string()); + let params = build_map! { + optional "country": country.map(|x| x.as_ref()), + optional "limit": limit.as_deref(), + optional "offset": offset.as_deref(), + }; + + let result = self.endpoint_get("browse/new-releases", ¶ms).await?; + convert_result::(&result).map(|x| x.albums) + } + + /// Get Recommendations Based on Seeds + /// + /// Parameters: + /// - seed_artists - a list of artist IDs, URIs or URLs + /// - seed_tracks - a list of artist IDs, URIs or URLs + /// - seed_genres - a list of genre names. Available genres for + /// - market - An ISO 3166-1 alpha-2 country code or the string from_token. If provided, all + /// results will be playable in this country. + /// - limit - The maximum number of items to return. Default: 20. + /// Minimum: 1. Maximum: 100 + /// - min/max/target_ - For the tuneable track attributes listed + /// in the documentation, these values provide filters and targeting on + /// results. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-recommendations) + async fn recommendations<'a>( + &self, + payload: &Map, + seed_artists: Option + 'a>, + seed_genres: Option + 'a>, + seed_tracks: Option + 'a>, + market: Option<&Market>, + limit: Option, + ) -> ClientResult { + let seed_artists = seed_artists.map(join_ids); + let seed_genres = seed_genres.map(|x| x.into_iter().collect::>().join(",")); + let seed_tracks = seed_tracks.map(join_ids); + let limit = limit.map(|x| x.to_string()); + let mut params = build_map! { + optional "seed_artists": seed_artists.as_ref(), + optional "seed_genres": seed_genres.as_ref(), + optional "seed_tracks": seed_tracks.as_ref(), + optional "market": market.map(|x| x.as_ref()), + optional "limit": limit.as_ref(), + }; + + let attributes = [ + "acousticness", + "danceability", + "duration_ms", + "energy", + "instrumentalness", + "key", + "liveness", + "loudness", + "mode", + "popularity", + "speechiness", + "tempo", + "time_signature", + "valence", + ]; + let prefixes = ["min", "max", "target"]; + + // This map is used to store the intermediate data which lives long enough + // to be borrowed into the `params` + let map_to_hold_owned_value = attributes + .iter() + // create cartesian product for attributes and prefixes + .flat_map(|attribute| { + prefixes + .iter() + .map(move |prefix| format!("{}_{}", prefix, attribute)) + }) + .filter_map( + // TODO: not sure if this `to_string` is what we want. It + // might add quotes to the strings. + |param| payload.get(¶m).map(|value| (param, value.to_string())), + ) + .collect::>(); + + for (ref key, ref value) in &map_to_hold_owned_value { + params.insert(key, value); + } + + let result = self.endpoint_get("recommendations", ¶ms).await?; + convert_result(&result) + } + + /// Get full details of the tracks of a playlist owned by a user. + /// + /// Parameters: + /// - playlist_id - the id of the playlist + /// - fields - which fields to return + /// - limit - the maximum number of tracks to return + /// - offset - the index of the first track to return + /// - market - an ISO 3166-1 alpha-2 country code or the string from_token. + /// + /// See [`Self::playlist_tracks`] for a manually paginated version of this. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-playlists-tracks) + fn playlist_tracks<'a>( + &'a self, + playlist_id: &'a PlaylistId, + fields: Option<&'a str>, + market: Option<&'a Market>, + ) -> Paginator<'_, ClientResult> { + paginate( + move |limit, offset| { + self.playlist_tracks_manual(playlist_id, fields, market, Some(limit), Some(offset)) + }, + self.get_config().pagination_chunks, + ) + } + + /// The manually paginated version of [`Self::playlist_tracks`]. + async fn playlist_tracks_manual( + &self, + playlist_id: &PlaylistId, + fields: Option<&str>, + market: Option<&Market>, + limit: Option, + offset: Option, + ) -> ClientResult> { + let limit = limit.map(|s| s.to_string()); + let offset = offset.map(|s| s.to_string()); + let params = build_map! { + optional "fields": fields, + optional "market": market.map(|x| x.as_ref()), + optional "limit": limit.as_deref(), + optional "offset": offset.as_deref(), + }; + + let url = format!("playlists/{}/tracks", playlist_id.id()); + let result = self.endpoint_get(&url, ¶ms).await?; + convert_result(&result) + } + + /// Gets playlists of a user. + /// + /// Parameters: + /// - user_id - the id of the usr + /// - limit - the number of items to return + /// - offset - the index of the first item to return + /// + /// See [`Self::user_playlists_manual`] for a manually paginated version of + /// this. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-list-users-playlists) + fn user_playlists<'a>( + &'a self, + user_id: &'a UserId, + ) -> Paginator<'_, ClientResult> { + paginate( + move |limit, offset| self.user_playlists_manual(user_id, Some(limit), Some(offset)), + self.get_config().pagination_chunks, + ) + } + + /// The manually paginated version of [`Self::user_playlists`]. + async fn user_playlists_manual( + &self, + user_id: &UserId, + limit: Option, + offset: Option, + ) -> ClientResult> { + let limit = limit.map(|s| s.to_string()); + let offset = offset.map(|s| s.to_string()); + let params = build_map! { + optional "limit": limit.as_deref(), + optional "offset": offset.as_deref(), + }; + + let url = format!("users/{}/playlists", user_id.id()); + let result = self.endpoint_get(&url, ¶ms).await?; + convert_result(&result) + } +} diff --git a/src/clients/mod.rs b/src/clients/mod.rs new file mode 100644 index 00000000..dcd98bbe --- /dev/null +++ b/src/clients/mod.rs @@ -0,0 +1,136 @@ +pub mod base; +pub mod oauth; +pub mod pagination; + +pub use base::BaseClient; +pub use oauth::OAuthClient; + +use crate::{ + model::{idtypes::IdType, Id}, + ClientResult, Token, +}; + +use serde::Deserialize; + +/// Converts a JSON response from Spotify into its model. +pub(in crate) fn convert_result<'a, T: Deserialize<'a>>(input: &'a str) -> ClientResult { + serde_json::from_str::(input).map_err(Into::into) +} + +/// Append device ID to an API path. +pub(in crate) fn append_device_id(path: &str, device_id: Option<&str>) -> String { + let mut new_path = path.to_string(); + if let Some(_device_id) = device_id { + if path.contains('?') { + new_path.push_str(&format!("&device_id={}", _device_id)); + } else { + new_path.push_str(&format!("?device_id={}", _device_id)); + } + } + new_path +} + +// TODO: move to `lib.rs` +#[inline] +pub(in crate) fn join_ids<'a, T: 'a + IdType>(ids: impl IntoIterator>) -> String { + ids.into_iter().collect::>().join(",") +} + +// TODO: move to `lib.rs` or integrate into Token. +/// Generates an HTTP token authorization header with proper formatting +pub fn bearer_auth(tok: &Token) -> (String, String) { + let auth = "authorization".to_owned(); + let value = format!("Bearer {}", tok.access_token); + + (auth, value) +} + +// TODO: move to `lib.rs` or integrate into Credentials. +/// Generates an HTTP basic authorization header with proper formatting +pub fn basic_auth(user: &str, password: &str) -> (String, String) { + let auth = "authorization".to_owned(); + let value = format!("{}:{}", user, password); + let value = format!("Basic {}", base64::encode(value)); + + (auth, value) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::{scopes, ClientCredsSpotify, Token}; + use chrono::{prelude::*, Duration}; + + #[test] + fn test_append_device_id_without_question_mark() { + let path = "me/player/play"; + let device_id = Some("fdafdsadfa"); + let new_path = append_device_id(path, device_id); + assert_eq!(new_path, "me/player/play?device_id=fdafdsadfa"); + } + + #[test] + fn test_append_device_id_with_question_mark() { + let path = "me/player/shuffle?state=true"; + let device_id = Some("fdafdsadfa"); + let new_path = append_device_id(path, device_id); + assert_eq!( + new_path, + "me/player/shuffle?state=true&device_id=fdafdsadfa" + ); + } + + #[test] + fn test_bearer_auth() { + let tok = Token { + access_token: "access_token".to_string(), + ..Default::default() + }; + + let (auth, value) = bearer_auth(&tok); + assert_eq!(auth, "authorization"); + assert_eq!(value, "Bearer access_token"); + } + + #[test] + fn test_basic_auth() { + let (auth, value) = basic_auth("ramsay", "123456"); + assert_eq!(auth, "authorization"); + assert_eq!(value, "Basic cmFtc2F5OjEyMzQ1Ng=="); + } + + #[test] + fn test_endpoint_url() { + let spotify = ClientCredsSpotify::default(); + assert_eq!( + spotify.endpoint_url("me/player/play"), + "https://api.spotify.com/v1/me/player/play" + ); + assert_eq!( + spotify.endpoint_url("http://api.spotify.com/v1/me/player/play"), + "http://api.spotify.com/v1/me/player/play" + ); + assert_eq!( + spotify.endpoint_url("https://api.spotify.com/v1/me/player/play"), + "https://api.spotify.com/v1/me/player/play" + ); + } + + #[test] + fn test_auth_headers() { + let tok = Token { + access_token: "test-access_token".to_string(), + expires_in: Duration::seconds(1), + expires_at: Some(Utc::now()), + scopes: scopes!("playlist-read-private"), + refresh_token: Some("...".to_string()), + }; + + let spotify = ClientCredsSpotify::from_token(tok); + let headers = spotify.auth_headers().unwrap(); + assert_eq!( + headers.get("authorization"), + Some(&"Bearer test-access_token".to_owned()) + ); + } +} diff --git a/src/clients/oauth.rs b/src/clients/oauth.rs new file mode 100644 index 00000000..997b5335 --- /dev/null +++ b/src/clients/oauth.rs @@ -0,0 +1,1202 @@ +use crate::{ + clients::{ + append_device_id, convert_result, join_ids, + pagination::{paginate, Paginator}, + BaseClient, + }, + http::Query, + macros::{build_json, build_map}, + model::*, + ClientResult, OAuth, Token, +}; + +use std::time; + +use log::error; +use maybe_async::maybe_async; +use rspotify_model::idtypes::PlayContextIdType; +use serde_json::{json, Map}; +use url::Url; + +/// This trait implements the methods available strictly to clients with user +/// authorization, including some parts of the authentication flow that are +/// shared, and the endpoints. +/// +/// Note that the base trait [`BaseClient`](crate::clients::BaseClient) may +/// have endpoints that conditionally require authorization like +/// [`user_playlist`](crate::clients::BaseClient::user_playlist). This trait +/// only separates endpoints that *always* need authorization from the base +/// ones. +#[maybe_async(?Send)] +pub trait OAuthClient: BaseClient { + fn get_oauth(&self) -> &OAuth; + + /// Obtains a user access token given a code, as part of the OAuth + /// authentication. The access token will be saved internally. + async fn request_token(&mut self, code: &str) -> ClientResult<()>; + + /// Refreshes the current access token given a refresh token. The obtained + /// token will be saved internally. + async fn refresh_token(&mut self, refresh_token: &str) -> ClientResult<()>; + + /// Tries to read the cache file's token, which may not exist. + async fn read_token_cache(&mut self) -> Option { + let tok = Token::from_cache(&self.get_config().cache_path)?; + + if !self.get_oauth().scopes.is_subset(&tok.scopes) || tok.is_expired() { + // Invalid token, since it doesn't have at least the currently + // required scopes or it's expired. + None + } else { + Some(tok) + } + } + + /// Parse the response code in the given response url. If the URL cannot be + /// parsed or the `code` parameter is not present, this will return `None`. + fn parse_response_code(&self, url: &str) -> Option { + let url = Url::parse(url).ok()?; + let mut params = url.query_pairs(); + let (_, url) = params.find(|(key, _)| key == "code")?; + Some(url.to_string()) + } + + /// Tries to open the authorization URL in the user's browser, and returns + /// the obtained code. + /// + /// Note: this method requires the `cli` feature. + #[cfg(feature = "cli")] + fn get_code_from_user(&self, url: &str) -> ClientResult { + use crate::ClientError; + + match webbrowser::open(&url) { + Ok(_) => println!("Opened {} in your browser.", url), + Err(why) => eprintln!( + "Error when trying to open an URL in your browser: {:?}. \ + Please navigate here manually: {}", + why, url + ), + } + + println!("Please enter the URL you were redirected to: "); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let code = self + .parse_response_code(&input) + .ok_or_else(|| ClientError::Cli("unable to parse the response code".to_string()))?; + + Ok(code) + } + + /// Opens up the authorization URL in the user's browser so that it can + /// authenticate. It also reads from the standard input the redirect URI + /// in order to obtain the access token information. The resulting access + /// token will be saved internally once the operation is successful. + /// + /// Note: this method requires the `cli` feature. + #[cfg(feature = "cli")] + #[maybe_async] + async fn prompt_for_token(&mut self, url: &str) -> ClientResult<()> { + match self.read_token_cache().await { + // TODO: shouldn't this also refresh the obtained token? + Some(ref mut new_token) => { + self.get_token_mut().replace(new_token); + } + // Otherwise following the usual procedure to get the token. + None => { + let code = self.get_code_from_user(url)?; + // Will write to the cache file if successful + self.request_token(&code).await?; + } + } + + self.write_token_cache() + } + + /// Get current user playlists without required getting his profile. + /// + /// Parameters: + /// - limit - the number of items to return + /// - offset - the index of the first item to return + /// + /// See [`Self::current_user_playlists_manual`] for a manually paginated + /// version of this. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-a-list-of-current-users-playlists) + fn current_user_playlists(&self) -> Paginator<'_, ClientResult> { + paginate( + move |limit, offset| self.current_user_playlists_manual(Some(limit), Some(offset)), + self.get_config().pagination_chunks, + ) + } + + /// The manually paginated version of [`Self::current_user_playlists`]. + async fn current_user_playlists_manual( + &self, + limit: Option, + offset: Option, + ) -> ClientResult> { + let limit = limit.map(|s| s.to_string()); + let offset = offset.map(|s| s.to_string()); + let params = build_map! { + optional "limit": limit.as_deref(), + optional "offset": offset.as_deref(), + }; + + let result = self.endpoint_get("me/playlists", ¶ms).await?; + convert_result(&result) + } + + /// Creates a playlist for a user. + /// + /// Parameters: + /// - user_id - the id of the user + /// - name - the name of the playlist + /// - public - is the created playlist public + /// - description - the description of the playlist + /// - collaborative - if the playlist will be collaborative + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-create-playlist) + async fn user_playlist_create( + &self, + user_id: &UserId, + name: &str, + public: Option, + collaborative: Option, + description: Option<&str>, + ) -> ClientResult { + let params = build_json! { + "name": name, + optional "public": public, + optional "collaborative": collaborative, + optional "description": description, + }; + + let url = format!("users/{}/playlists", user_id.id()); + let result = self.endpoint_post(&url, ¶ms).await?; + convert_result(&result) + } + + /// Changes a playlist's name and/or public/private state. + /// + /// Parameters: + /// - playlist_id - the id of the playlist + /// - name - optional name of the playlist + /// - public - optional is the playlist public + /// - collaborative - optional is the playlist collaborative + /// - description - optional description of the playlist + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-change-playlist-details) + async fn playlist_change_detail( + &self, + playlist_id: &str, + name: Option<&str>, + public: Option, + description: Option<&str>, + collaborative: Option, + ) -> ClientResult { + let params = build_json! { + optional "name": name, + optional "public": public, + optional "collaborative": collaborative, + optional "description": description, + }; + + let url = format!("playlists/{}", playlist_id); + self.endpoint_put(&url, ¶ms).await + } + + /// Unfollows (deletes) a playlist for a user. + /// + /// Parameters: + /// - playlist_id - the id of the playlist + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-unfollow-playlist) + async fn playlist_unfollow(&self, playlist_id: &str) -> ClientResult { + let url = format!("playlists/{}/followers", playlist_id); + self.endpoint_delete(&url, &json!({})).await + } + + /// Adds tracks to a playlist. + /// + /// Parameters: + /// - playlist_id - the id of the playlist + /// - track_ids - a list of track URIs, URLs or IDs + /// - position - the position to add the tracks + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-add-tracks-to-playlist) + async fn playlist_add_tracks<'a>( + &self, + playlist_id: &PlaylistId, + track_ids: impl IntoIterator + 'a, + position: Option, + ) -> ClientResult { + let uris = track_ids.into_iter().map(|id| id.uri()).collect::>(); + let params = build_json! { + "uris": uris, + optional "position": position, + }; + + let url = format!("playlists/{}/tracks", playlist_id.id()); + let result = self.endpoint_post(&url, ¶ms).await?; + convert_result(&result) + } + + /// Replace all tracks in a playlist + /// + /// Parameters: + /// - user - the id of the user + /// - playlist_id - the id of the playlist + /// - tracks - the list of track ids to add to the playlist + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-reorder-or-replace-playlists-tracks) + async fn playlist_replace_tracks<'a>( + &self, + playlist_id: &PlaylistId, + track_ids: impl IntoIterator + 'a, + ) -> ClientResult<()> { + let uris = track_ids.into_iter().map(|id| id.uri()).collect::>(); + let params = build_json! { + "uris": uris + }; + + let url = format!("playlists/{}/tracks", playlist_id.id()); + self.endpoint_put(&url, ¶ms).await?; + + Ok(()) + } + + /// Reorder tracks in a playlist. + /// + /// Parameters: + /// - playlist_id - the id of the playlist + /// - uris - a list of Spotify URIs to replace or clear + /// - range_start - the position of the first track to be reordered + /// - insert_before - the position where the tracks should be inserted + /// - range_length - optional the number of tracks to be reordered (default: + /// 1) + /// - snapshot_id - optional playlist's snapshot ID + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-reorder-or-replace-playlists-tracks) + async fn playlist_reorder_tracks<'a, T: PlayableIdType + 'a>( + &self, + playlist_id: &PlaylistId, + uris: Option> + 'a>, + range_start: Option, + insert_before: Option, + range_length: Option, + snapshot_id: Option<&str>, + ) -> ClientResult { + let uris = uris.map(|u| u.into_iter().map(|id| id.uri()).collect::>()); + let params = build_json! { + optional "uris": uris, + optional "range_start": range_start, + optional "insert_before": insert_before, + optional "range_length": range_length, + optional "snapshot_id": snapshot_id, + }; + + let url = format!("playlists/{}/tracks", playlist_id.id()); + let result = self.endpoint_put(&url, ¶ms).await?; + convert_result(&result) + } + + /// Removes all occurrences of the given tracks from the given playlist. + /// + /// Parameters: + /// - playlist_id - the id of the playlist + /// - track_ids - the list of track ids to add to the playlist + /// - snapshot_id - optional id of the playlist snapshot + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-remove-tracks-playlist) + async fn playlist_remove_all_occurrences_of_tracks<'a>( + &self, + playlist_id: &PlaylistId, + track_ids: impl IntoIterator + 'a, + snapshot_id: Option<&str>, + ) -> ClientResult { + let tracks = track_ids + .into_iter() + .map(|id| { + let mut map = Map::with_capacity(1); + map.insert("uri".to_owned(), id.uri().into()); + map + }) + .collect::>(); + + let params = build_json! { + "tracks": tracks, + optional "snapshot_id": snapshot_id, + }; + + let url = format!("playlists/{}/tracks", playlist_id.id()); + let result = self.endpoint_delete(&url, ¶ms).await?; + convert_result(&result) + } + + /// Removes specfic occurrences of the given tracks from the given playlist. + /// + /// Parameters: + /// - playlist_id: the id of the playlist + /// - tracks: an array of map containing Spotify URIs of the tracks to + /// remove with their current positions in the playlist. For example: + /// + /// ```json + /// { + /// "tracks":[ + /// { + /// "uri":"spotify:track:4iV5W9uYEdYUVa79Axb7Rh", + /// "positions":[ + /// 0, + /// 3 + /// ] + /// }, + /// { + /// "uri":"spotify:track:1301WleyT98MSxVHPZCA6M", + /// "positions":[ + /// 7 + /// ] + /// } + /// ] + /// } + /// ``` + /// - snapshot_id: optional id of the playlist snapshot + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-remove-tracks-playlist) + async fn playlist_remove_specific_occurrences_of_tracks<'a>( + &self, + playlist_id: &PlaylistId, + tracks: impl IntoIterator> + 'a, + snapshot_id: Option<&str>, + ) -> ClientResult { + let tracks = tracks + .into_iter() + .map(|track| { + let mut map = Map::new(); + map.insert("uri".to_owned(), track.id.uri().into()); + map.insert("positions".to_owned(), json!(track.positions)); + map + }) + .collect::>(); + + let params = build_json! { + "tracks": tracks, + optional "snapshot_id": snapshot_id, + }; + + let url = format!("playlists/{}/tracks", playlist_id.id()); + let result = self.endpoint_delete(&url, ¶ms).await?; + convert_result(&result) + } + + /// Add the current authenticated user as a follower of a playlist. + /// + /// Parameters: + /// - playlist_id - the id of the playlist + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-follow-playlist) + async fn playlist_follow( + &self, + playlist_id: &PlaylistId, + public: Option, + ) -> ClientResult<()> { + let url = format!("playlists/{}/followers", playlist_id.id()); + + let params = build_json! { + optional "public": public, + }; + + self.endpoint_put(&url, ¶ms).await?; + + Ok(()) + } + + /// Get detailed profile information about the current user. + /// An alias for the 'current_user' method. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-current-users-profile) + async fn me(&self) -> ClientResult { + let result = self.endpoint_get("me/", &Query::new()).await?; + convert_result(&result) + } + + /// Get detailed profile information about the current user. + /// An alias for the 'me' method. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-current-users-profile) + async fn current_user(&self) -> ClientResult { + self.me().await + } + + /// Get information about the current users currently playing track. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-recently-played) + async fn current_user_playing_track(&self) -> ClientResult> { + let result = self + .get("me/player/currently-playing", None, &Query::new()) + .await?; + if result.is_empty() { + Ok(None) + } else { + convert_result(&result) + } + } + + /// Gets a list of the albums saved in the current authorized user's + /// "Your Music" library + /// + /// Parameters: + /// - limit - the number of albums to return + /// - offset - the index of the first album to return + /// - market - Provide this parameter if you want to apply Track Relinking. + /// + /// See [`Self::current_user_saved_albums`] for a manually paginated version + /// of this. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-users-saved-albums) + fn current_user_saved_albums(&self) -> Paginator<'_, ClientResult> { + paginate( + move |limit, offset| self.current_user_saved_albums_manual(Some(limit), Some(offset)), + self.get_config().pagination_chunks, + ) + } + + /// The manually paginated version of [`Self::current_user_saved_albums`]. + async fn current_user_saved_albums_manual( + &self, + limit: Option, + offset: Option, + ) -> ClientResult> { + let limit = limit.map(|s| s.to_string()); + let offset = offset.map(|s| s.to_string()); + let params = build_map! { + optional "limit": limit.as_deref(), + optional "offset": offset.as_deref(), + }; + + let result = self.endpoint_get("me/albums", ¶ms).await?; + convert_result(&result) + } + + /// Get a list of the songs saved in the current Spotify user's "Your Music" + /// library. + /// + /// Parameters: + /// - limit - the number of tracks to return + /// - offset - the index of the first track to return + /// - market - Provide this parameter if you want to apply Track Relinking. + /// + /// See [`Self::current_user_saved_tracks_manual`] for a manually paginated + /// version of this. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-users-saved-tracks) + fn current_user_saved_tracks(&self) -> Paginator<'_, ClientResult> { + paginate( + move |limit, offset| self.current_user_saved_tracks_manual(Some(limit), Some(offset)), + self.get_config().pagination_chunks, + ) + } + + /// The manually paginated version of [`Self::current_user_saved_tracks`]. + async fn current_user_saved_tracks_manual( + &self, + limit: Option, + offset: Option, + ) -> ClientResult> { + let limit = limit.map(|s| s.to_string()); + let offset = offset.map(|s| s.to_string()); + let params = build_map! { + optional "limit": limit.as_deref(), + optional "offset": offset.as_deref(), + }; + + let result = self.endpoint_get("me/tracks", ¶ms).await?; + convert_result(&result) + } + + /// Gets a list of the artists followed by the current authorized user. + /// + /// Parameters: + /// - after - the last artist ID retrieved from the previous request + /// - limit - the number of tracks to return + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-followed) + async fn current_user_followed_artists( + &self, + after: Option<&str>, + limit: Option, + ) -> ClientResult> { + let limit = limit.map(|s| s.to_string()); + let params = build_map! { + "type": Type::Artist.as_ref(), + optional "after": after, + optional "limit": limit.as_deref(), + }; + + let result = self.endpoint_get("me/following", ¶ms).await?; + convert_result::(&result).map(|x| x.artists) + } + + /// Remove one or more tracks from the current user's "Your Music" library. + /// + /// Parameters: + /// - track_ids - a list of track URIs, URLs or IDs + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-remove-tracks-user) + async fn current_user_saved_tracks_delete<'a>( + &self, + track_ids: impl IntoIterator + 'a, + ) -> ClientResult<()> { + let url = format!("me/tracks/?ids={}", join_ids(track_ids)); + self.endpoint_delete(&url, &json!({})).await?; + + Ok(()) + } + + /// Check if one or more tracks is already saved in the current Spotify + /// user’s "Your Music" library. + /// + /// Parameters: + /// - track_ids - a list of track URIs, URLs or IDs + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-check-users-saved-tracks) + async fn current_user_saved_tracks_contains<'a>( + &self, + track_ids: impl IntoIterator + 'a, + ) -> ClientResult> { + let url = format!("me/tracks/contains/?ids={}", join_ids(track_ids)); + let result = self.endpoint_get(&url, &Query::new()).await?; + convert_result(&result) + } + + /// Save one or more tracks to the current user's "Your Music" library. + /// + /// Parameters: + /// - track_ids - a list of track URIs, URLs or IDs + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-save-tracks-user) + async fn current_user_saved_tracks_add<'a>( + &self, + track_ids: impl IntoIterator + 'a, + ) -> ClientResult<()> { + let url = format!("me/tracks/?ids={}", join_ids(track_ids)); + self.endpoint_put(&url, &json!({})).await?; + + Ok(()) + } + + /// Get the current user's top artists. + /// + /// Parameters: + /// - limit - the number of entities to return + /// - offset - the index of the first entity to return + /// - time_range - Over what time frame are the affinities computed + /// + /// See [`Self::current_user_top_artists_manual`] for a manually paginated + /// version of this. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-users-top-artists-and-tracks) + fn current_user_top_artists<'a>( + &'a self, + time_range: Option<&'a TimeRange>, + ) -> Paginator<'_, ClientResult> { + paginate( + move |limit, offset| { + self.current_user_top_artists_manual(time_range, Some(limit), Some(offset)) + }, + self.get_config().pagination_chunks, + ) + } + + /// The manually paginated version of [`Self::current_user_top_artists`]. + async fn current_user_top_artists_manual( + &self, + time_range: Option<&TimeRange>, + limit: Option, + offset: Option, + ) -> ClientResult> { + let limit = limit.map(|s| s.to_string()); + let offset = offset.map(|s| s.to_string()); + let params = build_map! { + optional "time_range": time_range.map(|x| x.as_ref()), + optional "limit": limit.as_deref(), + optional "offset": offset.as_deref(), + }; + + let result = self.endpoint_get(&"me/top/artists", ¶ms).await?; + convert_result(&result) + } + + /// Get the current user's top tracks. + /// + /// Parameters: + /// - limit - the number of entities to return + /// - offset - the index of the first entity to return + /// - time_range - Over what time frame are the affinities computed + /// + /// See [`Self::current_user_top_tracks_manual`] for a manually paginated + /// version of this. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-users-top-artists-and-tracks) + fn current_user_top_tracks<'a>( + &'a self, + time_range: Option<&'a TimeRange>, + ) -> Paginator<'_, ClientResult> { + paginate( + move |limit, offset| { + self.current_user_top_tracks_manual(time_range, Some(limit), Some(offset)) + }, + self.get_config().pagination_chunks, + ) + } + + /// The manually paginated version of [`Self::current_user_top_tracks`]. + async fn current_user_top_tracks_manual( + &self, + time_range: Option<&TimeRange>, + limit: Option, + offset: Option, + ) -> ClientResult> { + let limit = limit.map(|x| x.to_string()); + let offset = offset.map(|x| x.to_string()); + let params = build_map! { + optional "time_range": time_range.map(|x| x.as_ref()), + optional "limit": limit.as_deref(), + optional "offset": offset.as_deref(), + }; + + let result = self.endpoint_get("me/top/tracks", ¶ms).await?; + convert_result(&result) + } + + /// Get the current user's recently played tracks. + /// + /// Parameters: + /// - limit - the number of entities to return + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-the-users-currently-playing-track) + async fn current_user_recently_played( + &self, + limit: Option, + ) -> ClientResult> { + let limit = limit.map(|x| x.to_string()); + let params = build_map! { + optional "limit": limit.as_deref(), + }; + + let result = self + .endpoint_get("me/player/recently-played", ¶ms) + .await?; + convert_result(&result) + } + + /// Add one or more albums to the current user's "Your Music" library. + /// + /// Parameters: + /// - album_ids - a list of album URIs, URLs or IDs + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-save-albums-user) + async fn current_user_saved_albums_add<'a>( + &self, + album_ids: impl IntoIterator + 'a, + ) -> ClientResult<()> { + let url = format!("me/albums/?ids={}", join_ids(album_ids)); + self.endpoint_put(&url, &json!({})).await?; + + Ok(()) + } + + /// Remove one or more albums from the current user's "Your Music" library. + /// + /// Parameters: + /// - album_ids - a list of album URIs, URLs or IDs + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-remove-albums-user) + async fn current_user_saved_albums_delete<'a>( + &self, + album_ids: impl IntoIterator + 'a, + ) -> ClientResult<()> { + let url = format!("me/albums/?ids={}", join_ids(album_ids)); + self.endpoint_delete(&url, &json!({})).await?; + + Ok(()) + } + + /// Check if one or more albums is already saved in the current Spotify + /// user’s "Your Music” library. + /// + /// Parameters: + /// - album_ids - a list of album URIs, URLs or IDs + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-check-users-saved-albums) + async fn current_user_saved_albums_contains<'a>( + &self, + album_ids: impl IntoIterator + 'a, + ) -> ClientResult> { + let url = format!("me/albums/contains/?ids={}", join_ids(album_ids)); + let result = self.endpoint_get(&url, &Query::new()).await?; + convert_result(&result) + } + + /// Follow one or more artists. + /// + /// Parameters: + /// - artist_ids - a list of artist IDs + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-follow-artists-users) + async fn user_follow_artists<'a>( + &self, + artist_ids: impl IntoIterator + 'a, + ) -> ClientResult<()> { + let url = format!("me/following?type=artist&ids={}", join_ids(artist_ids)); + self.endpoint_put(&url, &json!({})).await?; + + Ok(()) + } + + /// Unfollow one or more artists. + /// + /// Parameters: + /// - artist_ids - a list of artist IDs + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-unfollow-artists-users) + async fn user_unfollow_artists<'a>( + &self, + artist_ids: impl IntoIterator + 'a, + ) -> ClientResult<()> { + let url = format!("me/following?type=artist&ids={}", join_ids(artist_ids)); + self.endpoint_delete(&url, &json!({})).await?; + + Ok(()) + } + + /// Check to see if the current user is following one or more artists or + /// other Spotify users. + /// + /// Parameters: + /// - artist_ids - the ids of the users that you want to + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-check-current-user-follows) + async fn user_artist_check_follow<'a>( + &self, + artist_ids: impl IntoIterator + 'a, + ) -> ClientResult> { + let url = format!( + "me/following/contains?type=artist&ids={}", + join_ids(artist_ids) + ); + let result = self.endpoint_get(&url, &Query::new()).await?; + convert_result(&result) + } + + /// Follow one or more users. + /// + /// Parameters: + /// - user_ids - a list of artist IDs + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-follow-artists-users) + async fn user_follow_users<'a>( + &self, + user_ids: impl IntoIterator + 'a, + ) -> ClientResult<()> { + let url = format!("me/following?type=user&ids={}", join_ids(user_ids)); + self.endpoint_put(&url, &json!({})).await?; + + Ok(()) + } + + /// Unfollow one or more users. + /// + /// Parameters: + /// - user_ids - a list of artist IDs + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-unfollow-artists-users) + async fn user_unfollow_users<'a>( + &self, + user_ids: impl IntoIterator + 'a, + ) -> ClientResult<()> { + let url = format!("me/following?type=user&ids={}", join_ids(user_ids)); + self.endpoint_delete(&url, &json!({})).await?; + + Ok(()) + } + + /// Get a User’s Available Devices + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-a-users-available-devices) + async fn device(&self) -> ClientResult> { + let result = self + .endpoint_get("me/player/devices", &Query::new()) + .await?; + convert_result::(&result).map(|x| x.devices) + } + + /// Get Information About The User’s Current Playback + /// + /// Parameters: + /// - market: Optional. an ISO 3166-1 alpha-2 country code or the string from_token. + /// - additional_types: Optional. A comma-separated list of item types that + /// your client supports besides the default track type. Valid types are: + /// `track` and `episode`. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-information-about-the-users-current-playback) + async fn current_playback<'a>( + &self, + country: Option<&Market>, + additional_types: Option + 'a>, + ) -> ClientResult> { + let additional_types = additional_types.map(|x| { + x.into_iter() + .map(|x| x.as_ref()) + .collect::>() + .join(",") + }); + let params = build_map! { + optional "country": country.map(|x| x.as_ref()), + optional "additional_types": additional_types.as_deref(), + }; + + let result = self.endpoint_get("me/player", ¶ms).await?; + if result.is_empty() { + Ok(None) + } else { + convert_result(&result) + } + } + + /// Get the User’s Currently Playing Track + /// + /// Parameters: + /// - market: Optional. an ISO 3166-1 alpha-2 country code or the string from_token. + /// - additional_types: Optional. A comma-separated list of item types that + /// your client supports besides the default track type. Valid types are: + /// `track` and `episode`. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-recently-played) + async fn current_playing<'a>( + &self, + market: Option<&'a Market>, + additional_types: Option + 'a>, + ) -> ClientResult> { + let additional_types = additional_types.map(|x| { + x.into_iter() + .map(|x| x.as_ref()) + .collect::>() + .join(",") + }); + let params = build_map! { + optional "market": market.map(|x| x.as_ref()), + optional "additional_types": additional_types.as_ref(), + }; + + let result = self + .endpoint_get("me/player/currently-playing", ¶ms) + .await?; + if result.is_empty() { + Ok(None) + } else { + convert_result(&result) + } + } + + /// Transfer a User’s Playback. + /// + /// Note: Although an array is accepted, only a single device_id is + /// currently supported. Supplying more than one will return 400 Bad Request + /// + /// Parameters: + /// - device_id - transfer playback to this device + /// - force_play - true: after transfer, play. false: keep current state. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-transfer-a-users-playback) + async fn transfer_playback(&self, device_id: &str, play: Option) -> ClientResult<()> { + let params = build_json! { + "device_ids": [device_id], + optional "play": play, + }; + + self.endpoint_put("me/player", ¶ms).await?; + Ok(()) + } + + /// Start/Resume a User’s Playback. + /// + /// Provide a `context_uri` to start playback or a album, artist, or + /// playlist. Provide a `uris` list to start playback of one or more tracks. + /// Provide `offset` as {"position": } or {"uri": ""} to + /// start playback at a particular offset. + /// + /// Parameters: + /// - device_id - device target for playback + /// - context_uri - spotify context uri to play + /// - uris - spotify track uris + /// - offset - offset into context by index or track + /// - position_ms - Indicates from what position to start playback. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-start-a-users-playback) + async fn start_context_playback( + &self, + context_uri: &Id, + device_id: Option<&str>, + offset: Option>, + position_ms: Option, + ) -> ClientResult<()> { + let params = build_json! { + "context_uri": context_uri.uri(), + optional "offset": offset.map(|x| match x { + Offset::Position(position) => json!({ "position": position }), + Offset::Uri(uri) => json!({ "uri": uri.uri() }), + }), + optional "position_ms": position_ms, + + }; + + let url = append_device_id("me/player/play", device_id); + self.put(&url, None, ¶ms).await?; + + Ok(()) + } + + async fn start_uris_playback<'a, T: PlayableIdType + 'a>( + &self, + uris: impl IntoIterator> + 'a, + device_id: Option<&str>, + offset: Option>, + position_ms: Option, + ) -> ClientResult<()> { + let params = build_json! { + "uris": uris.into_iter().map(|id| id.uri()).collect::>(), + optional "position_ms": position_ms, + optional "offset": offset.map(|x| match x { + Offset::Position(position) => json!({ "position": position }), + Offset::Uri(uri) => json!({ "uri": uri.uri() }), + }), + }; + + let url = append_device_id("me/player/play", device_id); + self.endpoint_put(&url, ¶ms).await?; + + Ok(()) + } + + /// Pause a User’s Playback. + /// + /// Parameters: + /// - device_id - device target for playback + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-pause-a-users-playback) + async fn pause_playback(&self, device_id: Option<&str>) -> ClientResult<()> { + let url = append_device_id("me/player/pause", device_id); + self.endpoint_put(&url, &json!({})).await?; + + Ok(()) + } + + /// Skip User’s Playback To Next Track. + /// + /// Parameters: + /// - device_id - device target for playback + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-skip-users-playback-to-next-track) + async fn next_track(&self, device_id: Option<&str>) -> ClientResult<()> { + let url = append_device_id("me/player/next", device_id); + self.endpoint_post(&url, &json!({})).await?; + + Ok(()) + } + + /// Skip User’s Playback To Previous Track. + /// + /// Parameters: + /// - device_id - device target for playback + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-skip-users-playback-to-previous-track) + async fn previous_track(&self, device_id: Option<&str>) -> ClientResult<()> { + let url = append_device_id("me/player/previous", device_id); + self.endpoint_post(&url, &json!({})).await?; + + Ok(()) + } + + /// Seek To Position In Currently Playing Track. + /// + /// Parameters: + /// - position_ms - position in milliseconds to seek to + /// - device_id - device target for playback + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-seek-to-position-in-currently-playing-track) + async fn seek_track(&self, position_ms: u32, device_id: Option<&str>) -> ClientResult<()> { + let url = append_device_id( + &format!("me/player/seek?position_ms={}", position_ms), + device_id, + ); + self.endpoint_put(&url, &json!({})).await?; + + Ok(()) + } + + /// Set Repeat Mode On User’s Playback. + /// + /// Parameters: + /// - state - `track`, `context`, or `off` + /// - device_id - device target for playback + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-set-repeat-mode-on-users-playback) + async fn repeat(&self, state: &RepeatState, device_id: Option<&str>) -> ClientResult<()> { + let url = append_device_id( + &format!("me/player/repeat?state={}", state.as_ref()), + device_id, + ); + self.endpoint_put(&url, &json!({})).await?; + + Ok(()) + } + + /// Set Volume For User’s Playback. + /// + /// Parameters: + /// - volume_percent - volume between 0 and 100 + /// - device_id - device target for playback + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-set-volume-for-users-playback) + async fn volume(&self, volume_percent: u8, device_id: Option<&str>) -> ClientResult<()> { + if volume_percent > 100u8 { + error!("volume must be between 0 and 100, inclusive"); + } + let url = append_device_id( + &format!("me/player/volume?volume_percent={}", volume_percent), + device_id, + ); + self.endpoint_put(&url, &json!({})).await?; + + Ok(()) + } + + /// Toggle Shuffle For User’s Playback. + /// + /// Parameters: + /// - state - true or false + /// - device_id - device target for playback + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-toggle-shuffle-for-users-playback) + async fn shuffle(&self, state: bool, device_id: Option<&str>) -> ClientResult<()> { + let url = append_device_id(&format!("me/player/shuffle?state={}", state), device_id); + self.endpoint_put(&url, &json!({})).await?; + + Ok(()) + } + + /// Add an item to the end of the user's playback queue. + /// + /// Parameters: + /// - uri - The uri of the item to add, Track or Episode + /// - device id - The id of the device targeting + /// - If no device ID provided the user's currently active device is + /// targeted + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-add-to-queue) + async fn add_item_to_queue( + &self, + item: &Id, + device_id: Option<&str>, + ) -> ClientResult<()> { + let url = append_device_id(&format!("me/player/queue?uri={}", item), device_id); + self.endpoint_post(&url, &json!({})).await?; + + Ok(()) + } + + /// Add a show or a list of shows to a user’s library. + /// + /// Parameters: + /// - ids(Required) A comma-separated list of Spotify IDs for the shows to + /// be added to the user’s library. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-save-shows-user) + async fn save_shows<'a>( + &self, + show_ids: impl IntoIterator + 'a, + ) -> ClientResult<()> { + let url = format!("me/shows/?ids={}", join_ids(show_ids)); + self.endpoint_put(&url, &json!({})).await?; + + Ok(()) + } + + /// Get a list of shows saved in the current Spotify user’s library. + /// Optional parameters can be used to limit the number of shows returned. + /// + /// Parameters: + /// - limit(Optional). The maximum number of shows to return. Default: 20. + /// Minimum: 1. Maximum: 50. + /// - offset(Optional). The index of the first show to return. Default: 0 + /// (the first object). Use with limit to get the next set of shows. + /// + /// See [`Self::get_saved_show_manual`] for a manually paginated version of + /// this. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-users-saved-shows) + fn get_saved_show(&self) -> Paginator<'_, ClientResult> { + paginate( + move |limit, offset| self.get_saved_show_manual(Some(limit), Some(offset)), + self.get_config().pagination_chunks, + ) + } + + /// The manually paginated version of [`Self::get_saved_show`]. + async fn get_saved_show_manual( + &self, + limit: Option, + offset: Option, + ) -> ClientResult> { + let limit = limit.map(|x| x.to_string()); + let offset = offset.map(|x| x.to_string()); + let params = build_map! { + optional "limit": limit.as_ref(), + optional "offset": offset.as_ref(), + }; + + let result = self.endpoint_get("me/shows", ¶ms).await?; + convert_result(&result) + } + + /// Check if one or more shows is already saved in the current Spotify user’s library. + /// + /// Query Parameters + /// - ids: Required. A comma-separated list of the Spotify IDs for the shows. Maximum: 50 IDs. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-check-users-saved-shows) + async fn check_users_saved_shows<'a>( + &self, + ids: impl IntoIterator + 'a, + ) -> ClientResult> { + let ids = join_ids(ids); + let params = build_map! { + "ids": &ids, + }; + let result = self.endpoint_get("me/shows/contains", ¶ms).await?; + convert_result(&result) + } + + /// Delete one or more shows from current Spotify user's library. + /// Changes to a user's saved shows may not be visible in other Spotify applications immediately. + /// + /// Query Parameters + /// - ids: Required. A comma-separated list of Spotify IDs for the shows to be deleted from the user’s library. + /// - market: Optional. An ISO 3166-1 alpha-2 country code or the string from_token. + /// + /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#endpoint-remove-shows-user) + async fn remove_users_saved_shows<'a>( + &self, + show_ids: impl IntoIterator + 'a, + country: Option<&Market>, + ) -> ClientResult<()> { + let url = format!("me/shows?ids={}", join_ids(show_ids)); + let params = build_json! { + optional "country": country.map(|x| x.as_ref()) + }; + self.endpoint_delete(&url, ¶ms).await?; + + Ok(()) + } +} diff --git a/src/pagination/iter.rs b/src/clients/pagination/iter.rs similarity index 85% rename from src/pagination/iter.rs rename to src/clients/pagination/iter.rs index 79db3575..80432f0d 100644 --- a/src/pagination/iter.rs +++ b/src/clients/pagination/iter.rs @@ -1,14 +1,15 @@ //! Synchronous implementation of automatic pagination requests. -use crate::client::{ClientError, ClientResult}; -use crate::model::Page; +use crate::{model::Page, ClientError, ClientResult}; /// Alias for `Iterator`, since sync mode is enabled. -pub trait Paginator: Iterator {} -impl> Paginator for I {} +pub type Paginator<'a, T> = Box + 'a>; /// This is used to handle paginated requests automatically. -pub fn paginate(req: Request, page_size: u32) -> impl Iterator> +pub fn paginate<'a, T: 'a, Request: 'a>( + req: Request, + page_size: u32, +) -> Paginator<'a, ClientResult> where Request: Fn(u32, u32) -> ClientResult>, { @@ -19,7 +20,7 @@ where page_size, }; - pages.flat_map(|result| ResultIter::new(result.map(|page| page.items.into_iter()))) + Box::new(pages.flat_map(|result| ResultIter::new(result.map(|page| page.items.into_iter())))) } /// Iterator that repeatedly calls a function that returns a page until an empty diff --git a/src/clients/pagination/mod.rs b/src/clients/pagination/mod.rs new file mode 100644 index 00000000..363b5cd4 --- /dev/null +++ b/src/clients/pagination/mod.rs @@ -0,0 +1,30 @@ +//! Utilities for pagination requests. If the configured client is asynchronous, +//! it'll be based on `futures::stream::Stream`, if it's synchronous it'll just +//! use `std::iter::Iterator`. +//! +//! All implementations export: +//! +//! * A `Paginator` struct which wraps the iterable of items +//! * A `paginate` function, which returns a `Paginator` based on a request that +//! may be repeated in order to return a continuous sequence of `Page`s +//! +//! Note that `Paginator` should actually be a trait so that a dynamic +//! allocation can be avoided when returning it with `-> impl Iterator`, as +//! opposed to `-> Box>`. But since the Spotify clients are +//! trait-based, they can't return anonymous types, and the former option is +//! impossible for now. This is the same small overhead introduced by the +//! `async_trait` crate and that will hopefully be fixed in the future. +//! +//! Both `Paginator` and `paginate` have a lifetime of `'a`. This is because the +//! pagination may borrow the client itself in order to make requests, and said +//! lifetime helps ensure the `Paginator` struct won't outlive the client. + +#[cfg(feature = "__sync")] +mod iter; +#[cfg(feature = "__async")] +mod stream; + +#[cfg(feature = "__sync")] +pub use iter::{paginate, Paginator}; +#[cfg(feature = "__async")] +pub use stream::{paginate, Paginator}; diff --git a/src/pagination/stream.rs b/src/clients/pagination/stream.rs similarity index 69% rename from src/pagination/stream.rs rename to src/clients/pagination/stream.rs index b093ad63..8c383019 100644 --- a/src/pagination/stream.rs +++ b/src/clients/pagination/stream.rs @@ -1,19 +1,19 @@ //! Asynchronous implementation of automatic pagination requests. -use crate::client::ClientResult; -use crate::model::Page; -use futures::future::Future; -use futures::stream::Stream; +use crate::{model::Page, ClientResult}; + +use std::pin::Pin; + +use futures::{future::Future, stream::Stream}; /// Alias for `futures::stream::Stream`, since async mode is enabled. -pub trait Paginator: Stream {} -impl> Paginator for I {} +pub type Paginator<'a, T> = Pin + 'a>>; /// This is used to handle paginated requests automatically. -pub fn paginate( +pub fn paginate<'a, T: 'a, Fut, Request: 'a>( req: Request, page_size: u32, -) -> impl Stream> +) -> Paginator<'a, ClientResult> where T: Unpin, Fut: Future>>, @@ -21,7 +21,7 @@ where { use async_stream::stream; let mut offset = 0; - stream! { + Box::pin(stream! { loop { let page = req(page_size, offset).await?; offset += page.items.len() as u32; @@ -32,5 +32,5 @@ where break; } } - } + }) } diff --git a/src/http/mod.rs b/src/http/mod.rs deleted file mode 100644 index edd5ea95..00000000 --- a/src/http/mod.rs +++ /dev/null @@ -1,304 +0,0 @@ -//! The HTTP client may vary depending on which one the user configures. This -//! module contains the required logic to use different clients interchangeably. - -#[cfg(feature = "client-reqwest")] -mod reqwest; -#[cfg(feature = "client-ureq")] -mod ureq; - -use crate::client::{ClientResult, Spotify}; - -use std::collections::HashMap; -use std::fmt; - -use maybe_async::maybe_async; -use serde_json::Value; - -#[cfg(feature = "client-reqwest")] -pub use self::reqwest::ReqwestClient as HTTPClient; -#[cfg(feature = "client-ureq")] -pub use self::ureq::UreqClient as HTTPClient; - -pub type Headers = HashMap; -pub type Query<'a> = HashMap<&'a str, &'a str>; -pub type Form<'a> = HashMap<&'a str, &'a str>; - -pub mod headers { - use crate::oauth2::Token; - - // Common headers as constants - pub const CLIENT_ID: &str = "client_id"; - pub const CODE: &str = "code"; - pub const GRANT_AUTH_CODE: &str = "authorization_code"; - pub const GRANT_CLIENT_CREDS: &str = "client_credentials"; - pub const GRANT_REFRESH_TOKEN: &str = "refresh_token"; - pub const GRANT_TYPE: &str = "grant_type"; - pub const REDIRECT_URI: &str = "redirect_uri"; - pub const REFRESH_TOKEN: &str = "refresh_token"; - pub const RESPONSE_CODE: &str = "code"; - pub const RESPONSE_TYPE: &str = "response_type"; - pub const SCOPE: &str = "scope"; - pub const SHOW_DIALOG: &str = "show_dialog"; - pub const STATE: &str = "state"; - - /// Generates an HTTP token authorization header with proper formatting - pub fn bearer_auth(tok: &Token) -> (String, String) { - let auth = "authorization".to_owned(); - let value = format!("Bearer {}", tok.access_token); - - (auth, value) - } - - /// Generates an HTTP basic authorization header with proper formatting - pub fn basic_auth(user: &str, password: &str) -> (String, String) { - let auth = "authorization".to_owned(); - let value = format!("{}:{}", user, password); - let value = format!("Basic {}", base64::encode(value)); - - (auth, value) - } -} - -/// This trait represents the interface to be implemented for an HTTP client, -/// which is kept separate from the Spotify client for cleaner code. Thus, it -/// also requires other basic traits that are needed for the Spotify client. -/// -/// When a request doesn't need to pass parameters, the empty or default value -/// of the payload type should be passed, like `json!({})` or `Query::new()`. -/// This avoids using `Option` because `Value` itself may be null in other -/// different ways (`Value::Null`, an empty `Value::Object`...), so this removes -/// redundancy and edge cases (a `Some(Value::Null), for example, doesn't make -/// much sense). -#[maybe_async] -pub trait BaseHttpClient: Default + Clone + fmt::Debug { - // This internal function should always be given an object value in JSON. - async fn get( - &self, - url: &str, - headers: Option<&Headers>, - payload: &Query, - ) -> ClientResult; - - async fn post( - &self, - url: &str, - headers: Option<&Headers>, - payload: &Value, - ) -> ClientResult; - - async fn post_form<'a>( - &self, - url: &str, - headers: Option<&Headers>, - payload: &Form<'a>, - ) -> ClientResult; - - async fn put( - &self, - url: &str, - headers: Option<&Headers>, - payload: &Value, - ) -> ClientResult; - - async fn delete( - &self, - url: &str, - headers: Option<&Headers>, - payload: &Value, - ) -> ClientResult; -} - -/// HTTP-related methods for the Spotify client. It wraps the basic HTTP client -/// with features needed of higher level. -/// -/// The Spotify client has two different wrappers to perform requests: -/// -/// * Basic wrappers: `get`, `post`, `put`, `delete`, `post_form`. These only -/// append the configured Spotify API URL to the relative URL provided so that -/// it's not forgotten. They're used in the authentication process to request -/// an access token and similars. -/// * Endpoint wrappers: `endpoint_get`, `endpoint_post`, `endpoint_put`, -/// `endpoint_delete`. These append the authentication headers for endpoint -/// requests to reduce the code needed for endpoints and make them as concise -/// as possible. -impl Spotify { - /// If it's a relative URL like "me", the prefix is appended to it. - /// Otherwise, the same URL is returned. - fn endpoint_url(&self, url: &str) -> String { - // Using the client's prefix in case it's a relative route. - if !url.starts_with("http") { - self.prefix.clone() + url - } else { - url.to_string() - } - } - - /// The headers required for authenticated requests to the API - fn auth_headers(&self) -> ClientResult { - let mut auth = Headers::new(); - let (key, val) = headers::bearer_auth(self.get_token()?); - auth.insert(key, val); - - Ok(auth) - } - - #[inline] - #[maybe_async] - pub(crate) async fn get( - &self, - url: &str, - headers: Option<&Headers>, - payload: &Query<'_>, - ) -> ClientResult { - let url = self.endpoint_url(url); - self.http.get(&url, headers, payload).await - } - - #[inline] - #[maybe_async] - pub(crate) async fn post( - &self, - url: &str, - headers: Option<&Headers>, - payload: &Value, - ) -> ClientResult { - let url = self.endpoint_url(url); - self.http.post(&url, headers, payload).await - } - - #[inline] - #[maybe_async] - pub(crate) async fn post_form( - &self, - url: &str, - headers: Option<&Headers>, - payload: &Form<'_>, - ) -> ClientResult { - let url = self.endpoint_url(url); - self.http.post_form(&url, headers, payload).await - } - - #[inline] - #[maybe_async] - pub(crate) async fn put( - &self, - url: &str, - headers: Option<&Headers>, - payload: &Value, - ) -> ClientResult { - let url = self.endpoint_url(url); - self.http.put(&url, headers, payload).await - } - - #[inline] - #[maybe_async] - pub(crate) async fn delete( - &self, - url: &str, - headers: Option<&Headers>, - payload: &Value, - ) -> ClientResult { - let url = self.endpoint_url(url); - self.http.delete(&url, headers, payload).await - } - - /// The wrapper for the endpoints, which also includes the required - /// autentication. - #[inline] - #[maybe_async] - pub(crate) async fn endpoint_get( - &self, - url: &str, - payload: &Query<'_>, - ) -> ClientResult { - let headers = self.auth_headers()?; - self.get(url, Some(&headers), payload).await - } - - #[inline] - #[maybe_async] - pub(crate) async fn endpoint_post(&self, url: &str, payload: &Value) -> ClientResult { - let headers = self.auth_headers()?; - self.post(url, Some(&headers), payload).await - } - - #[inline] - #[maybe_async] - pub(crate) async fn endpoint_put(&self, url: &str, payload: &Value) -> ClientResult { - let headers = self.auth_headers()?; - self.put(url, Some(&headers), payload).await - } - - #[inline] - #[maybe_async] - pub(crate) async fn endpoint_delete(&self, url: &str, payload: &Value) -> ClientResult { - let headers = self.auth_headers()?; - self.delete(url, Some(&headers), payload).await - } -} - -#[cfg(test)] -mod test { - use super::*; - use crate::client::SpotifyBuilder; - use crate::oauth2::TokenBuilder; - use crate::scopes; - use chrono::prelude::*; - use chrono::Duration; - - #[test] - fn test_bearer_auth() { - let access_token = "access_token"; - let tok = TokenBuilder::default() - .access_token(access_token) - .build() - .unwrap(); - let (auth, value) = headers::bearer_auth(&tok); - assert_eq!(auth, "authorization"); - assert_eq!(value, "Bearer access_token"); - } - - #[test] - fn test_basic_auth() { - let (auth, value) = headers::basic_auth("ramsay", "123456"); - assert_eq!(auth, "authorization"); - assert_eq!(value, "Basic cmFtc2F5OjEyMzQ1Ng=="); - } - - #[test] - fn test_endpoint_url() { - let spotify = SpotifyBuilder::default().build().unwrap(); - assert_eq!( - spotify.endpoint_url("me/player/play"), - "https://api.spotify.com/v1/me/player/play" - ); - assert_eq!( - spotify.endpoint_url("http://api.spotify.com/v1/me/player/play"), - "http://api.spotify.com/v1/me/player/play" - ); - assert_eq!( - spotify.endpoint_url("https://api.spotify.com/v1/me/player/play"), - "https://api.spotify.com/v1/me/player/play" - ); - } - - #[test] - fn test_auth_headers() { - let tok = TokenBuilder::default() - .access_token("test-access_token") - .expires_in(Duration::seconds(1)) - .expires_at(Utc::now()) - .scope(scopes!("playlist-read-private")) - .refresh_token("...") - .build() - .unwrap(); - - let spotify = SpotifyBuilder::default().token(tok).build().unwrap(); - - let headers = spotify.auth_headers().unwrap(); - assert_eq!( - headers.get("authorization"), - Some(&"Bearer test-access_token".to_owned()) - ); - } -} diff --git a/src/lib.rs b/src/lib.rs index 3c9180ea..66faf0d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,25 +1,23 @@ -//! Rspotify is a wrapper for the [Spotify Web API -//! ](https://developer.spotify.com/web-api/), inspired by [spotipy -//! ](https://github.com/plamere/spotipy). It includes support for all the -//! [authorization flows](https://developer.spotify.com/documentation/general/guides/authorization-guide/), -//! and helper methods for [all available endpoints -//! ](https://developer.spotify.com/documentation/web-api/reference/). +//! Rspotify is a wrapper for the [Spotify Web API][spotify-main], inspired by +//! [spotipy][spotipy-github]. It includes support for all the [authorization +//! flows][spotify-auth-flows], and helper methods for [all available +//! endpoints][spotify-reference]. //! //! ## Configuration //! //! ### HTTP Client //! -//! By default, Rspotify uses the [`reqwest`] asynchronous HTTP client with its -//! default TLS, but you can customize both the HTTP client and the TLS with the -//! following features: +//! By default, Rspotify uses the [reqwest][reqwest-docs] asynchronous HTTP +//! client with its default TLS, but you can customize both the HTTP client and +//! the TLS with the following features: //! -//! - [`reqwest`](https://github.com/seanmonstar/reqwest): enabling +//! - [reqwest][reqwest-docs]: enabling //! `client-reqwest`, TLS available: //! + `reqwest-default-tls` (reqwest's default) //! + `reqwest-rustls-tls` //! + `reqwest-native-tls` //! + `reqwest-native-tls-vendored` -//! - [`ureq`](https://github.com/algesten/ureq): enabling `client-ureq`, TLS +//! - [ureq][ureq-docs]: enabling `client-ureq`, TLS //! available: //! + `ureq-rustls-tls` (ureq's default) //! @@ -51,145 +49,202 @@ //! //! ### Proxies //! -//! [`reqwest`](reqwest#proxies) supports system proxies by default. It reads -//! the environment variables `HTTP_PROXY` and `HTTPS_PROXY` environmental -//! variables to set HTTP and HTTPS proxies, respectively. +//! [reqwest supports system proxies by default][reqwest-proxies]. It reads the +//! environment variables `HTTP_PROXY` and `HTTPS_PROXY` environmental variables +//! to set HTTP and HTTPS proxies, respectively. //! //! ### Environmental variables //! //! Rspotify supports the [`dotenv`] crate, which allows you to save credentials //! in a `.env` file. These will then be automatically available as -//! environmental values when using methods like -//! [`CredentialsBuilder::from_env`](crate::oauth2::CredentialsBuilder::from_env): +//! environmental values when using methods like [`Credentials::from_env`]. //! //! ```toml //! [dependencies] //! rspotify = { version = "...", features = ["env-file"] } //! ``` //! -//! ### Cli utilities +//! ### CLI utilities //! //! Rspotify includes basic support for Cli apps to obtain access tokens by -//! prompting the user, after enabling the `cli` feature. See the [Authorization -//! ](#authorization) section for more information. +//! prompting the user, after enabling the `cli` feature. See the +//! [Authorization](#authorization) section for more information. //! //! ## Getting Started //! //! ### Authorization //! -//! All endpoints require authorization. You will need to generate a token +//! All endpoints require app authorization; you will need to generate a token //! that indicates that the client has been granted permission to perform -//! requests. You will need to [register your app to get the necessary client -//! credentials](https://developer.spotify.com/dashboard/applications). Read -//! the [official guide for a detailed explanation of the different -//! authorization flows available -//! ](https://developer.spotify.com/documentation/general/guides/authorization-guide/). -//! -//! The most basic authentication flow, named the [Client Credentials flow -//! ](https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow), -//! consists on requesting a token to Spotify given some client credentials. -//! This can be done with [`Spotify::request_client_token` -//! ](crate::client::Spotify::request_client_token), as seen in -//! [this example -//! ](https://github.com/ramsayleung/rspotify/blob/master/examples/album.rs). -//! -//! Some of the available endpoints also require access to the user's personal -//! information, meaning that you have to follow the [Authorization Flow -//! ](https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow) -//! instead. In a nutshell, these are the steps you need to make for this: -//! -//! 0. Generate a request URL with [`Spotify::get_authorize_url` -//! ](crate::client::Spotify::get_authorize_url). -//! 1. The user logs in with the request URL, which redirects to the redirect -//! URI and provides a code in the parameters. This happens on your side. -//! 2. The code obtained in the previous step is parsed with -//! [`Spotify::parse_response_code` -//! ](crate::client::Spotify::parse_response_code). -//! 3. The code is sent to Spotify in order to obtain an access token with -//! [`Spotify::request_user_token` -//! ](crate::client::Spotify::request_user_token) or -//! [`Spotify::request_user_token_without_cache` -//! ](crate::client::Spotify::prompt_for_user_token_without_cache). -//! 4. Finally, this access token can be used internally for the requests. -//! This access token may expire relatively soon, so it can be refreshed -//! with the refresh token (obtained in the third step as well) using -//! [`Spotify::refresh_user_token` -//! ](crate::client::Spotify::refresh_user_token) or -//! [`Spotify::refresh_user_token_without_cache` -//! ](crate::client::Spotify::refresh_user_token_without_cache). -//! Otherwise, a new access token may be generated from scratch by repeating -//! these steps, but the advantage of refreshing it is that this doesn't -//! require the user to log in, and that it's a simpler procedure. -//! -//! See the [`webapp` -//! ](https://github.com/ramsayleung/rspotify/tree/master/examples/webapp) -//! example for more details on how you can implement it for something like a -//! web server. -//! -//! If you're developing a Cli application, you might be interested in the -//! `cli` feature, which brings the [`Spotify::prompt_for_user_token` -//! ](crate::client::Spotify::prompt_for_user_token) and -//! [`Spotify::prompt_for_user_token_without_cache` -//! ](crate::client::Spotify::prompt_for_user_token_without_cache) -//! methods. These will run all the authentication steps. The user wil log in -//! by opening the request URL in its default browser, and the requests will be -//! performed automatically. -//! -//! An example of the Cli authentication: -//! -//! ![demo](https://raw.githubusercontent.com/ramsayleung/rspotify/master/doc/images/rspotify.gif) -//! -//! Note: even if your script does not have an accessible URL, you will have to -//! specify a redirect URI. It doesn't need to work or be accessible, you can -//! use `http://localhost:8888/callback` for example, which will also have the -//! code appended like so: `http://localhost/?code=...`. +//! requests. You can start by [registering your app to get the necessary client +//! credentials][spotify-register-app]. Read the [official guide for a detailed +//! explanation of the different authorization flows +//! available][spotify-auth-flows]. +//! +//! Rspotify has a different client for each of the available authentication +//! flows. They may implement the endpoints in +//! [`BaseClient`](crate::clients::BaseClient) or +//! [`OAuthClient`](crate::clients::OAuthClient) according to what kind of +//! flow it is. Please refer to their documentation for more details: +//! +//! * [Client Credentials Flow][spotify-client-creds]: see +//! [`ClientCredsSpotify`]. +//! * [Authorization Code Flow][spotify-auth-code]: see [`AuthCodeSpotify`]. +//! * [Authorization Code Flow with Proof Key for Code Exchange +//! (PKCE)][spotify-auth-code-pkce]: see [`AuthCodePkceSpotify`]. +//! * [Implicit Grant Flow][spotify-implicit-grant]: unimplemented, as Rspotify +//! has not been tested on a browser yet. If you'd like support for it, let us +//! know in an issue! //! //! In order to help other developers to get used to `rspotify`, there are //! public credentials available for a dummy account. You can test `rspotify` -//! with this account's `RSPOTIFY_CLIENT_ID` and `RSPOTIFY_CLIENT_SECRET` -//! inside the [`.env` file -//! ](https://github.com/ramsayleung/rspotify/blob/master/.env) for more -//! details. +//! with this account's `RSPOTIFY_CLIENT_ID` and `RSPOTIFY_CLIENT_SECRET` inside +//! the [`.env` file](https://github.com/ramsayleung/rspotify/blob/master/.env) +//! for more details. //! //! ### Examples //! -//! There are some [available examples -//! ](https://github.com/ramsayleung/rspotify/tree/master/examples) -//! which can serve as a learning tool. +//! There are some [available examples on the GitHub +//! repository][examples-github] which can serve as a learning tool. +//! +//! [spotipy-github]: https://github.com/plamere/spotipy +//! [reqwest-docs]: https://docs.rs/reqwest/ +//! [reqwest-proxies]: https://docs.rs/reqwest/#proxies +//! [ureq-docs]: https://docs.rs/ureq/ +//! [examples-github]: https://github.com/ramsayleung/rspotify/tree/master/examples +//! [spotify-main]: https://developer.spotify.com/web-api/ +//! [spotify-auth-flows]: https://developer.spotify.com/documentation/general/guides/authorization-guide +//! [spotify-reference]: https://developer.spotify.com/documentation/web-api/reference/ +//! [spotify-register-app]: https://developer.spotify.com/dashboard/applications +//! [spotify-client-creds]: https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow +//! [spotify-auth-code]: https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow +//! [spotify-auth-code-pkce]: https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce +//! [spotify-implicit-grant]: https://developer.spotify.com/documentation/general/guides/authorization-guide/#implicit-grant-flow + +pub mod auth_code; +pub mod auth_code_pkce; +pub mod client_creds; +pub mod clients; + +// Subcrate re-exports +pub use rspotify_http as http; +pub use rspotify_macros as macros; +pub use rspotify_model as model; +// Top-level re-exports +pub use auth_code::AuthCodeSpotify; +pub use auth_code_pkce::AuthCodePkceSpotify; +pub use client_creds::ClientCredsSpotify; +pub use macros::scopes; + +use crate::http::HttpError; +use std::{ + collections::HashSet, + env, fs, + io::{Read, Write}, + path::Path, + path::PathBuf, +}; + +use chrono::{DateTime, Duration, Utc}; use getrandom::getrandom; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub mod prelude { + pub use crate::clients::{BaseClient, OAuthClient}; +} + +pub(in crate) mod headers { + // Common headers as constants + pub const CLIENT_ID: &str = "client_id"; + pub const CODE: &str = "code"; + pub const GRANT_AUTH_CODE: &str = "authorization_code"; + pub const GRANT_CLIENT_CREDS: &str = "client_credentials"; + pub const GRANT_REFRESH_TOKEN: &str = "refresh_token"; + pub const GRANT_TYPE: &str = "grant_type"; + pub const REDIRECT_URI: &str = "redirect_uri"; + pub const REFRESH_TOKEN: &str = "refresh_token"; + pub const RESPONSE_CODE: &str = "code"; + pub const RESPONSE_TYPE: &str = "response_type"; + pub const SCOPE: &str = "scope"; + pub const SHOW_DIALOG: &str = "show_dialog"; + pub const STATE: &str = "state"; + // TODO: + // pub const CODE_CHALLENGE: &str = "code_challenge"; + // pub const CODE_CHALLENGE_METHOD: &str = "code_challenge_method"; +} + +pub(in crate) mod auth_urls { + pub const AUTHORIZE: &str = "https://accounts.spotify.com/authorize"; + pub const TOKEN: &str = "https://accounts.spotify.com/api/token"; +} + +/// Possible errors returned from the `rspotify` client. +#[derive(Debug, Error)] +pub enum ClientError { + #[error("json parse error: {0}")] + ParseJson(#[from] serde_json::Error), + + #[error("url parse error: {0}")] + ParseUrl(#[from] url::ParseError), + + #[error("http error: {0}")] + Http(#[from] HttpError), + + #[error("input/output error: {0}")] + Io(#[from] std::io::Error), + + #[cfg(feature = "cli")] + #[error("cli error: {0}")] + Cli(String), + + #[error("cache file error: {0}")] + CacheFile(String), +} + +pub type ClientResult = Result; -// Disable all modules when both client features are enabled or when none are. -// This way only the compile error below gets shown instead of a whole list of -// confusing errors.. -#[cfg(any(feature = "client-reqwest", feature = "client-ureq"))] -#[cfg(not(all(feature = "client-reqwest", feature = "client-ureq")))] -pub mod client; -#[cfg(any(feature = "client-reqwest", feature = "client-ureq"))] -#[cfg(not(all(feature = "client-reqwest", feature = "client-ureq")))] -mod http; -pub mod model; -#[cfg(any(feature = "client-reqwest", feature = "client-ureq"))] -#[cfg(not(all(feature = "client-reqwest", feature = "client-ureq")))] -pub mod oauth2; -#[cfg(not(all(feature = "client-reqwest", feature = "client-ureq")))] -pub mod pagination; - -#[macro_use] -mod macros; - -#[cfg(all(feature = "client-reqwest", feature = "client-ureq"))] -compile_error!( - "`client-reqwest` and `client-ureq` features cannot both be enabled at \ - the same time, if you want to use `client-ureq` you need to set \ - `default-features = false`" -); - -#[cfg(not(any(feature = "client-reqwest", feature = "client-ureq")))] -compile_error!( - "You have to enable at least one of the available clients with the \ - `client-reqwest` or `client-ureq` features." -); +pub const DEFAULT_API_PREFIX: &str = "https://api.spotify.com/v1/"; +pub const DEFAULT_CACHE_PATH: &str = ".spotify_token_cache.json"; +pub const DEFAULT_PAGINATION_CHUNKS: u32 = 50; + +/// Struct to configure the Spotify client. +#[derive(Debug, Clone)] +pub struct Config { + /// The Spotify API prefix, [`DEFAULT_API_PREFIX`] by default. + pub prefix: String, + + /// The cache file path, in case it's used. By default it's + /// [`DEFAULT_CACHE_PATH`] + pub cache_path: PathBuf, + + /// The pagination chunk size used when performing automatically paginated + /// requests, like [`artist_albums`](crate::clients::BaseClient). This + /// means that a request will be performed every `pagination_chunks` items. + /// By default this is [`DEFAULT_PAGINATION_CHUNKS`]. + /// + /// Note that most endpoints set a maximum to the number of items per + /// request, which most times is 50. + pub pagination_chunks: u32, + + pub token_cached: bool, + + /// TODO + pub token_refreshing: bool, +} + +impl Default for Config { + fn default() -> Self { + Config { + prefix: String::from(DEFAULT_API_PREFIX), + cache_path: PathBuf::from(DEFAULT_CACHE_PATH), + pagination_chunks: DEFAULT_PAGINATION_CHUNKS, + token_cached: false, + token_refreshing: false, + } + } +} /// Generate `length` random chars pub(in crate) fn generate_random_string(length: usize) -> String { @@ -204,6 +259,185 @@ pub(in crate) fn generate_random_string(length: usize) -> String { .collect() } +mod duration_second { + use chrono::Duration; + use serde::{de, Deserialize, Serializer}; + + /// Deserialize `chrono::Duration` from seconds (represented as u64) + pub(in crate) fn deserialize<'de, D>(d: D) -> Result + where + D: de::Deserializer<'de>, + { + let duration: i64 = Deserialize::deserialize(d)?; + Ok(Duration::seconds(duration)) + } + + /// Serialize `chrono::Duration` to seconds (represented as u64) + pub(in crate) fn serialize(x: &Duration, s: S) -> Result + where + S: Serializer, + { + s.serialize_i64(x.num_seconds()) + } +} + +mod space_separated_scopes { + use serde::{de, Deserialize, Serializer}; + use std::collections::HashSet; + + pub(crate) fn deserialize<'de, D>(d: D) -> Result, D::Error> + where + D: de::Deserializer<'de>, + { + let scopes: &str = Deserialize::deserialize(d)?; + Ok(scopes.split_whitespace().map(|x| x.to_owned()).collect()) + } + + pub(crate) fn serialize(scopes: &HashSet, s: S) -> Result + where + S: Serializer, + { + let scopes = scopes.clone().into_iter().collect::>().join(" "); + s.serialize_str(&scopes) + } +} + +/// Spotify access token information +/// [Reference](https://developer.spotify.com/documentation/general/guides/authorization-guide/) +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Token { + /// An access token that can be provided in subsequent calls + pub access_token: String, + /// The time period for which the access token is valid. + #[serde(with = "duration_second")] + pub expires_in: Duration, + /// The valid time for which the access token is available represented + /// in ISO 8601 combined date and time. + pub expires_at: Option>, + /// A token that can be sent to the Spotify Accounts service + /// in place of an authorization code + pub refresh_token: Option, + /// A list of [scopes](https://developer.spotify.com/documentation/general/guides/scopes/) + /// which have been granted for this `access_token` + /// You could use macro [scopes!](crate::scopes) to build it at compile time easily + #[serde(default, with = "space_separated_scopes")] + pub scopes: HashSet, +} + +impl Default for Token { + fn default() -> Self { + Token { + access_token: String::new(), + expires_in: Duration::seconds(0), + expires_at: Some(Utc::now()), + refresh_token: None, + scopes: HashSet::new(), + } + } +} + +impl Token { + /// Tries to initialize the token from a cache file. + // TODO: maybe ClientResult for these things instead? + pub fn from_cache>(path: T) -> Option { + let mut file = fs::File::open(path).ok()?; + let mut tok_str = String::new(); + file.read_to_string(&mut tok_str).ok()?; + + serde_json::from_str::(&tok_str).ok() + } + + /// Saves the token information into its cache file. + pub fn write_cache>(&self, path: T) -> ClientResult<()> { + let token_info = serde_json::to_string(&self)?; + + let mut file = fs::OpenOptions::new().write(true).create(true).open(path)?; + file.set_len(0)?; + file.write_all(token_info.as_bytes())?; + + Ok(()) + } + + /// Check if the token is expired + pub fn is_expired(&self) -> bool { + self.expires_at + .map_or(true, |x| Utc::now().timestamp() > x.timestamp()) + } +} + +/// Simple client credentials object for Spotify. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct Credentials { + pub id: String, + pub secret: String, +} + +impl Credentials { + pub fn new(id: &str, secret: &str) -> Self { + Credentials { + id: id.to_owned(), + secret: secret.to_owned(), + } + } + + /// Parses the credentials from the environment variables + /// `RSPOTIFY_CLIENT_ID` and `RSPOTIFY_CLIENT_SECRET`. You can optionally + /// activate the `env-file` feature in order to read these variables from + /// a `.env` file. + pub fn from_env() -> Option { + #[cfg(feature = "env-file")] + { + dotenv::dotenv().ok(); + } + + Some(Credentials { + id: env::var("RSPOTIFY_CLIENT_ID").ok()?, + secret: env::var("RSPOTIFY_CLIENT_SECRET").ok()?, + }) + } +} + +/// Structure that holds the required information for requests with OAuth. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuth { + pub redirect_uri: String, + /// The state is generated by default, as suggested by the OAuth2 spec: + /// [Cross-Site Request Forgery](https://tools.ietf.org/html/rfc6749#section-10.12) + pub state: String, + /// You could use macro [scopes!](crate::scopes) to build it at compile time easily + pub scopes: HashSet, + pub proxies: Option, +} + +impl Default for OAuth { + fn default() -> Self { + OAuth { + redirect_uri: String::new(), + state: generate_random_string(16), + scopes: HashSet::new(), + proxies: None, + } + } +} + +impl OAuth { + /// Parses the credentials from the environment variable + /// `RSPOTIFY_REDIRECT_URI`. You can optionally activate the `env-file` + /// feature in order to read these variables from a `.env` file. + pub fn from_env(scopes: HashSet) -> Option { + #[cfg(feature = "env-file")] + { + dotenv::dotenv().ok(); + } + + Some(OAuth { + scopes, + redirect_uri: env::var("RSPOTIFY_REDIRECT_URI").ok()?, + ..Default::default() + }) + } +} + #[cfg(test)] mod test { use super::generate_random_string; diff --git a/src/oauth2.rs b/src/oauth2.rs deleted file mode 100644 index b37fe14b..00000000 --- a/src/oauth2.rs +++ /dev/null @@ -1,424 +0,0 @@ -//! User authorization and client credentials management. - -use chrono::prelude::*; -use derive_builder::Builder; -use maybe_async::maybe_async; -use serde::{Deserialize, Serialize}; -use url::Url; - -use chrono::Duration; -use std::collections::{HashMap, HashSet}; -use std::{ - env, fs, - io::{Read, Write}, - path::Path, -}; - -use super::client::{ClientResult, Spotify}; -use super::http::{headers, Form, Headers}; -use crate::generate_random_string; - -mod auth_urls { - pub const AUTHORIZE: &str = "https://accounts.spotify.com/authorize"; - pub const TOKEN: &str = "https://accounts.spotify.com/api/token"; -} - -mod duration_second { - use chrono::Duration; - use serde::{de, Deserialize, Serializer}; - - /// Deserialize `chrono::Duration` from seconds (represented as u64) - pub(in crate) fn deserialize<'de, D>(d: D) -> Result - where - D: de::Deserializer<'de>, - { - let duration: i64 = Deserialize::deserialize(d)?; - Ok(Duration::seconds(duration)) - } - - /// Serialize `chrono::Duration` to seconds (represented as u64) - pub(in crate) fn serialize(x: &Duration, s: S) -> Result - where - S: Serializer, - { - s.serialize_i64(x.num_seconds()) - } -} - -mod space_separated_scope { - use serde::{de, Deserialize, Serializer}; - use std::collections::HashSet; - - pub(crate) fn deserialize<'de, D>(d: D) -> Result, D::Error> - where - D: de::Deserializer<'de>, - { - let scope: &str = Deserialize::deserialize(d)?; - Ok(scope.split_whitespace().map(|x| x.to_owned()).collect()) - } - - pub(crate) fn serialize(scope: &HashSet, s: S) -> Result - where - S: Serializer, - { - let scope = scope.clone().into_iter().collect::>().join(" "); - s.serialize_str(&scope) - } -} - -/// Spotify access token information -/// [Reference](https://developer.spotify.com/documentation/general/guides/authorization-guide/) -#[derive(Builder, Clone, Debug, Serialize, Deserialize)] -pub struct Token { - /// An access token that can be provided in subsequent calls - #[builder(setter(into))] - pub access_token: String, - /// The time period for which the access token is valid. - #[builder(default = "Duration::seconds(0)")] - #[serde(with = "duration_second")] - pub expires_in: Duration, - /// The valid time for which the access token is available represented - /// in ISO 8601 combined date and time. - #[builder(setter(strip_option), default = "Some(Utc::now())")] - pub expires_at: Option>, - /// A token that can be sent to the Spotify Accounts service - /// in place of an authorization code - #[builder(setter(into, strip_option), default)] - pub refresh_token: Option, - /// A list of [scopes](https://developer.spotify.com/documentation/general/guides/scopes/) - /// which have been granted for this `access_token` - /// You could use macro [scopes!](crate::scopes) to build it at compile time easily - #[builder(default)] - #[serde(default, with = "space_separated_scope")] - pub scope: HashSet, -} - -impl TokenBuilder { - /// Tries to initialize the token from a cache file. - pub fn from_cache>(path: T) -> Self { - if let Ok(mut file) = fs::File::open(path) { - let mut tok_str = String::new(); - if file.read_to_string(&mut tok_str).is_ok() { - if let Ok(tok) = serde_json::from_str::(&tok_str) { - return TokenBuilder { - access_token: Some(tok.access_token), - expires_in: Some(tok.expires_in), - expires_at: Some(tok.expires_at), - refresh_token: Some(tok.refresh_token), - scope: Some(tok.scope), - }; - } - } - } - - TokenBuilder::default() - } -} - -impl Token { - /// Saves the token information into its cache file. - pub fn write_cache>(&self, path: T) -> ClientResult<()> { - let token_info = serde_json::to_string(&self)?; - - let mut file = fs::OpenOptions::new().write(true).create(true).open(path)?; - file.set_len(0)?; - file.write_all(token_info.as_bytes())?; - - Ok(()) - } - - /// Check if the token is expired - pub fn is_expired(&self) -> bool { - self.expires_at - .map_or(true, |x| Utc::now().timestamp() > x.timestamp()) - } -} - -/// Simple client credentials object for Spotify. -#[derive(Builder, Debug, Default, Clone, Serialize, Deserialize)] -pub struct Credentials { - #[builder(setter(into))] - pub id: String, - #[builder(setter(into))] - pub secret: String, -} - -impl CredentialsBuilder { - /// Parses the credentials from the environment variables - /// `RSPOTIFY_CLIENT_ID` and `RSPOTIFY_CLIENT_SECRET`. You can optionally - /// activate the `env-file` feature in order to read these variables from - /// a `.env` file. - pub fn from_env() -> Self { - #[cfg(feature = "env-file")] - { - dotenv::dotenv().ok(); - } - - CredentialsBuilder { - id: env::var("RSPOTIFY_CLIENT_ID").ok(), - secret: env::var("RSPOTIFY_CLIENT_SECRET").ok(), - } - } -} - -/// Structure that holds the required information for requests with OAuth. -#[derive(Builder, Debug, Default, Clone, Serialize, Deserialize)] -pub struct OAuth { - #[builder(setter(into))] - pub redirect_uri: String, - /// The state is generated by default, as suggested by the OAuth2 spec: - /// [Cross-Site Request Forgery](https://tools.ietf.org/html/rfc6749#section-10.12) - #[builder(setter(into), default = "generate_random_string(16)")] - pub state: String, - /// You could use macro [scopes!](crate::scopes) to build it at compile time easily - #[builder(default)] - pub scope: HashSet, - #[builder(setter(into, strip_option), default)] - pub proxies: Option, -} - -impl OAuthBuilder { - /// Parses the credentials from the environment variable - /// `RSPOTIFY_REDIRECT_URI`. You can optionally activate the `env-file` - /// feature in order to read these variables from a `.env` file. - pub fn from_env() -> Self { - #[cfg(feature = "env-file")] - { - dotenv::dotenv().ok(); - } - - OAuthBuilder { - redirect_uri: env::var("RSPOTIFY_REDIRECT_URI").ok(), - ..Default::default() - } - } -} - -/// Authorization-related methods for the client. -impl Spotify { - /// Updates the cache file at the internal cache path. - pub fn write_token_cache(&self) -> ClientResult<()> { - if let Some(tok) = self.token.as_ref() { - tok.write_cache(&self.cache_path)?; - } - - Ok(()) - } - - /// Gets the required URL to authorize the current client to start the - /// [Authorization Code Flow](https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow). - pub fn get_authorize_url(&self, show_dialog: bool) -> ClientResult { - let oauth = self.get_oauth()?; - let mut payload: HashMap<&str, &str> = HashMap::new(); - let scope = oauth - .scope - .clone() - .into_iter() - .collect::>() - .join(" "); - payload.insert(headers::CLIENT_ID, &self.get_creds()?.id); - payload.insert(headers::RESPONSE_TYPE, headers::RESPONSE_CODE); - payload.insert(headers::REDIRECT_URI, &oauth.redirect_uri); - payload.insert(headers::SCOPE, &scope); - payload.insert(headers::STATE, &oauth.state); - - if show_dialog { - payload.insert(headers::SHOW_DIALOG, "true"); - } - - let parsed = Url::parse_with_params(auth_urls::AUTHORIZE, payload)?; - Ok(parsed.into_string()) - } - - /// Tries to read the cache file's token, which may not exist. - #[maybe_async] - pub async fn read_token_cache(&mut self) -> Option { - let tok = TokenBuilder::from_cache(&self.cache_path).build().ok()?; - - if !self.get_oauth().ok()?.scope.is_subset(&tok.scope) || tok.is_expired() { - // Invalid token, since it doesn't have at least the currently - // required scopes or it's expired. - None - } else { - Some(tok) - } - } - - /// Sends a request to Spotify for an access token. - #[maybe_async] - async fn fetch_access_token(&self, payload: &Form<'_>) -> ClientResult { - // This request uses a specific content type, and the client ID/secret - // as the authentication, since the access token isn't available yet. - let mut head = Headers::new(); - let (key, val) = headers::basic_auth(&self.get_creds()?.id, &self.get_creds()?.secret); - head.insert(key, val); - - let response = self - .post_form(auth_urls::TOKEN, Some(&head), payload) - .await?; - let mut tok = serde_json::from_str::(&response)?; - tok.expires_at = Utc::now().checked_add_signed(tok.expires_in); - Ok(tok) - } - - /// Refreshes the access token with the refresh token provided by the - /// [Authorization Code Flow](https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow), - /// without saving it into the cache file. - /// - /// The obtained token will be saved internally. - #[maybe_async] - pub async fn refresh_user_token_without_cache( - &mut self, - refresh_token: &str, - ) -> ClientResult<()> { - let mut data = Form::new(); - data.insert(headers::REFRESH_TOKEN, refresh_token); - data.insert(headers::GRANT_TYPE, headers::GRANT_REFRESH_TOKEN); - - let mut tok = self.fetch_access_token(&data).await?; - tok.refresh_token = Some(refresh_token.to_string()); - self.token = Some(tok); - - Ok(()) - } - - /// The same as `refresh_user_token_without_cache`, but saves the token - /// into the cache file if possible. - #[maybe_async] - pub async fn refresh_user_token(&mut self, refresh_token: &str) -> ClientResult<()> { - self.refresh_user_token_without_cache(refresh_token).await?; - - Ok(()) - } - - /// Obtains the client access token for the app without saving it into the - /// cache file. The resulting token is saved internally. - #[maybe_async] - pub async fn request_client_token_without_cache(&mut self) -> ClientResult<()> { - let mut data = Form::new(); - data.insert(headers::GRANT_TYPE, headers::GRANT_CLIENT_CREDS); - - self.token = Some(self.fetch_access_token(&data).await?); - - Ok(()) - } - - /// The same as `request_client_token_without_cache`, but saves the token - /// into the cache file if possible. - #[maybe_async] - pub async fn request_client_token(&mut self) -> ClientResult<()> { - self.request_client_token_without_cache().await?; - self.write_token_cache() - } - - /// Parse the response code in the given response url. If the URL cannot be - /// parsed or the `code` parameter is not present, this will return `None`. - /// - /// Step 2 of the [Authorization Code Flow - /// ](https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow). - pub fn parse_response_code(&self, url: &str) -> Option { - let url = Url::parse(url).ok()?; - let mut params = url.query_pairs(); - let (_, url) = params.find(|(key, _)| key == "code")?; - Some(url.to_string()) - } - - /// Obtains the user access token for the app with the given code without - /// saving it into the cache file, as part of the OAuth authentication. - /// The access token will be saved inside the Spotify instance. - /// - /// Step 3 of the [Authorization Code Flow](https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow). - #[maybe_async] - pub async fn request_user_token_without_cache(&mut self, code: &str) -> ClientResult<()> { - let oauth = self.get_oauth()?; - let mut data = Form::new(); - let scopes = oauth - .scope - .clone() - .into_iter() - .collect::>() - .join(" "); - data.insert(headers::GRANT_TYPE, headers::GRANT_AUTH_CODE); - data.insert(headers::REDIRECT_URI, oauth.redirect_uri.as_ref()); - data.insert(headers::CODE, code); - data.insert(headers::SCOPE, scopes.as_ref()); - data.insert(headers::STATE, oauth.state.as_ref()); - - self.token = Some(self.fetch_access_token(&data).await?); - - Ok(()) - } - - /// The same as `request_user_token_without_cache`, but saves the token into - /// the cache file if possible. - #[maybe_async] - pub async fn request_user_token(&mut self, code: &str) -> ClientResult<()> { - self.request_user_token_without_cache(code).await?; - self.write_token_cache() - } - - /// Opens up the authorization URL in the user's browser so that it can - /// authenticate. It also reads from the standard input the redirect URI - /// in order to obtain the access token information. The resulting access - /// token will be saved internally once the operation is successful. - /// - /// Note: this method requires the `cli` feature. - #[cfg(feature = "cli")] - #[maybe_async] - pub async fn prompt_for_user_token_without_cache(&mut self) -> ClientResult<()> { - let code = self.get_code_from_user()?; - self.request_user_token_without_cache(&code).await?; - - Ok(()) - } - - /// The same as the `prompt_for_user_token_without_cache` method, but it - /// will try to use the user token into the cache file, and save it in - /// case it didn't exist/was invalid. - /// - /// Note: this method requires the `cli` feature. - #[cfg(feature = "cli")] - #[maybe_async] - pub async fn prompt_for_user_token(&mut self) -> ClientResult<()> { - // TODO: shouldn't this also refresh the obtained token? - self.token = self.read_token_cache().await; - - // Otherwise following the usual procedure to get the token. - if self.token.is_none() { - let code = self.get_code_from_user()?; - // Will write to the cache file if successful - self.request_user_token(&code).await?; - } - - Ok(()) - } - - /// Tries to open the authorization URL in the user's browser, and returns - /// the obtained code. - /// - /// Note: this method requires the `cli` feature. - #[cfg(feature = "cli")] - fn get_code_from_user(&self) -> ClientResult { - use crate::client::ClientError; - - let url = self.get_authorize_url(false)?; - - match webbrowser::open(&url) { - Ok(_) => println!("Opened {} in your browser.", url), - Err(why) => eprintln!( - "Error when trying to open an URL in your browser: {:?}. \ - Please navigate here manually: {}", - why, url - ), - } - - println!("Please enter the URL you were redirected to: "); - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - let code = self - .parse_response_code(&input) - .ok_or_else(|| ClientError::Cli("unable to parse the response code".to_string()))?; - - Ok(code) - } -} diff --git a/src/pagination/mod.rs b/src/pagination/mod.rs deleted file mode 100644 index 31a60d94..00000000 --- a/src/pagination/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! Utilities for pagination requests. If the configured client is asynchronous, -//! it'll be based on `futures::stream::Stream`, if it's synchronous it'll just -//! use `std::iter::Iterator`. - -#[cfg(feature = "__sync")] -mod iter; -#[cfg(feature = "__async")] -mod stream; - -#[cfg(feature = "__sync")] -pub use iter::{paginate, Paginator}; -#[cfg(feature = "__async")] -pub use stream::{paginate, Paginator}; diff --git a/tests/common/mod.rs b/tests/common/mod.rs deleted file mode 100644 index 1a3d6cb3..00000000 --- a/tests/common/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -#[cfg(feature = "__sync")] -pub use test as maybe_async_test; - -#[cfg(feature = "__async")] -pub use tokio::test as maybe_async_test; diff --git a/tests/test_oauth2.rs b/tests/test_oauth2.rs index 8002a044..9542e2a7 100644 --- a/tests/test_oauth2.rs +++ b/tests/test_oauth2.rs @@ -1,35 +1,22 @@ use chrono::prelude::*; use chrono::Duration; -use maybe_async::maybe_async; -use rspotify::client::SpotifyBuilder; -use rspotify::oauth2::{CredentialsBuilder, OAuthBuilder, Token, TokenBuilder}; -use rspotify::scopes; +use rspotify::{ + prelude::*, scopes, AuthCodeSpotify, ClientCredsSpotify, Config, Credentials, OAuth, Token, +}; use std::{collections::HashMap, fs, io::Read, path::PathBuf, thread::sleep}; use url::Url; -mod common; - -use common::maybe_async_test; #[test] fn test_get_authorize_url() { - let oauth = OAuthBuilder::default() - .state("fdsafdsfa") - .redirect_uri("localhost") - .scope(scopes!("playlist-read-private")) - .build() - .unwrap(); - - let creds = CredentialsBuilder::default() - .id("this-is-my-client-id") - .secret("this-is-my-client-secret") - .build() - .unwrap(); - - let spotify = SpotifyBuilder::default() - .credentials(creds) - .oauth(oauth) - .build() - .unwrap(); + let oauth = OAuth { + state: "fdsafdsfa".to_owned(), + redirect_uri: "localhost".to_owned(), + scopes: scopes!("playlist-read-private"), + ..Default::default() + }; + let creds = Credentials::new("this-is-my-client-id", "this-is-my-client-secret"); + + let spotify = AuthCodeSpotify::new(creds, oauth); let authorize_url = spotify.get_authorize_url(false).unwrap(); let hash_query: HashMap<_, _> = Url::parse(&authorize_url) @@ -45,104 +32,92 @@ fn test_get_authorize_url() { assert_eq!(hash_query.get("state").unwrap(), "fdsafdsfa"); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] async fn test_read_token_cache() { - let now: DateTime = Utc::now(); - let scope = scopes!("playlist-read-private", "playlist-read-collaborative"); - - let tok = TokenBuilder::default() - .access_token("test-access_token") - .expires_in(Duration::seconds(3600)) - .expires_at(now) - .scope(scope.clone()) - .refresh_token("...") - .build() - .unwrap(); - - let predefined_spotify = SpotifyBuilder::default() - .token(tok.clone()) - .cache_path(PathBuf::from(".test_read_token_cache.json")) - .build() - .unwrap(); + let now = Utc::now(); + let scopes = scopes!("playlist-read-private", "playlist-read-collaborative"); + + let tok = Token { + access_token: "test-access_token".to_owned(), + expires_in: Duration::seconds(3600), + expires_at: Some(now), + scopes: scopes.clone(), + refresh_token: Some("...".to_owned()), + }; + + let config = Config { + token_cached: true, + cache_path: PathBuf::from(".test_read_token_cache.json"), + ..Default::default() + }; + let mut predefined_spotify = ClientCredsSpotify::from_token(tok.clone()); + predefined_spotify.config = config.clone(); // write token data to cache_path predefined_spotify.write_token_cache().unwrap(); - assert!(predefined_spotify.cache_path.exists()); - - let oauth_scope = scopes!("playlist-read-private"); - let oauth = OAuthBuilder::default() - .state("fdasfasfdasd") - .redirect_uri("http://localhost:8000") - .scope(oauth_scope) - .build() - .unwrap(); - - let mut spotify = SpotifyBuilder::default() - .oauth(oauth) - .cache_path(PathBuf::from(".test_read_token_cache.json")) - .build() - .unwrap(); + assert!(predefined_spotify.config.cache_path.exists()); + + let mut spotify = ClientCredsSpotify::default(); + spotify.config = config; + // read token from cache file let tok_from_file = spotify.read_token_cache().await.unwrap(); - assert_eq!(tok_from_file.scope, scope); + assert_eq!(tok_from_file.scopes, scopes); assert_eq!(tok_from_file.refresh_token.unwrap(), "..."); assert_eq!(tok_from_file.expires_in, Duration::seconds(3600)); assert_eq!(tok_from_file.expires_at.unwrap(), now); // delete cache file in the end - fs::remove_file(&spotify.cache_path).unwrap(); + fs::remove_file(&spotify.config.cache_path).unwrap(); } #[test] fn test_write_token() { - let now: DateTime = Utc::now(); - let scope = scopes!("playlist-read-private", "playlist-read-collaborative"); - - let tok = TokenBuilder::default() - .access_token("test-access_token") - .expires_in(Duration::seconds(3600)) - .expires_at(now) - .scope(scope.clone()) - .refresh_token("...") - .build() - .unwrap(); - - let spotify = SpotifyBuilder::default() - .token(tok.clone()) - .cache_path(PathBuf::from(".test_write_token_cache.json")) - .build() - .unwrap(); + let now = Utc::now(); + let scopes = scopes!("playlist-read-private", "playlist-read-collaborative"); + + let tok = Token { + access_token: "test-access_token".to_owned(), + expires_in: Duration::seconds(3600), + expires_at: Some(now), + scopes: scopes.clone(), + refresh_token: Some("...".to_owned()), + }; + + let config = Config { + token_cached: true, + cache_path: PathBuf::from(".test_write_token_cache.json"), + ..Default::default() + }; + let mut spotify = ClientCredsSpotify::from_token(tok.clone()); + spotify.config = config; let tok_str = serde_json::to_string(&tok).unwrap(); spotify.write_token_cache().unwrap(); - let mut file = fs::File::open(&spotify.cache_path).unwrap(); + let mut file = fs::File::open(&spotify.config.cache_path).unwrap(); let mut tok_str_file = String::new(); file.read_to_string(&mut tok_str_file).unwrap(); assert_eq!(tok_str, tok_str_file); let tok_from_file: Token = serde_json::from_str(&tok_str_file).unwrap(); - assert_eq!(tok_from_file.scope, scope); + assert_eq!(tok_from_file.scopes, scopes); assert_eq!(tok_from_file.expires_in, Duration::seconds(3600)); assert_eq!(tok_from_file.expires_at.unwrap(), now); // delete cache file in the end - fs::remove_file(&spotify.cache_path).unwrap(); + fs::remove_file(&spotify.config.cache_path).unwrap(); } #[test] fn test_token_is_expired() { - let scope = scopes!("playlist-read-private", "playlist-read-collaborative"); - - let tok = TokenBuilder::default() - .access_token("test-access_token") - .expires_in(Duration::seconds(1)) - .expires_at(Utc::now()) - .scope(scope) - .refresh_token("...") - .build() - .unwrap(); + let tok = Token { + scopes: scopes!("playlist-read-private", "playlist-read-collaborative"), + access_token: "test-access_token".to_owned(), + expires_in: Duration::seconds(1), + expires_at: Some(Utc::now()), + refresh_token: Some("...".to_owned()), + }; assert!(!tok.is_expired()); sleep(std::time::Duration::from_secs(2)); assert!(tok.is_expired()); @@ -150,7 +125,7 @@ fn test_token_is_expired() { #[test] fn test_parse_response_code() { - let spotify = SpotifyBuilder::default().build().unwrap(); + let spotify = AuthCodeSpotify::default(); let url = "http://localhost:8888/callback"; let code = spotify.parse_response_code(url); diff --git a/tests/test_with_credential.rs b/tests/test_with_credential.rs index 81ec48af..fa7706ad 100644 --- a/tests/test_with_credential.rs +++ b/tests/test_with_credential.rs @@ -1,19 +1,16 @@ -mod common; - -use common::maybe_async_test; -use rspotify::oauth2::CredentialsBuilder; use rspotify::{ - client::{Spotify, SpotifyBuilder}, model::{AlbumType, Country, Id, Market}, + prelude::*, + ClientCredsSpotify, Credentials, }; use maybe_async::maybe_async; /// Generating a new basic client for the requests. #[maybe_async] -pub async fn creds_client() -> Spotify { +pub async fn creds_client() -> ClientCredsSpotify { // The credentials must be available in the environment. - let creds = CredentialsBuilder::from_env().build().unwrap_or_else(|_| { + let creds = Credentials::from_env().unwrap_or_else(|| { panic!( "No credentials configured. Make sure that either the `env-file` \ feature is enabled, or that the required environment variables are \ @@ -21,25 +18,18 @@ pub async fn creds_client() -> Spotify { ) }); - let mut spotify = SpotifyBuilder::default() - .credentials(creds) - .build() - .unwrap(); - - spotify.request_client_token().await.unwrap(); - + let mut spotify = ClientCredsSpotify::new(creds); + spotify.request_token().await.unwrap(); spotify } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] async fn test_album() { let birdy_uri = Id::from_uri("spotify:album:0sNOF9WDwhWunNAHPD3Baj").unwrap(); creds_client().await.album(birdy_uri).await.unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] async fn test_albums() { let birdy_uri1 = Id::from_uri("spotify:album:41MnTivkwTO3UUJ8DrqEJJ").unwrap(); let birdy_uri2 = Id::from_uri("spotify:album:6JWc4iAiJ9FjyK0B59ABb4").unwrap(); @@ -48,8 +38,7 @@ async fn test_albums() { creds_client().await.albums(track_uris).await.unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] async fn test_album_tracks() { let birdy_uri = Id::from_uri("spotify:album:6akEvsycLGftJxYudPjmqK").unwrap(); creds_client() @@ -59,8 +48,7 @@ async fn test_album_tracks() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] async fn test_artist_related_artists() { let birdy_uri = Id::from_uri("spotify:artist:43ZHCT0cAZBISjO8DG9PnE").unwrap(); creds_client() @@ -70,15 +58,13 @@ async fn test_artist_related_artists() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] async fn test_artist() { let birdy_uri = Id::from_uri("spotify:artist:2WX2uTcsvV5OnS0inACecP").unwrap(); creds_client().await.artist(birdy_uri).await.unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] async fn test_artists_albums() { let birdy_uri = Id::from_uri("spotify:artist:2WX2uTcsvV5OnS0inACecP").unwrap(); creds_client() @@ -94,8 +80,7 @@ async fn test_artists_albums() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] async fn test_artists() { let birdy_uri1 = Id::from_uri("spotify:artist:0oSGxfWSnnOXhD2fKuz2Gy").unwrap(); let birdy_uri2 = Id::from_uri("spotify:artist:3dBVyJ7JuOMt4GE9607Qin").unwrap(); @@ -103,8 +88,7 @@ async fn test_artists() { creds_client().await.artists(artist_uris).await.unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] async fn test_artist_top_tracks() { let birdy_uri = Id::from_uri("spotify:artist:2WX2uTcsvV5OnS0inACecP").unwrap(); creds_client() @@ -114,22 +98,19 @@ async fn test_artist_top_tracks() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] async fn test_audio_analysis() { let track = Id::from_id("06AKEBrKUckW0KREUWRnvT").unwrap(); creds_client().await.track_analysis(track).await.unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] async fn test_audio_features() { let track = Id::from_uri("spotify:track:06AKEBrKUckW0KREUWRnvT").unwrap(); creds_client().await.track_features(track).await.unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] async fn test_audios_features() { let mut tracks_ids = vec![]; let track_id1 = Id::from_uri("spotify:track:4JpKVNYnVcJ8tuMKjAj50A").unwrap(); @@ -143,22 +124,19 @@ async fn test_audios_features() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] async fn test_user() { let birdy_uri = Id::from_id("tuggareutangranser").unwrap(); creds_client().await.user(birdy_uri).await.unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] async fn test_track() { let birdy_uri = Id::from_uri("spotify:track:6rqhFgbbKwnb9MLmUQDhG6").unwrap(); creds_client().await.track(birdy_uri).await.unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] async fn test_tracks() { let birdy_uri1 = Id::from_uri("spotify:track:3n3Ppam7vgaVa1iaRUc9Lp").unwrap(); let birdy_uri2 = Id::from_uri("spotify:track:3twNvmDtFQtAd5gMKedhLD").unwrap(); @@ -166,8 +144,7 @@ async fn test_tracks() { creds_client().await.tracks(track_uris, None).await.unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] async fn test_existing_playlist() { creds_client() .await @@ -176,8 +153,7 @@ async fn test_existing_playlist() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] async fn test_fake_playlist() { let playlist = creds_client() .await @@ -208,7 +184,7 @@ mod test_pagination { #[test] fn test_pagination_sync() { let mut client = creds_client(); - client.pagination_chunks = 2; + client.config.pagination_chunks = 2; let album = Id::from_uri(ALBUM).unwrap(); let names = client @@ -226,7 +202,7 @@ mod test_pagination { use futures_util::StreamExt; let mut client = creds_client().await; - client.pagination_chunks = 2; + client.config.pagination_chunks = 2; let album = Id::from_uri(ALBUM).unwrap(); let names = client diff --git a/tests/test_with_oauth.rs b/tests/test_with_oauth.rs index 6a10236c..9bebee7f 100644 --- a/tests/test_with_oauth.rs +++ b/tests/test_with_oauth.rs @@ -14,18 +14,13 @@ //! (these tokens must have been generated for all available scopes, see //! the `oauth_tokens` example). -mod common; - -use common::maybe_async_test; -use rspotify::model::offset::Offset; -use rspotify::oauth2::{CredentialsBuilder, OAuthBuilder, TokenBuilder}; use rspotify::{ - client::{Spotify, SpotifyBuilder}, model::{ - Country, EpisodeId, Id, Market, RepeatState, SearchType, ShowId, TimeRange, TrackId, - TrackPositions, + Country, EpisodeId, Id, Market, Offset, RepeatState, SearchType, ShowId, TimeRange, + TrackId, TrackPositions, }, - scopes, + prelude::*, + scopes, AuthCodeSpotify, Credentials, OAuth, Token, }; use chrono::prelude::*; @@ -35,18 +30,18 @@ use std::env; /// Generating a new OAuth client for the requests. #[maybe_async] -pub async fn oauth_client() -> Spotify { +pub async fn oauth_client() -> AuthCodeSpotify { if let Ok(access_token) = env::var("RSPOTIFY_ACCESS_TOKEN") { - let tok = TokenBuilder::default() - .access_token(access_token) - .build() - .unwrap(); + let tok = Token { + access_token, + ..Default::default() + }; - SpotifyBuilder::default().token(tok).build().unwrap() + AuthCodeSpotify::from_token(tok) } else if let Ok(refresh_token) = env::var("RSPOTIFY_REFRESH_TOKEN") { // The credentials must be available in the environment. Enable // `env-file` in order to read them from an `.env` file. - let creds = CredentialsBuilder::from_env().build().unwrap_or_else(|_| { + let creds = Credentials::from_env().unwrap_or_else(|| { panic!( "No credentials configured. Make sure that either the \ `env-file` feature is enabled, or that the required \ @@ -55,7 +50,7 @@ pub async fn oauth_client() -> Spotify { ) }); - let scope = scopes!( + let scopes = scopes!( "user-read-email", "user-read-private", "user-top-read", @@ -75,16 +70,10 @@ pub async fn oauth_client() -> Spotify { "ugc-image-upload" ); // Using every possible scope - let oauth = OAuthBuilder::from_env().scope(scope).build().unwrap(); - - let mut spotify = SpotifyBuilder::default() - .credentials(creds) - .oauth(oauth) - .build() - .unwrap(); - - spotify.refresh_user_token(&refresh_token).await.unwrap(); + let oauth = OAuth::from_env(scopes).unwrap(); + let mut spotify = AuthCodeSpotify::new(creds, oauth); + spotify.refresh_token(&refresh_token).await.unwrap(); spotify } else { panic!( @@ -95,8 +84,7 @@ pub async fn oauth_client() -> Spotify { } } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_categories() { oauth_client() @@ -111,8 +99,7 @@ async fn test_categories() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_category_playlists() { oauth_client() @@ -127,19 +114,17 @@ async fn test_category_playlists() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_current_playback() { oauth_client() .await - .current_playback::<&[_]>(None, None) + .current_playback(None, None::<&[_]>) .await .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_current_playing() { oauth_client() @@ -149,8 +134,7 @@ async fn test_current_playing() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_current_user_followed_artists() { oauth_client() @@ -160,8 +144,7 @@ async fn test_current_user_followed_artists() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_current_user_playing_track() { oauth_client() @@ -171,8 +154,7 @@ async fn test_current_user_playing_track() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_current_user_playlists() { oauth_client() @@ -182,8 +164,7 @@ async fn test_current_user_playlists() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_current_user_recently_played() { oauth_client() @@ -193,8 +174,7 @@ async fn test_current_user_recently_played() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_current_user_saved_albums_add() { let mut album_ids = vec![]; @@ -209,8 +189,7 @@ async fn test_current_user_saved_albums_add() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_current_user_saved_albums_delete() { let mut album_ids = vec![]; @@ -225,8 +204,7 @@ async fn test_current_user_saved_albums_delete() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_current_user_saved_albums() { oauth_client() @@ -236,8 +214,7 @@ async fn test_current_user_saved_albums() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_current_user_saved_tracks_add() { let mut tracks_ids = vec![]; @@ -252,8 +229,7 @@ async fn test_current_user_saved_tracks_add() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_current_user_saved_tracks_contains() { let mut tracks_ids = vec![]; @@ -268,8 +244,7 @@ async fn test_current_user_saved_tracks_contains() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_current_user_saved_tracks_delete() { let mut tracks_ids = vec![]; @@ -284,8 +259,7 @@ async fn test_current_user_saved_tracks_delete() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_current_user_saved_tracks() { oauth_client() @@ -295,8 +269,7 @@ async fn test_current_user_saved_tracks() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_current_user_top_artists() { oauth_client() @@ -306,8 +279,7 @@ async fn test_current_user_top_artists() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_current_user_top_tracks() { oauth_client() @@ -317,15 +289,13 @@ async fn test_current_user_top_tracks() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_device() { oauth_client().await.device().await.unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_featured_playlists() { let now: DateTime = Utc::now(); @@ -336,15 +306,13 @@ async fn test_featured_playlists() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_me() { oauth_client().await.me().await.unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_new_releases() { oauth_client() @@ -354,8 +322,7 @@ async fn test_new_releases() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_new_releases_with_from_token() { oauth_client() @@ -365,8 +332,7 @@ async fn test_new_releases_with_from_token() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_next_playback() { let device_id = "74ASZWbe4lXaubB36ztrGX"; @@ -377,8 +343,7 @@ async fn test_next_playback() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_pause_playback() { let device_id = "74ASZWbe4lXaubB36ztrGX"; @@ -389,8 +354,7 @@ async fn test_pause_playback() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_previous_playback() { let device_id = "74ASZWbe4lXaubB36ztrGX"; @@ -401,8 +365,7 @@ async fn test_previous_playback() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_recommendations() { let mut payload = Map::new(); @@ -424,8 +387,7 @@ async fn test_recommendations() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_repeat() { oauth_client() @@ -435,8 +397,7 @@ async fn test_repeat() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_search_album() { let query = "album:arrival artist:abba"; @@ -447,8 +408,7 @@ async fn test_search_album() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_search_artist() { let query = "tania bowra"; @@ -466,8 +426,7 @@ async fn test_search_artist() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_search_playlist() { let query = "\"doom metal\""; @@ -485,8 +444,7 @@ async fn test_search_playlist() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_search_track() { let query = "abba"; @@ -504,22 +462,19 @@ async fn test_search_track() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_seek_track() { oauth_client().await.seek_track(25000, None).await.unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_shuffle() { oauth_client().await.shuffle(true, None).await.unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_start_playback() { let device_id = "74ASZWbe4lXaubB36ztrGX"; @@ -531,8 +486,7 @@ async fn test_start_playback() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_transfer_playback() { let device_id = "74ASZWbe4lXaubB36ztrGX"; @@ -543,8 +497,7 @@ async fn test_transfer_playback() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_user_follow_artist() { let mut artists = vec![]; @@ -559,8 +512,7 @@ async fn test_user_follow_artist() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_user_unfollow_artist() { let mut artists = vec![]; @@ -575,8 +527,7 @@ async fn test_user_unfollow_artist() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_user_follow_users() { let mut users = vec![]; @@ -585,8 +536,7 @@ async fn test_user_follow_users() { oauth_client().await.user_follow_users(users).await.unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_user_unfollow_users() { let mut users = vec![]; @@ -599,8 +549,7 @@ async fn test_user_unfollow_users() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_playlist_add_tracks() { let playlist_id = Id::from_id("5jAOgWXCBKuinsGiZxjDQ5").unwrap(); @@ -616,8 +565,7 @@ async fn test_playlist_add_tracks() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_playlist_change_detail() { let playlist_id = "5jAOgWXCBKuinsGiZxjDQ5"; @@ -629,8 +577,7 @@ async fn test_playlist_change_detail() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_playlist_check_follow() { let playlist_id = Id::from_id("2v3iNvBX8Ay1Gt2uXtUKUT").unwrap(); @@ -646,8 +593,7 @@ async fn test_playlist_check_follow() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_user_playlist_create() { let user_id = Id::from_id("2257tjys2e2u2ygfke42niy2q").unwrap(); @@ -659,8 +605,7 @@ async fn test_user_playlist_create() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_playlist_follow_playlist() { let playlist_id = Id::from_id("2v3iNvBX8Ay1Gt2uXtUKUT").unwrap(); @@ -671,8 +616,7 @@ async fn test_playlist_follow_playlist() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_playlist_recorder_tracks() { let uris = Some(vec![EpisodeId::from_id("0lbiy3LKzIY2fnyjioC11p").unwrap()]); @@ -694,8 +638,7 @@ async fn test_playlist_recorder_tracks() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_playlist_remove_all_occurrences_of_tracks() { let playlist_id = Id::from_id("5jAOgWXCBKuinsGiZxjDQ5").unwrap(); @@ -711,8 +654,7 @@ async fn test_playlist_remove_all_occurrences_of_tracks() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_playlist_remove_specific_occurrences_of_tracks() { let playlist_id = Id::from_id("5jAOgWXCBKuinsGiZxjDQ5").unwrap(); @@ -732,8 +674,7 @@ async fn test_playlist_remove_specific_occurrences_of_tracks() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_playlist_replace_tracks() { let playlist_id = Id::from_id("5jAOgWXCBKuinsGiZxjDQ5").unwrap(); @@ -749,8 +690,7 @@ async fn test_playlist_replace_tracks() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_user_playlist() { let user_id = Id::from_id("spotify").unwrap(); @@ -762,8 +702,7 @@ async fn test_user_playlist() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_user_playlists() { let user_id = Id::from_id("2257tjys2e2u2ygfke42niy2q").unwrap(); @@ -774,8 +713,7 @@ async fn test_user_playlists() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_playlist_tracks() { let playlist_id = Id::from_uri("spotify:playlist:59ZbFPES4DQwEjBpWHzrtC").unwrap(); @@ -786,8 +724,7 @@ async fn test_playlist_tracks() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_playlist_unfollow() { let playlist_id = "65V6djkcVRyOStLd8nza8E"; @@ -798,15 +735,13 @@ async fn test_playlist_unfollow() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_volume() { oauth_client().await.volume(78, None).await.unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_add_queue() { let birdy_uri = TrackId::from_uri("spotify:track:6rqhFgbbKwnb9MLmUQDhG6").unwrap(); @@ -817,8 +752,7 @@ async fn test_add_queue() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_get_several_shows() { oauth_client() @@ -834,8 +768,7 @@ async fn test_get_several_shows() { .unwrap(); } -#[maybe_async] -#[maybe_async_test] +#[maybe_async::test(feature = "__sync", async(feature = "__async", tokio::test))] #[ignore] async fn test_get_several_episodes() { oauth_client()