docs: document packages, exported API and non-obvious logic

This commit is contained in:
Mathis Maquenne
2026-08-06 13:04:10 +02:00
parent 5dfd832d0d
commit b1ad9b4904
41 changed files with 757 additions and 7 deletions
+24
View File
@@ -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)
+38
View File
@@ -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()
+21
View File
@@ -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 {
+15
View File
@@ -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)
+8
View File
@@ -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{}
+6
View File
@@ -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)
+2
View File
@@ -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")
+12
View File
@@ -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 {
+28
View File
@@ -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 {
+30
View File
@@ -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