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 {
|
func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID string, albumMeta map[string]any) error {
|
||||||
|
if err := m.requireSourceDownloadAuth(source); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
albumTitle := titleFromMetadata(albumMeta, albumID)
|
albumTitle := titleFromMetadata(albumMeta, albumID)
|
||||||
albumArtist := nestedString(albumMeta, "artist", "name")
|
albumArtist := nestedString(albumMeta, "artist", "name")
|
||||||
if albumArtist == "" {
|
if albumArtist == "" {
|
||||||
@@ -620,11 +624,19 @@ func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playlistID string, playlistMeta map[string]any) error {
|
func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playlistID string, playlistMeta map[string]any) error {
|
||||||
|
if err := m.requireSourceDownloadAuth(source); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
name := titleFromMetadata(playlistMeta, playlistID)
|
name := titleFromMetadata(playlistMeta, playlistID)
|
||||||
if n := stringFromAny(playlistMeta["name"]); n != "" {
|
if n := stringFromAny(playlistMeta["name"]); n != "" {
|
||||||
name = n
|
name = n
|
||||||
}
|
}
|
||||||
folder := filepath.Join(m.Config.Session.Downloads.Folder, naming.CleanName(name, naming.Config{
|
base := m.Config.Session.Downloads.Folder
|
||||||
|
if m.Config.Session.Downloads.SourceSubdirectories {
|
||||||
|
base = filepath.Join(base, strings.Title(source))
|
||||||
|
}
|
||||||
|
folder := filepath.Join(base, naming.CleanName(name, naming.Config{
|
||||||
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
|
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
|
||||||
TruncateTo: m.Config.Session.Filepaths.TruncateTo,
|
TruncateTo: m.Config.Session.Filepaths.TruncateTo,
|
||||||
}))
|
}))
|
||||||
@@ -765,6 +777,18 @@ func (m *Main) ripPlaylistMixed(ctx context.Context, playlistID, name string, re
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Main) requireSourceDownloadAuth(source string) error {
|
||||||
|
if source == "deezer" {
|
||||||
|
hasARL := strings.TrimSpace(m.Config.Session.Deezer.ARL) != ""
|
||||||
|
hasCreds := strings.TrimSpace(m.Config.Session.Deezer.Email) != "" && strings.TrimSpace(m.Config.Session.Deezer.Password) != ""
|
||||||
|
hasRefresh := strings.TrimSpace(m.Config.Session.Deezer.RefreshToken) != ""
|
||||||
|
if !hasARL && !hasCreds && !hasRefresh {
|
||||||
|
return fmt.Errorf("deezer native download requires deezer.arl, deezer.email+deezer.password, or deezer.refresh_token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fallbackTitle string, opts ripTrackOptions) error {
|
func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fallbackTitle string, opts ripTrackOptions) error {
|
||||||
alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, id)
|
alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, id)
|
||||||
if err == nil && alreadyDownloaded {
|
if err == nil && alreadyDownloaded {
|
||||||
@@ -1217,6 +1241,9 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
|
|||||||
comment := stringFromAny(trackMeta["comment"])
|
comment := stringFromAny(trackMeta["comment"])
|
||||||
description := stringFromAny(trackMeta["description"])
|
description := stringFromAny(trackMeta["description"])
|
||||||
lyrics := stringFromAny(trackMeta["lyrics"])
|
lyrics := stringFromAny(trackMeta["lyrics"])
|
||||||
|
if lrc := stringFromAny(trackMeta["lyrics_synced"]); lrc != "" {
|
||||||
|
lyrics = lrc
|
||||||
|
}
|
||||||
trackGain := replaygainGainFromAny(trackMeta["replaygain_track_gain"])
|
trackGain := replaygainGainFromAny(trackMeta["replaygain_track_gain"])
|
||||||
if trackGain == "" {
|
if trackGain == "" {
|
||||||
trackGain = replaygainGainFromAny(trackMeta["replayGain"])
|
trackGain = replaygainGainFromAny(trackMeta["replayGain"])
|
||||||
|
|||||||
@@ -464,6 +464,77 @@ func TestPlaylistRipPipeline(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPlaylistRipUsesSourceSubdirectory(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("audio-bytes"))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
d := config.DefaultConfigData()
|
||||||
|
d.Downloads.Folder = tmp
|
||||||
|
d.Downloads.Concurrency = false
|
||||||
|
d.Downloads.SourceSubdirectories = true
|
||||||
|
d.Filepaths.RestrictCharacters = false
|
||||||
|
cfg := &config.Config{File: d, Session: d}
|
||||||
|
|
||||||
|
sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSQLite() error = %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = sqlite.Close() }()
|
||||||
|
|
||||||
|
m := &Main{
|
||||||
|
Config: cfg,
|
||||||
|
Providers: map[string]provider.Client{
|
||||||
|
"qobuz": &fakePlaylistProvider{url: ts.URL},
|
||||||
|
},
|
||||||
|
Store: sqlite,
|
||||||
|
DL: download.NewWithOptions(true, false),
|
||||||
|
Tagger: noopTagger{},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err = m.AddByID(ctx, "qobuz", "playlist", "pl1"); err != nil {
|
||||||
|
t.Fatalf("AddByID() error = %v", err)
|
||||||
|
}
|
||||||
|
if err = m.Resolve(ctx); err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
if err = m.Rip(ctx); err != nil {
|
||||||
|
t.Fatalf("Rip() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
folder := filepath.Join(tmp, "Qobuz", "Road Trip")
|
||||||
|
if _, err = os.Stat(filepath.Join(folder, "01. Artist - Track One.flac")); err != nil {
|
||||||
|
t.Fatalf("missing first playlist track in source subdir: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRipPlaylistRequiresDeezerARL(t *testing.T) {
|
||||||
|
d := config.DefaultConfigData()
|
||||||
|
m := &Main{Config: &config.Config{File: d, Session: d}}
|
||||||
|
|
||||||
|
err := m.ripPlaylist(context.Background(), nil, "deezer", "pl1", map[string]any{
|
||||||
|
"name": "Road Trip",
|
||||||
|
"tracks": map[string]any{"items": []any{map[string]any{"id": "p1"}}},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "deezer") {
|
||||||
|
t.Fatalf("expected deezer arl error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRipAlbumRequiresDeezerARL(t *testing.T) {
|
||||||
|
d := config.DefaultConfigData()
|
||||||
|
m := &Main{Config: &config.Config{File: d, Session: d}}
|
||||||
|
|
||||||
|
err := m.ripAlbum(context.Background(), nil, "deezer", "alb1", map[string]any{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "deezer") {
|
||||||
|
t.Fatalf("expected deezer arl error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyQobuzArtistFiltersRepeats(t *testing.T) {
|
func TestApplyQobuzArtistFiltersRepeats(t *testing.T) {
|
||||||
albums := []collectionAlbum{
|
albums := []collectionAlbum{
|
||||||
{ID: "a1", Title: "Album X", BitDepth: 16, Sampling: 44.1, Explicit: false},
|
{ID: "a1", Title: "Album X", BitDepth: 16, Sampling: 44.1, Explicit: false},
|
||||||
|
|||||||
@@ -71,8 +71,9 @@ type DeezerConfig struct {
|
|||||||
Quality int `toml:"quality"`
|
Quality int `toml:"quality"`
|
||||||
LowerQualityIfNotAvailable bool `toml:"lower_quality_if_not_available"`
|
LowerQualityIfNotAvailable bool `toml:"lower_quality_if_not_available"`
|
||||||
ARL string `toml:"arl"`
|
ARL string `toml:"arl"`
|
||||||
UseDeezloader bool `toml:"use_deezloader"`
|
Email string `toml:"email"`
|
||||||
DeezloaderWarnings bool `toml:"deezloader_warnings"`
|
Password string `toml:"password"`
|
||||||
|
RefreshToken string `toml:"refresh_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SoundcloudConfig struct {
|
type SoundcloudConfig struct {
|
||||||
@@ -236,8 +237,6 @@ func DefaultConfigData() ConfigData {
|
|||||||
Deezer: DeezerConfig{
|
Deezer: DeezerConfig{
|
||||||
Quality: 2,
|
Quality: 2,
|
||||||
LowerQualityIfNotAvailable: true,
|
LowerQualityIfNotAvailable: true,
|
||||||
UseDeezloader: true,
|
|
||||||
DeezloaderWarnings: true,
|
|
||||||
},
|
},
|
||||||
Soundcloud: SoundcloudConfig{
|
Soundcloud: SoundcloudConfig{
|
||||||
Quality: 0,
|
Quality: 0,
|
||||||
|
|||||||
@@ -77,15 +77,66 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP
|
|||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("download failed: status=%d", resp.StatusCode)
|
return fmt.Errorf("download failed: status=%d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
encrypted, err := io.ReadAll(resp.Body)
|
out, err := os.Create(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
plain, err := decryptDeezerBFCBCStripe(encrypted, trackID)
|
defer func() { _ = out.Close() }()
|
||||||
|
|
||||||
|
var bar *mpb.Bar
|
||||||
|
if d.ProgressEnabled() && resp.ContentLength > 0 {
|
||||||
|
d.barStarted.Store(1)
|
||||||
|
desc := shortenName(filepath.Base(outputPath), 54)
|
||||||
|
bar = d.progress.AddBar(
|
||||||
|
resp.ContentLength,
|
||||||
|
mpb.PrependDecorators(
|
||||||
|
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
|
||||||
|
decor.Percentage(decor.WCSyncWidthR),
|
||||||
|
),
|
||||||
|
mpb.AppendDecorators(
|
||||||
|
decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR),
|
||||||
|
decor.Name(" | ", decor.WCSyncWidth),
|
||||||
|
decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR),
|
||||||
|
decor.Name(" | ETA ", decor.WCSyncWidth),
|
||||||
|
decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR),
|
||||||
|
),
|
||||||
|
mpb.BarRemoveOnComplete(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(outputPath, plain, 0o644)
|
buf := make([]byte, deezerBFChunkSize)
|
||||||
|
dec := make([]byte, deezerBFChunkSize)
|
||||||
|
chunkIndex := 0
|
||||||
|
for {
|
||||||
|
n, readErr := io.ReadFull(resp.Body, buf)
|
||||||
|
if readErr == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if readErr != nil && readErr != io.ErrUnexpectedEOF {
|
||||||
|
return readErr
|
||||||
|
}
|
||||||
|
chunk := buf[:n]
|
||||||
|
if chunkIndex%3 == 0 && n == deezerBFChunkSize {
|
||||||
|
mode := cipher.NewCBCDecrypter(block, deezerBFIV)
|
||||||
|
mode.CryptBlocks(dec[:n], chunk)
|
||||||
|
chunk = dec[:n]
|
||||||
|
}
|
||||||
|
if _, err = out.Write(chunk); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if bar != nil {
|
||||||
|
bar.IncrBy(n)
|
||||||
|
}
|
||||||
|
chunkIndex++
|
||||||
|
if readErr == io.ErrUnexpectedEOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool, includeVideo bool) error {
|
func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool, includeVideo bool) error {
|
||||||
|
|||||||
@@ -84,3 +84,39 @@ func TestDecryptDeezerBFCBCStripe(t *testing.T) {
|
|||||||
t.Fatalf("decrypted data mismatch")
|
t.Fatalf("decrypted data mismatch")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileDeezerEncrypted(t *testing.T) {
|
||||||
|
trackID := "3135556"
|
||||||
|
plain := make([]byte, deezerBFChunkSize+777)
|
||||||
|
for i := range plain {
|
||||||
|
plain[i] = byte((i * 7) % 251)
|
||||||
|
}
|
||||||
|
enc := make([]byte, len(plain))
|
||||||
|
copy(enc, plain)
|
||||||
|
|
||||||
|
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cipher error: %v", err)
|
||||||
|
}
|
||||||
|
cbc := cipher.NewCBCEncrypter(block, deezerBFIV)
|
||||||
|
cbc.CryptBlocks(enc[:deezerBFChunkSize], enc[:deezerBFChunkSize])
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write(enc)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
d := NewWithOptions(true, false)
|
||||||
|
out := filepath.Join(t.TempDir(), "x", "a.flac")
|
||||||
|
if err = d.FileDeezerEncrypted(context.Background(), ts.URL, out, trackID); err != nil {
|
||||||
|
t.Fatalf("FileDeezerEncrypted() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := os.ReadFile(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != string(plain) {
|
||||||
|
t.Fatalf("decrypted file mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package deezer
|
package deezer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -21,8 +25,18 @@ import (
|
|||||||
var (
|
var (
|
||||||
baseURL = "https://api.deezer.com"
|
baseURL = "https://api.deezer.com"
|
||||||
webGWLight = "https://www.deezer.com/ajax/gw-light.php"
|
webGWLight = "https://www.deezer.com/ajax/gw-light.php"
|
||||||
|
gatewayURL = "https://api.deezer.com/1.0/gateway.php"
|
||||||
mediaURL = "https://media.deezer.com/v1/get_url"
|
mediaURL = "https://media.deezer.com/v1/get_url"
|
||||||
deezerUA = "Deezer/9.0.11.4 (Android; 14; Mobile; us) Xiaomi Redmi Note 7"
|
pipeURL = "https://pipe.deezer.com/api"
|
||||||
|
authURL = "https://auth.deezer.com/login/renew"
|
||||||
|
apiKey = "4VCYIJUCDLOUELGD1V8WBVYBNVDYOXEWSLLZDONGBBDFVXTZJRXPR29JRLQFO6ZE"
|
||||||
|
gatewayDec = "VBK1FSUEXHTSDBJJ"
|
||||||
|
deezerUAPool = []string{
|
||||||
|
"Deezer/9.0.11.4 (Android; 14; Mobile; us) Xiaomi Redmi Note 7",
|
||||||
|
"Deezer/9.0.11.4 (Android; 14; Mobile; us) Samsung SM-G991B",
|
||||||
|
"Deezer/9.0.11.4 (Android; 13; Mobile; us) Google Pixel 6",
|
||||||
|
"Deezer/9.0.11.4 (Android; 14; Mobile; us) OnePlus IN2023",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -30,6 +44,8 @@ type Client struct {
|
|||||||
http *http.Client
|
http *http.Client
|
||||||
limiter *ratelimit.Limiter
|
limiter *ratelimit.Limiter
|
||||||
loggedIn bool
|
loggedIn bool
|
||||||
|
ua string
|
||||||
|
deviceID string
|
||||||
sid string
|
sid string
|
||||||
arl string
|
arl string
|
||||||
jwt string
|
jwt string
|
||||||
@@ -43,7 +59,10 @@ func New(cfg *config.Config) *Client {
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL),
|
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL),
|
||||||
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
|
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
|
||||||
|
ua: randomDeezerUA(),
|
||||||
|
deviceID: randomHexN(16),
|
||||||
arl: strings.TrimSpace(cfg.Session.Deezer.ARL),
|
arl: strings.TrimSpace(cfg.Session.Deezer.ARL),
|
||||||
|
refresh: strings.TrimSpace(cfg.Session.Deezer.RefreshToken),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +72,32 @@ func (c *Client) Source() string {
|
|||||||
|
|
||||||
func (c *Client) Login(ctx context.Context) error {
|
func (c *Client) Login(ctx context.Context) error {
|
||||||
c.arl = strings.TrimSpace(c.cfg.Session.Deezer.ARL)
|
c.arl = strings.TrimSpace(c.cfg.Session.Deezer.ARL)
|
||||||
|
c.sid = ""
|
||||||
|
c.jwt = ""
|
||||||
|
c.refresh = strings.TrimSpace(c.cfg.Session.Deezer.RefreshToken)
|
||||||
|
c.license = ""
|
||||||
|
c.userID = ""
|
||||||
|
email := strings.TrimSpace(c.cfg.Session.Deezer.Email)
|
||||||
|
password := strings.TrimSpace(c.cfg.Session.Deezer.Password)
|
||||||
|
if c.refresh != "" {
|
||||||
|
if err := c.refreshJWT(ctx); err == nil {
|
||||||
|
_ = c.refreshLicenseFromPipe(ctx)
|
||||||
|
if c.license != "" {
|
||||||
|
c.loggedIn = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if c.arl != "" {
|
if c.arl != "" {
|
||||||
if err := c.refreshSessionFromARL(ctx); err != nil {
|
if err := c.refreshSessionFromARL(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else if email != "" && password != "" {
|
||||||
|
if err := c.loginWithCredentials(ctx, email, password); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return errors.New("deezer login requires deezer.arl, deezer.email+deezer.password, or deezer.refresh_token")
|
||||||
}
|
}
|
||||||
c.loggedIn = true
|
c.loggedIn = true
|
||||||
return nil
|
return nil
|
||||||
@@ -111,6 +152,14 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
enrichTrack(resp)
|
enrichTrack(resp)
|
||||||
|
if lyr, lyrErr := c.fetchLyricsFromPipe(ctx, strings.TrimSpace(stringFromAny(resp["id"]))); lyrErr == nil {
|
||||||
|
if strings.TrimSpace(lyr.Text) != "" {
|
||||||
|
resp["lyrics"] = lyr.Text
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(lyr.SyncedLRC) != "" {
|
||||||
|
resp["lyrics_synced"] = lyr.SyncedLRC
|
||||||
|
}
|
||||||
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
case "album":
|
case "album":
|
||||||
resp, err := c.apiGet(ctx, "/album/"+item, nil)
|
resp, err := c.apiGet(ctx, "/album/"+item, nil)
|
||||||
@@ -176,13 +225,29 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*provider.Downloadable, error) {
|
func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*provider.Downloadable, error) {
|
||||||
if strings.TrimSpace(c.arl) == "" {
|
|
||||||
return nil, errors.New("deezer native download requires deezer.arl in config")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(c.license) == "" {
|
if strings.TrimSpace(c.license) == "" {
|
||||||
|
if strings.TrimSpace(c.arl) != "" {
|
||||||
if err := c.refreshSessionFromARL(ctx); err != nil {
|
if err := c.refreshSessionFromARL(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if strings.TrimSpace(c.refresh) != "" {
|
||||||
|
_ = c.refreshJWT(ctx)
|
||||||
|
if strings.TrimSpace(c.jwt) != "" {
|
||||||
|
_ = c.refreshLicenseFromPipe(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
email := strings.TrimSpace(c.cfg.Session.Deezer.Email)
|
||||||
|
password := strings.TrimSpace(c.cfg.Session.Deezer.Password)
|
||||||
|
if strings.TrimSpace(c.license) == "" && email != "" && password != "" {
|
||||||
|
if err := c.loginWithCredentials(ctx, email, password); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.license) == "" {
|
||||||
|
return nil, errors.New("deezer native download requires deezer.arl, deezer.email+deezer.password, or deezer.refresh_token")
|
||||||
}
|
}
|
||||||
meta, err := c.GetMetadata(ctx, item, "track")
|
meta, err := c.GetMetadata(ctx, item, "track")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -273,7 +338,7 @@ func (c *Client) refreshSessionFromARL(ctx context.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", deezerUA)
|
req.Header.Set("User-Agent", c.ua)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("Cookie", "arl="+strings.TrimSpace(c.arl))
|
req.Header.Set("Cookie", "arl="+strings.TrimSpace(c.arl))
|
||||||
|
|
||||||
@@ -300,11 +365,156 @@ func (c *Client) refreshSessionFromARL(ctx context.Context) error {
|
|||||||
if len(results) == 0 {
|
if len(results) == 0 {
|
||||||
return errors.New("deezer getUserData returned empty results")
|
return errors.New("deezer getUserData returned empty results")
|
||||||
}
|
}
|
||||||
|
c.sid = firstNonEmpty(c.sid, sidFromCookies(c.http, webGWLight))
|
||||||
c.license = findStringByKey(results, "license_token")
|
c.license = findStringByKey(results, "license_token")
|
||||||
c.userID = findStringByKey(results, "USER_ID")
|
c.userID = findStringByKey(results, "USER_ID")
|
||||||
|
c.jwt = firstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
|
||||||
|
c.refresh = firstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
|
||||||
|
if c.sid == "" {
|
||||||
|
if sid, sidErr := c.bootstrapSID(ctx); sidErr == nil {
|
||||||
|
c.sid = sid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.sid != "" && c.userID != "" {
|
||||||
|
_ = c.mobileUserAutolog(ctx)
|
||||||
|
}
|
||||||
|
if c.jwt == "" && c.refresh != "" {
|
||||||
|
_ = c.refreshJWT(ctx)
|
||||||
|
}
|
||||||
|
if c.license == "" && c.jwt != "" {
|
||||||
|
_ = c.refreshLicenseFromPipe(ctx)
|
||||||
|
}
|
||||||
if c.license == "" {
|
if c.license == "" {
|
||||||
return errors.New("deezer getUserData missing license_token")
|
return errors.New("deezer getUserData missing license_token")
|
||||||
}
|
}
|
||||||
|
c.persistRefreshToken()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) persistRefreshToken() {
|
||||||
|
if c.cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rt := strings.TrimSpace(c.refresh)
|
||||||
|
if rt == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.cfg.Session.Deezer.RefreshToken = rt
|
||||||
|
c.cfg.File.Deezer.RefreshToken = rt
|
||||||
|
if strings.TrimSpace(c.cfg.Path) != "" {
|
||||||
|
_ = c.cfg.SaveFile()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) loginWithCredentials(ctx context.Context, email, password string) error {
|
||||||
|
email = strings.TrimSpace(email)
|
||||||
|
password = strings.TrimSpace(password)
|
||||||
|
if email == "" || password == "" {
|
||||||
|
return errors.New("missing deezer credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
mobileToken, err := c.mobileAuth(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
authToken, err := deriveGatewayAuthToken(mobileToken)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sid, err := c.apiCheckToken(ctx, authToken)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.sid = firstNonEmpty(c.sid, sid)
|
||||||
|
|
||||||
|
encryptedPassword, err := encryptPassword(mobileToken, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"platform": "Xiaomi_lavender_14",
|
||||||
|
"custo_version_id": "",
|
||||||
|
"custo_partner": nil,
|
||||||
|
"model": "Redmi Note 7",
|
||||||
|
"device_name": "Redmi Note 7",
|
||||||
|
"device_os": "Android",
|
||||||
|
"device_type": "phone",
|
||||||
|
"google_play_services_availability": "1",
|
||||||
|
"device_serial": c.deviceID,
|
||||||
|
"mail": email,
|
||||||
|
"password": encryptedPassword,
|
||||||
|
}
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("api_key", apiKey)
|
||||||
|
params.Set("sid", c.sid)
|
||||||
|
params.Set("method", "mobile_userAuth")
|
||||||
|
params.Set("output", "3")
|
||||||
|
params.Set("input", "3")
|
||||||
|
params.Set("network", randomHexN(32))
|
||||||
|
|
||||||
|
b, _ := json.Marshal(payload)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, gatewayURL+"?"+params.Encode(), bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", c.ua)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("deezer mobile_userAuth failed: status=%d body=%s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string]any{}
|
||||||
|
if err = json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
|
||||||
|
msg := firstNonEmpty(stringFromAny(errObj["message"]), stringFromAny(errObj["type"]))
|
||||||
|
if msg == "" {
|
||||||
|
msg = "unknown mobile_userAuth error"
|
||||||
|
}
|
||||||
|
return errors.New(msg)
|
||||||
|
}
|
||||||
|
results := nestedMap(out, "results")
|
||||||
|
if len(results) == 0 {
|
||||||
|
return errors.New("mobile_userAuth returned empty results")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.arl = firstNonEmpty(c.arl, findStringByKey(results, "ARL"))
|
||||||
|
c.jwt = firstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
|
||||||
|
c.refresh = firstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
|
||||||
|
c.license = firstNonEmpty(c.license, findStringByKey(results, "license_token"))
|
||||||
|
c.userID = firstNonEmpty(c.userID, findStringByKey(results, "USER_ID"))
|
||||||
|
|
||||||
|
if c.arl == "" {
|
||||||
|
return errors.New("mobile_userAuth missing arl")
|
||||||
|
}
|
||||||
|
if c.license == "" {
|
||||||
|
if c.jwt == "" && c.refresh != "" {
|
||||||
|
_ = c.refreshJWT(ctx)
|
||||||
|
}
|
||||||
|
if c.jwt != "" {
|
||||||
|
_ = c.refreshLicenseFromPipe(ctx)
|
||||||
|
}
|
||||||
|
if c.license == "" {
|
||||||
|
_ = c.refreshSessionFromARL(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.license == "" {
|
||||||
|
return errors.New("mobile_userAuth missing license_token")
|
||||||
|
}
|
||||||
|
c.persistRefreshToken()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,6 +530,487 @@ func (c *Client) getTrackToken(ctx context.Context, trackID string) (string, err
|
|||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type lyricsResult struct {
|
||||||
|
Text string
|
||||||
|
SyncedLRC string
|
||||||
|
}
|
||||||
|
|
||||||
|
var errDeezerJWTExpired = errors.New("deezer jwt expired")
|
||||||
|
|
||||||
|
func (c *Client) fetchLyricsFromPipe(ctx context.Context, trackID string) (*lyricsResult, error) {
|
||||||
|
fetchOnce := func(jwt string) (*lyricsResult, error) {
|
||||||
|
query := `query GetLyrics($trackId: String!) { track(trackId: $trackId) { id lyrics { text synchronizedLines { line lineTranslated milliseconds } } } }`
|
||||||
|
body := map[string]any{
|
||||||
|
"operationName": "GetLyrics",
|
||||||
|
"variables": map[string]any{"trackId": strings.TrimSpace(trackID)},
|
||||||
|
"query": query,
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, pipeURL, bytes.NewReader(encoded))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", c.ua)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(jwt))
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("deezer lyrics query failed: status=%d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
out := map[string]any{}
|
||||||
|
if err = json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if errs, ok := out["errors"].([]any); ok && len(errs) > 0 {
|
||||||
|
msg := ""
|
||||||
|
typ := ""
|
||||||
|
if em, ok := errs[0].(map[string]any); ok {
|
||||||
|
msg = strings.TrimSpace(stringFromAny(em["message"]))
|
||||||
|
typ = strings.TrimSpace(stringFromAny(em["type"]))
|
||||||
|
}
|
||||||
|
if strings.EqualFold(typ, "JwtTokenExpiredError") || strings.Contains(strings.ToLower(msg), "not valid anymore") || strings.Contains(strings.ToLower(msg), "jwt") && strings.Contains(strings.ToLower(msg), "expired") {
|
||||||
|
return nil, errDeezerJWTExpired
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = "unknown graphql error"
|
||||||
|
}
|
||||||
|
return nil, errors.New(msg)
|
||||||
|
}
|
||||||
|
lyrics := nestedMap(nestedMap(nestedMap(out, "data"), "track"), "lyrics")
|
||||||
|
text := strings.TrimSpace(stringFromAny(lyrics["text"]))
|
||||||
|
synced := buildSyncedLRC(lyrics["synchronizedLines"])
|
||||||
|
if text != "" || synced != "" {
|
||||||
|
return &lyricsResult{Text: text, SyncedLRC: synced}, nil
|
||||||
|
}
|
||||||
|
lines, _ := lyrics["synchronizedLines"].([]any)
|
||||||
|
parts := make([]string, 0, len(lines))
|
||||||
|
for _, rawLine := range lines {
|
||||||
|
m, ok := rawLine.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line := strings.TrimSpace(stringFromAny(m["line"]))
|
||||||
|
if line == "" {
|
||||||
|
line = strings.TrimSpace(stringFromAny(m["lineTranslated"]))
|
||||||
|
}
|
||||||
|
if line != "" {
|
||||||
|
parts = append(parts, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &lyricsResult{Text: strings.Join(parts, "\n")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(c.jwt) == "" {
|
||||||
|
if err := c.refreshSessionFromARL(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.jwt) == "" {
|
||||||
|
return nil, errors.New("deezer jwt unavailable for lyrics query")
|
||||||
|
}
|
||||||
|
res, err := fetchOnce(c.jwt)
|
||||||
|
if errors.Is(err, errDeezerJWTExpired) {
|
||||||
|
if strings.TrimSpace(c.refresh) != "" {
|
||||||
|
_ = c.refreshJWT(ctx)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.jwt) == "" && strings.TrimSpace(c.arl) != "" {
|
||||||
|
_ = c.refreshSessionFromARL(ctx)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.jwt) != "" {
|
||||||
|
return fetchOnce(c.jwt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSyncedLRC(v any) string {
|
||||||
|
lines, _ := v.([]any)
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(lines))
|
||||||
|
for _, rawLine := range lines {
|
||||||
|
m, ok := rawLine.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line := strings.TrimSpace(stringFromAny(m["line"]))
|
||||||
|
if line == "" {
|
||||||
|
line = strings.TrimSpace(stringFromAny(m["lineTranslated"]))
|
||||||
|
}
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ms := intFromAny(m["milliseconds"])
|
||||||
|
out = append(out, fmt.Sprintf("[%02d:%05.2f]%s", ms/60000, float64(ms%60000)/1000.0, line))
|
||||||
|
}
|
||||||
|
return strings.Join(out, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) bootstrapSID(ctx context.Context) (string, error) {
|
||||||
|
mobileToken, err := c.mobileAuth(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
authToken, err := deriveGatewayAuthToken(mobileToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return c.apiCheckToken(ctx, authToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) mobileAuth(ctx context.Context) (string, error) {
|
||||||
|
if err := c.limiter.Wait(ctx); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("api_key", apiKey)
|
||||||
|
params.Set("output", "3")
|
||||||
|
params.Set("method", "mobile_auth")
|
||||||
|
params.Set("network", randomHexN(32))
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, gatewayURL+"?"+params.Encode(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", c.ua)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out := map[string]any{}
|
||||||
|
if err = json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
token := findStringByKey(nestedMap(out, "results"), "TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
return "", errors.New("mobile_auth returned empty token")
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) apiCheckToken(ctx context.Context, authToken string) (string, error) {
|
||||||
|
if err := c.limiter.Wait(ctx); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("api_key", apiKey)
|
||||||
|
params.Set("method", "api_checkToken")
|
||||||
|
params.Set("auth_token", authToken)
|
||||||
|
params.Set("output", "3")
|
||||||
|
params.Set("network", randomHexN(32))
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, gatewayURL+"?"+params.Encode(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", c.ua)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out := map[string]any{}
|
||||||
|
if err = json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sid := strings.TrimSpace(stringFromAny(out["results"]))
|
||||||
|
if sid == "" {
|
||||||
|
return "", errors.New("api_checkToken returned empty sid")
|
||||||
|
}
|
||||||
|
return sid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) mobileUserAutolog(ctx context.Context) error {
|
||||||
|
if c.sid == "" || c.userID == "" || c.arl == "" {
|
||||||
|
return errors.New("mobile_userAutolog requires sid, user id, and arl")
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"platform": "Xiaomi_lavender_14",
|
||||||
|
"custo_version_id": "",
|
||||||
|
"custo_partner": nil,
|
||||||
|
"model": "Redmi Note 7",
|
||||||
|
"device_name": "Redmi Note 7",
|
||||||
|
"device_os": "Android",
|
||||||
|
"device_type": "phone",
|
||||||
|
"google_play_services_availability": "1",
|
||||||
|
"device_serial": c.deviceID,
|
||||||
|
"ACCOUNT_ID": c.userID,
|
||||||
|
"arl": c.arl,
|
||||||
|
}
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("api_key", apiKey)
|
||||||
|
params.Set("sid", c.sid)
|
||||||
|
params.Set("output", "3")
|
||||||
|
params.Set("input", "3")
|
||||||
|
params.Set("network", randomHexN(32))
|
||||||
|
params.Set("arl", c.arl)
|
||||||
|
|
||||||
|
for _, method := range []string{"mobile_userAutolog", "mobile_userAutoLog"} {
|
||||||
|
if err := c.limiter.Wait(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
params.Set("method", method)
|
||||||
|
b, _ := json.Marshal(payload)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, gatewayURL+"?"+params.Encode(), bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", c.ua)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
out := map[string]any{}
|
||||||
|
if json.Unmarshal(raw, &out) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results := nestedMap(out, "results")
|
||||||
|
if len(results) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.jwt = firstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
|
||||||
|
c.refresh = firstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
|
||||||
|
c.license = firstNonEmpty(c.license, findStringByKey(results, "license_token"))
|
||||||
|
if c.jwt != "" || c.license != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("mobile_userAutolog failed to produce jwt/license")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) refreshJWT(ctx context.Context) error {
|
||||||
|
if strings.TrimSpace(c.refresh) == "" {
|
||||||
|
return errors.New("missing deezer refresh token")
|
||||||
|
}
|
||||||
|
if err := c.limiter.Wait(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]string{"refresh_token": c.refresh}
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, authURL+"?i=p&jo=p&rto=p", bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", c.ua)
|
||||||
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
out := map[string]any{}
|
||||||
|
if json.Unmarshal(raw, &out) != nil {
|
||||||
|
return errors.New("invalid jwt refresh response")
|
||||||
|
}
|
||||||
|
if jwt := strings.TrimSpace(stringFromAny(out["jwt"])); jwt != "" {
|
||||||
|
c.jwt = jwt
|
||||||
|
}
|
||||||
|
if rt := strings.TrimSpace(stringFromAny(out["refresh_token"])); rt != "" {
|
||||||
|
c.refresh = rt
|
||||||
|
}
|
||||||
|
if c.jwt == "" {
|
||||||
|
return errors.New("jwt refresh returned empty jwt")
|
||||||
|
}
|
||||||
|
c.persistRefreshToken()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) refreshLicenseFromPipe(ctx context.Context) error {
|
||||||
|
if strings.TrimSpace(c.jwt) == "" {
|
||||||
|
return errors.New("missing deezer jwt")
|
||||||
|
}
|
||||||
|
if err := c.limiter.Wait(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"operationName": "KmpMpMediaServiceLicenseToken",
|
||||||
|
"query": "query KmpMpMediaServiceLicenseToken { tokens { mediaServiceLicenseToken { token expirationDate } } }",
|
||||||
|
"variables": map[string]any{},
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, pipeURL, bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", c.ua)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.jwt))
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
out := map[string]any{}
|
||||||
|
if json.Unmarshal(raw, &out) != nil {
|
||||||
|
return errors.New("invalid pipe response")
|
||||||
|
}
|
||||||
|
token := findStringByKey(out, "token")
|
||||||
|
if token == "" {
|
||||||
|
return errors.New("pipe response missing license token")
|
||||||
|
}
|
||||||
|
c.license = token
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveGatewayAuthToken(mobileToken string) (string, error) {
|
||||||
|
dec, err := decryptMobileToken(mobileToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(dec) < 80 {
|
||||||
|
return "", errors.New("decrypted mobile token too short")
|
||||||
|
}
|
||||||
|
decryptKey := []byte(string(dec[:64]))
|
||||||
|
encryptKey := []byte(string(dec[64:80]))
|
||||||
|
enc, err := aesECBEncrypt(encryptKey, decryptKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(enc), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptMobileToken(mobileToken string) ([]byte, error) {
|
||||||
|
b, err := hex.DecodeString(strings.TrimSpace(mobileToken))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return aesECBDecrypt([]byte(gatewayDec), b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptPassword(mobileToken, password string) (string, error) {
|
||||||
|
if strings.TrimSpace(password) == "" {
|
||||||
|
return "", errors.New("missing deezer password")
|
||||||
|
}
|
||||||
|
dec, err := decryptMobileToken(mobileToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(dec) < 96 {
|
||||||
|
return "", errors.New("decrypted mobile token too short for password encryption")
|
||||||
|
}
|
||||||
|
key := []byte(string(dec[80:96]))
|
||||||
|
padded := zeroPad([]byte(password), aes.BlockSize)
|
||||||
|
enc, err := aesECBEncrypt(key, padded)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(enc), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func zeroPad(data []byte, blockSize int) []byte {
|
||||||
|
if blockSize <= 0 {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
rem := len(data) % blockSize
|
||||||
|
if rem == 0 {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
out := make([]byte, len(data)+(blockSize-rem))
|
||||||
|
copy(out, data)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func aesECBDecrypt(key []byte, data []byte) ([]byte, error) {
|
||||||
|
if len(data)%aes.BlockSize != 0 {
|
||||||
|
return nil, errors.New("ecb decrypt input not multiple of block size")
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]byte, len(data))
|
||||||
|
for i := 0; i < len(data); i += aes.BlockSize {
|
||||||
|
block.Decrypt(out[i:i+aes.BlockSize], data[i:i+aes.BlockSize])
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aesECBEncrypt(key []byte, data []byte) ([]byte, error) {
|
||||||
|
if len(data)%aes.BlockSize != 0 {
|
||||||
|
return nil, errors.New("ecb encrypt input not multiple of block size")
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]byte, len(data))
|
||||||
|
for i := 0; i < len(data); i += aes.BlockSize {
|
||||||
|
block.Encrypt(out[i:i+aes.BlockSize], data[i:i+aes.BlockSize])
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sidFromCookies(client *http.Client, rawURL string) string {
|
||||||
|
if client == nil || client.Jar == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, ck := range client.Jar.Cookies(u) {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(ck.Name), "sid") {
|
||||||
|
return strings.TrimSpace(ck.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomHexN(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomDeezerUA() string {
|
||||||
|
if len(deezerUAPool) == 0 {
|
||||||
|
return "Deezer/9.0.11.4 (Android; 14; Mobile; us)"
|
||||||
|
}
|
||||||
|
b := make([]byte, 1)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return deezerUAPool[0]
|
||||||
|
}
|
||||||
|
return deezerUAPool[int(b[0])%len(deezerUAPool)]
|
||||||
|
}
|
||||||
|
|
||||||
type mediaResult struct {
|
type mediaResult struct {
|
||||||
URL string
|
URL string
|
||||||
Format string
|
Format string
|
||||||
@@ -369,7 +1060,7 @@ func (c *Client) getMediaURLForFormat(ctx context.Context, trackToken, format st
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", deezerUA)
|
req.Header.Set("User-Agent", c.ua)
|
||||||
req.Header.Set("Accept", "*/*")
|
req.Header.Set("Accept", "*/*")
|
||||||
req.Header.Set("Content-Type", "text/plain; charset=UTF-8")
|
req.Header.Set("Content-Type", "text/plain; charset=UTF-8")
|
||||||
|
|
||||||
@@ -477,6 +1168,14 @@ func findStringByKey(v any, wantedKey string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nestedMap(m map[string]any, key string) map[string]any {
|
||||||
|
v, ok := m[key].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
func enrichTrack(track map[string]any) {
|
func enrichTrack(track map[string]any) {
|
||||||
if artist, ok := track["artist"].(map[string]any); ok {
|
if artist, ok := track["artist"].(map[string]any); ok {
|
||||||
track["performer"] = map[string]any{"name": stringFromAny(artist["name"]), "id": stringFromAny(artist["id"])}
|
track["performer"] = map[string]any{"name": stringFromAny(artist["name"]), "id": stringFromAny(artist["id"])}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package deezer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -57,14 +58,18 @@ func TestGetDownloadableNativeCipher(t *testing.T) {
|
|||||||
c.loggedIn = true
|
c.loggedIn = true
|
||||||
c.arl = "arl"
|
c.arl = "arl"
|
||||||
c.license = "license"
|
c.license = "license"
|
||||||
|
c.jwt = "jwt"
|
||||||
|
|
||||||
origBase := baseURL
|
origBase := baseURL
|
||||||
origMedia := mediaURL
|
origMedia := mediaURL
|
||||||
|
origPipe := pipeURL
|
||||||
baseURL = ts.URL
|
baseURL = ts.URL
|
||||||
mediaURL = ts.URL + "/media"
|
mediaURL = ts.URL + "/media"
|
||||||
|
pipeURL = ts.URL + "/pipe"
|
||||||
defer func() {
|
defer func() {
|
||||||
baseURL = origBase
|
baseURL = origBase
|
||||||
mediaURL = origMedia
|
mediaURL = origMedia
|
||||||
|
pipeURL = origPipe
|
||||||
}()
|
}()
|
||||||
|
|
||||||
d, err := c.GetDownloadable(context.Background(), "42", 2)
|
d, err := c.GetDownloadable(context.Background(), "42", 2)
|
||||||
@@ -106,14 +111,18 @@ func TestGetDownloadableDRMError(t *testing.T) {
|
|||||||
c.loggedIn = true
|
c.loggedIn = true
|
||||||
c.arl = "arl"
|
c.arl = "arl"
|
||||||
c.license = "license"
|
c.license = "license"
|
||||||
|
c.jwt = "jwt"
|
||||||
|
|
||||||
origBase := baseURL
|
origBase := baseURL
|
||||||
origMedia := mediaURL
|
origMedia := mediaURL
|
||||||
|
origPipe := pipeURL
|
||||||
baseURL = ts.URL
|
baseURL = ts.URL
|
||||||
mediaURL = ts.URL + "/media"
|
mediaURL = ts.URL + "/media"
|
||||||
|
pipeURL = ts.URL + "/pipe"
|
||||||
defer func() {
|
defer func() {
|
||||||
baseURL = origBase
|
baseURL = origBase
|
||||||
mediaURL = origMedia
|
mediaURL = origMedia
|
||||||
|
pipeURL = origPipe
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_, err := c.GetDownloadable(context.Background(), "42", 2)
|
_, err := c.GetDownloadable(context.Background(), "42", 2)
|
||||||
@@ -121,3 +130,154 @@ func TestGetDownloadableDRMError(t *testing.T) {
|
|||||||
t.Fatalf("expected drm error, got %v", err)
|
t.Fatalf("expected drm error, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetMetadataAddsLyricsFromPipe(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/track/1141668":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": 1141668, "title": "In Da Club", "artist": map[string]any{"name": "50 Cent"}})
|
||||||
|
case "/pipe":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"track": map[string]any{"lyrics": map[string]any{"text": "Go, go, go\nGo shawty", "synchronizedLines": []any{map[string]any{"line": "Go, go, go", "milliseconds": 0}, map[string]any{"line": "Go shawty", "milliseconds": 4280}}}}}})
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
cfgData := config.DefaultConfigData()
|
||||||
|
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||||
|
c.loggedIn = true
|
||||||
|
c.jwt = "jwt"
|
||||||
|
|
||||||
|
origBase := baseURL
|
||||||
|
origPipe := pipeURL
|
||||||
|
baseURL = ts.URL
|
||||||
|
pipeURL = ts.URL + "/pipe"
|
||||||
|
defer func() {
|
||||||
|
baseURL = origBase
|
||||||
|
pipeURL = origPipe
|
||||||
|
}()
|
||||||
|
|
||||||
|
meta, err := c.GetMetadata(context.Background(), "1141668", "track")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetMetadata() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stringFromAny(meta["lyrics"]), "Go shawty") {
|
||||||
|
t.Fatalf("expected lyrics text, got %q", stringFromAny(meta["lyrics"]))
|
||||||
|
}
|
||||||
|
if !strings.Contains(stringFromAny(meta["lyrics_synced"]), "[00:00.00]Go, go, go") {
|
||||||
|
t.Fatalf("expected synced lyrics, got %q", stringFromAny(meta["lyrics_synced"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginWithCredentials(t *testing.T) {
|
||||||
|
mobileToken := testMobileToken(t)
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/gateway" {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.URL.Query().Get("method") {
|
||||||
|
case "mobile_auth":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"results": map[string]any{"TOKEN": mobileToken}})
|
||||||
|
case "api_checkToken":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"results": "sid123"})
|
||||||
|
case "mobile_userAuth":
|
||||||
|
var payload map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if strings.TrimSpace(stringFromAny(payload["mail"])) == "" || strings.TrimSpace(stringFromAny(payload["password"])) == "" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": "missing creds"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"results": map[string]any{"ARL": "arl-token", "JWT": "jwt-token", "refresh_token": "refresh-token", "license_token": "license-token", "USER_ID": "42"}})
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
cfgData := config.DefaultConfigData()
|
||||||
|
cfgData.Deezer.Email = "tidal1@alpin.sbs"
|
||||||
|
cfgData.Deezer.Password = "tidal1@alpin.sbs"
|
||||||
|
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||||
|
|
||||||
|
origGateway := gatewayURL
|
||||||
|
gatewayURL = ts.URL + "/gateway"
|
||||||
|
defer func() { gatewayURL = origGateway }()
|
||||||
|
|
||||||
|
if err := c.Login(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Login() error = %v", err)
|
||||||
|
}
|
||||||
|
if !c.loggedIn {
|
||||||
|
t.Fatalf("expected logged in client")
|
||||||
|
}
|
||||||
|
if c.arl != "arl-token" {
|
||||||
|
t.Fatalf("arl = %q, want arl-token", c.arl)
|
||||||
|
}
|
||||||
|
if c.jwt != "jwt-token" {
|
||||||
|
t.Fatalf("jwt = %q, want jwt-token", c.jwt)
|
||||||
|
}
|
||||||
|
if c.refresh != "refresh-token" {
|
||||||
|
t.Fatalf("refresh = %q, want refresh-token", c.refresh)
|
||||||
|
}
|
||||||
|
if c.license != "license-token" {
|
||||||
|
t.Fatalf("license = %q, want license-token", c.license)
|
||||||
|
}
|
||||||
|
if c.cfg.Session.Deezer.RefreshToken != "refresh-token" {
|
||||||
|
t.Fatalf("session refresh token = %q", c.cfg.Session.Deezer.RefreshToken)
|
||||||
|
}
|
||||||
|
if c.cfg.File.Deezer.RefreshToken != "refresh-token" {
|
||||||
|
t.Fatalf("file refresh token = %q", c.cfg.File.Deezer.RefreshToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMobileToken(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
plain := []byte(strings.Repeat("A", 64) + strings.Repeat("B", 16) + strings.Repeat("C", 16))
|
||||||
|
enc, err := aesECBEncrypt([]byte(gatewayDec), plain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("aesECBEncrypt() error = %v", err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(enc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginWithRefreshToken(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/renew":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"jwt": "jwt-token", "refresh_token": "refresh-token-2"})
|
||||||
|
case "/pipe":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"tokens": map[string]any{"mediaServiceLicenseToken": map[string]any{"token": "license-token"}}}})
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
cfgData := config.DefaultConfigData()
|
||||||
|
cfgData.Deezer.RefreshToken = "refresh-token"
|
||||||
|
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||||
|
|
||||||
|
origAuth := authURL
|
||||||
|
origPipe := pipeURL
|
||||||
|
authURL = ts.URL + "/renew"
|
||||||
|
pipeURL = ts.URL + "/pipe"
|
||||||
|
defer func() {
|
||||||
|
authURL = origAuth
|
||||||
|
pipeURL = origPipe
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := c.Login(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Login() error = %v", err)
|
||||||
|
}
|
||||||
|
if !c.loggedIn {
|
||||||
|
t.Fatalf("expected logged in client")
|
||||||
|
}
|
||||||
|
if c.jwt != "jwt-token" || c.license != "license-token" {
|
||||||
|
t.Fatalf("unexpected jwt/license: jwt=%q license=%q", c.jwt, c.license)
|
||||||
|
}
|
||||||
|
if c.cfg.Session.Deezer.RefreshToken != "refresh-token-2" {
|
||||||
|
t.Fatalf("session refresh token = %q", c.cfg.Session.Deezer.RefreshToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user