Merge branch 'feat/wav-output' into dev
This commit is contained in:
@@ -19,7 +19,7 @@ A simple Go tool for downloading music from [Deezer](https://www.deezer.com).
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Download playlists, albums, artists' top tracks, and individual tracks
|
- 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
|
- Authenticate with an ARL cookie or with your Deezer email and password
|
||||||
- Automatically embed metadata tags (artist, album, title, artwork, etc.)
|
- Automatically embed metadata tags (artist, album, title, artwork, etc.)
|
||||||
- Fetch and tag tracks with BPM, musical key, and genre
|
- Fetch and tag tracks with BPM, musical key, and genre
|
||||||
@@ -191,7 +191,7 @@ Flags:
|
|||||||
--bpm fetch BPM/key and add to file tags
|
--bpm fetch BPM/key and add to file tags
|
||||||
--genre fetch genre and add to file tags
|
--genre fetch genre and add to file tags
|
||||||
-h, --help help for download
|
-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
|
--strict fail the download if the requested quality is unavailable
|
||||||
-t, --timeout duration timeout for each download (e.g. 10s, 1m, 2m30s) (default 2m0s)
|
-t, --timeout duration timeout for each download (e.g. 10s, 1m, 2m30s) (default 2m0s)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module github.com/mathismqn/godeez
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/mewkiz/flac v1.0.13
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/zalando/go-keyring v0.2.8
|
github.com/zalando/go-keyring v0.2.8
|
||||||
golang.org/x/mod v0.38.0
|
golang.org/x/mod v0.38.0
|
||||||
@@ -14,8 +15,11 @@ require (
|
|||||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||||
github.com/fatih/color v1.19.0 // indirect
|
github.com/fatih/color v1.19.0 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.2 // 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-colorable v0.1.15 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // 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/net v0.57.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.40.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
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 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 := ""
|
||||||
|
|||||||
@@ -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)"},
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(),
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const PartPattern = ".godeez-*.part"
|
||||||
|
|
||||||
func EnsureDir(path string) error {
|
func EnsureDir(path string) error {
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
|
|||||||
+34
-30
@@ -14,68 +14,72 @@ type id3v2Tagger struct {
|
|||||||
func (t *id3v2Tagger) write(m Metadata) error {
|
func (t *id3v2Tagger) write(m Metadata) error {
|
||||||
defer t.tag.Close()
|
defer t.tag.Close()
|
||||||
|
|
||||||
|
applyID3Frames(t.tag, m)
|
||||||
|
|
||||||
|
return t.tag.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyID3Frames(tag *id3v2.Tag, m Metadata) {
|
||||||
if m.Album != nil {
|
if m.Album != nil {
|
||||||
year := m.Album.ReleaseDate
|
year := m.Album.ReleaseDate
|
||||||
if parts := strings.Split(year, "-"); len(parts) == 3 {
|
if parts := strings.Split(year, "-"); len(parts) == 3 {
|
||||||
year = parts[0]
|
year = parts[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
t.addTag("TRCK", m.TrackNumber)
|
addID3Text(tag, "TRCK", m.TrackNumber)
|
||||||
t.addTag("TPE2", m.Album.Artist)
|
addID3Text(tag, "TPE2", m.Album.Artist)
|
||||||
t.addTag("TALB", m.Album.Title)
|
addID3Text(tag, "TALB", m.Album.Title)
|
||||||
t.addTag("TPUB", m.Album.Label)
|
addID3Text(tag, "TPUB", m.Album.Label)
|
||||||
t.addTag("TDOR", m.Album.OriginalReleaseDate)
|
addID3Text(tag, "TDOR", m.Album.OriginalReleaseDate)
|
||||||
t.addTag("TYER", year)
|
addID3Text(tag, "TYER", year)
|
||||||
t.addComment(m.Album.ProducerLine)
|
addID3Comment(tag, m.Album.ProducerLine)
|
||||||
t.addTag("TCOP", m.Album.Copyright)
|
addID3Text(tag, "TCOP", m.Album.Copyright)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.addTag("TPE1", m.Artists)
|
addID3Text(tag, "TPE1", m.Artists)
|
||||||
t.addTag("TIT2", m.Title)
|
addID3Text(tag, "TIT2", m.Title)
|
||||||
t.addTag("TCOM", m.Composers)
|
addID3Text(tag, "TCOM", m.Composers)
|
||||||
t.addTag("TEXT", m.Lyricists)
|
addID3Text(tag, "TEXT", m.Lyricists)
|
||||||
t.addTag("TCON", m.Genre)
|
addID3Text(tag, "TCON", m.Genre)
|
||||||
if duration, err := strconv.Atoi(m.Duration); err == nil {
|
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)
|
addID3Text(tag, "TBPM", m.BPM)
|
||||||
t.addTag("TKEY", m.Key)
|
addID3Text(tag, "TKEY", m.Key)
|
||||||
t.addTXXX("GAIN", m.Gain)
|
addID3TXXX(tag, "GAIN", m.Gain)
|
||||||
t.addTXXX("ISRC", m.ISRC)
|
addID3TXXX(tag, "ISRC", m.ISRC)
|
||||||
|
|
||||||
if len(m.Cover) > 0 {
|
if len(m.Cover) > 0 {
|
||||||
t.tag.AddAttachedPicture(id3v2.PictureFrame{
|
tag.AddAttachedPicture(id3v2.PictureFrame{
|
||||||
Encoding: t.tag.DefaultEncoding(),
|
Encoding: tag.DefaultEncoding(),
|
||||||
MimeType: "image/jpeg",
|
MimeType: "image/jpeg",
|
||||||
PictureType: id3v2.PTFrontCover,
|
PictureType: id3v2.PTFrontCover,
|
||||||
Description: "Cover",
|
Description: "Cover",
|
||||||
Picture: m.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 != "" {
|
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 != "" {
|
if value != "" {
|
||||||
t.tag.AddCommentFrame(id3v2.CommentFrame{
|
tag.AddCommentFrame(id3v2.CommentFrame{
|
||||||
Encoding: t.tag.DefaultEncoding(),
|
Encoding: tag.DefaultEncoding(),
|
||||||
Language: "eng",
|
Language: "eng",
|
||||||
Text: value,
|
Text: value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *id3v2Tagger) addTXXX(description, value string) {
|
func addID3TXXX(tag *id3v2.Tag, description, value string) {
|
||||||
if value != "" {
|
if value != "" {
|
||||||
t.tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{
|
tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{
|
||||||
Encoding: t.tag.DefaultEncoding(),
|
Encoding: tag.DefaultEncoding(),
|
||||||
Description: description,
|
Description: description,
|
||||||
Value: value,
|
Value: value,
|
||||||
})
|
})
|
||||||
|
|||||||
+4
-1
@@ -39,12 +39,15 @@ type tagger interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newTagger(filePath string) (tagger, error) {
|
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})
|
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &id3v2Tagger{tag: tag}, nil
|
return &id3v2Tagger{tag: tag}, nil
|
||||||
|
case ".wav":
|
||||||
|
return &wavTagger{path: filePath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := flac.ParseFile(filePath)
|
file, err := flac.ParseFile(filePath)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user