Files
NaviMigrate/internal/qobuz/client.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[:])
}