diff --git a/go.mod b/go.mod index ff120d0..cbcfbdc 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index c8d9eab..ed54da1 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/deezer/album.go b/internal/deezer/album.go index 611f87d..789c0a6 100644 --- a/internal/deezer/album.go +++ b/internal/deezer/album.go @@ -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" } diff --git a/internal/deezer/playlist.go b/internal/deezer/playlist.go index 8ee84d8..61c596b 100644 --- a/internal/deezer/playlist.go +++ b/internal/deezer/playlist.go @@ -2,7 +2,9 @@ package deezer import ( "encoding/json" + "fmt" "path" + "time" "github.com/flytam/filenamify" ) @@ -10,9 +12,10 @@ import ( type Playlist struct { Results struct { Data struct { - Title string `json:"TITLE"` - Status int `json:"STATUS"` - CollabKey string `json:"COLLAB_KEY"` + Title string `json:"TITLE"` + Status int `json:"STATUS"` + 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" } diff --git a/internal/downloader/client.go b/internal/downloader/client.go index 9f93779..8347d01 100644 --- a/internal/downloader/client.go +++ b/internal/downloader/client.go @@ -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 { diff --git a/internal/downloader/skip.go b/internal/downloader/skip.go new file mode 100644 index 0000000..76a2fa4 --- /dev/null +++ b/internal/downloader/skip.go @@ -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 +}