refactor: consolidate download domain into download package
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, " / ")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user