release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m33s
release / android (push) Successful in 4m54s
release / integration (push) Successful in 4m59s
release / Build signed APK (releases and dev) (push) Successful in 5m6s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
The first run on the library refused singles whose one track has the
album's title ("0 of 1 agree") and near-complete albums (11 of 14), because
every track's length had to fit as well. Lengths now only decide between
groups whose names merely overlap. The reason for a refusal names the first
track that differed, with both titles and lengths, so what is left can be
judged. Migration 0087 clears the refusals so those albums are matched
again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
390 lines
13 KiB
Go
390 lines
13 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 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.
|
|
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, 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, sameTitle(c.Title, 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)) {
|
|
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
|
|
}
|
|
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))
|
|
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).
|
|
func (s *Service) agreementWith(ctx context.Context, group string, tracks []libraryTrack, titlesEnough 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, titlesEnough); 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
|
|
}
|
|
|
|
func plural(n int, one, many string) string {
|
|
if n == 1 {
|
|
return one
|
|
}
|
|
return many
|
|
}
|