diff --git a/internal/provider/deezer/client.go b/internal/provider/deezer/client.go index e5d8695..d781ac0 100644 --- a/internal/provider/deezer/client.go +++ b/internal/provider/deezer/client.go @@ -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 { diff --git a/internal/provider/deezer/client_test.go b/internal/provider/deezer/client_test.go index e8575a3..48b35af 100644 --- a/internal/provider/deezer/client_test.go +++ b/internal/provider/deezer/client_test.go @@ -99,6 +99,118 @@ func TestGetMetadataArtistPaginatesAlbums(t *testing.T) { } } +func TestGetMetadataAlbumPaginatesTracks(t *testing.T) { + callCount := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/album/46514392": + _ = json.NewEncoder(w).Encode(map[string]any{"id": 46514392, "title": "Clouseau30", "tracks": map[string]any{"data": []any{}}}) + case "/album/46514392/tracks": + callCount++ + index := r.URL.Query().Get("index") + limit := r.URL.Query().Get("limit") + if limit != "100" { + w.WriteHeader(http.StatusBadRequest) + return + } + switch index { + case "0": + items := make([]any, 0, 100) + for i := 0; i < 100; i++ { + items = append(items, map[string]any{"id": i + 1, "title": "T"}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": items, "total": 105}) + case "100": + items := make([]any, 0, 5) + for i := 0; i < 5; i++ { + items = append(items, map[string]any{"id": 101 + i, "title": "T"}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": items, "total": 105}) + default: + w.WriteHeader(http.StatusBadRequest) + } + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfgData := config.DefaultConfigData() + c := New(&config.Config{File: cfgData, Session: cfgData}) + c.loggedIn = true + + origBase := baseURL + baseURL = ts.URL + defer func() { baseURL = origBase }() + + meta, err := c.GetMetadata(context.Background(), "46514392", "album") + if err != nil { + t.Fatalf("GetMetadata() error = %v", err) + } + tracksObj, _ := meta["tracks"].(map[string]any) + items, _ := tracksObj["items"].([]any) + if len(items) != 105 { + t.Fatalf("tracks len = %d, want 105", len(items)) + } + if callCount != 2 { + t.Fatalf("track page call count = %d, want 2", callCount) + } +} + +func TestGetMetadataPlaylistPaginatesTracks(t *testing.T) { + callCount := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/playlist/123": + _ = json.NewEncoder(w).Encode(map[string]any{"id": 123, "title": "Mix", "tracks": map[string]any{"data": []any{}}}) + case "/playlist/123/tracks": + callCount++ + index := r.URL.Query().Get("index") + limit := r.URL.Query().Get("limit") + if limit != "100" { + w.WriteHeader(http.StatusBadRequest) + return + } + switch index { + case "0": + items := make([]any, 0, 100) + for i := 0; i < 100; i++ { + items = append(items, map[string]any{"id": i + 1, "title": "T"}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": items, "total": 101}) + case "100": + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": 101, "title": "T"}}, "total": 101}) + default: + w.WriteHeader(http.StatusBadRequest) + } + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfgData := config.DefaultConfigData() + c := New(&config.Config{File: cfgData, Session: cfgData}) + c.loggedIn = true + + origBase := baseURL + baseURL = ts.URL + defer func() { baseURL = origBase }() + + meta, err := c.GetMetadata(context.Background(), "123", "playlist") + if err != nil { + t.Fatalf("GetMetadata() error = %v", err) + } + tracksObj, _ := meta["tracks"].(map[string]any) + items, _ := tracksObj["items"].([]any) + if len(items) != 101 { + t.Fatalf("tracks len = %d, want 101", len(items)) + } + if callCount != 2 { + t.Fatalf("track page call count = %d, want 2", callCount) + } +} + func TestGetDownloadableNativeCipher(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -161,6 +273,58 @@ func TestGetDownloadableNativeCipher(t *testing.T) { } } +func TestGetDownloadablePrefersNoneCipherWhenAvailable(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/track/42": + _ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "title": "X", "track_token": "tt"}) + case "/media": + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"errors": []any{}, "media": []any{ + map[string]any{"cipher": map[string]any{"type": "BF_CBC_STRIPE"}, "format": "FLAC", "sources": []any{map[string]any{"url": "https://cdn.example/bf"}}}, + map[string]any{"cipher": map[string]any{"type": "NONE"}, "format": "FLAC", "sources": []any{map[string]any{"url": "https://cdn.example/plain"}}}, + }}}}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfgData := config.DefaultConfigData() + cfgData.Deezer.ARL = "arl" + c := New(&config.Config{File: cfgData, Session: cfgData}) + c.loggedIn = true + c.arl = "arl" + c.license = "license" + c.jwt = "jwt" + + origBase := baseURL + origMedia := mediaURL + origPipe := pipeURL + baseURL = ts.URL + mediaURL = ts.URL + "/media" + pipeURL = ts.URL + "/pipe" + defer func() { + baseURL = origBase + mediaURL = origMedia + pipeURL = origPipe + }() + + d, err := c.GetDownloadable(context.Background(), "42", 2) + if err != nil { + t.Fatalf("GetDownloadable() error = %v", err) + } + if d.Cipher != "NONE" || d.URL != "https://cdn.example/plain" { + t.Fatalf("expected NONE cipher source, got %+v", d) + } +} + +func TestExtractTrackIDFromMediaURL(t *testing.T) { + url := "https://f-cdnt-stream.dzcdn.net/media/1/9/6/4/8/2552667002/64821d6a2007e90768fa0300b508fcf4.flac?hdnea=x" + if got := extractTrackIDFromMediaURL(url); got != "2552667002" { + t.Fatalf("extractTrackIDFromMediaURL() = %q, want 2552667002", got) + } +} + func TestLoginPrefersARLFlowOverRefreshShortcut(t *testing.T) { mobileToken := testMobileToken(t) refreshCalled := false @@ -298,13 +462,16 @@ func TestGetTrackTokenPrefersPipeToken(t *testing.T) { pipeURL = origPipe }() - token, err := c.getTrackToken(context.Background(), "42") + token, mediaID, err := c.getTrackToken(context.Background(), "42") if err != nil { t.Fatalf("getTrackToken() error = %v", err) } if token != "pipe-track-token" { t.Fatalf("token = %q, want pipe-track-token", token) } + if mediaID != "" { + t.Fatalf("mediaID = %q, want empty when pipe media id missing", mediaID) + } } func TestGetDownloadableUsesPipeTrackToken(t *testing.T) {