Files
yellowjacket/backend/download/reconcile.go
T
logan 760021ea5a
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m35s
CI / e2e (pull_request) Canceled after 0s
fix(downloads): stop searching a list there is nothing to search with
Every pass attempted every request, each came back "no download clients
are enabled", and RecordAttempt wrote that down as an attempt and put a
retry on the clock -- so a wanted list built deliberately without a
client accrued failures and announced "next check in 6 hours" about a
check that cannot happen.

Wanting something with no way to fetch it is supported. Being told it
is being looked for is a lie, and the row says what is true instead.

Everything above the attempt still runs: an artist subscription still
expands, and a request satisfied by some other route -- ripped, bought,
copied in -- is still retired. Neither needs a provider.

TestReconcileRespectsBatchSize now installs a client that finds
nothing, because a batch size is about how many requests one pass
searches for and that only means something when there is something to
search with.

Refs #37
2026-08-18 11:26:35 -04:00

821 lines
22 KiB
Go

package download
import (
"context"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
"time"
)
// The reconciler is the only thing that turns requests into downloads.
//
// It runs on a slow loop rather than reacting to events, because
// everything it cares about changes slowly: a release the user requests
// appears on a source days or weeks after they asked, an artist puts
// out an album once a year, and a library gains files by scan rather
// than by notification. A loop that wakes a few times a day is
// sufficient for all of it and costs nothing, where an event-driven
// design here would mean subscribing to three subsystems to learn the
// same facts later anyway.
//
// Each pass does four things, in this order and for this reason:
//
// 1. Expand artist subscriptions into per-album requests, so step 2 sees
// them this pass rather than next.
// 2. Retire requests the library already owns — including ones the user
// satisfied by other means, which is why ownership is checked
// rather than assumed from our own downloads.
// 3. Push the list to clients that keep their own (Lidarr), so the
// user's intent is expressed in both places.
// 4. Attempt a bounded batch of due requests.
//
// Nothing here fails a request. A request that cannot be found gets an
// attempt recorded and a longer backoff, and stays exactly as requested as
// it was.
// CatalogPort is what the reconciler needs to know about the world of
// music, kept narrow so the download package does not depend on the
// explore package (and so tests can answer these four questions from a
// map).
type CatalogPort interface {
// ReleaseGroupsForArtist returns an artist's discography. An empty
// result is not an error: the explore index fetches discographies
// lazily, so the honest answer is often "not yet".
ReleaseGroupsForArtist(
ctx context.Context,
artistMBID string,
) ([]CatalogItem, error)
// Tracklist resolves a release group or release to the tracks it
// should contain. This is what makes a request's download safe to
// complete unattended, so a request with no tracklist is never
// auto-grabbed.
Tracklist(
ctx context.Context,
entity Entity,
mbid string,
) ([]ExpectedTrack, error)
// Owns reports whether the library already has the thing an MBID
// names.
Owns(ctx context.Context, entity Entity, mbid string) (bool, error)
// Describe fills in display text for a request the user added by MBID
// alone. Best-effort: an unknown MBID returns false and the request
// is still perfectly valid.
Describe(
ctx context.Context,
entity Entity,
mbid string,
) (CatalogItem, bool)
}
// CatalogItem is one thing the catalog knows about, in the download
// package's own terms.
type CatalogItem struct {
MBID string
Title string
Artist string
ArtistMBID string
// PrimaryType is the MusicBrainz release-group type ("Album",
// "Single", "EP").
PrimaryType string
// SecondaryTypes carries "Compilation", "Live", "Remix" and
// friends. Their presence is what an artist request's default scope
// filters out.
SecondaryTypes []string
// FirstReleaseDate is a MusicBrainz partial date: "1997",
// "1997-04", or "1997-04-22".
FirstReleaseDate string
InLibrary bool
}
// Reconciler defaults.
const (
// defaultReconcileInterval is how often the request list is worked.
// Four times a day is far more often than new music appears and far
// less often than any provider would object to.
defaultReconcileInterval = 6 * time.Hour
// startupDelay lets the app finish starting — library scan, explore
// index, provider construction — before the first pass. A requested
// list worked against an index that has not loaded yet would record
// a pile of pointless attempts.
startupDelay = 3 * time.Minute
// maxExpandPerArtist bounds how many child requests one artist
// subscription creates in a single pass, so switching an artist to
// full-discography scope does not enqueue four hundred albums at
// once. The remainder is picked up next pass.
maxExpandPerArtist = 40
)
// Reconciler works the request list.
type Reconciler struct {
logger *slog.Logger
store *Store
manager *Manager
catalog CatalogPort
interval time.Duration
batch int
// now is injectable so tests can drive backoff without waiting.
now func() time.Time
// trigger is a nudge for an out-of-band pass, buffered to one
// because more than one pending "run now" is the same as one.
trigger chan struct{}
// onChange fires after any pass that altered the list, so the UI
// can refresh without polling.
onChange func()
stopOnce sync.Once
stop chan struct{}
// runMu serializes passes: two reconcilers racing would search for
// the same request twice.
runMu sync.Mutex
}
// NewReconciler builds a reconciler. catalog may be nil, in which case
// the request list still stores and lists requests but never acts on them —
// which is the right behaviour when the explore index is unavailable.
func NewReconciler(
logger *slog.Logger,
store *Store,
manager *Manager,
catalog CatalogPort,
) *Reconciler {
return &Reconciler{
logger: logger,
store: store,
manager: manager,
catalog: catalog,
interval: defaultReconcileInterval,
batch: defaultDueBatch,
now: time.Now,
trigger: make(chan struct{}, 1),
stop: make(chan struct{}),
}
}
// SetInterval overrides the pass interval.
func (r *Reconciler) SetInterval(d time.Duration) {
if d > 0 {
r.interval = d
}
}
// SetBatch overrides how many requests one pass attempts.
func (r *Reconciler) SetBatch(n int) {
if n > 0 {
r.batch = n
}
}
// SetOnChange registers a callback fired after a pass that changed the
// list.
func (r *Reconciler) SetOnChange(fn func()) {
r.onChange = fn
}
// Start runs the reconcile loop until ctx is done or Stop is called.
func (r *Reconciler) Start(ctx context.Context) {
go r.loop(ctx)
}
// Stop ends the loop.
func (r *Reconciler) Stop() {
r.stopOnce.Do(func() { close(r.stop) })
}
// Trigger asks for a pass as soon as possible without blocking the
// caller. Used when the user adds a request and expects something to
// happen.
func (r *Reconciler) Trigger() {
select {
case r.trigger <- struct{}{}:
default:
}
}
// loop is the reconcile timer.
func (r *Reconciler) loop(ctx context.Context) {
first := time.NewTimer(startupDelay)
defer first.Stop()
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-r.stop:
return
case <-first.C:
case <-ticker.C:
case <-r.trigger:
}
if _, err := r.RunOnce(ctx); err != nil {
r.logger.Warn("request list reconcile failed", "error", err)
}
}
}
// Summary reports what a pass did, for logging and for the UI.
type Summary struct {
// Expanded is how many child requests artist subscriptions produced.
Expanded int `json:"expanded"`
// Satisfied is how many requests the library turned out to own.
Satisfied int `json:"satisfied"`
// Attempted is how many requests were searched for.
Attempted int `json:"attempted"`
// Started is how many of those found a clear enough winner to
// download unattended.
Started int `json:"started"`
// Synced is how many requests were pushed to an external list.
Synced int `json:"synced"`
// Waiting is how many requests are on the list and still being
// looked for. A pass that did nothing is the normal case, and the
// UI can only say so honestly if it knows the list was not empty.
Waiting int `json:"waiting"`
// NoProviders reports that nothing could be searched because no
// download client is enabled — the one "nothing happened" the user
// can actually fix.
NoProviders bool `json:"noProviders"`
}
// changed reports whether the pass altered anything worth refreshing
// the UI for.
func (s Summary) changed() bool {
return s.Expanded > 0 || s.Satisfied > 0 || s.Started > 0
}
// RunOnce works the request list once, honouring each request's
// backoff. This is what the loop calls.
func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
return r.run(ctx, false)
}
// RunNow works the request list ignoring backoff. This is what the
// "check now" button calls: a scheduled retry is a promise to the
// provider, not to the user, and a person who presses a button expects
// their list to actually be searched rather than to be told it is not
// due yet.
func (r *Reconciler) RunNow(ctx context.Context) (Summary, error) {
return r.run(ctx, true)
}
func (r *Reconciler) run(ctx context.Context, force bool) (Summary, error) {
r.runMu.Lock()
defer r.runMu.Unlock()
var summary Summary
if r.catalog == nil {
return summary, nil
}
expanded, err := r.expandArtists(ctx)
if err != nil {
return summary, err
}
summary.Expanded = expanded
satisfied, err := r.retireOwned(ctx)
if err != nil {
return summary, err
}
summary.Satisfied = satisfied
summary.Synced = r.syncExternalLists(ctx)
// Nothing is searched for when there is nothing to search with, and
// the point is what that *does not* do to the list.
//
// Attempting anyway is not merely wasted work: every request comes
// back "no download clients are enabled", which RecordAttempt writes
// down as an attempt and schedules a retry for -- so a user who has
// deliberately built a wanted list with no client watched their
// requests accrue failures and announce "next check in 6 hours"
// about a check that cannot happen. Wanting something without a way
// to fetch it is a supported thing to do; being told it is being
// looked for is a lie.
//
// Everything above this line still runs: an artist subscription
// still expands, and a request the user satisfied by some other
// route -- ripped, bought, copied in -- is still retired, because
// neither needs a provider.
summary.NoProviders = len(r.manager.enabledProviders()) == 0
if !summary.NoProviders {
attempted, started, err := r.attemptDue(ctx, force)
if err != nil {
return summary, err
}
summary.Attempted = attempted
summary.Started = started
}
summary.Waiting = r.countWaiting(ctx)
r.logger.Info(
"reconciled request list",
"expanded", summary.Expanded,
"satisfied", summary.Satisfied,
"attempted", summary.Attempted,
"started", summary.Started,
"synced", summary.Synced,
)
if summary.changed() && r.onChange != nil {
r.onChange()
}
return summary, nil
}
// ---------------------------------------------------------------------------
// Artist expansion
// ---------------------------------------------------------------------------
// expandArtists turns artist subscriptions into per-album requests.
//
// The expansion is idempotent: child requests are upserted on (mbid,
// library), so re-running adds only what is genuinely new. That is
// what makes an artist request a standing subscription rather than a
// one-time queue-filling operation — an album released next year gets
// picked up by the same code path that ran today.
func (r *Reconciler) expandArtists(ctx context.Context) (int, error) {
artists, err := r.store.ListActiveRequests(ctx)
if err != nil {
return 0, err
}
created := 0
for _, artist := range artists {
n, err := r.expandArtist(ctx, artist)
if err != nil {
// One artist whose discography will not resolve must not
// stop the rest of the list.
r.logger.Warn(
"could not expand artist request",
"artist", artist.Label(),
"mbid", artist.MBID,
"error", err,
)
continue
}
created += n
}
return created, nil
}
// expandArtist expands one subscription.
func (r *Reconciler) expandArtist(ctx context.Context, artist Request) (int, error) {
groups, err := r.catalog.ReleaseGroupsForArtist(ctx, artist.MBID)
if err != nil {
return 0, err
}
created := 0
for _, rg := range groups {
if created >= maxExpandPerArtist {
break
}
if !requestsReleaseGroup(artist, rg) {
continue
}
// Existence is checked before inserting rather than relying on
// the upsert, because "how many albums are new this pass" is
// the number the UI reports and an upsert cannot tell an insert
// from a no-op. It also means a request the user pinned by hand
// is never quietly reparented under the artist.
if _, exists, err := r.store.FindRequest(
ctx, rg.MBID, artist.LibraryID,
); err != nil || exists {
continue
}
credit := rg.Artist
if credit == "" {
credit = artist.Artist
}
if _, err := r.store.AddRequest(ctx, Request{
MBID: rg.MBID,
Entity: EntityReleaseGroup,
LibraryID: artist.LibraryID,
Artist: credit,
Title: rg.Title,
ParentID: artist.ID,
}); err != nil {
r.logger.Warn(
"could not add derived request",
"release_group", rg.MBID,
"error", err,
)
continue
}
created++
}
return created, nil
}
// requestsReleaseGroup applies an artist subscription's filters to one
// release group.
func requestsReleaseGroup(artist Request, rg CatalogItem) bool {
if rg.MBID == "" || rg.InLibrary {
return false
}
if !artist.Secondary && len(rg.SecondaryTypes) > 0 {
return false
}
if artist.Scope == ScopeAll {
return true
}
// ScopeFuture: only releases the artist put out after the user
// subscribed. A partial MusicBrainz date is compared as a string,
// which sorts correctly for ISO dates and treats a bare year as the
// first of January — the conservative reading, since a release
// dated only "2026" against a subscription made in March 2026
// should not be assumed to be new.
return releaseDateAfter(rg.FirstReleaseDate, artist.CreatedAt)
}
// releaseDateAfter compares a MusicBrainz partial date against a
// timestamp. An unknown or unparseable date is treated as not-after,
// because a release with no date is almost always an old one.
func releaseDateAfter(date string, since time.Time) bool {
date = strings.TrimSpace(date)
if date == "" {
return false
}
// Pad a partial date to a full one so string comparison works:
// "1997" becomes "1997-01-01", "1997-04" becomes "1997-04-01".
switch len(date) {
case 4:
date += "-01-01"
case 7: //nolint:mnd // length of "YYYY-MM"
date += "-01"
}
return date > since.Format(time.DateOnly)
}
// ---------------------------------------------------------------------------
// Retiring what the library already has
// ---------------------------------------------------------------------------
// retireOwned satisfies requests the library turns out to own.
//
// Ownership is asked of the library rather than inferred from our own
// completed downloads on purpose: the user may have bought the album,
// ripped their CD, or copied it in from another machine, and a requested
// list that keeps hunting for music already sitting on disk is worse
// than no request list at all.
func (r *Reconciler) retireOwned(ctx context.Context) (int, error) {
requests, err := r.store.ListRequests(ctx)
if err != nil {
return 0, err
}
satisfied := 0
for _, req := range requests {
if req.State != RequestStateWanted || req.Entity.Expands() {
continue
}
owned, err := r.catalog.Owns(ctx, req.Entity, req.MBID)
if err != nil {
r.logger.Debug(
"ownership check failed", "request", req.MBID, "error", err,
)
continue
}
if !owned {
continue
}
if err := r.store.SatisfyRequest(ctx, req.ID); err != nil {
r.logger.Warn("could not satisfy request", "request", req.ID, "error", err)
continue
}
satisfied++
}
return satisfied, nil
}
// ---------------------------------------------------------------------------
// Attempting downloads
// ---------------------------------------------------------------------------
// attemptDue searches for a bounded batch of requests and grabs the
// ones with a clear winner. force takes requests whose backoff has not
// elapsed as well.
func (r *Reconciler) attemptDue(
ctx context.Context,
force bool,
) (attempted, started int, err error) {
list := r.store.ListDueRequests
if force {
list = r.store.ListWantedRequests
}
due, err := list(ctx, r.batch)
if err != nil {
return 0, 0, err
}
for _, req := range due {
select {
case <-ctx.Done():
return attempted, started, nil
default:
}
attempted++
ok, reason := r.attempt(ctx, req)
if ok {
started++
continue
}
if err := r.store.RecordAttempt(
ctx, req.ID, req.Attempts, reason,
); err != nil {
r.logger.Warn(
"could not record request attempt", "request", req.ID, "error", err,
)
}
}
return attempted, started, nil
}
// tracklistFor resolves what a request should contain, which is the
// evidence an unattended download is checked against.
//
// A track request is its own tracklist: one entry, built from the title
// the request already carries. That single expected title is what lets
// filename matching score a track download at all — without it a
// request for one song would be scored as an album with no tracks and
// could never clear the auto-pick bar.
func (r *Reconciler) tracklistFor(
ctx context.Context,
req Request,
) ([]ExpectedTrack, error) {
if req.Entity != EntityRecording {
return r.catalog.Tracklist(ctx, req.Entity, req.MBID)
}
if req.Title == "" {
return nil, nil
}
return []ExpectedTrack{{
Position: 1,
Title: req.Title,
Artist: req.Artist,
}}, nil
}
// attempt tries one request. It returns false with a human-readable
// reason rather than an error, because none of the ways this does not
// work out are failures: no providers configured yet, nothing on any
// source, or nothing good enough to take without asking are all just
// "not today".
func (r *Reconciler) attempt(ctx context.Context, req Request) (bool, string) {
expected, err := r.tracklistFor(ctx, req)
if err != nil || len(expected) == 0 {
// Without a tracklist an unattended grab has nothing to verify
// itself against, so this request waits rather than guessing. The
// tracklist usually arrives on its own once the explore index
// fetches the release.
return false, "waiting for the tracklist to resolve"
}
dl := req.ToDownload(newID())
dl.Expected = expected
if dl.Artist == "" || dl.Album == "" {
if item, ok := r.catalog.Describe(ctx, req.Entity, req.MBID); ok {
if dl.Artist == "" {
dl.Artist = item.Artist
}
if dl.Album == "" {
dl.Album = item.Title
}
}
}
started, reason, err := r.manager.Attempt(ctx, dl)
if err != nil {
if errors.Is(err, ErrNoProviders) {
return false, "no download clients are enabled"
}
if errors.Is(err, ErrNoCandidates) {
return false, "no source has it yet"
}
return false, err.Error()
}
return started, reason
}
// countWaiting reports how many non-artist requests are still being
// looked for, so "nothing happened" can be reported as "nothing new
// for the twelve things on your list" rather than as silence.
func (r *Reconciler) countWaiting(ctx context.Context) int {
requests, err := r.store.ListRequests(ctx)
if err != nil {
return 0
}
waiting := 0
for _, req := range requests {
if req.State == RequestStateWanted && !req.Entity.Expands() {
waiting++
}
}
return waiting
}
// ---------------------------------------------------------------------------
// External list sync
// ---------------------------------------------------------------------------
// syncExternalLists pushes requests to providers that keep a persistent
// list of their own.
//
// The sync is one-directional by design. Two systems that both accept
// edits to the same list need conflict resolution, and the honest
// version of that here is "whichever the user touched last", which is
// not something we can observe. So this app's list is the source of
// truth and the external one is a projection of it — with the single
// exception of ImportExternal below, which the user runs deliberately.
func (r *Reconciler) syncExternalLists(ctx context.Context) int {
listers := r.manager.listers()
if len(listers) == 0 {
return 0
}
requests, err := r.store.ListRequests(ctx)
if err != nil {
r.logger.Warn("could not list requests for sync", "error", err)
return 0
}
synced := 0
for _, req := range requests {
if req.State != RequestStateWanted {
continue
}
external := req.ExternalIDs
if external == nil {
external = map[string]string{}
}
changed := false
for id, l := range listers {
key := strconv.FormatInt(id, 10)
if _, done := external[key]; done {
continue
}
externalID, err := l.PushRequest(ctx, req)
if err != nil {
r.logger.Debug(
"could not push request to external list",
"request", req.MBID,
"provider", id,
"error", err,
)
continue
}
if externalID == "" {
continue
}
external[key] = externalID
changed = true
synced++
}
if !changed {
continue
}
if err := r.store.SetRequestExternalIDs(ctx, req.ID, external); err != nil {
r.logger.Warn(
"could not record external request ids", "request", req.ID, "error", err,
)
}
}
return synced
}
// ImportExternal pulls an external manager's own list into the requested
// list. This is the one place data flows the other way, and it is a
// deliberate user action ("import my monitored Lidarr artists") rather
// than part of the loop, because silently adopting whatever another
// system is monitoring is not something to do behind the user's back.
func (r *Reconciler) ImportExternal(
ctx context.Context,
providerID int64,
libraryID int64,
) (int, error) {
listers := r.manager.listers()
l, ok := listers[providerID]
if !ok {
return 0, fmt.Errorf(
"%w: provider %d keeps no list", ErrUnsupported, providerID,
)
}
external, err := l.ListRequests(ctx)
if err != nil {
return 0, fmt.Errorf("list external requests: %w", err)
}
imported := 0
for _, req := range external {
req.LibraryID = libraryID
if _, err := r.store.AddRequest(ctx, req); err != nil {
r.logger.Warn(
"could not import external request", "mbid", req.MBID, "error", err,
)
continue
}
imported++
}
if imported > 0 && r.onChange != nil {
r.onChange()
}
r.Trigger()
return imported, nil
}