feat: artist images from MusicBrainz/Wikidata/Wikimedia Commons
Add ArtistImageProvider that resolves artist MBIDs to photo URLs: 1. MB url-rels 'image' type → extract Commons filename → thumb URL 2. MB url-rels 'wikidata' type → Wikidata P18 property → thumb URL 3. No image → falls back to initial-letter avatar Wikimedia Commons thumb URLs constructed via MD5 hash bucketing (standard Commons URL scheme). Results cached in explore_cache with 30-day TTL — subsequent lookups are instant. Frontend: search results and artist detail page show artist photos in the circular avatar when available. Images load async and replace the initial-letter fallback on arrival. Artist detail page fires the image fetch alongside the other 4 parallel data loads. Architecture supports adding more sources (fanart.tv, etc.) by extending the resolve() method's source chain.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
+25
-14
@@ -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
|
||||
|
||||
@@ -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`<img
|
||||
src="${this.artistImageURL}"
|
||||
alt="${this.displayName}"
|
||||
/>`
|
||||
: this.getInitial(this.displayName)}
|
||||
</div>
|
||||
<div class="artist-info">
|
||||
<h1 class="artist-title" title="${this.displayName}">
|
||||
|
||||
@@ -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<typeof setTimeout> | null = null;
|
||||
private thumbnailCache = new Map<string, string>();
|
||||
private artistImageCache = new Map<string, string>();
|
||||
|
||||
@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`<img
|
||||
src="${this.artistImageCache.get(a.mbid)}"
|
||||
alt="${a.englishName || a.name}"
|
||||
/>`
|
||||
: (a.englishName || a.name).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div class="artist-name" title="${a.englishName || a.name}">
|
||||
${a.englishName || a.name}
|
||||
|
||||
+4
-7
@@ -11,15 +11,11 @@ export function CoverArtGroupURL(arg1:string):Promise<string>;
|
||||
|
||||
export function CoverArtURL(arg1:string):Promise<string>;
|
||||
|
||||
export function GetThumbnail(arg1:string, arg2:string, arg3:string):Promise<string>;
|
||||
export function GetArtistImageURL(arg1:string):Promise<string>;
|
||||
|
||||
export interface ThumbnailRequest {
|
||||
mbid: string;
|
||||
albumName: string;
|
||||
artistName: string;
|
||||
}
|
||||
export function GetThumbnail(arg1:string,arg2:string,arg3:string):Promise<string>;
|
||||
|
||||
export function GetThumbnails(arg1:ThumbnailRequest[]):Promise<Record<string, string>>;
|
||||
export function GetThumbnails(arg1:Array<explore.ThumbnailRequest>):Promise<Record<string, string>>;
|
||||
|
||||
export function LookupArtist(arg1:string):Promise<explore.MBArtist>;
|
||||
|
||||
@@ -38,3 +34,4 @@ export function SetContext(arg1:context.Context):Promise<void>;
|
||||
export function SimilarArtists(arg1:string):Promise<Array<explore.LBSimilarArtist>>;
|
||||
|
||||
export function TopRecordingsForArtist(arg1:string):Promise<Array<explore.LBTopRecording>>;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user