From 67b4fe404e921f3e2ff6787513bdc3ef5c8d203e Mon Sep 17 00:00:00 2001 From: Mathis Maquenne <124215603+mathismqn@users.noreply.github.com> Date: Fri, 20 Jun 2025 20:00:59 -0400 Subject: [PATCH] feat: download artist's top tracks with --limit flag --- cmd/download.go | 13 +++++- internal/deezer/album.go | 4 ++ internal/deezer/artist.go | 82 ++++++++++++++++++++++++++++++++++ internal/deezer/client.go | 34 +++++++++----- internal/deezer/playlist.go | 4 ++ internal/deezer/resource.go | 1 + internal/downloader/client.go | 6 +++ internal/downloader/options.go | 4 ++ 8 files changed, 136 insertions(+), 12 deletions(-) create mode 100644 internal/deezer/artist.go diff --git a/cmd/download.go b/cmd/download.go index a33e1a7..d857ab8 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -27,13 +27,19 @@ func init() { downloadCmd.AddCommand( newDownloadCmd("album"), newDownloadCmd("playlist"), + newDownloadCmd("artist"), ) } func newDownloadCmd(resourceType string) *cobra.Command { + article := "a" + if resourceType == "album" { + article = "an" + } + cmd := &cobra.Command{ Use: fmt.Sprintf("%s <%s_id>", resourceType, resourceType), - Short: fmt.Sprintf("Download songs from %s", resourceType), + Short: fmt.Sprintf("Download songs from %s %s", article, resourceType), Args: cobra.ExactArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { return opts.Validate() @@ -54,5 +60,10 @@ func newDownloadCmd(resourceType string) *cobra.Command { }, } + if resourceType == "artist" { + cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of songs to download") + cmd.Short = fmt.Sprintf("Download top songs from an artist") + } + return cmd } diff --git a/internal/deezer/album.go b/internal/deezer/album.go index 789c0a6..2630cf0 100644 --- a/internal/deezer/album.go +++ b/internal/deezer/album.go @@ -59,6 +59,10 @@ func (a *Album) GetSongs() []*Song { return a.Results.Songs.Data } +func (a *Album) SetSongs(s []*Song) { + a.Results.Songs.Data = s +} + func (a *Album) GetOutputDir(outputDir string) string { base := fmt.Sprintf("%s - %s", a.Results.Data.Artist, a.Results.Data.Title) base, _ = filenamify.Filenamify(base, filenamify.Options{}) diff --git a/internal/deezer/artist.go b/internal/deezer/artist.go new file mode 100644 index 0000000..0358797 --- /dev/null +++ b/internal/deezer/artist.go @@ -0,0 +1,82 @@ +package deezer + +import ( + "encoding/json" + "fmt" + "path" + "strconv" + "strings" + "time" + + "github.com/flytam/filenamify" +) + +type Artist struct { + Results struct { + Data struct { + Name string `json:"ART_NAME"` + } `json:"DATA"` + Songs struct { + Data []*Song `json:"data"` + } `json:"TOP"` + } `json:"results"` +} + +func (a *Artist) GetType() string { + return "Artist" +} + +func (a *Artist) GetTitle() string { + return a.Results.Data.Name +} + +func (a *Artist) GetSongs() []*Song { + return a.Results.Songs.Data +} + +func (a *Artist) SetSongs(s []*Song) { + a.Results.Songs.Data = s +} + +func (a *Artist) GetOutputDir(outputDir string) string { + base, _ := filenamify.Filenamify(a.GetTitle(), filenamify.Options{}) + return path.Join(outputDir, base) +} + +func (a *Artist) Unmarshal(data []byte) error { + return json.Unmarshal(data, a) +} + +func (a *Artist) String() string { + tracks := a.GetSongs() + count := len(tracks) + + limit := 3 + if count < limit { + limit = count + } + + totalSec := 0 + for _, s := range tracks { + if d, err := strconv.Atoi(s.Duration); err == nil { + totalSec += d + } + } + totalDuration := time.Duration(totalSec) * time.Second + + var b strings.Builder + fmt.Fprintf(&b, "============= [ Artist Info ] =============\n") + fmt.Fprintf(&b, "Artist: %s\n", a.GetTitle()) + fmt.Fprintf(&b, "Tracks: %d\n", count) + fmt.Fprintf(&b, "Playtime: %s\n", totalDuration) + fmt.Fprintf(&b, "-------------------------------------------\n") + fmt.Fprintf(&b, "Top %d most popular tracks:\n", limit) + for i := 0; i < limit; i++ { + s := tracks[i] + title := s.GetTitle() + fmt.Fprintf(&b, " %2d. %s – %s\n", i+1, s.Artist, title) + } + fmt.Fprintf(&b, "===========================================\n") + + return b.String() +} diff --git a/internal/deezer/client.go b/internal/deezer/client.go index 8e8c2a5..964b675 100644 --- a/internal/deezer/client.go +++ b/internal/deezer/client.go @@ -29,23 +29,32 @@ func NewClient(ctx context.Context, appConfig *config.Config) (*Client, error) { }, nil } -func (c *Client) FetchResource(ctx context.Context, ressource Resource, id string) error { +func (c *Client) FetchResource(ctx context.Context, resource Resource, id string) error { payload := map[string]interface{}{ - "nb": 10000, - "start": 0, - "playlist_id": id, - "alb_id": id, - "lang": "en", - "tab": 0, - "tags": true, - "header": true, + "nb": 10000, + "start": 0, + "lang": "en", + "tab": 0, + "tags": true, + "header": true, } + switch r := resource.(type) { + case *Playlist: + payload["playlist_id"] = id + case *Album: + payload["alb_id"] = id + case *Artist: + payload["art_id"] = id + default: + return fmt.Errorf("unsupported resource type: %T", r) + } + jsonData, err := json.Marshal(payload) if err != nil { return err } - url := fmt.Sprintf("https://www.deezer.com/ajax/gw-light.php?method=deezer.page%s&input=3&api_version=1.0&api_token=%s", ressource.GetType(), c.Session.APIToken) + url := fmt.Sprintf("https://www.deezer.com/ajax/gw-light.php?method=deezer.page%s&input=3&api_version=1.0&api_token=%s", resource.GetType(), c.Session.APIToken) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) if err != nil { return err @@ -72,11 +81,14 @@ func (c *Client) FetchResource(ctx context.Context, ressource Resource, id strin if strings.Contains(string(body), `"DATA_ERROR":"album::getData"`) { return fmt.Errorf("invalid album ID") } + if strings.Contains(string(body), `"DATA_ERROR":"artist::getData"`) { + return fmt.Errorf("invalid artist ID") + } if strings.Contains(string(body), `"results":{}`) { return fmt.Errorf("unexpected response") } - return ressource.Unmarshal(body) + return resource.Unmarshal(body) } func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*Media, error) { diff --git a/internal/deezer/playlist.go b/internal/deezer/playlist.go index 61c596b..f500f14 100644 --- a/internal/deezer/playlist.go +++ b/internal/deezer/playlist.go @@ -50,6 +50,10 @@ func (p *Playlist) GetSongs() []*Song { return p.Results.Songs.Data } +func (p *Playlist) SetSongs(s []*Song) { + p.Results.Songs.Data = s +} + func (p *Playlist) GetOutputDir(outputDir string) string { p.Results.Data.Title, _ = filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{}) outputDir = path.Join(outputDir, p.Results.Data.Title) diff --git a/internal/deezer/resource.go b/internal/deezer/resource.go index 2149788..53a45e1 100644 --- a/internal/deezer/resource.go +++ b/internal/deezer/resource.go @@ -4,6 +4,7 @@ type Resource interface { GetTitle() string GetType() string GetSongs() []*Song + SetSongs(songs []*Song) GetOutputDir(outputDir string) string Unmarshal(data []byte) error } diff --git a/internal/downloader/client.go b/internal/downloader/client.go index 7043a4a..c3da39c 100644 --- a/internal/downloader/client.go +++ b/internal/downloader/client.go @@ -57,6 +57,8 @@ func (c *Client) Run(ctx context.Context, opts Options, id string) error { resource = &deezer.Album{} case "playlist": resource = &deezer.Playlist{} + case "artist": + resource = &deezer.Artist{} default: return fmt.Errorf("unsupported resource type: %s", c.resourceType) } @@ -69,6 +71,10 @@ func (c *Client) Run(ctx context.Context, opts Options, id string) error { if len(songs) == 0 { return fmt.Errorf("%s has no songs", c.resourceType) } + if opts.Limit > 0 && len(songs) > opts.Limit { + songs = songs[:opts.Limit] + resource.SetSongs(songs) + } rootOutputDir := c.appConfig.OutputDir resourceOutputDir := resource.GetOutputDir(rootOutputDir) diff --git a/internal/downloader/options.go b/internal/downloader/options.go index eb7a53e..76ff839 100644 --- a/internal/downloader/options.go +++ b/internal/downloader/options.go @@ -16,12 +16,16 @@ type Options struct { Quality string Timeout time.Duration BPM bool + Limit int } func (o *Options) Validate() error { if !validQualities[o.Quality] { return fmt.Errorf("invalid quality option: %s", o.Quality) } + if o.Limit < 0 { + return fmt.Errorf("limit must be a non-negative integer") + } return nil }