diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..73f69e0
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+# Editor-based HTTP Client requests
+/httpRequests/
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..9db4c77
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/spotify-api.iml b/.idea/spotify-api.iml
new file mode 100644
index 0000000..c956989
--- /dev/null
+++ b/.idea/spotify-api.iml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/accounts_api.go b/accounts_api.go
new file mode 100644
index 0000000..ecb7e5a
--- /dev/null
+++ b/accounts_api.go
@@ -0,0 +1,123 @@
+package spotify
+
+import (
+ secure "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/json"
+ "math/rand"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/brianstrauch/spotify/model"
+)
+
+const (
+ AccountsAPIBaseURL = "https://accounts.spotify.com"
+ ClientID = "81dddfee3e8d47d89b7902ba616f3357"
+)
+
+func StartProof() (string, string, error) {
+ verifier, err := generateRandomVerifier()
+ if err != nil {
+ return "", "", err
+ }
+
+ hash := sha256.Sum256(verifier)
+ challenge := base64.URLEncoding.EncodeToString(hash[:])
+ challenge = strings.TrimRight(challenge, "=")
+
+ return string(verifier), challenge, nil
+}
+
+func generateRandomVerifier() ([]byte, error) {
+ seed, err := generateSecureSeed()
+ if err != nil {
+ return nil, err
+ }
+ rand.Seed(seed)
+
+ chars := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-~"
+
+ verifier := make([]byte, 128)
+ for i := 0; i < len(verifier); i++ {
+ idx := rand.Intn(len(chars))
+ verifier[i] = chars[idx]
+ }
+
+ return verifier, nil
+}
+
+func generateSecureSeed() (int64, error) {
+ buf := make([]byte, 8)
+ _, err := secure.Read(buf)
+ if err != nil {
+ return 0, err
+ }
+
+ seed := int64(binary.BigEndian.Uint64(buf))
+ return seed, nil
+}
+
+func BuildAuthURI(redirectURI, challenge, state string, scope string) string {
+ q := url.Values{}
+ q.Add("client_id", ClientID)
+ q.Add("response_type", "code")
+ q.Add("redirect_uri", redirectURI)
+ q.Add("code_challenge_method", "S256")
+ q.Add("code_challenge", challenge)
+ q.Add("state", state)
+ q.Add("scope", scope)
+
+ return AccountsAPIBaseURL + "/authorize?" + q.Encode()
+}
+
+func RequestToken(code, redirectURI, verifier string) (*model.Token, error) {
+ v := url.Values{}
+ v.Set("client_id", ClientID)
+ v.Set("grant_type", "authorization_code")
+ v.Set("code", code)
+ v.Set("redirect_uri", redirectURI)
+ v.Set("code_verifier", verifier)
+ body := strings.NewReader(v.Encode())
+
+ res, err := http.Post(AccountsAPIBaseURL+"/api/token", "application/x-www-form-urlencoded", body)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+
+ // TODO: Handle errors
+
+ token := new(model.Token)
+ if err := json.NewDecoder(res.Body).Decode(token); err != nil {
+ return nil, err
+ }
+
+ return token, nil
+}
+
+func RefreshToken(refreshToken string) (*model.Token, error) {
+ v := url.Values{}
+ v.Set("grant_type", "refresh_token")
+ v.Set("refresh_token", refreshToken)
+ v.Set("client_id", ClientID)
+ body := strings.NewReader(v.Encode())
+
+ res, err := http.Post(AccountsAPIBaseURL+"/api/token", "application/x-www-form-urlencoded", body)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+
+ // TODO: Handle errors
+
+ token := new(model.Token)
+ if err := json.NewDecoder(res.Body).Decode(token); err != nil {
+ return nil, err
+ }
+
+ return token, nil
+}
diff --git a/accounts_api_test.go b/accounts_api_test.go
new file mode 100644
index 0000000..1d522f4
--- /dev/null
+++ b/accounts_api_test.go
@@ -0,0 +1,53 @@
+package spotify
+
+import (
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+func TestStartProof(t *testing.T) {
+ verifier, challenge, err := StartProof()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !regexp.MustCompile(`^[[:alnum:]_.\-~]{128}$`).MatchString(verifier) {
+ t.Fatal("Verifier string does not match")
+ }
+
+ // Hash with SHA-256 (64 chars)
+ // Convert to Base64 (44 chars)
+ // Remove trailing = (43 chars)
+ if !regexp.MustCompile(`^[[:alnum:]\-_]{43}$`).MatchString(challenge) {
+ t.Fatal("Challenge string does not match")
+ }
+}
+
+func TestBuildAuthURI(t *testing.T) {
+ var (
+ redirectURI = "http://localhost:1024"
+ challenge = "challenge"
+ state = "state"
+ scope = "user-modify-playback-state"
+ )
+
+ uri := BuildAuthURI(redirectURI, challenge, state, scope)
+
+ substrings := []string{
+ "client_id=" + ClientID,
+ "response_type=code",
+ "redirect_uri=" + url.QueryEscape(redirectURI),
+ "code_challenge_method=S256",
+ "code_challenge=" + challenge,
+ "state=" + state,
+ "scope=" + url.QueryEscape(scope),
+ }
+
+ for _, substring := range substrings {
+ if !strings.Contains(uri, substring) {
+ t.Fatalf("URI %s does not contain substring %s", uri, substring)
+ }
+ }
+}
diff --git a/api.go b/api.go
new file mode 100644
index 0000000..f5a3dc0
--- /dev/null
+++ b/api.go
@@ -0,0 +1,208 @@
+package spotify
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/brianstrauch/spotify/model"
+)
+
+type APIInterface interface {
+ Back() error
+ Next() error
+ Pause() error
+ Play(uri string) error
+ Queue(uri string) error
+ Repeat(state string) error
+ Save(id string) error
+ Search(queue string, limit int) (*model.Page, error)
+ Shuffle(state bool) error
+ Status() (*model.Playback, error)
+ Unsave(id string) error
+ WaitForUpdatedPlayback(isUpdated func(*model.Playback) bool) (*model.Playback, error)
+}
+
+const (
+ APIBaseURL = "https://api.spotify.com/v1"
+)
+
+type API struct {
+ token string
+}
+
+func NewAPI(token string) *API {
+ return &API{token}
+}
+
+func (a *API) Back() error {
+ _, err := a.call(http.MethodPost, "/me/player/previous", nil)
+ return err
+}
+
+func (a *API) Next() error {
+ _, err := a.call(http.MethodPost, "/me/player/next", nil)
+ return err
+}
+
+func (a *API) Pause() error {
+ _, err := a.call(http.MethodPut, "/me/player/pause", nil)
+ return err
+}
+
+func (a *API) Play(uri string) error {
+ if len(uri) == 0 {
+ _, err := a.call(http.MethodPut, "/me/player/play", nil)
+ return err
+ }
+
+ type Body struct {
+ URIs []string `json:"uris"`
+ }
+
+ body := new(Body)
+ body.URIs = []string{uri}
+
+ data, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+
+ _, err = a.call(http.MethodPut, "/me/player/play", bytes.NewReader(data))
+ return err
+}
+
+func (a *API) Queue(uri string) error {
+ q := url.Values{}
+ q.Add("uri", uri)
+
+ _, err := a.call(http.MethodPost, "/me/player/queue?"+q.Encode(), nil)
+ return err
+}
+
+func (a *API) Repeat(state string) error {
+ q := url.Values{}
+ q.Add("state", state)
+
+ _, err := a.call(http.MethodPut, "/me/player/repeat?"+q.Encode(), nil)
+ return err
+}
+
+func (a *API) Save(id string) error {
+ q := url.Values{}
+ q.Add("ids", id)
+
+ _, err := a.call(http.MethodPut, "/me/tracks?"+q.Encode(), nil)
+ return err
+}
+
+func (a *API) Search(query string, limit int) (*model.Page, error) {
+ q := url.Values{}
+ q.Add("q", query)
+ q.Add("type", "track")
+ q.Add("limit", strconv.Itoa(limit))
+
+ res, err := a.call(http.MethodGet, "/search?"+q.Encode(), nil)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+
+ page := new(model.Page)
+ err = json.NewDecoder(res.Body).Decode(page)
+
+ return page, err
+}
+
+func (a *API) Shuffle(state bool) error {
+ q := url.Values{}
+ q.Add("state", strconv.FormatBool(state))
+
+ _, err := a.call(http.MethodPut, "/me/player/shuffle?"+q.Encode(), nil)
+ return err
+}
+
+func (a *API) Status() (*model.Playback, error) {
+ q := url.Values{}
+ q.Add("additional_types", "episode")
+
+ res, err := a.call(http.MethodGet, "/me/player?"+q.Encode(), nil)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode == http.StatusNoContent {
+ return nil, nil
+ }
+
+ playback := new(model.Playback)
+ err = json.NewDecoder(res.Body).Decode(playback)
+
+ return playback, err
+}
+
+func (a *API) Unsave(id string) error {
+ q := url.Values{}
+ q.Add("ids", id)
+
+ _, err := a.call(http.MethodDelete, "/me/tracks?"+q.Encode(), nil)
+ return err
+}
+
+func (a *API) WaitForUpdatedPlayback(isUpdated func(playback *model.Playback) bool) (*model.Playback, error) {
+ timeout := time.After(time.Second)
+ tick := time.Tick(100 * time.Millisecond)
+
+ for {
+ select {
+ case <-timeout:
+ return nil, errors.New("request timed out")
+ case <-tick:
+ playback, err := a.Status()
+ if err != nil {
+ return nil, err
+ }
+
+ if isUpdated(playback) {
+ return playback, nil
+ }
+ }
+ }
+}
+
+func (a *API) call(method string, endpoint string, body io.Reader) (*http.Response, error) {
+ url := APIBaseURL + endpoint
+
+ req, err := http.NewRequest(method, url, body)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", a.token))
+
+ client := http.Client{}
+ res, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ // Success
+ if res.StatusCode >= 200 && res.StatusCode < 300 {
+ return res, nil
+ }
+
+ // Error
+ spotifyErr := new(model.SpotifyError)
+ if err := json.NewDecoder(res.Body).Decode(spotifyErr); err != nil {
+ return nil, err
+ }
+
+ return nil, errors.New(spotifyErr.Error.Message)
+}
diff --git a/api_mock.go b/api_mock.go
new file mode 100644
index 0000000..acd5954
--- /dev/null
+++ b/api_mock.go
@@ -0,0 +1,94 @@
+package spotify
+
+import (
+ "github.com/brianstrauch/spotify/model"
+ "github.com/stretchr/testify/mock"
+)
+
+type MockAPI struct {
+ mock.Mock
+}
+
+func (m *MockAPI) Back() error {
+ args := m.Called()
+ return args.Error(0)
+}
+
+func (m *MockAPI) Next() error {
+ args := m.Called()
+ return args.Error(0)
+}
+
+func (m *MockAPI) Pause() error {
+ args := m.Called()
+ return args.Error(0)
+}
+
+func (m *MockAPI) Play(uri string) error {
+ args := m.Called(uri)
+ return args.Error(0)
+}
+
+func (m *MockAPI) Queue(uri string) error {
+ args := m.Called(uri)
+ return args.Error(0)
+}
+
+func (m *MockAPI) Repeat(state string) error {
+ args := m.Called(state)
+ return args.Error(0)
+}
+
+func (m *MockAPI) Save(id string) error {
+ args := m.Called(id)
+ return args.Error(0)
+}
+
+func (m *MockAPI) Search(queue string, limit int) (*model.Page, error) {
+ args := m.Called(queue, limit)
+
+ page := args.Get(0)
+ err := args.Error(1)
+
+ if page == nil {
+ return nil, err
+ }
+
+ return page.(*model.Page), err
+}
+
+func (m *MockAPI) Shuffle(state bool) error {
+ args := m.Called(state)
+ return args.Error(0)
+}
+
+func (m *MockAPI) Status() (*model.Playback, error) {
+ args := m.Called()
+
+ playback := args.Get(0)
+ err := args.Error(1)
+
+ if playback == nil {
+ return nil, err
+ }
+
+ return playback.(*model.Playback), err
+}
+
+func (m *MockAPI) Unsave(id string) error {
+ args := m.Called(id)
+ return args.Error(0)
+}
+
+func (m *MockAPI) WaitForUpdatedPlayback(isUpdated func(playback *model.Playback) bool) (*model.Playback, error) {
+ args := m.Called(isUpdated)
+
+ playback := args.Get(0)
+ err := args.Error(1)
+
+ if playback == nil {
+ return nil, err
+ }
+
+ return playback.(*model.Playback), err
+}
\ No newline at end of file
diff --git a/api_test.go b/api_test.go
new file mode 100644
index 0000000..4fb7c70
--- /dev/null
+++ b/api_test.go
@@ -0,0 +1 @@
+package spotify
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..a4e8ba5
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,5 @@
+module github.com/brianstrauch/spotify
+
+go 1.16
+
+require github.com/stretchr/testify v1.7.0
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..26500d5
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,12 @@
+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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+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/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=
diff --git a/model/artist.go b/model/artist.go
new file mode 100644
index 0000000..399d696
--- /dev/null
+++ b/model/artist.go
@@ -0,0 +1,5 @@
+package model
+
+type Artist struct {
+ Name string `json:"name"`
+}
diff --git a/model/item.go b/model/item.go
new file mode 100644
index 0000000..244489e
--- /dev/null
+++ b/model/item.go
@@ -0,0 +1,10 @@
+package model
+
+type Item struct {
+ Artists []Artist `json:"artists"`
+ DurationMs int `json:"duration_ms"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Show Show `json:"show"`
+ Type string `json:"type"`
+}
diff --git a/model/page.go b/model/page.go
new file mode 100644
index 0000000..e6bdf13
--- /dev/null
+++ b/model/page.go
@@ -0,0 +1,5 @@
+package model
+
+type Page struct {
+ Tracks Tracks `json:"tracks"`
+}
diff --git a/model/playback.go b/model/playback.go
new file mode 100644
index 0000000..99ade79
--- /dev/null
+++ b/model/playback.go
@@ -0,0 +1,9 @@
+package model
+
+type Playback struct {
+ IsPlaying bool `json:"is_playing"`
+ Item Item `json:"item"`
+ ProgressMs int `json:"progress_ms"`
+ RepeatState string `json:"repeat_state"`
+ ShuffleState bool `json:"shuffle_state"`
+}
diff --git a/model/show.go b/model/show.go
new file mode 100644
index 0000000..d9cc09c
--- /dev/null
+++ b/model/show.go
@@ -0,0 +1,5 @@
+package model
+
+type Show struct {
+ Name string `json:"name"`
+}
diff --git a/model/spotify_error.go b/model/spotify_error.go
new file mode 100644
index 0000000..fff9a55
--- /dev/null
+++ b/model/spotify_error.go
@@ -0,0 +1,9 @@
+package model
+
+type SpotifyError struct {
+ Error struct {
+ Status int `json:"status"`
+ Message string `json:"message"`
+ Reason string `json:"reason"`
+ } `json:"error"`
+}
diff --git a/model/token.go b/model/token.go
new file mode 100644
index 0000000..7a9a58f
--- /dev/null
+++ b/model/token.go
@@ -0,0 +1,9 @@
+package model
+
+type Token struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ Scope string `json:"scope"`
+}
diff --git a/model/track.go b/model/track.go
new file mode 100644
index 0000000..9d2a78e
--- /dev/null
+++ b/model/track.go
@@ -0,0 +1,5 @@
+package model
+
+type Track struct {
+ URI string `json:"uri"`
+}
diff --git a/model/tracks.go b/model/tracks.go
new file mode 100644
index 0000000..a6375ac
--- /dev/null
+++ b/model/tracks.go
@@ -0,0 +1,5 @@
+package model
+
+type Tracks struct {
+ Items []Track `json:"items"`
+}