-
Notifications
You must be signed in to change notification settings - Fork 298
/
spotify.go
349 lines (303 loc) · 9.25 KB
/
spotify.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
// Package spotify provides utilities for interfacing
// with Spotify's Web API.
package spotify
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"time"
"golang.org/x/oauth2"
)
const (
// DateLayout can be used with time.Parse to create time.Time values
// from Spotify date strings. For example, PrivateUser.Birthdate
// uses this format.
DateLayout = "2006-01-02"
// TimestampLayout can be used with time.Parse to create time.Time
// values from SpotifyTimestamp strings. It is an ISO 8601 UTC timestamp
// with a zero offset. For example, PlaylistTrack's AddedAt field uses
// this format.
TimestampLayout = "2006-01-02T15:04:05Z"
// defaultRetryDurationS helps us fix an apparent server bug whereby we will
// be told to retry but not be given a wait-interval.
defaultRetryDuration = time.Second * 5
)
// Client is a client for working with the Spotify Web API.
// It is best to create this using spotify.New()
type Client struct {
http *http.Client
baseURL string
autoRetry bool
acceptLanguage string
}
type ClientOption func(client *Client)
// WithRetry configures the Spotify API client to automatically retry requests that fail due to rate limiting.
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
}
}
// 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,
// spotify:track:6rqhFgbbKwnb9MLmUQDhG6
type URI string
// ID is a base-62 identifier for an artist, track, album, etc.
// It can be found at the end of a spotify.URI.
type ID string
func (id *ID) String() string {
return string(*id)
}
// Numeric is a convenience type for handling numbers sent as either integers or floats.
type Numeric int
// UnmarshalJSON unmarshals a JSON number (float or int) into the Numeric type.
func (n *Numeric) UnmarshalJSON(data []byte) error {
var f float64
if err := json.Unmarshal(data, &f); err != nil {
return err
}
*n = Numeric(int(f))
return nil
}
// Followers contains information about the number of people following a
// particular artist or playlist.
type Followers struct {
// The total number of followers.
Count Numeric `json:"total"`
// A link to the Web API endpoint providing full details of the followers,
// or the empty string if this data is not available.
Endpoint string `json:"href"`
}
// Image identifies an image associated with an item.
type Image struct {
// The image height, in pixels.
Height Numeric `json:"height"`
// The image width, in pixels.
Width Numeric `json:"width"`
// The source URL of the image.
URL string `json:"url"`
}
// Download downloads the image and writes its data to the specified io.Writer.
func (i Image) Download(dst io.Writer) error {
resp, err := http.Get(i.URL)
if err != nil {
return err
}
defer resp.Body.Close()
// TODO: get Content-Type from header?
if resp.StatusCode != http.StatusOK {
return errors.New("Couldn't download image - HTTP" + strconv.Itoa(resp.StatusCode))
}
_, err = io.Copy(dst, resp.Body)
return err
}
// Error represents an error returned by the Spotify Web API.
type Error struct {
// A short description of the error.
Message string `json:"message"`
// The HTTP status code.
Status int `json:"status"`
// RetryAfter contains the time before which client should not retry a
// rate-limited request, calculated from the Retry-After header, when present.
RetryAfter time.Time `json:"-"`
}
func (e Error) Error() string {
return e.Message
}
// decodeError decodes an Error from an io.Reader.
func decodeError(resp *http.Response) error {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if ctHeader := resp.Header.Get("Content-Type"); ctHeader == "" {
msg := string(responseBody)
if len(msg) == 0 {
msg = http.StatusText(resp.StatusCode)
}
return Error{
Message: msg,
Status: resp.StatusCode,
}
}
if len(responseBody) == 0 {
return fmt.Errorf("spotify: HTTP %d: %s (body empty)", resp.StatusCode, http.StatusText(resp.StatusCode))
}
buf := bytes.NewBuffer(responseBody)
var e struct {
E Error `json:"error"`
}
err = json.NewDecoder(buf).Decode(&e)
if err != nil {
return fmt.Errorf("spotify: couldn't decode error: (%d) [%s]", len(responseBody), responseBody)
}
if e.E.Message == "" {
// Some errors will result in there being a useful status-code but an
// empty message, which will confuse the user (who only has access to
// the message and not the code). An example of this is when we send
// some of the arguments directly in the HTTP query and the URL ends-up
// being too long.
e.E.Message = fmt.Sprintf("spotify: unexpected HTTP %d: %s (empty error)",
resp.StatusCode, http.StatusText(resp.StatusCode))
}
if retryAfter, _ := strconv.Atoi(resp.Header.Get("Retry-After")); retryAfter != 0 {
e.E.RetryAfter = time.Now().Add(time.Duration(retryAfter) * time.Second)
}
return e.E
}
// shouldRetry determines whether the status code indicates that the
// previous operation should be retried at a later time
func shouldRetry(status int) bool {
return status == http.StatusAccepted || status == http.StatusTooManyRequests
}
// isFailure determines whether the code indicates failure
func isFailure(code int, validCodes []int) bool {
for _, item := range validCodes {
if item == code {
return false
}
}
return true
}
// `execute` executes a non-GET request. `needsStatus` describes other HTTP
// 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)
}
for {
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if c.autoRetry &&
isFailure(resp.StatusCode, needsStatus) &&
shouldRetry(resp.StatusCode) {
select {
case <-req.Context().Done():
// If the context is cancelled, return the original error
case <-time.After(retryDuration(resp)):
continue
}
}
if resp.StatusCode == http.StatusNoContent {
return nil
}
if (resp.StatusCode >= 300 ||
resp.StatusCode < 200) &&
isFailure(resp.StatusCode, needsStatus) {
return decodeError(resp)
}
if result != nil {
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return err
}
}
break
}
return nil
}
func retryDuration(resp *http.Response) time.Duration {
raw := resp.Header.Get("Retry-After")
if raw == "" {
return defaultRetryDuration
}
seconds, err := strconv.ParseInt(raw, 10, 32)
if err != nil {
return defaultRetryDuration
}
return time.Duration(seconds) * time.Second
}
func (c *Client) get(ctx context.Context, url string, result interface{}) error {
for {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if c.acceptLanguage != "" {
req.Header.Set("Accept-Language", c.acceptLanguage)
}
if err != nil {
return err
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests && c.autoRetry {
select {
case <-ctx.Done():
// If the context is cancelled, return the original error
case <-time.After(retryDuration(resp)):
continue
}
}
if resp.StatusCode == http.StatusNoContent {
return nil
}
if resp.StatusCode != http.StatusOK {
return decodeError(resp)
}
return json.NewDecoder(resp.Body).Decode(result)
}
}
// NewReleases gets a list of new album releases featured in Spotify.
// Supported options: Country, Limit, Offset
func (c *Client) NewReleases(ctx context.Context, opts ...RequestOption) (albums *SimpleAlbumPage, err error) {
spotifyURL := c.baseURL + "browse/new-releases"
if params := processOptions(opts...).urlParams.Encode(); params != "" {
spotifyURL += "?" + params
}
var objmap map[string]*json.RawMessage
err = c.get(ctx, spotifyURL, &objmap)
if err != nil {
return nil, err
}
var result SimpleAlbumPage
err = json.Unmarshal(*objmap["albums"], &result)
if err != nil {
return nil, err
}
return &result, nil
}
// Token gets the client's current token.
func (c *Client) Token() (*oauth2.Token, error) {
transport, ok := c.http.Transport.(*oauth2.Transport)
if !ok {
return nil, errors.New("spotify: client not backed by oauth2 transport")
}
t, err := transport.Source.Token()
if err != nil {
return nil, err
}
return t, nil
}