From e6a070188edb68be24ae22aed97b15f40ebf2e60 Mon Sep 17 00:00:00 2001
From: Brian Strauch <bstrauch24@gmail.com>
Date: Wed, 9 Jun 2021 17:50:27 -0500
Subject: [PATCH] initial commit

---
 .idea/.gitignore       |   8 ++
 .idea/modules.xml      |   8 ++
 .idea/spotify-api.iml  |   8 ++
 accounts_api.go        | 123 ++++++++++++++++++++++++
 accounts_api_test.go   |  53 +++++++++++
 api.go                 | 208 +++++++++++++++++++++++++++++++++++++++++
 api_mock.go            |  94 +++++++++++++++++++
 api_test.go            |   1 +
 go.mod                 |   5 +
 go.sum                 |  12 +++
 model/artist.go        |   5 +
 model/item.go          |  10 ++
 model/page.go          |   5 +
 model/playback.go      |   9 ++
 model/show.go          |   5 +
 model/spotify_error.go |   9 ++
 model/token.go         |   9 ++
 model/track.go         |   5 +
 model/tracks.go        |   5 +
 19 files changed, 582 insertions(+)
 create mode 100644 .idea/.gitignore
 create mode 100644 .idea/modules.xml
 create mode 100644 .idea/spotify-api.iml
 create mode 100644 accounts_api.go
 create mode 100644 accounts_api_test.go
 create mode 100644 api.go
 create mode 100644 api_mock.go
 create mode 100644 api_test.go
 create mode 100644 go.mod
 create mode 100644 go.sum
 create mode 100644 model/artist.go
 create mode 100644 model/item.go
 create mode 100644 model/page.go
 create mode 100644 model/playback.go
 create mode 100644 model/show.go
 create mode 100644 model/spotify_error.go
 create mode 100644 model/token.go
 create mode 100644 model/track.go
 create mode 100644 model/tracks.go

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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectModuleManager">
+    <modules>
+      <module fileurl="file://$PROJECT_DIR$/.idea/spotify-api.iml" filepath="$PROJECT_DIR$/.idea/spotify-api.iml" />
+    </modules>
+  </component>
+</project>
\ 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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="WEB_MODULE" version="4">
+  <component name="NewModuleRootManager">
+    <content url="file://$MODULE_DIR$" />
+    <orderEntry type="inheritedJdk" />
+    <orderEntry type="sourceFolder" forTests="false" />
+  </component>
+</module>
\ 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"`
+}