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

+31 -14
View File
@@ -104,11 +104,19 @@ type masterIndex struct {
lower map[string]string // lower(rel) -> rel
byDir map[string][]string // lower(dir) -> rels
byTitle map[string][]string // lower(top folder)|normTitle -> rels
approx *approxIndex // nil unless the pair enables approximate matching
threshold float64
}
func buildIndex(files map[string]fsops.Info) *masterIndex {
func buildIndex(files map[string]fsops.Info, p store.Pair) *masterIndex {
ix := &masterIndex{files: files, lower: map[string]string{}, byDir: map[string][]string{}, byTitle: map[string][]string{}}
for _, rel := range sortedKeys(files) {
rels := sortedKeys(files)
if p.ApproxMatching {
ix.approx = buildApproxIndex(rels)
ix.threshold = float64(p.ApproxThreshold) / 100
}
for _, rel := range rels {
ix.lower[strings.ToLower(rel)] = rel
d := strings.ToLower(filepath.Dir(rel))
ix.byDir[d] = append(ix.byDir[d], rel)
@@ -130,13 +138,14 @@ func extRank(rel string) int {
return 2
}
// find locates the master counterpart of a slave file.
func (ix *masterIndex) find(relSlave string) (string, string) {
// find locates the master counterpart of a slave file. The reason is only
// set for approximate matches.
func (ix *masterIndex) find(relSlave string) (rel, kind, reason string) {
if _, ok := ix.files[relSlave]; ok {
return relSlave, store.KindPath
return relSlave, store.KindPath, ""
}
if r, ok := ix.lower[strings.ToLower(relSlave)]; ok {
return r, store.KindPathCase
return r, store.KindPathCase, ""
}
dir := strings.ToLower(filepath.Dir(relSlave))
base := filepath.Base(relSlave)
@@ -158,17 +167,25 @@ func (ix *masterIndex) find(relSlave string) (string, string) {
best = r
}
}
return best, store.KindExtension
return best, store.KindExtension, ""
}
if len(fuzzyHits) == 1 {
return fuzzyHits[0], store.KindFuzzy
return fuzzyHits[0], store.KindFuzzy, ""
}
// Approximate matching runs before the top-folder fallback because it
// searches the album (disc folders ignored) first, which resolves titles
// that appear on several albums.
if ix.approx != nil {
if r, why := ix.approx.find(relSlave, ix.threshold); r != "" {
return r, store.KindApprox, why
}
}
if top := firstComponent(relSlave); top != "" && norm != "" {
if hits := ix.byTitle[strings.ToLower(top)+"|"+norm]; len(hits) == 1 {
return hits[0], store.KindElsewhere
return hits[0], store.KindElsewhere, ""
}
}
return "", store.KindNone
return "", store.KindNone, ""
}
// classify fills in status, probes and verdict for a match, reusing cached
@@ -269,7 +286,7 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string)
_ = e.st.FinishScan(ctx, pairID, started.Unix(), err.Error(), warns)
return err
}
ix := buildIndex(masterFiles)
ix := buildIndex(masterFiles, l.pair)
prev, err := e.st.AllMatches(ctx, pairID)
if err != nil {
return err
@@ -284,7 +301,7 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string)
}
si := slaveFiles[rel]
m := store.Match{PairID: pairID, RelSlave: rel}
m.RelMaster, m.Kind = ix.find(rel)
m.RelMaster, m.Kind, m.Reason = ix.find(rel)
var pm *store.Match
if p, ok := prev[rel]; ok {
pm = &p
@@ -304,7 +321,7 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string)
// Layout warnings based on what was actually found.
if ss.files > 0 {
if n := kinds[store.KindElsewhere] + kinds[store.KindNone]; n*4 > ss.files {
if n := kinds[store.KindElsewhere] + kinds[store.KindApprox] + kinds[store.KindNone]; n*4 > ss.files {
warns = append(warns, fmt.Sprintf("%d of %d slave files have no counterpart at the same path in the master library. "+
"Check that both libraries use the same folder layout.", n, ss.files))
}
@@ -426,7 +443,7 @@ func (e *Engine) refresh(ctx context.Context, l *libs, relSlave string) {
if pm != nil && pm.RelMaster != "" {
if in, err := fsops.RegularFile(filepath.Join(l.master.Path, pm.RelMaster)); err == nil {
mi = in
m.RelMaster, m.Kind = pm.RelMaster, pm.Kind
m.RelMaster, m.Kind, m.Reason = pm.RelMaster, pm.Kind, pm.Reason
}
}
}