From 9b23e3a8d9bf42aa1d95244b452f0e281cba7a65 Mon Sep 17 00:00:00 2001 From: Felipe Marinho Date: Wed, 10 Sep 2025 05:26:58 -0300 Subject: [PATCH 1/2] feat: download single tracks (#5) Introduces the `godeez download track ` command, allowing users to download individual songs directly. Single tracks are saved into a "Singles" folder by default to keep them organized. The README has been updated with documentation and examples for this new functionality. --- README.md | 24 ++++++++++- cmd/download.go | 5 +++ internal/deezer/client.go | 5 +++ internal/deezer/track.go | 81 +++++++++++++++++++++++++++++++++++ internal/downloader/client.go | 2 + 5 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 internal/deezer/track.go diff --git a/README.md b/README.md index 8a38617..2b50fff 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A simple Go tool for downloading music from [Deezer](https://www.deezer.com). ## Features -* Download playlists, albums, and artists' top tracks from Deezer +* Download playlists, albums, artists' top tracks, and individual tracks from Deezer * Select audio quality: MP3 128kbps, MP3 320kbps (default), or FLAC (⚠️ non-premium accounts are limited to 128kbps) * Automatically adds metadata tags to downloaded files * Fetch and tag songs with BPM and musical key @@ -122,6 +122,7 @@ Available Commands: album Download songs from an album artist Download top songs from an artist playlist Download songs from a playlist + track Download a single track Flags: --bpm fetch BPM/key and add to file tags @@ -134,6 +135,27 @@ Flags: Use "godeez download [command] --help" for more information about a command. ``` +### Examples + +Here are some examples of how to use the different download commands: + +```bash +# Download an album +godeez download album 12345678 + +# Download a playlist +godeez download playlist 87654321 + +# Download top tracks from an artist +godeez download artist 11223344 --limit 5 + +# Download a single track +godeez download track 98765432 + +# Download with specific quality and BPM data +godeez download track 98765432 --quality flac --bpm +``` + ## Contributing Contributions help make **GoDeez** a better tool for everyone, and any help is greatly appreciated. diff --git a/cmd/download.go b/cmd/download.go index dddc103..52cf653 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -35,6 +35,7 @@ func init() { newDownloadCmd("album"), newDownloadCmd("playlist"), newDownloadCmd("artist"), + newDownloadCmd("track"), ) } @@ -82,5 +83,9 @@ func newDownloadCmd(resourceType string) *cobra.Command { cmd.Short = "Download top songs from an artist" } + if resourceType == "track" { + cmd.Short = "Download a single track" + } + return cmd } diff --git a/internal/deezer/client.go b/internal/deezer/client.go index b6d53a2..7b7d5fa 100644 --- a/internal/deezer/client.go +++ b/internal/deezer/client.go @@ -45,6 +45,8 @@ func (c *Client) FetchResource(ctx context.Context, resource Resource, id string payload["alb_id"] = id case *Artist: payload["art_id"] = id + case *Track: + payload["sng_id"] = id default: return fmt.Errorf("unsupported resource type: %T", r) } @@ -84,6 +86,9 @@ func (c *Client) FetchResource(ctx context.Context, resource Resource, id string if strings.Contains(string(body), `"DATA_ERROR":"artist::getData"`) { return fmt.Errorf("invalid artist ID") } + if strings.Contains(string(body), `"DATA_ERROR":"song::getData"`) { + return fmt.Errorf("invalid track ID") + } if strings.Contains(string(body), `"results":{}`) { return fmt.Errorf("unexpected response") } diff --git a/internal/deezer/track.go b/internal/deezer/track.go new file mode 100644 index 0000000..4f1e180 --- /dev/null +++ b/internal/deezer/track.go @@ -0,0 +1,81 @@ +package deezer + +import ( + "encoding/json" + "fmt" + "path" + "time" + + "github.com/flytam/filenamify" +) + +type Track struct { + Results struct { + Data *Song `json:"DATA"` + } `json:"results"` +} + +func (t *Track) String() string { + if t.Results.Data == nil { + return "Track: No data available" + } + + duration := "Unknown" + if t.Results.Data.Duration != "" { + if d, err := time.ParseDuration(t.Results.Data.Duration + "s"); err == nil { + duration = d.String() + } + } + + return fmt.Sprintf( + `================= [ Track Info ] ================= +Title: %s +Artist: %s +Duration: %s +==================================================`, + t.Results.Data.GetTitle(), + t.Results.Data.Artist, + duration, + ) +} + +func (t *Track) GetType() string { + return "Track" +} + +func (t *Track) GetTitle() string { + if t.Results.Data == nil { + return "" + } + return t.Results.Data.GetTitle() +} + +func (t *Track) GetSongs() []*Song { + if t.Results.Data == nil { + return []*Song{} + } + return []*Song{t.Results.Data} +} + +func (t *Track) SetSongs(songs []*Song) { + if len(songs) > 0 { + t.Results.Data = songs[0] + } +} + +func (t *Track) GetOutputDir(outputDir string) string { + if t.Results.Data == nil { + return outputDir + } + + // For single tracks, create a simple "Singles" folder + base := "Singles" + base, _ = filenamify.Filenamify(base, filenamify.Options{}) + outputDir = path.Join(outputDir, base) + + return outputDir +} + +func (t *Track) Unmarshal(data []byte) error { + return json.Unmarshal(data, t) +} diff --git a/internal/downloader/client.go b/internal/downloader/client.go index 6af653b..19f17d5 100644 --- a/internal/downloader/client.go +++ b/internal/downloader/client.go @@ -63,6 +63,8 @@ func (c *Client) Run(ctx context.Context, opts Options, id string) error { resource = &deezer.Playlist{} case "artist": resource = &deezer.Artist{} + case "track": + resource = &deezer.Track{} default: return fmt.Errorf("unsupported resource type: %s", c.resourceType) } From c2b4fcfa11766b6f16fe5e85e54c6472d1bb6377 Mon Sep 17 00:00:00 2001 From: Mathis Maquenne <124215603+mathismqn@users.noreply.github.com> Date: Wed, 10 Sep 2025 11:10:52 +0200 Subject: [PATCH 2/2] feat(cli): simplify output for individual songs --- cmd/download.go | 9 ++++----- internal/deezer/client.go | 13 ++++++------- internal/deezer/track.go | 6 +----- internal/downloader/client.go | 24 ++++++++++++++++-------- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/cmd/download.go b/cmd/download.go index 52cf653..008e121 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -78,12 +78,11 @@ func newDownloadCmd(resourceType string) *cobra.Command { }, } - if resourceType == "artist" { - cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of songs to download") + switch resourceType { + case "artist": cmd.Short = "Download top songs from an artist" - } - - if resourceType == "track" { + cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of songs to download") + case "track": cmd.Short = "Download a single track" } diff --git a/internal/deezer/client.go b/internal/deezer/client.go index 7b7d5fa..b276900 100644 --- a/internal/deezer/client.go +++ b/internal/deezer/client.go @@ -77,18 +77,17 @@ func (c *Client) FetchResource(ctx context.Context, resource Resource, id string return err } - if strings.Contains(string(body), `"DATA_ERROR":"playlist::getData"`) { + switch { + case strings.Contains(string(body), `"DATA_ERROR":"playlist::getData"`): return fmt.Errorf("invalid playlist ID") - } - if strings.Contains(string(body), `"DATA_ERROR":"album::getData"`) { + case strings.Contains(string(body), `"DATA_ERROR":"album::getData"`): return fmt.Errorf("invalid album ID") - } - if strings.Contains(string(body), `"DATA_ERROR":"artist::getData"`) { + case strings.Contains(string(body), `"DATA_ERROR":"artist::getData"`): return fmt.Errorf("invalid artist ID") - } - if strings.Contains(string(body), `"DATA_ERROR":"song::getData"`) { + case strings.Contains(string(body), `"DATA_ERROR":"song::getData"`): return fmt.Errorf("invalid track ID") } + if strings.Contains(string(body), `"results":{}`) { return fmt.Errorf("unexpected response") } diff --git a/internal/deezer/track.go b/internal/deezer/track.go index 4f1e180..d110b8d 100644 --- a/internal/deezer/track.go +++ b/internal/deezer/track.go @@ -57,11 +57,7 @@ func (t *Track) GetSongs() []*Song { return []*Song{t.Results.Data} } -func (t *Track) SetSongs(songs []*Song) { - if len(songs) > 0 { - t.Results.Data = songs[0] - } -} +func (t *Track) SetSongs(songs []*Song) {} func (t *Track) GetOutputDir(outputDir string) string { if t.Results.Data == nil { diff --git a/internal/downloader/client.go b/internal/downloader/client.go index 19f17d5..cfbb430 100644 --- a/internal/downloader/client.go +++ b/internal/downloader/client.go @@ -75,6 +75,9 @@ func (c *Client) Run(ctx context.Context, opts Options, id string) error { songs := resource.GetSongs() if len(songs) == 0 { + if c.resourceType == "track" { + return fmt.Errorf("track with ID %s not found", id) + } return fmt.Errorf("%s has no songs", c.resourceType) } if c.resourceType == "artist" && len(songs) > opts.Limit { @@ -89,7 +92,9 @@ func (c *Client) Run(ctx context.Context, opts Options, id string) error { } startTime := time.Now() - fmt.Printf("%s\n\nStarting download...\n\n", resource) + if c.resourceType != "track" { + fmt.Printf("%s\n\nStarting download...\n\n", resource) + } downloaded := 0 skipped := 0 @@ -147,7 +152,9 @@ func (c *Client) Run(ctx context.Context, opts Options, id string) error { if downloaded > 0 || failed > 0 { c.Logger.Infof("Playlist %s (%s): %d downloaded, %d skipped, %d failed\n", resource.GetTitle(), id, downloaded, skipped, failed) } - fmt.Printf(` + + if c.resourceType != "track" { + fmt.Printf(` ================== [ Summary ] ================== Downloaded: %d Skipped: %d @@ -156,12 +163,13 @@ Elapsed time: %s Files saved to: %s ================================================= `, - downloaded, - skipped, - failed, - time.Since(startTime).Round(time.Second), - resourceOutputDir, - ) + downloaded, + skipped, + failed, + time.Since(startTime).Round(time.Second), + resourceOutputDir, + ) + } return nil }