Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1ad9b4904 | ||
|
|
5dfd832d0d | ||
|
|
3b51426251 |
@@ -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
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](https://github.com/mathismqn/godeez/blob/main/LICENSE)
|
[](https://github.com/mathismqn/godeez/blob/main/LICENSE)
|
||||||
[](https://github.com/mathismqn/godeez/commits/main)
|
[](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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+51
-3
@@ -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 = 44
|
// headerSize is the canonical PCM wav header: a 12 byte RIFF/WAVE header,
|
||||||
formatPCM = 1
|
// 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
|
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 {
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 = "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 (
|
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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ import (
|
|||||||
const noCheckEnv = "GODEEZ_NO_UPDATE_CHECK"
|
const noCheckEnv = "GODEEZ_NO_UPDATE_CHECK"
|
||||||
|
|
||||||
const (
|
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
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ 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 = 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{
|
var githubAPIHeaders = map[string]string{
|
||||||
@@ -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" {
|
||||||
|
|||||||
@@ -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,9 +18,13 @@ 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-*"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Updater struct {
|
type Updater struct {
|
||||||
@@ -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{},
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user