Files
NaviWatcher/internal/normalize/normalize.go

109 lines
3.7 KiB
Go

// Package normalize provides string normalization helpers used for
// fuzzy matching across NaviWatcher (artist names, album titles, etc.).
//
// It is the single shared home for normalization logic; previously this
// lived inside the musicbrainz package but is needed by the scanner engine
// and any other consumer that compares strings.
package normalize
import (
"regexp"
"strings"
"unicode"
)
// Precompiled regexes — compiled once at package init.
var (
bracketRe = regexp.MustCompile(`\[[^\]]*\]`)
parenRe = regexp.MustCompile(`\([^)]*\)`)
yearRe = regexp.MustCompile(`\b[0-9]{4}\b`)
spaceRe = regexp.MustCompile(`\s+`)
// bareYearRe matches a title that is *only* a single year (with optional
// surrounding whitespace), e.g. "1989" or "2112". Used to decide whether a
// title that collapses entirely to a year should keep it (so it matches
// itself) or be treated as a distinct reissue that must collapse to empty.
bareYearRe = regexp.MustCompile(`^\s*[0-9]{4}\s*$`)
)
// NormalizeString normalizes a string for fuzzy matching by:
// - Converting to lowercase
// - Removing special characters (keeping only letters, digits, and spaces)
// - Removing years (4-digit numbers that look like years)
// - Removing bracketed keywords (e.g., [Deluxe], [Remastered])
// - Collapsing multiple spaces into one
// - Trimming leading/trailing whitespace
func NormalizeString(s string) string {
// Capture the original input; used after stripping to tell a bare year
// title apart from a title that merely collapses to a year.
original := s
// Convert to lowercase
s = strings.ToLower(s)
// Remove bracketed content first (e.g., [Deluxe Edition], [Remastered 2020])
s = bracketRe.ReplaceAllString(s, "")
// Remove parenthesized content (e.g., (Deluxe), (Remastered))
s = parenRe.ReplaceAllString(s, "")
// Remove years (any 4-digit number). If stripping the year
// empties the entire string, decide what to keep:
// - A bare year title (e.g. "1989", "2112") has no other words, so keep
// the year so it can still match itself (the user owns that album).
// - A title that had OTHER words alongside the year (e.g. "1989 (Deluxe)")
// collapses to empty on purpose: it is a distinct release group that
// must NOT be considered already-present just because the user owns the
// standard "1989". Collapsing to empty makes it score 0.0 against a
// plain "1989", correctly reporting the reissue as missing. The check
// is against the original (brackets intact) so a title like "1989
// [2020]" is correctly NOT treated as a bare year.
stripped := yearRe.ReplaceAllString(s, "")
if strings.TrimSpace(stripped) == "" {
if bareYearRe.MatchString(strings.TrimSpace(original)) {
s = strings.TrimSpace(s)
} else {
s = ""
}
} else {
s = stripped
}
// Replace common separators with spaces before stripping other special chars
s = strings.ReplaceAll(s, "-", " ")
s = strings.ReplaceAll(s, "_", " ")
// Keep only letters, digits, and spaces
var b strings.Builder
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsSpace(r) {
b.WriteRune(r)
}
}
s = b.String()
// Collapse multiple spaces
s = spaceRe.ReplaceAllString(s, " ")
// Trim
s = strings.TrimSpace(s)
return s
}
// NormalizeArtistName normalizes an artist name for comparison.
// It applies NormalizeString and additionally handles common prefixes.
func NormalizeArtistName(name string) string {
name = NormalizeString(name)
// Remove common leading articles for better matching
prefixes := []string{"the ", "a ", "an "}
for _, prefix := range prefixes {
if strings.HasPrefix(name, prefix) {
name = strings.TrimPrefix(name, prefix)
break
}
}
return strings.TrimSpace(name)
}