mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
harden deezer auth and lyrics tagging behavior
This commit is contained in:
158
config.toml.example
Normal file
158
config.toml.example
Normal 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
|
||||
@@ -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"])
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -21,8 +25,18 @@ import (
|
||||
var (
|
||||
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"
|
||||
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 {
|
||||
@@ -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
|
||||
@@ -43,7 +59,10 @@ func New(cfg *config.Config) *Client {
|
||||
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) == "" {
|
||||
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"])}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user