feat: AND + wildcard Lucene queries, fuzzy library search, limit 50
Three search improvements: 1. MB queries now use AND + wildcard syntax instead of default OR. 'the teenagers' → 'the AND teenagers*'. This eliminates common- word pollution: The Beatles no longer match because they only contain 'the'. The trailing wildcard on the last term preserves type-ahead behavior. Special Lucene characters are escaped. 2. mbSearchLimit increased from 20 to 50. Gives the ranking pipeline more raw material — with AND filtering there's less noise, and our name-match tiers + popularity reranking handle the rest. Final display is still capped at 15. 3. Frontend library cache now uses fuzzy matching with Levenshtein edit distance (max 2) as fallback. Exact substring match is tried first, then per-word fuzzy matching for words >= 4 chars. 'florene and the machine' matches 'Florence and the Machine'. Pure JS, no API cost — runs against the in-memory library arrays.
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user