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"` +}