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:
2026-03-30 02:26:55 -04:00
parent 920a96d9b1
commit 52632b470c
2 changed files with 137 additions and 9 deletions
@@ -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,