// Package reacquisition turns a missing file back into a Lidarr request // without anyone pressing anything (milestone #290). // // The unit of work 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 also does most of the // safety work: the loss that produced #2523 — three reorganised albums, ~40 // missing files — becomes three requests rather than forty. package reacquisition import ( "context" "errors" "fmt" "log/slog" "sync" "time" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // Settings is the operator-tunable policy, mirroring the columns and CHECK // ranges in migration 0056. type Settings struct { Enabled bool GraceHours int32 BackoffBaseHours int32 BackoffMaxHours int32 MaxAttempts int32 MaxPerPass int32 AutoApprove bool } // Defaults mirror migration 0056's column defaults. Duplicated here so a // database that cannot be read still yields a sane policy rather than a // zero-valued one — a zero grace window would fire on every transient // unmount, which is the exact failure the grace period exists to prevent. var Defaults = Settings{ Enabled: true, GraceHours: 24, BackoffBaseHours: 6, BackoffMaxHours: 168, MaxAttempts: 3, MaxPerPass: 20, AutoApprove: true, } // ErrOutOfRange is returned by Set for values migration 0056's CHECKs would // reject, so the API layer answers 400 instead of surfacing a constraint // violation. var ErrOutOfRange = errors.New("reacquisition setting out of range") // SettingsService caches the settings and owns their persistence. Cached // because the sweeper reads them every pass and the admin card reads them on // every render; neither needs a round-trip. type SettingsService struct { pool *pgxpool.Pool logger *slog.Logger mu sync.RWMutex cur Settings } // NewSettingsService loads once and caches. // // Always returns a usable service, even alongside a non-nil error: a // boot-time database hiccup should leave the sweeper running on defaults // rather than take it out entirely. The error is returned so the caller can // log that the cache holds defaults rather than stored state. func NewSettingsService(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*SettingsService, error) { s := &SettingsService{pool: pool, logger: logger, cur: Defaults} row, err := dbq.New(pool).GetReacquisitionSettings(ctx) if err != nil { return s, fmt.Errorf("reacquisition: load settings: %w", err) } s.cur = fromRow(row) return s, nil } // Get returns the cached settings. func (s *SettingsService) Get() Settings { s.mu.RLock() defer s.mu.RUnlock() return s.cur } // Set validates, persists and re-caches. func (s *SettingsService) Set(ctx context.Context, in Settings) (Settings, error) { if err := validate(in); err != nil { return Settings{}, err } row, err := dbq.New(s.pool).UpdateReacquisitionSettings(ctx, dbq.UpdateReacquisitionSettingsParams{ Enabled: in.Enabled, GraceHours: in.GraceHours, BackoffBaseHours: in.BackoffBaseHours, BackoffMaxHours: in.BackoffMaxHours, MaxAttempts: in.MaxAttempts, MaxPerPass: in.MaxPerPass, AutoApprove: in.AutoApprove, }) if err != nil { return Settings{}, fmt.Errorf("reacquisition: save settings: %w", err) } out := fromRow(row) s.mu.Lock() s.cur = out s.mu.Unlock() return out, nil } // Backoff is how long to wait before the next attempt on an album that has // already been tried [attempts] times: base * 2^(attempts-1), clamped to the // configured maximum. Zero attempts means "never tried", which is always due. // // Exported and pure so the schedule is testable without a database, and so // the admin surface can show the same number the sweeper will act on. func (s Settings) Backoff(attempts int32) time.Duration { if attempts <= 0 { return 0 } hours := s.BackoffBaseHours for i := int32(1); i < attempts; i++ { hours *= 2 // Clamp inside the loop as well as after: doubling from a large base // enough times would overflow int32 before the comparison ran. if hours >= s.BackoffMaxHours { return time.Duration(s.BackoffMaxHours) * time.Hour } } if hours > s.BackoffMaxHours { hours = s.BackoffMaxHours } return time.Duration(hours) * time.Hour } func validate(in Settings) error { switch { case in.GraceHours < 1 || in.GraceHours > 720: return fmt.Errorf("%w: grace_hours must be 1-720", ErrOutOfRange) case in.BackoffBaseHours < 1 || in.BackoffBaseHours > 168: return fmt.Errorf("%w: backoff_base_hours must be 1-168", ErrOutOfRange) case in.BackoffMaxHours < 1 || in.BackoffMaxHours > 720: return fmt.Errorf("%w: backoff_max_hours must be 1-720", ErrOutOfRange) case in.BackoffMaxHours < in.BackoffBaseHours: return fmt.Errorf("%w: backoff_max_hours must be >= backoff_base_hours", ErrOutOfRange) case in.MaxAttempts < 1 || in.MaxAttempts > 10: return fmt.Errorf("%w: max_attempts must be 1-10", ErrOutOfRange) case in.MaxPerPass < 1 || in.MaxPerPass > 200: return fmt.Errorf("%w: max_per_pass must be 1-200", ErrOutOfRange) } return nil } func fromRow(row dbq.ReacquisitionSetting) Settings { return Settings{ Enabled: row.Enabled, GraceHours: row.GraceHours, BackoffBaseHours: row.BackoffBaseHours, BackoffMaxHours: row.BackoffMaxHours, MaxAttempts: row.MaxAttempts, MaxPerPass: row.MaxPerPass, AutoApprove: row.AutoApprove, } }