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:
2026-03-30 15:36:37 -04:00
parent 6440b0333c
commit d73226b173
11 changed files with 278 additions and 24 deletions
+33 -14
View File
@@ -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)