3 Commits
Author SHA1 Message Date
arnef ba6bf2f34c feat: allow overriding the output directory via GODEEZ_OUTPUT_DIR
The output directory used to be fixed at ~/Music/GoDeez. config.Load
now honours GODEEZ_OUTPUT_DIR when set, with a leading ~ expanded by
hand since the shell only does that for unquoted arguments, not for
values read out of the environment. Falls back to the previous default
when unset.
2026-08-14 21:00:15 +02:00
arnef b1c15341fd feat: write an m3u playlist to preserve playlist track order
Playlist file names carry no ordering information, so the sequence a
user curated on Deezer was lost once the tracks landed on disk (unlike
albums, which get a zero padded track number prefix). downloadTrack
now reports the output path on success as well as on skip, and
downloadAllTracks collects the per-track paths for playlist downloads
and writes them, in order, to an extended m3u file next to the audio.
Failed tracks are left out since there is nothing on disk to point at;
a write failure is reported as a warning rather than failing the run.
2026-08-14 21:00:08 +02:00
arnef 765595133f docs: add copilot instructions for future sessions
Document build/test/lint commands, the cmd -> download -> deezer
pipeline architecture, and codebase conventions so future Copilot
sessions can work effectively in this repository.
2026-08-14 20:58:51 +02:00
6 changed files with 203 additions and 6 deletions
+87
View File
@@ -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.
+8
View File
@@ -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
+32 -5
View File
@@ -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
}
+15
View File
@@ -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))
+60
View File
@@ -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)
}
+1 -1
View File
@@ -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)",