feat(explore): say whether a page is the catalog or your own copy

The album and artist pages draw from two sources and rendered
identically either way. An album showing one track because that is all
you own was indistinguishable from an album that has one track, and
both were indistinguishable from a page still waiting on a background
catalog fetch — so the answer to "is more coming?" was to keep
reloading and find out.

<catalog-scope-notice> names the source in one line: silent for full
catalog data, "still loading" while a fetch may land, "library only"
for an entity with no MBID (which will never fill in, so it points at
Autotag), and a retryable notice when the catalog had nothing to say.

Both pages needed a new distinction to drive it. loadingReleases and
its artist-side equivalents mean "something is renderable", which a
library stand-in satisfies — so catalogPending/catalogLoaded track the
different question of whether the catalog has actually answered.

Also fixes the artist page clobbering its library-hydrated discography
with an empty catalog result. An empty BrowseReleaseGroups means the
index has not built this artist yet, not that they released nothing.
This commit is contained in:
2026-08-11 01:15:11 -04:00
parent 7c3c0e25b9
commit ba35858208
4 changed files with 351 additions and 1 deletions
@@ -0,0 +1,170 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
/**
* How much of an album or artist page the user is actually looking at.
*
* The album and artist pages draw from two sources — the MusicBrainz
* catalog, and the local library — and until now the page looked the
* same either way. That is the confusing part: a page showing one
* track because that is all you own is indistinguishable from a page
* showing one track because that is all the album has, and a page that
* is still waiting on a background catalog fetch looks like a page that
* has finished and found nothing.
*
* - `catalog` — full catalog data. Nothing is rendered; the normal
* case does not need a banner.
* - `loading` — a catalog fetch is in flight; what is on screen is
* the library copy, standing in.
* - `library` — the entity carries no MusicBrainz ID, so the catalog
* has nothing to say about it, now or later.
* - `unavailable` — the catalog was asked and did not answer (offline,
* timeout, error). Retrying is meaningful here, and
* only here.
*/
export type CatalogScope = 'catalog' | 'loading' | 'library' | 'unavailable';
/**
* One-line banner naming the source of what is on screen.
*
* Emits `catalog-retry` (bubbling, composed) when the user asks for
* another attempt, which only appears for the `unavailable` scope.
*/
@customElement('catalog-scope-notice')
export class CatalogScopeNotice extends LitElement {
@property({ type: String })
scope: CatalogScope = 'catalog';
/** What the page is about, so the copy can name it. */
@property({ type: String, attribute: 'entity-type' })
entityType: 'album' | 'artist' = 'album';
static override styles = [
designTokens,
css`
:host {
display: block;
}
.notice {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
font-size: var(--yj-text-sm, 12px);
line-height: 1.4;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-secondary, #b3b3b3);
}
.notice.unavailable {
color: var(--yj-text-primary, #fff);
}
wa-icon {
flex-shrink: 0;
}
.text {
flex: 1;
min-width: 0;
}
button {
flex-shrink: 0;
background: none;
border: 1px solid var(--yj-border-subtle, #333);
border-radius: 4px;
color: inherit;
cursor: pointer;
font-size: var(--yj-text-sm, 12px);
padding: 3px 10px;
}
button:hover {
border-color: var(--yj-accent, #ffd43b);
}
.spin {
animation: spin 1.4s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`,
];
override render() {
if (this.scope === 'catalog') return nothing;
const entity = this.entityType;
const copy = this.copyFor(entity);
return html`
<div class="notice ${this.scope}" role="status">
<wa-icon
class=${this.scope === 'loading' ? 'spin' : ''}
name=${copy.icon}
></wa-icon>
<span class="text">${copy.text}</span>
${this.scope === 'unavailable'
? html`<button @click=${this.retry}>Retry</button>`
: nothing}
</div>
`;
}
private copyFor(entity: 'album' | 'artist'): {
icon: string;
text: string;
} {
const thing = entity === 'album' ? 'album' : 'artist';
switch (this.scope) {
case 'loading':
return {
icon: 'rotate',
text: `Showing what your library has while the full ${thing} details load\u2026`,
};
case 'library':
return {
icon: 'database',
text:
`Library only \u2014 this ${thing} isn't matched to MusicBrainz, `
+ 'so only what you already have is shown. Tag it in Autotag '
+ 'to see the rest.',
};
case 'unavailable':
return {
icon: 'triangle-exclamation',
// Deliberately covers both "the fetch failed" and
// "the catalog has nothing for this one": the user
// cannot tell those apart and does not need to —
// what matters is that this page is their own copy.
text:
`No catalog details for this ${thing} right now, so this is your `
+ 'library copy \u2014 anything you do not own is missing from this page.',
};
default:
return { icon: 'circle-info', text: '' };
}
}
private retry = () => {
this.dispatchEvent(
new CustomEvent('catalog-retry', { bubbles: true, composed: true }),
);
};
}
declare global {
interface HTMLElementTagNameMap {
'catalog-scope-notice': CatalogScopeNotice;
}
}
@@ -19,6 +19,8 @@ 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 '../catalog-scope-notice/catalog-scope-notice.js';
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import '../download-picker/download-picker';
import { downloadStore } from '../../store/download-store';
@@ -113,6 +115,13 @@ export class ExploreAlbumDetails extends LitElement {
@state() private loadingReleases = true;
@state() private errorInfo = '';
@state() private errorReleases = '';
/** True once the versions/tracklist on screen came from the catalog
* rather than standing in from the local library. */
@state() private catalogReleasesLoaded = false;
/** True while a catalog fetch (foreground or background) may still
* land. Distinct from loadingReleases, which goes false as soon as
* *something* is renderable — including a library stand-in. */
@state() private catalogPending = false;
/** Unified entries shown in the dropdown — synthetics first, then real clusters. */
@state() private versionEntries: VersionEntry[] = [];
/** Currently-selected dropdown entry (by VersionEntry.key). */
@@ -548,6 +557,7 @@ export class ExploreAlbumDetails extends LitElement {
if (this.releasesReloaded.has(mbid)) return;
this.releasesReloaded.add(mbid);
this.catalogPending = false;
if (this.releases.length === 0) this.loadingReleases = false;
}, 12000);
}
@@ -646,6 +656,8 @@ export class ExploreAlbumDetails extends LitElement {
this.errorReleases = '';
this.loadingInfo = true;
this.loadingReleases = true;
this.catalogReleasesLoaded = false;
this.catalogPending = Boolean(this.releaseGroupMBID);
this.releases = [];
this.versionEntries = [];
this.selectedVersionKey = '';
@@ -930,6 +942,8 @@ export class ExploreAlbumDetails extends LitElement {
if (releases && releases.length > 0) {
// Warm cache hit (or the background re-fetch landed):
// authoritative MB versions replace any local placeholder.
this.catalogReleasesLoaded = true;
this.catalogPending = false;
this.releases = releases;
this.buildClusters();
this.loadingReleases = false;
@@ -944,11 +958,18 @@ export class ExploreAlbumDetails extends LitElement {
if (this.releases.length > 0 || this.releasesReloaded.has(mbid)) {
this.loadingReleases = false;
}
// A cold miss after the background fetch already signalled
// ready is as far as the catalog is going to get.
if (this.releasesReloaded.has(mbid)) {
this.catalogPending = false;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.errorReleases = msg;
console.error(`[explore-album] BrowseReleases error: ${msg}`);
this.loadingReleases = false;
this.catalogPending = false;
}
}
@@ -1452,6 +1473,11 @@ export class ExploreAlbumDetails extends LitElement {
return html`
${this.renderHeader()}
<div class="content">
<catalog-scope-notice
scope=${this.catalogScope()}
entity-type="album"
@catalog-retry=${this.retryCatalog}
></catalog-scope-notice>
${this.renderVersionSelector()}
${this.renderTracklist()}
</div>
@@ -1675,6 +1701,38 @@ export class ExploreAlbumDetails extends LitElement {
`;
}
/**
* Where the tracklist on screen came from. The distinction the
* user cares about is not "did a fetch fail" but "is what I am
* looking at everything, or only my own copy" — so an album with
* no MBID is `library` permanently, while one whose catalog fetch
* has not landed is `loading` and then either resolves or degrades
* to `unavailable`.
*/
private catalogScope(): CatalogScope {
if (!this.releaseGroupMBID) return 'library';
if (this.catalogReleasesLoaded) return 'catalog';
if (this.catalogPending) return 'loading';
// Not pending and no catalog data: the browse errored, came
// back empty, or the fallback timer gave up. Whatever is on
// screen is the library copy, and retrying is worth offering.
return 'unavailable';
}
/** Ask the catalog again after a failed or empty fetch. */
private retryCatalog = () => {
const mbid = this.releaseGroupMBID;
if (!mbid) return;
this.errorReleases = '';
this.loadingReleases = this.releases.length === 0;
this.catalogPending = true;
this.releasesReloaded.delete(mbid);
this.armReleasesFallback(mbid);
void this.fetchReleases(mbid);
};
/** Tracks of the version currently selected in the dropdown. */
private currentTracks(): MBTrack[] {
const entry = this.versionEntries.find(
@@ -32,6 +32,8 @@ 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 '../catalog-scope-notice/catalog-scope-notice.js';
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
/* ── Constants ── */
@@ -103,6 +105,12 @@ export class ExploreArtistDetails extends LitElement {
@state() private loadingReleases = true;
@state() private errorArtist = '';
@state() private errorReleases = '';
/** True once the discography on screen came from the catalog rather
* than standing in from the local library. */
@state() private catalogLoaded = false;
/** True while a catalog fetch — foreground or the background
* discography build — may still land. */
@state() private catalogPending = false;
@state() private similarArtists: LBSimilarArtist[] = [];
@state() private loadingSimilar = true;
@state() private artistImageURL = '';
@@ -920,6 +928,7 @@ export class ExploreArtistDetails extends LitElement {
this.discogReloaded.add(mbid);
this.similarReloaded.add(mbid);
this.catalogPending = false;
if (this.topTracks.length === 0) this.loadingTracks = false;
if (this.topReleaseGroups.length === 0) this.loadingTopReleases = false;
if (this.releaseGroups.length === 0) this.loadingReleases = false;
@@ -1037,6 +1046,8 @@ export class ExploreArtistDetails extends LitElement {
// forever if ArtistDiscographyReady never arrives.
this.discogReloaded.delete(mbid);
this.similarReloaded.delete(mbid);
this.catalogLoaded = false;
this.catalogPending = true;
this.armDiscogFallback(mbid);
// Phase 1: fire all API requests independently so the UI
@@ -1246,6 +1257,35 @@ export class ExploreArtistDetails extends LitElement {
}
}
/**
* Where this page's discography came from. An artist with no MBID
* can only ever show what the library holds; one whose catalog
* fetch is still in flight says so rather than looking finished.
*/
private catalogScope(): CatalogScope {
if (!this.artistMBID) return 'library';
if (this.catalogLoaded) return 'catalog';
if (this.catalogPending) return 'loading';
return 'unavailable';
}
/** Ask the catalog again after a failed or empty discography fetch. */
private retryCatalog = () => {
const mbid = this.artistMBID;
if (!mbid) return;
this.errorReleases = '';
this.catalogPending = true;
this.discogReloaded.delete(mbid);
this.similarReloaded.delete(mbid);
this.armDiscogFallback(mbid);
void this.fetchTopTracks(mbid);
void this.fetchTopReleaseGroups(mbid);
void this.fetchReleaseGroups(mbid);
void this.fetchSimilarArtists(mbid);
};
private async fetchArtist(mbid: string) {
try {
this.artist = await LookupArtist(mbid);
@@ -1396,7 +1436,17 @@ export class ExploreArtistDetails extends LitElement {
private async fetchReleaseGroups(mbid: string) {
try {
const rgs = await BrowseReleaseGroups(mbid);
this.releaseGroups = rgs ?? [];
// An empty result is "the index has not built this artist
// yet", not "this artist released nothing" — so it must not
// wipe the library albums hydrateFromCache put on screen.
if (rgs && rgs.length > 0) {
this.releaseGroups = rgs;
this.catalogLoaded = true;
this.catalogPending = false;
} else if (this.discogReloaded.has(mbid)) {
this.catalogPending = false;
}
// Populate libraryMBIDs from the inLibrary flag (already
// set by the backend via local_release_group_id cross-ref).
@@ -1414,6 +1464,7 @@ export class ExploreArtistDetails extends LitElement {
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.errorReleases = msg;
this.catalogPending = false;
console.error(
`[explore-artist] BrowseReleaseGroups error: ${msg}`,
);
@@ -1889,6 +1940,11 @@ export class ExploreArtistDetails extends LitElement {
</div>
</div>
<div class="content">
<catalog-scope-notice
scope=${this.catalogScope()}
entity-type="artist"
@catalog-retry=${this.retryCatalog}
></catalog-scope-notice>
${this.renderTopSection()} ${this.renderDiscography()}
${this.renderSimilarArtists()}
</div>
@@ -0,0 +1,66 @@
/**
* The scope notice exists because an album or artist page looked
* identical whether it was showing the catalog, a library stand-in, or
* nothing yet. So the assertions here are about the one thing it must
* never do — stay silent when the page is not the whole story — and
* about staying out of the way when it is.
*/
import { describe, expect, it } from 'vitest';
import '@components/catalog-scope-notice/catalog-scope-notice';
import { fixture, shadow, text } from '@test/support/render';
describe('catalog scope notice', () => {
it('renders nothing at all for full catalog data', async () => {
const el = await fixture('catalog-scope-notice', { scope: 'catalog' });
expect(shadow(el, '.notice')).toBeNull();
});
it('names the entity it is talking about', async () => {
const album = await fixture('catalog-scope-notice', {
scope: 'library',
entityType: 'album',
});
const artist = await fixture('catalog-scope-notice', {
scope: 'library',
entityType: 'artist',
});
expect(text(album, '.text')).toContain('album');
expect(text(artist, '.text')).toContain('artist');
});
it('distinguishes "still loading" from "this is all there is"', async () => {
const loading = await fixture('catalog-scope-notice', { scope: 'loading' });
const library = await fixture('catalog-scope-notice', { scope: 'library' });
expect(text(loading, '.text')).toContain('load');
expect(text(library, '.text')).toContain('Library only');
});
it('offers a retry only where retrying could change anything', async () => {
for (const scope of ['loading', 'library']) {
const el = await fixture('catalog-scope-notice', { scope });
expect(shadow(el, 'button')).toBeNull();
}
const el = await fixture('catalog-scope-notice', { scope: 'unavailable' });
expect(shadow(el, 'button')).not.toBeNull();
});
it('asks its host to retry rather than fetching anything itself', async () => {
const el = await fixture('catalog-scope-notice', { scope: 'unavailable' });
let asked = 0;
el.addEventListener('catalog-retry', () => {
asked += 1;
});
shadow<HTMLElement>(el, 'button')!.click();
expect(asked).toBe(1);
});
});