refactor(cli): improve output display

This commit is contained in:
Mathis Maquenne
2025-05-19 13:35:06 +02:00
parent 5d18ab2088
commit 0a9e6c9a4d
6 changed files with 182 additions and 42 deletions
+5
View File
@@ -9,9 +9,12 @@ require (
require (
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/fatih/color v1.14.1 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
@@ -25,6 +28,7 @@ require (
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.25.0 // indirect
golang.org/x/text v0.19.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
@@ -33,6 +37,7 @@ require (
require (
github.com/PuerkitoBio/goquery v1.10.0
github.com/bogem/id3v2/v2 v2.1.4
github.com/briandowns/spinner v1.23.2
github.com/flytam/filenamify v1.2.0
github.com/go-flac/flacpicture/v2 v2.0.2
github.com/go-flac/flacvorbis/v2 v2.0.2
+12
View File
@@ -4,11 +4,15 @@ github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsVi
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
github.com/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI=
github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -33,6 +37,11 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
@@ -105,6 +114,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
@@ -113,6 +123,8 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+23
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"path"
"strconv"
"time"
"github.com/flytam/filenamify"
)
@@ -17,6 +19,7 @@ type Album struct {
PhysicalReleaseDate string `json:"PHYSICAL_RELEASE_DATE"`
Label string `json:"LABEL_NAME"`
ProducerLine string `json:"PRODUCER_LINE"`
Duration string `json:"DURATION"`
} `json:"DATA"`
Songs struct {
Data []*Song `json:"data"`
@@ -24,6 +27,26 @@ type Album struct {
} `json:"results"`
}
func (a *Album) String() string {
duration, err := strconv.Atoi(a.Results.Data.Duration)
if err != nil {
duration = 0
}
return fmt.Sprintf(
`================= [ Album Info ] =================
Title: %s
Artist: %s
Tracks: %d
Duration: %s
==================================================`,
a.Results.Data.Title,
a.Results.Data.Artist,
len(a.Results.Songs.Data),
time.Duration(duration)*time.Second,
)
}
func (a *Album) GetType() string {
return "Album"
}
+19 -1
View File
@@ -2,7 +2,9 @@ package deezer
import (
"encoding/json"
"fmt"
"path"
"time"
"github.com/flytam/filenamify"
)
@@ -12,7 +14,8 @@ type Playlist struct {
Data struct {
Title string `json:"TITLE"`
Status int `json:"STATUS"`
CollabKey string `json:"COLLAB_KEY"`
Creator string `json:"PARENT_USERNAME"`
Duration int `json:"DURATION"`
} `json:"DATA"`
Songs struct {
Data []*Song `json:"data"`
@@ -20,6 +23,21 @@ type Playlist struct {
} `json:"results"`
}
func (p *Playlist) String() string {
return fmt.Sprintf(
`=============== [ Playlist Info ] ===============
Title: %s
Creator: %s
Tracks: %d
Duration: %s
=================================================`,
p.Results.Data.Title,
p.Results.Data.Creator,
len(p.Results.Songs.Data),
time.Duration(p.Results.Data.Duration)*time.Second,
)
}
func (p *Playlist) GetType() string {
return "Playlist"
}
+78 -39
View File
@@ -10,6 +10,7 @@ import (
"sync"
"time"
"github.com/briandowns/spinner"
"github.com/mathismqn/godeez/internal/app"
"github.com/mathismqn/godeez/internal/bpm"
"github.com/mathismqn/godeez/internal/crypto"
@@ -51,6 +52,10 @@ func (c *Client) Run(ctx context.Context, opts Options, ids []string) error {
return ctx.Err()
}
downloaded := 0
skipped := 0
failed := 0
var resource deezer.Resource
switch c.resourceType {
@@ -76,29 +81,80 @@ func (c *Client) Run(ctx context.Context, opts Options, ids []string) error {
return fmt.Errorf("failed to create output directory: %w", err)
}
for _, song := range songs {
startTime := time.Now()
fmt.Printf("%s\n\nStarting download...\n\n", resource)
for i, song := range songs {
if ctx.Err() != nil {
return ctx.Err()
}
if err := c.downloadSong(ctx, resource, song, opts, outputDir); err != nil {
trackProgress := fmt.Sprintf("[%d/%d]", i+1, len(songs))
sp := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
sp.Writer = os.Stdout
sp.Prefix = trackProgress + " "
sp.Suffix = fmt.Sprintf(" Downloading: %s - %s", song.Artist, song.Title)
sp.Start()
warnings, err := c.downloadSong(ctx, resource, song, opts, outputDir)
sp.Stop()
if err != nil {
if errors.Is(err, context.Canceled) {
return err
}
fmt.Fprintf(os.Stderr, "Error: failed to download %s: %v\n", song.Title, err)
if path, ok := IsSkipError(err); ok {
skipped++
fmt.Printf("%s ↷ Skipped: %s - %s\n Already exists at: %s\n", trackProgress, song.Artist, song.Title, path)
continue
}
failed++
fmt.Printf("%s ✖ Failed: %s - %s:\n Error: %v\n", trackProgress, song.Artist, song.Title, err)
continue
}
symbol := "✔"
if len(warnings) > 0 {
symbol = "⚠"
}
downloaded++
fmt.Printf("%s %s Downloaded: %s - %s\n", trackProgress, symbol, song.Artist, song.Title)
for _, w := range warnings {
fmt.Printf(" Warning: %s\n", w)
}
}
fmt.Printf(`
================== [ Summary ] ==================
Downloaded: %d
Skipped: %d
Failed: %d
Elapsed time: %s
Files saved to: %s
=================================================
`,
downloaded,
skipped,
failed,
time.Since(startTime).Round(time.Second),
outputDir,
)
}
return nil
}
func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, song *deezer.Song, opts Options, outputDir string) error {
func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, song *deezer.Song, opts Options, outputDir string) ([]string, error) {
var warnings []string
media, err := c.deezerClient.FetchMedia(ctx, song, opts.Quality)
if err != nil {
return err
return warnings, fmt.Errorf("failed to fetch media: %w", err)
}
fileName := song.GetFileName(c.resourceType, song, media)
@@ -106,11 +162,11 @@ func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, son
mediaFormat, err := media.GetFormat()
if err != nil {
return err
return warnings, fmt.Errorf("failed to get media format: %w", err)
}
if _, skip := c.shouldSkipDownload(ctx, song.ID, mediaFormat); skip {
return nil
if path, skip := c.shouldSkipDownload(ctx, song.ID, mediaFormat); skip {
return warnings, SkipError{Path: path}
}
metricsChan := make(chan *bpm.Metrics, 1)
@@ -128,7 +184,7 @@ func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, son
stream, err := c.deezerClient.GetMediaStream(ctx, media, song.ID)
if err != nil {
return fmt.Errorf("media stream unavailable: %w", err)
return warnings, fmt.Errorf("failed to get media stream: %w", err)
}
dlCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
@@ -138,47 +194,26 @@ func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, son
if err := c.streamToFile(dlCtx, stream, outputPath, key); err != nil {
fileutil.DeleteFile(outputPath)
return fmt.Errorf("unable to write to file: %w", err)
return warnings, fmt.Errorf("failed to stream to file: %w", err)
}
metrics := &bpm.Metrics{}
select {
case metrics = <-metricsChan:
fmt.Printf("BPM: %s, Key: %s\n", metrics.BPM, metrics.Key)
case err := <-errChan:
if !errors.Is(err, context.Canceled) {
fmt.Printf("Warning: failed to fetch BPM and key: %v\n", err)
warnings = append(warnings, fmt.Sprintf("failed to fetch BPM and key: %v", err))
}
}
cover, err := c.deezerClient.FetchCoverImage(ctx, song)
if err != nil && !errors.Is(err, context.Canceled) {
fmt.Printf("Warning: failed to fetch cover image: %v\n", err)
warnings = append(warnings, fmt.Sprintf("failed to fetch cover image: %v", err))
}
c.finalizeDownload(resource, song, outputPath, mediaFormat, cover, metrics)
warnings = append(warnings, c.finalizeDownload(resource, song, outputPath, mediaFormat, cover, metrics)...)
return nil
}
func (c *Client) shouldSkipDownload(ctx context.Context, songID, mediaFormat string) (string, bool) {
if existing, err := store.GetDownloadInfo(songID); err == nil && existing.Quality == mediaFormat {
if fileutil.FileExists(existing.Path) {
return existing.Path, true
}
if existing.Hash != "" {
if err := c.initHashIndex(ctx); err == nil {
if foundPath, ok := c.hashIndex.Find(existing.Hash); ok {
existing.Path = foundPath
_ = existing.Save()
return foundPath, true
}
}
}
}
return "", false
return warnings, nil
}
func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error {
@@ -238,14 +273,16 @@ func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputP
return nil
}
func (c *Client) finalizeDownload(resource deezer.Resource, song *deezer.Song, outputPath, mediaFormat string, cover []byte, metrics *bpm.Metrics) {
func (c *Client) finalizeDownload(resource deezer.Resource, song *deezer.Song, outputPath, mediaFormat string, cover []byte, metrics *bpm.Metrics) []string {
var warnings []string
if err := tags.AddTags(resource, song, cover, outputPath, metrics.BPM, metrics.Key); err != nil {
fmt.Printf("Warning: failed to add tags: %v\n", err)
warnings = append(warnings, fmt.Sprintf("failed to add tags: %v", err))
}
hash, err := fileutil.GetFileHash(outputPath)
if err != nil {
fmt.Printf("Warning: failed to get file hash: %v\n", err)
warnings = append(warnings, fmt.Sprintf("failed to get file hash: %v", err))
}
info := &store.DownloadInfo{
@@ -257,8 +294,10 @@ func (c *Client) finalizeDownload(resource deezer.Resource, song *deezer.Song, o
}
if err := info.Save(); err != nil {
fmt.Printf("Warning: failed to save download info: %v\n", err)
warnings = append(warnings, fmt.Sprintf("failed to save download info: %v", err))
}
return warnings
}
func (c *Client) initHashIndex(ctx context.Context) error {
+43
View File
@@ -0,0 +1,43 @@
package downloader
import (
"context"
"github.com/mathismqn/godeez/internal/fileutil"
"github.com/mathismqn/godeez/internal/store"
)
type SkipError struct {
Path string
}
func (e SkipError) Error() string {
return e.Path
}
func IsSkipError(err error) (string, bool) {
if skipErr, ok := err.(SkipError); ok {
return skipErr.Path, true
}
return "", false
}
func (c *Client) shouldSkipDownload(ctx context.Context, songID, mediaFormat string) (string, bool) {
if existing, err := store.GetDownloadInfo(songID); err == nil && existing.Quality == mediaFormat {
if fileutil.FileExists(existing.Path) {
return existing.Path, true
}
if existing.Hash != "" {
if err := c.initHashIndex(ctx); err == nil {
if foundPath, ok := c.hashIndex.Find(existing.Hash); ok {
existing.Path = foundPath
_ = existing.Save()
return foundPath, true
}
}
}
}
return "", false
}