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 }