Files
minstrel/internal/wanted/library.go
T
bvandeusenandClaude Opus 5.5 b887ab759b
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
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)
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>
2026-10-10 17:16:05 -04:00

152 lines
4.9 KiB
Go

package wanted
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// ReasonLibrary: on the list because the library held it when the list
// began (#5567).
const ReasonLibrary = "library"
// 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
// 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 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.
//
// 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)
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)
}
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)
}
res.Listed += int(n)
s.logger.Info("wanted: listing the library's albums as held", "listed", n)
}
deadline := s.now().Add(libraryMatchBudget)
for {
albums, err := q.ListAlbumsToMatch(ctx, dbq.ListAlbumsToMatchParams{Cutoff: cutoff, MaxRows: libraryMatchBatch})
if err != nil {
return fmt.Errorf("wanted: albums to match: %w", err)
}
if len(albums) == 0 {
break
}
for _, a := range albums {
if ctx.Err() != nil {
return ctx.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 != nil {
return err
}
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,
"unmatched", done.LibraryUnlisted)
return nil
}
func deref(p *string) string {
if p == nil {
return ""
}
return *p
}