first commit
This commit is contained in:
372
internal/qobuz/client.go
Normal file
372
internal/qobuz/client.go
Normal file
@@ -0,0 +1,372 @@
|
||||
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[:])
|
||||
}
|
||||
48
internal/qobuz/client_integration_test.go
Normal file
48
internal/qobuz/client_integration_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package qobuz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLiveLoginVerifyAndSearch(t *testing.T) {
|
||||
username := os.Getenv("QOBUZ_IT_USERNAME")
|
||||
password := os.Getenv("QOBUZ_IT_PASSWORD")
|
||||
if username == "" || password == "" {
|
||||
t.Skip("set QOBUZ_IT_USERNAME and QOBUZ_IT_PASSWORD to run live integration test")
|
||||
}
|
||||
|
||||
appID := os.Getenv("QOBUZ_IT_APP_ID")
|
||||
if appID == "" {
|
||||
appID = "312369995"
|
||||
}
|
||||
appSecret := os.Getenv("QOBUZ_IT_APP_SECRET")
|
||||
if appSecret == "" {
|
||||
appSecret = "e79f8b9be485692b0e5f9dd895826368"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c := NewClient(appID, appSecret)
|
||||
if err := c.Login(ctx, username, password); err != nil {
|
||||
t.Fatalf("login failed: %v", err)
|
||||
}
|
||||
if c.token == "" {
|
||||
t.Fatalf("login succeeded but token is empty")
|
||||
}
|
||||
|
||||
if err := c.VerifyAuth(ctx); err != nil {
|
||||
t.Fatalf("verify auth failed: %v", err)
|
||||
}
|
||||
|
||||
tracks, err := c.SearchTracks(ctx, "Daft Punk One More Time", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("search failed: %v", err)
|
||||
}
|
||||
if len(tracks) == 0 {
|
||||
t.Fatalf("search returned no results")
|
||||
}
|
||||
}
|
||||
101
internal/qobuz/signer.go
Normal file
101
internal/qobuz/signer.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package qobuz
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func signGet(path, appSecret string, query url.Values) (requestTS, requestSig string) {
|
||||
ts := nowTS()
|
||||
method := methodName(path)
|
||||
|
||||
keys := make([]string, 0, len(query))
|
||||
for k := range query {
|
||||
if k == "app_id" || k == "request_ts" || k == "request_sig" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
b := strings.Builder{}
|
||||
b.WriteString(method)
|
||||
for _, k := range keys {
|
||||
vals := query[k]
|
||||
if len(vals) == 0 {
|
||||
continue
|
||||
}
|
||||
b.WriteString(k)
|
||||
b.WriteString(vals[0])
|
||||
}
|
||||
b.WriteString(ts)
|
||||
b.WriteString(appSecret)
|
||||
|
||||
h := md5.Sum([]byte(b.String()))
|
||||
return ts, hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func signPost(path, appSecret string, form url.Values, includeValues bool) (requestTS, requestSig string) {
|
||||
ts := nowTS()
|
||||
method := methodName(path)
|
||||
|
||||
keys := make([]string, 0, len(form))
|
||||
for k := range form {
|
||||
if k == "app_id" || k == "request_ts" || k == "request_sig" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
b := strings.Builder{}
|
||||
b.WriteString(method)
|
||||
for _, k := range keys {
|
||||
b.WriteString(k)
|
||||
if includeValues {
|
||||
vals := form[k]
|
||||
if len(vals) > 0 {
|
||||
b.WriteString(vals[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteString(ts)
|
||||
b.WriteString(appSecret)
|
||||
|
||||
h := md5.Sum([]byte(b.String()))
|
||||
return ts, hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func methodName(path string) string {
|
||||
return strings.ReplaceAll(strings.Trim(path, "/"), "/", "")
|
||||
}
|
||||
|
||||
func nowTS() string {
|
||||
return strconvI64(time.Now().Unix())
|
||||
}
|
||||
|
||||
func strconvI64(v int64) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := v < 0
|
||||
if neg {
|
||||
v = -v
|
||||
}
|
||||
buf := [20]byte{}
|
||||
i := len(buf)
|
||||
for v > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
Reference in New Issue
Block a user