467 lines
14 KiB
Go
467 lines
14 KiB
Go
package qobuz
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
apiBaseURL = "https://www.qobuz.com/api.json/0.2/"
|
|
apiUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0"
|
|
defaultQuality = 6
|
|
)
|
|
|
|
// validQuality reports whether quality is one of the Qobuz format_id values
|
|
// cliamp accepts.
|
|
//
|
|
// 5 = MP3 320kbps
|
|
// 6 = FLAC 16-bit/44.1kHz (CD)
|
|
// 7 = FLAC 24-bit up to 96kHz
|
|
// 27 = FLAC 24-bit up to 192kHz (Hi-Res)
|
|
func validQuality(quality int) bool {
|
|
switch quality {
|
|
case 5, 6, 7, 27:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// maxResponseBody limits JSON API responses to 20 MB.
|
|
const maxResponseBody = 20 << 20
|
|
|
|
// client is a Qobuz API client. It is safe for concurrent use once
|
|
// authenticated (its fields are not mutated after login).
|
|
type client struct {
|
|
appID string
|
|
secrets []string // candidate signing secrets to validate
|
|
secret string // validated signing secret (set by validateSecret)
|
|
uat string // user_auth_token
|
|
userID string
|
|
label string // subscription tier short label
|
|
http *http.Client
|
|
}
|
|
|
|
func newClient(appID string, secrets []string) *client {
|
|
return &client{
|
|
appID: appID,
|
|
secrets: secrets,
|
|
http: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
func md5hex(s string) string {
|
|
return fmt.Sprintf("%x", md5.Sum([]byte(s)))
|
|
}
|
|
|
|
// doRequest performs a Qobuz API request and returns the raw response body.
|
|
func (c *client) doRequest(ctx context.Context, method, endpoint string, params url.Values, body string) ([]byte, error) {
|
|
var reqBody io.Reader
|
|
if body != "" {
|
|
reqBody = strings.NewReader(body)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, apiBaseURL+endpoint, reqBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("qobuz: %s: build request: %w", endpoint, err)
|
|
}
|
|
req.Header.Set("User-Agent", apiUA)
|
|
req.Header.Set("X-App-Id", c.appID)
|
|
if body != "" {
|
|
// Request bodies are always form-encoded (user/login, oauth/callback).
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;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 nil, fmt.Errorf("qobuz: %s: request: %w", endpoint, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("qobuz: %s: read response: %w", endpoint, err)
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("qobuz: %s: HTTP %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(respBody)))
|
|
}
|
|
return respBody, nil
|
|
}
|
|
|
|
// doGet performs a GET and decodes the JSON response into out.
|
|
func (c *client) doGet(ctx context.Context, endpoint string, params url.Values, out any) error {
|
|
body, err := c.doRequest(ctx, http.MethodGet, endpoint, params, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(body, out); err != nil {
|
|
return fmt.Errorf("qobuz: %s: decode: %w", endpoint, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// authWithToken authenticates using a user_id + user_auth_token obtained via
|
|
// OAuth and populates the client's user info.
|
|
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 loginResponse
|
|
if err := c.doGet(ctx, "user/login", params, &info); err != nil {
|
|
return err
|
|
}
|
|
c.uat = userAuthToken
|
|
c.userID = userID
|
|
return c.applyUserInfo(info)
|
|
}
|
|
|
|
// loginResponse is the shape of user/login and oauth/callback responses.
|
|
type loginResponse struct {
|
|
UserAuthToken string `json:"user_auth_token"`
|
|
User struct {
|
|
ID json.Number `json:"id"`
|
|
Credential struct {
|
|
Parameters *struct {
|
|
ShortLabel string `json:"short_label"`
|
|
} `json:"parameters"`
|
|
} `json:"credential"`
|
|
} `json:"user"`
|
|
}
|
|
|
|
func (c *client) applyUserInfo(info loginResponse) error {
|
|
if info.User.Credential.Parameters == nil {
|
|
return fmt.Errorf("qobuz: account is not eligible for streaming (free accounts cannot stream)")
|
|
}
|
|
if c.uat == "" {
|
|
c.uat = info.UserAuthToken
|
|
}
|
|
if c.userID == "" && info.User.ID != "" {
|
|
c.userID = info.User.ID.String()
|
|
}
|
|
c.label = info.User.Credential.Parameters.ShortLabel
|
|
return nil
|
|
}
|
|
|
|
// loginWithOAuth completes authentication from an OAuth redirect result. Qobuz
|
|
// may return either a user_auth_token directly or a code that must be exchanged.
|
|
func (c *client) loginWithOAuth(ctx context.Context, result oauthResult, privateKey string) error {
|
|
if result.Token != "" {
|
|
c.uat = result.Token
|
|
if result.UserID != "" {
|
|
c.userID = result.UserID
|
|
}
|
|
return c.loadOAuthUserInfo(ctx, "with OAuth token")
|
|
}
|
|
if result.Code != "" {
|
|
return c.exchangeOAuthCode(ctx, result.Code, privateKey)
|
|
}
|
|
return fmt.Errorf("qobuz: OAuth redirect contained neither token nor code")
|
|
}
|
|
|
|
// exchangeOAuthCode exchanges an OAuth code for a token. Qobuz has used
|
|
// different parameter names and HTTP methods over time, so all combinations of
|
|
// (GET|POST) x ("code"|"code_autorisation") are tried.
|
|
func (c *client) exchangeOAuthCode(ctx context.Context, code, privateKey string) error {
|
|
type attempt struct {
|
|
method string
|
|
paramName string
|
|
}
|
|
attempts := []attempt{
|
|
{http.MethodGet, "code"},
|
|
{http.MethodPost, "code"},
|
|
{http.MethodGet, "code_autorisation"},
|
|
{http.MethodPost, "code_autorisation"},
|
|
}
|
|
|
|
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 body []byte
|
|
var err error
|
|
if a.method == http.MethodGet {
|
|
body, err = c.doRequest(ctx, http.MethodGet, "oauth/callback", params, "")
|
|
} else {
|
|
body, err = c.doRequest(ctx, http.MethodPost, "oauth/callback", nil, params.Encode())
|
|
}
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
|
|
var resp struct {
|
|
Token string `json:"token"`
|
|
loginResponse
|
|
}
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
if resp.Token == "" {
|
|
if resp.User.Credential.Parameters != nil {
|
|
return c.applyUserInfo(resp.loginResponse)
|
|
}
|
|
lastErr = fmt.Errorf("qobuz: no token in oauth/callback response")
|
|
continue
|
|
}
|
|
|
|
c.uat = resp.Token
|
|
return c.loadOAuthUserInfo(ctx, "after OAuth")
|
|
}
|
|
return fmt.Errorf("qobuz: oauth code exchange failed: %w", lastErr)
|
|
}
|
|
|
|
func (c *client) loadOAuthUserInfo(ctx context.Context, phase string) error {
|
|
body, err := c.doRequest(ctx, http.MethodPost, "user/login", nil, "extra=partner")
|
|
if err != nil {
|
|
return fmt.Errorf("qobuz: user/login %s: %w", phase, err)
|
|
}
|
|
var info loginResponse
|
|
if err := json.Unmarshal(body, &info); err != nil {
|
|
return fmt.Errorf("qobuz: decode user/login: %w", err)
|
|
}
|
|
return c.applyUserInfo(info)
|
|
}
|
|
|
|
// validateSecret picks the first signing secret that the API accepts and stores
|
|
// it on the client. It must be called before any signed request (getFileUrl,
|
|
// favorites).
|
|
func (c *client) validateSecret(ctx context.Context) error {
|
|
if c.secret != "" {
|
|
return nil
|
|
}
|
|
for _, secret := range c.secrets {
|
|
if secret == "" {
|
|
continue
|
|
}
|
|
// 5966783 is a known public track id used purely to probe the secret.
|
|
if _, err := c.trackFileURL(ctx, "5966783", 5, secret); err == nil {
|
|
c.secret = secret
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("qobuz: no valid signing secret found")
|
|
}
|
|
|
|
// apiFileURL is the track/getFileUrl response.
|
|
//
|
|
// We use track/getFileUrl (the legacy endpoint) on purpose: it returns a plain
|
|
// "url" pointing at a complete FLAC/MP3 file that the buffered ffmpeg pipeline
|
|
// can stream directly. The current web player instead uses /file/url +
|
|
// /session/start, which returns segmented, AES-128-CTR-encrypted CMAF (qbz-1)
|
|
// requiring a full key-derivation + per-frame decryption pipeline. Per the
|
|
// SofusA/qobine reverse-engineering notes, getFileUrl "may still work but the
|
|
// web player now uses /file/url", so this is a known, monitored assumption.
|
|
type apiFileURL struct {
|
|
URL string `json:"url"`
|
|
FormatID int `json:"format_id"`
|
|
MimeType string `json:"mime_type"`
|
|
Duration int `json:"duration"`
|
|
SamplingRate float64 `json:"sampling_rate"`
|
|
BitDepth int `json:"bit_depth"`
|
|
}
|
|
|
|
// trackFileURLSig computes the request_sig for track/getFileUrl. Qobuz signs
|
|
// the concatenation of the endpoint path, the params in alphabetical order, the
|
|
// request timestamp and the app secret. The exact layout matters: a change here
|
|
// silently breaks streaming, so it's pinned by a test.
|
|
func trackFileURLSig(trackID string, formatID int, ts, secret string) string {
|
|
raw := fmt.Sprintf("trackgetFileUrlformat_id%dintentstreamtrack_id%s%s%s",
|
|
formatID, trackID, ts, secret)
|
|
return md5hex(raw)
|
|
}
|
|
|
|
// trackFileURL returns a signed streaming URL for the given track. If
|
|
// secretOverride is empty, the validated client secret is used.
|
|
func (c *client) trackFileURL(ctx context.Context, trackID string, formatID int, secretOverride string) (apiFileURL, error) {
|
|
if !validQuality(formatID) {
|
|
return apiFileURL{}, fmt.Errorf("qobuz: invalid quality %d (choose 5, 6, 7 or 27)", formatID)
|
|
}
|
|
secret := secretOverride
|
|
if secret == "" {
|
|
secret = c.secret
|
|
}
|
|
unix := strconv.FormatInt(time.Now().Unix(), 10)
|
|
params := url.Values{
|
|
"request_ts": {unix},
|
|
"request_sig": {trackFileURLSig(trackID, formatID, unix, secret)},
|
|
"track_id": {trackID},
|
|
"format_id": {strconv.Itoa(formatID)},
|
|
"intent": {"stream"},
|
|
}
|
|
var out apiFileURL
|
|
if err := c.doGet(ctx, "track/getFileUrl", params, &out); err != nil {
|
|
return apiFileURL{}, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// userPlaylists returns the authenticated user's playlists.
|
|
func (c *client) userPlaylists(ctx context.Context) ([]apiPlaylist, error) {
|
|
var out struct {
|
|
Playlists apiPlaylistList `json:"playlists"`
|
|
}
|
|
params := url.Values{"limit": {"500"}, "offset": {"0"}}
|
|
if err := c.doGet(ctx, "playlist/getUserPlaylists", params, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.Playlists.Items, nil
|
|
}
|
|
|
|
// playlistTracks returns the tracks of a playlist, following pagination.
|
|
func (c *client) playlistTracks(ctx context.Context, playlistID string) ([]apiTrack, error) {
|
|
const pageSize = 500
|
|
var all []apiTrack
|
|
for offset := 0; ; offset += pageSize {
|
|
var out apiPlaylist
|
|
params := url.Values{
|
|
"playlist_id": {playlistID},
|
|
"extra": {"tracks"},
|
|
"limit": {strconv.Itoa(pageSize)},
|
|
"offset": {strconv.Itoa(offset)},
|
|
}
|
|
if err := c.doGet(ctx, "playlist/get", params, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
if out.Tracks == nil || len(out.Tracks.Items) == 0 {
|
|
break
|
|
}
|
|
all = append(all, out.Tracks.Items...)
|
|
if offset+pageSize >= out.Tracks.Total {
|
|
break
|
|
}
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
// albumTracks returns the tracks of an album along with the album metadata.
|
|
func (c *client) albumGet(ctx context.Context, albumID string) (apiAlbum, error) {
|
|
var out apiAlbum
|
|
if err := c.doGet(ctx, "album/get", url.Values{"album_id": {albumID}}, &out); err != nil {
|
|
return apiAlbum{}, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// signedFavorites builds the request_ts/request_sig params for favorites calls.
|
|
//
|
|
// Note: favorite/getUserFavorites signs only object+method+ts+secret; the
|
|
// query params (type/limit/offset) are deliberately NOT folded into the
|
|
// signature, matching the working qobuz-dl-go behavior. The qobine reference
|
|
// documents a generic "sorted params" rule, but getUserFavorites does not
|
|
// require it in practice; do not add the params to rawSig.
|
|
func (c *client) favoriteParams(favType string, offset, limit int) url.Values {
|
|
unix := strconv.FormatInt(time.Now().Unix(), 10)
|
|
rawSig := "favoritegetUserFavorites" + unix + c.secret
|
|
return url.Values{
|
|
"app_id": {c.appID},
|
|
"user_auth_token": {c.uat},
|
|
"type": {favType},
|
|
"request_ts": {unix},
|
|
"request_sig": {md5hex(rawSig)},
|
|
"limit": {strconv.Itoa(limit)},
|
|
"offset": {strconv.Itoa(offset)},
|
|
}
|
|
}
|
|
|
|
// favoriteTracks returns the user's favorite tracks.
|
|
func (c *client) favoriteTracks(ctx context.Context, offset, limit int) ([]apiTrack, error) {
|
|
var out struct {
|
|
Tracks apiTrackList `json:"tracks"`
|
|
}
|
|
if err := c.doGet(ctx, "favorite/getUserFavorites", c.favoriteParams("tracks", offset, limit), &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.Tracks.Items, nil
|
|
}
|
|
|
|
// favoriteAlbums returns the user's favorite albums.
|
|
func (c *client) favoriteAlbums(ctx context.Context, offset, limit int) ([]apiAlbum, error) {
|
|
var out struct {
|
|
Albums apiAlbumList `json:"albums"`
|
|
}
|
|
if err := c.doGet(ctx, "favorite/getUserFavorites", c.favoriteParams("albums", offset, limit), &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.Albums.Items, nil
|
|
}
|
|
|
|
// favoriteArtists returns the user's favorite artists.
|
|
func (c *client) favoriteArtists(ctx context.Context, offset, limit int) ([]apiArtist, error) {
|
|
var out struct {
|
|
Artists apiArtistList `json:"artists"`
|
|
}
|
|
if err := c.doGet(ctx, "favorite/getUserFavorites", c.favoriteParams("artists", offset, limit), &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.Artists.Items, nil
|
|
}
|
|
|
|
// artistAlbums returns the albums for an artist, following pagination.
|
|
func (c *client) artistAlbums(ctx context.Context, artistID string) ([]apiAlbum, error) {
|
|
const pageSize = 500
|
|
var all []apiAlbum
|
|
for offset := 0; ; offset += pageSize {
|
|
var out struct {
|
|
apiArtist
|
|
Albums apiAlbumList `json:"albums"`
|
|
}
|
|
params := url.Values{
|
|
"app_id": {c.appID},
|
|
"artist_id": {artistID},
|
|
"extra": {"albums"},
|
|
"limit": {strconv.Itoa(pageSize)},
|
|
"offset": {strconv.Itoa(offset)},
|
|
}
|
|
if err := c.doGet(ctx, "artist/get", params, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(out.Albums.Items) == 0 {
|
|
break
|
|
}
|
|
all = append(all, out.Albums.Items...)
|
|
if offset+pageSize >= out.Albums.Total {
|
|
break
|
|
}
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
// searchTracks searches the Qobuz catalog for tracks.
|
|
func (c *client) searchTracks(ctx context.Context, query string, limit int) ([]apiTrack, error) {
|
|
var out struct {
|
|
Tracks apiTrackList `json:"tracks"`
|
|
}
|
|
params := url.Values{"query": {query}, "limit": {strconv.Itoa(limit)}}
|
|
if err := c.doGet(ctx, "track/search", params, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.Tracks.Items, nil
|
|
}
|