Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba6bf2f34c | ||
|
|
b1c15341fd | ||
|
|
765595133f |
@@ -0,0 +1,87 @@
|
|||||||
|
# GoDeez
|
||||||
|
|
||||||
|
GoDeez is a Go CLI that downloads music (tracks, albums, playlists, artist top
|
||||||
|
tracks) from Deezer in MP3 or lossless FLAC/WAV, embeds metadata tags, and
|
||||||
|
tracks what has already been downloaded so repeat runs can skip it.
|
||||||
|
|
||||||
|
## Build, test, lint
|
||||||
|
|
||||||
|
Mirror what CI (`.github/workflows/ci.yml`) runs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gofmt -l . # must print nothing; CI fails on unformatted files
|
||||||
|
go vet ./...
|
||||||
|
go test ./...
|
||||||
|
go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a single test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/download/ -run TestHashFile
|
||||||
|
go test ./internal/download/ -run TestHashFile -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Release packaging is validated with `goreleaser check` (config in
|
||||||
|
`.goreleaser.yaml`); releases are cut by pushing a `v*` tag, which requires a
|
||||||
|
matching section in `CHANGELOG.md` (`.github/scripts/release-notes.sh`
|
||||||
|
extracts it and fails the release otherwise).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The flow is `cmd` → `download.Downloader` → `deezer` client → `store`/`tag`/`audio`:
|
||||||
|
|
||||||
|
- **`cmd`** is a thin cobra layer (`download`, `login`, `logout`, `update`,
|
||||||
|
`version`). It parses flags and delegates; it does not contain download
|
||||||
|
logic. `cmd.Execute` also kicks off an async update check
|
||||||
|
(`internal/update.StartCheck`) before running the command and prints the
|
||||||
|
result after, so the network round trip overlaps with the command instead
|
||||||
|
of adding to startup time.
|
||||||
|
- **`internal/deezer`** is the API client. `Kind` (`album`/`playlist`/
|
||||||
|
`artist`/`track`) is the single source of truth mapping a resource type to
|
||||||
|
its gw-light page method, request id field, and `Resource` implementation
|
||||||
|
(`Album`/`Playlist`/`Artist`/`Single`). Adding a new resource kind means
|
||||||
|
extending every switch in `kind.go`. `Resource`'s `decode` method is
|
||||||
|
unexported to seal the interface to this package.
|
||||||
|
- **`internal/download`** (package `download`) drives the per-track pipeline
|
||||||
|
via `Downloader.Run`: fetch resource → for each track, resolve a media
|
||||||
|
source → decide skip/hash-dedupe → stream and decrypt → optionally convert
|
||||||
|
FLAC to WAV → write tags → record in the store. Tracks are processed
|
||||||
|
sequentially, one at a time. Most per-track failures (bad cover, failed BPM
|
||||||
|
lookup, etc.) are collected as warnings rather than aborting the whole run;
|
||||||
|
only `context.Canceled` stops the loop early.
|
||||||
|
- **`internal/store`** is a bbolt-backed ledger (`.tracks.db`) written as a
|
||||||
|
hidden file inside the output directory, so it travels with the music
|
||||||
|
library it describes. Losing it is harmless — the only cost is
|
||||||
|
re-downloading.
|
||||||
|
- **`internal/tag`** writes metadata per container format (ID3v2 for MP3,
|
||||||
|
Vorbis comments for FLAC, ID3 chunk + RIFF LIST/INFO for WAV), dispatching
|
||||||
|
on file extension. Taggers write through a temp file so a mid-write failure
|
||||||
|
can't corrupt existing audio.
|
||||||
|
- **`internal/config`** has no config file — the output directory
|
||||||
|
(`~/Music/GoDeez`) is fixed, and the only setting is the `DEEZER_ARL`
|
||||||
|
env var, which is optional (falls back to keyring-stored credentials from
|
||||||
|
`godeez login`).
|
||||||
|
- **`internal/buildinfo`** holds the version/commit/date injected by
|
||||||
|
goreleaser via `-ldflags`; unreleased builds report `dev`, which disables
|
||||||
|
update-related behavior (`IsDev()`).
|
||||||
|
- **`internal/update`** implements self-update: checks GitHub releases,
|
||||||
|
verifies the downloaded binary's SHA256 against the published
|
||||||
|
`checksums.txt`, and replaces the running binary in place.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Package- and function-level doc comments consistently explain *why*, not
|
||||||
|
just what — e.g. why a check happens where it does, or why an error is
|
||||||
|
handled a particular way. Preserve this style when touching existing code
|
||||||
|
or adding new exported functions.
|
||||||
|
- Most per-track errors during a download are non-fatal and surfaced as
|
||||||
|
warnings in the run summary; only cancellation (`context.Canceled`) should
|
||||||
|
abort the loop. Keep new failure modes inside this pattern unless they are
|
||||||
|
truly unrecoverable for the whole run.
|
||||||
|
- `Kind` in `internal/deezer/kind.go` is deliberately exhaustive with no
|
||||||
|
default case that silently returns a zero value in the id/method switches —
|
||||||
|
follow the same pattern (explicit cases, empty/error fallback) if extending
|
||||||
|
it.
|
||||||
|
- Credentials: `DEEZER_ARL` always takes precedence over keyring-stored
|
||||||
|
login credentials when both are present.
|
||||||
@@ -147,6 +147,14 @@ export DEEZER_MOBILE_GW_KEY="your_gateway_key" # exactly 16 characters
|
|||||||
|
|
||||||
Downloaded files are saved to `~/Music/GoDeez`. The download database (`.tracks.db`) is stored in the same directory as your music.
|
Downloaded files are saved to `~/Music/GoDeez`. The download database (`.tracks.db`) is stored in the same directory as your music.
|
||||||
|
|
||||||
|
To use a different location, set `GODEEZ_OUTPUT_DIR`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GODEEZ_OUTPUT_DIR="/path/to/your/music"
|
||||||
|
```
|
||||||
|
|
||||||
|
A leading `~` is expanded to your home directory.
|
||||||
|
|
||||||
> **Upgrading from v1.3.0?** The `~/.godeez` directory and `config.toml` are no longer used. Set the `DEEZER_ARL` environment variable instead. Your existing database will be migrated automatically on first run.
|
> **Upgrading from v1.3.0?** The `~/.godeez` directory and `config.toml` are no longer used. Set the `DEEZER_ARL` environment variable instead. Your existing database will be migrated automatically on first run.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Package config resolves where godeez reads its session from and writes its
|
// 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
|
// downloads to. There is no config file: the output directory defaults to
|
||||||
// the only setting is the DEEZER_ARL environment variable, which exists as an
|
// ~/Music/GoDeez but can be overridden with GODEEZ_OUTPUT_DIR, and the only
|
||||||
|
// other setting is the DEEZER_ARL environment variable, which exists as an
|
||||||
// escape hatch for users who would rather not store credentials in the system
|
// escape hatch for users who would rather not store credentials in the system
|
||||||
// keyring.
|
// keyring.
|
||||||
package config
|
package config
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/mathismqn/godeez/internal/fsutil"
|
"github.com/mathismqn/godeez/internal/fsutil"
|
||||||
)
|
)
|
||||||
@@ -27,12 +29,11 @@ type Config struct {
|
|||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
arl := os.Getenv("DEEZER_ARL")
|
arl := os.Getenv("DEEZER_ARL")
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
outputDir, err := resolveOutputDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
outputDir := filepath.Join(homeDir, "Music", "GoDeez")
|
|
||||||
if err := fsutil.EnsureDir(outputDir); err != nil {
|
if err := fsutil.EnsureDir(outputDir); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create output directory: %w", err)
|
return nil, fmt.Errorf("failed to create output directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -42,3 +43,29 @@ func Load() (*Config, error) {
|
|||||||
OutputDir: outputDir,
|
OutputDir: outputDir,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveOutputDir honours GODEEZ_OUTPUT_DIR when set, falling back to
|
||||||
|
// ~/Music/GoDeez otherwise.
|
||||||
|
//
|
||||||
|
// A leading "~" is expanded by hand because the shell only does that for
|
||||||
|
// unquoted arguments, not for values read out of the environment, so users
|
||||||
|
// setting this in a profile file would otherwise end up with a literal "~"
|
||||||
|
// directory.
|
||||||
|
func resolveOutputDir() (string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dir := strings.TrimSpace(os.Getenv("GODEEZ_OUTPUT_DIR")); dir != "" {
|
||||||
|
if dir == "~" {
|
||||||
|
return homeDir, nil
|
||||||
|
}
|
||||||
|
if rest, ok := strings.CutPrefix(dir, "~/"); ok {
|
||||||
|
return filepath.Join(homeDir, rest), nil
|
||||||
|
}
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(homeDir, "Music", "GoDeez"), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ func (d *Downloader) downloadAllTracks(ctx context.Context, resource deezer.Reso
|
|||||||
|
|
||||||
progress := newProgressTracker(len(tracks), d.kind)
|
progress := newProgressTracker(len(tracks), d.kind)
|
||||||
|
|
||||||
|
var m3uEntries []m3uEntry
|
||||||
|
|
||||||
for i, track := range tracks {
|
for i, track := range tracks {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
@@ -148,6 +150,19 @@ func (d *Downloader) downloadAllTracks(ctx context.Context, resource deezer.Reso
|
|||||||
}
|
}
|
||||||
|
|
||||||
progress.handleResult(i, track, result)
|
progress.handleResult(i, track, result)
|
||||||
|
|
||||||
|
if d.kind == deezer.KindPlaylist && result.err == nil {
|
||||||
|
m3uEntries = append(m3uEntries, m3uEntry{track: track, path: result.path})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Written after the loop, not per track, so a run that fails partway
|
||||||
|
// through still produces a playlist covering whatever did make it to
|
||||||
|
// disk instead of no playlist at all.
|
||||||
|
if d.kind == deezer.KindPlaylist {
|
||||||
|
if err := writeM3U(outputDir, m3uEntries); err != nil {
|
||||||
|
fmt.Printf("\nWarning: failed to write m3u playlist: %v\n", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
progress.printSummary(outputDir, time.Since(startTime))
|
progress.printSummary(outputDir, time.Since(startTime))
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mathismqn/godeez/internal/deezer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// m3uEntry pairs a playlist track with the file it ended up at, so the m3u
|
||||||
|
// can be written after the fact without re-deriving the path.
|
||||||
|
type m3uEntry struct {
|
||||||
|
track *deezer.Track
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeM3U records a playlist's tracks in their original order as an
|
||||||
|
// extended M3U file next to the downloaded audio.
|
||||||
|
//
|
||||||
|
// This exists because the on-disk file names carry no ordering for
|
||||||
|
// playlists (unlike albums, which get a zero padded track number prefix):
|
||||||
|
// without an m3u, the sequence the user curated on Deezer is lost the moment
|
||||||
|
// the tracks land in a directory that any player will list alphabetically.
|
||||||
|
//
|
||||||
|
// Skipped tracks are included: they are already on disk from a previous run
|
||||||
|
// and belong at their playlist position just as much as one downloaded this
|
||||||
|
// run. Failed tracks are left out since there is nothing on disk to point
|
||||||
|
// at, and a broken entry would only confuse the player.
|
||||||
|
//
|
||||||
|
// Paths are written relative to outputDir so the m3u keeps working if the
|
||||||
|
// whole playlist folder is moved or copied elsewhere.
|
||||||
|
func writeM3U(outputDir string, entries []m3uEntry) error {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m3uPath := filepath.Join(outputDir, filepath.Base(outputDir)+".m3u")
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("#EXTM3U\n")
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
duration, err := strconv.Atoi(e.track.Duration)
|
||||||
|
if err != nil {
|
||||||
|
duration = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(outputDir, e.path)
|
||||||
|
if err != nil {
|
||||||
|
rel = e.path
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&b, "#EXTINF:%d,%s - %s\n%s\n", duration, e.track.Artist, e.track.FullTitle(), rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(m3uPath, []byte(b.String()), 0644)
|
||||||
|
}
|
||||||
@@ -107,7 +107,7 @@ func (d *Downloader) downloadTrack(ctx context.Context, resource deezer.Resource
|
|||||||
warnings = append(warnings, metadata.warnings...)
|
warnings = append(warnings, metadata.warnings...)
|
||||||
warnings = append(warnings, d.finalizeDownload(resource, track, outputPath, outputFormat, metadata.genre, cover, metadata.bpmKey)...)
|
warnings = append(warnings, d.finalizeDownload(resource, track, outputPath, outputFormat, metadata.genre, cover, metadata.bpmKey)...)
|
||||||
|
|
||||||
return downloadResult{warnings: warnings}
|
return downloadResult{path: outputPath, warnings: warnings}
|
||||||
}
|
}
|
||||||
|
|
||||||
// uniqueOutputPath avoids clobbering an unrelated file by appending " (2)",
|
// uniqueOutputPath avoids clobbering an unrelated file by appending " (2)",
|
||||||
|
|||||||
Reference in New Issue
Block a user