refactor(cli): improve output display
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user