diff --git a/backend/explore/explore.go b/backend/explore/explore.go index 3f7589f..7ea0919 100644 --- a/backend/explore/explore.go +++ b/backend/explore/explore.go @@ -329,7 +329,11 @@ func (e *Service) GetArtistImages(names []string) map[string]string { // failures degrade to MB-only ordering. func (e *Service) Search(query string) (*MBSearchResult, error) { searchStart := time.Now() - e.logger.Info("search started", "query", query) + + // Build the Lucene query: AND terms with wildcard on last. + luceneQuery := buildLuceneQuery(query) + + e.logger.Info("search started", "query", query, "lucene", luceneQuery) // Phase 0: query local popularity index (instant, no API calls). p0Start := time.Now() @@ -365,7 +369,7 @@ func (e *Service) Search(query string) (*MBSearchResult, error) { name: "artists", fn: func() { t := time.Now() - artists, err := e.mb.SearchArtists(mbCtx, query, mbSearchLimit) + artists, err := e.mb.SearchArtists(mbCtx, luceneQuery, mbSearchLimit) e.logger.Info("search MB sub-call", "entity", "artists", @@ -392,7 +396,7 @@ func (e *Service) Search(query string) (*MBSearchResult, error) { name: "releaseGroups", fn: func() { t := time.Now() - rgs, err := e.mb.SearchReleaseGroups(mbCtx, query, mbSearchLimit) + rgs, err := e.mb.SearchReleaseGroups(mbCtx, luceneQuery, mbSearchLimit) e.logger.Info("search MB sub-call", "entity", "releaseGroups", @@ -419,7 +423,7 @@ func (e *Service) Search(query string) (*MBSearchResult, error) { name: "recordings", fn: func() { t := time.Now() - recs, err := e.mb.SearchRecordings(mbCtx, query, mbSearchLimit) + recs, err := e.mb.SearchRecordings(mbCtx, luceneQuery, mbSearchLimit) e.logger.Info("search MB sub-call", "entity", "recordings", @@ -872,9 +876,10 @@ const ( relevanceWeight = 0.4 popularityWeight = 0.6 - // mbSearchLimit is passed to each MB search call. Slightly - // larger than maxResults to allow headroom for filtering. - mbSearchLimit = 20 + // mbSearchLimit is passed to each MB search call. Larger than + // maxResults to give the ranking pipeline more raw material. + // Noise is filtered out by name-match tiers and score cutoffs. + mbSearchLimit = 50 // searchMBTimeout is the maximum time to wait for MusicBrainz // API responses during interactive search. If MB is slow, @@ -1308,3 +1313,75 @@ func maxListenCount(pop map[string]int) int { return maxVal } + +// --------------------------------------------------------------------------- +// Lucene query building +// --------------------------------------------------------------------------- + +// luceneSpecialChars are characters that have special meaning in +// Lucene query syntax and must be escaped in user input. +var luceneSpecialChars = strings.NewReplacer( //nolint:gochecknoglobals + `\`, `\\`, + `+`, `\+`, + `-`, `\-`, + `!`, `\!`, + `(`, `\(`, + `)`, `\)`, + `{`, `\{`, + `}`, `\}`, + `[`, `\[`, + `]`, `\]`, + `^`, `\^`, + `"`, `\"`, + `~`, `\~`, + `*`, `\*`, + `?`, `\?`, + `:`, `\:`, + `/`, `\/`, +) + +// buildLuceneQuery converts a user's search input into a Lucene +// AND query with a wildcard on the last term for type-ahead. +// +// Examples: +// +// "radiohead" → "radiohead*" +// "the teenagers" → "the AND teenagers*" +// "florence machine" → "florence AND machine*" +// "ac/dc" → "ac\/dc*" +// +// This eliminates the common-word pollution problem: "the teenagers" +// no longer matches "The Beatles" (which only contains "the"). +// The trailing wildcard enables prefix matching as the user types. +func buildLuceneQuery(input string) string { + words := strings.Fields(strings.TrimSpace(input)) + if len(words) == 0 { + return "" + } + + // Escape special Lucene characters in each word. + for i, w := range words { + words[i] = luceneSpecialChars.Replace(w) + } + + if len(words) == 1 { + return words[0] + "*" + } + + // AND all terms, wildcard on the last (type-ahead). + var b strings.Builder + + for i, w := range words { + if i > 0 { + b.WriteString(" AND ") + } + + b.WriteString(w) + + if i == len(words)-1 { + b.WriteByte('*') + } + } + + return b.String() +} diff --git a/frontend/src/components/explore-view/explore-view.ts b/frontend/src/components/explore-view/explore-view.ts index fcb387f..5d800a0 100644 --- a/frontend/src/components/explore-view/explore-view.ts +++ b/frontend/src/components/explore-view/explore-view.ts @@ -16,6 +16,57 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js'; /* ── Constants ── */ const DEBOUNCE_MS = 300; const MIN_QUERY_LENGTH = 2; +const FUZZY_MAX_DISTANCE = 2; + +/* ── Fuzzy matching ── */ + +/** Levenshtein edit distance between two strings. */ +function editDistance(a: string, b: string): number { + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + + const matrix: number[][] = []; + + for (let i = 0; i <= a.length; i++) matrix[i] = [i]; + for (let j = 0; j <= b.length; j++) matrix[0][j] = j; + + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j - 1] + cost, + ); + } + } + + return matrix[a.length][b.length]; +} + +/** + * Check if a name fuzzy-matches a query. Returns true if: + * - the name contains the query as a substring (exact), OR + * - any word-aligned segment of the name is within edit distance + * FUZZY_MAX_DISTANCE of the query + */ +function fuzzyMatch(query: string, name: string): boolean { + if (name.includes(query)) return true; + + // Split both into words and check if all query words match + // a name word within edit distance (handles per-word typos). + const qWords = query.split(/\s+/); + const nWords = name.split(/\s+/); + + return qWords.every((qw) => + nWords.some( + (nw) => + nw.includes(qw) || + qw.includes(nw) || + (qw.length >= 4 && editDistance(qw, nw) <= FUZZY_MAX_DISTANCE), + ), + ); +} const MAX_SECTION_RESULTS = 10; const CAA_GROUP_BASE = 'https://coverartarchive.org/release-group'; @@ -556,7 +607,7 @@ export class ExploreView extends LitElement { const cachedArtists = libraryStore.cachedArtists; if (cachedArtists) { for (const a of cachedArtists) { - if (a.Name.toLowerCase().includes(q)) { + if (fuzzyMatch(q, a.Name.toLowerCase())) { artists.push({ mbid: a.MBID || '', name: a.Name, @@ -578,7 +629,7 @@ export class ExploreView extends LitElement { const cachedAlbums = libraryStore.cachedAlbums; if (cachedAlbums) { for (const a of cachedAlbums) { - if (a.Name.toLowerCase().includes(q) || a.ArtistName.toLowerCase().includes(q)) { + if (fuzzyMatch(q, a.Name.toLowerCase()) || fuzzyMatch(q, a.ArtistName.toLowerCase())) { releaseGroups.push({ mbid: a.MBID || '', title: a.Name,