Loosen approximate matching and link to the album version's folders
Matching: - Prefer the studio album over singles, EPs and compilations when a song exists on several releases, including when the master also has the single at the slave's exact folder. - Ignore release labels (Single Version, Remastered, Explicit, Deluxe) inside brackets or a trailing " - ..." part; "Clean" stays a version. - Treat live/acoustic/remix/... album folders as different recordings; the strict top-folder fallback now respects this too. - Add a last-resort library-wide search that only accepts candidates whose artist tag agrees with the slave file. Linking: - Cross-folder matches now mirror the master's path in the slave (e.g. a single moves into the album folder) and prune the emptied folder; a "keep in current folder" option preserves the old behaviour. Undo recreates and prunes folders accordingly. - Operations record the match kind and reason, so undo (and promotes that rename the master file) restore the correct pairing instead of leaving the file unmatched until the next scan. UI: rewrite the approximate matching description with Avenged Sevenfold examples and clear safeguards, and show where linking will place a file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
381b804742
commit
5f895cab6a
12 files changed
+717
-128
No files matched your search
+276
-69
@@ -11,9 +11,10 @@ import (
|
||||
)
|
||||
|
||||
// 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.
|
||||
// strict rules could not place. It tolerates disc subfolders, small title
|
||||
// differences, release labels such as "Single Version" or "Remastered", and
|
||||
// songs filed under a different release (a single vs. the album). It 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.*$`)
|
||||
@@ -21,9 +22,39 @@ var discFolder = regexp.MustCompile(`(?i)^(disc|disk|cd|dvd|side|vol|volume)[\s.
|
||||
// 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+)`)
|
||||
|
||||
// releaseNoise are labels describing a release rather than the recording.
|
||||
// They are only removed inside brackets or a trailing " - ..." part, so
|
||||
// "Nightmare (Single Version)" and "Nightmare - Remastered 2013" compare as
|
||||
// "Nightmare", while a song titled "Single Ladies" keeps its name. "Clean"
|
||||
// is deliberately absent: a clean edit is different audio.
|
||||
var releaseNoise = regexp.MustCompile(`(?i)\b(?:(?:single|album|lp|original)\s+version|` +
|
||||
`(?:\d{4}\s+)?remaster(?:ed)?(?:\s+\d{4})?(?:\s+version)?|explicit(?:\s+version)?|` +
|
||||
`deluxe(?:\s+edition)?|bonus\s+track|single)\b`)
|
||||
|
||||
var (
|
||||
bracketPart = regexp.MustCompile(`[(\[]([^)\]]*)[)\]]`)
|
||||
dashPart = regexp.MustCompile(`\s+-\s+([^-]+)$`)
|
||||
)
|
||||
|
||||
// stripRelease removes release labels from bracketed and trailing parts.
|
||||
func stripRelease(stem string) string {
|
||||
clean := func(re *regexp.Regexp) func(string) string {
|
||||
return func(part string) string {
|
||||
inner := re.FindStringSubmatch(part)[1]
|
||||
rest := releaseNoise.ReplaceAllString(inner, " ")
|
||||
if len(words(rest)) == 0 {
|
||||
return " "
|
||||
}
|
||||
return strings.Replace(part, inner, rest, 1)
|
||||
}
|
||||
}
|
||||
stem = bracketPart.ReplaceAllStringFunc(stem, clean(bracketPart))
|
||||
return dashPart.ReplaceAllStringFunc(stem, clean(dashPart))
|
||||
}
|
||||
|
||||
// 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".
|
||||
// "Nightmare (Live)" never matches "Nightmare".
|
||||
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,
|
||||
@@ -32,6 +63,19 @@ var versionWords = map[string]bool{
|
||||
"sped": true, "slowed": true, "nightcore": true, "reverb": true, "mashup": true, "bootleg": true,
|
||||
}
|
||||
|
||||
// folderVersions are album-folder words that mean every track in it is a
|
||||
// different recording ("Live in the LBC", "Acoustic Sessions", "Remixes").
|
||||
var folderVersions = map[string]string{
|
||||
"live": "live", "acoustic": "acoustic", "unplugged": "unplugged", "remix": "remix", "remixes": "remix",
|
||||
"instrumental": "instrumental", "instrumentals": "instrumental", "karaoke": "karaoke", "demo": "demo",
|
||||
"demos": "demo", "orchestral": "orchestral", "acapella": "acapella",
|
||||
}
|
||||
|
||||
var (
|
||||
singleFolder = regexp.MustCompile(`(?i)\b(singles?|ep)\b`)
|
||||
compilationFolder = regexp.MustCompile(`(?i)\b(greatest hits|best of|the best|essentials|anthology|collection|compilation|hits)\b`)
|
||||
)
|
||||
|
||||
// albumKey is the lower-cased directory with disc folders removed.
|
||||
func albumKey(rel string) string {
|
||||
var parts []string
|
||||
@@ -43,40 +87,67 @@ func albumKey(rel string) string {
|
||||
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
|
||||
// releaseFolders returns the folder names below the top-level folder,
|
||||
// without disc folders: normally just the album folder.
|
||||
func releaseFolders(rel string) []string {
|
||||
parts := strings.Split(filepath.Dir(rel), string(filepath.Separator))
|
||||
var out []string
|
||||
for i, p := range parts {
|
||||
if i == 0 || p == "." || discFolder.MatchString(p) {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return false
|
||||
return out
|
||||
}
|
||||
|
||||
func words(s string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) })
|
||||
}
|
||||
|
||||
type titleInfo struct {
|
||||
norm string // normTitle: letters+digits only
|
||||
norm string // letters+digits of the cleaned title
|
||||
words []string // non-version words
|
||||
versions map[string]bool // version words present
|
||||
versions map[string]bool // version words from the title and album folder
|
||||
track int // 0 = unknown
|
||||
|
||||
single bool // filed under a single/EP folder
|
||||
compilation bool // filed under a compilation folder
|
||||
}
|
||||
|
||||
func parseTitle(name string) titleInfo {
|
||||
// parseTitle analyses a relative path (or bare file name).
|
||||
func parseTitle(rel string) titleInfo {
|
||||
name := filepath.Base(rel)
|
||||
stem := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
ti := titleInfo{norm: normTitle(name), versions: map[string]bool{}}
|
||||
ti := titleInfo{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 s := stripRelease(stem); len(words(s)) > 0 {
|
||||
stem = s
|
||||
}
|
||||
var norm strings.Builder
|
||||
for _, w := range words(stem) {
|
||||
norm.WriteString(w)
|
||||
if versionWords[w] {
|
||||
ti.versions[w] = true
|
||||
} else {
|
||||
ti.words = append(ti.words, w)
|
||||
}
|
||||
}
|
||||
ti.norm = norm.String()
|
||||
for _, f := range releaseFolders(rel) {
|
||||
for _, w := range words(f) {
|
||||
if v, ok := folderVersions[w]; ok {
|
||||
ti.versions[v] = true
|
||||
}
|
||||
}
|
||||
ti.single = ti.single || singleFolder.MatchString(f)
|
||||
ti.compilation = ti.compilation || compilationFolder.MatchString(f)
|
||||
}
|
||||
return ti
|
||||
}
|
||||
|
||||
@@ -118,8 +189,8 @@ func levenshteinRatio(a, b string) float64 {
|
||||
}
|
||||
|
||||
// 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".
|
||||
// ("So Far Away" vs "So Far Away feat. Someone"). It needs at least two
|
||||
// shared words so a one-word title cannot swallow a longer one.
|
||||
func wordContainment(a, b []string) float64 {
|
||||
if len(a) > len(b) {
|
||||
a, b = b, a
|
||||
@@ -131,15 +202,11 @@ func wordContainment(a, b []string) float64 {
|
||||
for _, w := range b {
|
||||
set[w] = true
|
||||
}
|
||||
hit := 0
|
||||
for _, w := range a {
|
||||
if set[w] {
|
||||
hit++
|
||||
if !set[w] {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
if hit != len(a) {
|
||||
return 0
|
||||
}
|
||||
return 0.9 // a full subset is strong but never a perfect match
|
||||
}
|
||||
|
||||
@@ -160,22 +227,40 @@ func titleScore(a, b titleInfo) float64 {
|
||||
return min(s, 1)
|
||||
}
|
||||
|
||||
// stopWords are too common to narrow down library-wide candidates.
|
||||
var stopWords = map[string]bool{"the": true, "and": true, "for": true, "you": true, "feat": true, "with": true}
|
||||
|
||||
// maxPosting skips words that appear in too many titles to be useful.
|
||||
const maxPosting = 2000
|
||||
|
||||
type approxIndex struct {
|
||||
byAlbum map[string][]string // albumKey -> master rels
|
||||
byTop map[string][]string // lower(top folder) -> master rels
|
||||
titles map[string]titleInfo
|
||||
byAlbum map[string][]string // albumKey -> master rels
|
||||
byTop map[string][]string // lower(top folder) -> master rels
|
||||
byWord map[string][]string // title word -> master rels (library-wide search)
|
||||
titles map[string]titleInfo
|
||||
albumTracks map[string]int // albumKey -> number of tracks
|
||||
}
|
||||
|
||||
func buildApproxIndex(files []string) *approxIndex {
|
||||
ax := &approxIndex{byAlbum: map[string][]string{}, byTop: map[string][]string{}, titles: map[string]titleInfo{}}
|
||||
ax := &approxIndex{byAlbum: map[string][]string{}, byTop: map[string][]string{}, byWord: map[string][]string{},
|
||||
titles: map[string]titleInfo{}, albumTracks: map[string]int{}}
|
||||
for _, rel := range files {
|
||||
ax.titles[rel] = parseTitle(filepath.Base(rel))
|
||||
ti := parseTitle(rel)
|
||||
ax.titles[rel] = ti
|
||||
k := albumKey(rel)
|
||||
ax.byAlbum[k] = append(ax.byAlbum[k], rel)
|
||||
ax.albumTracks[k]++
|
||||
if top := firstComponent(rel); top != "" {
|
||||
t := strings.ToLower(top)
|
||||
ax.byTop[t] = append(ax.byTop[t], rel)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, w := range ti.words {
|
||||
if len(w) >= 3 && !stopWords[w] && !seen[w] {
|
||||
seen[w] = true
|
||||
ax.byWord[w] = append(ax.byWord[w], rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ax
|
||||
}
|
||||
@@ -185,61 +270,133 @@ type scored struct {
|
||||
score float64
|
||||
}
|
||||
|
||||
// approxMargin is how far the best candidate must beat the runner-up.
|
||||
// approxMargin: candidates within this score of the best are considered
|
||||
// equally good titles, and release preference decides between them.
|
||||
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)]},
|
||||
// VerifyFunc confirms a library-wide candidate (by comparing artist tags).
|
||||
type VerifyFunc func(relSlave, relMaster string) bool
|
||||
|
||||
// better reports whether candidate a is a preferable release to b: an album
|
||||
// over a single/EP, a regular album over a compilation, then the fuller
|
||||
// album, then the better format.
|
||||
func (ax *approxIndex) better(a, b string) (bool, bool) {
|
||||
ta, tb := ax.titles[a], ax.titles[b]
|
||||
if ta.single != tb.single {
|
||||
return !ta.single, true
|
||||
}
|
||||
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)]})
|
||||
if ta.compilation != tb.compilation {
|
||||
return !ta.compilation, true
|
||||
}
|
||||
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})
|
||||
}
|
||||
na, nb := ax.albumTracks[albumKey(a)], ax.albumTracks[albumKey(b)]
|
||||
if na != nb {
|
||||
return na > nb, true
|
||||
}
|
||||
if ra, rb := extRank(a), extRank(b); ra != rb {
|
||||
return ra < rb, false // format alone does not tell two releases apart
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func sameSong(a, b string) bool {
|
||||
return filepath.Dir(a) == filepath.Dir(b) &&
|
||||
strings.TrimSuffix(filepath.Base(a), filepath.Ext(a)) == strings.TrimSuffix(filepath.Base(b), filepath.Ext(b))
|
||||
}
|
||||
|
||||
// pick chooses the best candidate. It returns "" when two different songs
|
||||
// are equally good, because guessing could link the wrong file.
|
||||
func (ax *approxIndex) pick(cands []scored) (scored, bool) {
|
||||
sort.Slice(cands, func(i, j int) bool { return cands[i].score > cands[j].score })
|
||||
var tier []scored
|
||||
for _, c := range cands {
|
||||
if cands[0].score-c.score <= approxMargin {
|
||||
tier = append(tier, c)
|
||||
}
|
||||
if len(best) == 0 {
|
||||
}
|
||||
best := tier[0]
|
||||
for _, c := range tier[1:] {
|
||||
if b, _ := ax.better(c.rel, best.rel); b {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
for _, c := range tier {
|
||||
if c.rel == best.rel || sameSong(c.rel, best.rel) {
|
||||
continue
|
||||
}
|
||||
sort.Slice(best, func(i, j int) bool {
|
||||
if best[i].score != best[j].score {
|
||||
return best[i].score > best[j].score
|
||||
if _, decisive := ax.better(best.rel, c.rel); !decisive {
|
||||
return scored{}, false // two different songs, no reason to prefer either
|
||||
}
|
||||
}
|
||||
return best, true
|
||||
}
|
||||
|
||||
// find returns the best approximate master match for a slave file and a
|
||||
// human-readable reason. Scopes are tried in order: the same album (disc
|
||||
// folders ignored), the same top-level folder, then the whole library, where
|
||||
// verify must confirm the match.
|
||||
func (ax *approxIndex) find(relSlave string, threshold float64, verify VerifyFunc) (string, string) {
|
||||
st := parseTitle(relSlave)
|
||||
type scope struct {
|
||||
name string
|
||||
cands []string
|
||||
verify bool
|
||||
}
|
||||
scopes := []scope{{"same album", ax.byAlbum[albumKey(relSlave)], false}}
|
||||
if top := firstComponent(relSlave); top != "" {
|
||||
scopes = append(scopes, scope{"same top-level folder, different album folder", ax.byTop[strings.ToLower(top)], false})
|
||||
}
|
||||
if verify != nil {
|
||||
scopes = append(scopes, scope{"elsewhere in the master library, artist tag matches", ax.wordCandidates(st), true})
|
||||
}
|
||||
tried := map[string]bool{}
|
||||
for _, sc := range scopes {
|
||||
var cands []scored
|
||||
for _, rel := range sc.cands {
|
||||
if tried[rel] {
|
||||
continue
|
||||
}
|
||||
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
|
||||
tried[rel] = true
|
||||
if s := titleScore(st, ax.titles[rel]); s >= threshold {
|
||||
// Library-wide, the artist tag must agree before a title counts.
|
||||
if !sc.verify || verify(relSlave, rel) {
|
||||
cands = append(cands, scored{rel, s})
|
||||
}
|
||||
}
|
||||
}
|
||||
if ambiguous {
|
||||
return "", ""
|
||||
if len(cands) == 0 {
|
||||
continue
|
||||
}
|
||||
return top.rel, approxReason(relSlave, top.rel, st, ax.titles[top.rel], top.score, sc.name)
|
||||
best, ok := ax.pick(cands)
|
||||
if !ok {
|
||||
return "", "" // ambiguous: never fall through to a wider scope
|
||||
}
|
||||
return best.rel, approxReason(relSlave, best.rel, st, ax.titles[best.rel], best.score, sc.name)
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// wordCandidates lists master files sharing a meaningful title word.
|
||||
func (ax *approxIndex) wordCandidates(st titleInfo) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, w := range st.words {
|
||||
if len(w) < 3 || stopWords[w] {
|
||||
continue
|
||||
}
|
||||
post := ax.byWord[w]
|
||||
if len(post) > maxPosting {
|
||||
continue
|
||||
}
|
||||
for _, rel := range post {
|
||||
if !seen[rel] {
|
||||
seen[rel] = true
|
||||
out = append(out, rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -252,6 +409,56 @@ func approxReason(relSlave, relMaster string, st, mt titleInfo, score float64, s
|
||||
if filepath.Dir(relSlave) != filepath.Dir(relMaster) && albumKey(relSlave) == albumKey(relMaster) {
|
||||
parts = append(parts, "disc folder ignored")
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(scope, "album version preferred"):
|
||||
// the scope already says it
|
||||
case st.single && !mt.single:
|
||||
parts = append(parts, "single → album version")
|
||||
case st.compilation && !mt.compilation:
|
||||
parts = append(parts, "compilation → album version")
|
||||
}
|
||||
parts = append(parts, scope)
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// albumAlternative looks for the studio-album version of a match that landed
|
||||
// on a single, EP or compilation release in the master library.
|
||||
func (ax *approxIndex) albumAlternative(relSlave, relMaster string, threshold float64) (string, string) {
|
||||
mt, ok := ax.titles[relMaster]
|
||||
if !ok || !(mt.single || mt.compilation) {
|
||||
return "", ""
|
||||
}
|
||||
st := parseTitle(relSlave)
|
||||
top := firstComponent(relMaster)
|
||||
if top == "" {
|
||||
return "", ""
|
||||
}
|
||||
var cands []scored
|
||||
for _, rel := range ax.byTop[strings.ToLower(top)] {
|
||||
t := ax.titles[rel]
|
||||
if t.single || t.compilation {
|
||||
continue
|
||||
}
|
||||
// Must be the same recording as the release that matched.
|
||||
if !sameVersions(mt.versions, t.versions) {
|
||||
continue
|
||||
}
|
||||
if s := titleScore(st, t); s >= threshold {
|
||||
cands = append(cands, scored{rel, s})
|
||||
}
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
best, ok := ax.pick(cands)
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
kind := "single"
|
||||
if mt.compilation && !mt.single {
|
||||
kind = "compilation"
|
||||
}
|
||||
folder := filepath.Base(filepath.Dir(relMaster))
|
||||
return best.rel, approxReason(relSlave, best.rel, st, ax.titles[best.rel], best.score,
|
||||
fmt.Sprintf("album version preferred over the %s in %q", kind, folder))
|
||||
}
|
||||
Reference in new issue
Block a user