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" "git.fabledsword.com/bvandeusen/minstrel/internal/words" ) // 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 } // tracksToMatch reads an album's tracks, each once: a second copy of a // track (the same disc, number and title, as a duplicate file in another // format is) would otherwise count against the match as a track no edition // has. func tracksToMatch(rows []dbq.ListAlbumTracksToMatchRow) []libraryTrack { out := make([]libraryTrack, 0, len(rows)) seen := make(map[string]bool, 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) } key := fmt.Sprintf("%d/%d/%s", max(t.Disc, 1), t.Number, strings.Join(release.Tokens(t.Title), " ")) if seen[key] { continue } seen[key] = true out = append(out, t) } return out } // trackAgreement counts the album's tracks an edition agrees with: the same // recording, or a title that agrees at a length that fits. When the group is // called just what the album is called, the titles alone are enough: the // lengths are there to tell apart albums whose names only overlap, and a // rip's single may be the edit MusicBrainz lists at another length. 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. miss says how the first track // that did not agree differs, for the reason a match was refused. func trackAgreement(tracks []libraryTrack, edition []editionTrack, titlesEnough bool) (n int, miss string) { 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.TitleAgrees(t.Title, e.Title) && (titlesEnough || release.LengthFits(t.LengthMs, e.LengthMs)) } used := make([]bool, len(edition)) for _, t := range tracks { at, placed := -1, false if t.Number > 0 { if i, ok := byPos[[2]int{max(t.Disc, 1), t.Number}]; ok { at = i placed = !used[i] && agree(t, edition[i]) if placed { used[i] = true } } } for i, e := range edition { if !placed && !used[i] && agree(t, e) { used[i], placed = true, true } } switch { case placed: n++ case miss != "": case at >= 0: e := edition[at] miss = fmt.Sprintf("%q runs %s here; MusicBrainz's track %d is %q, %s", t.Title, minutes(t.LengthMs), e.Position, e.Title, minutes(e.LengthMs)) default: miss = fmt.Sprintf("no track of its edition is like %q (%s)", t.Title, minutes(t.LengthMs)) } } return n, miss } // minutes writes a length as m:ss, or "no length". func minutes(ms int) string { if ms <= 0 { return "no length" } sec := (ms + 500) / 1000 return fmt.Sprintf("%d:%02d", sec/60, sec%60) } // agrees: enough of the album's tracks agree for the edition to be it, or // the album holds every track of the edition and more besides (a folder // with the deluxe edition and its demos). A single or a two-track release // is not taken on containment: too many albums hold its songs. func agrees(n, tracks, edition int) bool { return tracks > 0 && (n*10 >= tracks*matchAgreement || (edition >= 3 && n == edition)) } // sameTitle: the same words, not merely one holding the other. "And", // "&" and "+" join two titles however a release spells it ("Brain Stew + // Jaded", "Brain Stew / Jaded"), so they count for nothing. func sameTitle(a, b string) bool { words := func(s string) []string { return slices.DeleteFunc(release.Tokens(s), func(w string) bool { return w == "and" }) } wa, wb := words(a), words(b) return len(wa) > 0 && slices.Equal(wa, wb) } // 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, bestMiss := -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, miss, err := s.agreementWith(ctx, c.Group, tracks, func(edition string) bool { return sameTitle(c.Title, a.Title) || sameTitle(edition, a.Title) }) 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, bestMiss = albumMatch{candidate: c}, n, size, miss } } 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), bestEdition) { 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), bestEdition) { 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 } detail := fmt.Sprintf("MusicBrainz has %s called %q by %s, but none has its tracks: the closest, %q, agrees on %d of its %d", words.Count(considered, "album", "albums"), a.Title, a.ArtistName, best.Title, max(bestN, 0), len(tracks)) switch { case bestN < 0: detail += " (MusicBrainz lists no edition of it)" case bestMiss != "": detail += ": " + bestMiss } return albumMatch{Detail: detail + "."}, 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, that edition's length, and how it differs. A group // MusicBrainz no longer has, or lists no edition of, agrees with none (-1). // ownName says whether an edition, by its title, is called what the album // is, so that its track titles are enough. func (s *Service) agreementWith(ctx context.Context, group string, tracks []libraryTrack, ownName func(edition string) bool) (n, size int, miss string, 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, m := trackAgreement(tracks, edition, ownName(e.Release.Title)); got > n || (got == n && abs(len(edition)-len(tracks)) < abs(size-len(tracks))) { n, size, miss = got, len(edition), m } } return n, size, miss, 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 }