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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user