// Package reacquisition wants an album back when its files go missing, // without anyone pressing anything (milestone #290; a want since M502 // #5520). // // The unit of work is the ALBUM, not the track. Music is acquired as // releases; there is no meaningful "fetch me one track", and a recording // MBID is something 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 wants rather than forty. package reacquisition import ( "context" "errors" "fmt" "log/slog" "sync" "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 MaxPerPass int32 } // 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, MaxPerPass: 20, } // 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, MaxPerPass: in.MaxPerPass, }) 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 } 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.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, MaxPerPass: row.MaxPerPass, } }