From b527dc8757d3ef8ea994de77170cc34eb98fe061 Mon Sep 17 00:00:00 2001 From: janvrska <1644599+janvrska@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:45:41 +0100 Subject: [PATCH] feat: implement Spotify Web API February 2026 breaking changes - Remove deleted fields from structs (album, artist, track, user, show) - Rename playlist 'tracks' to 'items' across types, endpoints, and JSON - Remove deleted endpoints (batch gets, follows, categories, new releases) - Replace old library methods with generic URI-based endpoints - Replace CreatePlaylistForUser with CreatePlaylist (POST /me/playlists) - Update search limit documentation (max 10, default 5) - Update all tests and test data to match new API shape --- album.go | 56 +---- album_test.go | 39 --- artist.go | 46 +--- artist_test.go | 30 --- category.go | 76 ------ category_test.go | 237 ------------------ examples/profile/profile.go | 19 +- library.go | 123 ++++----- library_test.go | 166 ++++++------ page.go | 10 +- playlist.go | 165 ++---------- playlist_test.go | 113 ++------- search.go | 2 +- show.go | 23 -- show_test.go | 25 -- spotify.go | 23 -- spotify_test.go | 103 -------- test_data/current_users_playlists.txt | 10 +- test_data/featured_playlists.txt | 10 +- test_data/get_playlist.txt | 8 +- test_data/get_playlist_opt.txt | 2 +- test_data/playlist_items_episodes.json | 8 +- .../playlist_items_episodes_and_tracks.json | 8 +- test_data/playlist_items_tracks.json | 4 +- test_data/playlists_for_user.txt | 14 +- track.go | 80 +----- track_test.go | 37 --- user.go | 136 ---------- user_test.go | 178 +------------ 29 files changed, 221 insertions(+), 1530 deletions(-) delete mode 100644 category.go delete mode 100644 category_test.go diff --git a/album.go b/album.go index e30d0be..383cb22 100644 --- a/album.go +++ b/album.go @@ -2,7 +2,6 @@ package spotify import ( "context" - "errors" "fmt" "strconv" "strings" @@ -15,12 +14,7 @@ type SimpleAlbum struct { Name string `json:"name"` // A slice of [SimpleArtist]. Artists []SimpleArtist `json:"artists"` - // The field is present when getting an artist’s - // albums. Possible values are “album”, “single”, - // “compilation”, “appears_on”. Compare to album_type - // this field represents relationship between the artist - // and the album. - AlbumGroup string `json:"album_group"` + // The type of the album: one of "album", // "single", or "compilation". AlbumType string `json:"album_type"` @@ -32,13 +26,6 @@ type SimpleAlbum struct { // // [Spotify URI]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids URI URI `json:"uri"` - // The markets in which the album is available, identified using - // [ISO 3166-1 alpha-2] country codes. Note that an album is considered - // available in a market when at least 1 of its tracks is available in that - // market. - // - // [ISO 3166-1 alpha-2]: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 - AvailableMarkets []string `json:"available_markets"` // A link to the Web API endpoint providing full // details of the album. Endpoint string `json:"href"` @@ -88,14 +75,9 @@ type Copyright struct { // FullAlbum provides extra album data in addition to the data provided by [SimpleAlbum]. type FullAlbum struct { SimpleAlbum - Copyrights []Copyright `json:"copyrights"` - Genres []string `json:"genres"` - // The popularity of the album, represented as an integer between 0 and 100, - // with 100 being the most popular. Popularity of an album is calculated - // from the popularity of the album's individual tracks. - Popularity Numeric `json:"popularity"` - Tracks SimpleTrackPage `json:"tracks"` - ExternalIDs map[string]string `json:"external_ids"` + Copyrights []Copyright `json:"copyrights"` + Genres []string `json:"genres"` + Tracks SimpleTrackPage `json:"tracks"` } // SavedAlbum provides info about an album saved to a user's account. @@ -136,36 +118,6 @@ func toStringSlice(ids []ID) []string { return result } -// GetAlbums gets Spotify Catalog information for [multiple albums], given their -// [Spotify ID]s. It supports up to 20 IDs in a single call. Albums are returned -// in the order requested. If an album is not found, that position in the -// result slice will be nil. -// -// Supported options: [Market]. -// -// [multiple albums]: https://developer.spotify.com/documentation/web-api/reference/get-multiple-albums -// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids -func (c *Client) GetAlbums(ctx context.Context, ids []ID, opts ...RequestOption) ([]*FullAlbum, error) { - if len(ids) > 20 { - return nil, errors.New("spotify: exceeded maximum number of albums") - } - params := processOptions(opts...).urlParams - params.Set("ids", strings.Join(toStringSlice(ids), ",")) - - spotifyURL := fmt.Sprintf("%salbums?%s", c.baseURL, params.Encode()) - - var a struct { - Albums []*FullAlbum `json:"albums"` - } - - err := c.get(ctx, spotifyURL, &a) - if err != nil { - return nil, err - } - - return a.Albums, nil -} - // AlbumType represents the type of an album. It can be used to filter // results when searching for albums. type AlbumType int diff --git a/album_test.go b/album_test.go index 218f989..cd98458 100644 --- a/album_test.go +++ b/album_test.go @@ -47,45 +47,6 @@ func TestFindAlbumBadID(t *testing.T) { } } -// The example from https://developer.spotify.com/web-api/get-several-albums/ -func TestFindAlbums(t *testing.T) { - client, server := testClientFile(http.StatusOK, "test_data/find_albums.txt") - defer server.Close() - - res, err := client.GetAlbums(context.Background(), []ID{"41MnTivkwTO3UUJ8DrqEJJ", "6JWc4iAiJ9FjyK0B59ABb4", "6UXCm6bOO4gFlDQZV5yL37", "0X8vBD8h1Ga9eLT8jx9VCC"}) - if err != nil { - t.Fatal(err) - } - if len(res) != 4 { - t.Fatalf("Expected 4 albums, got %d", len(res)) - } - expectedAlbums := []string{ - "The Best Of Keane (Deluxe Edition)", - "Strangeland", - "Night Train", - "Mirrored", - } - for i, name := range expectedAlbums { - if res[i].Name != name { - t.Error("Expected album", name, "but got", res[i].Name) - } - } - release := res[0].ReleaseDateTime() - if release.Year() != 2013 || - release.Month() != 11 || - release.Day() != 8 { - t.Errorf("Expected release 2013-11-08, got %d-%02d-%02d\n", - release.Year(), release.Month(), release.Day()) - } - releaseMonthPrecision := res[3].ReleaseDateTime() - if releaseMonthPrecision.Year() != 2007 || - releaseMonthPrecision.Month() != 3 || - releaseMonthPrecision.Day() != 1 { - t.Errorf("Expected release 2007-03-01, got %d-%02d-%02d\n", - releaseMonthPrecision.Year(), releaseMonthPrecision.Month(), releaseMonthPrecision.Day()) - } -} - func TestFindAlbumTracks(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/find_album_tracks.txt") defer server.Close() diff --git a/artist.go b/artist.go index 6b733b8..c61f6c3 100644 --- a/artist.go +++ b/artist.go @@ -20,13 +20,9 @@ type SimpleArtist struct { // FullArtist provides extra artist data in addition to what is provided by [SimpleArtist]. type FullArtist struct { SimpleArtist - // The popularity of the artist, expressed as an integer between 0 and 100. - // The artist's popularity is calculated from the popularity of the artist's tracks. - Popularity Numeric `json:"popularity"` // A list of genres the artist is associated with. For example, "Prog Rock" // or "Post-Grunge". If not yet classified, the slice is empty. - Genres []string `json:"genres"` - Followers Followers `json:"followers"` + Genres []string `json:"genres"` // Images of the artist in various sizes, widest first. Images []Image `json:"images"` } @@ -44,46 +40,6 @@ func (c *Client) GetArtist(ctx context.Context, id ID) (*FullArtist, error) { return &a, nil } -// GetArtists gets spotify catalog information for several artists based on their -// Spotify IDs. It supports up to 50 artists in a single call. Artists are -// returned in the order requested. If an artist is not found, that position -// in the result will be nil. Duplicate IDs will result in duplicate artists -// in the result. -func (c *Client) GetArtists(ctx context.Context, ids ...ID) ([]*FullArtist, error) { - spotifyURL := fmt.Sprintf("%sartists?ids=%s", c.baseURL, strings.Join(toStringSlice(ids), ",")) - - var a struct { - Artists []*FullArtist - } - - err := c.get(ctx, spotifyURL, &a) - if err != nil { - return nil, err - } - - return a.Artists, nil -} - -// GetArtistsTopTracks gets Spotify catalog information about an artist's top -// tracks in a particular country. It returns a maximum of 10 tracks. The -// country is specified as an [ISO 3166-1 alpha-2] country code. -// -// [ISO 3166-1 alpha-2]: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 -func (c *Client) GetArtistsTopTracks(ctx context.Context, artistID ID, country string) ([]FullTrack, error) { - spotifyURL := fmt.Sprintf("%sartists/%s/top-tracks?country=%s", c.baseURL, artistID, country) - - var t struct { - Tracks []FullTrack `json:"tracks"` - } - - err := c.get(ctx, spotifyURL, &t) - if err != nil { - return nil, err - } - - return t.Tracks, nil -} - // GetRelatedArtists gets Spotify catalog information about artists similar to a // given artist. Similarity is based on analysis of the Spotify community's // listening history. This function returns up to 20 artists that are considered diff --git a/artist_test.go b/artist_test.go index cf6537b..87c628b 100644 --- a/artist_test.go +++ b/artist_test.go @@ -87,35 +87,11 @@ func TestFindArtist(t *testing.T) { if err != nil { t.Fatal(err) } - if followers := artist.Followers.Count; followers != 2265279 { - t.Errorf("Got %d followers, want 2265279\n", followers) - } if artist.Name != "Pitbull" { t.Error("Got ", artist.Name, ", wanted Pitbull") } } -func TestArtistTopTracks(t *testing.T) { - client, server := testClientFile(http.StatusOK, "test_data/artist_top_tracks.txt") - defer server.Close() - - tracks, err := client.GetArtistsTopTracks(context.Background(), ID("43ZHCT0cAZBISjO8DG9PnE"), "SE") - if err != nil { - t.Fatal(err) - } - - if l := len(tracks); l != 10 { - t.Fatalf("Got %d tracks, expected 10\n", l) - } - track := tracks[9] - if track.Name != "(You're The) Devil in Disguise" { - t.Error("Incorrect track name") - } - if track.TrackNumber != 24 { - t.Errorf("Track number was %d, expected 24\n", track.TrackNumber) - } -} - func TestRelatedArtists(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/related_artists.txt") defer server.Close() @@ -131,9 +107,6 @@ func TestRelatedArtists(t *testing.T) { if a2.Name != "Carl Perkins" { t.Error("Expected Carl Perkins, got ", a2.Name) } - if a2.Popularity != 54 { - t.Errorf("Expected popularity 54, got %d\n", a2.Popularity) - } } func TestRelatedArtistsWithFloats(t *testing.T) { @@ -151,9 +124,6 @@ func TestRelatedArtistsWithFloats(t *testing.T) { if a2.Name != "Carl Perkins" { t.Error("Expected Carl Perkins, got ", a2.Name) } - if a2.Popularity != 54 { - t.Errorf("Expected popularity 54, got %d\n", a2.Popularity) - } } func TestArtistAlbumsFiltered(t *testing.T) { diff --git a/category.go b/category.go deleted file mode 100644 index 40d7d35..0000000 --- a/category.go +++ /dev/null @@ -1,76 +0,0 @@ -package spotify - -import ( - "context" - "fmt" -) - -// Category is used by Spotify to tag items in. For example, on the Spotify -// player's "Browse" tab. -type Category struct { - // A link to the Web API endpoint returning full details of the category - Endpoint string `json:"href"` - // The category icon, in various sizes - Icons []Image `json:"icons"` - // The Spotify category ID. This isn't a base-62 Spotify ID, its just - // a short string that describes and identifies the category (ie "party"). - ID string `json:"id"` - // The name of the category - Name string `json:"name"` -} - -// GetCategory gets a single category used to tag items in Spotify. -// -// Supported options: [Country], [Locale]. -func (c *Client) GetCategory(ctx context.Context, id string, opts ...RequestOption) (Category, error) { - cat := Category{} - spotifyURL := fmt.Sprintf("%sbrowse/categories/%s", c.baseURL, id) - if params := processOptions(opts...).urlParams.Encode(); params != "" { - spotifyURL += "?" + params - } - - err := c.get(ctx, spotifyURL, &cat) - return cat, err -} - -// GetCategoryPlaylists gets a list of Spotify playlists tagged with a particular category. -// -// Supported options: [Country], [Limit], [Offset]. -func (c *Client) GetCategoryPlaylists(ctx context.Context, catID string, opts ...RequestOption) (*SimplePlaylistPage, error) { - spotifyURL := fmt.Sprintf("%sbrowse/categories/%s/playlists", c.baseURL, catID) - if params := processOptions(opts...).urlParams.Encode(); params != "" { - spotifyURL += "?" + params - } - - wrapper := struct { - Playlists SimplePlaylistPage `json:"playlists"` - }{} - - err := c.get(ctx, spotifyURL, &wrapper) - if err != nil { - return nil, err - } - - return &wrapper.Playlists, nil -} - -// GetCategories gets a list of categories used to tag items in Spotify -// -// Supported options: [Country], [Locale], [Limit], [Offset]. -func (c *Client) GetCategories(ctx context.Context, opts ...RequestOption) (*CategoryPage, error) { - spotifyURL := c.baseURL + "browse/categories" - if query := processOptions(opts...).urlParams.Encode(); query != "" { - spotifyURL += "?" + query - } - - wrapper := struct { - Categories CategoryPage `json:"categories"` - }{} - - err := c.get(ctx, spotifyURL, &wrapper) - if err != nil { - return nil, err - } - - return &wrapper.Categories, nil -} diff --git a/category_test.go b/category_test.go deleted file mode 100644 index 400dbcb..0000000 --- a/category_test.go +++ /dev/null @@ -1,237 +0,0 @@ -package spotify - -import ( - "context" - "net/http" - "testing" -) - -func TestGetCategories(t *testing.T) { - client, server := testClientString(http.StatusOK, getCategories) - defer server.Close() - - page, err := client.GetCategories(context.Background()) - if err != nil { - t.Fatal(err) - } - if l := len(page.Categories); l != 2 { - t.Fatalf("Expected 2 categories, got %d\n", l) - } - if name := page.Categories[1].Name; name != "Mood" { - t.Errorf("Expected 'Mood', got '%s'", name) - } -} - -func TestGetCategory(t *testing.T) { - client, server := testClientString(http.StatusOK, getCategory) - defer server.Close() - - cat, err := client.GetCategory(context.Background(), "dinner") - if err != nil { - t.Fatal(err) - } - if cat.ID != "dinner" || cat.Name != "Dinner" { - t.Errorf("Invalid name/id (%s, %s)\n", cat.Name, cat.ID) - } -} - -func TestGetCategoryPlaylists(t *testing.T) { - client, server := testClientString(http.StatusOK, getCategoryPlaylists) - defer server.Close() - - page, err := client.GetCategoryPlaylists(context.Background(), "dinner") - if err != nil { - t.Fatal(err) - } - if l := len(page.Playlists); l != 2 { - t.Fatalf("Expected 2 playlists, got %d\n", l) - } - if name := page.Playlists[0].Name; name != "Dinner with Friends" { - t.Errorf("Expected 'Dinner with Friends', got '%s'\n", name) - } - if tracks := page.Playlists[1].Tracks.Total; tracks != 91 { - t.Errorf("Expected 'Dinner Music' to have 91 tracks, but got %d\n", tracks) - } - if page.Total != 36 { - t.Errorf("Expected 26 playlists in category 'dinner' - got %d\n", page.Total) - } -} - -func TestGetCategoryOpt(t *testing.T) { - client, server := testClientString(http.StatusNotFound, "", func(r *http.Request) { - // verify that the optional parameters were included in the request - values := r.URL.Query() - if c := values.Get("country"); c != CountryBrazil { - t.Errorf("Expected country '%s', got '%s'\n", CountryBrazil, c) - } - if l := values.Get("locale"); l != "es_MX" { - t.Errorf("Expected locale 'es_MX', got '%s'\n", l) - } - }) - defer server.Close() - - _, err := client.GetCategory(context.Background(), "id", Country(CountryBrazil), Locale("es_MX")) - if err == nil { - t.Fatal("Expected error") - } -} - -func TestGetCategoryPlaylistsOpt(t *testing.T) { - client, server := testClientString(http.StatusNotFound, "", func(r *http.Request) { - values := r.URL.Query() - if c := values.Get("country"); c != "" { - t.Errorf("Country should not have been set, got %s\n", c) - } - if l := values.Get("limit"); l != "5" { - t.Errorf("Expected limit 5, got %s\n", l) - } - if o := values.Get("offset"); o != "10" { - t.Errorf("Expected offset 10, got %s\n", o) - } - }) - defer server.Close() - - _, err := client.GetCategoryPlaylists(context.Background(), "id", Limit(5), Offset(10)) - if want := "spotify: Not Found [404]"; err == nil || err.Error() != want { - t.Errorf("Expected error: want %v, got %v", want, err) - } -} - -func TestGetCategoriesInvalidToken(t *testing.T) { - client, server := testClientString(http.StatusUnauthorized, invalidToken) - defer server.Close() - - _, err := client.GetCategories(context.Background()) - if err == nil { - t.Fatal("Expected error but didn't get one") - } - serr, ok := err.(Error) - if !ok { - t.Fatal("Expected a 'spotify.Error'") - } - if serr.Status != http.StatusUnauthorized { - t.Error("Error didn't have status code 401") - } -} - -var getCategories = ` -{ - "categories" : { - "href" : "https://api.spotify.com/v1/browse/categories?country=CA&offset=0&limit=2", - "items" : [ { - "href" : "https://api.spotify.com/v1/browse/categories/toplists", - "icons" : [ { - "height" : 275, - "url" : "https://datsnxq1rwndn.cloudfront.net/media/derived/toplists_11160599e6a04ac5d6f2757f5511778f_0_0_275_275.jpg", - "width" : 275 - } ], - "id" : "toplists", - "name" : "Top Lists" - }, { - "href" : "https://api.spotify.com/v1/browse/categories/mood", - "icons" : [ { - "height" : 274, - "url" : "https://datsnxq1rwndn.cloudfront.net/media/original/mood-274x274_976986a31ac8c49794cbdc7246fd5ad7_274x274.jpg", - "width" : 274 - } ], - "id" : "mood", - "name" : "Mood" - } ], - "limit" : 2, - "next" : "https://api.spotify.com/v1/browse/categories?country=CA&offset=2&limit=2", - "offset" : 0, - "previous" : null, - "total" : 31 - } -}` - -var getCategory = ` -{ - "href" : "https://api.spotify.com/v1/browse/categories/dinner", - "icons" : [ { - "height" : 274, - "url" : "https://datsnxq1rwndn.cloudfront.net/media/original/dinner_1b6506abba0ba52c54e6d695c8571078_274x274.jpg", - "width" : 274 - } ], - "id" : "dinner", - "name" : "Dinner" -}` - -var getCategoryPlaylists = ` -{ - "playlists" : { - "href" : "https://api.spotify.com/v1/browse/categories/dinner/playlists?offset=0&limit=2", - "items" : [ { - "collaborative" : false, - "external_urls" : { - "spotify" : "http://open.spotify.com/user/spotify/playlist/59ZbFPES4DQwEjBpWHzrtC" - }, - "href" : "https://api.spotify.com/v1/users/spotify/playlists/59ZbFPES4DQwEjBpWHzrtC", - "id" : "59ZbFPES4DQwEjBpWHzrtC", - "images" : [ { - "height" : 300, - "url" : "https://i.scdn.co/image/68b6a65573a55095e9c0c0c33a274b18e0422736", - "width" : 300 - } ], - "name" : "Dinner with Friends", - "owner" : { - "external_urls" : { - "spotify" : "http://open.spotify.com/user/spotify" - }, - "href" : "https://api.spotify.com/v1/users/spotify", - "id" : "spotify", - "type" : "user", - "uri" : "spotify:user:spotify" - }, - "public" : null, - "tracks" : { - "href" : "https://api.spotify.com/v1/users/spotify/playlists/59ZbFPES4DQwEjBpWHzrtC/tracks", - "total" : 98 - }, - "type" : "playlist", - "uri" : "spotify:user:spotify:playlist:59ZbFPES4DQwEjBpWHzrtC" - }, { - "collaborative" : false, - "external_urls" : { - "spotify" : "http://open.spotify.com/user/spotify/playlist/1WDw5izv4UhpobNdGXQug7" - }, - "href" : "https://api.spotify.com/v1/users/spotify/playlists/1WDw5izv4UhpobNdGXQug7", - "id" : "1WDw5izv4UhpobNdGXQug7", - "images" : [ { - "height" : 300, - "url" : "https://i.scdn.co/image/acdcc5e1aa4e9c1db523d684a35f9c0785e50152", - "width" : 300 - } ], - "name" : "Dinner Music", - "owner" : { - "external_urls" : { - "spotify" : "http://open.spotify.com/user/spotify" - }, - "href" : "https://api.spotify.com/v1/users/spotify", - "id" : "spotify", - "type" : "user", - "uri" : "spotify:user:spotify" - }, - "public" : null, - "tracks" : { - "href" : "https://api.spotify.com/v1/users/spotify/playlists/1WDw5izv4UhpobNdGXQug7/tracks", - "total" : 91 - }, - "type" : "playlist", - "uri" : "spotify:user:spotify:playlist:1WDw5izv4UhpobNdGXQug7" - } ], - "limit" : 2, - "next" : "https://api.spotify.com/v1/browse/categories/dinner/playlists?offset=2&limit=2", - "offset" : 0, - "previous" : null, - "total" : 36 - } -}` - -var invalidToken = ` -{ - "error": { - "status": 401, - "message": "Invalid access token" - } -}` diff --git a/examples/profile/profile.go b/examples/profile/profile.go index 17a1768..9502215 100644 --- a/examples/profile/profile.go +++ b/examples/profile/profile.go @@ -1,32 +1,22 @@ -// Command profile gets the public profile information about a Spotify user. +// Command profile gets the profile information about the current Spotify user. package main import ( "context" - "flag" "fmt" - spotifyauth "github.com/zmb3/spotify/v2/auth" "log" "os" + spotifyauth "github.com/zmb3/spotify/v2/auth" + "golang.org/x/oauth2/clientcredentials" "github.com/zmb3/spotify/v2" ) -var userID = flag.String("user", "", "the Spotify user ID to look up") - func main() { - flag.Parse() - ctx := context.Background() - if *userID == "" { - fmt.Fprintf(os.Stderr, "Error: missing user ID\n") - flag.Usage() - return - } - config := &clientcredentials.Config{ ClientID: os.Getenv("SPOTIFY_ID"), ClientSecret: os.Getenv("SPOTIFY_SECRET"), @@ -39,7 +29,7 @@ func main() { httpClient := spotifyauth.New().Client(ctx, token) client := spotify.New(httpClient) - user, err := client.GetUsersPublicProfile(ctx, spotify.ID(*userID)) + user, err := client.CurrentUser(ctx) if err != nil { fmt.Fprintln(os.Stderr, err.Error()) return @@ -49,5 +39,4 @@ func main() { fmt.Println("Display name:", user.DisplayName) fmt.Println("Spotify URI:", string(user.URI)) fmt.Println("Endpoint:", user.Endpoint) - fmt.Println("Followers:", user.Followers.Count) } diff --git a/library.go b/library.go index 1ba4e27..0055f8e 100644 --- a/library.go +++ b/library.go @@ -1,83 +1,84 @@ package spotify import ( + "bytes" "context" - "errors" + "encoding/json" "fmt" "net/http" "strings" ) -// UserHasTracks checks if one or more tracks are saved to the current user's -// "Your Music" library. -func (c *Client) UserHasTracks(ctx context.Context, ids ...ID) ([]bool, error) { - return c.libraryContains(ctx, "tracks", ids...) +// SaveToLibrary saves one or more items to the current user's library. +// This call accepts Spotify URIs (e.g. "spotify:track:xxx", "spotify:album:xxx", +// "spotify:artist:xxx", "spotify:playlist:xxx"). +// +// Appropriate scopes need to be passed depending on the entities being saved. +func (c *Client) SaveToLibrary(ctx context.Context, uris ...URI) error { + if l := len(uris); l == 0 { + return fmt.Errorf("spotify: at least one URI is required") + } + spotifyURL := c.baseURL + "me/library" + body := struct { + URIs []URI `json:"uris"` + }{URIs: uris} + bodyJSON, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, "PUT", spotifyURL, bytes.NewReader(bodyJSON)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + return c.execute(req, nil) } -// UserHasAlbums checks if one or more albums are saved to the current user's -// "Your Albums" library. -func (c *Client) UserHasAlbums(ctx context.Context, ids ...ID) ([]bool, error) { - return c.libraryContains(ctx, "albums", ids...) +// RemoveFromLibrary removes one or more items from the current user's library. +// This call accepts Spotify URIs (e.g. "spotify:track:xxx", "spotify:album:xxx", +// "spotify:artist:xxx", "spotify:playlist:xxx"). +// +// Appropriate scopes need to be passed depending on the entities being removed. +func (c *Client) RemoveFromLibrary(ctx context.Context, uris ...URI) error { + if l := len(uris); l == 0 { + return fmt.Errorf("spotify: at least one URI is required") + } + spotifyURL := c.baseURL + "me/library" + body := struct { + URIs []URI `json:"uris"` + }{URIs: uris} + bodyJSON, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, "DELETE", spotifyURL, bytes.NewReader(bodyJSON)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + return c.execute(req, nil) } -func (c *Client) libraryContains(ctx context.Context, typ string, ids ...ID) ([]bool, error) { - if l := len(ids); l == 0 || l > 50 { - return nil, errors.New("spotify: supports 1 to 50 IDs per call") +// UserHasSavedItems checks if one or more items are saved in the current user's library. +// This call accepts Spotify URIs (e.g. "spotify:track:xxx", "spotify:album:xxx"). +// +// The result is returned as a slice of bool values in the same order +// in which the URIs were specified. +func (c *Client) UserHasSavedItems(ctx context.Context, uris ...URI) ([]bool, error) { + if l := len(uris); l == 0 { + return nil, fmt.Errorf("spotify: at least one URI is required") + } + uriStrings := make([]string, len(uris)) + for i, u := range uris { + uriStrings[i] = string(u) } - spotifyURL := fmt.Sprintf("%sme/%s/contains?ids=%s", c.baseURL, typ, strings.Join(toStringSlice(ids), ",")) + spotifyURL := fmt.Sprintf("%sme/library/contains?uris=%s", c.baseURL, strings.Join(uriStrings, ",")) var result []bool - err := c.get(ctx, spotifyURL, &result) if err != nil { return nil, err } - return result, err -} - -// AddTracksToLibrary saves one or more tracks to the current user's -// "Your Music" library. This call requires the [ScopeUserLibraryModify] scope. -// A track can only be saved once; duplicate IDs are ignored. -func (c *Client) AddTracksToLibrary(ctx context.Context, ids ...ID) error { - return c.modifyLibrary(ctx, "tracks", true, ids...) -} - -// RemoveTracksFromLibrary removes one or more tracks from the current user's -// "Your Music" library. This call requires the [ScopeUserModifyLibrary] scope. -// Trying to remove a track when you do not have the user's authorization -// results in an [Error] with the status code set to [net/http.StatusUnauthorized]. -func (c *Client) RemoveTracksFromLibrary(ctx context.Context, ids ...ID) error { - return c.modifyLibrary(ctx, "tracks", false, ids...) -} - -// AddAlbumsToLibrary saves one or more albums to the current user's -// "Your Albums" library. This call requires the [ScopeUserLibraryModify] scope. -// A track can only be saved once; duplicate IDs are ignored. -func (c *Client) AddAlbumsToLibrary(ctx context.Context, ids ...ID) error { - return c.modifyLibrary(ctx, "albums", true, ids...) -} - -// RemoveAlbumsFromLibrary removes one or more albums from the current user's -// "Your Albums" library. This call requires the [ScopeUserModifyLibrary] scope. -// Trying to remove a track when you do not have the user's authorization -// results in an [Error] with the status code set to [net/http.StatusUnauthorized]. -func (c *Client) RemoveAlbumsFromLibrary(ctx context.Context, ids ...ID) error { - return c.modifyLibrary(ctx, "albums", false, ids...) -} - -func (c *Client) modifyLibrary(ctx context.Context, typ string, add bool, ids ...ID) error { - if l := len(ids); l == 0 || l > 50 { - return errors.New("spotify: this call supports 1 to 50 IDs per call") - } - spotifyURL := fmt.Sprintf("%sme/%s?ids=%s", c.baseURL, typ, strings.Join(toStringSlice(ids), ",")) - method := "DELETE" - if add { - method = "PUT" - } - req, err := http.NewRequestWithContext(ctx, method, spotifyURL, nil) - if err != nil { - return err - } - return c.execute(req, nil) + return result, nil } diff --git a/library_test.go b/library_test.go index e7cbbb6..a3c71ff 100644 --- a/library_test.go +++ b/library_test.go @@ -1,123 +1,105 @@ package spotify import ( - "context" - "errors" - "net/http" - "testing" +"context" +"encoding/json" +"errors" +"io" +"net/http" +"testing" ) -func TestUserHasTracks(t *testing.T) { - client, server := testClientString(http.StatusOK, `[ false, true ]`) - defer server.Close() - - contains, err := client.UserHasTracks(context.Background(), "0udZHhCi7p1YzMlvI4fXoK", "55nlbqqFVnSsArIeYSQlqx") - if err != nil { - t.Error(err) - } - if l := len(contains); l != 2 { - t.Error("Expected 2 results, got", l) - } - if contains[0] || !contains[1] { - t.Error("Expected [false, true], got", contains) - } +func TestSaveToLibrary(t *testing.T) { +client, server := testClientString(http.StatusOK, "", func(req *http.Request) { +if req.Method != "PUT" { +t.Errorf("Expected PUT, got %s", req.Method) } +body, err := io.ReadAll(req.Body) +if err != nil { +t.Fatal(err) +} +var b struct { +URIs []URI `json:"uris"` +} +if err := json.Unmarshal(body, &b); err != nil { +t.Fatal(err) +} +if len(b.URIs) != 2 { +t.Errorf("Expected 2 URIs, got %d", len(b.URIs)) +} +}) +defer server.Close() -func TestAddTracksToLibrary(t *testing.T) { - client, server := testClientString(http.StatusOK, "") - defer server.Close() - - err := client.AddTracksToLibrary(context.Background(), "4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") - if err != nil { - t.Error(err) - } +err := client.SaveToLibrary(context.Background(), "spotify:track:4iV5W9uYEdYUVa79Axb7Rh", "spotify:album:1301WleyT98MSxVHPZCA6M") +if err != nil { +t.Error(err) +} } -func TestAddTracksToLibraryFailure(t *testing.T) { - client, server := testClientString(http.StatusUnauthorized, ` +func TestSaveToLibraryFailure(t *testing.T) { +client, server := testClientString(http.StatusUnauthorized, ` { "error": { "status": 401, "message": "Invalid access token" } }`) - defer server.Close() - err := client.AddTracksToLibrary(context.Background(), "4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") - if err == nil { - t.Error("Expected error and didn't get one") - } +defer server.Close() +err := client.SaveToLibrary(context.Background(), "spotify:track:4iV5W9uYEdYUVa79Axb7Rh") +if err == nil { +t.Error("Expected error and didn't get one") +} } -func TestAddTracksToLibraryWithContextCancelled(t *testing.T) { - client, server := testClientString(http.StatusOK, ``) - defer server.Close() +func TestSaveToLibraryWithContextCancelled(t *testing.T) { +client, server := testClientString(http.StatusOK, "") +defer server.Close() - ctx, done := context.WithCancel(context.Background()) - done() +ctx, done := context.WithCancel(context.Background()) +done() - err := client.AddTracksToLibrary(ctx, "4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") - if !errors.Is(err, context.Canceled) { - t.Error("Expected error and didn't get one") - } +err := client.SaveToLibrary(ctx, "spotify:track:4iV5W9uYEdYUVa79Axb7Rh") +if !errors.Is(err, context.Canceled) { +t.Error("Expected error and didn't get one") } - -func TestRemoveTracksFromLibrary(t *testing.T) { - client, server := testClientString(http.StatusOK, "") - defer server.Close() - - err := client.RemoveTracksFromLibrary(context.Background(), "4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") - if err != nil { - t.Error(err) - } } -func TestUserHasAlbums(t *testing.T) { - client, server := testClientString(http.StatusOK, `[ false, true ]`) - defer server.Close() +func TestRemoveFromLibrary(t *testing.T) { +client, server := testClientString(http.StatusOK, "", func(req *http.Request) { +if req.Method != "DELETE" { +t.Errorf("Expected DELETE, got %s", req.Method) +} +}) +defer server.Close() - contains, err := client.UserHasAlbums(context.Background(), "0udZHhCi7p1YzMlvI4fXoK", "55nlbqqFVnSsArIeYSQlqx") - if err != nil { - t.Error(err) - } - if l := len(contains); l != 2 { - t.Error("Expected 2 results, got", l) - } - if contains[0] || !contains[1] { - t.Error("Expected [false, true], got", contains) - } +err := client.RemoveFromLibrary(context.Background(), "spotify:track:4iV5W9uYEdYUVa79Axb7Rh", "spotify:album:1301WleyT98MSxVHPZCA6M") +if err != nil { +t.Error(err) +} } -func TestAddAlbumsToLibrary(t *testing.T) { - client, server := testClientString(http.StatusOK, "") - defer server.Close() +func TestUserHasSavedItems(t *testing.T) { +client, server := testClientString(http.StatusOK, `[ false, true ]`) +defer server.Close() - err := client.AddAlbumsToLibrary(context.Background(), "4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") - if err != nil { - t.Error(err) - } +contains, err := client.UserHasSavedItems(context.Background(), "spotify:track:0udZHhCi7p1YzMlvI4fXoK", "spotify:track:55nlbqqFVnSsArIeYSQlqx") +if err != nil { +t.Error(err) +} +if l := len(contains); l != 2 { +t.Error("Expected 2 results, got", l) +} +if contains[0] || !contains[1] { +t.Error("Expected [false, true], got", contains) } - -func TestAddAlbumsToLibraryFailure(t *testing.T) { - client, server := testClientString(http.StatusUnauthorized, ` -{ - "error": { - "status": 401, - "message": "Invalid access token" - } -}`) - defer server.Close() - err := client.AddAlbumsToLibrary(context.Background(), "4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") - if err == nil { - t.Error("Expected error and didn't get one") - } } -func TestRemoveAlbumsFromLibrary(t *testing.T) { - client, server := testClientString(http.StatusOK, "") - defer server.Close() +func TestSaveToLibraryEmpty(t *testing.T) { +client, server := testClientString(http.StatusOK, "") +defer server.Close() - err := client.RemoveAlbumsFromLibrary(context.Background(), "4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") - if err != nil { - t.Error(err) - } +err := client.SaveToLibrary(context.Background()) +if err == nil { +t.Error("Expected error for empty URIs") +} } diff --git a/page.go b/page.go index e8b6475..8b58f21 100644 --- a/page.go +++ b/page.go @@ -83,16 +83,10 @@ type SavedTrackPage struct { Tracks []SavedTrack `json:"items"` } -// PlaylistTrackPage contains information about tracks in a playlist. +// PlaylistTrackPage contains information about items in a playlist. type PlaylistTrackPage struct { basePage - Tracks []PlaylistTrack `json:"items"` -} - -// CategoryPage contains [Category] objects returned by the Web API. -type CategoryPage struct { - basePage - Categories []Category `json:"items"` + Items []PlaylistTrack `json:"items"` } // SimpleEpisodePage contains [EpisodePage] returned by the Web API. diff --git a/playlist.go b/playlist.go index 3f4e319..29d194d 100644 --- a/playlist.go +++ b/playlist.go @@ -8,16 +8,15 @@ import ( "fmt" "io" "net/http" - "strconv" "strings" ) -// PlaylistTracks contains details about the tracks in a playlist. -type PlaylistTracks struct { +// PlaylistItems contains details about the items in a playlist. +type PlaylistItems struct { // A link to the Web API endpoint where full details of - // the playlist's tracks can be retrieved. + // the playlist's items can be retrieved. Endpoint string `json:"href"` - // The total number of tracks in the playlist. + // The total number of items in the playlist. Total Numeric `json:"total"` } @@ -43,17 +42,17 @@ type SimplePlaylist struct { // requests to target a specific playlist version. SnapshotID string `json:"snapshot_id"` // A collection to the Web API endpoint where full details of the playlist's - // tracks can be retrieved, along with the total number of tracks in the playlist. - Tracks PlaylistTracks `json:"tracks"` - URI URI `json:"uri"` + // items can be retrieved, along with the total number of items in the playlist. + Items PlaylistItems `json:"items"` + URI URI `json:"uri"` } // FullPlaylist provides extra playlist data in addition to the data provided by [SimplePlaylist]. type FullPlaylist struct { SimplePlaylist // Information about the followers of this playlist. - Followers Followers `json:"followers"` - Tracks PlaylistTrackPage `json:"tracks"` + Followers Followers `json:"followers"` + Items PlaylistItemPage `json:"items"` } // FeaturedPlaylists gets a [list of playlists featured by Spotify]. @@ -80,75 +79,6 @@ func (c *Client) FeaturedPlaylists(ctx context.Context, opts ...RequestOption) ( return result.Message, &result.Playlists, nil } -// FollowPlaylist [adds the current user as a follower] of the specified -// playlist. Any playlist can be followed, regardless of its private/public -// status, as long as you know the playlist ID. -// -// If the public argument is true, then the playlist will be included in the -// user's public playlists. To be able to follow playlists privately, the user -// must have granted the [ScopePlaylistModifyPrivate] scope. The -// [ScopePlaylistModifyPublic] scope is required to follow playlists publicly. -// -// [adds the current user as a follower]: https://developer.spotify.com/documentation/web-api/reference/follow-playlist -func (c *Client) FollowPlaylist(ctx context.Context, playlist ID, public bool) error { - spotifyURL := buildFollowURI(c.baseURL, playlist) - body := strings.NewReader(strconv.FormatBool(public)) - req, err := http.NewRequestWithContext(ctx, "PUT", spotifyURL, body) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - return c.execute(req, nil) -} - -// UnfollowPlaylist [removes the current user as a follower of a playlist]. -// Unfollowing a publicly followed playlist requires [ScopePlaylistModifyPublic]. -// Unfolowing a privately followed playlist requies [ScopePlaylistModifyPrivate]. -// -// [removes the current user as a follower of a playlist]: https://developer.spotify.com/documentation/web-api/reference/unfollow-playlist -func (c *Client) UnfollowPlaylist(ctx context.Context, playlist ID) error { - spotifyURL := buildFollowURI(c.baseURL, playlist) - req, err := http.NewRequestWithContext(ctx, "DELETE", spotifyURL, nil) - if err != nil { - return err - } - return c.execute(req, nil) -} - -func buildFollowURI(url string, playlist ID) string { - return fmt.Sprintf("%splaylists/%s/followers", - url, string(playlist)) -} - -// GetPlaylistsForUser [gets a list of the playlists] owned or followed by a -// particular Spotify user. -// -// Private playlists and collaborative playlists are only retrievable for the -// current user. In order to read private playlists, the user must have granted -// the [ScopePlaylistReadPrivate] scope. Note that this scope alone will not -// return collaborative playlists, even though they are always private. In -// order to read collaborative playlists, the user must have granted the -// [ScopePlaylistReadCollaborative] scope. -// -// Supported options: [Limit], [Offset]. -// -// [gets a list of the playlists]: https://developer.spotify.com/documentation/web-api/reference/get-list-users-playlists -func (c *Client) GetPlaylistsForUser(ctx context.Context, userID string, opts ...RequestOption) (*SimplePlaylistPage, error) { - spotifyURL := c.baseURL + "users/" + userID + "/playlists" - if params := processOptions(opts...).urlParams.Encode(); params != "" { - spotifyURL += "?" + params - } - - var result SimplePlaylistPage - - err := c.get(ctx, spotifyURL, &result) - if err != nil { - return nil, err - } - - return &result, err -} - // GetPlaylist [fetches a playlist] from spotify. // // Supported options: [Fields]. @@ -170,33 +100,6 @@ func (c *Client) GetPlaylist(ctx context.Context, playlistID ID, opts ...Request return &playlist, err } -// GetPlaylistTracks [gets full details of the tracks in a playlist], given the -// playlist's Spotify ID. -// -// Supported options: [Limit], [Offset], [Market], [Fields]. -// -// Deprecated: the Spotify api is moving towards supporting both tracks and episodes. Use [GetPlaylistItems] which -// supports these. -func (c *Client) GetPlaylistTracks( - ctx context.Context, - playlistID ID, - opts ...RequestOption, -) (*PlaylistTrackPage, error) { - spotifyURL := fmt.Sprintf("%splaylists/%s/tracks", c.baseURL, playlistID) - if params := processOptions(opts...).urlParams.Encode(); params != "" { - spotifyURL += "?" + params - } - - var result PlaylistTrackPage - - err := c.get(ctx, spotifyURL, &result) - if err != nil { - return nil, err - } - - return &result, nil -} - // PlaylistItem contains info about an item in a playlist. type PlaylistItem struct { // The date and time the track was added to the playlist. @@ -209,8 +112,8 @@ type PlaylistItem struct { AddedBy User `json:"added_by"` // Whether this track is a local file or not. IsLocal bool `json:"is_local"` - // Information about the track. - Track PlaylistItemTrack `json:"track"` + // Information about the item. + Item PlaylistItemTrack `json:"item"` } // PlaylistItemTrack is a union type for both tracks and episodes. If both @@ -263,7 +166,7 @@ type PlaylistItemPage struct { // [gets full details of the items in a playlist]: https://developer.spotify.com/documentation/web-api/reference/get-playlists-tracks // [Spotify ID]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids func (c *Client) GetPlaylistItems(ctx context.Context, playlistID ID, opts ...RequestOption) (*PlaylistItemPage, error) { - spotifyURL := fmt.Sprintf("%splaylists/%s/tracks", c.baseURL, playlistID) + spotifyURL := fmt.Sprintf("%splaylists/%s/items", c.baseURL, playlistID) // Add default as the first option so it gets override by url.Values#Set opts = append([]RequestOption{AdditionalTypes(EpisodeAdditionalType, TrackAdditionalType)}, opts...) @@ -282,19 +185,19 @@ func (c *Client) GetPlaylistItems(ctx context.Context, playlistID ID, opts ...Re return &result, nil } -// CreatePlaylistForUser [creates a playlist] for a Spotify user. -// The playlist will be empty until you add tracks to it. +// CreatePlaylist [creates a playlist] for the current user. +// The playlist will be empty until you add items to it. // The playlistName does not need to be unique - a user can have // several playlists with the same name. // -// Creating a public playlist for a user requires [ScopePlaylistModifyPublic]; +// Creating a public playlist requires [ScopePlaylistModifyPublic]; // creating a private playlist requires [ScopePlaylistModifyPrivate]. // // On success, the newly created playlist is returned. // // [creates a playlist]: https://developer.spotify.com/documentation/web-api/reference/create-playlist -func (c *Client) CreatePlaylistForUser(ctx context.Context, userID, playlistName, description string, public bool, collaborative bool) (*FullPlaylist, error) { - spotifyURL := fmt.Sprintf("%susers/%s/playlists", c.baseURL, userID) +func (c *Client) CreatePlaylist(ctx context.Context, playlistName, description string, public bool, collaborative bool) (*FullPlaylist, error) { + spotifyURL := fmt.Sprintf("%sme/playlists", c.baseURL) body := struct { Name string `json:"name"` Public bool `json:"public"` @@ -409,7 +312,7 @@ func (c *Client) AddTracksToPlaylist(ctx context.Context, playlistID ID, trackID m := make(map[string]interface{}) m["uris"] = uris - spotifyURL := fmt.Sprintf("%splaylists/%s/tracks", + spotifyURL := fmt.Sprintf("%splaylists/%s/items", c.baseURL, string(playlistID)) body, err := json.Marshal(m) if err != nil { @@ -498,12 +401,12 @@ func (c *Client) removeTracksFromPlaylist( snapshotID string, ) (newSnapshotID string, err error) { m := make(map[string]interface{}) - m["tracks"] = tracks + m["items"] = tracks if snapshotID != "" { m["snapshot_id"] = snapshotID } - spotifyURL := fmt.Sprintf("%splaylists/%s/tracks", + spotifyURL := fmt.Sprintf("%splaylists/%s/items", c.baseURL, string(playlistID)) body, err := json.Marshal(m) if err != nil { @@ -544,7 +447,7 @@ func (c *Client) ReplacePlaylistTracks(ctx context.Context, playlistID ID, track for i, u := range trackIDs { trackURIs[i] = fmt.Sprintf("spotify:track:%s", u) } - spotifyURL := fmt.Sprintf("%splaylists/%s/tracks?uris=%s", + spotifyURL := fmt.Sprintf("%splaylists/%s/items?uris=%s", c.baseURL, playlistID, strings.Join(trackURIs, ",")) req, err := http.NewRequestWithContext(ctx, "PUT", spotifyURL, nil) if err != nil { @@ -574,7 +477,7 @@ func (c *Client) ReplacePlaylistItems(ctx context.Context, playlistID ID, items return "", err } - spotifyURL := fmt.Sprintf("%splaylists/%s/tracks", c.baseURL, playlistID) + spotifyURL := fmt.Sprintf("%splaylists/%s/items", c.baseURL, playlistID) req, err := http.NewRequestWithContext(ctx, "PUT", spotifyURL, bytes.NewReader(body)) if err != nil { return "", err @@ -593,28 +496,6 @@ func (c *Client) ReplacePlaylistItems(ctx context.Context, playlistID ID, items return result.SnapshotID, nil } -// UserFollowsPlaylist [checks if one or more (up to 5) users are following] -// a Spotify playlist, given the playlist's owner and ID. -// -// Checking if a user follows a playlist publicly doesn't require any scopes. -// Checking if the user is privately following a playlist is only possible for the -// current user when that user has granted access to the [ScopePlaylistReadPrivate] scope. -// -// [checks if one or more (up to 5) users are following]: https://developer.spotify.com/documentation/web-api/reference/check-if-user-follows-playlist -func (c *Client) UserFollowsPlaylist(ctx context.Context, playlistID ID, userIDs ...string) ([]bool, error) { - spotifyURL := fmt.Sprintf("%splaylists/%s/followers/contains?ids=%s", - c.baseURL, playlistID, strings.Join(userIDs, ",")) - - follows := make([]bool, len(userIDs)) - - err := c.get(ctx, spotifyURL, &follows) - if err != nil { - return nil, err - } - - return follows, nil -} - // PlaylistReorderOptions is used with ReorderPlaylistTracks to reorder // a track or group of tracks in a playlist. // @@ -653,7 +534,7 @@ type PlaylistReorderOptions struct { // Reordering tracks in the user's private playlists (including collaborative playlists) requires // [ScopePlaylistModifyPrivate]. func (c *Client) ReorderPlaylistTracks(ctx context.Context, playlistID ID, opt PlaylistReorderOptions) (snapshotID string, err error) { - spotifyURL := fmt.Sprintf("%splaylists/%s/tracks", c.baseURL, playlistID) + spotifyURL := fmt.Sprintf("%splaylists/%s/items", c.baseURL, playlistID) j, err := json.Marshal(opt) if err != nil { return "", err diff --git a/playlist_test.go b/playlist_test.go index 06f2e3a..4e9af95 100644 --- a/playlist_test.go +++ b/playlist_test.go @@ -61,33 +61,6 @@ func TestFeaturedPlaylistsExpiredToken(t *testing.T) { } } -func TestPlaylistsForUser(t *testing.T) { - client, server := testClientFile(http.StatusOK, "test_data/playlists_for_user.txt") - defer server.Close() - - playlists, err := client.GetPlaylistsForUser(context.Background(), "whizler") - if err != nil { - t.Error(err) - } - if l := len(playlists.Playlists); l == 0 { - t.Fatal("Didn't get any results") - } else if l != 7 { - t.Errorf("Got %d playlists, expected 7\n", l) - } - - p := playlists.Playlists[0] - if p.Name != "Top 40" { - t.Error("Expected Top 40, got", p.Name) - } - if p.Tracks.Total != 40 { - t.Error("Expected 40 tracks, got", p.Tracks.Total) - } - expected := "Nederlandse Top 40, de enige echte hitlijst van Nederland! Official Dutch Top 40. Check top40.nl voor alle details en luister iedere vrijdag vanaf 14.00 uur naar de lijst op Qmusic met Domien Verschuuren." - if p.Description != expected { - t.Errorf("Expected '%s', got '%s'\n", expected, p.Description) - } -} - func TestGetPlaylist(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/get_playlist.txt") defer server.Close() @@ -125,51 +98,8 @@ func TestGetPlaylistOpt(t *testing.T) { t.Error("No description should be included") } // A bit counterintuitive, but we excluded tracks.total from the API call so it should be 0 in the model. - if p.Tracks.Total != 0 { - t.Errorf("Tracks.Total should be 0, got %d", p.Tracks.Total) - } -} - -func TestFollowPlaylistSetsContentType(t *testing.T) { - client, server := testClientString(http.StatusOK, "", func(req *http.Request) { - if req.Header.Get("Content-Type") != "application/json" { - t.Error("Follow playlist request didn't contain Content-Type: application/json") - } - }) - defer server.Close() - - err := client.FollowPlaylist(context.Background(), "playlistID", true) - if err != nil { - t.Error(err) - } -} - -func TestGetPlaylistTracks(t *testing.T) { - client, server := testClientFile(http.StatusOK, "test_data/playlist_tracks.txt") - defer server.Close() - - tracks, err := client.GetPlaylistTracks(context.Background(), "5lH9NjOeJvctAO92ZrKQNB") - if err != nil { - t.Error(err) - } - if tracks.Total != 40 { - t.Errorf("Got %d tracks, expected 40\n", tracks.Total) - } - if len(tracks.Tracks) == 0 { - t.Fatal("No tracks returned") - } - expected := "Calm Down" - actual := tracks.Tracks[0].Track.Name - if expected != actual { - t.Errorf("Got '%s', expected '%s'\n", actual, expected) - } - added := tracks.Tracks[0].AddedAt - tm, err := time.Parse(TimestampLayout, added) - if err != nil { - t.Error(err) - } - if f := tm.Format(DateLayout); f != "2022-07-15" { - t.Errorf("Expected added at 2022-07-15, got %s\n", f) + if p.Items.Total != 0 { + t.Errorf("Items.Total should be 0, got %d", p.Items.Total) } } @@ -188,7 +118,7 @@ func TestGetPlaylistItemsEpisodes(t *testing.T) { t.Fatal("No tracks returned") } expected := "112: Dirty Coms" - actual := tracks.Items[0].Track.Episode.Name + actual := tracks.Items[0].Item.Episode.Name if expected != actual { t.Errorf("Got '%s', expected '%s'\n", actual, expected) } @@ -217,7 +147,7 @@ func TestGetPlaylistItemsTracks(t *testing.T) { t.Fatal("No tracks returned") } expected := "Typhoons" - actual := tracks.Items[0].Track.Track.Name + actual := tracks.Items[0].Item.Track.Name if expected != actual { t.Errorf("Got '%s', expected '%s'\n", actual, expected) } @@ -247,7 +177,7 @@ func TestGetPlaylistItemsTracksAndEpisodes(t *testing.T) { } expected := "491- The Missing Middle" - actual := tracks.Items[0].Track.Episode.Name + actual := tracks.Items[0].Item.Episode.Name if expected != actual { t.Errorf("Got '%s', expected '%s'\n", actual, expected) } @@ -261,7 +191,7 @@ func TestGetPlaylistItemsTracksAndEpisodes(t *testing.T) { } expected = "Typhoons" - actual = tracks.Items[2].Track.Track.Name + actual = tracks.Items[2].Item.Track.Name if expected != actual { t.Errorf("Got '%s', expected '%s'\n", actual, expected) } @@ -303,19 +233,6 @@ func TestGetPlaylistItemsDefault(t *testing.T) { } } -func TestUserFollowsPlaylist(t *testing.T) { - client, server := testClientString(http.StatusOK, `[ true, false ]`) - defer server.Close() - - follows, err := client.UserFollowsPlaylist(context.Background(), ID("2v3iNvBS8Ay1Gt2uXtUKUT"), "possan", "elogain") - if err != nil { - t.Error(err) - } - if len(follows) != 2 || !follows[0] || follows[1] { - t.Errorf("Expected '[true, false]', got %#v\n", follows) - } -} - // NOTE collaborative is a fmt boolean. var newPlaylist = ` { @@ -343,8 +260,8 @@ var newPlaylist = ` }, "public": false, "snapshot_id": "s0o3TSuYnRLl2jch+oA4OEbKwq/fNxhGBkSPnvhZdmWjNV0q3uCAWuGIhEx8SHIx", -"tracks": { - "href": "https://api.spotify.com/v1/users/thelinmichael/playlists/7d2D2S200NyUE5KYs80PwO/tracks", +"items": { + "href": "https://api.spotify.com/v1/users/thelinmichael/playlists/7d2D2S200NyUE5KYs80PwO/items", "items": [ ], "limit": 100, "next": null, @@ -360,7 +277,7 @@ func TestCreatePlaylist(t *testing.T) { client, server := testClientString(http.StatusCreated, fmt.Sprintf(newPlaylist, false)) defer server.Close() - p, err := client.CreatePlaylistForUser(context.Background(), "thelinmichael", "A New Playlist", "Test Description", false, false) + p, err := client.CreatePlaylist(context.Background(), "A New Playlist", "Test Description", false, false) if err != nil { t.Error(err) } @@ -373,7 +290,7 @@ func TestCreatePlaylist(t *testing.T) { if p.Description != "Test Description" { t.Errorf("Expected 'Test Description', got '%s'\n", p.Description) } - if p.Tracks.Total != 0 { + if p.Items.Total != 0 { t.Error("Expected new playlist to be empty") } if p.Collaborative { @@ -385,7 +302,7 @@ func TestCreateCollaborativePlaylist(t *testing.T) { client, server := testClientString(http.StatusCreated, fmt.Sprintf(newPlaylist, true)) defer server.Close() - p, err := client.CreatePlaylistForUser(context.Background(), "thelinmichael", "A New Playlist", "Test Description", false, true) + p, err := client.CreatePlaylist(context.Background(), "A New Playlist", "Test Description", false, true) if err != nil { t.Error(err) } @@ -398,7 +315,7 @@ func TestCreateCollaborativePlaylist(t *testing.T) { if p.Description != "Test Description" { t.Errorf("Expected 'Test Description', got '%s'\n", p.Description) } - if p.Tracks.Total != 0 { + if p.Items.Total != 0 { t.Error("Expected new playlist to be empty") } if !p.Collaborative { @@ -485,9 +402,9 @@ func TestRemoveTracksFromPlaylist(t *testing.T) { if err != nil { t.Fatal("Error decoding request body:", err) } - tracksArray, ok := body["tracks"] + tracksArray, ok := body["items"] if !ok { - t.Error("No tracks JSON object in request body") + t.Error("No items JSON object in request body") } tracksSlice := tracksArray.([]interface{}) if l := len(tracksSlice); l != 2 { @@ -530,7 +447,7 @@ func TestRemoveTracksFromPlaylistOpt(t *testing.T) { fmt.Println(string(requestBody)) return } - jsonTracks := body["tracks"].([]interface{}) + jsonTracks := body["items"].([]interface{}) if len(jsonTracks) != 3 { t.Fatal("Expected 3 tracks, got", len(jsonTracks)) } diff --git a/search.go b/search.go index 21ead18..5a11006 100644 --- a/search.go +++ b/search.go @@ -119,7 +119,7 @@ type SearchResult struct { // If the client has a valid access token, then the results will only include // content playable in the user's country. // -// Supported options: [Limit], [Market], [Offset]. +// Supported options: [Limit] (max 10, default 5), [Market], [Offset]. // // [Spotify catalog information]: https://developer.spotify.com/documentation/web-api/reference/search func (c *Client) Search(ctx context.Context, query string, t SearchType, opts ...RequestOption) (*SearchResult, error) { diff --git a/show.go b/show.go index f7d8999..9036ba6 100644 --- a/show.go +++ b/show.go @@ -2,7 +2,6 @@ package spotify import ( "context" - "net/http" "strconv" "strings" "time" @@ -26,12 +25,6 @@ type FullShow struct { // SimpleShow contains basic data about a show. type SimpleShow struct { - // A list of the countries in which the show can be played, - // identified by their [ISO 3166-1 alpha-2] code. - // - // [ISO 3166-1 alpha-2]: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 - AvailableMarkets []string `json:"available_markets"` - // The copyright statements of the show. Copyrights []Copyright `json:"copyrights"` @@ -72,9 +65,6 @@ type SimpleShow struct { // The name of the show. Name string `json:"name"` - // The publisher of the show. - Publisher string `json:"publisher"` - // The object type: “show”. Type string `json:"type"` @@ -218,19 +208,6 @@ func (c *Client) GetShowEpisodes(ctx context.Context, id string, opts ...Request return &result, nil } -// SaveShowsForCurrentUser [saves one or more shows] to current Spotify user's library. -// -// [saves one or more shows]: https://developer.spotify.com/documentation/web-api/reference/save-shows-user -func (c *Client) SaveShowsForCurrentUser(ctx context.Context, ids []ID) error { - spotifyURL := c.baseURL + "me/shows?ids=" + strings.Join(toStringSlice(ids), ",") - req, err := http.NewRequestWithContext(ctx, http.MethodPut, spotifyURL, nil) - if err != nil { - return err - } - - return c.execute(req, nil, http.StatusOK) -} - // GetEpisode gets an [episode] from a show. // // [episode]: https://developer.spotify.com/documentation/web-api/reference/get-an-episode diff --git a/show_test.go b/show_test.go index c70341b..1045d4f 100644 --- a/show_test.go +++ b/show_test.go @@ -1,7 +1,6 @@ package spotify import ( - "bytes" "context" "net/http" "testing" @@ -42,30 +41,6 @@ func TestGetShowEpisodes(t *testing.T) { } } -func TestSaveShowsForCurrentUser(t *testing.T) { - c, s := testClient(http.StatusOK, new(bytes.Buffer), func(req *http.Request) { - if ids := req.URL.Query().Get("ids"); ids != "1,2" { - t.Error("Invalid data:", ids) - } - }) - defer s.Close() - - err := c.SaveShowsForCurrentUser(context.Background(), []ID{"1", "2"}) - if err != nil { - t.Fatal(err) - } -} - -func TestSaveShowsForCurrentUser_Errors(t *testing.T) { - c, s := testClient(http.StatusInternalServerError, new(bytes.Buffer)) - defer s.Close() - - err := c.SaveShowsForCurrentUser(context.Background(), []ID{"1"}) - if err == nil { - t.Fatal(err) - } -} - func TestGetEpisode(t *testing.T) { c, s := testClientFile(http.StatusOK, "test_data/get_episode.txt") defer s.Close() diff --git a/spotify.go b/spotify.go index 2380058..83c2ca6 100644 --- a/spotify.go +++ b/spotify.go @@ -323,29 +323,6 @@ func (c *Client) get(ctx context.Context, url string, result interface{}) error } } -// NewReleases gets a list of new album releases featured in Spotify. -// Supported options: Country, Limit, Offset -func (c *Client) NewReleases(ctx context.Context, opts ...RequestOption) (albums *SimpleAlbumPage, err error) { - spotifyURL := c.baseURL + "browse/new-releases" - if params := processOptions(opts...).urlParams.Encode(); params != "" { - spotifyURL += "?" + params - } - - var objmap map[string]*json.RawMessage - err = c.get(ctx, spotifyURL, &objmap) - if err != nil { - return nil, err - } - - var result SimpleAlbumPage - err = json.Unmarshal(*objmap["albums"], &result) - if err != nil { - return nil, err - } - - return &result, nil -} - // Token gets the client's current token. func (c *Client) Token() (*oauth2.Token, error) { transport, ok := c.http.Transport.(*oauth2.Transport) diff --git a/spotify_test.go b/spotify_test.go index bbac0e3..1169ff5 100644 --- a/spotify_test.go +++ b/spotify_test.go @@ -7,7 +7,6 @@ import ( "net/http" "net/http/httptest" "os" - "strconv" "strings" "testing" "time" @@ -51,108 +50,6 @@ func testClientFile(code int, filename string, validators ...func(*http.Request) return testClient(code, f, validators...) } -func TestNewReleases(t *testing.T) { - c, s := testClientFile(http.StatusOK, "test_data/new_releases.txt") - defer s.Close() - - r, err := c.NewReleases(context.Background()) - if err != nil { - t.Fatal(err) - } - if r.Albums[0].ID != "60mvULtYiNSRmpVvoa3RE4" { - t.Error("Invalid data:", r.Albums[0].ID) - } - if r.Albums[0].Name != "We Are One (Ole Ola) [The Official 2014 FIFA World Cup Song]" { - t.Error("Invalid data", r.Albums[0].Name) - } -} - -func TestNewReleasesRateLimitExceeded(t *testing.T) { - t.Parallel() - handlers := []http.HandlerFunc{ - // first attempt fails - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Retry-After", "2") - w.WriteHeader(http.StatusTooManyRequests) - _, _ = io.WriteString(w, `{ "error": { "message": "slow down", "status": 429 } }`) - }), - // next attempt succeeds - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - f, err := os.Open("test_data/new_releases.txt") - if err != nil { - t.Fatal(err) - } - defer f.Close() - _, err = io.Copy(w, f) - if err != nil { - t.Fatal(err) - } - }), - } - - i := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - handlers[i](w, r) - i++ - })) - defer server.Close() - - client := &Client{http: http.DefaultClient, baseURL: server.URL + "/", autoRetry: true} - releases, err := client.NewReleases(context.Background()) - if err != nil { - t.Fatal(err) - } - if releases.Albums[0].ID != "60mvULtYiNSRmpVvoa3RE4" { - t.Error("Invalid data:", releases.Albums[0].ID) - } -} - -func TestRateLimitExceededReportsRetryAfter(t *testing.T) { - t.Parallel() - const retryAfter = 2 - - handlers := []http.HandlerFunc{ - // first attempt fails - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) - w.WriteHeader(http.StatusTooManyRequests) - _, _ = io.WriteString(w, `{ "error": { "message": "slow down", "status": 429 } }`) - }), - // next attempt succeeds - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - f, err := os.Open("test_data/new_releases.txt") - if err != nil { - t.Fatal(err) - } - defer f.Close() - _, err = io.Copy(w, f) - if err != nil { - t.Fatal(err) - } - }), - } - - i := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - handlers[i](w, r) - i++ - })) - defer server.Close() - - client := &Client{http: http.DefaultClient, baseURL: server.URL + "/"} - _, err := client.NewReleases(context.Background()) - if err == nil { - t.Fatal("expected an error") - } - var spotifyError Error - if !errors.As(err, &spotifyError) { - t.Fatalf("expected a spotify error, got %T", err) - } - if retryAfter*time.Second-time.Until(spotifyError.RetryAfter) > time.Second { - t.Error("expected RetryAfter value") - } -} - func TestClient_Token(t *testing.T) { // oauth setup for valid test token config := oauth2.Config{ diff --git a/test_data/current_users_playlists.txt b/test_data/current_users_playlists.txt index a31a410..34b16de 100644 --- a/test_data/current_users_playlists.txt +++ b/test_data/current_users_playlists.txt @@ -27,7 +27,7 @@ "primary_color" : null, "public" : true, "snapshot_id" : "NSw5ZTBhOGQ5M2JkMWM1NDViMWI2YjBiZDcyOTE0NzE0Nzg4MWY0NmNm", - "tracks" : { + "items" : { "href" : "https://api.spotify.com/v1/playlists/3sPyrAW9PVUMxL9i6QFbaV/tracks", "total" : 3 }, @@ -68,7 +68,7 @@ "primary_color" : null, "public" : true, "snapshot_id" : "MTUsNDAyOTY5ZjNiNTZlZjIzNWYwYzNhMDc2ZTc5NzI4OTI2OGM5YmJlNg==", - "tracks" : { + "items" : { "href" : "https://api.spotify.com/v1/playlists/1hronnZ0DOUXcMna7JtgaN/tracks", "total" : 10 }, @@ -109,7 +109,7 @@ "primary_color" : null, "public" : false, "snapshot_id" : "MTIsNzk4ZWUwMWM4Zjg1MDY0MDgyODBlZmEzMDFkOTlhZTUwYmM3ZDkwZg==", - "tracks" : { + "items" : { "href" : "https://api.spotify.com/v1/playlists/46TfdmGiWxxX4uZzqohw89/tracks", "total" : 7 }, @@ -142,7 +142,7 @@ "primary_color" : null, "public" : true, "snapshot_id" : "OSw0OWMxZjMxNDAwMjM0ODk5NTE0MTlkMTNlM2UzYzA5NzllZTU2ZjI0", - "tracks" : { + "items" : { "href" : "https://api.spotify.com/v1/playlists/01ckpilMpZMY54FNOmM6hx/tracks", "total" : 3 }, @@ -183,7 +183,7 @@ "primary_color" : null, "public" : true, "snapshot_id" : "MTgsMTE3NDA3N2VjNzA5ZDBkNzJjMDA1MDk4YjBlNDRjOTkzZTZiNDRkMg==", - "tracks" : { + "items" : { "href" : "https://api.spotify.com/v1/playlists/19DESVha4FbNzqQfcGXMjc/tracks", "total" : 10 }, diff --git a/test_data/featured_playlists.txt b/test_data/featured_playlists.txt index 740a9b3..b86fcde 100644 --- a/test_data/featured_playlists.txt +++ b/test_data/featured_playlists.txt @@ -29,7 +29,7 @@ "primary_color" : null, "public" : null, "snapshot_id" : "MTY2MzI3OTIwMCwwMDAwMDAwMDRjMGYyYjU2ZDkyMjE1YjM5ZDc0MzBmNTc0NzBjZmFj", - "tracks" : { + "items" : { "href" : "https://api.spotify.com/v1/playlists/37i9dQZF1DXcecv7ESbOPu/tracks", "total" : 104 }, @@ -62,7 +62,7 @@ "primary_color" : null, "public" : null, "snapshot_id" : "MTY2MzI3OTIwMCwwMDAwMDAwMGQ2OTAzNDE4OTkzMWQ5ZTVlNzM3YTk1YTJjZjc3YmY5", - "tracks" : { + "items" : { "href" : "https://api.spotify.com/v1/playlists/37i9dQZF1DWXfgo3OOonqa/tracks", "total" : 85 }, @@ -95,7 +95,7 @@ "primary_color" : null, "public" : null, "snapshot_id" : "MTY2MzI3OTIwMCwwMDAwMDAwMGQ1NmM3MzJkOWM4MjRkODE1ZjI3ODk4MTEyNWUzMWEw", - "tracks" : { + "items" : { "href" : "https://api.spotify.com/v1/playlists/37i9dQZF1DXc4CrXgl8cum/tracks", "total" : 80 }, @@ -128,7 +128,7 @@ "primary_color" : null, "public" : null, "snapshot_id" : "MTY2MzI3OTIwMCwwMDAwMDAwMGU3ZjlhMDE3NDgyNjQ3ZjhmOTMxZDM5YWY2ODcxM2Fk", - "tracks" : { + "items" : { "href" : "https://api.spotify.com/v1/playlists/37i9dQZF1DX7FV7CCq9byu/tracks", "total" : 70 }, @@ -161,7 +161,7 @@ "primary_color" : null, "public" : null, "snapshot_id" : "MTY2MzI3OTIwMCwwMDAwMDAwMDBlNzdlZTRjNTY1NTFhY2JjMGM2OTUwMThjOGQ5NzQ0", - "tracks" : { + "items" : { "href" : "https://api.spotify.com/v1/playlists/37i9dQZF1DXdiF2k2CLnQA/tracks", "total" : 80 }, diff --git a/test_data/get_playlist.txt b/test_data/get_playlist.txt index 9ddbc5b..b1a72de 100644 --- a/test_data/get_playlist.txt +++ b/test_data/get_playlist.txt @@ -57,7 +57,7 @@ }, "is_local": false, "primary_color": null, - "track": { + "item": { "album": { "album_type": "single", "artists": [ @@ -153,7 +153,7 @@ }, "is_local": false, "primary_color": null, - "track": { + "item": { "album": { "album_type": "album", "artists": [ @@ -249,7 +249,7 @@ }, "is_local": false, "primary_color": null, - "track": { + "item": { "album": { "album_type": "album", "artists": [ @@ -345,7 +345,7 @@ }, "is_local": false, "primary_color": null, - "track": { + "item": { "album": { "album_type": "single", "artists": [ diff --git a/test_data/get_playlist_opt.txt b/test_data/get_playlist_opt.txt index c735e3b..1e4b23c 100644 --- a/test_data/get_playlist_opt.txt +++ b/test_data/get_playlist_opt.txt @@ -7,7 +7,7 @@ "type" : "user", "uri" : "spotify:user:nederlandse_top_40" }, - "tracks" : { + "items" : { "items" : [ { "added_by" : { "id" : "nederlandse_top_40" diff --git a/test_data/playlist_items_episodes.json b/test_data/playlist_items_episodes.json index 3d027c8..f57058c 100644 --- a/test_data/playlist_items_episodes.json +++ b/test_data/playlist_items_episodes.json @@ -14,7 +14,7 @@ }, "is_local": false, "primary_color": null, - "track": { + "item": { "audio_preview_url": "https://p.scdn.co/mp3-preview/58e56ec5a44a886fc17def91ed22dd8b60f6a22d", "description": "This episode we talk with a guy named “Drew” who gives us a rare peek into what some of the young hackers are up to today. From listening to Drew, we can see that times are changing for the motive behind hacking. In the ’90s and ’00s it was done for fun and curiosity. In the ’10s Anonymous showed us what Hacktivism is. And now, in the ’20s, the young hackers seem to be profit driven. Sponsors Support for this show comes from Linode. Linode supplies you with virtual servers. Visit linode.com/darknet and get a special offer. Support for this show comes from Juniper Networks. Juniper Networks is dedicated to simplifying network operations and driving superior experiences for end users. Visit juniper.net/darknet to learn more about how Juniper Secure Edge can help you keep your remote workforce seamlessly secure wherever they are.", "duration_ms": 5303484, @@ -284,7 +284,7 @@ }, "is_local": false, "primary_color": null, - "track": { + "item": { "audio_preview_url": "https://p.scdn.co/mp3-preview/51a69faa68c2aa450f62f2a987f7c7b1ba2211c4", "description": "Adam got a job doing IT work at a learning academy. He liked it and was happy there and feeling part of the team. But a strange series of events took him in another direction, that definitely didn’t make him happy. Sponsors Support for this show comes from Axonius. Securing assets — whether managed, unmanaged, ephemeral, or in the cloud — is a tricky task. The Axonius Cybersecurity Asset Management Platform correlates asset data from existing solutions to provide an always up-to-date inventory, uncover gaps, and automate action. Axonius gives IT and security teams the confidence to control complexity by mitigating threats, navigating risk, decreasing incidents, and informing business-level strategy — all while eliminating manual, repetitive tasks. Visit axonius.com/darknet to learn more and try it free. Support for this podcast comes from Cybereason. Cybereason reverses the attacker’s advantage and puts the power back in the defender’s hands. End cyber attacks. From endpoints to everywhere. Learn more at Cybereason.com/darknet. Support for this show comes from Varonis. Do you wonder what your company’s ransomware blast radius is? Varonis does a free cyber resilience assessment that tells you how many important files a compromised user could steal, whether anything would beep if they did, and a whole lot more. They actually do all the work – show you where your data is too open, if anyone is using it, and what you can lock down before attackers get inside. They also can detect behavior that looks like ransomware and stop it automatically. To learn more visit www.varonis.com/darknet.", "duration_ms": 3253838, @@ -554,7 +554,7 @@ }, "is_local": false, "primary_color": null, - "track": { + "item": { "audio_preview_url": "https://p.scdn.co/mp3-preview/7103b353fb65f620a30270f41ceb8a0d57549b53", "description": "HD Moore (https://twitter.com/hdmoore) invented a hacking tool called Metasploit. He crammed it with tons of exploits and payloads that can be used to hack into computers. What could possibly go wrong? Learn more about what HD does today by visiting rumble.run/. Sponsors Support for this show comes from Quorum Cyber. They exist to defend organisations against cyber security breaches and attacks. That’s it. No noise. No hard sell. If you’re looking for a partner to help you reduce risk and defend against the threats that are targeting your business — and specially if you are interested in Microsoft Security - reach out to www.quorumcyber.com. Support for this show comes from Snyk. Snyk is a developer security platform that helps you secure your applications from the start. It automatically scans your code, dependencies, containers, and cloud infrastructure configs — finding and fixing vulnerabilities in real time. And Snyk does it all right from the existing tools and workflows you already use. IDEs, CLI, repos, pipelines, Docker Hub, and more — so your work isn’t interrupted. Create your free account at snyk.co/darknet.", "duration_ms": 4553665, @@ -824,7 +824,7 @@ }, "is_local": false, "primary_color": null, - "track": { + "item": { "audio_preview_url": "https://p.scdn.co/mp3-preview/a02a9a07c09bc940e684c42c35238a786975e914", "description": "Some video game players buy cheats to win. Let’s take a look at this game cheating industry to see who the players are. Sponsors Support for this show comes from Axonius. Securing assets — whether managed, unmanaged, ephemeral, or in the cloud — is a tricky task. The Axonius Cybersecurity Asset Management Platform correlates asset data from existing solutions to provide an always up-to-date inventory, uncover gaps, and automate action. Axonius gives IT and security teams the confidence to control complexity by mitigating threats, navigating risk, decreasing incidents, and informing business-level strategy — all while eliminating manual, repetitive tasks. Visit axonius.com/darknet to learn more and try it free. Support for this podcast comes from Cybereason. Cybereason reverses the attacker’s advantage and puts the power back in the defender’s hands. End cyber attacks. From endpoints to everywhere. Learn more at Cybereason.com/darknet.", "duration_ms": 2443102, diff --git a/test_data/playlist_items_episodes_and_tracks.json b/test_data/playlist_items_episodes_and_tracks.json index 4eee8a9..1a2464c 100644 --- a/test_data/playlist_items_episodes_and_tracks.json +++ b/test_data/playlist_items_episodes_and_tracks.json @@ -14,7 +14,7 @@ }, "is_local": false, "primary_color": null, - "track": { + "item": { "audio_preview_url": "https://p.scdn.co/mp3-preview/95c6a3ea1a2144cfdc55dab435901eb443d327ca", "description": "Downtown Toronto has a dense core of tall, glassy buildings along the waterfront of Lake Ontario. Outside of that, lots short single family homes sprawl out in every direction. Residents looking for something in between an expensive house and a condo in a tall, generic tower struggle to find places to live. There just aren’t a lot of these mid-sized rental buildings in the city.And it's not just Toronto -- a similar architectural void can be found in many other North American cities, like Los Angeles, Seattle, Boston and Vancouver. And this is a big concern for urban planners -- so big, there's a term for it. The \"missing middle.\" That moniker can be confusing, because it's not directly about middle class housing -- rather, it's about a specific range of building sizes and typologies, including: duplexes, triplexes, courtyard buildings, multi-story apartment complexes, the list goes on. Buildings like these have an outsized effect on cities, and cities without enough of these kinds of buildings often suffer from their absence.The Missing Middle", "duration_ms": 2249560, @@ -284,7 +284,7 @@ }, "is_local": false, "primary_color": null, - "track": { + "item": { "audio_preview_url": "https://p.scdn.co/mp3-preview/43438e848c27656afb45e21b1b418d18548444b3", "description": "What began as a supposed accounting error landed Cliff Stoll in the midst of database intrusions, government organizations, and the beginnings of a newer threat—cyber-espionage. This led the eclectic astronomer-cum-systems administrator to create what we know today as intrusion detection. And it all began at a time when people didn’t understand the importance of cybersecurity. This is a story that many in the infosec community have already heard, but the lessons from Stoll’s journey are still relevant. Katie Hafner gives us the background on this unbelievable story. Richard Bejtlich outlines the “honey pot” that finally cracked open the international case. And Don Cavender discusses the impact of Stoll’s work, and how it has inspired generations of security professionals.If you want to read up on some of our research on ransomware, you can check out all our bonus material over at redhat.com/commandlineheroes. Follow along with the episode transcript.  ", "duration_ms": 1338827, @@ -554,7 +554,7 @@ }, "is_local": false, "primary_color": null, - "track": { + "item": { "album": { "album_type": "album", "artists": [ @@ -1018,7 +1018,7 @@ }, "is_local": false, "primary_color": null, - "track": { + "item": { "album": { "album_type": "single", "artists": [ diff --git a/test_data/playlist_items_tracks.json b/test_data/playlist_items_tracks.json index 6000d3f..5ede323 100644 --- a/test_data/playlist_items_tracks.json +++ b/test_data/playlist_items_tracks.json @@ -14,7 +14,7 @@ }, "is_local": false, "primary_color": null, - "track": { + "item": { "album": { "album_type": "album", "artists": [ @@ -478,7 +478,7 @@ }, "is_local": false, "primary_color": null, - "track": { + "item": { "album": { "album_type": "single", "artists": [ diff --git a/test_data/playlists_for_user.txt b/test_data/playlists_for_user.txt index 9ecfc6a..74956b0 100644 --- a/test_data/playlists_for_user.txt +++ b/test_data/playlists_for_user.txt @@ -27,7 +27,7 @@ "primary_color" : null, "public" : true, "snapshot_id" : "MTM1MDMsNThlNjY3ZmM0OGQxNDM1MzE3OGY1NDk4NmQyNTMzNDczOGFlZWI2Yg==", - "tracks" : { + "items" : { "href" : "https://api.spotify.com/v1/playlists/5lH9NjOeJvctAO92ZrKQNB/tracks", "total" : 40 }, @@ -60,7 +60,7 @@ "primary_color" : null, "public" : true, "snapshot_id" : "MTY2MzI3OTI2MCwwMDAwMDAwMDg2MTBkOGJjODI0ZTRkM2ExOGZhMDQ5ZjhhZjgyMTFh", - "tracks" : { + "items" : { "href" : "https://api.spotify.com/v1/playlists/37i9dQZF1DXdPec7aLTmlC/tracks", "total" : 100 }, @@ -93,7 +93,7 @@ "primary_color" : null, "public" : true, "snapshot_id" : "MTY2MzI2NDczOSwwMDAwMDAwMGZjZDJjNjBjNTY4NjI0NDNlNWZiYzhlOGY1NjQ0NTUw", - "tracks" : { + "items" : { "href" : "https://api.spotify.com/v1/playlists/37i9dQZF1DWWeNODNe68OF/tracks", "total" : 100 }, @@ -126,7 +126,7 @@ "primary_color" : null, "public" : true, "snapshot_id" : "MTY2Mjc1NDQ5OCwwMDAwMDAwMDNmNDdiZjY0MGM4YzI1Y2ZkZmRkZGEzNzk1ZTYxOTlk", - "tracks" : { + "items" : { "href" : "https://api.spotify.com/v1/playlists/37i9dQZF1DWZpGSuzrdTXg/tracks", "total" : 100 }, @@ -159,7 +159,7 @@ "primary_color" : null, "public" : true, "snapshot_id" : "MzEsOGI2NjA5YWUzNzI1MjZlNjVmYTM2YzZkYTQ1NWJlZThiNTBlZDVmNg==", - "tracks" : { + "items" : { "href" : "https://api.spotify.com/v1/playlists/18V3GF4nC6II1tqwo4Sa5k/tracks", "total" : 53 }, @@ -192,7 +192,7 @@ "primary_color" : null, "public" : true, "snapshot_id" : "NDM3OCw0ZDhiNWRmZDNjNjc4Y2E0Y2Q1MzRmYzMxMzdkOTZiNTk2NWJmZTlk", - "tracks" : { + "items" : { "href" : "https://api.spotify.com/v1/playlists/1yzak2pFIMsjWQRD38Hstf/tracks", "total" : 50 }, @@ -225,7 +225,7 @@ "primary_color" : null, "public" : true, "snapshot_id" : "MTMwMDAsZjJmOTNiZDc5ZWQ1ZjhjZTQwNjZhZDQzOTFjZGZiMjQ4M2Q1MWM4Ng==", - "tracks" : { + "items" : { "href" : "https://api.spotify.com/v1/playlists/7vrhBxI0VHV0CNcj06Cno3/tracks", "total" : 30 }, diff --git a/track.go b/track.go index 24172cf..6ff0443 100644 --- a/track.go +++ b/track.go @@ -2,27 +2,14 @@ package spotify import ( "context" - "errors" "fmt" - "strings" "time" ) -type TrackExternalIDs struct { - ISRC string `json:"isrc"` - EAN string `json:"ean"` - UPC string `json:"upc"` -} - // SimpleTrack contains basic info about a track. type SimpleTrack struct { Album SimpleAlbum `json:"album"` Artists []SimpleArtist `json:"artists"` - // A list of the countries in which the track can be played, - // identified by their [ISO 3166-1 alpha-2] codes. - // - // [ISO 3166-1 alpha=2]: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 - AvailableMarkets []string `json:"available_markets"` // The disc number (usually 1 unless the album consists of more than one disc). DiscNumber Numeric `json:"disc_number"` // The length of the track, in milliseconds. @@ -32,8 +19,6 @@ type SimpleTrack struct { Explicit bool `json:"explicit"` // External URLs for this track. ExternalURLs map[string]string `json:"external_urls"` - // ExternalIDs are IDs for this track in other databases - ExternalIDs TrackExternalIDs `json:"external_ids"` // A link to the Web API endpoint providing full details for this track. Endpoint string `json:"href"` ID ID `json:"id"` @@ -53,35 +38,9 @@ func (st SimpleTrack) String() string { return fmt.Sprintf("TRACK<[%s] [%s]>", st.ID, st.Name) } -// LinkedFromInfo is included in a track response when [Track Relinking] is applied. -// -// [Track Relinking]: https://developer.spotify.com/documentation/general/guides/track-relinking-guide/ -type LinkedFromInfo struct { - // ExternalURLs are the known external APIs for this track or album - ExternalURLs map[string]string `json:"external_urls"` - - // Href is a link to the Web API endpoint providing full details - Href string `json:"href"` - - // ID of the linked track - ID ID `json:"id"` - - // Type of the link: album of the track - Type string `json:"type"` - - // URI is the [Spotify URI] of the track/album. - // - // [Spotify URI]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids - URI string `json:"uri"` -} - // FullTrack provides extra track data in addition to what is provided by [SimpleTrack]. type FullTrack struct { SimpleTrack - // Popularity of the track. The value will be between 0 and 100, - // with 100 being the most popular. The popularity is calculated from - // both total plays and most recent plays. - Popularity Numeric `json:"popularity"` // IsPlayable is included when [Track Relinking] is applied, and reports if // the track is playable. It's reported when the "market" parameter is @@ -89,13 +48,6 @@ type FullTrack struct { // // [Track Relinking]: https://developer.spotify.com/documentation/general/guides/track-relinking-guide/ IsPlayable *bool `json:"is_playable"` - - // LinkedFromInfo is included in a track response when [Track Relinking] is - // applied, and points to the linked track. It's reported when the "market" - // parameter is passed to the tracks listing API. - // - // [Track Relinking]: https://developer.spotify.com/documentation/general/guides/track-relinking-guide/ - LinkedFrom *LinkedFromInfo `json:"linked_from"` } // PlaylistTrack contains info about a track in a playlist. @@ -110,7 +62,7 @@ type PlaylistTrack struct { // Whether this track is a local file or not. IsLocal bool `json:"is_local"` // Information about the track. - Track FullTrack `json:"track"` + Item FullTrack `json:"item"` } // SavedTrack provides info about a track saved to a user's account. @@ -150,33 +102,3 @@ func (c *Client) GetTrack(ctx context.Context, id ID, opts ...RequestOption) (*F return &t, nil } - -// GetTracks gets Spotify catalog information for [multiple tracks] based on their -// Spotify IDs. It supports up to 50 tracks in a single call. Tracks are -// returned in the order requested. If a track is not found, that position in the -// result will be nil. Duplicate ids in the query will result in duplicate -// tracks in the result. -// -// Supported options: [Market]. -// -// [multiple tracks]: https://developer.spotify.com/documentation/web-api/reference/get-several-tracks -func (c *Client) GetTracks(ctx context.Context, ids []ID, opts ...RequestOption) ([]*FullTrack, error) { - if len(ids) > 50 { - return nil, errors.New("spotify: FindTracks supports up to 50 tracks") - } - - params := processOptions(opts...).urlParams - params.Set("ids", strings.Join(toStringSlice(ids), ",")) - spotifyURL := c.baseURL + "tracks?" + params.Encode() - - var t struct { - Tracks []*FullTrack `json:"tracks"` - } - - err := c.get(ctx, spotifyURL, &t) - if err != nil { - return nil, err - } - - return t.Tracks, nil -} diff --git a/track_test.go b/track_test.go index 6ef489b..3e6864e 100644 --- a/track_test.go +++ b/track_test.go @@ -33,40 +33,3 @@ func TestFindTrackWithFloats(t *testing.T) { t.Errorf("Wanted track Timer, got %s\n", track.Name) } } - -func TestFindTracksSimple(t *testing.T) { - client, server := testClientFile(http.StatusOK, "test_data/find_tracks_simple.txt") - defer server.Close() - - tracks, err := client.GetTracks(context.Background(), []ID{"0eGsygTp906u18L0Oimnem", "1lDWb6b6ieDQ2xT7ewTC3G"}) - if err != nil { - t.Error(err) - return - } - if l := len(tracks); l != 2 { - t.Errorf("Wanted 2 tracks, got %d\n", l) - return - } - -} - -func TestFindTracksNotFound(t *testing.T) { - client, server := testClientFile(http.StatusOK, "test_data/find_tracks_notfound.txt") - defer server.Close() - - tracks, err := client.GetTracks(context.Background(), []ID{"0eGsygTp906u18L0Oimnem", "1lDWb6b6iecccdsdckTC3G"}) - if err != nil { - t.Error(err) - return - } - if l := len(tracks); l != 2 { - t.Errorf("Expected 2 results, got %d\n", l) - return - } - if tracks[0].Name != "Mr. Brightside" { - t.Errorf("Expected Mr. Brightside, got %s\n", tracks[0].Name) - } - if tracks[1] != nil { - t.Error("Expected nil track (invalid ID) but got valid track") - } -} diff --git a/user.go b/user.go index ccfa6d6..a801e37 100644 --- a/user.go +++ b/user.go @@ -2,11 +2,6 @@ package spotify import ( "context" - "errors" - "fmt" - "net/http" - "net/url" - "strings" ) // User contains the basic, publicly available information about a Spotify user. @@ -17,8 +12,6 @@ type User struct { DisplayName string `json:"display_name"` // Known public external URLs for the user. ExternalURLs map[string]string `json:"external_urls"` - // Information about followers of the user. - Followers Followers `json:"followers"` // A link to the Web API endpoint for this user. Endpoint string `json:"href"` // The Spotify user ID for the user. @@ -33,22 +26,6 @@ type User struct { // This data is private and requires user authentication. type PrivateUser struct { User - // The country of the user, as set in the user's account profile. - // An [ISO 3166-1 alpha-2] country code. This field is only available when - // the current user has granted access to the [ScopeUserReadPrivate] scope. - // - // [ISO 3166-1 alpha-2]: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 - Country string `json:"country"` - // The user's email address, as entered by the user when creating their account. - // Note: this email is UNVERIFIED - there is no proof that it actually - // belongs to the user. This field is only available when the current user - // has granted access to the [ScopeUserReadEmail] scope. - Email string `json:"email"` - // The user's Spotify subscription level: "premium", "free", etc. - // The subscription level "open" can be considered the same as "free". - // This field is only available when the current user has granted access to - // the [ScopeUserReadPrivate] scope. - Product string `json:"product"` // The user's date of birth, in the format 'YYYY-MM-DD'. You can use // [DateLayout] to convert this to a [time.Time] value. This field is only // available when the current user has granted access to the @@ -56,23 +33,6 @@ type PrivateUser struct { Birthdate string `json:"birthdate"` } -// GetUsersPublicProfile gets [public profile] information about a -// Spotify User. It does not require authentication. -// -// [public profile]: https://developer.spotify.com/documentation/web-api/reference/get-users-profile -func (c *Client) GetUsersPublicProfile(ctx context.Context, userID ID) (*User, error) { - spotifyURL := c.baseURL + "users/" + string(userID) - - var user User - - err := c.get(ctx, spotifyURL, &user) - if err != nil { - return nil, err - } - - return &user, nil -} - // CurrentUser gets detailed profile information about the // [current user]. // @@ -142,102 +102,6 @@ func (c *Client) CurrentUsersTracks(ctx context.Context, opts ...RequestOption) return &result, nil } -// FollowUser [adds the current user as a follower] of one or more -// spotify users, identified by their [Spotify ID]s. -// -// Modifying the lists of artists or users the current user follows -// requires that the application has the [ScopeUserFollowModify] scope. -// -// [adds the current user as a follower]: https://developer.spotify.com/documentation/web-api/reference/follow-artists-users -// [Spotify ID]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids -func (c *Client) FollowUser(ctx context.Context, ids ...ID) error { - return c.modifyFollowers(ctx, "user", true, ids...) -} - -// FollowArtist [adds the current user as a follower] of one or more -// spotify artists, identified by their [Spotify ID]s. -// -// Modifying the lists of artists or users the current user follows -// requires that the application has the [ScopeUserFollowModify] scope. -// -// [adds the current user as a follower]: https://developer.spotify.com/documentation/web-api/reference/follow-artists-users -// [Spotify ID]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids -func (c *Client) FollowArtist(ctx context.Context, ids ...ID) error { - return c.modifyFollowers(ctx, "artist", true, ids...) -} - -// UnfollowUser [removes the current user as a follower] of one or more -// Spotify users. -// -// Modifying the lists of artists or users the current user follows -// requires that the application has the [ScopeUserFollowModify] scope. -// -// [removes the current user as a follower]: https://developer.spotify.com/documentation/web-api/reference/unfollow-artists-users -func (c *Client) UnfollowUser(ctx context.Context, ids ...ID) error { - return c.modifyFollowers(ctx, "user", false, ids...) -} - -// UnfollowArtist [removes the current user as a follower] of one or more -// Spotify artists. -// -// Modifying the lists of artists or users the current user follows -// requires that the application has the [ScopeUserFollowModify] scope. -// -// [removes the current user as a follower]: https://developer.spotify.com/documentation/web-api/reference/unfollow-artists-users -func (c *Client) UnfollowArtist(ctx context.Context, ids ...ID) error { - return c.modifyFollowers(ctx, "artist", false, ids...) -} - -// CurrentUserFollows [checks to see if the current user is following] -// one or more artists or other Spotify Users. This call requires -// [ScopeUserFollowRead]. -// -// The t argument indicates the type of the IDs, and must be either -// "user" or "artist". -// -// The result is returned as a slice of bool values in the same order -// in which the IDs were specified. -// -// [checks to see if the current user is following]: https://developer.spotify.com/documentation/web-api/reference/check-current-user-follows -func (c *Client) CurrentUserFollows(ctx context.Context, t string, ids ...ID) ([]bool, error) { - if l := len(ids); l == 0 || l > 50 { - return nil, errors.New("spotify: UserFollows supports 1 to 50 IDs") - } - if t != "artist" && t != "user" { - return nil, errors.New("spotify: t must be 'artist' or 'user'") - } - spotifyURL := fmt.Sprintf("%sme/following/contains?type=%s&ids=%s", - c.baseURL, t, strings.Join(toStringSlice(ids), ",")) - - var result []bool - - err := c.get(ctx, spotifyURL, &result) - if err != nil { - return nil, err - } - - return result, nil -} - -func (c *Client) modifyFollowers(ctx context.Context, usertype string, follow bool, ids ...ID) error { - if l := len(ids); l == 0 || l > 50 { - return errors.New("spotify: Follow/Unfollow supports 1 to 50 IDs") - } - v := url.Values{} - v.Add("type", usertype) - v.Add("ids", strings.Join(toStringSlice(ids), ",")) - spotifyURL := c.baseURL + "me/following?" + v.Encode() - method := "PUT" - if !follow { - method = "DELETE" - } - req, err := http.NewRequestWithContext(ctx, method, spotifyURL, nil) - if err != nil { - return err - } - return c.execute(req, nil, http.StatusNoContent) -} - // CurrentUsersFollowedArtists gets the [current user's followed artists]. // This call requires that the user has granted the [ScopeUserFollowRead] scope. // diff --git a/user_test.go b/user_test.go index c68d9f1..b55e205 100644 --- a/user_test.go +++ b/user_test.go @@ -3,67 +3,20 @@ package spotify import ( "context" "fmt" - "io" "net/http" - "net/http/httptest" "strings" "testing" ) -const userResponse = ` -{ - "display_name" : "Ronald Pompa", - "external_urls" : { - "spotify" : "https://open.spotify.com/user/wizzler" - }, - "followers" : { - "href" : null, - "total" : 3829 - }, - "href" : "https://api.spotify.com/v1/users/wizzler", - "id" : "wizzler", - "images" : [ { - "height" : null, - "url" : "http://profile-images.scdn.co/images/userprofile/default/9d51820e73667ea5f1e97ea601cf0593b558050e", - "width" : null - } ], - "type" : "user", - "uri" : "spotify:user:wizzler" -}` - -func TestUserProfile(t *testing.T) { - client, server := testClientString(http.StatusOK, userResponse) - defer server.Close() - - user, err := client.GetUsersPublicProfile(context.Background(), "wizzler") - if err != nil { - t.Error(err) - return - } - if user.ID != "wizzler" { - t.Error("Expected user wizzler, got ", user.ID) - } - if f := user.Followers.Count; f != 3829 { - t.Errorf("Expected 3829 followers, got %d\n", f) - } -} - func TestCurrentUser(t *testing.T) { json := `{ - "country" : "US", "display_name" : null, - "email" : "username@domain.com", "external_urls" : { "spotify" : "https://open.spotify.com/user/username" }, - "followers" : { - "href" : null, - "total" : 0 - }, "href" : "https://api.spotify.com/v1/users/userame", "id" : "username", "images" : [ ], - "product" : "premium", "type" : "user", "uri" : "spotify:user:username", "birthdate" : "1985-05-01" @@ -76,120 +29,11 @@ func TestCurrentUser(t *testing.T) { t.Error(err) return } - if me.Country != CountryUSA || - me.Email != "username@domain.com" || - me.Product != "premium" { - t.Error("Received incorrect response") - } if me.Birthdate != "1985-05-01" { t.Errorf("Expected '1985-05-01', got '%s'\n", me.Birthdate) } } -func TestFollowUsersMissingScope(t *testing.T) { - json := `{ - "error": { - "status": 403, - "message": "Insufficient client scope" - } - }` - client, server := testClientString(http.StatusForbidden, json, func(req *http.Request) { - if req.URL.Query().Get("type") != "user" { - t.Error("Request made with the wrong type parameter") - } - }) - defer server.Close() - - err := client.FollowUser(context.Background(), ID("exampleuser01")) - serr, ok := err.(Error) - if !ok { - t.Fatal("Expected insufficient client scope error") - } - if serr.Status != http.StatusForbidden { - t.Error("Expected HTTP 403") - } -} - -func TestFollowArtist(t *testing.T) { - client, server := testClientString(http.StatusNoContent, "", func(req *http.Request) { - if req.URL.Query().Get("type") != "artist" { - t.Error("Request made with the wrong type parameter") - } - }) - defer server.Close() - - if err := client.FollowArtist(context.Background(), "3ge4xOaKvWfhRwgx0Rldov"); err != nil { - t.Error(err) - } -} - -func TestFollowArtistAutoRetry(t *testing.T) { - t.Parallel() - handlers := []http.HandlerFunc{ - // first attempt fails - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Retry-After", "2") - w.WriteHeader(http.StatusTooManyRequests) - _, _ = io.WriteString(w, `{ "error": { "message": "slow down", "status": 429 } }`) - }), - // next attempt succeeds - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - } - - i := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - handlers[i](w, r) - i++ - })) - defer server.Close() - - client := &Client{http: http.DefaultClient, baseURL: server.URL + "/", autoRetry: true} - if err := client.FollowArtist(context.Background(), "3ge4xOaKvWfhRwgx0Rldov"); err != nil { - t.Error(err) - } -} - -func TestFollowUsersInvalidToken(t *testing.T) { - json := `{ - "error": { - "status": 401, - "message": "Invalid access token" - } - }` - client, server := testClientString(http.StatusUnauthorized, json, func(req *http.Request) { - if req.URL.Query().Get("type") != "user" { - t.Error("Request made with the wrong type parameter") - } - }) - defer server.Close() - - err := client.FollowUser(context.Background(), ID("dummyID")) - serr, ok := err.(Error) - if !ok { - t.Fatal("Expected invalid token error") - } - if serr.Status != http.StatusUnauthorized { - t.Error("Expected HTTP 401") - } -} - -func TestUserFollows(t *testing.T) { - json := "[ false, true ]" - client, server := testClientString(http.StatusOK, json) - defer server.Close() - - follows, err := client.CurrentUserFollows(context.Background(), "artist", ID("74ASZWbe4lXaubB36ztrGX"), ID("08td7MxkoHQkXnWAYD8d6Q")) - if err != nil { - t.Error(err) - return - } - if len(follows) != 2 || follows[0] || !follows[1] { - t.Error("Incorrect result", follows) - } -} - func TestCurrentUsersTracks(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/current_users_tracks.txt") defer server.Close() @@ -248,15 +92,6 @@ func TestCurrentUsersAlbums(t *testing.T) { t.Errorf("Expected '%s', got '%s'\n", expected, albums.Albums[0].Name) fmt.Printf("\n%#v\n", albums.Albums[0]) } - - upc := "886444160742" - u, ok := albums.Albums[0].ExternalIDs["upc"] - if !ok { - t.Error("External IDs missing UPC") - } - if u != upc { - t.Errorf("Wrong UPC: want %s, got %s\n", upc, u) - } } func TestCurrentUsersPlaylists(t *testing.T) { @@ -300,8 +135,8 @@ func TestCurrentUsersPlaylists(t *testing.T) { if p.IsPublic != tests[i].Public { t.Errorf("Expected public to be %#v, got %#v\n", tests[i].Public, p.IsPublic) } - if int(p.Tracks.Total) != tests[i].TrackCount { - t.Errorf("Expected %d tracks, got %d\n", tests[i].TrackCount, p.Tracks.Total) + if int(p.Items.Total) != tests[i].TrackCount { + t.Errorf("Expected %d tracks, got %d\n", tests[i].TrackCount, p.Items.Total) } } } @@ -406,9 +241,6 @@ func TestCurrentUsersTopArtists(t *testing.T) { t.Error("Didn't get expected number of results") return } - if artists.Artists[0].Followers.Count != 8437 { - t.Errorf("Expected follower count of 8437, got %d\n", artists.Artists[0].Followers.Count) - } name := "insaneintherainmusic" if artists.Artists[0].Name != name { @@ -445,10 +277,4 @@ func TestCurrentUsersTopTracks(t *testing.T) { t.Errorf("Expected '%s', got '%s'\n", name, tracks.Tracks[0].Name) fmt.Printf("\n%#v\n", tracks.Tracks[0]) } - - isrc := "QZ4JJ1764466" - i := tracks.Tracks[0].ExternalIDs.ISRC - if i != isrc { - t.Errorf("Wrong ISRC: want %s, got %s\n", isrc, i) - } }