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, 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.*$`) // 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 // "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, "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, } // 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 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, "/") } // 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 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 // letters+digits of the cleaned title words []string // non-version words 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 } // 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{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 } } 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 } 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 // ("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 } if len(a) < 2 { return 0 } set := map[string]bool{} for _, w := range b { set[w] = true } for _, w := range a { if !set[w] { 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) } // 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 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{}, byWord: map[string][]string{}, titles: map[string]titleInfo{}, albumTracks: map[string]int{}} for _, rel := range files { 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 } type scored struct { rel string score float64 } // approxMargin: candidates within this score of the best are considered // equally good titles, and release preference decides between them. const approxMargin = 0.05 // 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 ta.compilation != tb.compilation { return !ta.compilation, true } 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) } } 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 } 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 } 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 len(cands) == 0 { continue } 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 { 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") } 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)) }