fix deezer track pagination and decryption track id resolution

This commit is contained in:
2026-04-26 22:23:23 +02:00
parent 63e1f20e04
commit 9e27ba842f
2 changed files with 271 additions and 28 deletions

View File

@@ -176,6 +176,9 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
if err != nil {
return nil, err
}
if tracks, pageErr := c.getCollectionPageItems(ctx, "/album/"+strings.TrimSpace(item)+"/tracks"); pageErr == nil {
resp["tracks"] = map[string]any{"data": tracks}
}
items := make([]any, 0)
if tracks, ok := resp["tracks"].(map[string]any); ok {
if data, ok := tracks["data"].([]any); ok {
@@ -197,6 +200,9 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
if err != nil {
return nil, err
}
if tracks, pageErr := c.getCollectionPageItems(ctx, "/playlist/"+strings.TrimSpace(item)+"/tracks"); pageErr == nil {
resp["tracks"] = map[string]any{"data": tracks}
}
items := make([]any, 0)
if tracks, ok := resp["tracks"].(map[string]any); ok {
if data, ok := tracks["data"].([]any); ok {
@@ -269,6 +275,35 @@ func (c *Client) getArtistAlbums(ctx context.Context, artistID string) (map[stri
return map[string]any{"data": all, "total": total}, nil
}
func (c *Client) getCollectionPageItems(ctx context.Context, path string) ([]any, error) {
const pageSize = 100
index := 0
total := -1
all := make([]any, 0)
for {
params := url.Values{}
params.Set("limit", strconv.Itoa(pageSize))
params.Set("index", strconv.Itoa(index))
resp, err := c.apiGet(ctx, path, params)
if err != nil {
return nil, err
}
data, _ := resp["data"].([]any)
all = append(all, data...)
if total < 0 {
total = jsonutil.IntFromAny(resp["total"])
}
if len(data) < pageSize {
break
}
index += len(data)
if total > 0 && index >= total {
break
}
}
return all, nil
}
func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*provider.Downloadable, error) {
if strings.TrimSpace(c.license) == "" {
if err := c.ensureLaunchSession(ctx); err != nil {
@@ -282,7 +317,7 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov
if err != nil {
return nil, err
}
trackToken, err := c.getTrackToken(ctx, item)
trackToken, mediaTrackID, err := c.getTrackToken(ctx, item)
if err != nil {
return nil, err
}
@@ -294,7 +329,13 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov
if ext == "" {
ext = "mp3"
}
trackID := strings.TrimSpace(jsonutil.StringFromAny(meta["id"]))
trackID := strings.TrimSpace(media.TrackID)
if trackID == "" {
trackID = strings.TrimSpace(mediaTrackID)
}
if trackID == "" {
trackID = strings.TrimSpace(jsonutil.StringFromAny(meta["id"]))
}
if trackID == "" {
trackID = strings.TrimSpace(item)
}
@@ -549,32 +590,33 @@ func (c *Client) loginWithCredentials(ctx context.Context, email, password strin
return nil
}
func (c *Client) getTrackToken(ctx context.Context, trackID string) (string, error) {
if token, err := c.getTrackTokenFromPipe(ctx, trackID); err == nil && strings.TrimSpace(token) != "" {
return token, nil
func (c *Client) getTrackToken(ctx context.Context, trackID string) (string, string, error) {
if token, mediaID, err := c.getTrackTokenFromPipe(ctx, trackID); err == nil && strings.TrimSpace(token) != "" {
return token, mediaID, nil
} else if errors.Is(err, errDeezerJWTExpired) {
c.refreshJWTFromAvailableState(ctx)
if token, retryErr := c.getTrackTokenFromPipe(ctx, trackID); retryErr == nil && strings.TrimSpace(token) != "" {
return token, nil
if token, mediaID, retryErr := c.getTrackTokenFromPipe(ctx, trackID); retryErr == nil && strings.TrimSpace(token) != "" {
return token, mediaID, nil
}
}
if err := c.ensureJWT(ctx, "deezer jwt unavailable for track media token"); err == nil {
if token, retryErr := c.getTrackTokenFromPipe(ctx, trackID); retryErr == nil && strings.TrimSpace(token) != "" {
return token, nil
if token, mediaID, retryErr := c.getTrackTokenFromPipe(ctx, trackID); retryErr == nil && strings.TrimSpace(token) != "" {
return token, mediaID, nil
}
}
resp, err := c.apiGet(ctx, "/track/"+url.PathEscape(strings.TrimSpace(trackID)), nil)
if err != nil {
return "", err
return "", "", err
}
token := strings.TrimSpace(jsonutil.StringFromAny(resp["track_token"]))
if token == "" {
return "", errors.New("deezer track metadata missing track_token")
return "", "", errors.New("deezer track metadata missing track_token")
}
return token, nil
mediaID := strings.TrimSpace(jsonutil.StringFromAny(resp["id"]))
return token, mediaID, nil
}
func (c *Client) getTrackTokenFromPipe(ctx context.Context, trackID string) (string, error) {
func (c *Client) getTrackTokenFromPipe(ctx context.Context, trackID string) (string, string, error) {
query := `query KmpMpTrackMedia($trackId: String!) { track(trackId: $trackId) { media { __typename ...TrackMediaFields } } } fragment TrackMediaFields on TrackMedia { id version token { payload expiresAt version } estimatedSizes { flac: FLAC mp3_320: MP3_320 mp3_128: MP3_128 mp3_misc: MP3_MISC opus_std: OPUS_STD opus_high: OPUS_HIGH sbc_256: SBC_256 aac_96: AAC_96 aac_64: AAC_64 ac4_ims: AC4_IMS dd_joc: DD_JOC mp4_ra1: MP4_RA1 mp4_ra2: MP4_RA2 mp4_ra3: MP4_RA3 } gain rights { sub { available } ads { available } } }`
body := map[string]any{
"operationName": "KmpMpTrackMedia",
@@ -589,13 +631,15 @@ func (c *Client) getTrackTokenFromPipe(ctx context.Context, trackID string) (str
}
out, err := c.pipeGraphQL(ctx, body, "deezer track media query")
if err != nil {
return "", err
return "", "", err
}
payload := strings.TrimSpace(jsonutil.StringFromAny(jsonutil.NestedMap(jsonutil.NestedMap(jsonutil.NestedMap(jsonutil.NestedMap(out, "data"), "track"), "media"), "token")["payload"]))
media := jsonutil.NestedMap(jsonutil.NestedMap(jsonutil.NestedMap(out, "data"), "track"), "media")
payload := strings.TrimSpace(jsonutil.StringFromAny(jsonutil.NestedMap(media, "token")["payload"]))
if payload == "" {
return "", errors.New("deezer track media response missing token payload")
return "", "", errors.New("deezer track media response missing token payload")
}
return payload, nil
mediaID := strings.TrimSpace(jsonutil.StringFromAny(media["id"]))
return payload, mediaID, nil
}
type lyricsResult struct {
@@ -1145,9 +1189,10 @@ func randomDeezerUA() string {
}
type mediaResult struct {
URL string
Format string
Cipher string
URL string
Format string
Cipher string
TrackID string
}
type deezerMediaError struct {
@@ -1270,19 +1315,50 @@ func (c *Client) getMediaURLWithRequest(ctx context.Context, trackToken string,
return nil, &deezerMediaError{Code: e.Code, Message: e.Message}
}
for _, want := range requestedFormats {
for _, m := range parsed.Data[0].Media {
if !strings.EqualFold(strings.TrimSpace(m.Format), want) {
continue
for _, preferredCipher := range []string{"NONE", "BF_CBC_STRIPE"} {
for _, m := range parsed.Data[0].Media {
if !strings.EqualFold(strings.TrimSpace(m.Format), want) {
continue
}
if !strings.EqualFold(strings.TrimSpace(m.Cipher.Type), preferredCipher) {
continue
}
if len(m.Sources) == 0 || strings.TrimSpace(m.Sources[0].URL) == "" {
continue
}
sourceURL := strings.TrimSpace(m.Sources[0].URL)
return &mediaResult{URL: sourceURL, Format: m.Format, Cipher: m.Cipher.Type, TrackID: extractTrackIDFromMediaURL(sourceURL)}, nil
}
if len(m.Sources) == 0 || strings.TrimSpace(m.Sources[0].URL) == "" {
continue
}
return &mediaResult{URL: m.Sources[0].URL, Format: m.Format, Cipher: m.Cipher.Type}, nil
}
}
return nil, errors.New("deezer media response contains no sources")
}
func extractTrackIDFromMediaURL(rawURL string) string {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return ""
}
parts := strings.Split(strings.TrimSpace(strings.Trim(u.Path, "/")), "/")
for i := len(parts) - 1; i >= 0; i-- {
p := strings.TrimSpace(parts[i])
if p == "" {
continue
}
digitsOnly := true
for _, r := range p {
if r < '0' || r > '9' {
digitsOnly = false
break
}
}
if digitsOnly {
return p
}
}
return ""
}
func buildFormatPriority(quality int, allowFallback bool) []string {
want := "FLAC"
if quality <= 0 {