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

158
config.toml.example Normal file
View File

@@ -0,0 +1,158 @@
[downloads]
# Folder where tracks are downloaded to
folder = "/path/to/StreamripDownloads"
# Put Qobuz albums in a 'Qobuz' folder, Tidal albums in 'Tidal', etc.
source_subdirectories = false
# Put tracks in albums with 2+ discs into subfolders named `Disc N`
disc_subdirectories = true
# Download (and convert) tracks concurrently instead of sequentially
concurrency = true
# The maximum number of tracks to download at once
# Set to -1 for no limit
max_connections = 6
# Max number of API requests per source per minute
# Set to -1 for no limit
requests_per_minute = 60
# Verify SSL certificates for API connections
# Set to false only if certificate verification fails (not recommended)
verify_ssl = true
[qobuz]
# 1: 320kbps MP3, 2: 16/44.1, 3: 24/<=96, 4: 24/>96
quality = 3
# Download booklet PDFs when available
download_booklets = true
# Authenticate using Qobuz auth token instead of email/password hash
use_auth_token = false
# If use_auth_token=true, set your user id. Otherwise set your email.
email_or_userid = ""
# If use_auth_token=true, set your auth token. Otherwise set md5(password).
password_or_token = ""
# Managed automatically by streamrip-go
app_id = ""
# Managed automatically by streamrip-go
secrets = []
[tidal]
# 0: AAC 256, 1: AAC 320, 2: FLAC 16/44.1, 3: FLAC hi-res when available
quality = 3
# Download videos included in supported Tidal media
download_videos = true
# Session values are managed automatically. Do not modify manually.
user_id = ""
country_code = ""
access_token = ""
refresh_token = ""
# Unix timestamp when access_token expires
token_expiry = 0
[deezer]
# 0: MP3_128, 1: MP3_320, 2: FLAC
quality = 2
# If target quality is unavailable, fallback down quality ladder
lower_quality_if_not_available = true
# Deezer ARL cookie (recommended auth method)
arl = ""
# Optional login alternative when ARL is not provided
email = ""
password = ""
# Optional cached Deezer refresh token. Managed automatically when available.
refresh_token = ""
[soundcloud]
# Only 0 is currently supported
quality = 0
# Managed automatically when available
client_id = ""
app_version = ""
[youtube]
# Only 0 is currently supported
quality = 0
# Download video streams together with audio when supported
download_videos = false
# Folder used for video outputs
video_downloads_folder = "/path/to/StreamripDownloads/YouTubeVideos"
[database]
# Track IDs already downloaded are stored here and skipped next time
downloads_enabled = true
downloads_path = "/path/to/.config/streamrip/downloads.db"
# Failed item IDs are stored here for retry/repair workflows
failed_downloads_enabled = true
failed_downloads_path = "/path/to/.config/streamrip/failed_downloads.db"
[conversion]
# Convert tracks after download
enabled = false
# ALAC, FLAC, OGG, MP3, or AAC
codec = "ALAC"
# In Hz. Audio is downsampled when above this rate.
sampling_rate = 48000
# Applied only when source bit depth is higher than this value
bit_depth = 24
# Used for lossy codecs
lossy_bitrate = 320
[qobuz_filters]
# Filter a Qobuz artist discography (best-effort for other sources)
extras = false
repeats = false
non_albums = false
features = false
non_studio_albums = false
non_remaster = false
[artwork]
# Embed artwork in the audio file
embed = true
# thumbnail, small, large, or original
embed_size = "large"
# If > 0, embedded image max(width, height) in pixels
embed_max_width = -1
# Save artwork as separate jpg file
save_artwork = true
# If > 0, saved image max(width, height) in pixels
saved_max_width = -1
[metadata]
# Set ALBUM metadata to playlist name for playlist items
set_playlist_to_album = true
# Use playlist position as tracknumber for playlist items
renumber_playlist_tracks = true
# Metadata fields to exclude from tagging
exclude = []
[filepaths]
# Create folders for single tracks using folder_format template
add_singles_to_folder = false
# Available keys: albumartist, title, year, bit_depth, sampling_rate, id, albumcomposer
folder_format = "{albumartist} - {title} ({year}) [{container}] [{bit_depth}B-{sampling_rate}kHz]"
# Available keys: id, tracknumber, artist, albumartist, composer, title, albumcomposer, explicit
track_format = "{tracknumber:02}. {artist} - {title}{explicit}"
# Restrict filenames to printable ASCII
restrict_characters = false
# Truncate filenames longer than this value
truncate_to = 120
[lastfm]
# Primary source used to resolve Last.fm playlist tracks
source = "qobuz"
# Fallback source when primary lookup fails
fallback_source = ""
[cli]
# Print informational output like "Downloading <album>"
text_output = true
# Show resolve and download progress bars
progress_bars = true
# Max interactive search results displayed
max_search_results = 100
[misc]
# Metadata used for config compatibility checks
version = "2.2.0"
# Notify when a new version is available
check_for_updates = true

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 { 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) albumTitle := titleFromMetadata(albumMeta, albumID)
albumArtist := nestedString(albumMeta, "artist", "name") albumArtist := nestedString(albumMeta, "artist", "name")
if albumArtist == "" { 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 { 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) name := titleFromMetadata(playlistMeta, playlistID)
if n := stringFromAny(playlistMeta["name"]); n != "" { if n := stringFromAny(playlistMeta["name"]); n != "" {
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, RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
TruncateTo: m.Config.Session.Filepaths.TruncateTo, TruncateTo: m.Config.Session.Filepaths.TruncateTo,
})) }))
@@ -765,6 +777,18 @@ func (m *Main) ripPlaylistMixed(ctx context.Context, playlistID, name string, re
return nil 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 { 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) alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, id)
if err == nil && alreadyDownloaded { if err == nil && alreadyDownloaded {
@@ -1217,6 +1241,9 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
comment := stringFromAny(trackMeta["comment"]) comment := stringFromAny(trackMeta["comment"])
description := stringFromAny(trackMeta["description"]) description := stringFromAny(trackMeta["description"])
lyrics := stringFromAny(trackMeta["lyrics"]) lyrics := stringFromAny(trackMeta["lyrics"])
if lrc := stringFromAny(trackMeta["lyrics_synced"]); lrc != "" {
lyrics = lrc
}
trackGain := replaygainGainFromAny(trackMeta["replaygain_track_gain"]) trackGain := replaygainGainFromAny(trackMeta["replaygain_track_gain"])
if trackGain == "" { if trackGain == "" {
trackGain = replaygainGainFromAny(trackMeta["replayGain"]) 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) { func TestApplyQobuzArtistFiltersRepeats(t *testing.T) {
albums := []collectionAlbum{ albums := []collectionAlbum{
{ID: "a1", Title: "Album X", BitDepth: 16, Sampling: 44.1, Explicit: false}, {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"` Quality int `toml:"quality"`
LowerQualityIfNotAvailable bool `toml:"lower_quality_if_not_available"` LowerQualityIfNotAvailable bool `toml:"lower_quality_if_not_available"`
ARL string `toml:"arl"` ARL string `toml:"arl"`
UseDeezloader bool `toml:"use_deezloader"` Email string `toml:"email"`
DeezloaderWarnings bool `toml:"deezloader_warnings"` Password string `toml:"password"`
RefreshToken string `toml:"refresh_token"`
} }
type SoundcloudConfig struct { type SoundcloudConfig struct {
@@ -236,8 +237,6 @@ func DefaultConfigData() ConfigData {
Deezer: DeezerConfig{ Deezer: DeezerConfig{
Quality: 2, Quality: 2,
LowerQualityIfNotAvailable: true, LowerQualityIfNotAvailable: true,
UseDeezloader: true,
DeezloaderWarnings: true,
}, },
Soundcloud: SoundcloudConfig{ Soundcloud: SoundcloudConfig{
Quality: 0, Quality: 0,

View File

@@ -77,15 +77,66 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: status=%d", resp.StatusCode) return fmt.Errorf("download failed: status=%d", resp.StatusCode)
} }
encrypted, err := io.ReadAll(resp.Body) out, err := os.Create(outputPath)
if err != nil { if err != nil {
return err 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 { if err != nil {
return err 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 { 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") 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 package deezer
import ( import (
"bytes"
"context" "context"
"crypto/aes"
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -21,8 +25,18 @@ import (
var ( var (
baseURL = "https://api.deezer.com" baseURL = "https://api.deezer.com"
webGWLight = "https://www.deezer.com/ajax/gw-light.php" 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" mediaURL = "https://media.deezer.com/v1/get_url"
deezerUA = "Deezer/9.0.11.4 (Android; 14; Mobile; us) Xiaomi Redmi Note 7" 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 { type Client struct {
@@ -30,6 +44,8 @@ type Client struct {
http *http.Client http *http.Client
limiter *ratelimit.Limiter limiter *ratelimit.Limiter
loggedIn bool loggedIn bool
ua string
deviceID string
sid string sid string
arl string arl string
jwt string jwt string
@@ -43,7 +59,10 @@ func New(cfg *config.Config) *Client {
cfg: cfg, cfg: cfg,
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL), http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL),
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute), limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
ua: randomDeezerUA(),
deviceID: randomHexN(16),
arl: strings.TrimSpace(cfg.Session.Deezer.ARL), 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 { func (c *Client) Login(ctx context.Context) error {
c.arl = strings.TrimSpace(c.cfg.Session.Deezer.ARL) 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 c.arl != "" {
if err := c.refreshSessionFromARL(ctx); err != nil { if err := c.refreshSessionFromARL(ctx); err != nil {
return err 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 c.loggedIn = true
return nil return nil
@@ -111,6 +152,14 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
return nil, err return nil, err
} }
enrichTrack(resp) 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 return resp, nil
case "album": case "album":
resp, err := c.apiGet(ctx, "/album/"+item, nil) 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) { 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.license) == "" {
if strings.TrimSpace(c.arl) != "" {
if err := c.refreshSessionFromARL(ctx); err != nil { if err := c.refreshSessionFromARL(ctx); err != nil {
return nil, err 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) == "" {
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") meta, err := c.GetMetadata(ctx, item, "track")
if err != nil { if err != nil {
@@ -273,7 +338,7 @@ func (c *Client) refreshSessionFromARL(ctx context.Context) error {
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("User-Agent", deezerUA) req.Header.Set("User-Agent", c.ua)
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Cookie", "arl="+strings.TrimSpace(c.arl)) 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 { if len(results) == 0 {
return errors.New("deezer getUserData returned empty results") return errors.New("deezer getUserData returned empty results")
} }
c.sid = firstNonEmpty(c.sid, sidFromCookies(c.http, webGWLight))
c.license = findStringByKey(results, "license_token") c.license = findStringByKey(results, "license_token")
c.userID = findStringByKey(results, "USER_ID") 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 == "" { if c.license == "" {
return errors.New("deezer getUserData missing license_token") 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 return nil
} }
@@ -320,6 +530,487 @@ func (c *Client) getTrackToken(ctx context.Context, trackID string) (string, err
return token, nil 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 { type mediaResult struct {
URL string URL string
Format string Format string
@@ -369,7 +1060,7 @@ func (c *Client) getMediaURLForFormat(ctx context.Context, trackToken, format st
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("User-Agent", deezerUA) req.Header.Set("User-Agent", c.ua)
req.Header.Set("Accept", "*/*") req.Header.Set("Accept", "*/*")
req.Header.Set("Content-Type", "text/plain; charset=UTF-8") req.Header.Set("Content-Type", "text/plain; charset=UTF-8")
@@ -477,6 +1168,14 @@ func findStringByKey(v any, wantedKey string) string {
return "" 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) { func enrichTrack(track map[string]any) {
if artist, ok := track["artist"].(map[string]any); ok { if artist, ok := track["artist"].(map[string]any); ok {
track["performer"] = map[string]any{"name": stringFromAny(artist["name"]), "id": stringFromAny(artist["id"])} track["performer"] = map[string]any{"name": stringFromAny(artist["name"]), "id": stringFromAny(artist["id"])}

View File

@@ -2,6 +2,7 @@ package deezer
import ( import (
"context" "context"
"encoding/hex"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -57,14 +58,18 @@ func TestGetDownloadableNativeCipher(t *testing.T) {
c.loggedIn = true c.loggedIn = true
c.arl = "arl" c.arl = "arl"
c.license = "license" c.license = "license"
c.jwt = "jwt"
origBase := baseURL origBase := baseURL
origMedia := mediaURL origMedia := mediaURL
origPipe := pipeURL
baseURL = ts.URL baseURL = ts.URL
mediaURL = ts.URL + "/media" mediaURL = ts.URL + "/media"
pipeURL = ts.URL + "/pipe"
defer func() { defer func() {
baseURL = origBase baseURL = origBase
mediaURL = origMedia mediaURL = origMedia
pipeURL = origPipe
}() }()
d, err := c.GetDownloadable(context.Background(), "42", 2) d, err := c.GetDownloadable(context.Background(), "42", 2)
@@ -106,14 +111,18 @@ func TestGetDownloadableDRMError(t *testing.T) {
c.loggedIn = true c.loggedIn = true
c.arl = "arl" c.arl = "arl"
c.license = "license" c.license = "license"
c.jwt = "jwt"
origBase := baseURL origBase := baseURL
origMedia := mediaURL origMedia := mediaURL
origPipe := pipeURL
baseURL = ts.URL baseURL = ts.URL
mediaURL = ts.URL + "/media" mediaURL = ts.URL + "/media"
pipeURL = ts.URL + "/pipe"
defer func() { defer func() {
baseURL = origBase baseURL = origBase
mediaURL = origMedia mediaURL = origMedia
pipeURL = origPipe
}() }()
_, err := c.GetDownloadable(context.Background(), "42", 2) _, err := c.GetDownloadable(context.Background(), "42", 2)
@@ -121,3 +130,154 @@ func TestGetDownloadableDRMError(t *testing.T) {
t.Fatalf("expected drm error, got %v", err) 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)
}
}