Files
bvandeusenandClaude Opus 5.5 db15713798
release / govulncheck (push) Successful in 22s
release / web (push) Successful in 1m35s
release / go (push) Successful in 1m45s
release / integration (push) Successful in 5m41s
release / android (push) Successful in 6m7s
release / Build signed APK (releases and dev) (push) Successful in 6m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped
refactor: the server says a count one way, words.Count, for notifications, a wanted match's reason and the duplicate resolver's activity line (M505 #5643)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:18:50 -04:00

407 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 (#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
}