feat: autotag scoring overhaul, dump-based explore index, and lyrics search

Consolidates in-progress work across autotag, explore, and library:

- autotag: beets/Picard-informed scoring engine — ID-first matching, VA
  handling, recommendation tiers, and a merged distance/rank cascade, with
  an eval harness for regression tracking.
- explore: offline MusicBrainz dump import/incremental refresh replaces the
  legacy tier crawl; index-first local search with fuzzy matching and a
  dedicated ranker; disk-free guards for dump downloads.
- library: artist-credit extraction and matching.
- lyrics: owned-library lyric search (FTS) with LRCLIB backfill.

Also: rewrite README to be user-focused, and migrate upstream to
git.ljones.me/yonlu/yellowjacket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 12:14:20 -04:00
co-authored by Claude Opus 4.8
parent d5140395da
commit 65048401e8
117 changed files with 17033 additions and 4767 deletions
+114 -62
View File
@@ -1,5 +1,7 @@
package autotag
import "slices"
// alignTitleFloor is the minimum title similarity required to
// pair a local file with a candidate track at all. Below this,
// even the best available pair is treated as no pair: the local
@@ -10,14 +12,28 @@ package autotag
// happens to share a track number stays in its own group.
const alignTitleFloor = 0.30
// AlignTracks pairs local tracks with candidate tracks using a
// greedy best-score algorithm: repeatedly pick the (local, cand)
// pair with the highest trackDistance that hasn't already been
// claimed. Not optimal (Hungarian would be), but good enough for
// the small cardinalities we see (album tracks, ~10-50) and much
// simpler.
// alignPair carries the per-combination scores AlignTracks computes
// once per (local, candidate) pair.
type alignPair struct {
li int
ci int
score float64
title float64
length float64
}
// AlignTracks pairs local tracks with candidate tracks in two
// passes:
//
// Pairs whose title similarity is below alignTitleFloor are
// 1. Recording-MBID locks: a local track whose RecordingMBID equals
// a candidate track's MBID is the same recording by definition —
// it pairs unconditionally, regardless of how the titles compare.
// 2. Greedy best-score: repeatedly pick the remaining (local, cand)
// pair with the highest track score. Not optimal (Hungarian
// would be), but good enough for the small cardinalities we see
// (album tracks, ~10-50) and much simpler.
//
// Greedy pairs whose title similarity is below alignTitleFloor are
// rejected: the local stays "unmatched" and the candidate slot
// surfaces as "missing". This lets a wrong-track-number-but-
// matching-title file pair correctly while keeping a totally
@@ -25,44 +41,72 @@ const alignTitleFloor = 0.30
//
// Returns one TrackAlignment per local track (status = matched,
// mismatched, or unmatched) plus additional missing alignments
// for candidate tracks with no local file. The caller sums Score
// fields to get a release-level score.
// for candidate tracks with no local file.
func AlignTracks(locals []LocalTrack, cands []CandidateTrack) []TrackAlignment {
type pair struct {
li int
ci int
score float64
title float64
localUsed := make([]bool, len(locals))
candUsed := make([]bool, len(cands))
alignments := make([]TrackAlignment, len(locals))
localMatched := 0
// Pass 1: recording-MBID locks.
for li, local := range locals {
if local.RecordingMBID == "" {
continue
}
for ci, cand := range cands {
if candUsed[ci] || cand.MBID == "" || cand.MBID != local.RecordingMBID {
continue
}
localUsed[li] = true
candUsed[ci] = true
localMatched++
alignments[li] = mkAlignment(li, local, cand, alignPair{
title: titleSimilarity(local.Title, cand.Title),
length: lengthScore(local.LengthMillis, cand.LengthMillis),
}, true)
break
}
}
// Score every (local, cand) combination.
pairs := make([]pair, 0, len(locals)*len(cands))
// Pass 2: greedy best-score over the remaining combinations.
pairs := make([]alignPair, 0, len(locals)*len(cands))
for li, local := range locals {
if localUsed[li] {
continue
}
for ci, cand := range cands {
pairs = append(pairs, pair{
li: li,
ci: ci,
score: trackDistance(local, cand),
title: titleSimilarity(local.Title, cand.Title),
if candUsed[ci] {
continue
}
title := titleSimilarity(local.Title, cand.Title)
length := lengthScore(local.LengthMillis, cand.LengthMillis)
pairs = append(pairs, alignPair{
li: li,
ci: ci,
score: combineTrackScore(title, length, trackNumberOK(local, cand)),
title: title,
length: length,
})
}
}
// Sort descending by score — pick best pairs first. Insertion
// sort keeps the dependency surface zero; N^2 here is fine for
// album-sized inputs.
for i := 1; i < len(pairs); i++ {
for j := i; j > 0 && pairs[j].score > pairs[j-1].score; j-- {
pairs[j], pairs[j-1] = pairs[j-1], pairs[j]
// Sort descending by score — pick best pairs first.
slices.SortStableFunc(pairs, func(a, b alignPair) int {
switch {
case a.score > b.score:
return -1
case a.score < b.score:
return 1
default:
return 0
}
}
localUsed := make([]bool, len(locals))
candUsed := make([]bool, len(cands))
alignments := make([]TrackAlignment, len(locals))
localMatched := 0
})
for _, p := range pairs {
if localMatched == len(locals) {
@@ -84,34 +128,7 @@ func AlignTracks(locals []LocalTrack, cands []CandidateTrack) []TrackAlignment {
localUsed[p.li] = true
candUsed[p.ci] = true
localMatched++
l := locals[p.li]
c := cands[p.ci]
status := AlignmentMatched
if p.title < titleReject {
status = AlignmentMismatched
}
delta := l.LengthMillis - c.LengthMillis
if delta < 0 {
delta = -delta
}
alignments[p.li] = TrackAlignment{
LocalIndex: p.li,
LocalTitle: l.Title,
LocalLengthMillis: l.LengthMillis,
CandidatePosition: c.Position,
CandidateDiscNumber: c.DiscNumber,
CandidateTitle: c.Title,
CandidateMBID: c.MBID,
CandidateLength: c.LengthMillis,
TitleScore: p.title,
LengthDeltaMs: delta,
TrackNumberOK: l.TrackNumber > 0 && l.TrackNumber == c.Position,
Status: status,
}
alignments[p.li] = mkAlignment(p.li, locals[p.li], cands[p.ci], p, false)
}
// Local tracks left unclaimed → folder has them, candidate
@@ -151,3 +168,38 @@ func AlignTracks(locals []LocalTrack, cands []CandidateTrack) []TrackAlignment {
return alignments
}
// mkAlignment builds the matched/mismatched alignment for one
// claimed (local, candidate) pair. idMatch pairs are always
// "matched" — same recording MBID means same recording, however
// the titles are spelled.
func mkAlignment(
li int, l LocalTrack, c CandidateTrack, p alignPair, idMatch bool,
) TrackAlignment {
status := AlignmentMatched
if !idMatch && p.title < titleReject {
status = AlignmentMismatched
}
delta := l.LengthMillis - c.LengthMillis
if delta < 0 {
delta = -delta
}
return TrackAlignment{
LocalIndex: li,
LocalTitle: l.Title,
LocalLengthMillis: l.LengthMillis,
CandidatePosition: c.Position,
CandidateDiscNumber: c.DiscNumber,
CandidateTitle: c.Title,
CandidateMBID: c.MBID,
CandidateLength: c.LengthMillis,
TitleScore: p.title,
LengthScore: p.length,
LengthDeltaMs: delta,
TrackNumberOK: trackNumberOK(l, c),
IDMatch: idMatch,
Status: status,
}
}
+164 -27
View File
@@ -1,5 +1,11 @@
package autotag
import (
"regexp"
"strings"
"unicode"
)
// levenshtein returns the Levenshtein edit distance between a and
// b, operating on runes so multi-byte characters count as one edit.
// Allocates a single O(min(len)) scratch slice.
@@ -63,29 +69,142 @@ func min3(a, b, c int) int {
return c
}
// titleSimilarity returns a score in [0, 1] from normalized edit
// distance. 1.0 means identical after normalization, 0.0 means
// fully dissimilar. Both sides are normalized inside.
func titleSimilarity(a, b string) float64 {
na := Normalize(a)
nb := Normalize(b)
// sdPattern couples a regexp with the weight its removal carries in
// stringDist: when deleting the matched portion from both strings
// shrinks their distance, the recovered distance is re-added at
// `weight` instead of counting fully. Weight 0 makes a difference
// in that portion free (known-cosmetic); higher weights make it
// cheap but not free. Modeled on beets' SD_PATTERNS.
type sdPattern struct {
re *regexp.Regexp
weight float64
}
if na == "" && nb == "" {
return 1.0
}
// sdPatterns are applied in order; earlier patterns claim their
// portion of the string first. Known qualifiers (whitelist) are
// free; generic parenthetical / bracketed content, featured-artist
// credits, leading articles, and part suffixes are de-weighted.
var sdPatterns = []sdPattern{
{qualifierPattern, 0.0},
{dashQualifierPattern, 0.0},
{regexp.MustCompile(`^the `), 0.1},
{regexp.MustCompile(`\b(featuring|feat\.?|ft\.?)[ :].*$`), 0.1},
{regexp.MustCompile(`\(.*?\)`), 0.3},
{regexp.MustCompile(`\[.*?\]`), 0.3},
{regexp.MustCompile(`(, )?\b(pt\.|part) .+$`), 0.2},
}
longest := len(na)
if len(nb) > longest {
longest = len(nb)
}
// sdEndWords are articles that user tags sometimes rotate to the
// end with a comma: "Beatles, The" ≡ "The Beatles".
var sdEndWords = []string{"the", "a", "an"}
if longest == 0 {
// stringDistBasic is the normalized edit distance between the two
// strings reduced to lowercase alphanumerics, in [0, 1]. Inputs
// are assumed to be lowercased and ASCII-folded already.
func stringDistBasic(a, b string) float64 {
a = alnumOnly(a)
b = alnumOnly(b)
if a == "" && b == "" {
return 0.0
}
dist := levenshtein(na, nb)
longest := max(len(a), len(b))
return 1.0 - float64(dist)/float64(longest)
return float64(levenshtein(a, b)) / float64(longest)
}
// alnumOnly strips everything but letters and digits.
func alnumOnly(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
// stringDist returns an "intuitive" distance between two titles or
// artist credits, in [0, 1]. It is a normalized edit distance with
// tweaks reflecting how music metadata actually differs (ported
// from beets' string_dist):
//
// - accents transliterated, case ignored
// - "X, The" rotated back to "The X" (same for "A"/"An")
// - "&" ≡ "and"
// - known qualifier suffixes ("(Remastered 2009)", "- Radio Edit")
// are free; unknown parenthesized/bracketed content, featured-
// artist credits, leading articles, and "Part N" suffixes are
// de-weighted rather than counting as full edits
func stringDist(a, b string) float64 {
a = strings.ToLower(asciiFold(a))
b = strings.ToLower(asciiFold(b))
a = rotateEndWord(a)
b = rotateEndWord(b)
a = strings.ReplaceAll(a, "&", " and ")
b = strings.ReplaceAll(b, "&", " and ")
base := stringDistBasic(a, b)
penalty := 0.0
for _, p := range sdPatterns {
ca := p.re.ReplaceAllString(a, "")
cb := p.re.ReplaceAllString(b, "")
if ca == a && cb == b {
continue
}
// The pattern was present: measure how much of the distance
// it accounted for and re-add that share at reduced weight.
caseDist := stringDistBasic(ca, cb)
delta := base - caseDist
if delta <= 0 {
continue
}
a, b = ca, cb
base = caseDist
penalty += p.weight * delta
}
return base + penalty
}
// rotateEndWord undoes sort-style article rotation: "beatles, the"
// → "the beatles". Input must be lowercased.
func rotateEndWord(s string) string {
for _, w := range sdEndWords {
suffix := ", " + w
if strings.HasSuffix(s, suffix) {
return w + " " + s[:len(s)-len(suffix)]
}
}
return s
}
// titleSimilarity returns a score in [0, 1] from stringDist. 1.0
// means identical after normalization, 0.0 means fully dissimilar.
func titleSimilarity(a, b string) float64 {
if strings.TrimSpace(a) == "" && strings.TrimSpace(b) == "" {
return 1.0
}
sim := 1.0 - stringDist(a, b)
if sim < 0 {
return 0.0
}
return sim
}
// Scoring weights for the per-track distance function. Local reads
@@ -98,13 +217,17 @@ const (
// Length deltas at or below lengthExactMs score 1.0 — matches
// the frontend's "subtle drift" threshold so anything the UI
// hides also doesn't count against the score. Past the
// threshold the penalty scales with delta / candidateMs (i.e.
// percentage of candidate-track length): a 5 s delta on a
// hides also doesn't count against the score. MB recording
// lengths routinely differ from file durations by a few seconds
// (encoder padding, different masters), so the grace band is
// deliberately wider than perceptual accuracy; Picard tolerates
// up to 30 s linearly and beets grants a flat 10 s grace. Past
// the threshold the penalty scales with delta / candidateMs
// (i.e. percentage of candidate-track length): a 5 s delta on a
// 4 min track is small, the same delta on a 30 s interlude is
// huge. At lengthFullyWrongPct of candidate length the score
// hits zero; beyond that it stays clamped to zero.
lengthExactMs int64 = 2000
lengthExactMs int64 = 5000
lengthFullyWrongPct float64 = 0.20
// A title below titleReject has too little signal for this
@@ -139,17 +262,31 @@ func lengthScore(localMs, candidateMs int64) float64 {
return 1.0 - pct/lengthFullyWrongPct
}
// trackDistance scores how well one local track aligns with one
// candidate track. Higher is better. Caller decides what to do
// with the result — this function has no threshold.
func trackDistance(local LocalTrack, cand CandidateTrack) float64 {
title := titleSimilarity(local.Title, cand.Title)
length := lengthScore(local.LengthMillis, cand.LengthMillis)
// trackNumberOK reports whether the local track number agrees with
// the candidate position (0 = unknown, never a match).
func trackNumberOK(local LocalTrack, cand CandidateTrack) bool {
return local.TrackNumber > 0 && local.TrackNumber == cand.Position
}
// combineTrackScore folds the per-track components into one score.
// Split out so AlignTracks can compute the components once per pair
// and still share the exact formula with trackDistance.
func combineTrackScore(title, length float64, numberOK bool) float64 {
var trackOK float64
if local.TrackNumber > 0 && local.TrackNumber == cand.Position {
if numberOK {
trackOK = 1.0
}
return title*weightTitle + length*weightLength + trackOK*weightTrackNumber
}
// trackDistance scores how well one local track aligns with one
// candidate track. Higher is better. Caller decides what to do
// with the result — this function has no threshold.
func trackDistance(local LocalTrack, cand CandidateTrack) float64 {
return combineTrackScore(
titleSimilarity(local.Title, cand.Title),
lengthScore(local.LengthMillis, cand.LengthMillis),
trackNumberOK(local, cand),
)
}
+115 -6
View File
@@ -33,10 +33,18 @@ func TestTitleSimilarity(t *testing.T) {
minScore float64
}{
{"Hey Jude", "Hey Jude", 1.00},
{"Hey Jude", "Hey Jude (Remastered 2009)", 1.00}, // qualifier stripped
{"Hey Jude", "Hey Jude (Remastered 2009)", 1.00}, // whitelisted qualifier: free
{"Hey Jude", "Hey Jude - 2015 Remaster", 1.00}, // dash-suffix qualifier: free
{"Hey Jude", "HEY JUDE!", 1.00}, // case + punct
{"Hey Jude", "Hay Jude", 0.85}, // one char off
{"Hey Jude", "Let It Be", 0.00}, // different
{"Beyoncé", "Beyonce", 1.00}, // transliteration
{"Simon & Garfunkel", "Simon and Garfunkel", 1.00},
{"Beatles, The", "The Beatles", 1.00}, // article rotation
{"Hey Jude", "Hay Jude", 0.85}, // one char off
// Unknown parenthetical content is de-weighted, not free —
// still similar, but detectably not identical.
{"Song Title (Special Whatever)", "Song Title", 0.80},
{"Yellow (feat. Somebody)", "Yellow", 0.90}, // feat credit nearly free
{"Hey Jude", "Let It Be", 0.00}, // different
}
for _, tc := range cases {
@@ -66,9 +74,9 @@ func TestLengthScore(t *testing.T) {
want float64
}{
{200000, 200000, 1.0}, // exact
{200000, 200500, 1.0}, // 0.5s — under 2s threshold
{200000, 201900, 1.0}, // 1.9s — under 2s threshold
{200000, 202000, 1.0}, // exactly 2s — still full credit
{200000, 200500, 1.0}, // 0.5s — under the grace band
{200000, 204900, 1.0}, // 4.9s — under the grace band
{200000, 205000, 1.0}, // exactly 5s — still full credit
{0, 200000, 0.5}, // unknown local → neutral
{200000, 0, 0.5}, // unknown candidate → neutral
@@ -130,3 +138,104 @@ func TestTrackDistance(t *testing.T) {
t.Errorf("wrong match = %.2f, want < 0.2", got)
}
}
func TestDominantArtist(t *testing.T) {
t.Parallel()
cases := []struct {
name string
local []LocalTrack
want string
}{
{"empty", nil, ""},
{"all blank", []LocalTrack{{Artist: ""}, {Artist: ""}}, ""},
{
"unanimous",
[]LocalTrack{{Artist: "Radiohead"}, {Artist: "Radiohead"}},
"Radiohead",
},
{
"majority wins over a stray",
[]LocalTrack{{Artist: "Radiohead"}, {Artist: "Radiohead"}, {Artist: "Guest"}},
"Radiohead",
},
{
"blanks ignored, one real value wins",
[]LocalTrack{{Artist: ""}, {Artist: "Bjork"}, {Artist: ""}},
"Bjork",
},
}
for _, tc := range cases {
if got := dominantArtist(tc.local); got != tc.want {
t.Errorf("%s: dominantArtist = %q, want %q", tc.name, got, tc.want)
}
}
}
func TestArtistCreditFit(t *testing.T) {
t.Parallel()
cases := []struct {
name string
local, cnd string
wantMin float64 // when > 0, require >= ; when 0, require <= 0.5 (mismatch)
}{
{"unknown local is neutral", "", "Whoever", 1.0},
{"unknown candidate is neutral", "Whoever", "", 1.0},
{"exact", "The Beatles", "The Beatles", 1.0},
{"case/punct only", "The Beatles", "THE BEATLES!", 1.0},
{"almost-right stays high", "Beyonce", "Beyoncé", 0.85},
{"completely different artist", "The Beatles", "Metallica", 0.0},
}
for _, tc := range cases {
got := artistCreditFit(tc.local, tc.cnd)
if tc.wantMin == 0 {
if got > 0.5 { //nolint:mnd
t.Errorf(
"%s: artistCreditFit(%q,%q) = %.2f, want low",
tc.name,
tc.local,
tc.cnd,
got,
)
}
continue
}
if got < tc.wantMin {
t.Errorf(
"%s: artistCreditFit(%q,%q) = %.2f, want >= %.2f",
tc.name,
tc.local,
tc.cnd,
got,
tc.wantMin,
)
}
}
}
func TestEvidenceFactor(t *testing.T) {
t.Parallel()
cases := []struct {
tracks int
want float64
}{
{0, evidenceFloor}, // no tracks — treated as minimum evidence
{1, evidenceFloor}, // singleton — the harshest case
{2, 0.925}, // halfway between floor and full
{3, 1.0}, // full evidence
{10, 1.0}, // large album — unscaled
}
for _, tc := range cases {
got := evidenceFactor(tc.tracks)
if diff := got - tc.want; diff < -0.001 || diff > 0.001 {
t.Errorf("evidenceFactor(%d) = %.4f, want %.4f", tc.tracks, got, tc.want)
}
}
}
+108
View File
@@ -0,0 +1,108 @@
// Package eval is the autotag candidate-scoring evaluation harness.
// It turns "this match feels wrong" into a number that goes up or
// down, so a scoring change can be validated against a frozen set of
// labelled cases instead of tuned by anecdote.
//
// A case describes a local album-group (the files on disk) plus a set
// of candidate releases, and pins expectations: which candidate must
// rank first, and per-candidate score floors/ceilings. The harness
// is decoupled from the scorer: a caller adapts whatever ranking
// function it wants to measure to the Ranker interface (the autotag
// package wires autotag.RankCandidates to it in eval_harness_test.go).
//
// The point of the ceiling assertions is negative testing: a known
// wrong candidate (same title, different artist) must stay BELOW a
// confidence bar, which is exactly the false-positive class the
// artist term + evidence scaling exist to suppress.
package eval
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
)
// ErrNoCases is returned when a fixture file contains zero cases.
var ErrNoCases = errors.New("eval: fixture set is empty")
// LocalTrackFixture is one on-disk track, in the shape a case author
// hand-writes. Millisecond durations match the scorer's units.
type LocalTrackFixture struct {
Title string `json:"title"`
Artist string `json:"artist,omitempty"`
Track int `json:"track,omitempty"`
Disc int `json:"disc,omitempty"`
LengthMs int64 `json:"lengthMs,omitempty"`
}
// CandidateTrackFixture is one track inside a candidate release.
type CandidateTrackFixture struct {
Pos int `json:"pos"`
Disc int `json:"disc,omitempty"`
Title string `json:"title"`
LengthMs int64 `json:"lengthMs,omitempty"`
}
// CandidateFixture is one candidate release the scorer must rank.
// Source is "local" or "musicbrainz" (default) — it drives
// evidence scaling, so it matters for singleton cases.
type CandidateFixture struct {
MBID string `json:"mbid"`
Title string `json:"title,omitempty"`
ArtistCredit string `json:"artistCredit,omitempty"`
Status string `json:"status,omitempty"`
Country string `json:"country,omitempty"`
PrimaryType string `json:"primaryType,omitempty"`
Source string `json:"source,omitempty"`
Tracks []CandidateTrackFixture `json:"tracks"`
}
// Case is a single labelled scoring scenario. Every real-world
// mismatch worth guarding against belongs here so it can never
// silently regress.
//
// - AlbumName / AlbumArtist mirror the tagging item's fields —
// leave them empty to keep the album-title / artist terms
// neutral for the case.
// - ExpectTop, when set, is the MBID that must rank first.
// - MaxScore pins per-MBID ceilings (candidate must score <= value).
// - MinScore pins per-MBID floors (candidate must score >= value).
type Case struct {
Note string `json:"note,omitempty"`
AlbumName string `json:"albumName,omitempty"`
AlbumArtist string `json:"albumArtist,omitempty"`
Local []LocalTrackFixture `json:"local"`
Candidates []CandidateFixture `json:"candidates"`
ExpectTop string `json:"expectTop,omitempty"`
MaxScore map[string]float64 `json:"maxScore,omitempty"`
MinScore map[string]float64 `json:"minScore,omitempty"`
}
// LoadCases reads a JSON fixture file from disk.
func LoadCases(path string) ([]Case, error) {
f, err := os.Open(path) //nolint:gosec // path is a test fixture, not user input
if err != nil {
return nil, fmt.Errorf("eval: open cases: %w", err)
}
defer func() { _ = f.Close() }()
return ParseCases(f)
}
// ParseCases decodes a JSON case set from a reader.
func ParseCases(r io.Reader) ([]Case, error) {
var cases []Case
if err := json.NewDecoder(r).Decode(&cases); err != nil {
return nil, fmt.Errorf("eval: decode cases: %w", err)
}
if len(cases) == 0 {
return nil, ErrNoCases
}
return cases, nil
}
+123
View File
@@ -0,0 +1,123 @@
package eval
import "fmt"
// ScoredCandidate is one ranked candidate reduced to what the harness
// checks: its MBID and final score. The slice a Ranker returns must
// be ordered best-first.
type ScoredCandidate struct {
MBID string
Score float64
}
// Ranker scores and orders the candidates of a single case. Best
// candidate first. Implemented by adapting the real scorer (see
// eval_harness_test.go), which is why the eval package itself never
// imports autotag.
type Ranker interface {
Rank(c Case) []ScoredCandidate
}
// RankerFunc adapts a plain function to the Ranker interface.
type RankerFunc func(c Case) []ScoredCandidate
// Rank calls the underlying function.
func (f RankerFunc) Rank(c Case) []ScoredCandidate {
return f(c)
}
// CaseResult records how one case fared. Failures is empty when the
// case passed every pinned expectation.
type CaseResult struct {
Case Case
Failures []string
}
// Passed reports whether the case met every expectation.
func (r CaseResult) Passed() bool {
return len(r.Failures) == 0
}
// Report aggregates results across a case set.
type Report struct {
Results []CaseResult
}
// Passed counts cases that met every expectation.
func (r Report) Passed() int {
n := 0
for _, c := range r.Results {
if c.Passed() {
n++
}
}
return n
}
// Accuracy is the fraction of cases that passed, in [0, 1].
func (r Report) Accuracy() float64 {
if len(r.Results) == 0 {
return 0
}
return float64(r.Passed()) / float64(len(r.Results))
}
// Evaluate runs every case through the ranker and checks its pinned
// expectations (ExpectTop, MaxScore ceilings, MinScore floors),
// returning a Report the caller can assert on and print.
func Evaluate(cases []Case, ranker Ranker) Report {
rep := Report{Results: make([]CaseResult, 0, len(cases))}
for _, c := range cases {
rep.Results = append(rep.Results, evaluateCase(c, ranker))
}
return rep
}
func evaluateCase(c Case, ranker Ranker) CaseResult {
ranked := ranker.Rank(c)
res := CaseResult{Case: c}
byMBID := make(map[string]float64, len(ranked))
for _, r := range ranked {
byMBID[r.MBID] = r.Score
}
if c.ExpectTop != "" {
switch {
case len(ranked) == 0:
res.Failures = append(
res.Failures,
"expected top "+c.ExpectTop+" but ranking was empty",
)
case ranked[0].MBID != c.ExpectTop:
res.Failures = append(res.Failures, fmt.Sprintf(
"top = %q (%.3f), want %q (%.3f)",
ranked[0].MBID, ranked[0].Score, c.ExpectTop, byMBID[c.ExpectTop],
))
}
}
for mbid, ceil := range c.MaxScore {
if got, ok := byMBID[mbid]; ok && got > ceil {
res.Failures = append(res.Failures, fmt.Sprintf(
"%s scored %.3f, want <= %.3f", mbid, got, ceil,
))
}
}
for mbid, floor := range c.MinScore {
if got, ok := byMBID[mbid]; ok && got < floor {
res.Failures = append(res.Failures, fmt.Sprintf(
"%s scored %.3f, want >= %.3f", mbid, got, floor,
))
}
}
return res
}
+225
View File
@@ -0,0 +1,225 @@
[
{
"note": "seed: single with a generic title must reject a same-title, different-artist, wrong-length MB hit and prefer the correct artist. This is the reported 92%-false-positive class.",
"local": [
{ "title": "Intro", "artist": "Real Artist", "track": 1, "lengthMs": 90000 }
],
"candidates": [
{
"mbid": "right",
"artistCredit": "Real Artist",
"status": "Official",
"source": "musicbrainz",
"tracks": [{ "pos": 1, "title": "Intro", "lengthMs": 90000 }]
},
{
"mbid": "wrong-artist",
"artistCredit": "Some Other Band",
"status": "Official",
"source": "musicbrainz",
"tracks": [{ "pos": 1, "title": "Intro", "lengthMs": 240000 }]
}
],
"expectTop": "right",
"maxScore": { "wrong-artist": 0.75 }
},
{
"note": "seed: full-album exact match should still read as a confident, near-perfect match (evidence scaling must not punish real albums).",
"local": [
{ "title": "Song A", "artist": "The Band", "track": 1, "lengthMs": 200000 },
{ "title": "Song B", "artist": "The Band", "track": 2, "lengthMs": 210000 },
{ "title": "Song C", "artist": "The Band", "track": 3, "lengthMs": 195000 },
{ "title": "Song D", "artist": "The Band", "track": 4, "lengthMs": 220000 }
],
"candidates": [
{
"mbid": "album",
"artistCredit": "The Band",
"status": "Official",
"source": "musicbrainz",
"tracks": [
{ "pos": 1, "title": "Song A", "lengthMs": 200000 },
{ "pos": 2, "title": "Song B", "lengthMs": 210000 },
{ "pos": 3, "title": "Song C", "lengthMs": 195000 },
{ "pos": 4, "title": "Song D", "lengthMs": 220000 }
]
}
],
"expectTop": "album",
"minScore": { "album": 0.9 }
},
{
"note": "seed: near-right artist (accent/spelling) must stay a strong match; soft artist term, not a gate.",
"local": [
{ "title": "Jolene", "artist": "Beyonce", "track": 1, "lengthMs": 200000 },
{ "title": "Halo", "artist": "Beyonce", "track": 2, "lengthMs": 220000 }
],
"candidates": [
{
"mbid": "accented",
"artistCredit": "Beyoncé",
"status": "Official",
"source": "musicbrainz",
"tracks": [
{ "pos": 1, "title": "Jolene", "lengthMs": 200000 },
{ "pos": 2, "title": "Halo", "lengthMs": 220000 }
]
}
],
"expectTop": "accented",
"minScore": { "accented": 0.85 }
},
{
"note": "VA compilation: per-track artists all differ, album-artist says Various Artists. The VA candidate must not be penalised for its artist credit and must read as a strong match.",
"albumName": "Now That's What I Call Music! 60",
"albumArtist": "Various Artists",
"local": [
{ "title": "Song One", "artist": "Artist A", "track": 1, "lengthMs": 200000 },
{ "title": "Song Two", "artist": "Artist B", "track": 2, "lengthMs": 210000 },
{ "title": "Song Three", "artist": "Artist C", "track": 3, "lengthMs": 195000 },
{ "title": "Song Four", "artist": "Artist D", "track": 4, "lengthMs": 205000 }
],
"candidates": [
{
"mbid": "va-comp",
"title": "Now That's What I Call Music! 60",
"artistCredit": "Various Artists",
"status": "Official",
"primaryType": "Compilation",
"source": "musicbrainz",
"tracks": [
{ "pos": 1, "title": "Song One", "lengthMs": 200000 },
{ "pos": 2, "title": "Song Two", "lengthMs": 210000 },
{ "pos": 3, "title": "Song Three", "lengthMs": 195000 },
{ "pos": 4, "title": "Song Four", "lengthMs": 205000 }
]
}
],
"expectTop": "va-comp",
"minScore": { "va-comp": 0.85 }
},
{
"note": "same recordings, two release groups: folder album name must pull the studio album above the greatest-hits comp with an identical tracklist.",
"albumName": "The Studio Album",
"albumArtist": "The Band",
"local": [
{ "title": "Song A", "artist": "The Band", "track": 1, "lengthMs": 200000 },
{ "title": "Song B", "artist": "The Band", "track": 2, "lengthMs": 210000 },
{ "title": "Song C", "artist": "The Band", "track": 3, "lengthMs": 195000 }
],
"candidates": [
{
"mbid": "studio",
"title": "The Studio Album",
"artistCredit": "The Band",
"status": "Official",
"primaryType": "Album",
"source": "musicbrainz",
"tracks": [
{ "pos": 1, "title": "Song A", "lengthMs": 200000 },
{ "pos": 2, "title": "Song B", "lengthMs": 210000 },
{ "pos": 3, "title": "Song C", "lengthMs": 195000 }
]
},
{
"mbid": "hits-comp",
"title": "Greatest Hits",
"artistCredit": "The Band",
"status": "Official",
"primaryType": "Compilation",
"source": "musicbrainz",
"tracks": [
{ "pos": 1, "title": "Song A", "lengthMs": 200000 },
{ "pos": 2, "title": "Song B", "lengthMs": 210000 },
{ "pos": 3, "title": "Song C", "lengthMs": 195000 }
]
}
],
"expectTop": "studio",
"minScore": { "studio": 0.9 }
},
{
"note": "disc 1 of a 2-disc release: per-disc group must not be punished for 'missing' disc 2, and must not lose to a random single-disc release with fewer matching tracks.",
"albumName": "The Double Album",
"albumArtist": "The Band",
"local": [
{ "title": "D1 Track 1", "artist": "The Band", "track": 1, "disc": 1, "lengthMs": 200000 },
{ "title": "D1 Track 2", "artist": "The Band", "track": 2, "disc": 1, "lengthMs": 210000 },
{ "title": "D1 Track 3", "artist": "The Band", "track": 3, "disc": 1, "lengthMs": 195000 }
],
"candidates": [
{
"mbid": "double",
"title": "The Double Album",
"artistCredit": "The Band",
"status": "Official",
"primaryType": "Album",
"source": "musicbrainz",
"tracks": [
{ "pos": 1, "disc": 1, "title": "D1 Track 1", "lengthMs": 200000 },
{ "pos": 2, "disc": 1, "title": "D1 Track 2", "lengthMs": 210000 },
{ "pos": 3, "disc": 1, "title": "D1 Track 3", "lengthMs": 195000 },
{ "pos": 1, "disc": 2, "title": "D2 Track 1", "lengthMs": 220000 },
{ "pos": 2, "disc": 2, "title": "D2 Track 2", "lengthMs": 230000 },
{ "pos": 3, "disc": 2, "title": "D2 Track 3", "lengthMs": 240000 }
]
}
],
"expectTop": "double",
"minScore": { "double": 0.9 }
},
{
"note": "streaming-style dash qualifiers: '- 2011 Remaster' suffixes on every local title must not cost title score against the clean MB tracklist.",
"albumName": "Classic Album",
"albumArtist": "The Band",
"local": [
{ "title": "Opener - 2011 Remaster", "artist": "The Band", "track": 1, "lengthMs": 200000 },
{ "title": "Middle Cut - 2011 Remaster", "artist": "The Band", "track": 2, "lengthMs": 210000 },
{ "title": "Closer - 2011 Remaster", "artist": "The Band", "track": 3, "lengthMs": 195000 }
],
"candidates": [
{
"mbid": "clean-titles",
"title": "Classic Album",
"artistCredit": "The Band",
"status": "Official",
"primaryType": "Album",
"source": "musicbrainz",
"tracks": [
{ "pos": 1, "title": "Opener", "lengthMs": 200000 },
{ "pos": 2, "title": "Middle Cut", "lengthMs": 210000 },
{ "pos": 3, "title": "Closer", "lengthMs": 195000 }
]
}
],
"expectTop": "clean-titles",
"minScore": { "clean-titles": 0.9 }
},
{
"note": "sorted-artist tag: 'Beatles, The' must match 'The Beatles' releases at full strength (article rotation).",
"albumName": "Abbey Road",
"albumArtist": "Beatles, The",
"local": [
{ "title": "Come Together", "artist": "Beatles, The", "track": 1, "lengthMs": 259000 },
{ "title": "Something", "artist": "Beatles, The", "track": 2, "lengthMs": 182000 },
{ "title": "Octopus's Garden", "artist": "Beatles, The", "track": 3, "lengthMs": 170000 }
],
"candidates": [
{
"mbid": "abbey",
"title": "Abbey Road",
"artistCredit": "The Beatles",
"status": "Official",
"primaryType": "Album",
"source": "musicbrainz",
"tracks": [
{ "pos": 1, "title": "Come Together", "lengthMs": 259000 },
{ "pos": 2, "title": "Something", "lengthMs": 182000 },
{ "pos": 3, "title": "Octopus's Garden", "lengthMs": 170000 }
]
}
],
"expectTop": "abbey",
"minScore": { "abbey": 0.9 }
}
]
+102
View File
@@ -0,0 +1,102 @@
package autotag_test
import (
"testing"
"yellowjacket/backend/autotag"
"yellowjacket/backend/autotag/eval"
)
// adaptRanker converts an eval.Case (hand-written fixture shapes) into
// the autotag domain types, runs the real RankCandidates, and maps
// the result back to []eval.ScoredCandidate. This is the one place
// the eval package's decoupled fixtures meet the concrete scorer.
func adaptRanker(c eval.Case) []eval.ScoredCandidate {
locals := make([]autotag.LocalTrack, len(c.Local))
for i, l := range c.Local {
locals[i] = autotag.LocalTrack{
Title: l.Title,
Artist: l.Artist,
TrackNumber: l.Track,
DiscNumber: l.Disc,
LengthMillis: l.LengthMs,
}
}
cands := make([]autotag.Candidate, len(c.Candidates))
for i, cf := range c.Candidates {
tracks := make([]autotag.CandidateTrack, len(cf.Tracks))
for j, tf := range cf.Tracks {
tracks[j] = autotag.CandidateTrack{
Position: tf.Pos,
DiscNumber: tf.Disc,
Title: tf.Title,
LengthMillis: tf.LengthMs,
}
}
cands[i] = autotag.Candidate{
ReleaseMBID: cf.MBID,
Title: cf.Title,
ArtistCredit: cf.ArtistCredit,
Status: cf.Status,
Country: cf.Country,
PrimaryType: cf.PrimaryType,
Source: candidateSource(cf.Source),
Tracks: tracks,
}
}
ranked := autotag.RankCandidates(autotag.Group{
AlbumName: c.AlbumName,
AlbumArtist: c.AlbumArtist,
Tracks: locals,
}, cands)
out := make([]eval.ScoredCandidate, len(ranked))
for i, r := range ranked {
out[i] = eval.ScoredCandidate{MBID: r.ReleaseMBID, Score: r.Score}
}
return out
}
// candidateSource maps the fixture string to the domain type,
// defaulting to MusicBrainz (the interesting, evidence-scaled path).
func candidateSource(s string) autotag.CandidateSource {
if s == string(autotag.SourceLocal) {
return autotag.SourceLocal
}
return autotag.SourceMusicBrainz
}
// TestScoringCorpus runs the frozen labelled corpus through the real
// ranker. Add real-world mismatches to testdata/scoring_cases.json —
// every case that fails here is a scoring regression, and the harness
// prints exactly which expectation broke.
func TestScoringCorpus(t *testing.T) {
t.Parallel()
cases, err := eval.LoadCases("eval/testdata/scoring_cases.json")
if err != nil {
t.Fatalf("load cases: %v", err)
}
report := eval.Evaluate(cases, eval.RankerFunc(adaptRanker))
for _, r := range report.Results {
if r.Passed() {
continue
}
for _, f := range r.Failures {
t.Errorf("case %q: %s", r.Case.Note, f)
}
}
t.Logf(
"scoring corpus: %d/%d cases passed (%.0f%% accuracy)",
report.Passed(), len(report.Results), report.Accuracy()*100,
)
}
+327 -51
View File
@@ -32,6 +32,25 @@ type MBRelease struct {
Tracks []CandidateTrack
}
// MBRecordingHit is the minimal projection of a MusicBrainz recording
// search result — used by the in-app search path (singletons).
type MBRecordingHit struct {
MBID string
Title string
ArtistCredit string
LengthMillis int64
}
// MBReleaseRef is a slim reference to one release a recording appears
// on. The resolver ranks these to pick a representative release and
// then resolves it in full via ResolveOneReleaseMBID.
type MBReleaseRef struct {
MBID string
Title string
Status string
Date string
}
// MBClient is the subset of the explore.MusicBrainzClient surface
// the autotagger depends on. Implementations must be cache-first
// — repeated calls with the same inputs must not repeat network
@@ -42,16 +61,42 @@ type MBClient interface {
query string,
limit int,
) ([]MBReleaseGroupHit, int, error)
SearchRecordings(
ctx context.Context,
query string,
limit int,
) ([]MBRecordingHit, int, error)
LookupRecordingReleases(ctx context.Context, recordingMBID string) ([]MBReleaseRef, error)
BrowseReleases(ctx context.Context, releaseGroupMBID string) ([]MBRelease, error)
LookupRelease(ctx context.Context, releaseMBID string) (MBRelease, error)
LookupReleaseGroup(ctx context.Context, releaseGroupMBID string) (MBReleaseGroupHit, error)
LookupArtist(ctx context.Context, mbid string) (string, error) // returns sort name or name
}
// mbidVariousArtists is the MusicBrainz artist MBID for the special
// "Various Artists" entity — used as an arid filter when a group
// looks like a compilation.
const mbidVariousArtists = "89ad4ac3-39f7-470e-963a-56509c546377"
// cascadeSufficient is the merged-best score at which the cascade
// stops issuing looser queries. "First step with any hits" is the
// wrong stop condition — a strict query can return plausible-but-
// wrong release groups and starve the looser steps of the chance to
// surface the right one. Scoring is free; searches and browses are
// rate-limited network calls, so the cascade pays for another step
// only while the best candidate so far is still mediocre.
const cascadeSufficient = 0.70
// hitBrowseFloor is the minimum title-or-artist similarity a search
// hit needs before the resolver pays a rate-limited BrowseReleases
// call for it. Hits failing both checks are junk from MB's fuzzy
// tokenizer.
const hitBrowseFloor = 0.30
// MBResolver orchestrates MusicBrainz lookups for a tagging group.
// Strategy: normalize the user-provided album/artist first, then
// issue a cascade of progressively looser Lucene queries, stopping
// at the first one that yields enough candidates.
// Strategy: use recording MBIDs already present in local tags when
// possible (exact, cheap); otherwise issue a cascade of
// progressively looser Lucene queries, merging results until a
// candidate scores well enough to stop.
type MBResolver struct {
client MBClient
logger *slog.Logger
@@ -73,27 +118,21 @@ type mbQueryStep struct {
}
// ResolveMB returns MB-sourced candidates for a tagging group.
// Runs a cascade of Lucene queries, returning at the first step
// that produces results. Each search hit fans out to
// BrowseReleases (one per release-group) for track-level data.
func (r *MBResolver) ResolveMB(
ctx context.Context,
albumName, albumArtist string,
trackCount int,
knownArtistMBID string,
) ([]Candidate, error) {
if albumName == "" {
return nil, nil
}
nAlbum := Normalize(albumName)
nArtist := Normalize(albumArtist)
// Runs the Lucene query cascade, accumulating deduplicated
// candidates across steps and stopping once the best merged
// candidate scores at least cascadeSufficient against the group.
func (r *MBResolver) ResolveMB(ctx context.Context, g Group) ([]Candidate, error) {
nAlbum := Normalize(g.AlbumName)
if nAlbum == "" {
return nil, nil
}
steps := buildMBQueryCascade(nAlbum, nArtist, trackCount, knownArtistMBID)
nArtist := Normalize(groupArtist(g))
steps := buildMBQueryCascade(nAlbum, nArtist, len(g.Tracks), vaLikely(g))
seen := make(map[string]bool)
var merged []Candidate
for _, step := range steps {
hits, _, err := r.client.SearchReleaseGroups(ctx, step.query, r.limit)
@@ -106,35 +145,65 @@ func (r *MBResolver) ResolveMB(
continue
}
if len(hits) == 0 {
added := r.fanOutBrowse(ctx, g, hits, step.label, seen, &merged)
r.logger.Debug(
"MB search step done",
"step", step.label, "hits", len(hits), "new_candidates", added,
)
if added == 0 {
continue
}
// Score what we have so far; good enough means the looser
// (noisier, costlier) steps aren't needed.
ranked := RankCandidates(g, merged)
if len(ranked) > 0 && ranked[0].Score >= cascadeSufficient {
r.logger.Info(
"MB cascade stopped — sufficient candidate",
"step", step.label, "score", ranked[0].Score,
)
break
}
}
return merged, nil
}
// fanOutBrowse iterates search hits, fetches each plausible
// release-group's releases, and appends previously-unseen ones to
// merged as Candidates. Returns how many candidates were added.
// Errors on individual browses are logged and skipped.
func (r *MBResolver) fanOutBrowse(
ctx context.Context,
g Group,
hits []MBReleaseGroupHit,
step string,
seen map[string]bool,
merged *[]Candidate,
) int {
added := 0
for _, h := range hits {
if seen["rg:"+h.MBID] {
continue
}
seen["rg:"+h.MBID] = true
// Don't pay a rate-limited browse for a hit that resembles
// neither the folder's album name nor its artist.
if !hitPlausible(g, h) {
r.logger.Debug(
"MB search step empty — trying next",
"step", step.label, "query", step.query,
"skipping implausible search hit",
"title", h.Title, "artist", h.ArtistCredit,
)
continue
}
r.logger.Info(
"MB search step succeeded",
"step", step.label, "hits", len(hits),
)
return r.fanOutBrowse(ctx, hits, step.label), nil
}
return nil, nil
}
// fanOutBrowse iterates search hits, fetches each release-group's
// releases, and returns them as Candidates. Errors on individual
// browses are logged and skipped.
func (r *MBResolver) fanOutBrowse(
ctx context.Context, hits []MBReleaseGroupHit, step string,
) []Candidate {
var out []Candidate
for _, h := range hits {
releases, err := r.client.BrowseReleases(ctx, h.MBID)
if err != nil {
r.logger.Warn(
@@ -146,11 +215,105 @@ func (r *MBResolver) fanOutBrowse(
}
for _, rel := range releases {
out = append(out, mkCandidate(h, rel, step))
if rel.MBID != "" && seen[rel.MBID] {
continue
}
seen[rel.MBID] = true
*merged = append(*merged, mkCandidate(h, rel, step))
added++
}
}
return out
return added
}
// hitPlausible reports whether a release-group search hit is worth
// a BrowseReleases round-trip: its title or artist must bear at
// least a loose resemblance to the group's. Unknown local fields
// never disqualify a hit.
func hitPlausible(g Group, h MBReleaseGroupHit) bool {
if g.AlbumName != "" && h.Title != "" &&
titleSimilarity(g.AlbumName, h.Title) >= hitBrowseFloor {
return true
}
artist := groupArtist(g)
if artist != "" && h.ArtistCredit != "" &&
titleSimilarity(artist, h.ArtistCredit) >= hitBrowseFloor {
return true
}
// Nothing to compare against (or a VA credit): stay permissive.
return g.AlbumName == "" || h.Title == "" || isVAName(h.ArtistCredit)
}
// ResolveByRecordingMBIDs resolves candidates from recording MBIDs
// already present in the local tags — the highest-precision signal
// available, and the reason previously-tagged files should never
// need a fuzzy search. Each recording is looked up, releases are
// counted as votes, and the best-voted release (Official and
// earliest among ties) is resolved in full with provenance "id".
// Returns nil when no recording resolves to any release.
func (r *MBResolver) ResolveByRecordingMBIDs(
ctx context.Context, recordingMBIDs []string,
) ([]Candidate, error) {
votes := make(map[string]int)
refs := make(map[string]MBReleaseRef)
for _, id := range recordingMBIDs {
rels, err := r.client.LookupRecordingReleases(ctx, id)
if err != nil {
r.logger.Warn(
"recording lookup failed — skipping",
"recording_mbid", id, "err", err,
)
continue
}
counted := make(map[string]bool, len(rels))
for _, ref := range rels {
if ref.MBID == "" || counted[ref.MBID] {
continue
}
counted[ref.MBID] = true
votes[ref.MBID]++
refs[ref.MBID] = ref
}
}
if len(votes) == 0 {
return nil, nil
}
// Highest vote count wins; betterRelease breaks ties so the
// pick is deterministic and favours Official + earliest.
var (
bestMBID string
bestVotes int
)
for mbid, n := range votes {
switch {
case n > bestVotes:
bestMBID, bestVotes = mbid, n
case n == bestVotes && betterRelease(refs[mbid], refs[bestMBID]):
bestMBID = mbid
}
}
cand, err := r.ResolveOneReleaseMBID(ctx, bestMBID)
if err != nil {
return nil, fmt.Errorf("resolve voted release %s: %w", bestMBID, err)
}
cand.Provenance = "id"
return []Candidate{cand}, nil
}
// ResolveOneReleaseMBID fetches a single release by MBID and
@@ -208,6 +371,7 @@ func mkCandidate(h MBReleaseGroupHit, rel MBRelease, step string) Candidate {
OriginalDate: h.FirstDate,
Country: rel.Country,
Status: rel.Status,
PrimaryType: h.PrimaryType,
TrackCount: len(rel.Tracks),
Tracks: rel.Tracks,
Source: SourceMusicBrainz,
@@ -215,19 +379,131 @@ func mkCandidate(h MBReleaseGroupHit, rel MBRelease, step string) Candidate {
}
}
// SearchReleaseGroupHits runs a single release-group search from a
// user-supplied album + artist (the in-app "suggest a candidate"
// path). Both fields are normalized and phrase-quoted; artist is
// dropped from the query when empty.
func (r *MBResolver) SearchReleaseGroupHits(
ctx context.Context, album, artist string,
) ([]MBReleaseGroupHit, error) {
query := "release:" + luceneQuote(Normalize(album))
if a := Normalize(artist); a != "" {
query += " AND artist:" + luceneQuote(a)
}
hits, _, err := r.client.SearchReleaseGroups(ctx, query, r.limit)
if err != nil {
return nil, fmt.Errorf("search release groups: %w", err)
}
return hits, nil
}
// SearchRecordingHits runs a single recording search from a user-
// supplied title + artist — the singleton path, where the folder has
// one track and release-group search is too coarse.
func (r *MBResolver) SearchRecordingHits(
ctx context.Context, title, artist string,
) ([]MBRecordingHit, error) {
query := "recording:" + luceneQuote(Normalize(title))
if a := Normalize(artist); a != "" {
query += " AND artist:" + luceneQuote(a)
}
hits, _, err := r.client.SearchRecordings(ctx, query, r.limit)
if err != nil {
return nil, fmt.Errorf("search recordings: %w", err)
}
return hits, nil
}
// ResolveOneRecordingMBID turns a picked recording into a fully-scored
// Candidate by resolving it to a representative release (so the
// existing release-based diff + Apply pipeline works unchanged).
// Picks the release the same way a human would default: prefer an
// Official status, then the earliest date. Provenance is
// "search-recording" so the UI can label where it came from.
func (r *MBResolver) ResolveOneRecordingMBID(
ctx context.Context, recordingMBID string,
) (Candidate, error) {
refs, err := r.client.LookupRecordingReleases(ctx, recordingMBID)
if err != nil {
return Candidate{}, fmt.Errorf("lookup recording releases: %w", err)
}
best := pickRepresentativeRelease(refs)
if best.MBID == "" {
return Candidate{}, fmt.Errorf("%w: %s", errNoReleasesForRecording, recordingMBID)
}
cand, err := r.ResolveOneReleaseMBID(ctx, best.MBID)
if err != nil {
return Candidate{}, err
}
cand.Provenance = "search-recording"
return cand, nil
}
// pickRepresentativeRelease chooses the release most likely to be the
// one the user means: an Official release beats a non-Official one,
// and among equals the earliest date wins (favouring the original
// over later reissues). Returns the zero value for an empty slice.
func pickRepresentativeRelease(refs []MBReleaseRef) MBReleaseRef {
var best MBReleaseRef
for _, ref := range refs {
if best.MBID == "" || betterRelease(ref, best) {
best = ref
}
}
return best
}
// betterRelease reports whether a should be preferred over b.
func betterRelease(a, b MBReleaseRef) bool {
aOfficial := strings.EqualFold(a.Status, "Official")
bOfficial := strings.EqualFold(b.Status, "Official")
if aOfficial != bOfficial {
return aOfficial
}
// Same official-ness: earlier date wins. Empty dates sort last
// so a dated release beats an undated one.
switch {
case a.Date == "":
return false
case b.Date == "":
return true
default:
return a.Date < b.Date
}
}
// errNoReleasesForRecording signals a recording that resolved to zero
// releases — nothing to diff or apply against.
var errNoReleasesForRecording = errors.New("autotag: recording has no releases")
// buildMBQueryCascade returns the Lucene queries to try in order.
// Cascade:
//
// 1. Full: release + arid/artist + tracks:N
// 1. Full: release + artist (or VA arid) + tracks:N
// 2. Drop tracks:N (bonus tracks, live editions, etc.)
// 3. Drop artist entirely (wrong artist tag is common)
// 4. Fuzzy title (unquoted; Lucene does token/prefix match)
//
// Each step is only added when it would differ from the previous.
// VA-likely groups filter on the Various Artists arid instead of an
// artist name — per-track artists on a compilation say nothing
// about the release's artist credit. Each step is only added when
// it would differ from the previous.
func buildMBQueryCascade(
normAlbum, normArtist string,
trackCount int,
artistMBID string,
va bool,
) []mbQueryStep {
var steps []mbQueryStep
@@ -235,8 +511,8 @@ func buildMBQueryCascade(
artistClause := ""
switch {
case artistMBID != "":
artistClause = "arid:" + artistMBID
case va:
artistClause = "arid:" + mbidVariousArtists
case normArtist != "":
artistClause = "artist:" + luceneQuote(normArtist)
}
+308 -18
View File
@@ -15,11 +15,14 @@ var errFakeNotFound = errors.New("fake: not found")
// cascade tests orchestrate `searchByStep` so step N returns hits
// only when the resolver has already tried steps < N.
type fakeMBClient struct {
queries []string
searchByStep map[int][]MBReleaseGroupHit
browseByMBID map[string][]MBRelease
lookupRels map[string]MBRelease
lookupRGs map[string]MBReleaseGroupHit
queries []string
searchByStep map[int][]MBReleaseGroupHit
browseByMBID map[string][]MBRelease
browseCalls map[string]int
lookupRels map[string]MBRelease
lookupRGs map[string]MBReleaseGroupHit
searchRecs []MBRecordingHit
recRelsByMBID map[string][]MBReleaseRef
}
func (f *fakeMBClient) SearchReleaseGroups(
@@ -36,6 +39,12 @@ func (f *fakeMBClient) SearchReleaseGroups(
func (f *fakeMBClient) BrowseReleases(
_ context.Context, mbid string,
) ([]MBRelease, error) {
if f.browseCalls == nil {
f.browseCalls = make(map[string]int)
}
f.browseCalls[mbid]++
return f.browseByMBID[mbid], nil
}
@@ -61,8 +70,18 @@ func (f *fakeMBClient) LookupReleaseGroup(
return rg, nil
}
func (f *fakeMBClient) LookupArtist(_ context.Context, _ string) (string, error) {
return "", nil
func (f *fakeMBClient) SearchRecordings(
_ context.Context, query string, _ int,
) ([]MBRecordingHit, int, error) {
f.queries = append(f.queries, query)
return f.searchRecs, len(f.searchRecs), nil
}
func (f *fakeMBClient) LookupRecordingReleases(
_ context.Context, mbid string,
) ([]MBReleaseRef, error) {
return f.recRelsByMBID[mbid], nil
}
func TestBuildMBQueryCascade_StepsOrder(t *testing.T) {
@@ -70,7 +89,7 @@ func TestBuildMBQueryCascade_StepsOrder(t *testing.T) {
// Normalize() is a no-op for these inputs — ASCII, no
// qualifier suffix, no punctuation — so cascade builds directly.
steps := buildMBQueryCascade("abbey road", "the beatles", 17, "")
steps := buildMBQueryCascade("abbey road", "the beatles", 17, false)
if len(steps) < 3 {
t.Fatalf("expected ≥3 cascade steps, got %d", len(steps))
@@ -94,7 +113,7 @@ func TestBuildMBQueryCascade_NormalizesInputs(t *testing.T) {
// Qualifier suffix "(Remastered 2009)" must be stripped by
// the *caller*; verify the query emitter doesn't reintroduce it.
steps := buildMBQueryCascade("abbey road", "", 0, "")
steps := buildMBQueryCascade("abbey road", "", 0, false)
for _, step := range steps {
if strings.Contains(strings.ToLower(step.query), "remastered") {
t.Errorf("step %q should not contain qualifier: %q", step.label, step.query)
@@ -102,26 +121,56 @@ func TestBuildMBQueryCascade_NormalizesInputs(t *testing.T) {
}
}
func TestMBResolver_CascadeStopsOnFirstHit(t *testing.T) {
func TestBuildMBQueryCascade_VariousArtists(t *testing.T) {
t.Parallel()
// VA-likely groups must filter on the Various Artists arid, not
// on whatever plurality artist the compilation's tracks have.
steps := buildMBQueryCascade("now that's music", "artist one", 12, true)
if !strings.Contains(steps[0].query, "arid:"+mbidVariousArtists) {
t.Errorf("VA step 1 should carry the VA arid, got %q", steps[0].query)
}
if strings.Contains(steps[0].query, "artist:") {
t.Errorf("VA step 1 must not carry an artist: clause, got %q", steps[0].query)
}
}
// abbeyRoadGroup is a group whose single track matches the rg1
// fixture release well enough to clear cascadeSufficient.
func abbeyRoadGroup() Group {
return Group{
AlbumName: "Abbey Road",
AlbumArtist: "The Beatles",
Tracks: []LocalTrack{{
Title: "Come Together", TrackNumber: 1, LengthMillis: 259000,
}},
}
}
func TestMBResolver_CascadeStopsWhenSufficient(t *testing.T) {
t.Parallel()
fake := &fakeMBClient{
searchByStep: map[int][]MBReleaseGroupHit{
// step 0 (strict) returns nothing; step 1 (no-track-count) hits.
// step 0 (strict) returns nothing; step 1 (no-track-count)
// hits with a release that scores well against the group.
1: {{MBID: "rg1", Title: "Abbey Road"}},
},
browseByMBID: map[string][]MBRelease{
"rg1": {{MBID: "rel1", Title: "Abbey Road", Tracks: []CandidateTrack{
{Position: 1, Title: "Come Together"},
}}},
"rg1": {{
MBID: "rel1", Title: "Abbey Road", Status: "Official",
Tracks: []CandidateTrack{
{Position: 1, Title: "Come Together", LengthMillis: 259000},
},
}},
},
}
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
cands, err := r.ResolveMB(
context.Background(), "Abbey Road", "The Beatles", 17, "",
)
cands, err := r.ResolveMB(context.Background(), abbeyRoadGroup())
if err != nil {
t.Fatalf("ResolveMB: %v", err)
}
@@ -139,13 +188,123 @@ func TestMBResolver_CascadeStopsOnFirstHit(t *testing.T) {
}
}
func TestMBResolver_CascadeContinuesPastMediocreHits(t *testing.T) {
t.Parallel()
// Step 0 returns a same-title release whose track list doesn't
// match the folder at all — plausible enough to browse, but it
// must NOT stop the cascade ("first non-empty step wins" was the
// old, wrong behavior). Step 1 surfaces the real album; both
// candidates come back merged.
fake := &fakeMBClient{
searchByStep: map[int][]MBReleaseGroupHit{
0: {{MBID: "rg-decoy", Title: "Abbey Road"}},
1: {{MBID: "rg-real", Title: "Abbey Road"}},
},
browseByMBID: map[string][]MBRelease{
"rg-decoy": {{
MBID: "rel-decoy", Title: "Abbey Road",
Tracks: []CandidateTrack{
{Position: 1, Title: "Something Else Entirely", LengthMillis: 111000},
{Position: 2, Title: "Not It Either", LengthMillis: 122000},
},
}},
"rg-real": {{
MBID: "rel-real", Title: "Abbey Road", Status: "Official",
Tracks: []CandidateTrack{
{Position: 1, Title: "Come Together", LengthMillis: 259000},
},
}},
},
}
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
cands, err := r.ResolveMB(context.Background(), abbeyRoadGroup())
if err != nil {
t.Fatalf("ResolveMB: %v", err)
}
if len(cands) != 2 { //nolint:mnd
t.Fatalf("expected merged candidates from both steps, got %d", len(cands))
}
if len(fake.queries) < 2 { //nolint:mnd
t.Errorf(
"cascade should have continued past the decoy step, got %d queries",
len(fake.queries),
)
}
}
func TestMBResolver_CascadeBrowsesEachReleaseGroupOnce(t *testing.T) {
t.Parallel()
// The same release group surfacing at multiple cascade steps must
// only be browsed (and returned) once.
fake := &fakeMBClient{
searchByStep: map[int][]MBReleaseGroupHit{
0: {{MBID: "rg-dup", Title: "Abbey Road"}},
1: {{MBID: "rg-dup", Title: "Abbey Road"}},
2: {{MBID: "rg-dup", Title: "Abbey Road"}},
3: {{MBID: "rg-dup", Title: "Abbey Road"}},
},
browseByMBID: map[string][]MBRelease{
// Poor track match so the cascade keeps going.
"rg-dup": {{
MBID: "rel-dup", Title: "Abbey Road",
Tracks: []CandidateTrack{
{Position: 1, Title: "Unrelated", LengthMillis: 100000},
},
}},
},
}
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
cands, err := r.ResolveMB(context.Background(), abbeyRoadGroup())
if err != nil {
t.Fatalf("ResolveMB: %v", err)
}
if len(cands) != 1 {
t.Fatalf("expected 1 deduplicated candidate, got %d", len(cands))
}
if fake.browseCalls["rg-dup"] != 1 {
t.Errorf("browse calls for rg-dup = %d, want 1", fake.browseCalls["rg-dup"])
}
}
func TestMBResolver_SkipsImplausibleHits(t *testing.T) {
t.Parallel()
// A hit resembling neither the album name nor the artist must
// not cost a browse round-trip.
fake := &fakeMBClient{
searchByStep: map[int][]MBReleaseGroupHit{
0: {{MBID: "rg-junk", Title: "Polka Party Hits", ArtistCredit: "Zzyzx Ensemble"}},
},
}
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
if _, err := r.ResolveMB(context.Background(), abbeyRoadGroup()); err != nil {
t.Fatalf("ResolveMB: %v", err)
}
if fake.browseCalls["rg-junk"] != 0 {
t.Errorf("junk hit was browsed %d times, want 0", fake.browseCalls["rg-junk"])
}
}
func TestMBResolver_AbortsOnEmptyAlbumName(t *testing.T) {
t.Parallel()
fake := &fakeMBClient{}
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
cands, err := r.ResolveMB(context.Background(), "", "", 0, "")
cands, err := r.ResolveMB(context.Background(), Group{})
if err != nil {
t.Fatalf("ResolveMB: %v", err)
}
@@ -191,3 +350,134 @@ func TestMBResolver_ResolveOneReleaseMBID(t *testing.T) {
t.Errorf("expected 1 track, got %d", len(cand.Tracks))
}
}
func TestResolveByRecordingMBIDs_VotesAcrossRecordings(t *testing.T) {
t.Parallel()
// rec-1 and rec-2 both appear on rel-shared; rec-1 also appears
// on rel-solo. The shared release gets 2 votes and wins.
fake := &fakeMBClient{
recRelsByMBID: map[string][]MBReleaseRef{
"rec-1": {
{MBID: "rel-shared", Status: "Official", Date: "1969"},
{MBID: "rel-solo", Status: "Official", Date: "1968"},
},
"rec-2": {
{MBID: "rel-shared", Status: "Official", Date: "1969"},
},
},
lookupRels: map[string]MBRelease{
"rel-shared": {
MBID: "rel-shared", Title: "Abbey Road",
Tracks: []CandidateTrack{
{Position: 1, Title: "Come Together", MBID: "rec-1"},
{Position: 2, Title: "Something", MBID: "rec-2"},
},
},
},
}
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
cands, err := r.ResolveByRecordingMBIDs(
context.Background(), []string{"rec-1", "rec-2"},
)
if err != nil {
t.Fatalf("ResolveByRecordingMBIDs: %v", err)
}
if len(cands) != 1 {
t.Fatalf("expected 1 candidate, got %d", len(cands))
}
if cands[0].ReleaseMBID != "rel-shared" {
t.Errorf("release = %q, want 'rel-shared' (2 votes beats 1)", cands[0].ReleaseMBID)
}
if cands[0].Provenance != "id" {
t.Errorf("provenance = %q, want 'id'", cands[0].Provenance)
}
}
func TestResolveByRecordingMBIDs_NoResults(t *testing.T) {
t.Parallel()
fake := &fakeMBClient{recRelsByMBID: map[string][]MBReleaseRef{}}
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
cands, err := r.ResolveByRecordingMBIDs(context.Background(), []string{"rec-x"})
if err != nil {
t.Fatalf("ResolveByRecordingMBIDs: %v", err)
}
if cands != nil {
t.Errorf("expected nil candidates for unknown recordings, got %d", len(cands))
}
}
func TestPickRepresentativeRelease(t *testing.T) {
t.Parallel()
refs := []MBReleaseRef{
{MBID: "promo-1970", Status: "Promotion", Date: "1970"},
{MBID: "official-1975", Status: "Official", Date: "1975"},
{MBID: "official-1969", Status: "Official", Date: "1969"},
{MBID: "undated-official", Status: "Official", Date: ""},
}
// Official beats Promotion; among Official the earliest date wins.
best := pickRepresentativeRelease(refs)
if best.MBID != "official-1969" {
t.Errorf("best = %q, want 'official-1969'", best.MBID)
}
if got := pickRepresentativeRelease(nil); got.MBID != "" {
t.Errorf("empty input = %+v, want zero", got)
}
}
func TestResolveOneRecordingMBID_ResolvesToRepresentativeRelease(t *testing.T) {
t.Parallel()
fake := &fakeMBClient{
recRelsByMBID: map[string][]MBReleaseRef{
"rec-1": {
{MBID: "rel-reissue", Status: "Official", Date: "2011"},
{MBID: "rel-original", Status: "Official", Date: "1979"},
},
},
lookupRels: map[string]MBRelease{
"rel-original": {
MBID: "rel-original", Title: "The Wall",
Tracks: []CandidateTrack{{Position: 1, Title: "Hey You"}},
},
},
}
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
cand, err := r.ResolveOneRecordingMBID(context.Background(), "rec-1")
if err != nil {
t.Fatalf("ResolveOneRecordingMBID: %v", err)
}
// Earliest official release chosen and resolved in full.
if cand.ReleaseMBID != "rel-original" {
t.Errorf("release = %q, want 'rel-original'", cand.ReleaseMBID)
}
if cand.Provenance != "search-recording" {
t.Errorf("provenance = %q, want 'search-recording'", cand.Provenance)
}
}
func TestResolveOneRecordingMBID_NoReleases(t *testing.T) {
t.Parallel()
fake := &fakeMBClient{recRelsByMBID: map[string][]MBReleaseRef{}}
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
if _, err := r.ResolveOneRecordingMBID(context.Background(), "rec-x"); err == nil {
t.Fatal("expected error for recording with no releases")
}
}
+98 -30
View File
@@ -5,60 +5,128 @@ import (
"strings"
"unicode"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
)
// qualifierAlternation is the regex alternation of parenthesized /
// dash-suffixed qualifiers that MB sometimes adds to titles but user
// tags often omit — e.g. `Remastered 2009`, `Bonus Track`, `feat. X`.
// Shared between the parenthesized and dash-suffix qualifier patterns.
const qualifierAlternation = `remaster(ed)?(\s+\d{4})?|` +
`re-?master(ed)?(\s+\d{4})?|` +
`\d{4}\s+remaster(ed)?|` +
`deluxe(\s+(edition|version))?|` +
`expanded(\s+(edition|version))?|` +
`anniversary(\s+(edition|version))?|` +
`explicit|` +
`clean|` +
`bonus\s+track|` +
`live(\s+at\s+[^\)\]]*)?|` +
`acoustic|` +
`radio\s+edit|` +
`single\s+version|` +
`album\s+version|` +
`original\s+mix|` +
`instrumental|` +
`demo|` +
`mono|` +
`stereo|` +
`feat\.?\s+[^\)\]]*|` +
`featuring\s+[^\)\]]*|` +
`ft\.?\s+[^\)\]]*`
// qualifierPattern matches common parenthesized / bracketed
// qualifiers that MB sometimes adds to titles but user tags often
// omit — e.g. `(Remastered 2009)`, `[Bonus Track]`, `(feat. X)`.
// Case-insensitive. Only strips when the qualifier is at a
// word-boundary to avoid mangling titles like "Untitled (1)".
// qualifiers. Case-insensitive. Only strips when the qualifier is
// at a word-boundary to avoid mangling titles like "Untitled (1)".
var qualifierPattern = regexp.MustCompile(
`(?i)\s*[\(\[]\s*(` +
`remaster(ed)?(\s+\d{4})?|` +
`re-?master(ed)?(\s+\d{4})?|` +
`\d{4}\s+remaster(ed)?|` +
`deluxe(\s+edition)?|` +
`expanded(\s+edition)?|` +
`explicit|` +
`clean|` +
`bonus\s+track|` +
`live(\s+at\s+[^\)\]]*)?|` +
`acoustic|` +
`radio\s+edit|` +
`single\s+version|` +
`album\s+version|` +
`instrumental|` +
`demo|` +
`mono|` +
`stereo|` +
`feat\.?\s+[^\)\]]*|` +
`featuring\s+[^\)\]]*|` +
`ft\.?\s+[^\)\]]*` +
`)\s*[\)\]]`,
`(?i)\s*[\(\[]\s*(` + qualifierAlternation + `)\s*[\)\]]`,
)
// dashQualifierPattern matches the dash-suffix form of the same
// qualifiers — `Song - 2009 Remaster`, `Song Radio Edit` — which
// streaming-service-derived tags use instead of parentheses.
var dashQualifierPattern = regexp.MustCompile(
`(?i)\s+[-–—]\s+(` + qualifierAlternation + `)\s*$`,
)
// whitespaceCollapse replaces runs of whitespace with a single space.
var whitespaceCollapse = regexp.MustCompile(`\s+`)
// asciiSpecials maps letters that unicode decomposition alone can't
// reduce to ASCII (they aren't combining-mark compositions).
var asciiSpecials = strings.NewReplacer(
"ß", "ss", "ẞ", "SS",
"æ", "ae", "Æ", "AE",
"œ", "oe", "Œ", "OE",
"ø", "o", "Ø", "O",
"đ", "d", "Đ", "D",
"ð", "d", "Ð", "D",
"þ", "th", "Þ", "Th",
"ł", "l", "Ł", "L",
"ı", "i",
)
// asciiFold transliterates accented characters to their closest
// ASCII equivalent ("Beyoncé" → "Beyonce", "Björk" → "Bjork") so
// diacritic differences between user tags and MB data don't count
// as edits. Non-Latin scripts pass through unchanged.
func asciiFold(s string) string {
// Fast path: nothing to fold in pure-ASCII strings.
ascii := true
for i := range len(s) {
if s[i] >= 0x80 {
ascii = false
break
}
}
if ascii {
return s
}
s = asciiSpecials.Replace(s)
// NFKD splits accented letters into base + combining marks;
// dropping the marks (unicode.Mn) leaves the base letter. The
// chain is stateful, so build it per call — it's cheap and this
// keeps concurrent scorers safe.
t := transform.Chain(norm.NFKD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
folded, _, err := transform.String(t, s)
if err != nil {
return s
}
return folded
}
// Normalize returns a comparison-friendly form of a title or
// artist-credit string:
//
// - NFC unicode composition
// - ASCII transliteration (accents folded)
// - qualifier suffixes stripped (see qualifierPattern)
// - all punctuation dropped
// - "&" replaced with "and"
// - all remaining punctuation dropped
// - case folded (lowercased)
// - whitespace collapsed and trimmed
//
// The result is not intended to be human-readable — only used for
// equality and edit-distance comparisons inside the scorer.
// equality comparisons (local candidate matching) and search query
// building. Fuzzy comparisons go through titleSimilarity, which
// keeps more structure.
func Normalize(s string) string {
if s == "" {
return ""
}
s = norm.NFC.String(s)
s = asciiFold(s)
s = qualifierPattern.ReplaceAllString(s, "")
s = dashQualifierPattern.ReplaceAllString(s, "")
s = strings.ReplaceAll(s, "&", " and ")
var b strings.Builder
+5 -1
View File
@@ -20,12 +20,16 @@ func TestNormalize(t *testing.T) {
"Sgt. Pepper's Lonely Hearts Club Band!",
"sgt peppers lonely hearts club band",
},
"NFC unicode": {"Beyoncé", "beyoncé"},
"accents fold to ascii": {"Beyoncé", "beyonce"},
"eszett folds": {"Motörhead & Björk", "motorhead and bjork"},
"ampersand becomes and": {"Simon & Garfunkel", "simon and garfunkel"},
"remastered qualifier": {"Abbey Road (Remastered 2009)", "abbey road"},
"remaster no year": {"Abbey Road (Remaster)", "abbey road"},
"explicit qualifier": {"Lemonade [Explicit]", "lemonade"},
"feat qualifier": {"Yellow (feat. Coldplay)", "yellow"},
"bonus track qualifier": {"Hey Jude [Bonus Track]", "hey jude"},
"dash suffix qualifier": {"Hey Jude - 2015 Remaster", "hey jude"},
"dash radio edit": {"One More Time - Radio Edit", "one more time"},
"collapse whitespace": {" Abbey Road ", "abbey road"},
"non-ascii digits kept": {"Track 7", "track 7"},
"numbered title not mangled": {"Untitled (1)", "untitled 1"},
+315 -37
View File
@@ -8,23 +8,90 @@ import (
// Release-level scoring weights. Aggregate track score is the
// dominant signal — the release-level signals are tie-breakers
// when the track alignment is roughly comparable.
// when the track alignment is roughly comparable. Weights sum to
// 1.0 so a perfect candidate scores 1.0 before the evidence scale.
const (
weightTrackAggregate = 0.70
weightTrackCountMatch = 0.15
weightReleaseMeta = 0.15 // Official + country averaged
weightTrackAggregate = 0.55
weightArtist = 0.12 // album-artist vs candidate artist-credit
weightAlbumTitle = 0.10 // folder album name vs candidate release title
weightTrackCountMatch = 0.13
weightReleaseMeta = 0.10 // official + country + RG type, averaged
// Country preference: a very mild nudge toward releases from
// the user's locale. Will become a config option in 012.
preferredCountry = "US"
// Evidence scaling: a folder with very few tracks offers little
// corroborating signal, so even a perfect title+length match on
// a single track is inherently less trustworthy than the same
// match across a full album. The final release score is scaled
// by evidenceFactor(localTrackCount): folders at or above
// evidenceFullTracks are unscaled; smaller folders are pulled
// toward evidenceFloor. This is the "harsher on singletons"
// lever — a single can never present as a near-certain match on
// its own, which is also why 012 keeps singletons out of
// auto-accept entirely.
evidenceFloor = 0.85
evidenceFullTracks = 3
)
// vaNames are artist strings that signal "various artists" — used
// both to detect VA-likely folders and to recognize VA candidate
// credits. Mirrors beets' VA_ARTISTS.
var vaNames = map[string]bool{
"various artists": true,
"various": true,
"va": true,
"v a": true, // "V.A." after Normalize
"unknown": true,
}
// isVAName reports whether an artist string reads as "various
// artists". Empty strings are NOT VA — they're unknown, which the
// artist term already treats as neutral.
func isVAName(s string) bool {
return vaNames[Normalize(s)]
}
// vaLikely reports whether a group is probably a various-artists
// compilation: the album-artist tag says so outright, or the
// per-track artists have no consensus (≥2 distinct values, or none
// at all). Mirrors beets' va_likely heuristic.
func vaLikely(g Group) bool {
if isVAName(g.AlbumArtist) {
return true
}
if g.AlbumArtist != "" {
return false
}
distinct := make(map[string]bool, 2) //nolint:mnd
for _, t := range g.Tracks {
if t.Artist == "" {
continue
}
distinct[Normalize(t.Artist)] = true
}
return len(distinct) != 1
}
// ScoreCandidate fills in c.Alignments, c.Score, c.Breakdown, and
// c.TrackCount for a single candidate against the given local
// tracks. The returned Candidate is safe to copy — no shared
// state with the caller's slice.
func ScoreCandidate(local []LocalTrack, c Candidate, localTrackCount int) Candidate {
c.Alignments = AlignTracks(local, c.Tracks)
// c.TrackCount for a single candidate against the given group.
// The returned Candidate is safe to copy — no shared state with
// the caller's slice.
func ScoreCandidate(g Group, c Candidate) Candidate {
local := g.Tracks
// When the group is one disc of a multi-disc candidate, align
// and count against that disc only — a "disc 1 of 2" folder is
// complete for its disc, not half an album.
targets := alignmentTargets(local, c.Tracks)
c.Alignments = AlignTracks(local, targets)
var (
titleSum float64
@@ -38,10 +105,19 @@ func ScoreCandidate(local []LocalTrack, c Candidate, localTrackCount int) Candid
}
counted++
titleSum += a.TitleScore
l := local[a.LocalIndex]
lengthSum += lengthScore(l.LengthMillis, a.CandidateLength)
// A recording-MBID lock is identity, not similarity: the
// title may be garbled in the local tag, but the track IS
// the candidate's track. Count it as a perfect title so a
// confirmed match isn't dragged down by its own typos (the
// UI still shows the textual diff).
if a.IDMatch {
titleSum++
} else {
titleSum += a.TitleScore
}
lengthSum += a.LengthScore
}
titleAvg, lengthAvg := 0.0, 0.0
@@ -63,31 +139,213 @@ func ScoreCandidate(local []LocalTrack, c Candidate, localTrackCount int) Candid
trackAgg := ((titleAvg*weightTitle + lengthAvg*weightLength) / trackWeightSum) * coverage
trackCountScore := trackCountMatch(len(c.Tracks), localTrackCount)
// Release-meta is just official-status + country preference,
// averaged. We used to mix in a year bonus too, but that
// compared candidate years against time.Now() — penalising
// every album that wasn't from this year, regardless of how
// well it matched the local files. See git history.
const metaTerms = 2.0
trackCountScore := trackCountMatch(len(targets), len(local))
meta := (officialBonus(c.Status) + countryBonus(c.Country)) / metaTerms
// Artist fit: compare the folder's artist against the
// candidate's release artist-credit. This is a SOFT signal, not
// a gate — user artist tags are often slightly wrong (misspelled,
// "&" vs "and", missing "feat."), so an almost-right artist still
// matches well while a completely different artist is penalised.
// Critically, artist is otherwise only a *search* filter (see
// buildMBQueryCascade), and that cascade drops the artist clause
// on its looser steps — so without this term a same-title,
// different-artist release scores as if the artist matched.
artistFit := artistCreditFit(groupArtist(g), c.ArtistCredit)
c.Score = trackAgg*weightTrackAggregate +
// Album-title fit: the same soft-signal contract for the release
// title. Without it, a compilation containing the same
// recordings scores as if it WERE the album ("Greatest Hits" vs
// the studio album with an identical tracklist).
albumFit := albumTitleFit(g.AlbumName, c.Title)
// Release-meta: official-status + country preference + release-
// group type, averaged. All mild tie-breakers. (We used to mix
// in a year bonus too, but that compared candidate years against
// time.Now() — see git history.)
const metaTerms = 3.0
meta := (officialBonus(c.Status) + countryBonus(c.Country) + rgTypeBonus(c.PrimaryType)) /
metaTerms
// Evidence scaling applies only to MusicBrainz candidates. A
// local candidate is the *same* release-group already tagged with
// MBIDs in another library — its confidence comes from that
// confirmed tagging, not from thin per-track heuristics, so a
// small-folder local match stays fully trusted (and keeps
// clearing the localSufficient MB-skip short-circuit). MB
// matches, by contrast, are fuzzy search results where a
// single-track folder genuinely offers little corroboration.
evidence := 1.0
if c.Source == SourceMusicBrainz {
evidence = evidenceFactor(len(local))
}
c.Score = (trackAgg*weightTrackAggregate +
artistFit*weightArtist +
albumFit*weightAlbumTitle +
trackCountScore*weightTrackCountMatch +
meta*weightReleaseMeta
meta*weightReleaseMeta) * evidence
c.Breakdown = ScoreBreakdown{
TitleAvg: titleAvg,
LengthAvg: lengthAvg,
ArtistFit: artistFit,
AlbumFit: albumFit,
TrackCountFit: trackCountScore,
ReleaseMeta: meta,
Evidence: evidence,
}
c.TrackCount = len(c.Tracks)
return c
}
// alignmentTargets returns the candidate tracks the local group
// should be aligned against. When every local track sits on the
// same disc D and the candidate spans multiple discs including D,
// only disc D's tracks are targets — the group key is per-disc, so
// a single-disc folder must not be penalised for "missing" the
// candidate's other discs.
func alignmentTargets(local []LocalTrack, cands []CandidateTrack) []CandidateTrack {
disc := uniformDisc(local)
if disc == 0 {
return cands
}
var (
onDisc int
multiDiscs bool
)
for _, c := range cands {
if c.DiscNumber == disc {
onDisc++
} else if c.DiscNumber > 0 {
multiDiscs = true
}
}
if !multiDiscs || onDisc == 0 {
return cands
}
out := make([]CandidateTrack, 0, onDisc)
for _, c := range cands {
if c.DiscNumber == disc {
out = append(out, c)
}
}
return out
}
// uniformDisc returns the disc number shared by every local track,
// or 0 when discs are mixed or unknown.
func uniformDisc(local []LocalTrack) int {
disc := 0
for _, t := range local {
switch {
case t.DiscNumber <= 0:
return 0
case disc == 0:
disc = t.DiscNumber
case t.DiscNumber != disc:
return 0
}
}
return disc
}
// groupArtist returns the artist string to compare candidates
// against: the tagging item's album-artist when it's a real name,
// otherwise the most common per-track artist. Returns "" when
// nothing is known (neutral, no penalty).
func groupArtist(g Group) string {
if g.AlbumArtist != "" && !isVAName(g.AlbumArtist) {
return g.AlbumArtist
}
return dominantArtist(g.Tracks)
}
// dominantArtist returns the most common non-empty per-track artist
// in a local group. Ties resolve to the first-seen value so the
// result is deterministic. Returns "" when no track has an artist,
// which artistCreditFit treats as "unknown, no penalty".
func dominantArtist(local []LocalTrack) string {
counts := make(map[string]int, len(local))
var (
best string
bestCount int
)
for _, t := range local {
if t.Artist == "" {
continue
}
counts[t.Artist]++
if counts[t.Artist] > bestCount {
best = t.Artist
bestCount = counts[t.Artist]
}
}
return best
}
// artistCreditFit scores how well a folder's artist matches a
// candidate's release artist-credit, in [0, 1]. Returns 1.0 (no
// penalty) when either side is unknown or reads as "various
// artists": absence of artist data must not push a candidate down,
// and VA credits are placeholders, not disagreements. Reuses the
// edit-distance similarity so near-right artists stay high.
func artistCreditFit(localArtist, candidateArtist string) float64 {
if localArtist == "" || candidateArtist == "" {
return 1.0
}
if isVAName(localArtist) || isVAName(candidateArtist) {
return 1.0
}
return titleSimilarity(localArtist, candidateArtist)
}
// albumTitleFit scores how well the folder's album name matches the
// candidate's release title, in [0, 1]. Neutral (1.0) when either
// side is unknown — same soft-signal contract as artistCreditFit.
func albumTitleFit(albumName, candidateTitle string) float64 {
if albumName == "" || candidateTitle == "" {
return 1.0
}
return titleSimilarity(albumName, candidateTitle)
}
// evidenceFactor scales the release score down when a folder has too
// few tracks to corroborate the match. Folders at or above
// evidenceFullTracks are unscaled (1.0); a single-track folder is
// pulled to evidenceFloor; two tracks land halfway. See the
// evidence-scaling note on the weight constants.
func evidenceFactor(localTrackCount int) float64 {
if localTrackCount >= evidenceFullTracks {
return 1.0
}
if localTrackCount <= 1 {
return evidenceFloor
}
span := float64(localTrackCount-1) / float64(evidenceFullTracks-1)
return evidenceFloor + (1.0-evidenceFloor)*span
}
// trackCountMatch returns 1.0 when equal, 0.0 when off by >= 50%,
// linear between.
func trackCountMatch(a, b int) float64 {
@@ -104,10 +362,7 @@ func trackCountMatch(a, b int) float64 {
diff = -diff
}
larger := a
if b > larger {
larger = b
}
larger := max(a, b)
frac := float64(diff) / float64(larger)
@@ -124,14 +379,11 @@ func trackCountMatch(a, b int) float64 {
func officialBonus(status string) float64 {
const partial = 0.5
switch strings.ToLower(status) {
case "official":
if strings.EqualFold(status, "official") {
return 1.0
case "":
return partial
default:
return partial
}
return partial
}
// countryBonus gives a mild nudge toward releases from the
@@ -153,6 +405,32 @@ func countryBonus(country string) float64 {
return neutral
}
// rgTypeBonus nudges toward studio albums over compilations and
// live releases when the track evidence is otherwise comparable —
// Picard weights release type heavily for the same reason. The
// nudge is mild: a genuine single folder still matches its Single
// release because track count and alignment dominate. Unknown
// types (including all local candidates) sit near the top so the
// term only separates candidates we positively know differ.
func rgTypeBonus(primaryType string) float64 {
switch strings.ToLower(primaryType) {
case "album":
return 1.0
case "ep":
return 0.9
case "single":
return 0.85
case "":
return 0.85
case "soundtrack":
return 0.7
case "compilation", "live":
return 0.6
default:
return 0.7
}
}
// parseYear pulls the first 4-digit year out of date strings like
// "2009", "2009-05-18", "".
func parseYear(date string) int {
@@ -168,13 +446,13 @@ func parseYear(date string) int {
return y
}
// RankCandidates scores each candidate against the local tracks
// and returns a new slice sorted descending by score. Input slice
// is not modified.
func RankCandidates(local []LocalTrack, candidates []Candidate) []Candidate {
// RankCandidates scores each candidate against the group and
// returns a new slice sorted descending by score. Input slice is
// not modified.
func RankCandidates(g Group, candidates []Candidate) []Candidate {
scored := make([]Candidate, 0, len(candidates))
for _, c := range candidates {
scored = append(scored, ScoreCandidate(local, c, len(local)))
scored = append(scored, ScoreCandidate(g, c))
}
sort.SliceStable(scored, func(i, j int) bool {
+302 -5
View File
@@ -37,7 +37,10 @@ func TestRankCandidates_PrefersExactTrackCountMatch(t *testing.T) {
},
}
ranked := autotag.RankCandidates(local, []autotag.Candidate{longer, matching})
ranked := autotag.RankCandidates(
autotag.Group{Tracks: local},
[]autotag.Candidate{longer, matching},
)
if ranked[0].ReleaseMBID != "exact" {
t.Errorf(
"top = %q (score %.2f vs %.2f), want 'exact'",
@@ -69,7 +72,10 @@ func TestRankCandidates_PrefersOfficial(t *testing.T) {
Tracks: tracks,
}
ranked := autotag.RankCandidates(local, []autotag.Candidate{promo, official})
ranked := autotag.RankCandidates(
autotag.Group{Tracks: local},
[]autotag.Candidate{promo, official},
)
if ranked[0].ReleaseMBID != "official" {
t.Errorf("top = %q, want 'official'", ranked[0].ReleaseMBID)
}
@@ -98,7 +104,7 @@ func TestRankCandidates_MultiDisc(t *testing.T) {
},
}
ranked := autotag.RankCandidates(local, []autotag.Candidate{cand})
ranked := autotag.RankCandidates(autotag.Group{Tracks: local}, []autotag.Candidate{cand})
if ranked[0].Score < 0.75 { //nolint:mnd
t.Errorf("multi-disc exact match = %.2f, want >= 0.75", ranked[0].Score)
}
@@ -127,12 +133,123 @@ func TestRankCandidates_VariousArtists(t *testing.T) {
},
}
ranked := autotag.RankCandidates(local, []autotag.Candidate{cand})
ranked := autotag.RankCandidates(autotag.Group{Tracks: local}, []autotag.Candidate{cand})
if ranked[0].Score < 0.75 { //nolint:mnd
t.Errorf("VA-compilation exact track match = %.2f, want >= 0.75", ranked[0].Score)
}
}
func TestRankCandidates_PenalizesWrongArtistSingleton(t *testing.T) {
t.Parallel()
// The reported false positive: a single with a generic title
// matched a same-titled song by a DIFFERENT artist with a very
// different length, at high confidence. Artist was only a search
// filter, never a scoring signal, so nothing pulled the wrong
// candidate down. Now the artist term + singleton evidence
// scaling must keep it well below a confident match.
local := []autotag.LocalTrack{
{Title: "Intro", Artist: "Real Artist", TrackNumber: 1, LengthMillis: 90000},
}
// Same title, wrong artist, and a wildly different length — an MB
// search hit that happens to share a common title.
wrongArtist := autotag.Candidate{
ReleaseMBID: "wrong",
Title: "Intro",
ArtistCredit: "Some Other Band",
Status: "Official",
Source: autotag.SourceMusicBrainz,
Tracks: []autotag.CandidateTrack{
{Position: 1, Title: "Intro", LengthMillis: 240000},
},
}
// The correct release: same title, right artist, right length.
rightArtist := autotag.Candidate{
ReleaseMBID: "right",
Title: "Intro",
ArtistCredit: "Real Artist",
Status: "Official",
Source: autotag.SourceMusicBrainz,
Tracks: []autotag.CandidateTrack{
{Position: 1, Title: "Intro", LengthMillis: 90000},
},
}
ranked := autotag.RankCandidates(
autotag.Group{Tracks: local},
[]autotag.Candidate{wrongArtist, rightArtist},
)
if ranked[0].ReleaseMBID != "right" {
t.Fatalf(
"top = %q (%.2f vs %.2f), want 'right'",
ranked[0].ReleaseMBID, ranked[0].Score, ranked[1].Score,
)
}
// The wrong-artist candidate must not read as a confident match.
var wrongScore float64
for _, c := range ranked {
if c.ReleaseMBID == "wrong" {
wrongScore = c.Score
}
}
if wrongScore >= 0.75 { //nolint:mnd
t.Errorf("wrong-artist singleton scored %.2f, want < 0.75", wrongScore)
}
}
func TestRankCandidates_EvidenceScalingIsSourceAware(t *testing.T) {
t.Parallel()
// A perfect single-track match: identical title, artist, length.
// From MusicBrainz it should be evidence-scaled (thin corroboration
// on one track); from a local library it should NOT be — a local
// candidate is the same release-group already tagged with MBIDs
// elsewhere, so its confidence is not heuristic.
local := []autotag.LocalTrack{
{Title: "Solo", Artist: "Someone", TrackNumber: 1, LengthMillis: 200000},
}
tracks := []autotag.CandidateTrack{
{Position: 1, Title: "Solo", LengthMillis: 200000},
}
mbCand := autotag.Candidate{
ReleaseMBID: "mb", ArtistCredit: "Someone", Status: "Official",
Source: autotag.SourceMusicBrainz, Tracks: tracks,
}
localCand := autotag.Candidate{
ReleaseMBID: "local", ArtistCredit: "Someone", Status: "Official",
Source: autotag.SourceLocal, Tracks: tracks,
}
scoredMB := autotag.RankCandidates(autotag.Group{Tracks: local}, []autotag.Candidate{mbCand})[0]
scoredLocal := autotag.RankCandidates(autotag.Group{Tracks: local}, []autotag.Candidate{localCand})[0]
if scoredMB.Breakdown.Evidence >= 1.0 {
t.Errorf("MB singleton evidence = %.3f, want < 1.0", scoredMB.Breakdown.Evidence)
}
if scoredLocal.Breakdown.Evidence < 1.0 {
t.Errorf(
"local singleton evidence = %.3f, want 1.0 (not scaled)",
scoredLocal.Breakdown.Evidence,
)
}
if scoredLocal.Score <= scoredMB.Score {
t.Errorf(
"local perfect single (%.3f) should outscore the evidence-scaled MB single (%.3f)",
scoredLocal.Score, scoredMB.Score,
)
}
}
func TestRankCandidates_AmbiguousAlbumNames(t *testing.T) {
t.Parallel()
@@ -164,7 +281,10 @@ func TestRankCandidates_AmbiguousAlbumNames(t *testing.T) {
},
}
ranked := autotag.RankCandidates(local, []autotag.Candidate{queen, eagles})
ranked := autotag.RankCandidates(
autotag.Group{Tracks: local},
[]autotag.Candidate{queen, eagles},
)
if ranked[0].ReleaseMBID != "eagles-gh" {
t.Errorf(
"top = %q (%.2f vs %.2f), want 'eagles-gh'",
@@ -172,3 +292,180 @@ func TestRankCandidates_AmbiguousAlbumNames(t *testing.T) {
)
}
}
func TestRankCandidates_AlbumTitleSeparatesCompilation(t *testing.T) {
t.Parallel()
// Identical tracklists: the studio album and a greatest-hits comp
// that contains the same recordings. Track alignment can't
// separate them — the folder's album name must.
local := []autotag.LocalTrack{
{Title: "Song A", Artist: "The Band", TrackNumber: 1, LengthMillis: 200000},
{Title: "Song B", Artist: "The Band", TrackNumber: 2, LengthMillis: 210000},
{Title: "Song C", Artist: "The Band", TrackNumber: 3, LengthMillis: 195000},
}
tracks := []autotag.CandidateTrack{
{Position: 1, Title: "Song A", LengthMillis: 200000},
{Position: 2, Title: "Song B", LengthMillis: 210000},
{Position: 3, Title: "Song C", LengthMillis: 195000},
}
album := autotag.Candidate{
ReleaseMBID: "studio", Title: "The Studio Album",
ArtistCredit: "The Band", Status: "Official", Tracks: tracks,
}
comp := autotag.Candidate{
ReleaseMBID: "comp", Title: "Greatest Hits",
ArtistCredit: "The Band", Status: "Official", Tracks: tracks,
}
g := autotag.Group{
AlbumName: "The Studio Album", AlbumArtist: "The Band", Tracks: local,
}
ranked := autotag.RankCandidates(g, []autotag.Candidate{comp, album})
if ranked[0].ReleaseMBID != "studio" {
t.Errorf(
"top = %q (%.3f vs %.3f), want 'studio' (album-title term)",
ranked[0].ReleaseMBID, ranked[0].Score, ranked[1].Score,
)
}
}
func TestRankCandidates_ReleaseGroupTypeBreaksTies(t *testing.T) {
t.Parallel()
// Same tracks, same title — one RG is an Album, the other a
// Compilation. Type preference should break the tie toward the
// studio album (Picard weights release type for the same reason).
local := []autotag.LocalTrack{
{Title: "Song A", TrackNumber: 1, LengthMillis: 200000},
{Title: "Song B", TrackNumber: 2, LengthMillis: 210000},
{Title: "Song C", TrackNumber: 3, LengthMillis: 195000},
}
tracks := []autotag.CandidateTrack{
{Position: 1, Title: "Song A", LengthMillis: 200000},
{Position: 2, Title: "Song B", LengthMillis: 210000},
{Position: 3, Title: "Song C", LengthMillis: 195000},
}
album := autotag.Candidate{
ReleaseMBID: "album", Title: "X", Status: "Official",
PrimaryType: "Album", Tracks: tracks,
}
comp := autotag.Candidate{
ReleaseMBID: "comp", Title: "X", Status: "Official",
PrimaryType: "Compilation", Tracks: tracks,
}
ranked := autotag.RankCandidates(
autotag.Group{Tracks: local}, []autotag.Candidate{comp, album},
)
if ranked[0].ReleaseMBID != "album" {
t.Errorf(
"top = %q (%.3f vs %.3f), want 'album' (RG type preference)",
ranked[0].ReleaseMBID, ranked[0].Score, ranked[1].Score,
)
}
}
func TestRankCandidates_SingleDiscGroupAgainstMultiDiscRelease(t *testing.T) {
t.Parallel()
// The group key is per-disc, so "disc 1 of 2" folders score
// against multi-disc releases. Alignment and track count must
// compare against disc 1's tracks only — not get punished for
// "missing" all of disc 2.
local := []autotag.LocalTrack{
{Title: "D1T1", DiscNumber: 1, TrackNumber: 1, LengthMillis: 200000},
{Title: "D1T2", DiscNumber: 1, TrackNumber: 2, LengthMillis: 210000},
{Title: "D1T3", DiscNumber: 1, TrackNumber: 3, LengthMillis: 195000},
}
cand := autotag.Candidate{
ReleaseMBID: "2disc",
Status: "Official",
Tracks: []autotag.CandidateTrack{
{Position: 1, DiscNumber: 1, Title: "D1T1", LengthMillis: 200000},
{Position: 2, DiscNumber: 1, Title: "D1T2", LengthMillis: 210000},
{Position: 3, DiscNumber: 1, Title: "D1T3", LengthMillis: 195000},
{Position: 1, DiscNumber: 2, Title: "D2T1", LengthMillis: 220000},
{Position: 2, DiscNumber: 2, Title: "D2T2", LengthMillis: 230000},
{Position: 3, DiscNumber: 2, Title: "D2T3", LengthMillis: 240000},
},
}
ranked := autotag.RankCandidates(
autotag.Group{Tracks: local}, []autotag.Candidate{cand},
)
top := ranked[0]
if top.Score < 0.85 { //nolint:mnd
t.Errorf("disc-1 folder vs 2-disc release = %.3f, want >= 0.85", top.Score)
}
if top.Breakdown.TrackCountFit != 1.0 {
t.Errorf(
"track count fit = %.2f, want 1.0 (counted against disc 1 only)",
top.Breakdown.TrackCountFit,
)
}
// No "missing" rows for disc 2 — the folder is complete for its
// disc.
for _, a := range top.Alignments {
if a.Status == autotag.AlignmentMissing {
t.Errorf("unexpected missing alignment for %q", a.CandidateTitle)
}
}
}
func TestRankCandidates_RecordingMBIDLocksAlignment(t *testing.T) {
t.Parallel()
// The local title is garbled, but its recording MBID matches a
// candidate track — identity beats similarity: the pair must
// align, count as matched, and not drag the title average down.
local := []autotag.LocalTrack{
{Title: "trck 01", RecordingMBID: "rec-a", TrackNumber: 1, LengthMillis: 200000},
{Title: "Song B", TrackNumber: 2, LengthMillis: 210000},
{Title: "Song C", TrackNumber: 3, LengthMillis: 195000},
}
cand := autotag.Candidate{
ReleaseMBID: "rel",
Status: "Official",
Tracks: []autotag.CandidateTrack{
{Position: 1, Title: "Song A", LengthMillis: 200000, MBID: "rec-a"},
{Position: 2, Title: "Song B", LengthMillis: 210000},
{Position: 3, Title: "Song C", LengthMillis: 195000},
},
}
ranked := autotag.RankCandidates(
autotag.Group{Tracks: local}, []autotag.Candidate{cand},
)
top := ranked[0]
var locked *autotag.TrackAlignment
for i := range top.Alignments {
if top.Alignments[i].LocalIndex == 0 {
locked = &top.Alignments[i]
}
}
if locked == nil || locked.Status != autotag.AlignmentMatched || !locked.IDMatch {
t.Fatalf("garbled-title track should be ID-locked matched, got %+v", locked)
}
if top.Breakdown.TitleAvg < 0.99 {
t.Errorf(
"title avg = %.3f, want ~1.0 (ID-locked pair counts as perfect title)",
top.Breakdown.TitleAvg,
)
}
}
+133
View File
@@ -0,0 +1,133 @@
package autotag
// Recommendation is a qualitative confidence tier for a group's
// ranked candidates — the piece a raw score can't express on its
// own. Modeled on beets' Recommendation enum: the tier starts from
// the top candidate's absolute score and is then CAPPED by defects
// (ambiguity with a different release group, missing/unmatched
// tracks, thin evidence). Auto-accept (plan 011) should require
// RecommendationStrong; the review UI can badge the rest.
type Recommendation string
// Recommendation tiers, weakest to strongest.
const (
RecommendationNone Recommendation = "none"
RecommendationLow Recommendation = "low"
RecommendationMedium Recommendation = "medium"
RecommendationStrong Recommendation = "strong"
)
const (
// Absolute score tiers.
strongScoreThresh = 0.90
mediumScoreThresh = 0.75
// A runner-up from a DIFFERENT release group within this margin
// of the top score makes the match ambiguous — two genuinely
// different albums both fit, so a human should look. Editions
// of the same release group are expected to score nearly
// identically and never count as ambiguity.
ambiguityMargin = 0.05
)
// Recommend derives the confidence tier for a ranked candidate
// list. candidates must already be sorted best-first (the shape
// RankCandidates returns).
func Recommend(g Group, candidates []Candidate) Recommendation {
if len(candidates) == 0 {
return RecommendationNone
}
top := candidates[0]
var rec Recommendation
switch {
case top.Score >= strongScoreThresh:
rec = RecommendationStrong
case top.Score >= mediumScoreThresh:
rec = RecommendationMedium
default:
return RecommendationLow
}
// Cap: a different release group scoring within the ambiguity
// margin means the score alone can't pick between two albums.
if rivalWithinMargin(top, candidates[1:]) {
rec = minRecommendation(rec, RecommendationMedium)
}
// Cap: missing or unmatched tracks mean the alignment itself is
// incomplete, however good the matched tracks look (beets caps
// these penalties at "medium" the same way).
for _, a := range top.Alignments {
if a.Status == AlignmentMissing || a.Status == AlignmentUnmatched {
rec = minRecommendation(rec, RecommendationMedium)
break
}
}
// Cap: tiny folders can't corroborate a match strongly enough
// to act on without review, whatever the arithmetic says.
if len(g.Tracks) < evidenceFullTracks {
rec = minRecommendation(rec, RecommendationMedium)
}
return rec
}
// rivalWithinMargin reports whether any candidate from a different
// release group scores within ambiguityMargin of the top candidate.
func rivalWithinMargin(top Candidate, rest []Candidate) bool {
for _, c := range rest {
if top.Score-c.Score > ambiguityMargin {
// Sorted descending: everything further is farther away.
return false
}
if !sameReleaseGroup(top, c) {
return true
}
}
return false
}
// sameReleaseGroup reports whether two candidates belong to the
// same release group — by MBID when both carry one, by normalized
// title + artist-credit otherwise (local candidates may lack RG
// MBIDs).
func sameReleaseGroup(a, b Candidate) bool {
if a.ReleaseGroupMBID != "" && b.ReleaseGroupMBID != "" {
return a.ReleaseGroupMBID == b.ReleaseGroupMBID
}
return Normalize(a.Title) == Normalize(b.Title) &&
Normalize(a.ArtistCredit) == Normalize(b.ArtistCredit)
}
// recommendationRank orders tiers for min-comparison.
func recommendationRank(r Recommendation) int {
switch r {
case RecommendationNone:
return 0
case RecommendationLow:
return 1
case RecommendationMedium:
return 2
case RecommendationStrong:
return 3
default:
return 0
}
}
// minRecommendation returns the weaker of two tiers.
func minRecommendation(a, b Recommendation) Recommendation {
if recommendationRank(a) <= recommendationRank(b) {
return a
}
return b
}
+130
View File
@@ -0,0 +1,130 @@
package autotag
import "testing"
// mkScoredCandidate builds a minimal candidate with a preset score
// for Recommend tests — Recommend never re-scores, it only reads.
func mkScoredCandidate(rgMBID string, score float64) Candidate {
return Candidate{
ReleaseMBID: "rel-" + rgMBID,
ReleaseGroupMBID: rgMBID,
Score: score,
}
}
func fullGroup() Group {
return Group{Tracks: []LocalTrack{{Title: "A"}, {Title: "B"}, {Title: "C"}}}
}
func TestRecommend_Tiers(t *testing.T) {
t.Parallel()
g := fullGroup()
cases := []struct {
name string
cands []Candidate
want Recommendation
}{
{"no candidates", nil, RecommendationNone},
{"strong", []Candidate{mkScoredCandidate("rg1", 0.95)}, RecommendationStrong},
{"medium", []Candidate{mkScoredCandidate("rg1", 0.80)}, RecommendationMedium},
{"low", []Candidate{mkScoredCandidate("rg1", 0.50)}, RecommendationLow},
}
for _, tc := range cases {
if got := Recommend(g, tc.cands); got != tc.want {
t.Errorf("%s: Recommend = %q, want %q", tc.name, got, tc.want)
}
}
}
func TestRecommend_AmbiguousRivalCapsAtMedium(t *testing.T) {
t.Parallel()
// A different release group within the margin → ambiguous, even
// though the top score alone reads strong.
cands := []Candidate{
mkScoredCandidate("rg1", 0.95),
mkScoredCandidate("rg2", 0.93),
}
if got := Recommend(fullGroup(), cands); got != RecommendationMedium {
t.Errorf("ambiguous rival: Recommend = %q, want medium", got)
}
}
func TestRecommend_SameRGEditionsAreNotAmbiguous(t *testing.T) {
t.Parallel()
// Multiple editions of the SAME release group score nearly
// identically by construction — that's not ambiguity.
cands := []Candidate{
mkScoredCandidate("rg1", 0.95),
mkScoredCandidate("rg1", 0.94),
mkScoredCandidate("rg1", 0.93),
}
if got := Recommend(fullGroup(), cands); got != RecommendationStrong {
t.Errorf("same-RG editions: Recommend = %q, want strong", got)
}
}
func TestRecommend_DistantRivalDoesNotCap(t *testing.T) {
t.Parallel()
cands := []Candidate{
mkScoredCandidate("rg1", 0.95),
mkScoredCandidate("rg2", 0.60),
}
if got := Recommend(fullGroup(), cands); got != RecommendationStrong {
t.Errorf("distant rival: Recommend = %q, want strong", got)
}
}
func TestRecommend_AlignmentDefectsCapAtMedium(t *testing.T) {
t.Parallel()
top := mkScoredCandidate("rg1", 0.95)
top.Alignments = []TrackAlignment{
{Status: AlignmentMatched},
{Status: AlignmentMissing, LocalIndex: -1},
}
if got := Recommend(fullGroup(), []Candidate{top}); got != RecommendationMedium {
t.Errorf("missing track: Recommend = %q, want medium", got)
}
}
func TestRecommend_ThinEvidenceCapsAtMedium(t *testing.T) {
t.Parallel()
// A 2-track folder can't be auto-accept confident however well
// it matches. (Local candidates skip evidence *scaling* but not
// this cap — acting without review still needs corroboration.)
g := Group{Tracks: []LocalTrack{{Title: "A"}, {Title: "B"}}}
cands := []Candidate{mkScoredCandidate("rg1", 0.96)}
if got := Recommend(g, cands); got != RecommendationMedium {
t.Errorf("thin evidence: Recommend = %q, want medium", got)
}
}
func TestRecommend_LocalCandidatesWithoutRGMBIDCompareByTitle(t *testing.T) {
t.Parallel()
// Local candidates may lack RG MBIDs; same title+artist means
// same release group for ambiguity purposes.
a := Candidate{Title: "Album", ArtistCredit: "Band", Score: 0.95}
b := Candidate{Title: "Album", ArtistCredit: "Band", Score: 0.94}
if got := Recommend(fullGroup(), []Candidate{a, b}); got != RecommendationStrong {
t.Errorf("same title/artist locals: Recommend = %q, want strong", got)
}
c := Candidate{Title: "Different Album", ArtistCredit: "Band", Score: 0.94}
if got := Recommend(fullGroup(), []Candidate{a, c}); got != RecommendationMedium {
t.Errorf("different-title rival: Recommend = %q, want medium", got)
}
}
+177 -64
View File
@@ -40,13 +40,51 @@ func NewScorer(q *sqlcgen.Queries, mb MBClient, logger *slog.Logger) *Scorer {
}
}
// ScoreGroup produces the full GroupScore for one tagging item.
// Always runs both the local resolver (free) and the MB resolver
// (cache-first, so repeats cost nothing). Candidates from both
// sources are merged and ranked — the UI displays them with
// provenance badges so the user can compare.
// localSufficient is the local-candidate score above which a
// local-first caller skips the MusicBrainz round-trip entirely: a
// local candidate at or above this is a strong match (the same album
// already tagged correctly in another library), so the MB cascade
// wouldn't change the top pick and isn't worth a rate-limited network
// call. Interactive scoring ignores this and always consults MB so
// the review UI can show both sources side by side.
const localSufficient = 0.90
// idSufficient is the score at which an ID-resolved candidate (built
// from recording MBIDs already present in the local tags) makes the
// fuzzy search cascade unnecessary — mirrors beets, where a strong
// mb_albumid match returns immediately without a text search.
const idSufficient = 0.90
// maxIDSampleTracks caps how many local recording MBIDs the ID-first
// path looks up — three spread across the folder corroborate a
// release without paying for a lookup per track.
const maxIDSampleTracks = 3
// ScoreGroup produces the full GroupScore for one tagging item,
// always consulting MusicBrainz (when a client is configured) so the
// review UI can display local + MB candidates side by side with
// provenance badges. Use this on interactive paths where the user is
// looking at the result.
func (s *Scorer) ScoreGroup(
ctx context.Context, groupKey string,
) (*GroupScore, error) {
return s.scoreGroup(ctx, groupKey, false)
}
// ScoreGroupLocalFirst is the cheap variant for background work
// (prefetch): it scores local candidates first and skips the
// MusicBrainz cascade when the best local candidate is already a
// strong match (score >= localSufficient). Falls back to the full
// MB-consulting path otherwise, so albums with no strong local match
// still get a real score for the sidebar pill.
func (s *Scorer) ScoreGroupLocalFirst(
ctx context.Context, groupKey string,
) (*GroupScore, error) {
return s.scoreGroup(ctx, groupKey, true)
}
func (s *Scorer) scoreGroup(
ctx context.Context, groupKey string, localFirst bool,
) (*GroupScore, error) {
item, err := s.q.GetTaggingItem(ctx, groupKey)
if err != nil {
@@ -62,36 +100,153 @@ func (s *Scorer) ScoreGroup(
return nil, err
}
g := Group{
AlbumName: item.AlbumName,
AlbumArtist: item.AlbumArtist,
Tracks: locals,
}
localHits, err := s.local.ResolveLocal(ctx, item.AlbumName)
if err != nil {
return nil, err
}
// Local-first short-circuit: pre-score the free local candidates
// and, if one is already a strong match, skip the MB round-trip.
var localCandidates []Candidate
skipMB := false
if localFirst && s.mb != nil {
localCandidates = RankCandidates(g, localHits)
skipMB = len(localCandidates) > 0 && localCandidates[0].Score >= localSufficient
}
var mbHits []Candidate
if s.mb != nil {
mbHits, err = s.mb.ResolveMB(
ctx,
item.AlbumName,
item.AlbumArtist,
len(locals),
guessArtistMBID(locals),
)
if s.mb != nil && !skipMB {
mbHits = s.resolveMBCandidates(ctx, g, groupKey)
}
// Reuse the pre-ranked local list when we skipped MB; otherwise
// rank the merged set.
candidates := localCandidates
if !skipMB {
candidates = RankCandidates(g, append(localHits, mbHits...))
}
return &GroupScore{
GroupKey: groupKey,
AlbumName: item.AlbumName,
AlbumArtist: item.AlbumArtist,
LocalTracks: locals,
Candidates: candidates,
Recommendation: Recommend(g, candidates),
}, nil
}
// resolveMBCandidates gathers MusicBrainz candidates for a group:
// ID-first (recording MBIDs already in the tags), then the search
// cascade when the ID path didn't produce a strong match. Failures
// on either path degrade to fewer candidates, never to an error —
// local candidates must still surface when MB is unreachable.
func (s *Scorer) resolveMBCandidates(
ctx context.Context, g Group, groupKey string,
) []Candidate {
var out []Candidate
if ids := sampleRecordingMBIDs(g.Tracks); len(ids) > 0 {
idCands, err := s.mb.ResolveByRecordingMBIDs(ctx, ids)
if err != nil {
s.log.Warn(
"MB resolve failed — returning local-only candidates",
"group_key", groupKey,
"err", err,
"MB ID-first resolve failed — falling back to search",
"group_key", groupKey, "err", err,
)
}
if len(idCands) > 0 {
ranked := RankCandidates(g, idCands)
if ranked[0].Score >= idSufficient {
s.log.Info(
"MB ID-first match — skipping search cascade",
"group_key", groupKey, "score", ranked[0].Score,
)
return idCands
}
out = idCands
}
}
candidates := RankCandidates(locals, append(localHits, mbHits...))
searchHits, err := s.mb.ResolveMB(ctx, g)
if err != nil {
s.log.Warn(
"MB resolve failed — returning local-only candidates",
"group_key", groupKey, "err", err,
)
return &GroupScore{
GroupKey: groupKey,
LocalTracks: locals,
Candidates: candidates,
}, nil
return out
}
return append(out, dropDuplicateReleases(out, searchHits)...)
}
// dropDuplicateReleases filters from `extra` any candidate whose
// release MBID already appears in `have`.
func dropDuplicateReleases(have, extra []Candidate) []Candidate {
if len(have) == 0 {
return extra
}
seen := make(map[string]bool, len(have))
for _, c := range have {
if c.ReleaseMBID != "" {
seen[c.ReleaseMBID] = true
}
}
out := make([]Candidate, 0, len(extra))
for _, c := range extra {
if c.ReleaseMBID != "" && seen[c.ReleaseMBID] {
continue
}
out = append(out, c)
}
return out
}
// sampleRecordingMBIDs picks up to maxIDSampleTracks distinct
// recording MBIDs spread across the group (first, middle, last) —
// enough to corroborate a release via voting without a lookup per
// track.
func sampleRecordingMBIDs(tracks []LocalTrack) []string {
distinct := make([]string, 0, len(tracks))
seen := make(map[string]bool, len(tracks))
for _, t := range tracks {
if t.RecordingMBID == "" || seen[t.RecordingMBID] {
continue
}
seen[t.RecordingMBID] = true
distinct = append(distinct, t.RecordingMBID)
}
if len(distinct) <= maxIDSampleTracks {
return distinct
}
return []string{
distinct[0],
distinct[len(distinct)/2],
distinct[len(distinct)-1],
}
}
// LocalTracksForGroup exposes the local resolver so callers that
@@ -104,32 +259,6 @@ func (s *Scorer) LocalTracksForGroup(
return s.local.LocalTracksForGroup(ctx, groupKey)
}
// PersistBest writes the top candidate's release MBID and score
// onto the tagging_items row, bumping status to 'matched' when a
// candidate exists. No-op when candidates is empty (keeps the
// current status — likely 'pending').
func (s *Scorer) PersistBest(
ctx context.Context, score *GroupScore,
) error {
if score == nil || len(score.Candidates) == 0 {
return nil
}
top := score.Candidates[0]
mbid := top.ReleaseMBID
if mbid == "" {
mbid = top.ReleaseGroupMBID
}
return s.q.SetTaggingItemBestMatch(ctx, sqlcgen.SetTaggingItemBestMatchParams{
BestMatchReleaseMbid: sql.NullString{String: mbid, Valid: mbid != ""},
Score: sql.NullFloat64{Float64: top.Score, Valid: true},
Status: "matched",
GroupKey: score.GroupKey,
})
}
// PersistScore writes the top candidate's release MBID and score
// onto the tagging_items row WITHOUT touching its status. Use
// this from paths that want the sidebar pill / sort to reflect a
@@ -156,19 +285,3 @@ func (s *Scorer) PersistScore(
GroupKey: score.GroupKey,
})
}
// guessArtistMBID returns the first non-empty recording MBID-
// derived artist hint we can find. Tracks carry recording MBIDs,
// not artist MBIDs, but existing partial tags are often consistent
// enough that any non-empty MBID signals "this album already has
// some MB lineage". A follow-up in 010 can resolve actual artist
// MBIDs via the artists table.
func guessArtistMBID(tracks []LocalTrack) string {
// Phase 009 doesn't wire up per-track artist MBIDs through
// the LocalTrack struct yet — keeping the hook so the MB
// resolver still compiles with the empty hint. Real artist
// MBIDs flow in once 010 wires them into LocalTrack.
_ = tracks
return ""
}
+191 -7
View File
@@ -166,7 +166,179 @@ func TestScorer_LocalHitSurfacesFirst(t *testing.T) {
}
}
func TestScorer_PersistBestWritesMatched(t *testing.T) {
// TestScorer_LocalFirstSkipsMB asserts the background (prefetch)
// scoring path makes zero MusicBrainz calls when a local candidate is
// already a strong match — the whole point of ScoreGroupLocalFirst.
func TestScorer_LocalFirstSkipsMB(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
// A canonical local album (MBIDs present) that the pending group
// matches track-for-track — the local candidate scores >= 0.90.
seed(t, db, seededAlbum{
groupKey: "g-canonical",
albumName: "Good Album",
releaseMBID: "rg-abcd",
tracks: []seededTrack{
{
filePath: "/lib/a.mp3", title: "Song A",
trackNumber: 1, lengthMillis: 200000, recordingMBID: "rec-a",
},
{
filePath: "/lib/b.mp3", title: "Song B",
trackNumber: 2, lengthMillis: 180000, recordingMBID: "rec-b",
},
},
})
seed(t, db, seededAlbum{
groupKey: "g-pending",
albumName: "Good Album",
tracks: []seededTrack{
{filePath: "/other/a.mp3", title: "Song A", trackNumber: 1, lengthMillis: 200000},
{filePath: "/other/b.mp3", title: "Song B", trackNumber: 2, lengthMillis: 180000},
},
})
mbCalls := 0
fakeMB := &countingMBClient{onSearch: func() { mbCalls++ }}
scorer := autotag.NewScorer(db.Queries, fakeMB, slog.New(slog.DiscardHandler))
result, err := scorer.ScoreGroupLocalFirst(context.Background(), "g-pending")
if err != nil {
t.Fatalf("ScoreGroupLocalFirst: %v", err)
}
if mbCalls != 0 {
t.Errorf("made %d MB calls, want 0 (strong local match should skip MB)", mbCalls)
}
if len(result.Candidates) == 0 || result.Candidates[0].ReleaseGroupMBID != "rg-abcd" {
t.Errorf("top candidate = %+v, want local rg-abcd", result.Candidates)
}
}
// TestScorer_IDFirstSkipsSearch asserts that a group whose tracks
// already carry recording MBIDs resolves through the ID-first path
// — the release the recordings vote for is looked up directly and
// the fuzzy search cascade never runs.
func TestScorer_IDFirstSkipsSearch(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seed(t, db, seededAlbum{
groupKey: "g-tagged",
albumName: "Good Album",
tracks: []seededTrack{
{
filePath: "/lib/a.mp3", title: "Song A",
trackNumber: 1, lengthMillis: 200000, recordingMBID: "rec-a",
},
{
filePath: "/lib/b.mp3", title: "Song B",
trackNumber: 2, lengthMillis: 180000, recordingMBID: "rec-b",
},
},
})
fake := &idFakeClient{
recRels: map[string][]autotag.MBReleaseRef{
"rec-a": {{MBID: "rel-full", Status: "Official", Date: "1999"}},
"rec-b": {{MBID: "rel-full", Status: "Official", Date: "1999"}},
},
releases: map[string]autotag.MBRelease{
"rel-full": {
MBID: "rel-full", Title: "Good Album",
ArtistCredit: "Test Artist", Status: "Official", Country: "US",
Tracks: []autotag.CandidateTrack{
{Position: 1, Title: "Song A", LengthMillis: 200000, MBID: "rec-a"},
{Position: 2, Title: "Song B", LengthMillis: 180000, MBID: "rec-b"},
},
},
},
}
scorer := autotag.NewScorer(db.Queries, fake, slog.New(slog.DiscardHandler))
result, err := scorer.ScoreGroup(context.Background(), "g-tagged")
if err != nil {
t.Fatalf("ScoreGroup: %v", err)
}
if fake.searches != 0 {
t.Errorf("made %d search calls, want 0 (ID-first should skip the cascade)", fake.searches)
}
if len(result.Candidates) == 0 {
t.Fatal("no candidates")
}
top := result.Candidates[0]
if top.ReleaseMBID != "rel-full" || top.Provenance != "id" {
t.Errorf(
"top = %q via %q (%.3f), want 'rel-full' via 'id'",
top.ReleaseMBID, top.Provenance, top.Score,
)
}
}
// idFakeClient serves canned recording→release lookups and counts
// search calls so the ID-first test can assert the cascade stayed
// cold.
type idFakeClient struct {
searches int
recRels map[string][]autotag.MBReleaseRef
releases map[string]autotag.MBRelease
}
func (c *idFakeClient) SearchReleaseGroups(
_ context.Context, _ string, _ int,
) ([]autotag.MBReleaseGroupHit, int, error) {
c.searches++
return nil, 0, nil
}
func (c *idFakeClient) SearchRecordings(
_ context.Context, _ string, _ int,
) ([]autotag.MBRecordingHit, int, error) {
c.searches++
return nil, 0, nil
}
func (c *idFakeClient) LookupRecordingReleases(
_ context.Context, mbid string,
) ([]autotag.MBReleaseRef, error) {
return c.recRels[mbid], nil
}
func (c *idFakeClient) BrowseReleases(
_ context.Context, _ string,
) ([]autotag.MBRelease, error) {
return nil, nil
}
func (c *idFakeClient) LookupRelease(
_ context.Context, mbid string,
) (autotag.MBRelease, error) {
rel, ok := c.releases[mbid]
if !ok {
return autotag.MBRelease{}, autotag.ErrGroupNotFound
}
return rel, nil
}
func (c *idFakeClient) LookupReleaseGroup(
_ context.Context, _ string,
) (autotag.MBReleaseGroupHit, error) {
return autotag.MBReleaseGroupHit{}, nil
}
func TestScorer_PersistScoreWritesTopMatch(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
@@ -200,8 +372,8 @@ func TestScorer_PersistBestWritesMatched(t *testing.T) {
t.Fatalf("ScoreGroup: %v", err)
}
if err := scorer.PersistBest(context.Background(), result); err != nil {
t.Fatalf("PersistBest: %v", err)
if err := scorer.PersistScore(context.Background(), result); err != nil {
t.Fatalf("PersistScore: %v", err)
}
got, err := db.Queries.GetTaggingItem(context.Background(), "g-pending")
@@ -209,8 +381,10 @@ func TestScorer_PersistBestWritesMatched(t *testing.T) {
t.Fatalf("reload: %v", err)
}
if got.Status != "matched" {
t.Errorf("status = %q, want 'matched'", got.Status)
// PersistScore records the pill score + best match but must leave
// the review status untouched so the folder stays in the queue.
if got.Status != "pending" {
t.Errorf("status = %q, want 'pending' (PersistScore must not flip status)", got.Status)
}
if !got.BestMatchReleaseMbid.Valid || got.BestMatchReleaseMbid.String != "rg-abcd" {
@@ -258,10 +432,20 @@ func (c *countingMBClient) BrowseReleases(
return nil, nil
}
func (c *countingMBClient) LookupArtist(_ context.Context, _ string) (string, error) {
func (c *countingMBClient) SearchRecordings(
_ context.Context, _ string, _ int,
) ([]autotag.MBRecordingHit, int, error) {
c.onSearch()
return "", nil
return nil, 0, nil
}
func (c *countingMBClient) LookupRecordingReleases(
_ context.Context, _ string,
) ([]autotag.MBReleaseRef, error) {
c.onSearch()
return nil, nil
}
func (c *countingMBClient) LookupRelease(_ context.Context, _ string) (autotag.MBRelease, error) {
+25 -7
View File
@@ -14,6 +14,15 @@ type LocalTrack struct {
RecordingMBID string
}
// Group is the folder-level context candidates are ranked against:
// the tagging item's album name/artist plus its local tracks. The
// album fields are soft signals — empty values never penalize.
type Group struct {
AlbumName string
AlbumArtist string
Tracks []LocalTrack
}
// CandidateSource distinguishes candidates served from the local
// release_groups cache (zero network cost) from those fetched live.
type CandidateSource string
@@ -42,22 +51,26 @@ type Candidate struct {
OriginalDate string // "YYYY" or "YYYY-MM-DD" — release group's first release
Country string
Status string // "Official", "Promotion", ...
PrimaryType string // release group's primary type: "Album", "Single", ...
TrackCount int
Tracks []CandidateTrack
Alignments []TrackAlignment
Score float64 // 0..1, higher is better
Breakdown ScoreBreakdown
Source CandidateSource
Provenance string // cascade step that produced this ("strict", "fuzzy-title", "paste", "local")
Provenance string // cascade step that produced this ("strict", "fuzzy-title", "id", "paste", "local")
}
// ScoreBreakdown exposes the four inputs that go into Candidate.Score
// so the review UI can explain the ranking to the user.
// ScoreBreakdown exposes the inputs that go into Candidate.Score so
// the review UI can explain the ranking to the user.
type ScoreBreakdown struct {
TitleAvg float64 // average per-track title similarity (0..1)
LengthAvg float64 // average per-track length similarity (0..1)
ArtistFit float64 // album-artist vs candidate artist-credit similarity (0..1)
AlbumFit float64 // folder album name vs candidate release title similarity (0..1)
TrackCountFit float64 // 1.0 when local and candidate track counts match
ReleaseMeta float64 // year + official + country, averaged
ReleaseMeta float64 // official + country + release-group type, averaged
Evidence float64 // confidence scale from corroborating track count (0..1)
}
// CandidateTrack is one track inside a candidate release.
@@ -97,14 +110,19 @@ type TrackAlignment struct {
CandidateLength int64
TitleScore float64 // 0..1 from normalized-title edit distance
LengthScore float64 // 0..1 from lengthScore; 0.5 (neutral) if either side unknown
LengthDeltaMs int64 // abs(local - candidate); 0 if either side missing
TrackNumberOK bool // local track number matches candidate position
IDMatch bool // local recording MBID equals candidate track MBID
Status AlignmentStatus
}
// GroupScore is the scorer's full output for one tagging group.
type GroupScore struct {
GroupKey string
LocalTracks []LocalTrack
Candidates []Candidate // sorted by Score, descending
GroupKey string
AlbumName string
AlbumArtist string
LocalTracks []LocalTrack
Candidates []Candidate // sorted by Score, descending
Recommendation Recommendation
}