docs: document packages, exported API and non-obvious logic
This commit is contained in:
@@ -15,6 +15,15 @@ import (
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
// songbpm.com publishes the tempo and key as prose rather than as structured
|
||||
// data, so these match the surrounding sentence instead of a CSS selector.
|
||||
// The attribute wildcards absorb the utility classes the site regenerates on
|
||||
// every deploy, but the wording itself is load bearing: if the sentence
|
||||
// changes, the lookup starts returning no data. The double space in modeRegex
|
||||
// is present in the real markup and is not a typo.
|
||||
//
|
||||
// The key pattern accepts both the typographic accidentals the page renders
|
||||
// and their ASCII equivalents, since which one appears varies by track.
|
||||
var (
|
||||
bpmRegex = regexp.MustCompile(`tempo of <span[^>]*>(\d+) BPM`)
|
||||
keyRegex = regexp.MustCompile(`with a <span[^>]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?)</span> key`)
|
||||
@@ -35,6 +44,14 @@ func fetchBPM(ctx context.Context, httpClient *http.Client, artist, title, durat
|
||||
return parseBPM(html)
|
||||
}
|
||||
|
||||
// findTrackURL searches songbpm.com and returns the page for the track.
|
||||
//
|
||||
// Artist and title alone are not enough to identify a track, since the search
|
||||
// happily returns remixes, live versions and covers under the same names.
|
||||
// Duration is used as the tiebreaker, with a couple of seconds of tolerance
|
||||
// to absorb the disagreement between Deezer's rounding and songbpm's. No
|
||||
// match within tolerance is treated as not found rather than guessed at,
|
||||
// because a wrong BPM is worse than a missing one.
|
||||
func findTrackURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) {
|
||||
const rootURL = "https://songbpm.com"
|
||||
|
||||
@@ -133,6 +150,13 @@ func fetchBPMPage(ctx context.Context, httpClient *http.Client, pageURL string)
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// parseBPM extracts the tempo and musical key from a track page.
|
||||
//
|
||||
// All three patterns must match: a page with a tempo but no key is treated as
|
||||
// no data, since a half filled tag is not worth writing. Enharmonic keys are
|
||||
// published as pairs like "C#/Db" and only the first spelling is kept, the
|
||||
// accidentals are folded to ASCII for tag compatibility, and a minor mode is
|
||||
// encoded with a trailing "m" to match the convention DJ software expects.
|
||||
func parseBPM(html string) (bpmKey, error) {
|
||||
bpmMatch := bpmRegex.FindStringSubmatch(html)
|
||||
keyMatch := keyRegex.FindStringSubmatch(html)
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
// 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 (
|
||||
@@ -32,6 +42,9 @@ func New(appConfig *config.Config, st *store.Store, kind deezer.Kind) *Downloade
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -45,6 +58,14 @@ func (d *Downloader) Run(ctx context.Context, opts Options, id string) error {
|
||||
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)
|
||||
@@ -59,6 +80,16 @@ func (d *Downloader) initDeezerClient(ctx context.Context, opts Options) error {
|
||||
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 {
|
||||
@@ -86,6 +117,13 @@ func (d *Downloader) prepareResource(ctx context.Context, id string, opts Option
|
||||
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()
|
||||
|
||||
@@ -13,6 +13,13 @@ import (
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
// Genres come from last.fm's community tags, which are free text and range
|
||||
// from real genres to things like "seen live". These two lists are the filter
|
||||
// that keeps only the useful ones. They are matched as substrings, so "deep
|
||||
// house" is caught by "house".
|
||||
//
|
||||
// The split into two lists drives the ordering in filterTags, which prefers
|
||||
// the electronic tag as the primary genre.
|
||||
var electronicKeywords = toLower([]string{
|
||||
"Ambient", "Bass", "Big Room", "Breakbeat", "Dance", "Disco", "Downtempo",
|
||||
"Drum And Bass", "Dub", "Dubstep", "EDM", "Electro", "Electronic", "Electronica",
|
||||
@@ -44,6 +51,9 @@ func fetchGenre(ctx context.Context, httpClient *http.Client, artist, title stri
|
||||
return "", err
|
||||
}
|
||||
|
||||
// last.fm orders tags by popularity, so the first two are the consensus
|
||||
// view. Taking more starts pulling in mood and era tags that make a poor
|
||||
// genre field.
|
||||
tags := parseGenreTags(doc)
|
||||
if len(tags) > 2 {
|
||||
tags = tags[:2]
|
||||
@@ -76,6 +86,9 @@ func fetchGenrePage(ctx context.Context, httpClient *http.Client, pageURL string
|
||||
return goquery.NewDocumentFromReader(resp.Body)
|
||||
}
|
||||
|
||||
// parseGenreTags reads the tag list out of a last.fm page. The selector
|
||||
// tracks last.fm's current markup and is the first thing to break if they
|
||||
// redesign; a failure here is non-fatal and simply leaves the genre unset.
|
||||
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) {
|
||||
@@ -96,6 +109,11 @@ func matchesKeyword(tag string, keywords []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// filterTags keeps only recognised genre tags, electronic ones first.
|
||||
//
|
||||
// It returns nothing at all unless at least one electronic tag matched, so a
|
||||
// purely non-electronic track ends up with no genre rather than a partial
|
||||
// one. Tags matching neither list are dropped.
|
||||
func filterTags(tags []string) []string {
|
||||
var electronic, nonElectronic []string
|
||||
|
||||
@@ -113,6 +131,9 @@ func filterTags(tags []string) []string {
|
||||
return electronic
|
||||
}
|
||||
|
||||
// formatTags title cases the tags and joins them for the genre field.
|
||||
// last.fm tags arrive in whatever case the tagger typed, so they are
|
||||
// normalised rather than written through as is.
|
||||
func formatTags(tags []string) string {
|
||||
formatted := make([]string, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
|
||||
@@ -25,10 +25,19 @@ func hashFile(path string) (string, error) {
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// hashIndex maps content hash to path for everything under the output
|
||||
// directory. It is what lets a moved or renamed file still be recognised as
|
||||
// an existing download.
|
||||
type hashIndex struct {
|
||||
files map[string]string
|
||||
}
|
||||
|
||||
// newHashIndex hashes every file under root.
|
||||
//
|
||||
// Unreadable files and directories are skipped rather than failing the walk,
|
||||
// since a permission error somewhere in a music library should not break the
|
||||
// skip check. Only cancellation aborts it. Duplicated content collapses to
|
||||
// whichever path is walked last, which is fine: any copy is a valid answer.
|
||||
func newHashIndex(ctx context.Context, root string) (*hashIndex, error) {
|
||||
index := &hashIndex{files: make(map[string]string)}
|
||||
|
||||
@@ -60,6 +69,12 @@ func (h *hashIndex) find(hash string) (string, bool) {
|
||||
return path, ok
|
||||
}
|
||||
|
||||
// initHashIndex builds the index on first use and reuses it afterwards.
|
||||
//
|
||||
// Building it means hashing an entire music library, so it is deferred until
|
||||
// something actually needs it: a run where every recorded path is still valid
|
||||
// never pays that cost. The error is cached alongside the index so a failed
|
||||
// build is not retried once per track.
|
||||
func (d *Downloader) initHashIndex(ctx context.Context) error {
|
||||
d.hashIndexOnce.Do(func() {
|
||||
d.hashIndex, d.hashIndexErr = newHashIndex(ctx, d.appConfig.OutputDir)
|
||||
|
||||
@@ -20,6 +20,14 @@ type metadataResult struct {
|
||||
warnings []string
|
||||
}
|
||||
|
||||
// fetchMetadata looks up BPM, key and genre from third party sites, running
|
||||
// the two lookups concurrently since neither depends on the other.
|
||||
//
|
||||
// Both channels are buffered so a goroutine whose result is never collected
|
||||
// still exits instead of blocking forever. Failures become warnings rather
|
||||
// than errors: these are nice to have tags, and a site being down should not
|
||||
// cost the user the track. Cancellation is silent, because the run is already
|
||||
// being torn down and a warning per track would just be noise.
|
||||
func fetchMetadata(ctx context.Context, httpClient *http.Client, track *deezer.Track, opts Options) metadataResult {
|
||||
if !opts.BPM && !opts.Genre {
|
||||
return metadataResult{}
|
||||
|
||||
@@ -24,6 +24,9 @@ type Options struct {
|
||||
Strict bool
|
||||
}
|
||||
|
||||
// sourceQuality is the quality to request from Deezer, which is not always
|
||||
// the quality the user asked for. Deezer does not serve wav, so a wav
|
||||
// download pulls flac and converts it locally.
|
||||
func (o *Options) sourceQuality() string {
|
||||
if o.Quality == "wav" {
|
||||
return "flac"
|
||||
@@ -35,6 +38,9 @@ func (o *Options) convertsToWAV() bool {
|
||||
return o.Quality == "wav"
|
||||
}
|
||||
|
||||
// Validate checks the options against the resource kind. The limit is only
|
||||
// meaningful for artists, whose top track list is open ended, and is capped
|
||||
// at 100 because that is as many as Deezer returns.
|
||||
func (o *Options) Validate(kind deezer.Kind) error {
|
||||
if !validQualities[o.Quality] {
|
||||
return fmt.Errorf("invalid quality option: %s", o.Quality)
|
||||
|
||||
@@ -112,6 +112,8 @@ Files saved to: %s
|
||||
}
|
||||
}
|
||||
|
||||
// showSupportMessage nudges the user to star the repository, but only on
|
||||
// roughly one run in ten.
|
||||
func (*progressTracker) showSupportMessage() {
|
||||
if rand.Float64() < 0.1 {
|
||||
fmt.Println("\n⭐ Enjoying GoDeez? Star it on GitHub: https://github.com/mathismqn/godeez")
|
||||
|
||||
@@ -6,6 +6,18 @@ import (
|
||||
"github.com/mathismqn/godeez/internal/fsutil"
|
||||
)
|
||||
|
||||
// shouldSkipDownload reports whether trackID has already been downloaded at
|
||||
// mediaFormat, returning the path of the existing file.
|
||||
//
|
||||
// A recorded download at a different quality is not a skip: asking for flac
|
||||
// after previously fetching mp3_128 should download again.
|
||||
//
|
||||
// When the recorded path is gone the file may simply have been moved or
|
||||
// renamed by the user, so the content hash is used to look for it elsewhere
|
||||
// under the output directory before giving up. A match repairs the stored
|
||||
// path, which keeps the ledger useful across library reorganisations. That
|
||||
// lookup is best effort throughout: every failure falls through to
|
||||
// downloading again, which is always safe.
|
||||
func (d *Downloader) shouldSkipDownload(ctx context.Context, trackID, mediaFormat string) (string, bool) {
|
||||
existing, err := d.store.DownloadInfo(trackID)
|
||||
if err != nil || existing.Quality != mediaFormat {
|
||||
|
||||
@@ -11,8 +11,14 @@ import (
|
||||
"github.com/mathismqn/godeez/internal/fsutil"
|
||||
)
|
||||
|
||||
// chunkSize is the stripe width Deezer encrypts with. It is fixed by the
|
||||
// BF_CBC_STRIPE cipher named in the media request and is not tunable: reading
|
||||
// in any other unit would misalign the stripe pattern and corrupt the output.
|
||||
const chunkSize = 2048
|
||||
|
||||
// sweepPartFiles deletes leftover .part files in dir. Failures are ignored
|
||||
// because this is opportunistic cleanup, and refusing to download because a
|
||||
// stale temp file could not be removed would be worse than leaving it.
|
||||
func sweepPartFiles(dir string) {
|
||||
matches, err := filepath.Glob(filepath.Join(dir, fsutil.PartPattern))
|
||||
if err != nil {
|
||||
@@ -23,6 +29,11 @@ func sweepPartFiles(dir string) {
|
||||
}
|
||||
}
|
||||
|
||||
// streamToFile writes the decrypted stream to outputPath.
|
||||
//
|
||||
// The download lands in a temporary file first and is only renamed into place
|
||||
// once it is complete, so an interrupted run never leaves a truncated file
|
||||
// sitting at the real path where it would look like a finished download.
|
||||
func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error {
|
||||
tmpPath, err := d.streamToTempFile(ctx, stream, filepath.Dir(outputPath), key)
|
||||
if err != nil {
|
||||
@@ -37,6 +48,12 @@ func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, out
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamToTempFile decrypts the stream into a .part file in dir and returns
|
||||
// its path. The caller owns the file from that point on. It closes stream.
|
||||
//
|
||||
// The temp file is created in the destination directory rather than the
|
||||
// system temp dir so the caller's rename stays on one filesystem and is
|
||||
// therefore atomic.
|
||||
func (d *Downloader) streamToTempFile(ctx context.Context, stream io.ReadCloser, dir string, key []byte) (string, error) {
|
||||
defer stream.Close()
|
||||
|
||||
@@ -45,6 +62,8 @@ func (d *Downloader) streamToTempFile(ctx context.Context, stream io.ReadCloser,
|
||||
return "", err
|
||||
}
|
||||
tmpPath := file.Name()
|
||||
// done stays false until the file is fully written and closed, so every
|
||||
// early return below removes the partial file instead of orphaning it.
|
||||
done := false
|
||||
defer func() {
|
||||
if !done {
|
||||
@@ -61,6 +80,10 @@ func (d *Downloader) streamToTempFile(ctx context.Context, stream io.ReadCloser,
|
||||
default:
|
||||
}
|
||||
|
||||
// Read until the chunk is full rather than trusting a single Read.
|
||||
// A short read is legal and common on a network stream, and treating
|
||||
// one as a chunk boundary would shift every following chunk out of
|
||||
// step with the stripe pattern.
|
||||
totalRead := 0
|
||||
for totalRead < chunkSize {
|
||||
n, err := stream.Read(buffer[totalRead:])
|
||||
@@ -77,6 +100,11 @@ func (d *Downloader) streamToTempFile(ctx context.Context, stream io.ReadCloser,
|
||||
break
|
||||
}
|
||||
|
||||
// Deezer encrypts only every third chunk and leaves the other two in
|
||||
// the clear, which is what BF_CBC_STRIPE means. A trailing partial
|
||||
// chunk is never encrypted even when its index is a multiple of three,
|
||||
// hence the length check: decrypting it would corrupt the end of the
|
||||
// file.
|
||||
if chunk%3 == 0 && totalRead == chunkSize {
|
||||
buffer, err = deezer.DecryptBlowfish(buffer, key)
|
||||
if err != nil {
|
||||
|
||||
@@ -15,6 +15,20 @@ import (
|
||||
"github.com/mathismqn/godeez/internal/tag"
|
||||
)
|
||||
|
||||
// downloadTrack runs the whole pipeline for one track and reports the outcome
|
||||
// rather than returning an error, so the caller can keep going.
|
||||
//
|
||||
// Ordering matters here. The format is resolved before the skip check,
|
||||
// because whether a track counts as already downloaded depends on the format
|
||||
// that will actually be written, which is not always the one requested. The
|
||||
// external metadata lookup is started concurrently and collected late, since
|
||||
// it hits third party sites and is the slowest part of the pipeline while
|
||||
// also being the least important. Tagging and the store write happen last, in
|
||||
// finalizeDownload, once the file is known to be complete.
|
||||
//
|
||||
// Only cancellation and a failure to produce the audio itself are fatal.
|
||||
// Everything else, including a missing cover or a quality downgrade, is
|
||||
// reported as a warning.
|
||||
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.sourceQuality())
|
||||
if err != nil {
|
||||
@@ -82,6 +96,9 @@ func (d *Downloader) downloadTrack(ctx context.Context, resource deezer.Resource
|
||||
|
||||
metadata := <-metadataChan
|
||||
|
||||
// Cancellation between the write and the tagging leaves a complete but
|
||||
// untagged file. Removing it keeps a cancelled run from being mistaken
|
||||
// for a finished one, and nothing has been recorded in the store yet.
|
||||
if err := ctx.Err(); err != nil {
|
||||
fsutil.Remove(outputPath)
|
||||
return downloadResult{err: err}
|
||||
@@ -93,6 +110,12 @@ func (d *Downloader) downloadTrack(ctx context.Context, resource deezer.Resource
|
||||
return downloadResult{warnings: warnings}
|
||||
}
|
||||
|
||||
// uniqueOutputPath avoids clobbering an unrelated file by appending " (2)",
|
||||
// " (3)" and so on until the name is free.
|
||||
//
|
||||
// The file this track already owns according to the store is exempt: a
|
||||
// re-download of the same track should overwrite its own output rather than
|
||||
// pile up numbered copies next to it.
|
||||
func (d *Downloader) uniqueOutputPath(trackID, path string) string {
|
||||
owned := ""
|
||||
if info, err := d.store.DownloadInfo(trackID); err == nil {
|
||||
@@ -109,6 +132,13 @@ func (d *Downloader) uniqueOutputPath(trackID, path string) string {
|
||||
return candidate
|
||||
}
|
||||
|
||||
// finalizeDownload tags the finished file and records it in the store,
|
||||
// returning any non-fatal problems as warnings.
|
||||
//
|
||||
// The hash is taken after tagging so it matches the bytes actually on disk,
|
||||
// which is what the skip check later compares against. The download is
|
||||
// recorded even when tagging or hashing failed: the audio is there, and
|
||||
// refusing to record it would mean downloading it all over again next time.
|
||||
func (d *Downloader) finalizeDownload(resource deezer.Resource, track *deezer.Track, outputPath, outputFormat, genre string, cover []byte, bpmKey bpmKey) []string {
|
||||
var warnings []string
|
||||
|
||||
|
||||
Reference in New Issue
Block a user