diff --git a/backend/explore/artistimage.go b/backend/explore/artistimage.go new file mode 100644 index 0000000..bf85c76 --- /dev/null +++ b/backend/explore/artistimage.go @@ -0,0 +1,327 @@ +package explore + +import ( + "context" + "crypto/md5" //nolint:gosec // MD5 used for Wikimedia URL hashing, not security + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" +) + +// ErrArtistImage is returned when an artist image HTTP fetch fails. +var ErrArtistImage = errors.New("artist image fetch failed") + +const ( + // wikimediaThumbBase is the base URL for Wikimedia Commons + // thumbnail generation. + wikimediaThumbBase = "https://upload.wikimedia.org/wikipedia/commons/thumb" + + // wikidataAPIBase is the base URL for the Wikidata API. + wikidataAPIBase = "https://www.wikidata.org/w/api.php" + + // artistImageSize is the default thumbnail width in pixels. + artistImageSize = 250 + + // artistImageTimeout is the HTTP timeout for image URL lookups. + artistImageTimeout = 10 * time.Second +) + +// ArtistImageProvider resolves artist MBIDs to image URLs. It +// checks multiple sources in priority order and caches results in +// the explore_cache. Designed to be extended with additional +// sources (fanart.tv, etc.) by adding to the providers slice. +type ArtistImageProvider struct { + mb *MusicBrainzClient + cache *Cache + client *http.Client + logger *slog.Logger +} + +// NewArtistImageProvider creates a provider that resolves artist +// images via MusicBrainz relationships and Wikidata. +func NewArtistImageProvider( + mb *MusicBrainzClient, + cache *Cache, + logger *slog.Logger, +) *ArtistImageProvider { + return &ArtistImageProvider{ + mb: mb, + cache: cache, + client: &http.Client{Timeout: artistImageTimeout}, + logger: logger, + } +} + +// GetArtistImageURL returns a Wikimedia Commons thumbnail URL for +// the given artist MBID, or "" if no image is available. Results +// are cached in explore_cache with a 30-day TTL. +func (p *ArtistImageProvider) GetArtistImageURL(artistMBID string) string { + if artistMBID == "" { + return "" + } + + cacheKey := "artist-image:" + artistMBID + + // Check cache. + if data, ok := p.cache.Get(cacheKey); ok { + return string(data) + } + + // Resolve image URL. + imageURL := p.resolve(artistMBID) + + // Cache the result (even empty string = no image found). + cacheTTL := 30 * 24 * time.Hour + p.cache.Set(cacheKey, []byte(imageURL), cacheTTL, artistMBID, "artist") + + return imageURL +} + +// resolve tries each source in order and returns the first image +// URL found. +func (p *ArtistImageProvider) resolve(artistMBID string) string { + // Source 1: MB direct image relation (Commons wiki page link). + if url := p.fromMBImageRelation(artistMBID); url != "" { + return url + } + + // Source 2: MB wikidata relation → Wikidata P18 → Commons thumb. + if url := p.fromWikidata(artistMBID); url != "" { + return url + } + + // No image found from any source. + return "" +} + +// --------------------------------------------------------------------------- +// Source 1: MB direct image relation +// --------------------------------------------------------------------------- + +// fromMBImageRelation checks the artist's MB url-rels for a direct +// "image" type pointing to Wikimedia Commons. +func (p *ArtistImageProvider) fromMBImageRelation(artistMBID string) string { + ctx := context.Background() + + artist, err := p.mb.LookupArtist(ctx, artistMBID) + if err != nil || artist == nil { + return "" + } + + // The LookupArtist doesn't include rels in our current wrapper. + // We need the raw MB data with url-rels. Check if there's a + // cached response that includes relations. + cacheKey := "mb:artist-rels:" + artistMBID + + if data, ok := p.cache.Get(cacheKey); ok { + return p.parseImageFromRels(data) + } + + // Fetch with url-rels included. + url := fmt.Sprintf( + "https://musicbrainz.org/ws/2/artist/%s?fmt=json&inc=url-rels", + artistMBID, + ) + + body, err := p.fetchURL(ctx, url) + if err != nil { + return "" + } + + // Cache the response. + cacheTTL := 30 * 24 * time.Hour + p.cache.Set(cacheKey, body, cacheTTL, artistMBID, "artist") + + return p.parseImageFromRels(body) +} + +func (p *ArtistImageProvider) parseImageFromRels(data []byte) string { + var mb struct { + Relations []struct { + Type string `json:"type"` + URL struct { + Resource string `json:"resource"` + } `json:"url"` + } `json:"relations"` + } + + if err := json.Unmarshal(data, &mb); err != nil { + return "" + } + + for _, rel := range mb.Relations { + if rel.Type != "image" { + continue + } + + resource := rel.URL.Resource + + // Direct Commons file link: "https://commons.wikimedia.org/wiki/File:Name.jpg" + if strings.Contains(resource, "commons.wikimedia.org/wiki/File:") { + filename := resource[strings.LastIndex(resource, "File:")+5:] + + return wikimediaThumbURL(filename) + } + } + + return "" +} + +// --------------------------------------------------------------------------- +// Source 2: Wikidata P18 +// --------------------------------------------------------------------------- + +// fromWikidata looks up the artist's Wikidata Q-ID from MB rels, +// then fetches the P18 (image) property from Wikidata. +func (p *ArtistImageProvider) fromWikidata(artistMBID string) string { + // Get the wikidata Q-ID from cached MB rels. + qid := p.getWikidataQID(artistMBID) + if qid == "" { + return "" + } + + // Check cache for Wikidata image. + cacheKey := "wikidata-image:" + qid + + if data, ok := p.cache.Get(cacheKey); ok { + return string(data) + } + + // Fetch P18 from Wikidata API. + ctx := context.Background() + url := fmt.Sprintf( + "%s?action=wbgetclaims&entity=%s&property=P18&format=json", + wikidataAPIBase, qid, + ) + + body, err := p.fetchURL(ctx, url) + if err != nil { + return "" + } + + var wd struct { + Claims struct { + P18 []struct { + Mainsnak struct { + Datavalue struct { + Value string `json:"value"` + } `json:"datavalue"` + } `json:"mainsnak"` + } `json:"P18"` + } `json:"claims"` + } + + if err := json.Unmarshal(body, &wd); err != nil || len(wd.Claims.P18) == 0 { + // Cache empty result. + cacheTTL := 30 * 24 * time.Hour + p.cache.Set(cacheKey, []byte(""), cacheTTL, "", "") + + return "" + } + + filename := strings.ReplaceAll(wd.Claims.P18[0].Mainsnak.Datavalue.Value, " ", "_") + thumbURL := wikimediaThumbURL(filename) + + cacheTTL := 30 * 24 * time.Hour + p.cache.Set(cacheKey, []byte(thumbURL), cacheTTL, "", "") + + return thumbURL +} + +func (p *ArtistImageProvider) getWikidataQID(artistMBID string) string { + cacheKey := "mb:artist-rels:" + artistMBID + + data, ok := p.cache.Get(cacheKey) + if !ok { + // Need to fetch rels — fromMBImageRelation should have + // populated this, but if not, fetch now. + ctx := context.Background() + url := fmt.Sprintf( + "https://musicbrainz.org/ws/2/artist/%s?fmt=json&inc=url-rels", + artistMBID, + ) + + var err error + + data, err = p.fetchURL(ctx, url) + if err != nil { + return "" + } + + cacheTTL := 30 * 24 * time.Hour + p.cache.Set(cacheKey, data, cacheTTL, artistMBID, "artist") + } + + var mb struct { + Relations []struct { + Type string `json:"type"` + URL struct { + Resource string `json:"resource"` + } `json:"url"` + } `json:"relations"` + } + + if err := json.Unmarshal(data, &mb); err != nil { + return "" + } + + for _, rel := range mb.Relations { + if rel.Type == "wikidata" { + parts := strings.Split(rel.URL.Resource, "/") + + return parts[len(parts)-1] + } + } + + return "" +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// wikimediaThumbURL constructs a Wikimedia Commons thumbnail URL +// from a filename. The URL scheme uses MD5 hashing of the filename +// for directory bucketing. +func wikimediaThumbURL(filename string) string { + if filename == "" { + return "" + } + + filename = strings.ReplaceAll(filename, " ", "_") + + hash := fmt.Sprintf("%x", md5.Sum([]byte(filename))) //nolint:gosec + h1 := string(hash[0]) + h2 := hash[:2] + + return fmt.Sprintf("%s/%s/%s/%s/%dpx-%s", + wikimediaThumbBase, h1, h2, filename, artistImageSize, filename, + ) +} + +func (p *ArtistImageProvider) fetchURL(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", lbUserAgent) + + resp, err := p.client.Do(req) + if err != nil { + return nil, err + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: HTTP %d", ErrArtistImage, resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} diff --git a/backend/explore/explore.go b/backend/explore/explore.go index 2a52a0c..133f70d 100644 --- a/backend/explore/explore.go +++ b/backend/explore/explore.go @@ -18,13 +18,14 @@ import ( // response cache. Its exported methods form the binding surface // that the frontend calls via generated TypeScript stubs. type Service struct { - mb *MusicBrainzClient - lb *ListenBrainzClient - cache *Cache - index *SearchIndex - artProxy *CoverArtProxy - logger *slog.Logger - ctx context.Context + mb *MusicBrainzClient + lb *ListenBrainzClient + cache *Cache + index *SearchIndex + artProxy *CoverArtProxy + artistImg *ArtistImageProvider + logger *slog.Logger + ctx context.Context } // NewExploreService creates a Service backed by the given @@ -37,17 +38,19 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service { lb := NewListenBrainzClient(limiter, cache, logger.WithGroup("listenbrainz")) index := NewSearchIndex(db, lb, logger.WithGroup("search-index")) artProxy := NewCoverArtProxy(db, limiter) + artistImg := NewArtistImageProvider(mb, cache, logger.WithGroup("artist-image")) logger.Info("explore service created") return &Service{ - mb: mb, - lb: lb, - cache: cache, - index: index, - artProxy: artProxy, - logger: logger, - ctx: context.Background(), + mb: mb, + lb: lb, + cache: cache, + index: index, + artProxy: artProxy, + artistImg: artistImg, + logger: logger, + ctx: context.Background(), } } @@ -185,6 +188,14 @@ func (e *Service) GetThumbnails(requests []ThumbnailRequest) map[string]string { return result } +// GetArtistImageURL returns a Wikimedia Commons thumbnail URL for +// the given artist MBID. Resolved via MB url-rels → Wikidata P18 +// → Commons thumb URL. Cached for 30 days. Returns "" if no +// image is available. +func (e *Service) GetArtistImageURL(artistMBID string) string { + return e.artistImg.GetArtistImageURL(artistMBID) +} + // 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/frontend/src/components/explore-artist-details/explore-artist-details.ts b/frontend/src/components/explore-artist-details/explore-artist-details.ts index e02dcf8..5999dd4 100644 --- a/frontend/src/components/explore-artist-details/explore-artist-details.ts +++ b/frontend/src/components/explore-artist-details/explore-artist-details.ts @@ -6,6 +6,7 @@ import { BrowseReleaseGroups, TopRecordingsForArtist, SimilarArtists, + GetArtistImageURL, } from '@go/explore/Service'; import type { MBArtist, @@ -88,6 +89,7 @@ export class ExploreArtistDetails extends LitElement { @state() private errorReleases = ''; @state() private similarArtists: LBSimilarArtist[] = []; @state() private loadingSimilar = true; + @state() private artistImageURL = ''; /* ── Styles ── */ @@ -149,6 +151,13 @@ export class ExploreArtistDetails extends LitElement { user-select: none; flex-shrink: 0; line-height: 1; + overflow: hidden; + } + + .artist-avatar img { + width: 100%; + height: 100%; + object-fit: cover; } .artist-info { @@ -484,7 +493,7 @@ export class ExploreArtistDetails extends LitElement { `[explore-artist] loading: "${this.artistName}" (${mbid})`, ); - // Fire all four requests in parallel — each section is independent. + // Fire all five requests in parallel — each section is independent. const [artistResult, tracksResult, releasesResult, similarResult] = await Promise.allSettled([ this.fetchArtist(mbid), @@ -493,6 +502,9 @@ export class ExploreArtistDetails extends LitElement { this.fetchSimilarArtists(mbid), ]); + // Artist image is fire-and-forget — doesn't block the page. + this.fetchArtistImage(mbid); + const summary = [ `artist=${artistResult.status}`, `tracks=${tracksResult.status}`, @@ -562,6 +574,17 @@ export class ExploreArtistDetails extends LitElement { } } + private async fetchArtistImage(mbid: string) { + try { + const url = await GetArtistImageURL(mbid); + if (url) { + this.artistImageURL = url; + } + } catch { + // No image available — avatar stays as initial letter. + } + } + /* ── Navigation ── */ private navigateBack() { @@ -709,7 +732,12 @@ export class ExploreArtistDetails extends LitElement { class="artist-avatar" style="background: hsl(${hue}, 45%, 35%)" > - ${this.getInitial(this.displayName)} + ${this.artistImageURL + ? html`${this.displayName}` + : this.getInitial(this.displayName)}

diff --git a/frontend/src/components/explore-view/explore-view.ts b/frontend/src/components/explore-view/explore-view.ts index 35f0aa2..eab26ef 100644 --- a/frontend/src/components/explore-view/explore-view.ts +++ b/frontend/src/components/explore-view/explore-view.ts @@ -1,7 +1,7 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, state, query as litQuery } from 'lit/decorators.js'; import { designTokens } from '../../styles/tokens.css'; -import { Search, GetThumbnails } from '@go/explore/Service'; +import { Search, GetThumbnails, GetArtistImageURL } from '@go/explore/Service'; import type { ThumbnailRequest } from '@go/explore/Service'; import type { MBSearchResult, @@ -76,6 +76,7 @@ export class ExploreView extends LitElement { private searchVersion = 0; private debounceTimer: ReturnType | null = null; private thumbnailCache = new Map(); + private artistImageCache = new Map(); @litQuery('input') private inputEl!: HTMLInputElement; @@ -293,6 +294,13 @@ export class ExploreView extends LitElement { text-transform: uppercase; user-select: none; flex-shrink: 0; + overflow: hidden; + } + + .artist-avatar img { + width: 100%; + height: 100%; + object-fit: cover; } .artist-name { @@ -568,6 +576,7 @@ export class ExploreView extends LitElement { this.results = result; this.loadThumbnails(); + this.loadArtistImages(); const elapsed = (performance.now() - startTime).toFixed(0); console.log( @@ -653,6 +662,32 @@ export class ExploreView extends LitElement { }); } + /** + * Load artist images for all visible artist cards. Each call + * is async and updates the cache + re-renders on success. + */ + private loadArtistImages() { + if (!this.results?.artists?.length) return; + + for (const a of this.results.artists) { + if (this.artistImageCache.has(a.mbid)) continue; + + // Mark as loading. + this.artistImageCache.set(a.mbid, ''); + + GetArtistImageURL(a.mbid) + .then((url) => { + if (url) { + this.artistImageCache.set(a.mbid, url); + this.requestUpdate(); + } + }) + .catch(() => { + // No image — leave empty string. + }); + } + } + /* ── Top Results ── */ private getTopResults(): ScoredItem[] { @@ -905,7 +940,12 @@ export class ExploreView extends LitElement { class="artist-avatar" style="background: hsl(${hue}, 45%, 35%)" > - ${(a.englishName || a.name).charAt(0).toUpperCase()} + ${this.artistImageCache.get(a.mbid) + ? html`${a.englishName || a.name}` + : (a.englishName || a.name).charAt(0).toUpperCase()}

${a.englishName || a.name} diff --git a/frontend/wailsjs/go/explore/Service.d.ts b/frontend/wailsjs/go/explore/Service.d.ts old mode 100755 new mode 100644 index ae6c8d6..abfb9e4 --- a/frontend/wailsjs/go/explore/Service.d.ts +++ b/frontend/wailsjs/go/explore/Service.d.ts @@ -11,15 +11,11 @@ export function CoverArtGroupURL(arg1:string):Promise; export function CoverArtURL(arg1:string):Promise; -export function GetThumbnail(arg1:string, arg2:string, arg3:string):Promise; +export function GetArtistImageURL(arg1:string):Promise; -export interface ThumbnailRequest { - mbid: string; - albumName: string; - artistName: string; -} +export function GetThumbnail(arg1:string,arg2:string,arg3:string):Promise; -export function GetThumbnails(arg1:ThumbnailRequest[]):Promise>; +export function GetThumbnails(arg1:Array):Promise>; export function LookupArtist(arg1:string):Promise; @@ -38,3 +34,4 @@ export function SetContext(arg1:context.Context):Promise; export function SimilarArtists(arg1:string):Promise>; export function TopRecordingsForArtist(arg1:string):Promise>; + diff --git a/frontend/wailsjs/go/explore/Service.js b/frontend/wailsjs/go/explore/Service.js index 671f5be..6dd4138 100755 --- a/frontend/wailsjs/go/explore/Service.js +++ b/frontend/wailsjs/go/explore/Service.js @@ -18,6 +18,10 @@ export function CoverArtURL(arg1) { return window['go']['explore']['Service']['CoverArtURL'](arg1); } +export function GetArtistImageURL(arg1) { + return window['go']['explore']['Service']['GetArtistImageURL'](arg1); +} + export function GetThumbnail(arg1, arg2, arg3) { return window['go']['explore']['Service']['GetThumbnail'](arg1, arg2, arg3); }