feat: Library Only mode — toggle, search, artist page, similar artists
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
This commit is contained in:
@@ -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 {
|
||||
|
||||
+33
-14
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
<h1 class="title">YellowJacket</h1>
|
||||
<h3 class="subtitle">Music how it was meant to bee.</h3>
|
||||
</hgroup>
|
||||
<button id="library-only-toggle" class="library-only-toggle" title="Library Only mode">
|
||||
<wa-icon name="book"></wa-icon>
|
||||
<span>Library Only</span>
|
||||
</button>
|
||||
<library-filter></library-filter>
|
||||
<search-bar></search-bar>
|
||||
</header>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
@state() private similarExpanded = false;
|
||||
private libraryMBIDs = new Set<string>();
|
||||
|
||||
/* ── 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`<div class="artist-native-name">${this.artist.name}</div>`
|
||||
: nothing}
|
||||
${this.renderArtistMeta()}
|
||||
${this.artistPlayCount > 0
|
||||
${this.artistPlayCount > 0 && !exploreSettings.libraryOnly
|
||||
? html`<span class="artist-meta">${formatListenCount(this.artistPlayCount)} plays on ListenBrainz</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
@@ -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`
|
||||
<section>
|
||||
<h3 class="section-header">Similar Artists</h3>
|
||||
<div class="horizontal-row">
|
||||
<div class="similar-row ${collapsed ? 'collapsed' : ''}">
|
||||
${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 {
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
${showToggle
|
||||
? html`
|
||||
<button
|
||||
class="disco-toggle"
|
||||
aria-expanded="${this.similarExpanded}"
|
||||
@click=${() => { this.similarExpanded = !this.similarExpanded; }}
|
||||
>
|
||||
${this.similarExpanded
|
||||
? 'Show less'
|
||||
: `Show all ${this.similarArtists.length}`}
|
||||
<wa-icon name="chevron-down"></wa-icon>
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Listener>();
|
||||
|
||||
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();
|
||||
+2
@@ -21,6 +21,8 @@ export function GetArtistMBID(arg1:string):Promise<string>;
|
||||
|
||||
export function GetArtistPlayCount(arg1:string):Promise<number>;
|
||||
|
||||
export function GetLibrarySimilarArtists(arg1:string):Promise<Array<explore.LBSimilarArtist>>;
|
||||
|
||||
export function GetThumbnail(arg1:string,arg2:string,arg3:string):Promise<string>;
|
||||
|
||||
export function GetThumbnails(arg1:Array<explore.ThumbnailRequest>):Promise<Record<string, string>>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user