// Package api wraps the Qobuz API. // Translated from qopy.py, originally by Sorrow446 (Qo-DL-Reborn). package api import ( "context" "crypto/md5" "encoding/json" "fmt" "io" "net/http" "net/url" "sort" "strconv" "strings" "time" ) const ( baseURL = "https://www.qobuz.com/api.json/0.2/" userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0" resetMsg = "Reset your credentials with 'gobuz --reset'" ) // Client is a Qobuz API client. Methods accept ctx as their first parameter // for cancellation; the Client itself does not store a context. type Client struct { AppID string Secrets []string UAT string // user_auth_token UserID string Label string // subscription tier Secret string // validated app secret http *http.Client } // New creates a Client without authenticating. func New(appID string, secrets []string) *Client { return &Client{ AppID: appID, Secrets: secrets, http: &http.Client{Timeout: 30 * time.Second}, } } // NewWithHTTP is New with a caller-supplied HTTP client. baseURL is a // constant, so tests in other packages need this to reach an httptest server: // they pass a client whose Transport rewrites the Qobuz host. Production code // should call New. func NewWithHTTP(appID string, secrets []string, hc *http.Client) *Client { c := New(appID, secrets) if hc != nil { c.http = hc } return c } func (c *Client) doRequest(ctx context.Context, method, endpoint string, params url.Values, body string, dest any) error { fullURL := baseURL + endpoint var reqBody io.Reader if body != "" { reqBody = strings.NewReader(body) } req, err := http.NewRequestWithContext(ctx, method, fullURL, reqBody) if err != nil { return err } req.Header.Set("User-Agent", userAgent) req.Header.Set("X-App-Id", c.AppID) if body != "" { req.Header.Set("Content-Type", "text/plain;charset=UTF-8") } else { req.Header.Set("Content-Type", "application/json;charset=UTF-8") } if c.UAT != "" { req.Header.Set("X-User-Auth-Token", c.UAT) } if params != nil { req.URL.RawQuery = params.Encode() } resp, err := c.http.Do(req) if err != nil { return err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return err } if endpoint == "user/login" { switch resp.StatusCode { case 401: return &AuthenticationError{"Invalid credentials. " + resetMsg} case 400: return &InvalidAppIDError{"Invalid app id. " + resetMsg} } } else if (endpoint == "track/getFileUrl" || endpoint == "favorite/getUserFavorites") && resp.StatusCode == 400 { return &InvalidAppSecretError{fmt.Sprintf("Invalid app secret: %s. %s", string(respBody), resetMsg)} } if resp.StatusCode >= 400 { return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) } if dest != nil { if raw, ok := dest.(*[]byte); ok { *raw = respBody return nil } if err := json.Unmarshal(respBody, dest); err != nil { return fmt.Errorf("decode response: %w (body: %s)", err, string(respBody)) } } return nil } func (c *Client) get(ctx context.Context, endpoint string, params url.Values, dest any) error { return c.doRequest(ctx, "GET", endpoint, params, "", dest) } func (c *Client) post(ctx context.Context, endpoint, body string, dest any) error { return c.doRequest(ctx, "POST", endpoint, nil, body, dest) } func md5hex(s string) string { return fmt.Sprintf("%x", md5.Sum([]byte(s))) } func (c *Client) signParams(endpoint string, params url.Values) url.Values { if params == nil { params = make(url.Values) } method := strings.ReplaceAll(endpoint, "/", "") unix := strconv.FormatInt(time.Now().Unix(), 10) keys := make([]string, 0, len(params)) for k := range params { keys = append(keys, k) } sort.Strings(keys) var sb strings.Builder sb.WriteString(method) for _, k := range keys { for _, v := range params[k] { sb.WriteString(k) sb.WriteString(v) } } secret := c.Secret if secret == "" && len(c.Secrets) > 0 { secret = c.Secrets[0] } sb.WriteString(unix) sb.WriteString(secret) signedParams := make(url.Values, len(params)+2) for k, v := range params { signedParams[k] = v } signedParams.Set("request_ts", unix) signedParams.Set("request_sig", md5hex(sb.String())) return signedParams } func (c *Client) getSigned(ctx context.Context, endpoint string, params url.Values, dest any) error { return c.get(ctx, endpoint, c.signParams(endpoint, params), dest) } // GetRaw executes a GET request against the specified Qobuz endpoint and returns the raw response body. func (c *Client) GetRaw(ctx context.Context, endpoint string, params url.Values) ([]byte, error) { var raw []byte if err := c.get(ctx, endpoint, params, &raw); err != nil { return nil, err } return raw, nil } // GetRawSigned executes a signed GET request against the specified Qobuz endpoint and returns the raw response body. func (c *Client) GetRawSigned(ctx context.Context, endpoint string, params url.Values) ([]byte, error) { var raw []byte if err := c.getSigned(ctx, endpoint, params, &raw); err != nil { return nil, err } return raw, nil } // AuthWithToken authenticates using user_id + user_auth_token obtained via OAuth. func (c *Client) AuthWithToken(ctx context.Context, userID, userAuthToken string) error { params := url.Values{ "user_id": {userID}, "user_auth_token": {userAuthToken}, "app_id": {c.AppID}, } var info UserLoginResponse if err := c.get(ctx, "user/login", params, &info); err != nil { return &AuthenticationError{fmt.Sprintf("user/login: %v", err)} } c.UAT = userAuthToken c.UserID = userID return c.extractUserInfo(&info) } func (c *Client) extractUserInfo(info *UserLoginResponse) error { if info == nil { return &AuthenticationError{"unexpected response shape"} } if info.User.Credential.Parameters == nil { return &IneligibleError{"Free accounts are not eligible to download tracks."} } if c.UAT == "" && info.UserAuthToken != "" { c.UAT = info.UserAuthToken } if c.UserID == "" && string(info.User.ID) != "" { c.UserID = string(info.User.ID) } if info.User.Credential.Parameters.ShortLabel != "" { c.Label = info.User.Credential.Parameters.ShortLabel } return nil } // LoginWithOAuthToken authenticates the client using an OAuth token directly. func (c *Client) LoginWithOAuthToken(ctx context.Context, token, userID string) error { c.UAT = token if userID != "" { c.UserID = userID } var info UserLoginResponse if err := c.post(ctx, "user/login", "extra=partner", &info); err != nil { return fmt.Errorf("user/login with OAuth token: %w", err) } return c.extractUserInfo(&info) } // LoginWithOAuthCode exchanges an authorization code for a token and authenticates the client. func (c *Client) LoginWithOAuthCode(ctx context.Context, code, privateKey string) error { return c.exchangeOAuthCode(ctx, code, privateKey) } // exchangeOAuthCode tries to exchange a code for a token via /oauth/callback. // Qobuz has used different parameter names and HTTP methods over time, so we // try all combinations: (GET|POST) × ("code"|"code_autorisation"). func (c *Client) exchangeOAuthCode(ctx context.Context, code, privateKey string) error { type attempt struct { method string paramName string } // "code" first — Qobuz error messages say "Missing argument: code" attempts := []attempt{ {"GET", "code"}, {"POST", "code"}, {"GET", "code_autorisation"}, {"POST", "code_autorisation"}, } type oauthCallbackResponse struct { Token string `json:"token"` UserAuthToken string `json:"user_auth_token"` User UserInfo `json:"user"` } var lastErr error for _, a := range attempts { params := url.Values{ a.paramName: {code}, "app_id": {c.AppID}, } if privateKey != "" { params.Set("private_key", privateKey) } var ( resp oauthCallbackResponse err error ) if a.method == "GET" { err = c.get(ctx, "oauth/callback", params, &resp) } else { err = c.post(ctx, "oauth/callback", params.Encode(), &resp) } if err != nil { lastErr = err continue // try next combination } // Exchange succeeded — extract token token := resp.Token if token == "" { // Some Qobuz flows return user info directly without a separate token field if string(resp.User.ID) != "" { loginResp := UserLoginResponse{ UserAuthToken: resp.UserAuthToken, User: resp.User, } return c.extractUserInfo(&loginResp) } lastErr = &AuthenticationError{"no token in oauth/callback response"} continue } c.UAT = token var info UserLoginResponse if err := c.post(ctx, "user/login", "extra=partner", &info); err != nil { return fmt.Errorf("user/login after OAuth: %w", err) } return c.extractUserInfo(&info) } return fmt.Errorf("oauth code exchange failed (tried all GET/POST combinations): %w", lastErr) } // CfgSetup validates secrets and picks the first working one. func (c *Client) CfgSetup(ctx context.Context) error { for _, secret := range c.Secrets { if secret == "" { continue } if c.testSecret(ctx, secret) { c.Secret = secret return nil } } return &InvalidAppSecretError{"Can't find any valid app secret. " + resetMsg} } func (c *Client) testSecret(ctx context.Context, secret string) bool { _, err := c.getTrackURLWithSecret(ctx, "5966783", 5, secret) return err == nil } // GetAlbum returns album metadata. func (c *Client) GetAlbum(ctx context.Context, id string) (*Album, error) { var album Album if err := c.get(ctx, "album/get", url.Values{"album_id": {id}}, &album); err != nil { return nil, err } return &album, nil } // GetAlbumRaw fetches the raw JSON response for an album. func (c *Client) GetAlbumRaw(ctx context.Context, id string) ([]byte, error) { return c.GetRaw(ctx, "album/get", url.Values{"album_id": {id}}) } // GetTrack returns track metadata. func (c *Client) GetTrack(ctx context.Context, id string) (*Track, error) { var track Track if err := c.get(ctx, "track/get", url.Values{"track_id": {id}}, &track); err != nil { return nil, err } return &track, nil } // GetTrackURL returns a signed download URL for a track using the active app secret. func (c *Client) GetTrackURL(ctx context.Context, trackID string, fmtID int) (*TrackURL, error) { return c.getTrackURLWithSecret(ctx, trackID, fmtID, c.Secret) } func (c *Client) getTrackURLWithSecret(ctx context.Context, trackID string, fmtID int, secret string) (*TrackURL, error) { if fmtID != 5 && fmtID != 6 && fmtID != 7 && fmtID != 27 { return nil, &InvalidQualityError{"choose between 5, 6, 7 or 27"} } unix := strconv.FormatInt(time.Now().Unix(), 10) rawSig := fmt.Sprintf("trackgetFileUrlformat_id%dintentstreamtrack_id%s%s%s", fmtID, trackID, unix, secret) sig := md5hex(rawSig) var trackURL TrackURL err := c.get(ctx, "track/getFileUrl", url.Values{ "request_ts": {unix}, "request_sig": {sig}, "track_id": {trackID}, "format_id": {strconv.Itoa(fmtID)}, "intent": {"stream"}, }, &trackURL) if err != nil { return nil, err } return &trackURL, nil } type searchAlbumsResponse struct { Albums AlbumList `json:"albums"` } // SearchAlbums searches for albums returning album results. func (c *Client) SearchAlbums(ctx context.Context, query string, limit int) (*AlbumList, error) { var resp searchAlbumsResponse if err := c.get(ctx, "album/search", url.Values{"query": {query}, "limit": {strconv.Itoa(limit)}}, &resp); err != nil { return nil, err } return &resp.Albums, nil } // SearchAlbumsRaw searches for albums and returns the raw albums JSON object. func (c *Client) SearchAlbumsRaw(ctx context.Context, query string, limit int) (json.RawMessage, error) { var resp struct { Albums json.RawMessage `json:"albums"` } if err := c.get(ctx, "album/search", url.Values{"query": {query}, "limit": {strconv.Itoa(limit)}}, &resp); err != nil { return nil, err } return resp.Albums, nil } type searchArtistsResponse struct { Artists ArtistList `json:"artists"` } // SearchArtists searches for artists returning typed artist results. func (c *Client) SearchArtists(ctx context.Context, query string, limit int) (*ArtistList, error) { var resp searchArtistsResponse if err := c.get(ctx, "artist/search", url.Values{"query": {query}, "limit": {strconv.Itoa(limit)}}, &resp); err != nil { return nil, err } return &resp.Artists, nil } // SearchArtistsRaw searches for artists and returns the raw artists JSON object. func (c *Client) SearchArtistsRaw(ctx context.Context, query string, limit int) (json.RawMessage, error) { var resp struct { Artists json.RawMessage `json:"artists"` } if err := c.get(ctx, "artist/search", url.Values{"query": {query}, "limit": {strconv.Itoa(limit)}}, &resp); err != nil { return nil, err } return resp.Artists, nil } // GetArtistPage returns aggregated artist page metadata including categorized release groups. func (c *Client) GetArtistPage(ctx context.Context, artistID, sort string) (*ArtistPage, error) { params := url.Values{ "artist_id": {artistID}, } if sort != "" { params.Set("sort", sort) } var page ArtistPage if err := c.getSigned(ctx, "artist/page", params, &page); err != nil { return nil, err } return &page, nil } // GetArtistPageRaw fetches the raw JSON response for an artist page. func (c *Client) GetArtistPageRaw(ctx context.Context, artistID, sort string) ([]byte, error) { params := url.Values{ "artist_id": {artistID}, } if sort != "" { params.Set("sort", sort) } return c.GetRawSigned(ctx, "artist/page", params) } // GetArtistReleasesGrid returns paginated releases for an artist filtered by release_type. func (c *Client) GetArtistReleasesGrid(ctx context.Context, artistID, releaseType string, limit, offset int, sort string) (*ArtistReleasesGrid, error) { params := url.Values{ "artist_id": {artistID}, "release_type": {releaseType}, "limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(offset)}, } if sort != "" { params.Set("sort", sort) } var grid ArtistReleasesGrid if err := c.getSigned(ctx, "artist/getReleasesGrid", params, &grid); err != nil { return nil, err } return &grid, nil } // GetArtist fetches artist metadata by identifier. func (c *Client) GetArtist(ctx context.Context, id string) (*Artist, error) { var artist Artist if err := c.get(ctx, "artist/get", url.Values{"artist_id": {id}}, &artist); err != nil { return nil, err } return &artist, nil } // CatalogSearch performs a combined search across albums, tracks, artists, and playlists. func (c *Client) CatalogSearch(ctx context.Context, query string, limit, offset int) (*SearchResults, error) { params := url.Values{ "query": {query}, "limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(offset)}, } var results SearchResults if err := c.get(ctx, "catalog/search", params, &results); err != nil { return nil, err } return &results, nil }