package spotify import ( "bytes" "context" "crypto/tls" "encoding/json" "errors" "fmt" "golang.org/x/oauth2" "golang.org/x/oauth2/clientcredentials" spotifyAuth "golang.org/x/oauth2/spotify" "io" "log" "net/http" "net/url" "reflect" "strings" ) type SpotifyClient struct { clientID string clientSecret string bearerAuth string baseURL *url.URL httpClient *http.Client config *oauth2.Config context context.Context } type TrackInfo struct { Album AlbumInfo `json:"album"` Artists []ArtistInfo `json:"artists"` DiskNumber int `json:"disc_number"` DurationMS int `json:"duration_ms"` Explicit bool `json:"explicit"` ExternalIds ExternalIds `json:"external_ids"` ExternalUrls ExternalUrls `json:"external_urls"` Href string `json:"href"` ID string `json:"id"` IsPlayable bool `json:"is_playable"` Name string `json:"name"` Popularity int `json:"popularity"` PreviewUrl string `json:"preview_url"` TrackNumber int `json:"track_number"` Type string `json:"type"` Uri string `json:"uri"` } type AlbumInfo struct { AlbumType string `json:"album_type"` Artists []ArtistInfo `json:"artists"` ExternalUrls ExternalUrls `json:"external_urls"` Href string `json:"href"` ID string `json:"id"` Images []ImageInfo `json:"images"` Name string `json:"name"` Type string `json:"type"` Uri string `json:"uri"` } type ArtistInfo struct { ExternalUrls ExternalUrls `json:"external_urls"` Href string `json:"href"` ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` Uri string `json:"uri"` } type ImageInfo struct { Height int `json:"height"` URL string `json:url` Width int `json:"width"` } type ExternalIds struct { ISRC string `json:"isrc"` } type ExternalUrls struct { Spotify string `json:"spotify"` } type TokenInfo struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` } type AuthErrorResponse struct { Error string `json:"error"` ErrorDescription string `json:"error_description"` } type ErrorResponse struct { Error ErrorInfo `json:"error"` } type ErrorInfo struct { Status int `json:"status"` Message string `json:"message"` } type PlaylistInfo struct { Collaborative bool `json:"collaborative"` Description string `json:"description"` ExternalUrls ExternalUrls `json:"external_urls"` Followers Followers `json:"followers"` Href string `json:"href"` Id string `json:"id"` // TODO images Name string `json:"name"` // TODO owner Public bool `json:"public"` SnapshotId string `json:"snapshot_id"` Tracks TracksInfo `json:"tracks"` Type string `json:"type"` Uri string `json:"uri"` } type TracksInfo struct { Href string `json:"href"` Items []PlaylistTrack `json:"items"` Limit int `json:"limit"` Next string `json:"next"` Offset int `json:"offset"` Type string `json:"type"` Previous string `json:"string"` Total int `json:"total"` } type PlaylistTrack struct { AddedAt string `json:"added_at"` AddedBy string `json:"added_by"` IsLocal bool `json:"is_local"` Track TrackInfo `json":track"` } type Followers struct { Href string `json:"href"` Total int `json:"total"` } type SpotifyProfile struct { Birthdate string `json:"birthdate"` Country string `json:"country"` DisplayName string `json:"display_name"` Email string `json:"email"` ExternalUrls ExternalUrls `json:"external_urls"` Followers Followers `json:"followers"` Id string `json:"id"` Product string `json:"product"` Type string `json:"type"` Uri string `json:"uri"` } func NewClient(username, secret string) *SpotifyClient { baseURL, _ := url.Parse("https://api.spotify.com") httpClient := &http.Client{} return &SpotifyClient{ clientID: username, clientSecret: secret, baseURL: baseURL, httpClient: httpClient, } } func (s *SpotifyClient) Authenticate() error { config := &clientcredentials.Config{ ClientID: s.clientID, ClientSecret: s.clientSecret, TokenURL: spotifyAuth.Endpoint.TokenURL, } token, err := config.Token(context.Background()) if err != nil { log.Println(err) return err } s.bearerAuth = token.AccessToken return nil } func (s *SpotifyClient) SetupUserAuthenticate(redirectURL string) error { s.config = &oauth2.Config{ ClientID: s.clientID, ClientSecret: s.clientSecret, RedirectURL: redirectURL, Scopes: []string{"playlist-modify-public"}, Endpoint: spotifyAuth.Endpoint, } tr := &http.Transport{ TLSNextProto: map[string]func(authority string, c *tls.Conn) http.RoundTripper{}, } s.context = context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{Transport: tr}) return nil } func (s *SpotifyClient) GetTrack(trackId string) (*TrackInfo, error) { var trackInfo TrackInfo url := s.urlFromBase(fmt.Sprintf("/v1/tracks/%s", trackId)) err := s.doRequest("GET", url, &trackInfo, nil, nil) if err != nil { return nil, err } else { return &trackInfo, nil } } func (s *SpotifyClient) NewPlaylist(name, userId string) (*PlaylistInfo, error) { var playlistInfo PlaylistInfo values := &url.Values{} values.Add("name", name) url := s.urlFromBase(fmt.Sprintf("/v1/users/%s/playlists", userId)) err := s.doRequest("POST", url, &playlistInfo, values, nil) if err != nil { return nil, err } else { return &playlistInfo, nil } } type Playlist struct { Uris []string `json:"uris"` } func (s *SpotifyClient) UpdatePlaylist(userId, playlistId string, tracks []string) error { uris := make([]string, 0) for _, track := range tracks { uris = append(uris, "spotify:track:"+track) } //playlist := &Playlist{uris} values := &url.Values{} values.Add("uris", strings.Join(uris, ",")) result := struct { SnapshotID string `json:"snapshot_id"` }{} url := s.urlFromBase(fmt.Sprintf("/v1/users/%s/playlists/%s", userId, playlistId)) err := s.doRequest("PUT", url, &result, values, nil) return err } func (s *SpotifyClient) GetMe() (*SpotifyProfile, error) { var profile SpotifyProfile url := s.urlFromBase(fmt.Sprintf("/v1/me")) err := s.doRequest("GET", url, &profile, nil, nil) if err != nil { return nil, err } else { return &profile, nil } } func (s *SpotifyClient) urlFromBase(path string) *url.URL { endpoint := &url.URL{Path: path} return s.baseURL.ResolveReference(endpoint) } func (s *SpotifyClient) doRequest(method string, url *url.URL, object interface{}, values *url.Values, inputBody interface{}) error { var bodyReader io.Reader var contentType string if inputBody != nil { //pr, pw := io.Pipe() //go func() { // err := json.NewEncoder(pw).Encode(&inputBody) // pw.Close() // if err != nil { // log.Printf("Spotify: Encoding body failed: %s\n", err.Error()) // } //}() var buf bytes.Buffer err := json.NewEncoder(&buf).Encode(&inputBody) log.Printf("Spotify: Body is '%s'\n", buf.String()) if err != nil { log.Printf("Spotify: Encoding body failed: %s\n", err.Error()) return err } bodyReader = &buf contentType = "application/json" } else if values != nil { s := values.Encode() bodyReader = strings.NewReader(s) contentType = "application/x-www-form-urlencoded" } req, err := http.NewRequest(method, url.String(), bodyReader) if err != nil { return err } req.Header.Set("Content-Type", contentType) if s.bearerAuth != "" { req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.bearerAuth)) } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "github.com/lamperi/e4bot/spotify") log.Printf("Spotify: %s %s\n", method, url.Path) resp, err := s.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 { var errorResp ErrorResponse err = json.NewDecoder(resp.Body).Decode(&errorResp) return errors.New(fmt.Sprintf("Spotify API returned: %d: %s", resp.StatusCode, errorResp.Error.Message)) } if object != nil { log.Printf("Spotify: Trying to decode payload %s\n", reflect.TypeOf(object)) err = json.NewDecoder(resp.Body).Decode(object) if err != nil { return err } } return nil } func (s *SpotifyClient) GetAuthUrl(state string) string { return s.config.AuthCodeURL(state) } func (s *SpotifyClient) HandleToken(state string, r *http.Request) error { values := r.URL.Query() if e := values.Get("error"); e != "" { return errors.New("spotify: auth failed - " + e) } code := values.Get("code") if code == "" { return errors.New("spotify: didn't get access code") } actualState := values.Get("state") if actualState != state { return errors.New("spotify: redirect state parameter doesn't match") } token, err := s.config.Exchange(s.context, code) if err != nil { return err } log.Println("Got token from Spotify", token) client := s.config.Client(s.context, token) s.httpClient = client return nil } func (s *SpotifyClient) HasUserLogin() bool { return s.httpClient != nil && s.config != nil && s.context != nil }