package qobuz import ( "bytes" "context" "crypto/aes" "crypto/cipher" "crypto/md5" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "hash" "io" "net/http" "net/url" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "time" "streamrip-go/internal/config" "streamrip-go/internal/jsonutil" "streamrip-go/internal/netutil" "streamrip-go/internal/provider" "streamrip-go/internal/ratelimit" "github.com/vbauerster/mpb/v8" "github.com/vbauerster/mpb/v8/decor" "golang.org/x/crypto/hkdf" ) const baseURL = "https://www.qobuz.com/api.json/0.2" const ( mobileAppID = "312369995" mobileAppSecret = "e79f8b9be485692b0e5f9dd895826368" mobileUserAgent = "Dalvik/2.1.0 (Linux; U; Android 9; Nexus 6P Build/PQ3A.190801.002) QobuzMobileAndroid/9.7.0.3-b26022717" mobileAppVersion = "9.7.0.3" mobileSessionProf = "qbz-1" mobileSegmentTries = 3 ) var qobuzUUIDBytes = []byte{ 0x3b, 0x42, 0x12, 0x92, 0x56, 0xf3, 0x5f, 0x75, 0x92, 0x36, 0x63, 0xb6, 0x9a, 0x1f, 0x52, 0xb2, } 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 mobileMu sync.Mutex mobileAccessToken string mobileSessionID string mobileSessionInfo string mobileKEK []byte } type mobileFileURL struct { URL string `json:"url"` URLTemplate string `json:"url_template"` NSegments int `json:"n_segments"` FormatID int `json:"format_id"` MimeType string `json:"mime_type"` Sampling float64 `json:"sampling_rate"` BitDepth int `json:"bits_depth"` Key string `json:"key"` } 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) DownloadTrackFallback(ctx context.Context, trackID string, quality int, outputPath string) error { q := &c.cfg.Session.Qobuz if strings.TrimSpace(q.EmailOrUserID) == "" || strings.TrimSpace(q.PasswordOrToken) == "" || q.UseAuthToken { return errors.New("qobuz mobile fallback requires email/password credentials") } if quality < 1 || quality > 4 { quality = q.Quality } formatID := qualityMap(quality) if err := c.ensureMobileSession(ctx); err != nil { return err } fileURL, err := c.mobileGetFileURL(ctx, trackID, formatID) if err != nil { return err } if err = os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { return err } out, err := os.Create(outputPath) if err != nil { return err } success := false defer func() { _ = out.Close() if !success { _ = os.Remove(outputPath) } }() progress := mpb.New(mpb.WithWidth(40), mpb.WithOutput(os.Stderr)) defer progress.Wait() desc := shortenName(filepath.Base(outputPath), 54) bar := progress.AddSpinner( 0, mpb.PrependDecorators( decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), ), mpb.AppendDecorators( decor.CurrentKibiByte("% .1f", decor.WCSyncWidthR), decor.Name(" | ", decor.WCSyncWidth), decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR), ), mpb.BarRemoveOnComplete(), ) defer func() { if !success { bar.Abort(true) } bar.SetTotal(-1, true) }() if strings.TrimSpace(fileURL.URL) != "" && fileURL.NSegments == 0 { err = c.mobileCopyURLToWriter(ctx, strings.TrimSpace(fileURL.URL), out, bar) if err != nil { return err } success = true return nil } if strings.TrimSpace(fileURL.URLTemplate) == "" { return errors.New("qobuz mobile fallback: no download URL available") } var trackKey []byte if strings.TrimSpace(fileURL.Key) != "" { trackKey, err = c.mobileDeriveTrackKey(fileURL.Key) if err != nil { return fmt.Errorf("derive mobile track key: %w", err) } } nSegs := fileURL.NSegments if nSegs == 0 { nSegs = 1 } initURL := strings.Replace(fileURL.URLTemplate, "$SEGMENT$", "0", 1) initData, err := c.mobileDownloadSegment(ctx, initURL) if err != nil { return fmt.Errorf("download init segment: %w", err) } if hdr := extractFLACHeader(initData); hdr != nil { if _, err = out.Write(hdr); err != nil { return err } bar.IncrBy(len(hdr)) } else { if _, err = out.Write(initData); err != nil { return err } bar.IncrBy(len(initData)) } for seg := 1; seg <= nSegs; seg++ { segURL := strings.Replace(fileURL.URLTemplate, "$SEGMENT$", strconv.Itoa(seg), 1) data, dlErr := c.mobileDownloadSegment(ctx, segURL) if dlErr != nil { return fmt.Errorf("download segment %d: %w", seg, dlErr) } frames := extractFrames(data, trackKey) if _, err = out.Write(frames); err != nil { return err } bar.IncrBy(len(frames)) } if err = out.Sync(); err != nil { return err } success = true 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(``) 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\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[\w=]+)",window\.utimezone\.(?P[a-z]+)\)`) infoExtrasTemplate := `name:"\w+/(?P%s)",info:"(?P[\w=]+)",extras:"(?P[\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 } func (c *Client) ensureMobileSession(ctx context.Context) error { c.mobileMu.Lock() defer c.mobileMu.Unlock() if c.mobileAccessToken != "" && c.mobileSessionID != "" && c.mobileSessionInfo != "" { return nil } q := &c.cfg.Session.Qobuz if err := c.mobileLogin(ctx, q.EmailOrUserID, q.PasswordOrToken); err != nil { return err } if err := c.mobileStartSession(ctx); err != nil { c.mobileAccessToken = "" return err } return nil } func (c *Client) mobileLogin(ctx context.Context, username, password string) error { ts := time.Now().Unix() params := url.Values{} params.Set("app_id", mobileAppID) params.Set("username", username) params.Set("password", password) params.Set("request_ts", strconv.FormatInt(ts, 10)) params.Set("request_sig", mobileSignRequest("oauth2/login", []kv{{"password", password}, {"username", username}}, ts)) reqURL := baseURL + "/oauth2/login?" + params.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return err } c.setMobileHeaders(req) resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("mobile login request failed: %w", err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode != http.StatusOK { return fmt.Errorf("mobile login failed: status=%d body=%s", resp.StatusCode, string(body)) } var parsed struct { OAuth2 struct { AccessToken string `json:"access_token"` } `json:"oauth2"` } if err = json.Unmarshal(body, &parsed); err != nil { return fmt.Errorf("mobile login parse failed: %w", err) } if strings.TrimSpace(parsed.OAuth2.AccessToken) == "" { return errors.New("mobile login returned empty token") } c.mobileAccessToken = strings.TrimSpace(parsed.OAuth2.AccessToken) return nil } func (c *Client) mobileStartSession(ctx context.Context) error { ts := time.Now().Unix() params := url.Values{} params.Set("app_id", mobileAppID) params.Set("request_ts", strconv.FormatInt(ts, 10)) params.Set("request_sig", mobileSignRequest("session/start", []kv{{"profile", mobileSessionProf}}, ts)) reqURL := baseURL + "/session/start?" + params.Encode() form := url.Values{} form.Set("profile", mobileSessionProf) req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, strings.NewReader(form.Encode())) if err != nil { return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") c.setMobileHeaders(req) resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("mobile session request failed: %w", err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode != http.StatusOK { return fmt.Errorf("mobile session start failed: status=%d body=%s", resp.StatusCode, string(body)) } var parsed struct { SessionID string `json:"session_id"` Infos string `json:"infos"` } if err = json.Unmarshal(body, &parsed); err != nil { return fmt.Errorf("mobile session parse failed: %w", err) } if strings.TrimSpace(parsed.SessionID) == "" || strings.TrimSpace(parsed.Infos) == "" { return errors.New("mobile session start returned incomplete session data") } c.mobileSessionID = strings.TrimSpace(parsed.SessionID) c.mobileSessionInfo = strings.TrimSpace(parsed.Infos) c.mobileKEK = nil return nil } func (c *Client) mobileGetFileURL(ctx context.Context, trackID string, formatID int) (*mobileFileURL, error) { ts := time.Now().Unix() params := url.Values{} params.Set("app_id", mobileAppID) params.Set("track_id", trackID) params.Set("format_id", strconv.Itoa(formatID)) params.Set("intent", "stream") params.Set("request_ts", strconv.FormatInt(ts, 10)) params.Set("request_sig", mobileSignRequest("file/url", []kv{{"format_id", strconv.Itoa(formatID)}, {"intent", "stream"}, {"track_id", trackID}}, ts)) reqURL := baseURL + "/file/url?" + params.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return nil, err } c.setMobileHeaders(req) resp, err := c.http.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("mobile file url failed: status=%d body=%s", resp.StatusCode, string(body)) } var parsed mobileFileURL if err = json.Unmarshal(body, &parsed); err != nil { return nil, fmt.Errorf("mobile file url parse failed: %w", err) } return &parsed, nil } func (c *Client) mobileCopyURLToWriter(ctx context.Context, sourceURL string, out io.Writer, bar *mpb.Bar) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) if err != nil { return err } c.setMobileHeaders(req) resp, err := c.http.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("mobile fallback download failed: status=%d", resp.StatusCode) } written, err := io.Copy(out, &countingReader{r: resp.Body, onRead: func(n int) { if bar != nil && n > 0 { bar.IncrBy(n) } }}) if err != nil { return err } if resp.ContentLength > 0 && written != resp.ContentLength { return io.ErrUnexpectedEOF } return nil } type countingReader struct { r io.Reader onRead func(int) } func (c *countingReader) Read(p []byte) (int, error) { n, err := c.r.Read(p) if n > 0 && c.onRead != nil { c.onRead(n) } return n, err } func shortenName(name string, max int) string { if max <= 0 { return name } r := []rune(name) if len(r) <= max { return name } if max <= 3 { return string(r[:max]) } return string(r[:max-3]) + "..." } func (c *Client) mobileDownloadSegment(ctx context.Context, sourceURL string) ([]byte, error) { var lastErr error for attempt := 0; attempt < mobileSegmentTries; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) if err != nil { return nil, err } c.setMobileHeaders(req) resp, err := c.http.Do(req) if err != nil { lastErr = err time.Sleep(time.Duration(500*(attempt+1)) * time.Millisecond) continue } if resp.StatusCode != http.StatusOK { lastErr = fmt.Errorf("status=%d", resp.StatusCode) _ = resp.Body.Close() time.Sleep(time.Duration(500*(attempt+1)) * time.Millisecond) continue } data, readErr := io.ReadAll(resp.Body) _ = resp.Body.Close() if readErr != nil { lastErr = readErr time.Sleep(time.Duration(500*(attempt+1)) * time.Millisecond) continue } return data, nil } if lastErr == nil { lastErr = errors.New("unknown segment error") } return nil, fmt.Errorf("mobile segment download failed after retries: %w", lastErr) } func (c *Client) setMobileHeaders(req *http.Request) { req.Header.Set("User-Agent", mobileUserAgent) req.Header.Set("X-App-Id", mobileAppID) req.Header.Set("X-App-Version", mobileAppVersion) req.Header.Set("X-Device-Platform", "android") req.Header.Set("X-Device-Model", "Nexus 6P") req.Header.Set("X-Device-Os-Version", "9") if c.mobileAccessToken != "" { req.Header.Set("Authorization", "Bearer "+c.mobileAccessToken) } if c.mobileSessionID != "" { req.Header.Set("X-Session-Id", c.mobileSessionID) } } func mobileSignRequest(endpoint string, params []kv, ts int64) string { method := strings.ReplaceAll(endpoint, "/", "") sortKVs(params) var sb strings.Builder sb.WriteString(method) for _, p := range params { sb.WriteString(p.Key) sb.WriteString(p.Value) } sb.WriteString(strconv.FormatInt(ts, 10)) sb.WriteString(mobileAppSecret) h := md5.Sum([]byte(sb.String())) return hex.EncodeToString(h[:]) } type kv struct { Key string Value string } func sortKVs(s []kv) { for i := 0; i < len(s); i++ { for j := i + 1; j < len(s); j++ { if s[j].Key < s[i].Key { s[i], s[j] = s[j], s[i] } } } } func (c *Client) mobileDeriveTrackKey(encryptedKey string) ([]byte, error) { if len(c.mobileKEK) == 16 { return unwrapQobuzTrackKey(encryptedKey, c.mobileKEK) } parts := strings.SplitN(c.mobileSessionInfo, ".", 2) if len(parts) != 2 { return nil, errors.New("invalid mobile session infos format") } salt, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return nil, fmt.Errorf("decode mobile salt: %w", err) } info, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return nil, fmt.Errorf("decode mobile info: %w", err) } reader := hkdf.New(func() hash.Hash { return sha256.New() }, hexDecodeOrNil(mobileAppSecret), salt, info) kek := make([]byte, 16) if _, err = io.ReadFull(reader, kek); err != nil { return nil, fmt.Errorf("mobile hkdf derive failed: %w", err) } c.mobileKEK = kek return unwrapQobuzTrackKey(encryptedKey, kek) } func hexDecodeOrNil(s string) []byte { b, _ := hex.DecodeString(s) return b } func unwrapQobuzTrackKey(encryptedKey string, kek []byte) ([]byte, error) { parts := strings.SplitN(encryptedKey, ".", 3) if len(parts) != 3 || parts[0] != mobileSessionProf { return nil, errors.New("invalid qobuz track key format") } encKey, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return nil, fmt.Errorf("decode encrypted key failed: %w", err) } iv, err := base64.RawURLEncoding.DecodeString(parts[2]) if err != nil { return nil, fmt.Errorf("decode key iv failed: %w", err) } block, err := aes.NewCipher(kek) if err != nil { return nil, err } decrypted := make([]byte, len(encKey)) mode := cipher.NewCBCDecrypter(block, iv) mode.CryptBlocks(decrypted, encKey) if len(decrypted) < 16 { return nil, errors.New("decrypted key too short") } return decrypted[:16], nil } func extractFLACHeader(data []byte) []byte { blocks := findDFLABlocks(data) if blocks == nil { return nil } out := make([]byte, 4+len(blocks)) copy(out, "fLaC") copy(out[4:], blocks) return out } func findDFLABlocks(data []byte) []byte { pos := 0 for pos+8 <= len(data) { size := int(uint32(data[pos])<<24 | uint32(data[pos+1])<<16 | uint32(data[pos+2])<<8 | uint32(data[pos+3])) if size < 8 || pos+size > len(data) { break } t := data[pos+4 : pos+8] if string(t) == "dfLa" { body := data[pos+8 : pos+size] if len(body) > 4 { return body[4:] } } var inner []byte switch string(t) { case "moov", "trak", "mdia", "minf", "stbl": inner = data[pos+8 : pos+size] case "stsd": if pos+16 <= pos+size { inner = data[pos+16 : pos+size] } case "fLaC": if pos+36 <= pos+size { inner = data[pos+36 : pos+size] } } if inner != nil { if result := findDFLABlocks(inner); result != nil { return result } } pos += size } return nil } func extractFrames(data []byte, key []byte) []byte { var frames []byte pos := 0 for pos+8 <= len(data) { boxSize := int(uint32(data[pos])<<24 | uint32(data[pos+1])<<16 | uint32(data[pos+2])<<8 | uint32(data[pos+3])) if boxSize < 8 || pos+boxSize > len(data) { break } if string(data[pos+4:pos+8]) == "uuid" && boxSize >= 36 { if pos+24 > len(data) { pos += boxSize continue } if bytes.Equal(data[pos+8:pos+24], qobuzUUIDBytes) { f := parseUUIDBox(data, pos, boxSize, key) frames = append(frames, f...) } } pos += boxSize } return frames } func parseUUIDBox(data []byte, boxStart, boxSize int, key []byte) []byte { bodyOff := boxStart + 24 if bodyOff+12 > len(data) { return nil } rawOffset := readU32BE(data, bodyOff+4) numSamples := int(readU24BE(data, bodyOff+9)) if numSamples == 0 || numSamples > 10000 { return nil } tableOff := bodyOff + 12 sampleDataOff := boxStart + int(rawOffset) var frames []byte offset := sampleDataOff for i := 0; i < numSamples; i++ { et := tableOff + i*16 if et+16 > len(data) || offset >= len(data) { break } size := readU32BE(data, et) encFlag := data[et+6] != 0 || data[et+7] != 0 end := offset + int(size) if end > len(data) { break } if encFlag && len(key) == 16 { iv := make([]byte, 16) copy(iv[:8], data[et+8:et+16]) block, err := aes.NewCipher(key) if err != nil { return frames } stream := cipher.NewCTR(block, iv) decrypted := make([]byte, end-offset) stream.XORKeyStream(decrypted, data[offset:end]) frames = append(frames, decrypted...) } else { frames = append(frames, data[offset:end]...) } offset = end } return frames } func readU32BE(data []byte, off int) uint32 { if off+4 > len(data) { return 0 } return uint32(data[off])<<24 | uint32(data[off+1])<<16 | uint32(data[off+2])<<8 | uint32(data[off+3]) } func readU24BE(data []byte, off int) uint32 { if off+3 > len(data) { return 0 } return uint32(data[off])<<16 | uint32(data[off+1])<<8 | uint32(data[off+2]) }