import { LitElement, html, css, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { designTokens } from '../../styles/tokens.css'; import { LookupArtist, BrowseReleaseGroups, TopRecordingsForArtist, TopReleaseGroupsForArtist, SimilarArtists, GetArtistImageURL, GetArtistImageCachedPath, GetLibrarySimilarArtists, GetThumbnail, GetThumbnails, GetTrackThumbnail, GetTrackThumbnails, ResolveReleaseGroupMBIDs, } from '@go/explore/Service'; import type { MBArtist, MBReleaseGroup, LBTopRecording, LBTopReleaseGroup, LBSimilarArtist, } from '@go/explore/Service'; import { exploreCache } from '../../store/explore-cache'; import { exploreSettings } from '../../store/explore-settings'; import { libraryStore } from '../../store/library-store'; import { GetAlbumsByArtist } from '@go/library/Library'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '../library-status-indicator/library-status-indicator.js'; /* ── Constants ── */ const CAA_GROUP_BASE = 'https://coverartarchive.org/release-group'; /** Desired section order for grouping release types. */ const TYPE_ORDER = ['Albums', 'EP', 'Single', 'Other Albums']; /** * Secondary types that move an "Album" out of the studio albums * bucket and into "Other Albums". */ const NON_STUDIO_SECONDARY_TYPES = new Set([ 'Compilation', 'Soundtrack', 'Live', 'Remix', 'Spokenword', 'Interview', 'DJ-mix', 'Mixtape/Street', 'Demo', 'Audio drama', ]); /* ── Utility functions (duplicated from explore-view per design decision) ── */ function CoverArtGroupURL(releaseGroupMBID: string): string { return `${CAA_GROUP_BASE}/${releaseGroupMBID}/front-250`; } function CoverArtReleaseURL(releaseMBID: string): string { return `https://coverartarchive.org/release/${releaseMBID}/front-250`; } function nameToHue(name: string): number { let hash = 0; for (let i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); } return Math.abs(hash) % 360; } function extractYear(dateStr: string): string { if (!dateStr) return ''; return dateStr.substring(0, 4); } function formatListenCount(count: number): string { if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; return String(count); } /* ── Component ── */ @customElement('explore-artist-details') export class ExploreArtistDetails extends LitElement { /* ── Public attributes ── */ @property({ type: String, attribute: 'artist-mbid' }) artistMBID = ''; @property({ type: String, attribute: 'artist-name' }) artistName = ''; @property({ type: Number, attribute: 'local-artist-id' }) localArtistId = 0; /* ── Internal state ── */ @state() private artist: MBArtist | null = null; @state() private topTracks: LBTopRecording[] = []; @state() private topReleaseGroups: LBTopReleaseGroup[] = []; @state() private releaseGroups: MBReleaseGroup[] = []; @state() private loadingArtist = true; @state() private loadingTracks = true; @state() private loadingTopReleases = true; @state() private loadingReleases = true; @state() private errorArtist = ''; @state() private errorTracks = ''; @state() private errorReleases = ''; @state() private similarArtists: LBSimilarArtist[] = []; @state() private loadingSimilar = true; @state() private artistImageURL = ''; @state() private similarImageURLs = new Map(); /** Resolved cover art data URLs keyed by release group MBID. */ @state() private thumbnailURLs = new Map(); /** Track thumbnails keyed by recording MBID — resolved via RG * when possible, or the release MBID as fallback. */ @state() private trackThumbnails = new Map(); @state() private topTracksExpanded = false; @state() private topReleasesExpanded = false; private topSectionStacked = false; private topSectionObserver?: ResizeObserver; @state() private expandedDiscoGroups = new Set(); /** Number of album cards that fit in one row of the discography grid. */ @state() private discoRowSize = 5; private discoObserver?: ResizeObserver; @state() private similarExpanded = false; private libraryMBIDs = new Set(); /* ── Styles ── */ static override styles = [ designTokens, css` :host { display: flex; flex-direction: column; overflow: hidden; height: 100%; box-sizing: border-box; } /* ── Header ── */ .artist-header { display: flex; align-items: center; gap: 20px; padding: 16px 20px; flex-shrink: 0; border-bottom: 1px solid var(--yj-border-subtle, rgba(255, 255, 255, 0.06)); } .back-button { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border: none; border-radius: 50%; background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); color: var(--yj-text-primary, #fff); cursor: pointer; flex-shrink: 0; transition: background-color 0.15s ease; } .back-button:hover { background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12)); } .back-button wa-icon { font-size: 16px; } .artist-avatar { width: 80px; height: 80px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 600; font-size: 32px; text-transform: uppercase; user-select: none; flex-shrink: 0; line-height: 1; overflow: hidden; } .artist-avatar img { width: 100%; height: 100%; object-fit: cover; } .artist-info { display: flex; flex-direction: column; gap: 4px; min-width: 0; } .artist-title { font-size: 24px; font-weight: 700; color: var(--yj-text-primary, #fff); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin: 0; line-height: 1.2; } .artist-native-name { font-size: var(--yj-text-md); color: var(--yj-text-secondary, #b3b3b3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; } .artist-meta { font-size: var(--yj-text-md); color: var(--yj-text-secondary, #b3b3b3); display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .meta-separator { opacity: 0.4; } /* ── Scrollable content ── */ .content { flex: 1; overflow-y: auto; padding: 20px 24px 32px; display: flex; flex-direction: column; gap: 32px; } /* ── Section headers ── */ .section-header { font-size: 11px; font-weight: 600; color: var(--yj-text-secondary, #b3b3b3); text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 12px; } /* ── Loading / error states ── */ .section-loading { color: var(--yj-text-secondary, #b3b3b3); font-size: var(--yj-text-md); animation: pulse 1.5s ease-in-out infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .section-error { display: flex; align-items: center; gap: 6px; color: var(--yj-text-secondary, #b3b3b3); font-size: var(--yj-text-sm); } .section-error wa-icon { color: #e5534b; font-size: var(--yj-icon-sm); flex-shrink: 0; } /* ── Top tracks ── */ .track-list { display: flex; flex-direction: column; } .track-item { display: flex; align-items: center; gap: 12px; padding: 8px 12px; border-radius: 6px; cursor: default; transition: background 0.1s ease; } .track-item:hover { background: var( --yj-bg-overlay, rgba(255, 255, 255, 0.04) ); } .track-rank { width: 24px; text-align: right; color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-md); font-variant-numeric: tabular-nums; flex-shrink: 0; } .track-art { width: 32px; height: 32px; border-radius: 4px; overflow: hidden; flex-shrink: 0; background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); position: relative; display: flex; align-items: center; justify-content: center; } .track-art img { width: 100%; height: 100%; object-fit: cover; display: block; } .track-art wa-icon { font-size: 16px; color: var(--yj-text-tertiary, #888); opacity: 0.5; } .track-info { flex: 1; min-width: 0; } .track-title { font-weight: 500; color: var(--yj-text-primary, #fff); font-size: var(--yj-text-md); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .track-artist { color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-sm); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .track-listens { color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-sm); flex-shrink: 0; font-variant-numeric: tabular-nums; white-space: nowrap; } .track-item library-status-indicator { flex-shrink: 0; } /* ── Top section (tracks + releases side-by-side) ── */ .top-section-wrapper { container-type: inline-size; } .top-section-columns { display: grid; grid-template-columns: 1fr minmax(300px, 1fr); gap: 24px; } .top-section-col { min-width: 0; } /* Wide: releases column uses absolute positioning so tracks alone determine the row height. */ .top-section-col-releases { position: relative; } .top-releases-content { position: absolute; inset: 0; display: flex; flex-direction: column; } .top-releases-grid { flex: 1; min-height: 0; } .top-releases-inner { height: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } /* Per-column toggles: hidden in wide mode */ .column-toggle { display: none; } /* Shared toggle: visible in wide mode */ .top-section-toggle { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 6px 12px; margin-top: 12px; border: none; border-radius: 6px; background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); color: var(--yj-text-secondary, #b3b3b3); font-size: var(--yj-text-sm); cursor: pointer; transition: background 0.15s ease, color 0.15s ease; width: 100%; } .top-section-toggle:hover { background: var(--yj-bg-hover, rgba(255, 255, 255, 0.1)); color: var(--yj-text-primary, #fff); } .top-section-toggle wa-icon { font-size: 12px; transition: transform 0.2s ease; } .top-section-toggle[aria-expanded='true'] wa-icon { transform: rotate(180deg); } /* ── Narrow / stacked layout ── */ @container (max-width: 650px) { .top-section-columns { display: flex; flex-direction: column; gap: 16px; } /* Releases column goes back to normal flow */ .top-section-col-releases { position: static; } .top-releases-content { position: static; display: flex; flex-direction: column; } .top-releases-inner { height: auto; } /* Per-column toggles visible */ .column-toggle { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 6px 12px; margin-top: 8px; border: none; border-radius: 6px; background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); color: var(--yj-text-secondary, #b3b3b3); font-size: var(--yj-text-sm); cursor: pointer; transition: background 0.15s ease, color 0.15s ease; width: 100%; } .column-toggle:hover { background: var(--yj-bg-hover, rgba(255, 255, 255, 0.1)); color: var(--yj-text-primary, #fff); } .column-toggle wa-icon { font-size: 12px; transition: transform 0.2s ease; } .column-toggle[aria-expanded='true'] wa-icon { transform: rotate(180deg); } /* Shared toggle hidden */ .top-section-toggle { display: none; } } .top-release-card { display: flex; flex-direction: column; align-items: center; gap: 4px; cursor: pointer; transition: background 0.15s ease; padding: 4px; border-radius: 6px; min-width: 0; min-height: 0; overflow: hidden; } .top-release-card:hover { background: var( --yj-bg-overlay, rgba(255, 255, 255, 0.06) ); } .top-release-card:active { transform: scale(0.97); } .top-release-art { width: 100%; flex: 1; min-height: 0; border-radius: 4px; overflow: hidden; position: relative; display: flex; align-items: center; justify-content: center; } .top-release-art img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; margin: auto; } .top-release-art .album-art-fallback { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; } .top-release-art .album-art-fallback wa-icon { font-size: 20px; color: var(--yj-text-tertiary, #888); opacity: 0.5; } .top-release-text { min-width: 0; text-align: center; } .top-release-title { font-weight: 500; color: var(--yj-text-primary, #fff); font-size: var(--yj-text-xs); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .top-release-meta { display: flex; align-items: center; justify-content: space-between; gap: 6px; color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-xs); min-height: 18px; } .top-release-meta-text { display: flex; align-items: center; gap: 6px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .top-release-meta library-status-indicator { flex-shrink: 0; } /* ── Discography grid ── */ .disco-group { display: flex; flex-direction: column; gap: 12px; } .disco-type-header { font-size: var(--yj-text-md); font-weight: 600; color: var(--yj-text-primary, #fff); margin: 0; } .album-grid { display: grid; grid-template-columns: repeat(auto-fill, 140px); gap: 16px; } .album-grid.collapsed { grid-template-rows: 1fr; overflow: hidden; } .disco-toggle { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 4px 10px; margin-top: 4px; border: none; border-radius: 6px; background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); color: var(--yj-text-secondary, #b3b3b3); font-size: var(--yj-text-xs); cursor: pointer; transition: background 0.15s ease, color 0.15s ease; width: 100%; } .disco-toggle:hover { background: var(--yj-bg-hover, rgba(255, 255, 255, 0.1)); color: var(--yj-text-primary, #fff); } .disco-toggle wa-icon { font-size: 11px; transition: transform 0.2s ease; } .disco-toggle[aria-expanded='true'] wa-icon { transform: rotate(180deg); } .album-card { display: flex; flex-direction: column; gap: 6px; padding: 8px; border-radius: 8px; cursor: pointer; transition: background 0.15s ease; } .album-card:hover { background: var( --yj-bg-overlay, rgba(255, 255, 255, 0.06) ); } .album-card:active { transform: scale(0.97); } .album-art-container { width: 100%; aspect-ratio: 1; border-radius: 4px; overflow: hidden; flex-shrink: 0; position: relative; background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); } .album-art-container img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: 4px; } .album-art-fallback { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; position: absolute; inset: 0; } .album-art-fallback wa-icon { color: var(--yj-text-tertiary, #888); font-size: 24px; opacity: 0.5; } .album-title { font-weight: 500; color: var(--yj-text-primary, #fff); font-size: var(--yj-text-sm); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .album-meta { display: flex; align-items: center; justify-content: space-between; gap: 6px; color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-xs); min-height: 20px; } .album-meta-text { display: flex; align-items: center; gap: 6px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .album-meta library-status-indicator { flex-shrink: 0; margin-left: auto; } /* ── Similar artists ── */ .similar-row { display: grid; grid-template-columns: repeat(auto-fill, 140px); gap: 16px; overflow: hidden; } .similar-row.collapsed { grid-template-rows: 1fr; overflow: hidden; } .similar-artist-card { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 10px; border-radius: 8px; cursor: pointer; text-align: center; transition: background 0.15s ease; } .similar-artist-card:hover { background: var( --yj-bg-overlay, rgba(255, 255, 255, 0.06) ); } .similar-artist-card:active { transform: scale(0.97); } .similar-avatar { width: 67px; height: 67px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 600; font-size: 28px; text-transform: uppercase; user-select: none; flex-shrink: 0; overflow: hidden; } .similar-avatar img { width: 100%; height: 100%; object-fit: cover; } .similar-name { font-weight: 500; color: var(--yj-text-primary, #fff); font-size: var(--yj-text-md); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width: 100%; } `, ]; /* ── Lifecycle ── */ private unsubSettings?: () => void; override connectedCallback() { super.connectedCallback(); if (this.artistMBID || this.localArtistId) { void this.loadAllData(); } // Re-render when library-only mode toggles. this.unsubSettings = exploreSettings.subscribe(() => { this.requestUpdate(); if (this.artistMBID || this.localArtistId) { void this.loadAllData(); } }); } override disconnectedCallback() { super.disconnectedCallback(); this.unsubSettings?.(); this.topSectionObserver?.disconnect(); this.discoObserver?.disconnect(); } protected override firstUpdated() { this.observeTopSectionWidth(); this.observeDiscoWidth(); } protected override updated() { // Re-attach observers if elements appeared after initial render. if (!this.topSectionObserver) { this.observeTopSectionWidth(); } if (!this.discoObserver) { this.observeDiscoWidth(); } } /** * Watch the top-section-wrapper width. When transitioning from * stacked (≤650px) to side-by-side (>650px), sync expand states * so both columns match. */ private observeTopSectionWidth() { const wrapper = this.renderRoot.querySelector('.top-section-wrapper'); if (!wrapper) return; this.topSectionObserver = new ResizeObserver((entries) => { for (const entry of entries) { const width = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; const nowStacked = width <= 650; if (this.topSectionStacked && !nowStacked) { // Transitioning stacked → side-by-side: sync states. // If either is expanded, expand both. if (this.topTracksExpanded || this.topReleasesExpanded) { this.topTracksExpanded = true; this.topReleasesExpanded = true; } } this.topSectionStacked = nowStacked; } }); this.topSectionObserver.observe(wrapper); } /** * Watch the .content width and compute how many album cards * fit in one row of the discography grid. * Grid uses: repeat(auto-fill, minmax(140px, 1fr)) with 16px gap * and album-card has 8px padding on each side. */ private observeDiscoWidth() { const content = this.renderRoot.querySelector('.content'); if (!content) return; const CARD_MIN = 140; const GAP = 16; this.discoObserver = new ResizeObserver((entries) => { for (const entry of entries) { const width = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; const cols = Math.max(1, Math.floor((width + GAP) / (CARD_MIN + GAP))); if (cols !== this.discoRowSize) { this.discoRowSize = cols; } } }); this.discoObserver.observe(content); } /* ── Data Loading ── */ private async loadAllData() { const mbid = this.artistMBID; // Local-only artist (no MBID) — populate from library store. if (!mbid && this.localArtistId) { console.log( `[explore-artist] loading local-only: "${this.artistName}" (id=${this.localArtistId})`, ); this.loadingArtist = false; this.loadingTracks = false; this.loadingTopReleases = false; this.loadingReleases = false; this.loadingSimilar = false; this.similarArtists = []; this.hydrateLocalOnly(); console.log( `[explore-artist] loaded (local-only): "${this.artistName}"`, ); return; } console.log( `[explore-artist] loading: "${this.artistName}" (${mbid})`, ); // Phase 0: hydrate from caches (instant, no Go calls). this.hydrateFromCache(mbid); if (exploreSettings.libraryOnly) { // Library-only mode: no external API calls. // Discography comes from library store (already hydrated). // Similar artists from pre-computed DB table. this.loadingArtist = false; this.loadingTracks = false; this.loadingTopReleases = false; this.loadingReleases = false; this.loadingSimilar = false; // If the library store hasn't eagerly fetched yet, // await it and re-hydrate. Covers the race between // navigation and the deferred eagerFetch on DOMContentLoaded. if (!libraryStore.cachedArtists || !libraryStore.cachedAlbums) { try { const pending: Promise[] = []; if (!libraryStore.cachedArtists) { pending.push(libraryStore.getArtists()); } if (!libraryStore.cachedAlbums) { pending.push(libraryStore.getAlbums()); } await Promise.all(pending); this.hydrateFromCache(mbid); } catch { // Ignore — we'll fall through to the Wails-cached path. } } // Artist image: if hydrateFromCache didn't find one (e.g. // the library store's cached artist row has an empty // ImageMedium because the on-disk file post-dates the // store's last fetch), fall back to a disk-only Wails // call. This just asks the backend whether // /artist-images/.../primary_md.jpg exists — no network, // no base64 transfer. if (!this.artistImageURL && mbid) { void GetArtistImageCachedPath(mbid) .then((url) => { if (url) { this.artistImageURL = url; } else { // Final fallback: album art by artist name. this.fallbackArtistImageFromAlbumArt(); } }) .catch(() => { this.fallbackArtistImageFromAlbumArt(); }); } // Fetch library-only similar artists (single Go call, no external API). try { const similar = await GetLibrarySimilarArtists(mbid); // Dedupe by MBID as a safety net — the backend query // should already return unique rows but multiple library // artists can share an MBID (ensemble credits), so this // guards against any future query regression. const seen = new Set(); const deduped: LBSimilarArtist[] = []; for (const s of similar ?? []) { const key = s.artistMbid || s.name; if (!key || seen.has(key)) continue; seen.add(key); deduped.push(s); } this.similarArtists = deduped; // Resolve similar artist images from library cache — // no Go calls, no network. These artists are all in // the library (that's the filter GetLibrarySimilarArtists // applies), so the library store has their image paths. this.seedSimilarArtistImagesFromLibrary(); } catch { this.similarArtists = []; } console.log( `[explore-artist] loaded (library-only): "${this.artistName}"`, ); return; } // Phase 1: fire all API requests independently so the UI // renders each section as its data arrives, rather than // waiting for the slowest call to finish. void this.fetchArtist(mbid); void this.fetchTopTracks(mbid); void this.fetchTopReleaseGroups(mbid); void this.fetchReleaseGroups(mbid); void this.fetchSimilarArtists(mbid); // Artist image is fire-and-forget — doesn't block the page. if (!this.artistImageURL) { this.fetchArtistImage(mbid); } // Play count comes from LookupArtist (already populated from index). // checkLibrary now runs from fetchReleaseGroups when data arrives. console.log( `[explore-artist] data requests fired: "${this.artistName}" (${mbid})`, ); } /** * Hydrate state from the explore cache and library store. * Shows cached data instantly before API calls complete. */ private hydrateFromCache(mbid: string) { // Artist image: check explore cache first, then library store. if (!this.artistImageURL) { const cachedArtist = exploreCache.getArtist(mbid); if (cachedArtist) { this.artistImageURL = cachedArtist.imageURL || cachedArtist.imageMedium || cachedArtist.imageSmall || ''; } } if (!this.artistImageURL) { const cachedArtists = libraryStore.cachedArtists; if (cachedArtists) { for (const a of cachedArtists) { if (a.MBID === mbid) { this.artistImageURL = a.ImageMedium || a.ImageSmall || ''; break; } } } } // Library albums by this artist — show as discography instantly. const cachedAlbums = libraryStore.cachedAlbums; if (cachedAlbums) { const artistName = this.artistName.toLowerCase(); const libraryAlbums: MBReleaseGroup[] = []; const thumbUpdates = new Map(this.thumbnailURLs); let thumbsChanged = false; for (const a of cachedAlbums) { if (a.ArtistName.toLowerCase() === artistName) { const mbid = a.MBID || `local:${a.ID}`; libraryAlbums.push({ mbid, title: a.Name, primaryType: 'Album', artistCredit: a.ArtistName, firstReleaseDate: a.Year ? String(a.Year) : '', inLibrary: true, localId: a.ID, } as MBReleaseGroup); // Seed the thumbnail map directly from the library // cover art so the renderer doesn't need to call // GetThumbnail. Album cover art lives at // /covers/... served by the Wails dev server. const art = a.CoverArtMedium || a.CoverArtSmall || a.CoverArtPath; if (art && !thumbUpdates.has(mbid)) { thumbUpdates.set(mbid, art); thumbsChanged = true; } } } if (libraryAlbums.length > 0) { this.releaseGroups = libraryAlbums; this.loadingReleases = false; } if (thumbsChanged) { this.thumbnailURLs = thumbUpdates; } } // Fallback: use album cover art if no artist image found. if (!this.artistImageURL && cachedAlbums) { const artistName = this.artistName.toLowerCase(); for (const a of cachedAlbums) { if (a.ArtistName.toLowerCase() === artistName) { const art = a.CoverArtMedium || a.CoverArtSmall || a.CoverArtPath; if (art) { this.artistImageURL = art; break; } } } } } /** * Populate the view entirely from library data when the artist * has no MusicBrainz ID. Uses the local artist ID to fetch * albums and resolves the artist image from album cover art. */ private async hydrateLocalOnly() { // Ensure library store is populated before reading. if (!libraryStore.cachedArtists || !libraryStore.cachedAlbums) { try { const pending: Promise[] = []; if (!libraryStore.cachedArtists) { pending.push(libraryStore.getArtists()); } if (!libraryStore.cachedAlbums) { pending.push(libraryStore.getAlbums()); } await Promise.all(pending); } catch { // Non-fatal — continue with whatever's loaded. } } // Artist image: use library store cached artist, then try // the on-disk image cache by MBID if available, then fall // back to album art. const cachedArtists = libraryStore.cachedArtists; let artistMbidForImage = ''; if (cachedArtists) { for (const a of cachedArtists) { if (a.ID === this.localArtistId) { this.artistImageURL = a.ImageMedium || a.ImageSmall || ''; artistMbidForImage = a.MBID || ''; break; } } } if (!this.artistImageURL && artistMbidForImage) { try { const url = await GetArtistImageCachedPath(artistMbidForImage); if (url) this.artistImageURL = url; } catch { // Ignore. } } // Discography: fetch albums by artist ID and seed thumbnails // directly from library cover art. try { const albums = await GetAlbumsByArtist(this.localArtistId); const thumbUpdates = new Map(this.thumbnailURLs); let thumbsChanged = false; this.releaseGroups = (albums ?? []).map((a) => { const mbid = a.MBID || `local:${a.ID}`; const art = a.CoverArtMedium || a.CoverArtSmall || a.CoverArtPath; if (art && !thumbUpdates.has(mbid)) { thumbUpdates.set(mbid, art); thumbsChanged = true; } return { mbid, title: a.Name, primaryType: 'Album', artistCredit: a.ArtistName, firstReleaseDate: a.Year ? String(a.Year) : '', inLibrary: true, localId: a.ID, _coverArt: art || '', _inLibrary: true, } as MBReleaseGroup & { _coverArt: string; _inLibrary: boolean }; }); if (thumbsChanged) { this.thumbnailURLs = thumbUpdates; } } catch { this.releaseGroups = []; } // Final fallback: artist image from first album's cover art. if (!this.artistImageURL) { for (const rg of this.releaseGroups) { const art = (rg as MBReleaseGroup & { _coverArt?: string })._coverArt; if (art) { this.artistImageURL = art; break; } } } } private async fetchArtist(mbid: string) { try { this.artist = await LookupArtist(mbid); } catch (err) { const msg = err instanceof Error ? err.message : String(err); this.errorArtist = msg; console.error(`[explore-artist] LookupArtist error: ${msg}`); } finally { this.loadingArtist = false; } } private async fetchTopTracks(mbid: string) { try { const tracks = await TopRecordingsForArtist(mbid); this.topTracks = tracks ?? []; if (!tracks || tracks.length === 0) return; // Resolve track CAA release MBIDs to their parent release // group MBIDs where possible. When a track's CAA release // belongs to a release group in the index, we reuse the // shared RG cache (discography, top releases). When it // doesn't, we fall back to fetching by the release MBID // directly via the backend's GetTrackThumbnail. const caaMbids = tracks .map((t) => t.caaReleaseMbid) .filter((m): m is string => !!m); let mapping: Record = {}; if (caaMbids.length > 0) { try { mapping = (await ResolveReleaseGroupMBIDs(caaMbids)) || {}; } catch (err) { console.warn('[explore-artist] failed to resolve release groups for tracks:', err); } } void this.batchResolveTrackThumbnails(tracks, mapping); } catch (err) { const msg = err instanceof Error ? err.message : String(err); this.errorTracks = msg; console.error( `[explore-artist] TopRecordingsForArtist error: ${msg}`, ); } finally { this.loadingTracks = false; } } /** * Two-phase track thumbnail loader: checks the backend cache in * one batch call, then fires individual network fetches for any * that are missing. Each track's thumbnail is keyed by the * track's recording MBID so the renderer doesn't have to know * whether the art came from RG or release fallback. */ private async batchResolveTrackThumbnails( tracks: LBTopRecording[], releaseToRG: Record, ) { const needed = tracks.filter( (t) => t.recordingMbid && !this.trackThumbnails.has(t.recordingMbid), ); if (needed.length === 0) return; // Mark all as in-flight. for (const t of needed) { this.trackThumbnails.set(t.recordingMbid, ''); } const requests = needed.map((t) => ({ key: t.recordingMbid, releaseMbid: t.caaReleaseMbid || '', releaseGroupMbid: releaseToRG[t.caaReleaseMbid] || '', albumName: t.releaseName || '', artistName: t.artistName || '', })); // Phase 1: batched cached lookup. let cached: Record = {}; try { cached = (await GetTrackThumbnails(requests)) || {}; if (Object.keys(cached).length > 0) { const updated = new Map(this.trackThumbnails); for (const [key, url] of Object.entries(cached)) { if (url) updated.set(key, url); } this.trackThumbnails = updated; } } catch { // Best-effort. } // Phase 2: per-track network fetches for anything still missing. const uncached = requests.filter((r) => !cached[r.key]); for (const req of uncached) { GetTrackThumbnail( req.releaseMbid, req.releaseGroupMbid, req.albumName, req.artistName, ) .then((url) => { if (url) { this.trackThumbnails = new Map(this.trackThumbnails).set(req.key, url); } }) .catch(() => {}); } } private async fetchTopReleaseGroups(mbid: string) { try { const rgs = await TopReleaseGroupsForArtist(mbid); this.topReleaseGroups = rgs ?? []; // Batch-resolve cover art for top releases. void this.batchResolveThumbnails( rgs?.map((r) => ({ mbid: r.releaseGroupMbid, albumName: r.title, artistName: r.artistName })) ?? [], ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error( `[explore-artist] TopReleaseGroupsForArtist error: ${msg}`, ); this.topReleaseGroups = []; } finally { this.loadingTopReleases = false; } } private async fetchReleaseGroups(mbid: string) { try { const rgs = await BrowseReleaseGroups(mbid); this.releaseGroups = rgs ?? []; // Populate libraryMBIDs from the inLibrary flag (already // set by the backend via local_release_group_id cross-ref). this.checkLibrary(); // Batch-resolve cover art for discography (lower priority — loaded after top sections). void this.batchResolveThumbnails( rgs?.map((r) => ({ mbid: r.mbid, albumName: r.title, artistName: r.artistCredit })) ?? [], ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); this.errorReleases = msg; console.error( `[explore-artist] BrowseReleaseGroups error: ${msg}`, ); } finally { this.loadingReleases = false; } } private async fetchSimilarArtists(mbid: string) { try { const artists = await SimilarArtists(mbid); this.similarArtists = artists ?? []; } catch (err) { // D024: graceful degradation — silently omit similar artists on failure. const msg = err instanceof Error ? err.message : String(err); console.error( `[explore-artist] SimilarArtists error: ${msg}`, ); this.similarArtists = []; } finally { this.loadingSimilar = false; } // Fire-and-forget: resolve images for similar artists in parallel. if (this.similarArtists.length > 0) { void this.fetchSimilarArtistImages(); } } /** * Resolve a cover art thumbnail for a release group (or release) * via the backend proxy (local library → disk cache → CAA). * Updates thumbnailURLs reactively so the image pops in when ready. */ private resolveThumbnail(mbid: string, albumName: string, artistName: string) { if (!mbid || this.thumbnailURLs.has(mbid)) return; // Mark as in-flight so we don't fire duplicate requests. this.thumbnailURLs.set(mbid, ''); GetThumbnail(mbid, albumName, artistName) .then((url) => { if (url) { this.thumbnailURLs = new Map(this.thumbnailURLs).set(mbid, url); } }) .catch(() => {}); } /** * Batch-resolve thumbnails in two phases: * 1. Batch call for cached/local art — instant. * 2. Individual async calls for uncached items — stream in one by one. * This prevents a slow CAA fetch from blocking cached items from rendering. */ private async batchResolveThumbnails( items: Array<{ mbid: string; albumName: string; artistName: string }>, ) { // Filter out already-resolved or in-flight items. const needed = items.filter((i) => i.mbid && !this.thumbnailURLs.has(i.mbid)); if (needed.length === 0) return; // Mark all as in-flight. for (const item of needed) { this.thumbnailURLs.set(item.mbid, ''); } // Phase 1: batch cached lookup — instant return. let cached: Record = {}; try { const requests = needed.map((i) => ({ mbid: i.mbid, albumName: i.albumName, artistName: i.artistName, })); cached = (await GetThumbnails(requests)) || {}; if (Object.keys(cached).length > 0) { const updated = new Map(this.thumbnailURLs); for (const [mbid, url] of Object.entries(cached)) { if (url) updated.set(mbid, url); } this.thumbnailURLs = updated; } } catch { // Best-effort. } // Phase 2: fire individual fetches for uncached items so they // stream in as CAA responds, rather than blocking on the slowest. const uncached = needed.filter((i) => !cached[i.mbid]); for (const item of uncached) { GetThumbnail(item.mbid, item.albumName, item.artistName) .then((url) => { if (url) { this.thumbnailURLs = new Map(this.thumbnailURLs).set(item.mbid, url); } }) .catch(() => {}); } } /** * Populate similarImageURLs for the current similarArtists list * using only library-store data and disk-cached artist images. * Makes ZERO network calls — safe for library-only mode. * * Resolution priority per artist: * 1. libraryStore.cachedArtists[mbid].ImageMedium (in-memory) * 2. GetArtistImageCachedPath(mbid) (disk-only Wails call) * 3. First library album cover art by that artist name (fallback) */ private async seedSimilarArtistImagesFromLibrary() { if (this.similarArtists.length === 0) return; const cachedArtists = libraryStore.cachedArtists; const cachedAlbums = libraryStore.cachedAlbums; // Build an MBID → ImageMedium map from library store. const byMbid = new Map(); if (cachedArtists) { for (const a of cachedArtists) { if (!a.MBID) continue; const img = a.ImageMedium || a.ImageSmall; if (img) byMbid.set(a.MBID, img); } } // Seed synchronous hits first. let updates = new Map(this.similarImageURLs); let changed = false; for (const s of this.similarArtists) { if (updates.has(s.artistMbid)) continue; const img = byMbid.get(s.artistMbid); if (img) { updates.set(s.artistMbid, img); changed = true; } } if (changed) { this.similarImageURLs = updates; changed = false; updates = new Map(this.similarImageURLs); } // Disk-only Wails calls for any similars that weren't in the // library store cache (e.g. artist row exists but ImageMedium // is empty because primary_md.jpg was added after the eager // fetch). Fire in parallel, update the map as each returns. const pending: Promise[] = []; for (const s of this.similarArtists) { if (updates.has(s.artistMbid) && updates.get(s.artistMbid)) continue; if (!s.artistMbid) continue; pending.push( GetArtistImageCachedPath(s.artistMbid) .then((url) => { if (url) { this.similarImageURLs = new Map(this.similarImageURLs).set( s.artistMbid, url, ); return; } // Last-resort fallback: first library album by // this artist name (covers artists without // cached portraits). if (!cachedAlbums) return; const targetName = (s.name || '').toLowerCase(); for (const album of cachedAlbums) { if (album.ArtistName.toLowerCase() !== targetName) continue; const art = album.CoverArtMedium || album.CoverArtSmall || album.CoverArtPath; if (art) { this.similarImageURLs = new Map(this.similarImageURLs).set( s.artistMbid, art, ); return; } } }) .catch(() => { // Ignore — letter avatar stays. }), ); } await Promise.allSettled(pending); } private async fetchSimilarArtistImages() { // Phase 1: instant seed from library store + disk cache. // This mirrors the library-only path so any card whose image // is already on disk appears immediately, without waiting // for a network-enabled GetArtistImageURL round-trip. await this.seedSimilarArtistImagesFromLibrary(); // Phase 2: network fetch for any similars still without // an image. GetArtistImageURL triggers MB → Wikidata → // Wikimedia resolution which is slow (~1-5s per artist), // so we only hit it for cards that weren't resolved // instantly from local sources above. const artists = this.similarArtists; await Promise.allSettled( artists.map(async (a) => { if (!a.artistMbid) return; if (this.similarImageURLs.get(a.artistMbid)) return; try { const url = await GetArtistImageURL(a.artistMbid); if (url) { this.similarImageURLs = new Map(this.similarImageURLs).set( a.artistMbid, url, ); } } catch { // No image — letter avatar stays. } }), ); } private async fetchArtistImage(mbid: string) { try { const url = await GetArtistImageURL(mbid); if (url) { this.artistImageURL = url; } } catch { // No image available. } // Fallback: album cover art if no artist image resolved. if (!this.artistImageURL) { this.fallbackArtistImageFromAlbumArt(); } } /** * Populate artistImageURL from the first matching library album's * cover art. Used as a last-resort fallback when no portrait is * available and we don't want to make a network call. */ private fallbackArtistImageFromAlbumArt() { if (this.artistImageURL) return; const cachedAlbums = libraryStore.cachedAlbums; if (!cachedAlbums) return; const artistName = this.artistName.toLowerCase(); for (const a of cachedAlbums) { if (a.ArtistName.toLowerCase() !== artistName) continue; const art = a.CoverArtMedium || a.CoverArtSmall || a.CoverArtPath; if (art) { this.artistImageURL = art; return; } } } private checkLibrary() { // Backend now populates `inLibrary` directly on each MBReleaseGroup // via the local_release_group_id cross-reference column. Just read it. let updated = false; for (const rg of this.releaseGroups) { if (rg.mbid && rg.inLibrary && !this.libraryMBIDs.has(rg.mbid)) { this.libraryMBIDs.add(rg.mbid); updated = true; } } if (updated) { this.requestUpdate(); } } /* ── Navigation ── */ private navigateBack() { this.dispatchEvent( new CustomEvent('navigate-back', { bubbles: true, composed: true, }), ); } private navigateToAlbum(rg: MBReleaseGroup) { // Strip the synthetic local: prefix that hydrateFromCache uses // for library-only albums without an MBID — those navigate by // local ID instead. const isLocal = typeof rg.mbid === 'string' && rg.mbid.startsWith('local:'); const realMBID = isLocal ? '' : (rg.mbid || ''); const localId = (rg as MBReleaseGroup & { localId?: number }).localId || (isLocal ? Number(rg.mbid.slice('local:'.length)) : 0); this.dispatchEvent( new CustomEvent('navigate', { bubbles: true, composed: true, detail: { view: 'explore-album-details', releaseGroupMBID: realMBID, albumName: rg.title, artistName: rg.artistCredit || this.artistName, localAlbumId: localId, }, }), ); } private navigateToSimilarArtist(artist: LBSimilarArtist) { this.dispatchEvent( new CustomEvent('navigate', { bubbles: true, composed: true, detail: { view: 'explore-artist-details', artistMBID: artist.artistMbid, artistName: artist.name, }, }), ); } /* ── Image Error Handling ── */ private handleImageError(e: Event) { const img = e.target as HTMLImageElement; img.style.display = 'none'; const fallback = img.nextElementSibling as HTMLElement | null; if (fallback) { fallback.style.display = 'flex'; } } /** On similar-artist image error, remove the img so the letter initial shows. */ private handleSimilarImageError(e: Event) { const img = e.target as HTMLImageElement; img.remove(); } /* ── Helpers ── */ private getInitial(name: string): string { if (!name) return '?'; return name.charAt(0).toUpperCase(); } /** English name if available, otherwise the native name. */ private get displayName(): string { return this.artist?.englishName || this.artistName; } /** * Group release groups by type, returning entries in the * canonical order: Albums → EP → Single → Other Albums → ...rest. * * "Albums" contains release groups with primaryType "Album" and * no non-studio secondary types. "Other Albums" collects * compilations, soundtracks, live albums, etc. */ private groupByType(): Array<{ type: string; items: MBReleaseGroup[] }> { const map = new Map(); for (const rg of this.releaseGroups) { let key = rg.primaryType || 'Other'; // Split "Album" into studio vs other based on secondary types. if (key === 'Album') { const hasNonStudio = rg.secondaryTypes?.some((t) => NON_STUDIO_SECONDARY_TYPES.has(t), ); key = hasNonStudio ? 'Other Albums' : 'Albums'; } let bucket = map.get(key); if (!bucket) { bucket = []; map.set(key, bucket); } bucket.push(rg); } // Sort each bucket by firstReleaseDate descending (newest first). for (const bucket of map.values()) { bucket.sort((a, b) => { const da = a.firstReleaseDate || ''; const db = b.firstReleaseDate || ''; return db.localeCompare(da); }); } // Build ordered result following TYPE_ORDER, then any remaining types. const result: Array<{ type: string; items: MBReleaseGroup[] }> = []; const seen = new Set(); for (const type of TYPE_ORDER) { const items = map.get(type); if (items && items.length > 0) { result.push({ type, items }); seen.add(type); } } // Remaining types not in TYPE_ORDER (alphabetical). const remaining = [...map.keys()] .filter((k) => !seen.has(k)) .sort(); for (const type of remaining) { const items = map.get(type); if (items && items.length > 0) { result.push({ type, items }); } } return result; } /* ── Render ── */ override render() { const hue = nameToHue(this.artistName); return html`
${this.artistImageURL ? html`${this.displayName}` : this.getInitial(this.displayName)}

${this.displayName}

${this.artist?.englishName ? html`
${this.artist.name}
` : nothing} ${this.renderArtistMeta()} ${this.artist?.popularity && this.artist.popularity > 0 && !exploreSettings.libraryOnly ? html`${formatListenCount(this.artist.popularity)} plays on ListenBrainz` : nothing}
${this.renderTopSection()} ${this.renderDiscography()} ${this.renderSimilarArtists()}
`; } private renderArtistMeta() { if (this.loadingArtist) { return html`Loading\u2026`; } if (this.errorArtist) { return html`${this.errorArtist}`; } if (!this.artist) return nothing; const parts: string[] = []; if (this.artist.type) parts.push(this.artist.type); if (this.artist.country) parts.push(this.artist.country); if (this.artist.disambiguation) parts.push(this.artist.disambiguation); if (parts.length === 0) return nothing; return html` ${parts.map( (p, i) => html`${i > 0 ? html`\u00B7` : nothing}${p}`, )} `; } /* ── Top Section (tracks + releases side-by-side) ── */ /** Shared toggle (wide mode) — expands both at once. */ private toggleTopSection() { const next = !(this.topTracksExpanded && this.topReleasesExpanded); this.topTracksExpanded = next; this.topReleasesExpanded = next; } /** Per-column toggle (narrow/stacked mode). */ private toggleTopTracks() { this.topTracksExpanded = !this.topTracksExpanded; } private toggleTopReleases() { this.topReleasesExpanded = !this.topReleasesExpanded; } private toggleDiscoGroup(type: string) { const next = new Set(this.expandedDiscoGroups); if (next.has(type)) { next.delete(type); } else { next.add(type); } this.expandedDiscoGroups = next; } private renderTopSection() { // Library-only mode: no top tracks/releases from LB. if (exploreSettings.libraryOnly) return nothing; const hasTracks = !this.loadingTracks && this.topTracks.length > 0; const hasReleases = !this.loadingTopReleases && this.topReleaseGroups.length > 0; const tracksLoading = this.loadingTracks; const releasesLoading = this.loadingTopReleases; // Both still loading — show single loading state. if (tracksLoading && releasesLoading) { return html`

Popular

Loading\u2026
`; } // Both done, neither has data. if (!tracksLoading && !releasesLoading && !hasTracks && !hasReleases) { return nothing; } const tracksExpanded = this.topTracksExpanded; const releasesExpanded = this.topReleasesExpanded; const bothExpanded = tracksExpanded && releasesExpanded; const trackLimit = tracksExpanded ? 10 : 5; const releaseLimit = releasesExpanded ? 4 : 2; const tracks = this.topTracks.slice(0, trackLimit); const releases = this.topReleaseGroups.slice(0, releaseLimit); const canExpandTracks = this.topTracks.length > 5; const canExpandReleases = this.topReleaseGroups.length > 2; const canExpand = canExpandTracks || canExpandReleases; return html`
${hasTracks ? html`

Top Tracks

${tracks.map( (t, i) => html`
${i + 1}
${(() => { const art = this.trackThumbnails.get(t.recordingMbid) || ''; return art ? html` { const img = e.target as HTMLImageElement; img.style.display = 'none'; }} />` : html``; })()}
${t.trackName}
${t.artistName}
${formatListenCount(t.totalListenCount)} plays
`, )}
${canExpandTracks ? html` ` : nothing}
` : nothing} ${hasReleases ? html`

Top Releases

${releases.map((rg) => this.renderTopReleaseCard(rg))}
${canExpandReleases ? html` ` : nothing}
` : releasesLoading ? html`

Top Releases

Loading\u2026
` : nothing}
${canExpand ? html` ` : nothing}
`; } private renderTopReleaseCard(rg: LBTopReleaseGroup) { const artURL = this.thumbnailURLs.get(rg.releaseGroupMbid) || ''; return html`
this.navigateToTopRelease(rg)} role="button" tabindex="0" @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.navigateToTopRelease(rg); } }} >
${artURL ? html`${rg.title}` : nothing}
${rg.title}
${rg.date ? html`${extractYear(rg.date)}` : nothing}
`; } private navigateToTopRelease(rg: LBTopReleaseGroup) { this.dispatchEvent( new CustomEvent('navigate', { bubbles: true, composed: true, detail: { view: 'explore-album-details', releaseGroupMBID: rg.releaseGroupMbid, albumName: rg.title, }, }), ); } /* ── Discography Section ── */ private renderDiscography() { if (this.loadingReleases) { return html`

