diff --git a/internal/provider/deezer/client.go b/internal/provider/deezer/client.go index 009af3a..e5d8695 100644 --- a/internal/provider/deezer/client.go +++ b/internal/provider/deezer/client.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "net/http" + "net/http/cookiejar" "net/url" "strconv" "strings" @@ -38,6 +39,11 @@ var ( "Deezer/9.0.11.4 (Android; 13; Mobile; us) Google Pixel 6", "Deezer/9.0.11.4 (Android; 14; Mobile; us) OnePlus IN2023", } + deezerAppVersion = "9.0.11.4" + deezerAppLang = "us" + deezerBuildID = "android_minSDK26" + deezerScreenW = "1080" + deezerScreenH = "2134" ) type Client struct { @@ -56,12 +62,16 @@ type Client struct { } func New(cfg *config.Config) *Client { + httpClient := netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL) + if jar, err := cookiejar.New(nil); err == nil { + httpClient.Jar = jar + } return &Client{ cfg: cfg, - http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL), + http: httpClient, limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute), ua: randomDeezerUA(), - deviceID: randomHexN(16), + deviceID: randomHexN(32), arl: strings.TrimSpace(cfg.Session.Deezer.ARL), refresh: strings.TrimSpace(cfg.Session.Deezer.RefreshToken), } @@ -78,32 +88,31 @@ func (c *Client) Login(ctx context.Context) error { c.refresh = strings.TrimSpace(c.cfg.Session.Deezer.RefreshToken) c.license = "" c.userID = "" - email := strings.TrimSpace(c.cfg.Session.Deezer.Email) - password := strings.TrimSpace(c.cfg.Session.Deezer.Password) - if c.refresh != "" { - if err := c.refreshJWT(ctx); err == nil { - _ = c.refreshLicenseFromPipe(ctx) - if c.license != "" { - c.loggedIn = true - return nil - } - } - } - if c.arl != "" { - if err := c.refreshSessionFromARL(ctx); err != nil { - return err - } - } else if email != "" && password != "" { - if err := c.loginWithCredentials(ctx, email, password); err != nil { - return err - } - } else { - return errors.New("deezer login requires deezer.arl, deezer.email+deezer.password, or deezer.refresh_token") + if err := c.ensureLaunchSession(ctx); err != nil { + return err } c.loggedIn = true return nil } +func (c *Client) ensureLaunchSession(ctx context.Context) error { + email := strings.TrimSpace(c.cfg.Session.Deezer.Email) + password := strings.TrimSpace(c.cfg.Session.Deezer.Password) + if strings.TrimSpace(c.arl) != "" { + return c.refreshSessionFromARL(ctx) + } + if email != "" && password != "" { + return c.loginWithCredentials(ctx, email, password) + } + if strings.TrimSpace(c.refresh) != "" { + if err := c.refreshJWT(ctx); err != nil { + return err + } + return c.refreshLicenseFromPipe(ctx) + } + return errors.New("deezer login requires deezer.arl, deezer.email+deezer.password, or deezer.refresh_token") +} + func (c *Client) LoggedIn() bool { return c.loggedIn } @@ -262,24 +271,8 @@ func (c *Client) getArtistAlbums(ctx context.Context, artistID string) (map[stri func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*provider.Downloadable, error) { if strings.TrimSpace(c.license) == "" { - if strings.TrimSpace(c.arl) != "" { - if err := c.refreshSessionFromARL(ctx); err != nil { - return nil, err - } - } else { - if strings.TrimSpace(c.refresh) != "" { - _ = c.refreshJWT(ctx) - if strings.TrimSpace(c.jwt) != "" { - _ = c.refreshLicenseFromPipe(ctx) - } - } - email := strings.TrimSpace(c.cfg.Session.Deezer.Email) - password := strings.TrimSpace(c.cfg.Session.Deezer.Password) - if strings.TrimSpace(c.license) == "" && email != "" && password != "" { - if err := c.loginWithCredentials(ctx, email, password); err != nil { - return nil, err - } - } + if err := c.ensureLaunchSession(ctx); err != nil { + return nil, err } } if strings.TrimSpace(c.license) == "" { @@ -289,12 +282,9 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov if err != nil { return nil, err } - trackToken := strings.TrimSpace(jsonutil.StringFromAny(meta["track_token"])) - if trackToken == "" { - trackToken, err = c.getTrackToken(ctx, item) - if err != nil { - return nil, err - } + trackToken, err := c.getTrackToken(ctx, item) + if err != nil { + return nil, err } media, err := c.getMediaURL(ctx, trackToken, c.cfg.Session.Deezer.Quality, c.cfg.Session.Deezer.LowerQualityIfNotAvailable) if err != nil { @@ -413,10 +403,8 @@ func (c *Client) refreshSessionFromARL(ctx context.Context) error { c.userID = findStringByKey(results, "USER_ID") c.jwt = jsonutil.FirstNonEmpty(c.jwt, findStringByKey(results, "JWT")) c.refresh = jsonutil.FirstNonEmpty(c.refresh, findStringByKey(results, "refresh_token")) - if c.sid == "" { - if sid, sidErr := c.bootstrapSID(ctx); sidErr == nil { - c.sid = sid - } + if sid, sidErr := c.bootstrapSID(ctx); sidErr == nil && strings.TrimSpace(sid) != "" { + c.sid = sid } if c.sid != "" && c.userID != "" { _ = c.mobileUserAutolog(ctx) @@ -424,7 +412,7 @@ func (c *Client) refreshSessionFromARL(ctx context.Context) error { if c.jwt == "" && c.refresh != "" { _ = c.refreshJWT(ctx) } - if c.license == "" && c.jwt != "" { + if c.jwt != "" { _ = c.refreshLicenseFromPipe(ctx) } if c.license == "" { @@ -562,6 +550,19 @@ func (c *Client) loginWithCredentials(ctx context.Context, email, password strin } 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 + } 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 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 + } + } resp, err := c.apiGet(ctx, "/track/"+url.PathEscape(strings.TrimSpace(trackID)), nil) if err != nil { return "", err @@ -573,6 +574,30 @@ func (c *Client) getTrackToken(ctx context.Context, trackID string) (string, err return token, nil } +func (c *Client) getTrackTokenFromPipe(ctx context.Context, trackID 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", + "variables": map[string]any{"trackId": strings.TrimSpace(trackID)}, + "query": query, + "extensions": map[string]any{ + "clientLibrary": map[string]any{ + "name": "apollo-kotlin", + "version": "4.4.2", + }, + }, + } + out, err := c.pipeGraphQL(ctx, body, "deezer track media query") + if err != nil { + return "", err + } + payload := strings.TrimSpace(jsonutil.StringFromAny(jsonutil.NestedMap(jsonutil.NestedMap(jsonutil.NestedMap(jsonutil.NestedMap(out, "data"), "track"), "media"), "token")["payload"])) + if payload == "" { + return "", errors.New("deezer track media response missing token payload") + } + return payload, nil +} + type lyricsResult struct { Text string SyncedLRC string @@ -588,50 +613,10 @@ func (c *Client) fetchLyricsFromPipe(ctx context.Context, trackID string) (*lyri "variables": map[string]any{"trackId": strings.TrimSpace(trackID)}, "query": query, } - encoded, err := json.Marshal(body) + out, err := c.pipeGraphQLWithJWT(ctx, jwt, body, "deezer lyrics query") if err != nil { return nil, err } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, pipeURL, bytes.NewReader(encoded)) - if err != nil { - return nil, err - } - req.Header.Set("User-Agent", c.ua) - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(jwt)) - - resp, err := c.http.Do(req) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - raw, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("deezer lyrics query failed: status=%d", resp.StatusCode) - } - out := map[string]any{} - if err = json.Unmarshal(raw, &out); err != nil { - return nil, err - } - if errs, ok := out["errors"].([]any); ok && len(errs) > 0 { - msg := "" - typ := "" - if em, ok := errs[0].(map[string]any); ok { - msg = strings.TrimSpace(jsonutil.StringFromAny(em["message"])) - typ = strings.TrimSpace(jsonutil.StringFromAny(em["type"])) - } - if strings.EqualFold(typ, "JwtTokenExpiredError") || strings.Contains(strings.ToLower(msg), "not valid anymore") || strings.Contains(strings.ToLower(msg), "jwt") && strings.Contains(strings.ToLower(msg), "expired") { - return nil, errDeezerJWTExpired - } - if msg == "" { - msg = "unknown graphql error" - } - return nil, errors.New(msg) - } lyrics := jsonutil.NestedMap(jsonutil.NestedMap(jsonutil.NestedMap(out, "data"), "track"), "lyrics") text := strings.TrimSpace(jsonutil.StringFromAny(lyrics["text"])) synced := buildSyncedLRC(lyrics["synchronizedLines"]) @@ -656,22 +641,12 @@ func (c *Client) fetchLyricsFromPipe(ctx context.Context, trackID string) (*lyri return &lyricsResult{Text: strings.Join(parts, "\n")}, nil } - if strings.TrimSpace(c.jwt) == "" { - if err := c.refreshSessionFromARL(ctx); err != nil { - return nil, err - } - } - if strings.TrimSpace(c.jwt) == "" { - return nil, errors.New("deezer jwt unavailable for lyrics query") + if err := c.ensureJWT(ctx, "deezer jwt unavailable for lyrics query"); err != nil { + return nil, err } res, err := fetchOnce(c.jwt) if errors.Is(err, errDeezerJWTExpired) { - if strings.TrimSpace(c.refresh) != "" { - _ = c.refreshJWT(ctx) - } - if strings.TrimSpace(c.jwt) == "" && strings.TrimSpace(c.arl) != "" { - _ = c.refreshSessionFromARL(ctx) - } + c.refreshJWTFromAvailableState(ctx) if strings.TrimSpace(c.jwt) != "" { return fetchOnce(c.jwt) } @@ -724,6 +699,12 @@ func (c *Client) mobileAuth(ctx context.Context) (string, error) { params.Set("output", "3") params.Set("method", "mobile_auth") params.Set("network", randomHexN(32)) + params.Set("version", deezerAppVersion) + params.Set("lang", deezerAppLang) + params.Set("buildId", deezerBuildID) + params.Set("screenWidth", deezerScreenW) + params.Set("screenHeight", deezerScreenH) + params.Set("uniq_id", randomHexN(16)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, gatewayURL+"?"+params.Encode(), nil) if err != nil { @@ -826,13 +807,15 @@ func (c *Client) mobileUserAutolog(ctx context.Context) error { "ACCOUNT_ID": c.userID, "arl": c.arl, } + if strings.TrimSpace(c.refresh) != "" { + payload["refresh_token"] = strings.TrimSpace(c.refresh) + } params := url.Values{} params.Set("api_key", apiKey) params.Set("sid", c.sid) params.Set("output", "3") params.Set("input", "3") params.Set("network", randomHexN(32)) - params.Set("arl", c.arl) for _, method := range []string{"mobile_userAutolog", "mobile_userAutoLog"} { if err := c.limiter.Wait(ctx); err != nil { @@ -922,49 +905,113 @@ func (c *Client) refreshJWT(ctx context.Context) error { return nil } -func (c *Client) refreshLicenseFromPipe(ctx context.Context) error { +func (c *Client) ensureJWT(ctx context.Context, unavailableMsg string) error { + if strings.TrimSpace(c.jwt) != "" { + return nil + } + if strings.TrimSpace(c.arl) != "" { + _ = c.refreshSessionFromARL(ctx) + } + if strings.TrimSpace(c.jwt) == "" && strings.TrimSpace(c.refresh) != "" { + _ = c.refreshJWT(ctx) + } if strings.TrimSpace(c.jwt) == "" { - return errors.New("missing deezer jwt") + if strings.TrimSpace(unavailableMsg) == "" { + unavailableMsg = "deezer jwt unavailable" + } + return errors.New(unavailableMsg) + } + return nil +} + +func (c *Client) refreshJWTFromAvailableState(ctx context.Context) { + if strings.TrimSpace(c.refresh) != "" { + _ = c.refreshJWT(ctx) + } + if strings.TrimSpace(c.jwt) == "" && strings.TrimSpace(c.arl) != "" { + _ = c.refreshSessionFromARL(ctx) + } +} + +func (c *Client) pipeGraphQL(ctx context.Context, body map[string]any, operation string) (map[string]any, error) { + return c.pipeGraphQLWithJWT(ctx, c.jwt, body, operation) +} + +func (c *Client) pipeGraphQLWithJWT(ctx context.Context, jwt string, body map[string]any, operation string) (map[string]any, error) { + jwt = strings.TrimSpace(jwt) + if jwt == "" { + return nil, errors.New("missing deezer jwt") } if err := c.limiter.Wait(ctx); err != nil { - return err + return nil, err } - body := map[string]any{ - "operationName": "KmpMpMediaServiceLicenseToken", - "query": "query KmpMpMediaServiceLicenseToken { tokens { mediaServiceLicenseToken { token expirationDate } } }", - "variables": map[string]any{}, - } - b, _ := json.Marshal(body) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, pipeURL, bytes.NewReader(b)) + encoded, err := json.Marshal(body) if err != nil { - return err + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, pipeURL, bytes.NewReader(encoded)) + if err != nil { + return nil, err } req.Header.Set("User-Agent", c.ua) - req.Header.Set("Accept", "application/json") + req.Header.Set("Accept", "multipart/mixed;deferSpec=20220824, application/graphql-response+json, application/json") + req.Header.Set("Accept-Charset", "UTF-8") + req.Header.Set("Accept-Language", "en-US") req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.jwt)) + req.Header.Set("Authorization", "Bearer "+jwt) + resp, err := c.http.Do(req) if err != nil { - return err + return nil, err } defer func() { _ = resp.Body.Close() }() - raw, _ := io.ReadAll(resp.Body) + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("pipe license refresh failed: status=%d body=%s", resp.StatusCode, string(raw)) + if strings.TrimSpace(operation) == "" { + operation = "pipe graphql" + } + return nil, fmt.Errorf("%s failed: status=%d body=%s", operation, resp.StatusCode, string(raw)) } out := map[string]any{} - if json.Unmarshal(raw, &out) != nil { - return errors.New("invalid pipe response") + if err = json.Unmarshal(raw, &out); err != nil { + return nil, errors.New("invalid pipe response") } if errs, ok := out["errors"].([]any); ok && len(errs) > 0 { msg := "" + typ := "" if em, ok := errs[0].(map[string]any); ok { msg = strings.TrimSpace(jsonutil.StringFromAny(em["message"])) + typ = strings.TrimSpace(jsonutil.StringFromAny(em["type"])) + } + if strings.EqualFold(typ, "JwtTokenExpiredError") || strings.Contains(strings.ToLower(msg), "not valid anymore") || strings.Contains(strings.ToLower(msg), "jwt") && strings.Contains(strings.ToLower(msg), "expired") { + return nil, errDeezerJWTExpired } if msg == "" { msg = "pipe response returned graphql error" } - return errors.New(msg) + return nil, errors.New(msg) + } + return out, nil +} + +func (c *Client) refreshLicenseFromPipe(ctx context.Context) error { + body := map[string]any{ + "operationName": "KmpMpMediaServiceLicenseToken", + "query": "query KmpMpMediaServiceLicenseToken { tokens { mediaServiceLicenseToken { token expirationDate } } }", + "variables": map[string]any{}, + "extensions": map[string]any{ + "clientLibrary": map[string]any{ + "name": "apollo-kotlin", + "version": "4.4.2", + }, + }, + } + out, err := c.pipeGraphQL(ctx, body, "pipe license refresh") + if err != nil { + return err } token := findStringByKey(out, "token") if token == "" { @@ -1103,26 +1150,51 @@ type mediaResult struct { Cipher string } -func (c *Client) getMediaURL(ctx context.Context, trackToken string, quality int, allowFallback bool) (*mediaResult, error) { - requestedFormats := buildFormatPriority(quality, allowFallback) - var lastErr error - for _, format := range requestedFormats { - result, err := c.getMediaURLForFormat(ctx, trackToken, format) - if err == nil { - return result, nil - } - lastErr = err - if !allowFallback { - break - } - } - if lastErr != nil { - return nil, lastErr - } - return nil, errors.New("deezer media response contains no playable variants") +type deezerMediaError struct { + Code int + Message string } -func (c *Client) getMediaURLForFormat(ctx context.Context, trackToken, format string) (*mediaResult, error) { +func (e *deezerMediaError) Error() string { + msg := strings.TrimSpace(e.Message) + if strings.Contains(strings.ToLower(msg), "drm") { + return "deezer media is DRM protected for this format/account" + } + return fmt.Sprintf("deezer media error %d: %s", e.Code, msg) +} + +func (c *Client) getMediaURL(ctx context.Context, trackToken string, quality int, allowFallback bool) (*mediaResult, error) { + requestedFormats := buildFormatPriority(quality, allowFallback) + result, err := c.getMediaURLWithRequest(ctx, trackToken, requestedFormats) + if err == nil { + return result, nil + } + var mediaErr *deezerMediaError + if errors.As(err, &mediaErr) && mediaErr.Code == 2004 { + if refreshErr := c.forceMobileLaunchRefresh(ctx); refreshErr == nil { + return c.getMediaURLWithRequest(ctx, trackToken, requestedFormats) + } + } + return nil, err +} + +func (c *Client) forceMobileLaunchRefresh(ctx context.Context) error { + if strings.TrimSpace(c.arl) != "" { + c.sid = "" + c.jwt = "" + c.license = "" + return c.refreshSessionFromARL(ctx) + } + if strings.TrimSpace(c.refresh) != "" { + if err := c.refreshJWT(ctx); err != nil { + return err + } + return c.refreshLicenseFromPipe(ctx) + } + return errors.New("deezer launch refresh requires arl or refresh token") +} + +func (c *Client) getMediaURLWithRequest(ctx context.Context, trackToken string, requestedFormats []string) (*mediaResult, error) { if strings.TrimSpace(c.license) == "" { return nil, errors.New("missing deezer license token") } @@ -1130,24 +1202,32 @@ func (c *Client) getMediaURLForFormat(ctx context.Context, trackToken, format st return nil, err } + formats := make([]map[string]string, 0, 6) + for _, format := range []string{"FLAC", "MP3_320", "MP3_128"} { + formats = append(formats, + map[string]string{"cipher": "BF_CBC_STRIPE", "format": format}, + map[string]string{"cipher": "NONE", "format": format}, + ) + } reqBody := map[string]any{ "license_token": c.license, "track_tokens": []string{trackToken}, "media": []map[string]any{{ "type": "FULL", - "formats": []map[string]string{{"cipher": "BF_CBC_STRIPE", "format": format}, {"cipher": "NONE", "format": format}}, + "formats": formats, }}, } b, err := json.Marshal(reqBody) if err != nil { return nil, err } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, mediaURL, strings.NewReader(string(b))) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, mediaURL, bytes.NewReader(b)) if err != nil { return nil, err } req.Header.Set("User-Agent", c.ua) req.Header.Set("Accept", "*/*") + req.Header.Set("Accept-Charset", "UTF-8") req.Header.Set("Content-Type", "text/plain; charset=UTF-8") resp, err := c.http.Do(req) @@ -1187,16 +1267,18 @@ func (c *Client) getMediaURLForFormat(ctx context.Context, trackToken, format st } if len(parsed.Data[0].Errors) > 0 { e := parsed.Data[0].Errors[0] - if strings.Contains(strings.ToLower(e.Message), "drm") { - return nil, errors.New("deezer media is DRM protected for this format/account") - } - return nil, fmt.Errorf("deezer media error %d: %s", e.Code, e.Message) + return nil, &deezerMediaError{Code: e.Code, Message: e.Message} } - for _, m := range parsed.Data[0].Media { - if len(m.Sources) == 0 || strings.TrimSpace(m.Sources[0].URL) == "" { - continue + for _, want := range requestedFormats { + for _, m := range parsed.Data[0].Media { + if !strings.EqualFold(strings.TrimSpace(m.Format), want) { + continue + } + 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 &mediaResult{URL: m.Sources[0].URL, Format: m.Format, Cipher: m.Cipher.Type}, nil } return nil, errors.New("deezer media response contains no sources") } diff --git a/internal/provider/deezer/client_test.go b/internal/provider/deezer/client_test.go index e47deba..e8575a3 100644 --- a/internal/provider/deezer/client_test.go +++ b/internal/provider/deezer/client_test.go @@ -105,6 +105,23 @@ func TestGetDownloadableNativeCipher(t *testing.T) { case "/track/42": _ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "title": "X", "track_token": "tt"}) case "/media": + if got := strings.TrimSpace(r.Header.Get("Accept-Charset")); got != "UTF-8" { + t.Fatalf("accept-charset = %q, want UTF-8", got) + } + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + media, _ := payload["media"].([]any) + if len(media) != 1 { + t.Fatalf("media length = %d, want 1", len(media)) + } + entry, _ := media[0].(map[string]any) + formats, _ := entry["formats"].([]any) + if len(formats) != 6 { + t.Fatalf("formats length = %d, want 6", len(formats)) + } _ = 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/file"}}}}}}}) default: w.WriteHeader(http.StatusNotFound) @@ -144,6 +161,66 @@ func TestGetDownloadableNativeCipher(t *testing.T) { } } +func TestLoginPrefersARLFlowOverRefreshShortcut(t *testing.T) { + mobileToken := testMobileToken(t) + refreshCalled := false + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/web": + _ = json.NewEncoder(w).Encode(map[string]any{"results": map[string]any{"USER_ID": "42", "JWT": "jwt-from-web", "refresh_token": "refresh-from-web", "license_token": "license-from-web"}}) + case "/gateway": + switch r.URL.Query().Get("method") { + case "mobile_auth": + _ = json.NewEncoder(w).Encode(map[string]any{"results": map[string]any{"TOKEN": mobileToken}}) + case "api_checkToken": + _ = json.NewEncoder(w).Encode(map[string]any{"results": "sid123"}) + case "mobile_userAutolog": + _ = json.NewEncoder(w).Encode(map[string]any{"results": map[string]any{"JWT": "jwt-from-autolog", "license_token": "license-from-autolog", "refresh_token": "refresh-from-autolog"}}) + default: + w.WriteHeader(http.StatusNotFound) + } + case "/pipe": + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"tokens": map[string]any{"mediaServiceLicenseToken": map[string]any{"token": "license-from-pipe"}}}}) + case "/renew": + refreshCalled = true + w.WriteHeader(http.StatusInternalServerError) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfgData := config.DefaultConfigData() + cfgData.Deezer.ARL = "arl" + cfgData.Deezer.RefreshToken = "refresh-token" + c := New(&config.Config{File: cfgData, Session: cfgData}) + + origGateway := gatewayURL + origWeb := webGWLight + origPipe := pipeURL + origAuth := authURL + gatewayURL = ts.URL + "/gateway" + webGWLight = ts.URL + "/web" + pipeURL = ts.URL + "/pipe" + authURL = ts.URL + "/renew" + defer func() { + gatewayURL = origGateway + webGWLight = origWeb + pipeURL = origPipe + authURL = origAuth + }() + + if err := c.Login(context.Background()); err != nil { + t.Fatalf("Login() error = %v", err) + } + if refreshCalled { + t.Fatalf("expected ARL launch flow without refresh shortcut") + } + if c.license != "license-from-pipe" { + t.Fatalf("license = %q, want license-from-pipe", c.license) + } +} + func TestGetDownloadableRequiresARL(t *testing.T) { cfgData := config.DefaultConfigData() cfgData.Deezer.ARL = "" @@ -194,6 +271,96 @@ func TestGetDownloadableDRMError(t *testing.T) { } } +func TestGetTrackTokenPrefersPipeToken(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/pipe": + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"track": map[string]any{"media": map[string]any{"token": map[string]any{"payload": "pipe-track-token"}}}}}) + case "/track/42": + _ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "track_token": "api-track-token"}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfgData := config.DefaultConfigData() + c := New(&config.Config{File: cfgData, Session: cfgData}) + c.loggedIn = true + c.jwt = "jwt-token" + + origBase := baseURL + origPipe := pipeURL + baseURL = ts.URL + pipeURL = ts.URL + "/pipe" + defer func() { + baseURL = origBase + pipeURL = origPipe + }() + + token, 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) + } +} + +func TestGetDownloadableUsesPipeTrackToken(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": "api-track-token"}) + case "/pipe": + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"track": map[string]any{"media": map[string]any{"token": map[string]any{"payload": "pipe-track-token"}}}}}) + case "/media": + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + tokens, _ := payload["track_tokens"].([]any) + if len(tokens) == 0 || strings.TrimSpace(jsonutil.StringFromAny(tokens[0])) != "pipe-track-token" { + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"errors": []any{map[string]any{"code": 2004, "message": "The track country differs from the license."}}, "media": []any{}}}}) + return + } + _ = 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/file"}}}}}}}) + 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.URL != "https://cdn.example/file" || d.Extension != "flac" { + t.Fatalf("unexpected downloadable: %+v", d) + } +} + func TestGetMetadataAddsLyricsFromPipe(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -295,6 +462,102 @@ func TestLoginWithCredentials(t *testing.T) { } } +func TestMobileAuthIncludesAppContextParams(t *testing.T) { + mobileToken := testMobileToken(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/gateway" { + w.WriteHeader(http.StatusNotFound) + return + } + if r.URL.Query().Get("method") != "mobile_auth" { + w.WriteHeader(http.StatusBadRequest) + return + } + if got := strings.TrimSpace(r.URL.Query().Get("version")); got != deezerAppVersion { + t.Fatalf("version = %q, want %q", got, deezerAppVersion) + } + if got := strings.TrimSpace(r.URL.Query().Get("lang")); got != deezerAppLang { + t.Fatalf("lang = %q, want %q", got, deezerAppLang) + } + if got := strings.TrimSpace(r.URL.Query().Get("buildId")); got != deezerBuildID { + t.Fatalf("buildId = %q, want %q", got, deezerBuildID) + } + if got := strings.TrimSpace(r.URL.Query().Get("screenWidth")); got != deezerScreenW { + t.Fatalf("screenWidth = %q, want %q", got, deezerScreenW) + } + if got := strings.TrimSpace(r.URL.Query().Get("screenHeight")); got != deezerScreenH { + t.Fatalf("screenHeight = %q, want %q", got, deezerScreenH) + } + if got := strings.TrimSpace(r.URL.Query().Get("uniq_id")); got == "" { + t.Fatalf("uniq_id is empty") + } + _ = json.NewEncoder(w).Encode(map[string]any{"results": map[string]any{"TOKEN": mobileToken}}) + })) + defer ts.Close() + + cfgData := config.DefaultConfigData() + c := New(&config.Config{File: cfgData, Session: cfgData}) + + origGateway := gatewayURL + gatewayURL = ts.URL + "/gateway" + defer func() { gatewayURL = origGateway }() + + token, err := c.mobileAuth(context.Background()) + if err != nil { + t.Fatalf("mobileAuth() error = %v", err) + } + if token != mobileToken { + t.Fatalf("token = %q, want %q", token, mobileToken) + } +} + +func TestMobileUserAutologIncludesRefreshToken(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/gateway" { + w.WriteHeader(http.StatusNotFound) + return + } + if method := r.URL.Query().Get("method"); method != "mobile_userAutolog" { + w.WriteHeader(http.StatusBadRequest) + return + } + if got := strings.TrimSpace(r.URL.Query().Get("arl")); got != "" { + t.Fatalf("unexpected arl query parameter: %q", got) + } + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + if got := strings.TrimSpace(jsonutil.StringFromAny(payload["refresh_token"])); got != "refresh-token" { + t.Fatalf("refresh_token payload = %q, want refresh-token", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{"results": map[string]any{"JWT": "jwt-token", "license_token": "license-token"}}) + })) + defer ts.Close() + + cfgData := config.DefaultConfigData() + c := New(&config.Config{File: cfgData, Session: cfgData}) + c.sid = "sid123" + c.userID = "42" + c.arl = "arl-token" + c.refresh = "refresh-token" + + origGateway := gatewayURL + gatewayURL = ts.URL + "/gateway" + defer func() { gatewayURL = origGateway }() + + if err := c.mobileUserAutolog(context.Background()); err != nil { + t.Fatalf("mobileUserAutolog() error = %v", err) + } + if c.jwt != "jwt-token" { + t.Fatalf("jwt = %q, want jwt-token", c.jwt) + } + if c.license != "license-token" { + t.Fatalf("license = %q, want license-token", c.license) + } +} + func testMobileToken(t *testing.T) string { t.Helper() plain := []byte(strings.Repeat("A", 64) + strings.Repeat("B", 16) + strings.Repeat("C", 16)) @@ -385,3 +648,51 @@ func TestRefreshLicenseFromPipeGraphQLError(t *testing.T) { t.Fatalf("expected graphql error, got %v", err) } } + +func TestRefreshLicenseFromPipeUsesMobileGraphQLShape(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := strings.TrimSpace(r.Header.Get("Authorization")); got != "Bearer jwt-token" { + t.Fatalf("authorization = %q, want Bearer jwt-token", got) + } + if got := r.Header.Get("Accept"); !strings.Contains(got, "application/graphql-response+json") { + t.Fatalf("accept header = %q", got) + } + if got := strings.TrimSpace(r.Header.Get("Accept-Language")); got != "en-US" { + t.Fatalf("accept-language = %q, want en-US", got) + } + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + op := strings.TrimSpace(jsonutil.StringFromAny(payload["operationName"])) + if op != "KmpMpMediaServiceLicenseToken" { + t.Fatalf("operationName = %q, want KmpMpMediaServiceLicenseToken", op) + } + ext, _ := payload["extensions"].(map[string]any) + clientLib, _ := ext["clientLibrary"].(map[string]any) + if got := strings.TrimSpace(jsonutil.StringFromAny(clientLib["name"])); got != "apollo-kotlin" { + t.Fatalf("clientLibrary.name = %q, want apollo-kotlin", got) + } + if got := strings.TrimSpace(jsonutil.StringFromAny(clientLib["version"])); got != "4.4.2" { + t.Fatalf("clientLibrary.version = %q, want 4.4.2", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"tokens": map[string]any{"mediaServiceLicenseToken": map[string]any{"token": "license-token"}}}}) + })) + defer ts.Close() + + cfgData := config.DefaultConfigData() + c := New(&config.Config{File: cfgData, Session: cfgData}) + c.jwt = "jwt-token" + + origPipe := pipeURL + pipeURL = ts.URL + defer func() { pipeURL = origPipe }() + + if err := c.refreshLicenseFromPipe(context.Background()); err != nil { + t.Fatalf("refreshLicenseFromPipe() error = %v", err) + } + if c.license != "license-token" { + t.Fatalf("license = %q, want license-token", c.license) + } +}