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..008e121 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -35,6 +35,7 @@ func init() { newDownloadCmd("album"), newDownloadCmd("playlist"), newDownloadCmd("artist"), + newDownloadCmd("track"), ) } @@ -77,9 +78,12 @@ 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" + cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of songs to download") + case "track": + cmd.Short = "Download a single track" } return cmd diff --git a/internal/deezer/client.go b/internal/deezer/client.go index 5d5f9db..e9f46d9 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) } @@ -75,15 +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") + 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 new file mode 100644 index 0000000..d110b8d --- /dev/null +++ b/internal/deezer/track.go @@ -0,0 +1,77 @@ +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) {} + +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 1c11e37..8dc4853 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) } @@ -73,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 { @@ -87,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 @@ -146,7 +153,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 @@ -155,12 +164,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 }