Discography

Loading\u2026
`; } if (this.errorReleases) { return html`

Discography

${this.errorReleases}
`; } if (this.releaseGroups.length === 0) return nothing; const groups = this.groupByType(); return html`

Discography

${groups.map( (g) => { const isExpanded = this.expandedDiscoGroups.has(g.type); const rowSize = this.discoRowSize; const showToggle = g.items.length > rowSize; const visibleItems = isExpanded ? g.items : g.items.slice(0, rowSize); return html`

${g.type === 'Other' ? 'Other Releases' : g.type.endsWith('s') ? g.type : `${g.type}s`}

${visibleItems.map((rg) => this.renderAlbumCard(rg))}
${showToggle ? html` ` : nothing}
`; }, )}
`; } private renderAlbumCard(rg: MBReleaseGroup) { const artURL = this.thumbnailURLs.get(rg.mbid) || ''; const year = extractYear(rg.firstReleaseDate); const inLibrary = this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary); const status: 'in-library' | 'not-in-library' = inLibrary ? 'in-library' : 'not-in-library'; return html`
this.navigateToAlbum(rg)} role="button" tabindex="0" @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.navigateToAlbum(rg); } }} >
${artURL ? html`${rg.title}` : nothing}
${rg.title}
${year ? html`${year}` : nothing}
`; } /* ── Similar Artists Section ── */ private renderSimilarArtists() { if (this.loadingSimilar || this.similarArtists.length === 0) { return nothing; } // Library-only mode: show all library-matching similar artists // (up to the 20 stored per seed). Online mode: cap at 10 to // avoid a very long list. const maxSimilar = exploreSettings.libraryOnly ? 20 : 10; const artists = this.similarArtists.slice(0, maxSimilar); const showToggle = artists.length > this.discoRowSize; const collapsed = !this.similarExpanded && showToggle; const visible = collapsed ? artists.slice(0, this.discoRowSize) : artists; return html`

Similar Artists

${visible.map((a) => { const hue = nameToHue(a.name); const imgURL = this.similarImageURLs.get(a.artistMbid); return html`
this.navigateToSimilarArtist(a)} role="button" tabindex="0" @keydown=${(e: KeyboardEvent) => { if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); this.navigateToSimilarArtist(a); } }} >
${imgURL ? html`${a.name}` : a.name.charAt(0).toUpperCase()}
${a.name}
`; })}
${showToggle ? html` ` : nothing}
`; } } declare global { interface HTMLElementTagNameMap { 'explore-artist-details': ExploreArtistDetails; } }