3 Commits
43 changed files with 759 additions and 13 deletions
-4
View File
@@ -39,9 +39,5 @@ checksum:
snapshot: snapshot:
version_template: '{{ incpatch .Version }}-snapshot' version_template: '{{ incpatch .Version }}-snapshot'
# Release notes are written by hand in CHANGELOG.md
changelog:
disable: true
release: release:
prerelease: auto prerelease: auto
+2 -2
View File
@@ -6,7 +6,7 @@
[![License](https://img.shields.io/github/license/mathismqn/godeez)](https://github.com/mathismqn/godeez/blob/main/LICENSE) [![License](https://img.shields.io/github/license/mathismqn/godeez)](https://github.com/mathismqn/godeez/blob/main/LICENSE)
[![Last commit](https://img.shields.io/github/last-commit/mathismqn/godeez)](https://github.com/mathismqn/godeez/commits/main) [![Last commit](https://img.shields.io/github/last-commit/mathismqn/godeez)](https://github.com/mathismqn/godeez/commits/main)
A simple Go tool for downloading music from [Deezer](https://www.deezer.com). Download music from [Deezer](https://www.deezer.com) in MP3 or lossless FLAC/WAV.
[Features](#features) • [Features](#features) •
[Installation](#installation) • [Installation](#installation) •
@@ -19,7 +19,7 @@ A simple Go tool for downloading music from [Deezer](https://www.deezer.com).
## Features ## Features
- Download playlists, albums, artists' top tracks, and individual tracks - Download playlists, albums, artists' top tracks, and individual tracks
- Choose audio quality: MP3 128 kbps, MP3 320 kbps (default), or lossless FLAC/WAV (⚠️ non-premium accounts are limited to 128 kbps) - Choose audio quality: MP3 128 kbps, MP3 320 kbps (default), or lossless FLAC/WAV (⚠️ non-premium accounts are limited to MP3 128 kbps)
- Authenticate with an ARL cookie or with your Deezer email and password - Authenticate with an ARL cookie or with your Deezer email and password
- Automatically embed metadata tags (artist, album, title, artwork, etc.) - Automatically embed metadata tags (artist, album, title, artwork, etc.)
- Fetch and tag tracks with BPM, musical key, and genre - Fetch and tag tracks with BPM, musical key, and genre
+10
View File
@@ -38,6 +38,16 @@ func newDownloadCmd() *cobra.Command {
return cmd return cmd
} }
// newDownloadSubCmd builds one download subcommand from a deezer.Kind. The
// four kinds differ only in wording and in whether they take a track limit,
// so they share this constructor rather than being written out four times.
//
// All four share one Options value through the parent's persistent flags,
// which is safe because exactly one subcommand ever runs.
//
// A cancelled download is reported as success: the user pressed Ctrl-C and
// has already seen the progress output, so an error on top of it would be
// noise, and a non-zero exit would misreport a deliberate stop as a failure.
func newDownloadSubCmd(kind deezer.Kind, opts *download.Options) *cobra.Command { func newDownloadSubCmd(kind deezer.Kind, opts *download.Options) *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: fmt.Sprintf("%s <%s_id>", kind, kind), Use: fmt.Sprintf("%s <%s_id>", kind, kind),
+12
View File
@@ -49,6 +49,16 @@ func runLogin(ctx context.Context) error {
return nil return nil
} }
// promptCredentials reads the email and password, giving up if ctx is
// cancelled.
//
// Reading stdin cannot itself be cancelled, so the read runs in a goroutine
// and this selects on whichever finishes first. That goroutine outlives a
// cancelled prompt, which is why the channel is buffered.
//
// Terminal state is captured up front and restored on cancellation: Ctrl-C
// during the password prompt would otherwise leave echo disabled and the
// user's shell silently typing blind.
func promptCredentials(ctx context.Context) (string, string, error) { func promptCredentials(ctx context.Context) (string, string, error) {
oldState, stateErr := term.GetState(int(os.Stdin.Fd())) oldState, stateErr := term.GetState(int(os.Stdin.Fd()))
@@ -77,6 +87,8 @@ func promptCredentials(ctx context.Context) (string, string, error) {
} }
} }
// readCredentials prompts on the terminal. The password is read with echo
// off so it neither appears on screen nor reaches the shell history.
func readCredentials() (string, string, error) { func readCredentials() (string, string, error) {
fmt.Print("Email: ") fmt.Print("Email: ")
line, err := bufio.NewReader(os.Stdin).ReadString('\n') line, err := bufio.NewReader(os.Stdin).ReadString('\n')
+24
View File
@@ -1,3 +1,6 @@
// Package cmd defines the godeez command line: the root command and its
// download, login, logout, update and version subcommands. It is a thin layer
// that parses flags and delegates to the internal packages.
package cmd package cmd
import ( import (
@@ -12,8 +15,16 @@ import (
"golang.org/x/term" "golang.org/x/term"
) )
// updateNoticeAnnotation marks the commands that may print an update notice.
// It is an annotation rather than a field because it is inherited: marking
// the download command opts in all of its subcommands.
const updateNoticeAnnotation = "godeez:update-notice" const updateNoticeAnnotation = "godeez:update-notice"
// Execute runs the CLI.
//
// The update check is started before the command and collected after it, so
// the network round trip overlaps with work the user actually asked for
// instead of adding to the startup time.
func Execute(ctx context.Context) error { func Execute(ctx context.Context) error {
root := newRootCmd() root := newRootCmd()
@@ -47,6 +58,13 @@ func newRootCmd() *cobra.Command {
return root return root
} }
// wantsUpdateNotice decides whether this invocation should check for updates.
//
// The aim is to nag only during real interactive use. Notices are suppressed
// when stderr is not a terminal, so they cannot corrupt piped or scripted
// output; on help output, where they are noise; on commands that only print
// their usage; and on anything not explicitly opted in via the annotation,
// which notably keeps `godeez version` and `godeez update` quiet.
func wantsUpdateNotice(root *cobra.Command) bool { func wantsUpdateNotice(root *cobra.Command) bool {
if !term.IsTerminal(int(os.Stderr.Fd())) { if !term.IsTerminal(int(os.Stderr.Fd())) {
return false return false
@@ -75,6 +93,12 @@ func wantsUpdateNotice(root *cobra.Command) bool {
return false return false
} }
// printUpdateNotice prints the notice only if the check has already finished.
//
// The non-blocking receive is the point: the command is done and the user
// should get their prompt back, so a check that is still in flight is
// dropped rather than waited on. A nil channel, meaning no check was started,
// takes the same path.
func printUpdateNotice(notice <-chan string) { func printUpdateNotice(notice <-chan string) {
select { select {
case latest := <-notice: case latest := <-notice:
+8
View File
@@ -38,6 +38,14 @@ func newUpdateCmd() *cobra.Command {
return cmd return cmd
} }
// runUpdate reports the current and latest versions and installs the update.
//
// Whether the binary can be replaced at all is checked before the network
// call, so a package-managed install is told so straight away instead of
// after a pointless round trip.
//
// The force check comes before the check-only one so that `--check --force`
// still just reports rather than installing.
func runUpdate(ctx context.Context, opts *updateOptions) error { func runUpdate(ctx context.Context, opts *updateOptions) error {
if err := update.CheckUpdatable(); err != nil { if err := update.CheckUpdatable(); err != nil {
return err return err
+48
View File
@@ -1,3 +1,9 @@
// Package audio converts downloaded audio between formats.
//
// Deezer does not serve wav, so a wav download is really a flac download
// followed by FLACToWAV. The conversion is lossless in both directions: flac
// decodes to exactly the PCM samples it was encoded from, so nothing is lost
// by going through it.
package audio package audio
import ( import (
@@ -16,12 +22,37 @@ import (
) )
const ( const (
// headerSize is the canonical PCM wav header: a 12 byte RIFF/WAVE header,
// a 24 byte fmt chunk, and an 8 byte data chunk header.
headerSize = 44 headerSize = 44
formatPCM = 1 formatPCM = 1
// ctxCheckInterval is how often, in flac frames, cancellation is polled.
// A flac frame is a few thousand samples, so checking every frame would
// add a select to the innermost decode loop for no practical gain in
// responsiveness.
ctxCheckInterval = 64 ctxCheckInterval = 64
// maxDataSize is the largest audio payload that still fits. RIFF stores
// its sizes as uint32, and the RIFF size field covers the header after
// its own first 8 bytes as well as the data, so the audio itself has to
// stay that much below the limit. This works out to roughly 6 hours of
// CD quality stereo, which no single track will reach, but silently
// producing a file with a wrapped size field would be worse than an
// error.
maxDataSize = math.MaxUint32 - (headerSize - 8) maxDataSize = math.MaxUint32 - (headerSize - 8)
) )
// FLACToWAV decodes the flac at srcPath and writes it as a PCM wav to
// dstPath.
//
// The size is checked twice, once from the flac header before doing any work
// and once against the bytes actually written, because NSamples is zero in
// flac streams that were encoded without a known length.
//
// Output goes to a temporary file in the destination directory and is renamed
// into place at the end, so a cancelled or failed conversion never leaves a
// half decoded file where a playable one is expected.
func FLACToWAV(ctx context.Context, srcPath, dstPath string) error { func FLACToWAV(ctx context.Context, srcPath, dstPath string) error {
stream, err := flac.Open(srcPath) stream, err := flac.Open(srcPath)
if err != nil { if err != nil {
@@ -54,6 +85,9 @@ func FLACToWAV(ctx context.Context, srcPath, dstPath string) error {
} }
}() }()
// The header goes down with a zero data size and is patched afterwards:
// the real length is only known once every frame has been decoded, and
// buffering the whole stream in memory to find out first is not worth it.
w := bufio.NewWriter(file) w := bufio.NewWriter(file)
if err := writeHeader(w, info.SampleRate, info.NChannels, info.BitsPerSample, 0); err != nil { if err := writeHeader(w, info.SampleRate, info.NChannels, info.BitsPerSample, 0); err != nil {
return err return err
@@ -66,6 +100,8 @@ func FLACToWAV(ctx context.Context, srcPath, dstPath string) error {
if dataSize > maxDataSize { if dataSize > maxDataSize {
return fmt.Errorf("audio data of %d bytes exceeds the wav format limit", dataSize) return fmt.Errorf("audio data of %d bytes exceeds the wav format limit", dataSize)
} }
// RIFF chunks must end on an even offset. Only reachable with 8 or 24 bit
// mono, where a sample is an odd number of bytes.
if dataSize%2 != 0 { if dataSize%2 != 0 {
if err := w.WriteByte(0); err != nil { if err := w.WriteByte(0); err != nil {
return err return err
@@ -162,6 +198,12 @@ func writeSamples(ctx context.Context, w io.Writer, stream *flac.Stream, nChanne
return dataSize, nil return dataSize, nil
} }
// putSample encodes one sample little endian into buf.
//
// 8 bit wav is the odd one out: it stores unsigned samples biased by 128,
// while every wider depth is signed two's complement. Writing an 8 bit sample
// signed produces audio that sounds like loud static, so the bias is not
// optional.
func putSample(buf []byte, sample int32, bytesPerSample int) { func putSample(buf []byte, sample int32, bytesPerSample int) {
if bytesPerSample == 1 { if bytesPerSample == 1 {
buf[0] = byte(sample + 128) buf[0] = byte(sample + 128)
@@ -174,6 +216,12 @@ func putSample(buf []byte, sample int32, bytesPerSample int) {
} }
} }
// patchSizes rewrites the two length fields once the real data size is known:
// the RIFF size at offset 4 and the data chunk size just before the samples
// begin.
//
// The pad byte counts towards the RIFF size but not towards the data chunk
// size, which is why only the first of the two includes it.
func patchSizes(file *os.File, dataSize int64) error { func patchSizes(file *os.File, dataSize int64) error {
buf := make([]byte, 4) buf := make([]byte, 4)
+23
View File
@@ -1,3 +1,11 @@
// Package buildinfo reports the version, commit and build date of the running
// binary.
//
// Release builds have these stamped in by goreleaser through -ldflags. When
// that has not happened, as with `go build` or `go install`, the values are
// recovered from the module metadata the toolchain embeds. Anything that
// cannot be established as a real release is reported as a development build,
// which is what disables the update machinery.
package buildinfo package buildinfo
import ( import (
@@ -14,12 +22,18 @@ import (
// release. Both the update check and `godeez update` refuse to run on them. // release. Both the update check and `godeez update` refuse to run on them.
const devVersion = "dev" const devVersion = "dev"
// Injected at link time by goreleaser. They are unexported and read through
// the accessors below so nothing can depend on their zero values directly.
var ( var (
version = devVersion version = devVersion
commit = "" commit = ""
date = "" date = ""
) )
// Version returns the release version without a leading "v", or devVersion
// for anything that is not a release build. Binaries built with `go install`
// carry no ldflags but do record the module version, so that is consulted
// before giving up.
func Version() string { func Version() string {
if version != devVersion { if version != devVersion {
return version return version
@@ -34,6 +48,12 @@ func Version() string {
return devVersion return devVersion
} }
// releaseVersion accepts v only if it names a published release, returning ""
// otherwise.
//
// Pseudo-versions describe a commit that was never tagged, and a build suffix
// marks a local or modified build. Treating either as a release would offer
// the user an update path from a version that does not exist.
func releaseVersion(v string) string { func releaseVersion(v string) string {
if !semver.IsValid(v) { if !semver.IsValid(v) {
return "" return ""
@@ -49,6 +69,9 @@ func IsDev() bool {
return Version() == devVersion return Version() == devVersion
} }
// Commit returns the revision the binary was built from, falling back to the
// VCS stamp the Go toolchain records when building inside a repository. It
// returns "" when neither is available.
func Commit() string { func Commit() string {
if commit != "" { if commit != "" {
return commit return commit
+11
View File
@@ -1,3 +1,8 @@
// Package config resolves where godeez reads its session from and writes its
// downloads to. There is no config file: the output directory is fixed and
// the only setting is the DEEZER_ARL environment variable, which exists as an
// escape hatch for users who would rather not store credentials in the system
// keyring.
package config package config
import ( import (
@@ -13,6 +18,12 @@ type Config struct {
OutputDir string OutputDir string
} }
// Load resolves the configuration and creates the output directory.
//
// An empty ARLCookie is normal and not an error: it means fall back to the
// stored credentials, which is the usual path. Creating the directory here
// rather than at first write means a bad path fails immediately instead of
// after the first track has been fetched.
func Load() (*Config, error) { func Load() (*Config, error) {
arl := os.Getenv("DEEZER_ARL") arl := os.Getenv("DEEZER_ARL")
+11
View File
@@ -6,6 +6,17 @@ import (
"path/filepath" "path/filepath"
) )
// MigrateLegacy moves the download ledger from the old ~/.godeez directory
// next to the user's music, where it now lives.
//
// It is silent and best effort throughout. A failed migration costs the user
// their skip history, which the next download simply rebuilds, so there is
// nothing worth interrupting them about. An existing database at the new
// location always wins, which makes this safe to run on every download rather
// than needing a flag to say whether it has happened yet.
//
// The rename is attempted first and falls back to a copy, because the old and
// new locations are often on different filesystems.
func MigrateLegacy(outputDir string) { func MigrateLegacy(outputDir string) {
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
if err != nil { if err != nil {
+18
View File
@@ -8,11 +8,23 @@ import (
"golang.org/x/crypto/blowfish" "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 ( var (
blowfishIV = []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07} blowfishIV = []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
blowfishSecretKey = []byte("g4el58wc0zvf9na1") 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 { func BlowfishKey(trackID string) []byte {
hash := md5.Sum([]byte(trackID)) hash := md5.Sum([]byte(trackID))
hashHex := hex.EncodeToString(hash[:]) hashHex := hex.EncodeToString(hash[:])
@@ -26,6 +38,12 @@ func BlowfishKey(trackID string) []byte {
return key 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) { func DecryptBlowfish(data, key []byte) ([]byte, error) {
block, err := blowfish.NewCipher(key) block, err := blowfish.NewCipher(key)
if err != nil { if err != nil {
+47
View File
@@ -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 package deezer
import ( import (
@@ -15,6 +27,9 @@ type Client struct {
Session *Session 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) { func NewClient(ctx context.Context, arlCookie string) (*Client, error) {
session, err := resolveSession(ctx, arlCookie) session, err := resolveSession(ctx, arlCookie)
if err != nil { if err != nil {
@@ -26,6 +41,13 @@ func NewClient(ctx context.Context, arlCookie string) (*Client, error) {
}, nil }, 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) { func resolveSession(ctx context.Context, arlCookie string) (*Session, error) {
if arlCookie != "" { if arlCookie != "" {
return authenticate(ctx, arlCookie) return authenticate(ctx, arlCookie)
@@ -57,6 +79,14 @@ func resolveSession(ctx context.Context, arlCookie string) (*Session, error) {
return session, nil 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) { func (c *Client) FetchResource(ctx context.Context, kind Kind, id string) (Resource, error) {
resource, err := kind.newResource() resource, err := kind.newResource()
if err != nil { if err != nil {
@@ -125,6 +155,17 @@ func (c *Client) FetchResource(ctx context.Context, kind Kind, id string) (Resou
return resource, nil 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) { func (c *Client) FetchMedia(ctx context.Context, track *Track, quality string) (*Media, error) {
qualityFormats := map[string]string{ qualityFormats := map[string]string{
"mp3_128": `[{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`, "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) 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) { func (c *Client) MediaStream(ctx context.Context, media *Media) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, media.URL(), nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, media.URL(), nil)
if err != nil { if err != nil {
+10
View File
@@ -13,12 +13,20 @@ const (
keyringUser = "default" 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 { type Credentials struct {
Email string `json:"email"` Email string `json:"email"`
Password string `json:"password"` Password string `json:"password"`
ARL string `json:"arl,omitempty"` 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) { func loadCredentials() (*Credentials, error) {
secret, err := keyring.Get(keyringService, keyringUser) secret, err := keyring.Get(keyringService, keyringUser)
if err != nil { if err != nil {
@@ -49,6 +57,8 @@ func saveCredentials(creds *Credentials) error {
return nil 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 { func ClearCredentials() error {
if err := keyring.Delete(keyringService, keyringUser); err != nil { if err := keyring.Delete(keyringService, keyringUser); err != nil {
if errors.Is(err, keyring.ErrNotFound) { if errors.Is(err, keyring.ErrNotFound) {
+10
View File
@@ -6,6 +6,9 @@ import (
"fmt" "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 { func zeroPad(data []byte) []byte {
bs := aes.BlockSize bs := aes.BlockSize
padded := make([]byte, len(data)+(bs-len(data)%bs)%bs) 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) 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) { func ecbTransform(key, data []byte, op func(cipher.Block, []byte, []byte)) ([]byte, error) {
block, err := aes.NewCipher(key) block, err := aes.NewCipher(key)
if err != nil { if err != nil {
+50
View File
@@ -20,9 +20,18 @@ import (
const ( const (
gatewayBaseURL = "https://api.deezer.com/1.0/gateway.php" gatewayBaseURL = "https://api.deezer.com/1.0/gateway.php"
gatewayUserAgent = "Deezer/6.1.22.49 (Android; 9; Tablet; us) innotek GmbH VirtualBox" gatewayUserAgent = "Deezer/6.1.22.49 (Android; 9; Tablet; us) innotek GmbH VirtualBox"
// 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" 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 ( const (
deviceOS = "Android" deviceOS = "Android"
deviceName = "VirtualBox" deviceName = "VirtualBox"
@@ -39,11 +48,22 @@ type mobileClient struct {
sid string 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 { func CheckGatewayEnv() error {
_, _, err := gatewayEnv() _, _, err := gatewayEnv()
return err 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) { func gatewayEnv() (string, string, error) {
apiKey := os.Getenv("DEEZER_MOBILE_API_KEY") apiKey := os.Getenv("DEEZER_MOBILE_API_KEY")
gwKey := os.Getenv("DEEZER_MOBILE_GW_KEY") gwKey := os.Getenv("DEEZER_MOBILE_GW_KEY")
@@ -71,6 +91,14 @@ func newMobileClient() (*mobileClient, error) {
}, nil }, 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) { func (m *mobileClient) login(ctx context.Context, email, password string) (*Credentials, string, error) {
token, tokenKey, userKey, err := m.authenticate(ctx) token, tokenKey, userKey, err := m.authenticate(ctx)
if err != nil { 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 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) { func (m *mobileClient) authenticate(ctx context.Context) (string, string, string, error) {
body, err := m.gatewayRequest(ctx, "mobile_auth", http.MethodGet, "uniq_id", genUniqID(), nil) body, err := m.gatewayRequest(ctx, "mobile_auth", http.MethodGet, "uniq_id", genUniqID(), nil)
if err != nil { if err != nil {
@@ -132,6 +170,9 @@ func (m *mobileClient) authenticate(ctx context.Context) (string, string, string
return token, tokenKey, userKey, nil 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 { func (m *mobileClient) checkToken(ctx context.Context, token, tokenKey string) error {
encrypted, err := ecbEncrypt([]byte(tokenKey), []byte(token)) encrypted, err := ecbEncrypt([]byte(tokenKey), []byte(token))
if err != nil { if err != nil {
@@ -158,6 +199,12 @@ func (m *mobileClient) checkToken(ctx context.Context, token, tokenKey string) e
return nil 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) { func (m *mobileClient) userAuth(ctx context.Context, email, password, userKey string) (string, string, error) {
encryptedPassword, err := ecbEncrypt([]byte(userKey), zeroPad([]byte(password))) encryptedPassword, err := ecbEncrypt([]byte(userKey), zeroPad([]byte(password)))
if err != nil { if err != nil {
@@ -259,6 +306,9 @@ func (m *mobileClient) gatewayRequest(ctx context.Context, method, httpMethod, p
return io.ReadAll(resp.Body) 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 { func genUniqID() string {
b := make([]byte, 32) b := make([]byte, 32)
for i := range b { for i := range b {
+11
View File
@@ -2,6 +2,11 @@ package deezer
import "fmt" 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 type Kind string
const ( const (
@@ -26,6 +31,9 @@ func (k Kind) pageMethod() string {
return "" 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 { func (k Kind) idKey() string {
switch k { switch k {
case KindAlbum: case KindAlbum:
@@ -41,6 +49,9 @@ func (k Kind) idKey() string {
return "" 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) { func (k Kind) newResource() (Resource, error) {
switch k { switch k {
case KindAlbum: case KindAlbum:
+14
View File
@@ -6,6 +6,14 @@ import (
"fmt" "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) { func resolveARL(ctx context.Context, validate func(ctx context.Context, arl string) error) (string, error) {
creds, err := loadCredentials() creds, err := loadCredentials()
if err != nil { 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") 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) { func Login(ctx context.Context, email, password string) (string, string, error) {
client, err := newMobileClient() client, err := newMobileClient()
if err != nil { if err != nil {
+5
View File
@@ -18,6 +18,11 @@ type mediaError struct {
Message string `json:"message"` 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 { func (m *Media) URL() string {
return m.Data[0].Media[0].Sources[0].URL return m.Data[0].Media[0].Sources[0].URL
} }
+9
View File
@@ -1,5 +1,14 @@
package deezer 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 { type Resource interface {
Title() string Title() string
Tracks() []*Track Tracks() []*Track
+14
View File
@@ -11,6 +11,9 @@ import (
"time" "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") var ErrInvalidARL = errors.New("invalid or expired ARL cookie")
type Session struct { type Session struct {
@@ -20,6 +23,17 @@ type Session struct {
Premium bool 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) { func authenticate(ctx context.Context, arlCookie string) (*Session, error) {
jar, err := cookiejar.New(nil) jar, err := cookiejar.New(nil)
if err != nil { if err != nil {
+16
View File
@@ -14,12 +14,16 @@ type Contributors struct {
Authors []string `json:"author"` 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 { func (c *Contributors) UnmarshalJSON(data []byte) error {
if string(data) == "[]" { if string(data) == "[]" {
*c = Contributors{} *c = Contributors{}
return nil return nil
} }
// Alias drops the method set, so this Unmarshal does not recurse.
type Alias Contributors type Alias Contributors
aux := (*Alias)(c) aux := (*Alias)(c)
@@ -47,6 +51,10 @@ func (t *Track) FullTitle() string {
return t.Title 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 { func (t *Track) Filename(kind Kind, format string) string {
ext := "mp3" ext := "mp3"
switch format { 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 := fmt.Sprintf("%s%s - %s", prefix, t.Artist, t.FullTitle())
base, _ = filenamify.Filenamify(base, filenamify.Options{MaxLength: 255}) 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")) base = truncateBytes(base, 255-len(ext)-1-len("-id3v2"))
return base + "." + ext 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 { func truncateBytes(s string, maxLen int) string {
if maxLen <= 0 { if maxLen <= 0 {
return "" return ""
+24
View File
@@ -15,6 +15,15 @@ import (
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
) )
// songbpm.com publishes the tempo and key as prose rather than as structured
// data, so these match the surrounding sentence instead of a CSS selector.
// The attribute wildcards absorb the utility classes the site regenerates on
// every deploy, but the wording itself is load bearing: if the sentence
// changes, the lookup starts returning no data. The double space in modeRegex
// is present in the real markup and is not a typo.
//
// The key pattern accepts both the typographic accidentals the page renders
// and their ASCII equivalents, since which one appears varies by track.
var ( var (
bpmRegex = regexp.MustCompile(`tempo of <span[^>]*>(\d+) BPM`) bpmRegex = regexp.MustCompile(`tempo of <span[^>]*>(\d+) BPM`)
keyRegex = regexp.MustCompile(`with a <span[^>]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?)</span> key`) keyRegex = regexp.MustCompile(`with a <span[^>]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?)</span> key`)
@@ -35,6 +44,14 @@ func fetchBPM(ctx context.Context, httpClient *http.Client, artist, title, durat
return parseBPM(html) return parseBPM(html)
} }
// findTrackURL searches songbpm.com and returns the page for the track.
//
// Artist and title alone are not enough to identify a track, since the search
// happily returns remixes, live versions and covers under the same names.
// Duration is used as the tiebreaker, with a couple of seconds of tolerance
// to absorb the disagreement between Deezer's rounding and songbpm's. No
// match within tolerance is treated as not found rather than guessed at,
// because a wrong BPM is worse than a missing one.
func findTrackURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) { func findTrackURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) {
const rootURL = "https://songbpm.com" const rootURL = "https://songbpm.com"
@@ -133,6 +150,13 @@ func fetchBPMPage(ctx context.Context, httpClient *http.Client, pageURL string)
return string(body), nil return string(body), nil
} }
// parseBPM extracts the tempo and musical key from a track page.
//
// All three patterns must match: a page with a tempo but no key is treated as
// no data, since a half filled tag is not worth writing. Enharmonic keys are
// published as pairs like "C#/Db" and only the first spelling is kept, the
// accidentals are folded to ASCII for tag compatibility, and a minor mode is
// encoded with a trailing "m" to match the convention DJ software expects.
func parseBPM(html string) (bpmKey, error) { func parseBPM(html string) (bpmKey, error) {
bpmMatch := bpmRegex.FindStringSubmatch(html) bpmMatch := bpmRegex.FindStringSubmatch(html)
keyMatch := keyRegex.FindStringSubmatch(html) keyMatch := keyRegex.FindStringSubmatch(html)
+38
View File
@@ -1,3 +1,13 @@
// Package download drives the end to end download of a Deezer resource.
//
// Run fetches the resource, then walks its tracks in order: resolve a media
// source, decide whether the track can be skipped, stream and decrypt it,
// optionally convert to wav, write tags, and record the result in the store
// so a later run can skip it. Tracks are processed one at a time.
//
// Most per-track failures are collected as warnings rather than aborting the
// run, so an unavailable cover or a failed BPM lookup does not cost the user
// the rest of an album. Only context cancellation stops the loop early.
package download package download
import ( import (
@@ -32,6 +42,9 @@ func New(appConfig *config.Config, st *store.Store, kind deezer.Kind) *Downloade
} }
} }
// Run downloads every track of the resource identified by id. opts is
// expected to have passed Validate already, which the cmd package does while
// parsing flags.
func (d *Downloader) Run(ctx context.Context, opts Options, id string) error { func (d *Downloader) Run(ctx context.Context, opts Options, id string) error {
if err := d.initDeezerClient(ctx, opts); err != nil { if err := d.initDeezerClient(ctx, opts); err != nil {
return err return err
@@ -45,6 +58,14 @@ func (d *Downloader) Run(ctx context.Context, opts Options, id string) error {
return d.downloadAllTracks(ctx, resource, opts, outputDir) return d.downloadAllTracks(ctx, resource, opts, outputDir)
} }
// initDeezerClient authenticates and rejects quality settings the account
// cannot serve.
//
// The check runs against sourceQuality rather than the raw option because wav
// is produced locally from a flac source, so it carries the same premium
// requirement as flac. mp3_128 is the only format available without a
// subscription. Failing here keeps the user from watching a whole album
// download at a silently downgraded quality.
func (d *Downloader) initDeezerClient(ctx context.Context, opts Options) error { func (d *Downloader) initDeezerClient(ctx context.Context, opts Options) error {
var err error var err error
d.deezerClient, err = deezer.NewClient(ctx, d.appConfig.ARLCookie) d.deezerClient, err = deezer.NewClient(ctx, d.appConfig.ARLCookie)
@@ -59,6 +80,16 @@ func (d *Downloader) initDeezerClient(ctx context.Context, opts Options) error {
return nil return nil
} }
// prepareResource fetches the resource, applies the artist track limit, and
// makes sure the output directory exists.
//
// The limit only applies to artists because that is the one kind whose track
// list is unbounded: it is the artist's top tracks, not a finite album or
// playlist.
//
// Sweeping the part files last clears leftovers from a previous run that was
// killed mid-write. They are ignorable on their own, but they accumulate and
// would otherwise be mistaken for real downloads.
func (d *Downloader) prepareResource(ctx context.Context, id string, opts Options) (deezer.Resource, string, error) { func (d *Downloader) prepareResource(ctx context.Context, id string, opts Options) (deezer.Resource, string, error) {
resource, err := d.deezerClient.FetchResource(ctx, d.kind, id) resource, err := d.deezerClient.FetchResource(ctx, d.kind, id)
if err != nil { if err != nil {
@@ -86,6 +117,13 @@ func (d *Downloader) prepareResource(ctx context.Context, id string, opts Option
return resource, outputDir, nil return resource, outputDir, nil
} }
// downloadAllTracks runs the per-track pipeline over the whole resource and
// prints the summary.
//
// Cancellation is checked both before each track and against the result,
// because a track cancelled mid-stream surfaces the error through the result
// rather than through ctx. Any other per-track error is recorded and the loop
// continues.
func (d *Downloader) downloadAllTracks(ctx context.Context, resource deezer.Resource, opts Options, outputDir string) error { func (d *Downloader) downloadAllTracks(ctx context.Context, resource deezer.Resource, opts Options, outputDir string) error {
tracks := resource.Tracks() tracks := resource.Tracks()
startTime := time.Now() startTime := time.Now()
+21
View File
@@ -13,6 +13,13 @@ import (
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
) )
// Genres come from last.fm's community tags, which are free text and range
// from real genres to things like "seen live". These two lists are the filter
// that keeps only the useful ones. They are matched as substrings, so "deep
// house" is caught by "house".
//
// The split into two lists drives the ordering in filterTags, which prefers
// the electronic tag as the primary genre.
var electronicKeywords = toLower([]string{ var electronicKeywords = toLower([]string{
"Ambient", "Bass", "Big Room", "Breakbeat", "Dance", "Disco", "Downtempo", "Ambient", "Bass", "Big Room", "Breakbeat", "Dance", "Disco", "Downtempo",
"Drum And Bass", "Dub", "Dubstep", "EDM", "Electro", "Electronic", "Electronica", "Drum And Bass", "Dub", "Dubstep", "EDM", "Electro", "Electronic", "Electronica",
@@ -44,6 +51,9 @@ func fetchGenre(ctx context.Context, httpClient *http.Client, artist, title stri
return "", err return "", err
} }
// last.fm orders tags by popularity, so the first two are the consensus
// view. Taking more starts pulling in mood and era tags that make a poor
// genre field.
tags := parseGenreTags(doc) tags := parseGenreTags(doc)
if len(tags) > 2 { if len(tags) > 2 {
tags = tags[:2] tags = tags[:2]
@@ -76,6 +86,9 @@ func fetchGenrePage(ctx context.Context, httpClient *http.Client, pageURL string
return goquery.NewDocumentFromReader(resp.Body) return goquery.NewDocumentFromReader(resp.Body)
} }
// parseGenreTags reads the tag list out of a last.fm page. The selector
// tracks last.fm's current markup and is the first thing to break if they
// redesign; a failure here is non-fatal and simply leaves the genre unset.
func parseGenreTags(doc *goquery.Document) []string { func parseGenreTags(doc *goquery.Document) []string {
var tags []string var tags []string
doc.Find("ol.big-tags .big-tags-item-name a").Each(func(_ int, s *goquery.Selection) { doc.Find("ol.big-tags .big-tags-item-name a").Each(func(_ int, s *goquery.Selection) {
@@ -96,6 +109,11 @@ func matchesKeyword(tag string, keywords []string) bool {
return false return false
} }
// filterTags keeps only recognised genre tags, electronic ones first.
//
// It returns nothing at all unless at least one electronic tag matched, so a
// purely non-electronic track ends up with no genre rather than a partial
// one. Tags matching neither list are dropped.
func filterTags(tags []string) []string { func filterTags(tags []string) []string {
var electronic, nonElectronic []string var electronic, nonElectronic []string
@@ -113,6 +131,9 @@ func filterTags(tags []string) []string {
return electronic return electronic
} }
// formatTags title cases the tags and joins them for the genre field.
// last.fm tags arrive in whatever case the tagger typed, so they are
// normalised rather than written through as is.
func formatTags(tags []string) string { func formatTags(tags []string) string {
formatted := make([]string, 0, len(tags)) formatted := make([]string, 0, len(tags))
for _, tag := range tags { for _, tag := range tags {
+15
View File
@@ -25,10 +25,19 @@ func hashFile(path string) (string, error) {
return hex.EncodeToString(h.Sum(nil)), nil return hex.EncodeToString(h.Sum(nil)), nil
} }
// hashIndex maps content hash to path for everything under the output
// directory. It is what lets a moved or renamed file still be recognised as
// an existing download.
type hashIndex struct { type hashIndex struct {
files map[string]string files map[string]string
} }
// newHashIndex hashes every file under root.
//
// Unreadable files and directories are skipped rather than failing the walk,
// since a permission error somewhere in a music library should not break the
// skip check. Only cancellation aborts it. Duplicated content collapses to
// whichever path is walked last, which is fine: any copy is a valid answer.
func newHashIndex(ctx context.Context, root string) (*hashIndex, error) { func newHashIndex(ctx context.Context, root string) (*hashIndex, error) {
index := &hashIndex{files: make(map[string]string)} index := &hashIndex{files: make(map[string]string)}
@@ -60,6 +69,12 @@ func (h *hashIndex) find(hash string) (string, bool) {
return path, ok return path, ok
} }
// initHashIndex builds the index on first use and reuses it afterwards.
//
// Building it means hashing an entire music library, so it is deferred until
// something actually needs it: a run where every recorded path is still valid
// never pays that cost. The error is cached alongside the index so a failed
// build is not retried once per track.
func (d *Downloader) initHashIndex(ctx context.Context) error { func (d *Downloader) initHashIndex(ctx context.Context) error {
d.hashIndexOnce.Do(func() { d.hashIndexOnce.Do(func() {
d.hashIndex, d.hashIndexErr = newHashIndex(ctx, d.appConfig.OutputDir) d.hashIndex, d.hashIndexErr = newHashIndex(ctx, d.appConfig.OutputDir)
+8
View File
@@ -20,6 +20,14 @@ type metadataResult struct {
warnings []string warnings []string
} }
// fetchMetadata looks up BPM, key and genre from third party sites, running
// the two lookups concurrently since neither depends on the other.
//
// Both channels are buffered so a goroutine whose result is never collected
// still exits instead of blocking forever. Failures become warnings rather
// than errors: these are nice to have tags, and a site being down should not
// cost the user the track. Cancellation is silent, because the run is already
// being torn down and a warning per track would just be noise.
func fetchMetadata(ctx context.Context, httpClient *http.Client, track *deezer.Track, opts Options) metadataResult { func fetchMetadata(ctx context.Context, httpClient *http.Client, track *deezer.Track, opts Options) metadataResult {
if !opts.BPM && !opts.Genre { if !opts.BPM && !opts.Genre {
return metadataResult{} return metadataResult{}
+6
View File
@@ -24,6 +24,9 @@ type Options struct {
Strict bool Strict bool
} }
// sourceQuality is the quality to request from Deezer, which is not always
// the quality the user asked for. Deezer does not serve wav, so a wav
// download pulls flac and converts it locally.
func (o *Options) sourceQuality() string { func (o *Options) sourceQuality() string {
if o.Quality == "wav" { if o.Quality == "wav" {
return "flac" return "flac"
@@ -35,6 +38,9 @@ func (o *Options) convertsToWAV() bool {
return o.Quality == "wav" return o.Quality == "wav"
} }
// Validate checks the options against the resource kind. The limit is only
// meaningful for artists, whose top track list is open ended, and is capped
// at 100 because that is as many as Deezer returns.
func (o *Options) Validate(kind deezer.Kind) error { func (o *Options) Validate(kind deezer.Kind) error {
if !validQualities[o.Quality] { if !validQualities[o.Quality] {
return fmt.Errorf("invalid quality option: %s", o.Quality) return fmt.Errorf("invalid quality option: %s", o.Quality)
+2
View File
@@ -112,6 +112,8 @@ Files saved to: %s
} }
} }
// showSupportMessage nudges the user to star the repository, but only on
// roughly one run in ten.
func (*progressTracker) showSupportMessage() { func (*progressTracker) showSupportMessage() {
if rand.Float64() < 0.1 { if rand.Float64() < 0.1 {
fmt.Println("\n⭐ Enjoying GoDeez? Star it on GitHub: https://github.com/mathismqn/godeez") fmt.Println("\n⭐ Enjoying GoDeez? Star it on GitHub: https://github.com/mathismqn/godeez")
+12
View File
@@ -6,6 +6,18 @@ import (
"github.com/mathismqn/godeez/internal/fsutil" "github.com/mathismqn/godeez/internal/fsutil"
) )
// shouldSkipDownload reports whether trackID has already been downloaded at
// mediaFormat, returning the path of the existing file.
//
// A recorded download at a different quality is not a skip: asking for flac
// after previously fetching mp3_128 should download again.
//
// When the recorded path is gone the file may simply have been moved or
// renamed by the user, so the content hash is used to look for it elsewhere
// under the output directory before giving up. A match repairs the stored
// path, which keeps the ledger useful across library reorganisations. That
// lookup is best effort throughout: every failure falls through to
// downloading again, which is always safe.
func (d *Downloader) shouldSkipDownload(ctx context.Context, trackID, mediaFormat string) (string, bool) { func (d *Downloader) shouldSkipDownload(ctx context.Context, trackID, mediaFormat string) (string, bool) {
existing, err := d.store.DownloadInfo(trackID) existing, err := d.store.DownloadInfo(trackID)
if err != nil || existing.Quality != mediaFormat { if err != nil || existing.Quality != mediaFormat {
+28
View File
@@ -11,8 +11,14 @@ import (
"github.com/mathismqn/godeez/internal/fsutil" "github.com/mathismqn/godeez/internal/fsutil"
) )
// chunkSize is the stripe width Deezer encrypts with. It is fixed by the
// BF_CBC_STRIPE cipher named in the media request and is not tunable: reading
// in any other unit would misalign the stripe pattern and corrupt the output.
const chunkSize = 2048 const chunkSize = 2048
// sweepPartFiles deletes leftover .part files in dir. Failures are ignored
// because this is opportunistic cleanup, and refusing to download because a
// stale temp file could not be removed would be worse than leaving it.
func sweepPartFiles(dir string) { func sweepPartFiles(dir string) {
matches, err := filepath.Glob(filepath.Join(dir, fsutil.PartPattern)) matches, err := filepath.Glob(filepath.Join(dir, fsutil.PartPattern))
if err != nil { if err != nil {
@@ -23,6 +29,11 @@ func sweepPartFiles(dir string) {
} }
} }
// streamToFile writes the decrypted stream to outputPath.
//
// The download lands in a temporary file first and is only renamed into place
// once it is complete, so an interrupted run never leaves a truncated file
// sitting at the real path where it would look like a finished download.
func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error { func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error {
tmpPath, err := d.streamToTempFile(ctx, stream, filepath.Dir(outputPath), key) tmpPath, err := d.streamToTempFile(ctx, stream, filepath.Dir(outputPath), key)
if err != nil { if err != nil {
@@ -37,6 +48,12 @@ func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, out
return nil return nil
} }
// streamToTempFile decrypts the stream into a .part file in dir and returns
// its path. The caller owns the file from that point on. It closes stream.
//
// The temp file is created in the destination directory rather than the
// system temp dir so the caller's rename stays on one filesystem and is
// therefore atomic.
func (d *Downloader) streamToTempFile(ctx context.Context, stream io.ReadCloser, dir string, key []byte) (string, error) { func (d *Downloader) streamToTempFile(ctx context.Context, stream io.ReadCloser, dir string, key []byte) (string, error) {
defer stream.Close() defer stream.Close()
@@ -45,6 +62,8 @@ func (d *Downloader) streamToTempFile(ctx context.Context, stream io.ReadCloser,
return "", err return "", err
} }
tmpPath := file.Name() tmpPath := file.Name()
// done stays false until the file is fully written and closed, so every
// early return below removes the partial file instead of orphaning it.
done := false done := false
defer func() { defer func() {
if !done { if !done {
@@ -61,6 +80,10 @@ func (d *Downloader) streamToTempFile(ctx context.Context, stream io.ReadCloser,
default: default:
} }
// Read until the chunk is full rather than trusting a single Read.
// A short read is legal and common on a network stream, and treating
// one as a chunk boundary would shift every following chunk out of
// step with the stripe pattern.
totalRead := 0 totalRead := 0
for totalRead < chunkSize { for totalRead < chunkSize {
n, err := stream.Read(buffer[totalRead:]) n, err := stream.Read(buffer[totalRead:])
@@ -77,6 +100,11 @@ func (d *Downloader) streamToTempFile(ctx context.Context, stream io.ReadCloser,
break break
} }
// Deezer encrypts only every third chunk and leaves the other two in
// the clear, which is what BF_CBC_STRIPE means. A trailing partial
// chunk is never encrypted even when its index is a multiple of three,
// hence the length check: decrypting it would corrupt the end of the
// file.
if chunk%3 == 0 && totalRead == chunkSize { if chunk%3 == 0 && totalRead == chunkSize {
buffer, err = deezer.DecryptBlowfish(buffer, key) buffer, err = deezer.DecryptBlowfish(buffer, key)
if err != nil { if err != nil {
+30
View File
@@ -15,6 +15,20 @@ import (
"github.com/mathismqn/godeez/internal/tag" "github.com/mathismqn/godeez/internal/tag"
) )
// downloadTrack runs the whole pipeline for one track and reports the outcome
// rather than returning an error, so the caller can keep going.
//
// Ordering matters here. The format is resolved before the skip check,
// because whether a track counts as already downloaded depends on the format
// that will actually be written, which is not always the one requested. The
// external metadata lookup is started concurrently and collected late, since
// it hits third party sites and is the slowest part of the pipeline while
// also being the least important. Tagging and the store write happen last, in
// finalizeDownload, once the file is known to be complete.
//
// Only cancellation and a failure to produce the audio itself are fatal.
// Everything else, including a missing cover or a quality downgrade, is
// reported as a warning.
func (d *Downloader) downloadTrack(ctx context.Context, resource deezer.Resource, track *deezer.Track, opts Options, outputDir string) downloadResult { func (d *Downloader) downloadTrack(ctx context.Context, resource deezer.Resource, track *deezer.Track, opts Options, outputDir string) downloadResult {
media, err := d.deezerClient.FetchMedia(ctx, track, opts.sourceQuality()) media, err := d.deezerClient.FetchMedia(ctx, track, opts.sourceQuality())
if err != nil { if err != nil {
@@ -82,6 +96,9 @@ func (d *Downloader) downloadTrack(ctx context.Context, resource deezer.Resource
metadata := <-metadataChan metadata := <-metadataChan
// Cancellation between the write and the tagging leaves a complete but
// untagged file. Removing it keeps a cancelled run from being mistaken
// for a finished one, and nothing has been recorded in the store yet.
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
fsutil.Remove(outputPath) fsutil.Remove(outputPath)
return downloadResult{err: err} return downloadResult{err: err}
@@ -93,6 +110,12 @@ func (d *Downloader) downloadTrack(ctx context.Context, resource deezer.Resource
return downloadResult{warnings: warnings} return downloadResult{warnings: warnings}
} }
// uniqueOutputPath avoids clobbering an unrelated file by appending " (2)",
// " (3)" and so on until the name is free.
//
// The file this track already owns according to the store is exempt: a
// re-download of the same track should overwrite its own output rather than
// pile up numbered copies next to it.
func (d *Downloader) uniqueOutputPath(trackID, path string) string { func (d *Downloader) uniqueOutputPath(trackID, path string) string {
owned := "" owned := ""
if info, err := d.store.DownloadInfo(trackID); err == nil { if info, err := d.store.DownloadInfo(trackID); err == nil {
@@ -109,6 +132,13 @@ func (d *Downloader) uniqueOutputPath(trackID, path string) string {
return candidate return candidate
} }
// finalizeDownload tags the finished file and records it in the store,
// returning any non-fatal problems as warnings.
//
// The hash is taken after tagging so it matches the bytes actually on disk,
// which is what the skip check later compares against. The download is
// recorded even when tagging or hashing failed: the audio is there, and
// refusing to record it would mean downloading it all over again next time.
func (d *Downloader) finalizeDownload(resource deezer.Resource, track *deezer.Track, outputPath, outputFormat, genre string, cover []byte, bpmKey bpmKey) []string { func (d *Downloader) finalizeDownload(resource deezer.Resource, track *deezer.Track, outputPath, outputFormat, genre string, cover []byte, bpmKey bpmKey) []string {
var warnings []string var warnings []string
+10
View File
@@ -1,3 +1,4 @@
// Package fsutil holds the few filesystem helpers shared across godeez.
package fsutil package fsutil
import ( import (
@@ -5,8 +6,14 @@ import (
"os" "os"
) )
// PartPattern names in-progress downloads. It is both an os.CreateTemp
// pattern and a glob, which is what lets the downloader sweep away leftovers
// from an interrupted run. The leading dot hides them from file managers, and
// the suffix keeps them from being mistaken for finished audio.
const PartPattern = ".godeez-*.part" const PartPattern = ".godeez-*.part"
// EnsureDir creates path if it does not exist. An existing non-directory at
// that path is an error rather than something to overwrite.
func EnsureDir(path string) error { func EnsureDir(path string) error {
info, err := os.Stat(path) info, err := os.Stat(path)
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -21,6 +28,9 @@ func EnsureDir(path string) error {
return nil return nil
} }
// Exists reports whether path is an existing regular file. A directory is
// deliberately not "exists" here: every caller is asking about a file it
// intends to read, write or delete.
func Exists(path string) bool { func Exists(path string) bool {
info, err := os.Stat(path) info, err := os.Stat(path)
return err == nil && !info.IsDir() return err == nil && !info.IsDir()
+7
View File
@@ -9,6 +9,10 @@ import (
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
) )
// DownloadInfo records one completed download. Quality is stored so that
// re-requesting the same track at a higher quality is not mistaken for a
// duplicate, and Hash lets a file that has since been moved or renamed still
// be recognised.
type DownloadInfo struct { type DownloadInfo struct {
TrackID string `json:"song_id"` TrackID string `json:"song_id"`
Quality string `json:"quality"` Quality string `json:"quality"`
@@ -19,6 +23,9 @@ type DownloadInfo struct {
var trackBucket = []byte("tracks") var trackBucket = []byte("tracks")
// DownloadInfo returns the record for trackID. A track that has never been
// downloaded is reported as an error rather than a nil result, and callers
// treat any error the same way: download it.
func (s *Store) DownloadInfo(trackID string) (*DownloadInfo, error) { func (s *Store) DownloadInfo(trackID string) (*DownloadInfo, error) {
var info DownloadInfo var info DownloadInfo
+11
View File
@@ -1,3 +1,9 @@
// Package store keeps the ledger of what has already been downloaded, so a
// repeated run can skip tracks instead of fetching them again.
//
// It is a bbolt database written as a hidden file inside the output
// directory, which keeps it travelling with the music library it describes.
// Losing it is harmless: the worst outcome is re-downloading.
package store package store
import ( import (
@@ -14,6 +20,11 @@ type Store struct {
db *bbolt.DB db *bbolt.DB
} }
// Open opens the ledger in dir, creating it if needed.
//
// bbolt takes an exclusive file lock, so a second godeez running against the
// same output directory blocks here. The timeout turns that into a clear
// message rather than an apparent hang.
func Open(dir string) (*Store, error) { func Open(dir string) (*Store, error) {
db, err := bbolt.Open(filepath.Join(dir, ".tracks.db"), 0600, &bbolt.Options{Timeout: 5 * time.Second}) db, err := bbolt.Open(filepath.Join(dir, ".tracks.db"), 0600, &bbolt.Options{Timeout: 5 * time.Second})
if err != nil { if err != nil {
+10
View File
@@ -45,6 +45,9 @@ func (t *flacTagger) write(m Metadata) error {
t.addTag("INITIALKEY", m.Key) t.addTag("INITIALKEY", m.Key)
cmtsMeta := t.cmts.Marshal() cmtsMeta := t.cmts.Marshal()
// index 0 means no comment block was found: a valid flac always starts
// with STREAMINFO, so a real Vorbis comment can never be the first block.
// Anything else is the index of the block being replaced.
if t.index > 0 { if t.index > 0 {
t.file.Meta[t.index] = &cmtsMeta t.file.Meta[t.index] = &cmtsMeta
} else { } else {
@@ -66,6 +69,13 @@ func (t *flacTagger) write(m Metadata) error {
return os.Rename(tmpPath, t.path) return os.Rename(tmpPath, t.path)
} }
// addTag appends a Vorbis comment. Vorbis allows repeated keys, so this adds
// to whatever the file already had rather than replacing it; re-tagging a
// file that was already tagged would therefore duplicate entries. That does
// not arise in practice because godeez only tags files it just downloaded.
//
// The key is written twice for the musical key: KEY is the common spelling
// and INITIALKEY is what several DJ applications look for.
func (t *flacTagger) addTag(name, value string) { func (t *flacTagger) addTag(name, value string) {
if value != "" { if value != "" {
t.cmts.Add(name, value) t.cmts.Add(name, value)
+19
View File
@@ -1,3 +1,13 @@
// Package tag writes track metadata into finished audio files.
//
// Every container stores metadata differently: mp3 uses ID3v2 frames, flac
// uses Vorbis comments, and wav carries an ID3 chunk plus a RIFF LIST/INFO
// chunk for players that read only one of the two. Write hides that behind a
// single Metadata struct and dispatches on the file extension.
//
// The taggers are written for freshly downloaded files. Empty fields are
// skipped rather than written as blanks, and each tagger writes through a
// temporary file so a failure part way cannot corrupt the audio.
package tag package tag
import ( import (
@@ -8,6 +18,8 @@ import (
"github.com/go-flac/go-flac/v2" "github.com/go-flac/go-flac/v2"
) )
// AlbumMetadata is the subset of tags that only make sense for a track that
// belongs to an album. It is nil on a standalone single.
type AlbumMetadata struct { type AlbumMetadata struct {
Artist string Artist string
Title string Title string
@@ -18,6 +30,9 @@ type AlbumMetadata struct {
Copyright string Copyright string
} }
// Metadata is the container-independent tag set. Every field is a string
// because the underlying formats store them as text; conversions such as
// Duration to milliseconds happen inside the individual taggers.
type Metadata struct { type Metadata struct {
Title string Title string
Artists string Artists string
@@ -38,6 +53,10 @@ type tagger interface {
write(m Metadata) error write(m Metadata) error
} }
// newTagger picks an implementation from the file extension. Anything that is
// not mp3 or wav is attempted as flac rather than rejected, so an unexpected
// extension fails with a parse error from the flac library instead of a
// generic unsupported-format message.
func newTagger(filePath string) (tagger, error) { func newTagger(filePath string) (tagger, error) {
switch filepath.Ext(filePath) { switch filepath.Ext(filePath) {
case ".mp3": case ".mp3":
+51
View File
@@ -25,6 +25,12 @@ type infoField struct {
value string value string
} }
// write replaces the metadata chunks in a wav file.
//
// Both a LIST/INFO chunk and an id3 chunk are written because wav has no
// single agreed metadata convention: older players and file managers read
// LIST/INFO, while music libraries and DJ software expect ID3. Writing only
// one leaves the tags invisible to half the tools people use.
func (t *wavTagger) write(m Metadata) error { func (t *wavTagger) write(m Metadata) error {
id3Chunk, err := buildID3Chunk(m) id3Chunk, err := buildID3Chunk(m)
if err != nil { if err != nil {
@@ -57,6 +63,15 @@ func buildID3Chunk(m Metadata) ([]byte, error) {
return buf.Bytes(), nil return buf.Bytes(), nil
} }
// buildInfoChunk assembles the LIST/INFO payload, or nil when there is
// nothing worth writing.
//
// The four character ids are the RIFF INFO registry's, not arbitrary names.
// INFO only has a year field, so a full release date is reduced to its year.
// Values are NUL terminated because RIFF INFO strings are C strings.
//
// A payload of exactly 4 bytes is just the "INFO" marker with no fields
// after it, which is why that length means empty.
func buildInfoChunk(m Metadata) []byte { func buildInfoChunk(m Metadata) []byte {
fields := []infoField{ fields := []infoField{
{"INAM", m.Title}, {"INAM", m.Title},
@@ -96,6 +111,12 @@ func buildInfoChunk(m Metadata) []byte {
return buf.Bytes() return buf.Bytes()
} }
// writeChunk writes one RIFF chunk: a four character id, the payload length
// as a little endian uint32, then the payload.
//
// RIFF requires chunks to start on even offsets, so an odd length is followed
// by a pad byte. That byte is not counted in the declared size, which is the
// detail that makes chunk walking fiddly; see skipPad for the reading side.
func writeChunk(w io.Writer, id string, payload []byte) { func writeChunk(w io.Writer, id string, payload []byte) {
header := make([]byte, 0, 8) header := make([]byte, 0, 8)
header = append(header, id...) header = append(header, id...)
@@ -108,6 +129,16 @@ func writeChunk(w io.Writer, id string, payload []byte) {
} }
} }
// rewriteWAV copies path into a new file, dropping any existing metadata
// chunks, appending the given ones, and swapping the result into place.
//
// A wav file cannot be edited in place: chunk sizes and the RIFF size in the
// header would all have to shift. Rewriting is simpler and, combined with the
// rename at the end, means an interrupted tag write leaves the original
// untouched.
//
// The RIFF size field is patched at offset 4 only after everything is written,
// since the final size is not known until then.
func rewriteWAV(path string, chunks []wavChunk) error { func rewriteWAV(path string, chunks []wavChunk) error {
src, err := os.Open(path) src, err := os.Open(path)
if err != nil { if err != nil {
@@ -174,7 +205,21 @@ func rewriteWAV(path string, chunks []wavChunk) error {
return nil return nil
} }
// copyChunks streams every chunk from src to dst except the metadata ones,
// and returns the byte count that belongs in the RIFF size field.
//
// Dropping the existing id3 and LIST/INFO chunks here is what makes tagging
// repeatable: the caller appends fresh ones, so tags are replaced rather than
// accumulated. A LIST chunk that is not an INFO list is something else
// entirely, such as an adtl annotation list, and is preserved.
//
// A truncated final chunk is treated as the end of the file rather than an
// error, because trailing garbage after the audio data is common and should
// not make the file untaggable.
func copyChunks(dst io.Writer, src io.Reader) (int64, error) { func copyChunks(dst io.Writer, src io.Reader) (int64, error) {
// The count starts at 4 for the "WAVE" id, which sits inside the RIFF
// chunk and so counts towards its size, while the 8 byte RIFF header
// itself does not.
size := int64(4) size := int64(4)
head := make([]byte, 8) head := make([]byte, 8)
@@ -245,6 +290,12 @@ func skipPayload(src io.Reader, payloadSize int64) error {
return skipPad(src, payloadSize) return skipPad(src, payloadSize)
} }
// skipPad consumes the pad byte that follows an odd length chunk. It is not
// included in the chunk's declared size, so skipping it is what keeps the
// reader aligned on the next chunk header.
//
// A missing pad byte at the very end of the file is tolerated: some encoders
// omit it on the last chunk.
func skipPad(src io.Reader, payloadSize int64) error { func skipPad(src io.Reader, payloadSize int64) error {
if payloadSize%2 == 0 { if payloadSize%2 == 0 {
return nil return nil
+40
View File
@@ -16,6 +16,11 @@ import (
"github.com/mathismqn/godeez/internal/fsutil" "github.com/mathismqn/godeez/internal/fsutil"
) )
// managedPrefixes are install roots owned by a package manager. Overwriting a
// binary there would leave the package manager's database describing a file
// that no longer matches, and its next upgrade would silently revert the
// self-update. Users on these installs are pointed back at their package
// manager instead.
var managedPrefixes = []string{ var managedPrefixes = []string{
"/nix/store", "/nix/store",
"/opt/homebrew", "/opt/homebrew",
@@ -25,6 +30,14 @@ var managedPrefixes = []string{
"/var/lib/flatpak", "/var/lib/flatpak",
} }
// resolveTarget returns the binary that should be replaced, or an error
// explaining why self-updating is not appropriate here.
//
// Symlinks are resolved first so the real file is replaced rather than the
// link: package managers commonly expose a binary through a symlink, and
// following it is what makes the managed prefix check meaningful. A build
// that was not produced by a release is refused outright, since there is no
// version to compare against.
func resolveTarget() (string, error) { func resolveTarget() (string, error) {
if buildinfo.IsDev() { if buildinfo.IsDev() {
return "", fmt.Errorf("development build cannot self-update; install a release from https://github.com/%s/%s/releases", return "", fmt.Errorf("development build cannot self-update; install a release from https://github.com/%s/%s/releases",
@@ -56,6 +69,10 @@ func CheckUpdatable() error {
return err return err
} }
// checkWritable proves the install directory is writable by actually creating
// and removing a file there. Inspecting permission bits would not account for
// read-only mounts or the platform's own rules, and finding out only after
// the download has finished wastes the user's time.
func checkWritable(dir string) error { func checkWritable(dir string) error {
f, err := os.CreateTemp(dir, tmpPattern) f, err := os.CreateTemp(dir, tmpPattern)
if err != nil { if err != nil {
@@ -74,6 +91,14 @@ func checkWritable(dir string) error {
return nil return nil
} }
// Apply downloads release and replaces the running binary with it.
//
// The order of these steps is the safety property. The expected checksum is
// fetched before the asset, so a release that does not publish one fails
// before anything is downloaded. The download lands in a temporary file in
// the install directory, which keeps the final rename on the same filesystem
// and therefore atomic. The binary is only replaced after the checksum
// matches, so a corrupted or tampered download can never be executed.
func (u *Updater) Apply(ctx context.Context, release *Release) error { func (u *Updater) Apply(ctx context.Context, release *Release) error {
target, err := resolveTarget() target, err := resolveTarget()
if err != nil { if err != nil {
@@ -134,6 +159,10 @@ func (u *Updater) fetchChecksum(ctx context.Context, release *Release, assetName
return parseChecksums(io.LimitReader(body, maxResponseSize), assetName) return parseChecksums(io.LimitReader(body, maxResponseSize), assetName)
} }
// parseChecksums finds the digest for name in a sha256sum style file.
//
// The optional "*" before the filename is the marker sha256sum uses for
// binary mode and is not part of the name.
func parseChecksums(r io.Reader, name string) (string, error) { func parseChecksums(r io.Reader, name string) (string, error) {
scanner := bufio.NewScanner(r) scanner := bufio.NewScanner(r)
for scanner.Scan() { for scanner.Scan() {
@@ -152,6 +181,9 @@ func parseChecksums(r io.Reader, name string) (string, error) {
return "", fmt.Errorf("no checksum listed for %s", name) return "", fmt.Errorf("no checksum listed for %s", name)
} }
// download writes asset to a temporary file in dir and returns its path and
// sha256. The hash is computed while streaming, so the file is never read a
// second time and never has to be held in memory.
func (u *Updater) download(ctx context.Context, dir string, asset Asset) (string, string, error) { func (u *Updater) download(ctx context.Context, dir string, asset Asset) (string, string, error) {
ctx, cancel := context.WithTimeout(ctx, downloadTimeout) ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
defer cancel() defer cancel()
@@ -184,6 +216,14 @@ func (u *Updater) download(ctx context.Context, dir string, asset Asset) (string
return tmp, hex.EncodeToString(hash.Sum(nil)), nil return tmp, hex.EncodeToString(hash.Sum(nil)), nil
} }
// replaceBinary swaps the new binary into place.
//
// Unix lets a running executable be renamed over, so a single atomic rename
// is enough. Windows locks the file of a running process, so the current
// binary has to be moved aside first, which leaves a window where the target
// does not exist; if installing the replacement then fails, the old one is
// moved back. The .old file is removed on the next update rather than
// immediately, since it is still locked while this process runs.
func (u *Updater) replaceBinary(target, tmp string) error { func (u *Updater) replaceBinary(target, tmp string) error {
if runtime.GOOS != "windows" { if runtime.GOOS != "windows" {
return os.Rename(tmp, target) return os.Rename(tmp, target)
+25
View File
@@ -14,7 +14,13 @@ import (
const noCheckEnv = "GODEEZ_NO_UPDATE_CHECK" const noCheckEnv = "GODEEZ_NO_UPDATE_CHECK"
const ( const (
// cacheTTL keeps the check to roughly once a day, which is often enough
// to notice a release without hitting the GitHub API on every command.
cacheTTL = 24 * time.Hour cacheTTL = 24 * time.Hour
// checkTimeout is deliberately short. The check is a courtesy running
// alongside a download, so it gives up quickly rather than delaying
// anything the user actually asked for.
checkTimeout = 3 * time.Second checkTimeout = 3 * time.Second
) )
@@ -32,6 +38,9 @@ func cachePath() (string, error) {
return filepath.Join(dir, "godeez", "update.json"), nil return filepath.Join(dir, "godeez", "update.json"), nil
} }
// readCache returns the cached result, or false if there is nothing usable.
// Every failure, including a corrupt or unreadable file, is reported the same
// way: the caller simply checks again, so there is nothing to distinguish.
func readCache() (cacheEntry, bool) { func readCache() (cacheEntry, bool) {
path, err := cachePath() path, err := cachePath()
if err != nil { if err != nil {
@@ -71,6 +80,11 @@ func writeCache(version string) error {
return os.WriteFile(path, data, 0644) return os.WriteFile(path, data, 0644)
} }
// check returns the latest version if it is newer than the running one, or
// "" if it is not. The cache is written even when the release turns out not
// to be newer, since the point is to record that GitHub was asked recently,
// and a failure to write it is ignored: an uncacheable check still works, it
// just repeats.
func check(ctx context.Context) (string, error) { func check(ctx context.Context) (string, error) {
if entry, ok := readCache(); ok { if entry, ok := readCache(); ok {
return latestIfNewer(entry.LatestVersion), nil return latestIfNewer(entry.LatestVersion), nil
@@ -95,6 +109,17 @@ func latestIfNewer(latest string) string {
return "" return ""
} }
// StartCheck begins a background update check and returns a channel that
// yields the newer version, if there is one, and is closed either way.
//
// It runs concurrently so the check never delays the command the user ran,
// and the channel is buffered so the goroutine exits even if nobody reads the
// result. Errors are swallowed: a failed check is not something to report.
//
// The check is skipped entirely for development builds, which have no version
// to compare, and whenever GODEEZ_NO_UPDATE_CHECK is set, which is the escape
// hatch for packagers and offline use. Both cases close the channel
// immediately so callers need no special handling.
func StartCheck(ctx context.Context) <-chan string { func StartCheck(ctx context.Context) <-chan string {
ch := make(chan string, 1) ch := make(chan string, 1)
+10
View File
@@ -14,6 +14,10 @@ const (
repoName = "godeez" repoName = "godeez"
latestReleaseURL = "https://api.github.com/repos/" + repoOwner + "/" + repoName + "/releases/latest" latestReleaseURL = "https://api.github.com/repos/" + repoOwner + "/" + repoName + "/releases/latest"
checksumsAsset = "checksums.txt" checksumsAsset = "checksums.txt"
// maxResponseSize caps what is read from GitHub, so a malformed or
// hostile response cannot exhaust memory. Release JSON and the checksums
// file are both a few kilobytes.
maxResponseSize = 1 << 20 maxResponseSize = 1 << 20
) )
@@ -67,6 +71,12 @@ func (r *Release) asset(name string) (Asset, bool) {
return Asset{}, false return Asset{}, false
} }
// assetForRuntime finds the release asset for the current platform.
//
// The name is reconstructed from the goreleaser naming template rather than
// discovered, so this has to stay in step with the name_template in
// .goreleaser.yaml: a change there breaks self-update for everyone already
// running an older build.
func (r *Release) assetForRuntime() (Asset, error) { func (r *Release) assetForRuntime() (Asset, error) {
name := fmt.Sprintf("%s_%s_%s_%s", repoName, r.Version(), runtime.GOOS, runtime.GOARCH) name := fmt.Sprintf("%s_%s_%s_%s", repoName, r.Version(), runtime.GOOS, runtime.GOARCH)
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
+15
View File
@@ -1,3 +1,10 @@
// Package update handles both halves of keeping godeez current: the passive
// background check that tells the user a newer release exists, and the
// `godeez update` command that installs it.
//
// Releases come from the GitHub releases API. Downloads are verified against
// the published checksums file before anything replaces the running binary,
// and installs owned by a package manager are refused rather than overwritten.
package update package update
import ( import (
@@ -11,8 +18,12 @@ import (
) )
const ( const (
// Timeouts are per request rather than for the whole operation, so a slow
// but progressing download is not killed part way. The generous download
// timeout covers a binary of a few tens of megabytes on a poor connection.
apiTimeout = 30 * time.Second apiTimeout = 30 * time.Second
downloadTimeout = 5 * time.Minute downloadTimeout = 5 * time.Minute
tmpPattern = ".godeez-update-*" tmpPattern = ".godeez-update-*"
) )
@@ -21,6 +32,10 @@ type Updater struct {
Out io.Writer Out io.Writer
} }
// New returns an Updater that reports nothing. Callers that want the step by
// step progress, such as the update command, set Out themselves; the
// background check leaves it discarding so it cannot write over the download
// output.
func New() *Updater { func New() *Updater {
return &Updater{ return &Updater{
client: &http.Client{}, client: &http.Client{},
+9
View File
@@ -10,6 +10,10 @@ func trimV(v string) string {
return strings.TrimPrefix(strings.TrimSpace(v), "v") return strings.TrimPrefix(strings.TrimSpace(v), "v")
} }
// canonical normalises a version for comparison, returning "" if it is not
// valid semver. Tags carry a leading "v" and buildinfo reports versions
// without one, so the prefix is added back before validating rather than
// requiring callers to agree on a spelling.
func canonical(v string) string { func canonical(v string) string {
v = strings.TrimSpace(v) v = strings.TrimSpace(v)
if v == "" { if v == "" {
@@ -25,6 +29,11 @@ func canonical(v string) string {
return v return v
} }
// IsNewer reports whether latest is a strictly newer release than current.
//
// An unparseable version on either side yields false rather than an error or
// a guess: this decides whether to nag the user about an update, and staying
// quiet is the right failure mode when the comparison is meaningless.
func IsNewer(current, latest string) bool { func IsNewer(current, latest string) bool {
c, l := canonical(current), canonical(latest) c, l := canonical(current), canonical(latest)
if c == "" || l == "" { if c == "" || l == "" {
+8
View File
@@ -1,3 +1,5 @@
// Command godeez downloads music from Deezer. See the cmd package for the
// command line surface and the internal packages for the download pipeline.
package main package main
import ( import (
@@ -9,9 +11,15 @@ import (
) )
func main() { func main() {
// The interrupt-cancelled context is threaded through every network call
// and file write, so Ctrl-C unwinds the download cleanly and leaves no
// partial files behind rather than killing the process mid-write.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop() defer stop()
// Nothing is printed here because cobra has already reported the error.
// stop is called explicitly since the deferred call would not run before
// os.Exit.
if err := cmd.Execute(ctx); err != nil { if err := cmd.Execute(ctx); err != nil {
stop() stop()
os.Exit(1) os.Exit(1)