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), refused albums whose tracks // have changed since, and library tracks a re-tag filed under a new album // (#5579). A person matches the rest by hand, or leaves them off, in the // Collection's Unmatched section (MatchAlbumTo, LeaveAlbumOff). 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.CountAlbumsToMatch(ctx, dbq.CountAlbumsToMatchParams{ Cutoff: state.LibraryListingStartedAt, Matcher: libraryMatcher}) if err != nil { return fmt.Errorf("wanted: count albums to match again: %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 }