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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
407 lines
14 KiB
Go
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
|
|
}
|