From 5c955548550af11e050fc5c718c6e1a41542cc1f Mon Sep 17 00:00:00 2001 From: Mathis Maquenne <124215603+mathismqn@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:46:10 +0200 Subject: [PATCH] refactor: consolidate download domain into download package --- cmd/download.go | 8 +- internal/{provider => download}/bpm.go | 19 +- internal/download/download.go | 117 ++++++++ internal/{provider => download}/genre.go | 4 +- internal/{downloader => download}/hash.go | 10 +- internal/{downloader => download}/info.go | 2 +- internal/{downloader => download}/metadata.go | 9 +- internal/{downloader => download}/options.go | 2 +- internal/{downloader => download}/progress.go | 2 +- internal/{downloader => download}/skip.go | 12 +- internal/download/stream.go | 64 +++++ internal/{downloader => download}/tagmeta.go | 2 +- internal/download/track.go | 97 +++++++ internal/downloader/client.go | 266 ------------------ 14 files changed, 314 insertions(+), 300 deletions(-) rename internal/{provider => download}/bpm.go (90%) create mode 100644 internal/download/download.go rename internal/{provider => download}/genre.go (97%) rename internal/{downloader => download}/hash.go (82%) rename internal/{downloader => download}/info.go (99%) rename internal/{downloader => download}/metadata.go (79%) rename internal/{downloader => download}/options.go (97%) rename internal/{downloader => download}/progress.go (99%) rename internal/{downloader => download}/skip.go (54%) create mode 100644 internal/download/stream.go rename internal/{downloader => download}/tagmeta.go (98%) create mode 100644 internal/download/track.go delete mode 100644 internal/downloader/client.go diff --git a/cmd/download.go b/cmd/download.go index 7a3ed46..c4c668f 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -9,7 +9,7 @@ import ( "github.com/mathismqn/godeez/internal/config" "github.com/mathismqn/godeez/internal/deezer" - "github.com/mathismqn/godeez/internal/downloader" + "github.com/mathismqn/godeez/internal/download" "github.com/mathismqn/godeez/internal/store" "github.com/spf13/cobra" ) @@ -21,7 +21,7 @@ func newDownloadCmd() *cobra.Command { Annotations: map[string]string{updateNoticeAnnotation: "true"}, } - opts := &downloader.Options{} + opts := &download.Options{} cmd.PersistentFlags().StringVarP(&opts.Quality, "quality", "q", "mp3_320", "download quality [mp3_128, mp3_320, flac]") cmd.PersistentFlags().DurationVarP(&opts.Timeout, "timeout", "t", 2*time.Minute, "timeout for each download (e.g. 10s, 1m, 2m30s)") cmd.PersistentFlags().BoolVar(&opts.BPM, "bpm", false, "fetch BPM/key and add to file tags") @@ -40,7 +40,7 @@ func newDownloadCmd() *cobra.Command { return cmd } -func newDownloadSubCmd(kind deezer.Kind, opts *downloader.Options) *cobra.Command { +func newDownloadSubCmd(kind deezer.Kind, opts *download.Options) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("%s <%s_id>", kind, kind), Short: downloadShort(kind), @@ -62,7 +62,7 @@ func newDownloadSubCmd(kind deezer.Kind, opts *downloader.Options) *cobra.Comman } defer st.Close() - err = downloader.New(cfg, st, kind).Run(cmd.Context(), *opts, args[0]) + err = download.New(cfg, st, kind).Run(cmd.Context(), *opts, args[0]) if errors.Is(err, context.Canceled) { return nil } diff --git a/internal/provider/bpm.go b/internal/download/bpm.go similarity index 90% rename from internal/provider/bpm.go rename to internal/download/bpm.go index ef5119f..bda64b3 100644 --- a/internal/provider/bpm.go +++ b/internal/download/bpm.go @@ -1,4 +1,4 @@ -package provider +package download import ( "bytes" @@ -14,26 +14,21 @@ import ( "github.com/PuerkitoBio/goquery" ) -type BPMKey struct { - BPM string - Key string -} - var ( bpmRegex = regexp.MustCompile(`tempo of ]*>(\d+) BPM`) keyRegex = regexp.MustCompile(`with a ]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?) key`) modeRegex = regexp.MustCompile(`a ]*>([a-z]+) mode`) ) -func FetchBPM(ctx context.Context, httpClient *http.Client, artist, title, duration string) (BPMKey, error) { +func fetchBPM(ctx context.Context, httpClient *http.Client, artist, title, duration string) (bpmKey, error) { trackURL, err := findTrackURL(ctx, httpClient, artist, title, duration) if err != nil { - return BPMKey{}, err + return bpmKey{}, err } html, err := fetchBPMPage(ctx, httpClient, trackURL) if err != nil { - return BPMKey{}, err + return bpmKey{}, err } return parseBPM(html) @@ -137,13 +132,13 @@ func fetchBPMPage(ctx context.Context, httpClient *http.Client, url string) (str return string(body), nil } -func parseBPM(html string) (BPMKey, error) { +func parseBPM(html string) (bpmKey, error) { bpmMatch := bpmRegex.FindStringSubmatch(html) keyMatch := keyRegex.FindStringSubmatch(html) modeMatch := modeRegex.FindStringSubmatch(html) if len(bpmMatch) != 2 || len(keyMatch) != 2 || len(modeMatch) != 2 { - return BPMKey{}, fmt.Errorf("no data found") + return bpmKey{}, fmt.Errorf("no data found") } bpm := bpmMatch[1] @@ -155,5 +150,5 @@ func parseBPM(html string) (BPMKey, error) { key += "m" } - return BPMKey{BPM: bpm, Key: key}, nil + return bpmKey{BPM: bpm, Key: key}, nil } diff --git a/internal/download/download.go b/internal/download/download.go new file mode 100644 index 0000000..c5a0e6e --- /dev/null +++ b/internal/download/download.go @@ -0,0 +1,117 @@ +package download + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/mathismqn/godeez/internal/config" + "github.com/mathismqn/godeez/internal/deezer" + "github.com/mathismqn/godeez/internal/fsutil" + "github.com/mathismqn/godeez/internal/store" +) + +type Downloader struct { + appConfig *config.Config + store *store.Store + kind deezer.Kind + deezerClient *deezer.Client + + hashIndexOnce sync.Once + hashIndex *hashIndex + hashIndexErr error +} + +func New(appConfig *config.Config, st *store.Store, kind deezer.Kind) *Downloader { + return &Downloader{ + appConfig: appConfig, + store: st, + kind: kind, + } +} + +func (d *Downloader) Run(ctx context.Context, opts Options, id string) error { + if err := d.initDeezerClient(ctx, opts); err != nil { + return err + } + + resource, outputDir, err := d.prepareResource(ctx, id, opts) + if err != nil { + return err + } + + return d.downloadAllTracks(ctx, resource, opts, outputDir) +} + +func (d *Downloader) initDeezerClient(ctx context.Context, opts Options) error { + var err error + d.deezerClient, err = deezer.NewClient(ctx, d.appConfig.ARLCookie) + if err != nil { + return err + } + + if !d.deezerClient.Session.Premium && (opts.Quality == "mp3_320" || opts.Quality == "flac") { + return fmt.Errorf("premium account required for '%s' quality", opts.Quality) + } + + return nil +} + +func (d *Downloader) prepareResource(ctx context.Context, id string, opts Options) (deezer.Resource, string, error) { + resource, err := d.deezerClient.FetchResource(ctx, d.kind, id) + if err != nil { + return nil, "", fmt.Errorf("failed to fetch resource: %w", err) + } + + tracks := resource.GetTracks() + if len(tracks) == 0 { + if d.kind == deezer.KindTrack { + return nil, "", fmt.Errorf("track with ID %s not found", id) + } + return nil, "", fmt.Errorf("%s has no tracks", d.kind) + } + + if d.kind == deezer.KindArtist && len(tracks) > opts.Limit { + resource.SetTracks(tracks[:opts.Limit]) + } + + outputDir := resource.GetOutputDir(d.appConfig.OutputDir) + if err := fsutil.EnsureDir(outputDir); err != nil { + return nil, "", fmt.Errorf("failed to create output directory: %w", err) + } + + return resource, outputDir, nil +} + +func (d *Downloader) downloadAllTracks(ctx context.Context, resource deezer.Resource, opts Options, outputDir string) error { + tracks := resource.GetTracks() + startTime := time.Now() + + if d.kind != deezer.KindTrack { + fmt.Printf("%s\n\nStarting download...\n\n", resourceInfo(resource)) + } + + progress := newProgressTracker(len(tracks), d.kind) + + for i, track := range tracks { + if ctx.Err() != nil { + return ctx.Err() + } + + sp := progress.startDownload(i, track) + result := d.downloadTrack(ctx, resource, track, opts, outputDir) + sp.Stop() + + if result.err != nil && errors.Is(result.err, context.Canceled) { + return result.err + } + + progress.handleResult(i, track, result) + } + + progress.printSummary(outputDir, time.Since(startTime)) + + return nil +} diff --git a/internal/provider/genre.go b/internal/download/genre.go similarity index 97% rename from internal/provider/genre.go rename to internal/download/genre.go index a32db83..f98c4a8 100644 --- a/internal/provider/genre.go +++ b/internal/download/genre.go @@ -1,4 +1,4 @@ -package provider +package download import ( "context" @@ -30,7 +30,7 @@ func toLower(ss []string) []string { return out } -func FetchGenre(ctx context.Context, httpClient *http.Client, artist, title string) (string, error) { +func fetchGenre(ctx context.Context, httpClient *http.Client, artist, title string) (string, error) { reqURL := fmt.Sprintf("https://www.last.fm/music/%s/%s/+tags", artist, title) doc, err := fetchGenrePage(ctx, httpClient, reqURL) diff --git a/internal/downloader/hash.go b/internal/download/hash.go similarity index 82% rename from internal/downloader/hash.go rename to internal/download/hash.go index bb52d5d..2d8b29c 100644 --- a/internal/downloader/hash.go +++ b/internal/download/hash.go @@ -1,4 +1,4 @@ -package downloader +package download import ( "context" @@ -59,3 +59,11 @@ func (h *hashIndex) find(hash string) (string, bool) { path, ok := h.files[hash] return path, ok } + +func (d *Downloader) initHashIndex(ctx context.Context) error { + d.hashIndexOnce.Do(func() { + d.hashIndex, d.hashIndexErr = newHashIndex(ctx, d.appConfig.OutputDir) + }) + + return d.hashIndexErr +} diff --git a/internal/downloader/info.go b/internal/download/info.go similarity index 99% rename from internal/downloader/info.go rename to internal/download/info.go index 429e441..ec40bdb 100644 --- a/internal/downloader/info.go +++ b/internal/download/info.go @@ -1,4 +1,4 @@ -package downloader +package download import ( "fmt" diff --git a/internal/downloader/metadata.go b/internal/download/metadata.go similarity index 79% rename from internal/downloader/metadata.go rename to internal/download/metadata.go index 5fb71d6..f910b4f 100644 --- a/internal/downloader/metadata.go +++ b/internal/download/metadata.go @@ -1,4 +1,4 @@ -package downloader +package download import ( "context" @@ -7,7 +7,6 @@ import ( "net/http" "github.com/mathismqn/godeez/internal/deezer" - "github.com/mathismqn/godeez/internal/provider" ) type bpmKey struct { @@ -40,14 +39,14 @@ func fetchMetadata(httpClient *http.Client, ctx context.Context, track *deezer.T if opts.BPM { go func() { - result, err := provider.FetchBPM(ctx, httpClient, track.Artist, track.Title, track.Duration) - bpmChan <- bpmResult{value: bpmKey{BPM: result.BPM, Key: result.Key}, err: err} + result, err := fetchBPM(ctx, httpClient, track.Artist, track.Title, track.Duration) + bpmChan <- bpmResult{value: result, err: err} }() } if opts.Genre { go func() { - genre, err := provider.FetchGenre(ctx, httpClient, track.Artist, track.GetTitle()) + genre, err := fetchGenre(ctx, httpClient, track.Artist, track.GetTitle()) genreChan <- genreResult{value: genre, err: err} }() } diff --git a/internal/downloader/options.go b/internal/download/options.go similarity index 97% rename from internal/downloader/options.go rename to internal/download/options.go index 315bdcf..2f0c9a7 100644 --- a/internal/downloader/options.go +++ b/internal/download/options.go @@ -1,4 +1,4 @@ -package downloader +package download import ( "fmt" diff --git a/internal/downloader/progress.go b/internal/download/progress.go similarity index 99% rename from internal/downloader/progress.go rename to internal/download/progress.go index c451dc1..60e765d 100644 --- a/internal/downloader/progress.go +++ b/internal/download/progress.go @@ -1,4 +1,4 @@ -package downloader +package download import ( "fmt" diff --git a/internal/downloader/skip.go b/internal/download/skip.go similarity index 54% rename from internal/downloader/skip.go rename to internal/download/skip.go index 9b57b11..bbabef3 100644 --- a/internal/downloader/skip.go +++ b/internal/download/skip.go @@ -1,4 +1,4 @@ -package downloader +package download import ( "context" @@ -6,8 +6,8 @@ import ( "github.com/mathismqn/godeez/internal/fsutil" ) -func (c *Client) shouldSkipDownload(ctx context.Context, trackID, mediaFormat string) (string, bool) { - existing, err := c.store.DownloadInfo(trackID) +func (d *Downloader) shouldSkipDownload(ctx context.Context, trackID, mediaFormat string) (string, bool) { + existing, err := d.store.DownloadInfo(trackID) if err != nil || existing.Quality != mediaFormat { return "", false } @@ -20,17 +20,17 @@ func (c *Client) shouldSkipDownload(ctx context.Context, trackID, mediaFormat st return "", false } - if err := c.initHashIndex(ctx); err != nil { + if err := d.initHashIndex(ctx); err != nil { return "", false } - foundPath, ok := c.hashIndex.find(existing.Hash) + foundPath, ok := d.hashIndex.find(existing.Hash) if !ok { return "", false } existing.Path = foundPath - _ = c.store.PutDownloadInfo(existing) + _ = d.store.PutDownloadInfo(existing) return foundPath, true } diff --git a/internal/download/stream.go b/internal/download/stream.go new file mode 100644 index 0000000..bdb0ad4 --- /dev/null +++ b/internal/download/stream.go @@ -0,0 +1,64 @@ +package download + +import ( + "context" + "errors" + "io" + "os" + + "github.com/mathismqn/godeez/internal/deezer" +) + +const chunkSize = 2048 + +func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error { + defer stream.Close() + + file, err := os.Create(outputPath) + if err != nil { + return err + } + defer file.Close() + + buffer := make([]byte, chunkSize) + for chunk := 0; ; chunk++ { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + totalRead := 0 + for totalRead < chunkSize { + n, err := stream.Read(buffer[totalRead:]) + totalRead += n + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return err + } + } + + if totalRead == 0 { + break + } + + if chunk%3 == 0 && totalRead == chunkSize { + buffer, err = deezer.DecryptBlowfish(buffer, key) + if err != nil { + return err + } + } + + if _, err = file.Write(buffer[:totalRead]); err != nil { + return err + } + + if totalRead < chunkSize { + break + } + } + + return nil +} diff --git a/internal/downloader/tagmeta.go b/internal/download/tagmeta.go similarity index 98% rename from internal/downloader/tagmeta.go rename to internal/download/tagmeta.go index 526d081..95827d8 100644 --- a/internal/downloader/tagmeta.go +++ b/internal/download/tagmeta.go @@ -1,4 +1,4 @@ -package downloader +package download import ( "strings" diff --git a/internal/download/track.go b/internal/download/track.go new file mode 100644 index 0000000..cf47b14 --- /dev/null +++ b/internal/download/track.go @@ -0,0 +1,97 @@ +package download + +import ( + "context" + "errors" + "fmt" + "path" + "strings" + "time" + + "github.com/mathismqn/godeez/internal/deezer" + "github.com/mathismqn/godeez/internal/fsutil" + "github.com/mathismqn/godeez/internal/store" + "github.com/mathismqn/godeez/internal/tag" +) + +func (d *Downloader) downloadTrack(ctx context.Context, resource deezer.Resource, track *deezer.Track, opts Options, outputDir string) downloadResult { + media, err := d.deezerClient.FetchMedia(ctx, track, opts.Quality) + if err != nil { + return downloadResult{err: fmt.Errorf("failed to fetch media: %w", err)} + } + + mediaFormat := media.GetFormat() + if opts.Strict && strings.ToLower(mediaFormat) != opts.Quality { + return downloadResult{err: fmt.Errorf("requested quality '%s' not available", opts.Quality)} + } + + if skipPath, skip := d.shouldSkipDownload(ctx, track.ID, mediaFormat); skip { + return downloadResult{skipped: true, path: skipPath} + } + + metadataChan := make(chan metadataResult, 1) + go func() { + metadataChan <- fetchMetadata(d.deezerClient.Session.HttpClient, ctx, track, opts) + }() + + stream, err := d.deezerClient.GetMediaStream(ctx, media) + if err != nil { + return downloadResult{err: fmt.Errorf("failed to get media stream: %w", err)} + } + + dlCtx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + fileName := track.Filename(d.kind, mediaFormat) + outputPath := path.Join(outputDir, fileName) + + key := deezer.BlowfishKey(track.ID) + if err := d.streamToFile(dlCtx, stream, outputPath, key); err != nil { + fsutil.Remove(outputPath) + return downloadResult{err: fmt.Errorf("failed to stream to file: %w", err)} + } + + var warnings []string + + if opts.Quality != strings.ToLower(mediaFormat) { + warnings = append(warnings, fmt.Sprintf("requested quality '%s' not available, using '%s' instead", opts.Quality, strings.ToLower(mediaFormat))) + } + + cover, err := d.deezerClient.FetchCoverImage(ctx, track) + if err != nil && !errors.Is(err, context.Canceled) { + warnings = append(warnings, fmt.Sprintf("failed to fetch cover image: %v", err)) + } + + metadata := <-metadataChan + warnings = append(warnings, metadata.warnings...) + warnings = append(warnings, d.finalizeDownload(resource, track, outputPath, mediaFormat, metadata.genre, cover, metadata.bpmKey)...) + + return downloadResult{warnings: warnings} +} + +func (d *Downloader) finalizeDownload(resource deezer.Resource, track *deezer.Track, outputPath, mediaFormat, genre string, cover []byte, bpmKey bpmKey) []string { + var warnings []string + + if err := tag.Write(outputPath, buildTagMetadata(resource, track, cover, bpmKey, genre)); err != nil { + warnings = append(warnings, fmt.Sprintf("failed to add tags: %v", err)) + } + + hash, err := hashFile(outputPath) + if err != nil { + warnings = append(warnings, fmt.Sprintf("failed to get file hash: %v", err)) + } + + info := &store.DownloadInfo{ + TrackID: track.ID, + Quality: mediaFormat, + Path: outputPath, + Hash: hash, + Downloaded: time.Now(), + } + + if err := d.store.PutDownloadInfo(info); err != nil { + warnings = append(warnings, fmt.Sprintf("failed to save download info: %v", err)) + } + + return warnings +} diff --git a/internal/downloader/client.go b/internal/downloader/client.go deleted file mode 100644 index 6b7f6df..0000000 --- a/internal/downloader/client.go +++ /dev/null @@ -1,266 +0,0 @@ -package downloader - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "path" - "strings" - "sync" - "time" - - "github.com/mathismqn/godeez/internal/config" - "github.com/mathismqn/godeez/internal/deezer" - "github.com/mathismqn/godeez/internal/fsutil" - "github.com/mathismqn/godeez/internal/store" - "github.com/mathismqn/godeez/internal/tag" -) - -const chunkSize = 2048 - -type Client struct { - appConfig *config.Config - store *store.Store - kind deezer.Kind - deezerClient *deezer.Client - - hashIndexOnce sync.Once - hashIndex *hashIndex - hashIndexErr error -} - -func New(appConfig *config.Config, st *store.Store, kind deezer.Kind) *Client { - return &Client{ - appConfig: appConfig, - store: st, - kind: kind, - } -} - -func (c *Client) Run(ctx context.Context, opts Options, id string) error { - if err := c.initDeezerClient(ctx, opts); err != nil { - return err - } - - resource, outputDir, err := c.prepareResource(ctx, id, opts) - if err != nil { - return err - } - - return c.downloadAllTracks(ctx, resource, opts, outputDir) -} - -func (c *Client) initDeezerClient(ctx context.Context, opts Options) error { - var err error - c.deezerClient, err = deezer.NewClient(ctx, c.appConfig.ARLCookie) - if err != nil { - return err - } - - if !c.deezerClient.Session.Premium && (opts.Quality == "mp3_320" || opts.Quality == "flac") { - return fmt.Errorf("premium account required for '%s' quality", opts.Quality) - } - - return nil -} - -func (c *Client) prepareResource(ctx context.Context, id string, opts Options) (deezer.Resource, string, error) { - resource, err := c.deezerClient.FetchResource(ctx, c.kind, id) - if err != nil { - return nil, "", fmt.Errorf("failed to fetch resource: %w", err) - } - - tracks := resource.GetTracks() - if len(tracks) == 0 { - if c.kind == deezer.KindTrack { - return nil, "", fmt.Errorf("track with ID %s not found", id) - } - return nil, "", fmt.Errorf("%s has no tracks", c.kind) - } - - if c.kind == deezer.KindArtist && len(tracks) > opts.Limit { - resource.SetTracks(tracks[:opts.Limit]) - } - - outputDir := resource.GetOutputDir(c.appConfig.OutputDir) - if err := fsutil.EnsureDir(outputDir); err != nil { - return nil, "", fmt.Errorf("failed to create output directory: %w", err) - } - - return resource, outputDir, nil -} - -func (c *Client) downloadAllTracks(ctx context.Context, resource deezer.Resource, opts Options, outputDir string) error { - tracks := resource.GetTracks() - startTime := time.Now() - - if c.kind != deezer.KindTrack { - fmt.Printf("%s\n\nStarting download...\n\n", resourceInfo(resource)) - } - - progress := newProgressTracker(len(tracks), c.kind) - - for i, track := range tracks { - if ctx.Err() != nil { - return ctx.Err() - } - - sp := progress.startDownload(i, track) - result := c.downloadTrack(ctx, resource, track, opts, outputDir) - sp.Stop() - - if result.err != nil && errors.Is(result.err, context.Canceled) { - return result.err - } - - progress.handleResult(i, track, result) - } - - progress.printSummary(outputDir, time.Since(startTime)) - - return nil -} - -func (c *Client) downloadTrack(ctx context.Context, resource deezer.Resource, track *deezer.Track, opts Options, outputDir string) downloadResult { - media, err := c.deezerClient.FetchMedia(ctx, track, opts.Quality) - if err != nil { - return downloadResult{err: fmt.Errorf("failed to fetch media: %w", err)} - } - - mediaFormat := media.GetFormat() - if opts.Strict && strings.ToLower(mediaFormat) != opts.Quality { - return downloadResult{err: fmt.Errorf("requested quality '%s' not available", opts.Quality)} - } - - if skipPath, skip := c.shouldSkipDownload(ctx, track.ID, mediaFormat); skip { - return downloadResult{skipped: true, path: skipPath} - } - - metadataChan := make(chan metadataResult, 1) - go func() { - metadataChan <- fetchMetadata(c.deezerClient.Session.HttpClient, ctx, track, opts) - }() - - stream, err := c.deezerClient.GetMediaStream(ctx, media) - if err != nil { - return downloadResult{err: fmt.Errorf("failed to get media stream: %w", err)} - } - - dlCtx, cancel := context.WithTimeout(ctx, opts.Timeout) - defer cancel() - - fileName := track.Filename(c.kind, mediaFormat) - outputPath := path.Join(outputDir, fileName) - - key := deezer.BlowfishKey(track.ID) - if err := c.streamToFile(dlCtx, stream, outputPath, key); err != nil { - fsutil.Remove(outputPath) - return downloadResult{err: fmt.Errorf("failed to stream to file: %w", err)} - } - - var warnings []string - - if opts.Quality != strings.ToLower(mediaFormat) { - warnings = append(warnings, fmt.Sprintf("requested quality '%s' not available, using '%s' instead", opts.Quality, strings.ToLower(mediaFormat))) - } - - cover, err := c.deezerClient.FetchCoverImage(ctx, track) - if err != nil && !errors.Is(err, context.Canceled) { - warnings = append(warnings, fmt.Sprintf("failed to fetch cover image: %v", err)) - } - - metadata := <-metadataChan - warnings = append(warnings, metadata.warnings...) - warnings = append(warnings, c.finalizeDownload(resource, track, outputPath, mediaFormat, metadata.genre, cover, metadata.bpmKey)...) - - return downloadResult{warnings: warnings} -} - -func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error { - defer stream.Close() - - file, err := os.Create(outputPath) - if err != nil { - return err - } - defer file.Close() - - buffer := make([]byte, chunkSize) - for chunk := 0; ; chunk++ { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - totalRead := 0 - for totalRead < chunkSize { - n, err := stream.Read(buffer[totalRead:]) - totalRead += n - if err != nil { - if errors.Is(err, io.EOF) { - break - } - return err - } - } - - if totalRead == 0 { - break - } - - if chunk%3 == 0 && totalRead == chunkSize { - buffer, err = deezer.DecryptBlowfish(buffer, key) - if err != nil { - return err - } - } - - if _, err = file.Write(buffer[:totalRead]); err != nil { - return err - } - - if totalRead < chunkSize { - break - } - } - - return nil -} - -func (c *Client) finalizeDownload(resource deezer.Resource, track *deezer.Track, outputPath, mediaFormat, genre string, cover []byte, bpmKey bpmKey) []string { - var warnings []string - - if err := tag.Write(outputPath, buildTagMetadata(resource, track, cover, bpmKey, genre)); err != nil { - warnings = append(warnings, fmt.Sprintf("failed to add tags: %v", err)) - } - - hash, err := hashFile(outputPath) - if err != nil { - warnings = append(warnings, fmt.Sprintf("failed to get file hash: %v", err)) - } - - info := &store.DownloadInfo{ - TrackID: track.ID, - Quality: mediaFormat, - Path: outputPath, - Hash: hash, - Downloaded: time.Now(), - } - - if err := c.store.PutDownloadInfo(info); err != nil { - warnings = append(warnings, fmt.Sprintf("failed to save download info: %v", err)) - } - - return warnings -} - -func (c *Client) initHashIndex(ctx context.Context) error { - c.hashIndexOnce.Do(func() { - c.hashIndex, c.hashIndexErr = newHashIndex(ctx, c.appConfig.OutputDir) - }) - - return c.hashIndexErr -}