feat: add wav download quality
This commit is contained in:
+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")
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user