feat: add wav download quality

This commit is contained in:
Mathis Maquenne
2026-08-05 22:51:43 +02:00
parent b3777bf8eb
commit b804c5e288
8 changed files with 109 additions and 32 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ func newDownloadCmd() *cobra.Command {
} }
opts := &download.Options{} 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().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.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") cmd.PersistentFlags().BoolVar(&opts.Genre, "genre", false, "fetch genre and add to file tags")
+5 -2
View File
@@ -47,10 +47,13 @@ func (t *Track) FullTitle() string {
return t.Title return t.Title
} }
func (t *Track) Filename(kind Kind, mediaFormat string) string { func (t *Track) Filename(kind Kind, format string) string {
ext := "mp3" ext := "mp3"
if mediaFormat == "FLAC" { switch format {
case "FLAC":
ext = "flac" ext = "flac"
case "WAV":
ext = "wav"
} }
prefix := "" prefix := ""
+7
View File
@@ -60,6 +60,13 @@ func TestFilename(t *testing.T) {
mediaFormat: "FLAC", mediaFormat: "FLAC",
want: "Artist - Song.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", name: "version appended",
track: Track{Artist: "Artist", Title: "Song", Version: "(Live)"}, track: Track{Artist: "Artist", Title: "Song", Version: "(Live)"},
+1 -1
View File
@@ -52,7 +52,7 @@ func (d *Downloader) initDeezerClient(ctx context.Context, opts Options) error {
return err 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) return fmt.Errorf("premium account required for '%s' quality", opts.Quality)
} }
+12
View File
@@ -12,6 +12,7 @@ var validQualities = map[string]bool{
"mp3_128": true, "mp3_128": true,
"mp3_320": true, "mp3_320": true,
"flac": true, "flac": true,
"wav": true,
} }
type Options struct { type Options struct {
@@ -23,6 +24,17 @@ type Options struct {
Strict bool 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 { func (o *Options) Validate(kind deezer.Kind) error {
if !validQualities[o.Quality] { if !validQualities[o.Quality] {
return fmt.Errorf("invalid quality option: %s", o.Quality) return fmt.Errorf("invalid quality option: %s", o.Quality)
+27
View File
@@ -7,6 +7,32 @@ import (
"github.com/mathismqn/godeez/internal/deezer" "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) { func TestOptionsValidate(t *testing.T) {
valid := Options{Quality: "mp3_320", Timeout: time.Minute, Limit: 10} 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}, {"valid", func(o *Options) {}, deezer.KindAlbum, false},
{"mp3_128", func(o *Options) { o.Quality = "mp3_128" }, 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}, {"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}, {"invalid quality", func(o *Options) { o.Quality = "ogg" }, deezer.KindAlbum, true},
{"uppercase quality", func(o *Options) { o.Quality = "MP3_320" }, 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}, {"zero timeout", func(o *Options) { o.Timeout = 0 }, deezer.KindAlbum, true},
+27 -18
View File
@@ -8,15 +8,13 @@ import (
"path/filepath" "path/filepath"
"github.com/mathismqn/godeez/internal/deezer" "github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/fsutil"
) )
const ( const chunkSize = 2048
chunkSize = 2048
partPattern = ".godeez-*.part"
)
func sweepPartFiles(dir string) { func sweepPartFiles(dir string) {
matches, err := filepath.Glob(filepath.Join(dir, partPattern)) matches, err := filepath.Glob(filepath.Join(dir, fsutil.PartPattern))
if err != nil { if err != nil {
return return
} }
@@ -26,12 +24,26 @@ func sweepPartFiles(dir string) {
} }
func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error { func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error {
defer stream.Close() tmpPath, err := d.streamToTempFile(ctx, stream, filepath.Dir(outputPath), key)
file, err := os.CreateTemp(filepath.Dir(outputPath), partPattern)
if err != nil { if err != nil {
return err 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() tmpPath := file.Name()
done := false done := false
defer func() { defer func() {
@@ -45,7 +57,7 @@ func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, out
for chunk := 0; ; chunk++ { for chunk := 0; ; chunk++ {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return "", ctx.Err()
default: default:
} }
@@ -57,7 +69,7 @@ func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, out
if errors.Is(err, io.EOF) { if errors.Is(err, io.EOF) {
break 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 { if chunk%3 == 0 && totalRead == chunkSize {
buffer, err = deezer.DecryptBlowfish(buffer, key) buffer, err = deezer.DecryptBlowfish(buffer, key)
if err != nil { if err != nil {
return err return "", err
} }
} }
if _, err = file.Write(buffer[:totalRead]); err != nil { if _, err = file.Write(buffer[:totalRead]); err != nil {
return err return "", err
} }
if totalRead < chunkSize { if totalRead < chunkSize {
@@ -82,15 +94,12 @@ func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, out
} }
if err := file.Sync(); err != nil { if err := file.Sync(); err != nil {
return err return "", err
} }
if err := file.Close(); err != nil { if err := file.Close(); err != nil {
return err return "", err
}
if err := os.Rename(tmpPath, outputPath); err != nil {
return err
} }
done = true done = true
return nil return tmpPath, nil
} }
+29 -10
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/mathismqn/godeez/internal/audio"
"github.com/mathismqn/godeez/internal/deezer" "github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/fsutil" "github.com/mathismqn/godeez/internal/fsutil"
"github.com/mathismqn/godeez/internal/store" "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 { 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 { if err != nil {
return downloadResult{err: fmt.Errorf("failed to fetch media: %w", err)} return downloadResult{err: fmt.Errorf("failed to fetch media: %w", err)}
} }
mediaFormat := media.Format() 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)} 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} 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)} 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)) outputPath := d.uniqueOutputPath(track.ID, filepath.Join(outputDir, fileName))
key := deezer.BlowfishKey(track.ID) 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)} return downloadResult{err: fmt.Errorf("failed to stream to file: %w", err)}
} }
var warnings []string var warnings []string
if 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(mediaFormat))) 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) 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, 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} return downloadResult{warnings: warnings}
} }
@@ -90,7 +109,7 @@ func (d *Downloader) uniqueOutputPath(trackID, path string) string {
return candidate 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 var warnings []string
if err := tag.Write(outputPath, buildTagMetadata(resource, track, cover, bpmKey, genre)); err != nil { 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{ info := &store.DownloadInfo{
TrackID: track.ID, TrackID: track.ID,
Quality: mediaFormat, Quality: outputFormat,
Path: outputPath, Path: outputPath,
Hash: hash, Hash: hash,
Downloaded: time.Now(), Downloaded: time.Now(),