Files
yellowjacket/backend/download/want.go
T
yonluandClaude Sonnet 5 e190fd75b9
Build & publish Arch package / arch-package (push) Successful in 2m12s
Search index maintenance / maintain-index (push) Successful in 2h22m28s
feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Ships the fresh-start schema cleanup: rebuilt explore catalog index
pipeline (dump import, artifact fetch/build, incremental listen-count
refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/
slskd/yt-dlp providers, staging, reconciliation, wanted list), and the
supporting schema/query/store changes across backend and frontend.

Also includes two smaller follow-ups: bump the central index's
rebuild-after cadence from 90 to 180 days, and remove the Explore
"library only" online/offline toggle entirely (frontend-only, no
backend counterpart) rather than carry unused UI/state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
2026-08-06 17:12:01 -04:00

237 lines
7.5 KiB
Go

package download
import (
"math"
"math/rand/v2"
"time"
)
// A Want is a persistent "I want this", stored as a MusicBrainz ID and
// almost nothing else.
//
// The distinction from Request is the whole point of this file. A
// Request is one attempt: it searches, it grabs, it succeeds or fails,
// and then it is history. A Want outlives every attempt made on its
// behalf. Nothing being findable today is the normal case for obscure
// music, and the correct response is to try again next week, not to
// show the user a failed row they have to remember to retry.
//
// Because a Want is only an MBID, it stays true when everything around
// it changes: the explore index is rebuilt, a provider is swapped out,
// the release the user originally saw is superseded by a remaster. The
// display fields are a cache for the list view and are never consulted
// for matching.
// Entity says what a want's MBID names, and is the only type
// distinction the wanted list makes.
type Entity string
// Want entity types.
const (
// EntityArtist is a subscription rather than a thing to fetch: it
// is never satisfied, and each reconcile expands the artist's
// discography into child wants.
EntityArtist Entity = "artist"
// EntityReleaseGroup is an album in the abstract — any release of
// it satisfies the want, which is what a user means by "I want this
// album".
EntityReleaseGroup Entity = "release-group"
// EntityRelease is one specific edition, used when the user picked
// a particular pressing.
EntityRelease Entity = "release"
// EntityRecording is a single track.
EntityRecording Entity = "recording"
)
// Valid reports whether e is a known entity type.
func (e Entity) Valid() bool {
switch e {
case EntityArtist, EntityReleaseGroup, EntityRelease, EntityRecording:
return true
default:
return false
}
}
// Expands reports whether this entity produces child wants rather than
// being downloaded directly.
func (e Entity) Expands() bool {
return e == EntityArtist
}
// WantState is where a want sits. There is deliberately no "failed":
// an attempt can fail, a want cannot. A want that has tried and not
// found anything is still wanted, with attempts and last_error
// recording why it is taking a while.
type WantState string
// Want states.
const (
// WantStateWanted is the active state: due for another attempt when
// its backoff elapses.
WantStateWanted WantState = "wanted"
// WantStateSatisfied means the library owns it. How it got there —
// downloaded here, ripped, bought elsewhere — does not matter.
WantStateSatisfied WantState = "satisfied"
// WantStatePaused is the user saying "keep this on the list but
// stop trying".
WantStatePaused WantState = "paused"
)
// WantScope applies to artist wants only.
type WantScope string
// Artist want scopes.
const (
// ScopeFuture takes only releases first published after the artist
// was added. Default, because subscribing to an artist should not
// silently queue their entire back catalogue.
ScopeFuture WantScope = "future"
// ScopeAll backfills the whole discography as well.
ScopeAll WantScope = "all"
)
// Want is one row of the wanted list.
type Want struct {
ID int64 `json:"id"`
MBID string `json:"mbid"`
Entity Entity `json:"entity"`
LibraryID int64 `json:"libraryId"`
// Artist and Title are display cache only. Matching always uses
// the MBID.
Artist string `json:"artist"`
Title string `json:"title"`
Scope WantScope `json:"scope"`
// Secondary includes compilations, live albums and remixes in an
// artist want's expansion.
Secondary bool `json:"secondary"`
State WantState `json:"state"`
// ParentID is set on wants the reconciler derived from an artist
// subscription. A want the user pinned directly has none, so
// removing the artist leaves it alone.
ParentID int64 `json:"parentId,omitempty"`
Attempts int `json:"attempts"`
LastError string `json:"lastError,omitempty"`
LastTriedAt time.Time `json:"lastTriedAt,omitempty"`
NextTryAt time.Time `json:"nextTryAt,omitempty"`
// ExternalIDs maps provider row ID (as a string, because JSON
// object keys are strings) to that provider's own identifier for
// this want. Only set for providers that keep a persistent list of
// their own.
ExternalIDs map[string]string `json:"externalIds,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Anchored is always true for a want: it is an MBID by construction.
// The method exists so wants and requests read the same at call sites.
func (w Want) Anchored() bool { return w.MBID != "" }
// Label is the wanted list's one-line description of a want.
func (w Want) Label() string {
switch {
case w.Artist != "" && w.Title != "":
return w.Artist + " — " + w.Title
case w.Title != "":
return w.Title
case w.Artist != "":
return w.Artist
default:
return string(w.Entity) + " " + w.MBID
}
}
// Retry backoff. A want that cannot be found is usually one that will
// not be findable for a while — a pre-release, something only ever on
// physical media, an artist no source indexes — so the schedule climbs
// fast and then sits at a weekly poll rather than hammering providers
// with the same fruitless search.
const (
// wantRetryBase is the delay after the first unsuccessful attempt.
wantRetryBase = 6 * time.Hour
// wantRetryMax caps the backoff. A weekly retry on a list of a few
// hundred wants is a handful of searches a day, which every
// provider tolerates.
wantRetryMax = 7 * 24 * time.Hour
// wantRetryJitter spreads retries so a list added in one sitting
// does not come due in one burst.
wantRetryJitter = 0.2
)
// nextRetry returns when a want with the given attempt count should be
// tried again: exponential from wantRetryBase, capped at wantRetryMax,
// jittered so a batch added together does not stay in lockstep forever.
func nextRetry(now time.Time, attempts int) time.Time {
if attempts < 1 {
attempts = 1
}
// Cap the exponent before shifting so a long-lived want cannot
// overflow the duration into something negative.
const maxExp = 16
exp := min(attempts-1, maxExp)
delay := float64(wantRetryBase) * math.Pow(2, float64(exp))
if delay > float64(wantRetryMax) {
delay = float64(wantRetryMax)
}
jitter := delay * wantRetryJitter * (rand.Float64()*2 - 1) //nolint:gosec // spreading retries, not a secret
return now.Add(time.Duration(delay + jitter))
}
// wantSource is the request source recorded for reconciler-raised
// requests, so the downloads list can tell them apart from the ones a
// user started by hand.
const wantSource = "wanted"
// ToRequest builds the download request that would satisfy this want.
// Expected is filled by the caller from the catalog, since resolving a
// tracklist is I/O and this is not.
func (w Want) ToRequest(id string) Request {
req := Request{
ID: id,
LibraryID: w.LibraryID,
Artist: w.Artist,
Album: w.Title,
WantID: w.ID,
Source: wantSource,
}
switch w.Entity {
case EntityRelease:
req.ReleaseMBID = w.MBID
case EntityReleaseGroup:
req.ReleaseGroupMBID = w.MBID
case EntityRecording:
// A recording has no release anchor, so ranking has only the
// title to go on and auto-pick stays off. The MBID is still
// carried in RecordingMBID so a provider that can use it does.
req.RecordingMBID = w.MBID
case EntityArtist:
// Artist wants expand into children and are never turned into
// a request directly; this case exists so the switch is
// exhaustive rather than because it can happen.
}
return req
}