Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

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
}