feat: ranked library search with match-quality tiers

Library cache search was returning results in alphabetical order
with no ranking. 'massive' showed Blanck Mass before Massive Attack
because B comes before M.

Now all matches are collected, scored by match quality, and sorted:

Artists:
  exact match = 100, starts-with = 90, substring = 70, fuzzy = 50

Albums (same tiers as remote rgMatchTier):
  artist-exact = 100, artist-contains = 85, title-exact = 80,
  title-starts-with = 75, title-contains = 60, fuzzy = 40

Results are sorted by score descending, then alphabetically as
tiebreaker. Cap increased from 5 to 10 per entity type to show
more library content.
This commit is contained in:
2026-03-30 16:26:15 -04:00
parent c530dc6cb8
commit cec69dede2
@@ -637,47 +637,89 @@ export class ExploreView extends LitElement {
private searchLibraryCache(query: string): MBSearchResult | null { private searchLibraryCache(query: string): MBSearchResult | null {
const q = query.toLowerCase(); const q = query.toLowerCase();
const artists: MBArtist[] = []; // Collect all matching artists with match-quality scores.
const artistMatches: Array<{ artist: any; score: number }> = [];
const cachedArtists = libraryStore.cachedArtists; const cachedArtists = libraryStore.cachedArtists;
if (cachedArtists) { if (cachedArtists) {
for (const a of cachedArtists) { for (const a of cachedArtists) {
if (fuzzyMatch(q, a.Name.toLowerCase())) { const name = a.Name.toLowerCase();
artists.push({ if (!fuzzyMatch(q, name)) continue;
mbid: a.MBID || '',
name: a.Name, // Score by match quality (same tiers as remote search).
sortName: '', let score: number;
type: '', if (name === q) {
country: '', score = 100; // exact
disambiguation: '', } else if (name.startsWith(q)) {
score: 100, score = 90; // starts with
_imageSmall: a.ImageSmall || '', } else if (name.includes(q)) {
_imageMedium: a.ImageMedium || '', score = 70; // substring
_inLibrary: true, } else {
} as MBArtist & { _imageSmall: string; _imageMedium: string; _inLibrary: boolean }); score = 50; // fuzzy/word match
if (artists.length >= 5) break;
} }
artistMatches.push({ artist: a, score });
} }
} }
const releaseGroups: MBReleaseGroup[] = []; // Sort by score descending, then alphabetically.
artistMatches.sort((a, b) => b.score - a.score || a.artist.Name.localeCompare(b.artist.Name));
const artists: MBArtist[] = artistMatches.slice(0, 10).map((m) => ({
mbid: m.artist.MBID || '',
name: m.artist.Name,
sortName: '',
type: '',
country: '',
disambiguation: '',
score: m.score,
_imageSmall: m.artist.ImageSmall || '',
_imageMedium: m.artist.ImageMedium || '',
_inLibrary: true,
} as MBArtist & { _imageSmall: string; _imageMedium: string; _inLibrary: boolean }));
// Collect all matching albums with match-quality scores.
const albumMatches: Array<{ album: any; score: number }> = [];
const cachedAlbums = libraryStore.cachedAlbums; const cachedAlbums = libraryStore.cachedAlbums;
if (cachedAlbums) { if (cachedAlbums) {
for (const a of cachedAlbums) { for (const a of cachedAlbums) {
if (fuzzyMatch(q, a.Name.toLowerCase()) || fuzzyMatch(q, a.ArtistName.toLowerCase())) { const name = a.Name.toLowerCase();
releaseGroups.push({ const artist = a.ArtistName.toLowerCase();
mbid: a.MBID || '', const matchesName = fuzzyMatch(q, name);
title: a.Name, const matchesArtist = fuzzyMatch(q, artist);
primaryType: 'Album', if (!matchesName && !matchesArtist) continue;
artistCredit: a.ArtistName,
firstReleaseDate: a.Year ? String(a.Year) : '', let score: number;
_coverArt: a.CoverArtMedium || a.CoverArtSmall || '', // Artist name match is strongest (same as remote rgMatchTier).
_inLibrary: true, if (artist === q) {
} as MBReleaseGroup & { _coverArt: string; _inLibrary: boolean }); score = 100;
if (releaseGroups.length >= 5) break; } else if (artist.startsWith(q) || artist.includes(q)) {
score = 85;
} else if (name === q) {
score = 80;
} else if (name.startsWith(q)) {
score = 75;
} else if (name.includes(q)) {
score = 60;
} else {
score = 40;
} }
albumMatches.push({ album: a, score });
} }
} }
albumMatches.sort((a, b) => b.score - a.score || a.album.Name.localeCompare(b.album.Name));
const releaseGroups: MBReleaseGroup[] = albumMatches.slice(0, 10).map((m) => ({
mbid: m.album.MBID || '',
title: m.album.Name,
primaryType: 'Album',
artistCredit: m.album.ArtistName,
firstReleaseDate: m.album.Year ? String(m.album.Year) : '',
_coverArt: m.album.CoverArtMedium || m.album.CoverArtSmall || '',
_inLibrary: true,
} as MBReleaseGroup & { _coverArt: string; _inLibrary: boolean }));
if (artists.length === 0 && releaseGroups.length === 0) { if (artists.length === 0 && releaseGroups.length === 0) {
return null; return null;
} }