mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
1439 lines
37 KiB
Go
1439 lines
37 KiB
Go
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(`<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
|
|
}
|
|
|
|
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])
|
|
}
|