Files

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
}