fix(explore): show a requested album as queued, not absent

`library-status-indicator` has had three states since it was written
and produced two: all eight call sites were a two-way ternary between
`in-library` and `not-in-library`, so the `queued` state it styles and
labels was unreachable.

The result was the app contradicting itself on one page. An album added
to the request list showed a plus and announced "is not in your
library", forty pixels from a filled button reading "Wanted".

The rule was written at eight places, which is why none of them had all
of it, so it is `utils/library-status.ts` now: owning outranks wanting,
a satisfied request is not queued, and a request is by MBID — a track
inside a requested album is not itself requested and still says so.

`explore-view` gains the `downloadStore` subscription both detail views
already had, registered `whileActive` because it is a cached view that
never unmounts. `top-results-row` needs its own: its host re-rendering
sets the same `results` array back, so Lit stops at the property and
the row never hears about a change.
This commit is contained in:
2026-08-13 14:13:30 -04:00
parent d33dfb2264
commit 451b46e63c
6 changed files with 292 additions and 14 deletions
@@ -24,6 +24,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 { libraryStatusFor } from '@utils/library-status';
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator';
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';
@@ -1443,7 +1445,7 @@ export class ExploreAlbumDetails extends LitElement {
* - cachedAlbums has MBID match → owned
* - any selected version has → owned (covers local-only albums
* a track marked inLibrary where releaseGroup may be null)
* - else → not owned
* - else → whatever the request list says
*
* Four different claims of decreasing confidence, OR'd together and
* reported as one tick — the last of which fires when a *single*
@@ -1452,10 +1454,11 @@ export class ExploreAlbumDetails extends LitElement {
* actions key off; this stays as it was, because the indicator's
* job is "is any of this yours" and that is what it answers.
*
* No queued state for now — that's reserved for future
* download-client integration.
* When none of them hold the answer is not automatically "no":
* the album may be on the request list, which the button directly
* below this badge has reported as "Wanted" all along.
*/
private albumLibraryStatus(): 'in-library' | 'not-in-library' {
private albumLibraryStatus(): LibraryStatus {
if (this.localAlbumId > 0) return 'in-library';
if (this.releaseGroup?.inLibrary) return 'in-library';
@@ -1477,7 +1480,11 @@ export class ExploreAlbumDetails extends LitElement {
}
}
return 'not-in-library';
// None of the five ownership claims held, so the badge falls
// through to the one thing this page already knew and never
// said: whether the album is on the request list. The button
// below it has read "Wanted" all along.
return libraryStatusFor(false, this.releaseGroupMBID);
}
/**
@@ -2280,7 +2287,7 @@ export class ExploreAlbumDetails extends LitElement {
)}</span
>
<library-status-indicator
status=${track.inLibrary ? 'in-library' : 'not-in-library'}
status=${libraryStatusFor(Boolean(track.inLibrary), track.mbid)}
entity-type="track"
label=${track.title}
></library-status-indicator>
@@ -34,6 +34,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 '../catalog-scope-notice/catalog-scope-notice.js';
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
@@ -2113,7 +2114,7 @@ export class ExploreArtistDetails extends LitElement {
${formatListenCount(t.totalListenCount)} plays
</span>
<library-status-indicator
status=${t.inLibrary || t.localId ? 'in-library' : 'not-in-library'}
status=${libraryStatusFor(Boolean(t.inLibrary || t.localId), t.recordingMbid)}
entity-type="track"
label=${t.trackName}
></library-status-indicator>
@@ -2225,7 +2226,7 @@ export class ExploreArtistDetails extends LitElement {
${rg.date ? html`<span>${extractYear(rg.date)}</span>` : nothing}
</div>
<library-status-indicator
status=${rg.inLibrary || rg.localId ? 'in-library' : 'not-in-library'}
status=${libraryStatusFor(Boolean(rg.inLibrary || rg.localId), rg.releaseGroupMbid)}
entity-type="album"
label=${rg.title}
size="18"
@@ -2320,9 +2321,7 @@ export class ExploreArtistDetails extends LitElement {
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';
const status = libraryStatusFor(inLibrary, rg.mbid);
return html`
<div
@@ -1,4 +1,6 @@
import { avatarBackground } from '@utils/avatar-color';
import { libraryStatusFor } from '@utils/library-status';
import { downloadStore } from '@store/download-store';
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query as litQuery } from 'lit/decorators.js';
import '@components/page-header/page-header';
@@ -724,6 +726,19 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
this.cancelIndexStatus = EventsOn(Events.IndexStatusChanged, () => {
if (this.shelves?.state !== 'ready') void this.loadShelves();
});
// The badges on every result card say whether something is
// already requested, which a background reconcile pass changes
// without this page doing anything. Registered `whileActive`
// rather than on connect: this view is cached and never
// unmounts, so a connect-time subscription would run for the
// life of the session from pages it is not on.
//
// `init()` is four fetches, and it happens on arrival for the
// same reason `loadShelves()` does — a user who never opens
// Explore should not pay for it.
this.whileActive(downloadStore.subscribe(() => this.requestUpdate()));
void downloadStore.init().then(() => this.requestUpdate());
}
/** A debounced search that lands after the user has left the page is
@@ -1836,7 +1851,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
${year ? html`<span>${year}</span>` : nothing}
</div>
<library-status-indicator
status=${this.libraryMBIDs.has(rg.mbid) || rg.inLibrary ? 'in-library' : 'not-in-library'}
status=${libraryStatusFor(this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary), rg.mbid)}
entity-type="album"
label=${rg.title}
></library-status-indicator>
@@ -1874,7 +1889,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
: nothing}
</div>
<library-status-indicator
status=${this.libraryMBIDs.has(r.mbid) || r.inLibrary ? 'in-library' : 'not-in-library'}
status=${libraryStatusFor(this.libraryMBIDs.has(r.mbid) || Boolean(r.inLibrary), r.mbid)}
entity-type="track"
label=${r.title}
></library-status-indicator>
@@ -10,6 +10,8 @@ import {
import '../library-status-indicator/library-status-indicator.js';
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js';
import { artistLink, exploreLinkStyles } from '../../utils/explore-link';
import { libraryStatusFor } from '../../utils/library-status';
import { downloadStore } from '../../store/download-store';
/** Format milliseconds as m:ss. */
function formatDuration(ms: number | undefined): string {
@@ -50,6 +52,28 @@ export class TopResultsRow extends LitElement {
// Per-card state: cover images.
private images = new Map<string, string>();
private unsubRequests?: () => void;
/**
* The badges here say whether something is already requested, and
* this row will not hear about a change from its host: `explore-view`
* re-rendering sets the same `results` array back, so Lit stops at
* the property and never updates this element. One subscription for
* the row, not one per card.
*/
override connectedCallback(): void {
super.connectedCallback();
this.unsubRequests = downloadStore.subscribe(() =>
this.requestUpdate(),
);
}
override disconnectedCallback(): void {
this.unsubRequests?.();
this.unsubRequests = undefined;
super.disconnectedCallback();
}
static override styles = [
designTokens,
exploreLinkStyles,
@@ -255,7 +279,10 @@ export class TopResultsRow extends LitElement {
? r.year || ''
: formatDuration(r.length) || '';
const status: LibraryStatus = r.inLibrary ? 'in-library' : 'not-in-library';
const status: LibraryStatus = libraryStatusFor(
Boolean(r.inLibrary),
r.mbid,
);
const entityType: 'artist' | 'album' | 'track' =
r.entityType === 'artist'
? 'artist'