From 9ebddc831632e96283c4e588b39e77d509b9b33d Mon Sep 17 00:00:00 2001 From: Joren Date: Tue, 21 Apr 2026 11:14:57 +0200 Subject: [PATCH] harden deezer auth and lyrics tagging behavior --- config.toml.example | 158 +++++ internal/app/app.go | 29 +- internal/app/app_test.go | 71 +++ internal/config/config.go | 7 +- internal/download/downloader.go | 57 +- internal/download/downloader_test.go | 36 ++ internal/provider/deezer/client.go | 729 +++++++++++++++++++++++- internal/provider/deezer/client_test.go | 160 ++++++ 8 files changed, 1224 insertions(+), 23 deletions(-) create mode 100644 config.toml.example diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..92f7750 --- /dev/null +++ b/config.toml.example @@ -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 " +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 diff --git a/internal/app/app.go b/internal/app/app.go index c5bec1f..062162e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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"]) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index fb0aada..cda8812 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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}, diff --git a/internal/config/config.go b/internal/config/config.go index 148f002..4144497 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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, diff --git a/internal/download/downloader.go b/internal/download/downloader.go index 7fa51cd..a1edf27 100644 --- a/internal/download/downloader.go +++ b/internal/download/downloader.go @@ -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 { diff --git a/internal/download/downloader_test.go b/internal/download/downloader_test.go index 280913e..8c62c7c 100644 --- a/internal/download/downloader_test.go +++ b/internal/download/downloader_test.go @@ -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") + } +} diff --git a/internal/provider/deezer/client.go b/internal/provider/deezer/client.go index 1068eb0..194db37 100644 --- a/internal/provider/deezer/client.go +++ b/internal/provider/deezer/client.go @@ -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"])} diff --git a/internal/provider/deezer/client_test.go b/internal/provider/deezer/client_test.go index 834ce0f..ee2dcc0 100644 --- a/internal/provider/deezer/client_test.go +++ b/internal/provider/deezer/client_test.go @@ -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) + } +}