refactor: consolidate download domain into download package
This commit is contained in:
+4
-4
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/mathismqn/godeez/internal/config"
|
"github.com/mathismqn/godeez/internal/config"
|
||||||
"github.com/mathismqn/godeez/internal/deezer"
|
"github.com/mathismqn/godeez/internal/deezer"
|
||||||
"github.com/mathismqn/godeez/internal/downloader"
|
"github.com/mathismqn/godeez/internal/download"
|
||||||
"github.com/mathismqn/godeez/internal/store"
|
"github.com/mathismqn/godeez/internal/store"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -21,7 +21,7 @@ func newDownloadCmd() *cobra.Command {
|
|||||||
Annotations: map[string]string{updateNoticeAnnotation: "true"},
|
Annotations: map[string]string{updateNoticeAnnotation: "true"},
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := &downloader.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]")
|
||||||
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")
|
||||||
@@ -40,7 +40,7 @@ func newDownloadCmd() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDownloadSubCmd(kind deezer.Kind, opts *downloader.Options) *cobra.Command {
|
func newDownloadSubCmd(kind deezer.Kind, opts *download.Options) *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: fmt.Sprintf("%s <%s_id>", kind, kind),
|
Use: fmt.Sprintf("%s <%s_id>", kind, kind),
|
||||||
Short: downloadShort(kind),
|
Short: downloadShort(kind),
|
||||||
@@ -62,7 +62,7 @@ func newDownloadSubCmd(kind deezer.Kind, opts *downloader.Options) *cobra.Comman
|
|||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
err = downloader.New(cfg, st, kind).Run(cmd.Context(), *opts, args[0])
|
err = download.New(cfg, st, kind).Run(cmd.Context(), *opts, args[0])
|
||||||
if errors.Is(err, context.Canceled) {
|
if errors.Is(err, context.Canceled) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package provider
|
package download
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -14,26 +14,21 @@ import (
|
|||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BPMKey struct {
|
|
||||||
BPM string
|
|
||||||
Key string
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
bpmRegex = regexp.MustCompile(`tempo of <span[^>]*>(\d+) BPM`)
|
bpmRegex = regexp.MustCompile(`tempo of <span[^>]*>(\d+) BPM`)
|
||||||
keyRegex = regexp.MustCompile(`with a <span[^>]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?)</span> key`)
|
keyRegex = regexp.MustCompile(`with a <span[^>]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?)</span> key`)
|
||||||
modeRegex = regexp.MustCompile(`a <span[^>]*>([a-z]+)</span> mode`)
|
modeRegex = regexp.MustCompile(`a <span[^>]*>([a-z]+)</span> mode`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func FetchBPM(ctx context.Context, httpClient *http.Client, artist, title, duration string) (BPMKey, error) {
|
func fetchBPM(ctx context.Context, httpClient *http.Client, artist, title, duration string) (bpmKey, error) {
|
||||||
trackURL, err := findTrackURL(ctx, httpClient, artist, title, duration)
|
trackURL, err := findTrackURL(ctx, httpClient, artist, title, duration)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return BPMKey{}, err
|
return bpmKey{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
html, err := fetchBPMPage(ctx, httpClient, trackURL)
|
html, err := fetchBPMPage(ctx, httpClient, trackURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return BPMKey{}, err
|
return bpmKey{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseBPM(html)
|
return parseBPM(html)
|
||||||
@@ -137,13 +132,13 @@ func fetchBPMPage(ctx context.Context, httpClient *http.Client, url string) (str
|
|||||||
return string(body), nil
|
return string(body), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseBPM(html string) (BPMKey, error) {
|
func parseBPM(html string) (bpmKey, error) {
|
||||||
bpmMatch := bpmRegex.FindStringSubmatch(html)
|
bpmMatch := bpmRegex.FindStringSubmatch(html)
|
||||||
keyMatch := keyRegex.FindStringSubmatch(html)
|
keyMatch := keyRegex.FindStringSubmatch(html)
|
||||||
modeMatch := modeRegex.FindStringSubmatch(html)
|
modeMatch := modeRegex.FindStringSubmatch(html)
|
||||||
|
|
||||||
if len(bpmMatch) != 2 || len(keyMatch) != 2 || len(modeMatch) != 2 {
|
if len(bpmMatch) != 2 || len(keyMatch) != 2 || len(modeMatch) != 2 {
|
||||||
return BPMKey{}, fmt.Errorf("no data found")
|
return bpmKey{}, fmt.Errorf("no data found")
|
||||||
}
|
}
|
||||||
|
|
||||||
bpm := bpmMatch[1]
|
bpm := bpmMatch[1]
|
||||||
@@ -155,5 +150,5 @@ func parseBPM(html string) (BPMKey, error) {
|
|||||||
key += "m"
|
key += "m"
|
||||||
}
|
}
|
||||||
|
|
||||||
return BPMKey{BPM: bpm, Key: key}, nil
|
return bpmKey{BPM: bpm, Key: key}, nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathismqn/godeez/internal/config"
|
||||||
|
"github.com/mathismqn/godeez/internal/deezer"
|
||||||
|
"github.com/mathismqn/godeez/internal/fsutil"
|
||||||
|
"github.com/mathismqn/godeez/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Downloader struct {
|
||||||
|
appConfig *config.Config
|
||||||
|
store *store.Store
|
||||||
|
kind deezer.Kind
|
||||||
|
deezerClient *deezer.Client
|
||||||
|
|
||||||
|
hashIndexOnce sync.Once
|
||||||
|
hashIndex *hashIndex
|
||||||
|
hashIndexErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(appConfig *config.Config, st *store.Store, kind deezer.Kind) *Downloader {
|
||||||
|
return &Downloader{
|
||||||
|
appConfig: appConfig,
|
||||||
|
store: st,
|
||||||
|
kind: kind,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Downloader) Run(ctx context.Context, opts Options, id string) error {
|
||||||
|
if err := d.initDeezerClient(ctx, opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resource, outputDir, err := d.prepareResource(ctx, id, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.downloadAllTracks(ctx, resource, opts, outputDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Downloader) initDeezerClient(ctx context.Context, opts Options) error {
|
||||||
|
var err error
|
||||||
|
d.deezerClient, err = deezer.NewClient(ctx, d.appConfig.ARLCookie)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !d.deezerClient.Session.Premium && (opts.Quality == "mp3_320" || opts.Quality == "flac") {
|
||||||
|
return fmt.Errorf("premium account required for '%s' quality", opts.Quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Downloader) prepareResource(ctx context.Context, id string, opts Options) (deezer.Resource, string, error) {
|
||||||
|
resource, err := d.deezerClient.FetchResource(ctx, d.kind, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("failed to fetch resource: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := resource.GetTracks()
|
||||||
|
if len(tracks) == 0 {
|
||||||
|
if d.kind == deezer.KindTrack {
|
||||||
|
return nil, "", fmt.Errorf("track with ID %s not found", id)
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("%s has no tracks", d.kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.kind == deezer.KindArtist && len(tracks) > opts.Limit {
|
||||||
|
resource.SetTracks(tracks[:opts.Limit])
|
||||||
|
}
|
||||||
|
|
||||||
|
outputDir := resource.GetOutputDir(d.appConfig.OutputDir)
|
||||||
|
if err := fsutil.EnsureDir(outputDir); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("failed to create output directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resource, outputDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Downloader) downloadAllTracks(ctx context.Context, resource deezer.Resource, opts Options, outputDir string) error {
|
||||||
|
tracks := resource.GetTracks()
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
if d.kind != deezer.KindTrack {
|
||||||
|
fmt.Printf("%s\n\nStarting download...\n\n", resourceInfo(resource))
|
||||||
|
}
|
||||||
|
|
||||||
|
progress := newProgressTracker(len(tracks), d.kind)
|
||||||
|
|
||||||
|
for i, track := range tracks {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
sp := progress.startDownload(i, track)
|
||||||
|
result := d.downloadTrack(ctx, resource, track, opts, outputDir)
|
||||||
|
sp.Stop()
|
||||||
|
|
||||||
|
if result.err != nil && errors.Is(result.err, context.Canceled) {
|
||||||
|
return result.err
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.handleResult(i, track, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.printSummary(outputDir, time.Since(startTime))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package provider
|
package download
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -30,7 +30,7 @@ func toLower(ss []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchGenre(ctx context.Context, httpClient *http.Client, artist, title string) (string, error) {
|
func fetchGenre(ctx context.Context, httpClient *http.Client, artist, title string) (string, error) {
|
||||||
reqURL := fmt.Sprintf("https://www.last.fm/music/%s/%s/+tags", artist, title)
|
reqURL := fmt.Sprintf("https://www.last.fm/music/%s/%s/+tags", artist, title)
|
||||||
|
|
||||||
doc, err := fetchGenrePage(ctx, httpClient, reqURL)
|
doc, err := fetchGenrePage(ctx, httpClient, reqURL)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package downloader
|
package download
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -59,3 +59,11 @@ func (h *hashIndex) find(hash string) (string, bool) {
|
|||||||
path, ok := h.files[hash]
|
path, ok := h.files[hash]
|
||||||
return path, ok
|
return path, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *Downloader) initHashIndex(ctx context.Context) error {
|
||||||
|
d.hashIndexOnce.Do(func() {
|
||||||
|
d.hashIndex, d.hashIndexErr = newHashIndex(ctx, d.appConfig.OutputDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
return d.hashIndexErr
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package downloader
|
package download
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package downloader
|
package download
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/mathismqn/godeez/internal/deezer"
|
"github.com/mathismqn/godeez/internal/deezer"
|
||||||
"github.com/mathismqn/godeez/internal/provider"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type bpmKey struct {
|
type bpmKey struct {
|
||||||
@@ -40,14 +39,14 @@ func fetchMetadata(httpClient *http.Client, ctx context.Context, track *deezer.T
|
|||||||
|
|
||||||
if opts.BPM {
|
if opts.BPM {
|
||||||
go func() {
|
go func() {
|
||||||
result, err := provider.FetchBPM(ctx, httpClient, track.Artist, track.Title, track.Duration)
|
result, err := fetchBPM(ctx, httpClient, track.Artist, track.Title, track.Duration)
|
||||||
bpmChan <- bpmResult{value: bpmKey{BPM: result.BPM, Key: result.Key}, err: err}
|
bpmChan <- bpmResult{value: result, err: err}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.Genre {
|
if opts.Genre {
|
||||||
go func() {
|
go func() {
|
||||||
genre, err := provider.FetchGenre(ctx, httpClient, track.Artist, track.GetTitle())
|
genre, err := fetchGenre(ctx, httpClient, track.Artist, track.GetTitle())
|
||||||
genreChan <- genreResult{value: genre, err: err}
|
genreChan <- genreResult{value: genre, err: err}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package downloader
|
package download
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package downloader
|
package download
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package downloader
|
package download
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"github.com/mathismqn/godeez/internal/fsutil"
|
"github.com/mathismqn/godeez/internal/fsutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (c *Client) shouldSkipDownload(ctx context.Context, trackID, mediaFormat string) (string, bool) {
|
func (d *Downloader) shouldSkipDownload(ctx context.Context, trackID, mediaFormat string) (string, bool) {
|
||||||
existing, err := c.store.DownloadInfo(trackID)
|
existing, err := d.store.DownloadInfo(trackID)
|
||||||
if err != nil || existing.Quality != mediaFormat {
|
if err != nil || existing.Quality != mediaFormat {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -20,17 +20,17 @@ func (c *Client) shouldSkipDownload(ctx context.Context, trackID, mediaFormat st
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.initHashIndex(ctx); err != nil {
|
if err := d.initHashIndex(ctx); err != nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
foundPath, ok := c.hashIndex.find(existing.Hash)
|
foundPath, ok := d.hashIndex.find(existing.Hash)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
existing.Path = foundPath
|
existing.Path = foundPath
|
||||||
_ = c.store.PutDownloadInfo(existing)
|
_ = d.store.PutDownloadInfo(existing)
|
||||||
|
|
||||||
return foundPath, true
|
return foundPath, true
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/mathismqn/godeez/internal/deezer"
|
||||||
|
)
|
||||||
|
|
||||||
|
const chunkSize = 2048
|
||||||
|
|
||||||
|
func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error {
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
file, err := os.Create(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
buffer := make([]byte, chunkSize)
|
||||||
|
for chunk := 0; ; chunk++ {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
totalRead := 0
|
||||||
|
for totalRead < chunkSize {
|
||||||
|
n, err := stream.Read(buffer[totalRead:])
|
||||||
|
totalRead += n
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalRead == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if chunk%3 == 0 && totalRead == chunkSize {
|
||||||
|
buffer, err = deezer.DecryptBlowfish(buffer, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = file.Write(buffer[:totalRead]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalRead < chunkSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package downloader
|
package download
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathismqn/godeez/internal/deezer"
|
||||||
|
"github.com/mathismqn/godeez/internal/fsutil"
|
||||||
|
"github.com/mathismqn/godeez/internal/store"
|
||||||
|
"github.com/mathismqn/godeez/internal/tag"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return downloadResult{err: fmt.Errorf("failed to fetch media: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaFormat := media.GetFormat()
|
||||||
|
if opts.Strict && strings.ToLower(mediaFormat) != opts.Quality {
|
||||||
|
return downloadResult{err: fmt.Errorf("requested quality '%s' not available", opts.Quality)}
|
||||||
|
}
|
||||||
|
|
||||||
|
if skipPath, skip := d.shouldSkipDownload(ctx, track.ID, mediaFormat); skip {
|
||||||
|
return downloadResult{skipped: true, path: skipPath}
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataChan := make(chan metadataResult, 1)
|
||||||
|
go func() {
|
||||||
|
metadataChan <- fetchMetadata(d.deezerClient.Session.HttpClient, ctx, track, opts)
|
||||||
|
}()
|
||||||
|
|
||||||
|
stream, err := d.deezerClient.GetMediaStream(ctx, media)
|
||||||
|
if err != nil {
|
||||||
|
return downloadResult{err: fmt.Errorf("failed to get media stream: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
dlCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
fileName := track.Filename(d.kind, mediaFormat)
|
||||||
|
outputPath := path.Join(outputDir, fileName)
|
||||||
|
|
||||||
|
key := deezer.BlowfishKey(track.ID)
|
||||||
|
if err := d.streamToFile(dlCtx, stream, outputPath, key); err != nil {
|
||||||
|
fsutil.Remove(outputPath)
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
cover, err := d.deezerClient.FetchCoverImage(ctx, track)
|
||||||
|
if err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("failed to fetch cover image: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := <-metadataChan
|
||||||
|
warnings = append(warnings, metadata.warnings...)
|
||||||
|
warnings = append(warnings, d.finalizeDownload(resource, track, outputPath, mediaFormat, metadata.genre, cover, metadata.bpmKey)...)
|
||||||
|
|
||||||
|
return downloadResult{warnings: warnings}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Downloader) finalizeDownload(resource deezer.Resource, track *deezer.Track, outputPath, mediaFormat, genre string, cover []byte, bpmKey bpmKey) []string {
|
||||||
|
var warnings []string
|
||||||
|
|
||||||
|
if err := tag.Write(outputPath, buildTagMetadata(resource, track, cover, bpmKey, genre)); err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("failed to add tags: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := hashFile(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("failed to get file hash: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &store.DownloadInfo{
|
||||||
|
TrackID: track.ID,
|
||||||
|
Quality: mediaFormat,
|
||||||
|
Path: outputPath,
|
||||||
|
Hash: hash,
|
||||||
|
Downloaded: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := d.store.PutDownloadInfo(info); err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("failed to save download info: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
}
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
package downloader
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mathismqn/godeez/internal/config"
|
|
||||||
"github.com/mathismqn/godeez/internal/deezer"
|
|
||||||
"github.com/mathismqn/godeez/internal/fsutil"
|
|
||||||
"github.com/mathismqn/godeez/internal/store"
|
|
||||||
"github.com/mathismqn/godeez/internal/tag"
|
|
||||||
)
|
|
||||||
|
|
||||||
const chunkSize = 2048
|
|
||||||
|
|
||||||
type Client struct {
|
|
||||||
appConfig *config.Config
|
|
||||||
store *store.Store
|
|
||||||
kind deezer.Kind
|
|
||||||
deezerClient *deezer.Client
|
|
||||||
|
|
||||||
hashIndexOnce sync.Once
|
|
||||||
hashIndex *hashIndex
|
|
||||||
hashIndexErr error
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(appConfig *config.Config, st *store.Store, kind deezer.Kind) *Client {
|
|
||||||
return &Client{
|
|
||||||
appConfig: appConfig,
|
|
||||||
store: st,
|
|
||||||
kind: kind,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Run(ctx context.Context, opts Options, id string) error {
|
|
||||||
if err := c.initDeezerClient(ctx, opts); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
resource, outputDir, err := c.prepareResource(ctx, id, opts)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.downloadAllTracks(ctx, resource, opts, outputDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) initDeezerClient(ctx context.Context, opts Options) error {
|
|
||||||
var err error
|
|
||||||
c.deezerClient, err = deezer.NewClient(ctx, c.appConfig.ARLCookie)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !c.deezerClient.Session.Premium && (opts.Quality == "mp3_320" || opts.Quality == "flac") {
|
|
||||||
return fmt.Errorf("premium account required for '%s' quality", opts.Quality)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) prepareResource(ctx context.Context, id string, opts Options) (deezer.Resource, string, error) {
|
|
||||||
resource, err := c.deezerClient.FetchResource(ctx, c.kind, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", fmt.Errorf("failed to fetch resource: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks := resource.GetTracks()
|
|
||||||
if len(tracks) == 0 {
|
|
||||||
if c.kind == deezer.KindTrack {
|
|
||||||
return nil, "", fmt.Errorf("track with ID %s not found", id)
|
|
||||||
}
|
|
||||||
return nil, "", fmt.Errorf("%s has no tracks", c.kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.kind == deezer.KindArtist && len(tracks) > opts.Limit {
|
|
||||||
resource.SetTracks(tracks[:opts.Limit])
|
|
||||||
}
|
|
||||||
|
|
||||||
outputDir := resource.GetOutputDir(c.appConfig.OutputDir)
|
|
||||||
if err := fsutil.EnsureDir(outputDir); err != nil {
|
|
||||||
return nil, "", fmt.Errorf("failed to create output directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resource, outputDir, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) downloadAllTracks(ctx context.Context, resource deezer.Resource, opts Options, outputDir string) error {
|
|
||||||
tracks := resource.GetTracks()
|
|
||||||
startTime := time.Now()
|
|
||||||
|
|
||||||
if c.kind != deezer.KindTrack {
|
|
||||||
fmt.Printf("%s\n\nStarting download...\n\n", resourceInfo(resource))
|
|
||||||
}
|
|
||||||
|
|
||||||
progress := newProgressTracker(len(tracks), c.kind)
|
|
||||||
|
|
||||||
for i, track := range tracks {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
sp := progress.startDownload(i, track)
|
|
||||||
result := c.downloadTrack(ctx, resource, track, opts, outputDir)
|
|
||||||
sp.Stop()
|
|
||||||
|
|
||||||
if result.err != nil && errors.Is(result.err, context.Canceled) {
|
|
||||||
return result.err
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.handleResult(i, track, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.printSummary(outputDir, time.Since(startTime))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) downloadTrack(ctx context.Context, resource deezer.Resource, track *deezer.Track, opts Options, outputDir string) downloadResult {
|
|
||||||
media, err := c.deezerClient.FetchMedia(ctx, track, opts.Quality)
|
|
||||||
if err != nil {
|
|
||||||
return downloadResult{err: fmt.Errorf("failed to fetch media: %w", err)}
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaFormat := media.GetFormat()
|
|
||||||
if opts.Strict && strings.ToLower(mediaFormat) != opts.Quality {
|
|
||||||
return downloadResult{err: fmt.Errorf("requested quality '%s' not available", opts.Quality)}
|
|
||||||
}
|
|
||||||
|
|
||||||
if skipPath, skip := c.shouldSkipDownload(ctx, track.ID, mediaFormat); skip {
|
|
||||||
return downloadResult{skipped: true, path: skipPath}
|
|
||||||
}
|
|
||||||
|
|
||||||
metadataChan := make(chan metadataResult, 1)
|
|
||||||
go func() {
|
|
||||||
metadataChan <- fetchMetadata(c.deezerClient.Session.HttpClient, ctx, track, opts)
|
|
||||||
}()
|
|
||||||
|
|
||||||
stream, err := c.deezerClient.GetMediaStream(ctx, media)
|
|
||||||
if err != nil {
|
|
||||||
return downloadResult{err: fmt.Errorf("failed to get media stream: %w", err)}
|
|
||||||
}
|
|
||||||
|
|
||||||
dlCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
fileName := track.Filename(c.kind, mediaFormat)
|
|
||||||
outputPath := path.Join(outputDir, fileName)
|
|
||||||
|
|
||||||
key := deezer.BlowfishKey(track.ID)
|
|
||||||
if err := c.streamToFile(dlCtx, stream, outputPath, key); err != nil {
|
|
||||||
fsutil.Remove(outputPath)
|
|
||||||
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)))
|
|
||||||
}
|
|
||||||
|
|
||||||
cover, err := c.deezerClient.FetchCoverImage(ctx, track)
|
|
||||||
if err != nil && !errors.Is(err, context.Canceled) {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("failed to fetch cover image: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := <-metadataChan
|
|
||||||
warnings = append(warnings, metadata.warnings...)
|
|
||||||
warnings = append(warnings, c.finalizeDownload(resource, track, outputPath, mediaFormat, metadata.genre, cover, metadata.bpmKey)...)
|
|
||||||
|
|
||||||
return downloadResult{warnings: warnings}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error {
|
|
||||||
defer stream.Close()
|
|
||||||
|
|
||||||
file, err := os.Create(outputPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
buffer := make([]byte, chunkSize)
|
|
||||||
for chunk := 0; ; chunk++ {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
totalRead := 0
|
|
||||||
for totalRead < chunkSize {
|
|
||||||
n, err := stream.Read(buffer[totalRead:])
|
|
||||||
totalRead += n
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, io.EOF) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if totalRead == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if chunk%3 == 0 && totalRead == chunkSize {
|
|
||||||
buffer, err = deezer.DecryptBlowfish(buffer, key)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err = file.Write(buffer[:totalRead]); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if totalRead < chunkSize {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) finalizeDownload(resource deezer.Resource, track *deezer.Track, outputPath, mediaFormat, genre string, cover []byte, bpmKey bpmKey) []string {
|
|
||||||
var warnings []string
|
|
||||||
|
|
||||||
if err := tag.Write(outputPath, buildTagMetadata(resource, track, cover, bpmKey, genre)); err != nil {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("failed to add tags: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
hash, err := hashFile(outputPath)
|
|
||||||
if err != nil {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("failed to get file hash: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
info := &store.DownloadInfo{
|
|
||||||
TrackID: track.ID,
|
|
||||||
Quality: mediaFormat,
|
|
||||||
Path: outputPath,
|
|
||||||
Hash: hash,
|
|
||||||
Downloaded: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.store.PutDownloadInfo(info); err != nil {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("failed to save download info: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
return warnings
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) initHashIndex(ctx context.Context) error {
|
|
||||||
c.hashIndexOnce.Do(func() {
|
|
||||||
c.hashIndex, c.hashIndexErr = newHashIndex(ctx, c.appConfig.OutputDir)
|
|
||||||
})
|
|
||||||
|
|
||||||
return c.hashIndexErr
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user