diff --git a/frontend/src/components/catalog-scope-notice/catalog-scope-notice.ts b/frontend/src/components/catalog-scope-notice/catalog-scope-notice.ts
new file mode 100644
index 0000000..286f3cd
--- /dev/null
+++ b/frontend/src/components/catalog-scope-notice/catalog-scope-notice.ts
@@ -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`
+
@@ -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(
diff --git a/frontend/src/components/explore-artist-details/explore-artist-details.ts b/frontend/src/components/explore-artist-details/explore-artist-details.ts
index c7a4f3f..403cf9a 100644
--- a/frontend/src/components/explore-artist-details/explore-artist-details.ts
+++ b/frontend/src/components/explore-artist-details/explore-artist-details.ts
@@ -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 {
diff --git a/frontend/test/components/catalog-scope-notice.test.ts b/frontend/test/components/catalog-scope-notice.test.ts
new file mode 100644
index 0000000..e85ba66
--- /dev/null
+++ b/frontend/test/components/catalog-scope-notice.test.ts
@@ -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(el, 'button')!.click();
+
+ expect(asked).toBe(1);
+ });
+});