feat: the library's albums go on the wanted list as held, once; after that the list is the truth (M502 #5567)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m44s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m31s
release / Build signed APK (releases and dev) (push) Successful in 5m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped

A one-time job in the wanted worker lists every album in the library as a
held want, one per release group, with a record that it happened
(acquisition_settings.library_listed_at). Albums whose tags name only a
release are named on MusicBrainz first, 120 a pass; what nothing names is
counted and shown on the Held tab.

Re-acquisition now wants back only albums on the list. Taking a held album
off the list leaves its files, and it is never fetched back; albums put in
the library folder by hand afterwards are not added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 16:13:46 -04:00
co-authored by Claude Opus 5.5
parent bc198a9389
commit 2c677b3184
19 changed files with 965 additions and 172 deletions
+117
View File
@@ -0,0 +1,117 @@
package wanted
import (
"context"
"errors"
"fmt"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// ReasonLibrary: on the list because the library held it when the list
// began (#5567).
const ReasonLibrary = "library"
// libraryNamesPerPass bounds the MusicBrainz lookups one pass makes for
// albums whose tags named a release but not its group: about two minutes at
// MusicBrainz's one request a second, so the pass still settles its wants.
const libraryNamesPerPass = 120
// listLibrary puts the library's albums on the wanted list as held, once
// (operator, 2026-10-10: "make a record saying that the import has
// happened so later changes like unwanting hold and dont auto import from
// the file directory"). It runs a step each pass until done:
//
// - the first step stamps when the listing began, which is its cutoff:
// an album the library gains afterwards, dropped in by hand, is never
// listed;
// - every album whose release group is known goes on the list at once;
// - albums that name a release but not its group are looked up on
// MusicBrainz, a bounded number a pass, and listed as each is named;
// - once none is left to name, the listing is stamped done, with a count
// of the albums nothing names to MusicBrainz.
//
// After that it never runs again, so an album taken off the list stays off.
func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
q := dbq.New(s.pool)
state, err := q.GetLibraryListing(ctx)
if err != nil {
return fmt.Errorf("wanted: read the library listing: %w", err)
}
if state.LibraryListedAt.Valid {
return nil
}
cutoff := state.LibraryListingStartedAt
if !cutoff.Valid {
if cutoff, err = q.StartLibraryListing(ctx); err != nil {
return fmt.Errorf("wanted: start the library listing: %w", err)
}
n, err := q.ListLibraryAlbums(ctx, dbq.ListLibraryAlbumsParams{Cutoff: cutoff})
if err != nil {
return fmt.Errorf("wanted: list the library: %w", err)
}
res.Listed += int(n)
s.logger.Info("wanted: listing the library's albums as held", "listed", n)
}
for {
albums, err := q.ListLibraryAlbumsToName(ctx, dbq.ListLibraryAlbumsToNameParams{Cutoff: cutoff, MaxRows: libraryNamesPerPass})
if err != nil {
return fmt.Errorf("wanted: albums to name: %w", err)
}
if len(albums) == 0 {
break
}
for _, a := range albums {
if ctx.Err() != nil {
return ctx.Err()
}
group, err := s.releaseGroup(ctx, deref(a.Mbid))
switch {
case errors.Is(err, tags.ErrSwitchedOff):
// Nothing can be named while MusicBrainz is off: the rest are
// counted as unlisted rather than left waiting for ever.
return s.finishLibrary(ctx, q, res)
case errors.Is(err, tags.ErrNotFound):
group = "" // MusicBrainz has none: asked once
case err != nil:
// Unreachable or refusing for now: the next pass goes on.
s.logger.Info("wanted: naming the library's albums paused", "err", err)
return nil
}
if err := q.SetAlbumReleaseGroup(ctx, dbq.SetAlbumReleaseGroupParams{ID: a.ID, ReleaseGroupMbid: &group}); err != nil {
return fmt.Errorf("wanted: name album %s: %w", a.Title, err)
}
if group == "" {
continue
}
n, err := q.ListLibraryAlbums(ctx, dbq.ListLibraryAlbumsParams{Cutoff: cutoff, AlbumID: a.ID})
if err != nil {
return fmt.Errorf("wanted: list album %s: %w", a.Title, err)
}
res.Listed += int(n)
}
if len(albums) == libraryNamesPerPass {
return nil // the rest next pass
}
}
return s.finishLibrary(ctx, q, res)
}
func (s *Service) finishLibrary(ctx context.Context, q *dbq.Queries, res *PassResult) error {
done, err := q.FinishLibraryListing(ctx)
if err != nil {
return fmt.Errorf("wanted: finish the library listing: %w", err)
}
s.logger.Info("wanted: the library's albums are on the list", "listed_this_pass", res.Listed,
"unlisted", done.LibraryUnlisted)
return nil
}
func deref(p *string) string {
if p == nil {
return ""
}
return *p
}
+11 -3
View File
@@ -2,7 +2,9 @@
// artists, each on a release-type profile, and wanted release groups. A want
// comes from an approved request, a followed artist's new release, or an
// album whose files went missing; it is held once the library has a full
// copy. Searching for open wants is #5521's.
// copy. The library's own albums were put on the list as held once (#5567),
// so the list is the record of what the library keeps. Searching for open
// wants is #5521's.
package wanted
import (
@@ -172,9 +174,9 @@ func (s *Service) Run(ctx context.Context) {
switch {
case err != nil && ctx.Err() == nil:
s.logger.Warn("wanted: pass stopped", "err", err, "follows", res.Follows, "wanted", res.Wanted, "held", res.Held)
case res.Wanted > 0 || res.Held > 0 || res.Baselined > 0:
case res.Wanted > 0 || res.Held > 0 || res.Baselined > 0 || res.Listed > 0:
s.logger.Info("wanted: pass complete", "follows", res.Follows, "baselined", res.Baselined,
"wanted", res.Wanted, "held", res.Held)
"wanted", res.Wanted, "held", res.Held, "listed", res.Listed)
}
select {
case <-ctx.Done():
@@ -190,6 +192,7 @@ type PassResult struct {
Baselined int // of those, first reads
Wanted int // wants recorded from follows
Held int // wants the library now answers
Listed int // library albums put on the list as held (#5567)
}
// Pass applies the follows due and settles open wants. An unreachable
@@ -201,6 +204,11 @@ func (s *Service) Pass(ctx context.Context) (PassResult, error) {
if ctx.Err() != nil {
return res, ctx.Err()
}
// The one-time listing of the library (#5567); a failure waits for the
// next pass and does not hold back settling.
if err := s.listLibrary(ctx, &res); err != nil && ctx.Err() == nil {
s.logger.Warn("wanted: list the library", "err", err)
}
if err := s.settleWants(ctx, &res); err != nil {
return res, err
}
+132
View File
@@ -455,3 +455,135 @@ func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
}
return u.ID
}
// startListing clears the reset's "already listed", as a fresh install has it.
func startListing(t *testing.T, pool *pgxpool.Pool) {
t.Helper()
if _, err := pool.Exec(context.Background(), `
UPDATE acquisition_settings SET library_listing_started_at = NULL, library_listed_at = NULL, library_unlisted = 0`); err != nil {
t.Fatal(err)
}
}
// The library's albums go on the list as held, once (#5567). An album the
// tags name by release only is named through MusicBrainz first; one nothing
// names is counted, not listed. Taken off the list, an album stays off, and
// one added to the library afterwards is never listed.
func TestListLibrary_OnceAndThenTheListIsTheTruth_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
s := newService(t, pool, newFakeMB(t))
s.releaseGroup = func(_ context.Context, release string) (string, error) {
if release == "rel-parklife" {
return "rg-parklife", nil
}
return "", tags.ErrNotFound
}
blurAlbum := seedHeld(t, q, "Blur", "rel-blur", "rg-blur", 3)
parklife := seedHeld(t, q, "Parklife", "rel-parklife", "", 2)
_ = seedHeld(t, q, "Bootleg", "rel-bootleg", "", 1)
// An album with a track gone: held from before the loss, so
// re-acquisition still fetches it back.
lost := seedHeld(t, q, "13", "rel-13", "rg-13", 2)
if _, err := pool.Exec(ctx, `UPDATE tracks SET missing_since = now() - interval '1 day'
WHERE album_id = $1 AND track_number = 1`, lost.ID); err != nil {
t.Fatal(err)
}
startListing(t, pool)
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
t.Fatal(err)
}
for group, album := range map[string]pgtype.UUID{"rg-blur": blurAlbum.ID, "rg-parklife": parklife.ID, "rg-13": lost.ID} {
w, err := q.GetWantByReleaseGroup(ctx, group)
if err != nil {
t.Fatalf("%s not listed: %v", group, err)
}
if w.Reason != ReasonLibrary || !w.FulfilledAt.Valid || w.AlbumID != album {
t.Errorf("%s = %+v: a held library want about the album", group, w)
}
}
if w, _ := q.GetWantByReleaseGroup(ctx, "rg-13"); !w.FulfilledAt.Time.Before(time.Now().Add(-23 * time.Hour)) {
t.Errorf("an album missing a track is held from %v, not from before the loss", w.FulfilledAt.Time)
}
state, err := q.GetLibraryListing(ctx)
if err != nil || !state.LibraryListedAt.Valid || state.LibraryUnlisted != 1 {
t.Fatalf("listing = %+v %v: done, with the bootleg unlisted", state, err)
}
// Off the list stays off; an album added later is never listed.
w, _ := q.GetWantByReleaseGroup(ctx, "rg-blur")
if _, err := s.RemoveWant(ctx, w.ID); err != nil {
t.Fatal(err)
}
_ = seedHeld(t, q, "Think Tank", "rel-think", "rg-think", 2)
if _, err := s.Pass(ctx); err != nil {
t.Fatal(err)
}
for _, group := range []string{"rg-blur", "rg-think"} {
if _, err := q.GetWantByReleaseGroup(ctx, group); err == nil {
t.Errorf("%s is on the list", group)
}
}
}
// While MusicBrainz is switched off nothing can be named, so the listing
// finishes with what it has rather than waiting for ever.
func TestListLibrary_MusicBrainzOffFinishesWithWhatIsNamed_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
s := newService(t, pool, newFakeMB(t))
s.releaseGroup = func(context.Context, string) (string, error) { return "", tags.ErrSwitchedOff }
_ = seedHeld(t, q, "Blur", "rel-blur", "rg-blur", 1)
_ = seedHeld(t, q, "Parklife", "rel-parklife", "", 1)
startListing(t, pool)
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
t.Fatal(err)
}
state, _ := q.GetLibraryListing(ctx)
if !state.LibraryListedAt.Valid || state.LibraryUnlisted != 1 {
t.Errorf("listing = %+v", state)
}
var named *string
if err := pool.QueryRow(ctx, `SELECT release_group_mbid FROM albums WHERE title = 'Parklife'`).Scan(&named); err != nil {
t.Fatal(err)
}
if named != nil {
t.Errorf("an album was marked unnameable while MusicBrainz was off: %q", *named)
}
}
// A MusicBrainz that does not answer pauses the naming; the next pass goes on.
func TestListLibrary_UnreachablePausesTheNaming_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
s := newService(t, pool, newFakeMB(t))
down := true
s.releaseGroup = func(context.Context, string) (string, error) {
if down {
return "", tags.ErrTransient
}
return "rg-parklife", nil
}
_ = seedHeld(t, q, "Parklife", "rel-parklife", "", 1)
startListing(t, pool)
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
t.Fatal(err)
}
if state, _ := q.GetLibraryListing(ctx); state.LibraryListedAt.Valid {
t.Fatal("finished while an album was still to be named")
}
down = false
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
t.Fatal(err)
}
if _, err := q.GetWantByReleaseGroup(ctx, "rg-parklife"); err != nil {
t.Errorf("not listed once named: %v", err)
}
if state, _ := q.GetLibraryListing(ctx); !state.LibraryListedAt.Valid {
t.Error("not finished")
}
}