bring over desktop's updated fuzzy search

This commit is contained in:
Boof2015
2026-08-11 19:38:38 -04:00
parent 5a58070371
commit 6fb2da03b2
5 changed files with 423 additions and 118 deletions
+1
View File
@@ -89,6 +89,7 @@
"test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts src/lyrics/embedded.test.mts src/lyrics/resolver.test.mts",
"test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts",
"test:troubleshooting": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/cacheInvalidation.test.mts",
"test:fuzzy-search": "node --experimental-strip-types --test src/lib/fuzzySearch.test.mts",
"test:settings-search": "node --experimental-strip-types --test src/components/search/settingsSearchRoutes.test.mts",
"test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts src/components/player/nowPlayingDismiss.test.mts src/components/player/artistCreditVisibility.test.mts src/playback/playbackTargetPresentation.test.mts",
"test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs",
+1
View File
@@ -18,6 +18,7 @@ const TEST_SCRIPTS = [
'test:lyrics',
'test:sleep',
'test:troubleshooting',
'test:fuzzy-search',
'test:settings-search',
'test:now-playing-layout',
'test:memory-lifecycle',
+35 -32
View File
@@ -36,7 +36,7 @@ import {
artworkUri,
trackArtworkThumbSource
} from '@/library/artwork';
import { multiFieldScore, MIN_SCORE_THRESHOLD } from '@/lib/fuzzySearch';
import { findFuzzyMatch, multiFieldScore } from '@/lib/fuzzySearch';
import { formatDuration } from '@/lib/format';
import { playHaptic } from '@/lib/haptics';
import { useLibraryStore } from '@/stores/libraryStore';
@@ -332,7 +332,7 @@ function plural(count: number, noun: string): string {
}
function scoreOrNull(score: number | null): score is number {
return score !== null && score >= MIN_SCORE_THRESHOLD;
return score !== null;
}
function playlistFromRow(playlist: Playlist): SearchPlaylist {
@@ -413,42 +413,45 @@ function resultIcon(result: SearchResult): IconName {
function HighlightedLabel({ text, query }: { text: string; query: string }) {
const styles = useStyles();
const normalizedText = text.toLocaleLowerCase();
const normalizedQuery = query.toLocaleLowerCase().trim();
if (!normalizedQuery) {
return <>{text}</>;
}
const substringIndex = normalizedText.indexOf(normalizedQuery);
if (substringIndex >= 0) {
return (
<>
{text.slice(0, substringIndex)}
<Text variant="body" style={styles.highlight}>
{text.slice(substringIndex, substringIndex + normalizedQuery.length)}
</Text>
{text.slice(substringIndex + normalizedQuery.length)}
</>
);
}
const match = findFuzzyMatch(query, text);
if (!match || match.indices.length === 0) return <>{text}</>;
const matchedIndices = [...new Set(match.indices)].sort((left, right) => left - right);
const parts: { text: string; highlighted: boolean; key: string }[] = [];
let queryIndex = 0;
let lastPushed = 0;
let cursor = 0;
let groupStart = matchedIndices[0];
let groupEnd = groupStart + 1;
for (let i = 0; i < text.length && queryIndex < normalizedQuery.length; i += 1) {
if (text[i].toLocaleLowerCase() !== normalizedQuery[queryIndex]) continue;
if (i > lastPushed) {
parts.push({ text: text.slice(lastPushed, i), highlighted: false, key: `plain-${i}` });
const pushGroup = () => {
if (groupStart > cursor) {
parts.push({
text: text.slice(cursor, groupStart),
highlighted: false,
key: `plain-${cursor}`,
});
}
parts.push({ text: text[i], highlighted: true, key: `mark-${i}` });
queryIndex += 1;
lastPushed = i + 1;
parts.push({
text: text.slice(groupStart, groupEnd),
highlighted: true,
key: `mark-${groupStart}`,
});
cursor = groupEnd;
};
for (let index = 1; index < matchedIndices.length; index += 1) {
const textIndex = matchedIndices[index];
if (textIndex === groupEnd) {
groupEnd += 1;
continue;
}
pushGroup();
groupStart = textIndex;
groupEnd = textIndex + 1;
}
if (lastPushed < text.length) {
parts.push({ text: text.slice(lastPushed), highlighted: false, key: 'tail' });
pushGroup();
if (cursor < text.length) {
parts.push({ text: text.slice(cursor), highlighted: false, key: 'tail' });
}
return (
+123
View File
@@ -0,0 +1,123 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
findFuzzyMatch,
multiFieldScore,
type FuzzyMatch,
type FuzzyMatchKind,
} from './fuzzySearch.ts';
function requireMatch(
query: string,
candidate: string,
kind: FuzzyMatchKind
): FuzzyMatch {
const match = findFuzzyMatch(query, candidate);
if (!match) assert.fail(`Expected ${JSON.stringify(query)} to match ${JSON.stringify(candidate)}`);
assert.equal(match.kind, kind);
return match;
}
test('classifies every accepted match shape in relevance order', () => {
const exact = requireMatch('Radiohead', 'Radiohead', 'exact');
const prefix = requireMatch('Radio', 'Radiohead', 'prefix');
const wordPrefix = requireMatch('Radio', 'The Radio Dept.', 'word-prefix');
const substring = requireMatch('radio', 'Piradio Signal', 'substring');
const initialism = requireMatch('rhc', 'Red Hot Chili Peppers', 'initialism');
const compact = requireMatch('rdio', 'Radiohead', 'compact');
assert.ok(exact.score > prefix.score);
assert.ok(prefix.score > wordPrefix.score);
assert.ok(wordPrefix.score > substring.score);
assert.ok(substring.score > initialism.score);
assert.ok(initialism.score > compact.score);
});
test('keeps short queries strict while accepting consecutive initials', () => {
assert.equal(findFuzzyMatch('rd', 'Radiohead'), null);
assert.equal(findFuzzyMatch('io', 'Radiohead'), null);
requireMatch('ra', 'Radiohead', 'prefix');
requireMatch('de', 'The Department', 'word-prefix');
requireMatch('rh', 'Red Hot Chili Peppers', 'initialism');
assert.equal(findFuzzyMatch('rhc', 'Red Hot Spicy Chili Peppers'), null);
});
test('bounds compact matches and rejects scattered, typo, incomplete, and multiword matches', () => {
requireMatch('rdio', 'Radiohead', 'compact');
assert.equal(findFuzzyMatch('rdio', 'Raaaaaaaadio'), null);
assert.equal(findFuzzyMatch('rhd', 'Radiohead'), null);
assert.equal(findFuzzyMatch('the', 'Everything in Its Right Place'), null);
assert.equal(findFuzzyMatch('kid', 'Knights of Cydonia'), null);
assert.equal(findFuzzyMatch('love', 'Long Drive Home'), null);
assert.equal(findFuzzyMatch('raido', 'Radiohead'), null);
assert.equal(findFuzzyMatch('radioz', 'Radiohead'), null);
assert.equal(findFuzzyMatch('r d', 'Radiohead'), null);
});
test('normalizes case and repeated whitespace without broadening eligibility', () => {
requireMatch(' RADIO DEPT ', 'Radio Dept', 'exact');
assert.equal(findFuzzyMatch('', 'Radiohead'), null);
assert.equal(findFuzzyMatch('radio', ''), null);
});
test('ranks match class before field weight and uses the strongest eligible field', () => {
const exactLowWeight = multiFieldScore('radio', [
{ value: 'Radio', weight: 0.1 },
]);
const prefixHighWeight = multiFieldScore('radio', [
{ value: 'Radiohead', weight: 9 },
]);
assert.notEqual(exactLowWeight, null);
assert.notEqual(prefixHighWeight, null);
assert.ok((exactLowWeight ?? 0) > (prefixHighWeight ?? 0));
const strongestField = multiFieldScore('radio', [
{ value: 'The Radio Dept.', weight: 2 },
{ value: 'Radiohead', weight: 1 },
]);
assert.equal(strongestField, multiFieldScore('radio', [
{ value: 'Radiohead', weight: 1 },
]));
});
test('handles nullable fields and does not let weights revive rejected fields', () => {
assert.equal(multiFieldScore('radio', [
{ value: null, weight: 999 },
{ value: undefined, weight: 999 },
{ value: 'Radio', weight: 1 },
]), multiFieldScore('radio', [{ value: 'Radio', weight: 1 }]));
assert.equal(multiFieldScore('kid', [
{ value: 'Knights of Cydonia', weight: 999 },
]), null);
});
test('prefers compact, early, shorter candidates within a match class', () => {
const compact = requireMatch('rdio', 'Radiohead', 'compact').score;
const spread = requireMatch('rdio', 'Raxdiohead', 'compact').score;
const earlier = requireMatch('radio', 'The Radio Dept.', 'word-prefix').score;
const later = requireMatch('radio', 'Music by Radio Dept.', 'word-prefix').score;
const shorter = requireMatch('radio', 'Radiohead', 'prefix').score;
const longer = requireMatch('radio', 'Radiotelegraph', 'prefix').score;
assert.ok(compact > spread);
assert.ok(earlier > later);
assert.ok(shorter > longer);
assert.equal(
multiFieldScore('radio', [{ value: 'Radiohead', weight: 1 }]),
multiFieldScore('radio', [{ value: 'Radiohead', weight: 1 }])
);
});
test('returns exact original indices for literal, initialism, and compact highlights', () => {
assert.deepEqual(requireMatch('radio', 'The Radio Dept.', 'word-prefix').indices, [4, 5, 6, 7, 8]);
assert.deepEqual(requireMatch('rhc', 'Red Hot Chili Peppers', 'initialism').indices, [0, 4, 8]);
assert.deepEqual(requireMatch('rdio', 'Radiohead', 'compact').indices, [0, 2, 3, 4]);
});
test('returns no indices for rejected or incomplete highlight queries', () => {
assert.equal(findFuzzyMatch('rhd', 'Radiohead'), null);
assert.equal(findFuzzyMatch('radioz', 'Radiohead'), null);
assert.equal(findFuzzyMatch('radio', 'Kid A'), null);
});
+263 -86
View File
@@ -17,6 +17,76 @@ const WORD_BOUNDARY_SEPARATORS = new Set([
'"',
"'",
]);
const WHITESPACE_PATTERN = /\s/u;
const MATCH_KIND_RANK = {
compact: 1,
initialism: 2,
substring: 3,
'word-prefix': 4,
prefix: 5,
exact: 6,
} as const;
const MATCH_KIND_SCORE_SCALE = 1_000_000_000;
const FIELD_WEIGHT_SCORE_SCALE = 1_000_000;
const MAX_FIELD_WEIGHT_RANK = 999;
const MAX_PROXIMITY_COMPONENT = 99;
export type FuzzyMatchKind = keyof typeof MATCH_KIND_RANK;
export interface FuzzyMatch {
kind: FuzzyMatchKind;
score: number;
indices: number[];
span: number;
startIndex: number;
}
interface NormalizedSearchValue {
value: string;
originalIndices: number[];
}
interface DetailedFuzzyMatch {
kind: FuzzyMatchKind;
score: number;
normalizedIndices: number[];
span: number;
startIndex: number;
normalizedCandidateLength: number;
normalizedQueryLength: number;
}
function normalizeSearchValueWithIndices(value: string): NormalizedSearchValue {
let normalized = '';
const originalIndices: number[] = [];
let pendingWhitespaceIndex = -1;
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (WHITESPACE_PATTERN.test(character)) {
if (normalized.length > 0 && pendingWhitespaceIndex < 0) {
pendingWhitespaceIndex = index;
}
continue;
}
if (pendingWhitespaceIndex >= 0) {
normalized += ' ';
originalIndices.push(pendingWhitespaceIndex);
pendingWhitespaceIndex = -1;
}
const lowerCharacter = character.toLocaleLowerCase();
normalized += lowerCharacter;
for (let lowerIndex = 0; lowerIndex < lowerCharacter.length; lowerIndex += 1) {
originalIndices.push(index);
}
}
return { value: normalized, originalIndices };
}
function normalizeSearchValue(value: string): string {
return value.toLocaleLowerCase().trim().replace(/\s+/g, ' ');
@@ -27,61 +97,188 @@ function isWordBoundary(value: string, index: number): boolean {
return WORD_BOUNDARY_SEPARATORS.has(value[index - 1]);
}
export function fuzzyScore(queryInput: string, candidateInput: string): number | null {
const query = normalizeSearchValue(queryInput);
const candidate = normalizeSearchValue(candidateInput);
function isWordStart(value: string, index: number): boolean {
return !WORD_BOUNDARY_SEPARATORS.has(value[index]) && isWordBoundary(value, index);
}
function isSingleToken(value: string): boolean {
for (const character of value) {
if (WORD_BOUNDARY_SEPARATORS.has(character)) return false;
}
return true;
}
function buildRange(start: number, length: number): number[] {
return Array.from({ length }, (_, offset) => start + offset);
}
function proximityScore(
normalizedQueryLength: number,
normalizedCandidateLength: number,
startIndex: number,
span: number
): number {
const compactnessRank = MAX_PROXIMITY_COMPONENT - Math.min(
MAX_PROXIMITY_COMPONENT,
Math.max(0, span - normalizedQueryLength)
);
const startRank = MAX_PROXIMITY_COMPONENT - Math.min(
MAX_PROXIMITY_COMPONENT,
Math.max(0, startIndex)
);
const lengthRank = MAX_PROXIMITY_COMPONENT - Math.min(
MAX_PROXIMITY_COMPONENT,
Math.max(0, normalizedCandidateLength - normalizedQueryLength)
);
return (compactnessRank * 10_000) + (startRank * 100) + lengthRank;
}
function createMatch(
kind: FuzzyMatchKind,
normalizedIndices: number[],
normalizedCandidate: string,
normalizedQueryLength: number
): DetailedFuzzyMatch {
const startIndex = normalizedIndices[0];
const endIndex = normalizedIndices[normalizedIndices.length - 1];
const span = endIndex - startIndex + 1;
const score = (MATCH_KIND_RANK[kind] * MATCH_KIND_SCORE_SCALE) + proximityScore(
normalizedQueryLength,
normalizedCandidate.length,
startIndex,
span
);
return {
kind,
score,
normalizedIndices,
span,
startIndex,
normalizedCandidateLength: normalizedCandidate.length,
normalizedQueryLength,
};
}
function findConsecutiveInitials(query: string, candidate: string): number[] | null {
const wordStarts: number[] = [];
for (let index = 0; index < candidate.length; index += 1) {
if (isWordStart(candidate, index)) wordStarts.push(index);
}
for (let start = 0; start <= wordStarts.length - query.length; start += 1) {
const indices = wordStarts.slice(start, start + query.length);
if (indices.every((candidateIndex, queryIndex) => candidate[candidateIndex] === query[queryIndex])) {
return indices;
}
}
return null;
}
function findCompactSubsequence(query: string, candidate: string): number[] | null {
const maximumSpan = query.length * 2;
let bestIndices: number[] | null = null;
for (let startIndex = 0; startIndex < candidate.length; startIndex += 1) {
if (candidate[startIndex] !== query[0] || !isWordStart(candidate, startIndex)) continue;
const indices = [startIndex];
let queryIndex = 1;
const endExclusive = Math.min(candidate.length, startIndex + maximumSpan);
for (let candidateIndex = startIndex + 1; candidateIndex < endExclusive; candidateIndex += 1) {
if (candidate[candidateIndex] !== query[queryIndex]) continue;
indices.push(candidateIndex);
queryIndex += 1;
if (queryIndex === query.length) break;
}
if (queryIndex !== query.length) continue;
if (!bestIndices) {
bestIndices = indices;
continue;
}
const span = indices[indices.length - 1] - indices[0] + 1;
const bestSpan = bestIndices[bestIndices.length - 1] - bestIndices[0] + 1;
if (span < bestSpan || (span === bestSpan && indices[0] < bestIndices[0])) {
bestIndices = indices;
}
}
return bestIndices;
}
function findNormalizedFuzzyMatch(query: string, candidate: string): DetailedFuzzyMatch | null {
if (!query || !candidate) return null;
let queryIndex = 0;
let firstMatchIndex = -1;
let lastMatchIndex = -1;
let previousMatchIndex = -2;
let contiguousMatches = 0;
let boundaryMatches = 0;
let score = 0;
for (let candidateIndex = 0; candidateIndex < candidate.length; candidateIndex += 1) {
if (candidate[candidateIndex] !== query[queryIndex]) continue;
if (firstMatchIndex === -1) {
firstMatchIndex = candidateIndex;
}
const contiguous = candidateIndex === previousMatchIndex + 1;
const boundary = isWordBoundary(candidate, candidateIndex);
if (queryIndex === 0) {
if (candidateIndex === 0) {
score += 20;
} else if (boundary) {
score += 12;
}
}
if (boundary) boundaryMatches += 1;
if (contiguous) contiguousMatches += 1;
previousMatchIndex = candidateIndex;
lastMatchIndex = candidateIndex;
queryIndex += 1;
if (queryIndex === query.length) break;
if (candidate === query) {
return createMatch('exact', buildRange(0, query.length), candidate, query.length);
}
if (queryIndex !== query.length || firstMatchIndex < 0 || lastMatchIndex < 0) {
return null;
if (candidate.startsWith(query)) {
return createMatch('prefix', buildRange(0, query.length), candidate, query.length);
}
const span = lastMatchIndex - firstMatchIndex + 1;
score += query.length * 8;
score += contiguousMatches * 5;
score += boundaryMatches * 4;
score += Math.max(0, 16 - span);
score += Math.max(0, 10 - firstMatchIndex);
score += Math.max(0, 10 - (candidate.length - query.length));
for (let index = 1; index <= candidate.length - query.length; index += 1) {
if (!isWordStart(candidate, index) || !candidate.startsWith(query, index)) continue;
return createMatch('word-prefix', buildRange(index, query.length), candidate, query.length);
}
return score;
if (query.length >= 3) {
const substringIndex = candidate.indexOf(query);
if (substringIndex >= 0) {
return createMatch('substring', buildRange(substringIndex, query.length), candidate, query.length);
}
}
if (!isSingleToken(query)) return null;
if (query.length >= 2) {
const initialIndices = findConsecutiveInitials(query, candidate);
if (initialIndices) {
return createMatch('initialism', initialIndices, candidate, query.length);
}
}
if (query.length >= 3) {
const compactIndices = findCompactSubsequence(query, candidate);
if (compactIndices) {
return createMatch('compact', compactIndices, candidate, query.length);
}
}
return null;
}
function findDetailedFuzzyMatch(queryInput: string, candidateInput: string): DetailedFuzzyMatch | null {
return findNormalizedFuzzyMatch(
normalizeSearchValue(queryInput),
normalizeSearchValue(candidateInput)
);
}
export function findFuzzyMatch(queryInput: string, candidateInput: string): FuzzyMatch | null {
const query = normalizeSearchValue(queryInput);
const candidate = normalizeSearchValueWithIndices(candidateInput);
const match = findNormalizedFuzzyMatch(query, candidate.value);
if (!match) return null;
const indices = match.normalizedIndices
.map((index) => candidate.originalIndices[index])
.filter((index, position, values) => position === 0 || index !== values[position - 1]);
return {
kind: match.kind,
score: match.score,
indices,
span: match.span,
startIndex: match.startIndex,
};
}
export function fuzzyScore(queryInput: string, candidateInput: string): number | null {
return findDetailedFuzzyMatch(queryInput, candidateInput)?.score ?? null;
}
export interface FieldDef {
@@ -89,7 +286,19 @@ export interface FieldDef {
weight: number;
}
export const MIN_SCORE_THRESHOLD = 25;
function weightedMatchScore(match: DetailedFuzzyMatch, weight: number): number {
const weightRank = Number.isFinite(weight)
? Math.max(0, Math.min(MAX_FIELD_WEIGHT_RANK, Math.round(weight * 100)))
: 0;
const baseKindScore = MATCH_KIND_RANK[match.kind] * MATCH_KIND_SCORE_SCALE;
const proximity = proximityScore(
match.normalizedQueryLength,
match.normalizedCandidateLength,
match.startIndex,
match.span
);
return baseKindScore + (weightRank * FIELD_WEIGHT_SCORE_SCALE) + proximity;
}
export function multiFieldScore(queryInput: string, fields: FieldDef[]): number | null {
const normalizedQuery = normalizeSearchValue(queryInput);
@@ -98,48 +307,16 @@ export function multiFieldScore(queryInput: string, fields: FieldDef[]): number
let bestScore: number | null = null;
for (const field of fields) {
const value = field.value ?? '';
const normalizedValue = normalizeSearchValue(value);
if (!normalizedValue) continue;
let fieldScore = fuzzyScore(queryInput, value);
if (fieldScore === null) continue;
if (normalizedValue === normalizedQuery) {
fieldScore += 60;
}
if (normalizedValue.includes(normalizedQuery)) {
fieldScore += 30;
}
if (normalizedValue.startsWith(normalizedQuery)) {
fieldScore += 20;
}
const words = normalizedValue.split(/\s+/);
if (words.some((word) => word.startsWith(normalizedQuery))) {
fieldScore += 15;
}
fieldScore = Math.round(fieldScore * field.weight);
const match = findNormalizedFuzzyMatch(
normalizedQuery,
normalizeSearchValue(field.value ?? '')
);
if (!match) continue;
const fieldScore = weightedMatchScore(match, field.weight);
if (bestScore === null || fieldScore > bestScore) {
bestScore = fieldScore;
}
}
if (bestScore === null) return null;
if (normalizedQuery.length <= 2) {
const hasStrictMatch = fields.some((field) => {
const normalizedValue = normalizeSearchValue(field.value ?? '');
if (!normalizedValue) return false;
if (normalizedValue.startsWith(normalizedQuery)) return true;
return normalizedValue.split(/\s+/).some((word) => word.startsWith(normalizedQuery));
});
if (!hasStrictMatch) return null;
}
return bestScore;
}