Files
yellowjacket/backend/autotag/distance.go
T
yonluandClaude Sonnet 5 e190fd75b9
Build & publish Arch package / arch-package (push) Successful in 2m12s
Search index maintenance / maintain-index (push) Successful in 2h22m28s
feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Ships the fresh-start schema cleanup: rebuilt explore catalog index
pipeline (dump import, artifact fetch/build, incremental listen-count
refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/
slskd/yt-dlp providers, staging, reconciliation, wanted list), and the
supporting schema/query/store changes across backend and frontend.

Also includes two smaller follow-ups: bump the central index's
rebuild-after cadence from 90 to 180 days, and remove the Explore
"library only" online/offline toggle entirely (frontend-only, no
backend counterpart) rather than carry unused UI/state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
2026-08-06 17:12:01 -04:00

303 lines
8.1 KiB
Go

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.
func levenshtein(a, b string) int {
ra := []rune(a)
rb := []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
if len(ra) > len(rb) {
ra, rb = rb, ra
}
prev := make([]int, len(ra)+1)
for i := range prev {
prev[i] = i
}
for j := 1; j <= len(rb); j++ {
curr0 := prev[0]
prev[0] = j
for i := 1; i <= len(ra); i++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
newVal := min3(
prev[i]+1, // deletion
prev[i-1]+1, // insertion
curr0+cost, // substitution
)
curr0 = prev[i]
prev[i] = newVal
}
}
return prev[len(ra)]
}
func min3(a, b, c int) int {
if a < b {
if a < c {
return a
}
return c
}
if b < c {
return b
}
return c
}
// 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
}
// 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},
}
// sdEndWords are articles that user tags sometimes rotate to the
// end with a comma: "Beatles, The" ≡ "The Beatles".
var sdEndWords = []string{"the", "a", "an"}
// 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
}
longest := max(len(a), len(b))
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 exposes titleSimilarity for callers outside the
// package that compare music metadata strings and should get the same
// answer the tagger would. The download pipeline uses it to match
// candidate filenames against an expected tracklist — Soulseek and
// torrent results carry paths, not tags, so filename comparison is the
// only signal available before the bytes arrive.
func TitleSimilarity(a, b string) float64 {
return titleSimilarity(a, b)
}
// 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
// only — never written at runtime, so no mutex. Values stay small
// so a future tuning pass can nudge them without rescaling.
const (
weightTitle = 0.60
weightLength = 0.30
weightTrackNumber = 0.10
// 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. 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 = 5000
lengthFullyWrongPct float64 = 0.20
// A title below titleReject has too little signal for this
// alignment to count as matched.
titleReject = 0.60
)
// lengthScore returns 1.0 for deltas <= lengthExactMs, 0.0 for
// deltas >= lengthFullyWrongPct of the candidate length, linear
// in delta-as-percentage-of-candidate-length between. When
// either side is zero (unknown), returns 0.5 so length is treated
// as neutral.
func lengthScore(localMs, candidateMs int64) float64 {
if localMs <= 0 || candidateMs <= 0 {
return 0.5
}
delta := localMs - candidateMs
if delta < 0 {
delta = -delta
}
if delta <= lengthExactMs {
return 1.0
}
pct := float64(delta) / float64(candidateMs)
if pct >= lengthFullyWrongPct {
return 0.0
}
return 1.0 - pct/lengthFullyWrongPct
}
// 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 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),
)
}