diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..37ec409 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,17 @@ +name: golangci-lint +on: + push: + branches: + - master + pull_request: +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: golangci-lint + uses: golangci/golangci-lint-action@v2 + with: + # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. + version: v1.39 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..861d8d3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,16 @@ +name: test +on: + push: + branches: + - master + pull_request: +jobs: + unit: + name: unit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-go@v2 + with: + go-version: '1.16.3' # The Go version to download (if necessary) and use. + - run: go test ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee770a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +.idea/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 5f64438..0000000 --- a/.travis.yml +++ /dev/null @@ -1,11 +0,0 @@ -language: go - -go: - - 1.15.x - - 1.16.x - -script: -- go test -race -coverprofile=coverage.txt -covermode=atomic - -after_success: -- bash <(curl -s https://codecov.io/bash) diff --git a/README.md b/README.md index 6eb7478..1f2474b 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,6 @@ Spotify ======= [![GoDoc](https://godoc.org/github.com/zmb3/spotify?status.svg)](http://godoc.org/github.com/zmb3/spotify) -[![Build status](https://ci.appveyor.com/api/projects/status/1nr9vv0jqq438nj2?svg=true)](https://ci.appveyor.com/project/zmb3/spotify) -[![Build Status](https://travis-ci.org/zmb3/spotify.svg)](https://travis-ci.org/zmb3/spotify) This is a Go wrapper for working with Spotify's [Web API](https://developer.spotify.com/web-api/). @@ -19,7 +17,7 @@ By using this library you agree to Spotify's To install the library, simply -`go get github.com/zmb3/spotify` +`go get github.com/zmb3/spotify/v2` ## Authentication @@ -44,11 +42,7 @@ provide this data manually. ````Go // the redirect URL must be an exact match of a URL you've registered for your application // scopes determine which permissions the user is prompted to authorize -auth := spotify.NewAuthenticator(redirectURL, spotify.ScopeUserReadPrivate) - -// if you didn't store your ID and secret key in the specified environment variables, -// you can set them manually here -auth.SetAuthInfo(clientID, secretKey) +auth := spotifyauth.New(spotifyauth.WithRedirectURL(redirectURL), spotifyauth.WithScopes(spotifyauth.ScopeUserReadPrivate)) // get the user to this URL - how you do that is up to you // you should specify a unique state string to identify the session @@ -58,13 +52,13 @@ url := auth.AuthURL(state) // typically you'll have a handler set up like the following: func redirectHandler(w http.ResponseWriter, r *http.Request) { // use the same state string here that you used to generate the URL - token, err := auth.Token(state, r) + token, err := auth.Token(r.Context(), state, r) if err != nil { http.Error(w, "Couldn't get token", http.StatusNotFound) return } // create a client using the specified token - client := auth.NewClient(token) + client := spotify.New(auth.Client(r.Context(), token)) // the client can now be used to make authenticated requests } @@ -81,14 +75,6 @@ https://godoc.org/golang.org/x/oauth2/google ## Helpful Hints - -### Optional Parameters - -Many of the functions in this package come in two forms - a simple version that -omits optional parameters and uses reasonable defaults, and a more sophisticated -version that accepts additional parameters. The latter is suffixed with `Opt` -to indicate that it accepts some optional parameters. - ### Automatic Retries The API will throttle your requests if you are sending them too rapidly. diff --git a/album.go b/album.go index e948865..64a3bc2 100644 --- a/album.go +++ b/album.go @@ -1,9 +1,9 @@ package spotify import ( + "context" "errors" "fmt" - "net/url" "strconv" "strings" "time" @@ -102,21 +102,17 @@ type SavedAlbum struct { } // GetAlbum gets Spotify catalog information for a single album, given its Spotify ID. -func (c *Client) GetAlbum(id ID) (*FullAlbum, error) { - return c.GetAlbumOpt(id, nil) -} - -// GetAlbum is like GetAlbumOpt but it accepts an additional country option for track relinking -func (c *Client) GetAlbumOpt(id ID, opt *Options) (*FullAlbum, error) { +// Supported options: Market +func (c *Client) GetAlbum(ctx context.Context, id ID, opts ...RequestOption) (*FullAlbum, error) { spotifyURL := fmt.Sprintf("%salbums/%s", c.baseURL, id) - if opt != nil && opt.Country != nil { - spotifyURL += "?market=" + *opt.Country + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } var a FullAlbum - err := c.get(spotifyURL, &a) + err := c.get(ctx, spotifyURL, &a) if err != nil { return nil, err } @@ -136,31 +132,24 @@ func toStringSlice(ids []ID) []string { // Spotify IDs. 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. -func (c *Client) GetAlbums(ids ...ID) ([]*FullAlbum, error) { - return c.GetAlbumsOpt(nil, ids...) -} - -// GetAlbumsOpt is like GetAlbums but it accepts an additional country option for track relinking +// // Doc API: https://developer.spotify.com/documentation/web-api/reference/albums/get-several-albums/ -func (c *Client) GetAlbumsOpt(opt *Options, ids ...ID) ([]*FullAlbum, error) { +// +// Supported options: Market +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 := url.Values{} + params := processOptions(opts...).urlParams params.Set("ids", strings.Join(toStringSlice(ids), ",")) - if opt != nil && opt.Country != nil { - params.Set("market", *opt.Country) - } - spotifyURL := fmt.Sprintf("%salbums?%s", c.baseURL, params.Encode()) var a struct { Albums []*FullAlbum `json:"albums"` } - err := c.get(spotifyURL, &a) + err := c.get(ctx, spotifyURL, &a) if err != nil { return nil, err } @@ -202,39 +191,17 @@ func (at AlbumType) encode() string { // GetAlbumTracks gets the tracks for a particular album. // If you only care about the tracks, this call is more efficient // than GetAlbum. -func (c *Client) GetAlbumTracks(id ID) (*SimpleTrackPage, error) { - return c.GetAlbumTracksOpt(id, nil) -} - -// GetAlbumTracksOpt behaves like GetAlbumTracks, with the exception that it -// allows you to specify options that limit the number of results returned and if -// track relinking should be used. -// The maximum number of results to return is specified by limit. -// The offset argument can be used to specify the index of the first track to return. -// It can be used along with limit to request the next set of results. -// Track relinking can be enabled by setting the Country option -func (c *Client) GetAlbumTracksOpt(id ID, opt *Options) (*SimpleTrackPage, error) { +// +// Supported Options: Market, Limit, Offset +func (c *Client) GetAlbumTracks(ctx context.Context, id ID, opts ...RequestOption) (*SimpleTrackPage, error) { spotifyURL := fmt.Sprintf("%salbums/%s/tracks", c.baseURL, id) - if opt != nil { - v := url.Values{} - if opt.Limit != nil { - v.Set("limit", strconv.Itoa(*opt.Limit)) - } - if opt.Offset != nil { - v.Set("offset", strconv.Itoa(*opt.Offset)) - } - if opt.Country != nil { - v.Set("market", *opt.Country) - } - optional := v.Encode() - if optional != "" { - spotifyURL += "?" + optional - } + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } var result SimpleTrackPage - err := c.get(spotifyURL, &result) + err := c.get(ctx, spotifyURL, &result) if err != nil { return nil, err } diff --git a/album_test.go b/album_test.go index ecb4874..218f989 100644 --- a/album_test.go +++ b/album_test.go @@ -1,6 +1,7 @@ package spotify import ( + "context" "net/http" "testing" ) @@ -10,7 +11,7 @@ func TestFindAlbum(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/find_album.txt") defer server.Close() - album, err := client.GetAlbum(ID("0sNOF9WDwhWunNAHPD3Baj")) + album, err := client.GetAlbum(context.Background(), ID("0sNOF9WDwhWunNAHPD3Baj")) if err != nil { t.Fatal(err) } @@ -30,7 +31,7 @@ func TestFindAlbumBadID(t *testing.T) { client, server := testClientString(http.StatusNotFound, `{ "error": { "status": 404, "message": "non existing id" } }`) defer server.Close() - album, err := client.GetAlbum(ID("asdf")) + album, err := client.GetAlbum(context.Background(), ID("asdf")) if album != nil { t.Fatal("Expected nil album, got", album.Name) } @@ -51,7 +52,7 @@ func TestFindAlbums(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/find_albums.txt") defer server.Close() - res, err := client.GetAlbums(ID("41MnTivkwTO3UUJ8DrqEJJ"), ID("6JWc4iAiJ9FjyK0B59ABb4"), ID("6UXCm6bOO4gFlDQZV5yL37"), ID("0X8vBD8h1Ga9eLT8jx9VCC")) + res, err := client.GetAlbums(context.Background(), []ID{"41MnTivkwTO3UUJ8DrqEJJ", "6JWc4iAiJ9FjyK0B59ABb4", "6UXCm6bOO4gFlDQZV5yL37", "0X8vBD8h1Ga9eLT8jx9VCC"}) if err != nil { t.Fatal(err) } @@ -89,8 +90,7 @@ func TestFindAlbumTracks(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/find_album_tracks.txt") defer server.Close() - limit := 1 - res, err := client.GetAlbumTracksOpt(ID("0sNOF9WDwhWunNAHPD3Baj"), &Options{Limit: &limit}) + res, err := client.GetAlbumTracks(context.Background(), ID("0sNOF9WDwhWunNAHPD3Baj"), Limit(1)) if err != nil { t.Fatal(err) } diff --git a/artist.go b/artist.go index 6ca2e55..50c76fe 100644 --- a/artist.go +++ b/artist.go @@ -1,9 +1,8 @@ package spotify import ( + "context" "fmt" - "net/url" - "strconv" "strings" ) @@ -33,11 +32,11 @@ type FullArtist struct { } // GetArtist gets Spotify catalog information for a single artist, given its Spotify ID. -func (c *Client) GetArtist(id ID) (*FullArtist, error) { +func (c *Client) GetArtist(ctx context.Context, id ID) (*FullArtist, error) { spotifyURL := fmt.Sprintf("%sartists/%s", c.baseURL, id) var a FullArtist - err := c.get(spotifyURL, &a) + err := c.get(ctx, spotifyURL, &a) if err != nil { return nil, err } @@ -50,14 +49,14 @@ func (c *Client) GetArtist(id ID) (*FullArtist, error) { // 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(ids ...ID) ([]*FullArtist, error) { +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(spotifyURL, &a) + err := c.get(ctx, spotifyURL, &a) if err != nil { return nil, err } @@ -68,14 +67,14 @@ func (c *Client) GetArtists(ids ...ID) ([]*FullArtist, error) { // 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. -func (c *Client) GetArtistsTopTracks(artistID ID, country string) ([]FullTrack, error) { +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(spotifyURL, &t) + err := c.get(ctx, spotifyURL, &t) if err != nil { return nil, err } @@ -87,14 +86,14 @@ func (c *Client) GetArtistsTopTracks(artistID ID, country string) ([]FullTrack, // given artist. Similarity is based on analysis of the Spotify community's // listening history. This function returns up to 20 artists that are considered // related to the specified artist. -func (c *Client) GetRelatedArtists(id ID) ([]FullArtist, error) { +func (c *Client) GetRelatedArtists(ctx context.Context, id ID) ([]FullArtist, error) { spotifyURL := fmt.Sprintf("%sartists/%s/related-artists", c.baseURL, id) var a struct { Artists []FullArtist `json:"artists"` } - err := c.get(spotifyURL, &a) + err := c.get(ctx, spotifyURL, &a) if err != nil { return nil, err } @@ -104,20 +103,17 @@ func (c *Client) GetRelatedArtists(id ID) ([]FullArtist, error) { // GetArtistAlbums gets Spotify catalog information about an artist's albums. // It is equivalent to GetArtistAlbumsOpt(artistID, nil). -func (c *Client) GetArtistAlbums(artistID ID) (*SimpleAlbumPage, error) { - return c.GetArtistAlbumsOpt(artistID, nil) -} - -// GetArtistAlbumsOpt is just like GetArtistAlbums, but it accepts optional -// parameters used to filter and sort the result. // // The AlbumType argument can be used to find a particular types of album. -// If the market (Options.Country) is not specified, Spotify will likely return a lot -// of duplicates (one for each market in which the album is available) -func (c *Client) GetArtistAlbumsOpt(artistID ID, options *Options, ts ...AlbumType) (*SimpleAlbumPage, error) { +// If the Market is not specified, Spotify will likely return a lot +// of duplicates (one for each market in which the album is available +// +// Supported options: Market +func (c *Client) GetArtistAlbums(ctx context.Context, artistID ID, ts []AlbumType, opts ...RequestOption) (*SimpleAlbumPage, error) { spotifyURL := fmt.Sprintf("%sartists/%s/albums", c.baseURL, artistID) // add optional query string if options were specified - values := url.Values{} + values := processOptions(opts...).urlParams + if ts != nil { types := make([]string, len(ts)) for i := range ts { @@ -125,24 +121,14 @@ func (c *Client) GetArtistAlbumsOpt(artistID ID, options *Options, ts ...AlbumTy } values.Set("include_groups", strings.Join(types, ",")) } - if options != nil { - if options.Country != nil { - values.Set("market", *options.Country) - } - if options.Limit != nil { - values.Set("limit", strconv.Itoa(*options.Limit)) - } - if options.Offset != nil { - values.Set("offset", strconv.Itoa(*options.Offset)) - } - } + if query := values.Encode(); query != "" { spotifyURL += "?" + query } var p SimpleAlbumPage - err := c.get(spotifyURL, &p) + err := c.get(ctx, spotifyURL, &p) if err != nil { return nil, err } diff --git a/artist_test.go b/artist_test.go index f523c3d..f1d4871 100644 --- a/artist_test.go +++ b/artist_test.go @@ -15,6 +15,7 @@ package spotify import ( + "context" "net/http" "testing" ) @@ -82,7 +83,7 @@ func TestFindArtist(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/find_artist.txt") defer server.Close() - artist, err := client.GetArtist(ID("0TnOYISbd1XYRBk9myaseg")) + artist, err := client.GetArtist(context.Background(), ID("0TnOYISbd1XYRBk9myaseg")) if err != nil { t.Fatal(err) } @@ -98,7 +99,7 @@ func TestArtistTopTracks(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/artist_top_tracks.txt") defer server.Close() - tracks, err := client.GetArtistsTopTracks(ID("43ZHCT0cAZBISjO8DG9PnE"), "SE") + tracks, err := client.GetArtistsTopTracks(context.Background(), ID("43ZHCT0cAZBISjO8DG9PnE"), "SE") if err != nil { t.Fatal(err) } @@ -119,7 +120,7 @@ func TestRelatedArtists(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/related_artists.txt") defer server.Close() - artists, err := client.GetRelatedArtists(ID("43ZHCT0cAZBISjO8DG9PnE")) + artists, err := client.GetRelatedArtists(context.Background(), ID("43ZHCT0cAZBISjO8DG9PnE")) if err != nil { t.Fatal(err) } @@ -139,12 +140,7 @@ func TestArtistAlbumsFiltered(t *testing.T) { client, server := testClientString(http.StatusOK, albumsResponse) defer server.Close() - l := 2 - - options := Options{} - options.Limit = &l - - albums, err := client.GetArtistAlbumsOpt(ID("1vCWHaC5f2uS3yhpwWbIA6"), &options, AlbumTypeSingle) + albums, err := client.GetArtistAlbums(context.Background(), "1vCWHaC5f2uS3yhpwWbIA6", []AlbumType{AlbumTypeSingle}, Limit(2)) if err != nil { t.Fatal(err) } @@ -153,7 +149,7 @@ func TestArtistAlbumsFiltered(t *testing.T) { } // since we didn't specify a country, we got duplicate albums // (the album has a different ID in different regions) - if l = len(albums.Albums); l != 2 { + if l := len(albums.Albums); l != 2 { t.Fatalf("Expected 2 albums, got %d\n", l) } if albums.Albums[0].Name != "The Days / Nights" { diff --git a/audio_analysis.go b/audio_analysis.go index 14c6895..7c84d8c 100644 --- a/audio_analysis.go +++ b/audio_analysis.go @@ -1,6 +1,7 @@ package spotify import ( + "context" "fmt" ) @@ -96,12 +97,12 @@ type AnalysisTrack struct { // GetAudioAnalysis queries the Spotify web API for an audio analysis of a // single track. -func (c *Client) GetAudioAnalysis(id ID) (*AudioAnalysis, error) { +func (c *Client) GetAudioAnalysis(ctx context.Context, id ID) (*AudioAnalysis, error) { url := fmt.Sprintf("%saudio-analysis/%s", c.baseURL, id) temp := AudioAnalysis{} - err := c.get(url, &temp) + err := c.get(ctx, url, &temp) if err != nil { return nil, err } diff --git a/audio_analysis_test.go b/audio_analysis_test.go index 79a581d..00ef413 100644 --- a/audio_analysis_test.go +++ b/audio_analysis_test.go @@ -1,6 +1,7 @@ package spotify import ( + "context" "net/http" "reflect" "testing" @@ -106,7 +107,7 @@ func TestAudioAnalysis(t *testing.T) { c, s := testClientFile(http.StatusOK, "test_data/get_audio_analysis.txt") defer s.Close() - analysis, err := c.GetAudioAnalysis("foo") + analysis, err := c.GetAudioAnalysis(context.Background(), "foo") if err != nil { t.Error(err) } diff --git a/audio_features.go b/audio_features.go index dcc72f0..b751a38 100644 --- a/audio_features.go +++ b/audio_features.go @@ -1,6 +1,7 @@ package spotify import ( + "context" "fmt" "strings" ) @@ -107,14 +108,14 @@ const ( // high-level acoustic attributes of audio tracks. // Objects are returned in the order requested. If an object // is not found, a nil value is returned in the appropriate position. -func (c *Client) GetAudioFeatures(ids ...ID) ([]*AudioFeatures, error) { +func (c *Client) GetAudioFeatures(ctx context.Context, ids ...ID) ([]*AudioFeatures, error) { url := fmt.Sprintf("%saudio-features?ids=%s", c.baseURL, strings.Join(toStringSlice(ids), ",")) temp := struct { F []*AudioFeatures `json:"audio_features"` }{} - err := c.get(url, &temp) + err := c.get(ctx, url, &temp) if err != nil { return nil, err } diff --git a/audio_features_test.go b/audio_features_test.go index e455545..e74bc1b 100644 --- a/audio_features_test.go +++ b/audio_features_test.go @@ -1,6 +1,7 @@ package spotify import ( + "context" "net/http" "testing" ) @@ -78,7 +79,7 @@ func TestAudioFeatures(t *testing.T) { "abc", // intentionally throw a bad one in "24JygzOLM0EmRQeGtFcIcG", } - features, err := c.GetAudioFeatures() + features, err := c.GetAudioFeatures(context.Background()) if err != nil { t.Error(err) } diff --git a/auth.go b/auth/auth.go similarity index 59% rename from auth.go rename to auth/auth.go index cf7dc63..f186d0b 100644 --- a/auth.go +++ b/auth/auth.go @@ -1,4 +1,4 @@ -package spotify +package spotifyauth import ( "context" @@ -67,84 +67,107 @@ const ( ) // Authenticator provides convenience functions for implementing the OAuth2 flow. -// You should always use `NewAuthenticator` to make them. +// You should always use `New` to make them. // // Example: // -// a := spotify.NewAuthenticator(redirectURL, spotify.ScopeUserLibaryRead, spotify.ScopeUserFollowRead) +// a := spotifyauth.New(redirectURL, spotify.ScopeUserLibaryRead, spotify.ScopeUserFollowRead) // // direct user to Spotify to log in // http.Redirect(w, r, a.AuthURL("state-string"), http.StatusFound) // // // then, in redirect handler: // token, err := a.Token(state, r) -// client := a.NewClient(token) +// client := a.Client(token) // type Authenticator struct { - config *oauth2.Config - context context.Context + config *oauth2.Config } -// NewAuthenticator creates an authenticator which is used to implement the -// OAuth2 authorization flow. The redirectURL must exactly match one of the +type AuthenticatorOption func(a *Authenticator) + +// WithClientID allows a client ID to be specified. Without this the value of the SPOTIFY_ID environment +// variable will be used. +func WithClientID(id string) AuthenticatorOption { + return func(a *Authenticator) { + a.config.ClientID = id + } +} + +// WithClientSecret allows a client secret to be specified. Without this the value of the SPOTIFY_SECRET environment +// variable will be used. +func WithClientSecret(secret string) AuthenticatorOption { + return func(a *Authenticator) { + a.config.ClientSecret = secret + } +} + +// WithScopes configures the oauth scopes that the client should request. +func WithScopes(scopes ...string) AuthenticatorOption { + return func(a *Authenticator) { + a.config.Scopes = scopes + } +} + +// WithRedirectURL configures a redirect url for oauth flows. It must exactly match one of the // URLs specified in your Spotify developer account. +func WithRedirectURL(url string) AuthenticatorOption { + return func(a *Authenticator) { + a.config.RedirectURL = url + } +} + +// New creates an authenticator which is used to implement the OAuth2 authorization flow. // -// By default, NewAuthenticator pulls your client ID and secret key from the -// SPOTIFY_ID and SPOTIFY_SECRET environment variables. If you'd like to provide -// them from some other source, you can call `SetAuthInfo(id, key)` on the -// returned authenticator. -func NewAuthenticator(redirectURL string, scopes ...string) Authenticator { +// By default, NewAuthenticator pulls your client ID and secret key from the SPOTIFY_ID and SPOTIFY_SECRET environment variables. +func New(opts ...AuthenticatorOption) *Authenticator { cfg := &oauth2.Config{ ClientID: os.Getenv("SPOTIFY_ID"), ClientSecret: os.Getenv("SPOTIFY_SECRET"), - RedirectURL: redirectURL, - Scopes: scopes, Endpoint: oauth2.Endpoint{ AuthURL: AuthURL, TokenURL: TokenURL, }, } - // disable HTTP/2 for DefaultClient, see: https://github.com/zmb3/spotify/issues/20 - tr := &http.Transport{ - TLSNextProto: map[string]func(authority string, c *tls.Conn) http.RoundTripper{}, + a := &Authenticator{ + config: cfg, } - ctx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{Transport: tr}) - return Authenticator{ - config: cfg, - context: ctx, + + for _, opt := range opts { + opt(a) } + + return a } -// SetAuthInfo overwrites the client ID and secret key used by the authenticator. -// You can use this if you don't want to store this information in environment variables. -func (a *Authenticator) SetAuthInfo(clientID, secretKey string) { - a.config.ClientID = clientID - a.config.ClientSecret = secretKey +// contextWithHTTPClient returns a context with a value set to override the oauth2 http client as Spotify does not +// support HTTP/2 +// +// see: https://github.com/zmb3/spotify/issues/20 +func contextWithHTTPClient(ctx context.Context) context.Context { + tr := &http.Transport{ + TLSNextProto: map[string]func(authority string, c *tls.Conn) http.RoundTripper{}, + } + return context.WithValue(ctx, oauth2.HTTPClient, &http.Client{Transport: tr}) } +// ShowDialog forces the user to approve the app, even if they have already done so. +// Without this, users who have already approved the app are immediately redirected to the redirect uri. +var ShowDialog = oauth2.SetAuthURLParam("show_dialog", "true") + // AuthURL returns a URL to the the Spotify Accounts Service's OAuth2 endpoint. // // State is a token to protect the user from CSRF attacks. You should pass the // same state to `Token`, where it will be validated. For more info, refer to // http://tools.ietf.org/html/rfc6749#section-10.12. -func (a Authenticator) AuthURL(state string) string { - return a.config.AuthCodeURL(state) -} - -// AuthURLWithDialog returns the same URL as AuthURL, but sets show_dialog to true -func (a Authenticator) AuthURLWithDialog(state string) string { - return a.config.AuthCodeURL(state, oauth2.SetAuthURLParam("show_dialog", "true")) -} - -// AuthURLWithOpts returns the bause AuthURL along with any extra URL Auth params -func (a Authenticator) AuthURLWithOpts(state string, opts ...oauth2.AuthCodeOption) string { +func (a Authenticator) AuthURL(state string, opts ...oauth2.AuthCodeOption) string { return a.config.AuthCodeURL(state, opts...) } // Token pulls an authorization code from an HTTP request and attempts to exchange // it for an access token. The standard use case is to call Token from the handler // that handles requests to your application's redirect URL. -func (a Authenticator) Token(state string, r *http.Request) (*oauth2.Token, error) { +func (a Authenticator) Token(ctx context.Context, state string, r *http.Request, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { values := r.URL.Query() if e := values.Get("error"); e != "" { return nil, errors.New("spotify: auth failed - " + e) @@ -157,52 +180,17 @@ func (a Authenticator) Token(state string, r *http.Request) (*oauth2.Token, erro if actualState != state { return nil, errors.New("spotify: redirect state parameter doesn't match") } - return a.config.Exchange(a.context, code) -} - -// TokenWithOpts performs the same function as the Authenticator Token function -// but takes in optional URL Auth params -func (a Authenticator) TokenWithOpts(state string, r *http.Request, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { - values := r.URL.Query() - if e := values.Get("error"); e != "" { - return nil, errors.New("spotify: auth failed - " + e) - } - code := values.Get("code") - if code == "" { - return nil, errors.New("spotify: didn't get access code") - } - actualState := values.Get("state") - if actualState != state { - return nil, errors.New("spotify: redirect state parameter doesn't match") - } - return a.config.Exchange(a.context, code, opts...) + return a.config.Exchange(contextWithHTTPClient(ctx), code, opts...) } // Exchange is like Token, except it allows you to manually specify the access // code instead of pulling it out of an HTTP request. -func (a Authenticator) Exchange(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { - return a.config.Exchange(a.context, code, opts...) +func (a Authenticator) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return a.config.Exchange(contextWithHTTPClient(ctx), code, opts...) } -// NewClient creates a Client that will use the specified access token for its API requests. -func (a Authenticator) NewClient(token *oauth2.Token) Client { - client := a.config.Client(a.context, token) - return Client{ - http: client, - baseURL: baseAddress, - } -} - -// Token gets the client's current token. -func (c *Client) Token() (*oauth2.Token, error) { - transport, ok := c.http.Transport.(*oauth2.Transport) - if !ok { - return nil, errors.New("spotify: oauth2 transport type not correct") - } - t, err := transport.Source.Token() - if err != nil { - return nil, err - } - - return t, nil +// Client creates a *http.Client that will use the specified access token for its API requests. +// Combine this with spotify.HTTPClientOpt. +func (a Authenticator) Client(ctx context.Context, token *oauth2.Token) *http.Client { + return a.config.Client(contextWithHTTPClient(ctx), token) } diff --git a/category.go b/category.go index d8ba74d..38bdce1 100644 --- a/category.go +++ b/category.go @@ -1,9 +1,8 @@ package spotify import ( + "context" "fmt" - "net/url" - "strconv" ) // Category is used by Spotify to tag items in. For example, on the Spotify @@ -20,30 +19,17 @@ type Category struct { Name string `json:"name"` } -// GetCategoryOpt is like GetCategory, but it accepts optional arguments. -// The country parameter is an ISO 3166-1 alpha-2 country code. It can be -// used to ensure that the category exists for a particular country. The -// locale argument is an ISO 639 language code and an ISO 3166-1 alpha-2 -// country code, separated by an underscore. It can be used to get the -// category strings in a particular language (for example: "es_MX" means -// get categories in Mexico, returned in Spanish). +// GetCategory gets a single category used to tag items in Spotify. // -// This call requires authorization. -func (c *Client) GetCategoryOpt(id, country, locale string) (Category, error) { +// 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) - values := url.Values{} - if country != "" { - values.Set("country", country) - } - if locale != "" { - values.Set("locale", locale) - } - if query := values.Encode(); query != "" { - spotifyURL += "?" + query + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } - err := c.get(spotifyURL, &cat) + err := c.get(ctx, spotifyURL, &cat) if err != nil { return cat, err } @@ -51,42 +37,19 @@ func (c *Client) GetCategoryOpt(id, country, locale string) (Category, error) { return cat, err } -// GetCategory gets a single category used to tag items in Spotify -// (on, for example, the Spotify player's Browse tab). -func (c *Client) GetCategory(id string) (Category, error) { - return c.GetCategoryOpt(id, "", "") -} - // GetCategoryPlaylists gets a list of Spotify playlists tagged with a particular category. -func (c *Client) GetCategoryPlaylists(catID string) (*SimplePlaylistPage, error) { - return c.GetCategoryPlaylistsOpt(catID, nil) -} - -// GetCategoryPlaylistsOpt is like GetCategoryPlaylists, but it accepts optional -// arguments. -func (c *Client) GetCategoryPlaylistsOpt(catID string, opt *Options) (*SimplePlaylistPage, error) { +// 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 opt != nil { - values := url.Values{} - if opt.Country != nil { - values.Set("country", *opt.Country) - } - if opt.Limit != nil { - values.Set("limit", strconv.Itoa(*opt.Limit)) - } - if opt.Offset != nil { - values.Set("offset", strconv.Itoa(*opt.Offset)) - } - if query := values.Encode(); query != "" { - spotifyURL += "?" + query - } + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } wrapper := struct { Playlists SimplePlaylistPage `json:"playlists"` }{} - err := c.get(spotifyURL, &wrapper) + err := c.get(ctx, spotifyURL, &wrapper) if err != nil { return nil, err } @@ -95,35 +58,11 @@ func (c *Client) GetCategoryPlaylistsOpt(catID string, opt *Options) (*SimplePla } // GetCategories gets a list of categories used to tag items in Spotify -// (on, for example, the Spotify player's "Browse" tab). -func (c *Client) GetCategories() (*CategoryPage, error) { - return c.GetCategoriesOpt(nil, "") -} - -// GetCategoriesOpt is like GetCategories, but it accepts optional parameters. // -// The locale option can be used to get the results in a particular language. -// It consists of an ISO 639 language code and an ISO 3166-1 alpha-2 country -// code, separated by an underscore. Specify the empty string to have results -// returned in the Spotify default language (American English). -func (c *Client) GetCategoriesOpt(opt *Options, locale string) (*CategoryPage, error) { +// Supported options: Country, Locale, Limit, Offset +func (c *Client) GetCategories(ctx context.Context, opts ...RequestOption) (*CategoryPage, error) { spotifyURL := c.baseURL + "browse/categories" - values := url.Values{} - if locale != "" { - values.Set("locale", locale) - } - if opt != nil { - if opt.Country != nil { - values.Set("country", *opt.Country) - } - if opt.Limit != nil { - values.Set("limit", strconv.Itoa(*opt.Limit)) - } - if opt.Offset != nil { - values.Set("offset", strconv.Itoa(*opt.Offset)) - } - } - if query := values.Encode(); query != "" { + if query := processOptions(opts...).urlParams.Encode(); query != "" { spotifyURL += "?" + query } @@ -131,7 +70,7 @@ func (c *Client) GetCategoriesOpt(opt *Options, locale string) (*CategoryPage, e Categories CategoryPage `json:"categories"` }{} - err := c.get(spotifyURL, &wrapper) + err := c.get(ctx, spotifyURL, &wrapper) if err != nil { return nil, err } diff --git a/category_test.go b/category_test.go index 5afc718..9f5b2eb 100644 --- a/category_test.go +++ b/category_test.go @@ -1,7 +1,9 @@ package spotify import ( + "context" "net/http" + "strings" "testing" ) @@ -9,7 +11,7 @@ func TestGetCategories(t *testing.T) { client, server := testClientString(http.StatusOK, getCategories) defer server.Close() - page, err := client.GetCategories() + page, err := client.GetCategories(context.Background()) if err != nil { t.Fatal(err) } @@ -25,7 +27,7 @@ func TestGetCategory(t *testing.T) { client, server := testClientString(http.StatusOK, getCategory) defer server.Close() - cat, err := client.GetCategory("dinner") + cat, err := client.GetCategory(context.Background(), "dinner") if err != nil { t.Fatal(err) } @@ -38,7 +40,7 @@ func TestGetCategoryPlaylists(t *testing.T) { client, server := testClientString(http.StatusOK, getCategoryPlaylists) defer server.Close() - page, err := client.GetCategoryPlaylists("dinner") + page, err := client.GetCategoryPlaylists(context.Background(), "dinner") if err != nil { t.Fatal(err) } @@ -69,7 +71,7 @@ func TestGetCategoryOpt(t *testing.T) { }) defer server.Close() - _, err := client.GetCategoryOpt("id", CountryBrazil, "es_MX") + _, err := client.GetCategory(context.Background(), "id", Country(CountryBrazil), Locale("es_MX")) if err == nil { t.Fatal("Expected error") } @@ -90,19 +92,17 @@ func TestGetCategoryPlaylistsOpt(t *testing.T) { }) defer server.Close() - opt := &Options{} - opt.Limit = new(int) - opt.Offset = new(int) - *opt.Limit = 5 - *opt.Offset = 10 - client.GetCategoryPlaylistsOpt("id", opt) + _, err := client.GetCategoryPlaylists(context.Background(), "id", Limit(5), Offset(10)) + if err == nil || !strings.Contains(err.Error(), "HTTP 404: Not Found") { + t.Errorf("Expected error 'spotify: HTTP 404: Not Found (body empty)', got %v", err) + } } func TestGetCategoriesInvalidToken(t *testing.T) { client, server := testClientString(http.StatusUnauthorized, invalidToken) defer server.Close() - _, err := client.GetCategories() + _, err := client.GetCategories(context.Background()) if err == nil { t.Fatal("Expected error but didn't get one") } diff --git a/examples/authenticate/authcode/authenticate.go b/examples/authenticate/authcode/authenticate.go index 618a8bd..7247f94 100644 --- a/examples/authenticate/authcode/authenticate.go +++ b/examples/authenticate/authcode/authenticate.go @@ -8,11 +8,13 @@ package main import ( + "context" "fmt" + "github.com/zmb3/spotify/v2/auth" "log" "net/http" - "github.com/zmb3/spotify" + "github.com/zmb3/spotify/v2" ) // redirectURI is the OAuth redirect URI for the application. @@ -21,7 +23,7 @@ import ( const redirectURI = "http://localhost:8080/callback" var ( - auth = spotify.NewAuthenticator(redirectURI, spotify.ScopeUserReadPrivate) + auth = spotifyauth.New(spotifyauth.WithRedirectURL(redirectURI), spotifyauth.WithScopes(spotifyauth.ScopeUserReadPrivate)) ch = make(chan *spotify.Client) state = "abc123" ) @@ -32,7 +34,12 @@ func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { log.Println("Got request for:", r.URL.String()) }) - go http.ListenAndServe(":8080", nil) + go func() { + err := http.ListenAndServe(":8080", nil) + if err != nil { + log.Fatal(err) + } + }() url := auth.AuthURL(state) fmt.Println("Please log in to Spotify by visiting the following page in your browser:", url) @@ -41,7 +48,7 @@ func main() { client := <-ch // use the client to make calls that require authorization - user, err := client.CurrentUser() + user, err := client.CurrentUser(context.Background()) if err != nil { log.Fatal(err) } @@ -49,7 +56,7 @@ func main() { } func completeAuth(w http.ResponseWriter, r *http.Request) { - tok, err := auth.Token(state, r) + tok, err := auth.Token(r.Context(), state, r) if err != nil { http.Error(w, "Couldn't get token", http.StatusForbidden) log.Fatal(err) @@ -58,8 +65,9 @@ func completeAuth(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) log.Fatalf("State mismatch: %s != %s\n", st, state) } + // use the token to get an authenticated client - client := auth.NewClient(tok) + client := spotify.New(auth.Client(r.Context(), tok)) fmt.Fprintf(w, "Login Completed!") - ch <- &client + ch <- client } diff --git a/examples/authenticate/clientcreds/client_credentials.go b/examples/authenticate/clientcreds/client_credentials.go index 0546080..2ef4c7e 100644 --- a/examples/authenticate/clientcreds/client_credentials.go +++ b/examples/authenticate/clientcreds/client_credentials.go @@ -9,26 +9,29 @@ package main import ( "context" "fmt" + "github.com/zmb3/spotify/v2/auth" "log" "os" - "github.com/zmb3/spotify" + "github.com/zmb3/spotify/v2" "golang.org/x/oauth2/clientcredentials" ) func main() { + ctx := context.Background() config := &clientcredentials.Config{ ClientID: os.Getenv("SPOTIFY_ID"), ClientSecret: os.Getenv("SPOTIFY_SECRET"), - TokenURL: spotify.TokenURL, + TokenURL: spotifyauth.TokenURL, } - token, err := config.Token(context.Background()) + token, err := config.Token(ctx) if err != nil { log.Fatalf("couldn't get token: %v", err) } - client := spotify.Authenticator{}.NewClient(token) - msg, page, err := client.FeaturedPlaylists() + httpClient := spotifyauth.New().Client(ctx, token) + client := spotify.New(httpClient) + msg, page, err := client.FeaturedPlaylists(ctx) if err != nil { log.Fatalf("couldn't get features playlists: %v", err) } diff --git a/examples/authenticate/pkce/pkce.go b/examples/authenticate/pkce/pkce.go index 8d0f6af..ef1f228 100644 --- a/examples/authenticate/pkce/pkce.go +++ b/examples/authenticate/pkce/pkce.go @@ -7,13 +7,15 @@ package main import ( + "context" "fmt" + spotifyauth "github.com/zmb3/spotify/v2/auth" "log" "net/http" "golang.org/x/oauth2" - "github.com/zmb3/spotify" + "github.com/zmb3/spotify/v2" ) // redirectURI is the OAuth redirect URI for the application. @@ -22,7 +24,7 @@ import ( const redirectURI = "http://localhost:8080/callback" var ( - auth = spotify.NewAuthenticator(redirectURI, spotify.ScopeUserReadPrivate) + auth = spotifyauth.New(spotifyauth.WithRedirectURL(redirectURI), spotifyauth.WithScopes(spotifyauth.ScopeUserReadPrivate)) ch = make(chan *spotify.Client) state = "abc123" // These should be randomly generated for each request @@ -40,7 +42,7 @@ func main() { }) go http.ListenAndServe(":8080", nil) - url := auth.AuthURLWithOpts(state, + url := auth.AuthURL(state, oauth2.SetAuthURLParam("code_challenge_method", "S256"), oauth2.SetAuthURLParam("code_challenge", codeChallenge), ) @@ -50,7 +52,7 @@ func main() { client := <-ch // use the client to make calls that require authorization - user, err := client.CurrentUser() + user, err := client.CurrentUser(context.Background()) if err != nil { log.Fatal(err) } @@ -58,7 +60,7 @@ func main() { } func completeAuth(w http.ResponseWriter, r *http.Request) { - tok, err := auth.TokenWithOpts(state, r, + tok, err := auth.Token(r.Context(), state, r, oauth2.SetAuthURLParam("code_verifier", codeVerifier)) if err != nil { http.Error(w, "Couldn't get token", http.StatusForbidden) @@ -69,7 +71,7 @@ func completeAuth(w http.ResponseWriter, r *http.Request) { log.Fatalf("State mismatch: %s != %s\n", st, state) } // use the token to get an authenticated client - client := auth.NewClient(tok) + client := spotify.New(auth.Client(r.Context(), tok)) fmt.Fprintf(w, "Login Completed!") - ch <- &client + ch <- client } diff --git a/examples/paging/page.go b/examples/paging/page.go index 8cf394f..2bc93a2 100644 --- a/examples/paging/page.go +++ b/examples/paging/page.go @@ -2,26 +2,29 @@ package main import ( "context" + "github.com/zmb3/spotify/v2" + spotifyauth "github.com/zmb3/spotify/v2/auth" + "golang.org/x/oauth2/clientcredentials" "log" "os" - - "github.com/zmb3/spotify" - "golang.org/x/oauth2/clientcredentials" ) func main() { + ctx := context.Background() config := &clientcredentials.Config{ ClientID: os.Getenv("SPOTIFY_ID"), ClientSecret: os.Getenv("SPOTIFY_SECRET"), - TokenURL: spotify.TokenURL, + TokenURL: spotifyauth.TokenURL, } - token, err := config.Token(context.Background()) + token, err := config.Token(ctx) if err != nil { log.Fatalf("couldn't get token: %v", err) } - client := spotify.Authenticator{}.NewClient(token) - tracks, err := client.GetPlaylistTracks("57qttz6pK881sjxj2TAEEo") + httpClient := spotifyauth.New().Client(ctx, token) + client := spotify.New(httpClient) + + tracks, err := client.GetPlaylistTracks(ctx, "57qttz6pK881sjxj2TAEEo") if err != nil { log.Fatal(err) } @@ -29,7 +32,7 @@ func main() { log.Printf("Playlist has %d total tracks", tracks.Total) for page := 1; ; page++ { log.Printf(" Page %d has %d tracks", page, len(tracks.Tracks)) - err = client.NextPage(tracks) + err = client.NextPage(ctx, tracks) if err == spotify.ErrNoMorePages { break } diff --git a/examples/player/player.go b/examples/player/player.go index 36a3104..bcc6540 100644 --- a/examples/player/player.go +++ b/examples/player/player.go @@ -8,12 +8,14 @@ package main import ( + "context" "fmt" + spotifyauth "github.com/zmb3/spotify/v2/auth" "log" "net/http" "strings" - "github.com/zmb3/spotify" + "github.com/zmb3/spotify/v2" ) // redirectURI is the OAuth redirect URI for the application. @@ -32,7 +34,10 @@ var html = ` ` var ( - auth = spotify.NewAuthenticator(redirectURI, spotify.ScopeUserReadCurrentlyPlaying, spotify.ScopeUserReadPlaybackState, spotify.ScopeUserModifyPlaybackState) + auth = spotifyauth.New( + spotifyauth.WithRedirectURL(redirectURI), + spotifyauth.WithScopes(spotifyauth.ScopeUserReadCurrentlyPlaying, spotifyauth.ScopeUserReadPlaybackState, spotifyauth.ScopeUserModifyPlaybackState), + ) ch = make(chan *spotify.Client) state = "abc123" ) @@ -45,21 +50,22 @@ func main() { http.HandleFunc("/callback", completeAuth) http.HandleFunc("/player/", func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() action := strings.TrimPrefix(r.URL.Path, "/player/") fmt.Println("Got request for:", action) var err error switch action { case "play": - err = client.Play() + err = client.Play(ctx) case "pause": - err = client.Pause() + err = client.Pause(ctx) case "next": - err = client.Next() + err = client.Next(ctx) case "previous": - err = client.Previous() + err = client.Previous(ctx) case "shuffle": playerState.ShuffleState = !playerState.ShuffleState - err = client.Shuffle(playerState.ShuffleState) + err = client.Shuffle(ctx, playerState.ShuffleState) } if err != nil { log.Print(err) @@ -81,13 +87,13 @@ func main() { client = <-ch // use the client to make calls that require authorization - user, err := client.CurrentUser() + user, err := client.CurrentUser(context.Background()) if err != nil { log.Fatal(err) } fmt.Println("You are logged in as:", user.ID) - playerState, err = client.PlayerState() + playerState, err = client.PlayerState(context.Background()) if err != nil { log.Fatal(err) } @@ -99,7 +105,7 @@ func main() { } func completeAuth(w http.ResponseWriter, r *http.Request) { - tok, err := auth.Token(state, r) + tok, err := auth.Token(r.Context(), state, r) if err != nil { http.Error(w, "Couldn't get token", http.StatusForbidden) log.Fatal(err) @@ -109,8 +115,8 @@ func completeAuth(w http.ResponseWriter, r *http.Request) { log.Fatalf("State mismatch: %s != %s\n", st, state) } // use the token to get an authenticated client - client := auth.NewClient(tok) + client := spotify.New(auth.Client(r.Context(), tok)) w.Header().Set("Content-Type", "text/html") fmt.Fprintf(w, "Login Completed!"+html) - ch <- &client + ch <- client } diff --git a/examples/profile/profile.go b/examples/profile/profile.go index 151c913..17a1768 100644 --- a/examples/profile/profile.go +++ b/examples/profile/profile.go @@ -5,12 +5,13 @@ import ( "context" "flag" "fmt" + spotifyauth "github.com/zmb3/spotify/v2/auth" "log" "os" "golang.org/x/oauth2/clientcredentials" - "github.com/zmb3/spotify" + "github.com/zmb3/spotify/v2" ) var userID = flag.String("user", "", "the Spotify user ID to look up") @@ -18,6 +19,8 @@ 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() @@ -27,15 +30,16 @@ func main() { config := &clientcredentials.Config{ ClientID: os.Getenv("SPOTIFY_ID"), ClientSecret: os.Getenv("SPOTIFY_SECRET"), - TokenURL: spotify.TokenURL, + TokenURL: spotifyauth.TokenURL, } token, err := config.Token(context.Background()) if err != nil { log.Fatalf("couldn't get token: %v", err) } - client := spotify.Authenticator{}.NewClient(token) - user, err := client.GetUsersPublicProfile(spotify.ID(*userID)) + httpClient := spotifyauth.New().Client(ctx, token) + client := spotify.New(httpClient) + user, err := client.GetUsersPublicProfile(ctx, spotify.ID(*userID)) if err != nil { fmt.Fprintln(os.Stderr, err.Error()) return diff --git a/examples/search/search.go b/examples/search/search.go index 05ff40a..c00cfe7 100644 --- a/examples/search/search.go +++ b/examples/search/search.go @@ -3,28 +3,31 @@ package main import ( "context" "fmt" + spotifyauth "github.com/zmb3/spotify/v2/auth" "log" "os" "golang.org/x/oauth2/clientcredentials" - "github.com/zmb3/spotify" + "github.com/zmb3/spotify/v2" ) func main() { + ctx := context.Background() config := &clientcredentials.Config{ ClientID: os.Getenv("SPOTIFY_ID"), ClientSecret: os.Getenv("SPOTIFY_SECRET"), - TokenURL: spotify.TokenURL, + TokenURL: spotifyauth.TokenURL, } - token, err := config.Token(context.Background()) + token, err := config.Token(ctx) if err != nil { log.Fatalf("couldn't get token: %v", err) } - client := spotify.Authenticator{}.NewClient(token) + httpClient := spotifyauth.New().Client(ctx, token) + client := spotify.New(httpClient) // search for playlists and albums containing "holiday" - results, err := client.Search("holiday", spotify.SearchTypePlaylist|spotify.SearchTypeAlbum) + results, err := client.Search(ctx, "holiday", spotify.SearchTypePlaylist|spotify.SearchTypeAlbum) if err != nil { log.Fatal(err) } diff --git a/full_tests.bat b/full_tests.bat deleted file mode 100644 index 2c781c3..0000000 --- a/full_tests.bat +++ /dev/null @@ -1,5 +0,0 @@ -@echo off -REM - The tests that actually hit the Spotify Web API don't run by default. -REM - Use this script to run them in addition to the standard unit tests. - -cmd /C "set FULLTEST=y && go test %*" diff --git a/go.mod b/go.mod index 961bbbe..ef7e471 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,12 @@ -module github.com/zmb3/spotify +module github.com/zmb3/spotify/v2 -go 1.14 +go 1.16 require ( + github.com/golang/protobuf v1.5.2 // indirect github.com/stretchr/testify v1.7.0 - golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d + golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect + golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5 + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.27.1 // indirect ) diff --git a/go.sum b/go.sum index 2009050..b5c86bb 100644 --- a/go.sum +++ b/go.sum @@ -1,24 +1,384 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d h1:LO7XpTYMwTqxjLcGWPijK3vRXg1aWdlNOVOHRq45d7c= +golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5 h1:Ati8dO7+U7mxpkPSxBZQEvzHVUYB/MqCklCN8ig5w/o= +golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/library.go b/library.go index c4ead7e..c89999d 100644 --- a/library.go +++ b/library.go @@ -1,6 +1,7 @@ package spotify import ( + "context" "errors" "fmt" "net/http" @@ -9,15 +10,25 @@ import ( // UserHasTracks checks if one or more tracks are saved to the current user's // "Your Music" library. -func (c *Client) UserHasTracks(ids ...ID) ([]bool, error) { +func (c *Client) UserHasTracks(ctx context.Context, ids ...ID) ([]bool, error) { + return c.libraryContains(ctx, "tracks", ids...) +} + +// 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...) +} + +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: UserHasTracks supports 1 to 50 IDs per call") + return nil, errors.New("spotify: supports 1 to 50 IDs per call") } - spotifyURL := fmt.Sprintf("%sme/tracks/contains?ids=%s", c.baseURL, strings.Join(toStringSlice(ids), ",")) + spotifyURL := fmt.Sprintf("%sme/%s/contains?ids=%s", c.baseURL, typ, strings.Join(toStringSlice(ids), ",")) var result []bool - err := c.get(spotifyURL, &result) + err := c.get(ctx, spotifyURL, &result) if err != nil { return nil, err } @@ -28,76 +39,38 @@ func (c *Client) UserHasTracks(ids ...ID) ([]bool, error) { // 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(ids ...ID) error { - return c.modifyLibraryTracks(true, ids...) +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 a `spotify.Error` with the status code set to http.StatusUnauthorized. -func (c *Client) RemoveTracksFromLibrary(ids ...ID) error { - return c.modifyLibraryTracks(false, ids...) -} - -func (c *Client) modifyLibraryTracks(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/tracks?ids=%s", c.baseURL, strings.Join(toStringSlice(ids), ",")) - method := "DELETE" - if add { - method = "PUT" - } - req, err := http.NewRequest(method, spotifyURL, nil) - if err != nil { - return err - } - err = c.execute(req, nil) - if err != nil { - return err - } - return nil -} - -// UserHasAlbums checks if one or more albums are saved to the current user's -// "Your Albums" library. -func (c *Client) UserHasAlbums(ids ...ID) ([]bool, error) { - if l := len(ids); l == 0 || l > 50 { - return nil, errors.New("spotify: UserHasAlbums supports 1 to 50 IDs per call") - } - spotifyURL := fmt.Sprintf("%sme/albums/contains?ids=%s", c.baseURL, strings.Join(toStringSlice(ids), ",")) - - var result []bool - - err := c.get(spotifyURL, &result) - if err != nil { - return nil, err - } - - return result, err +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(ids ...ID) error { - return c.modifyLibraryAlbums(true, ids...) +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 a `spotify.Error` with the status code set to http.StatusUnauthorized. -func (c *Client) RemoveAlbumsFromLibrary(ids ...ID) error { - return c.modifyLibraryAlbums(false, ids...) +func (c *Client) RemoveAlbumsFromLibrary(ctx context.Context, ids ...ID) error { + return c.modifyLibrary(ctx, "albums", false, ids...) } -func (c *Client) modifyLibraryAlbums(add bool, ids ...ID) error { +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/albums?ids=%s", c.baseURL, strings.Join(toStringSlice(ids), ",")) + spotifyURL := fmt.Sprintf("%sme/%s?ids=%s", c.baseURL, typ, strings.Join(toStringSlice(ids), ",")) method := "DELETE" if add { method = "PUT" diff --git a/library_test.go b/library_test.go index 82d607d..37bf7ff 100644 --- a/library_test.go +++ b/library_test.go @@ -1,6 +1,7 @@ package spotify import ( + "context" "net/http" "testing" ) @@ -9,7 +10,7 @@ func TestUserHasTracks(t *testing.T) { client, server := testClientString(http.StatusOK, `[ false, true ]`) defer server.Close() - contains, err := client.UserHasTracks("0udZHhCi7p1YzMlvI4fXoK", "55nlbqqFVnSsArIeYSQlqx") + contains, err := client.UserHasTracks(context.Background(), "0udZHhCi7p1YzMlvI4fXoK", "55nlbqqFVnSsArIeYSQlqx") if err != nil { t.Error(err) } @@ -25,7 +26,7 @@ func TestAddTracksToLibrary(t *testing.T) { client, server := testClientString(http.StatusOK, "") defer server.Close() - err := client.AddTracksToLibrary("4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") + err := client.AddTracksToLibrary(context.Background(), "4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") if err != nil { t.Error(err) } @@ -40,7 +41,7 @@ func TestAddTracksToLibraryFailure(t *testing.T) { } }`) defer server.Close() - err := client.AddTracksToLibrary("4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") + err := client.AddTracksToLibrary(context.Background(), "4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") if err == nil { t.Error("Expected error and didn't get one") } @@ -50,7 +51,7 @@ func TestRemoveTracksFromLibrary(t *testing.T) { client, server := testClientString(http.StatusOK, "") defer server.Close() - err := client.RemoveTracksFromLibrary("4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") + err := client.RemoveTracksFromLibrary(context.Background(), "4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") if err != nil { t.Error(err) } @@ -60,7 +61,7 @@ func TestUserHasAlbums(t *testing.T) { client, server := testClientString(http.StatusOK, `[ false, true ]`) defer server.Close() - contains, err := client.UserHasAlbums("0udZHhCi7p1YzMlvI4fXoK", "55nlbqqFVnSsArIeYSQlqx") + contains, err := client.UserHasAlbums(context.Background(), "0udZHhCi7p1YzMlvI4fXoK", "55nlbqqFVnSsArIeYSQlqx") if err != nil { t.Error(err) } @@ -76,7 +77,7 @@ func TestAddAlbumsToLibrary(t *testing.T) { client, server := testClientString(http.StatusOK, "") defer server.Close() - err := client.AddAlbumsToLibrary("4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") + err := client.AddAlbumsToLibrary(context.Background(), "4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") if err != nil { t.Error(err) } @@ -91,7 +92,7 @@ func TestAddAlbumsToLibraryFailure(t *testing.T) { } }`) defer server.Close() - err := client.AddAlbumsToLibrary("4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") + err := client.AddAlbumsToLibrary(context.Background(), "4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") if err == nil { t.Error("Expected error and didn't get one") } @@ -101,7 +102,7 @@ func TestRemoveAlbumsFromLibrary(t *testing.T) { client, server := testClientString(http.StatusOK, "") defer server.Close() - err := client.RemoveAlbumsFromLibrary("4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") + err := client.RemoveAlbumsFromLibrary(context.Background(), "4iV5W9uYEdYUVa79Axb7Rh", "1301WleyT98MSxVHPZCA6M") if err != nil { t.Error(err) } diff --git a/page.go b/page.go index dd90271..89a3b5c 100644 --- a/page.go +++ b/page.go @@ -1,6 +1,7 @@ package spotify import ( + "context" "errors" "fmt" "reflect" @@ -108,7 +109,7 @@ func (b *basePage) canPage() {} // NextPage fetches the next page of items and writes them into p. // It returns ErrNoMorePages if p already contains the last page. -func (c *Client) NextPage(p pageable) error { +func (c *Client) NextPage(ctx context.Context, p pageable) error { if p == nil || reflect.ValueOf(p).IsNil() { return fmt.Errorf("spotify: p must be a non-nil pointer to a page") } @@ -127,12 +128,12 @@ func (c *Client) NextPage(p pageable) error { zero := reflect.Zero(val.Type()) val.Set(zero) - return c.get(nextURL, p) + return c.get(ctx, nextURL, p) } // PreviousPage fetches the previous page of items and writes them into p. // It returns ErrNoMorePages if p already contains the last page. -func (c *Client) PreviousPage(p pageable) error { +func (c *Client) PreviousPage(ctx context.Context, p pageable) error { if p == nil || reflect.ValueOf(p).IsNil() { return fmt.Errorf("spotify: p must be a non-nil pointer to a page") } @@ -151,5 +152,5 @@ func (c *Client) PreviousPage(p pageable) error { zero := reflect.Zero(val.Type()) val.Set(zero) - return c.get(prevURL, p) + return c.get(ctx, prevURL, p) } diff --git a/page_test.go b/page_test.go index 9fa4e39..bdaa75c 100644 --- a/page_test.go +++ b/page_test.go @@ -1,6 +1,7 @@ package spotify import ( + "context" "errors" "github.com/stretchr/testify/assert" "net/http" @@ -50,7 +51,7 @@ func TestClient_NextPage(t *testing.T) { tt.Input.Next = server.URL + tt.Input.Next // add fake server url so we intercept the message } - err := client.NextPage(tt.Input) + err := client.NextPage(context.Background(), tt.Input) assert.Equal(t, tt.ExpectedPath != "", wasCalled) if tt.Err == nil { assert.NoError(t, err) @@ -105,7 +106,7 @@ func TestClient_PreviousPage(t *testing.T) { tt.Input.Previous = server.URL + tt.Input.Previous // add fake server url so we intercept the message } - err := client.PreviousPage(tt.Input) + err := client.PreviousPage(context.Background(), tt.Input) assert.Equal(t, tt.ExpectedPath != "", wasCalled) if tt.Err == nil { assert.NoError(t, err) diff --git a/player.go b/player.go index b450432..126a3c7 100755 --- a/player.go +++ b/player.go @@ -2,6 +2,7 @@ package spotify import ( "bytes" + "context" "encoding/json" "net/http" "net/url" @@ -83,7 +84,7 @@ type RecentlyPlayedResult struct { // the request may be accepted but with an unpredictable resulting action on playback. type PlaybackOffset struct { // Position is zero based and can’t be negative. - Position int `json:"position,omitempty"` + Position int `json:"position"` // URI is a string representing the uri of the item to start at. URI URI `json:"uri,omitempty"` } @@ -129,12 +130,12 @@ type RecentlyPlayedOptions struct { // PlayerDevices information about available devices for the current user. // // Requires the ScopeUserReadPlaybackState scope in order to read information -func (c *Client) PlayerDevices() ([]PlayerDevice, error) { +func (c *Client) PlayerDevices(ctx context.Context) ([]PlayerDevice, error) { var result struct { PlayerDevices []PlayerDevice `json:"devices"` } - err := c.get(c.baseURL+"me/player/devices", &result) + err := c.get(ctx, c.baseURL+"me/player/devices", &result) if err != nil { return nil, err } @@ -143,29 +144,18 @@ func (c *Client) PlayerDevices() ([]PlayerDevice, error) { } // PlayerState gets information about the playing state for the current user -// // Requires the ScopeUserReadPlaybackState scope in order to read information -func (c *Client) PlayerState() (*PlayerState, error) { - return c.PlayerStateOpt(nil) -} - -// PlayerStateOpt is like PlayerState, but it accepts additional -// options for sorting and filtering the results. -func (c *Client) PlayerStateOpt(opt *Options) (*PlayerState, error) { +// +// Supported options: Market +func (c *Client) PlayerState(ctx context.Context, opts ...RequestOption) (*PlayerState, error) { spotifyURL := c.baseURL + "me/player" - if opt != nil { - v := url.Values{} - if opt.Country != nil { - v.Set("market", *opt.Country) - } - if params := v.Encode(); params != "" { - spotifyURL += "?" + params - } + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } var result PlayerState - err := c.get(spotifyURL, &result) + err := c.get(ctx, spotifyURL, &result) if err != nil { return nil, err } @@ -178,25 +168,16 @@ func (c *Client) PlayerStateOpt(opt *Options) (*PlayerState, error) { // // Requires the ScopeUserReadCurrentlyPlaying scope or the ScopeUserReadPlaybackState // scope in order to read information -func (c *Client) PlayerCurrentlyPlaying() (*CurrentlyPlaying, error) { - return c.PlayerCurrentlyPlayingOpt(nil) -} - -// PlayerCurrentlyPlayingOpt is like PlayerCurrentlyPlaying, but it accepts -// additional options for sorting and filtering the results. -func (c *Client) PlayerCurrentlyPlayingOpt(opt *Options) (*CurrentlyPlaying, error) { +// +// Supported options: Market +func (c *Client) PlayerCurrentlyPlaying(ctx context.Context, opts ...RequestOption) (*CurrentlyPlaying, error) { spotifyURL := c.baseURL + "me/player/currently-playing" - if opt != nil { - v := url.Values{} - if opt.Country != nil { - v.Set("market", *opt.Country) - } - if params := v.Encode(); params != "" { - spotifyURL += "?" + params - } + + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } - req, err := http.NewRequest("GET", spotifyURL, nil) + req, err := http.NewRequestWithContext(ctx, "GET", spotifyURL, nil) if err != nil { return nil, err } @@ -212,13 +193,13 @@ func (c *Client) PlayerCurrentlyPlayingOpt(opt *Options) (*CurrentlyPlaying, err // PlayerRecentlyPlayed gets a list of recently-played tracks for the current // user. This call requires ScopeUserReadRecentlyPlayed. -func (c *Client) PlayerRecentlyPlayed() ([]RecentlyPlayedItem, error) { - return c.PlayerRecentlyPlayedOpt(nil) +func (c *Client) PlayerRecentlyPlayed(ctx context.Context) ([]RecentlyPlayedItem, error) { + return c.PlayerRecentlyPlayedOpt(ctx, nil) } // PlayerRecentlyPlayedOpt is like PlayerRecentlyPlayed, but it accepts // additional options for sorting and filtering the results. -func (c *Client) PlayerRecentlyPlayedOpt(opt *RecentlyPlayedOptions) ([]RecentlyPlayedItem, error) { +func (c *Client) PlayerRecentlyPlayedOpt(ctx context.Context, opt *RecentlyPlayedOptions) ([]RecentlyPlayedItem, error) { spotifyURL := c.baseURL + "me/player/recently-played" if opt != nil { v := url.Values{} @@ -237,7 +218,7 @@ func (c *Client) PlayerRecentlyPlayedOpt(opt *RecentlyPlayedOptions) ([]Recently } result := RecentlyPlayedResult{} - err := c.get(spotifyURL, &result) + err := c.get(ctx, spotifyURL, &result) if err != nil { return nil, err } @@ -254,7 +235,7 @@ func (c *Client) PlayerRecentlyPlayedOpt(opt *RecentlyPlayedOptions) ([]Recently // active device before transferring to the new device_id. // // Requires the ScopeUserModifyPlaybackState in order to modify the player state -func (c *Client) TransferPlayback(deviceID ID, play bool) error { +func (c *Client) TransferPlayback(ctx context.Context, deviceID ID, play bool) error { reqData := struct { DeviceID []ID `json:"device_ids"` Play bool `json:"play"` @@ -268,7 +249,7 @@ func (c *Client) TransferPlayback(deviceID ID, play bool) error { if err != nil { return err } - req, err := http.NewRequest(http.MethodPut, c.baseURL+"me/player", buf) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.baseURL+"me/player", buf) if err != nil { return err } @@ -282,12 +263,12 @@ func (c *Client) TransferPlayback(deviceID ID, play bool) error { // Play Start a new context or resume current playback on the user's active // device. This call requires ScopeUserModifyPlaybackState in order to modify the player state. -func (c *Client) Play() error { - return c.PlayOpt(nil) +func (c *Client) Play(ctx context.Context) error { + return c.PlayOpt(ctx, nil) } // PlayOpt is like Play but with more options -func (c *Client) PlayOpt(opt *PlayOptions) error { +func (c *Client) PlayOpt(ctx context.Context, opt *PlayOptions) error { spotifyURL := c.baseURL + "me/player/play" buf := new(bytes.Buffer) @@ -305,7 +286,7 @@ func (c *Client) PlayOpt(opt *PlayOptions) error { return err } } - req, err := http.NewRequest(http.MethodPut, spotifyURL, buf) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, spotifyURL, buf) if err != nil { return err } @@ -319,14 +300,14 @@ func (c *Client) PlayOpt(opt *PlayOptions) error { // Pause Playback on the user's currently active device. // // Requires the ScopeUserModifyPlaybackState in order to modify the player state -func (c *Client) Pause() error { - return c.PauseOpt(nil) +func (c *Client) Pause(ctx context.Context) error { + return c.PauseOpt(ctx, nil) } // PauseOpt is like Pause but with more options // // Only expects PlayOptions.DeviceID, all other options will be ignored -func (c *Client) PauseOpt(opt *PlayOptions) error { +func (c *Client) PauseOpt(ctx context.Context, opt *PlayOptions) error { spotifyURL := c.baseURL + "me/player/pause" if opt != nil { @@ -338,7 +319,7 @@ func (c *Client) PauseOpt(opt *PlayOptions) error { spotifyURL += "?" + params } } - req, err := http.NewRequest(http.MethodPut, spotifyURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, spotifyURL, nil) if err != nil { return err } @@ -352,14 +333,14 @@ func (c *Client) PauseOpt(opt *PlayOptions) error { // QueueSong adds a song to the user's queue on the user's currently // active device. This call requires ScopeUserModifyPlaybackState // in order to modify the player state -func (c *Client) QueueSong(trackID ID) error { - return c.QueueSongOpt(trackID, nil) +func (c *Client) QueueSong(ctx context.Context, trackID ID) error { + return c.QueueSongOpt(ctx, trackID, nil) } // QueueSongOpt is like QueueSong but with more options // // Only expects PlayOptions.DeviceID, all other options will be ignored -func (c *Client) QueueSongOpt(trackID ID, opt *PlayOptions) error { +func (c *Client) QueueSongOpt(ctx context.Context, trackID ID, opt *PlayOptions) error { uri := "spotify:track:" + trackID spotifyURL := c.baseURL + "me/player/queue" v := url.Values{} @@ -376,7 +357,7 @@ func (c *Client) QueueSongOpt(trackID ID, opt *PlayOptions) error { spotifyURL += "?" + params } - req, err := http.NewRequest(http.MethodPost, spotifyURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, spotifyURL, nil) if err != nil { return err } @@ -387,14 +368,14 @@ func (c *Client) QueueSongOpt(trackID ID, opt *PlayOptions) error { // Next skips to the next track in the user's queue in the user's // currently active device. This call requires ScopeUserModifyPlaybackState // in order to modify the player state -func (c *Client) Next() error { - return c.NextOpt(nil) +func (c *Client) Next(ctx context.Context) error { + return c.NextOpt(ctx, nil) } // NextOpt is like Next but with more options // // Only expects PlayOptions.DeviceID, all other options will be ignored -func (c *Client) NextOpt(opt *PlayOptions) error { +func (c *Client) NextOpt(ctx context.Context, opt *PlayOptions) error { spotifyURL := c.baseURL + "me/player/next" if opt != nil { @@ -420,14 +401,14 @@ func (c *Client) NextOpt(opt *PlayOptions) error { // Previous skips to the Previous track in the user's queue in the user's // currently active device. This call requires ScopeUserModifyPlaybackState // in order to modify the player state -func (c *Client) Previous() error { - return c.PreviousOpt(nil) +func (c *Client) Previous(ctx context.Context) error { + return c.PreviousOpt(ctx, nil) } // PreviousOpt is like Previous but with more options // // Only expects PlayOptions.DeviceID, all other options will be ignored -func (c *Client) PreviousOpt(opt *PlayOptions) error { +func (c *Client) PreviousOpt(ctx context.Context, opt *PlayOptions) error { spotifyURL := c.baseURL + "me/player/previous" if opt != nil { @@ -439,7 +420,7 @@ func (c *Client) PreviousOpt(opt *PlayOptions) error { spotifyURL += "?" + params } } - req, err := http.NewRequest(http.MethodPost, spotifyURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, spotifyURL, nil) if err != nil { return err } @@ -457,15 +438,16 @@ func (c *Client) PreviousOpt(opt *PlayOptions) error { // will cause the player to start playing the next song. // // Requires the ScopeUserModifyPlaybackState in order to modify the player state -func (c *Client) Seek(position int) error { - return c.SeekOpt(position, nil) +func (c *Client) Seek(ctx context.Context, position int) error { + return c.SeekOpt(ctx, position, nil) } // SeekOpt is like Seek but with more options // // Only expects PlayOptions.DeviceID, all other options will be ignored -func (c *Client) SeekOpt(position int, opt *PlayOptions) error { +func (c *Client) SeekOpt(ctx context.Context, position int, opt *PlayOptions) error { return c.playerFuncWithOpt( + ctx, "me/player/seek", url.Values{ "position_ms": []string{strconv.FormatInt(int64(position), 10)}, @@ -479,15 +461,16 @@ func (c *Client) SeekOpt(position int, opt *PlayOptions) error { // Options are repeat-track, repeat-context, and off. // // Requires the ScopeUserModifyPlaybackState in order to modify the player state. -func (c *Client) Repeat(state string) error { - return c.RepeatOpt(state, nil) +func (c *Client) Repeat(ctx context.Context, state string) error { + return c.RepeatOpt(ctx, state, nil) } // RepeatOpt is like Repeat but with more options // // Only expects PlayOptions.DeviceID, all other options will be ignored. -func (c *Client) RepeatOpt(state string, opt *PlayOptions) error { +func (c *Client) RepeatOpt(ctx context.Context, state string, opt *PlayOptions) error { return c.playerFuncWithOpt( + ctx, "me/player/repeat", url.Values{ "state": []string{state}, @@ -501,15 +484,16 @@ func (c *Client) RepeatOpt(state string, opt *PlayOptions) error { // Percent is must be a value from 0 to 100 inclusive. // // Requires the ScopeUserModifyPlaybackState in order to modify the player state -func (c *Client) Volume(percent int) error { - return c.VolumeOpt(percent, nil) +func (c *Client) Volume(ctx context.Context, percent int) error { + return c.VolumeOpt(ctx, percent, nil) } // VolumeOpt is like Volume but with more options // // Only expects PlayOptions.DeviceID, all other options will be ignored -func (c *Client) VolumeOpt(percent int, opt *PlayOptions) error { +func (c *Client) VolumeOpt(ctx context.Context, percent int, opt *PlayOptions) error { return c.playerFuncWithOpt( + ctx, "me/player/volume", url.Values{ "volume_percent": []string{strconv.FormatInt(int64(percent), 10)}, @@ -521,15 +505,16 @@ func (c *Client) VolumeOpt(percent int, opt *PlayOptions) error { // Shuffle switches shuffle on or off for user's playback. // // Requires the ScopeUserModifyPlaybackState in order to modify the player state -func (c *Client) Shuffle(shuffle bool) error { - return c.ShuffleOpt(shuffle, nil) +func (c *Client) Shuffle(ctx context.Context, shuffle bool) error { + return c.ShuffleOpt(ctx, shuffle, nil) } // ShuffleOpt is like Shuffle but with more options // // Only expects PlayOptions.DeviceID, all other options will be ignored -func (c *Client) ShuffleOpt(shuffle bool, opt *PlayOptions) error { +func (c *Client) ShuffleOpt(ctx context.Context, shuffle bool, opt *PlayOptions) error { return c.playerFuncWithOpt( + ctx, "me/player/shuffle", url.Values{ "state": []string{strconv.FormatBool(shuffle)}, @@ -538,7 +523,7 @@ func (c *Client) ShuffleOpt(shuffle bool, opt *PlayOptions) error { ) } -func (c *Client) playerFuncWithOpt(urlSuffix string, values url.Values, opt *PlayOptions) error { +func (c *Client) playerFuncWithOpt(ctx context.Context, urlSuffix string, values url.Values, opt *PlayOptions) error { spotifyURL := c.baseURL + urlSuffix if opt != nil { @@ -551,7 +536,7 @@ func (c *Client) playerFuncWithOpt(urlSuffix string, values url.Values, opt *Pla spotifyURL += "?" + params } - req, err := http.NewRequest(http.MethodPut, spotifyURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, spotifyURL, nil) if err != nil { return err } diff --git a/player_test.go b/player_test.go index 9681772..9a8bb98 100644 --- a/player_test.go +++ b/player_test.go @@ -1,6 +1,7 @@ package spotify import ( + "context" "net/http" "testing" ) @@ -8,7 +9,7 @@ import ( func TestTransferPlaybackDeviceUnavailable(t *testing.T) { client, server := testClientString(http.StatusNotFound, "") defer server.Close() - err := client.TransferPlayback("newdevice", false) + err := client.TransferPlayback(context.Background(), "newdevice", false) if err == nil { t.Error("expected error since auto retry is disabled") } @@ -18,7 +19,7 @@ func TestTransferPlayback(t *testing.T) { client, server := testClientString(http.StatusNoContent, "") defer server.Close() - err := client.TransferPlayback("newdevice", true) + err := client.TransferPlayback(context.Background(), "newdevice", true) if err != nil { t.Error(err) } @@ -28,7 +29,7 @@ func TestVolume(t *testing.T) { client, server := testClientString(http.StatusNoContent, "") defer server.Close() - err := client.Volume(50) + err := client.Volume(context.Background(), 50) if err != nil { t.Error(err) } @@ -38,7 +39,7 @@ func TestQueue(t *testing.T) { client, server := testClientString(http.StatusNoContent, "") defer server.Close() - err := client.QueueSong("4JpKVNYnVcJ8tuMKjAj50A") + err := client.QueueSong(context.Background(), "4JpKVNYnVcJ8tuMKjAj50A") if err != nil { t.Error(err) } @@ -48,7 +49,7 @@ func TestPlayerDevices(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/player_available_devices.txt") defer server.Close() - list, err := client.PlayerDevices() + list, err := client.PlayerDevices(context.Background()) if err != nil { t.Error(err) return @@ -69,7 +70,7 @@ func TestPlayerState(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/player_state.txt") defer server.Close() - state, err := client.PlayerState() + state, err := client.PlayerState(context.Background()) if err != nil { t.Error(err) return @@ -100,7 +101,7 @@ func TestPlayerCurrentlyPlaying(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/player_currently_playing.txt") defer server.Close() - state, err := client.PlayerCurrentlyPlaying() + state, err := client.PlayerCurrentlyPlaying(context.Background()) if err != nil { t.Error(err) return @@ -131,7 +132,7 @@ func TestPlayerRecentlyPlayed(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/player_recently_played.txt") defer server.Close() - items, err := client.PlayerRecentlyPlayed() + items, err := client.PlayerRecentlyPlayed(context.Background()) if err != nil { t.Fatal(err) } @@ -158,7 +159,7 @@ func TestPlayArgsError(t *testing.T) { client, server := testClientString(http.StatusUnauthorized, json) defer server.Close() - err := client.Play() + err := client.Play(context.Background()) if err == nil { t.Error("Expected an error") } diff --git a/playlist.go b/playlist.go index cdb1bb6..ea4be90 100644 --- a/playlist.go +++ b/playlist.go @@ -2,12 +2,12 @@ package spotify import ( "bytes" + "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" - "net/url" "strconv" "strings" ) @@ -56,49 +56,12 @@ type FullPlaylist struct { Tracks PlaylistTrackPage `json:"tracks"` } -// PlaylistOptions contains optional parameters that can be used when querying -// for featured playlists. Only the non-nil fields are used in the request. -type PlaylistOptions struct { - Options - // The desired language, consisting of a lowercase IO 639 - // language code and an uppercase ISO 3166-1 alpha-2 - // country code, joined by an underscore. Provide this - // parameter if you want the results returned in a particular - // language. If not specified, the result will be returned - // in the Spotify default language (American English). - Locale *string - // A timestamp in ISO 8601 format (yyyy-MM-ddTHH:mm:ss). - // use this parameter to specify the user's local time to - // get results tailored for that specific date and time - // in the day. If not provided, the response defaults to - // the current UTC time. - Timestamp *string -} - // FeaturedPlaylistsOpt gets a list of playlists featured by Spotify. -// It accepts a number of optional parameters via the opt argument. -func (c *Client) FeaturedPlaylistsOpt(opt *PlaylistOptions) (message string, playlists *SimplePlaylistPage, e error) { +// Supported options: Locale, Country, Timestamp, Limit, Offset +func (c *Client) FeaturedPlaylists(ctx context.Context, opts ...RequestOption) (message string, playlists *SimplePlaylistPage, e error) { spotifyURL := c.baseURL + "browse/featured-playlists" - if opt != nil { - v := url.Values{} - if opt.Locale != nil { - v.Set("locale", *opt.Locale) - } - if opt.Country != nil { - v.Set("country", *opt.Country) - } - if opt.Timestamp != nil { - v.Set("timestamp", *opt.Timestamp) - } - if opt.Limit != nil { - v.Set("limit", strconv.Itoa(*opt.Limit)) - } - if opt.Offset != nil { - v.Set("offset", strconv.Itoa(*opt.Offset)) - } - if params := v.Encode(); params != "" { - spotifyURL += "?" + params - } + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } var result struct { @@ -106,7 +69,7 @@ func (c *Client) FeaturedPlaylistsOpt(opt *PlaylistOptions) (message string, pla Message string `json:"message"` } - err := c.get(spotifyURL, &result) + err := c.get(ctx, spotifyURL, &result) if err != nil { return "", nil, err } @@ -114,24 +77,18 @@ func (c *Client) FeaturedPlaylistsOpt(opt *PlaylistOptions) (message string, pla return result.Message, &result.Playlists, nil } -// FeaturedPlaylists gets a list of playlists featured by Spotify. -// It is equivalent to c.FeaturedPlaylistsOpt(nil). -func (c *Client) FeaturedPlaylists() (message string, playlists *SimplePlaylistPage, e error) { - return c.FeaturedPlaylistsOpt(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 owner and playlist ID. +// 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. -func (c *Client) FollowPlaylist(owner ID, playlist ID, public bool) error { - spotifyURL := buildFollowURI(c.baseURL, owner, 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.NewRequest("PUT", spotifyURL, body) + req, err := http.NewRequestWithContext(ctx, "PUT", spotifyURL, body) if err != nil { return err } @@ -146,9 +103,9 @@ func (c *Client) FollowPlaylist(owner ID, playlist ID, public bool) error { // 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. -func (c *Client) UnfollowPlaylist(owner, playlist ID) error { - spotifyURL := buildFollowURI(c.baseURL, owner, playlist) - req, err := http.NewRequest("DELETE", spotifyURL, nil) +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 } @@ -159,9 +116,9 @@ func (c *Client) UnfollowPlaylist(owner, playlist ID) error { return nil } -func buildFollowURI(url string, owner, playlist ID) string { - return fmt.Sprintf("%susers/%s/playlists/%s/followers", - url, string(owner), string(playlist)) +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 @@ -173,30 +130,17 @@ func buildFollowURI(url string, owner, playlist ID) string { // return collaborative playlists, even though they are always private. In // order to read collaborative playlists, the user must have granted the // ScopePlaylistReadCollaborative scope. -func (c *Client) GetPlaylistsForUser(userID string) (*SimplePlaylistPage, error) { - return c.GetPlaylistsForUserOpt(userID, nil) -} - -// GetPlaylistsForUserOpt is like PlaylistsForUser, but it accepts optional parameters -// for filtering the results. -func (c *Client) GetPlaylistsForUserOpt(userID string, opt *Options) (*SimplePlaylistPage, error) { +// +// Supported options: Limit, Offset +func (c *Client) GetPlaylistsForUser(ctx context.Context, userID string, opts ...RequestOption) (*SimplePlaylistPage, error) { spotifyURL := c.baseURL + "users/" + userID + "/playlists" - if opt != nil { - v := url.Values{} - if opt.Limit != nil { - v.Set("limit", strconv.Itoa(*opt.Limit)) - } - if opt.Offset != nil { - v.Set("offset", strconv.Itoa(*opt.Offset)) - } - if params := v.Encode(); params != "" { - spotifyURL += "?" + params - } + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } var result SimplePlaylistPage - err := c.get(spotifyURL, &result) + err := c.get(ctx, spotifyURL, &result) if err != nil { return nil, err } @@ -204,38 +148,17 @@ func (c *Client) GetPlaylistsForUserOpt(userID string, opt *Options) (*SimplePla return &result, err } -// GetPlaylist gets a playlist -func (c *Client) GetPlaylist(playlistID ID) (*FullPlaylist, error) { - return c.GetPlaylistOpt(playlistID, "") -} - -// GetPlaylistOpt is like GetPlaylist, but it accepts an optional fields parameter -// that can be used to filter the query. -// -// fields is a comma-separated list of the fields to return. -// See the JSON tags on the FullPlaylist struct for valid field options. -// For example, to get just the playlist's description and URI: -// fields = "description,uri" -// -// A dot separator can be used to specify non-reoccurring fields, while -// parentheses can be used to specify reoccurring fields within objects. -// For example, to get just the added date and the user ID of the adder: -// fields = "tracks.items(added_at,added_by.id)" -// -// Use multiple parentheses to drill down into nested objects, for example: -// fields = "tracks.items(track(name,href,album(name,href)))" -// -// Fields can be excluded by prefixing them with an exclamation mark, for example; -// fields = "tracks.items(track(name,href,album(!name,href)))" -func (c *Client) GetPlaylistOpt(playlistID ID, fields string) (*FullPlaylist, error) { +// GetPlaylist fetches a playlist from spotify. +// Supported options: Fields +func (c *Client) GetPlaylist(ctx context.Context, playlistID ID, opts ...RequestOption) (*FullPlaylist, error) { spotifyURL := fmt.Sprintf("%splaylists/%s", c.baseURL, playlistID) - if fields != "" { - spotifyURL += "?fields=" + url.QueryEscape(fields) + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } var playlist FullPlaylist - err := c.get(spotifyURL, &playlist) + err := c.get(ctx, spotifyURL, &playlist) if err != nil { return nil, err } @@ -245,54 +168,21 @@ func (c *Client) GetPlaylistOpt(playlistID ID, fields string) (*FullPlaylist, er // GetPlaylistTracks gets full details of the tracks in a playlist, given the // playlist's Spotify ID. -func (c *Client) GetPlaylistTracks(playlistID ID) (*PlaylistTrackPage, error) { - return c.GetPlaylistTracksOpt(playlistID, nil, "") -} - -// GetPlaylistTracksOpt is like GetPlaylistTracks, but it accepts optional parameters -// for sorting and filtering the results. -// -// The field parameter is a comma-separated list of the fields to return. See the -// JSON struct tags for the PlaylistTrackPage type for valid field names. -// For example, to get just the total number of tracks and the request limit: -// fields = "total,limit" // -// A dot separator can be used to specify non-reoccurring fields, while parentheses -// can be used to specify reoccurring fields within objects. For example, to get -// just the added date and user ID of the adder: -// fields = "items(added_at,added_by.id -// -// Use multiple parentheses to drill down into nested objects. For example: -// fields = "items(track(name,href,album(name,href)))" -// -// Fields can be excluded by prefixing them with an exclamation mark. For example: -// fields = "items.track.album(!external_urls,images)" -func (c *Client) GetPlaylistTracksOpt(playlistID ID, - opt *Options, fields string) (*PlaylistTrackPage, error) { - +// Supported options: Limit, Offset, Market, Fields +func (c *Client) GetPlaylistTracks( + ctx context.Context, + playlistID ID, + opts ...RequestOption, +) (*PlaylistTrackPage, error) { spotifyURL := fmt.Sprintf("%splaylists/%s/tracks", c.baseURL, playlistID) - v := url.Values{} - if fields != "" { - v.Set("fields", fields) - } - if opt != nil { - if opt.Limit != nil { - v.Set("limit", strconv.Itoa(*opt.Limit)) - } - if opt.Offset != nil { - v.Set("offset", strconv.Itoa(*opt.Offset)) - } - if opt.Country != nil { - v.Set("market", *opt.Country) - } - } - if params := v.Encode(); params != "" { + if params := processOptions(opts...).urlParams.Encode(); params != "" { spotifyURL += "?" + params } var result PlaylistTrackPage - err := c.get(spotifyURL, &result) + err := c.get(ctx, spotifyURL, &result) if err != nil { return nil, err } @@ -309,43 +199,7 @@ func (c *Client) GetPlaylistTracksOpt(playlistID ID, // creating a private playlist requires ScopePlaylistModifyPrivate. // // On success, the newly created playlist is returned. -// TODO Accept a collaborative parameter and delete -// CreateCollaborativePlaylistForUser. -func (c *Client) CreatePlaylistForUser(userID, playlistName, description string, public bool) (*FullPlaylist, error) { - spotifyURL := fmt.Sprintf("%susers/%s/playlists", c.baseURL, userID) - body := struct { - Name string `json:"name"` - Public bool `json:"public"` - Description string `json:"description"` - }{ - playlistName, - public, - description, - } - bodyJSON, err := json.Marshal(body) - if err != nil { - return nil, err - } - req, err := http.NewRequest("POST", spotifyURL, bytes.NewReader(bodyJSON)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - - var p FullPlaylist - err = c.execute(req, &p, http.StatusCreated) - if err != nil { - return nil, err - } - - return &p, err -} - -// CreateCollaborativePlaylistForUser creates a playlist for a Spotify user. -// A collaborative playlist is one that could have tracks added to and removed -// from by other Spotify users. -// Collaborative playlists must be private as per the Spotify API. -func (c *Client) CreateCollaborativePlaylistForUser(userID, playlistName, description string) (*FullPlaylist, error) { +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) body := struct { Name string `json:"name"` @@ -354,15 +208,15 @@ func (c *Client) CreateCollaborativePlaylistForUser(userID, playlistName, descri Collaborative bool `json:"collaborative"` }{ playlistName, - false, + public, description, - true, + collaborative, } bodyJSON, err := json.Marshal(body) if err != nil { return nil, err } - req, err := http.NewRequest("POST", spotifyURL, bytes.NewReader(bodyJSON)) + req, err := http.NewRequestWithContext(ctx, "POST", spotifyURL, bytes.NewReader(bodyJSON)) if err != nil { return nil, err } @@ -381,43 +235,43 @@ func (c *Client) CreateCollaborativePlaylistForUser(userID, playlistName, descri // user has authorized the ScopePlaylistModifyPublic or ScopePlaylistModifyPrivate // scopes (depending on whether the playlist is public or private). // The current user must own the playlist in order to modify it. -func (c *Client) ChangePlaylistName(playlistID ID, newName string) error { - return c.modifyPlaylist(playlistID, newName, "", nil) +func (c *Client) ChangePlaylistName(ctx context.Context, playlistID ID, newName string) error { + return c.modifyPlaylist(ctx, playlistID, newName, "", nil) } // ChangePlaylistAccess modifies the public/private status of a playlist. This call // requires that the user has authorized the ScopePlaylistModifyPublic or // ScopePlaylistModifyPrivate scopes (depending on whether the playlist is // currently public or private). The current user must own the playlist in order to modify it. -func (c *Client) ChangePlaylistAccess(playlistID ID, public bool) error { - return c.modifyPlaylist(playlistID, "", "", &public) +func (c *Client) ChangePlaylistAccess(ctx context.Context, playlistID ID, public bool) error { + return c.modifyPlaylist(ctx, playlistID, "", "", &public) } // ChangePlaylistDescription modifies the description of a playlist. This call // requires that the user has authorized the ScopePlaylistModifyPublic or // ScopePlaylistModifyPrivate scopes (depending on whether the playlist is // currently public or private). The current user must own the playlist in order to modify it. -func (c *Client) ChangePlaylistDescription(playlistID ID, newDescription string) error { - return c.modifyPlaylist(playlistID, "", newDescription, nil) +func (c *Client) ChangePlaylistDescription(ctx context.Context, playlistID ID, newDescription string) error { + return c.modifyPlaylist(ctx, playlistID, "", newDescription, nil) } // ChangePlaylistNameAndAccess combines ChangePlaylistName and ChangePlaylistAccess into // a single Web API call. It requires that the user has authorized the ScopePlaylistModifyPublic // or ScopePlaylistModifyPrivate scopes (depending on whether the playlist is currently // public or private). The current user must own the playlist in order to modify it. -func (c *Client) ChangePlaylistNameAndAccess(playlistID ID, newName string, public bool) error { - return c.modifyPlaylist(playlistID, newName, "", &public) +func (c *Client) ChangePlaylistNameAndAccess(ctx context.Context, playlistID ID, newName string, public bool) error { + return c.modifyPlaylist(ctx, playlistID, newName, "", &public) } // ChangePlaylistNameAccessAndDescription combines ChangePlaylistName, ChangePlaylistAccess, and // ChangePlaylistDescription into a single Web API call. It requires that the user has authorized // the ScopePlaylistModifyPublic or ScopePlaylistModifyPrivate scopes (depending on whether the // playlist is currently public or private). The current user must own the playlist in order to modify it. -func (c *Client) ChangePlaylistNameAccessAndDescription(playlistID ID, newName, newDescription string, public bool) error { - return c.modifyPlaylist(playlistID, newName, newDescription, &public) +func (c *Client) ChangePlaylistNameAccessAndDescription(ctx context.Context, playlistID ID, newName, newDescription string, public bool) error { + return c.modifyPlaylist(ctx, playlistID, newName, newDescription, &public) } -func (c *Client) modifyPlaylist(playlistID ID, newName, newDescription string, public *bool) error { +func (c *Client) modifyPlaylist(ctx context.Context, playlistID ID, newName, newDescription string, public *bool) error { body := struct { Name string `json:"name,omitempty"` Public *bool `json:"public,omitempty"` @@ -432,7 +286,7 @@ func (c *Client) modifyPlaylist(playlistID ID, newName, newDescription string, p return err } spotifyURL := fmt.Sprintf("%splaylists/%s", c.baseURL, string(playlistID)) - req, err := http.NewRequest("PUT", spotifyURL, bytes.NewReader(bodyJSON)) + req, err := http.NewRequestWithContext(ctx, "PUT", spotifyURL, bytes.NewReader(bodyJSON)) if err != nil { return err } @@ -449,8 +303,7 @@ func (c *Client) modifyPlaylist(playlistID ID, newName, newDescription string, p // A maximum of 100 tracks can be added per call. It returns a snapshot ID that // can be used to identify this version (the new version) of the playlist in // future requests. -func (c *Client) AddTracksToPlaylist(playlistID ID, trackIDs ...ID) (snapshotID string, err error) { - +func (c *Client) AddTracksToPlaylist(ctx context.Context, playlistID ID, trackIDs ...ID) (snapshotID string, err error) { uris := make([]string, len(trackIDs)) for i, id := range trackIDs { uris[i] = fmt.Sprintf("spotify:track:%s", id) @@ -464,7 +317,7 @@ func (c *Client) AddTracksToPlaylist(playlistID ID, trackIDs ...ID) (snapshotID if err != nil { return "", err } - req, err := http.NewRequest("POST", spotifyURL, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, "POST", spotifyURL, bytes.NewReader(body)) if err != nil { return "", err } @@ -489,8 +342,7 @@ func (c *Client) AddTracksToPlaylist(playlistID ID, trackIDs ...ID) (snapshotID // If the track(s) occur multiple times in the specified playlist, then all occurrences // of the track will be removed. If successful, the snapshot ID returned can be used to // identify the playlist version in future requests. -func (c *Client) RemoveTracksFromPlaylist(playlistID ID, trackIDs ...ID) (newSnapshotID string, err error) { - +func (c *Client) RemoveTracksFromPlaylist(ctx context.Context, playlistID ID, trackIDs ...ID) (newSnapshotID string, err error) { tracks := make([]struct { URI string `json:"uri"` }, len(trackIDs)) @@ -498,7 +350,7 @@ func (c *Client) RemoveTracksFromPlaylist(playlistID ID, trackIDs ...ID) (newSna for i, u := range trackIDs { tracks[i].URI = fmt.Sprintf("spotify:track:%s", u) } - return c.removeTracksFromPlaylist(playlistID, tracks, "") + return c.removeTracksFromPlaylist(ctx, playlistID, tracks, "") } // TrackToRemove specifies a track to be removed from a playlist. @@ -530,14 +382,20 @@ func NewTrackToRemove(trackID string, positions []int) TrackToRemove { // specified position is not found, the entire request will fail and no edits // will take place. (Note: the snapshot is optional, pass the empty string if // you don't care about it.) -func (c *Client) RemoveTracksFromPlaylistOpt(playlistID ID, - tracks []TrackToRemove, snapshotID string) (newSnapshotID string, err error) { +func (c *Client) RemoveTracksFromPlaylistOpt( + ctx context.Context, + playlistID ID, + tracks []TrackToRemove, + snapshotID string) (newSnapshotID string, err error) { - return c.removeTracksFromPlaylist(playlistID, tracks, snapshotID) + return c.removeTracksFromPlaylist(ctx, playlistID, tracks, snapshotID) } -func (c *Client) removeTracksFromPlaylist(playlistID ID, - tracks interface{}, snapshotID string) (newSnapshotID string, err error) { +func (c *Client) removeTracksFromPlaylist( + ctx context.Context, + playlistID ID, + tracks interface{}, + snapshotID string) (newSnapshotID string, err error) { m := make(map[string]interface{}) m["tracks"] = tracks @@ -551,7 +409,7 @@ func (c *Client) removeTracksFromPlaylist(playlistID ID, if err != nil { return "", err } - req, err := http.NewRequest("DELETE", spotifyURL, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, "DELETE", spotifyURL, bytes.NewReader(body)) if err != nil { return "", err } @@ -579,14 +437,14 @@ func (c *Client) removeTracksFromPlaylist(playlistID ID, // // A maximum of 100 tracks is permited in this call. Additional tracks must be // added via AddTracksToPlaylist. -func (c *Client) ReplacePlaylistTracks(playlistID ID, trackIDs ...ID) error { +func (c *Client) ReplacePlaylistTracks(ctx context.Context, playlistID ID, trackIDs ...ID) error { trackURIs := make([]string, len(trackIDs)) for i, u := range trackIDs { trackURIs[i] = fmt.Sprintf("spotify:track:%s", u) } spotifyURL := fmt.Sprintf("%splaylists/%s/tracks?uris=%s", c.baseURL, playlistID, strings.Join(trackURIs, ",")) - req, err := http.NewRequest("PUT", spotifyURL, nil) + req, err := http.NewRequestWithContext(ctx, "PUT", spotifyURL, nil) if err != nil { return err } @@ -604,13 +462,13 @@ func (c *Client) ReplacePlaylistTracks(playlistID ID, trackIDs ...ID) error { // 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. -func (c *Client) UserFollowsPlaylist(playlistID ID, userIDs ...string) ([]bool, error) { +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(spotifyURL, &follows) + err := c.get(ctx, spotifyURL, &follows) if err != nil { return nil, err } @@ -655,13 +513,13 @@ type PlaylistReorderOptions struct { // Reordering tracks in the current user's public playlist requires ScopePlaylistModifyPublic. // Reordering tracks in the user's private playlists (including collaborative playlists) requires // ScopePlaylistModifyPrivate. -func (c *Client) ReorderPlaylistTracks(playlistID ID, opt PlaylistReorderOptions) (snapshotID string, err error) { +func (c *Client) ReorderPlaylistTracks(ctx context.Context, playlistID ID, opt PlaylistReorderOptions) (snapshotID string, err error) { spotifyURL := fmt.Sprintf("%splaylists/%s/tracks", c.baseURL, playlistID) j, err := json.Marshal(opt) if err != nil { return "", err } - req, err := http.NewRequest("PUT", spotifyURL, bytes.NewReader(j)) + req, err := http.NewRequestWithContext(ctx, "PUT", spotifyURL, bytes.NewReader(j)) if err != nil { return "", err } @@ -681,7 +539,7 @@ func (c *Client) ReorderPlaylistTracks(playlistID ID, opt PlaylistReorderOptions // SetPlaylistImage replaces the image used to represent a playlist. // This action can only be performed by the owner of the playlist, // and requires ScopeImageUpload as well as ScopeModifyPlaylist{Public|Private}.. -func (c *Client) SetPlaylistImage(playlistID ID, img io.Reader) error { +func (c *Client) SetPlaylistImage(ctx context.Context, playlistID ID, img io.Reader) error { spotifyURL := fmt.Sprintf("%splaylists/%s/images", c.baseURL, playlistID) // data flow: // img (reader) -> copy into base64 encoder (writer) -> pipe (write end) @@ -690,11 +548,11 @@ func (c *Client) SetPlaylistImage(playlistID ID, img io.Reader) error { go func() { enc := base64.NewEncoder(base64.StdEncoding, w) _, err := io.Copy(enc, img) - enc.Close() - w.CloseWithError(err) + _ = enc.Close() + _ = w.CloseWithError(err) }() - req, err := http.NewRequest("PUT", spotifyURL, r) + req, err := http.NewRequestWithContext(ctx, "PUT", spotifyURL, r) if err != nil { return err } diff --git a/playlist_test.go b/playlist_test.go index be4a769..0db2207 100644 --- a/playlist_test.go +++ b/playlist_test.go @@ -2,10 +2,12 @@ package spotify import ( "bytes" + "context" "encoding/json" "fmt" "io/ioutil" "net/http" + "strings" "testing" "time" ) @@ -15,10 +17,8 @@ func TestFeaturedPlaylists(t *testing.T) { defer server.Close() country := "SE" - opt := PlaylistOptions{} - opt.Country = &country - msg, p, err := client.FeaturedPlaylistsOpt(&opt) + msg, p, err := client.FeaturedPlaylists(context.Background(), Country(country)) if err != nil { t.Error(err) return @@ -45,7 +45,7 @@ func TestFeaturedPlaylistsExpiredToken(t *testing.T) { client, server := testClientString(http.StatusUnauthorized, json) defer server.Close() - msg, pl, err := client.FeaturedPlaylists() + msg, pl, err := client.FeaturedPlaylists(context.Background()) if msg != "" || pl != nil || err == nil { t.Fatal("Expected an error") } @@ -62,7 +62,7 @@ func TestPlaylistsForUser(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/playlists_for_user.txt") defer server.Close() - playlists, err := client.GetPlaylistsForUser("whizler") + playlists, err := client.GetPlaylistsForUser(context.Background(), "whizler") if err != nil { t.Error(err) } @@ -83,7 +83,7 @@ func TestGetPlaylistOpt(t *testing.T) { defer server.Close() fields := "href,name,owner(!href,external_urls),tracks.items(added_by.id,track(name,href,album(name,href)))" - p, err := client.GetPlaylistOpt("59ZbFPES4DQwEjBpWHzrtC", fields) + p, err := client.GetPlaylist(context.Background(), "59ZbFPES4DQwEjBpWHzrtC", Fields(fields)) if err != nil { t.Error(err) } @@ -106,7 +106,7 @@ func TestFollowPlaylistSetsContentType(t *testing.T) { }) defer server.Close() - err := client.FollowPlaylist("ownerID", "playlistID", true) + err := client.FollowPlaylist(context.Background(), "playlistID", true) if err != nil { t.Error(err) } @@ -116,7 +116,7 @@ func TestGetPlaylistTracks(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/playlist_tracks.txt") defer server.Close() - tracks, err := client.GetPlaylistTracks("playlistID") + tracks, err := client.GetPlaylistTracks(context.Background(), "playlistID") if err != nil { t.Error(err) } @@ -145,7 +145,7 @@ func TestUserFollowsPlaylist(t *testing.T) { client, server := testClientString(http.StatusOK, `[ true, false ]`) defer server.Close() - follows, err := client.UserFollowsPlaylist(ID("2v3iNvBS8Ay1Gt2uXtUKUT"), "possan", "elogain") + follows, err := client.UserFollowsPlaylist(context.Background(), ID("2v3iNvBS8Ay1Gt2uXtUKUT"), "possan", "elogain") if err != nil { t.Error(err) } @@ -198,7 +198,7 @@ func TestCreatePlaylist(t *testing.T) { client, server := testClientString(http.StatusCreated, fmt.Sprintf(newPlaylist, false)) defer server.Close() - p, err := client.CreatePlaylistForUser("thelinmichael", "A New Playlist", "Test Description", false) + p, err := client.CreatePlaylistForUser(context.Background(), "thelinmichael", "A New Playlist", "Test Description", false, false) if err != nil { t.Error(err) } @@ -223,7 +223,7 @@ func TestCreateCollaborativePlaylist(t *testing.T) { client, server := testClientString(http.StatusCreated, fmt.Sprintf(newPlaylist, true)) defer server.Close() - p, err := client.CreateCollaborativePlaylistForUser("thelinmichael", "A New Playlist", "Test Description") + p, err := client.CreatePlaylistForUser(context.Background(), "thelinmichael", "A New Playlist", "Test Description", false, true) if err != nil { t.Error(err) } @@ -248,7 +248,7 @@ func TestRenamePlaylist(t *testing.T) { client, server := testClientString(http.StatusOK, "") defer server.Close() - if err := client.ChangePlaylistName(ID("playlist-id"), "new name"); err != nil { + if err := client.ChangePlaylistName(context.Background(), ID("playlist-id"), "new name"); err != nil { t.Error(err) } } @@ -257,7 +257,7 @@ func TestChangePlaylistAccess(t *testing.T) { client, server := testClientString(http.StatusOK, "") defer server.Close() - if err := client.ChangePlaylistAccess(ID("playlist-id"), true); err != nil { + if err := client.ChangePlaylistAccess(context.Background(), ID("playlist-id"), true); err != nil { t.Error(err) } } @@ -266,7 +266,7 @@ func TestChangePlaylistDescription(t *testing.T) { client, server := testClientString(http.StatusOK, "") defer server.Close() - if err := client.ChangePlaylistDescription(ID("playlist-id"), "new description"); err != nil { + if err := client.ChangePlaylistDescription(context.Background(), ID("playlist-id"), "new description"); err != nil { t.Error(err) } } @@ -275,7 +275,7 @@ func TestChangePlaylistNamdAndAccess(t *testing.T) { client, server := testClientString(http.StatusOK, "") defer server.Close() - if err := client.ChangePlaylistNameAndAccess(ID("playlist-id"), "new_name", true); err != nil { + if err := client.ChangePlaylistNameAndAccess(context.Background(), ID("playlist-id"), "new_name", true); err != nil { t.Error(err) } } @@ -284,7 +284,7 @@ func TestChangePlaylistNamdAccessAndDescription(t *testing.T) { client, server := testClientString(http.StatusOK, "") defer server.Close() - if err := client.ChangePlaylistNameAccessAndDescription(ID("playlist-id"), "new_name", "new description", true); err != nil { + if err := client.ChangePlaylistNameAccessAndDescription(context.Background(), ID("playlist-id"), "new_name", "new description", true); err != nil { t.Error(err) } } @@ -293,7 +293,7 @@ func TestChangePlaylistNameFailure(t *testing.T) { client, server := testClientString(http.StatusForbidden, "") defer server.Close() - if err := client.ChangePlaylistName(ID("playlist-id"), "new_name"); err == nil { + if err := client.ChangePlaylistName(context.Background(), ID("playlist-id"), "new_name"); err == nil { t.Error("Expected error but didn't get one") } } @@ -302,7 +302,7 @@ func TestAddTracksToPlaylist(t *testing.T) { client, server := testClientString(http.StatusCreated, `{ "snapshot_id" : "JbtmHBDBAYu3/bt8BOXKjzKx3i0b6LCa/wVjyl6qQ2Yf6nFXkbmzuEa+ZI/U1yF+" }`) defer server.Close() - snapshot, err := client.AddTracksToPlaylist(ID("playlist_id"), ID("track1"), ID("track2")) + snapshot, err := client.AddTracksToPlaylist(context.Background(), ID("playlist_id"), ID("track1"), ID("track2")) if err != nil { t.Error(err) } @@ -342,7 +342,7 @@ func TestRemoveTracksFromPlaylist(t *testing.T) { }) defer server.Close() - snapshotID, err := client.RemoveTracksFromPlaylist("playlistID", "track1", "track2") + snapshotID, err := client.RemoveTracksFromPlaylist(context.Background(), "playlistID", "track1", "track2") if err != nil { t.Error(err) } @@ -390,7 +390,7 @@ func TestRemoveTracksFromPlaylistOpt(t *testing.T) { NewTrackToRemove("track2", []int{8}), } // intentionally not passing a snapshot ID here - snapshotID, err := client.RemoveTracksFromPlaylistOpt("playlistID", tracks, "") + snapshotID, err := client.RemoveTracksFromPlaylistOpt(context.Background(), "playlistID", tracks, "") if err != nil || snapshotID != "JbtmHBDBAYu3/bt8BOXKjzKx3i0b6LCa/wVjyl6qQ2Yf6nFXkbmzuEa+ZI/U1yF+" { t.Fatal("Remove call failed. err=", err) } @@ -400,7 +400,7 @@ func TestReplacePlaylistTracks(t *testing.T) { client, server := testClientString(http.StatusCreated, "") defer server.Close() - err := client.ReplacePlaylistTracks("playlistID", "track1", "track2") + err := client.ReplacePlaylistTracks(context.Background(), "playlistID", "track1", "track2") if err != nil { t.Error(err) } @@ -410,7 +410,7 @@ func TestReplacePlaylistTracksForbidden(t *testing.T) { client, server := testClientString(http.StatusForbidden, "") defer server.Close() - err := client.ReplacePlaylistTracks("playlistID", "track1", "track2") + err := client.ReplacePlaylistTracks(context.Background(), "playlistID", "track1", "track2") if err == nil { t.Error("Replace succeeded but shouldn't have") } @@ -427,7 +427,10 @@ func TestReorderPlaylistRequest(t *testing.T) { // unmarshal the JSON into a map[string]interface{} // so we can test for existence of certain keys var body map[string]interface{} - json.NewDecoder(req.Body).Decode(&body) + err := json.NewDecoder(req.Body).Decode(&body) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } if start, ok := body["range_start"]; ok { if start != float64(3) { @@ -454,10 +457,13 @@ func TestReorderPlaylistRequest(t *testing.T) { }) defer server.Close() - client.ReorderPlaylistTracks("playlist", PlaylistReorderOptions{ + _, err := client.ReorderPlaylistTracks(context.Background(), "playlist", PlaylistReorderOptions{ RangeStart: 3, InsertBefore: 8, }) + if err == nil || !strings.Contains(err.Error(), "HTTP 404: Not Found") { + t.Errorf("Expected error 'spotify: HTTP 404: Not Found (body empty)', got %v", err) + } } func TestSetPlaylistImage(t *testing.T) { @@ -479,7 +485,7 @@ func TestSetPlaylistImage(t *testing.T) { }) defer server.Close() - err := client.SetPlaylistImage("playlist", bytes.NewReader([]byte("foo"))) + err := client.SetPlaylistImage(context.Background(), "playlist", bytes.NewReader([]byte("foo"))) if err != nil { t.Fatal(err) } diff --git a/recommendation.go b/recommendation.go index 2d9fa7b..a205bb0 100644 --- a/recommendation.go +++ b/recommendation.go @@ -1,6 +1,7 @@ package spotify import ( + "context" "fmt" "net/url" "strconv" @@ -72,8 +73,10 @@ func setTrackAttributesValues(trackAttributes *TrackAttributes, values url.Value // about the provided seeds, a list of tracks will be returned together with pool size details. // For artists and tracks that are very new or obscure // there might not be enough data to generate a list of tracks. -func (c *Client) GetRecommendations(seeds Seeds, trackAttributes *TrackAttributes, opt *Options) (*Recommendations, error) { - v := url.Values{} +// +// Supported options: Limit, Country +func (c *Client) GetRecommendations(ctx context.Context, seeds Seeds, trackAttributes *TrackAttributes, opts ...RequestOption) (*Recommendations, error) { + v := processOptions(opts...).urlParams if seeds.count() == 0 { return nil, fmt.Errorf("spotify: at least one seed is required") @@ -85,19 +88,10 @@ func (c *Client) GetRecommendations(seeds Seeds, trackAttributes *TrackAttribute setSeedValues(seeds, v) setTrackAttributesValues(trackAttributes, v) - if opt != nil { - if opt.Limit != nil { - v.Set("limit", strconv.Itoa(*opt.Limit)) - } - if opt.Country != nil { - v.Set("market", *opt.Country) - } - } - spotifyURL := c.baseURL + "recommendations?" + v.Encode() var recommendations Recommendations - err := c.get(spotifyURL, &recommendations) + err := c.get(ctx, spotifyURL, &recommendations) if err != nil { return nil, err } @@ -107,12 +101,12 @@ func (c *Client) GetRecommendations(seeds Seeds, trackAttributes *TrackAttribute // GetAvailableGenreSeeds retrieves a list of available genres seed parameter values for // recommendations. -func (c *Client) GetAvailableGenreSeeds() ([]string, error) { +func (c *Client) GetAvailableGenreSeeds(ctx context.Context) ([]string, error) { spotifyURL := c.baseURL + "recommendations/available-genre-seeds" genreSeeds := make(map[string][]string) - err := c.get(spotifyURL, &genreSeeds) + err := c.get(ctx, spotifyURL, &genreSeeds) if err != nil { return nil, err } diff --git a/recommendation_test.go b/recommendation_test.go index bd4f953..2d90a29 100644 --- a/recommendation_test.go +++ b/recommendation_test.go @@ -1,6 +1,7 @@ package spotify import ( + "context" "net/url" "testing" ) @@ -17,11 +18,7 @@ func TestGetRecommendations(t *testing.T) { } country := "ES" limit := 10 - opts := Options{ - Country: &country, - Limit: &limit, - } - recommendations, err := client.GetRecommendations(seeds, nil, &opts) + recommendations, err := client.GetRecommendations(context.Background(), seeds, nil, Country(country), Limit(limit)) if err != nil { t.Fatal(err) } diff --git a/request_options.go b/request_options.go new file mode 100644 index 0000000..8da92fa --- /dev/null +++ b/request_options.go @@ -0,0 +1,122 @@ +package spotify + +import ( + "net/url" + "strconv" +) + +type RequestOption func(*requestOptions) + +type requestOptions struct { + urlParams url.Values +} + +// Limit sets the number of entries that a request should return +func Limit(amount int) RequestOption { + return func(o *requestOptions) { + o.urlParams.Set("limit", strconv.Itoa(amount)) + } +} + +// Market enables track re-linking +func Market(code string) RequestOption { + return func(o *requestOptions) { + o.urlParams.Set("market", code) + } +} + +// Country enables a specific region to be specified for region-specific suggestions e.g popular playlists +// The Country option takes an ISO 3166-1 alpha-2 country code. It can be +// used to ensure that the category exists for a particular country. +func Country(code string) RequestOption { + return func(o *requestOptions) { + o.urlParams.Set("country", code) + } +} + +// Locale enables a specific language to be used when returning results. +// The Locale argument is an ISO 639 language code and an ISO 3166-1 alpha-2 +// country code, separated by an underscore. It can be used to get the +// category strings in a particular language (for example: "es_MX" means +// get categories in Mexico, returned in Spanish). +func Locale(code string) RequestOption { + return func(o *requestOptions) { + o.urlParams.Set("locale", code) + } +} + +// Offset sets the index of the first entry to return +func Offset(amount int) RequestOption { + return func(o *requestOptions) { + o.urlParams.Set("offset", strconv.Itoa(amount)) + } +} + +// Timestamp in ISO 8601 format (yyyy-MM-ddTHH:mm:ss). +// use this parameter to specify the user's local time to +// get results tailored for that specific date and time +// in the day. If not provided, the response defaults to +// the current UTC time. +func Timestamp(ts string) RequestOption { + return func(o *requestOptions) { + o.urlParams.Set("timestamp", ts) + } +} + +// After is the last ID retrieved from the previous request. This allows pagination. +func After(after string) RequestOption { + return func(o *requestOptions) { + o.urlParams.Set("after", after) + } +} + +// Fields is a comma-separated list of the fields to return. +// See the JSON tags on the FullPlaylist struct for valid field options. +// For example, to get just the playlist's description and URI: +// fields = "description,uri" +// +// A dot separator can be used to specify non-reoccurring fields, while +// parentheses can be used to specify reoccurring fields within objects. +// For example, to get just the added date and the user ID of the adder: +// fields = "tracks.items(added_at,added_by.id)" +// +// Use multiple parentheses to drill down into nested objects, for example: +// fields = "tracks.items(track(name,href,album(name,href)))" +// +// Fields can be excluded by prefixing them with an exclamation mark, for example; +// fields = "tracks.items(track(name,href,album(!name,href)))" +func Fields(fields string) RequestOption { + return func(o *requestOptions) { + o.urlParams.Set("fields", fields) + } +} + +type Range string + +const ( + // LongTermRange is calculated from several years of data, including new data where possible + LongTermRange Range = "long_term" + // MediumTermRange is approximately the last six months + MediumTermRange Range = "medium_term" + // ShortTermRange is approximately the last four weeks + ShortTermRange Range = "short_term" +) + +// Timerange sets the time period that spoty will use when returning information. Use LongTermRange, MediumTermRange +// and ShortTermRange to set the appropriate period. +func Timerange(timerange Range) RequestOption { + return func(o *requestOptions) { + o.urlParams.Set("time_range", string(timerange)) + } +} + +func processOptions(options ...RequestOption) requestOptions { + o := requestOptions{ + urlParams: url.Values{}, + } + for _, opt := range options { + opt(&o) + } + + return o +} diff --git a/request_options_test.go b/request_options_test.go new file mode 100644 index 0000000..cfdb8a7 --- /dev/null +++ b/request_options_test.go @@ -0,0 +1,26 @@ +package spotify + +import ( + "testing" +) + +func TestOptions(t *testing.T) { + t.Parallel() + + resultSet := processOptions( + After("example_id"), + Country(CountryUnitedKingdom), + Limit(13), + Locale("en_GB"), + Market(CountryArgentina), + Offset(1), + Timerange("long"), + Timestamp("2000-11-02T13:37:00"), + ) + + expected := "after=example_id&country=GB&limit=13&locale=en_GB&market=AR&offset=1&time_range=long×tamp=2000-11-02T13%3A37%3A00" + actual := resultSet.urlParams.Encode() + if actual != expected { + t.Errorf("Expected '%v', got '%v'", expected, actual) + } +} diff --git a/search.go b/search.go index a971496..b818bda 100644 --- a/search.go +++ b/search.go @@ -1,8 +1,7 @@ package spotify import ( - "net/url" - "strconv" + "context" "strings" ) @@ -101,41 +100,25 @@ type SearchResult struct { // // Other possible field filters, depending on object types being searched, // include "genre", "upc", and "isrc". For example "damian genre:reggae-pop". -func (c *Client) Search(query string, t SearchType) (*SearchResult, error) { - return c.SearchOpt(query, t, nil) -} - -// SearchOpt works just like Search, but it accepts additional -// parameters for filtering the output. See the documentation for Search more -// more information. // -// If the Country field is specified in the options, then the results will only +// If the Market field is specified in the options, then the results will only // contain artists, albums, and tracks playable in the specified country -// (playlist results are not affected by the Country option). Additionally, +// (playlist results are not affected by the Market option). Additionally, // the constant MarketFromToken can be used with authenticated clients. // If the client has a valid access token, then the results will only include // content playable in the user's country. -func (c *Client) SearchOpt(query string, t SearchType, opt *Options) (*SearchResult, error) { - v := url.Values{} +// +// Limit, Market and Offset request options are supported +func (c *Client) Search(ctx context.Context, query string, t SearchType, opts ...RequestOption) (*SearchResult, error) { + v := processOptions(opts...).urlParams v.Set("q", query) v.Set("type", t.encode()) - if opt != nil { - if opt.Limit != nil { - v.Set("limit", strconv.Itoa(*opt.Limit)) - } - if opt.Country != nil { - v.Set("market", *opt.Country) - } - if opt.Offset != nil { - v.Set("offset", strconv.Itoa(*opt.Offset)) - } - } spotifyURL := c.baseURL + "search?" + v.Encode() var result SearchResult - err := c.get(spotifyURL, &result) + err := c.get(ctx, spotifyURL, &result) if err != nil { return nil, err } @@ -144,65 +127,65 @@ func (c *Client) SearchOpt(query string, t SearchType, opt *Options) (*SearchRes } // NextArtistResults loads the next page of artists into the specified search result. -func (c *Client) NextArtistResults(s *SearchResult) error { +func (c *Client) NextArtistResults(ctx context.Context, s *SearchResult) error { if s.Artists == nil || s.Artists.Next == "" { return ErrNoMorePages } - return c.get(s.Artists.Next, s) + return c.get(ctx, s.Artists.Next, s) } // PreviousArtistResults loads the previous page of artists into the specified search result. -func (c *Client) PreviousArtistResults(s *SearchResult) error { +func (c *Client) PreviousArtistResults(ctx context.Context, s *SearchResult) error { if s.Artists == nil || s.Artists.Previous == "" { return ErrNoMorePages } - return c.get(s.Artists.Previous, s) + return c.get(ctx, s.Artists.Previous, s) } // NextAlbumResults loads the next page of albums into the specified search result. -func (c *Client) NextAlbumResults(s *SearchResult) error { +func (c *Client) NextAlbumResults(ctx context.Context, s *SearchResult) error { if s.Albums == nil || s.Albums.Next == "" { return ErrNoMorePages } - return c.get(s.Albums.Next, s) + return c.get(ctx, s.Albums.Next, s) } // PreviousAlbumResults loads the previous page of albums into the specified search result. -func (c *Client) PreviousAlbumResults(s *SearchResult) error { +func (c *Client) PreviousAlbumResults(ctx context.Context, s *SearchResult) error { if s.Albums == nil || s.Albums.Previous == "" { return ErrNoMorePages } - return c.get(s.Albums.Previous, s) + return c.get(ctx, s.Albums.Previous, s) } // NextPlaylistResults loads the next page of playlists into the specified search result. -func (c *Client) NextPlaylistResults(s *SearchResult) error { +func (c *Client) NextPlaylistResults(ctx context.Context, s *SearchResult) error { if s.Playlists == nil || s.Playlists.Next == "" { return ErrNoMorePages } - return c.get(s.Playlists.Next, s) + return c.get(ctx, s.Playlists.Next, s) } // PreviousPlaylistResults loads the previous page of playlists into the specified search result. -func (c *Client) PreviousPlaylistResults(s *SearchResult) error { +func (c *Client) PreviousPlaylistResults(ctx context.Context, s *SearchResult) error { if s.Playlists == nil || s.Playlists.Previous == "" { return ErrNoMorePages } - return c.get(s.Playlists.Previous, s) + return c.get(ctx, s.Playlists.Previous, s) } // PreviousTrackResults loads the previous page of tracks into the specified search result. -func (c *Client) PreviousTrackResults(s *SearchResult) error { +func (c *Client) PreviousTrackResults(ctx context.Context, s *SearchResult) error { if s.Tracks == nil || s.Tracks.Previous == "" { return ErrNoMorePages } - return c.get(s.Tracks.Previous, s) + return c.get(ctx, s.Tracks.Previous, s) } // NextTrackResults loads the next page of tracks into the specified search result. -func (c *Client) NextTrackResults(s *SearchResult) error { +func (c *Client) NextTrackResults(ctx context.Context, s *SearchResult) error { if s.Tracks == nil || s.Tracks.Next == "" { return ErrNoMorePages } - return c.get(s.Tracks.Next, s) + return c.get(ctx, s.Tracks.Next, s) } diff --git a/search_test.go b/search_test.go index 9d4e0b8..98dc31f 100644 --- a/search_test.go +++ b/search_test.go @@ -1,6 +1,7 @@ package spotify import ( + "context" "net/http" "testing" ) @@ -9,7 +10,7 @@ func TestSearchArtist(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/search_artist.txt") defer server.Close() - result, err := client.Search("tania bowra", SearchTypeArtist) + result, err := client.Search(context.Background(), "tania bowra", SearchTypeArtist) if err != nil { t.Error(err) } @@ -34,7 +35,7 @@ func TestSearchTracks(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/search_tracks.txt") defer server.Close() - result, err := client.Search("uptown", SearchTypeTrack) + result, err := client.Search(context.Background(), "uptown", SearchTypeTrack) if err != nil { t.Error(err) } @@ -59,7 +60,7 @@ func TestSearchPlaylistTrack(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/search_trackplaylist.txt") defer server.Close() - result, err := client.Search("holiday", SearchTypePlaylist|SearchTypeTrack) + result, err := client.Search(context.Background(), "holiday", SearchTypePlaylist|SearchTypeTrack) if err != nil { t.Error(err) } @@ -86,16 +87,16 @@ func TestPrevNextSearchPageErrors(t *testing.T) { // 1) there are no results (nil) nilResults := &SearchResult{nil, nil, nil, nil} - if client.NextAlbumResults(nilResults) != ErrNoMorePages || - client.NextArtistResults(nilResults) != ErrNoMorePages || - client.NextPlaylistResults(nilResults) != ErrNoMorePages || - client.NextTrackResults(nilResults) != ErrNoMorePages { + if client.NextAlbumResults(context.Background(), nilResults) != ErrNoMorePages || + client.NextArtistResults(context.Background(), nilResults) != ErrNoMorePages || + client.NextPlaylistResults(context.Background(), nilResults) != ErrNoMorePages || + client.NextTrackResults(context.Background(), nilResults) != ErrNoMorePages { t.Error("Next search result page should have failed for nil results") } - if client.PreviousAlbumResults(nilResults) != ErrNoMorePages || - client.PreviousArtistResults(nilResults) != ErrNoMorePages || - client.PreviousPlaylistResults(nilResults) != ErrNoMorePages || - client.PreviousTrackResults(nilResults) != ErrNoMorePages { + if client.PreviousAlbumResults(context.Background(), nilResults) != ErrNoMorePages || + client.PreviousArtistResults(context.Background(), nilResults) != ErrNoMorePages || + client.PreviousPlaylistResults(context.Background(), nilResults) != ErrNoMorePages || + client.PreviousTrackResults(context.Background(), nilResults) != ErrNoMorePages { t.Error("Previous search result page should have failed for nil results") } // 2) the prev/next URL is empty @@ -105,16 +106,16 @@ func TestPrevNextSearchPageErrors(t *testing.T) { Playlists: new(SimplePlaylistPage), Tracks: new(FullTrackPage), } - if client.NextAlbumResults(emptyURL) != ErrNoMorePages || - client.NextArtistResults(emptyURL) != ErrNoMorePages || - client.NextPlaylistResults(emptyURL) != ErrNoMorePages || - client.NextTrackResults(emptyURL) != ErrNoMorePages { + if client.NextAlbumResults(context.Background(), emptyURL) != ErrNoMorePages || + client.NextArtistResults(context.Background(), emptyURL) != ErrNoMorePages || + client.NextPlaylistResults(context.Background(), emptyURL) != ErrNoMorePages || + client.NextTrackResults(context.Background(), emptyURL) != ErrNoMorePages { t.Error("Next search result page should have failed with empty URL") } - if client.PreviousAlbumResults(emptyURL) != ErrNoMorePages || - client.PreviousArtistResults(emptyURL) != ErrNoMorePages || - client.PreviousPlaylistResults(emptyURL) != ErrNoMorePages || - client.PreviousTrackResults(emptyURL) != ErrNoMorePages { + if client.PreviousAlbumResults(context.Background(), emptyURL) != ErrNoMorePages || + client.PreviousArtistResults(context.Background(), emptyURL) != ErrNoMorePages || + client.PreviousPlaylistResults(context.Background(), emptyURL) != ErrNoMorePages || + client.PreviousTrackResults(context.Background(), emptyURL) != ErrNoMorePages { t.Error("Previous search result page should have failed with empty URL") } } diff --git a/show.go b/show.go index d90f97e..b3a5955 100644 --- a/show.go +++ b/show.go @@ -1,7 +1,7 @@ package spotify import ( - "net/url" + "context" "strconv" "strings" "time" @@ -170,27 +170,16 @@ func (e *EpisodePage) ReleaseDateTime() time.Time { // GetShow retrieves information about a specific show. // API reference: https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-a-show -func (c *Client) GetShow(id string) (*FullShow, error) { - return c.GetShowOpt(nil, id) -} - -// GetShowOpt is like GetShow while supporting an optional market parameter. -// API reference: https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-a-show -func (c *Client) GetShowOpt(opt *Options, id string) (*FullShow, error) { - spotifyURL := c.baseURL + "shows/" + id - if opt != nil { - v := url.Values{} - if opt.Country != nil { - v.Set("market", *opt.Country) - } - if params := v.Encode(); params != "" { - spotifyURL += "?" + params - } +// Supported options: Market +func (c *Client) GetShow(ctx context.Context, id ID, opts ...RequestOption) (*FullShow, error) { + spotifyURL := c.baseURL + "shows/" + string(id) + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } var result FullShow - err := c.get(spotifyURL, &result) + err := c.get(ctx, spotifyURL, &result) if err != nil { return nil, err } @@ -198,35 +187,18 @@ func (c *Client) GetShowOpt(opt *Options, id string) (*FullShow, error) { return &result, nil } -// GetShowEpisodes retrieves paginated episode information about a specific show. -// API reference: https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-a-shows-episodes -func (c *Client) GetShowEpisodes(id string) (*SimpleEpisodePage, error) { - return c.GetShowEpisodesOpt(nil, id) -} - -// GetShowEpisodesOpt is like GetShowEpisodes while supporting optional market, limit, offset parameters. +// GetShowEpisodes retrieves paginated episode information about a specific show.. // API reference: https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-a-shows-episodes -func (c *Client) GetShowEpisodesOpt(opt *Options, id string) (*SimpleEpisodePage, error) { +// Supported options: Market, Limit, Offset +func (c *Client) GetShowEpisodes(ctx context.Context, id string, opts ...RequestOption) (*SimpleEpisodePage, error) { spotifyURL := c.baseURL + "shows/" + id + "/episodes" - if opt != nil { - v := url.Values{} - if opt.Country != nil { - v.Set("market", *opt.Country) - } - if opt.Limit != nil { - v.Set("limit", strconv.Itoa(*opt.Limit)) - } - if opt.Offset != nil { - v.Set("offset", strconv.Itoa(*opt.Offset)) - } - if params := v.Encode(); params != "" { - spotifyURL += "?" + params - } + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } var result SimpleEpisodePage - err := c.get(spotifyURL, &result) + err := c.get(ctx, spotifyURL, &result) if err != nil { return nil, err } diff --git a/show_test.go b/show_test.go index 8c8e9d7..a730b0e 100644 --- a/show_test.go +++ b/show_test.go @@ -1,6 +1,7 @@ package spotify import ( + "context" "net/http" "testing" ) @@ -9,7 +10,7 @@ func TestGetShow(t *testing.T) { c, s := testClientFile(http.StatusOK, "test_data/get_show.txt") defer s.Close() - r, err := c.GetShow("1234") + r, err := c.GetShow(context.Background(), "1234") if err != nil { t.Fatal(err) } @@ -25,7 +26,7 @@ func TestGetShowEpisodes(t *testing.T) { c, s := testClientFile(http.StatusOK, "test_data/get_show_episodes.txt") defer s.Close() - r, err := c.GetShowEpisodes("1234") + r, err := c.GetShowEpisodes(context.Background(),"1234") if err != nil { t.Fatal(err) } diff --git a/spotify.go b/spotify.go index 05e0001..30d515e 100644 --- a/spotify.go +++ b/spotify.go @@ -4,13 +4,13 @@ package spotify import ( "bytes" + "context" "encoding/json" "errors" "fmt" "io" "io/ioutil" "net/http" - "net/url" "strconv" "time" ) @@ -38,26 +38,54 @@ const ( rateLimitExceededStatusCode = 429 ) -const baseAddress = "https://api.spotify.com/v1/" - // Client is a client for working with the Spotify Web API. -// It is created by `NewClient` and `Authenticator.NewClient`. +// It is best to create this using spotify.New() type Client struct { http *http.Client baseURL string - AutoRetry bool - AcceptLanguage string + autoRetry bool + acceptLanguage string +} + +type ClientOption func(client *Client) + +// WithRetry configures the Spotify API client to automatically retry requests that fail due to ratelimiting. +func WithRetry(shouldRetry bool) ClientOption { + return func(client *Client) { + client.autoRetry = shouldRetry + } +} + +// WithBaseURL provides an alternative base url to use for requests to the Spotify API. This can be used to connect to a +// staging or other alternative environment. +func WithBaseURL(url string) ClientOption { + return func(client *Client) { + client.baseURL = url + } +} + +// WithAcceptLanguage configures the client to provide the accept language header on all requests. +func WithAcceptLanguage(lang string) ClientOption { + return func(client *Client) { + client.acceptLanguage = lang + } } -// NewClient returns a client for working with the Spotify Web API. -// The provided HTTP client must include the user's access token in each request; -// if you do not have such a client, use the `Authenticator.NewClient` method instead. -func NewClient(client *http.Client) Client { - return Client{ - http: client, - baseURL: baseAddress, +// New returns a client for working with the Spotify Web API. +// The provided httpClient must provide Authentication with the requests. +// The auth package may be used to generate a suitable client. +func New(httpClient *http.Client, opts ...ClientOption) *Client { + c := &Client{ + http: httpClient, + baseURL: "https://api.spotify.com/v1/", + } + + for _, opt := range opts { + opt(c) } + + return c } // URI identifies an artist, album, track, or category. For example, @@ -174,8 +202,8 @@ func isFailure(code int, validCodes []int) bool { // status codes that will be treated as success. Note that we allow all 200s // even if there are additional success codes that represent success. func (c *Client) execute(req *http.Request, result interface{}, needsStatus ...int) error { - if c.AcceptLanguage != "" { - req.Header.Set("Accept-Language", c.AcceptLanguage) + if c.acceptLanguage != "" { + req.Header.Set("Accept-Language", c.acceptLanguage) } for { resp, err := c.http.Do(req) @@ -184,7 +212,7 @@ func (c *Client) execute(req *http.Request, result interface{}, needsStatus ...i } defer resp.Body.Close() - if c.AutoRetry && shouldRetry(resp.StatusCode) { + if c.autoRetry && shouldRetry(resp.StatusCode) { time.Sleep(retryDuration(resp)) continue } @@ -219,11 +247,11 @@ func retryDuration(resp *http.Response) time.Duration { return time.Duration(seconds) * time.Second } -func (c *Client) get(url string, result interface{}) error { +func (c *Client) get(ctx context.Context, url string, result interface{}) error { for { - req, err := http.NewRequest("GET", url, nil) - if c.AcceptLanguage != "" { - req.Header.Set("Accept-Language", c.AcceptLanguage) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if c.acceptLanguage != "" { + req.Header.Set("Accept-Language", c.acceptLanguage) } if err != nil { return err @@ -235,7 +263,7 @@ func (c *Client) get(url string, result interface{}) error { defer resp.Body.Close() - if resp.StatusCode == rateLimitExceededStatusCode && c.AutoRetry { + if resp.StatusCode == rateLimitExceededStatusCode && c.autoRetry { time.Sleep(retryDuration(resp)) continue } @@ -257,48 +285,16 @@ func (c *Client) get(url string, result interface{}) error { return nil } -// Options contains optional parameters that can be provided -// to various API calls. Only the non-nil fields are used -// in queries. -type Options struct { - // Country is an ISO 3166-1 alpha-2 country code. Provide - // this parameter if you want the list of returned items to - // be relevant to a particular country. If omitted, the - // results will be relevant to all countries. - Country *string - // Limit is the maximum number of items to return. - Limit *int - // Offset is the index of the first item to return. Use it - // with Limit to get the next set of items. - Offset *int - // Timerange is the period of time from which to return results - // in certain API calls. The three options are the following string - // literals: "short", "medium", and "long" - Timerange *string -} - -// NewReleasesOpt is like NewReleases, but it accepts optional parameters -// for filtering the results. -func (c *Client) NewReleasesOpt(opt *Options) (albums *SimpleAlbumPage, err 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 opt != nil { - v := url.Values{} - if opt.Country != nil { - v.Set("country", *opt.Country) - } - if opt.Limit != nil { - v.Set("limit", strconv.Itoa(*opt.Limit)) - } - if opt.Offset != nil { - v.Set("offset", strconv.Itoa(*opt.Offset)) - } - if params := v.Encode(); params != "" { - spotifyURL += "?" + params - } + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } var objmap map[string]*json.RawMessage - err = c.get(spotifyURL, &objmap) + err = c.get(ctx, spotifyURL, &objmap) if err != nil { return nil, err } @@ -311,9 +307,3 @@ func (c *Client) NewReleasesOpt(opt *Options) (albums *SimpleAlbumPage, err erro return &result, nil } - -// NewReleases gets a list of new album releases featured in Spotify. -// This call requires bearer authorization. -func (c *Client) NewReleases() (albums *SimpleAlbumPage, err error) { - return c.NewReleasesOpt(nil) -} diff --git a/spotify_test.go b/spotify_test.go index e184d6e..7b69907 100644 --- a/spotify_test.go +++ b/spotify_test.go @@ -1,6 +1,7 @@ package spotify import ( + "context" "io" "net/http" "net/http/httptest" @@ -15,7 +16,7 @@ func testClient(code int, body io.Reader, validators ...func(*http.Request)) (*C v(r) } w.WriteHeader(code) - io.Copy(w, body) + _, _ = io.Copy(w, body) r.Body.Close() if closer, ok := body.(io.Closer); ok { closer.Close() @@ -49,7 +50,7 @@ func TestNewReleases(t *testing.T) { c, s := testClientFile(http.StatusOK, "test_data/new_releases.txt") defer s.Close() - r, err := c.NewReleases() + r, err := c.NewReleases(context.Background()) if err != nil { t.Fatal(err) } @@ -68,7 +69,7 @@ func TestNewReleasesRateLimitExceeded(t *testing.T) { http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Retry-After", "2") w.WriteHeader(rateLimitExceededStatusCode) - io.WriteString(w, `{ "error": { "message": "slow down", "status": 429 } }`) + _, _ = io.WriteString(w, `{ "error": { "message": "slow down", "status": 429 } }`) }), // next attempt succeeds http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -91,8 +92,8 @@ func TestNewReleasesRateLimitExceeded(t *testing.T) { })) defer server.Close() - client := &Client{http: http.DefaultClient, baseURL: server.URL + "/", AutoRetry: true} - releases, err := client.NewReleases() + client := &Client{http: http.DefaultClient, baseURL: server.URL + "/", autoRetry: true} + releases, err := client.NewReleases(context.Background()) if err != nil { t.Fatal(err) } diff --git a/track.go b/track.go index 53f1fb4..2a5a2c9 100644 --- a/track.go +++ b/track.go @@ -1,9 +1,9 @@ package spotify import ( + "context" "errors" "fmt" - "net/url" "strings" "time" ) @@ -116,28 +116,20 @@ func (t *SimpleTrack) TimeDuration() time.Duration { // GetTrack gets Spotify catalog information for // a single track identified by its unique Spotify ID. +// // API Doc: https://developer.spotify.com/documentation/web-api/reference/tracks/get-track/ -func (c *Client) GetTrack(id ID) (*FullTrack, error) { - return c.GetTrackOpt(id, nil) -} - -// GetTrackOpt is like GetTrack but it accepts additional arguments -func (c *Client) GetTrackOpt(id ID, opt *Options) (*FullTrack, error) { +// +// Supported options: Market +func (c *Client) GetTrack(ctx context.Context, id ID, opts ...RequestOption) (*FullTrack, error) { spotifyURL := c.baseURL + "tracks/" + string(id) var t FullTrack - if opt != nil { - v := url.Values{} - if opt.Country != nil { - v.Set("market", *opt.Country) - } - if params := v.Encode(); params != "" { - spotifyURL += "?" + params - } + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } - err := c.get(spotifyURL, &t) + err := c.get(ctx, spotifyURL, &t) if err != nil { return nil, err } @@ -150,29 +142,24 @@ func (c *Client) GetTrackOpt(id ID, opt *Options) (*FullTrack, error) { // 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. +// // API Doc: https://developer.spotify.com/documentation/web-api/reference/tracks/get-several-tracks/ -func (c *Client) GetTracks(ids ...ID) ([]*FullTrack, error) { - return c.GetTracksOpt(nil, ids...) -} - -// GetTracksOpt is like GetTracks but it accepts an additional country option for track relinking -func (c *Client) GetTracksOpt(opt *Options, ids ...ID) ([]*FullTrack, error) { +// +// Supported options: Market +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 := url.Values{} + params := processOptions(opts...).urlParams params.Set("ids", strings.Join(toStringSlice(ids), ",")) - if opt != nil && opt.Country != nil { - params.Set("market", *opt.Country) - } spotifyURL := c.baseURL + "tracks?" + params.Encode() var t struct { Tracks []*FullTrack `json:"tracks"` } - err := c.get(spotifyURL, &t) + err := c.get(ctx, spotifyURL, &t) if err != nil { return nil, err } diff --git a/track_test.go b/track_test.go index 3339cfe..f45a07d 100644 --- a/track_test.go +++ b/track_test.go @@ -1,6 +1,7 @@ package spotify import ( + "context" "net/http" "testing" ) @@ -9,7 +10,7 @@ func TestFindTrack(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/find_track.txt") defer server.Close() - track, err := client.GetTrack(ID("1zHlj4dQ8ZAtrayhuDDmkY")) + track, err := client.GetTrack(context.Background(), "1zHlj4dQ8ZAtrayhuDDmkY") if err != nil { t.Error(err) return @@ -23,7 +24,7 @@ func TestFindTracksSimple(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/find_tracks_simple.txt") defer server.Close() - tracks, err := client.GetTracks(ID("0eGsygTp906u18L0Oimnem"), ID("1lDWb6b6ieDQ2xT7ewTC3G")) + tracks, err := client.GetTracks(context.Background(), []ID{"0eGsygTp906u18L0Oimnem", "1lDWb6b6ieDQ2xT7ewTC3G"}) if err != nil { t.Error(err) return @@ -39,7 +40,7 @@ func TestFindTracksNotFound(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/find_tracks_notfound.txt") defer server.Close() - tracks, err := client.GetTracks(ID("0eGsygTp906u18L0Oimnem"), ID("1lDWb6b6iecccdsdckTC3G")) + tracks, err := client.GetTracks(context.Background(), []ID{"0eGsygTp906u18L0Oimnem", "1lDWb6b6iecccdsdckTC3G"}) if err != nil { t.Error(err) return diff --git a/user.go b/user.go index b1e20a2..76ef603 100644 --- a/user.go +++ b/user.go @@ -1,11 +1,11 @@ package spotify import ( + "context" "errors" "fmt" "net/http" "net/url" - "strconv" "strings" ) @@ -56,12 +56,12 @@ type PrivateUser struct { // GetUsersPublicProfile gets public profile information about a // Spotify User. It does not require authentication. -func (c *Client) GetUsersPublicProfile(userID ID) (*User, error) { +func (c *Client) GetUsersPublicProfile(ctx context.Context, userID ID) (*User, error) { spotifyURL := c.baseURL + "users/" + string(userID) var user User - err := c.get(spotifyURL, &user) + err := c.get(ctx, spotifyURL, &user) if err != nil { return nil, err } @@ -81,10 +81,10 @@ func (c *Client) GetUsersPublicProfile(userID ID) (*User, error) { // that was entered when the user created their spotify account. // This email address is unverified - do not assume that Spotify has // checked that the email address actually belongs to the user. -func (c *Client) CurrentUser() (*PrivateUser, error) { +func (c *Client) CurrentUser(ctx context.Context) (*PrivateUser, error) { var result PrivateUser - err := c.get(c.baseURL+"me", &result) + err := c.get(ctx, c.baseURL+"me", &result) if err != nil { return nil, err } @@ -94,31 +94,19 @@ func (c *Client) CurrentUser() (*PrivateUser, error) { // CurrentUsersShows gets a list of shows saved in the current // Spotify user's "Your Music" library. -func (c *Client) CurrentUsersShows() (*SavedShowPage, error) { - return c.CurrentUsersShowsOpt(nil) -} - -// CurrentUsersShowsOpt is like CurrentUsersShows, but it accepts additional -// options for sorting and filtering the results. +// // API Doc: https://developer.spotify.com/documentation/web-api/reference-beta/#endpoint-get-users-saved-shows -func (c *Client) CurrentUsersShowsOpt(opt *Options) (*SavedShowPage, error) { +// +// Supported options: Limit, Offset +func (c *Client) CurrentUsersShows(ctx context.Context, opts ...RequestOption) (*SavedShowPage, error) { spotifyURL := c.baseURL + "me/shows" - if opt != nil { - v := url.Values{} - if opt.Limit != nil { - v.Set("limit", strconv.Itoa(*opt.Limit)) - } - if opt.Offset != nil { - v.Set("offset", strconv.Itoa(*opt.Offset)) - } - if params := v.Encode(); params != "" { - spotifyURL += "?" + params - } + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } var result SavedShowPage - err := c.get(spotifyURL, &result) + err := c.get(ctx, spotifyURL, &result) if err != nil { return nil, err } @@ -128,34 +116,19 @@ func (c *Client) CurrentUsersShowsOpt(opt *Options) (*SavedShowPage, error) { // CurrentUsersTracks gets a list of songs saved in the current // Spotify user's "Your Music" library. -func (c *Client) CurrentUsersTracks() (*SavedTrackPage, error) { - return c.CurrentUsersTracksOpt(nil) -} - -// CurrentUsersTracksOpt is like CurrentUsersTracks, but it accepts additional -// options for track relinking, sorting and filtering the results. +// // API Doc: https://developer.spotify.com/documentation/web-api/reference-beta/#endpoint-get-users-saved-tracks -func (c *Client) CurrentUsersTracksOpt(opt *Options) (*SavedTrackPage, error) { +// +// Supported options: Limit, Country, Offset +func (c *Client) CurrentUsersTracks(ctx context.Context, opts ...RequestOption) (*SavedTrackPage, error) { spotifyURL := c.baseURL + "me/tracks" - if opt != nil { - v := url.Values{} - if opt.Country != nil { - v.Set("market", *opt.Country) - } - if opt.Limit != nil { - v.Set("limit", strconv.Itoa(*opt.Limit)) - } - if opt.Offset != nil { - v.Set("offset", strconv.Itoa(*opt.Offset)) - } - if params := v.Encode(); params != "" { - spotifyURL += "?" + params - } + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } var result SavedTrackPage - err := c.get(spotifyURL, &result) + err := c.get(ctx, spotifyURL, &result) if err != nil { return nil, err } @@ -168,8 +141,8 @@ func (c *Client) CurrentUsersTracksOpt(opt *Options) (*SavedTrackPage, error) { // // Modifying the lists of artists or users the current user follows // requires that the application has the ScopeUserFollowModify scope. -func (c *Client) FollowUser(ids ...ID) error { - return c.modifyFollowers("user", true, 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 @@ -177,8 +150,8 @@ func (c *Client) FollowUser(ids ...ID) error { // // Modifying the lists of artists or users the current user follows // requires that the application has the ScopeUserFollowModify scope. -func (c *Client) FollowArtist(ids ...ID) error { - return c.modifyFollowers("artist", true, 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 @@ -186,8 +159,8 @@ func (c *Client) FollowArtist(ids ...ID) error { // // Modifying the lists of artists or users the current user follows // requires that the application has the ScopeUserFollowModify scope. -func (c *Client) UnfollowUser(ids ...ID) error { - return c.modifyFollowers("user", false, ids...) +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 @@ -195,8 +168,8 @@ func (c *Client) UnfollowUser(ids ...ID) error { // // Modifying the lists of artists or users the current user follows // requires that the application has the ScopeUserFollowModify scope. -func (c *Client) UnfollowArtist(ids ...ID) error { - return c.modifyFollowers("artist", false, ids...) +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 @@ -208,7 +181,7 @@ func (c *Client) UnfollowArtist(ids ...ID) error { // // The result is returned as a slice of bool values in the same order // in which the IDs were specified. -func (c *Client) CurrentUserFollows(t string, ids ...ID) ([]bool, error) { +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") } @@ -220,7 +193,7 @@ func (c *Client) CurrentUserFollows(t string, ids ...ID) ([]bool, error) { var result []bool - err := c.get(spotifyURL, &result) + err := c.get(ctx, spotifyURL, &result) if err != nil { return nil, err } @@ -228,7 +201,7 @@ func (c *Client) CurrentUserFollows(t string, ids ...ID) ([]bool, error) { return result, nil } -func (c *Client) modifyFollowers(usertype string, follow bool, ids ...ID) error { +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") } @@ -240,7 +213,7 @@ func (c *Client) modifyFollowers(usertype string, follow bool, ids ...ID) error if !follow { method = "DELETE" } - req, err := http.NewRequest(method, spotifyURL, nil) + req, err := http.NewRequestWithContext(ctx, method, spotifyURL, nil) if err != nil { return err } @@ -253,26 +226,11 @@ func (c *Client) modifyFollowers(usertype string, follow bool, ids ...ID) error // CurrentUsersFollowedArtists gets the current user's followed artists. // This call requires that the user has granted the ScopeUserFollowRead scope. -func (c *Client) CurrentUsersFollowedArtists() (*FullArtistCursorPage, error) { - return c.CurrentUsersFollowedArtistsOpt(-1, "") -} - -// CurrentUsersFollowedArtistsOpt is like CurrentUsersFollowedArtists, -// but it accept the optional arguments limit and after. Limit is the -// maximum number of items to return (1 <= limit <= 50), and after is -// the last artist ID retrieved from the previous request. If you don't -// wish to specify either of the parameters, use -1 for limit and the empty -// string for after. -func (c *Client) CurrentUsersFollowedArtistsOpt(limit int, after string) (*FullArtistCursorPage, error) { +// Supported options: Limit, After +func (c *Client) CurrentUsersFollowedArtists(ctx context.Context, opts ...RequestOption) (*FullArtistCursorPage, error) { spotifyURL := c.baseURL + "me/following" - v := url.Values{} + v := processOptions(opts...).urlParams v.Set("type", "artist") - if limit != -1 { - v.Set("limit", strconv.Itoa(limit)) - } - if after != "" { - v.Set("after", after) - } if params := v.Encode(); params != "" { spotifyURL += "?" + params } @@ -281,7 +239,7 @@ func (c *Client) CurrentUsersFollowedArtistsOpt(limit int, after string) (*FullA A FullArtistCursorPage `json:"artists"` } - err := c.get(spotifyURL, &result) + err := c.get(ctx, spotifyURL, &result) if err != nil { return nil, err } @@ -291,33 +249,17 @@ func (c *Client) CurrentUsersFollowedArtistsOpt(limit int, after string) (*FullA // CurrentUsersAlbums gets a list of albums saved in the current // Spotify user's "Your Music" library. -func (c *Client) CurrentUsersAlbums() (*SavedAlbumPage, error) { - return c.CurrentUsersAlbumsOpt(nil) -} - -// CurrentUsersAlbumsOpt is like CurrentUsersAlbums, but it accepts additional -// options for sorting and filtering the results. -func (c *Client) CurrentUsersAlbumsOpt(opt *Options) (*SavedAlbumPage, error) { +// +// Supported options: Market, Limit, Offset +func (c *Client) CurrentUsersAlbums(ctx context.Context, opts ...RequestOption) (*SavedAlbumPage, error) { spotifyURL := c.baseURL + "me/albums" - if opt != nil { - v := url.Values{} - if opt.Country != nil { - v.Set("market", *opt.Country) - } - if opt.Limit != nil { - v.Set("limit", strconv.Itoa(*opt.Limit)) - } - if opt.Offset != nil { - v.Set("offset", strconv.Itoa(*opt.Offset)) - } - if params := v.Encode(); params != "" { - spotifyURL += "?" + params - } + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } var result SavedAlbumPage - err := c.get(spotifyURL, &result) + err := c.get(ctx, spotifyURL, &result) if err != nil { return nil, err } @@ -332,30 +274,17 @@ func (c *Client) CurrentUsersAlbumsOpt(opt *Options) (*SavedAlbumPage, error) { // this scope alone will not return collaborative playlists, even though // they are always private. In order to retrieve collaborative playlists // the user must authorize the ScopePlaylistReadCollaborative scope. -func (c *Client) CurrentUsersPlaylists() (*SimplePlaylistPage, error) { - return c.CurrentUsersPlaylistsOpt(nil) -} - -// CurrentUsersPlaylistsOpt is like CurrentUsersPlaylists, but it accepts -// additional options for sorting and filtering the results. -func (c *Client) CurrentUsersPlaylistsOpt(opt *Options) (*SimplePlaylistPage, error) { +// +// Supported options: Limit, Offset +func (c *Client) CurrentUsersPlaylists(ctx context.Context, opts ...RequestOption) (*SimplePlaylistPage, error) { spotifyURL := c.baseURL + "me/playlists" - if opt != nil { - v := url.Values{} - if opt.Limit != nil { - v.Set("limit", strconv.Itoa(*opt.Limit)) - } - if opt.Offset != nil { - v.Set("offset", strconv.Itoa(*opt.Offset)) - } - if params := v.Encode(); params != "" { - spotifyURL += "?" + params - } + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } var result SimplePlaylistPage - err := c.get(spotifyURL, &result) + err := c.get(ctx, spotifyURL, &result) if err != nil { return nil, err } @@ -363,27 +292,19 @@ func (c *Client) CurrentUsersPlaylistsOpt(opt *Options) (*SimplePlaylistPage, er return &result, nil } -// CurrentUsersTopArtistsOpt gets a list of the top played artists in a given time -// range of the current Spotify user. It supports up to 50 artists in a single -// call. This call requires ScopeUserTopRead. -func (c *Client) CurrentUsersTopArtistsOpt(opt *Options) (*FullArtistPage, error) { +// CurrentUsersTopArtists fetches a list of the user's top artists over the specified Timerange. +// The default is medium term. +// +// Supported options: Limit, Timerange +func (c *Client) CurrentUsersTopArtists(ctx context.Context, opts ...RequestOption) (*FullArtistPage, error) { spotifyURL := c.baseURL + "me/top/artists" - if opt != nil { - v := url.Values{} - if opt.Limit != nil { - v.Set("limit", strconv.Itoa(*opt.Limit)) - } - if opt.Timerange != nil { - v.Set("time_range", *opt.Timerange+"_term") - } - if params := v.Encode(); params != "" { - spotifyURL += "?" + params - } + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } var result FullArtistPage - err := c.get(spotifyURL, &result) + err := c.get(ctx, spotifyURL, &result) if err != nil { return nil, err } @@ -391,47 +312,23 @@ func (c *Client) CurrentUsersTopArtistsOpt(opt *Options) (*FullArtistPage, error return &result, nil } -// CurrentUsersTopArtists is like CurrentUsersTopArtistsOpt but with +// CurrentUsersTopTracks fetches the user's top tracks over the specified Timerange // sensible defaults. The default limit is 20 and the default timerange -// is medium_term. -func (c *Client) CurrentUsersTopArtists() (*FullArtistPage, error) { - return c.CurrentUsersTopArtistsOpt(nil) -} - -// CurrentUsersTopTracksOpt gets a list of the top played tracks in a given time -// range of the current Spotify user. It supports up to 50 tracks in a single -// call. This call requires ScopeUserTopRead. -func (c *Client) CurrentUsersTopTracksOpt(opt *Options) (*FullTrackPage, error) { +// is medium_term. This call requires ScopeUserTopRead. +// +// Supported options: Limit, Timerange, Offset +func (c *Client) CurrentUsersTopTracks(ctx context.Context, opts ...RequestOption) (*FullTrackPage, error) { spotifyURL := c.baseURL + "me/top/tracks" - if opt != nil { - v := url.Values{} - if opt.Limit != nil { - v.Set("limit", strconv.Itoa(*opt.Limit)) - } - if opt.Timerange != nil { - v.Set("time_range", *opt.Timerange+"_term") - } - if opt.Offset != nil { - v.Set("offset", strconv.Itoa(*opt.Offset)) - } - if params := v.Encode(); params != "" { - spotifyURL += "?" + params - } + if params := processOptions(opts...).urlParams.Encode(); params != "" { + spotifyURL += "?" + params } var result FullTrackPage - err := c.get(spotifyURL, &result) + err := c.get(ctx, spotifyURL, &result) if err != nil { return nil, err } return &result, nil } - -// CurrentUsersTopTracks is like CurrentUsersTopTracksOpt but with -// sensible defaults. The default limit is 20 and the default timerange -// is medium_term. -func (c *Client) CurrentUsersTopTracks() (*FullTrackPage, error) { - return c.CurrentUsersTopTracksOpt(nil) -} diff --git a/user_test.go b/user_test.go index e34247b..44226df 100644 --- a/user_test.go +++ b/user_test.go @@ -1,6 +1,7 @@ package spotify import ( + "context" "fmt" "io" "net/http" @@ -34,7 +35,7 @@ func TestUserProfile(t *testing.T) { client, server := testClientString(http.StatusOK, userResponse) defer server.Close() - user, err := client.GetUsersPublicProfile("wizzler") + user, err := client.GetUsersPublicProfile(context.Background(), "wizzler") if err != nil { t.Error(err) return @@ -70,7 +71,7 @@ func TestCurrentUser(t *testing.T) { client, server := testClientString(http.StatusOK, json) defer server.Close() - me, err := client.CurrentUser() + me, err := client.CurrentUser(context.Background()) if err != nil { t.Error(err) return @@ -99,7 +100,7 @@ func TestFollowUsersMissingScope(t *testing.T) { }) defer server.Close() - err := client.FollowUser(ID("exampleuser01")) + err := client.FollowUser(context.Background(), ID("exampleuser01")) serr, ok := err.(Error) if !ok { t.Fatal("Expected insufficient client scope error") @@ -117,7 +118,7 @@ func TestFollowArtist(t *testing.T) { }) defer server.Close() - if err := client.FollowArtist("3ge4xOaKvWfhRwgx0Rldov"); err != nil { + if err := client.FollowArtist(context.Background(), "3ge4xOaKvWfhRwgx0Rldov"); err != nil { t.Error(err) } } @@ -129,7 +130,7 @@ func TestFollowArtistAutoRetry(t *testing.T) { http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Retry-After", "2") w.WriteHeader(rateLimitExceededStatusCode) - io.WriteString(w, `{ "error": { "message": "slow down", "status": 429 } }`) + _, _ = io.WriteString(w, `{ "error": { "message": "slow down", "status": 429 } }`) }), // next attempt succeeds http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -144,8 +145,8 @@ func TestFollowArtistAutoRetry(t *testing.T) { })) defer server.Close() - client := &Client{http: http.DefaultClient, baseURL: server.URL + "/", AutoRetry: true} - if err := client.FollowArtist("3ge4xOaKvWfhRwgx0Rldov"); err != nil { + client := &Client{http: http.DefaultClient, baseURL: server.URL + "/", autoRetry: true} + if err := client.FollowArtist(context.Background(), "3ge4xOaKvWfhRwgx0Rldov"); err != nil { t.Error(err) } } @@ -164,7 +165,7 @@ func TestFollowUsersInvalidToken(t *testing.T) { }) defer server.Close() - err := client.FollowUser(ID("dummyID")) + err := client.FollowUser(context.Background(), ID("dummyID")) serr, ok := err.(Error) if !ok { t.Fatal("Expected invalid token error") @@ -179,7 +180,7 @@ func TestUserFollows(t *testing.T) { client, server := testClientString(http.StatusOK, json) defer server.Close() - follows, err := client.CurrentUserFollows("artist", ID("74ASZWbe4lXaubB36ztrGX"), ID("08td7MxkoHQkXnWAYD8d6Q")) + follows, err := client.CurrentUserFollows(context.Background(), "artist", ID("74ASZWbe4lXaubB36ztrGX"), ID("08td7MxkoHQkXnWAYD8d6Q")) if err != nil { t.Error(err) return @@ -193,7 +194,7 @@ func TestCurrentUsersTracks(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/current_users_tracks.txt") defer server.Close() - tracks, err := client.CurrentUsersTracks() + tracks, err := client.CurrentUsersTracks(context.Background()) if err != nil { t.Error(err) return @@ -223,7 +224,7 @@ func TestCurrentUsersAlbums(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/current_users_albums.txt") defer server.Close() - albums, err := client.CurrentUsersAlbums() + albums, err := client.CurrentUsersAlbums(context.Background()) if err != nil { t.Error(err) return @@ -262,7 +263,7 @@ func TestCurrentUsersPlaylists(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/current_users_playlists.txt") defer server.Close() - playlists, err := client.CurrentUsersPlaylists() + playlists, err := client.CurrentUsersPlaylists(context.Background()) if err != nil { t.Error(err) } @@ -341,7 +342,7 @@ func TestUsersFollowedArtists(t *testing.T) { client, server := testClientString(http.StatusOK, json) defer server.Close() - artists, err := client.CurrentUsersFollowedArtists() + artists, err := client.CurrentUsersFollowedArtists(context.Background()) if err != nil { t.Fatal(err) } @@ -368,14 +369,17 @@ func TestCurrentUsersFollowedArtistsOpt(t *testing.T) { }) defer server.Close() - client.CurrentUsersFollowedArtistsOpt(50, "0aV6DOiouImYTqrR5YlIqx") + _, err := client.CurrentUsersFollowedArtists(context.Background(), Limit(50), After("0aV6DOiouImYTqrR5YlIqx")) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } } func TestCurrentUsersTopArtists(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/current_users_top_artists.txt") defer server.Close() - artists, err := client.CurrentUsersTopArtists() + artists, err := client.CurrentUsersTopArtists(context.Background()) if err != nil { t.Error(err) } @@ -408,7 +412,7 @@ func TestCurrentUsersTopTracks(t *testing.T) { client, server := testClientFile(http.StatusOK, "test_data/current_users_top_tracks.txt") defer server.Close() - tracks, err := client.CurrentUsersTopTracks() + tracks, err := client.CurrentUsersTopTracks(context.Background()) if err != nil { t.Error(err) }