Files
yellowjacket/backend/autotag/align.go
T
yonluandClaude Opus 4.8 65048401e8 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>
2026-07-24 12:14:20 -04:00

206 lines
5.7 KiB
Go

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
// stays "unmatched" and the candidate slot stays "missing". Set
// well below titleReject so a local file with the wrong track
// number but mostly-correct title still pairs (and surfaces the
// number mismatch as a diff), while a totally unrelated file that
// happens to share a track number stays in its own group.
const alignTitleFloor = 0.30
// 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:
//
// 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
// unrelated file from being force-paired into a slot.
//
// Returns one TrackAlignment per local track (status = matched,
// mismatched, or unmatched) plus additional missing alignments
// for candidate tracks with no local file.
func AlignTracks(locals []LocalTrack, cands []CandidateTrack) []TrackAlignment {
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
}
}
// 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 {
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.
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
}
})
for _, p := range pairs {
if localMatched == len(locals) {
break
}
if localUsed[p.li] || candUsed[p.ci] {
continue
}
// Below the floor: this is the best pair available for both
// of these slots, but the title similarity is so low that
// pairing them would just be noise. Leave both unclaimed —
// they'll fall through to the unmatched/missing fixups below.
if p.title < alignTitleFloor {
continue
}
localUsed[p.li] = true
candUsed[p.ci] = true
localMatched++
alignments[p.li] = mkAlignment(p.li, locals[p.li], cands[p.ci], p, false)
}
// Local tracks left unclaimed → folder has them, candidate
// doesn't (or pairing was rejected by the floor).
for li, used := range localUsed {
if used {
continue
}
l := locals[li]
alignments[li] = TrackAlignment{
LocalIndex: li,
LocalTitle: l.Title,
LocalLengthMillis: l.LengthMillis,
Status: AlignmentUnmatched,
}
}
// Candidate tracks left unclaimed → candidate has them, folder
// doesn't.
for ci, used := range candUsed {
if used {
continue
}
c := cands[ci]
alignments = append(alignments, TrackAlignment{
LocalIndex: -1,
CandidatePosition: c.Position,
CandidateDiscNumber: c.DiscNumber,
CandidateTitle: c.Title,
CandidateMBID: c.MBID,
CandidateLength: c.LengthMillis,
Status: AlignmentMissing,
})
}
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,
}
}