harden deezer auth and lyrics tagging behavior

This commit is contained in:
2026-04-21 11:14:57 +02:00
parent 26c9d50fac
commit 9ebddc8316
8 changed files with 1224 additions and 23 deletions

View File

@@ -510,6 +510,10 @@ func (m *Main) Rip(ctx context.Context) error {
}
func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID string, albumMeta map[string]any) error {
if err := m.requireSourceDownloadAuth(source); err != nil {
return err
}
albumTitle := titleFromMetadata(albumMeta, albumID)
albumArtist := nestedString(albumMeta, "artist", "name")
if albumArtist == "" {
@@ -620,11 +624,19 @@ func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID
}
func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playlistID string, playlistMeta map[string]any) error {
if err := m.requireSourceDownloadAuth(source); err != nil {
return err
}
name := titleFromMetadata(playlistMeta, playlistID)
if n := stringFromAny(playlistMeta["name"]); n != "" {
name = n
}
folder := filepath.Join(m.Config.Session.Downloads.Folder, naming.CleanName(name, naming.Config{
base := m.Config.Session.Downloads.Folder
if m.Config.Session.Downloads.SourceSubdirectories {
base = filepath.Join(base, strings.Title(source))
}
folder := filepath.Join(base, naming.CleanName(name, naming.Config{
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
TruncateTo: m.Config.Session.Filepaths.TruncateTo,
}))
@@ -765,6 +777,18 @@ func (m *Main) ripPlaylistMixed(ctx context.Context, playlistID, name string, re
return nil
}
func (m *Main) requireSourceDownloadAuth(source string) error {
if source == "deezer" {
hasARL := strings.TrimSpace(m.Config.Session.Deezer.ARL) != ""
hasCreds := strings.TrimSpace(m.Config.Session.Deezer.Email) != "" && strings.TrimSpace(m.Config.Session.Deezer.Password) != ""
hasRefresh := strings.TrimSpace(m.Config.Session.Deezer.RefreshToken) != ""
if !hasARL && !hasCreds && !hasRefresh {
return fmt.Errorf("deezer native download requires deezer.arl, deezer.email+deezer.password, or deezer.refresh_token")
}
}
return nil
}
func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fallbackTitle string, opts ripTrackOptions) error {
alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, id)
if err == nil && alreadyDownloaded {
@@ -1217,6 +1241,9 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
comment := stringFromAny(trackMeta["comment"])
description := stringFromAny(trackMeta["description"])
lyrics := stringFromAny(trackMeta["lyrics"])
if lrc := stringFromAny(trackMeta["lyrics_synced"]); lrc != "" {
lyrics = lrc
}
trackGain := replaygainGainFromAny(trackMeta["replaygain_track_gain"])
if trackGain == "" {
trackGain = replaygainGainFromAny(trackMeta["replayGain"])

View File

@@ -464,6 +464,77 @@ func TestPlaylistRipPipeline(t *testing.T) {
}
}
func TestPlaylistRipUsesSourceSubdirectory(t *testing.T) {
tmp := t.TempDir()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("audio-bytes"))
}))
defer ts.Close()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
d.Downloads.Concurrency = false
d.Downloads.SourceSubdirectories = true
d.Filepaths.RestrictCharacters = false
cfg := &config.Config{File: d, Session: d}
sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite"))
if err != nil {
t.Fatalf("NewSQLite() error = %v", err)
}
defer func() { _ = sqlite.Close() }()
m := &Main{
Config: cfg,
Providers: map[string]provider.Client{
"qobuz": &fakePlaylistProvider{url: ts.URL},
},
Store: sqlite,
DL: download.NewWithOptions(true, false),
Tagger: noopTagger{},
}
ctx := context.Background()
if err = m.AddByID(ctx, "qobuz", "playlist", "pl1"); err != nil {
t.Fatalf("AddByID() error = %v", err)
}
if err = m.Resolve(ctx); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if err = m.Rip(ctx); err != nil {
t.Fatalf("Rip() error = %v", err)
}
folder := filepath.Join(tmp, "Qobuz", "Road Trip")
if _, err = os.Stat(filepath.Join(folder, "01. Artist - Track One.flac")); err != nil {
t.Fatalf("missing first playlist track in source subdir: %v", err)
}
}
func TestRipPlaylistRequiresDeezerARL(t *testing.T) {
d := config.DefaultConfigData()
m := &Main{Config: &config.Config{File: d, Session: d}}
err := m.ripPlaylist(context.Background(), nil, "deezer", "pl1", map[string]any{
"name": "Road Trip",
"tracks": map[string]any{"items": []any{map[string]any{"id": "p1"}}},
})
if err == nil || !strings.Contains(err.Error(), "deezer") {
t.Fatalf("expected deezer arl error, got %v", err)
}
}
func TestRipAlbumRequiresDeezerARL(t *testing.T) {
d := config.DefaultConfigData()
m := &Main{Config: &config.Config{File: d, Session: d}}
err := m.ripAlbum(context.Background(), nil, "deezer", "alb1", map[string]any{})
if err == nil || !strings.Contains(err.Error(), "deezer") {
t.Fatalf("expected deezer arl error, got %v", err)
}
}
func TestApplyQobuzArtistFiltersRepeats(t *testing.T) {
albums := []collectionAlbum{
{ID: "a1", Title: "Album X", BitDepth: 16, Sampling: 44.1, Explicit: false},

View File

@@ -71,8 +71,9 @@ type DeezerConfig struct {
Quality int `toml:"quality"`
LowerQualityIfNotAvailable bool `toml:"lower_quality_if_not_available"`
ARL string `toml:"arl"`
UseDeezloader bool `toml:"use_deezloader"`
DeezloaderWarnings bool `toml:"deezloader_warnings"`
Email string `toml:"email"`
Password string `toml:"password"`
RefreshToken string `toml:"refresh_token"`
}
type SoundcloudConfig struct {
@@ -236,8 +237,6 @@ func DefaultConfigData() ConfigData {
Deezer: DeezerConfig{
Quality: 2,
LowerQualityIfNotAvailable: true,
UseDeezloader: true,
DeezloaderWarnings: true,
},
Soundcloud: SoundcloudConfig{
Quality: 0,

View File

@@ -77,15 +77,66 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: status=%d", resp.StatusCode)
}
encrypted, err := io.ReadAll(resp.Body)
out, err := os.Create(outputPath)
if err != nil {
return err
}
plain, err := decryptDeezerBFCBCStripe(encrypted, trackID)
defer func() { _ = out.Close() }()
var bar *mpb.Bar
if d.ProgressEnabled() && resp.ContentLength > 0 {
d.barStarted.Store(1)
desc := shortenName(filepath.Base(outputPath), 54)
bar = d.progress.AddBar(
resp.ContentLength,
mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
decor.Percentage(decor.WCSyncWidthR),
),
mpb.AppendDecorators(
decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR),
decor.Name(" | ETA ", decor.WCSyncWidth),
decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR),
),
mpb.BarRemoveOnComplete(),
)
}
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID))
if err != nil {
return err
}
return os.WriteFile(outputPath, plain, 0o644)
buf := make([]byte, deezerBFChunkSize)
dec := make([]byte, deezerBFChunkSize)
chunkIndex := 0
for {
n, readErr := io.ReadFull(resp.Body, buf)
if readErr == io.EOF {
break
}
if readErr != nil && readErr != io.ErrUnexpectedEOF {
return readErr
}
chunk := buf[:n]
if chunkIndex%3 == 0 && n == deezerBFChunkSize {
mode := cipher.NewCBCDecrypter(block, deezerBFIV)
mode.CryptBlocks(dec[:n], chunk)
chunk = dec[:n]
}
if _, err = out.Write(chunk); err != nil {
return err
}
if bar != nil {
bar.IncrBy(n)
}
chunkIndex++
if readErr == io.ErrUnexpectedEOF {
break
}
}
return nil
}
func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool, includeVideo bool) error {

View File

@@ -84,3 +84,39 @@ func TestDecryptDeezerBFCBCStripe(t *testing.T) {
t.Fatalf("decrypted data mismatch")
}
}
func TestFileDeezerEncrypted(t *testing.T) {
trackID := "3135556"
plain := make([]byte, deezerBFChunkSize+777)
for i := range plain {
plain[i] = byte((i * 7) % 251)
}
enc := make([]byte, len(plain))
copy(enc, plain)
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID))
if err != nil {
t.Fatalf("cipher error: %v", err)
}
cbc := cipher.NewCBCEncrypter(block, deezerBFIV)
cbc.CryptBlocks(enc[:deezerBFChunkSize], enc[:deezerBFChunkSize])
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(enc)
}))
defer ts.Close()
d := NewWithOptions(true, false)
out := filepath.Join(t.TempDir(), "x", "a.flac")
if err = d.FileDeezerEncrypted(context.Background(), ts.URL, out, trackID); err != nil {
t.Fatalf("FileDeezerEncrypted() error = %v", err)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(got) != string(plain) {
t.Fatalf("decrypted file mismatch")
}
}

