From b63889f5596ea1004a4d51bd04fe8f2bd1a6ba71 Mon Sep 17 00:00:00 2001 From: Joren Date: Fri, 24 Apr 2026 15:19:47 +0200 Subject: [PATCH] refactor deezer session and pipe graphql flow --- internal/provider/deezer/client.go | 331 +++++++++++------------------ 1 file changed, 123 insertions(+), 208 deletions(-) diff --git a/internal/provider/deezer/client.go b/internal/provider/deezer/client.go index 0062078..cd41b07 100644 --- a/internal/provider/deezer/client.go +++ b/internal/provider/deezer/client.go @@ -88,30 +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.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 if c.refresh != "" { - if err := c.refreshJWT(ctx); err != nil { - return err - } - if err := c.refreshLicenseFromPipe(ctx); 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 } @@ -270,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) == "" { @@ -302,17 +287,6 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov return nil, err } media, err := c.getMediaURL(ctx, trackToken, c.cfg.Session.Deezer.Quality, c.cfg.Session.Deezer.LowerQualityIfNotAvailable) - if err != nil { - var mediaErr *deezerMediaError - if errors.As(err, &mediaErr) && mediaErr.Code == 2004 { - if freshToken, tokenErr := c.getTrackTokenFromPipe(ctx, item); tokenErr == nil && strings.TrimSpace(freshToken) != "" && freshToken != trackToken { - if retryMedia, retryErr := c.getMediaURL(ctx, freshToken, c.cfg.Session.Deezer.Quality, c.cfg.Session.Deezer.LowerQualityIfNotAvailable); retryErr == nil { - media = retryMedia - err = nil - } - } - } - } if err != nil { return nil, err } @@ -572,31 +546,14 @@ func (c *Client) getTrackToken(ctx context.Context, trackID string) (string, err if token, err := c.getTrackTokenFromPipe(ctx, trackID); err == nil && strings.TrimSpace(token) != "" { return token, nil } else 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 token, retryErr := c.getTrackTokenFromPipe(ctx, trackID); retryErr == nil && strings.TrimSpace(token) != "" { + return token, nil } } - if strings.TrimSpace(c.jwt) == "" { - if strings.TrimSpace(c.arl) != "" { - _ = c.refreshSessionFromARL(ctx) - } - if strings.TrimSpace(c.jwt) == "" && strings.TrimSpace(c.refresh) != "" { - _ = c.refreshJWT(ctx) - } - } - if token, err := c.getTrackTokenFromPipe(ctx, trackID); err == nil && strings.TrimSpace(token) != "" { - return token, nil - } else if errors.Is(err, errDeezerJWTExpired) { - if strings.TrimSpace(c.refresh) != "" { - _ = c.refreshJWT(ctx) - } - if strings.TrimSpace(c.jwt) != "" { - if retryToken, retryErr := c.getTrackTokenFromPipe(ctx, trackID); retryErr == nil && strings.TrimSpace(retryToken) != "" { - return retryToken, 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) @@ -611,12 +568,6 @@ func (c *Client) getTrackToken(ctx context.Context, trackID string) (string, err } func (c *Client) getTrackTokenFromPipe(ctx context.Context, trackID string) (string, error) { - if strings.TrimSpace(c.jwt) == "" { - return "", errors.New("deezer jwt unavailable for track media token") - } - if err := c.limiter.Wait(ctx); err != nil { - return "", err - } 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", @@ -629,52 +580,10 @@ func (c *Client) getTrackTokenFromPipe(ctx context.Context, trackID string) (str }, }, } - encoded, err := json.Marshal(body) + out, err := c.pipeGraphQL(ctx, body, "deezer track media query") if err != nil { return "", err } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, pipeURL, bytes.NewReader(encoded)) - if err != nil { - return "", 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 "+strings.TrimSpace(c.jwt)) - - 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 track media query failed: status=%d body=%s", resp.StatusCode, string(raw)) - } - out := map[string]any{} - if err = json.Unmarshal(raw, &out); err != nil { - return "", 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 "", errDeezerJWTExpired - } - if msg == "" { - msg = "unknown graphql error" - } - return "", errors.New(msg) - } 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") @@ -697,50 +606,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"]) @@ -765,22 +634,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) } @@ -1039,13 +898,99 @@ 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 } + 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 } } }", @@ -1057,40 +1002,10 @@ func (c *Client) refreshLicenseFromPipe(ctx context.Context) error { }, }, } - b, _ := json.Marshal(body) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, pipeURL, bytes.NewReader(b)) + out, err := c.pipeGraphQL(ctx, body, "pipe license refresh") if err != nil { return 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 "+strings.TrimSpace(c.jwt)) - 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("pipe license refresh failed: status=%d body=%s", resp.StatusCode, string(raw)) - } - out := map[string]any{} - if json.Unmarshal(raw, &out) != nil { - return errors.New("invalid pipe response") - } - if errs, ok := out["errors"].([]any); ok && len(errs) > 0 { - msg := "" - if em, ok := errs[0].(map[string]any); ok { - msg = strings.TrimSpace(jsonutil.StringFromAny(em["message"])) - } - if msg == "" { - msg = "pipe response returned graphql error" - } - return errors.New(msg) - } token := findStringByKey(out, "token") if token == "" { return errors.New("pipe response missing license token")