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
237 lines
7.5 KiB
Go
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
|
|
}
|