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