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
303 lines
8.1 KiB
Go
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),
|
|
)
|
|
}
|