fix: every library album is matched to MusicBrainz before it is listed, by its tags, its artist's discography or a search, and checked against its tracks (M502 #5569)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m28s
release / android (push) Successful in 5m44s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
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

The one-time listing took only albums whose tags already named their
release group and artist, and left 340 off the list. The rest are now
matched: the release their tags name, else the artist's release groups
called what the album is called, else a MusicBrainz search by artist and
title. A group found by name is taken only when its tracks agree with the
album's (by disc and number, then title; length and title, or recording),
so a partial copy still matches and a same-titled other album does not.

library_album_matches records each album the listing decided, listed or
unmatched with why, so an album taken off the list is never re-listed. The
albums no match was found for are named on the Held tab. Album rows with
no tracks are no longer counted.

The length and title checks move from the importer to release, shared by
both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 17:16:05 -04:00
co-authored by Claude Opus 5.5
parent 2c677b3184
commit b887ab759b
21 changed files with 1378 additions and 601 deletions
+71 -37
View File
@@ -4,19 +4,24 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"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
// libraryMatchBudget bounds the time one pass spends matching albums to
// MusicBrainz, at its one request a second, so the pass still settles its
// wants; the rest wait for the next pass.
const libraryMatchBudget = 2 * time.Minute
// libraryMatchBatch is how many albums one query hands the matching.
const libraryMatchBatch = 25
// 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
@@ -26,13 +31,15 @@ const libraryNamesPerPass = 120
// - 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.
// - every album whose tags name its release group and artist goes on the
// list at once;
// - every other album is matched to MusicBrainz (#5569), within a time
// budget a pass, and listed as it is matched;
// - once none is left to match, the listing is stamped done, with a count
// of the albums no match was found for.
//
// After that it never runs again, so an album taken off the list stays off.
// Each album decided is recorded in library_album_matches, so an album taken
// off the list stays off, and once done the listing never runs again.
func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
q := dbq.New(s.pool)
state, err := q.GetLibraryListing(ctx)
@@ -47,6 +54,9 @@ func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
if cutoff, err = q.StartLibraryListing(ctx); err != nil {
return fmt.Errorf("wanted: start the library listing: %w", err)
}
if err := q.RecordListedAlbums(ctx, cutoff); err != nil {
return fmt.Errorf("wanted: record the library: %w", err)
}
n, err := q.ListLibraryAlbums(ctx, dbq.ListLibraryAlbumsParams{Cutoff: cutoff})
if err != nil {
return fmt.Errorf("wanted: list the library: %w", err)
@@ -55,10 +65,11 @@ func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
s.logger.Info("wanted: listing the library's albums as held", "listed", n)
}
deadline := s.now().Add(libraryMatchBudget)
for {
albums, err := q.ListLibraryAlbumsToName(ctx, dbq.ListLibraryAlbumsToNameParams{Cutoff: cutoff, MaxRows: libraryNamesPerPass})
albums, err := q.ListAlbumsToMatch(ctx, dbq.ListAlbumsToMatchParams{Cutoff: cutoff, MaxRows: libraryMatchBatch})
if err != nil {
return fmt.Errorf("wanted: albums to name: %w", err)
return fmt.Errorf("wanted: albums to match: %w", err)
}
if len(albums) == 0 {
break
@@ -67,45 +78,68 @@ func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
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)
if s.now().After(deadline) {
return nil // the rest next pass
}
n, err := s.listAlbum(ctx, q, a)
if errors.Is(err, errMusicBrainzAway) {
s.logger.Info("wanted: matching 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)
return err
}
res.Listed += int(n)
}
if len(albums) == libraryNamesPerPass {
return nil // the rest next pass
res.Listed += n
}
}
return s.finishLibrary(ctx, q, res)
}
// listAlbum matches one album and lists it, or records why it could not.
func (s *Service) listAlbum(ctx context.Context, q *dbq.Queries, a dbq.ListAlbumsToMatchRow) (int, error) {
rows, err := q.ListAlbumTracksToMatch(ctx, a.ID)
if err != nil {
return 0, fmt.Errorf("wanted: read %s's tracks: %w", a.Title, err)
}
m, err := s.matchAlbum(ctx, a, tracksToMatch(rows))
if err != nil {
return 0, err
}
if !m.OK {
s.logger.Info("wanted: no MusicBrainz match for a library album", "album", a.Title, "artist", a.ArtistName, "why", m.Detail)
return 0, q.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{AlbumID: a.ID, Outcome: "unmatched", Detail: m.Detail})
}
var listed int64
err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
tq := dbq.New(tx)
if err := tq.SetAlbumReleaseGroup(ctx, dbq.SetAlbumReleaseGroupParams{ID: a.ID, ReleaseGroupMbid: &m.Group}); err != nil {
return err
}
n, err := tq.InsertLibraryWant(ctx, dbq.InsertLibraryWantParams{
ReleaseGroupMbid: m.Group, ArtistMbid: m.ArtistMBID, ArtistName: m.ArtistName, Title: m.Title,
PrimaryType: m.PrimaryType, FirstReleaseDate: m.FirstReleaseDate, AlbumID: a.ID,
})
if err != nil {
return err
}
listed = n
return tq.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{
AlbumID: a.ID, Outcome: "listed", Method: m.Method, ReleaseGroupMbid: m.Group,
})
})
if err != nil {
return 0, fmt.Errorf("wanted: list album %s: %w", a.Title, err)
}
return int(listed), nil
}
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)
"unmatched", done.LibraryUnlisted)
return nil
}