From 1c9b09fda2fb8b8430a22d6865a281608fb5be6e Mon Sep 17 00:00:00 2001 From: Thomas Ung Date: Mon, 28 Aug 2023 16:48:01 +0200 Subject: [PATCH 1/5] add playlist cover getter and setter --- src/clients/oauth.rs | 31 +++++++++++++++++++++++++++++++ tests/test_with_oauth.rs | 18 ++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/clients/oauth.rs b/src/clients/oauth.rs index 6b1a57cd..8e7ffe2c 100644 --- a/src/clients/oauth.rs +++ b/src/clients/oauth.rs @@ -333,6 +333,37 @@ pub trait OAuthClient: BaseClient { convert_result(&result) } + /// Replace the image used to represent a specific playlist + /// + /// Parameters: + /// - playlist_id - the id of the playlist + /// - image - Base64 encoded JPEG image data, maximum payload size is 256 KB. + /// [Reference] (https://developer.spotify.com/documentation/web-api/reference/upload-custom-playlist-cover) + async fn playlist_upload_cover_image( + &self, + playlist_id: PlaylistId<'_>, + image: &str, + ) -> ClientResult { + let url = format!("playlists/{}/images", playlist_id.id()); + let params = JsonBuilder::new().required("image", image).build(); + self.api_put(&url, ¶ms).await + } + + + /// Get cover image of a playlist. + /// + /// Parameters: + /// - playlist_id - the playlist ID, URI or URL + async fn playlist_cover_image(&self, playlist_id: PlaylistId<'_>)-> ClientResult>{ + let url = format!("playlists/{}/images" , playlist_id); + let result = self.api_get(&url, &Query::new()).await?; + if result.is_empty() { + Ok(None) + } else { + convert_result(&result) + } + } + /// Changes a playlist's name and/or public/private state. /// /// Parameters: diff --git a/tests/test_with_oauth.rs b/tests/test_with_oauth.rs index e92f0c19..09fe4c62 100644 --- a/tests/test_with_oauth.rs +++ b/tests/test_with_oauth.rs @@ -763,6 +763,23 @@ async fn check_playlist_create(client: &AuthCodeSpotify) -> FullPlaylist { playlist } +#[maybe_async] +async fn check_playlist_cover(client: &AuthCodeSpotify, playlist_id: PlaylistId<'_>) { + // add playlist cover image + let image = "/9j/2wCEABoZGSccJz4lJT5CLy8vQkc9Ozs9R0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0cBHCcnMyYzPSYmPUc9Mj1HR0dEREdHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR//dAAQAAf/uAA5BZG9iZQBkwAAAAAH/wAARCAABAAEDACIAAREBAhEB/8QASwABAQAAAAAAAAAAAAAAAAAAAAYBAQAAAAAAAAAAAAAAAAAAAAAQAQAAAAAAAAAAAAAAAAAAAAARAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwAAARECEQA/AJgAH//Z"; + client + .playlist_upload_cover_image(playlist_id.as_ref(), image) + .await + .unwrap(); + + // check cover image + let cover_res = client + .playlist_cover_image(playlist_id.as_ref()) + .await + .unwrap(); + assert_eq!(cover_res.unwrap().url, image); +} + #[maybe_async] async fn check_num_tracks(client: &AuthCodeSpotify, playlist_id: PlaylistId<'_>, num: i32) { let fetched_tracks = fetch_all(client.playlist_items(playlist_id, None, None)).await; @@ -896,6 +913,7 @@ async fn test_playlist() { let playlist = check_playlist_create(&client).await; check_playlist_tracks(&client, &playlist).await; check_playlist_follow(&client, &playlist).await; + check_playlist_cover(&client, playlist.id).await; } #[maybe_async::test( From f4425b67325d2520af20dfcb83cdf23a1c567481 Mon Sep 17 00:00:00 2001 From: Thomas Ung Date: Wed, 30 Aug 2023 18:49:34 +0200 Subject: [PATCH 2/5] move non oauth function to base.rs --- src/clients/base.rs | 18 ++++++++++++++++++ src/clients/oauth.rs | 17 +---------------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/clients/base.rs b/src/clients/base.rs index 5993f0a9..577c8f32 100644 --- a/src/clients/base.rs +++ b/src/clients/base.rs @@ -637,6 +637,24 @@ where convert_result(&result) } + /// Get cover image of a playlist. + /// + /// Parameters: + /// - playlist_id - the playlist ID, URI or URL + /// [reference](https://developer.spotify.com/documentation/web-api/reference/get-playlist-cover) + async fn playlist_cover_image( + &self, + playlist_id: PlaylistId<'_>, + ) -> ClientResult> { + let url = format!("playlists/{}/images", playlist_id); + let result = self.api_get(&url, &Query::new()).await?; + if result.is_empty() { + Ok(None) + } else { + convert_result(&result) + } + } + /// Get Spotify catalog information for a single show identified by its unique Spotify ID. /// /// Path Parameters: diff --git a/src/clients/oauth.rs b/src/clients/oauth.rs index 8e7ffe2c..6a9ebcb6 100644 --- a/src/clients/oauth.rs +++ b/src/clients/oauth.rs @@ -334,7 +334,7 @@ pub trait OAuthClient: BaseClient { } /// Replace the image used to represent a specific playlist - /// + /// /// Parameters: /// - playlist_id - the id of the playlist /// - image - Base64 encoded JPEG image data, maximum payload size is 256 KB. @@ -349,21 +349,6 @@ pub trait OAuthClient: BaseClient { self.api_put(&url, ¶ms).await } - - /// Get cover image of a playlist. - /// - /// Parameters: - /// - playlist_id - the playlist ID, URI or URL - async fn playlist_cover_image(&self, playlist_id: PlaylistId<'_>)-> ClientResult>{ - let url = format!("playlists/{}/images" , playlist_id); - let result = self.api_get(&url, &Query::new()).await?; - if result.is_empty() { - Ok(None) - } else { - convert_result(&result) - } - } - /// Changes a playlist's name and/or public/private state. /// /// Parameters: From b85d544066f8974fc5bdec67f3822a043164ebe9 Mon Sep 17 00:00:00 2001 From: Thomas Ung Date: Wed, 30 Aug 2023 19:02:40 +0200 Subject: [PATCH 3/5] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fade308..8e5fa5ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ ## 0.12.0 (2023.08.26) **New features** +- ([#439](https://github.com/ramsayleung/rspotify/pull/439)) Getter and Setter of playlist api endpoint - ([#390](https://github.com/ramsayleung/rspotify/pull/390)) The `scopes!` macro supports to split the scope by whitespace. - ([#418](https://github.com/ramsayleung/rspotify/pull/418)) Add a user-settable callback function whenever token is updated. From 65e7c685c63bf61101ae58f08d5ffc2885175e7d Mon Sep 17 00:00:00 2001 From: Thomas Ung Date: Fri, 8 Sep 2023 23:30:51 +0200 Subject: [PATCH 4/5] sample example for playlist --- Cargo.toml | 5 + examples/playlist_with_oauth.rs | 255 ++++++++++++++++++++++++++++++++ src/clients/base.rs | 9 +- 3 files changed, 265 insertions(+), 4 deletions(-) create mode 100644 examples/playlist_with_oauth.rs diff --git a/Cargo.toml b/Cargo.toml index 1bf4ff16..2aaef8e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -180,3 +180,8 @@ path = "examples/pagination_sync.rs" name = "pagination_async" required-features = ["env-file", "cli", "client-reqwest"] path = "examples/pagination_async.rs" + +[[example]] +name = "playlist" +required-features = ["env-file", "cli", "client-reqwest"] +path = "examples/playlist_with_oauth.rs" diff --git a/examples/playlist_with_oauth.rs b/examples/playlist_with_oauth.rs new file mode 100644 index 00000000..d269ff4a --- /dev/null +++ b/examples/playlist_with_oauth.rs @@ -0,0 +1,255 @@ +use rspotify::{prelude::*, + clients::pagination::Paginator, + model::{ + EpisodeId, FullPlaylist, + ItemPositions, PlaylistId, + TrackId, UserId, + }, + scopes,ClientResult, AuthCodeSpotify, Credentials, OAuth}; +use base64::{engine::general_purpose, Engine as _}; +use maybe_async::maybe_async; +use reqwest; + +async fn fetch_all(paginator: Paginator<'_, ClientResult>) -> Vec { + #[cfg(feature = "__async")] + { + use futures::stream::TryStreamExt; + + paginator.try_collect::>().await.unwrap() + } + + #[cfg(feature = "__sync")] + { + paginator.filter_map(|a| a.ok()).collect::>() + } +} + +#[maybe_async] +async fn check_num_tracks(client: &AuthCodeSpotify, playlist_id: PlaylistId<'_>, num: i32) { + let fetched_tracks = fetch_all(client.playlist_items(playlist_id, None, None)).await; + assert_eq!(fetched_tracks.len() as i32, num); +} + +async fn check_playlist_cover(client: &AuthCodeSpotify, playlist_id: PlaylistId<'_>) { + let img_url = "https://images.dog.ceo/breeds/poodle-toy/n02113624_8936.jpg"; + let img_bytes = tokio::task::spawn_blocking(move || {reqwest::blocking::get(img_url).unwrap().bytes().unwrap()}).await.unwrap(); + let playlist_cover_base64 = general_purpose::URL_SAFE_NO_PAD.encode(img_bytes.clone()); + + println!("playlist id : {}", playlist_id); + + // check cover image + let cover_res = client + .playlist_cover_image(playlist_id.as_ref()) + .await + .unwrap() + .unwrap(); + + println!("cover_res pre upload: {:?}", cover_res); + + // add playlist cover image + client + .playlist_upload_cover_image(playlist_id.as_ref(), &playlist_cover_base64) + .await + .unwrap(); + + // check cover image + let cover_res = client + .playlist_cover_image(playlist_id.as_ref()) + .await + .unwrap() + .unwrap(); + + println!("cover_res post upload: {:?}", cover_res); +} + + +async fn check_playlist_create(client: &AuthCodeSpotify) -> FullPlaylist { + let user = client.me().await.unwrap(); + let name = "A New Playlist"; + + // First creating the base playlist over which the tests will be ran + let playlist = client + .user_playlist_create(user.id.as_ref(), name, Some(false), None, None) + .await + .unwrap(); + + // Making sure that the playlist has been added to the user's profile + let fetched_playlist = client + .user_playlist(user.id.as_ref(), Some(playlist.id.as_ref()), None) + .await + .unwrap(); + assert_eq!(playlist.id, fetched_playlist.id); + let user_playlists = fetch_all(client.user_playlists(user.id)).await; + let current_user_playlists = fetch_all(client.current_user_playlists()).await; + assert_eq!(user_playlists.len(), current_user_playlists.len()); + + // Modifying the playlist details + let name = "A New Playlist-update"; + let description = "A random description"; + client + .playlist_change_detail( + playlist.id.as_ref(), + Some(name), + Some(true), + Some(description), + Some(false), + ) + .await + .unwrap(); + + playlist +} + +async fn check_playlist_tracks(client: &AuthCodeSpotify, playlist: &FullPlaylist) { + // The tracks in the playlist, some of them repeated + let tracks = [ + PlayableId::Track(TrackId::from_uri("spotify:track:5iKndSu1XI74U2OZePzP8L").unwrap()), + PlayableId::Track(TrackId::from_uri("spotify:track:5iKndSu1XI74U2OZePzP8L").unwrap()), + PlayableId::Episode(EpisodeId::from_uri("spotify/episode/381XrGKkcdNkLwfsQ4Mh5y").unwrap()), + PlayableId::Episode(EpisodeId::from_uri("spotify/episode/6O63eWrfWPvN41CsSyDXve").unwrap()), + ]; + + // Firstly adding some tracks + client + .playlist_add_items( + playlist.id.as_ref(), + tracks.iter().map(PlayableId::as_ref), + None, + ) + .await + .unwrap(); + check_num_tracks(client, playlist.id.as_ref(), tracks.len() as i32).await; + + // Reordering some tracks + client + .playlist_reorder_items(playlist.id.as_ref(), Some(0), Some(3), Some(2), None) + .await + .unwrap(); + // Making sure the number of tracks is the same + check_num_tracks(client, playlist.id.as_ref(), tracks.len() as i32).await; + + // Replacing the tracks + let replaced_tracks = [ + PlayableId::Track(TrackId::from_uri("spotify:track:4iV5W9uYEdYUVa79Axb7Rh").unwrap()), + PlayableId::Track(TrackId::from_uri("spotify:track:4iV5W9uYEdYUVa79Axb7Rh").unwrap()), + PlayableId::Track(TrackId::from_uri("spotify:track:1301WleyT98MSxVHPZCA6M").unwrap()), + PlayableId::Episode(EpisodeId::from_id("0lbiy3LKzIY2fnyjioC11p").unwrap()), + PlayableId::Track(TrackId::from_uri("spotify:track:5m2en2ndANCPembKOYr1xL").unwrap()), + PlayableId::Episode(EpisodeId::from_id("4zugY5eJisugQj9rj8TYuh").unwrap()), + PlayableId::Track(TrackId::from_uri("spotify:track:5m2en2ndANCPembKOYr1xL").unwrap()), + ]; + client + .playlist_replace_items( + playlist.id.as_ref(), + replaced_tracks.iter().map(|t| t.as_ref()), + ) + .await + .unwrap(); + // Making sure the number of tracks is updated + check_num_tracks(client, playlist.id.as_ref(), replaced_tracks.len() as i32).await; + + // Removes a few specific tracks + let tracks = [ + ItemPositions { + id: PlayableId::Track( + TrackId::from_uri("spotify:track:4iV5W9uYEdYUVa79Axb7Rh").unwrap(), + ), + positions: &[0], + }, + ItemPositions { + id: PlayableId::Track( + TrackId::from_uri("spotify:track:5m2en2ndANCPembKOYr1xL").unwrap(), + ), + positions: &[4, 6], + }, + ]; + client + .playlist_remove_specific_occurrences_of_items(playlist.id.as_ref(), tracks, None) + .await + .unwrap(); + // Making sure three tracks were removed + check_num_tracks( + client, + playlist.id.as_ref(), + replaced_tracks.len() as i32 - 3, + ) + .await; + + // Removes all occurrences of two tracks + let to_remove = vec![ + PlayableId::Track(TrackId::from_uri("spotify:track:4iV5W9uYEdYUVa79Axb7Rh").unwrap()), + PlayableId::Episode(EpisodeId::from_id("0lbiy3LKzIY2fnyjioC11p").unwrap()), + ]; + client + .playlist_remove_all_occurrences_of_items(playlist.id.as_ref(), to_remove, None) + .await + .unwrap(); + // Making sure two more tracks were removed + check_num_tracks( + client, + playlist.id.as_ref(), + replaced_tracks.len() as i32 - 5, + ) + .await; +} + +#[maybe_async] +async fn check_playlist_follow(client: &AuthCodeSpotify, playlist: &FullPlaylist) { + let user_ids = [ + UserId::from_id("possan").unwrap(), + UserId::from_id("elogain").unwrap(), + ]; + + // It's a new playlist, so it shouldn't have any followers + let following = client + .playlist_check_follow(playlist.id.as_ref(), &user_ids) + .await + .unwrap(); + assert_eq!(following, vec![false, false]); + + // Finally unfollowing the playlist in order to clean it up + client + .playlist_unfollow(playlist.id.as_ref()) + .await + .unwrap(); +} + +async fn test_playlist(client: AuthCodeSpotify) { + + let playlist = check_playlist_create(&client).await; + check_playlist_tracks(&client, &playlist).await; + check_playlist_follow(&client, &playlist).await; + check_playlist_cover(&client, playlist.id).await; +} + +#[tokio::main] +async fn main() { + // You can use any logger for debugging. + env_logger::init(); + + // The credentials must be available in the environment. Enable the + // `env-file` feature in order to read them from an `.env` file. + let creds = Credentials::from_env().unwrap(); + + // Using every possible scope + let scopes = scopes!( + "user-read-email", + "user-read-private", + "user-top-read", + "user-library-read", + "playlist-read-collaborative", + "playlist-read-private", + "ugc-image-upload", + "playlist-modify-public", + "playlist-modify-private" + ); + + let oauth = OAuth::from_env(scopes).unwrap(); + + let spotify = AuthCodeSpotify::new(creds, oauth); + + let url = spotify.get_authorize_url(false).unwrap(); + // This function requires the `cli` feature enabled. + spotify.prompt_for_token(&url).await.unwrap(); + test_playlist(spotify).await; +} diff --git a/src/clients/base.rs b/src/clients/base.rs index 577c8f32..e44dc05b 100644 --- a/src/clients/base.rs +++ b/src/clients/base.rs @@ -641,6 +641,7 @@ where /// /// Parameters: /// - playlist_id - the playlist ID, URI or URL + /// /// [reference](https://developer.spotify.com/documentation/web-api/reference/get-playlist-cover) async fn playlist_cover_image( &self, @@ -648,11 +649,11 @@ where ) -> ClientResult> { let url = format!("playlists/{}/images", playlist_id); let result = self.api_get(&url, &Query::new()).await?; - if result.is_empty() { - Ok(None) - } else { + // if result.is_empty() { + // Ok(None) + // } else { convert_result(&result) - } + // } } /// Get Spotify catalog information for a single show identified by its unique Spotify ID. From b6370ec5f8f6cd64989f9dd963daff752e123ef4 Mon Sep 17 00:00:00 2001 From: Thomas Ung Date: Tue, 14 Jan 2025 07:51:58 +0100 Subject: [PATCH 5/5] wip : example implementation --- examples/playlist_with_oauth.rs | 6 ++---- src/clients/base.rs | 13 +++++-------- src/clients/oauth.rs | 18 +++++++++++++++--- tests/test_with_oauth.rs | 2 +- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/examples/playlist_with_oauth.rs b/examples/playlist_with_oauth.rs index d269ff4a..0bc71ccd 100644 --- a/examples/playlist_with_oauth.rs +++ b/examples/playlist_with_oauth.rs @@ -30,7 +30,7 @@ async fn check_num_tracks(client: &AuthCodeSpotify, playlist_id: PlaylistId<'_>, assert_eq!(fetched_tracks.len() as i32, num); } -async fn check_playlist_cover(client: &AuthCodeSpotify, playlist_id: PlaylistId<'_>) { +async fn check_playlist_cover(client: &AuthCodeSpotify, playlist_id: &PlaylistId<'_>) { let img_url = "https://images.dog.ceo/breeds/poodle-toy/n02113624_8936.jpg"; let img_bytes = tokio::task::spawn_blocking(move || {reqwest::blocking::get(img_url).unwrap().bytes().unwrap()}).await.unwrap(); let playlist_cover_base64 = general_purpose::URL_SAFE_NO_PAD.encode(img_bytes.clone()); @@ -41,7 +41,6 @@ async fn check_playlist_cover(client: &AuthCodeSpotify, playlist_id: PlaylistId< let cover_res = client .playlist_cover_image(playlist_id.as_ref()) .await - .unwrap() .unwrap(); println!("cover_res pre upload: {:?}", cover_res); @@ -56,7 +55,6 @@ async fn check_playlist_cover(client: &AuthCodeSpotify, playlist_id: PlaylistId< let cover_res = client .playlist_cover_image(playlist_id.as_ref()) .await - .unwrap() .unwrap(); println!("cover_res post upload: {:?}", cover_res); @@ -218,8 +216,8 @@ async fn test_playlist(client: AuthCodeSpotify) { let playlist = check_playlist_create(&client).await; check_playlist_tracks(&client, &playlist).await; + check_playlist_cover(&client, &playlist.id).await; check_playlist_follow(&client, &playlist).await; - check_playlist_cover(&client, playlist.id).await; } #[tokio::main] diff --git a/src/clients/base.rs b/src/clients/base.rs index e44dc05b..cc63f1b9 100644 --- a/src/clients/base.rs +++ b/src/clients/base.rs @@ -646,14 +646,11 @@ where async fn playlist_cover_image( &self, playlist_id: PlaylistId<'_>, - ) -> ClientResult> { - let url = format!("playlists/{}/images", playlist_id); - let result = self.api_get(&url, &Query::new()).await?; - // if result.is_empty() { - // Ok(None) - // } else { - convert_result(&result) - // } + ) -> ClientResult { + let params = build_map([]); + let url = format!("playlists/{}/images", playlist_id.id()); + let result = self.api_get(&url, ¶ms).await?; + convert_result(&result) } /// Get Spotify catalog information for a single show identified by its unique Spotify ID. diff --git a/src/clients/oauth.rs b/src/clients/oauth.rs index 6a9ebcb6..bf9c9247 100644 --- a/src/clients/oauth.rs +++ b/src/clients/oauth.rs @@ -10,7 +10,7 @@ use crate::{ util::{build_map, JsonBuilder}, ClientError, ClientResult, OAuth, Token, }; - +use std::collections::HashMap; use std::{ collections::HashMap, io::{BufRead, BufReader, Write}, @@ -19,6 +19,7 @@ use std::{ use maybe_async::maybe_async; use rspotify_model::idtypes::{PlayContextId, PlayableId}; +use rspotify_http::BaseHttpClient; use serde_json::{json, Map}; use url::Url; @@ -345,8 +346,19 @@ pub trait OAuthClient: BaseClient { image: &str, ) -> ClientResult { let url = format!("playlists/{}/images", playlist_id.id()); - let params = JsonBuilder::new().required("image", image).build(); - self.api_put(&url, ¶ms).await + let url = self.api_url(&url); + + let mut headers = self.auth_headers().await?; + let content_type= "Content-Type".to_owned(); + let value = String::from("image/jpeg"); + headers.insert(content_type, value); + + let payload= JsonBuilder::new() + .required("playlist_id", playlist_id.id()) + .required("body", image.to_string()) + .build(); + + Ok(self.get_http().put(&url, Some(&headers), &payload).await?) } /// Changes a playlist's name and/or public/private state. diff --git a/tests/test_with_oauth.rs b/tests/test_with_oauth.rs index 09fe4c62..134640fd 100644 --- a/tests/test_with_oauth.rs +++ b/tests/test_with_oauth.rs @@ -777,7 +777,7 @@ async fn check_playlist_cover(client: &AuthCodeSpotify, playlist_id: PlaylistId< .playlist_cover_image(playlist_id.as_ref()) .await .unwrap(); - assert_eq!(cover_res.unwrap().url, image); + assert_eq!(cover_res.url, image); } #[maybe_async]