View File

@@ -1,7 +1,11 @@
package deezer
import (
"bytes"
"context"
"crypto/aes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -19,10 +23,20 @@ import (
)
var (
baseURL = "https://api.deezer.com"
webGWLight = "https://www.deezer.com/ajax/gw-light.php"
mediaURL = "https://media.deezer.com/v1/get_url"
deezerUA = "Deezer/9.0.11.4 (Android; 14; Mobile; us) Xiaomi Redmi Note 7"
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",
}
)
type Client struct {
@@ -30,6 +44,8 @@ type Client struct {
http *http.Client
limiter *ratelimit.Limiter
loggedIn bool
ua string
deviceID string
sid string
arl string
jwt string
@@ -40,10 +56,13 @@ type Client struct {
func New(cfg *config.Config) *Client {
return &Client{
cfg: cfg,
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL),
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
arl: strings.TrimSpace(cfg.Session.Deezer.ARL),
cfg: cfg,
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL),
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
ua: randomDeezerUA(),
deviceID: randomHexN(16),
arl: strings.TrimSpace(cfg.Session.Deezer.ARL),
refresh: strings.TrimSpace(cfg.Session.Deezer.RefreshToken),
}
}
@@ -53,10 +72,32 @@ func (c *Client) Source() string {
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 = ""
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")
}
c.loggedIn = true
return nil
@@ -111,6 +152,14 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
return nil, err
}
enrichTrack(resp)
if lyr, lyrErr := c.fetchLyricsFromPipe(ctx, strings.TrimSpace(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)
@@ -176,13 +225,29 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
}
func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*provider.Downloadable, error) {
if strings.TrimSpace(c.arl) == "" {
return nil, errors.New("deezer native download requires deezer.arl in config")
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 strings.TrimSpace(c.license) == "" {
if err := c.refreshSessionFromARL(ctx); err != nil {
return nil, err
}
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 {
@@ -273,7 +338,7 @@ func (c *Client) refreshSessionFromARL(ctx context.Context) error {
if err != nil {
return err
}
req.Header.Set("User-Agent", deezerUA)
req.Header.Set("User-Agent", c.ua)
req.Header.Set("Accept", "application/json")
req.Header.Set("Cookie", "arl="+strings.TrimSpace(c.arl))
@@ -300,11 +365,156 @@ func (c *Client) refreshSessionFromARL(ctx context.Context) error {
if len(results) == 0 {
return errors.New("deezer getUserData returned empty results")
}
c.sid = firstNonEmpty(c.sid, sidFromCookies(c.http, webGWLight))
c.license = findStringByKey(results, "license_token")
c.userID = findStringByKey(results, "USER_ID")
c.jwt = firstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
c.refresh = firstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
if c.sid == "" {
if sid, sidErr := c.bootstrapSID(ctx); sidErr == nil {
c.sid = sid
}
}
if c.sid != "" && c.userID != "" {
_ = c.mobileUserAutolog(ctx)
}
if c.jwt == "" && c.refresh != "" {
_ = c.refreshJWT(ctx)
}
if c.license == "" && 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 = 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 := firstNonEmpty(stringFromAny(errObj["message"]), stringFromAny(errObj["type"]))
if msg == "" {
msg = "unknown mobile_userAuth error"
}
return errors.New(msg)
}
results := nestedMap(out, "results")
if len(results) == 0 {
return errors.New("mobile_userAuth returned empty results")
}
c.arl = firstNonEmpty(c.arl, findStringByKey(results, "ARL"))
c.jwt = firstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
c.refresh = firstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
c.license = firstNonEmpty(c.license, findStringByKey(results, "license_token"))
c.userID = 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
}
@@ -320,6 +530,487 @@ func (c *Client) getTrackToken(ctx context.Context, trackID string) (string, err
return token, 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,
}
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", "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(stringFromAny(em["message"]))
typ = strings.TrimSpace(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 := nestedMap(nestedMap(nestedMap(out, "data"), "track"), "lyrics")
text := strings.TrimSpace(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(stringFromAny(m["line"]))
if line == "" {
line = strings.TrimSpace(stringFromAny(m["lineTranslated"]))
}
if line != "" {
parts = append(parts, line)
}
}
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")
}
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)
}
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(stringFromAny(m["line"]))
if line == "" {
line = strings.TrimSpace(stringFromAny(m["lineTranslated"]))
}
if line == "" {
continue
}
ms := 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))
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
}
out := map[string]any{}
if err = json.Unmarshal(raw, &out); err != nil {
return "", err
}
token := findStringByKey(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
}
out := map[string]any{}
if err = json.Unmarshal(raw, &out); err != nil {
return "", err
}
sid := strings.TrimSpace(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,
}
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 {
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 := nestedMap(out, "results")
if len(results) == 0 {
continue
}
c.jwt = firstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
c.refresh = firstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
c.license = 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)
out := map[string]any{}
if json.Unmarshal(raw, &out) != nil {
return errors.New("invalid jwt refresh response")
}
if jwt := strings.TrimSpace(stringFromAny(out["jwt"])); jwt != "" {
c.jwt = jwt
}
if rt := strings.TrimSpace(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) refreshLicenseFromPipe(ctx context.Context) error {
if strings.TrimSpace(c.jwt) == "" {
return errors.New("missing deezer jwt")
}
if err := c.limiter.Wait(ctx); err != nil {
return 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))
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")
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)
out := map[string]any{}
if json.Unmarshal(raw, &out) != nil {
return errors.New("invalid pipe response")
}
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
@@ -369,7 +1060,7 @@ func (c *Client) getMediaURLForFormat(ctx context.Context, trackToken, format st
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", deezerUA)
req.Header.Set("User-Agent", c.ua)
req.Header.Set("Accept", "*/*")
req.Header.Set("Content-Type", "text/plain; charset=UTF-8")
@@ -477,6 +1168,14 @@ func findStringByKey(v any, wantedKey string) string {
return ""
}
func nestedMap(m map[string]any, key string) map[string]any {
v, ok := m[key].(map[string]any)
if !ok {
return map[string]any{}
}
return v
}
func enrichTrack(track map[string]any) {
if artist, ok := track["artist"].(map[string]any); ok {
track["performer"] = map[string]any{"name": stringFromAny(artist["name"]), "id": stringFromAny(artist["id"])}

View File

@@ -2,6 +2,7 @@ package deezer
import (
"context"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -57,14 +58,18 @@ func TestGetDownloadableNativeCipher(t *testing.T) {
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)
@@ -106,14 +111,18 @@ func TestGetDownloadableDRMError(t *testing.T) {
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
}()
_, err := c.GetDownloadable(context.Background(), "42", 2)
@@ -121,3 +130,154 @@ func TestGetDownloadableDRMError(t *testing.T) {
t.Fatalf("expected drm error, got %v", err)
}
}
func TestGetMetadataAddsLyricsFromPipe(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/track/1141668":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 1141668, "title": "In Da Club", "artist": map[string]any{"name": "50 Cent"}})
case "/pipe":
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"track": map[string]any{"lyrics": map[string]any{"text": "Go, go, go\nGo shawty", "synchronizedLines": []any{map[string]any{"line": "Go, go, go", "milliseconds": 0}, map[string]any{"line": "Go shawty", "milliseconds": 4280}}}}}})
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"
origBase := baseURL
origPipe := pipeURL
baseURL = ts.URL
pipeURL = ts.URL + "/pipe"
defer func() {
baseURL = origBase
pipeURL = origPipe
}()
meta, err := c.GetMetadata(context.Background(), "1141668", "track")
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
if !strings.Contains(stringFromAny(meta["lyrics"]), "Go shawty") {
t.Fatalf("expected lyrics text, got %q", stringFromAny(meta["lyrics"]))
}
if !strings.Contains(stringFromAny(meta["lyrics_synced"]), "[00:00.00]Go, go, go") {
t.Fatalf("expected synced lyrics, got %q", stringFromAny(meta["lyrics_synced"]))
}
}
func TestLoginWithCredentials(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
}
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_userAuth":
var payload map[string]any
_ = json.NewDecoder(r.Body).Decode(&payload)
if strings.TrimSpace(stringFromAny(payload["mail"])) == "" || strings.TrimSpace(stringFromAny(payload["password"])) == "" {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": "missing creds"}})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"results": map[string]any{"ARL": "arl-token", "JWT": "jwt-token", "refresh_token": "refresh-token", "license_token": "license-token", "USER_ID": "42"}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Deezer.Email = "tidal1@alpin.sbs"
cfgData.Deezer.Password = "tidal1@alpin.sbs"
c := New(&config.Config{File: cfgData, Session: cfgData})
origGateway := gatewayURL
gatewayURL = ts.URL + "/gateway"
defer func() { gatewayURL = origGateway }()
if err := c.Login(context.Background()); err != nil {
t.Fatalf("Login() error = %v", err)
}
if !c.loggedIn {
t.Fatalf("expected logged in client")
}
if c.arl != "arl-token" {
t.Fatalf("arl = %q, want arl-token", c.arl)
}
if c.jwt != "jwt-token" {
t.Fatalf("jwt = %q, want jwt-token", c.jwt)
}
if c.refresh != "refresh-token" {
t.Fatalf("refresh = %q, want refresh-token", c.refresh)
}
if c.license != "license-token" {
t.Fatalf("license = %q, want license-token", c.license)
}
if c.cfg.Session.Deezer.RefreshToken != "refresh-token" {
t.Fatalf("session refresh token = %q", c.cfg.Session.Deezer.RefreshToken)
}
if c.cfg.File.Deezer.RefreshToken != "refresh-token" {
t.Fatalf("file refresh token = %q", c.cfg.File.Deezer.RefreshToken)
}
}
func testMobileToken(t *testing.T) string {
t.Helper()
plain := []byte(strings.Repeat("A", 64) + strings.Repeat("B", 16) + strings.Repeat("C", 16))
enc, err := aesECBEncrypt([]byte(gatewayDec), plain)
if err != nil {
t.Fatalf("aesECBEncrypt() error = %v", err)
}
return hex.EncodeToString(enc)
}
func TestLoginWithRefreshToken(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/renew":
_ = json.NewEncoder(w).Encode(map[string]any{"jwt": "jwt-token", "refresh_token": "refresh-token-2"})
case "/pipe":
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"tokens": map[string]any{"mediaServiceLicenseToken": map[string]any{"token": "license-token"}}}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Deezer.RefreshToken = "refresh-token"
c := New(&config.Config{File: cfgData, Session: cfgData})
origAuth := authURL
origPipe := pipeURL
authURL = ts.URL + "/renew"
pipeURL = ts.URL + "/pipe"
defer func() {
authURL = origAuth
pipeURL = origPipe
}()
if err := c.Login(context.Background()); err != nil {
t.Fatalf("Login() error = %v", err)
}
if !c.loggedIn {
t.Fatalf("expected logged in client")
}
if c.jwt != "jwt-token" || c.license != "license-token" {
t.Fatalf("unexpected jwt/license: jwt=%q license=%q", c.jwt, c.license)
}
if c.cfg.Session.Deezer.RefreshToken != "refresh-token-2" {
t.Fatalf("session refresh token = %q", c.cfg.Session.Deezer.RefreshToken)
}
}