Add opt-in approximate matching and upgrade recommendations

A per-pair "Approximate matching" setting adds a second pass for slave
files the strict rules could not place. It searches the same album with
disc folders (Disc 02, CD2, ...) ignored, then the top-level folder, and
scores titles by edit distance and word containment against a configurable
threshold (default 85%), with a small track-number bonus.

Guards against false matches: version words (live, remix, reprise,
instrumental, ...) must agree, near-tied candidates are left unmatched,
and approximate matches always go to manual review, never auto-link.
Each match records a human-readable reason.

Review now flags matches where the master copy is better as upgrades,
with an Upgrades filter and count and an "Upgrade to master copy" action.
The redundant "master is lossless" warning note is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-22 16:58:53 -07:00
1 parent 5b088bb4a6
commit 381b804742
12 files changed
+552 -51

No files matched your search

+257
View File
@@ -0,0 +1,257 @@
package engine
import (
"fmt"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"unicode"
)
// Approximate matching is an opt-in, per-pair second pass for slave files the
// strict rules could not place. It tolerates disc subfolders ("Disc 02/",
// "CD2/") and slightly different titles, but never auto-links: every result
// goes to manual review.
// discFolder matches disc/side subfolders such as "Disc 02", "CD2", "Disk 1 - Bonus".
var discFolder = regexp.MustCompile(`(?i)^(disc|disk|cd|dvd|side|vol|volume)[\s._-]*\d+\b.*$`)
// trackNum extracts the leading track number ("07 Memory", "2-07 Memory").
var trackNum = regexp.MustCompile(`^\s*(?:\d{1,2}[-.]\s*)?(\d{1,3})(?:\s*[-._)]\s*|\s+)`)
// versionWords mark a different recording or arrangement. Two titles only
// match approximately if they carry the same set of these words, so
// "Memory (Live)" never matches "Memory".
var versionWords = map[string]bool{
"live": true, "remix": true, "mix": true, "acoustic": true, "demo": true, "instrumental": true,
"edit": true, "version": true, "reprise": true, "karaoke": true, "cover": true, "extended": true,
"radio": true, "unplugged": true, "orchestral": true, "piano": true, "clean": true, "acapella": true,
"cappella": true, "interlude": true, "intro": true, "outro": true, "skit": true, "vip": true, "dub": true,
"sped": true, "slowed": true, "nightcore": true, "reverb": true, "mashup": true, "bootleg": true,
}
// albumKey is the lower-cased directory with disc folders removed.
func albumKey(rel string) string {
var parts []string
for _, p := range strings.Split(filepath.Dir(rel), string(filepath.Separator)) {
if p != "." && !discFolder.MatchString(p) {
parts = append(parts, strings.ToLower(p))
}
}
return strings.Join(parts, "/")
}
func hasDiscFolder(rel string) bool {
for _, p := range strings.Split(filepath.Dir(rel), string(filepath.Separator)) {
if discFolder.MatchString(p) {
return true
}
}
return false
}
type titleInfo struct {
norm string // normTitle: letters+digits only
words []string // non-version words
versions map[string]bool // version words present
track int // 0 = unknown
}
func parseTitle(name string) titleInfo {
stem := strings.TrimSuffix(name, filepath.Ext(name))
ti := titleInfo{norm: normTitle(name), versions: map[string]bool{}}
if m := trackNum.FindStringSubmatch(stem); m != nil {
ti.track, _ = strconv.Atoi(m[1])
if s := trackNum.ReplaceAllString(stem, ""); strings.TrimSpace(s) != "" {
stem = s
}
}
for _, w := range strings.FieldsFunc(strings.ToLower(stem), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
}) {
if versionWords[w] {
ti.versions[w] = true
} else {
ti.words = append(ti.words, w)
}
}
return ti
}
func sameVersions(a, b map[string]bool) bool {
if len(a) != len(b) {
return false
}
for k := range a {
if !b[k] {
return false
}
}
return true
}
// levenshteinRatio is 1 - editDistance/maxLen over runes.
func levenshteinRatio(a, b string) float64 {
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 || len(rb) == 0 {
return 0
}
prev := make([]int, len(rb)+1)
cur := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
cur[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
}
prev, cur = cur, prev
}
return 1 - float64(prev[len(rb)])/float64(max(len(ra), len(rb)))
}
// wordContainment scores titles where one adds words to the other
// ("Memory" vs "Memory from Cats"). It needs at least two shared words so a
// one-word title like "Love" cannot swallow "Love Story".
func wordContainment(a, b []string) float64 {
if len(a) > len(b) {
a, b = b, a
}
if len(a) < 2 {
return 0
}
set := map[string]bool{}
for _, w := range b {
set[w] = true
}
hit := 0
for _, w := range a {
if set[w] {
hit++
}
}
if hit != len(a) {
return 0
}
return 0.9 // a full subset is strong but never a perfect match
}
// titleScore is the similarity of two titles in [0,1], or -1 if they are
// different versions of a song.
func titleScore(a, b titleInfo) float64 {
if a.norm == "" || b.norm == "" || !sameVersions(a.versions, b.versions) {
return -1
}
s := max(levenshteinRatio(a.norm, b.norm), wordContainment(a.words, b.words))
if a.track > 0 && b.track > 0 {
if a.track == b.track {
s += 0.05
} else {
s -= 0.05
}
}
return min(s, 1)
}
type approxIndex struct {
byAlbum map[string][]string // albumKey -> master rels
byTop map[string][]string // lower(top folder) -> master rels
titles map[string]titleInfo
}
func buildApproxIndex(files []string) *approxIndex {
ax := &approxIndex{byAlbum: map[string][]string{}, byTop: map[string][]string{}, titles: map[string]titleInfo{}}
for _, rel := range files {
ax.titles[rel] = parseTitle(filepath.Base(rel))
k := albumKey(rel)
ax.byAlbum[k] = append(ax.byAlbum[k], rel)
if top := firstComponent(rel); top != "" {
t := strings.ToLower(top)
ax.byTop[t] = append(ax.byTop[t], rel)
}
}
return ax
}
type scored struct {
rel string
score float64
}
// approxMargin is how far the best candidate must beat the runner-up.
const approxMargin = 0.05
// find returns the best approximate master match for a slave file, plus a
// human-readable reason. The album (disc folders ignored) is searched first,
// then the whole top-level folder.
func (ax *approxIndex) find(relSlave string, threshold float64) (string, string) {
st := parseTitle(filepath.Base(relSlave))
scopes := []struct {
name string
cands []string
}{
{"same album", ax.byAlbum[albumKey(relSlave)]},
}
if top := firstComponent(relSlave); top != "" {
scopes = append(scopes, struct {
name string
cands []string
}{"same top-level folder, different album folder", ax.byTop[strings.ToLower(top)]})
}
for _, sc := range scopes {
var best []scored
for _, rel := range sc.cands {
if s := titleScore(st, ax.titles[rel]); s >= threshold {
best = append(best, scored{rel, s})
}
}
if len(best) == 0 {
continue
}
sort.Slice(best, func(i, j int) bool {
if best[i].score != best[j].score {
return best[i].score > best[j].score
}
return extRank(best[i].rel) < extRank(best[j].rel)
})
// Several formats of the same title in one folder are one song.
top := best[0]
ambiguous := false
for _, o := range best[1:] {
sameSong := filepath.Dir(o.rel) == filepath.Dir(top.rel) &&
strings.TrimSuffix(filepath.Base(o.rel), filepath.Ext(o.rel)) == strings.TrimSuffix(filepath.Base(top.rel), filepath.Ext(top.rel))
if !sameSong && top.score-o.score < approxMargin {
ambiguous = true
break
}
}
if ambiguous {
return "", ""
}
return top.rel, approxReason(relSlave, top.rel, st, ax.titles[top.rel], top.score, sc.name)
}
return "", ""
}
func approxReason(relSlave, relMaster string, st, mt titleInfo, score float64, scope string) string {
parts := []string{fmt.Sprintf("title %d%% similar", int(score*100+0.5))}
if st.track > 0 && mt.track > 0 {
if st.track == mt.track {
parts = append(parts, fmt.Sprintf("track %02d matches", st.track))
} else {
parts = append(parts, fmt.Sprintf("track numbers differ (%02d vs %02d)", st.track, mt.track))
}
}
if filepath.Dir(relSlave) != filepath.Dir(relMaster) && albumKey(relSlave) == albumKey(relMaster) {
parts = append(parts, "disc folder ignored")
}
parts = append(parts, scope)
return strings.Join(parts, " · ")
}