373 lines
9.0 KiB
Go
373 lines
9.0 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 int64
|
|
Title string
|
|
Version string
|
|
Duration int
|
|
ISRC string
|
|
Artist string
|
|
Album string
|
|
}
|
|
|
|
func NewClient(appID, appSecret string) *Client {
|
|
return &Client{
|
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
|
appID: appID,
|
|
appSecret: appSecret,
|
|
}
|
|
}
|
|
|
|
func (c *Client) SetToken(token string) {
|
|
c.token = strings.TrimSpace(token)
|
|
}
|
|
|
|
func (c *Client) Token() string {
|
|
return c.token
|
|
}
|
|
|
|
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 int64 `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 {
|
|
Title string `json:"title"`
|
|
} `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 {
|
|
res = append(res, Track{
|
|
ID: it.ID,
|
|
Title: it.Title,
|
|
Version: it.Version,
|
|
Duration: it.Duration,
|
|
ISRC: strings.ToUpper(strings.TrimSpace(it.ISRC)),
|
|
Artist: it.Performer.Name,
|
|
Album: it.Album.Title,
|
|
})
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (c *Client) CreatePlaylist(ctx context.Context, name, description string, isPublic bool) (int64, error) {
|
|
form := url.Values{}
|
|
form.Set("name", name)
|
|
form.Set("description", description)
|
|
if isPublic {
|
|
form.Set("is_public", "true")
|
|
} else {
|
|
form.Set("is_public", "false")
|
|
}
|
|
form.Set("is_collaborative", "false")
|
|
|
|
var out struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
if err := c.postFormSigned(ctx, "/playlist/create", form, &out); err != nil {
|
|
return 0, err
|
|
}
|
|
if out.ID == 0 {
|
|
return 0, fmt.Errorf("playlist/create returned empty playlist id")
|
|
}
|
|
return out.ID, nil
|
|
}
|
|
|
|
func (c *Client) AddTracksToPlaylist(ctx context.Context, playlistID int64, trackIDs []int64) error {
|
|
if len(trackIDs) == 0 {
|
|
return nil
|
|
}
|
|
chunks := chunk(trackIDs, 100)
|
|
for _, ch := range chunks {
|
|
ids := make([]string, 0, len(ch))
|
|
for _, id := range ch {
|
|
ids = append(ids, strconv.FormatInt(id, 10))
|
|
}
|
|
form := url.Values{}
|
|
form.Set("playlist_id", strconv.FormatInt(playlistID, 10))
|
|
form.Set("track_ids", strings.Join(ids, ","))
|
|
form.Set("no_duplicate", "true")
|
|
|
|
var out map[string]any
|
|
if err := c.postFormSigned(ctx, "/playlist/addTracks", form, &out); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) DeletePlaylist(ctx context.Context, playlistID int64) error {
|
|
params := url.Values{}
|
|
params.Set("playlist_id", strconv.FormatInt(playlistID, 10))
|
|
var out map[string]any
|
|
if err := c.getSigned(ctx, "/playlist/delete", params, &out); err != nil {
|
|
return err
|
|
}
|
|
return 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[:])
|
|
}
|