Something went wrong. Try again.
Self-hosted web interface and downloader for Qobuz.
downloader self-hosted music qobuz
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500// 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}