feat: autotag scoring overhaul, dump-based explore index, and lyrics search

Consolidates in-progress work across autotag, explore, and library:

- autotag: beets/Picard-informed scoring engine — ID-first matching, VA
  handling, recommendation tiers, and a merged distance/rank cascade, with
  an eval harness for regression tracking.
- explore: offline MusicBrainz dump import/incremental refresh replaces the
  legacy tier crawl; index-first local search with fuzzy matching and a
  dedicated ranker; disk-free guards for dump downloads.
- library: artist-credit extraction and matching.
- lyrics: owned-library lyric search (FTS) with LRCLIB backfill.

Also: rewrite README to be user-focused, and migrate upstream to
git.ljones.me/yonlu/yellowjacket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 12:14:20 -04:00
co-authored by Claude Opus 4.8
parent d5140395da
commit 65048401e8
117 changed files with 17033 additions and 4767 deletions
File diff suppressed because it is too large Load Diff
@@ -1945,7 +1945,7 @@ export class ConfigPage extends LitElement {
return html`
<config-section
heading="Search Index"
description="The explore search index pre-caches popular artists, albums, and tracks from ListenBrainz for fast offline search."
description="The explore search index is built from the MusicBrainz/ListenBrainz data dumps — popular artists, albums, and tracks with listen counts — for fast offline search."
.open=${true}
>
<div class="index-status">
@@ -46,6 +46,7 @@ import type { DragPayload } from '@utils/drag-controller';
import { ContextMenuController } from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { artistLink, exploreLinkStyles } from '../../utils/explore-link';
import {
createAlbumArtDragImage,
createDragImage,
@@ -264,7 +265,7 @@ export class CoverGrid
private splitEntriesCacheKey: GridEntry[] | null = null;
private splitEntriesCacheIndex = -1;
static override styles = coverGridStyles;
static override styles = [coverGridStyles, exploreLinkStyles];
/* ====================================================================
* Reactive state
@@ -1832,7 +1833,7 @@ export class CoverGrid
class="artist-name"
title="${album.ArtistName}"
>
${album.ArtistName}
${artistLink(album.ArtistName, album.ArtistMBID ?? '')}
</div>
</div>
</div>
@@ -15,6 +15,9 @@ type MBTrack = explore.MBTrack;
import { exploreCache } from '../../store/explore-cache';
import { exploreSettings } from '../../store/explore-settings';
import { libraryStore } from '../../store/library-store';
import { artistLink, exploreLinkStyles } from '../../utils/explore-link';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js';
@@ -113,6 +116,7 @@ export class ExploreAlbumDetails extends LitElement {
static override styles = [
designTokens,
exploreLinkStyles,
css`
:host {
display: flex;
@@ -451,6 +455,13 @@ export class ExploreAlbumDetails extends LitElement {
/* ── Lifecycle ── */
private unsubSettings?: () => void;
private unsubReleasesReady?: () => void;
/** Release-group MBIDs whose AlbumReleasesReady event we've handled,
* so a background BrowseReleases fetch re-hydrates versions once. */
private releasesReloaded = new Set<string>();
/** Fallback timer that stops the versions spinner if the background
* BrowseReleases fetch never signals readiness. */
private releasesFallbackTimer?: number;
override connectedCallback() {
super.connectedCallback();
@@ -465,11 +476,45 @@ export class ExploreAlbumDetails extends LitElement {
void this.loadAllData();
}
});
// A background BrowseReleases fetch (cold album, versions +
// tracklist not cached yet) finished — re-fetch the versions once
// per release group so they fill in without the initial request
// having blocked on a live MusicBrainz browse.
this.unsubReleasesReady = EventsOn(
Events.AlbumReleasesReady,
(mbid: string) => {
if (mbid !== this.releaseGroupMBID) return;
if (this.releasesReloaded.has(mbid)) return;
if (this.releasesFallbackTimer) clearTimeout(this.releasesFallbackTimer);
this.releasesReloaded.add(mbid);
void this.fetchReleases(mbid);
},
);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.unsubSettings?.();
this.unsubReleasesReady?.();
if (this.releasesFallbackTimer) clearTimeout(this.releasesFallbackTimer);
}
/**
* Arm a one-shot fallback that stops the versions spinner if
* AlbumReleasesReady never arrives (e.g. the background browse stalled
* or the release group genuinely has no releases).
*/
private armReleasesFallback(mbid: string) {
if (this.releasesFallbackTimer) clearTimeout(this.releasesFallbackTimer);
this.releasesFallbackTimer = window.setTimeout(() => {
if (this.releasesReloaded.has(mbid)) return;
this.releasesReloaded.add(mbid);
if (this.releases.length === 0) this.loadingReleases = false;
}, 12000);
}
/** Whether we've already scrolled to the highlight target. */
@@ -595,7 +640,11 @@ export class ExploreAlbumDetails extends LitElement {
}
// Phase 2: fire API calls independently so each section
// renders as its data arrives.
// renders as its data arrives. Allow the versions section one
// background-fetch re-fetch and arm a fallback so it can't spin
// forever if AlbumReleasesReady never arrives.
this.releasesReloaded.delete(mbid);
this.armReleasesFallback(mbid);
void this.fetchReleaseGroup(mbid);
void this.fetchReleases(mbid);
@@ -828,13 +877,28 @@ export class ExploreAlbumDetails extends LitElement {
private async fetchReleases(mbid: string) {
try {
const releases = await BrowseReleases(mbid);
this.releases = releases ?? [];
this.buildClusters();
if (releases && releases.length > 0) {
// Warm cache hit (or the background re-fetch landed):
// authoritative MB versions replace any local placeholder.
this.releases = releases;
this.buildClusters();
this.loadingReleases = false;
return;
}
// Cold miss: BrowseReleases is cache-first + async and the
// versions/tracklist are still being fetched in the background.
// Don't clobber a tracklist already hydrated from the library —
// keep showing it. Hold the spinner only when there's nothing
// on screen yet; AlbumReleasesReady (or the fallback) resolves it.
if (this.releases.length > 0 || this.releasesReloaded.has(mbid)) {
this.loadingReleases = false;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.errorReleases = msg;
console.error(`[explore-album] BrowseReleases error: ${msg}`);
} finally {
this.loadingReleases = false;
}
}
@@ -1437,6 +1501,7 @@ export class ExploreAlbumDetails extends LitElement {
const rg = this.releaseGroup;
const artist = rg.artistCredit || '';
const artistMbid = rg.artistMbid ?? '';
const year = extractYear(rg.firstReleaseDate);
const type = rg.primaryType || '';
@@ -1446,7 +1511,9 @@ export class ExploreAlbumDetails extends LitElement {
return html`
${artist
? html`<div class="album-artist">${artist}</div>`
? html`<div class="album-artist">
${artistLink(artist, artistMbid)}
</div>`
: nothing}
${metaParts.length > 0
? html`
@@ -15,6 +15,7 @@ import {
GetTrackThumbnail,
GetTrackThumbnails,
ResolveReleaseGroupMBIDs,
PrefetchReleases,
} from '@go/explore/Service';
import type { explore } from '@go/models';
type MBArtist = explore.MBArtist;
@@ -25,7 +26,10 @@ type LBSimilarArtist = explore.LBSimilarArtist;
import { exploreCache } from '../../store/explore-cache';
import { exploreSettings } from '../../store/explore-settings';
import { libraryStore } from '../../store/library-store';
import { trackLink, exploreLinkStyles } from '../../utils/explore-link';
import { GetAlbumsByArtist } from '@go/library/Library';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js';
@@ -123,6 +127,7 @@ export class ExploreArtistDetails extends LitElement {
static override styles = [
designTokens,
exploreLinkStyles,
css`
:host {
display: flex;
@@ -826,6 +831,17 @@ export class ExploreArtistDetails extends LitElement {
/* ── Lifecycle ── */
private unsubSettings?: () => void;
private unsubDiscogReady?: () => void;
private unsubSimilarReady?: () => void;
/** MBIDs whose ArtistSimilarReady event we've already handled, so a
* background similar-artists fetch re-hydrates that section once. */
private similarReloaded = new Set<string>();
/** MBIDs whose ArtistDiscographyReady event we've already handled,
* so an artist with no discography can't trigger a re-fetch loop. */
private discogReloaded = new Set<string>();
/** Fallback timer that stops the top-section spinners if the
* background discography fetch never signals readiness. */
private discogFallbackTimer?: number;
override connectedCallback() {
super.connectedCallback();
@@ -840,15 +856,72 @@ export class ExploreArtistDetails extends LitElement {
void this.loadAllData();
}
});
// A background discography fetch (top tracks / top releases for an
// artist that wasn't indexed yet) finished — re-fetch those two
// sections, once per artist, so they fill in without the initial
// request having blocked.
this.unsubDiscogReady = EventsOn(
Events.ArtistDiscographyReady,
(mbid: string) => {
if (mbid !== this.artistMBID) return;
if (this.discogReloaded.has(mbid)) return;
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
this.discogReloaded.add(mbid);
void this.fetchTopTracks(mbid);
void this.fetchTopReleaseGroups(mbid);
// Full discography section is also index-first + async now,
// so re-read it from the freshly-populated index too.
void this.fetchReleaseGroups(mbid);
},
);
// A background similar-artists fetch (LB labs, first view of an
// artist) finished — re-fetch that section once per artist.
this.unsubSimilarReady = EventsOn(
Events.ArtistSimilarReady,
(mbid: string) => {
if (mbid !== this.artistMBID) return;
if (this.similarReloaded.has(mbid)) return;
this.similarReloaded.add(mbid);
void this.fetchSimilarArtists(mbid);
},
);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.unsubSettings?.();
this.unsubDiscogReady?.();
this.unsubSimilarReady?.();
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
this.topSectionObserver?.disconnect();
this.discoObserver?.disconnect();
}
/**
* Arm a one-shot fallback that clears the top-section loading state
* if ArtistDiscographyReady never arrives (e.g. the artist genuinely
* has no discography, or the background fetch stalled). Treated as a
* "reload happened" so the finally blocks resolve to empty state.
*/
private armDiscogFallback(mbid: string) {
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
this.discogFallbackTimer = window.setTimeout(() => {
if (this.discogReloaded.has(mbid)) return;
this.discogReloaded.add(mbid);
this.similarReloaded.add(mbid);
if (this.topTracks.length === 0) this.loadingTracks = false;
if (this.topReleaseGroups.length === 0) this.loadingTopReleases = false;
if (this.releaseGroups.length === 0) this.loadingReleases = false;
if (this.similarArtists.length === 0) this.loadingSimilar = false;
}, 12000);
}
protected override firstUpdated() {
this.observeTopSectionWidth();
this.observeDiscoWidth();
@@ -1038,6 +1111,13 @@ export class ExploreArtistDetails extends LitElement {
return;
}
// Fresh load for this artist: allow the top sections one
// background-fetch re-fetch, and arm a fallback so they can't spin
// forever if ArtistDiscographyReady never arrives.
this.discogReloaded.delete(mbid);
this.similarReloaded.delete(mbid);
this.armDiscogFallback(mbid);
// Phase 1: fire all API requests independently so the UI
// renders each section as its data arrives, rather than
// waiting for the slowest call to finish.
@@ -1290,7 +1370,13 @@ export class ExploreArtistDetails extends LitElement {
`[explore-artist] TopRecordingsForArtist error: ${msg}`,
);
} finally {
this.loadingTracks = false;
// An empty first pass may mean a background discography fetch
// is still in flight (the artist wasn't indexed yet). Keep
// the loading state up until the ArtistDiscographyReady
// re-fetch runs, so the section doesn't flash empty.
if (this.topTracks.length > 0 || this.discogReloaded.has(mbid)) {
this.loadingTracks = false;
}
}
}
@@ -1367,6 +1453,10 @@ export class ExploreArtistDetails extends LitElement {
rgs?.map((r) => ({ mbid: r.releaseGroupMbid, albumName: r.title, artistName: r.artistName }))
?? [],
);
// Warm the release/tracklist cache for the top albums — these
// are the most likely to be clicked from the artist page.
this.prefetchReleases(rgs?.map((r) => r.releaseGroupMbid) ?? []);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(
@@ -1374,7 +1464,11 @@ export class ExploreArtistDetails extends LitElement {
);
this.topReleaseGroups = [];
} finally {
this.loadingTopReleases = false;
// See fetchTopTracks: hold the spinner while a background
// discography fetch may still populate this section.
if (this.topReleaseGroups.length > 0 || this.discogReloaded.has(mbid)) {
this.loadingTopReleases = false;
}
}
}
@@ -1392,6 +1486,10 @@ export class ExploreArtistDetails extends LitElement {
rgs?.map((r) => ({ mbid: r.mbid, albumName: r.title, artistName: r.artistCredit }))
?? [],
);
// Warm the release/tracklist cache for these albums so opening
// one from here is instant instead of a cold MB browse.
this.prefetchReleases(rgs?.map((r) => r.mbid) ?? []);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.errorReleases = msg;
@@ -1399,10 +1497,29 @@ export class ExploreArtistDetails extends LitElement {
`[explore-artist] BrowseReleaseGroups error: ${msg}`,
);
} finally {
this.loadingReleases = false;
// BrowseReleaseGroups is index-first + async: an empty result on
// a cold artist means the discography is still being fetched in
// the background. Hold the spinner until it arrives (via
// ArtistDiscographyReady) or the fallback fires.
if (this.releaseGroups.length > 0 || this.discogReloaded.has(mbid)) {
this.loadingReleases = false;
}
}
}
/**
* Warm the backend's release/tracklist cache for a set of release
* groups so opening an album from this page is instant. Fire-and-forget.
*/
private prefetchReleases(mbids: string[]) {
const filtered = mbids.filter((m) => m);
if (filtered.length === 0) return;
void PrefetchReleases(filtered).catch(() => {
/* best-effort cache warming — ignore failures */
});
}
private async fetchSimilarArtists(mbid: string) {
try {
const artists = await SimilarArtists(mbid);
@@ -1415,7 +1532,13 @@ export class ExploreArtistDetails extends LitElement {
);
this.similarArtists = [];
} finally {
this.loadingSimilar = false;
// SimilarArtists is DB-first + async: an empty result on the
// first view means the LB labs fetch is still running. Hold the
// spinner until ArtistSimilarReady re-fetches (or the fallback
// fires); once reloaded, an empty list is genuinely "none".
if (this.similarArtists.length > 0 || this.similarReloaded.has(mbid)) {
this.loadingSimilar = false;
}
}
// Fire-and-forget: resolve images for similar artists in parallel.
@@ -1973,7 +2096,7 @@ export class ExploreArtistDetails extends LitElement {
})()}
</div>
<div class="track-info">
<div class="track-title">${t.trackName}</div>
<div class="track-title">${trackLink(t.trackName, t.releaseName, t.releaseGroupMbid ?? '', t.recordingMbid)}</div>
<div class="track-artist">${t.artistName}</div>
</div>
<span class="track-listens">
@@ -1,74 +1,28 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query as litQuery } from 'lit/decorators.js';
import { designTokens } from '../../styles/tokens.css';
import { Search, GetThumbnail, GetThumbnails, GetArtistImageURL, GetPopularityBatch, RecordSearchClick } from '@go/explore/Service';
import { SearchLocal, SearchLyrics, GetThumbnail, GetThumbnails, GetArtistImageURL, RecordSearchClick } from '@go/explore/Service';
import { libraryStore } from '../../store/library-store';
import { exploreCache } from '../../store/explore-cache';
import { exploreSettings } from '../../store/explore-settings';
import { queueStore } from '../../store/queue-store';
import { artistLink, trackLink, exploreLinkStyles } from '../../utils/explore-link';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js';
import '../top-results-row/top-results-row.js';
import type { explore } from '@go/models';
import { explore } from '@go/models';
type ThumbnailRequest = explore.ThumbnailRequest;
type MBSearchResult = explore.MBSearchResult;
type LyricsResult = explore.LyricsResult;
type MBArtist = explore.MBArtist;
type MBReleaseGroup = explore.MBReleaseGroup;
type MBRecording = explore.MBRecording;
/* ── Constants ── */
const DEBOUNCE_MS = 300;
const MIN_QUERY_LENGTH = 2;
const FUZZY_MAX_DISTANCE = 2;
// Debounce window for live search-as-you-type. The index query is
// local (no network), so this only coalesces rapid keystrokes.
const SEARCH_DEBOUNCE_MS = 180;
/* ── 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) ||
(nw.length >= 3 && qw.includes(nw)) ||
(qw.length >= 4 && nw.length >= 4 && editDistance(qw, nw) <= FUZZY_MAX_DISTANCE),
),
);
}
const MAX_SECTION_RESULTS = 10;
@@ -98,27 +52,6 @@ function formatPopularity(count: number): string {
return `${count} plays`;
}
/**
* Check if `text` contains `word` as a whole word, bounded by
* spaces, hyphens, or string boundaries. Both args must be
* pre-lowercased.
*/
function containsWord(text: string, word: string): boolean {
const re = new RegExp(`(?:^|[\\s\\-])${escapeRegExp(word)}(?:$|[\\s\\-])`, 'i');
return re.test(text);
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Parse a TrackLength string (milliseconds as string) to number. */
function parseDuration(s: string): number {
if (!s) return 0;
const n = Number(s);
return isNaN(n) ? 0 : n;
}
/** Extract the year from a date string like "2005-03-29" or "2005". */
function extractYear(dateStr: string): string {
if (!dateStr) return '';
@@ -154,10 +87,15 @@ export class ExploreView extends LitElement {
@state() private loading = false;
@state() private error = '';
@state() private queryTooShort = false;
/** Which search surface is active: catalog (index) or lyrics. */
@state() private searchMode: 'catalog' | 'lyrics' = 'catalog';
/** Lyric-search hits (library tracks matched by lyric fragment). */
@state() private lyricsResults: LyricsResult[] | null = null;
/** Monotonic counter to discard stale responses. */
private searchVersion = 0;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
/** Debounce timer for live search-as-you-type. */
private searchDebounceTimer?: ReturnType<typeof setTimeout>;
private thumbnailCache = new Map<string, string>();
private artistImageCache = new Map<string, string>();
private libraryMBIDs = new Set<string>();
@@ -168,6 +106,7 @@ export class ExploreView extends LitElement {
static override styles = [
designTokens,
exploreLinkStyles,
css`
:host {
display: block;
@@ -177,6 +116,37 @@ export class ExploreView extends LitElement {
box-sizing: border-box;
}
/* ── Search mode tabs ── */
.search-mode-tabs {
display: flex;
gap: 4px;
margin-bottom: 10px;
}
.search-mode-tab {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: 1px solid transparent;
border-radius: 6px;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
padding: 5px 12px;
font-size: var(--yj-text-sm);
font-family: inherit;
transition: color 0.15s ease, background 0.15s ease;
}
.search-mode-tab:hover {
color: var(--yj-text-primary, #fff);
}
.search-mode-tab.active {
color: var(--yj-bg-base, #1a1a1a);
background: var(--yj-accent, #ffd43b);
}
/* ── Search input ── */
.search-container {
display: flex;
@@ -191,6 +161,66 @@ export class ExploreView extends LitElement {
transition: border-color 0.15s ease;
}
/* ── Lyrics results ── */
.lyrics-results {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 2px;
max-width: 640px;
}
.lyrics-hit {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
text-align: left;
background: none;
border: none;
border-radius: 6px;
color: var(--yj-text-primary, #fff);
cursor: pointer;
padding: 8px 10px;
font-family: inherit;
transition: background 0.12s ease;
}
.lyrics-hit:hover {
background: var(--yj-bg-surface, #212529);
}
.lyrics-hit-play {
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-icon-sm);
flex-shrink: 0;
}
.lyrics-hit:hover .lyrics-hit-play {
color: var(--yj-accent, #ffd43b);
}
.lyrics-hit-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.lyrics-hit-title {
font-size: var(--yj-text-md);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lyrics-hit-meta {
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #b3b3b3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search-container:focus-within {
border-color: var(--yj-accent, #ffd43b);
}
@@ -560,26 +590,10 @@ export class ExploreView extends LitElement {
/* ── Lifecycle ── */
private unsubSettings?: () => void;
override connectedCallback() {
super.connectedCallback();
// Re-render and re-search when library-only mode toggles.
this.unsubSettings = exploreSettings.subscribe(() => {
this.requestUpdate();
// Re-run the current search with the new mode.
if (this.searchQuery.trim().length >= MIN_QUERY_LENGTH) {
void this.executeSearch();
}
});
}
override disconnectedCallback() {
super.disconnectedCallback();
this.unsubSettings?.();
if (this.debounceTimer !== null) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
}
}
@@ -589,14 +603,10 @@ export class ExploreView extends LitElement {
const input = e.target as HTMLInputElement;
this.searchQuery = input.value;
if (this.debounceTimer !== null) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
const trimmed = this.searchQuery.trim();
if (!trimmed) {
this.cancelPendingSearch();
this.results = null;
this.error = '';
this.loading = false;
@@ -605,6 +615,7 @@ export class ExploreView extends LitElement {
}
if (trimmed.length < MIN_QUERY_LENGTH) {
this.cancelPendingSearch();
this.results = null;
this.error = '';
this.loading = false;
@@ -614,22 +625,57 @@ export class ExploreView extends LitElement {
this.queryTooShort = false;
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
// Both modes debounce straight to their backend search — catalog
// to the offline index (SearchLocal), lyrics to the FTS lyric
// search. No owned-library seed: the index is the sole source of
// catalog results, so we never paint temporary library matches.
this.scheduleSearch();
}
/** Switch between catalog and lyric search, resetting results. */
private setSearchMode(mode: 'catalog' | 'lyrics') {
if (this.searchMode === mode) return;
this.cancelPendingSearch();
this.searchMode = mode;
this.results = null;
this.lyricsResults = null;
this.error = '';
this.loading = false;
if (this.searchQuery.trim().length >= MIN_QUERY_LENGTH) {
void this.executeSearch();
}, DEBOUNCE_MS);
}
this.inputEl?.focus();
}
/** Debounce a live index search after the latest keystroke. */
private scheduleSearch() {
this.cancelPendingSearch();
this.searchDebounceTimer = setTimeout(() => {
this.searchDebounceTimer = undefined;
if (this.searchQuery.trim().length >= MIN_QUERY_LENGTH) {
void this.executeSearch();
}
}, SEARCH_DEBOUNCE_MS);
}
private cancelPendingSearch() {
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
this.searchDebounceTimer = undefined;
}
}
private handleClear() {
this.cancelPendingSearch();
this.searchQuery = '';
this.results = null;
this.lyricsResults = null;
this.error = '';
this.loading = false;
this.queryTooShort = false;
if (this.debounceTimer !== null) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
if (this.inputEl) {
this.inputEl.value = '';
this.inputEl.focus();
@@ -639,6 +685,17 @@ export class ExploreView extends LitElement {
private handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
this.handleClear();
return;
}
// Enter is optional now — search runs live as you type — but it
// still fires an immediate search, skipping the debounce wait.
if (e.key === 'Enter') {
e.preventDefault();
if (this.searchQuery.trim().length >= MIN_QUERY_LENGTH) {
this.cancelPendingSearch();
void this.executeSearch();
}
}
}
@@ -651,287 +708,18 @@ export class ExploreView extends LitElement {
this.error = '';
const startTime = performance.now();
console.log(`[explore] search started: "${query}"`);
console.log(`[explore] search started: "${query}" (${this.searchMode})`);
// Phase 1: instant library search — pure frontend, no Go calls.
const localResults = this.searchLibraryCache(query);
if (localResults && (localResults.artists?.length || localResults.releaseGroups?.length)) {
this.results = localResults;
exploreCache.populateFromSearch(
localResults.artists || [],
localResults.releaseGroups || [],
);
// Seed artist image cache from library data.
for (const a of localResults.artists || []) {
const img = (a as any)._imageMedium || (a as any)._imageSmall;
if (img && a.mbid) {
this.artistImageCache.set(a.mbid, img);
}
}
// Fallback: album art for artists without images.
for (const a of localResults.artists || []) {
if (a.mbid && !this.artistImageCache.get(a.mbid)) {
const albumArt = getArtistAlbumArt(a.name);
if (albumArt) {
this.artistImageCache.set(a.mbid, albumArt);
}
}
}
// In library-only mode, local results already have cover art
// and artist images from the library store — seed both caches
// from library data without making any API calls.
if (exploreSettings.libraryOnly) {
this.seedThumbnailsFromLibrary();
this.seedArtistImagesFromLibrary();
} else {
this.loadThumbnails();
this.loadArtistImages();
}
const elapsed = (performance.now() - startTime).toFixed(0);
console.log(
`[explore] library results: "${query}" in ${elapsed}ms — ` +
`artists=${localResults.artists?.length ?? 0}, ` +
`albums=${localResults.releaseGroups?.length ?? 0}`,
);
// Lyrics mode: a single FTS lyric search over the library.
if (this.searchMode === 'lyrics') {
void this.executeLyricsSearch(version, query, startTime);
return;
}
// Phase 2: full pipeline (MB + LB + reranking) via Wails RPC.
// Skip entirely in library-only mode — local results are final.
if (!exploreSettings.libraryOnly) {
void this.executeFullSearch(version, query, startTime);
} else {
// Rerank with popularity from the explore index, then finalize.
void this.rerankWithPopularity(localResults).then(() => {
this.loading = false;
});
}
}
/**
* Search the frontend library cache for matching artists and albums.
* Pure JS — no Go calls, guaranteed instant. Returns results with
* MBIDs and local cover art so they can navigate to explore pages.
*/
private searchLibraryCache(query: string): MBSearchResult | null {
const q = query.toLowerCase();
// Collect all matching artists with match-quality scores.
const artistMatches: Array<{ artist: any; score: number }> = [];
const cachedArtists = libraryStore.cachedArtists;
if (cachedArtists) {
for (const a of cachedArtists) {
const name = a.Name.toLowerCase();
if (!fuzzyMatch(q, name)) continue;
// Score by match quality.
let score: number;
if (name === q) {
score = 100; // exact
} else if (name.startsWith(q)) {
score = 90; // starts with
} else if (containsWord(name, q)) {
score = 75; // contains word
} else if (name.includes(q)) {
score = 60; // substring
} else {
score = 40; // fuzzy/word match
}
artistMatches.push({ artist: a, score });
}
}
// 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,
inLibrary: true,
localId: m.artist.ID,
_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;
if (cachedAlbums) {
for (const a of cachedAlbums) {
const name = a.Name.toLowerCase();
const artist = a.ArtistName.toLowerCase();
const matchesName = fuzzyMatch(q, name);
const matchesArtist = fuzzyMatch(q, artist);
if (!matchesName && !matchesArtist) continue;
let score: number;
if (artist === q) {
score = 100;
} else if (name === q) {
score = 95;
} else if (artist.startsWith(q)) {
score = 88;
} else if (name.startsWith(q)) {
score = 85;
} else if (containsWord(artist, q)) {
score = 78;
} else if (containsWord(name, q)) {
score = 75;
} else if (artist.includes(q)) {
score = 65;
} 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 }));
// Collect matching tracks by title or artist name.
const trackMatches: Array<{ track: any; score: number }> = [];
const cachedTracks = libraryStore.getCachedTracks();
if (cachedTracks) {
for (const t of cachedTracks) {
const title = t.TrackName.toLowerCase();
const artist = t.ArtistName.toLowerCase();
const matchesTitle = fuzzyMatch(q, title);
const matchesArtist = fuzzyMatch(q, artist);
if (!matchesTitle && !matchesArtist) continue;
let score: number;
if (title === q) {
score = 100;
} else if (artist === q) {
score = 95;
} else if (title.startsWith(q)) {
score = 88;
} else if (artist.startsWith(q)) {
score = 85;
} else if (containsWord(title, q)) {
score = 78;
} else if (containsWord(artist, q)) {
score = 75;
} else if (title.includes(q)) {
score = 65;
} else if (artist.includes(q)) {
score = 60;
} else {
score = 40;
}
trackMatches.push({ track: t, score });
}
}
trackMatches.sort((a, b) => b.score - a.score || a.track.TrackName.localeCompare(b.track.TrackName));
// Deduplicate by recording MBID (keep highest score).
const seenRecMBIDs = new Set<string>();
const recordings: MBRecording[] = [];
for (const m of trackMatches) {
if (recordings.length >= 15) break;
const mbid = m.track.RecordingMBID || '';
if (mbid && seenRecMBIDs.has(mbid)) continue;
if (mbid) seenRecMBIDs.add(mbid);
recordings.push({
mbid,
title: m.track.TrackName,
length: parseDuration(m.track.TrackLength),
artistCredit: m.track.ArtistName,
score: m.score,
} as MBRecording);
}
if (artists.length === 0 && releaseGroups.length === 0 && recordings.length === 0) {
return null;
}
return { artists, releaseGroups, recordings } as MBSearchResult;
}
/**
* Fetch LB popularity for all MBIDs in the result and re-sort
* each category using a blended score: match quality + log-scaled
* popularity. Same approach as the backend reranker.
*/
private async rerankWithPopularity(result: MBSearchResult | null): Promise<void> {
if (!result) return;
// Collect all non-empty MBIDs.
const mbids: string[] = [];
for (const a of result.artists ?? []) if (a.mbid) mbids.push(a.mbid);
for (const rg of result.releaseGroups ?? []) if (rg.mbid) mbids.push(rg.mbid);
for (const r of result.recordings ?? []) if (r.mbid) mbids.push(r.mbid);
if (mbids.length === 0) return;
let batch: Record<string, {popularity: number; inLibrary: boolean; similarityScore: number}>;
try {
batch = await GetPopularityBatch(mbids);
} catch {
return; // degrade gracefully — keep match-quality order
}
if (!batch || Object.keys(batch).length === 0) return;
// Weights matching backend: 0.35 relevance + 0.50 popularity + 0.15 personalization
const maxPop = Math.max(1, ...Object.values(batch).map(b => b.popularity));
const logMax = Math.log10(maxPop + 1);
const maxSim = Math.max(1, ...Object.values(batch).map(b => b.similarityScore || 0));
const blendedScore = (mbid: string, matchScore: number): number => {
const b = batch[mbid];
const relevance = matchScore / 100;
const logPop = b ? Math.log10(b.popularity + 1) / logMax : 0;
let personal = 0;
if (b?.inLibrary) {
personal = 1.0;
} else if (b?.similarityScore && maxSim > 0) {
personal = 0.5 * (b.similarityScore / maxSim);
}
return 0.35 * relevance + 0.50 * logPop + 0.15 * personal;
};
// Re-sort each category. Backend stamps a `score` field
// onto entries before returning them, but the Wails-
// generated MB types don't model it — cast through any to
// read it on the way to the comparator.
const cmp = (a: { mbid: string }, b: { mbid: string }): number =>
blendedScore(b.mbid, (b as any).score ?? 0) - blendedScore(a.mbid, (a as any).score ?? 0);
(result.artists ?? []).sort(cmp);
(result.releaseGroups ?? []).sort(cmp);
(result.recordings ?? []).sort(cmp);
// Trigger re-render. MBSearchResult is a Wails-generated
// class with bound methods (convertValues), so request an
// update directly rather than spreading the object — that
// would drop the methods.
this.results = result;
this.requestUpdate();
// Offline search over the local popularity index via Wails RPC.
// No network, and no owned-library seed — the index is the sole
// source of catalog results.
void this.executeIndexSearch(version, query, startTime);
}
/**
@@ -987,48 +775,19 @@ export class ExploreView extends LitElement {
return result;
}
/**
* Merge library-only results from this.results into the full
* search result. Adds local artists/albums that the MB search
* didn't find (by name dedup) so they aren't lost.
*/
private mergeLocalIntoFull(full: MBSearchResult) {
const prev = this.results;
if (!prev) return;
// Dedup artists by name (case-insensitive).
if (prev.artists?.length) {
const existing = new Set(
(full.artists || []).map((a) => a.name.toLowerCase()),
);
for (const a of prev.artists) {
if (!existing.has(a.name.toLowerCase())) {
full.artists = full.artists || [];
full.artists.push(a);
}
}
}
// Dedup albums by title + artist (case-insensitive).
if (prev.releaseGroups?.length) {
const existing = new Set(
(full.releaseGroups || []).map(
(rg: MBReleaseGroup) => `${rg.title}|${rg.artistCredit}`.toLowerCase(),
),
);
for (const rg of prev.releaseGroups) {
const key = `${rg.title}|${rg.artistCredit}`.toLowerCase();
if (!existing.has(key)) {
full.releaseGroups = full.releaseGroups || [];
full.releaseGroups.push(rg);
}
}
}
}
private async executeFullSearch(version: number, query: string, startTime: number) {
private async executeIndexSearch(version: number, query: string, startTime: number) {
try {
const result = await Search(query);
// Local FTS index only — no network. Returns null when the
// index has no hits, in which case we keep the owned-library
// matches already displayed.
const result =
(await SearchLocal(query)) ??
explore.MBSearchResult.createFrom({
artists: [],
releaseGroups: [],
recordings: [],
topResults: [],
});
// Discard stale response
if (version !== this.searchVersion) {
@@ -1038,32 +797,10 @@ export class ExploreView extends LitElement {
return;
}
const merged = this.mergeWithLibrary(result);
// If the full search returned results, use them.
// If it returned nothing but we had local results, keep those.
const hasFullResults =
(merged.artists?.length ?? 0) > 0 ||
(merged.releaseGroups?.length ?? 0) > 0 ||
(merged.recordings?.length ?? 0) > 0;
const hadLocalResults = this.results &&
((this.results.artists?.length ?? 0) > 0 ||
(this.results.releaseGroups?.length ?? 0) > 0 ||
(this.results.recordings?.length ?? 0) > 0);
if (hasFullResults) {
// Preserve any library-only artists/albums that the MB
// search didn't find (no MBID, or MB didn't match).
if (hadLocalResults) {
this.mergeLocalIntoFull(merged);
}
this.results = merged;
} else if (!hadLocalResults) {
// Both local and full are empty — show empty state.
this.results = merged;
}
// else: keep existing local results as-is.
// Enrich index results with local cover art / "In Library"
// badges, but never inject library-only entries — the index
// is the sole source of results shown.
this.results = this.mergeWithLibrary(result);
exploreCache.populateFromSearch(
this.results?.artists || [],
@@ -1092,6 +829,37 @@ export class ExploreView extends LitElement {
}
}
/* ── Lyrics Search ── */
private async executeLyricsSearch(version: number, query: string, startTime: number) {
try {
const hits = await SearchLyrics(query);
if (version !== this.searchVersion) return;
this.lyricsResults = hits ?? [];
const elapsed = (performance.now() - startTime).toFixed(0);
console.log(
`[explore] lyrics search: "${query}" in ${elapsed}ms — ` +
`hits=${this.lyricsResults.length}`,
);
} catch (err) {
if (version !== this.searchVersion) return;
this.error = err instanceof Error ? err.message : String(err);
console.error(`[explore] lyrics search error: "${query}" — ${this.error}`);
} finally {
if (version === this.searchVersion) {
this.loading = false;
}
}
}
/** Play a lyric-search hit immediately (replaces the queue). */
private playLyricHit(hit: LyricsResult) {
if (!hit.filePath) return;
queueStore.setQueue([hit.filePath], 0);
}
/* ── Thumbnail Loading ── */
private thumbnailBatchPending = false;
@@ -1446,9 +1214,26 @@ export class ExploreView extends LitElement {
);
break;
case 'recording':
// Navigate to the album page if we can resolve it,
// otherwise navigate to the artist.
if (r.artistCredit) {
// Standard track-click behaviour (matches the library list,
// queue, playlists, etc.): open the track's album page with
// the track highlighted. The backend resolves the parent
// release group from the local index, so this is an instant,
// index-backed load rather than a name-only artist lookup.
if (r.releaseGroupMbid) {
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'explore-album-details',
releaseGroupMBID: r.releaseGroupMbid,
albumName: r.releaseName || '',
highlightTrackMBID: r.mbid,
},
}),
);
} else if (r.artistCredit) {
// Fallback only when the album can't be resolved locally.
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
@@ -1496,12 +1281,33 @@ export class ExploreView extends LitElement {
}
private renderSearchInput() {
const placeholder =
this.searchMode === 'lyrics'
? 'Search by a lyric\u2026'
: 'Search artists, albums, and tracks\u2026';
return html`
<div class="search-mode-tabs">
<button
class="search-mode-tab ${this.searchMode === 'catalog' ? 'active' : ''}"
@click=${() => this.setSearchMode('catalog')}
>
<wa-icon name="magnifying-glass"></wa-icon>
Catalog
</button>
<button
class="search-mode-tab ${this.searchMode === 'lyrics' ? 'active' : ''}"
@click=${() => this.setSearchMode('lyrics')}
>
<wa-icon name="quote-left"></wa-icon>
Lyrics
</button>
</div>
<div class="search-container">
<wa-icon class="search-icon" name="magnifying-glass"></wa-icon>
<input
type="text"
placeholder="Search MusicBrainz\u2026"
placeholder=${placeholder}
.value=${this.searchQuery}
@input=${this.handleInput}
@keydown=${this.handleKeydown}
@@ -1520,7 +1326,57 @@ export class ExploreView extends LitElement {
`;
}
private renderLyricsBody() {
if (this.queryTooShort) {
return html`<div class="status-message">Keep typing…</div>`;
}
if (!this.searchQuery.trim() && !this.lyricsResults) {
return html`<div class="status-message">
Type a line of lyrics to find the track in your library.
</div>`;
}
if (this.loading && !this.lyricsResults) return nothing;
if (this.lyricsResults && this.lyricsResults.length === 0) {
return html`<div class="status-message">
No tracks with lyrics matching “${this.searchQuery}”.
</div>`;
}
if (!this.lyricsResults) return nothing;
return html`
<div class="lyrics-results">
${this.lyricsResults.map(
(hit) => html`
<button
class="lyrics-hit"
@click=${() => this.playLyricHit(hit)}
title="Play ${hit.title}"
>
<wa-icon class="lyrics-hit-play" name="play"></wa-icon>
<span class="lyrics-hit-main">
<span class="lyrics-hit-title">${hit.title || 'Unknown title'}</span>
<span class="lyrics-hit-meta">
${hit.artist || 'Unknown artist'}${hit.album
? html` · ${hit.album}`
: nothing}
</span>
</span>
</button>
`,
)}
</div>
`;
}
private renderBody() {
if (this.searchMode === 'lyrics') {
return this.renderLyricsBody();
}
// Query too short
if (this.queryTooShort) {
return html`<div class="status-message">
@@ -1531,7 +1387,7 @@ export class ExploreView extends LitElement {
// No query entered yet
if (!this.searchQuery.trim() && !this.results) {
return html`<div class="status-message">
Search MusicBrainz to discover artists, albums, and tracks.
Search to discover artists, albums, and tracks.
</div>`;
}
@@ -1673,7 +1529,7 @@ export class ExploreView extends LitElement {
<div class="album-title" title="${rg.title}">
${rg.title}
</div>
<div class="album-artist">${rg.artistCredit}</div>
<div class="album-artist">${artistLink(rg.artistCredit, rg.artistMbid ?? '')}</div>
<div class="album-meta">
<div class="album-meta-text">
${rg.primaryType
@@ -1706,9 +1562,11 @@ export class ExploreView extends LitElement {
(r) => html`
<div class="track-item">
<div class="track-info">
<div class="track-title">${r.title}</div>
<div class="track-title">
${trackLink(r.title, r.releaseName ?? '', r.releaseGroupMbid ?? '', r.mbid)}
</div>
<div class="track-artist">
${r.artistCredit}
${artistLink(r.artistCredit, r.artistMbid ?? '')}
</div>
</div>
<div class="track-meta">
File diff suppressed because it is too large Load Diff
@@ -8,13 +8,14 @@ import '@components/combobox/combobox.ts';
// ── Field / Operator constants ──────────────────────────────────────
/** All 16 fields matching the backend `fieldMap` keys. */
/** All fields matching the backend `fieldMap` keys. */
const FIELDS: string[] = [
'title',
'artist',
'album',
'genre',
'year',
'release_year',
'composer',
'file_type',
'duration',
@@ -32,6 +33,7 @@ const FIELDS: string[] = [
const NUMERIC_FIELDS = new Set([
'year',
'release_year',
'duration',
'sample_rate',
'bit_depth',
@@ -63,7 +65,16 @@ const NUMERIC_OPERATORS = [
'between',
];
const SORT_FIELDS = ['title', 'artist', 'album', 'year', 'duration', 'play_count', 'random'];
const SORT_FIELDS = [
'title',
'artist',
'album',
'year',
'release_year',
'duration',
'play_count',
'random',
];
// ── Helpers ─────────────────────────────────────────────────────────
@@ -103,7 +114,12 @@ function getAutocompleteOptions(field: string): string[] {
if (!tracks) return [];
return [...new Set(tracks.map((t) => t.FileType).filter(Boolean))];
}
case 'year': {
case 'year':
case 'release_year': {
// Both year fields draw suggestions from the set of years
// present in the library. The cached Track only carries the
// display (original) year, so it seeds both datalists — the
// list is just a hint, and the real filter runs server-side.
const tracks = libraryStore.getCachedTracks();
if (!tracks) return [];
return [
@@ -120,8 +136,21 @@ function getAutocompleteOptions(field: string): string[] {
}
}
/**
* Overrides for fields whose title-cased name would be ambiguous. The
* two year fields in particular need to disambiguate the album's
* original release from the specific (possibly reissue) release owned.
*/
const FIELD_LABEL_OVERRIDES: Record<string, string> = {
year: 'Year (Original Release)',
release_year: 'Year (This Release)',
};
/** Format a field name for display: `file_type` → "File Type". */
function formatFieldLabel(field: string): string {
const override = FIELD_LABEL_OVERRIDES[field];
if (override) return override;
return field
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
@@ -9,6 +9,7 @@ import {
} from '@go/explore/Service';
import '../library-status-indicator/library-status-indicator.js';
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js';
import { artistLink, exploreLinkStyles } from '../../utils/explore-link';
/** Format milliseconds as m:ss. */
function formatDuration(ms: number | undefined): string {
@@ -51,6 +52,7 @@ export class TopResultsRow extends LitElement {
static override styles = [
designTokens,
exploreLinkStyles,
css`
:host {
display: block;
@@ -242,13 +244,16 @@ export class TopResultsRow extends LitElement {
const imgUrl = this.images.get(r.mbid);
const isArtist = r.entityType === 'artist';
const subtitle = isArtist
? [r.artistType, r.country].filter(Boolean).join(' · ') || ''
// The artist portion of the subtitle links to the artist page;
// the remaining metadata (type/country, year, duration) is plain
// text. Artist cards have no artist credit — their whole subtitle
// is metadata.
const artistPart = isArtist ? '' : r.artistCredit || '';
const metaPart = isArtist
? [r.artistType, r.country].filter(Boolean).join(' · ')
: r.entityType === 'release_group'
? [r.artistCredit, r.year].filter(Boolean).join(' · ')
: [r.artistCredit, formatDuration(r.length)]
.filter(Boolean)
.join(' · ');
? r.year || ''
: formatDuration(r.length) || '';
const status: LibraryStatus = r.inLibrary ? 'in-library' : 'not-in-library';
const entityType: 'artist' | 'album' | 'track' =
@@ -280,9 +285,13 @@ export class TopResultsRow extends LitElement {
</div>`}
<div class="card-info">
<span class="card-name">${r.name}</span>
${subtitle
${artistPart || metaPart
? html`<span class="card-subtitle"
>${subtitle}</span
>${artistPart
? artistLink(artistPart, r.artistMbid ?? '')
: nothing}${artistPart && metaPart
? ' · '
: ''}${metaPart}</span
>`
: nothing}
</div>