Files
streamrip-go/internal/provider/qobuz/client.go

823 lines
20 KiB
Go

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/jsonutil"
"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
fetchCfg func(ctx context.Context) (string, []string, error)
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, cfg.Session.Downloads.MaxConnections),
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
baseURL: baseURL,
fetchCfg: nil,
}
}
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
q.EmailOrUserID = strings.TrimSpace(q.EmailOrUserID)
q.PasswordOrToken = strings.TrimSpace(q.PasswordOrToken)
if q.EmailOrUserID == "" || q.PasswordOrToken == "" {
return errMissingCredentials
}
refreshed := false
if err := c.ensureAppCredentials(ctx, q); err != nil {
return err
}
loginOnce := func() (map[string]any, int, error) {
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)
}
return c.apiRequest(ctx, "user/login", params, headers)
}
resp, status, err := loginOnce()
if err != nil {
return err
}
if status != http.StatusOK && !refreshed {
if refreshErr := c.refreshAppCredentials(ctx, q); refreshErr == nil {
refreshed = true
resp, status, err = loginOnce()
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 := map[string]string{"X-App-Id": q.AppID, "X-User-Auth-Token": uat}
validSecret, err := c.getValidSecret(ctx, q.Secrets, headers)
if err != nil && !refreshed {
if refreshErr := c.refreshAppCredentials(ctx, q); refreshErr == nil {
refreshed = true
headers["X-App-Id"] = q.AppID
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) ensureAppCredentials(ctx context.Context, q *config.QobuzConfig) error {
q.AppID = strings.TrimSpace(q.AppID)
if q.AppID != "" && len(q.Secrets) > 0 {
return nil
}
return c.refreshAppCredentials(ctx, q)
}
func (c *Client) refreshAppCredentials(ctx context.Context, q *config.QobuzConfig) error {
fetch := c.fetchCfg
if fetch == nil {
fetch = c.fetchAppIDAndSecrets
}
appID, secrets, err := fetch(ctx)
if err != nil {
return err
}
q.AppID = strings.TrimSpace(appID)
if q.AppID == "" {
return errors.New("qobuz app credential refresh returned empty app_id")
}
clean := make([]string, 0, len(secrets))
for _, s := range secrets {
if v := strings.TrimSpace(s); v != "" {
clean = append(clean, v)
}
}
if len(clean) == 0 {
return errors.New("qobuz app credential refresh returned no secrets")
}
q.Secrets = append([]string(nil), clean...)
c.cfg.File.Qobuz.AppID = q.AppID
c.cfg.File.Qobuz.Secrets = append([]string(nil), clean...)
_ = c.cfg.SaveFile()
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)
}
if mediaType == "artist" {
return c.getArtist(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 "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)
streamURL = strings.TrimSpace(streamURL)
if streamURL == "" {
return nil, fmt.Errorf("track is not streamable")
}
ext := qobuzDownloadExtension(resp, quality, streamURL)
profile := qobuzAudioProfile(resp, quality, ext)
return &provider.Downloadable{
URL: streamURL,
Extension: ext,
Source: "qobuz",
Audio: profile,
}, nil
}
func qobuzDownloadExtension(resp map[string]any, quality int, streamURL string) string {
if parsed, err := url.Parse(strings.TrimSpace(streamURL)); err == nil {
p := strings.ToLower(parsed.Path)
if strings.HasSuffix(p, ".flac") {
return "flac"
}
if strings.HasSuffix(p, ".mp3") {
return "mp3"
}
}
mimeType, _ := resp["mime_type"].(string)
mimeType = strings.ToLower(strings.TrimSpace(mimeType))
if strings.Contains(mimeType, "flac") {
return "flac"
}
if strings.Contains(mimeType, "mpeg") || strings.Contains(mimeType, "mp3") {
return "mp3"
}
if formatID, ok := intValue(resp["format_id"]); ok {
if formatID == 5 {
return "mp3"
}
if formatID > 5 {
return "flac"
}
}
if quality > 1 {
return "flac"
}
return "mp3"
}
func qobuzAudioProfile(resp map[string]any, requestedQuality int, ext string) provider.AudioProfile {
if formatID, ok := intValue(resp["format_id"]); ok {
switch formatID {
case 5:
return provider.AudioProfile{
Container: "MP3",
Codec: "MP3",
Quality: "HIGH",
BitDepth: 16,
SamplingRate: "44.1",
BitrateKbps: 320,
}
case 6:
return provider.AudioProfile{
Container: "FLAC",
Codec: "FLAC",
Quality: "LOSSLESS",
BitDepth: 16,
SamplingRate: "44.1",
}
case 7:
return provider.AudioProfile{
Container: "FLAC",
Codec: "FLAC",
Quality: "HI_RES",
BitDepth: 24,
SamplingRate: "96",
}
case 27:
return provider.AudioProfile{
Container: "FLAC",
Codec: "FLAC",
Quality: "HI_RES",
BitDepth: 24,
SamplingRate: "192",
}
}
}
if strings.EqualFold(ext, "mp3") {
bitrate := 128
if requestedQuality >= 1 {
bitrate = 320
}
return provider.AudioProfile{
Container: "MP3",
Codec: "MP3",
Quality: "HIGH",
BitDepth: 16,
SamplingRate: "44.1",
BitrateKbps: bitrate,
}
}
quality := "LOSSLESS"
bitDepth := 16
sampling := "44.1"
if requestedQuality >= 4 {
quality = "HI_RES"
bitDepth = 24
sampling = "192"
} else if requestedQuality >= 3 {
quality = "HI_RES"
bitDepth = 24
sampling = "96"
}
return provider.AudioProfile{
Container: "FLAC",
Codec: "FLAC",
Quality: quality,
BitDepth: bitDepth,
SamplingRate: sampling,
}
}
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) getArtist(ctx context.Context, artistID string) (map[string]any, error) {
pageLimit := 500
params := url.Values{}
params.Set("app_id", c.cfg.Session.Qobuz.AppID)
params.Set("artist_id", artistID)
params.Set("limit", strconv.Itoa(pageLimit))
params.Set("offset", "0")
params.Set("extra", "albums")
resp, status, err := c.apiRequest(ctx, "artist/get", params, c.authHeaders())
if err != nil {
return nil, err
}
if status != http.StatusOK {
return nil, fmt.Errorf("artist/get failed: status=%d", status)
}
albumsObj, ok := mapValue(resp["albums"])
if !ok {
return resp, nil
}
items, ok := albumsObj["items"].([]any)
if !ok {
return resp, nil
}
total, _ := intValue(resp["albums_count"])
if total <= 0 {
total, _ = intValue(albumsObj["total"])
}
if total <= pageLimit && len(items) < pageLimit {
return resp, nil
}
for offset := pageLimit; ; offset += pageLimit {
if total > 0 && offset >= total {
break
}
pageParams := url.Values{}
pageParams.Set("app_id", c.cfg.Session.Qobuz.AppID)
pageParams.Set("artist_id", artistID)
pageParams.Set("limit", strconv.Itoa(pageLimit))
pageParams.Set("offset", strconv.Itoa(offset))
pageParams.Set("extra", "albums")
pageResp, pageStatus, pageErr := c.apiRequest(ctx, "artist/get", pageParams, c.authHeaders())
if pageErr != nil {
return nil, pageErr
}
if pageStatus != http.StatusOK {
return nil, fmt.Errorf("artist/get pagination failed: status=%d offset=%d", pageStatus, offset)
}
pageAlbums, ok := mapValue(pageResp["albums"])
if !ok {
break
}
pageItems, ok := pageAlbums["items"].([]any)
if !ok || len(pageItems) == 0 {
break
}
items = append(items, pageItems...)
if len(pageItems) < pageLimit {
break
}
}
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, jsonutil.TitleCase(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
}