Files
godeez/internal/download/download.go
T
arnef b1c15341fd feat: write an m3u playlist to preserve playlist track order
Playlist file names carry no ordering information, so the sequence a
user curated on Deezer was lost once the tracks landed on disk (unlike
albums, which get a zero padded track number prefix). downloadTrack
now reports the output path on success as well as on skip, and
downloadAllTracks collects the per-track paths for playlist downloads
and writes them, in order, to an extended m3u file next to the audio.
Failed tracks are left out since there is nothing on disk to point at;
a write failure is reported as a warning rather than failing the run.
2026-08-14 21:00:08 +02:00

172 lines
5.4 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)
var m3uEntries []m3uEntry
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)
if d.kind == deezer.KindPlaylist && result.err == nil {
m3uEntries = append(m3uEntries, m3uEntry{track: track, path: result.path})
}
}
// Written after the loop, not per track, so a run that fails partway
// through still produces a playlist covering whatever did make it to
// disk instead of no playlist at all.
if d.kind == deezer.KindPlaylist {
if err := writeM3U(outputDir, m3uEntries); err != nil {
fmt.Printf("\nWarning: failed to write m3u playlist: %v\n", err)
}
}
progress.printSummary(outputDir, time.Since(startTime))
return nil
}