From b3777bf8eb0c365672f89c445b4b153b64d1a65e Mon Sep 17 00:00:00 2001 From: Mathis Maquenne <124215603+mathismqn@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:51:43 +0200 Subject: [PATCH 1/4] feat: add lossless flac to wav converter --- go.mod | 4 + go.sum | 10 ++ internal/audio/wav.go | 189 ++++++++++++++++++++++++ internal/audio/wav_test.go | 291 +++++++++++++++++++++++++++++++++++++ internal/fsutil/fsutil.go | 2 + 5 files changed, 496 insertions(+) create mode 100644 internal/audio/wav.go create mode 100644 internal/audio/wav_test.go diff --git a/go.mod b/go.mod index 63fbbba..7143ea7 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/mathismqn/godeez go 1.25.0 require ( + github.com/mewkiz/flac v1.0.13 github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.8 golang.org/x/mod v0.38.0 @@ -14,8 +15,11 @@ require ( github.com/danieljoos/wincred v1.2.3 // indirect github.com/fatih/color v1.19.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/icza/bitio v1.1.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect + github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect diff --git a/go.sum b/go.sum index e5a3c5e..97842b3 100644 --- a/go.sum +++ b/go.sum @@ -23,12 +23,22 @@ github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sY github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0= +github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs= +github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k= +github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU= +github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI= +github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8= +github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/audio/wav.go b/internal/audio/wav.go new file mode 100644 index 0000000..4778568 --- /dev/null +++ b/internal/audio/wav.go @@ -0,0 +1,189 @@ +package audio + +import ( + "bufio" + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "math" + "os" + "path/filepath" + + "github.com/mathismqn/godeez/internal/fsutil" + "github.com/mewkiz/flac" +) + +const ( + headerSize = 44 + formatPCM = 1 + ctxCheckInterval = 64 + maxDataSize = math.MaxUint32 - (headerSize - 8) +) + +func FLACToWAV(ctx context.Context, srcPath, dstPath string) error { + stream, err := flac.Open(srcPath) + if err != nil { + return err + } + defer stream.Close() + + info := stream.Info + bytesPerSample, err := bytesPerSample(info.BitsPerSample) + if err != nil { + return err + } + if info.NChannels < 1 || info.NChannels > 2 { + return fmt.Errorf("unsupported channel count: %d", info.NChannels) + } + if size := int64(info.NSamples) * int64(info.NChannels) * int64(bytesPerSample); size > maxDataSize { + return fmt.Errorf("audio data of %d bytes exceeds the wav format limit", size) + } + + file, err := os.CreateTemp(filepath.Dir(dstPath), fsutil.PartPattern) + if err != nil { + return err + } + tmpPath := file.Name() + done := false + defer func() { + if !done { + file.Close() + os.Remove(tmpPath) + } + }() + + w := bufio.NewWriter(file) + if err := writeHeader(w, info.SampleRate, info.NChannels, info.BitsPerSample, 0); err != nil { + return err + } + + dataSize, err := writeSamples(ctx, w, stream, int(info.NChannels), bytesPerSample) + if err != nil { + return err + } + if dataSize > maxDataSize { + return fmt.Errorf("audio data of %d bytes exceeds the wav format limit", dataSize) + } + if dataSize%2 != 0 { + if err := w.WriteByte(0); err != nil { + return err + } + } + if err := w.Flush(); err != nil { + return err + } + if err := patchSizes(file, dataSize); err != nil { + return err + } + + if err := file.Sync(); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, dstPath); err != nil { + return err + } + done = true + + return nil +} + +func bytesPerSample(bitsPerSample uint8) (int, error) { + switch bitsPerSample { + case 8, 16, 24: + return int(bitsPerSample) / 8, nil + default: + return 0, fmt.Errorf("unsupported bit depth: %d", bitsPerSample) + } +} + +func writeHeader(w io.Writer, sampleRate uint32, nChannels, bitsPerSample uint8, dataSize uint32) error { + blockAlign := uint32(nChannels) * uint32(bitsPerSample) / 8 + + header := make([]byte, 0, headerSize) + header = append(header, "RIFF"...) + header = binary.LittleEndian.AppendUint32(header, uint32(headerSize-8)+dataSize) + header = append(header, "WAVE"...) + header = append(header, "fmt "...) + header = binary.LittleEndian.AppendUint32(header, 16) + header = binary.LittleEndian.AppendUint16(header, formatPCM) + header = binary.LittleEndian.AppendUint16(header, uint16(nChannels)) + header = binary.LittleEndian.AppendUint32(header, sampleRate) + header = binary.LittleEndian.AppendUint32(header, sampleRate*blockAlign) + header = binary.LittleEndian.AppendUint16(header, uint16(blockAlign)) + header = binary.LittleEndian.AppendUint16(header, uint16(bitsPerSample)) + header = append(header, "data"...) + header = binary.LittleEndian.AppendUint32(header, dataSize) + + _, err := w.Write(header) + + return err +} + +func writeSamples(ctx context.Context, w io.Writer, stream *flac.Stream, nChannels, bytesPerSample int) (int64, error) { + var dataSize int64 + buf := make([]byte, 4) + + for i := 0; ; i++ { + if i%ctxCheckInterval == 0 { + select { + case <-ctx.Done(): + return dataSize, ctx.Err() + default: + } + } + + frame, err := stream.ParseNext() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return dataSize, err + } + if len(frame.Subframes) != nChannels { + return dataSize, fmt.Errorf("frame %d has %d channels, want %d", frame.Num, len(frame.Subframes), nChannels) + } + + for i := range frame.Subframes[0].Samples { + for _, subframe := range frame.Subframes { + putSample(buf, subframe.Samples[i], bytesPerSample) + if _, err := w.Write(buf[:bytesPerSample]); err != nil { + return dataSize, err + } + dataSize += int64(bytesPerSample) + } + } + } + + return dataSize, nil +} + +func putSample(buf []byte, sample int32, bytesPerSample int) { + if bytesPerSample == 1 { + buf[0] = byte(sample + 128) + return + } + + value := uint32(sample) + for i := range bytesPerSample { + buf[i] = byte(value >> (8 * i)) + } +} + +func patchSizes(file *os.File, dataSize int64) error { + buf := make([]byte, 4) + + binary.LittleEndian.PutUint32(buf, uint32(headerSize-8+dataSize+dataSize%2)) + if _, err := file.WriteAt(buf, 4); err != nil { + return err + } + + binary.LittleEndian.PutUint32(buf, uint32(dataSize)) + _, err := file.WriteAt(buf, headerSize-4) + + return err +} diff --git a/internal/audio/wav_test.go b/internal/audio/wav_test.go new file mode 100644 index 0000000..24643fe --- /dev/null +++ b/internal/audio/wav_test.go @@ -0,0 +1,291 @@ +package audio + +import ( + "bytes" + "context" + "encoding/binary" + "os" + "path/filepath" + "testing" + + "github.com/mewkiz/flac" + "github.com/mewkiz/flac/frame" + "github.com/mewkiz/flac/meta" +) + +func testSamples(nChannels, bitsPerSample, nSamples int) [][]int32 { + max := int32(1)<<(bitsPerSample-1) - 1 + min := -int32(1) << (bitsPerSample - 1) + + channels := make([][]int32, nChannels) + for c := range channels { + samples := make([]int32, nSamples) + for i := range samples { + switch i { + case 0: + samples[i] = min + case 1: + samples[i] = max + case 2: + samples[i] = 0 + default: + samples[i] = int32(i*(c+1)) % max + if i%3 == 0 { + samples[i] = -samples[i] + } + } + } + channels[c] = samples + } + + return channels +} + +func writeTestFLAC(t *testing.T, path string, sampleRate uint32, bitsPerSample uint8, channels [][]int32) { + t.Helper() + + nSamples := len(channels[0]) + info := &meta.StreamInfo{ + BlockSizeMin: uint16(nSamples), + BlockSizeMax: uint16(nSamples), + SampleRate: sampleRate, + NChannels: uint8(len(channels)), + BitsPerSample: bitsPerSample, + NSamples: uint64(nSamples), + } + + file, err := os.Create(path) + if err != nil { + t.Fatalf("create flac: %v", err) + } + defer file.Close() + + enc, err := flac.NewEncoder(file, info) + if err != nil { + t.Fatalf("new encoder: %v", err) + } + + subframes := make([]*frame.Subframe, len(channels)) + for c, samples := range channels { + subframes[c] = &frame.Subframe{ + SubHeader: frame.SubHeader{Pred: frame.PredVerbatim}, + Samples: samples, + NSamples: nSamples, + } + } + + channelsLayout := frame.ChannelsMono + if len(channels) == 2 { + channelsLayout = frame.ChannelsLR + } + + f := &frame.Frame{ + Header: frame.Header{ + HasFixedBlockSize: true, + BlockSize: uint16(nSamples), + SampleRate: sampleRate, + Channels: channelsLayout, + BitsPerSample: bitsPerSample, + }, + Subframes: subframes, + } + + if err := enc.WriteFrame(f); err != nil { + t.Fatalf("write frame: %v", err) + } + if err := enc.Close(); err != nil { + t.Fatalf("close encoder: %v", err) + } +} + +func expectedPCM(channels [][]int32, bytesPerSample int) []byte { + var buf bytes.Buffer + + for i := range channels[0] { + for _, samples := range channels { + sample := samples[i] + switch bytesPerSample { + case 1: + buf.WriteByte(byte(sample + 128)) + case 2: + buf.Write([]byte{byte(sample), byte(sample >> 8)}) + case 3: + buf.Write([]byte{byte(sample), byte(sample >> 8), byte(sample >> 16)}) + } + } + } + + return buf.Bytes() +} + +func TestFLACToWAV(t *testing.T) { + tests := []struct { + name string + sampleRate uint32 + bitsPerSample uint8 + nChannels int + nSamples int + }{ + {"16 bit stereo", 44100, 16, 2, 512}, + {"16 bit mono", 44100, 16, 1, 512}, + {"24 bit stereo", 48000, 24, 2, 333}, + {"8 bit mono", 22050, 8, 1, 128}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "in.flac") + dst := filepath.Join(dir, "out.wav") + + channels := testSamples(tt.nChannels, int(tt.bitsPerSample), tt.nSamples) + writeTestFLAC(t, src, tt.sampleRate, tt.bitsPerSample, channels) + + if err := FLACToWAV(context.Background(), src, dst); err != nil { + t.Fatalf("FLACToWAV() error = %v", err) + } + + got, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("read wav: %v", err) + } + + bytesPerSample := int(tt.bitsPerSample) / 8 + blockAlign := uint16(tt.nChannels * bytesPerSample) + want := expectedPCM(channels, bytesPerSample) + dataSize := uint32(len(want)) + pad := dataSize % 2 + + if len(got) != headerSize+len(want)+int(pad) { + t.Fatalf("file size = %d, want %d", len(got), headerSize+len(want)+int(pad)) + } + if string(got[0:4]) != "RIFF" || string(got[8:12]) != "WAVE" { + t.Errorf("magic = %q %q, want \"RIFF\" \"WAVE\"", got[0:4], got[8:12]) + } + if size := binary.LittleEndian.Uint32(got[4:8]); size != headerSize-8+dataSize+pad { + t.Errorf("riff size = %d, want %d", size, headerSize-8+dataSize+pad) + } + if string(got[12:16]) != "fmt " { + t.Errorf("fmt chunk id = %q, want \"fmt \"", got[12:16]) + } + if size := binary.LittleEndian.Uint32(got[16:20]); size != 16 { + t.Errorf("fmt chunk size = %d, want 16", size) + } + if format := binary.LittleEndian.Uint16(got[20:22]); format != formatPCM { + t.Errorf("format = %d, want %d", format, formatPCM) + } + if n := binary.LittleEndian.Uint16(got[22:24]); n != uint16(tt.nChannels) { + t.Errorf("channels = %d, want %d", n, tt.nChannels) + } + if rate := binary.LittleEndian.Uint32(got[24:28]); rate != tt.sampleRate { + t.Errorf("sample rate = %d, want %d", rate, tt.sampleRate) + } + if rate := binary.LittleEndian.Uint32(got[28:32]); rate != tt.sampleRate*uint32(blockAlign) { + t.Errorf("byte rate = %d, want %d", rate, tt.sampleRate*uint32(blockAlign)) + } + if align := binary.LittleEndian.Uint16(got[32:34]); align != blockAlign { + t.Errorf("block align = %d, want %d", align, blockAlign) + } + if bits := binary.LittleEndian.Uint16(got[34:36]); bits != uint16(tt.bitsPerSample) { + t.Errorf("bits per sample = %d, want %d", bits, tt.bitsPerSample) + } + if string(got[36:40]) != "data" { + t.Errorf("data chunk id = %q, want \"data\"", got[36:40]) + } + if size := binary.LittleEndian.Uint32(got[40:44]); size != dataSize { + t.Errorf("data chunk size = %d, want %d", size, dataSize) + } + if !bytes.Equal(got[headerSize:headerSize+len(want)], want) { + t.Error("pcm payload does not match the source samples") + } + }) + } +} + +func TestFLACToWAVErrors(t *testing.T) { + dir := t.TempDir() + + notFLAC := filepath.Join(dir, "not.flac") + if err := os.WriteFile(notFLAC, []byte("this is not a flac file"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + + valid := filepath.Join(dir, "valid.flac") + writeTestFLAC(t, valid, 44100, 16, testSamples(2, 16, 64)) + data, err := os.ReadFile(valid) + if err != nil { + t.Fatalf("read flac: %v", err) + } + truncated := filepath.Join(dir, "truncated.flac") + if err := os.WriteFile(truncated, data[:len(data)/2], 0644); err != nil { + t.Fatalf("write file: %v", err) + } + + tests := []struct { + name string + path string + }{ + {"not a flac file", notFLAC}, + {"missing file", filepath.Join(dir, "missing.flac")}, + {"truncated file", truncated}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dst := filepath.Join(t.TempDir(), "out.wav") + if err := FLACToWAV(context.Background(), tt.path, dst); err == nil { + t.Error("FLACToWAV() error = nil, want error") + } + if _, err := os.Stat(dst); err == nil { + t.Error("FLACToWAV() left an output file behind") + } + }) + } +} + +func TestFLACToWAVCanceled(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "in.flac") + dst := filepath.Join(dir, "out.wav") + writeTestFLAC(t, src, 44100, 16, testSamples(2, 16, 512)) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if err := FLACToWAV(ctx, src, dst); err == nil { + t.Error("FLACToWAV() error = nil, want context.Canceled") + } + if _, err := os.Stat(dst); err == nil { + t.Error("FLACToWAV() left an output file behind") + } + matches, _ := filepath.Glob(filepath.Join(dir, ".godeez-*.part")) + if len(matches) > 0 { + t.Errorf("FLACToWAV() left %d part files behind", len(matches)) + } +} + +func TestBytesPerSample(t *testing.T) { + tests := []struct { + bitsPerSample uint8 + want int + wantErr bool + }{ + {8, 1, false}, + {16, 2, false}, + {24, 3, false}, + {4, 0, true}, + {12, 0, true}, + {20, 0, true}, + {32, 0, true}, + } + + for _, tt := range tests { + got, err := bytesPerSample(tt.bitsPerSample) + if (err != nil) != tt.wantErr { + t.Errorf("bytesPerSample(%d) error = %v, wantErr %v", tt.bitsPerSample, err, tt.wantErr) + } + if got != tt.want { + t.Errorf("bytesPerSample(%d) = %d, want %d", tt.bitsPerSample, got, tt.want) + } + } +} diff --git a/internal/fsutil/fsutil.go b/internal/fsutil/fsutil.go index 3fc38c5..9d992c6 100644 --- a/internal/fsutil/fsutil.go +++ b/internal/fsutil/fsutil.go @@ -5,6 +5,8 @@ import ( "os" ) +const PartPattern = ".godeez-*.part" + func EnsureDir(path string) error { info, err := os.Stat(path) if os.IsNotExist(err) { From b804c5e288b17f41e5ec01dddfed01253b86d1b5 Mon Sep 17 00:00:00 2001 From: Mathis Maquenne <124215603+mathismqn@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:51:43 +0200 Subject: [PATCH 2/4] feat: add wav download quality --- cmd/download.go | 2 +- internal/deezer/track.go | 7 +++-- internal/deezer/track_test.go | 7 +++++ internal/download/download.go | 2 +- internal/download/options.go | 12 +++++++++ internal/download/options_test.go | 27 +++++++++++++++++++ internal/download/stream.go | 45 ++++++++++++++++++------------- internal/download/track.go | 39 ++++++++++++++++++++------- 8 files changed, 109 insertions(+), 32 deletions(-) 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(), From 84dd371fddc1ef7cd3b98feb1304d02479e3a20a Mon Sep 17 00:00:00 2001 From: Mathis Maquenne <124215603+mathismqn@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:51:43 +0200 Subject: [PATCH 3/4] feat: tag wav files with id3 and info chunks --- internal/tag/id3v2.go | 64 +++++----- internal/tag/tag.go | 5 +- internal/tag/wav.go | 256 +++++++++++++++++++++++++++++++++++++++ internal/tag/wav_test.go | 246 +++++++++++++++++++++++++++++++++++++ 4 files changed, 540 insertions(+), 31 deletions(-) create mode 100644 internal/tag/wav.go create mode 100644 internal/tag/wav_test.go diff --git a/internal/tag/id3v2.go b/internal/tag/id3v2.go index 0ef34c0..8c5c907 100644 --- a/internal/tag/id3v2.go +++ b/internal/tag/id3v2.go @@ -14,68 +14,72 @@ type id3v2Tagger struct { func (t *id3v2Tagger) write(m Metadata) error { defer t.tag.Close() + applyID3Frames(t.tag, m) + + return t.tag.Save() +} + +func applyID3Frames(tag *id3v2.Tag, m Metadata) { if m.Album != nil { year := m.Album.ReleaseDate if parts := strings.Split(year, "-"); len(parts) == 3 { year = parts[0] } - t.addTag("TRCK", m.TrackNumber) - t.addTag("TPE2", m.Album.Artist) - t.addTag("TALB", m.Album.Title) - t.addTag("TPUB", m.Album.Label) - t.addTag("TDOR", m.Album.OriginalReleaseDate) - t.addTag("TYER", year) - t.addComment(m.Album.ProducerLine) - t.addTag("TCOP", m.Album.Copyright) + addID3Text(tag, "TRCK", m.TrackNumber) + addID3Text(tag, "TPE2", m.Album.Artist) + addID3Text(tag, "TALB", m.Album.Title) + addID3Text(tag, "TPUB", m.Album.Label) + addID3Text(tag, "TDOR", m.Album.OriginalReleaseDate) + addID3Text(tag, "TYER", year) + addID3Comment(tag, m.Album.ProducerLine) + addID3Text(tag, "TCOP", m.Album.Copyright) } - t.addTag("TPE1", m.Artists) - t.addTag("TIT2", m.Title) - t.addTag("TCOM", m.Composers) - t.addTag("TEXT", m.Lyricists) - t.addTag("TCON", m.Genre) + addID3Text(tag, "TPE1", m.Artists) + addID3Text(tag, "TIT2", m.Title) + addID3Text(tag, "TCOM", m.Composers) + addID3Text(tag, "TEXT", m.Lyricists) + addID3Text(tag, "TCON", m.Genre) if duration, err := strconv.Atoi(m.Duration); err == nil { - t.addTag("TLEN", strconv.Itoa(duration*1000)) + addID3Text(tag, "TLEN", strconv.Itoa(duration*1000)) } - t.addTag("TBPM", m.BPM) - t.addTag("TKEY", m.Key) - t.addTXXX("GAIN", m.Gain) - t.addTXXX("ISRC", m.ISRC) + addID3Text(tag, "TBPM", m.BPM) + addID3Text(tag, "TKEY", m.Key) + addID3TXXX(tag, "GAIN", m.Gain) + addID3TXXX(tag, "ISRC", m.ISRC) if len(m.Cover) > 0 { - t.tag.AddAttachedPicture(id3v2.PictureFrame{ - Encoding: t.tag.DefaultEncoding(), + tag.AddAttachedPicture(id3v2.PictureFrame{ + Encoding: tag.DefaultEncoding(), MimeType: "image/jpeg", PictureType: id3v2.PTFrontCover, Description: "Cover", Picture: m.Cover, }) } - - return t.tag.Save() } -func (t *id3v2Tagger) addTag(name, value string) { +func addID3Text(tag *id3v2.Tag, name, value string) { if value != "" { - t.tag.AddTextFrame(name, t.tag.DefaultEncoding(), value) + tag.AddTextFrame(name, tag.DefaultEncoding(), value) } } -func (t *id3v2Tagger) addComment(value string) { +func addID3Comment(tag *id3v2.Tag, value string) { if value != "" { - t.tag.AddCommentFrame(id3v2.CommentFrame{ - Encoding: t.tag.DefaultEncoding(), + tag.AddCommentFrame(id3v2.CommentFrame{ + Encoding: tag.DefaultEncoding(), Language: "eng", Text: value, }) } } -func (t *id3v2Tagger) addTXXX(description, value string) { +func addID3TXXX(tag *id3v2.Tag, description, value string) { if value != "" { - t.tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{ - Encoding: t.tag.DefaultEncoding(), + tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{ + Encoding: tag.DefaultEncoding(), Description: description, Value: value, }) diff --git a/internal/tag/tag.go b/internal/tag/tag.go index bb3bd31..6b3ed40 100644 --- a/internal/tag/tag.go +++ b/internal/tag/tag.go @@ -39,12 +39,15 @@ type tagger interface { } func newTagger(filePath string) (tagger, error) { - if filepath.Ext(filePath) == ".mp3" { + switch filepath.Ext(filePath) { + case ".mp3": tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) if err != nil { return nil, err } return &id3v2Tagger{tag: tag}, nil + case ".wav": + return &wavTagger{path: filePath}, nil } file, err := flac.ParseFile(filePath) diff --git a/internal/tag/wav.go b/internal/tag/wav.go new file mode 100644 index 0000000..2cb5243 --- /dev/null +++ b/internal/tag/wav.go @@ -0,0 +1,256 @@ +package tag + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "os" + "strings" + + "github.com/bogem/id3v2/v2" +) + +type wavTagger struct { + path string +} + +type wavChunk struct { + id string + payload []byte +} + +type infoField struct { + id string + value string +} + +func (t *wavTagger) write(m Metadata) error { + id3Chunk, err := buildID3Chunk(m) + if err != nil { + return err + } + + var chunks []wavChunk + if info := buildInfoChunk(m); info != nil { + chunks = append(chunks, wavChunk{id: "LIST", payload: info}) + } + if id3Chunk != nil { + chunks = append(chunks, wavChunk{id: "id3 ", payload: id3Chunk}) + } + + return rewriteWAV(t.path, chunks) +} + +func buildID3Chunk(m Metadata) ([]byte, error) { + tag := id3v2.NewEmptyTag() + applyID3Frames(tag, m) + if !tag.HasFrames() { + return nil, nil + } + + var buf bytes.Buffer + if _, err := tag.WriteTo(&buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func buildInfoChunk(m Metadata) []byte { + fields := []infoField{ + {"INAM", m.Title}, + {"IART", m.Artists}, + {"IGNR", m.Genre}, + {"ITRK", m.TrackNumber}, + } + + if m.Album != nil { + date := m.Album.ReleaseDate + if parts := strings.Split(date, "-"); len(parts) == 3 { + date = parts[0] + } + + fields = append(fields, + infoField{"IPRD", m.Album.Title}, + infoField{"ICRD", date}, + infoField{"ICMT", m.Album.ProducerLine}, + infoField{"ICOP", m.Album.Copyright}, + ) + } + + var buf bytes.Buffer + buf.WriteString("INFO") + + for _, field := range fields { + if field.value == "" { + continue + } + writeChunk(&buf, field.id, append([]byte(field.value), 0)) + } + + if buf.Len() == 4 { + return nil + } + + return buf.Bytes() +} + +func writeChunk(w io.Writer, id string, payload []byte) { + header := make([]byte, 0, 8) + header = append(header, id...) + header = binary.LittleEndian.AppendUint32(header, uint32(len(payload))) + + w.Write(header) + w.Write(payload) + if len(payload)%2 != 0 { + w.Write([]byte{0}) + } +} + +func rewriteWAV(path string, chunks []wavChunk) error { + src, err := os.Open(path) + if err != nil { + return err + } + defer src.Close() + + header := make([]byte, 12) + if _, err := io.ReadFull(src, header); err != nil { + return err + } + if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" { + return errors.New("not a wav file") + } + + tmpPath := path + ".tmp" + dst, err := os.Create(tmpPath) + if err != nil { + return err + } + done := false + defer func() { + if !done { + dst.Close() + os.Remove(tmpPath) + } + }() + + if _, err := dst.Write(header); err != nil { + return err + } + + size, err := copyChunks(dst, src) + if err != nil { + return err + } + + for _, chunk := range chunks { + var buf bytes.Buffer + writeChunk(&buf, chunk.id, chunk.payload) + if _, err := dst.Write(buf.Bytes()); err != nil { + return err + } + size += int64(buf.Len()) + } + + riffSize := make([]byte, 4) + binary.LittleEndian.PutUint32(riffSize, uint32(size)) + if _, err := dst.WriteAt(riffSize, 4); err != nil { + return err + } + + if err := dst.Sync(); err != nil { + return err + } + if err := dst.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err != nil { + return err + } + done = true + + return nil +} + +func copyChunks(dst io.Writer, src io.Reader) (int64, error) { + size := int64(4) + head := make([]byte, 8) + + for { + if _, err := io.ReadFull(src, head); err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return size, nil + } + return size, err + } + + id := string(head[0:4]) + payloadSize := int64(binary.LittleEndian.Uint32(head[4:8])) + + if id == "id3 " || id == "ID3 " { + if err := skipPayload(src, payloadSize); err != nil { + return size, err + } + continue + } + + if id == "LIST" { + payload := make([]byte, payloadSize) + if _, err := io.ReadFull(src, payload); err != nil { + return size, err + } + if err := skipPad(src, payloadSize); err != nil { + return size, err + } + if bytes.HasPrefix(payload, []byte("INFO")) { + continue + } + + var buf bytes.Buffer + writeChunk(&buf, id, payload) + if _, err := dst.Write(buf.Bytes()); err != nil { + return size, err + } + size += int64(buf.Len()) + + continue + } + + if _, err := dst.Write(head); err != nil { + return size, err + } + if _, err := io.CopyN(dst, src, payloadSize); err != nil { + return size, err + } + size += 8 + payloadSize + + if payloadSize%2 != 0 { + if _, err := dst.Write([]byte{0}); err != nil { + return size, err + } + size++ + if err := skipPad(src, payloadSize); err != nil { + return size, err + } + } + } +} + +func skipPayload(src io.Reader, payloadSize int64) error { + if _, err := io.CopyN(io.Discard, src, payloadSize); err != nil { + return err + } + return skipPad(src, payloadSize) +} + +func skipPad(src io.Reader, payloadSize int64) error { + if payloadSize%2 == 0 { + return nil + } + if _, err := io.CopyN(io.Discard, src, 1); err != nil && !errors.Is(err, io.EOF) { + return err + } + return nil +} diff --git a/internal/tag/wav_test.go b/internal/tag/wav_test.go new file mode 100644 index 0000000..2d1cca5 --- /dev/null +++ b/internal/tag/wav_test.go @@ -0,0 +1,246 @@ +package tag + +import ( + "bytes" + "encoding/binary" + "os" + "path/filepath" + "testing" +) + +func minimalWAV(audio []byte) []byte { + var body bytes.Buffer + body.WriteString("WAVE") + + fmtPayload := make([]byte, 0, 16) + fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 1) + fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 2) + fmtPayload = binary.LittleEndian.AppendUint32(fmtPayload, 44100) + fmtPayload = binary.LittleEndian.AppendUint32(fmtPayload, 176400) + fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 4) + fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 16) + writeChunk(&body, "fmt ", fmtPayload) + writeChunk(&body, "data", audio) + + var out bytes.Buffer + out.WriteString("RIFF") + binary.Write(&out, binary.LittleEndian, uint32(body.Len())) + out.Write(body.Bytes()) + + return out.Bytes() +} + +func parseChunks(t *testing.T, data []byte) map[string][]byte { + t.Helper() + + if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" { + t.Fatalf("bad riff header: %q %q", data[0:4], data[8:12]) + } + if size := binary.LittleEndian.Uint32(data[4:8]); int(size) != len(data)-8 { + t.Errorf("riff size = %d, want %d", size, len(data)-8) + } + + chunks := make(map[string][]byte) + for offset := 12; offset < len(data); { + if offset%2 != 0 { + t.Errorf("chunk at offset %d is not word aligned", offset) + } + if offset+8 > len(data) { + t.Fatalf("truncated chunk header at offset %d", offset) + } + + id := string(data[offset : offset+4]) + size := int(binary.LittleEndian.Uint32(data[offset+4 : offset+8])) + if offset+8+size > len(data) { + t.Fatalf("chunk %q at offset %d overruns the file", id, offset) + } + if _, ok := chunks[id]; ok { + t.Errorf("duplicate %q chunk", id) + } + chunks[id] = data[offset+8 : offset+8+size] + + offset += 8 + size + size%2 + } + + return chunks +} + +func testMetadata() Metadata { + return Metadata{ + Title: "Song", + Artists: "Artist", + Genre: "Rock", + BPM: "120", + Key: "Am", + TrackNumber: "3", + Duration: "215", + Gain: "-7.5", + ISRC: "FR1234567890", + Cover: []byte{0xff, 0xd8, 0xff, 0xe0, 0x00}, + Album: &AlbumMetadata{ + Artist: "Album Artist", + Title: "Album", + Label: "Label", + ReleaseDate: "2024-05-01", + ProducerLine: "Producer line", + Copyright: "Copyright", + }, + } +} + +func writeTestWAV(t *testing.T, audio []byte) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "track.wav") + if err := os.WriteFile(path, minimalWAV(audio), 0644); err != nil { + t.Fatalf("write wav: %v", err) + } + + return path +} + +func TestWriteWAV(t *testing.T) { + audio := bytes.Repeat([]byte{0x11, 0x22, 0x33, 0x44}, 16) + path := writeTestWAV(t, audio) + original := parseChunks(t, minimalWAV(audio)) + + if err := Write(path, testMetadata()); err != nil { + t.Fatalf("Write() error = %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read wav: %v", err) + } + chunks := parseChunks(t, data) + + if !bytes.Equal(chunks["data"], original["data"]) { + t.Error("data chunk was modified") + } + if !bytes.Equal(chunks["fmt "], original["fmt "]) { + t.Error("fmt chunk was modified") + } + + id3, ok := chunks["id3 "] + if !ok { + t.Fatal("missing id3 chunk") + } + if string(id3[0:3]) != "ID3" { + t.Errorf("id3 chunk does not start with an ID3 header: %q", id3[0:3]) + } + for _, want := range []string{"Song", "Artist", "Album", "120", "Am", "FR1234567890", "Rock"} { + if !bytes.Contains(id3, []byte(want)) { + t.Errorf("id3 chunk is missing %q", want) + } + } + if !bytes.Contains(id3, []byte{0xff, 0xd8, 0xff, 0xe0}) { + t.Error("id3 chunk is missing the cover art") + } + + list, ok := chunks["LIST"] + if !ok { + t.Fatal("missing LIST chunk") + } + if string(list[0:4]) != "INFO" { + t.Errorf("LIST form = %q, want \"INFO\"", list[0:4]) + } + for _, want := range []struct{ id, value string }{ + {"INAM", "Song"}, + {"IART", "Artist"}, + {"IGNR", "Rock"}, + {"ITRK", "3"}, + {"IPRD", "Album"}, + {"ICRD", "2024"}, + {"ICMT", "Producer line"}, + {"ICOP", "Copyright"}, + } { + if !bytes.Contains(list, append([]byte(want.id), append([]byte{byte(len(want.value) + 1), 0, 0, 0}, want.value...)...)) { + t.Errorf("LIST chunk is missing %s = %q", want.id, want.value) + } + } +} + +func TestWriteWAVIsIdempotent(t *testing.T) { + audio := bytes.Repeat([]byte{0x01, 0x02}, 32) + path := writeTestWAV(t, audio) + + if err := Write(path, testMetadata()); err != nil { + t.Fatalf("first Write() error = %v", err) + } + first, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read wav: %v", err) + } + firstChunks := parseChunks(t, first) + + if err := Write(path, testMetadata()); err != nil { + t.Fatalf("second Write() error = %v", err) + } + second, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read wav: %v", err) + } + secondChunks := parseChunks(t, second) + + if len(first) != len(second) { + t.Errorf("re-tagging changed the file size: %d bytes then %d bytes", len(first), len(second)) + } + if len(firstChunks) != len(secondChunks) { + t.Errorf("chunk count = %d, want %d", len(secondChunks), len(firstChunks)) + } + for id, payload := range firstChunks { + got, ok := secondChunks[id] + if !ok { + t.Errorf("re-tagging dropped the %q chunk", id) + continue + } + if len(got) != len(payload) { + t.Errorf("%q chunk size = %d, want %d", id, len(got), len(payload)) + } + } + if !bytes.Equal(secondChunks["data"], audio) { + t.Error("data chunk was modified") + } + if !bytes.Equal(secondChunks["fmt "], firstChunks["fmt "]) { + t.Error("fmt chunk was modified") + } + if !bytes.Equal(secondChunks["LIST"], firstChunks["LIST"]) { + t.Error("LIST chunk was modified") + } +} + +func TestWriteWAVOddSizedChunks(t *testing.T) { + audio := bytes.Repeat([]byte{0x07}, 33) + path := writeTestWAV(t, audio) + + if err := Write(path, testMetadata()); err != nil { + t.Fatalf("Write() error = %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read wav: %v", err) + } + + chunks := parseChunks(t, data) + if !bytes.Equal(chunks["data"], audio) { + t.Error("data chunk was modified") + } + if _, ok := chunks["id3 "]; !ok { + t.Error("missing id3 chunk") + } +} + +func TestWriteWAVRejectsNonWAV(t *testing.T) { + path := filepath.Join(t.TempDir(), "track.wav") + if err := os.WriteFile(path, []byte("this is not a wav file at all"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + + if err := Write(path, testMetadata()); err == nil { + t.Error("Write() error = nil, want error") + } + if _, err := os.Stat(path + ".tmp"); err == nil { + t.Error("Write() left a temp file behind") + } +} From bcb3558a73f9061da0cde250534f1f09359451ae Mon Sep 17 00:00:00 2001 From: Mathis Maquenne <124215603+mathismqn@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:59:26 +0200 Subject: [PATCH 4/4] docs: document wav download quality --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e4523af..4ddcdae 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A simple Go tool for downloading music from [Deezer](https://www.deezer.com). ## Features - Download playlists, albums, artists' top tracks, and individual tracks -- Choose audio quality: MP3 128 kbps, MP3 320 kbps (default), or FLAC (⚠️ non-premium accounts are limited to 128 kbps) +- Choose audio quality: MP3 128 kbps, MP3 320 kbps (default), or lossless FLAC/WAV (⚠️ non-premium accounts are limited to 128 kbps) - Authenticate with an ARL cookie or with your Deezer email and password - Automatically embed metadata tags (artist, album, title, artwork, etc.) - Fetch and tag tracks with BPM, musical key, and genre @@ -191,7 +191,7 @@ Flags: --bpm fetch BPM/key and add to file tags --genre fetch genre and add to file tags -h, --help help for download - -q, --quality string download quality [mp3_128, mp3_320, flac] (default "mp3_320") + -q, --quality string download quality [mp3_128, mp3_320, flac, wav] (default "mp3_320") --strict fail the download if the requested quality is unavailable -t, --timeout duration timeout for each download (e.g. 10s, 1m, 2m30s) (default 2m0s)