|
|
|
@@ -101,10 +101,14 @@ func tracksToMatch(rows []dbq.ListAlbumTracksToMatchRow) []libraryTrack {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// trackAgreement counts the album's tracks an edition agrees with: the same
|
|
|
|
|
// recording, or a length that fits and a title that agrees. 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.
|
|
|
|
|
func trackAgreement(tracks []libraryTrack, edition []editionTrack) int {
|
|
|
|
|
// 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
|
|
|
|
@@ -113,27 +117,47 @@ func trackAgreement(tracks []libraryTrack, edition []editionTrack) int {
|
|
|
|
|
if t.Recording != "" && strings.EqualFold(t.Recording, e.Recording) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return release.LengthFits(t.LengthMs, e.LengthMs) && release.TitleAgrees(t.Title, e.Title)
|
|
|
|
|
return release.TitleAgrees(t.Title, e.Title) && (titlesEnough || release.LengthFits(t.LengthMs, e.LengthMs))
|
|
|
|
|
}
|
|
|
|
|
used := make([]bool, len(edition))
|
|
|
|
|
n := 0
|
|
|
|
|
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 && !used[i] && agree(t, edition[i]) {
|
|
|
|
|
used[i] = true
|
|
|
|
|
n++
|
|
|
|
|
continue
|
|
|
|
|
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 !used[i] && agree(t, e) {
|
|
|
|
|
used[i] = true
|
|
|
|
|
n++
|
|
|
|
|
break
|
|
|
|
|
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
|
|
|
|
|
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.
|
|
|
|
@@ -173,14 +197,14 @@ func (s *Service) matchAlbum(ctx context.Context, a dbq.ListAlbumsToMatchRow, tr
|
|
|
|
|
// discography first, then a search.
|
|
|
|
|
tried := map[string]bool{}
|
|
|
|
|
var best albumMatch
|
|
|
|
|
bestN, bestEdition, considered := -1, 0, 0
|
|
|
|
|
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, err := s.agreementWith(ctx, c.Group, tracks)
|
|
|
|
|
n, size, miss, err := s.agreementWith(ctx, c.Group, tracks, sameTitle(c.Title, a.Title))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
@@ -189,7 +213,7 @@ func (s *Service) matchAlbum(ctx context.Context, a dbq.ListAlbumsToMatchRow, tr
|
|
|
|
|
// 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 = albumMatch{candidate: c}, n, size
|
|
|
|
|
best, bestN, bestEdition, bestMiss = albumMatch{candidate: c}, n, size, miss
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
@@ -217,9 +241,15 @@ func (s *Service) matchAlbum(ctx context.Context, a dbq.ListAlbumsToMatchRow, tr
|
|
|
|
|
if considered == 0 {
|
|
|
|
|
return albumMatch{Detail: fmt.Sprintf("MusicBrainz has no album called %q by %s.", a.Title, a.ArtistName)}, nil
|
|
|
|
|
}
|
|
|
|
|
return albumMatch{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))}, 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
|
|
|
|
@@ -296,15 +326,15 @@ func (s *Service) searchCandidates(ctx context.Context, a dbq.ListAlbumsToMatchR
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// agreementWith is how many of the album's tracks the group's best edition
|
|
|
|
|
// agrees with, and that edition's length. A group MusicBrainz no longer has
|
|
|
|
|
// agrees with none.
|
|
|
|
|
func (s *Service) agreementWith(ctx context.Context, group string, tracks []libraryTrack) (n, size int, err error) {
|
|
|
|
|
// 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
|
|
|
|
|
return -1, 0, "", nil
|
|
|
|
|
case err != nil:
|
|
|
|
|
return 0, 0, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
|
|
|
|
|
return 0, 0, "", fmt.Errorf("%w: %v", errMusicBrainzAway, err)
|
|
|
|
|
}
|
|
|
|
|
n = -1
|
|
|
|
|
for _, e := range eds.Editions {
|
|
|
|
@@ -313,11 +343,11 @@ func (s *Service) agreementWith(ctx context.Context, group string, tracks []libr
|
|
|
|
|
edition = append(edition, editionTrack{Medium: int(t.MediumPosition), Position: int(t.Position),
|
|
|
|
|
Title: t.Title, LengthMs: int(t.LengthMs), Recording: t.RecordingMbid})
|
|
|
|
|
}
|
|
|
|
|
if got := trackAgreement(tracks, edition); got > n || (got == n && abs(len(edition)-len(tracks)) < abs(size-len(tracks))) {
|
|
|
|
|
n, size = got, len(edition)
|
|
|
|
|
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, nil
|
|
|
|
|
return n, size, miss, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// creditedArtist is the artist a want is filed under: the library's own
|
|
|
|
|