package reacquisition import ( "context" "errors" "fmt" "log/slog" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/notifications" "git.fabledsword.com/bvandeusen/minstrel/internal/tags" ) // requestCreator is the slice of lidarrrequests.Service the sweeper needs, // narrowed to an interface so the pass can be tested without a Lidarr client // or an approval path that talks to one. type requestCreator interface { CreateTracked(ctx context.Context, userID pgtype.UUID, p lidarrrequests.CreateParams) (dbq.LidarrRequest, bool, error) Approve(ctx context.Context, requestID, adminID pgtype.UUID, ov lidarrrequests.ApproveOverrides) (dbq.LidarrRequest, error) } // Sweeper periodically turns albums with long-missing files into Lidarr // requests (milestone #290). // // Deliberately a periodic worker rather than a hook inside the scanner's // reconcile pass. Reconcile runs inside a scan and has no business deciding // to talk to a third-party service; it also re-runs often, which would make // "attempt once, then back off" awkward to express. A worker paces itself, // survives a restart, and retries without needing another scan. type Sweeper struct { pool *pgxpool.Pool settings *SettingsService requests requestCreator logger *slog.Logger // notifier tells admins about a request the sweeper filed that still // needs their approval (M489). Nil: nobody is told. notifier *notifications.Notifier tick time.Duration // releaseGroup names the MusicBrainz release group of a release id, for an // album whose tags never carried one (#5241). Lidarr knows albums only by // release group. A field so tests can stand in for MusicBrainz. releaseGroup func(ctx context.Context, releaseMBID string) (string, error) } // NewSweeper constructs a Sweeper. The tick is deliberately coarse: the // smallest meaningful backoff is measured in hours, so waking more often than // hourly would only re-read settings and find nothing due. func NewSweeper( pool *pgxpool.Pool, settings *SettingsService, requests requestCreator, logger *slog.Logger, ) *Sweeper { return &Sweeper{ pool: pool, settings: settings, requests: requests, logger: logger, tick: 1 * time.Hour, releaseGroup: tags.ReleaseGroupForRelease, } } // Run drives the sweep loop until ctx is cancelled. func (s *Sweeper) Run(ctx context.Context) { t := time.NewTicker(s.tick) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: if err := s.SweepOnce(ctx); err != nil { s.logger.Warn("reacquisition: sweep failed", "err", err) } } } } // PassResult reports what a single sweep did, for logging and tests. type PassResult struct { Cleared int64 // albums whose files came back; state dropped Requested int // requests created this pass Approved int // of those, sent on to Lidarr GaveUp int // albums that spent their attempt budget Unnameable int64 // albums with missing files but no MBID to ask for // Unresolved: albums skipped this pass because MusicBrainz could not name // their release group (switched off, or no such release). Lidarr cannot be // asked for them, and no attempt is spent; the next pass tries again. Unresolved int } // SetNotifier makes requests the sweeper files, and cannot approve itself, // reach the admins' notifications inbox (M489). func (s *Sweeper) SetNotifier(n *notifications.Notifier) { s.notifier = n } // SweepOnce runs one pass. Exported so the admin surface can offer a "run // now" without waiting out the tick, and so tests drive it directly. func (s *Sweeper) SweepOnce(ctx context.Context) error { cfg := s.settings.Get() if !cfg.Enabled { return nil } q := dbq.New(s.pool) // Before selecting work: drop state for albums whose files came back, or // were adopted at a new path (#2528). Doing this first means a recovered // album cannot be picked in the same pass that would have retried it. cleared, err := q.ClearRecoveredReacquisitions(ctx) if err != nil { return fmt.Errorf("clear recovered: %w", err) } res := PassResult{Cleared: cleared} // Counted, not acted on: an album MusicBrainz cannot name is not a // failure to retry, it is a permanent gap the operator should see. if n, cerr := q.CountAlbumsMissingWithoutMbid(ctx); cerr == nil { res.Unnameable = n } due, err := q.ListAlbumsDueReacquisition(ctx, dbq.ListAlbumsDueReacquisitionParams{ GraceHours: cfg.GraceHours, BackoffBaseHours: cfg.BackoffBaseHours, BackoffMaxHours: cfg.BackoffMaxHours, PageLimit: cfg.MaxPerPass, }) if err != nil { return fmt.Errorf("list due: %w", err) } if len(due) == 0 { s.logSummary(res) return nil } // One admin lookup per pass, not per album. A library with no admin at // all cannot own a request, so the pass stops rather than half-working. admin, err := q.GetOldestAdmin(ctx) if err != nil { if errors.Is(err, pgx.ErrNoRows) { s.logger.Warn("reacquisition: no admin account to own requests; skipping pass") return nil } return fmt.Errorf("owner lookup: %w", err) } for _, album := range due { if err := s.attempt(ctx, q, cfg, admin.ID, album, &res); err != nil { // One album's failure must not abandon the rest of the pass: a // single unmatched MBID or a transient Lidarr error says nothing // about the next album in the list. s.logger.Warn("reacquisition: attempt failed", "album", album.AlbumTitle, "err", err) } } s.logSummary(res) return nil } // attempt creates (and optionally approves) the request for one album, then // records the attempt against its backoff budget. func (s *Sweeper) attempt( ctx context.Context, q *dbq.Queries, cfg Settings, adminID pgtype.UUID, album dbq.ListAlbumsDueReacquisitionRow, res *PassResult, ) error { // The query already filters these out; belt and braces, because Create // would reject the request and burn an attempt for no reason. if album.AlbumMbid == nil || album.ArtistMbid == nil { return nil } // Lidarr names albums by release group; albums.mbid is the release. group, err := s.albumReleaseGroup(ctx, q, album) if err != nil { if errors.Is(err, tags.ErrNotFound) { res.Unresolved++ s.logger.Info("reacquisition: no MusicBrainz release group for album; skipped", "album", album.AlbumTitle, "release_mbid", *album.AlbumMbid) return nil } return fmt.Errorf("release group: %w", err) } req, created, err := s.requests.CreateTracked(ctx, adminID, lidarrrequests.CreateParams{ Kind: "album", LidarrArtistMBID: *album.ArtistMbid, ArtistName: album.ArtistName, LidarrAlbumMBID: group, AlbumTitle: album.AlbumTitle, }) if err != nil { return fmt.Errorf("create request: %w", err) } res.Requested++ // Record the attempt even when Create deduped into somebody else's // existing request: the point of the counter is "how often have we gone // looking for this album", and a manual request in flight is a reason to // wait rather than to keep asking. row, err := q.RecordReacquisitionAttempt(ctx, dbq.RecordReacquisitionAttemptParams{ AlbumID: album.AlbumID, LastRequestID: req.ID, }) if err != nil { return fmt.Errorf("record attempt: %w", err) } // A request the sweeper filed and could not approve waits on an admin. // One it deduped into was announced when it was first filed. awaitingAdmin := created if cfg.AutoApprove { _, aerr := s.requests.Approve(ctx, req.ID, adminID, lidarrrequests.ApproveOverrides{}) switch { case aerr == nil: res.Approved++ awaitingAdmin = false case errors.Is(aerr, lidarrrequests.ErrLidarrDisabled): // Leave it pending rather than treating it as a failure. The // request is still the right record of intent, and it becomes // actionable the moment Lidarr is configured. s.logger.Info("reacquisition: request left pending, Lidarr disabled", "album", album.AlbumTitle) case errors.Is(aerr, lidarrrequests.ErrNotPending): // Deduped onto a request somebody already approved. Nothing to do // and nothing wrong. default: return fmt.Errorf("approve: %w", aerr) } } if awaitingAdmin { s.notifier.NotifyLogged(ctx, notifications.KindRequestPending, notifications.ToAdmins(pgtype.UUID{}), notifications.Payload{ RequestID: uuid.UUID(req.ID.Bytes).String(), RequestKind: string(req.Kind), Name: lidarrrequests.DisplayName(req), Actor: "Re-acquisition", }.Map()) } if row.Attempts >= cfg.MaxAttempts { if err := q.MarkReacquisitionGaveUp(ctx, album.AlbumID); err != nil { return fmt.Errorf("mark gave up: %w", err) } res.GaveUp++ } return nil } // albumReleaseGroup is the album's release group: the one its tags carried, // else MusicBrainz's answer for its release id, cached onto the album so the // next pass (and request completion) need not ask again. Returns // tags.ErrNotFound when MusicBrainz cannot name it. func (s *Sweeper) albumReleaseGroup(ctx context.Context, q *dbq.Queries, album dbq.ListAlbumsDueReacquisitionRow) (string, error) { if album.AlbumReleaseGroupMbid != nil && *album.AlbumReleaseGroupMbid != "" { return *album.AlbumReleaseGroupMbid, nil } group, err := s.releaseGroup(ctx, *album.AlbumMbid) if err != nil { return "", err } if cerr := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{ ID: album.AlbumID, ReleaseGroupMbid: &group, }); cerr != nil { s.logger.Warn("reacquisition: cache release group failed", "album", album.AlbumTitle, "err", cerr) } return group, nil } func (s *Sweeper) logSummary(res PassResult) { // Silence when a pass did nothing at all — this runs hourly forever, and // an unconditional line would bury the passes that mattered. if res.Requested == 0 && res.Cleared == 0 && res.GaveUp == 0 && res.Unresolved == 0 { return } s.logger.Info("reacquisition: sweep", "requested", res.Requested, "approved", res.Approved, "gave_up", res.GaveUp, "cleared", res.Cleared, "unnameable", res.Unnameable, "unresolved", res.Unresolved, ) }