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[:]) }