- Fix artist-ID namespace mismatch in MusicBrainz provider: SyncArtistDiscography now stores the canonical Navidrome artist ID (artist_settings.id) as external_releases.artist_id instead of the MusicBrainz MBID. Previously the MBID was stored, which violated the FK to artist_settings and broke the scanner join (local_albums.artist_id is the Navidrome ID), causing every external release to be falsely reported as missing and the sync insert to fail at runtime. getArtistFilterOptions now also resolves by the Navidrome ID. - Resolve threshold in FindMissingReleases so the exported primitive honors the same zero-means-default contract as ScanArtist/ScanAll. - Remove dead maxLen==0 guard in scanner.Similarity. - Inline trivial buildPath helper; drop unused url import in client.go. - Replace hand-rolled itoa with strconv.Itoa in tests. - Rewrite SyncArtistDiscography tests to seed artist_settings with the Navidrome ID (tests previously seeded the MBID to mask the FK mismatch). - Fix TestFuzzySmoke to exercise the real dependency (fuzzy.LevenshteinDistance / scanner.Similarity) instead of an unused API. - Fix TestAppRun_ScanLogsMissingReleases to run the scan against a live context and assert the missing release is found. - Document cached_at column in Specification.md and note startup scan / required musicbrainz.user_agent in README. - Stop tracking .serena/ tooling config; add it to .gitignore.
66 lines
2.1 KiB
Go
66 lines
2.1 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)
|
|
}
|
|
|
|
// 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
|
|
}
|