refactor: consolidate download domain into download package

This commit is contained in:
Mathis Maquenne
2026-08-05 11:46:10 +02:00
parent dbb808f371
commit 5c95554855
14 changed files with 314 additions and 300 deletions
+154
View File
@@ -0,0 +1,154 @@
package download
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
neturl "net/url"
"regexp"
"strconv"
"strings"
"github.com/PuerkitoBio/goquery"
)
var (
bpmRegex = regexp.MustCompile(`tempo of <span[^>]*>(\d+) BPM`)
keyRegex = regexp.MustCompile(`with a <span[^>]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?)</span> key`)
modeRegex = regexp.MustCompile(`a <span[^>]*>([a-z]+)</span> mode`)
)
func fetchBPM(ctx context.Context, httpClient *http.Client, artist, title, duration string) (bpmKey, error) {
trackURL, err := findTrackURL(ctx, httpClient, artist, title, duration)
if err != nil {
return bpmKey{}, err
}
html, err := fetchBPMPage(ctx, httpClient, trackURL)
if err != nil {
return bpmKey{}, err
}
return parseBPM(html)
}
func findTrackURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) {
const rootURL = "https://songbpm.com"
values := neturl.Values{}
values.Add("query", fmt.Sprintf("%s %s", artist, title))
req, err := http.NewRequestWithContext(ctx, "POST", rootURL+"/searches", bytes.NewBufferString(values.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", rootURL)
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return "", err
}
wantDuration, err := strconv.Atoi(duration)
if err != nil {
return "", fmt.Errorf("invalid duration: %w", err)
}
lowerTitle := strings.ToLower(title)
lowerArtist := strings.ToLower(artist)
var matchURL string
doc.Find("a.flex.flex-col").EachWithBreak(func(_ int, sel *goquery.Selection) bool {
text := strings.ToLower(sel.Text())
if !strings.Contains(text, lowerTitle) || !strings.Contains(text, lowerArtist) {
return true
}
durationStr := strings.TrimSpace(sel.Find("div.flex-1.flex-col.items-center").Eq(1).Find("span.text-2xl").Text())
parts := strings.Split(durationStr, ":")
if len(parts) != 2 {
return true
}
minutes, err := strconv.Atoi(parts[0])
if err != nil {
return true
}
seconds, err := strconv.Atoi(parts[1])
if err != nil {
return true
}
const toleranceSec = 2
foundDuration := minutes*60 + seconds
if foundDuration <= wantDuration-toleranceSec || foundDuration >= wantDuration+toleranceSec {
return true
}
matchURL = sel.AttrOr("href", "")
return false
})
if matchURL == "" {
return "", fmt.Errorf("no data found")
}
return rootURL + matchURL, nil
}
func fetchBPMPage(ctx context.Context, httpClient *http.Client, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", err
}
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}
func parseBPM(html string) (bpmKey, error) {
bpmMatch := bpmRegex.FindStringSubmatch(html)
keyMatch := keyRegex.FindStringSubmatch(html)
modeMatch := modeRegex.FindStringSubmatch(html)
if len(bpmMatch) != 2 || len(keyMatch) != 2 || len(modeMatch) != 2 {
return bpmKey{}, fmt.Errorf("no data found")
}
bpm := bpmMatch[1]
key := strings.SplitN(keyMatch[1], "/", 2)[0]
key = strings.ReplaceAll(key, "\u266f", "#")
key = strings.ReplaceAll(key, "\u266d", "b")
if modeMatch[1] == "minor" {
key += "m"
}
return bpmKey{BPM: bpm, Key: key}, nil
}
+117
View File
@@ -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
}
+124
View File
@@ -0,0 +1,124 @@
package download
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/PuerkitoBio/goquery"
)
var electronicKeywords = toLower([]string{
"Ambient", "Bass", "Big Room", "Breakbeat", "Dance", "Disco", "Downtempo",
"Drum And Bass", "Dub", "Dubstep", "EDM", "Electro", "Electronic", "Electronica",
"Eurodance", "Gabber", "Garage", "Hardcore", "Hardstyle", "House", "Industrial",
"Jungle", "Moombahton", "Synthpop", "Synthwave", "Techno", "Trance", "Trap",
"Trip Hop", "Vaporwave",
})
var nonElectronicKeywords = toLower([]string{
"Blues", "Chillout", "Classical", "Country", "Folk", "Funk", "Hip Hop", "Jazz",
"Latin", "Metal", "Pop", "R&B", "Rap", "Reggae", "Rock", "Soul",
})
func toLower(ss []string) []string {
out := make([]string, len(ss))
for i, s := range ss {
out[i] = strings.ToLower(s)
}
return out
}
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)
doc, err := fetchGenrePage(ctx, httpClient, reqURL)
if err != nil {
return "", err
}
tags := parseGenreTags(doc)
if len(tags) > 2 {
tags = tags[:2]
}
filtered := filterTags(tags)
if len(filtered) == 0 {
return "", fmt.Errorf("no data found")
}
return formatTags(filtered), nil
}
func fetchGenrePage(ctx context.Context, httpClient *http.Client, url string) (*goquery.Document, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return goquery.NewDocumentFromReader(resp.Body)
}
func parseGenreTags(doc *goquery.Document) []string {
var tags []string
doc.Find("ol.big-tags .big-tags-item-name a").Each(func(_ int, s *goquery.Selection) {
if tag := strings.TrimSpace(s.Text()); tag != "" {
tags = append(tags, tag)
}
})
return tags
}
func matchesKeyword(tag string, keywords []string) bool {
tagLower := strings.ToLower(tag)
for _, kw := range keywords {
if strings.Contains(tagLower, kw) {
return true
}
}
return false
}
func filterTags(tags []string) []string {
var electronic, nonElectronic []string
for _, tag := range tags {
if matchesKeyword(tag, electronicKeywords) {
electronic = append(electronic, tag)
} else if matchesKeyword(tag, nonElectronicKeywords) {
nonElectronic = append(nonElectronic, tag)
}
}
if len(electronic) > 0 {
return append(electronic, nonElectronic...)
}
return electronic
}
func formatTags(tags []string) string {
formatted := make([]string, 0, len(tags))
for _, tag := range tags {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
words := strings.Fields(tag)
for i, w := range words {
words[i] = strings.ToUpper(w[:1]) + strings.ToLower(w[1:])
}
formatted = append(formatted, strings.Join(words, " "))
}
return strings.Join(formatted, " / ")
}
+69
View File
@@ -0,0 +1,69 @@
package download
import (
"context"
"crypto/sha256"
"encoding/hex"
"io"
"io/fs"
"os"
"path/filepath"
)
func hashFile(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
h := sha256.New()
if _, err := io.Copy(h, file); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
type hashIndex struct {
files map[string]string
}
func newHashIndex(ctx context.Context, root string) (*hashIndex, error) {
index := &hashIndex{files: make(map[string]string)}
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if err != nil || d.IsDir() {
return nil
}
hash, err := hashFile(path)
if err != nil {
return nil
}
index.files[hash] = path
return nil
})
if err != nil {
return nil, err
}
return index, nil
}
func (h *hashIndex) find(hash string) (string, bool) {
path, ok := h.files[hash]
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
}
+111
View File
@@ -0,0 +1,111 @@
package download
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/mathismqn/godeez/internal/deezer"
)
func resourceInfo(resource deezer.Resource) string {
switch r := resource.(type) {
case *deezer.Album:
return albumInfo(r)
case *deezer.Playlist:
return playlistInfo(r)
case *deezer.Artist:
return artistInfo(r)
case *deezer.Single:
return singleInfo(r)
}
return ""
}
func albumInfo(a *deezer.Album) string {
duration, err := strconv.Atoi(a.Results.Data.Duration)
if err != nil {
duration = 0
}
return fmt.Sprintf(
`================= [ Album Info ] =================
Title: %s
Artist: %s
Tracks: %d
Duration: %s
==================================================`,
a.Results.Data.Title,
a.Results.Data.Artist,
len(a.Results.Tracks.Data),
time.Duration(duration)*time.Second,
)
}
func playlistInfo(p *deezer.Playlist) string {
return fmt.Sprintf(
`=============== [ Playlist Info ] ===============
Title: %s
Creator: %s
Tracks: %d
Duration: %s
=================================================`,
p.Results.Data.Title,
p.Results.Data.Creator,
len(p.Results.Tracks.Data),
time.Duration(p.Results.Data.Duration)*time.Second,
)
}
func artistInfo(a *deezer.Artist) string {
tracks := a.Results.Tracks.Data
count := len(tracks)
totalSec := 0
for _, t := range tracks {
if d, err := strconv.Atoi(t.Duration); err == nil {
totalSec += d
}
}
limit := min(3, count)
var b strings.Builder
fmt.Fprintf(&b, "============= [ Artist Info ] =============\n")
fmt.Fprintf(&b, "Artist: %s\n", a.Results.Data.Name)
fmt.Fprintf(&b, "Tracks: %d\n", count)
fmt.Fprintf(&b, "Playtime: %s\n", time.Duration(totalSec)*time.Second)
fmt.Fprintf(&b, "-------------------------------------------\n")
fmt.Fprintf(&b, "Top %d most popular tracks:\n", limit)
for i := 0; i < limit; i++ {
t := tracks[i]
fmt.Fprintf(&b, " %2d. %s %s\n", i+1, t.Artist, t.GetTitle())
}
fmt.Fprintf(&b, "===========================================\n")
return b.String()
}
func singleInfo(s *deezer.Single) string {
if s.Results.Data == nil {
return "Track: No data available"
}
duration, err := strconv.Atoi(s.Results.Data.Duration)
if err != nil {
duration = 0
}
return fmt.Sprintf(
`================= [ Track Info ] =================
Title: %s
Artist: %s
Duration: %s
==================================================`,
s.Results.Data.GetTitle(),
s.Results.Data.Artist,
time.Duration(duration)*time.Second,
)
}
+79
View File
@@ -0,0 +1,79 @@
package download
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/mathismqn/godeez/internal/deezer"
)
type bpmKey struct {
BPM string
Key string
}
type metadataResult struct {
bpmKey bpmKey
genre string
warnings []string
}
func fetchMetadata(httpClient *http.Client, ctx context.Context, track *deezer.Track, opts Options) metadataResult {
if !opts.BPM && !opts.Genre {
return metadataResult{}
}
type bpmResult struct {
value bpmKey
err error
}
type genreResult struct {
value string
err error
}
bpmChan := make(chan bpmResult, 1)
genreChan := make(chan genreResult, 1)
if opts.BPM {
go func() {
result, err := fetchBPM(ctx, httpClient, track.Artist, track.Title, track.Duration)
bpmChan <- bpmResult{value: result, err: err}
}()
}
if opts.Genre {
go func() {
genre, err := fetchGenre(ctx, httpClient, track.Artist, track.GetTitle())
genreChan <- genreResult{value: genre, err: err}
}()
}
var result metadataResult
if opts.BPM {
r := <-bpmChan
if r.err != nil {
if !errors.Is(r.err, context.Canceled) {
result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch BPM and key: %v", r.err))
}
} else {
result.bpmKey = r.value
}
}
if opts.Genre {
r := <-genreChan
if r.err != nil {
if !errors.Is(r.err, context.Canceled) {
result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch genre: %v", r.err))
}
} else {
result.genre = r.value
}
}
return result
}
+38
View File
@@ -0,0 +1,38 @@
package download
import (
"fmt"
"time"
)
var validQualities = map[string]bool{
"mp3_128": true,
"mp3_320": true,
"flac": true,
}
type Options struct {
Quality string
Timeout time.Duration
Limit int
BPM bool
Genre bool
Strict bool
}
func (o *Options) Validate() error {
if !validQualities[o.Quality] {
return fmt.Errorf("invalid quality option: %s", o.Quality)
}
if o.Timeout <= 0 {
return fmt.Errorf("timeout must be a positive duration")
}
if o.Limit <= 0 {
return fmt.Errorf("limit must be a positive integer")
}
if o.Limit > 100 {
return fmt.Errorf("limit must not exceed 100")
}
return nil
}
+119
View File
@@ -0,0 +1,119 @@
package download
import (
"fmt"
"math/rand"
"os"
"time"
"github.com/briandowns/spinner"
"github.com/mathismqn/godeez/internal/deezer"
)
type downloadResult struct {
skipped bool
path string
warnings []string
err error
}
type downloadStats struct {
downloaded int
skipped int
failed int
warnings int
}
type progressTracker struct {
stats downloadStats
totalTracks int
kind deezer.Kind
}
func newProgressTracker(totalTracks int, kind deezer.Kind) *progressTracker {
return &progressTracker{
totalTracks: totalTracks,
kind: kind,
}
}
func (pt *progressTracker) startDownload(index int, track *deezer.Track) *spinner.Spinner {
trackProgress := fmt.Sprintf("[%d/%d]", index+1, pt.totalTracks)
sp := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
sp.Writer = os.Stdout
sp.Prefix = trackProgress + " "
sp.Suffix = fmt.Sprintf(" Downloading: %s - %s", track.Artist, track.GetTitle())
sp.Start()
return sp
}
func (pt *progressTracker) handleResult(index int, track *deezer.Track, result downloadResult) {
trackProgress := fmt.Sprintf("[%d/%d]", index+1, pt.totalTracks)
trackTitle := track.GetTitle()
if result.skipped {
pt.stats.skipped++
fmt.Printf("%s ↷ Skipped: %s - %s\n Already exists at: %s\n",
trackProgress, track.Artist, trackTitle, result.path)
return
}
if result.err != nil {
pt.stats.failed++
fmt.Printf("%s ✖ Failed: %s - %s:\n Error: %v\n",
trackProgress, track.Artist, trackTitle, result.err)
return
}
pt.stats.downloaded++
if len(result.warnings) > 0 {
pt.stats.warnings++
}
symbol := "✔"
if len(result.warnings) > 0 {
symbol = "⚠"
}
fmt.Printf("%s %s Downloaded: %s - %s\n", trackProgress, symbol, track.Artist, trackTitle)
for _, w := range result.warnings {
fmt.Printf(" Warning: %s\n", w)
}
}
func (pt *progressTracker) printSummary(outputDir string, elapsed time.Duration) {
if pt.kind != deezer.KindTrack {
warningsLine := ""
if pt.stats.warnings > 0 {
warningsLine = fmt.Sprintf("\nWarnings: %d", pt.stats.warnings)
}
fmt.Printf(`
================== [ Summary ] ==================
Downloaded: %d
Skipped: %d
Failed: %d%s
Elapsed time: %s
Files saved to: %s
=================================================
`,
pt.stats.downloaded,
pt.stats.skipped,
pt.stats.failed,
warningsLine,
elapsed.Round(time.Second),
outputDir,
)
if pt.stats.downloaded > 0 {
pt.showSupportMessage()
}
}
}
func (*progressTracker) showSupportMessage() {
if rand.Float64() < 0.1 {
fmt.Println("\n⭐ Enjoying GoDeez? Star it on GitHub: https://github.com/mathismqn/godeez")
}
}
+36
View File
@@ -0,0 +1,36 @@
package download
import (
"context"
"github.com/mathismqn/godeez/internal/fsutil"
)
func (d *Downloader) shouldSkipDownload(ctx context.Context, trackID, mediaFormat string) (string, bool) {
existing, err := d.store.DownloadInfo(trackID)
if err != nil || existing.Quality != mediaFormat {
return "", false
}
if fsutil.Exists(existing.Path) {
return existing.Path, true
}
if existing.Hash == "" {
return "", false
}
if err := d.initHashIndex(ctx); err != nil {
return "", false
}
foundPath, ok := d.hashIndex.find(existing.Hash)
if !ok {
return "", false
}
existing.Path = foundPath
_ = d.store.PutDownloadInfo(existing)
return foundPath, true
}
+64
View File
@@ -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
}
+40
View File
@@ -0,0 +1,40 @@
package download
import (
"strings"
"github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/tag"
)
func buildTagMetadata(resource deezer.Resource, track *deezer.Track, cover []byte, bpm bpmKey, genre string) tag.Metadata {
m := tag.Metadata{
Title: track.GetTitle(),
Artists: strings.Join(track.Contributors.MainArtists, ", "),
Composers: strings.Join(track.Contributors.Composers, ", "),
Lyricists: strings.Join(track.Contributors.Authors, ", "),
Genre: genre,
BPM: bpm.BPM,
Key: bpm.Key,
TrackNumber: track.TrackNumber,
Duration: track.Duration,
Gain: track.Gain,
ISRC: track.ISRC,
Cover: cover,
}
if album, ok := resource.(*deezer.Album); ok {
data := album.Results.Data
m.Album = &tag.AlbumMetadata{
Artist: data.Artist,
Title: data.Title,
Label: data.Label,
OriginalReleaseDate: data.OriginalReleaseDate,
ReleaseDate: data.PhysicalReleaseDate,
ProducerLine: data.ProducerLine,
Copyright: data.Copyright,
}
}
return m
}
+97
View File
@@ -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
}