diff --git a/cmd/download.go b/cmd/download.go index b88cc62..09dc29c 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -22,7 +22,7 @@ func newDownloadCmd() *cobra.Command { } opts := &download.Options{} - cmd.PersistentFlags().StringVarP(&opts.Quality, "quality", "q", "mp3_320", "download quality [mp3_128, mp3_320, flac]") + cmd.PersistentFlags().StringVarP(&opts.Quality, "quality", "q", "mp3_320", "download quality [mp3_128, mp3_320, flac, wav]") 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") cmd.PersistentFlags().BoolVar(&opts.Genre, "genre", false, "fetch genre and add to file tags") diff --git a/internal/deezer/track.go b/internal/deezer/track.go index 09157c4..45b4d7c 100644 --- a/internal/deezer/track.go +++ b/internal/deezer/track.go @@ -47,10 +47,13 @@ func (t *Track) FullTitle() string { return t.Title } -func (t *Track) Filename(kind Kind, mediaFormat string) string { +func (t *Track) Filename(kind Kind, format string) string { ext := "mp3" - if mediaFormat == "FLAC" { + switch format { + case "FLAC": ext = "flac" + case "WAV": + ext = "wav" } prefix := "" diff --git a/internal/deezer/track_test.go b/internal/deezer/track_test.go index 44d3af4..441bf44 100644 --- a/internal/deezer/track_test.go +++ b/internal/deezer/track_test.go @@ -60,6 +60,13 @@ func TestFilename(t *testing.T) { mediaFormat: "FLAC", want: "Artist - Song.flac", }, + { + name: "wav extension", + track: Track{Artist: "Artist", Title: "Song"}, + kind: KindTrack, + mediaFormat: "WAV", + want: "Artist - Song.wav", + }, { name: "version appended", track: Track{Artist: "Artist", Title: "Song", Version: "(Live)"}, diff --git a/internal/download/download.go b/internal/download/download.go index 93172a8..730b5fe 100644 --- a/internal/download/download.go +++ b/internal/download/download.go @@ -52,7 +52,7 @@ func (d *Downloader) initDeezerClient(ctx context.Context, opts Options) error { return err } - if !d.deezerClient.Session.Premium && (opts.Quality == "mp3_320" || opts.Quality == "flac") { + if !d.deezerClient.Session.Premium && opts.sourceQuality() != "mp3_128" { return fmt.Errorf("premium account required for '%s' quality", opts.Quality) } diff --git a/internal/download/options.go b/internal/download/options.go index 881d79c..b16cc0e 100644 --- a/internal/download/options.go +++ b/internal/download/options.go @@ -12,6 +12,7 @@ var validQualities = map[string]bool{ "mp3_128": true, "mp3_320": true, "flac": true, + "wav": true, } type Options struct { @@ -23,6 +24,17 @@ type Options struct { Strict bool } +func (o *Options) sourceQuality() string { + if o.Quality == "wav" { + return "flac" + } + return o.Quality +} + +func (o *Options) convertsToWAV() bool { + return o.Quality == "wav" +} + func (o *Options) Validate(kind deezer.Kind) error { if !validQualities[o.Quality] { return fmt.Errorf("invalid quality option: %s", o.Quality) diff --git a/internal/download/options_test.go b/internal/download/options_test.go index 791a262..d7a7b1e 100644 --- a/internal/download/options_test.go +++ b/internal/download/options_test.go @@ -7,6 +7,32 @@ import ( "github.com/mathismqn/godeez/internal/deezer" ) +func TestOptionsSourceQuality(t *testing.T) { + tests := []struct { + quality string + want string + wantWAV bool + }{ + {"mp3_128", "mp3_128", false}, + {"mp3_320", "mp3_320", false}, + {"flac", "flac", false}, + {"wav", "flac", true}, + } + + for _, tt := range tests { + t.Run(tt.quality, func(t *testing.T) { + opts := Options{Quality: tt.quality} + + if got := opts.sourceQuality(); got != tt.want { + t.Errorf("sourceQuality() = %q, want %q", got, tt.want) + } + if got := opts.convertsToWAV(); got != tt.wantWAV { + t.Errorf("convertsToWAV() = %v, want %v", got, tt.wantWAV) + } + }) + } +} + func TestOptionsValidate(t *testing.T) { valid := Options{Quality: "mp3_320", Timeout: time.Minute, Limit: 10} @@ -19,6 +45,7 @@ func TestOptionsValidate(t *testing.T) { {"valid", func(o *Options) {}, deezer.KindAlbum, false}, {"mp3_128", func(o *Options) { o.Quality = "mp3_128" }, deezer.KindAlbum, false}, {"flac", func(o *Options) { o.Quality = "flac" }, deezer.KindAlbum, false}, + {"wav", func(o *Options) { o.Quality = "wav" }, deezer.KindAlbum, false}, {"invalid quality", func(o *Options) { o.Quality = "ogg" }, deezer.KindAlbum, true}, {"uppercase quality", func(o *Options) { o.Quality = "MP3_320" }, deezer.KindAlbum, true}, {"zero timeout", func(o *Options) { o.Timeout = 0 }, deezer.KindAlbum, true}, diff --git a/internal/download/stream.go b/internal/download/stream.go index 8170ffb..8d3e65a 100644 --- a/internal/download/stream.go +++ b/internal/download/stream.go @@ -8,15 +8,13 @@ import ( "path/filepath" "github.com/mathismqn/godeez/internal/deezer" + "github.com/mathismqn/godeez/internal/fsutil" ) -const ( - chunkSize = 2048 - partPattern = ".godeez-*.part" -) +const chunkSize = 2048 func sweepPartFiles(dir string) { - matches, err := filepath.Glob(filepath.Join(dir, partPattern)) + matches, err := filepath.Glob(filepath.Join(dir, fsutil.PartPattern)) if err != nil { return } @@ -26,12 +24,26 @@ func sweepPartFiles(dir string) { } func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error { - defer stream.Close() - - file, err := os.CreateTemp(filepath.Dir(outputPath), partPattern) + tmpPath, err := d.streamToTempFile(ctx, stream, filepath.Dir(outputPath), key) if err != nil { return err } + + if err := os.Rename(tmpPath, outputPath); err != nil { + os.Remove(tmpPath) + return err + } + + return nil +} + +func (d *Downloader) streamToTempFile(ctx context.Context, stream io.ReadCloser, dir string, key []byte) (string, error) { + defer stream.Close() + + file, err := os.CreateTemp(dir, fsutil.PartPattern) + if err != nil { + return "", err + } tmpPath := file.Name() done := false defer func() { @@ -45,7 +57,7 @@ func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, out for chunk := 0; ; chunk++ { select { case <-ctx.Done(): - return ctx.Err() + return "", ctx.Err() default: } @@ -57,7 +69,7 @@ func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, out if errors.Is(err, io.EOF) { break } - return err + return "", err } } @@ -68,12 +80,12 @@ func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, out if chunk%3 == 0 && totalRead == chunkSize { buffer, err = deezer.DecryptBlowfish(buffer, key) if err != nil { - return err + return "", err } } if _, err = file.Write(buffer[:totalRead]); err != nil { - return err + return "", err } if totalRead < chunkSize { @@ -82,15 +94,12 @@ func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, out } if err := file.Sync(); err != nil { - return err + return "", err } if err := file.Close(); err != nil { - return err - } - if err := os.Rename(tmpPath, outputPath); err != nil { - return err + return "", err } done = true - return nil + return tmpPath, nil } diff --git a/internal/download/track.go b/internal/download/track.go index d2787d2..35f6e49 100644 --- a/internal/download/track.go +++ b/internal/download/track.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/mathismqn/godeez/internal/audio" "github.com/mathismqn/godeez/internal/deezer" "github.com/mathismqn/godeez/internal/fsutil" "github.com/mathismqn/godeez/internal/store" @@ -15,17 +16,25 @@ import ( ) 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) + media, err := d.deezerClient.FetchMedia(ctx, track, opts.sourceQuality()) if err != nil { return downloadResult{err: fmt.Errorf("failed to fetch media: %w", err)} } mediaFormat := media.Format() - if opts.Strict && strings.ToLower(mediaFormat) != opts.Quality { + outputFormat := mediaFormat + if opts.convertsToWAV() { + if mediaFormat != "FLAC" { + return downloadResult{err: fmt.Errorf("wav requires a flac source, but only '%s' is available", strings.ToLower(mediaFormat))} + } + outputFormat = "WAV" + } + + if opts.Strict && strings.ToLower(outputFormat) != opts.Quality { return downloadResult{err: fmt.Errorf("requested quality '%s' not available", opts.Quality)} } - if skipPath, skip := d.shouldSkipDownload(ctx, track.ID, mediaFormat); skip { + if skipPath, skip := d.shouldSkipDownload(ctx, track.ID, outputFormat); skip { return downloadResult{skipped: true, path: skipPath} } @@ -42,18 +51,28 @@ func (d *Downloader) downloadTrack(ctx context.Context, resource deezer.Resource return downloadResult{err: fmt.Errorf("failed to get media stream: %w", err)} } - fileName := track.Filename(d.kind, mediaFormat) + fileName := track.Filename(d.kind, outputFormat) outputPath := d.uniqueOutputPath(track.ID, filepath.Join(outputDir, fileName)) key := deezer.BlowfishKey(track.ID) - if err := d.streamToFile(dlCtx, stream, outputPath, key); err != nil { + if opts.convertsToWAV() { + tmpPath, err := d.streamToTempFile(dlCtx, stream, outputDir, key) + if err != nil { + return downloadResult{err: fmt.Errorf("failed to stream to file: %w", err)} + } + defer fsutil.Remove(tmpPath) + + if err := audio.FLACToWAV(ctx, tmpPath, outputPath); err != nil { + return downloadResult{err: fmt.Errorf("failed to convert to wav: %w", err)} + } + } else if err := d.streamToFile(dlCtx, stream, outputPath, key); err != nil { 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))) + if opts.Quality != strings.ToLower(outputFormat) { + warnings = append(warnings, fmt.Sprintf("requested quality '%s' not available, using '%s' instead", opts.Quality, strings.ToLower(outputFormat))) } cover, err := d.deezerClient.FetchCoverImage(ctx, track) @@ -69,7 +88,7 @@ func (d *Downloader) downloadTrack(ctx context.Context, resource deezer.Resource } warnings = append(warnings, metadata.warnings...) - warnings = append(warnings, d.finalizeDownload(resource, track, outputPath, mediaFormat, metadata.genre, cover, metadata.bpmKey)...) + warnings = append(warnings, d.finalizeDownload(resource, track, outputPath, outputFormat, metadata.genre, cover, metadata.bpmKey)...) return downloadResult{warnings: warnings} } @@ -90,7 +109,7 @@ func (d *Downloader) uniqueOutputPath(trackID, path string) string { return candidate } -func (d *Downloader) finalizeDownload(resource deezer.Resource, track *deezer.Track, outputPath, mediaFormat, genre string, cover []byte, bpmKey bpmKey) []string { +func (d *Downloader) finalizeDownload(resource deezer.Resource, track *deezer.Track, outputPath, outputFormat, genre string, cover []byte, bpmKey bpmKey) []string { var warnings []string if err := tag.Write(outputPath, buildTagMetadata(resource, track, cover, bpmKey, genre)); err != nil { @@ -104,7 +123,7 @@ func (d *Downloader) finalizeDownload(resource deezer.Resource, track *deezer.Tr info := &store.DownloadInfo{ TrackID: track.ID, - Quality: mediaFormat, + Quality: outputFormat, Path: outputPath, Hash: hash, Downloaded: time.Now(),