feat: a wanted list — follow artists on release-type profiles; requests and re-acquisition become wants (M502 #5520)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Minstrel keeps its own list of what it is to get, keyed by MusicBrainz release group. A want comes from an approved request, a new release by a followed artist, or an album whose files went missing, and is held once the library has a full edition of it. - Migration 0081: release_type_profiles (Standard seeded: Album, EP, Single, studio only), follows, follow_seen_groups and wants. lidarr_requests becomes requests with plain *_mbid columns; approved album and track requests move onto the wanted list, approved artist requests become follows, and the library's album artists start out followed. missing_reacquisitions and the retry settings go. - internal/wanted: follows are checked against MusicBrainz discographies daily. The first check sets a baseline, and only releases after it are wanted unless the follow asked for what already exists. Profiles filter by primary and secondary type, so live recordings stay out unless a profile ticks Live. - internal/requests (was lidarrrequests): approving puts the request on the wanted list, and an artist request can be approved on a chosen profile. A request completes when its want is held. - The re-acquisition sweeper wants a missing album back instead of asking Lidarr; searching for it is the search schedule's (#5521). - Web: an admin Wanted page (Wanted, Missing files, Held, Following), a follow control on artist pages, a release-type profiles card on Integrations, "Approve on a profile" on Requests, and want state on Missing files. The artist detail API returns the artist's MBID. - Android: the request body sends artist_mbid, album_mbid and track_mbid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
// Package reacquisition turns a missing file back into a Lidarr request
|
||||
// without anyone pressing anything (milestone #290).
|
||||
// Package reacquisition wants an album back when its files go missing,
|
||||
// without anyone pressing anything (milestone #290; a want rather than a
|
||||
// Lidarr request since M502 #5520).
|
||||
//
|
||||
// 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
|
||||
// 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 requests rather than forty.
|
||||
// missing files — becomes three wants rather than forty.
|
||||
package reacquisition
|
||||
|
||||
import (
|
||||
@@ -14,7 +15,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
@@ -24,13 +24,9 @@ import (
|
||||
// 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
|
||||
Enabled bool
|
||||
GraceHours int32
|
||||
MaxPerPass int32
|
||||
}
|
||||
|
||||
// Defaults mirror migration 0056's column defaults. Duplicated here so a
|
||||
@@ -38,13 +34,9 @@ type Settings struct {
|
||||
// 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,
|
||||
Enabled: true,
|
||||
GraceHours: 24,
|
||||
MaxPerPass: 20,
|
||||
}
|
||||
|
||||
// ErrOutOfRange is returned by Set for values migration 0056's CHECKs would
|
||||
@@ -92,13 +84,9 @@ func (s *SettingsService) Set(ctx context.Context, in Settings) (Settings, error
|
||||
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,
|
||||
Enabled: in.Enabled,
|
||||
GraceHours: in.GraceHours,
|
||||
MaxPerPass: in.MaxPerPass,
|
||||
})
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("reacquisition: save settings: %w", err)
|
||||
@@ -110,43 +98,10 @@ func (s *SettingsService) Set(ctx context.Context, in Settings) (Settings, error
|
||||
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)
|
||||
}
|
||||
@@ -155,12 +110,8 @@ func validate(in Settings) error {
|
||||
|
||||
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,
|
||||
Enabled: row.Enabled,
|
||||
GraceHours: row.GraceHours,
|
||||
MaxPerPass: row.MaxPerPass,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,69 +3,8 @@ package reacquisition
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackoffSchedule(t *testing.T) {
|
||||
s := Defaults // 6h base, 168h (one week) cap, 3 attempts
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
attempts int32
|
||||
want time.Duration
|
||||
}{
|
||||
// Never tried is always due — the grace window, not the backoff, is
|
||||
// what holds the first attempt back.
|
||||
{"never attempted", 0, 0},
|
||||
{"negative is treated as never", -1, 0},
|
||||
{"after one attempt", 1, 6 * time.Hour},
|
||||
{"after two", 2, 12 * time.Hour},
|
||||
{"after three", 3, 24 * time.Hour},
|
||||
{"after four", 4, 48 * time.Hour},
|
||||
{"after five", 5, 96 * time.Hour},
|
||||
// 6h * 2^5 = 192h, past the one-week cap.
|
||||
{"clamped at the cap", 6, 168 * time.Hour},
|
||||
{"still clamped far out", 20, 168 * time.Hour},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := s.Backoff(c.attempts); got != c.want {
|
||||
t.Errorf("Backoff(%d) = %v, want %v", c.attempts, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The doubling must not be able to overflow int32 before the clamp is
|
||||
// consulted — a large base with a high attempt count is the case that would
|
||||
// wrap negative and make a spent album look due immediately.
|
||||
func TestBackoffDoesNotOverflow(t *testing.T) {
|
||||
s := Settings{BackoffBaseHours: 168, BackoffMaxHours: 720}
|
||||
for attempts := int32(1); attempts <= 40; attempts++ {
|
||||
got := s.Backoff(attempts)
|
||||
if got <= 0 {
|
||||
t.Fatalf("Backoff(%d) = %v, want a positive duration", attempts, got)
|
||||
}
|
||||
if got > 720*time.Hour {
|
||||
t.Fatalf("Backoff(%d) = %v, want <= the 720h cap", attempts, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoffRespectsCustomSettings(t *testing.T) {
|
||||
s := Settings{BackoffBaseHours: 1, BackoffMaxHours: 4}
|
||||
for attempts, want := range map[int32]time.Duration{
|
||||
1: 1 * time.Hour,
|
||||
2: 2 * time.Hour,
|
||||
3: 4 * time.Hour,
|
||||
4: 4 * time.Hour, // clamped
|
||||
} {
|
||||
if got := s.Backoff(attempts); got != want {
|
||||
t.Errorf("Backoff(%d) = %v, want %v", attempts, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsWhatTheCheckWouldReject(t *testing.T) {
|
||||
// Each case mirrors a CHECK in migration 0056. Validating in Go as well
|
||||
// means the API answers 400 with a readable message instead of surfacing
|
||||
@@ -77,15 +16,6 @@ func TestValidateRejectsWhatTheCheckWouldReject(t *testing.T) {
|
||||
{"zero grace would fire on every transient unmount",
|
||||
mutate(func(s *Settings) { s.GraceHours = 0 })},
|
||||
{"grace beyond a month", mutate(func(s *Settings) { s.GraceHours = 721 })},
|
||||
{"zero backoff base", mutate(func(s *Settings) { s.BackoffBaseHours = 0 })},
|
||||
{"backoff base beyond a week", mutate(func(s *Settings) { s.BackoffBaseHours = 169 })},
|
||||
{"zero backoff cap", mutate(func(s *Settings) { s.BackoffMaxHours = 0 })},
|
||||
{"cap below base is incoherent", mutate(func(s *Settings) {
|
||||
s.BackoffBaseHours = 48
|
||||
s.BackoffMaxHours = 24
|
||||
})},
|
||||
{"zero attempts means never try", mutate(func(s *Settings) { s.MaxAttempts = 0 })},
|
||||
{"attempts beyond ten", mutate(func(s *Settings) { s.MaxAttempts = 11 })},
|
||||
{"zero per pass means never sweep", mutate(func(s *Settings) { s.MaxPerPass = 0 })},
|
||||
{"per pass beyond the cap", mutate(func(s *Settings) { s.MaxPerPass = 201 })},
|
||||
}
|
||||
@@ -104,21 +34,6 @@ func TestValidateAcceptsDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Equal base and cap is legal — it is how an operator asks for a flat retry
|
||||
// interval rather than an escalating one.
|
||||
func TestValidateAcceptsFlatBackoff(t *testing.T) {
|
||||
s := mutate(func(s *Settings) {
|
||||
s.BackoffBaseHours = 12
|
||||
s.BackoffMaxHours = 12
|
||||
})
|
||||
if err := validate(s); err != nil {
|
||||
t.Fatalf("flat backoff should be allowed, got %v", err)
|
||||
}
|
||||
if got := s.Backoff(5); got != 12*time.Hour {
|
||||
t.Errorf("flat backoff gave %v, want 12h at every attempt", got)
|
||||
}
|
||||
}
|
||||
|
||||
func mutate(f func(*Settings)) Settings {
|
||||
s := Defaults
|
||||
f(&s)
|
||||
|
||||
@@ -7,61 +7,38 @@ import (
|
||||
"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).
|
||||
// Sweeper periodically wants back albums whose files have been missing past
|
||||
// the grace window (milestone #290, M502 #5520). Searching for them is the
|
||||
// search schedule's (#5521), which retries per want.
|
||||
//
|
||||
// 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.
|
||||
// reconcile pass: reconcile runs inside a scan and re-runs often, and a
|
||||
// filesystem lies transiently (an unmounted volume, a NAS mid-reboot) in ways
|
||||
// the grace window exists to ride out.
|
||||
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.
|
||||
// album whose tags never carried one (#5241). Wants are kept 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 {
|
||||
// NewSweeper constructs a Sweeper. The tick is deliberately coarse: the grace
|
||||
// window is measured in hours.
|
||||
func NewSweeper(pool *pgxpool.Pool, settings *SettingsService, logger *slog.Logger) *Sweeper {
|
||||
return &Sweeper{
|
||||
pool: pool,
|
||||
settings: settings,
|
||||
requests: requests,
|
||||
logger: logger,
|
||||
tick: 1 * time.Hour,
|
||||
releaseGroup: tags.ReleaseGroupForRelease,
|
||||
@@ -86,100 +63,49 @@ func (s *Sweeper) Run(ctx context.Context) {
|
||||
|
||||
// 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
|
||||
Wanted int // albums newly wanted, or wanted again after being held
|
||||
Unnameable int64 // albums with missing files but no MBID to want them by
|
||||
// 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.
|
||||
// their release group; 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.
|
||||
// SweepOnce runs one pass. Exported 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}
|
||||
|
||||
var res PassResult
|
||||
// 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,
|
||||
GraceHours: cfg.GraceHours,
|
||||
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)
|
||||
if err := s.want(ctx, q, album, &res); err != nil {
|
||||
// One album's failure must not abandon the rest of the pass.
|
||||
s.logger.Warn("reacquisition: want 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.
|
||||
// want records one album as wanted under its release group.
|
||||
func (s *Sweeper) want(ctx context.Context, q *dbq.Queries, album dbq.ListAlbumsDueReacquisitionRow, res *PassResult) error {
|
||||
// The query already filters these out; belt and braces.
|
||||
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) {
|
||||
@@ -190,77 +116,25 @@ func (s *Sweeper) attempt(
|
||||
}
|
||||
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,
|
||||
_, err = q.WantMissingAlbum(ctx, dbq.WantMissingAlbumParams{
|
||||
ReleaseGroupMbid: group, ArtistMbid: *album.ArtistMbid, ArtistName: album.ArtistName,
|
||||
Title: album.AlbumTitle, AlbumID: album.AlbumID,
|
||||
})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Already wanted, open: by a request, a follow, or this sweeper.
|
||||
return nil
|
||||
}
|
||||
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 fmt.Errorf("want: %w", err)
|
||||
}
|
||||
res.Wanted++
|
||||
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.
|
||||
// next pass 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
|
||||
@@ -281,14 +155,11 @@ func (s *Sweeper) albumReleaseGroup(ctx context.Context, q *dbq.Queries, album d
|
||||
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 {
|
||||
if res.Wanted == 0 && res.Unresolved == 0 {
|
||||
return
|
||||
}
|
||||
s.logger.Info("reacquisition: sweep",
|
||||
"requested", res.Requested,
|
||||
"approved", res.Approved,
|
||||
"gave_up", res.GaveUp,
|
||||
"cleared", res.Cleared,
|
||||
"wanted", res.Wanted,
|
||||
"unnameable", res.Unnameable,
|
||||
"unresolved", res.Unresolved,
|
||||
)
|
||||
|
||||
@@ -13,9 +13,6 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||
)
|
||||
|
||||
@@ -37,9 +34,6 @@ func newSweepPool(t *testing.T) *pgxpool.Pool {
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
dbtest.ResetDB(t, pool)
|
||||
if _, err := pool.Exec(context.Background(), "DELETE FROM lidarr_requests"); err != nil {
|
||||
t.Fatalf("reset requests: %v", err)
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
@@ -75,39 +69,25 @@ func lostAlbum(t *testing.T, pool *pgxpool.Pool, title, release, group string) p
|
||||
return al.ID
|
||||
}
|
||||
|
||||
// #5241: re-acquisition asks Lidarr for the album's release group, never its
|
||||
// release id. A tag-supplied group is used as is; otherwise MusicBrainz names
|
||||
// it and the answer is cached onto the album; an album MusicBrainz cannot
|
||||
// name is skipped without spending an attempt.
|
||||
func TestSweep_RequestsAlbumsByReleaseGroup_Integration(t *testing.T) {
|
||||
// #5241: an album is wanted by its release group, never its release id. A
|
||||
// tag-supplied group is used as is; otherwise MusicBrainz names it and the
|
||||
// answer is cached onto the album; an album MusicBrainz cannot name is
|
||||
// skipped and tried again next pass.
|
||||
func TestSweep_WantsAlbumsByReleaseGroup_Integration(t *testing.T) {
|
||||
pool := newSweepPool(t)
|
||||
ctx := context.Background()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
q := dbq.New(pool)
|
||||
|
||||
admin, err := q.CreateUser(ctx, dbq.CreateUserParams{
|
||||
Username: dbtest.TestUserPrefix + "sweepadmin", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err := NewSettingsService(ctx, pool, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := Defaults
|
||||
cfg.AutoApprove = false
|
||||
if _, err := settings.Set(ctx, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tagged := lostAlbum(t, pool, "Tagged", "release-tagged", "group-tagged")
|
||||
resolved := lostAlbum(t, pool, "Resolved", "release-resolved", "")
|
||||
unknown := lostAlbum(t, pool, "Unknown", "release-unknown", "")
|
||||
_ = lostAlbum(t, pool, "Unknown", "release-unknown", "")
|
||||
|
||||
requests := lidarrrequests.NewService(pool, lidarrconfig.New(pool), nil, nil)
|
||||
s := NewSweeper(pool, settings, requests, logger)
|
||||
s.SetNotifier(notifications.New(pool, nil, nil))
|
||||
s := NewSweeper(pool, settings, logger)
|
||||
asked := map[string]int{}
|
||||
s.releaseGroup = func(_ context.Context, release string) (string, error) {
|
||||
asked[release]++
|
||||
@@ -120,28 +100,21 @@ func TestSweep_RequestsAlbumsByReleaseGroup_Integration(t *testing.T) {
|
||||
t.Fatalf("sweep: %v", err)
|
||||
}
|
||||
|
||||
requested := func(album pgtype.UUID) (string, bool) {
|
||||
wantFor := func(group string, album pgtype.UUID) {
|
||||
t.Helper()
|
||||
var mbid *string
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT lr.lidarr_album_mbid
|
||||
FROM missing_reacquisitions r
|
||||
JOIN lidarr_requests lr ON lr.id = r.last_request_id
|
||||
WHERE r.album_id = $1`, album).Scan(&mbid)
|
||||
if err != nil || mbid == nil {
|
||||
return "", false
|
||||
w, err := q.GetWantByReleaseGroup(ctx, group)
|
||||
if err != nil {
|
||||
t.Fatalf("no want for %s: %v", group, err)
|
||||
}
|
||||
if w.Reason != "missing" || w.AlbumID != album || w.FulfilledAt.Valid {
|
||||
t.Errorf("want %s = %+v: an open missing want about the album", group, w)
|
||||
}
|
||||
return *mbid, true
|
||||
}
|
||||
if got, ok := requested(tagged); !ok || got != "group-tagged" {
|
||||
t.Errorf("tagged album requested as %q (ok=%v), want group-tagged", got, ok)
|
||||
}
|
||||
wantFor("group-tagged", tagged)
|
||||
wantFor("group-resolved", resolved)
|
||||
if asked["release-tagged"] != 0 {
|
||||
t.Error("MusicBrainz asked about an album whose tags named its group")
|
||||
}
|
||||
if got, ok := requested(resolved); !ok || got != "group-resolved" {
|
||||
t.Errorf("resolved album requested as %q (ok=%v), want group-resolved", got, ok)
|
||||
}
|
||||
var cached *string
|
||||
if err := pool.QueryRow(ctx, "SELECT release_group_mbid FROM albums WHERE id = $1", resolved).Scan(&cached); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -149,32 +122,55 @@ SELECT lr.lidarr_album_mbid
|
||||
if cached == nil || *cached != "group-resolved" {
|
||||
t.Errorf("resolved group not cached on the album: %v", cached)
|
||||
}
|
||||
var attempts int
|
||||
if err := pool.QueryRow(ctx, "SELECT count(*) FROM missing_reacquisitions WHERE album_id = $1", unknown).Scan(&attempts); err != nil {
|
||||
var n int
|
||||
if err := pool.QueryRow(ctx, "SELECT count(*) FROM wants").Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempts != 0 {
|
||||
t.Errorf("unresolvable album spent an attempt (%d state rows)", attempts)
|
||||
if n != 2 {
|
||||
t.Errorf("%d wants, want 2: the unresolvable album is skipped", n)
|
||||
}
|
||||
// M489: the two requests filed without auto-approval wait on an admin,
|
||||
// so each reaches the admins' inbox; the unresolvable album filed none.
|
||||
inbox, err := q.ListNotifications(ctx, dbq.ListNotificationsParams{UserID: admin.ID, PageLimit: 10})
|
||||
|
||||
// The next pass finds both already wanted and asks nothing new.
|
||||
asked = map[string]int{}
|
||||
if err := s.SweepOnce(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if asked["release-resolved"] != 0 || asked["release-unknown"] != 1 {
|
||||
t.Errorf("second pass asked MusicBrainz %v: only the unresolved album again", asked)
|
||||
}
|
||||
}
|
||||
|
||||
// A want held after the files went missing means the library has a full copy
|
||||
// despite the missing rows (#5245): the album is not wanted again. One held
|
||||
// before they went missing is reopened.
|
||||
func TestSweep_HeldWantsAreReopenedOnlyForANewLoss_Integration(t *testing.T) {
|
||||
pool := newSweepPool(t)
|
||||
ctx := context.Background()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
settings, err := NewSettingsService(ctx, pool, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(inbox) != 2 {
|
||||
t.Errorf("admin inbox holds %d notifications, want 2", len(inbox))
|
||||
}
|
||||
for _, n := range inbox {
|
||||
if n.Kind != string(notifications.KindRequestPending) {
|
||||
t.Errorf("admin inbox holds %q, want request_pending", n.Kind)
|
||||
stale := lostAlbum(t, pool, "Stale", "release-stale", "group-stale")
|
||||
lost := lostAlbum(t, pool, "Lost Again", "release-lost", "group-lost")
|
||||
for _, c := range []struct {
|
||||
group, heldAgo string
|
||||
album pgtype.UUID
|
||||
}{{"group-stale", "1 hour", stale}, {"group-lost", "3 days", lost}} {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id, fulfilled_at)
|
||||
VALUES ($1, 'a', 'A', 'T', 'request', $2, now() - $3::interval)`, c.group, c.album, c.heldAgo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var stray int
|
||||
if err := pool.QueryRow(ctx, "SELECT count(*) FROM lidarr_requests WHERE lidarr_album_mbid LIKE 'release-%'").Scan(&stray); err != nil {
|
||||
if err := NewSweeper(pool, settings, logger).SweepOnce(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stray != 0 {
|
||||
t.Errorf("%d request(s) named an album by its release id", stray)
|
||||
q := dbq.New(pool)
|
||||
if w, _ := q.GetWantByReleaseGroup(ctx, "group-stale"); !w.FulfilledAt.Valid {
|
||||
t.Error("a want held since the loss was reopened")
|
||||
}
|
||||
if w, _ := q.GetWantByReleaseGroup(ctx, "group-lost"); w.FulfilledAt.Valid || w.Reason != "missing" {
|
||||
t.Errorf("a want held before the loss should reopen as missing: %+v", w)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user