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.
This commit is contained in:
@@ -49,6 +49,15 @@ type CoverArt struct {
|
|||||||
MimeType string
|
MimeType string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExploreCache struct {
|
||||||
|
UrlKey string
|
||||||
|
Response string
|
||||||
|
Mbid sql.NullString
|
||||||
|
EntityType sql.NullString
|
||||||
|
ExpiresAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type FileType struct {
|
type FileType struct {
|
||||||
ID int64
|
ID int64
|
||||||
Extension string
|
Extension string
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Service struct {
|
|||||||
index *SearchIndex
|
index *SearchIndex
|
||||||
artProxy *CoverArtProxy
|
artProxy *CoverArtProxy
|
||||||
artistImg *ArtistImageProvider
|
artistImg *ArtistImageProvider
|
||||||
|
libMBID *LibraryMBIDIndex
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
}
|
}
|
||||||
@@ -41,6 +42,7 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
|
|||||||
db, cache, NewRateLimiter(), logger.WithGroup("artist-image"),
|
db, cache, NewRateLimiter(), logger.WithGroup("artist-image"),
|
||||||
)
|
)
|
||||||
index := NewSearchIndex(db, lb, artistImg, logger.WithGroup("search-index"))
|
index := NewSearchIndex(db, lb, artistImg, logger.WithGroup("search-index"))
|
||||||
|
libMBID := NewLibraryMBIDIndex(db)
|
||||||
|
|
||||||
logger.Info("explore service created")
|
logger.Info("explore service created")
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
|
|||||||
index: index,
|
index: index,
|
||||||
artProxy: artProxy,
|
artProxy: artProxy,
|
||||||
artistImg: artistImg,
|
artistImg: artistImg,
|
||||||
|
libMBID: libMBID,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
}
|
}
|
||||||
@@ -198,6 +201,19 @@ func (e *Service) GetArtistImageURL(artistMBID string) string {
|
|||||||
return e.artistImg.GetArtistImage(artistMBID)
|
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
|
// Search concurrently queries MusicBrainz for artists, release
|
||||||
// groups, and recordings matching the query, then boosts results
|
// groups, and recordings matching the query, then boosts results
|
||||||
// using ListenBrainz popularity data. The final score blends
|
// using ListenBrainz popularity data. The final score blends
|
||||||
|
|||||||
@@ -683,8 +683,11 @@ func (si *SearchIndex) buildTier3Library(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read local library artist names.
|
// Read local library artists — prefer direct MBIDs from tags,
|
||||||
libRows, err := si.db.QueryContext("SELECT DISTINCT name FROM artists")
|
// fall back to name matching against the sitewide/index map.
|
||||||
|
libRows, err := si.db.QueryContext(
|
||||||
|
"SELECT DISTINCT name, mbid FROM artists",
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
si.logger.Warn("search index: library artists query failed", "error", err)
|
si.logger.Warn("search index: library artists query failed", "error", err)
|
||||||
|
|
||||||
@@ -693,18 +696,35 @@ func (si *SearchIndex) buildTier3Library(
|
|||||||
|
|
||||||
defer func() { _ = libRows.Close() }()
|
defer func() { _ = libRows.Close() }()
|
||||||
|
|
||||||
// Collect MBIDs for matched library artists.
|
|
||||||
var matched []lbSitewideArtist
|
var matched []lbSitewideArtist
|
||||||
|
|
||||||
var resolvedMBIDs []string
|
var resolvedMBIDs []string
|
||||||
|
|
||||||
for libRows.Next() {
|
for libRows.Next() {
|
||||||
var name string
|
var name string
|
||||||
if err := libRows.Scan(&name); err != nil {
|
|
||||||
|
var mbidPtr *string
|
||||||
|
|
||||||
|
if err := libRows.Scan(&name, &mbidPtr); err != nil {
|
||||||
continue
|
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)
|
normalized := strings.ToLower(name)
|
||||||
if idx := strings.Index(normalized, " feat."); idx >= 0 {
|
if idx := strings.Index(normalized, " feat."); idx >= 0 {
|
||||||
normalized = normalized[:idx]
|
normalized = normalized[:idx]
|
||||||
@@ -719,7 +739,6 @@ func (si *SearchIndex) buildTier3Library(
|
|||||||
if a, ok := nameMap[normalized]; ok {
|
if a, ok := nameMap[normalized]; ok {
|
||||||
resolvedMBIDs = append(resolvedMBIDs, a.ArtistMBID)
|
resolvedMBIDs = append(resolvedMBIDs, a.ArtistMBID)
|
||||||
|
|
||||||
// Only index if not already in the index from Tier 2.
|
|
||||||
if !indexed[a.ArtistMBID] {
|
if !indexed[a.ArtistMBID] {
|
||||||
matched = append(matched, a)
|
matched = append(matched, a)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from 'lit/decorators.js';
|
} from 'lit/decorators.js';
|
||||||
import { library } from '@go/models';
|
import { library } from '@go/models';
|
||||||
import { LibraryController } from '@store/controllers/library-controller';
|
import { LibraryController } from '@store/controllers/library-controller';
|
||||||
|
import { GetArtistImageURL, GetArtistMBID } from '@go/explore/Service';
|
||||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
import '@components/cover-grid/cover-grid.js';
|
import '@components/cover-grid/cover-grid.js';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
@@ -18,12 +19,18 @@ export class ArtistDetails extends LitElement {
|
|||||||
@property({ type: String, attribute: 'artist-name' })
|
@property({ type: String, attribute: 'artist-name' })
|
||||||
artistName = '';
|
artistName = '';
|
||||||
|
|
||||||
|
@property({ type: String, attribute: 'artist-mbid' })
|
||||||
|
artistMBID = '';
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private albums: library.Album[] = [];
|
private albums: library.Album[] = [];
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private loading = true;
|
private loading = true;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private artistImageURL = '';
|
||||||
|
|
||||||
private libraryCtrl = new LibraryController(this);
|
private libraryCtrl = new LibraryController(this);
|
||||||
|
|
||||||
/** Tracks the store's cached array reference to detect refreshes. */
|
/** Tracks the store's cached array reference to detect refreshes. */
|
||||||
@@ -99,6 +106,12 @@ export class ArtistDetails extends LitElement {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.artist-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.artist-avatar .initial {
|
.artist-avatar .initial {
|
||||||
color: var(
|
color: var(
|
||||||
--yj-text-secondary,
|
--yj-text-secondary,
|
||||||
@@ -156,6 +169,7 @@ export class ArtistDetails extends LitElement {
|
|||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.loadAlbums();
|
this.loadAlbums();
|
||||||
|
this.loadArtistImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
override updated() {
|
override updated() {
|
||||||
@@ -174,6 +188,31 @@ export class ArtistDetails extends LitElement {
|
|||||||
* Data loading
|
* 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() {
|
private async loadAlbums() {
|
||||||
if (!this.artistId) return;
|
if (!this.artistId) return;
|
||||||
|
|
||||||
@@ -293,11 +332,14 @@ export class ArtistDetails extends LitElement {
|
|||||||
></wa-icon>
|
></wa-icon>
|
||||||
</button>
|
</button>
|
||||||
<div class="artist-avatar">
|
<div class="artist-avatar">
|
||||||
<span class="initial">
|
${this.artistImageURL
|
||||||
${this.getInitial(
|
? html`<img
|
||||||
this.artistName,
|
src="${this.artistImageURL}"
|
||||||
)}
|
alt="${this.artistName}"
|
||||||
</span>
|
/>`
|
||||||
|
: html`<span class="initial">
|
||||||
|
${this.getInitial(this.artistName)}
|
||||||
|
</span>`}
|
||||||
</div>
|
</div>
|
||||||
<div class="artist-info">
|
<div class="artist-info">
|
||||||
<h1
|
<h1
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { LitElement, html, css, nothing } from 'lit';
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
import { customElement, state, query as litQuery } from 'lit/decorators.js';
|
import { customElement, state, query as litQuery } from 'lit/decorators.js';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
import { Search, GetThumbnails, GetArtistImageURL } from '@go/explore/Service';
|
import { Search, GetThumbnails, GetArtistImageURL, CheckLibraryMBIDs } from '@go/explore/Service';
|
||||||
import type { ThumbnailRequest } from '@go/explore/Service';
|
import type { ThumbnailRequest } from '@go/explore/Service';
|
||||||
import type {
|
import type {
|
||||||
MBSearchResult,
|
MBSearchResult,
|
||||||
@@ -77,6 +77,7 @@ export class ExploreView extends LitElement {
|
|||||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private thumbnailCache = new Map<string, string>();
|
private thumbnailCache = new Map<string, string>();
|
||||||
private artistImageCache = new Map<string, string>();
|
private artistImageCache = new Map<string, string>();
|
||||||
|
private libraryMBIDs = new Set<string>();
|
||||||
|
|
||||||
@litQuery('input') private inputEl!: HTMLInputElement;
|
@litQuery('input') private inputEl!: HTMLInputElement;
|
||||||
|
|
||||||
@@ -432,6 +433,16 @@ export class ExploreView extends LitElement {
|
|||||||
white-space: nowrap;
|
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 ── */
|
||||||
.track-list {
|
.track-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -577,6 +588,7 @@ export class ExploreView extends LitElement {
|
|||||||
this.results = result;
|
this.results = result;
|
||||||
this.loadThumbnails();
|
this.loadThumbnails();
|
||||||
this.loadArtistImages();
|
this.loadArtistImages();
|
||||||
|
this.checkLibrary();
|
||||||
|
|
||||||
const elapsed = (performance.now() - startTime).toFixed(0);
|
const elapsed = (performance.now() - startTime).toFixed(0);
|
||||||
console.log(
|
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 ── */
|
/* ── Top Results ── */
|
||||||
|
|
||||||
private getTopResults(): ScoredItem[] {
|
private getTopResults(): ScoredItem[] {
|
||||||
@@ -966,6 +1011,9 @@ export class ExploreView extends LitElement {
|
|||||||
${a.country}
|
${a.country}
|
||||||
</div>`
|
</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
${this.libraryMBIDs.has(a.mbid)
|
||||||
|
? html`<div class="library-badge">In Library</div>`
|
||||||
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
@@ -1016,6 +1064,9 @@ export class ExploreView extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
<div class="album-artist">${rg.artistCredit}</div>
|
<div class="album-artist">${rg.artistCredit}</div>
|
||||||
<div class="album-meta">
|
<div class="album-meta">
|
||||||
|
${this.libraryMBIDs.has(rg.mbid)
|
||||||
|
? html`<span class="library-badge">In Library</span>`
|
||||||
|
: nothing}
|
||||||
${rg.primaryType
|
${rg.primaryType
|
||||||
? html`<span class="type-badge"
|
? html`<span class="type-badge"
|
||||||
>${rg.primaryType}</span
|
>${rg.primaryType}</span
|
||||||
|
|||||||
+3
@@ -35,3 +35,6 @@ export function SimilarArtists(arg1:string):Promise<Array<explore.LBSimilarArtis
|
|||||||
|
|
||||||
export function TopRecordingsForArtist(arg1:string):Promise<Array<explore.LBTopRecording>>;
|
export function TopRecordingsForArtist(arg1:string):Promise<Array<explore.LBTopRecording>>;
|
||||||
|
|
||||||
|
export function CheckLibraryMBIDs(arg1:string[]):Promise<Record<string, string>>;
|
||||||
|
|
||||||
|
export function GetArtistMBID(arg1:string):Promise<string>;
|
||||||
|
|||||||
@@ -65,3 +65,11 @@ export function SimilarArtists(arg1) {
|
|||||||
export function TopRecordingsForArtist(arg1) {
|
export function TopRecordingsForArtist(arg1) {
|
||||||
return window['go']['explore']['Service']['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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -196,6 +196,23 @@ export namespace explore {
|
|||||||
return a;
|
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"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user