mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
initial Go port of streamrip
This commit is contained in:
586
internal/provider/qobuz/client.go
Normal file
586
internal/provider/qobuz/client.go
Normal file
@@ -0,0 +1,586 @@
|
||||
package qobuz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
"streamrip-go/internal/netutil"
|
||||
"streamrip-go/internal/provider"
|
||||
"streamrip-go/internal/ratelimit"
|
||||
)
|
||||
|
||||
const baseURL = "https://www.qobuz.com/api.json/0.2"
|
||||
|
||||
var (
|
||||
errMissingCredentials = errors.New("missing qobuz credentials")
|
||||
errNotLoggedIn = errors.New("qobuz client not logged in")
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
cfg *config.Config
|
||||
http *http.Client
|
||||
limiter *ratelimit.Limiter
|
||||
baseURL string
|
||||
loggedIn bool
|
||||
secret string
|
||||
uat string
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) *Client {
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL),
|
||||
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Source() string {
|
||||
return "qobuz"
|
||||
}
|
||||
|
||||
func (c *Client) LoggedIn() bool {
|
||||
return c.loggedIn
|
||||
}
|
||||
|
||||
func (c *Client) Login(ctx context.Context) error {
|
||||
q := &c.cfg.Session.Qobuz
|
||||
if q.EmailOrUserID == "" || q.PasswordOrToken == "" {
|
||||
return errMissingCredentials
|
||||
}
|
||||
|
||||
if q.AppID == "" || len(q.Secrets) == 0 {
|
||||
appID, secrets, err := c.fetchAppIDAndSecrets(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q.AppID = appID
|
||||
q.Secrets = secrets
|
||||
c.cfg.File.Qobuz.AppID = appID
|
||||
c.cfg.File.Qobuz.Secrets = append([]string(nil), secrets...)
|
||||
_ = c.cfg.SaveFile()
|
||||
}
|
||||
|
||||
headers := map[string]string{"X-App-Id": q.AppID}
|
||||
params := url.Values{}
|
||||
params.Set("app_id", q.AppID)
|
||||
if q.UseAuthToken {
|
||||
params.Set("user_id", q.EmailOrUserID)
|
||||
params.Set("user_auth_token", q.PasswordOrToken)
|
||||
} else {
|
||||
params.Set("email", q.EmailOrUserID)
|
||||
params.Set("password", q.PasswordOrToken)
|
||||
}
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "user/login", params, headers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("qobuz login failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
|
||||
uat, _ := resp["user_auth_token"].(string)
|
||||
if uat == "" {
|
||||
return fmt.Errorf("qobuz login missing user_auth_token")
|
||||
}
|
||||
|
||||
headers["X-User-Auth-Token"] = uat
|
||||
validSecret, err := c.getValidSecret(ctx, q.Secrets, headers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.secret = validSecret
|
||||
c.uat = uat
|
||||
c.loggedIn = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errNotLoggedIn
|
||||
}
|
||||
if mediaType == "playlist" {
|
||||
return c.getPlaylist(ctx, item)
|
||||
}
|
||||
if mediaType == "label" {
|
||||
return c.getLabel(ctx, item)
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("app_id", c.cfg.Session.Qobuz.AppID)
|
||||
params.Set(mediaType+"_id", item)
|
||||
params.Set("limit", "500")
|
||||
params.Set("offset", "0")
|
||||
|
||||
switch mediaType {
|
||||
case "artist":
|
||||
params.Set("extra", "albums")
|
||||
case "playlist":
|
||||
params.Set("extra", "tracks")
|
||||
case "label":
|
||||
params.Set("extra", "albums")
|
||||
}
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, mediaType+"/get", params, c.authHeaders())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
msg, _ := resp["message"].(string)
|
||||
if msg == "" {
|
||||
msg = "non-streamable"
|
||||
}
|
||||
return nil, fmt.Errorf("metadata error: %s", msg)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetTrackMetadata(ctx context.Context, id string) (*TrackMetadata, error) {
|
||||
raw, err := c.GetMetadata(ctx, id, "track")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseTrackMetadata(raw)
|
||||
}
|
||||
|
||||
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errNotLoggedIn
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("query", query)
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, mediaType+"/search", params, c.authHeaders())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("search failed: status=%d", status)
|
||||
}
|
||||
|
||||
return []map[string]any{resp}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetDownloadable(ctx context.Context, item string, quality int) (*provider.Downloadable, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errNotLoggedIn
|
||||
}
|
||||
if quality < 1 || quality > 4 {
|
||||
quality = c.cfg.Session.Qobuz.Quality
|
||||
}
|
||||
|
||||
formatID := qualityMap(quality)
|
||||
requestTS := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
sigRaw := "trackgetFileUrlformat_id" + strconv.Itoa(formatID) + "intentstreamtrack_id" + item + requestTS + c.secret
|
||||
hash := md5.Sum([]byte(sigRaw))
|
||||
requestSig := hex.EncodeToString(hash[:])
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("request_ts", requestTS)
|
||||
params.Set("request_sig", requestSig)
|
||||
params.Set("track_id", item)
|
||||
params.Set("format_id", strconv.Itoa(formatID))
|
||||
params.Set("intent", "stream")
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "track/getFileUrl", params, c.authHeaders())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("downloadable lookup failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
|
||||
streamURL, _ := resp["url"].(string)
|
||||
if streamURL == "" {
|
||||
return nil, fmt.Errorf("track is not streamable")
|
||||
}
|
||||
|
||||
ext := "mp3"
|
||||
if quality > 1 {
|
||||
ext = "flac"
|
||||
}
|
||||
|
||||
return &provider.Downloadable{
|
||||
URL: streamURL,
|
||||
Extension: ext,
|
||||
Source: "qobuz",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) getPlaylist(ctx context.Context, playlistID string) (map[string]any, error) {
|
||||
pageLimit := 500
|
||||
params := url.Values{}
|
||||
params.Set("app_id", c.cfg.Session.Qobuz.AppID)
|
||||
params.Set("playlist_id", playlistID)
|
||||
params.Set("limit", strconv.Itoa(pageLimit))
|
||||
params.Set("offset", "0")
|
||||
params.Set("extra", "tracks")
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "playlist/get", params, c.authHeaders())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("playlist/get failed: status=%d", status)
|
||||
}
|
||||
|
||||
total, _ := intValue(resp["tracks_count"])
|
||||
if total <= pageLimit {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
tracksObj, ok := mapValue(resp["tracks"])
|
||||
if !ok {
|
||||
return resp, nil
|
||||
}
|
||||
items, ok := tracksObj["items"].([]any)
|
||||
if !ok {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
for offset := pageLimit; offset < total; offset += pageLimit {
|
||||
pageParams := url.Values{}
|
||||
pageParams.Set("app_id", c.cfg.Session.Qobuz.AppID)
|
||||
pageParams.Set("playlist_id", playlistID)
|
||||
pageParams.Set("limit", strconv.Itoa(pageLimit))
|
||||
pageParams.Set("offset", strconv.Itoa(offset))
|
||||
pageParams.Set("extra", "tracks")
|
||||
|
||||
pageResp, pageStatus, pageErr := c.apiRequest(ctx, "playlist/get", pageParams, c.authHeaders())
|
||||
if pageErr != nil {
|
||||
return nil, pageErr
|
||||
}
|
||||
if pageStatus != http.StatusOK {
|
||||
return nil, fmt.Errorf("playlist/get pagination failed: status=%d offset=%d", pageStatus, offset)
|
||||
}
|
||||
pageTracks, ok := mapValue(pageResp["tracks"])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pageItems, ok := pageTracks["items"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, pageItems...)
|
||||
}
|
||||
|
||||
tracksObj["items"] = items
|
||||
resp["tracks"] = tracksObj
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) getLabel(ctx context.Context, labelID string) (map[string]any, error) {
|
||||
pageLimit := 500
|
||||
params := url.Values{}
|
||||
params.Set("app_id", c.cfg.Session.Qobuz.AppID)
|
||||
params.Set("label_id", labelID)
|
||||
params.Set("limit", strconv.Itoa(pageLimit))
|
||||
params.Set("offset", "0")
|
||||
params.Set("extra", "albums")
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "label/get", params, c.authHeaders())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("label/get failed: status=%d", status)
|
||||
}
|
||||
|
||||
total, _ := intValue(resp["albums_count"])
|
||||
if total <= pageLimit {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
albumsObj, ok := mapValue(resp["albums"])
|
||||
if !ok {
|
||||
return resp, nil
|
||||
}
|
||||
items, ok := albumsObj["items"].([]any)
|
||||
if !ok {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
for offset := pageLimit; offset < total; offset += pageLimit {
|
||||
pageParams := url.Values{}
|
||||
pageParams.Set("app_id", c.cfg.Session.Qobuz.AppID)
|
||||
pageParams.Set("label_id", labelID)
|
||||
pageParams.Set("limit", strconv.Itoa(pageLimit))
|
||||
pageParams.Set("offset", strconv.Itoa(offset))
|
||||
pageParams.Set("extra", "albums")
|
||||
|
||||
pageResp, pageStatus, pageErr := c.apiRequest(ctx, "label/get", pageParams, c.authHeaders())
|
||||
if pageErr != nil {
|
||||
return nil, pageErr
|
||||
}
|
||||
if pageStatus != http.StatusOK {
|
||||
return nil, fmt.Errorf("label/get pagination failed: status=%d offset=%d", pageStatus, offset)
|
||||
}
|
||||
pageAlbums, ok := mapValue(pageResp["albums"])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pageItems, ok := pageAlbums["items"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, pageItems...)
|
||||
}
|
||||
|
||||
albumsObj["items"] = items
|
||||
resp["albums"] = albumsObj
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) authHeaders() map[string]string {
|
||||
headers := map[string]string{"X-App-Id": c.cfg.Session.Qobuz.AppID}
|
||||
if c.uat != "" {
|
||||
headers["X-User-Auth-Token"] = c.uat
|
||||
} else if c.cfg.Session.Qobuz.PasswordOrToken != "" && c.cfg.Session.Qobuz.UseAuthToken {
|
||||
headers["X-User-Auth-Token"] = c.cfg.Session.Qobuz.PasswordOrToken
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
func (c *Client) getValidSecret(ctx context.Context, secrets []string, headers map[string]string) (string, error) {
|
||||
type candidate struct {
|
||||
secret string
|
||||
valid bool
|
||||
}
|
||||
|
||||
results := make([]candidate, 0, len(secrets))
|
||||
for _, secret := range secrets {
|
||||
ok := c.testSecret(ctx, secret, headers)
|
||||
results = append(results, candidate{secret: secret, valid: ok})
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
if result.valid {
|
||||
return result.secret, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no valid qobuz app secret")
|
||||
}
|
||||
|
||||
func (c *Client) testSecret(ctx context.Context, secret string, headers map[string]string) bool {
|
||||
formatID := qualityMap(4)
|
||||
requestTS := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
sigRaw := "trackgetFileUrlformat_id" + strconv.Itoa(formatID) + "intentstreamtrack_id19512574" + requestTS + secret
|
||||
hash := md5.Sum([]byte(sigRaw))
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("request_ts", requestTS)
|
||||
params.Set("request_sig", hex.EncodeToString(hash[:]))
|
||||
params.Set("track_id", "19512574")
|
||||
params.Set("format_id", strconv.Itoa(formatID))
|
||||
params.Set("intent", "stream")
|
||||
|
||||
_, status, err := c.apiRequest(ctx, "track/getFileUrl", params, headers)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return status == http.StatusOK || status == http.StatusUnauthorized
|
||||
}
|
||||
|
||||
func (c *Client) apiRequest(ctx context.Context, endpoint string, params url.Values, headers map[string]string) (map[string]any, int, error) {
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
reqURL := baseURL + "/" + endpoint
|
||||
if c.baseURL != "" {
|
||||
reqURL = c.baseURL + "/" + endpoint
|
||||
}
|
||||
if len(params) > 0 {
|
||||
reqURL += "?" + params.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", "streamrip-go/0.1")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
|
||||
parsed := map[string]any{}
|
||||
if len(body) > 0 {
|
||||
if err = json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
}
|
||||
|
||||
return parsed, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func qualityMap(quality int) int {
|
||||
mapVals := []int{5, 6, 7, 27}
|
||||
if quality < 1 || quality > 4 {
|
||||
return mapVals[2]
|
||||
}
|
||||
return mapVals[quality-1]
|
||||
}
|
||||
|
||||
func (c *Client) fetchAppIDAndSecrets(ctx context.Context) (string, []string, error) {
|
||||
loginReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://play.qobuz.com/login", nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
loginResp, err := c.http.Do(loginReq)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer func() { _ = loginResp.Body.Close() }()
|
||||
|
||||
loginBody, err := io.ReadAll(loginResp.Body)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
bundleRe := regexp.MustCompile(`<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>`)
|
||||
bundleMatch := bundleRe.FindStringSubmatch(string(loginBody))
|
||||
if len(bundleMatch) < 2 {
|
||||
return "", nil, fmt.Errorf("could not find qobuz bundle js")
|
||||
}
|
||||
|
||||
bundleURL := "https://play.qobuz.com" + bundleMatch[1]
|
||||
bundleReq, err := http.NewRequestWithContext(ctx, http.MethodGet, bundleURL, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
bundleResp, err := c.http.Do(bundleReq)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer func() { _ = bundleResp.Body.Close() }()
|
||||
bundleBody, err := io.ReadAll(bundleResp.Body)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
bundle := string(bundleBody)
|
||||
appIDRe := regexp.MustCompile(`production:{api:{appId:"(?P<app_id>\d{9})",appSecret:"(\w{32})`)
|
||||
appIDMatch := appIDRe.FindStringSubmatch(bundle)
|
||||
if len(appIDMatch) < 2 {
|
||||
return "", nil, fmt.Errorf("could not parse qobuz app id")
|
||||
}
|
||||
appID := appIDMatch[1]
|
||||
|
||||
seedTZRe := regexp.MustCompile(`[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.utimezone\.(?P<timezone>[a-z]+)\)`)
|
||||
infoExtrasTemplate := `name:"\w+/(?P<timezone>%s)",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)"`
|
||||
|
||||
type seedParts struct {
|
||||
timezone string
|
||||
parts []string
|
||||
}
|
||||
|
||||
matches := seedTZRe.FindAllStringSubmatch(bundle, -1)
|
||||
idxSeed := seedTZRe.SubexpIndex("seed")
|
||||
idxTZ := seedTZRe.SubexpIndex("timezone")
|
||||
if len(matches) < 2 {
|
||||
return appID, nil, fmt.Errorf("could not parse qobuz secrets seeds")
|
||||
}
|
||||
|
||||
ordered := make([]seedParts, 0, len(matches))
|
||||
seen := map[string]bool{}
|
||||
for _, m := range matches {
|
||||
tz := m[idxTZ]
|
||||
seed := m[idxSeed]
|
||||
if !seen[tz] {
|
||||
ordered = append(ordered, seedParts{timezone: tz, parts: []string{seed}})
|
||||
seen[tz] = true
|
||||
}
|
||||
}
|
||||
if len(ordered) >= 2 {
|
||||
ordered[0], ordered[1] = ordered[1], ordered[0]
|
||||
}
|
||||
|
||||
tzNames := make([]string, 0, len(ordered))
|
||||
for _, o := range ordered {
|
||||
tzNames = append(tzNames, strings.Title(o.timezone))
|
||||
}
|
||||
infoRe := regexp.MustCompile(fmt.Sprintf(infoExtrasTemplate, strings.Join(tzNames, "|")))
|
||||
idxInfo := infoRe.SubexpIndex("info")
|
||||
idxExtras := infoRe.SubexpIndex("extras")
|
||||
idxInfoTZ := infoRe.SubexpIndex("timezone")
|
||||
|
||||
byTZ := map[string][]string{}
|
||||
for _, o := range ordered {
|
||||
byTZ[o.timezone] = append([]string(nil), o.parts...)
|
||||
}
|
||||
|
||||
for _, m := range infoRe.FindAllStringSubmatch(bundle, -1) {
|
||||
tz := strings.ToLower(m[idxInfoTZ])
|
||||
byTZ[tz] = append(byTZ[tz], m[idxInfo], m[idxExtras])
|
||||
}
|
||||
|
||||
final := make([]string, 0, len(byTZ))
|
||||
for _, tz := range sortedKeys(byTZ) {
|
||||
joined := strings.Join(byTZ[tz], "")
|
||||
if len(joined) < 44 {
|
||||
continue
|
||||
}
|
||||
dec, err := base64.StdEncoding.DecodeString(joined[:len(joined)-44])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
secret := string(dec)
|
||||
if secret != "" {
|
||||
final = append(final, secret)
|
||||
}
|
||||
}
|
||||
|
||||
if len(final) == 0 {
|
||||
return appID, nil, fmt.Errorf("could not decode qobuz secrets")
|
||||
}
|
||||
|
||||
return appID, final, nil
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string][]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
Reference in New Issue
Block a user