Merge remote-tracking branch 'origin/master' into fix/resolved-audio-folder-naming

This commit is contained in:
2026-04-24 18:37:30 +02:00
2 changed files with 547 additions and 154 deletions

View File

@@ -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")
}