diff --git a/cmd/download.go b/cmd/download.go index 09dc29c..dbb6b16 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -38,6 +38,16 @@ func newDownloadCmd() *cobra.Command { 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 { cmd := &cobra.Command{ Use: fmt.Sprintf("%s <%s_id>", kind, kind), diff --git a/cmd/login.go b/cmd/login.go index a120dfa..8cac8cc 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -49,6 +49,16 @@ func runLogin(ctx context.Context) error { 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) { 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) { fmt.Print("Email: ") line, err := bufio.NewReader(os.Stdin).ReadString('\n') diff --git a/cmd/root.go b/cmd/root.go index 0f22677..95cd1c1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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 import ( @@ -12,8 +15,16 @@ import ( "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" +// 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 { root := newRootCmd() @@ -47,6 +58,13 @@ func newRootCmd() *cobra.Command { 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 { if !term.IsTerminal(int(os.Stderr.Fd())) { return false @@ -75,6 +93,12 @@ func wantsUpdateNotice(root *cobra.Command) bool { 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) { select { case latest := <-notice: diff --git a/cmd/update.go b/cmd/update.go index cae8758..f507bc0 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -38,6 +38,14 @@ func newUpdateCmd() *cobra.Command { 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 { if err := update.CheckUpdatable(); err != nil { return err diff --git a/internal/audio/wav.go b/internal/audio/wav.go index 4778568..461686d 100644 --- a/internal/audio/wav.go +++ b/internal/audio/wav.go @@ -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 import ( @@ -16,12 +22,37 @@ import ( ) const ( - headerSize = 44 - formatPCM = 1 + // 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 + 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 - maxDataSize = math.MaxUint32 - (headerSize - 8) + + // 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) ) +// 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 { stream, err := flac.Open(srcPath) 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) if err := writeHeader(w, info.SampleRate, info.NChannels, info.BitsPerSample, 0); err != nil { return err @@ -66,6 +100,8 @@ func FLACToWAV(ctx context.Context, srcPath, dstPath string) error { if dataSize > maxDataSize { 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 err := w.WriteByte(0); err != nil { return err @@ -162,6 +198,12 @@ func writeSamples(ctx context.Context, w io.Writer, stream *flac.Stream, nChanne 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) { if bytesPerSample == 1 { 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 { buf := make([]byte, 4) diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index 8a62622..f72cc13 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -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 import ( @@ -14,12 +22,18 @@ import ( // release. Both the update check and `godeez update` refuse to run on them. 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 ( version = devVersion commit = "" 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 { if version != devVersion { return version @@ -34,6 +48,12 @@ func Version() string { 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 { if !semver.IsValid(v) { return "" @@ -49,6 +69,9 @@ func IsDev() bool { 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 { if commit != "" { return commit diff --git a/internal/config/config.go b/internal/config/config.go index 83c1957..211f98b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 import ( @@ -13,6 +18,12 @@ type Config struct { 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) { arl := os.Getenv("DEEZER_ARL") diff --git a/internal/config/migrate.go b/internal/config/migrate.go index 5b48256..0f67a1e 100644 --- a/internal/config/migrate.go +++ b/internal/config/migrate.go @@ -6,6 +6,17 @@ import ( "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) { homeDir, err := os.UserHomeDir() if err != nil { diff --git a/internal/deezer/blowfish.go b/internal/deezer/blowfish.go index 5f6eb86..dcaaa7f 100644 --- a/internal/deezer/blowfish.go +++ b/internal/deezer/blowfish.go @@ -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 { diff --git a/internal/deezer/client.go b/internal/deezer/client.go index bc762a7..56385a2 100644 --- a/internal/deezer/client.go +++ b/internal/deezer/client.go @@ -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 { diff --git a/internal/deezer/credentials.go b/internal/deezer/credentials.go index f7a7768..d317b4d 100644 --- a/internal/deezer/credentials.go +++ b/internal/deezer/credentials.go @@ -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) { diff --git a/internal/deezer/ecb.go b/internal/deezer/ecb.go index 921f63c..9be20e9 100644 --- a/internal/deezer/ecb.go +++ b/internal/deezer/ecb.go @@ -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 { diff --git a/internal/deezer/gateway.go b/internal/deezer/gateway.go index 71a7787..1b97aa4 100644 --- a/internal/deezer/gateway.go +++ b/internal/deezer/gateway.go @@ -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 { diff --git a/internal/deezer/kind.go b/internal/deezer/kind.go index b17986d..8ef9424 100644 --- a/internal/deezer/kind.go +++ b/internal/deezer/kind.go @@ -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: diff --git a/internal/deezer/login.go b/internal/deezer/login.go index 7978355..efc9701 100644 --- a/internal/deezer/login.go +++ b/internal/deezer/login.go @@ -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 { diff --git a/internal/deezer/media.go b/internal/deezer/media.go index 8128c57..501828a 100644 --- a/internal/deezer/media.go +++ b/internal/deezer/media.go @@ -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 } diff --git a/internal/deezer/resource.go b/internal/deezer/resource.go index e07b963..869f6c7 100644 --- a/internal/deezer/resource.go +++ b/internal/deezer/resource.go @@ -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 diff --git a/internal/deezer/session.go b/internal/deezer/session.go index 2392d9c..4fbd328 100644 --- a/internal/deezer/session.go +++ b/internal/deezer/session.go @@ -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 { diff --git a/internal/deezer/track.go b/internal/deezer/track.go index 45b4d7c..79f068b 100644 --- a/internal/deezer/track.go +++ b/internal/deezer/track.go @@ -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 "" diff --git a/internal/download/bpm.go b/internal/download/bpm.go index 3ed8930..bdc04e6 100644 --- a/internal/download/bpm.go +++ b/internal/download/bpm.go @@ -15,6 +15,15 @@ import ( "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 ( bpmRegex = regexp.MustCompile(`tempo of ]*>(\d+) BPM`) keyRegex = regexp.MustCompile(`with a ]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?) key`) @@ -35,6 +44,14 @@ func fetchBPM(ctx context.Context, httpClient *http.Client, artist, title, durat 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) { const rootURL = "https://songbpm.com" @@ -133,6 +150,13 @@ func fetchBPMPage(ctx context.Context, httpClient *http.Client, pageURL string) 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) { bpmMatch := bpmRegex.FindStringSubmatch(html) keyMatch := keyRegex.FindStringSubmatch(html) diff --git a/internal/download/download.go b/internal/download/download.go index 730b5fe..37be963 100644 --- a/internal/download/download.go +++ b/internal/download/download.go @@ -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 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 { if err := d.initDeezerClient(ctx, opts); err != nil { 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) } +// 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 { var err error 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 } +// 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) { resource, err := d.deezerClient.FetchResource(ctx, d.kind, id) if err != nil { @@ -86,6 +117,13 @@ func (d *Downloader) prepareResource(ctx context.Context, id string, opts Option 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 { tracks := resource.Tracks() startTime := time.Now() diff --git a/internal/download/genre.go b/internal/download/genre.go index c3bf59f..b9b545d 100644 --- a/internal/download/genre.go +++ b/internal/download/genre.go @@ -13,6 +13,13 @@ import ( "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{ "Ambient", "Bass", "Big Room", "Breakbeat", "Dance", "Disco", "Downtempo", "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 } + // 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) if len(tags) > 2 { tags = tags[:2] @@ -76,6 +86,9 @@ func fetchGenrePage(ctx context.Context, httpClient *http.Client, pageURL string 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 { var tags []string 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 } +// 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 { var electronic, nonElectronic []string @@ -113,6 +131,9 @@ func filterTags(tags []string) []string { 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 { formatted := make([]string, 0, len(tags)) for _, tag := range tags { diff --git a/internal/download/hash.go b/internal/download/hash.go index 2d8b29c..b56c56c 100644 --- a/internal/download/hash.go +++ b/internal/download/hash.go @@ -25,10 +25,19 @@ func hashFile(path string) (string, error) { 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 { 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) { index := &hashIndex{files: make(map[string]string)} @@ -60,6 +69,12 @@ func (h *hashIndex) find(hash string) (string, bool) { 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 { d.hashIndexOnce.Do(func() { d.hashIndex, d.hashIndexErr = newHashIndex(ctx, d.appConfig.OutputDir) diff --git a/internal/download/metadata.go b/internal/download/metadata.go index 7439849..5484e20 100644 --- a/internal/download/metadata.go +++ b/internal/download/metadata.go @@ -20,6 +20,14 @@ type metadataResult struct { 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 { if !opts.BPM && !opts.Genre { return metadataResult{} diff --git a/internal/download/options.go b/internal/download/options.go index b16cc0e..61f6b5d 100644 --- a/internal/download/options.go +++ b/internal/download/options.go @@ -24,6 +24,9 @@ type Options struct { 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 { if o.Quality == "wav" { return "flac" @@ -35,6 +38,9 @@ func (o *Options) convertsToWAV() bool { 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 { if !validQualities[o.Quality] { return fmt.Errorf("invalid quality option: %s", o.Quality) diff --git a/internal/download/progress.go b/internal/download/progress.go index aea9985..843da36 100644 --- a/internal/download/progress.go +++ b/internal/download/progress.go @@ -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() { if rand.Float64() < 0.1 { fmt.Println("\n⭐ Enjoying GoDeez? Star it on GitHub: https://github.com/mathismqn/godeez") diff --git a/internal/download/skip.go b/internal/download/skip.go index bbabef3..7c7b862 100644 --- a/internal/download/skip.go +++ b/internal/download/skip.go @@ -6,6 +6,18 @@ import ( "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) { existing, err := d.store.DownloadInfo(trackID) if err != nil || existing.Quality != mediaFormat { diff --git a/internal/download/stream.go b/internal/download/stream.go index 8d3e65a..90d357c 100644 --- a/internal/download/stream.go +++ b/internal/download/stream.go @@ -11,8 +11,14 @@ import ( "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 +// 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) { matches, err := filepath.Glob(filepath.Join(dir, fsutil.PartPattern)) 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 { tmpPath, err := d.streamToTempFile(ctx, stream, filepath.Dir(outputPath), key) if err != nil { @@ -37,6 +48,12 @@ func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, out 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) { defer stream.Close() @@ -45,6 +62,8 @@ func (d *Downloader) streamToTempFile(ctx context.Context, stream io.ReadCloser, return "", err } 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 defer func() { if !done { @@ -61,6 +80,10 @@ func (d *Downloader) streamToTempFile(ctx context.Context, stream io.ReadCloser, 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 for totalRead < chunkSize { n, err := stream.Read(buffer[totalRead:]) @@ -77,6 +100,11 @@ func (d *Downloader) streamToTempFile(ctx context.Context, stream io.ReadCloser, 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 { buffer, err = deezer.DecryptBlowfish(buffer, key) if err != nil { diff --git a/internal/download/track.go b/internal/download/track.go index 35f6e49..7d37e1b 100644 --- a/internal/download/track.go +++ b/internal/download/track.go @@ -15,6 +15,20 @@ import ( "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 { media, err := d.deezerClient.FetchMedia(ctx, track, opts.sourceQuality()) if err != nil { @@ -82,6 +96,9 @@ func (d *Downloader) downloadTrack(ctx context.Context, resource deezer.Resource 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 { fsutil.Remove(outputPath) return downloadResult{err: err} @@ -93,6 +110,12 @@ func (d *Downloader) downloadTrack(ctx context.Context, resource deezer.Resource 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 { owned := "" if info, err := d.store.DownloadInfo(trackID); err == nil { @@ -109,6 +132,13 @@ func (d *Downloader) uniqueOutputPath(trackID, path string) string { 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 { var warnings []string diff --git a/internal/fsutil/fsutil.go b/internal/fsutil/fsutil.go index 9d992c6..43490a5 100644 --- a/internal/fsutil/fsutil.go +++ b/internal/fsutil/fsutil.go @@ -1,3 +1,4 @@ +// Package fsutil holds the few filesystem helpers shared across godeez. package fsutil import ( @@ -5,8 +6,14 @@ import ( "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" +// 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 { info, err := os.Stat(path) if os.IsNotExist(err) { @@ -21,6 +28,9 @@ func EnsureDir(path string) error { 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 { info, err := os.Stat(path) return err == nil && !info.IsDir() diff --git a/internal/store/download_info.go b/internal/store/download_info.go index 40246ee..b33ffcc 100644 --- a/internal/store/download_info.go +++ b/internal/store/download_info.go @@ -9,6 +9,10 @@ import ( "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 { TrackID string `json:"song_id"` Quality string `json:"quality"` @@ -19,6 +23,9 @@ type DownloadInfo struct { 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) { var info DownloadInfo diff --git a/internal/store/store.go b/internal/store/store.go index c110ec7..c8ec2ca 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 import ( @@ -14,6 +20,11 @@ type Store struct { 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) { db, err := bbolt.Open(filepath.Join(dir, ".tracks.db"), 0600, &bbolt.Options{Timeout: 5 * time.Second}) if err != nil { diff --git a/internal/tag/flac.go b/internal/tag/flac.go index a141348..a524bb4 100644 --- a/internal/tag/flac.go +++ b/internal/tag/flac.go @@ -45,6 +45,9 @@ func (t *flacTagger) write(m Metadata) error { t.addTag("INITIALKEY", m.Key) 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 { t.file.Meta[t.index] = &cmtsMeta } else { @@ -66,6 +69,13 @@ func (t *flacTagger) write(m Metadata) error { 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) { if value != "" { t.cmts.Add(name, value) diff --git a/internal/tag/tag.go b/internal/tag/tag.go index 6b3ed40..5b5113b 100644 --- a/internal/tag/tag.go +++ b/internal/tag/tag.go @@ -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 import ( @@ -8,6 +18,8 @@ import ( "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 { Artist string Title string @@ -18,6 +30,9 @@ type AlbumMetadata struct { 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 { Title string Artists string @@ -38,6 +53,10 @@ type tagger interface { 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) { switch filepath.Ext(filePath) { case ".mp3": diff --git a/internal/tag/wav.go b/internal/tag/wav.go index 2cb5243..3305fa3 100644 --- a/internal/tag/wav.go +++ b/internal/tag/wav.go @@ -25,6 +25,12 @@ type infoField struct { 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 { id3Chunk, err := buildID3Chunk(m) if err != nil { @@ -57,6 +63,15 @@ func buildID3Chunk(m Metadata) ([]byte, error) { 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 { fields := []infoField{ {"INAM", m.Title}, @@ -96,6 +111,12 @@ func buildInfoChunk(m Metadata) []byte { 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) { header := make([]byte, 0, 8) 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 { src, err := os.Open(path) if err != nil { @@ -174,7 +205,21 @@ func rewriteWAV(path string, chunks []wavChunk) error { 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) { + // 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) head := make([]byte, 8) @@ -245,6 +290,12 @@ func skipPayload(src io.Reader, payloadSize int64) error { 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 { if payloadSize%2 == 0 { return nil diff --git a/internal/update/apply.go b/internal/update/apply.go index 9fc1ad6..a4601fb 100644 --- a/internal/update/apply.go +++ b/internal/update/apply.go @@ -16,6 +16,11 @@ import ( "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{ "/nix/store", "/opt/homebrew", @@ -25,6 +30,14 @@ var managedPrefixes = []string{ "/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) { if buildinfo.IsDev() { 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 } +// 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 { f, err := os.CreateTemp(dir, tmpPattern) if err != nil { @@ -74,6 +91,14 @@ func checkWritable(dir string) error { 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 { target, err := resolveTarget() if err != nil { @@ -134,6 +159,10 @@ func (u *Updater) fetchChecksum(ctx context.Context, release *Release, 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) { scanner := bufio.NewScanner(r) 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) } +// 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) { ctx, cancel := context.WithTimeout(ctx, downloadTimeout) 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 } +// 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 { if runtime.GOOS != "windows" { return os.Rename(tmp, target) diff --git a/internal/update/check.go b/internal/update/check.go index eef517a..bf330dc 100644 --- a/internal/update/check.go +++ b/internal/update/check.go @@ -14,7 +14,13 @@ import ( const noCheckEnv = "GODEEZ_NO_UPDATE_CHECK" const ( - cacheTTL = 24 * time.Hour + // 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 + + // 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 ) @@ -32,6 +38,9 @@ func cachePath() (string, error) { 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) { path, err := cachePath() if err != nil { @@ -71,6 +80,11 @@ func writeCache(version string) error { 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) { if entry, ok := readCache(); ok { return latestIfNewer(entry.LatestVersion), nil @@ -95,6 +109,17 @@ func latestIfNewer(latest string) string { 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 { ch := make(chan string, 1) diff --git a/internal/update/release.go b/internal/update/release.go index 0bec2b9..4fb4537 100644 --- a/internal/update/release.go +++ b/internal/update/release.go @@ -14,7 +14,11 @@ const ( repoName = "godeez" latestReleaseURL = "https://api.github.com/repos/" + repoOwner + "/" + repoName + "/releases/latest" checksumsAsset = "checksums.txt" - maxResponseSize = 1 << 20 + + // 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 ) var githubAPIHeaders = map[string]string{ @@ -67,6 +71,12 @@ func (r *Release) asset(name string) (Asset, bool) { 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) { name := fmt.Sprintf("%s_%s_%s_%s", repoName, r.Version(), runtime.GOOS, runtime.GOARCH) if runtime.GOOS == "windows" { diff --git a/internal/update/update.go b/internal/update/update.go index e69ecd3..a10cd50 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -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 import ( @@ -11,9 +18,13 @@ import ( ) 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 downloadTimeout = 5 * time.Minute - tmpPattern = ".godeez-update-*" + + tmpPattern = ".godeez-update-*" ) type Updater struct { @@ -21,6 +32,10 @@ type Updater struct { 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 { return &Updater{ client: &http.Client{}, diff --git a/internal/update/version.go b/internal/update/version.go index 6aa25c8..22fc39e 100644 --- a/internal/update/version.go +++ b/internal/update/version.go @@ -10,6 +10,10 @@ func trimV(v string) string { 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 { v = strings.TrimSpace(v) if v == "" { @@ -25,6 +29,11 @@ func canonical(v string) string { 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 { c, l := canonical(current), canonical(latest) if c == "" || l == "" { diff --git a/main.go b/main.go index 8adfa46..352a324 100644 --- a/main.go +++ b/main.go @@ -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 import ( @@ -9,9 +11,15 @@ import ( ) 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) 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 { stop() os.Exit(1)