From 8f6a4c6a8e4d1588ffbb732aa490ffffd356532b Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Thu, 26 Mar 2026 09:34:57 -0400 Subject: [PATCH] feat: 'In Library' badges, artist images on local pages, MBID-based Tier 3 Three features wired together: 1. 'In Library' badges on explore search results: CheckLibraryMBIDs Wails binding batch-checks which search result MBIDs exist in the local library. Green badges render on matching artist cards and album cards. 2. Artist images on local artist-details page: Local artist pages now call GetArtistMBID(name) to resolve the MBID from tags, then GetArtistImageURL(mbid) to fetch the cached Wikimedia photo. Falls back to initial-letter avatar. 3. Tier 3 search index uses direct MBIDs from tags: buildTier3Library now reads artists.mbid column (from audio tags) for direct MBID matching, falling back to name matching for untagged artists. Eliminates false matches and catches artists that name matching misses. --- backend/database/sql/sqlcgen/models.go | 9 ++++ backend/explore/explore.go | 16 ++++++ backend/explore/searchindex.go | 31 ++++++++--- .../artist-details/artist-details.ts | 52 ++++++++++++++++-- .../components/explore-view/explore-view.ts | 53 ++++++++++++++++++- frontend/wailsjs/go/explore/Service.d.ts | 3 ++ frontend/wailsjs/go/explore/Service.js | 8 +++ frontend/wailsjs/go/models.ts | 17 ++++++ 8 files changed, 177 insertions(+), 12 deletions(-) mode change 100644 => 100755 frontend/wailsjs/go/explore/Service.d.ts diff --git a/backend/database/sql/sqlcgen/models.go b/backend/database/sql/sqlcgen/models.go index e860e49..9df7e55 100644 --- a/backend/database/sql/sqlcgen/models.go +++ b/backend/database/sql/sqlcgen/models.go @@ -49,6 +49,15 @@ type CoverArt struct { MimeType string } +type ExploreCache struct { + UrlKey string + Response string + Mbid sql.NullString + EntityType sql.NullString + ExpiresAt time.Time + CreatedAt time.Time +} + type FileType struct { ID int64 Extension string diff --git a/backend/explore/explore.go b/backend/explore/explore.go index 42785d3..b98b5f8 100644 --- a/backend/explore/explore.go +++ b/backend/explore/explore.go @@ -24,6 +24,7 @@ type Service struct { index *SearchIndex artProxy *CoverArtProxy artistImg *ArtistImageProvider + libMBID *LibraryMBIDIndex logger *slog.Logger ctx context.Context } @@ -41,6 +42,7 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service { db, cache, NewRateLimiter(), logger.WithGroup("artist-image"), ) index := NewSearchIndex(db, lb, artistImg, logger.WithGroup("search-index")) + libMBID := NewLibraryMBIDIndex(db) logger.Info("explore service created") @@ -51,6 +53,7 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service { index: index, artProxy: artProxy, artistImg: artistImg, + libMBID: libMBID, logger: logger, ctx: context.Background(), } @@ -198,6 +201,19 @@ func (e *Service) GetArtistImageURL(artistMBID string) string { return e.artistImg.GetArtistImage(artistMBID) } +// CheckLibraryMBIDs returns which of the given MBIDs exist in the +// local music library. Returns a map of MBID → entity type +// ("artist", "release_group", "recording"). +func (e *Service) CheckLibraryMBIDs(mbids []string) map[string]string { + return e.libMBID.CheckMBIDs(mbids) +} + +// GetArtistMBID returns the MusicBrainz ID for a local library +// artist by name, or "" if not found or no MBID tagged. +func (e *Service) GetArtistMBID(artistName string) string { + return e.libMBID.GetArtistMBID(artistName) +} + // Search concurrently queries MusicBrainz for artists, release // groups, and recordings matching the query, then boosts results // using ListenBrainz popularity data. The final score blends diff --git a/backend/explore/searchindex.go b/backend/explore/searchindex.go index be20e01..ededa88 100644 --- a/backend/explore/searchindex.go +++ b/backend/explore/searchindex.go @@ -683,8 +683,11 @@ func (si *SearchIndex) buildTier3Library( } } - // Read local library artist names. - libRows, err := si.db.QueryContext("SELECT DISTINCT name FROM artists") + // Read local library artists — prefer direct MBIDs from tags, + // fall back to name matching against the sitewide/index map. + libRows, err := si.db.QueryContext( + "SELECT DISTINCT name, mbid FROM artists", + ) if err != nil { si.logger.Warn("search index: library artists query failed", "error", err) @@ -693,18 +696,35 @@ func (si *SearchIndex) buildTier3Library( defer func() { _ = libRows.Close() }() - // Collect MBIDs for matched library artists. var matched []lbSitewideArtist var resolvedMBIDs []string for libRows.Next() { var name string - if err := libRows.Scan(&name); err != nil { + + var mbidPtr *string + + if err := libRows.Scan(&name, &mbidPtr); err != nil { continue } - // Normalize: strip "feat." suffixes. + // Direct MBID from tags — most reliable. + if mbidPtr != nil && *mbidPtr != "" { + mbid := *mbidPtr + resolvedMBIDs = append(resolvedMBIDs, mbid) + + if !indexed[mbid] { + matched = append(matched, lbSitewideArtist{ + ArtistMBID: mbid, + ArtistName: name, + }) + } + + continue + } + + // Fall back to name matching. normalized := strings.ToLower(name) if idx := strings.Index(normalized, " feat."); idx >= 0 { normalized = normalized[:idx] @@ -719,7 +739,6 @@ func (si *SearchIndex) buildTier3Library( if a, ok := nameMap[normalized]; ok { resolvedMBIDs = append(resolvedMBIDs, a.ArtistMBID) - // Only index if not already in the index from Tier 2. if !indexed[a.ArtistMBID] { matched = append(matched, a) } diff --git a/frontend/src/components/artist-details/artist-details.ts b/frontend/src/components/artist-details/artist-details.ts index 4aca04e..5180d26 100644 --- a/frontend/src/components/artist-details/artist-details.ts +++ b/frontend/src/components/artist-details/artist-details.ts @@ -6,6 +6,7 @@ import { } from 'lit/decorators.js'; import { library } from '@go/models'; import { LibraryController } from '@store/controllers/library-controller'; +import { GetArtistImageURL, GetArtistMBID } from '@go/explore/Service'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@components/cover-grid/cover-grid.js'; import { designTokens } from '../../styles/tokens.css'; @@ -18,12 +19,18 @@ export class ArtistDetails extends LitElement { @property({ type: String, attribute: 'artist-name' }) artistName = ''; + @property({ type: String, attribute: 'artist-mbid' }) + artistMBID = ''; + @state() private albums: library.Album[] = []; @state() private loading = true; + @state() + private artistImageURL = ''; + private libraryCtrl = new LibraryController(this); /** Tracks the store's cached array reference to detect refreshes. */ @@ -99,6 +106,12 @@ export class ArtistDetails extends LitElement { flex-shrink: 0; } + .artist-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + } + .artist-avatar .initial { color: var( --yj-text-secondary, @@ -156,6 +169,7 @@ export class ArtistDetails extends LitElement { override connectedCallback() { super.connectedCallback(); this.loadAlbums(); + this.loadArtistImage(); } override updated() { @@ -174,6 +188,31 @@ export class ArtistDetails extends LitElement { * Data loading * ================================================================ */ + private async loadArtistImage() { + // Resolve MBID from tags if not provided via attribute. + let mbid = this.artistMBID; + + if (!mbid && this.artistName) { + try { + mbid = await GetArtistMBID(this.artistName); + } catch { + return; + } + } + + if (!mbid) return; + + try { + const url = await GetArtistImageURL(mbid); + + if (url) { + this.artistImageURL = url; + } + } catch { + // No image — avatar stays as initial letter. + } + } + private async loadAlbums() { if (!this.artistId) return; @@ -293,11 +332,14 @@ export class ArtistDetails extends LitElement { >
- - ${this.getInitial( - this.artistName, - )} - + ${this.artistImageURL + ? html`${this.artistName}` + : html` + ${this.getInitial(this.artistName)} + `}

| null = null; private thumbnailCache = new Map(); private artistImageCache = new Map(); + private libraryMBIDs = new Set(); @litQuery('input') private inputEl!: HTMLInputElement; @@ -432,6 +433,16 @@ export class ExploreView extends LitElement { white-space: nowrap; } + .library-badge { + background: var(--yj-accent, #1db954); + color: #000; + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + white-space: nowrap; + } + /* ── Track list ── */ .track-list { display: flex; @@ -577,6 +588,7 @@ export class ExploreView extends LitElement { this.results = result; this.loadThumbnails(); this.loadArtistImages(); + this.checkLibrary(); const elapsed = (performance.now() - startTime).toFixed(0); console.log( @@ -688,6 +700,39 @@ export class ExploreView extends LitElement { } } + /** + * Check which result MBIDs exist in the local library. + */ + private async checkLibrary() { + if (!this.results) return; + + const mbids: string[] = []; + + for (const a of this.results.artists ?? []) { + if (a.mbid) mbids.push(a.mbid); + } + + for (const rg of this.results.releaseGroups ?? []) { + if (rg.mbid) mbids.push(rg.mbid); + } + + if (mbids.length === 0) return; + + try { + const found = await CheckLibraryMBIDs(mbids); + + if (found && Object.keys(found).length > 0) { + for (const mbid of Object.keys(found)) { + this.libraryMBIDs.add(mbid); + } + + this.requestUpdate(); + } + } catch { + // Library check is non-critical. + } + } + /* ── Top Results ── */ private getTopResults(): ScoredItem[] { @@ -966,6 +1011,9 @@ export class ExploreView extends LitElement { ${a.country}

` : nothing} + ${this.libraryMBIDs.has(a.mbid) + ? html`
In Library
` + : nothing} `; })} @@ -1016,6 +1064,9 @@ export class ExploreView extends LitElement {
${rg.artistCredit}
+ ${this.libraryMBIDs.has(rg.mbid) + ? html`In Library` + : nothing} ${rg.primaryType ? html`${rg.primaryType}>; +export function CheckLibraryMBIDs(arg1:string[]):Promise>; + +export function GetArtistMBID(arg1:string):Promise; diff --git a/frontend/wailsjs/go/explore/Service.js b/frontend/wailsjs/go/explore/Service.js index 6dd4138..dee8239 100755 --- a/frontend/wailsjs/go/explore/Service.js +++ b/frontend/wailsjs/go/explore/Service.js @@ -65,3 +65,11 @@ export function SimilarArtists(arg1) { export function TopRecordingsForArtist(arg1) { return window['go']['explore']['Service']['TopRecordingsForArtist'](arg1); } + +export function CheckLibraryMBIDs(arg1) { + return window['go']['explore']['Service']['CheckLibraryMBIDs'](arg1); +} + +export function GetArtistMBID(arg1) { + return window['go']['explore']['Service']['GetArtistMBID'](arg1); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 45e7717..8b5deaa 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -196,6 +196,23 @@ export namespace explore { return a; } } + + export class ThumbnailRequest { + mbid: string; + albumName: string; + artistName: string; + + static createFrom(source: any = {}) { + return new ThumbnailRequest(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.mbid = source["mbid"]; + this.albumName = source["albumName"]; + this.artistName = source["artistName"]; + } + } }