diff --git a/.golangci.yml b/.golangci.yml
index 054f56b..4a0d7fa 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -3,7 +3,6 @@ run:
issues-exit-code: 2
modules-download-mode: readonly
allow-parallel-runners: false
- show-stats: true
output:
format: github-actions
@@ -58,14 +57,28 @@ linters:
- gosimple
- govet
- ineffassign
+ - interfacebloat
- lll
- misspell
- nakedret
+ - nilerr
- noctx
+ - paralleltest
- perfsprint
+ - predeclared
+ - revive
+ - rowserrcheck
+ - sqlclosecheck
+ - tenv
+ - thelper
- tparallel
+ - unconvert
+ - unparam
- unused
+ - varnamelen
+ - wastedassign
- wrapcheck
+ - wsl
linters-settings:
depguard:
@@ -74,3 +87,9 @@ linters-settings:
allow:
- $gostd
- github.com/tomjowitt/gotidal
+
+ exhaustive:
+ check:
+ - switch
+ - map
+ default-signifies-exhaustive: true
diff --git a/README.md b/README.md
index 3be4747..db66bf1 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,24 @@
# gotidal
-A Go library for interacting with the Tidal API.
+
+
+
+
+A Go library for interacting with the TIDAL streaming service API.
+
+## Official Documentation
+
+
+
+## Installation
+
+```bash
+go get -u github.com/tomjowitt/gotidal
+```
+
+## Usage
+
+## Credits
+
+Logo created with Gopher Konstructor based on original artwork
+by Renee French.
diff --git a/album.go b/album.go
new file mode 100644
index 0000000..9e00fc5
--- /dev/null
+++ b/album.go
@@ -0,0 +1,35 @@
+package gotidal
+
+type Album struct {
+ Resource Resource `json:"resource"`
+ ID string `json:"id"`
+ Status int `json:"status"`
+ Message string `json:"message"`
+}
+
+type Resource struct {
+ ID string `json:"id"`
+ BarcodeID string `json:"barcodeID"`
+ Title string `json:"title"`
+ Artists []Artist `json:"artists"`
+ Duration int `json:"duration"`
+ ReleaseDate string `json:"releaseDate"`
+ ImageCover []Image `json:"imageCover"`
+ VideoCover []Image `json:"videoCover"`
+ NumberOfVolumes int `json:"numberOfVolumes"`
+ NumberOfTracks int `json:"numberOfTracks"`
+ NumberOfVideos int `json:"numberOfVideos"`
+ Type string `json:"type"`
+ Copyright string `json:"copyright"`
+ MediaMetadata MediaMetaData `json:"mediaMetadata"`
+ Properties Properties `json:"properties"`
+ TidalURL string `json:"tidalUrl"`
+}
+
+type MediaMetaData struct {
+ Tags []string `json:"tags"`
+}
+
+type Properties struct {
+ Content []string `json:"content"`
+}
diff --git a/artist.go b/artist.go
new file mode 100644
index 0000000..36e67c4
--- /dev/null
+++ b/artist.go
@@ -0,0 +1,8 @@
+package gotidal
+
+type Artist struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Picture []Image `json:"picture"`
+ Main bool `json:"main"`
+}
diff --git a/assets/gotidal.png b/assets/gotidal.png
new file mode 100644
index 0000000..cc7195c
Binary files /dev/null and b/assets/gotidal.png differ
diff --git a/client.go b/client.go
index 283147f..5691e5a 100644
--- a/client.go
+++ b/client.go
@@ -9,12 +9,17 @@ import (
"fmt"
"io"
"net/http"
+ "net/url"
+ "reflect"
+ "strconv"
+ "strings"
+ "unicode"
)
const (
- ContentType = "application/vnd.tidal.v1+json"
- Environment = "https://openapi.tidal.com"
- OauthURI = "https://auth.tidal.com/v1/oauth2/token"
+ contentType = "application/vnd.tidal.v1+json"
+ environment = "https://openapi.tidal.com"
+ oauthURI = "https://auth.tidal.com/v1/oauth2/token"
)
var ErrUnexpectedResponseCode = errors.New("returned an unexpected status code")
@@ -27,19 +32,20 @@ type Client struct {
func NewClient(clientID string, clientSecret string) (*Client, error) {
ctx := context.Background()
+
token, err := getAccessToken(ctx, clientID, clientSecret)
if err != nil {
return nil, err
}
return &Client{
- ContentType: ContentType,
- Environment: Environment,
+ ContentType: contentType,
+ Environment: environment,
Token: token,
}, nil
}
-type AuthResponse struct {
+type authResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
@@ -48,35 +54,107 @@ type AuthResponse struct {
func getAccessToken(ctx context.Context, clientID string, clientSecret string) (string, error) {
basicAuth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", clientID, clientSecret)))
- client := http.Client{}
requestBody := []byte(`grant_type=client_credentials`)
- req, err := http.NewRequestWithContext(ctx, "POST", OauthURI, bytes.NewBuffer(requestBody))
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, oauthURI, bytes.NewBuffer(requestBody))
if err != nil {
return "", fmt.Errorf("failed to create OAuth request: %w", err)
}
+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", basicAuth))
+ responseBody, err := processRequest(req)
+ if err != nil {
+ return "", fmt.Errorf("failed to process the request: %w", err)
+ }
+
+ var authResponse authResponse
+
+ err = json.Unmarshal(responseBody, &authResponse)
+ if err != nil {
+ return "", fmt.Errorf("failed to unmarshal the OAuth response body: %w", err)
+ }
+
+ return authResponse.AccessToken, nil
+}
+
+func processRequest(req *http.Request) ([]byte, error) {
+ client := http.Client{}
+
response, err := client.Do(req)
if err != nil {
- return "", fmt.Errorf("failed to process OAuth request: %w", err)
+ return nil, fmt.Errorf("failed to process request: %w", err)
}
defer response.Body.Close()
- if response.StatusCode != http.StatusOK {
- return "", fmt.Errorf("%w: %d", ErrUnexpectedResponseCode, response.StatusCode)
+ if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusMultiStatus {
+ return nil, fmt.Errorf("%w: %d", ErrUnexpectedResponseCode, response.StatusCode)
}
responseBody, err := io.ReadAll(response.Body)
if err != nil {
- return "", fmt.Errorf("failed to read the OAuth response body: %w", err)
+ return nil, fmt.Errorf("failed to read the OAuth response body: %w", err)
}
- var authResponse AuthResponse
- err = json.Unmarshal(responseBody, &authResponse)
+ return responseBody, nil
+}
+
+func (c *Client) Request(ctx context.Context, method string, path string, params any) ([]byte, error) {
+ uri := fmt.Sprintf("%s%s?%s", c.Environment, path, toURLParams(params))
+
+ req, err := http.NewRequestWithContext(ctx, method, uri, nil)
if err != nil {
- return "", fmt.Errorf("failed to unmarshal the OAuth response body: %w", err)
+ return nil, fmt.Errorf("failed to create request for %s: %w", uri, err)
}
- return authResponse.AccessToken, nil
+ req.Header.Set("Content-Type", c.ContentType)
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token))
+ req.Header.Set("accept", c.ContentType)
+
+ return processRequest(req)
+}
+
+func toURLParams(s interface{}) string {
+ var params []string
+
+ v := reflect.ValueOf(s)
+ t := v.Type()
+
+ for i := 0; i < v.NumField(); i++ {
+ field := t.Field(i)
+ value := v.Field(i)
+
+ if value.IsValid() {
+ var paramValue string
+
+ switch value.Kind() {
+ case reflect.String:
+ paramValue = value.String()
+ case reflect.Int:
+ paramValue = strconv.FormatInt(value.Int(), 10)
+ default:
+ continue
+ }
+
+ if paramValue != "" && paramValue != "0" {
+ paramName := url.QueryEscape(lowercaseFirstLetter(field.Name))
+ paramValue = url.QueryEscape(paramValue)
+ params = append(params, fmt.Sprintf("%s=%s", paramName, paramValue))
+ }
+ }
+ }
+
+ return strings.Join(params, "&")
+}
+
+func lowercaseFirstLetter(str string) string {
+ if len(str) == 0 {
+ return str
+ }
+
+ firstChar := []rune(str)[0]
+ lowerFirstChar := unicode.ToLower(firstChar)
+
+ return string(lowerFirstChar) + str[1:]
}
diff --git a/examples/search/main.go b/examples/search/main.go
index 57fe1f3..2dcaa10 100644
--- a/examples/search/main.go
+++ b/examples/search/main.go
@@ -1,6 +1,7 @@
package main
import (
+ "context"
"log"
"os"
@@ -8,13 +9,27 @@ import (
)
func main() {
- clientId := os.Getenv("TIDAL_CLIENT_ID")
+ ctx := context.Background()
+
+ clientID := os.Getenv("TIDAL_CLIENT_ID")
clientSecret := os.Getenv("TIDAL_CLIENT_SECRET")
- client, err := gotidal.NewClient(clientId, clientSecret)
+ client, err := gotidal.NewClient(clientID, clientSecret)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ params := gotidal.SearchParams{
+ Query: "Black Flag",
+ CountryCode: "AU",
+ }
+
+ results, err := client.Search(ctx, params)
if err != nil {
log.Fatal(err)
}
- client.Search("searchQuery")
+ for _, album := range results.Albums {
+ log.Printf("%s - %s", album.Resource.Title, album.Resource.Artists[0].Name)
+ }
}
diff --git a/go.mod b/go.mod
index 6bbde4c..187cac6 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,3 @@
module github.com/tomjowitt/gotidal
-go 1.21.6
+go 1.22.0
diff --git a/image.go b/image.go
new file mode 100644
index 0000000..57ec315
--- /dev/null
+++ b/image.go
@@ -0,0 +1,7 @@
+package gotidal
+
+type Image struct {
+ URL string `json:"url"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+}
diff --git a/search.go b/search.go
index 43de350..9e7c9a9 100644
--- a/search.go
+++ b/search.go
@@ -1,4 +1,66 @@
package gotidal
-func (c *Client) Search(query string) {
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+)
+
+const (
+ SearchTypeAlbums = "ALBUMS"
+ SearchTypeArtists = "ARTISTS"
+ SearchTypeTracks = "TRACKS"
+ SearchTypeVideos = "VIDEOS"
+ SearchPopularityWorldwide = "WORLDWIDE"
+ SearchPopularityCountry = "COUNTRY"
+)
+
+// SearchParams defines the request parameters used by the TIDAL search API endpoint.
+// See: https://developer.tidal.com/apiref?spec=search&ref=search
+type SearchParams struct {
+ // Search query in plain text.
+ // Example: Beyoncé
+ Query string `json:"query"`
+
+ // Target search type. Optional. Searches for all types if not specified.
+ // Example: ARTISTS, ALBUMS, TRACKS, VIDEOS
+ Type string `json:"type"`
+
+ // Pagination offset (in number of items). Required if 'query' is provided.
+ // Example: 0
+ Offset int `json:"offset"`
+
+ // Page size. Required if 'query' is provided.
+ // Example: 10
+ Limit int `json:"limit"`
+
+ // ISO 3166-1 alpha-2 country code.
+ // Example: AU
+ CountryCode string `json:"countryCode"`
+
+ // Specify which popularity type to apply for query result: either worldwide or country popularity.
+ // Worldwide popularity is using by default if nothing is specified.
+ // Example: WORLDWIDE, COUNTRY
+ Popularity string `json:"popularity"`
+}
+
+type SearchResults struct {
+ Albums []Album
+}
+
+func (c *Client) Search(ctx context.Context, params SearchParams) (*SearchResults, error) {
+ response, err := c.Request(ctx, http.MethodGet, "/search", params)
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to the search endpoint: %w", err)
+ }
+
+ var results SearchResults
+
+ err = json.Unmarshal(response, &results)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal the search response body: %w", err)
+ }
+
+ return &results, nil
}