package qobuz import ( "context" "crypto/md5" "encoding/hex" "encoding/json" "errors" "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 } var ErrDuplicateTracks = errors.New("qobuz duplicate tracks") 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 } hadDuplicate := false 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 { if isDuplicateConflictErr(err) { hadDuplicate = true continue } return err } } if hadDuplicate { return ErrDuplicateTracks } 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 isDuplicateConflictErr(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) if !strings.Contains(msg, "duplicate track") { return false } return strings.Contains(msg, "(409)") || strings.Contains(msg, `"code":409`) || strings.Contains(msg, "code: 409") } func md5Hex(s string) string { h := md5.Sum([]byte(s)) return hex.EncodeToString(h[:]) }