// Package scanner implements the core fuzzy-diff engine of NaviWatcher. // // It compares a user's local albums (from Navidrome) against an artist's // external discography (from MusicBrainz) and reports the releases that are // present externally but have no sufficiently similar local album. package scanner import ( "github.com/lithammer/fuzzysearch/fuzzy" "naviwatcher/internal/normalize" ) // DefaultThreshold is the fallback similarity threshold used when a caller // passes threshold == 0. It matches config.Scanner.FuzzyThreshold default. const DefaultThreshold = 0.85 // resolveThreshold returns the provided threshold, or DefaultThreshold when // the caller passes zero (unset). This keeps the engine usable when config // defaults are not threaded through explicitly. func resolveThreshold(threshold float64) float64 { if threshold == 0 { return DefaultThreshold } return threshold } // Similarity returns a normalized similarity score in the range [0.0, 1.0] // between two strings. The strings are normalized first (lowercased, // bracketed/parenthesized content and years stripped, special characters // removed), then compared with a Levenshtein-distance-based ratio. // // A score of 1.0 means the normalized strings are identical; 0.0 means they // share nothing. Empty strings (after normalization) always score 0.0. func Similarity(a, b string) float64 { na := normalize.NormalizeString(a) nb := normalize.NormalizeString(b) // Two empty inputs are not considered a match. if na == "" && nb == "" { return 0.0 } // One empty, one non-empty: no similarity. if na == "" || nb == "" { return 0.0 } dist := fuzzy.LevenshteinDistance(na, nb) maxLen := len(na) if len(nb) > maxLen { maxLen = len(nb) } // 1.0 - normalized distance → higher is more similar. score := 1.0 - float64(dist)/float64(maxLen) if score < 0.0 { return 0.0 } return score } // IsMatch reports whether a and b are similar enough to be considered the // same release, given the provided threshold in [0.0, 1.0]. A threshold of 0 // (unset) falls back to DefaultThreshold, so this primitive honors the same // zero-means-default contract as FindMissingReleases/ScanArtist/ScanAll rather // than treating 0 as "always match". func IsMatch(a, b string, threshold float64) bool { return Similarity(a, b) >= resolveThreshold(threshold) }