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:
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>
|
||||
|
||||
Reference in New Issue
Block a user