perf(explore): ask the disk once, prefetch once, and menu the releases

Three things on the Explore surfaces, all about not asking twice.

A portrait already on disk costs no network call. explore-view seeded
only from the library store — owned artists, which on a catalog search
is nearly none of the results — and sent everything else to
GetArtistImageURL, the resolving entry point, one await at a time.
GetArtistImagesCachedPaths asks the disk about every unresolved artist
in one call, and only what it does not answer reaches the resolver,
in parallel.

The artist page's two sections both wanted PrefetchReleases and each
called it, so the most expensive call the app makes was issued twice
for an overlapping set on a 1 req/s limiter. They are collected and
sent once on a microtask, and prefetchRequested stops the cold-artist
refetch re-asking for what it already asked for.

The release cards — most of the artist page — had no context menu at
all. They have one now on both release shapes, normalised to a
ReleaseMenuTarget when the menu opens so the union does not reach the
action handlers. It is a discriminated union rather than one nullable
field per kind because the panel is shared with the track menu: that is
what keeps aria-label moving with the target, which is the fault
cover-grid shipped. Which items appear is three different questions —
playback is gated on a local album id, not on "owned", and the request
needs a catalog MBID, so it is absent for a library-only release.

Note on the docs: the CLAUDE.md and NOTES.md prose here was
reconstructed after a mishandled `git stash --keep-index` destroyed the
uncommitted originals. One NOTES.md section is marked as incomplete
where its text could not be recovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
This commit is contained in:
2026-08-14 13:34:15 -04:00
co-authored by Claude Opus 5
parent 20fbf28f2a
commit edb13a6f39
14 changed files with 1361 additions and 70 deletions
@@ -6,7 +6,11 @@ import {
} from 'lit/decorators.js';
import { library } from '@go/models';
import { LibraryController } from '@store/controllers/library-controller';
import { GetArtistImageURL, GetArtistMBID } from '@go/explore/Service';
import {
GetArtistImageURL,
GetArtistImageCachedPath,
GetArtistMBID,
} from '@go/explore/Service';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/cover-grid/cover-grid.js';
import { designTokens } from '../../styles/tokens.css';
@@ -204,6 +208,17 @@ export class ArtistDetails extends LitElement {
if (!mbid) return;
try {
// Disk cache first — the resolving call below is MB →
// Wikidata → Wikipedia → Wikimedia, and most artists this
// page renders have been resolved once already.
const cached = await GetArtistImageCachedPath(mbid);
if (cached) {
this.artistImageURL = cached;
return;
}
const url = await GetArtistImageURL(mbid);
if (url) {
@@ -39,7 +39,7 @@ import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js';
import { libraryStatusFor } from '@utils/library-status';
import { libraryStatusFor, toggleRequest } from '@utils/library-status';
import '../catalog-scope-notice/catalog-scope-notice.js';
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
import { queueStore } from '../../store/queue-store';
@@ -61,6 +61,29 @@ import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
/** The region the artist header's own failures are rendered in. */
export const ExploreArtistRegion = 'explore-artist';
/**
* A release the context menu can act on, whichever shape it came from.
*
* The page shows release groups in two forms — the top section's
* `LBTopReleaseGroup` and the discography's `MBReleaseGroup` — and the
* three questions the menu asks are not the same question: playback
* needs a *local album id*, a request needs a *catalog MBID*, and
* "owned" is neither on its own.
*/
interface ReleaseMenuTarget {
/** The catalog release-group MBID, or '' for a library-only release. */
mbid: string;
/** The local album id, or 0 when nothing local backs it. */
localId: number;
title: string;
owned: boolean;
}
/** What the shared context menu panel is currently about. */
type ContextMenuTarget =
| { kind: 'track'; track: LBTopRecording }
| { kind: 'release'; release: ReleaseMenuTarget };
/** Desired section order for grouping release types. */
const TYPE_ORDER = ['Albums', 'EP', 'Single', 'Other Albums'];
@@ -147,14 +170,32 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
@state() private similarExpanded = false;
private libraryMBIDs = new Set<string>();
/* ── Track context menu ── */
/* ── Release prefetch ── */
/** Top-section mbids awaiting the next coalesced prefetch. */
private pendingTopPrefetch = new Set<string>();
/** Discography mbids awaiting the next coalesced prefetch. */
private pendingPrefetch = new Set<string>();
private prefetchScheduled = false;
/** Every mbid already sent, so a refetch does not re-ask. */
private prefetchRequested = new Set<string>();
/* ── Context menu ── */
private ctxMenu = new ContextMenuController(this);
/** The top track the open context menu applies to. */
@state() private ctxMenuTrack: LBTopRecording | null = null;
/**
* What the open context menu applies to.
*
* A discriminated union rather than one nullable field per kind,
* because the panel is shared between the top-tracks list and the
* release cards: that is what keeps `aria-label` moving with the
* target, which is the fault `cover-grid` shipped — every menu
* announced as "Album actions".
*/
@state() private ctxMenuTarget: ContextMenuTarget | null = null;
@query('#track-context-menu')
@query('#context-menu')
private contextMenuPopup!: WaPopup;
// -- ContextMenuHost interface --
@@ -170,7 +211,21 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
}
onContextMenuClose(): void {
this.ctxMenuTrack = null;
this.ctxMenuTarget = null;
}
/** The open menu's track, or null when it is not a track menu. */
private get ctxMenuTrack(): LBTopRecording | null {
return this.ctxMenuTarget?.kind === 'track'
? this.ctxMenuTarget.track
: null;
}
/** The open menu's release, or null when it is not a release menu. */
private get ctxMenuRelease(): ReleaseMenuTarget | null {
return this.ctxMenuTarget?.kind === 'release'
? this.ctxMenuTarget.release
: null;
}
/* ── Styles ── */
@@ -1464,7 +1519,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
// Warm the release/tracklist cache for the top albums — these
// are the most likely to be clicked from the artist page.
this.prefetchReleases(rgs?.map((r) => r.releaseGroupMbid) ?? []);
this.prefetchReleases(rgs?.map((r) => r.releaseGroupMbid) ?? [], true);
} catch (err) {
console.error('[explore-artist] TopReleaseGroupsForArtist error', err);
this.topReleaseGroups = [];
@@ -1525,14 +1580,56 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
/**
* Warm the backend's release/tracklist cache for a set of release
* groups so opening an album from this page is instant. Fire-and-forget.
* groups so opening an album from this page is instant.
* Fire-and-forget.
*
* The page's two sections resolve independently and both want this,
* so the mbids are collected and sent once on a microtask rather
* than once per section — `BrowseReleases` is the most expensive
* call the app makes, on a 1 req/s limiter, and asking twice for an
* overlapping set spends that limiter on nothing.
*
* `prefetchRequested` is what stops the cold-artist refetch — which
* re-runs every fetch on `ArtistDiscographyReady` — asking again for
* everything it already asked for.
*/
private prefetchReleases(mbids: string[]) {
const filtered = mbids.filter((m) => m);
if (filtered.length === 0) return;
private prefetchReleases(mbids: string[], top = false) {
const pending = top ? this.pendingTopPrefetch : this.pendingPrefetch;
void PrefetchReleases(filtered).catch(() => {
/* best-effort cache warming — ignore failures */
for (const mbid of mbids) {
if (mbid) pending.add(mbid);
}
if (this.pendingTopPrefetch.size + this.pendingPrefetch.size === 0) {
return;
}
if (this.prefetchScheduled) return;
this.prefetchScheduled = true;
queueMicrotask(() => {
this.prefetchScheduled = false;
// Top releases lead: they are what the page shows first, and
// the backend takes the list in order.
const batch = [
...new Set([
...this.pendingTopPrefetch,
...this.pendingPrefetch,
]),
].filter((mbid) => !this.prefetchRequested.has(mbid));
this.pendingTopPrefetch.clear();
this.pendingPrefetch.clear();
if (batch.length === 0) return;
for (const mbid of batch) this.prefetchRequested.add(mbid);
void PrefetchReleases(batch).catch(() => {
/* best-effort cache warming — ignore failures */
});
});
}
@@ -1950,7 +2047,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
private onTrackRowKeydown(e: KeyboardEvent, track: LBTopRecording): void {
if (isContextMenuKey(e)) {
e.preventDefault();
this.ctxMenuTrack = track;
this.ctxMenuTarget = { kind: 'track', track };
this.ctxMenu.openFrom(e.currentTarget as HTMLElement);
return;
@@ -1966,10 +2063,169 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
e.preventDefault();
e.stopPropagation();
this.ctxMenuTrack = track;
this.ctxMenuTarget = { kind: 'track', track };
this.ctxMenu.openAt(e.clientX, e.clientY);
}
/* ── Release context menu ── */
// The two release shapes on this page are normalised at the moment
// the menu opens, so the union does not reach the action handlers.
/** Normalise a top-section release group. */
private topReleaseTarget(rg: LBTopReleaseGroup): ReleaseMenuTarget {
return {
mbid: rg.releaseGroupMbid || '',
localId: rg.localId ?? 0,
title: rg.title,
owned: Boolean(rg.inLibrary || rg.localId),
};
}
/**
* Normalise a discography release group.
*
* A library-only release arrives as `local:<n>`, which names nothing
* upstream — so its catalog MBID is empty and the local id is
* unwrapped from it, the same way `navigateToAlbum` does.
*/
private albumTarget(rg: MBReleaseGroup): ReleaseMenuTarget {
const isLocal =
typeof rg.mbid === 'string' && rg.mbid.startsWith('local:');
const localId = rg.localId || (isLocal ? Number(rg.mbid.slice(6)) : 0);
return {
mbid: isLocal ? '' : rg.mbid || '',
localId: Number.isFinite(localId) ? localId : 0,
title: rg.title,
owned:
this.libraryMBIDs.has(rg.mbid) ||
Boolean(rg.inLibrary) ||
localId > 0,
};
}
private onReleaseContextMenu(e: MouseEvent, release: ReleaseMenuTarget): void {
e.preventDefault();
e.stopPropagation();
this.ctxMenuTarget = { kind: 'release', release };
this.ctxMenu.openAt(e.clientX, e.clientY);
}
private onReleaseKeydown(
e: KeyboardEvent,
release: ReleaseMenuTarget,
activate: () => void,
): void {
if (isContextMenuKey(e)) {
e.preventDefault();
this.ctxMenuTarget = { kind: 'release', release };
this.ctxMenu.openFrom(e.currentTarget as HTMLElement);
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
activate();
}
}
/**
* The release's files, keyed on the local album id.
*
* Keyed on the id rather than the MBID for the reason the album page
* is: an owned but untagged release has no recording MBIDs, so an
* MBID-keyed lookup returns nothing while looking entirely correct.
*/
private async releaseFilePaths(
release: ReleaseMenuTarget,
): Promise<string[]> {
if (release.localId <= 0) return [];
const libraryID = libraryStore.getSelectedLibraryId() ?? 0;
const byAlbum = await GetFilePathsByAlbums([release.localId], libraryID);
return byAlbum[release.localId] ?? [];
}
private async onReleaseAction(
action: 'play' | 'add-to-queue' | 'play-next',
): Promise<void> {
const release = this.ctxMenuRelease;
this.ctxMenu.close();
if (!release) return;
try {
const paths = await this.releaseFilePaths(release);
if (paths.length === 0) {
notificationStore.inline(ExploreArtistRegion, {
text: `No files for ${release.title} were found in your library.`,
});
return;
}
switch (action) {
case 'play':
queueStore.setQueue(paths, 0, false, this.queueSource());
break;
case 'add-to-queue':
for (const path of paths) queueStore.addToQueue(path);
break;
case 'play-next':
for (const path of [...paths].reverse())
queueStore.playNext(path);
break;
}
} catch (err) {
console.error('Could not queue release:', err);
notificationStore.inline(ExploreArtistRegion, {
text: describeError(err, `Could not play ${release.title}.`),
});
}
}
private async onReleaseRequestToggle(): Promise<void> {
const release = this.ctxMenuRelease;
this.ctxMenu.close();
if (!release?.mbid) return;
try {
await toggleRequest({
mbid: release.mbid,
entity: 'album',
title: release.title,
artist: this.artist?.name ?? this.artistName,
});
} catch (err) {
console.error('Could not change the request:', err);
notificationStore.inline(ExploreArtistRegion, {
text: describeError(err, `Could not request ${release.title}.`),
});
}
}
private viewReleaseOnMusicBrainz(): void {
const release = this.ctxMenuRelease;
this.ctxMenu.close();
if (!release?.mbid) return;
window.open(
`https://musicbrainz.org/release-group/${release.mbid}`,
'_blank',
'noopener',
);
}
private onContextMenuAction(action: 'play' | 'add-to-queue' | 'play-next'): void {
const track = this.ctxMenuTrack;
@@ -2196,7 +2452,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
region=${ExploreArtistRegion}
testid="artist-action-message"
></inline-notice>
${this.renderTrackContextMenu()}
${this.renderContextMenu()}
`;
}
@@ -2233,40 +2489,37 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
`;
}
private renderTrackContextMenu() {
const track = this.ctxMenuTrack;
/**
* The one context menu panel, shared by the top tracks and the
* release cards.
*
* The label is computed from the target rather than written down,
* which is the fault `cover-grid` shipped — a shared panel that
* announced every menu as "Album actions".
*/
private renderContextMenu() {
const target = this.ctxMenuTarget;
return html`
<wa-popup
id="track-context-menu"
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen && track
${this.ctxMenu.contextMenuOpen && target
? html`
<div class="context-menu-panel" role="menu" aria-label="Track actions">
${this.isTrackOwned(track)
? html`
<wa-dropdown-item @click=${() => this.onContextMenuAction('play')}>
<wa-icon slot="icon" name="play"></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item @click=${() => this.onContextMenuAction('add-to-queue')}>
<wa-icon slot="icon" name="plus"></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item @click=${() => this.onContextMenuAction('play-next')}>
<wa-icon slot="icon" name="forward-step"></wa-icon>
Play Next
</wa-dropdown-item>
`
: nothing}
<wa-dropdown-item @click=${() => this.viewTrackOnMusicBrainz()}>
<wa-icon slot="icon" name="globe"></wa-icon>
View on MusicBrainz
</wa-dropdown-item>
<div
class="context-menu-panel"
role="menu"
aria-label=${target.kind === 'track'
? 'Track actions'
: 'Release actions'}
>
${target.kind === 'track'
? this.renderTrackMenuItems(target.track)
: this.renderReleaseMenuItems(target.release)}
</div>
`
: nothing}
@@ -2274,6 +2527,83 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
`;
}
private renderTrackMenuItems(track: LBTopRecording) {
return html`
${this.isTrackOwned(track)
? html`
<wa-dropdown-item @click=${() => this.onContextMenuAction('play')}>
<wa-icon slot="icon" name="play"></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item @click=${() => this.onContextMenuAction('add-to-queue')}>
<wa-icon slot="icon" name="plus"></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item @click=${() => this.onContextMenuAction('play-next')}>
<wa-icon slot="icon" name="forward-step"></wa-icon>
Play Next
</wa-dropdown-item>
`
: nothing}
<wa-dropdown-item @click=${() => this.viewTrackOnMusicBrainz()}>
<wa-icon slot="icon" name="globe"></wa-icon>
View on MusicBrainz
</wa-dropdown-item>
`;
}
/**
* Which items a release gets, decided by what it can actually do.
*
* Playback is gated on a local album id rather than on "owned": a
* release matched by MBID with no local album behind it has nothing
* to queue. The request needs the opposite — a catalog MBID — so it
* is absent for a library-only release, which is also the one case
* where wanting it makes no sense.
*/
private renderReleaseMenuItems(release: ReleaseMenuTarget) {
const requested =
libraryStatusFor(release.owned, release.mbid) === 'queued';
return html`
${release.localId > 0
? html`
<wa-dropdown-item @click=${() => void this.onReleaseAction('play')}>
<wa-icon slot="icon" name="play"></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item @click=${() => void this.onReleaseAction('add-to-queue')}>
<wa-icon slot="icon" name="plus"></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item @click=${() => void this.onReleaseAction('play-next')}>
<wa-icon slot="icon" name="forward-step"></wa-icon>
Play Next
</wa-dropdown-item>
`
: nothing}
${!release.owned && release.mbid
? html`
<wa-dropdown-item @click=${() => void this.onReleaseRequestToggle()}>
<wa-icon
slot="icon"
name=${requested ? 'xmark' : 'bookmark'}
></wa-icon>
${requested ? 'Cancel Request' : 'Want This'}
</wa-dropdown-item>
`
: nothing}
${release.mbid
? html`
<wa-dropdown-item @click=${() => this.viewReleaseOnMusicBrainz()}>
<wa-icon slot="icon" name="globe"></wa-icon>
View on MusicBrainz
</wa-dropdown-item>
`
: nothing}
`;
}
/**
* Subscribes to an artist: their new releases go on the requests
* list as they come out.
@@ -2550,6 +2880,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
private renderTopReleaseCard(rg: LBTopReleaseGroup) {
const artURL = this.thumbnailURLs.get(rg.releaseGroupMbid) || '';
const target = this.topReleaseTarget(rg);
return html`
<div
@@ -2557,12 +2888,12 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
@click=${() => this.navigateToTopRelease(rg)}
role="button"
tabindex="0"
@keydown=${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.navigateToTopRelease(rg);
}
}}
@contextmenu=${(e: MouseEvent) =>
this.onReleaseContextMenu(e, target)}
@keydown=${(e: KeyboardEvent) =>
this.onReleaseKeydown(e, target, () =>
this.navigateToTopRelease(rg),
)}
>
<div class="top-release-art">
${artURL
@@ -2685,18 +3016,20 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
const inLibrary = this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary);
const status = libraryStatusFor(inLibrary, rg.mbid);
const target = this.albumTarget(rg);
return html`
<div
class="album-card"
@click=${() => this.navigateToAlbum(rg)}
role="button"
tabindex="0"
@keydown=${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.navigateToAlbum(rg);
}
}}
@contextmenu=${(e: MouseEvent) =>
this.onReleaseContextMenu(e, target)}
@keydown=${(e: KeyboardEvent) =>
this.onReleaseKeydown(e, target, () =>
this.navigateToAlbum(rg),
)}
>
<div class="album-art-container">
${artURL
@@ -7,7 +7,7 @@ import { classMap } from 'lit/directives/class-map.js';
import '@components/page-header/page-header';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import { SearchLocal, SearchLyrics, GetThumbnail, GetThumbnails, GetArtistImageURL, GetExploreShelves, RecordSearchClick } from '@go/explore/Service';
import { SearchLocal, SearchLyrics, GetThumbnail, GetThumbnails, GetArtistImageURL, GetArtistImagesCachedPaths, GetExploreShelves, RecordSearchClick } from '@go/explore/Service';
import { GetFilePathsByAlbums, GetFilePathsByRecordingMBIDs } from '@go/library/Library';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
@@ -1517,8 +1517,19 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
}
/**
* Load artist images for all visible artist cards. Each call
* is async and updates the cache + re-renders on success.
* Load artist images for all visible artist cards.
*
* The order matters, because the three sources cost wildly
* different things. The library store is free. `GetArtistImages
* CachedPaths` is one call that asks the disk about every remaining
* artist at once — a portrait already downloaded costs no network
* at all, which on a catalog search is most of them, and seeding
* only from the library (owned artists, nearly none of a search's
* results) is what sent them to the resolver instead.
* `GetArtistImageURL` is the *resolving* entry point — MusicBrainz
* rels → Wikidata → Wikipedia → a download — so only what the disk
* did not answer reaches it, and those run together rather than one
* `await` at a time.
*/
private async loadArtistImages(
artists: MBArtist[],
@@ -1529,24 +1540,51 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
// Seed from library store first (instant, no API).
this.seedArtistImagesFromLibrary(artists);
// Fetch remaining from API (only artists not yet resolved).
for (const a of artists) {
if (this.artistImageCache.has(a.mbid)) continue;
this.artistImageCache.set(a.mbid, '');
// One disk existence check for everything still unresolved.
const unresolved = artists
.filter((a) => a.mbid && !this.artistImageCache.has(a.mbid))
.map((a) => a.mbid);
if (unresolved.length > 0) {
try {
const url = await GetArtistImageURL(a.mbid);
const cached = (await GetArtistImagesCachedPaths(unresolved)) || {};
let seeded = false;
if (url) {
this.artistImageCache.set(a.mbid, url);
this.requestUpdate();
for (const [mbid, path] of Object.entries(cached)) {
if (path) {
this.artistImageCache.set(mbid, path);
seeded = true;
}
}
if (seeded) this.requestUpdate();
} catch {
// No image — leave empty string.
// The disk check is an optimisation; fall through.
}
}
// Whatever the disk did not answer goes to the resolver, in
// parallel — these are independent lookups against different
// upstreams and nothing about them is ordered.
await Promise.all(
artists.map(async (a) => {
if (!a.mbid || this.artistImageCache.has(a.mbid)) return;
this.artistImageCache.set(a.mbid, '');
try {
const url = await GetArtistImageURL(a.mbid);
if (url) {
this.artistImageCache.set(a.mbid, url);
this.requestUpdate();
}
} catch {
// No image — leave empty string.
}
}),
);
// Final fallback: album art for artists the API couldn't resolve.
// Try library store first, then search-result release groups.
let fallbackUpdated = false;
@@ -11,6 +11,11 @@ export function jobIcon(job: Job): string {
return 'database';
case 'autotag-apply':
return 'tags';
case 'catalog-enrich':
// The globe rather than the database: this is catalog data
// arriving from the network, not the local index being
// rebuilt from it.
return 'globe';
default:
return 'gear';
}
+5 -1
View File
@@ -27,7 +27,11 @@ export type JobState =
| 'error';
/** Job kinds. Mirrors backend/jobs.Kind. */
export type JobKind = 'library-scan' | 'index-build' | 'autotag-apply';
export type JobKind =
| 'library-scan'
| 'index-build'
| 'autotag-apply'
| 'catalog-enrich';
type Subscriber = () => void;
+5 -2
View File
@@ -80,7 +80,7 @@ async function findLocalAlbum(
/** Find the library artist row for a name, loading the cache if needed. */
async function findLocalArtist(
artistName: string,
): Promise<{ ID: number; Name: string } | null> {
): Promise<{ ID: number; Name: string; MBID: string } | null> {
let artists = libraryStore.cachedArtists;
if (!artists) {
@@ -176,9 +176,12 @@ export function artistLink(
const local = await findLocalArtist(artistName);
if (!local) return;
// The caller's row had no MBID, but the library row for the
// same artist may — the grid routes by exactly this field,
// so reading it here is what keeps the two paths agreeing.
navigate(target, {
view: 'explore-artist-details',
artistMBID: '',
artistMBID: local.MBID || '',
artistName,
localArtistId: local.ID,
});
@@ -0,0 +1,89 @@
/**
* Which call an artist portrait comes from.
*
* `GetArtistImageURL` is the *resolving* entry point: on a miss it does
* MusicBrainz artist-rels → Wikidata → Wikipedia → a Wikimedia
* download. `GetArtistImagesCachedPaths` is a disk existence check.
* Explore used to seed only from the library store — owned artists,
* which on a catalog search is nearly none of the results — and send
* everything else to the resolver, one `await` at a time.
*
* So the rule under test is that a portrait already on disk costs no
* network call at all, and that the ones that do need resolving are not
* serialised behind each other.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-view/explore-view';
import { stub, flush, resetHarness, calls } from '@test/support/harness';
import { fixture } from '@test/support/render';
const CACHED = 'artist-cached';
const UNCACHED = 'artist-uncached';
function searchResult() {
return {
artists: [
{ mbid: CACHED, name: 'Tideline', popularity: 10 },
{ mbid: UNCACHED, name: 'Shorebreak', popularity: 5 },
],
releaseGroups: [],
recordings: [],
topResults: [],
};
}
beforeEach(() => {
resetHarness();
stub('explore.Service.SearchLocal', searchResult());
stub('explore.Service.GetThumbnails', {});
stub('explore.Service.GetExploreShelves', { state: 'ready', shelves: [] });
stub('explore.Service.GetArtistImagesCachedPaths', {
[CACHED]: '/artist-images/ar/artist-cached/primary_md.jpg',
});
stub('explore.Service.GetArtistImageURL', '');
});
async function search(): Promise<LitElement> {
const el = await fixture<LitElement>('explore-view', {});
await (
el as unknown as {
executeIndexSearch: (v: number, q: string) => Promise<void>;
}
).executeIndexSearch(0, 'tide');
await flush();
await el.updateComplete;
return el;
}
describe('where Explore gets its artist portraits', () => {
it('asks the disk about every unresolved artist in one call', async () => {
await search();
const cachedCalls = calls('explore.Service.GetArtistImagesCachedPaths');
// Exactly one: the point is that N artists cost one disk lookup,
// and a zero here would mean the search never ran.
expect(cachedCalls.length).toBe(1);
const asked = cachedCalls[0]?.args[0];
expect(asked).toContain(CACHED);
expect(asked).toContain(UNCACHED);
});
it('never sends a disk-cached artist to the resolver', async () => {
await search();
const resolved = calls('explore.Service.GetArtistImageURL').map(
(c) => c.args[0],
);
expect(resolved).not.toContain(CACHED);
});
});
@@ -0,0 +1,119 @@
/**
* How much the artist page asks the catalog to warm up.
*
* `PrefetchReleases` fires up to eight `BrowseReleases` calls, which is
* the most expensive request the app makes — every version of a release
* group, with `recordings` and `media`, on a shared rate limiter. The
* page used to call it from *both* the top-releases fetch and the
* discography fetch, and because the backend skips groups it has
* already cached, the second call did not collapse into the first: it
* spent its own cap of eight on the next eight albums. On a cold artist
* `ArtistDiscographyReady` re-runs both fetchers, so one page view could
* queue thirty-two of them.
*
* The rule under test is therefore about call *count*, not content: the
* two sections contribute to one batched request, the top releases lead
* it because that is what a visitor clicks, and nothing is asked for
* twice.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-artist-details/explore-artist-details';
import { stub, emit, flush, resetHarness, calls } from '@test/support/harness';
import { fixture } from '@test/support/render';
const ARTIST = 'artist-0001';
/** Every argument list PrefetchReleases has been called with. */
function prefetchCalls(): string[][] {
return calls('explore.Service.PrefetchReleases').map(
(c) => (c.args[0] as string[]) ?? [],
);
}
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupArtist', {
mbid: ARTIST,
name: 'Tideline',
});
// Top releases and the full discography overlap, as they do in life:
// the top list is a subset of the discography.
stub('explore.Service.TopReleaseGroupsForArtist', [
{ releaseGroupMbid: 'rg-top-1', title: 'Foreshore', artistName: 'Tideline' },
{ releaseGroupMbid: 'rg-top-2', title: 'Backwash', artistName: 'Tideline' },
]);
stub('explore.Service.BrowseReleaseGroups', [
{ mbid: 'rg-top-1', title: 'Foreshore', artistCredit: 'Tideline' },
{ mbid: 'rg-deep-1', title: 'Spring Tide', artistCredit: 'Tideline' },
]);
stub('explore.Service.TopRecordingsForArtist', []);
stub('explore.Service.SimilarArtists', []);
stub('explore.Service.PrefetchReleases', undefined);
});
describe('what the artist page asks the catalog to prefetch', () => {
it('makes one prefetch call for both sections, not one each', async () => {
await fixture<LitElement>('explore-artist-details', {
artistMBID: ARTIST,
artistName: 'Tideline',
});
await flush();
expect(prefetchCalls().length).toBe(1);
});
it('leads with the top releases and includes the deep cuts', async () => {
await fixture<LitElement>('explore-artist-details', {
artistMBID: ARTIST,
artistName: 'Tideline',
});
await flush();
const batch = prefetchCalls()[0] ?? [];
expect(batch.slice(0, 2)).toEqual(['rg-top-1', 'rg-top-2']);
expect(batch).toContain('rg-deep-1');
});
it('asks for each release group once, across both sections', async () => {
await fixture<LitElement>('explore-artist-details', {
artistMBID: ARTIST,
artistName: 'Tideline',
});
await flush();
const batch = prefetchCalls()[0] ?? [];
expect(batch.length).toBe(new Set(batch).size);
expect(batch.filter((m) => m === 'rg-top-1').length).toBe(1);
});
it('does not re-ask on the cold-artist refetch', async () => {
await fixture<LitElement>('explore-artist-details', {
artistMBID: ARTIST,
artistName: 'Tideline',
});
await flush();
const asked = prefetchCalls().flat().length;
// The background discography fetch reports in, and both fetchers
// run again against the freshly-populated index.
emit('ArtistDiscographyReady', ARTIST);
await flush();
const askedAfter = prefetchCalls().flat().length;
expect(askedAfter).toBe(asked);
});
});
@@ -0,0 +1,172 @@
/**
* The context menu on a release card on the artist page.
*
* The page already had one, for the top *tracks*, and the release cards
* — which are most of the page — had none: a right-click did whatever
* the browser does and the keyboard had no way in at all.
*
* What is worth pinning is not that the menu opens. It is that the
* items shown match what the release can actually do, because the three
* cases genuinely differ: an owned release has files to queue, an
* unowned catalog release has nothing to play but can be requested, and
* a library-only release has no catalog id, so it can be played and is
* the one case with nothing to view upstream. A menu that offers Play
* on a release with no files is the fault `library-status-indicator`
* was rewritten to stop making, one control over.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-artist-details/explore-artist-details';
import { stub, flush, resetHarness } from '@test/support/harness';
import { fixture, shadow } from '@test/support/render';
const ARTIST = 'artist-0001';
/** The labels of the open menu's items, trimmed. */
function menuItems(el: LitElement): string[] {
const panel = shadow(el, '.context-menu-panel');
if (!panel) return [];
return [...panel.querySelectorAll('wa-dropdown-item')].map(
(item) => item.textContent?.trim() ?? '',
);
}
/** Right-click the nth discography card and let the menu render. */
async function openMenuOnAlbum(el: LitElement, index: number): Promise<void> {
const cards = el.shadowRoot?.querySelectorAll('.album-card') ?? [];
const card = cards[index];
expect(card, `no album card at index ${index}`).toBeTruthy();
card?.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true }),
);
await flush();
}
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupArtist', { mbid: ARTIST, name: 'Tideline' });
stub('explore.Service.TopReleaseGroupsForArtist', []);
stub('explore.Service.TopRecordingsForArtist', []);
stub('explore.Service.SimilarArtists', []);
stub('explore.Service.PrefetchReleases', undefined);
// One of each case, in the order the assertions below index them.
stub('explore.Service.BrowseReleaseGroups', [
{
mbid: 'rg-owned',
title: 'Foreshore',
artistCredit: 'Tideline',
primaryType: 'Album',
inLibrary: true,
localId: 7,
},
{
mbid: 'rg-wanted',
title: 'Backwash',
artistCredit: 'Tideline',
primaryType: 'Album',
},
{
mbid: 'local:12',
title: 'Bootleg Tape',
artistCredit: 'Tideline',
primaryType: 'Album',
localId: 12,
},
]);
});
async function mount(): Promise<LitElement> {
const el = await fixture<LitElement>('explore-artist-details', {
artistMBID: ARTIST,
artistName: 'Tideline',
});
await flush();
return el;
}
describe('the context menu on an artist page release', () => {
it('opens on a right-click and names itself a release menu', async () => {
const el = await mount();
await openMenuOnAlbum(el, 0);
const panel = shadow(el, '.context-menu-panel');
expect(panel).toBeTruthy();
// The panel is shared with the track menu, so a label that does not
// move with the target is confidently wrong rather than merely
// missing.
expect(panel?.getAttribute('aria-label')).toBe('Release actions');
});
it('offers playback for a release with local files', async () => {
const el = await mount();
await openMenuOnAlbum(el, 0);
const items = menuItems(el);
expect(items).toContain('Play');
expect(items).toContain('Add to Queue');
expect(items).toContain('Play Next');
// Owned: there is nothing left to ask for.
expect(items).not.toContain('Want This');
});
it('offers a request, and no playback, for a release nobody owns', async () => {
const el = await mount();
await openMenuOnAlbum(el, 1);
const items = menuItems(el);
expect(items).not.toContain('Play');
expect(items).not.toContain('Add to Queue');
expect(items).toContain('Want This');
expect(items).toContain('View on MusicBrainz');
});
it('drops the catalog items for a library-only release', async () => {
const el = await mount();
await openMenuOnAlbum(el, 2);
const items = menuItems(el);
// It has files, so it plays…
expect(items).toContain('Play');
// …but a `local:` id names nothing upstream, and wanting something
// already in the library is not a thing to offer.
expect(items).not.toContain('View on MusicBrainz');
expect(items).not.toContain('Want This');
});
it('opens from the keyboard on Shift+F10', async () => {
const el = await mount();
const card = el.shadowRoot?.querySelectorAll('.album-card')[0];
card?.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'F10',
shiftKey: true,
bubbles: true,
cancelable: true,
}),
);
await flush();
expect(shadow(el, '.context-menu-panel')).toBeTruthy();
});
});