From d73226b173d2214c5a6788fc994cd439bb7db111 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Mon, 30 Mar 2026 15:36:37 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Library=20Only=20mode=20=E2=80=94=20tog?= =?UTF-8?q?gle,=20search,=20artist=20page,=20similar=20artists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Migration 17: similar_artist_map table stores per-artist similar artist relationships (source_mbid → similar_mbid + name + score) - Tier 4 index build now persists similar artists to this table - GetLibrarySimilarArtists(mbid) queries similar artists filtered by JOIN with the artists table (library-only, no API calls) - Added db field to explore.Service for direct queries Frontend: - ExploreSettingsStore with libraryOnly toggle, persisted to localStorage - Top bar toggle button with active/inactive styling - Explore search: skips full MB/LB pipeline when library-only, uses only searchLibraryCache (pure JS, instant) - Artist detail page: in library-only mode, skips all API calls (no top tracks, no top releases, no LB play count, no MB artist lookup). Uses library store for discography, calls GetLibrarySimilarArtists for similar artists. - Similar artists section: changed from horizontal scroll to wrapping flex layout with collapsible toggle (Show all N) - Removed debug artist ranking log --- backend/database/database.go | 45 +++++++++++++ backend/explore/explore.go | 47 ++++++++++---- backend/explore/searchindex.go | 34 ++++++++++ frontend/index.css | 31 +++++++++ frontend/index.html | 4 ++ frontend/index.ts | 22 +++++++ .../explore-artist-details.ts | 64 ++++++++++++++++--- .../components/explore-view/explore-view.ts | 8 ++- frontend/src/store/explore-settings.ts | 41 ++++++++++++ frontend/wailsjs/go/explore/Service.d.ts | 2 + frontend/wailsjs/go/explore/Service.js | 4 ++ 11 files changed, 278 insertions(+), 24 deletions(-) create mode 100644 frontend/src/store/explore-settings.ts 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`

Similar Artists

-
+
${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); }