Files
minstrel/internal/wanted/match.go
T
bvandeusenandClaude Opus 5.5 08a2c06b85 docs: api comments say what the endpoints do, not how they came to be (#6277)
Rewrites the internal/api comment blocks to rule 32: task, milestone and
rule ids gone, the stories behind them restated as the constraint they
left, stale references corrected (the stream secret is wired from config,
the timezone handler already refreshes the scheduler, the Android app is
the native client, the design docs cited are not in the repo). Also
rewraps comment lines over 80 columns left by the earlier batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:23:58 -04:00

405 lines
14 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"
"git.fabledsword.com/bvandeusen/minstrel/internal/words"
)
// Matching a library album to its MusicBrainz release group.
//
// Music from any mainstream source was matched against MusicBrainz on its way
// in, so nearly every album has 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
}