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

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:
2026-10-10 00:46:22 -04:00
co-authored by Claude Opus 5.5
parent 867f52358c
commit 04515a977c
93 changed files with 6522 additions and 4484 deletions
+19 -68
View File
@@ -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,
}
}
-85
View File
@@ -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)
+37 -166
View File
@@ -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,
)
+56 -60
View File
@@ -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)
}
}