diff --git a/backend/database/database.go b/backend/database/database.go
index 0277c59..a8db68c 100644
--- a/backend/database/database.go
+++ b/backend/database/database.go
@@ -409,6 +409,14 @@ func runMigrations(
}
}
+ if version < 17 { //nolint:mnd
+ if err := migration17SimilarArtistMap(
+ ctx, db, logger,
+ ); err != nil {
+ return err
+ }
+ }
+
return nil
}
@@ -1833,6 +1841,43 @@ func migration16ArtistImages(
return nil
}
+func migration17SimilarArtistMap(
+ ctx context.Context,
+ db *sql.DB,
+ logger *slog.Logger,
+) error {
+ logger.Info("applying migration 17: similar_artist_map table")
+
+ if _, err := db.ExecContext(ctx, `
+ CREATE TABLE IF NOT EXISTS similar_artist_map (
+ source_artist_mbid TEXT NOT NULL,
+ similar_artist_mbid TEXT NOT NULL,
+ similar_artist_name TEXT NOT NULL,
+ score INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (source_artist_mbid, similar_artist_mbid)
+ )
+ `); err != nil {
+ return fmt.Errorf("migration 17: create similar_artist_map: %w", err)
+ }
+
+ if _, err := db.ExecContext(ctx, `
+ CREATE INDEX IF NOT EXISTS idx_similar_artist_map_source
+ ON similar_artist_map(source_artist_mbid)
+ `); err != nil {
+ return fmt.Errorf("migration 17: create source index: %w", err)
+ }
+
+ if _, err := db.ExecContext(ctx,
+ "PRAGMA user_version = 17",
+ ); err != nil {
+ return fmt.Errorf("could not set user_version to 17: %w", err)
+ }
+
+ logger.Info("migration 17 complete")
+
+ return nil
+}
+
// readLibraryDirFromTOML reads the TOML config file and returns
// the Library.DirectoryPath value, or "" if not configured.
func readLibraryDirFromTOML(logger *slog.Logger) string {
diff --git a/backend/explore/explore.go b/backend/explore/explore.go
index 950e133..3c402bc 100644
--- a/backend/explore/explore.go
+++ b/backend/explore/explore.go
@@ -26,6 +26,7 @@ type Service struct {
artProxy *CoverArtProxy
artistImg *ArtistImageProvider
libMBID *LibraryMBIDIndex
+ db *database.DB
logger *slog.Logger
ctx context.Context
}
@@ -61,6 +62,7 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
artProxy: artProxy,
artistImg: artistImg,
libMBID: libMBID,
+ db: db,
logger: logger,
ctx: context.Background(),
}
@@ -235,6 +237,37 @@ func (e *Service) GetArtistPlayCount(artistMBID string) int {
return pop[artistMBID]
}
+// GetLibrarySimilarArtists returns similar artists to the given
+// MBID that are also in the user's local library. Uses the
+// pre-computed similar_artist_map table (populated during Tier 4
+// index build) joined with the artists table. No API calls.
+func (e *Service) GetLibrarySimilarArtists(artistMBID string) []LBSimilarArtist {
+ rows, err := e.db.QueryContext(`
+ SELECT s.similar_artist_mbid, s.similar_artist_name, s.score
+ FROM similar_artist_map s
+ JOIN artists a ON a.mbid = s.similar_artist_mbid
+ WHERE s.source_artist_mbid = ?
+ ORDER BY s.score DESC
+ `, artistMBID)
+ if err != nil {
+ return nil
+ }
+
+ defer func() { _ = rows.Close() }()
+
+ var result []LBSimilarArtist
+
+ for rows.Next() {
+ var a LBSimilarArtist
+
+ if err := rows.Scan(&a.ArtistMBID, &a.Name, &a.Score); err == nil {
+ result = append(result, a)
+ }
+ }
+
+ return result
+}
+
// ---------------------------------------------------------------------------
// Cover Art Archive
// ---------------------------------------------------------------------------
@@ -582,20 +615,6 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
// even when The Beatles have vastly more listens.
e.boostNameMatches(query, &result)
- // Debug: log artist scores before filtering.
- if len(result.Artists) > 0 {
- for i, a := range result.Artists {
- if i < 20 {
- e.logger.Info("search artist ranking",
- "pos", i+1,
- "name", a.Name,
- "score", a.Score,
- "mbid", a.MBID[:8],
- )
- }
- }
- }
-
// Phase 6: filter low-scoring results and cap counts.
filterAndCap(&result)
diff --git a/backend/explore/searchindex.go b/backend/explore/searchindex.go
index 89e03c3..f768a19 100644
--- a/backend/explore/searchindex.go
+++ b/backend/explore/searchindex.go
@@ -1060,6 +1060,9 @@ func (si *SearchIndex) buildTier4Similar(
similar := si.fetchSimilarArtists(ctx, artistMBID)
+ // Persist the similar artist relationships.
+ si.storeSimilarArtists(artistMBID, similar)
+
mu.Lock()
for _, s := range similar {
@@ -1637,6 +1640,37 @@ func (si *SearchIndex) markInLibrary(artists []lbSitewideArtist) {
}
}
+// storeSimilarArtists persists the similar artist relationships
+// for a source artist into the similar_artist_map table.
+func (si *SearchIndex) storeSimilarArtists(sourceMBID string, similar []lbSimilarArtistWire) {
+ if len(similar) == 0 {
+ return
+ }
+
+ tx, err := si.db.BeginTx()
+ if err != nil {
+ return
+ }
+
+ defer func() { _ = tx.Rollback() }()
+
+ // Clear existing entries for this source to avoid stale data.
+ _, _ = tx.Exec(
+ "DELETE FROM similar_artist_map WHERE source_artist_mbid = ?",
+ sourceMBID,
+ )
+
+ for _, s := range similar {
+ _, _ = tx.Exec(`
+ INSERT OR IGNORE INTO similar_artist_map
+ (source_artist_mbid, similar_artist_mbid, similar_artist_name, score)
+ VALUES (?, ?, ?, ?)
+ `, sourceMBID, s.ArtistMBID, s.Name, s.Score)
+ }
+
+ _ = tx.Commit()
+}
+
// markSimilar sets is_similar=1 for all index entries whose
// artist_mbid matches one of the given artists.
func (si *SearchIndex) markSimilar(artists []lbSitewideArtist) {
diff --git a/frontend/index.css b/frontend/index.css
index 4ca74a9..b4b5a71 100644
--- a/frontend/index.css
+++ b/frontend/index.css
@@ -40,6 +40,37 @@ p {
flex: 0 1 320px;
}
+.library-only-toggle {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ border: 1px solid var(--yj-border-subtle, rgba(255, 255, 255, 0.12));
+ border-radius: 6px;
+ background: transparent;
+ color: var(--yj-text-secondary, #b3b3b3);
+ font-size: 12px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.library-only-toggle:hover {
+ background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
+ color: var(--yj-text-primary, #fff);
+}
+
+.library-only-toggle.active {
+ background: var(--yj-accent, #ffd43b);
+ color: #000;
+ border-color: var(--yj-accent, #ffd43b);
+}
+
+.library-only-toggle wa-icon {
+ font-size: 14px;
+}
+
ul {
list-style-type: none;
}
diff --git a/frontend/index.html b/frontend/index.html
index 81c7bfb..5944bd7 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -15,6 +15,10 @@
YellowJacket
Music how it was meant to bee.
+
diff --git a/frontend/index.ts b/frontend/index.ts
index 1148729..fedd470 100644
--- a/frontend/index.ts
+++ b/frontend/index.ts
@@ -32,6 +32,7 @@ import '@store/theme-store';
// Importing the keyboard shortcut service triggers initialization:
// registers the document keydown listener for global shortcuts.
import './src/services/keyboard-shortcut-service';
+import { exploreSettings } from '@store/explore-settings';
import {
hasTrackPayload,
getDragPayload,
@@ -268,3 +269,24 @@ if (queueButton && queuePanel) {
// or timing assumptions needed.
void Player.EmitCurrentState();
void Queue.EmitCurrentState();
+
+// ---------------------------------------------------------------------------
+// Library Only toggle
+// ---------------------------------------------------------------------------
+const libraryOnlyToggle = document.getElementById('library-only-toggle');
+
+if (libraryOnlyToggle) {
+ // Sync initial state.
+ if (exploreSettings.libraryOnly) {
+ libraryOnlyToggle.classList.add('active');
+ }
+
+ libraryOnlyToggle.addEventListener('click', () => {
+ exploreSettings.toggle();
+ libraryOnlyToggle.classList.toggle('active', exploreSettings.libraryOnly);
+ });
+
+ exploreSettings.subscribe(() => {
+ libraryOnlyToggle.classList.toggle('active', exploreSettings.libraryOnly);
+ });
+}
diff --git a/frontend/src/components/explore-artist-details/explore-artist-details.ts b/frontend/src/components/explore-artist-details/explore-artist-details.ts
index 9c7b0bd..317ebb6 100644
--- a/frontend/src/components/explore-artist-details/explore-artist-details.ts
+++ b/frontend/src/components/explore-artist-details/explore-artist-details.ts
@@ -9,6 +9,7 @@ import {
SimilarArtists,
GetArtistImageURL,
GetArtistPlayCount,
+ GetLibrarySimilarArtists,
CheckLibraryMBIDs,
} from '@go/explore/Service';
import type {
@@ -19,6 +20,7 @@ import type {
LBSimilarArtist,
} from '@go/explore/Service';
import { exploreCache } from '../../store/explore-cache';
+import { exploreSettings } from '../../store/explore-settings';
import { libraryStore } from '../../store/library-store';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
@@ -102,6 +104,7 @@ export class ExploreArtistDetails extends LitElement {
@state() private topSectionExpanded = false;
@state() private artistPlayCount = 0;
@state() private expandedDiscoGroups = new Set();
+ @state() private similarExpanded = false;
private libraryMBIDs = new Set();
/* ── Styles ── */
@@ -601,16 +604,15 @@ export class ExploreArtistDetails extends LitElement {
}
/* ── Similar artists ── */
- .horizontal-row {
+ .similar-row {
display: flex;
+ flex-wrap: wrap;
gap: 12px;
- overflow-x: auto;
- padding-bottom: 4px;
- scrollbar-width: none;
+ overflow: hidden;
}
- .horizontal-row::-webkit-scrollbar {
- display: none;
+ .similar-row.collapsed {
+ max-height: 130px;
}
.similar-artist-card {
@@ -693,6 +695,31 @@ export class ExploreArtistDetails extends LitElement {
// Phase 0: hydrate from caches (instant, no Go calls).
this.hydrateFromCache(mbid);
+ if (exploreSettings.libraryOnly) {
+ // Library-only mode: no external API calls.
+ // Discography comes from library store (already hydrated).
+ // Similar artists from pre-computed DB table.
+ this.loadingArtist = false;
+ this.loadingTracks = false;
+ this.loadingTopReleases = false;
+ this.loadingReleases = false;
+ this.loadingSimilar = false;
+
+ // Fetch library-only similar artists (single Go call, no external API).
+ try {
+ const similar = await GetLibrarySimilarArtists(mbid);
+ this.similarArtists = similar ?? [];
+ } catch {
+ this.similarArtists = [];
+ }
+
+ console.log(
+ `[explore-artist] loaded (library-only): "${this.artistName}"`,
+ );
+
+ return;
+ }
+
// Phase 1: fire all API requests in parallel.
const [artistResult, tracksResult, topReleasesResult, releasesResult, similarResult] =
await Promise.allSettled([
@@ -1079,7 +1106,7 @@ export class ExploreArtistDetails extends LitElement {
? html`${this.artist.name}
`
: nothing}
${this.renderArtistMeta()}
- ${this.artistPlayCount > 0
+ ${this.artistPlayCount > 0 && !exploreSettings.libraryOnly
? html`${formatListenCount(this.artistPlayCount)} plays on ListenBrainz`
: nothing}
@@ -1140,6 +1167,9 @@ export class ExploreArtistDetails extends LitElement {
}
private renderTopSection() {
+ // Library-only mode: no top tracks/releases from LB.
+ if (exploreSettings.libraryOnly) return nothing;
+
const hasTracks = !this.loadingTracks && this.topTracks.length > 0;
const hasReleases = !this.loadingTopReleases && this.topReleaseGroups.length > 0;
const tracksLoading = this.loadingTracks;
@@ -1408,15 +1438,17 @@ export class ExploreArtistDetails extends LitElement {
/* ── Similar Artists Section ── */
private renderSimilarArtists() {
- // D024: when loading or empty/null, simply omit the section.
if (this.loadingSimilar || this.similarArtists.length === 0) {
return nothing;
}
+ const showToggle = this.similarArtists.length > 6;
+ const collapsed = !this.similarExpanded && showToggle;
+
return html`
-
+
${this.similarArtists.map((a) => {
const hue = nameToHue(a.name);
const imgURL = this.similarImageURLs.get(a.artistMbid);
@@ -1458,6 +1490,20 @@ export class ExploreArtistDetails extends LitElement {
`;
})}
+ ${showToggle
+ ? html`
+
+ `
+ : nothing}
`;
}
diff --git a/frontend/src/components/explore-view/explore-view.ts b/frontend/src/components/explore-view/explore-view.ts
index e2cba94..ba28422 100644
--- a/frontend/src/components/explore-view/explore-view.ts
+++ b/frontend/src/components/explore-view/explore-view.ts
@@ -11,6 +11,7 @@ import type {
} from '@go/explore/Service';
import { libraryStore } from '../../store/library-store';
import { exploreCache } from '../../store/explore-cache';
+import { exploreSettings } from '../../store/explore-settings';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
/* ── Constants ── */
@@ -592,7 +593,12 @@ export class ExploreView extends LitElement {
}
// Phase 2: full pipeline (MB + LB + reranking) via Wails RPC.
- void this.executeFullSearch(version, query, startTime);
+ // Skip entirely in library-only mode — local results are final.
+ if (!exploreSettings.libraryOnly) {
+ void this.executeFullSearch(version, query, startTime);
+ } else {
+ this.loading = false;
+ }
}
/**
diff --git a/frontend/src/store/explore-settings.ts b/frontend/src/store/explore-settings.ts
new file mode 100644
index 0000000..84ad8b3
--- /dev/null
+++ b/frontend/src/store/explore-settings.ts
@@ -0,0 +1,41 @@
+/**
+ * ExploreSettingsStore — global settings for the explore feature.
+ * Persists to localStorage so the toggle state survives restarts.
+ */
+
+type Listener = () => void;
+
+class ExploreSettingsStore {
+ private _libraryOnly: boolean;
+ private listeners = new Set
();
+
+ constructor() {
+ this._libraryOnly = localStorage.getItem('explore:libraryOnly') === 'true';
+ }
+
+ get libraryOnly(): boolean {
+ return this._libraryOnly;
+ }
+
+ setLibraryOnly(value: boolean) {
+ if (this._libraryOnly === value) return;
+ this._libraryOnly = value;
+ localStorage.setItem('explore:libraryOnly', String(value));
+ this.notify();
+ }
+
+ toggle() {
+ this.setLibraryOnly(!this._libraryOnly);
+ }
+
+ subscribe(fn: Listener): () => void {
+ this.listeners.add(fn);
+ return () => this.listeners.delete(fn);
+ }
+
+ private notify() {
+ for (const fn of this.listeners) fn();
+ }
+}
+
+export const exploreSettings = new ExploreSettingsStore();
diff --git a/frontend/wailsjs/go/explore/Service.d.ts b/frontend/wailsjs/go/explore/Service.d.ts
index fc4e35c..bbbf045 100755
--- a/frontend/wailsjs/go/explore/Service.d.ts
+++ b/frontend/wailsjs/go/explore/Service.d.ts
@@ -21,6 +21,8 @@ export function GetArtistMBID(arg1:string):Promise;
export function GetArtistPlayCount(arg1:string):Promise;
+export function GetLibrarySimilarArtists(arg1:string):Promise>;
+
export function GetThumbnail(arg1:string,arg2:string,arg3:string):Promise;
export function GetThumbnails(arg1:Array):Promise>;
diff --git a/frontend/wailsjs/go/explore/Service.js b/frontend/wailsjs/go/explore/Service.js
index 2a7fb22..adb8c90 100755
--- a/frontend/wailsjs/go/explore/Service.js
+++ b/frontend/wailsjs/go/explore/Service.js
@@ -38,6 +38,10 @@ export function GetArtistPlayCount(arg1) {
return window['go']['explore']['Service']['GetArtistPlayCount'](arg1);
}
+export function GetLibrarySimilarArtists(arg1) {
+ return window['go']['explore']['Service']['GetLibrarySimilarArtists'](arg1);
+}
+
export function GetThumbnail(arg1, arg2, arg3) {
return window['go']['explore']['Service']['GetThumbnail'](arg1, arg2, arg3);
}