docs: document packages, exported API and non-obvious logic
This commit is contained in:
@@ -8,11 +8,23 @@ import (
|
||||
"golang.org/x/crypto/blowfish"
|
||||
)
|
||||
|
||||
// blowfishIV and blowfishSecretKey are Deezer's own constants, not values
|
||||
// chosen by this project. They are the same for every user and every track,
|
||||
// and are widely published; the per-track key derived from them in
|
||||
// BlowfishKey is what actually varies. Changing either one simply produces
|
||||
// audio that will not decode.
|
||||
var (
|
||||
blowfishIV = []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
|
||||
blowfishSecretKey = []byte("g4el58wc0zvf9na1")
|
||||
)
|
||||
|
||||
// BlowfishKey derives the per-track decryption key for trackID.
|
||||
//
|
||||
// Deezer takes the MD5 of the track ID as a 32 character hex string and folds
|
||||
// its two halves back into the 16 byte secret, XORing byte i of the secret
|
||||
// with hex digits i and i+16. The loop therefore runs over the 16 bytes of
|
||||
// the raw digest, not the 32 characters of its hex encoding, and the result
|
||||
// is the same 16 byte length as the secret.
|
||||
func BlowfishKey(trackID string) []byte {
|
||||
hash := md5.Sum([]byte(trackID))
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
@@ -26,6 +38,12 @@ func BlowfishKey(trackID string) []byte {
|
||||
return key
|
||||
}
|
||||
|
||||
// DecryptBlowfish decrypts a single stream chunk with key and returns the
|
||||
// plaintext.
|
||||
//
|
||||
// This is not a whole-file operation. Only every third chunk of a Deezer
|
||||
// stream is encrypted, so callers are responsible for applying that stripe
|
||||
// pattern; see streamToTempFile in the download package.
|
||||
func DecryptBlowfish(data, key []byte) ([]byte, error) {
|
||||
block, err := blowfish.NewCipher(key)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
// Package deezer talks to Deezer's private endpoints: the gw-light web API,
|
||||
// the Android mobile gateway used for email and password login, and the
|
||||
// media servers that hand out encrypted audio streams. None of it is
|
||||
// documented or supported by Deezer, so the request shapes, the error
|
||||
// markers matched in response bodies and the crypto constants in this
|
||||
// package were all derived from the official clients and can break without
|
||||
// warning.
|
||||
//
|
||||
// A Client wraps an authenticated Session and fetches a Resource, which is
|
||||
// one of Album, Playlist, Artist or Single. Audio is served Blowfish
|
||||
// encrypted; see blowfish.go for the key derivation and the download
|
||||
// package for the stripe pattern that undoes it.
|
||||
package deezer
|
||||
|
||||
import (
|
||||
@@ -15,6 +27,9 @@ type Client struct {
|
||||
Session *Session
|
||||
}
|
||||
|
||||
// NewClient authenticates with Deezer and returns a client bound to the
|
||||
// resulting session. An empty arlCookie falls back to the credentials held
|
||||
// in the system keyring.
|
||||
func NewClient(ctx context.Context, arlCookie string) (*Client, error) {
|
||||
session, err := resolveSession(ctx, arlCookie)
|
||||
if err != nil {
|
||||
@@ -26,6 +41,13 @@ func NewClient(ctx context.Context, arlCookie string) (*Client, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveSession authenticates with arlCookie when one is supplied, and
|
||||
// otherwise falls back to the stored credentials.
|
||||
//
|
||||
// The validate callback handed to resolveARL is the real authentication, not
|
||||
// a separate probe, so a stored ARL that still works is not sent twice.
|
||||
// session is therefore only nil here when resolveARL had to log in again to
|
||||
// mint a fresh ARL.
|
||||
func resolveSession(ctx context.Context, arlCookie string) (*Session, error) {
|
||||
if arlCookie != "" {
|
||||
return authenticate(ctx, arlCookie)
|
||||
@@ -57,6 +79,14 @@ func resolveSession(ctx context.Context, arlCookie string) (*Session, error) {
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// FetchResource fetches the page for the given kind and id and decodes it
|
||||
// into the matching Resource implementation.
|
||||
//
|
||||
// gw-light answers 200 even for an unknown id and reports the failure inside
|
||||
// the JSON, so bad ids have to be detected by matching markers in the body
|
||||
// rather than by reading the status code. The nb parameter is set far above
|
||||
// any real tracklist length to pull an entire resource in one request and
|
||||
// avoid paging.
|
||||
func (c *Client) FetchResource(ctx context.Context, kind Kind, id string) (Resource, error) {
|
||||
resource, err := kind.newResource()
|
||||
if err != nil {
|
||||
@@ -125,6 +155,17 @@ func (c *Client) FetchResource(ctx context.Context, kind Kind, id string) (Resou
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
// FetchMedia resolves a playable source URL for track at the requested
|
||||
// quality.
|
||||
//
|
||||
// Each quality maps to an ordered fallback chain, so asking for flac on a
|
||||
// track that has none yields mp3_320 instead of an error; callers compare
|
||||
// Media.Format against what they asked for to detect a downgrade. There is
|
||||
// no wav entry because Deezer does not serve wav: the download package
|
||||
// requests flac and converts locally.
|
||||
//
|
||||
// A 400 is accepted alongside 200 because the gateway uses it to return a
|
||||
// structured error payload that is more useful than the status code.
|
||||
func (c *Client) FetchMedia(ctx context.Context, track *Track, quality string) (*Media, error) {
|
||||
qualityFormats := map[string]string{
|
||||
"mp3_128": `[{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
|
||||
@@ -199,6 +240,12 @@ func (c *Client) FetchCoverImage(ctx context.Context, track *Track) ([]byte, err
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// MediaStream opens the audio stream for media. The caller owns the returned
|
||||
// body and must close it.
|
||||
//
|
||||
// The session client is copied so its timeout can be cleared for this
|
||||
// request: the session timeout is sized for short API calls and would abort
|
||||
// a long track transfer. Cancellation is left to ctx.
|
||||
func (c *Client) MediaStream(ctx context.Context, media *Media) (io.ReadCloser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, media.URL(), nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -13,12 +13,20 @@ const (
|
||||
keyringUser = "default"
|
||||
)
|
||||
|
||||
// Credentials is the JSON blob stored as a single system keyring secret. The
|
||||
// password is kept alongside the ARL so an expired session can be renewed
|
||||
// without prompting; see Login. Nothing here is ever written to disk by
|
||||
// godeez itself.
|
||||
type Credentials struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
ARL string `json:"arl,omitempty"`
|
||||
}
|
||||
|
||||
// loadCredentials returns the stored credentials, or nil with no error when
|
||||
// the user has simply never logged in. That case is distinguished from a
|
||||
// genuine keyring failure so callers can fall back to DEEZER_ARL instead of
|
||||
// aborting.
|
||||
func loadCredentials() (*Credentials, error) {
|
||||
secret, err := keyring.Get(keyringService, keyringUser)
|
||||
if err != nil {
|
||||
@@ -49,6 +57,8 @@ func saveCredentials(creds *Credentials) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearCredentials removes the stored credentials. Logging out when nothing
|
||||
// is stored is not an error, so a missing entry is reported as success.
|
||||
func ClearCredentials() error {
|
||||
if err := keyring.Delete(keyringService, keyringUser); err != nil {
|
||||
if errors.Is(err, keyring.ErrNotFound) {
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// zeroPad right pads data with zero bytes to a whole number of AES blocks.
|
||||
// This is not PKCS#7 and is not unambiguously reversible, but it is what the
|
||||
// mobile gateway expects for the password field.
|
||||
func zeroPad(data []byte) []byte {
|
||||
bs := aes.BlockSize
|
||||
padded := make([]byte, len(data)+(bs-len(data)%bs)%bs)
|
||||
@@ -22,6 +25,13 @@ func ecbDecrypt(key, data []byte) ([]byte, error) {
|
||||
return ecbTransform(key, data, (cipher.Block).Decrypt)
|
||||
}
|
||||
|
||||
// ecbTransform applies op block by block in ECB mode.
|
||||
//
|
||||
// ECB leaks equality between identical plaintext blocks and would be the
|
||||
// wrong choice for anything designed today, but it is the mode Deezer's
|
||||
// mobile gateway uses, so interoperating requires it. The standard library
|
||||
// deliberately ships no ECB mode, which is why this exists. Do not reuse it
|
||||
// for anything outside the gateway handshake.
|
||||
func ecbTransform(key, data []byte, op func(cipher.Block, []byte, []byte)) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
|
||||
@@ -20,9 +20,18 @@ import (
|
||||
const (
|
||||
gatewayBaseURL = "https://api.deezer.com/1.0/gateway.php"
|
||||
gatewayUserAgent = "Deezer/6.1.22.49 (Android; 9; Tablet; us) innotek GmbH VirtualBox"
|
||||
nonceAlphabet = "012345689abdef"
|
||||
|
||||
// nonceAlphabet omits '7' and 'c' on purpose. It mirrors the alphabet the
|
||||
// Android client uses to build its uniq_id, and the gateway is picky about
|
||||
// the shape of that value, so this must not be "completed" into a full hex
|
||||
// alphabet.
|
||||
nonceAlphabet = "012345689abdef"
|
||||
)
|
||||
|
||||
// The gateway only answers requests that look like they come from the Android
|
||||
// app, so these describe a plausible device. They are deliberately generic
|
||||
// rather than derived from the user's real machine: nothing here should
|
||||
// identify the person running godeez.
|
||||
const (
|
||||
deviceOS = "Android"
|
||||
deviceName = "VirtualBox"
|
||||
@@ -39,11 +48,22 @@ type mobileClient struct {
|
||||
sid string
|
||||
}
|
||||
|
||||
// CheckGatewayEnv reports whether the mobile gateway keys are present and
|
||||
// well formed. It exists so the login command can fail immediately with a
|
||||
// clear message instead of prompting for a password it cannot use.
|
||||
func CheckGatewayEnv() error {
|
||||
_, _, err := gatewayEnv()
|
||||
return err
|
||||
}
|
||||
|
||||
// gatewayEnv reads the two mobile gateway keys from the environment.
|
||||
//
|
||||
// They are not shipped with godeez: they are Deezer's, and baking them into a
|
||||
// public repository would be both a licensing problem and a fast route to
|
||||
// having them revoked. Users who want email and password login supply their
|
||||
// own, which is why this is the one feature gated behind environment
|
||||
// variables. The gateway key doubles as an AES key, hence the exact length
|
||||
// requirement.
|
||||
func gatewayEnv() (string, string, error) {
|
||||
apiKey := os.Getenv("DEEZER_MOBILE_API_KEY")
|
||||
gwKey := os.Getenv("DEEZER_MOBILE_GW_KEY")
|
||||
@@ -71,6 +91,14 @@ func newMobileClient() (*mobileClient, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// login runs the three step mobile handshake and returns the resulting
|
||||
// credentials plus the account's display name.
|
||||
//
|
||||
// The steps are ordered and stateful, so none of them can be skipped or
|
||||
// reordered: authenticate yields a token and two one-shot AES keys,
|
||||
// checkToken trades the token for a session id that gatewayRequest then
|
||||
// attaches to every later call, and only then will userAuth accept the
|
||||
// encrypted password and return an ARL.
|
||||
func (m *mobileClient) login(ctx context.Context, email, password string) (*Credentials, string, error) {
|
||||
token, tokenKey, userKey, err := m.authenticate(ctx)
|
||||
if err != nil {
|
||||
@@ -89,6 +117,16 @@ func (m *mobileClient) login(ctx context.Context, email, password string) (*Cred
|
||||
return &Credentials{Email: email, Password: password, ARL: arl}, username, nil
|
||||
}
|
||||
|
||||
// authenticate performs the first handshake step and returns the session
|
||||
// token, the key used to sign it back in checkToken, and the key used to
|
||||
// encrypt the password in userAuth.
|
||||
//
|
||||
// The gateway packs all three into one hex blob encrypted under the gateway
|
||||
// key, at fixed offsets: 64 bytes of token, then two 16 byte keys. The length
|
||||
// check guards against a short or error response being sliced blindly.
|
||||
//
|
||||
// Errors arrive with a 200 status and are only visible as markers in the
|
||||
// body, so they are matched as strings.
|
||||
func (m *mobileClient) authenticate(ctx context.Context) (string, string, string, error) {
|
||||
body, err := m.gatewayRequest(ctx, "mobile_auth", http.MethodGet, "uniq_id", genUniqID(), nil)
|
||||
if err != nil {
|
||||
@@ -132,6 +170,9 @@ func (m *mobileClient) authenticate(ctx context.Context) (string, string, string
|
||||
return token, tokenKey, userKey, nil
|
||||
}
|
||||
|
||||
// checkToken proves possession of the token by returning it encrypted under
|
||||
// tokenKey, and stores the session id the gateway hands back. Every
|
||||
// subsequent request carries that id, so userAuth fails without this step.
|
||||
func (m *mobileClient) checkToken(ctx context.Context, token, tokenKey string) error {
|
||||
encrypted, err := ecbEncrypt([]byte(tokenKey), []byte(token))
|
||||
if err != nil {
|
||||
@@ -158,6 +199,12 @@ func (m *mobileClient) checkToken(ctx context.Context, token, tokenKey string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// userAuth exchanges the user's credentials for an ARL cookie and returns it
|
||||
// along with the account's display name.
|
||||
//
|
||||
// The password is sent AES-ECB encrypted under userKey rather than in the
|
||||
// clear. The empty and constant fields in the payload are not padding: the
|
||||
// gateway rejects the request outright if any of them are missing.
|
||||
func (m *mobileClient) userAuth(ctx context.Context, email, password, userKey string) (string, string, error) {
|
||||
encryptedPassword, err := ecbEncrypt([]byte(userKey), zeroPad([]byte(password)))
|
||||
if err != nil {
|
||||
@@ -259,6 +306,9 @@ func (m *mobileClient) gatewayRequest(ctx context.Context, method, httpMethod, p
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// genUniqID builds the 32 character device identifier sent with the first
|
||||
// handshake request. It is regenerated per login on purpose, so that repeated
|
||||
// logins are not linkable to one another by a stable device id.
|
||||
func genUniqID() string {
|
||||
b := make([]byte, 32)
|
||||
for i := range b {
|
||||
|
||||
@@ -2,6 +2,11 @@ package deezer
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Kind is the type of Deezer resource being downloaded. It is the single
|
||||
// source of truth for the four supported resources: the cmd package derives
|
||||
// its download subcommands from these constants, and each kind maps to a
|
||||
// gw-light page method, the request field naming its id, and a Resource
|
||||
// implementation. Adding a kind means extending all three switches below.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
@@ -26,6 +31,9 @@ func (k Kind) pageMethod() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// idKey returns the request field that carries the resource id. The names are
|
||||
// Deezer's own internal abbreviations and do not follow from the kind, so they
|
||||
// have to be spelled out. A track is a "song" on the wire.
|
||||
func (k Kind) idKey() string {
|
||||
switch k {
|
||||
case KindAlbum:
|
||||
@@ -41,6 +49,9 @@ func (k Kind) idKey() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// newResource returns an empty Resource for the kind. KindTrack maps to
|
||||
// Single because a single track page has its own response shape rather than
|
||||
// being an album with one entry.
|
||||
func (k Kind) newResource() (Resource, error) {
|
||||
switch k {
|
||||
case KindAlbum:
|
||||
|
||||
@@ -6,6 +6,14 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// resolveARL returns a usable ARL cookie from the stored credentials, logging
|
||||
// in again if the stored one has expired.
|
||||
//
|
||||
// validate is supplied by the caller so the check can be the real
|
||||
// authentication rather than a throwaway probe. Only ErrInvalidARL triggers a
|
||||
// re-login: any other validation failure is most likely the network or Deezer
|
||||
// being down, and silently re-sending the password in that case would turn a
|
||||
// transient outage into a spurious login attempt.
|
||||
func resolveARL(ctx context.Context, validate func(ctx context.Context, arl string) error) (string, error) {
|
||||
creds, err := loadCredentials()
|
||||
if err != nil {
|
||||
@@ -35,6 +43,12 @@ func resolveARL(ctx context.Context, validate func(ctx context.Context, arl stri
|
||||
return "", errors.New("run 'godeez login' or export DEEZER_ARL environment variable")
|
||||
}
|
||||
|
||||
// Login authenticates with email and password through the mobile gateway,
|
||||
// persists the credentials to the system keyring, and returns the resulting
|
||||
// ARL cookie and the account's display name.
|
||||
//
|
||||
// The password is stored, not just the ARL, because ARLs expire and renewing
|
||||
// one without prompting the user again requires replaying the login.
|
||||
func Login(ctx context.Context, email, password string) (string, string, error) {
|
||||
client, err := newMobileClient()
|
||||
if err != nil {
|
||||
|
||||
@@ -18,6 +18,11 @@ type mediaError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// URL and Format read the first source Deezer offered, which is the best one
|
||||
// available for the requested quality. Both index without checking because
|
||||
// FetchMedia has already rejected empty and error responses; do not call them
|
||||
// on a Media obtained any other way.
|
||||
|
||||
func (m *Media) URL() string {
|
||||
return m.Data[0].Media[0].Sources[0].URL
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
package deezer
|
||||
|
||||
// Resource is a downloadable Deezer page: an Album, Playlist, Artist or
|
||||
// Single. Implementations differ only in how they decode the gw-light
|
||||
// response and where they place their output, so the accessors are
|
||||
// intentionally thin and are not documented individually.
|
||||
//
|
||||
// The unexported decode method seals the interface. Only the four types in
|
||||
// this package can satisfy it, which lets Kind.newResource stay an exhaustive
|
||||
// switch and guarantees FetchResource never receives an implementation whose
|
||||
// wire format it does not know.
|
||||
type Resource interface {
|
||||
Title() string
|
||||
Tracks() []*Track
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrInvalidARL reports that an ARL cookie was rejected. Callers should treat
|
||||
// it as recoverable and re-login rather than as a hard failure; resolveARL
|
||||
// relies on that distinction to decide whether to renew a stored session.
|
||||
var ErrInvalidARL = errors.New("invalid or expired ARL cookie")
|
||||
|
||||
type Session struct {
|
||||
@@ -20,6 +23,17 @@ type Session struct {
|
||||
Premium bool
|
||||
}
|
||||
|
||||
// authenticate exchanges an ARL cookie for a Session. It returns
|
||||
// ErrInvalidARL if the cookie is rejected.
|
||||
//
|
||||
// The endpoint answers 200 with an empty user for a bad cookie rather than an
|
||||
// error status, so a zero user id is the only reliable signal that the ARL is
|
||||
// no longer valid. A cookie jar is required because gw-light sets session
|
||||
// cookies that later calls depend on.
|
||||
//
|
||||
// Premium is inferred from the offline listening options, which are the
|
||||
// closest thing the payload carries to a subscription flag; it gates the
|
||||
// higher quality formats.
|
||||
func authenticate(ctx context.Context, arlCookie string) (*Session, error) {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -14,12 +14,16 @@ type Contributors struct {
|
||||
Authors []string `json:"author"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON tolerates the empty array Deezer sends when a track has no
|
||||
// contributors. The field is an object in every other case, so decoding it
|
||||
// straight into the struct fails on those tracks.
|
||||
func (c *Contributors) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "[]" {
|
||||
*c = Contributors{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Alias drops the method set, so this Unmarshal does not recurse.
|
||||
type Alias Contributors
|
||||
aux := (*Alias)(c)
|
||||
|
||||
@@ -47,6 +51,10 @@ func (t *Track) FullTitle() string {
|
||||
return t.Title
|
||||
}
|
||||
|
||||
// Filename builds the on-disk name for the track, sanitised for the current
|
||||
// filesystem. Album downloads get a zero padded track number prefix so the
|
||||
// directory sorts in playing order; the other kinds have no meaningful
|
||||
// ordering to preserve.
|
||||
func (t *Track) Filename(kind Kind, format string) string {
|
||||
ext := "mp3"
|
||||
switch format {
|
||||
@@ -67,11 +75,19 @@ func (t *Track) Filename(kind Kind, format string) string {
|
||||
|
||||
base := fmt.Sprintf("%s%s - %s", prefix, t.Artist, t.FullTitle())
|
||||
base, _ = filenamify.Filenamify(base, filenamify.Options{MaxLength: 255})
|
||||
// 255 bytes is the per-component limit on ext4 and APFS. The budget also
|
||||
// has to cover the extension, its dot, and the "-id3v2" suffix the tagging
|
||||
// library appends to its temporary file: without that headroom, tagging a
|
||||
// long title fails after the download has already succeeded.
|
||||
base = truncateBytes(base, 255-len(ext)-1-len("-id3v2"))
|
||||
|
||||
return base + "." + ext
|
||||
}
|
||||
|
||||
// truncateBytes shortens s to at most maxLen bytes without splitting a rune.
|
||||
// The limit is in bytes because that is what filesystems enforce, but cutting
|
||||
// mid-rune would leave an invalid UTF-8 name, so it backs up to the last rune
|
||||
// boundary that fits.
|
||||
func truncateBytes(s string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
return ""
|
||||
|
||||
Reference in New Issue
Block a user