342 lines
8.2 KiB
Go
342 lines
8.2 KiB
Go
package qobuz
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const baseURL = "https://www.qobuz.com/api.json/0.2"
|
|
|
|
const defaultUA = "Dalvik/2.1.0 (Linux; U; Android 9; Nexus 6P Build/PQ3A.190801.002) QobuzMobileAndroid/9.7.0.3-b26022717"
|
|
const defaultAppVersion = "9.7.0.3"
|
|
const defaultDevicePlatform = "android"
|
|
const defaultDeviceModel = "Nexus 6P"
|
|
const defaultDeviceOSVersion = "9"
|
|
|
|
type Client struct {
|
|
httpClient *http.Client
|
|
appID string
|
|
appSecret string
|
|
token string
|
|
}
|
|
|
|
type Track struct {
|
|
ID string
|
|
Title string
|
|
Version string
|
|
Duration int
|
|
ISRC string
|
|
Artist string
|
|
Album string
|
|
AlbumID string
|
|
AlbumArtist string
|
|
}
|
|
|
|
type flexString string
|
|
|
|
func (f *flexString) UnmarshalJSON(data []byte) error {
|
|
if string(data) == "null" {
|
|
*f = ""
|
|
return nil
|
|
}
|
|
var s string
|
|
if err := json.Unmarshal(data, &s); err == nil {
|
|
*f = flexString(strings.TrimSpace(s))
|
|
return nil
|
|
}
|
|
var n json.Number
|
|
if err := json.Unmarshal(data, &n); err == nil {
|
|
*f = flexString(strings.TrimSpace(n.String()))
|
|
return nil
|
|
}
|
|
return fmt.Errorf("invalid flexible id")
|
|
}
|
|
|
|
func NewClient(appID, appSecret string) *Client {
|
|
return &Client{
|
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
|
appID: appID,
|
|
appSecret: appSecret,
|
|
}
|
|
}
|
|
|
|
func (c *Client) Login(ctx context.Context, username, password string) error {
|
|
type oauthResponse struct {
|
|
OAuth2 struct {
|
|
AccessToken string `json:"access_token"`
|
|
} `json:"oauth2"`
|
|
AccessToken string `json:"access_token"`
|
|
}
|
|
|
|
rawPassword := strings.TrimSpace(password)
|
|
md5Password := md5Hex(rawPassword)
|
|
|
|
attempts := []struct {
|
|
Method string
|
|
Password string
|
|
}{
|
|
{Method: http.MethodGet, Password: md5Password},
|
|
{Method: http.MethodGet, Password: rawPassword},
|
|
{Method: http.MethodPost, Password: md5Password},
|
|
{Method: http.MethodPost, Password: rawPassword},
|
|
}
|
|
|
|
var lastErr error
|
|
for _, a := range attempts {
|
|
params := url.Values{}
|
|
params.Set("username", username)
|
|
params.Set("password", a.Password)
|
|
|
|
var out oauthResponse
|
|
var err error
|
|
if a.Method == http.MethodPost {
|
|
err = c.postFormSigned(ctx, "/oauth2/login", params, &out)
|
|
} else {
|
|
err = c.getSigned(ctx, "/oauth2/login", params, &out)
|
|
}
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
|
|
token := strings.TrimSpace(out.OAuth2.AccessToken)
|
|
if token == "" {
|
|
token = strings.TrimSpace(out.AccessToken)
|
|
}
|
|
if token == "" {
|
|
lastErr = fmt.Errorf("qobuz login response missing access_token")
|
|
continue
|
|
}
|
|
c.token = token
|
|
return nil
|
|
}
|
|
|
|
if lastErr == nil {
|
|
lastErr = fmt.Errorf("qobuz login failed")
|
|
}
|
|
return lastErr
|
|
}
|
|
|
|
func (c *Client) VerifyAuth(ctx context.Context) error {
|
|
var out map[string]any
|
|
if err := c.getUnsigned(ctx, "/user/get", url.Values{}, &out); err == nil {
|
|
return nil
|
|
}
|
|
if err := c.getSigned(ctx, "/user/get", url.Values{}, &out); err != nil {
|
|
return fmt.Errorf("verify auth failed for both unsigned and signed user/get: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) SearchTracks(ctx context.Context, query string, limit int) ([]Track, error) {
|
|
if limit <= 0 {
|
|
limit = 8
|
|
}
|
|
params := url.Values{}
|
|
params.Set("query", query)
|
|
params.Set("limit", strconv.Itoa(limit))
|
|
params.Set("offset", "0")
|
|
|
|
type response struct {
|
|
Tracks struct {
|
|
Items []struct {
|
|
ID flexString `json:"id"`
|
|
Title string `json:"title"`
|
|
Version string `json:"version"`
|
|
Duration int `json:"duration"`
|
|
ISRC string `json:"isrc"`
|
|
Performer struct {
|
|
Name string `json:"name"`
|
|
} `json:"performer"`
|
|
Album struct {
|
|
ID flexString `json:"id"`
|
|
Title string `json:"title"`
|
|
Artist struct {
|
|
Name string `json:"name"`
|
|
} `json:"artist"`
|
|
} `json:"album"`
|
|
} `json:"items"`
|
|
} `json:"tracks"`
|
|
}
|
|
|
|
var out response
|
|
if err := c.getSigned(ctx, "/track/search", params, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res := make([]Track, 0, len(out.Tracks.Items))
|
|
for _, it := range out.Tracks.Items {
|
|
trackID := strings.TrimSpace(string(it.ID))
|
|
albumID := strings.TrimSpace(string(it.Album.ID))
|
|
if trackID == "" || albumID == "" {
|
|
continue
|
|
}
|
|
res = append(res, Track{
|
|
ID: trackID,
|
|
Title: it.Title,
|
|
Version: it.Version,
|
|
Duration: it.Duration,
|
|
ISRC: strings.ToUpper(strings.TrimSpace(it.ISRC)),
|
|
Artist: it.Performer.Name,
|
|
Album: it.Album.Title,
|
|
AlbumID: albumID,
|
|
AlbumArtist: it.Album.Artist.Name,
|
|
})
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (c *Client) getSigned(ctx context.Context, path string, params url.Values, out any) error {
|
|
query := cloneValues(params)
|
|
ts, sig := signGet(path, c.appSecret, query)
|
|
query.Set("app_id", c.appID)
|
|
query.Set("request_ts", ts)
|
|
query.Set("request_sig", sig)
|
|
|
|
return c.doJSON(ctx, http.MethodGet, path, query, url.Values{}, out)
|
|
}
|
|
|
|
func (c *Client) getUnsigned(ctx context.Context, path string, params url.Values, out any) error {
|
|
query := cloneValues(params)
|
|
query.Set("app_id", c.appID)
|
|
return c.doJSON(ctx, http.MethodGet, path, query, url.Values{}, out)
|
|
}
|
|
|
|
func (c *Client) postFormSigned(ctx context.Context, path string, form url.Values, out any) error {
|
|
for _, includeValues := range []bool{false, true} {
|
|
query := url.Values{}
|
|
ts, sig := signPost(path, c.appSecret, form, includeValues)
|
|
query.Set("app_id", c.appID)
|
|
query.Set("request_ts", ts)
|
|
query.Set("request_sig", sig)
|
|
|
|
err := c.doJSON(ctx, http.MethodPost, path, query, form, out)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if !isSigError(err) {
|
|
return err
|
|
}
|
|
}
|
|
return fmt.Errorf("qobuz request signature rejected for %s", path)
|
|
}
|
|
|
|
func (c *Client) doJSON(ctx context.Context, method, path string, query, form url.Values, out any) error {
|
|
u := baseURL + path
|
|
if len(query) > 0 {
|
|
u += "?" + query.Encode()
|
|
}
|
|
bodyEncoded := ""
|
|
if method == http.MethodPost {
|
|
bodyEncoded = form.Encode()
|
|
}
|
|
|
|
var lastErr error
|
|
for attempt := 1; attempt <= 4; attempt++ {
|
|
var body io.Reader
|
|
if method == http.MethodPost {
|
|
body = strings.NewReader(bodyEncoded)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, u, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("User-Agent", defaultUA)
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("X-App-Id", c.appID)
|
|
req.Header.Set("X-App-Version", defaultAppVersion)
|
|
req.Header.Set("X-Device-Platform", defaultDevicePlatform)
|
|
req.Header.Set("X-Device-Model", defaultDeviceModel)
|
|
req.Header.Set("X-Device-Os-Version", defaultDeviceOSVersion)
|
|
if c.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
req.Header.Set("X-User-Auth-Token", c.token)
|
|
}
|
|
if method == http.MethodPost {
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
lastErr = err
|
|
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
|
|
continue
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
|
|
resp.Body.Close()
|
|
time.Sleep(time.Duration(attempt) * time.Second)
|
|
continue
|
|
}
|
|
|
|
if resp.StatusCode >= 300 {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
return fmt.Errorf("qobuz api error (%d): %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
if out == nil {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
return nil
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if lastErr == nil {
|
|
lastErr = fmt.Errorf("qobuz request failed after retries")
|
|
}
|
|
return lastErr
|
|
}
|
|
|
|
func chunk(ids []int64, size int) [][]int64 {
|
|
if size <= 0 {
|
|
size = 100
|
|
}
|
|
out := make([][]int64, 0, (len(ids)+size-1)/size)
|
|
for i := 0; i < len(ids); i += size {
|
|
j := i + size
|
|
if j > len(ids) {
|
|
j = len(ids)
|
|
}
|
|
out = append(out, ids[i:j])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneValues(v url.Values) url.Values {
|
|
res := url.Values{}
|
|
for k, values := range v {
|
|
cp := make([]string, len(values))
|
|
copy(cp, values)
|
|
res[k] = cp
|
|
}
|
|
return res
|
|
}
|
|
|
|
func isSigError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := strings.ToLower(err.Error())
|
|
return strings.Contains(msg, "signature") || strings.Contains(msg, "request_sig")
|
|
}
|
|
|
|
func md5Hex(s string) string {
|
|
h := md5.Sum([]byte(s))
|
|
return hex.EncodeToString(h[:])
|
|
}
|