Files
minstrel/internal/wanted/match.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

360 lines
12 KiB
Go

package wanted
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// Matching a library album to its MusicBrainz release group (#5569).
//
// Everything in the library came from a service that matched it against
// MusicBrainz (operator, 2026-10-10: "they should all exist in music brainz
// we need to use their meta data or a search system to link them up"), so
// every album should have a group. The ids its tags carry are taken as
// they are. Without them, the groups called what the album is called, from
// the artist's discography and then from a search, are checked against the
// album's own tracks, and the one its tracks agree with is taken.
// Match methods, as library_album_matches records them.
const (
matchTags = "tags"
matchRelease = "release"
matchDiscography = "discography"
matchSearch = "search"
)
// matchAgreement is the share of an album's tracks, in tenths, an edition
// must agree with: most, not all, since a rip's track may carry a typo or a
// hidden track's length.
const matchAgreement = 8
// matchCandidates bounds how many groups one album is checked against: each
// costs a MusicBrainz read the first time.
const matchCandidates = 8
// matchDiscographyAge is how old a held discography may be and still be
// read for matching: a name does not change, and a new release is not in
// the library.
const matchDiscographyAge = 90 * 24 * time.Hour
// errMusicBrainzAway: MusicBrainz did not answer, so the album is left for
// the next pass rather than decided on half the evidence.
var errMusicBrainzAway = errors.New("wanted: MusicBrainz did not answer")
// libraryTrack is what one of an album's tracks says about itself.
type libraryTrack struct {
Disc, Number int
Title string
LengthMs int
Recording string
}
// editionTrack is one track of a MusicBrainz edition.
type editionTrack struct {
Medium, Position int
Title string
LengthMs int
Recording string
}
// candidate is a release group an album may be.
type candidate struct {
Group string
Title string
PrimaryType string
FirstReleaseDate string
ArtistMBID string
ArtistName string
Method string
}
// albumMatch is what matching decided: a group, or why there is none.
type albumMatch struct {
candidate
OK bool
Detail string
}
func tracksToMatch(rows []dbq.ListAlbumTracksToMatchRow) []libraryTrack {
out := make([]libraryTrack, 0, len(rows))
for _, r := range rows {
t := libraryTrack{Title: r.Title, LengthMs: int(r.DurationMs), Recording: deref(r.Mbid)}
if r.DiscNumber != nil {
t.Disc = int(*r.DiscNumber)
}
if r.TrackNumber != nil {
t.Number = int(*r.TrackNumber)
}
out = append(out, t)
}
return out
}
// trackAgreement counts the album's tracks an edition agrees with: the same
// recording, or a length that fits and a title that agrees. A track is
// paired by its disc and number first, then by title, so an album missing
// some tracks still pairs the ones it has.
func trackAgreement(tracks []libraryTrack, edition []editionTrack) int {
byPos := make(map[[2]int]int, len(edition))
for i, e := range edition {
byPos[[2]int{max(e.Medium, 1), e.Position}] = i
}
agree := func(t libraryTrack, e editionTrack) bool {
if t.Recording != "" && strings.EqualFold(t.Recording, e.Recording) {
return true
}
return release.LengthFits(t.LengthMs, e.LengthMs) && release.TitleAgrees(t.Title, e.Title)
}
used := make([]bool, len(edition))
n := 0
for _, t := range tracks {
if t.Number > 0 {
if i, ok := byPos[[2]int{max(t.Disc, 1), t.Number}]; ok && !used[i] && agree(t, edition[i]) {
used[i] = true
n++
continue
}
}
for i, e := range edition {
if !used[i] && agree(t, e) {
used[i] = true
n++
break
}
}
}
return n
}
// agrees: enough of the album's tracks agree for the edition to be it.
func agrees(n, tracks int) bool {
return tracks > 0 && n*10 >= tracks*matchAgreement
}
// sameTitle: the same words, not merely one holding the other.
func sameTitle(a, b string) bool {
return slices.Equal(release.Tokens(a), release.Tokens(b))
}
// matchAlbum finds the release group a library album is. A MusicBrainz
// that does not answer is errMusicBrainzAway; anything else MusicBrainz
// says is evidence, and an album with none is an albumMatch that is not OK.
func (s *Service) matchAlbum(ctx context.Context, a dbq.ListAlbumsToMatchRow, tracks []libraryTrack) (albumMatch, error) {
// The ids the tags carry name it.
if group := deref(a.ReleaseGroupMbid); group != "" {
if c, err := s.groupCandidate(ctx, group, matchTags, a); err != nil || c.Group != "" {
return albumMatch{candidate: c, OK: err == nil}, err
}
}
if rel := deref(a.Mbid); rel != "" {
group, err := s.releaseGroup(ctx, rel)
switch {
case errors.Is(err, tags.ErrNotFound): // includes MusicBrainz switched off for tags
case err != nil:
return albumMatch{}, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
default:
if c, err := s.groupCandidate(ctx, group, matchRelease, a); err != nil || c.Group != "" {
return albumMatch{candidate: c, OK: err == nil}, err
}
}
}
// Otherwise its name, checked against its tracks: the artist's own
// discography first, then a search.
tried := map[string]bool{}
var best albumMatch
bestN, bestEdition, considered := -1, 0, 0
check := func(cands []candidate) error {
for _, c := range cands {
if tried[c.Group] || len(tried) >= matchCandidates {
continue
}
tried[c.Group] = true
n, size, err := s.agreementWith(ctx, c.Group, tracks)
if err != nil {
return err
}
considered++
// More tracks agreeing wins; then the same title over a longer
// one; then the edition nearest the album's length.
st, bt := sameTitle(c.Title, a.Title), sameTitle(best.Title, a.Title)
if n > bestN || (n == bestN && ((st && !bt) || (st == bt && abs(size-len(tracks)) < abs(bestEdition-len(tracks))))) {
best, bestN, bestEdition = albumMatch{candidate: c}, n, size
}
}
return nil
}
discography, err := s.discographyCandidates(ctx, a)
if err != nil {
return albumMatch{}, err
}
if err := check(discography); err != nil {
return albumMatch{}, err
}
if !agrees(bestN, len(tracks)) {
found, err := s.searchCandidates(ctx, a)
if err != nil {
return albumMatch{}, err
}
if err := check(found); err != nil {
return albumMatch{}, err
}
}
if agrees(bestN, len(tracks)) {
best.OK = true
return best, nil
}
if considered == 0 {
return albumMatch{Detail: fmt.Sprintf("MusicBrainz has no album called %q by %s.", a.Title, a.ArtistName)}, nil
}
return albumMatch{Detail: fmt.Sprintf(
"MusicBrainz has %d %s called %q by %s, but none has its tracks: the closest, %q, agrees on %d of its %d.",
considered, plural(considered, "album", "albums"), a.Title, a.ArtistName, best.Title, max(bestN, 0), len(tracks))}, nil
}
// groupCandidate reads a group the tags name. A group MusicBrainz does not
// have is a zero candidate, so the album is matched by name instead.
func (s *Service) groupCandidate(ctx context.Context, group, method string, a dbq.ListAlbumsToMatchRow) (candidate, error) {
g, err := s.cat.MusicBrainz().ReleaseGroup(ctx, group)
switch {
case errors.Is(err, musicbrainz.ErrNotFound):
return candidate{}, nil
case err != nil:
return candidate{}, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
}
artist := creditedArtist(g.ArtistMBIDs, deref(a.ArtistMbid))
if artist == "" {
return candidate{}, nil
}
return candidate{Group: g.MBID, Title: g.Title, PrimaryType: g.PrimaryType, FirstReleaseDate: g.FirstReleaseDate,
ArtistMBID: artist, ArtistName: g.ArtistCredit, Method: method}, nil
}
// discographyCandidates are the artist's release groups called what the
// album is called.
func (s *Service) discographyCandidates(ctx context.Context, a dbq.ListAlbumsToMatchRow) ([]candidate, error) {
artist := deref(a.ArtistMbid)
if !Followable(artist, a.ArtistName) {
return nil, nil // no artist, or Various Artists: thousands of groups
}
d, err := s.cat.DiscographyWithin(ctx, artist, matchDiscographyAge)
switch {
case errors.Is(err, catalogue.ErrNotFound):
return nil, nil
case err != nil:
return nil, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
}
var out []candidate
for _, g := range d.Groups {
if release.TitleAgrees(g.Title, a.Title) {
out = append(out, candidate{Group: g.Mbid, Title: g.Title, PrimaryType: g.PrimaryType,
FirstReleaseDate: g.FirstReleaseDate, ArtistMBID: artist, ArtistName: d.Artist.Name, Method: matchDiscography})
}
}
sortSameTitleFirst(out, a.Title)
return out, nil
}
// searchCandidates are the groups MusicBrainz's search finds by the album's
// artist and title, whose title and artist both agree.
func (s *Service) searchCandidates(ctx context.Context, a dbq.ListAlbumsToMatchRow) ([]candidate, error) {
found, err := s.cat.MusicBrainz().SearchReleaseGroupsBy(ctx, a.ArtistName, a.Title, 10)
switch {
case errors.Is(err, musicbrainz.ErrNotFound):
return nil, nil
case errors.Is(err, musicbrainz.ErrBadResponse):
// A query MusicBrainz will not read is not a reason to wait.
s.logger.Warn("wanted: MusicBrainz refused a search", "album", a.Title, "artist", a.ArtistName, "err", err)
return nil, nil
case err != nil:
return nil, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
}
var out []candidate
for _, g := range found {
artist := creditedArtist(g.ArtistMBIDs, deref(a.ArtistMbid))
if artist == "" || !release.TitleAgrees(g.Title, a.Title) {
continue
}
if !slices.Contains(g.ArtistMBIDs, deref(a.ArtistMbid)) && !release.TitleAgrees(g.ArtistCredit, a.ArtistName) {
continue
}
out = append(out, candidate{Group: g.MBID, Title: g.Title, PrimaryType: g.PrimaryType,
FirstReleaseDate: g.FirstReleaseDate, ArtistMBID: artist, ArtistName: g.ArtistCredit, Method: matchSearch})
}
sortSameTitleFirst(out, a.Title)
return out, nil
}
// agreementWith is how many of the album's tracks the group's best edition
// agrees with, and that edition's length. A group MusicBrainz no longer has
// agrees with none.
func (s *Service) agreementWith(ctx context.Context, group string, tracks []libraryTrack) (n, size int, err error) {
eds, err := s.cat.Releases(ctx, group)
switch {
case errors.Is(err, catalogue.ErrNotFound):
return -1, 0, nil
case err != nil:
return 0, 0, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
}
n = -1
for _, e := range eds.Editions {
edition := make([]editionTrack, 0, len(e.Tracks))
for _, t := range e.Tracks {
edition = append(edition, editionTrack{Medium: int(t.MediumPosition), Position: int(t.Position),
Title: t.Title, LengthMs: int(t.LengthMs), Recording: t.RecordingMbid})
}
if got := trackAgreement(tracks, edition); got > n || (got == n && abs(len(edition)-len(tracks)) < abs(size-len(tracks))) {
n, size = got, len(edition)
}
}
return n, size, nil
}
// creditedArtist is the artist a want is filed under: the library's own
// artist when MusicBrainz credits them, else the first credited.
func creditedArtist(credited []string, own string) string {
if own != "" && (len(credited) == 0 || slices.Contains(credited, own)) {
return own
}
if len(credited) > 0 {
return credited[0]
}
return ""
}
func sortSameTitleFirst(cs []candidate, title string) {
slices.SortStableFunc(cs, func(x, y candidate) int {
switch sx, sy := sameTitle(x.Title, title), sameTitle(y.Title, title); {
case sx && !sy:
return -1
case sy && !sx:
return 1
}
return 0
})
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
func plural(n int, one, many string) string {
if n == 1 {
return one
}
return many
}