154 lines
4.4 KiB
Go
154 lines
4.4 KiB
Go
package autotag
|
|
|
|
// 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
|
|
|
|
// 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.
|
|
//
|
|
// 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. The caller sums Score
|
|
// fields to get a release-level score.
|
|
func AlignTracks(locals []LocalTrack, cands []CandidateTrack) []TrackAlignment {
|
|
type pair struct {
|
|
li int
|
|
ci int
|
|
score float64
|
|
title float64
|
|
}
|
|
|
|
// Score every (local, cand) combination.
|
|
pairs := make([]pair, 0, len(locals)*len(cands))
|
|
|
|
for li, local := range locals {
|
|
for ci, cand := range cands {
|
|
pairs = append(pairs, pair{
|
|
li: li,
|
|
ci: ci,
|
|
score: trackDistance(local, cand),
|
|
title: titleSimilarity(local.Title, cand.Title),
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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]
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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++
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|