Answers the open fork on #2527's last slice: automatic, not a button. Until now missing_since was a dead end -- reconcile marks it, every selection path skips it, the admin surface lists it, and there it sits. Two decisions carry most of the safety, both at the design level rather than as rate limits bolted on afterwards. The unit is the ALBUM, not the track. Lidarr acquires releases; there is no meaningful "fetch me one track", and a track-kind request needs a recording MBID plenty of files lack. Grouping means the loss that produced #2523 -- three reorganised albums, ~40 missing files -- becomes three requests instead of forty. The flood problem mostly dissolves. And nothing is requested until a file has been missing longer than the grace window (24h default). A filesystem lies transiently: an unmounted volume, a container that started before its media mount attached, a NAS mid-reboot. Every one of those resolves itself well inside a day at no cost. missing_since is never re-stamped (#2523), so it is a true "gone since" clock to measure against, not "when we last noticed". This is the difference between automatic and trigger-happy. Then the backoff proper: 6h -> 12h -> 24h -> 48h per album, clamped to a week, three attempts before giving up, and a per-pass ceiling so a genuinely large loss trickles instead of dumping hundreds of rows into the queue. Giving up is stamped as a timestamp rather than inferred from attempts >= max, so the verdict survives an operator later raising the maximum and the surface can say when. A sweeper, not a hook inside reconcile. 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. Recovered albums have their state deleted rather than reset -- a future loss is a new problem, not a continuation. Requests are attributed to the oldest admin: lidarr_requests.user_id is NOT NULL and a re-acquisition has no requesting human, so this keeps the row auditable and in the same queue as everything else without inventing a synthetic principal the schema would have to understand. Auto-approve defaults ON. Requests are created pending and nothing reaches Lidarr until approval, so with it off this would be a notification rather than an attempt. Lidarr disabled leaves the request pending rather than counting a failure -- the record of intent is still right and becomes actionable the moment Lidarr is configured. Albums with no MBID are counted, not silently skipped: nothing can be asked of Lidarr for a release MusicBrainz cannot name, and quietly doing nothing would read as the feature being broken. Settings are DB-backed per rule #25 with CHECK-guarded ranges, validated in Go as well so the API answers 400 rather than surfacing a constraint violation. The admin card and the state on the missing-files page are next; this is the engine.
229 lines
7.1 KiB
Go
229 lines
7.1 KiB
Go
package reacquisition
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
// 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 {
|
|
Create(ctx context.Context, userID pgtype.UUID, p lidarrrequests.CreateParams) (dbq.LidarrRequest, 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
|
|
tick time.Duration
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
req, err := s.requests.Create(ctx, adminID, lidarrrequests.CreateParams{
|
|
Kind: "album",
|
|
LidarrArtistMBID: *album.ArtistMbid,
|
|
ArtistName: album.ArtistName,
|
|
LidarrAlbumMBID: *album.AlbumMbid,
|
|
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)
|
|
}
|
|
|
|
if cfg.AutoApprove {
|
|
_, aerr := s.requests.Approve(ctx, req.ID, adminID, lidarrrequests.ApproveOverrides{})
|
|
switch {
|
|
case aerr == nil:
|
|
res.Approved++
|
|
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 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
|
|
}
|
|
|
|
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 {
|
|
return
|
|
}
|
|
s.logger.Info("reacquisition: sweep",
|
|
"requested", res.Requested,
|
|
"approved", res.Approved,
|
|
"gave_up", res.GaveUp,
|
|
"cleared", res.Cleared,
|
|
"unnameable", res.Unnameable,
|
|
)
|
|
}
|