Files
NaviWatcher/internal/scanner/scanner.go
Vladimir Zagainov c2da8c2095 feat: add DB-backed scanner entrypoint and compute-only main hook
Implements ScanArtist/ScanAll in internal/scanner loading local/external
releases via the database layer with ctx-cancellation checks, plus a
compute-only run() hook that logs missing-release counts. Add table-driven
tests using in-memory SQLite fixtures.
2026-07-19 18:19:57 +03:00

71 lines
2.2 KiB
Go

// 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)
}
// Guard against maxLen == 0 (already handled above, but kept for safety).
if maxLen == 0 {
return 0.0
}
// 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].
func IsMatch(a, b string, threshold float64) bool {
return Similarity(a, b) >= threshold
}