package deezer import ( "bytes" "context" "crypto/aes" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/cookiejar" "net/url" "strconv" "strings" "time" "streamrip-go/internal/config" "streamrip-go/internal/jsonutil" "streamrip-go/internal/netutil" "streamrip-go/internal/provider" "streamrip-go/internal/ratelimit" ) var ( baseURL = "https://api.deezer.com" webGWLight = "https://www.deezer.com/ajax/gw-light.php" gatewayURL = "https://api.deezer.com/1.0/gateway.php" mediaURL = "https://media.deezer.com/v1/get_url" pipeURL = "https://pipe.deezer.com/api" authURL = "https://auth.deezer.com/login/renew" apiKey = "4VCYIJUCDLOUELGD1V8WBVYBNVDYOXEWSLLZDONGBBDFVXTZJRXPR29JRLQFO6ZE" gatewayDec = "VBK1FSUEXHTSDBJJ" deezerUAPool = []string{ "Deezer/9.0.11.4 (Android; 14; Mobile; us) Xiaomi Redmi Note 7", "Deezer/9.0.11.4 (Android; 14; Mobile; us) Samsung SM-G991B", "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 { cfg *config.Config http *http.Client limiter *ratelimit.Limiter loggedIn bool ua string deviceID string sid string arl string jwt string refresh string license string userID string } 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: httpClient, limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute), ua: randomDeezerUA(), deviceID: randomHexN(32), arl: strings.TrimSpace(cfg.Session.Deezer.ARL), refresh: strings.TrimSpace(cfg.Session.Deezer.RefreshToken), } } func (c *Client) Source() string { return "deezer" } func (c *Client) Login(ctx context.Context) error { c.arl = strings.TrimSpace(c.cfg.Session.Deezer.ARL) c.sid = "" c.jwt = "" c.refresh = strings.TrimSpace(c.cfg.Session.Deezer.RefreshToken) c.license = "" c.userID = "" 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 } func (c *Client) Close() error { return nil } func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) { if !c.loggedIn { return nil, errors.New("deezer client not logged in") } if limit <= 0 { limit = 25 } pathType := mediaType if mediaType == "playlist" { pathType = "playlist" } params := url.Values{} params.Set("q", query) params.Set("limit", strconv.Itoa(limit)) resp, err := c.apiGet(ctx, "/search/"+pathType, params) if err != nil { return nil, err } data, _ := resp["data"].([]any) if len(data) == 0 { return []map[string]any{}, nil } bucket := map[string]any{"items": data} return []map[string]any{{mediaType + "s": bucket}}, nil } func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) { if !c.loggedIn { return nil, errors.New("deezer client not logged in") } switch mediaType { case "track": resp, err := c.apiGet(ctx, "/track/"+item, nil) if err != nil { return nil, err } enrichTrack(resp) if lyr, lyrErr := c.fetchLyricsFromPipe(ctx, strings.TrimSpace(jsonutil.StringFromAny(resp["id"]))); lyrErr == nil { if strings.TrimSpace(lyr.Text) != "" { resp["lyrics"] = lyr.Text } if strings.TrimSpace(lyr.SyncedLRC) != "" { resp["lyrics_synced"] = lyr.SyncedLRC } } return resp, nil case "album": resp, err := c.apiGet(ctx, "/album/"+item, nil) 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 { for _, raw := range data { itm, ok := raw.(map[string]any) if !ok { continue } enrichTrack(itm) items = append(items, itm) } } } resp["tracks"] = map[string]any{"items": items} enrichAlbumImage(resp) return resp, nil case "playlist": resp, err := c.apiGet(ctx, "/playlist/"+item, nil) 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 { for _, raw := range data { itm, ok := raw.(map[string]any) if !ok { continue } enrichTrack(itm) items = append(items, itm) } } } resp["tracks"] = map[string]any{"items": items} return resp, nil case "artist": name := strings.TrimSpace(item) if artistMeta, artistErr := c.apiGet(ctx, "/artist/"+item, nil); artistErr == nil { if n := strings.TrimSpace(jsonutil.StringFromAny(artistMeta["name"])); n != "" { name = n } } resp, err := c.getArtistAlbums(ctx, item) if err != nil { return nil, err } albums := make([]any, 0) if data, ok := resp["data"].([]any); ok { for _, raw := range data { itm, ok := raw.(map[string]any) if !ok { continue } enrichAlbumImage(itm) albums = append(albums, itm) } } return map[string]any{"name": name, "albums": map[string]any{"items": albums}}, nil default: return nil, fmt.Errorf("unsupported deezer media type: %s", mediaType) } } func (c *Client) getArtistAlbums(ctx context.Context, artistID string) (map[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, "/artist/"+strings.TrimSpace(artistID)+"/albums", 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 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 { return nil, err } } if strings.TrimSpace(c.license) == "" { return nil, errors.New("deezer native download requires deezer.arl, deezer.email+deezer.password, or deezer.refresh_token") } meta, err := c.GetMetadata(ctx, item, "track") if err != nil { return nil, err } trackToken, mediaTrackID, 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 { return nil, err } ext := extensionForFormat(media.Format) if ext == "" { ext = "mp3" } 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) } return &provider.Downloadable{ URL: media.URL, Extension: ext, Source: "deezer", Cipher: media.Cipher, TrackID: trackID, Audio: audioProfileForFormat(media.Format), }, nil } func (c *Client) apiGet(ctx context.Context, path string, params url.Values) (map[string]any, error) { if err := c.limiter.Wait(ctx); err != nil { return nil, err } u := strings.TrimSuffix(baseURL, "/") + "/" + strings.TrimPrefix(path, "/") if len(params) > 0 { u += "?" + params.Encode() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "streamrip-go/0.1") 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 } out := map[string]any{} if len(body) > 0 { if err = json.Unmarshal(body, &out); err != nil { return nil, err } } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("deezer api failed: status=%d body=%s", resp.StatusCode, string(body)) } if errObj, ok := out["error"].(map[string]any); ok { msg := strings.TrimSpace(jsonutil.StringFromAny(errObj["message"])) if msg == "" { msg = strings.TrimSpace(jsonutil.StringFromAny(errObj["type"])) } if msg == "" { msg = "unknown deezer error" } return nil, fmt.Errorf("deezer api error: %s", msg) } return out, nil } func (c *Client) refreshSessionFromARL(ctx context.Context) error { if strings.TrimSpace(c.arl) == "" { return errors.New("missing deezer arl") } if err := c.limiter.Wait(ctx); err != nil { return err } params := url.Values{} params.Set("method", "deezer.getUserData") params.Set("input", "3") params.Set("api_version", "1.0") params.Set("api_token", "") req, err := http.NewRequestWithContext(ctx, http.MethodGet, webGWLight+"?"+params.Encode(), nil) if err != nil { return err } req.Header.Set("User-Agent", c.ua) req.Header.Set("Accept", "application/json") req.Header.Set("Cookie", "arl="+strings.TrimSpace(c.arl)) resp, err := c.http.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("deezer getUserData failed: status=%d body=%s", resp.StatusCode, string(raw)) } out := map[string]any{} if err = json.Unmarshal(raw, &out); err != nil { return err } if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 { return fmt.Errorf("deezer getUserData error: %s", jsonutil.StringFromAny(errObj["message"])) } results, _ := out["results"].(map[string]any) if len(results) == 0 { return errors.New("deezer getUserData returned empty results") } c.sid = jsonutil.FirstNonEmpty(c.sid, sidFromCookies(c.http, webGWLight)) c.license = findStringByKey(results, "license_token") 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 sid, sidErr := c.bootstrapSID(ctx); sidErr == nil && strings.TrimSpace(sid) != "" { c.sid = sid } if c.sid != "" && c.userID != "" { _ = c.mobileUserAutolog(ctx) } if c.jwt == "" && c.refresh != "" { _ = c.refreshJWT(ctx) } if c.jwt != "" { _ = c.refreshLicenseFromPipe(ctx) } if c.license == "" { return errors.New("deezer getUserData missing license_token") } c.persistRefreshToken() return nil } func (c *Client) persistRefreshToken() { if c.cfg == nil { return } rt := strings.TrimSpace(c.refresh) if rt == "" { return } c.cfg.Session.Deezer.RefreshToken = rt c.cfg.File.Deezer.RefreshToken = rt if strings.TrimSpace(c.cfg.Path) != "" { _ = c.cfg.SaveFile() } } func (c *Client) loginWithCredentials(ctx context.Context, email, password string) error { email = strings.TrimSpace(email) password = strings.TrimSpace(password) if email == "" || password == "" { return errors.New("missing deezer credentials") } mobileToken, err := c.mobileAuth(ctx) if err != nil { return err } authToken, err := deriveGatewayAuthToken(mobileToken) if err != nil { return err } sid, err := c.apiCheckToken(ctx, authToken) if err != nil { return err } c.sid = jsonutil.FirstNonEmpty(c.sid, sid) encryptedPassword, err := encryptPassword(mobileToken, password) if err != nil { return err } payload := map[string]any{ "platform": "Xiaomi_lavender_14", "custo_version_id": "", "custo_partner": nil, "model": "Redmi Note 7", "device_name": "Redmi Note 7", "device_os": "Android", "device_type": "phone", "google_play_services_availability": "1", "device_serial": c.deviceID, "mail": email, "password": encryptedPassword, } params := url.Values{} params.Set("api_key", apiKey) params.Set("sid", c.sid) params.Set("method", "mobile_userAuth") params.Set("output", "3") params.Set("input", "3") params.Set("network", randomHexN(32)) b, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, http.MethodPost, gatewayURL+"?"+params.Encode(), bytes.NewReader(b)) if err != nil { return err } req.Header.Set("User-Agent", c.ua) req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json; charset=utf-8") resp, err := c.http.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("deezer mobile_userAuth failed: status=%d body=%s", resp.StatusCode, string(raw)) } out := map[string]any{} if err = json.Unmarshal(raw, &out); err != nil { return err } if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 { msg := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(errObj["message"]), jsonutil.StringFromAny(errObj["type"])) if msg == "" { msg = "unknown mobile_userAuth error" } return errors.New(msg) } results := jsonutil.NestedMap(out, "results") if len(results) == 0 { return errors.New("mobile_userAuth returned empty results") } c.arl = jsonutil.FirstNonEmpty(c.arl, findStringByKey(results, "ARL")) c.jwt = jsonutil.FirstNonEmpty(c.jwt, findStringByKey(results, "JWT")) c.refresh = jsonutil.FirstNonEmpty(c.refresh, findStringByKey(results, "refresh_token")) c.license = jsonutil.FirstNonEmpty(c.license, findStringByKey(results, "license_token")) c.userID = jsonutil.FirstNonEmpty(c.userID, findStringByKey(results, "USER_ID")) if c.arl == "" { return errors.New("mobile_userAuth missing arl") } if c.license == "" { if c.jwt == "" && c.refresh != "" { _ = c.refreshJWT(ctx) } if c.jwt != "" { _ = c.refreshLicenseFromPipe(ctx) } if c.license == "" { _ = c.refreshSessionFromARL(ctx) } } if c.license == "" { return errors.New("mobile_userAuth missing license_token") } c.persistRefreshToken() return 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, 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, 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 } token := strings.TrimSpace(jsonutil.StringFromAny(resp["track_token"])) if token == "" { return "", "", errors.New("deezer track metadata missing track_token") } mediaID := strings.TrimSpace(jsonutil.StringFromAny(resp["id"])) return token, mediaID, nil } 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", "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 } 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") } mediaID := strings.TrimSpace(jsonutil.StringFromAny(media["id"])) return payload, mediaID, nil } type lyricsResult struct { Text string SyncedLRC string } var errDeezerJWTExpired = errors.New("deezer jwt expired") func (c *Client) fetchLyricsFromPipe(ctx context.Context, trackID string) (*lyricsResult, error) { fetchOnce := func(jwt string) (*lyricsResult, error) { query := `query GetLyrics($trackId: String!) { track(trackId: $trackId) { id lyrics { text synchronizedLines { line lineTranslated milliseconds } } } }` body := map[string]any{ "operationName": "GetLyrics", "variables": map[string]any{"trackId": strings.TrimSpace(trackID)}, "query": query, } out, err := c.pipeGraphQLWithJWT(ctx, jwt, body, "deezer lyrics query") if err != nil { return nil, err } lyrics := jsonutil.NestedMap(jsonutil.NestedMap(jsonutil.NestedMap(out, "data"), "track"), "lyrics") text := strings.TrimSpace(jsonutil.StringFromAny(lyrics["text"])) synced := buildSyncedLRC(lyrics["synchronizedLines"]) if text != "" || synced != "" { return &lyricsResult{Text: text, SyncedLRC: synced}, nil } lines, _ := lyrics["synchronizedLines"].([]any) parts := make([]string, 0, len(lines)) for _, rawLine := range lines { m, ok := rawLine.(map[string]any) if !ok { continue } line := strings.TrimSpace(jsonutil.StringFromAny(m["line"])) if line == "" { line = strings.TrimSpace(jsonutil.StringFromAny(m["lineTranslated"])) } if line != "" { parts = append(parts, line) } } return &lyricsResult{Text: strings.Join(parts, "\n")}, nil } 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) { c.refreshJWTFromAvailableState(ctx) if strings.TrimSpace(c.jwt) != "" { return fetchOnce(c.jwt) } } return res, err } func buildSyncedLRC(v any) string { lines, _ := v.([]any) if len(lines) == 0 { return "" } out := make([]string, 0, len(lines)) for _, rawLine := range lines { m, ok := rawLine.(map[string]any) if !ok { continue } line := strings.TrimSpace(jsonutil.StringFromAny(m["line"])) if line == "" { line = strings.TrimSpace(jsonutil.StringFromAny(m["lineTranslated"])) } if line == "" { continue } ms := jsonutil.IntFromAny(m["milliseconds"]) out = append(out, fmt.Sprintf("[%02d:%05.2f]%s", ms/60000, float64(ms%60000)/1000.0, line)) } return strings.Join(out, "\n") } func (c *Client) bootstrapSID(ctx context.Context) (string, error) { mobileToken, err := c.mobileAuth(ctx) if err != nil { return "", err } authToken, err := deriveGatewayAuthToken(mobileToken) if err != nil { return "", err } return c.apiCheckToken(ctx, authToken) } func (c *Client) mobileAuth(ctx context.Context) (string, error) { if err := c.limiter.Wait(ctx); err != nil { return "", err } params := url.Values{} params.Set("api_key", apiKey) 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 { return "", err } req.Header.Set("User-Agent", c.ua) req.Header.Set("Accept", "application/json") resp, err := c.http.Do(req) if err != nil { return "", err } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return "", err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", fmt.Errorf("mobile_auth failed: status=%d body=%s", resp.StatusCode, string(raw)) } out := map[string]any{} if err = json.Unmarshal(raw, &out); err != nil { return "", err } if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 { msg := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(errObj["message"]), jsonutil.StringFromAny(errObj["type"])) if msg == "" { msg = "mobile_auth returned an error" } return "", errors.New(msg) } token := findStringByKey(jsonutil.NestedMap(out, "results"), "TOKEN") if token == "" { return "", errors.New("mobile_auth returned empty token") } return token, nil } func (c *Client) apiCheckToken(ctx context.Context, authToken string) (string, error) { if err := c.limiter.Wait(ctx); err != nil { return "", err } params := url.Values{} params.Set("api_key", apiKey) params.Set("method", "api_checkToken") params.Set("auth_token", authToken) params.Set("output", "3") params.Set("network", randomHexN(32)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, gatewayURL+"?"+params.Encode(), nil) if err != nil { return "", err } req.Header.Set("User-Agent", c.ua) req.Header.Set("Accept", "application/json") resp, err := c.http.Do(req) if err != nil { return "", err } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return "", err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", fmt.Errorf("api_checkToken failed: status=%d body=%s", resp.StatusCode, string(raw)) } out := map[string]any{} if err = json.Unmarshal(raw, &out); err != nil { return "", err } if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 { msg := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(errObj["message"]), jsonutil.StringFromAny(errObj["type"])) if msg == "" { msg = "api_checkToken returned an error" } return "", errors.New(msg) } sid := strings.TrimSpace(jsonutil.StringFromAny(out["results"])) if sid == "" { return "", errors.New("api_checkToken returned empty sid") } return sid, nil } func (c *Client) mobileUserAutolog(ctx context.Context) error { if c.sid == "" || c.userID == "" || c.arl == "" { return errors.New("mobile_userAutolog requires sid, user id, and arl") } payload := map[string]any{ "platform": "Xiaomi_lavender_14", "custo_version_id": "", "custo_partner": nil, "model": "Redmi Note 7", "device_name": "Redmi Note 7", "device_os": "Android", "device_type": "phone", "google_play_services_availability": "1", "device_serial": c.deviceID, "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)) for _, method := range []string{"mobile_userAutolog", "mobile_userAutoLog"} { if err := c.limiter.Wait(ctx); err != nil { return err } params.Set("method", method) b, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, http.MethodPost, gatewayURL+"?"+params.Encode(), bytes.NewReader(b)) if err != nil { return err } req.Header.Set("User-Agent", c.ua) req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json; charset=utf-8") resp, err := c.http.Do(req) if err != nil { continue } raw, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() out := map[string]any{} if json.Unmarshal(raw, &out) != nil { continue } if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 { continue } results := jsonutil.NestedMap(out, "results") if len(results) == 0 { continue } c.jwt = jsonutil.FirstNonEmpty(c.jwt, findStringByKey(results, "JWT")) c.refresh = jsonutil.FirstNonEmpty(c.refresh, findStringByKey(results, "refresh_token")) c.license = jsonutil.FirstNonEmpty(c.license, findStringByKey(results, "license_token")) if c.jwt != "" || c.license != "" { return nil } } return errors.New("mobile_userAutolog failed to produce jwt/license") } func (c *Client) refreshJWT(ctx context.Context) error { if strings.TrimSpace(c.refresh) == "" { return errors.New("missing deezer refresh token") } if err := c.limiter.Wait(ctx); err != nil { return err } body := map[string]string{"refresh_token": c.refresh} b, _ := json.Marshal(body) req, err := http.NewRequestWithContext(ctx, http.MethodPost, authURL+"?i=p&jo=p&rto=p", bytes.NewReader(b)) if err != nil { return err } req.Header.Set("User-Agent", c.ua) req.Header.Set("Content-Type", "application/json; charset=utf-8") resp, err := c.http.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("jwt refresh failed: status=%d body=%s", resp.StatusCode, string(raw)) } out := map[string]any{} if json.Unmarshal(raw, &out) != nil { return errors.New("invalid jwt refresh response") } if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 { msg := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(errObj["message"]), jsonutil.StringFromAny(errObj["type"])) if msg == "" { msg = "jwt refresh returned an error" } return errors.New(msg) } if jwt := strings.TrimSpace(jsonutil.StringFromAny(out["jwt"])); jwt != "" { c.jwt = jwt } if rt := strings.TrimSpace(jsonutil.StringFromAny(out["refresh_token"])); rt != "" { c.refresh = rt } if c.jwt == "" { return errors.New("jwt refresh returned empty jwt") } c.persistRefreshToken() return nil } 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) == "" { 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 nil, err } encoded, err := json.Marshal(body) 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", "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 "+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 { 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 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 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 == "" { return errors.New("pipe response missing license token") } c.license = token return nil } func deriveGatewayAuthToken(mobileToken string) (string, error) { dec, err := decryptMobileToken(mobileToken) if err != nil { return "", err } if len(dec) < 80 { return "", errors.New("decrypted mobile token too short") } decryptKey := []byte(string(dec[:64])) encryptKey := []byte(string(dec[64:80])) enc, err := aesECBEncrypt(encryptKey, decryptKey) if err != nil { return "", err } return hex.EncodeToString(enc), nil } func decryptMobileToken(mobileToken string) ([]byte, error) { b, err := hex.DecodeString(strings.TrimSpace(mobileToken)) if err != nil { return nil, err } return aesECBDecrypt([]byte(gatewayDec), b) } func encryptPassword(mobileToken, password string) (string, error) { if strings.TrimSpace(password) == "" { return "", errors.New("missing deezer password") } dec, err := decryptMobileToken(mobileToken) if err != nil { return "", err } if len(dec) < 96 { return "", errors.New("decrypted mobile token too short for password encryption") } key := []byte(string(dec[80:96])) padded := zeroPad([]byte(password), aes.BlockSize) enc, err := aesECBEncrypt(key, padded) if err != nil { return "", err } return hex.EncodeToString(enc), nil } func zeroPad(data []byte, blockSize int) []byte { if blockSize <= 0 { return data } rem := len(data) % blockSize if rem == 0 { return data } out := make([]byte, len(data)+(blockSize-rem)) copy(out, data) return out } func aesECBDecrypt(key []byte, data []byte) ([]byte, error) { if len(data)%aes.BlockSize != 0 { return nil, errors.New("ecb decrypt input not multiple of block size") } block, err := aes.NewCipher(key) if err != nil { return nil, err } out := make([]byte, len(data)) for i := 0; i < len(data); i += aes.BlockSize { block.Decrypt(out[i:i+aes.BlockSize], data[i:i+aes.BlockSize]) } return out, nil } func aesECBEncrypt(key []byte, data []byte) ([]byte, error) { if len(data)%aes.BlockSize != 0 { return nil, errors.New("ecb encrypt input not multiple of block size") } block, err := aes.NewCipher(key) if err != nil { return nil, err } out := make([]byte, len(data)) for i := 0; i < len(data); i += aes.BlockSize { block.Encrypt(out[i:i+aes.BlockSize], data[i:i+aes.BlockSize]) } return out, nil } func sidFromCookies(client *http.Client, rawURL string) string { if client == nil || client.Jar == nil { return "" } u, err := url.Parse(rawURL) if err != nil { return "" } for _, ck := range client.Jar.Cookies(u) { if strings.EqualFold(strings.TrimSpace(ck.Name), "sid") { return strings.TrimSpace(ck.Value) } } return "" } func randomHexN(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return fmt.Sprintf("%d", time.Now().UnixNano()) } return hex.EncodeToString(b) } func randomDeezerUA() string { if len(deezerUAPool) == 0 { return "Deezer/9.0.11.4 (Android; 14; Mobile; us)" } b := make([]byte, 1) if _, err := rand.Read(b); err != nil { return deezerUAPool[0] } return deezerUAPool[int(b[0])%len(deezerUAPool)] } type mediaResult struct { URL string Format string Cipher string TrackID string } type deezerMediaError struct { Code int Message string } 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") } if err := c.limiter.Wait(ctx); err != nil { 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": formats, }}, } b, err := json.Marshal(reqBody) if err != nil { return nil, err } 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) 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 media get_url failed: status=%d body=%s", resp.StatusCode, string(raw)) } var parsed struct { Data []struct { Errors []struct { Code int `json:"code"` Message string `json:"message"` } `json:"errors"` Media []struct { Cipher struct { Type string `json:"type"` } `json:"cipher"` Format string `json:"format"` Sources []struct { URL string `json:"url"` } `json:"sources"` } `json:"media"` } `json:"data"` } if err = json.Unmarshal(raw, &parsed); err != nil { return nil, err } if len(parsed.Data) == 0 { return nil, errors.New("deezer media response contains no data") } if len(parsed.Data[0].Errors) > 0 { e := parsed.Data[0].Errors[0] return nil, &deezerMediaError{Code: e.Code, Message: e.Message} } for _, want := range requestedFormats { 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 } } } 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 { want = "MP3_128" } else if quality == 1 { want = "MP3_320" } priority := []string{want} if allowFallback { for _, f := range []string{"FLAC", "MP3_320", "MP3_128"} { if f != want { priority = append(priority, f) } } } return priority } func extensionForFormat(format string) string { switch strings.ToUpper(strings.TrimSpace(format)) { case "FLAC": return "flac" case "MP3_320", "MP3_128", "MP3_64", "MP3_MISC": return "mp3" default: return "mp3" } } func audioProfileForFormat(format string) provider.AudioProfile { profile := provider.AudioProfile{} switch strings.ToUpper(strings.TrimSpace(format)) { case "FLAC": profile.Container = "FLAC" profile.Codec = "FLAC" profile.Quality = "LOSSLESS" profile.BitDepth = 16 profile.SamplingRate = "44.1" case "MP3_320": profile.Container = "MP3" profile.Codec = "MP3" profile.Quality = "HIGH" profile.BitrateKbps = 320 profile.BitDepth = 16 profile.SamplingRate = "44.1" case "MP3_128": profile.Container = "MP3" profile.Codec = "MP3" profile.Quality = "LOW" profile.BitrateKbps = 128 profile.BitDepth = 16 profile.SamplingRate = "44.1" case "MP3_64", "MP3_MISC": profile.Container = "MP3" profile.Codec = "MP3" profile.Quality = "LOW" profile.BitrateKbps = 64 profile.BitDepth = 16 profile.SamplingRate = "44.1" default: if ext := extensionForFormat(format); ext == "flac" { profile.Container = "FLAC" profile.Codec = "FLAC" profile.Quality = "LOSSLESS" profile.BitDepth = 16 profile.SamplingRate = "44.1" } else { profile.Container = "MP3" profile.Codec = "MP3" profile.Quality = "LOW" profile.BitrateKbps = 128 profile.BitDepth = 16 profile.SamplingRate = "44.1" } } return profile } func findStringByKey(v any, wantedKey string) string { w := strings.ToLower(strings.TrimSpace(wantedKey)) switch x := v.(type) { case map[string]any: for k, value := range x { if strings.ToLower(k) == w { if s := jsonutil.StringFromAny(value); strings.TrimSpace(s) != "" { return s } } if nested := findStringByKey(value, wantedKey); nested != "" { return nested } } case []any: for _, item := range x { if nested := findStringByKey(item, wantedKey); nested != "" { return nested } } } return "" } func enrichTrack(track map[string]any) { if artist, ok := track["artist"].(map[string]any); ok { track["performer"] = map[string]any{"name": jsonutil.StringFromAny(artist["name"]), "id": jsonutil.StringFromAny(artist["id"])} } if album, ok := track["album"].(map[string]any); ok { enrichAlbumImage(album) } if _, ok := track["track_number"]; !ok { if p := track["track_position"]; p != nil { track["track_number"] = p } } if _, ok := track["media_number"]; !ok { if d := track["disk_number"]; d != nil { track["media_number"] = d } } if jsonutil.BoolFromAny(track["explicit_lyrics"]) { track["explicit"] = true } } func enrichAlbumImage(meta map[string]any) { if _, ok := meta["image"].(map[string]any); ok { return } cover := jsonutil.FirstNonEmpty( jsonutil.StringFromAny(meta["cover_xl"]), jsonutil.StringFromAny(meta["cover_big"]), jsonutil.StringFromAny(meta["cover_medium"]), jsonutil.StringFromAny(meta["cover_small"]), ) if cover == "" { return } meta["image"] = map[string]any{ "small": cover, "large": cover, "extralarge": cover, "original": cover, } }