157 lines
4.9 KiB
Go
157 lines
4.9 KiB
Go
// Package download drives the end to end download of a Deezer resource.
|
|
//
|
|
// Run fetches the resource, then walks its tracks in order: resolve a media
|
|
// source, decide whether the track can be skipped, stream and decrypt it,
|
|
// optionally convert to wav, write tags, and record the result in the store
|
|
// so a later run can skip it. Tracks are processed one at a time.
|
|
//
|
|
// Most per-track failures are collected as warnings rather than aborting the
|
|
// run, so an unavailable cover or a failed BPM lookup does not cost the user
|
|
// the rest of an album. Only context cancellation stops the loop early.
|
|
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,
|
|
}
|
|
}
|
|
|
|
// Run downloads every track of the resource identified by id. opts is
|
|
// expected to have passed Validate already, which the cmd package does while
|
|
// parsing flags.
|
|
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)
|
|
}
|
|
|
|
// initDeezerClient authenticates and rejects quality settings the account
|
|
// cannot serve.
|
|
//
|
|
// The check runs against sourceQuality rather than the raw option because wav
|
|
// is produced locally from a flac source, so it carries the same premium
|
|
// requirement as flac. mp3_128 is the only format available without a
|
|
// subscription. Failing here keeps the user from watching a whole album
|
|
// download at a silently downgraded quality.
|
|
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.sourceQuality() != "mp3_128" {
|
|
return fmt.Errorf("premium account required for '%s' quality", opts.Quality)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// prepareResource fetches the resource, applies the artist track limit, and
|
|
// makes sure the output directory exists.
|
|
//
|
|
// The limit only applies to artists because that is the one kind whose track
|
|
// list is unbounded: it is the artist's top tracks, not a finite album or
|
|
// playlist.
|
|
//
|
|
// Sweeping the part files last clears leftovers from a previous run that was
|
|
// killed mid-write. They are ignorable on their own, but they accumulate and
|
|
// would otherwise be mistaken for real downloads.
|
|
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.Tracks()
|
|
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.OutputDir(d.appConfig.OutputDir)
|
|
if err := fsutil.EnsureDir(outputDir); err != nil {
|
|
return nil, "", fmt.Errorf("failed to create output directory: %w", err)
|
|
}
|
|
sweepPartFiles(outputDir)
|
|
|
|
return resource, outputDir, nil
|
|
}
|
|
|
|
// downloadAllTracks runs the per-track pipeline over the whole resource and
|
|
// prints the summary.
|
|
//
|
|
// Cancellation is checked both before each track and against the result,
|
|
// because a track cancelled mid-stream surfaces the error through the result
|
|
// rather than through ctx. Any other per-track error is recorded and the loop
|
|
// continues.
|
|
func (d *Downloader) downloadAllTracks(ctx context.Context, resource deezer.Resource, opts Options, outputDir string) error {
|
|
tracks := resource.Tracks()
|
|
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
|
|
}
|