Files
minstrel/internal/wanted/library.go
T
bvandeusenandClaude Opus 5.5 7ef1fca90c
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m40s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m23s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: an album holding every track of an edition, and more besides, is that release group (M502 #5569)
Savant's "Void" folder holds all 23 tracks of the VOID (DLC) edition plus
20 more, and was refused at 23 of 43. A whole edition of three or more
tracks now matches whatever else the folder holds; singles and two-track
releases still need most of the album's tracks, since many albums hold
their songs. Matcher version 4 retries the refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:14:56 -04:00

171 lines
5.8 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
// libraryMatcher is the matching's version. Raise it when the matching
// changes in a way that could match an album it refused: albums an older
// version refused are matched again, even after the listing is done.
//
// 1 tags, release, discography and search, checked by title and length
// 2 titles alone under the album's own name; a refusal names the track
// 3 a second copy of a track counts once; joining words count for nothing
// 4 an album holding every track of an edition, and more, is that group
const libraryMatcher = 4
// 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. Once done the listing runs again only for albums
// an older matcher refused (libraryMatcher).
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 {
stale, err := q.CountAlbumsToRematch(ctx, libraryMatcher)
if err != nil {
return fmt.Errorf("wanted: count refusals to retry: %w", err)
}
if stale == 0 {
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, Matcher: libraryMatcher, 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, Matcher: libraryMatcher})
}
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, Matcher: libraryMatcher,
})
})
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
}