feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Build & publish Arch package / arch-package (push) Successful in 2m12s
Search index maintenance / maintain-index (push) Successful in 2h22m28s

Ships the fresh-start schema cleanup: rebuilt explore catalog index
pipeline (dump import, artifact fetch/build, incremental listen-count
refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/
slskd/yt-dlp providers, staging, reconciliation, wanted list), and the
supporting schema/query/store changes across backend and frontend.

Also includes two smaller follow-ups: bump the central index's
rebuild-after cadence from 90 to 180 days, and remove the Explore
"library only" online/offline toggle entirely (frontend-only, no
backend counterpart) rather than carry unused UI/state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
This commit is contained in:
2026-08-06 17:12:01 -04:00
co-authored by Claude Sonnet 5
parent d0d86f85d5
commit e190fd75b9
165 changed files with 31088 additions and 5192 deletions
@@ -32,6 +32,7 @@ import {
import './config-field';
import './config-section';
import './download-clients';
import './shortcut-capture';
import { shortcutsStore } from '../../store/shortcuts-store';
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
@@ -959,6 +960,12 @@ export class ConfigPage extends LitElement {
font-variant-numeric: tabular-nums;
}
.tier-detail {
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-xs, 11px);
font-variant-numeric: tabular-nums;
}
.tier-error {
color: var(--yj-accent-error, #f44);
font-size: var(--yj-text-xs, 11px);
@@ -1457,6 +1464,7 @@ export class ConfigPage extends LitElement {
${this.renderFavoritesSection()}
${this.renderTrackListSection()}
${this.renderShortcutsSection()}
<download-clients></download-clients>
${this.renderLibrarySection()}
`;
}
@@ -1499,6 +1507,9 @@ export class ConfigPage extends LitElement {
${t.state === 'running' && t.total > 0
? html`<span class="tier-progress">${t.completed}/${t.total}</span>`
: nothing}
${t.state === 'running' && t.detail
? html`<span class="tier-detail">${t.detail}</span>`
: nothing}
${t.state === 'error'
? html`<span class="tier-error">${t.error}</span>`
: nothing}
@@ -0,0 +1,514 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import '@awesome.me/webawesome/dist/components/input/input.js';
import '@awesome.me/webawesome/dist/components/select/select.js';
import '@awesome.me/webawesome/dist/components/option/option.js';
import '@awesome.me/webawesome/dist/components/switch/switch.js';
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
import '@awesome.me/webawesome/dist/components/callout/callout.js';
import { designTokens } from '../../styles/tokens.css';
import type {
DownloadDescriptor,
DownloadProvider,
} from '@store/download-store';
import { downloadStore } from '@store/download-store';
import './config-section';
/**
* Download client configuration.
*
* The forms are rendered from the descriptors the backend publishes, not
* from anything hard-coded here, so adding a provider on the backend
* gives it a settings UI with no frontend change. That is also why
* secret fields render as password inputs purely on the descriptor's
* say-so — the frontend never needs to know which services have keys.
*/
@customElement('download-clients')
export class DownloadClients extends LitElement {
@state()
private providers: DownloadProvider[] = [];
@state()
private descriptors: DownloadDescriptor[] = [];
/** Provider being edited, or 'new' while adding one. */
@state()
private editing: number | 'new' | null = null;
/** Kind selected in the add form. */
@state()
private newKind = '';
/** Working copy of the form's field values. */
@state()
private draft: Record<string, string> = {};
@state()
private draftName = '';
/** Per-provider connection test results, keyed by provider ID. */
@state()
private testResults: Record<number, { ok: boolean; message: string }> = {};
@state()
private testing: number | null = null;
@state()
private errorMessage = '';
private unsubscribe: (() => void) | null = null;
override connectedCallback(): void {
super.connectedCallback();
this.unsubscribe = downloadStore.subscribe(() => this.syncFromStore());
void downloadStore.init().then(() => this.syncFromStore());
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribe?.();
this.unsubscribe = null;
}
private syncFromStore(): void {
this.providers = downloadStore.providers;
this.descriptors = downloadStore.descriptors;
}
static override styles = [
designTokens,
css`
:host {
display: block;
}
.clients {
display: flex;
flex-direction: column;
gap: 0.6em;
}
.client {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75em;
align-items: center;
padding: 0.7em 0.85em;
border: 1px solid var(--wa-color-surface-border, #333);
border-radius: 8px;
}
.client-name {
font-weight: 600;
}
.client-meta {
font-size: 0.82em;
opacity: 0.7;
margin-top: 0.15em;
}
.client-actions {
display: flex;
gap: 0.4em;
align-items: center;
}
.test-result {
font-size: 0.8em;
margin-top: 0.35em;
}
.test-result.ok {
color: var(--wa-color-success-fill-loud, #4c9f70);
}
.test-result.fail {
color: var(--wa-color-danger-fill-loud, #c65f5f);
}
.form {
display: flex;
flex-direction: column;
gap: 0.7em;
padding: 0.9em;
border: 1px solid var(--wa-color-surface-border, #333);
border-radius: 8px;
margin-top: 0.6em;
}
.form-actions {
display: flex;
gap: 0.5em;
justify-content: flex-end;
margin-top: 0.3em;
}
.requires {
font-size: 0.82em;
opacity: 0.75;
}
.empty {
opacity: 0.7;
font-size: 0.9em;
padding: 0.5em 0;
}
.add-row {
margin-top: 0.8em;
}
`,
];
override render() {
return html`
<config-section
heading="Download Clients"
description="Connect services you already run to search for and download music. Nothing is enabled until you add a client."
>
${this.errorMessage
? html`<wa-callout variant="danger">${this.errorMessage}</wa-callout>`
: nothing}
<div class="clients">
${this.providers.length === 0 && this.editing !== 'new'
? html`<div class="empty">No download clients connected.</div>`
: nothing}
${this.providers.map((provider) => this.renderProvider(provider))}
</div>
${this.editing === 'new'
? this.renderAddForm()
: html`
<div class="add-row">
<wa-button size="small" @click=${this.startAdd}>
Add download client
</wa-button>
</div>
`}
</config-section>
`;
}
private renderProvider(provider: DownloadProvider) {
const descriptor = this.descriptorFor(provider.kind);
const test = this.testResults[provider.id];
if (this.editing === provider.id) {
return this.renderEditForm(provider);
}
return html`
<div class="client">
<div>
<div class="client-name">${provider.name}</div>
<div class="client-meta">
${descriptor?.name ?? provider.kind} ·
${provider.enabled ? 'Enabled' : 'Disabled'} ·
priority ${provider.priority}
</div>
${test
? html`<div class="test-result ${test.ok ? 'ok' : 'fail'}">
${test.message}
</div>`
: nothing}
</div>
<div class="client-actions">
<wa-button
size="small"
appearance="plain"
?disabled=${this.testing === provider.id}
@click=${() => this.testProvider(provider)}
>
${this.testing === provider.id
? html`<wa-spinner></wa-spinner>`
: 'Test'}
</wa-button>
<wa-button
size="small"
appearance="plain"
@click=${() => this.startEdit(provider)}
>
Edit
</wa-button>
<wa-button
size="small"
appearance="plain"
variant="danger"
@click=${() => this.deleteProvider(provider)}
>
Remove
</wa-button>
</div>
</div>
`;
}
private renderAddForm() {
const descriptor = this.descriptorFor(this.newKind);
return html`
<div class="form">
<wa-select
label="Client type"
.value=${this.newKind}
@wa-change=${this.onKindChange}
>
${this.descriptors.map(
(d) => html`<wa-option value=${d.kind}>${d.name}</wa-option>`,
)}
</wa-select>
${descriptor
? html`
<div class="requires">
${descriptor.summary}
${descriptor.requiresExternal
? html`<br />Requires a running
${descriptor.requiresExternal} instance.`
: nothing}
</div>
<wa-input
label="Name"
.value=${this.draftName}
@wa-input=${(e: Event) => {
this.draftName = (e.target as HTMLInputElement).value;
}}
></wa-input>
${this.renderFields(descriptor)}
`
: nothing}
<div class="form-actions">
<wa-button size="small" appearance="plain" @click=${this.cancelEdit}>
Cancel
</wa-button>
<wa-button
size="small"
variant="brand"
?disabled=${!descriptor}
@click=${this.saveNew}
>
Add
</wa-button>
</div>
</div>
`;
}
private renderEditForm(provider: DownloadProvider) {
const descriptor = this.descriptorFor(provider.kind);
return html`
<div class="form">
<wa-input
label="Name"
.value=${this.draftName}
@wa-input=${(e: Event) => {
this.draftName = (e.target as HTMLInputElement).value;
}}
></wa-input>
${descriptor ? this.renderFields(descriptor) : nothing}
<wa-input
label="Priority"
type="number"
.value=${String(provider.priority)}
@wa-input=${(e: Event) => {
this.draft['__priority'] = (e.target as HTMLInputElement).value;
}}
></wa-input>
<wa-switch
?checked=${provider.enabled}
@wa-change=${(e: Event) => {
this.draft['__enabled'] = (e.target as HTMLInputElement)
.checked
? '1'
: '';
}}
>
Enabled
</wa-switch>
<div class="form-actions">
<wa-button size="small" appearance="plain" @click=${this.cancelEdit}>
Cancel
</wa-button>
<wa-button
size="small"
variant="brand"
@click=${() => this.saveEdit(provider)}
>
Save
</wa-button>
</div>
</div>
`;
}
/** Renders one input per descriptor field. */
private renderFields(descriptor: DownloadDescriptor) {
return (descriptor.fields ?? []).map(
(field) => html`
<wa-input
label=${field.label}
placeholder=${field.placeholder ?? ''}
type=${field.secret ? 'password' : 'text'}
.value=${this.draft[field.key] ?? ''}
@wa-input=${(e: Event) => {
this.draft = {
...this.draft,
[field.key]: (e.target as HTMLInputElement).value,
};
}}
>
${field.help ? html`<span slot="hint">${field.help}</span>` : nothing}
</wa-input>
`,
);
}
private descriptorFor(kind: string): DownloadDescriptor | undefined {
return this.descriptors.find((d) => d.kind === kind);
}
private startAdd = () => {
this.editing = 'new';
this.errorMessage = '';
this.newKind = this.descriptors[0]?.kind ?? '';
this.draftName = this.descriptorFor(this.newKind)?.name ?? '';
this.draft = this.defaultsFor(this.newKind);
};
private startEdit(provider: DownloadProvider) {
this.editing = provider.id;
this.errorMessage = '';
this.draftName = provider.name;
// Secrets are never sent back to the frontend, so their fields
// start blank; a blank secret on save means "leave it alone"
// rather than "clear it".
this.draft = { ...(provider.settings ?? {}) };
}
private cancelEdit = () => {
this.editing = null;
this.draft = {};
this.errorMessage = '';
};
private onKindChange = (event: Event) => {
this.newKind = (event.target as HTMLInputElement).value;
this.draftName = this.descriptorFor(this.newKind)?.name ?? '';
this.draft = this.defaultsFor(this.newKind);
};
private defaultsFor(kind: string): Record<string, string> {
const descriptor = this.descriptorFor(kind);
const out: Record<string, string> = {};
for (const field of descriptor?.fields ?? []) {
if (field.default) out[field.key] = field.default;
}
return out;
}
private saveNew = async () => {
this.errorMessage = '';
try {
await downloadStore.addProvider(
this.newKind,
this.draftName || this.newKind,
this.cleanDraft(),
);
this.cancelEdit();
} catch (err) {
this.errorMessage = String(err);
}
};
private async saveEdit(provider: DownloadProvider) {
this.errorMessage = '';
const priority = this.draft['__priority']
? Number(this.draft['__priority'])
: provider.priority;
const enabled =
'__enabled' in this.draft
? this.draft['__enabled'] === '1'
: provider.enabled;
try {
await downloadStore.updateProvider(
provider.id,
this.draftName || provider.name,
enabled,
priority,
this.cleanDraft(),
);
this.cancelEdit();
} catch (err) {
this.errorMessage = String(err);
}
}
/** Strips the form's internal bookkeeping keys before saving. */
private cleanDraft(): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(this.draft)) {
if (!key.startsWith('__')) out[key] = value;
}
return out;
}
private async deleteProvider(provider: DownloadProvider) {
this.errorMessage = '';
try {
await downloadStore.deleteProvider(provider.id);
} catch (err) {
this.errorMessage = String(err);
}
}
private async testProvider(provider: DownloadProvider) {
this.testing = provider.id;
try {
await downloadStore.testProvider(provider.id);
this.testResults = {
...this.testResults,
[provider.id]: { ok: true, message: 'Connected.' },
};
} catch (err) {
this.testResults = {
...this.testResults,
[provider.id]: { ok: false, message: String(err) },
};
} finally {
this.testing = null;
}
}
}
declare global {
interface HTMLElementTagNameMap {
'download-clients': DownloadClients;
}
}
@@ -0,0 +1,254 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import { designTokens } from '../../styles/tokens.css';
import type { DownloadCandidate } from '@store/download-store';
import { candidateSummary, scorePercent } from '@store/download-store';
/**
* One candidate in the download picker.
*
* The row shows match and quality as two separate meters rather than
* one blended score, because they fail differently: a flawless copy of
* the wrong album is useless, a mediocre copy of the right one is
* merely disappointing, and only the user knows which they will accept.
* Collapsing them into a single number would make the ranking
* impossible to argue with.
*/
@customElement('candidate-row')
export class CandidateRow extends LitElement {
@property({ type: Object })
candidate!: DownloadCandidate;
/** Marks the row the ranking put first. */
@property({ type: Boolean, attribute: 'is-best' })
isBest = false;
@property({ type: Boolean })
busy = false;
static override styles = [
designTokens,
css`
:host {
display: block;
}
.row {
display: grid;
grid-template-columns: 1fr auto;
gap: 1em;
align-items: center;
padding: 0.75em 0.9em;
border: 1px solid var(--wa-color-surface-border, #333);
border-radius: 8px;
background: var(--wa-color-surface-raised, #1c1c1c);
}
.row.best {
border-color: var(--wa-color-brand-fill-loud, #d9a441);
}
.title {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.summary {
font-size: 0.85em;
opacity: 0.75;
margin-top: 0.15em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badges {
display: flex;
gap: 0.4em;
margin-top: 0.4em;
flex-wrap: wrap;
}
.badge {
font-size: 0.72em;
padding: 0.1em 0.45em;
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
white-space: nowrap;
}
.badge.best {
background: var(--wa-color-brand-fill-loud, #d9a441);
color: #111;
font-weight: 600;
}
.badge.warn {
background: rgba(217, 119, 65, 0.25);
}
.meters {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.3em 0.5em;
align-items: center;
margin-top: 0.5em;
font-size: 0.75em;
max-width: 340px;
}
.meter-label {
opacity: 0.7;
}
.track {
height: 5px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.fill {
height: 100%;
border-radius: 3px;
transition: width 150ms ease;
}
.fill.match {
background: var(--wa-color-success-fill-loud, #4c9f70);
}
.fill.match.low {
background: var(--wa-color-warning-fill-loud, #d97741);
}
.fill.quality {
background: var(--wa-color-brand-fill-loud, #6a8cc7);
}
.value {
font-variant-numeric: tabular-nums;
opacity: 0.85;
}
`,
];
/** Match below this reads as "probably not what you asked for". */
private static readonly LOW_MATCH = 0.7;
override render() {
const candidate = this.candidate;
if (!candidate) return nothing;
const match = candidate.match?.overall ?? 0;
const quality = candidate.quality?.overall ?? 0;
return html`
<div class="row ${this.isBest ? 'best' : ''}">
<div class="info">
<div class="title" title=${candidate.title}>
${candidate.title}
</div>
<div class="summary">${candidateSummary(candidate)}</div>
${this.renderBadges()}
<div class="meters">
<span class="meter-label">Match</span>
<div class="track">
<div
class="fill match ${match < CandidateRow.LOW_MATCH
? 'low'
: ''}"
style="width: ${Math.round(match * 100)}%"
></div>
</div>
<span class="value">${scorePercent(match)}</span>
<span class="meter-label">Quality</span>
<div class="track">
<div
class="fill quality"
style="width: ${Math.round(quality * 100)}%"
></div>
</div>
<span class="value">${scorePercent(quality)}</span>
</div>
</div>
<wa-button
variant=${this.isBest ? 'brand' : 'neutral'}
size="small"
?disabled=${this.busy}
@click=${this.onPick}
>
Download
</wa-button>
</div>
`;
}
private renderBadges() {
const candidate = this.candidate;
const badges = [];
if (this.isBest) {
badges.push(html`<span class="badge best">Best match</span>`);
}
// An unanchored match is a guess: there was no MusicBrainz ID to
// check the result against, so the score cannot mean much and
// saying so is more honest than showing a confident number.
if (candidate.match && !candidate.match.anchored) {
badges.push(
html`<span class="badge warn" title="No MusicBrainz match to verify against">
Unverified
</span>`,
);
}
if (candidate.quality?.mixed) {
badges.push(
html`<span class="badge warn" title="Files are not all the same format">
Mixed formats
</span>`,
);
}
const completeness = candidate.match?.completeness ?? 1;
if (completeness < 1 && completeness > 0) {
badges.push(
html`<span class="badge warn">
${scorePercent(completeness)} of tracks
</span>`,
);
}
if (candidate.protocol && candidate.protocol !== 'direct') {
badges.push(html`<span class="badge">${candidate.protocol}</span>`);
}
return badges.length > 0
? html`<div class="badges">${badges}</div>`
: nothing;
}
private onPick() {
this.dispatchEvent(
new CustomEvent('candidate-pick', {
detail: { candidateId: this.candidate.id },
bubbles: true,
composed: true,
}),
);
}
}
declare global {
interface HTMLElementTagNameMap {
'candidate-row': CandidateRow;
}
}
@@ -0,0 +1,278 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
import '@awesome.me/webawesome/dist/components/callout/callout.js';
import { designTokens } from '../../styles/tokens.css';
import type { DownloadCandidate } from '@store/download-store';
import { downloadStore } from '@store/download-store';
import type { download } from '@go/models';
import './candidate-row';
/**
* The "find this album" dialog: searches every enabled download client,
* ranks what comes back, and asks the user to choose.
*
* When the pipeline finds a clear winner it starts on its own and this
* dialog reports that rather than asking a question with one obvious
* answer. When it does not — two equally good candidates, or a free-text
* request with nothing to verify against — the choice is the user's,
* because guessing wrong puts the wrong files in their library.
*/
@customElement('download-picker')
export class DownloadPicker extends LitElement {
@property({ type: Boolean, reflect: true })
open = false;
/** Library the imported files belong to. */
@property({ type: Number, attribute: 'library-id' })
libraryId = 0;
@property({ type: String })
artist = '';
@property({ type: String })
album = '';
/** MusicBrainz release-group ID, when the caller has one. */
@property({ type: String, attribute: 'release-group-mbid' })
releaseGroupMbid = '';
@property({ type: String, attribute: 'release-mbid' })
releaseMbid = '';
/**
* Expected tracklist. Supplying it is what makes the result
* trustworthy: without it there is nothing to check a candidate
* against, and the pipeline will never auto-pick.
*/
@property({ type: Array })
expected: download.ExpectedTrack[] = [];
@state()
private searching = false;
@state()
private candidates: DownloadCandidate[] = [];
@state()
private requestId = '';
@state()
private autoPicked = false;
@state()
private picking = false;
@state()
private errorMessage = '';
static override styles = [
designTokens,
css`
:host {
display: contents;
}
.heading {
display: flex;
flex-direction: column;
gap: 0.15em;
margin-bottom: 1em;
}
.album {
font-size: 1.05em;
font-weight: 600;
}
.artist {
opacity: 0.75;
font-size: 0.9em;
}
.status {
display: flex;
align-items: center;
gap: 0.6em;
padding: 1.5em 0;
justify-content: center;
opacity: 0.85;
}
.list {
display: flex;
flex-direction: column;
gap: 0.6em;
max-height: 55vh;
overflow-y: auto;
}
.footnote {
margin-top: 1em;
font-size: 0.8em;
opacity: 0.65;
}
`,
];
override updated(changed: Map<string, unknown>) {
if (changed.has('open') && this.open) {
void this.search();
}
}
/** Runs the search that populates the dialog. */
private async search(): Promise<void> {
this.searching = true;
this.errorMessage = '';
this.candidates = [];
this.autoPicked = false;
try {
const result = await downloadStore.start({
libraryId: this.libraryId,
releaseMbid: this.releaseMbid,
releaseGroupMbid: this.releaseGroupMbid,
artist: this.artist,
album: this.album,
query: '',
expected: this.expected ?? [],
} as download.SearchRequest);
this.requestId = result.requestId;
this.candidates = result.candidates ?? [];
this.autoPicked = result.autoPicked;
} catch (err) {
this.errorMessage = String(err);
} finally {
this.searching = false;
}
}
private async onPick(event: CustomEvent<{ candidateId: string }>) {
if (this.picking) return;
this.picking = true;
this.errorMessage = '';
try {
await downloadStore.pick(this.requestId, event.detail.candidateId);
this.close();
} catch (err) {
this.errorMessage = String(err);
} finally {
this.picking = false;
}
}
private close() {
this.open = false;
this.dispatchEvent(
new CustomEvent('picker-close', { bubbles: true, composed: true }),
);
}
override render() {
return html`
<wa-dialog
label="Find this album"
?open=${this.open}
@wa-hide=${() => this.close()}
>
<div class="heading">
<span class="album">${this.album || 'Unknown album'}</span>
<span class="artist">${this.artist}</span>
</div>
${this.renderBody()}
<wa-button slot="footer" variant="neutral" @click=${() => this.close()}>
Close
</wa-button>
</wa-dialog>
`;
}
private renderBody() {
if (this.errorMessage) {
return html`
<wa-callout variant="danger">${this.errorMessage}</wa-callout>
`;
}
if (this.searching) {
return html`
<div class="status">
<wa-spinner></wa-spinner>
<span>Searching your download clients…</span>
</div>
`;
}
if (this.autoPicked) {
return html`
<wa-callout variant="success">
Found a clear match and started downloading it. Progress is
in the background jobs panel.
</wa-callout>
`;
}
if (this.candidates.length === 0) {
return html`
<wa-callout variant="neutral">
Nothing found. Try a different spelling, or connect more
download clients in Settings.
</wa-callout>
`;
}
return html`
<div class="list">
${this.candidates.map(
(candidate, index) => html`
<candidate-row
.candidate=${candidate}
?is-best=${index === 0}
?busy=${this.picking}
@candidate-pick=${this.onPick}
></candidate-row>
`,
)}
</div>
${this.renderFootnote()}
`;
}
private renderFootnote() {
const best = this.candidates[0];
if (!best?.match) return nothing;
// Say plainly why nothing was auto-picked, so the dialog does
// not look like it is asking a question it could have answered.
if (!best.match.anchored) {
return html`
<div class="footnote">
This search had no MusicBrainz match to verify against, so
these results could not be checked automatically.
</div>
`;
}
return html`
<div class="footnote">
Downloads are checked and tagged before they are added to your
library.
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'download-picker': DownloadPicker;
}
}
@@ -8,18 +8,20 @@ import {
} from '@go/explore/Service';
import { GetAlbumTracks } from '@go/library/Library';
import { library } from '@go/models';
import type { explore } from '@go/models';
import type { download, explore } from '@go/models';
type MBReleaseGroup = explore.MBReleaseGroup;
type MBRelease = explore.MBRelease;
type MBTrack = explore.MBTrack;
import { exploreCache } from '../../store/explore-cache';
import { exploreSettings } from '../../store/explore-settings';
import { libraryStore } from '../../store/library-store';
import { artistLink, exploreLinkStyles } from '../../utils/explore-link';
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 '@awesome.me/webawesome/dist/components/button/button.js';
import '../download-picker/download-picker';
import { downloadStore } from '../../store/download-store';
/* ── Utility functions (duplicated per Knowledge Pattern #9 — no cross-component imports) ── */
@@ -112,6 +114,18 @@ export class ExploreAlbumDetails extends LitElement {
@state() private selectedVersionKey: string = '';
@state() private coverArtURL = '';
/** Open state of the "find this album" dialog. */
@state() private pickerOpen = false;
/** True once a download client is configured and enabled. */
@state() private canDownload = false;
/** True when this album is already on the wanted list. */
@state() private isWanted = false;
/** Unsubscribe handle for the download store. */
private downloadUnsub: (() => void) | null = null;
/* ── Styles ── */
static override styles = [
@@ -454,7 +468,6 @@ export class ExploreAlbumDetails extends LitElement {
/* ── Lifecycle ── */
private unsubSettings?: () => void;
private unsubReleasesReady?: () => void;
/** Release-group MBIDs whose AlbumReleasesReady event we've handled,
* so a background BrowseReleases fetch re-hydrates versions once. */
@@ -469,12 +482,16 @@ export class ExploreAlbumDetails extends LitElement {
void this.loadAllData();
}
this.unsubSettings = exploreSettings.subscribe(() => {
// Re-run data loading — library-only mode may show/hide
// API-sourced content or hydrate from local tracks.
if (this.releaseGroupMBID || this.localAlbumId) {
void this.loadAllData();
}
// The download button only appears once a client is connected,
// so this tracks the provider list rather than assuming.
this.downloadUnsub = downloadStore.subscribe(() => {
this.canDownload = downloadStore.available;
this.syncWanted();
});
void downloadStore.init().then(() => {
this.canDownload = downloadStore.available;
this.syncWanted();
});
// A background BrowseReleases fetch (cold album, versions +
@@ -496,7 +513,8 @@ export class ExploreAlbumDetails extends LitElement {
override disconnectedCallback() {
super.disconnectedCallback();
this.unsubSettings?.();
this.downloadUnsub?.();
this.downloadUnsub = null;
this.unsubReleasesReady?.();
if (this.releasesFallbackTimer) clearTimeout(this.releasesFallbackTimer);
}
@@ -631,14 +649,6 @@ export class ExploreAlbumDetails extends LitElement {
// tracklist; the return value isn't currently consumed.
await this.hydrateFromLibrary(mbid);
// Library-only mode: local data is all we show.
if (exploreSettings.libraryOnly) {
this.loadingInfo = false;
this.loadingReleases = false;
console.log(`[explore-album] loaded (library-only): "${this.albumName}"`);
return;
}
// Phase 2: fire API calls independently so each section
// renders as its data arrives. Allow the versions section one
// background-fetch re-fetch and arm a fallback so it can't spin
@@ -1336,7 +1346,7 @@ export class ExploreAlbumDetails extends LitElement {
* - localAlbumId set → owned
* - releaseGroup.inLibrary set → owned (backend cross-ref)
* - cachedAlbums has MBID match → owned
* - any selected version has → owned (covers library-only mode
* - any selected version has → owned (covers local-only albums
* a track marked inLibrary where releaseGroup may be null)
* - else → not owned
*
@@ -1478,11 +1488,137 @@ export class ExploreAlbumDetails extends LitElement {
></library-status-indicator>
</h1>
${this.renderAlbumMeta()}
${this.renderDownloadAction()}
</div>
</div>
${this.renderPicker()}
`;
}
/**
* Offers to acquire the album, but only when the user has actually
* connected a download client and does not already own it. Showing
* the button otherwise would advertise a feature that cannot work.
*/
private renderDownloadAction() {
if (this.albumLibraryStatus() === 'in-library') return nothing;
return html`
<div class="album-download">
${this.canDownload
? html`
<wa-button
size="small"
appearance="outlined"
@click=${() => {
this.pickerOpen = true;
}}
>
<wa-icon slot="start" name="download"></wa-icon>
Find this album
</wa-button>
`
: nothing}
${this.renderWantAction()}
</div>
`;
}
/**
* Adds the album to the wanted list, which is the answer to "look
* for it, but not right now".
*
* Unlike the download button this shows whether or not a client is
* connected: wanting something is a durable statement about the
* library, and it stays true — and stays queued — until a client
* exists to act on it.
*/
private renderWantAction() {
if (!this.releaseGroupMBID) return nothing;
const want = downloadStore.wantFor(this.releaseGroupMBID);
return html`
<wa-button
size="small"
appearance=${this.isWanted ? 'filled' : 'outlined'}
@click=${() => void this.toggleWanted(want?.id)}
>
<wa-icon
slot="start"
name=${this.isWanted ? 'bookmark-check' : 'bookmark'}
></wa-icon>
${this.isWanted ? 'Wanted' : 'Want this'}
</wa-button>
`;
}
/** Reflects the store's view of whether this album is wanted. */
private syncWanted(): void {
this.isWanted = this.releaseGroupMBID
? downloadStore.isWanted(this.releaseGroupMBID)
: false;
}
private async toggleWanted(wantId: number | undefined): Promise<void> {
if (!this.releaseGroupMBID) return;
try {
if (wantId) {
await downloadStore.removeWant(wantId);
} else {
await downloadStore.addWant({
mbid: this.releaseGroupMBID,
entity: 'release-group',
libraryId: libraryStore.getSelectedLibraryId() ?? 0,
artist: this.releaseGroup?.artistCredit ?? '',
title: this.albumName,
scope: 'future',
secondary: false,
} as download.WantRequest);
}
} catch (err) {
console.error('Could not update the wanted list:', err);
}
this.syncWanted();
}
private renderPicker() {
if (!this.pickerOpen) return nothing;
const tracks = this.currentTracks();
return html`
<download-picker
?open=${this.pickerOpen}
library-id=${libraryStore.getSelectedLibraryId() ?? 0}
artist=${this.releaseGroup?.artistCredit ?? ''}
album=${this.albumName}
release-group-mbid=${this.releaseGroupMBID ?? ''}
.expected=${tracks.map((t, index) => ({
position: t.position || index + 1,
discNumber: t.discNumber ?? 0,
title: t.title,
artist: '',
lengthMillis: t.length ?? 0,
}))}
@picker-close=${() => {
this.pickerOpen = false;
}}
></download-picker>
`;
}
/** Tracks of the version currently selected in the dropdown. */
private currentTracks(): MBTrack[] {
const entry = this.versionEntries.find(
(e) => e.key === this.selectedVersionKey,
);
return entry?.tracks ?? [];
}
private renderAlbumMeta() {
if (this.loadingInfo) {
return html`<span class="album-artist section-loading"
@@ -1535,9 +1671,6 @@ export class ExploreAlbumDetails extends LitElement {
/* ── Version Selector (R025, R026, R027) ── */
private renderVersionSelector() {
// Library-only mode: no version selector (only local tracks).
if (exploreSettings.libraryOnly) return nothing;
if (this.loadingReleases) {
return html`
<section>
@@ -1683,19 +1816,6 @@ export class ExploreAlbumDetails extends LitElement {
}
const current = this.currentVersion();
if (!current) {
// In library-only mode with no local tracks, show a gentle message.
if (exploreSettings.libraryOnly) {
return html`
<section>
<h3 class="section-header">Tracklist</h3>
<div
style="color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-md)"
>
This album is not in your library.
</div>
</section>
`;
}
return html`
<section>
<h3 class="section-header">Tracklist</h3>
@@ -9,7 +9,6 @@ import {
SimilarArtists,
GetArtistImageURL,
GetArtistImageCachedPath,
GetLibrarySimilarArtists,
GetThumbnail,
GetThumbnails,
GetTrackThumbnail,
@@ -24,8 +23,9 @@ type LBTopRecording = explore.LBTopRecording;
type LBTopReleaseGroup = explore.LBTopReleaseGroup;
type LBSimilarArtist = explore.LBSimilarArtist;
import { exploreCache } from '../../store/explore-cache';
import { exploreSettings } from '../../store/explore-settings';
import { libraryStore } from '../../store/library-store';
import { downloadStore } from '../../store/download-store';
import '@awesome.me/webawesome/dist/components/button/button.js';
import { trackLink, exploreLinkStyles } from '../../utils/explore-link';
import { GetAlbumsByArtist } from '@go/library/Library';
import { EventsOn } from '@runtime/runtime';
@@ -194,6 +194,10 @@ export class ExploreArtistDetails extends LitElement {
object-fit: cover;
}
.artist-follow {
margin-top: 10px;
}
.artist-info {
display: flex;
flex-direction: column;
@@ -830,7 +834,6 @@ export class ExploreArtistDetails extends LitElement {
/* ── Lifecycle ── */
private unsubSettings?: () => void;
private unsubDiscogReady?: () => void;
private unsubSimilarReady?: () => void;
/** MBIDs whose ArtistSimilarReady event we've already handled, so a
@@ -843,19 +846,20 @@ export class ExploreArtistDetails extends LitElement {
* background discography fetch never signals readiness. */
private discogFallbackTimer?: number;
/** Unsubscribe handle for the wanted list. */
private unsubWanted: (() => void) | null = null;
override connectedCallback() {
super.connectedCallback();
if (this.artistMBID || this.localArtistId) {
void this.loadAllData();
}
// Re-render when library-only mode toggles.
this.unsubSettings = exploreSettings.subscribe(() => {
this.requestUpdate();
if (this.artistMBID || this.localArtistId) {
void this.loadAllData();
}
});
// Keep the follow button in step with the wanted list, which a
// background reconcile pass can change without this page doing
// anything.
this.unsubWanted = downloadStore.subscribe(() => this.requestUpdate());
void downloadStore.init().then(() => this.requestUpdate());
// A background discography fetch (top tracks / top releases for an
// artist that wasn't indexed yet) finished — re-fetch those two
@@ -893,7 +897,8 @@ export class ExploreArtistDetails extends LitElement {
override disconnectedCallback() {
super.disconnectedCallback();
this.unsubSettings?.();
this.unsubWanted?.();
this.unsubWanted = null;
this.unsubDiscogReady?.();
this.unsubSimilarReady?.();
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
@@ -1027,90 +1032,6 @@ export class ExploreArtistDetails extends LitElement {
// Phase 0: hydrate from caches (instant, no Go calls).
this.hydrateFromCache(mbid);
if (exploreSettings.libraryOnly) {
// Library-only mode: no external API calls.
// Discography comes from library store (already hydrated).
// Similar artists from pre-computed DB table.
this.loadingArtist = false;
this.loadingTracks = false;
this.loadingTopReleases = false;
this.loadingReleases = false;
this.loadingSimilar = false;
// If the library store hasn't eagerly fetched yet,
// await it and re-hydrate. Covers the race between
// navigation and the deferred eagerFetch on DOMContentLoaded.
if (!libraryStore.cachedArtists || !libraryStore.cachedAlbums) {
try {
const pending: Promise<unknown>[] = [];
if (!libraryStore.cachedArtists) {
pending.push(libraryStore.getArtists());
}
if (!libraryStore.cachedAlbums) {
pending.push(libraryStore.getAlbums());
}
await Promise.all(pending);
this.hydrateFromCache(mbid);
} catch {
// Ignore — we'll fall through to the Wails-cached path.
}
}
// Artist image: if hydrateFromCache didn't find one (e.g.
// the library store's cached artist row has an empty
// ImageMedium because the on-disk file post-dates the
// store's last fetch), fall back to a disk-only Wails
// call. This just asks the backend whether
// /artist-images/.../primary_md.jpg exists — no network,
// no base64 transfer.
if (!this.artistImageURL && mbid) {
void GetArtistImageCachedPath(mbid)
.then((url) => {
if (url) {
this.artistImageURL = url;
} else {
// Final fallback: album art by artist name.
this.fallbackArtistImageFromAlbumArt();
}
})
.catch(() => {
this.fallbackArtistImageFromAlbumArt();
});
}
// Fetch library-only similar artists (single Go call, no external API).
try {
const similar = await GetLibrarySimilarArtists(mbid);
// Dedupe by MBID as a safety net — the backend query
// should already return unique rows but multiple library
// artists can share an MBID (ensemble credits), so this
// guards against any future query regression.
const seen = new Set<string>();
const deduped: LBSimilarArtist[] = [];
for (const s of similar ?? []) {
const key = s.artistMbid || s.name;
if (!key || seen.has(key)) continue;
seen.add(key);
deduped.push(s);
}
this.similarArtists = deduped;
// Resolve similar artist images from library cache —
// no Go calls, no network. These artists are all in
// the library (that's the filter GetLibrarySimilarArtists
// applies), so the library store has their image paths.
this.seedSimilarArtistImagesFromLibrary();
} catch {
this.similarArtists = [];
}
console.log(
`[explore-artist] loaded (library-only): "${this.artistName}"`,
);
return;
}
// Fresh load for this artist: allow the top sections one
// background-fetch re-fetch, and arm a fallback so they can't spin
// forever if ArtistDiscographyReady never arrives.
@@ -1604,7 +1525,7 @@ export class ExploreArtistDetails extends LitElement {
/**
* Populate similarImageURLs for the current similarArtists list
* using only library-store data and disk-cached artist images.
* Makes ZERO network calls — safe for library-only mode.
* Makes ZERO network calls.
*
* Resolution priority per artist:
* 1. libraryStore.cachedArtists[mbid].ImageMedium (in-memory)
@@ -1694,10 +1615,10 @@ export class ExploreArtistDetails extends LitElement {
}
private async fetchSimilarArtistImages() {
// Phase 1: instant seed from library store + disk cache.
// This mirrors the library-only path so any card whose image
// is already on disk appears immediately, without waiting
// for a network-enabled GetArtistImageURL round-trip.
// Phase 1: instant seed from library store + disk cache, so any
// card whose image is already on disk appears immediately,
// without waiting for a network-enabled GetArtistImageURL
// round-trip.
await this.seedSimilarArtistImagesFromLibrary();
// Phase 2: network fetch for any similars still without
@@ -1961,9 +1882,10 @@ export class ExploreArtistDetails extends LitElement {
? html`<div class="artist-native-name">${this.artist.name}</div>`
: nothing}
${this.renderArtistMeta()}
${this.artist?.popularity && this.artist.popularity > 0 && !exploreSettings.libraryOnly
${this.artist?.popularity && this.artist.popularity > 0
? html`<span class="artist-meta">${formatListenCount(this.artist.popularity)} plays on ListenBrainz</span>`
: nothing}
${this.renderFollowAction()}
</div>
</div>
<div class="content">
@@ -1973,6 +1895,61 @@ export class ExploreArtistDetails extends LitElement {
`;
}
/**
* Subscribes to an artist: their new releases go on the wanted list
* as they come out.
*
* The default is new releases only. Following an artist should not
* silently queue forty albums — someone who wants the back
* catalogue can widen it from the wanted list, and will not be
* surprised by having done so.
*/
private renderFollowAction() {
if (!this.artistMBID) return nothing;
const want = downloadStore.wantFor(this.artistMBID);
return html`
<div class="artist-follow">
<wa-button
size="small"
appearance=${want ? 'filled' : 'outlined'}
@click=${() => void this.toggleFollow(want?.id)}
>
<wa-icon
slot="start"
name=${want ? 'bookmark-check' : 'bookmark'}
></wa-icon>
${want ? 'Following' : 'Follow for new releases'}
</wa-button>
</div>
`;
}
private async toggleFollow(wantId: number | undefined): Promise<void> {
if (!this.artistMBID) return;
try {
if (wantId) {
await downloadStore.removeWant(wantId);
} else {
await downloadStore.addWant({
mbid: this.artistMBID,
entity: 'artist',
libraryId: libraryStore.getSelectedLibraryId() ?? 0,
artist: this.displayName,
title: this.displayName,
scope: 'future',
secondary: false,
} as never);
}
} catch (err) {
console.error('Could not update the wanted list:', err);
}
this.requestUpdate();
}
private renderArtistMeta() {
if (this.loadingArtist) {
return html`<span class="artist-meta section-loading"
@@ -2034,9 +2011,6 @@ export class ExploreArtistDetails extends LitElement {
}
private renderTopSection() {
// Library-only mode: no top tracks/releases from LB.
if (exploreSettings.libraryOnly) return nothing;
const hasTracks = !this.loadingTracks && this.topTracks.length > 0;
const hasReleases = !this.loadingTopReleases && this.topReleaseGroups.length > 0;
const tracksLoading = this.loadingTracks;
@@ -2362,10 +2336,8 @@ export class ExploreArtistDetails extends LitElement {
return nothing;
}
// Library-only mode: show all library-matching similar artists
// (up to the 20 stored per seed). Online mode: cap at 10 to
// avoid a very long list.
const maxSimilar = exploreSettings.libraryOnly ? 20 : 10;
// Cap the similar-artists list at 10 to avoid a very long list.
const maxSimilar = 10;
const artists = this.similarArtists.slice(0, maxSimilar);
const showToggle = artists.length > this.discoRowSize;
const collapsed = !this.similarExpanded && showToggle;
@@ -869,10 +869,10 @@ export class ExploreView extends LitElement {
* Wails call. Called after search results are set.
*/
/**
* Seed the thumbnail cache from local library data only. Safe
* to call in library-only mode — does no API calls. Reads from
* cachedAlbums (by MBID) and from any `_coverArt` underscore
* field that searchLibraryCache stamped on the release group.
* Seed the thumbnail cache from local library data only — does
* no API calls. Reads from cachedAlbums (by MBID) and from any
* `_coverArt` underscore field that searchLibraryCache stamped
* on the release group.
*/
private seedThumbnailsFromLibrary() {
if (!this.results?.releaseGroups?.length) return;
@@ -906,8 +906,8 @@ export class ExploreView extends LitElement {
}
/**
* Seed the artist image cache from local library data only.
* Safe to call in library-only mode. Reads from cachedArtists
* Seed the artist image cache from local library data only
* does no API calls. Reads from cachedArtists
* by MBID and from any `_imageMedium`/`_imageSmall` underscore
* field that searchLibraryCache stamped on the artist. Falls
* back to library album art when an artist has no portrait.
@@ -3,21 +3,20 @@ import { customElement, state, query } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import {
GetLibraryDirectory,
SetLibraryDirectory,
} from '@go/config/Config';
AddLibrary,
GetAllLibrariesWithTrackCounts,
} from '@go/library/Library';
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
/**
* First-run setup wizard.
*
* On startup it checks whether a library directory has already been
* configured. If one exists the wizard stays hidden and the app
* proceeds as normal. If none is set (fresh install), it presents a
* non-dismissable modal prompting the user to pick their music folder,
* saves it to the config, and dismisses itself. Saving the directory
* emits LibraryConfigChanged on the backend, which kicks off the
* initial scan automatically.
* On startup it checks whether any library has already been registered.
* If one exists the wizard stays hidden and the app proceeds as normal.
* If there are none (fresh install), it presents a non-dismissable modal
* prompting the user to pick their music folder, registers it through the
* library CRUD API, and dismisses itself. AddLibrary emits LibraryAdded
* and kicks off the initial scan automatically.
*/
@customElement('first-run-wizard')
export class FirstRunWizard extends LitElement {
@@ -40,13 +39,13 @@ export class FirstRunWizard extends LitElement {
super.connectedCallback();
try {
const existing = await GetLibraryDirectory();
const existing = await GetAllLibrariesWithTrackCounts();
// A configured directory means setup is already complete.
if (existing) return;
// An existing library means setup is already complete.
if (existing && existing.length > 0) return;
} catch (err) {
console.error(
'First-run wizard: failed to read library directory:',
'First-run wizard: failed to read libraries:',
err,
);
@@ -249,7 +248,7 @@ export class FirstRunWizard extends LitElement {
this.errorMessage = '';
try {
await SetLibraryDirectory(this.selectedDirectory);
await AddLibrary(this.selectedDirectory);
this.finished = true;
@@ -257,8 +256,8 @@ export class FirstRunWizard extends LitElement {
this.active = false;
} catch (err) {
this.errorMessage = `Could not save the folder: ${err}`;
console.error('First-run wizard: save failed:', err);
this.errorMessage = `Could not add the folder: ${err}`;
console.error('First-run wizard: add library failed:', err);
} finally {
this.saving = false;
}
@@ -5,7 +5,7 @@ import { designTokens } from '../../styles/tokens.css';
import type { DragActiveDetail } from '@utils/drag-controller';
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'autotag' | 'jobs' | 'settings';
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'wanted' | 'autotag' | 'jobs' | 'settings';
interface NavItem {
id: View;
@@ -149,6 +149,7 @@ export class AppSidebar extends LitElement {
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
{ id: 'tracks', label: 'Tracks', icon: 'music' },
{ id: 'explore', label: 'Explore', icon: 'globe' },
{ id: 'wanted', label: 'Wanted', icon: 'bookmark' },
{ id: 'autotag', label: 'Autotag', icon: 'tag' },
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
{ id: 'settings', label: 'Settings', icon: 'gear' },
@@ -0,0 +1,377 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import { designTokens } from '../../styles/tokens.css';
import { downloadStore } from '@store/download-store';
import type { Want, WantSummary } from '@store/download-store';
import { libraryStore } from '@store/library-store';
/**
* The wanted list: music the user has said they want but does not have.
*
* The list is the durable thing here, not the downloads it produces.
* Something unfindable today stays on the list and is retried on a
* backoff, so this view is mostly about making the waiting legible —
* what is being looked for, when it was last tried, and why it has not
* turned up. A row is not a failure just because it is still here.
*/
@customElement('wanted-view')
export class WantedView extends LitElement {
@state() private wants: Want[] = [];
@state() private checking = false;
@state() private lastSummary: WantSummary | null = null;
private unsubscribe: (() => void) | null = null;
static override styles = [
designTokens,
css`
:host {
display: block;
height: 100%;
overflow-y: auto;
padding: 20px;
box-sizing: border-box;
}
header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--yj-text-primary, #fff);
flex: 1;
}
.subtitle {
margin: 0 0 20px;
font-size: 13px;
color: var(--yj-text-secondary, #b3b3b3);
}
h2 {
margin: 24px 0 8px;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--yj-text-secondary, #b3b3b3);
}
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 6px;
background: var(--yj-bg-surface, #181818);
}
.row + .row {
margin-top: 6px;
}
.row-main {
flex: 1;
min-width: 0;
}
.title {
font-size: 14px;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.detail {
font-size: 12px;
color: var(--yj-text-tertiary, #888);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
color: var(--yj-text-secondary, #b3b3b3);
flex-shrink: 0;
}
.empty {
padding: 40px 20px;
text-align: center;
color: var(--yj-text-tertiary, #888);
font-size: 14px;
}
.actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.summary {
font-size: 12px;
color: var(--yj-text-secondary, #b3b3b3);
margin: 8px 0 0;
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
this.unsubscribe = downloadStore.subscribe(() => {
this.wants = downloadStore.wants;
});
void downloadStore.init().then(() => {
this.wants = downloadStore.wants;
});
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribe?.();
this.unsubscribe = null;
}
override render() {
const subscriptions = this.wants.filter((w) => w.entity === 'artist');
const wanted = this.wants.filter(
(w) => w.entity !== 'artist' && w.state === 'wanted',
);
const paused = this.wants.filter((w) => w.state === 'paused');
const satisfied = this.wants.filter((w) => w.state === 'satisfied');
return html`
<header>
<h1>Wanted</h1>
<wa-button
size="small"
appearance="outlined"
?disabled=${this.checking}
@click=${() => void this.checkNow()}
>
<wa-icon slot="start" name="rotate"></wa-icon>
${this.checking ? 'Checking…' : 'Check now'}
</wa-button>
${satisfied.length > 0
? html`
<wa-button
size="small"
appearance="plain"
@click=${() =>
void downloadStore.clearSatisfiedWants()}
>
Clear found
</wa-button>
`
: nothing}
</header>
<p class="subtitle">
Music you want but do not have. Anything that cannot be found
stays here and is looked for again later.
</p>
${this.renderSummary()}
${this.wants.length === 0 ? this.renderEmpty() : nothing}
${this.renderSection(
'Following',
subscriptions,
(w) => this.renderSubscription(w),
)}
${this.renderSection('Looking for', wanted, (w) => this.renderWant(w))}
${this.renderSection('Paused', paused, (w) => this.renderWant(w))}
${this.renderSection('Found', satisfied, (w) => this.renderWant(w))}
`;
}
private renderEmpty() {
return html`
<div class="empty">
Nothing wanted yet. Use “Want this” on an album or artist to
add it here.
</div>
`;
}
private renderSummary() {
if (!this.lastSummary) return nothing;
const s = this.lastSummary;
const parts = [
s.expanded > 0 ? `${s.expanded} new album${s.expanded === 1 ? '' : 's'} found` : '',
s.satisfied > 0 ? `${s.satisfied} already owned` : '',
s.started > 0 ? `${s.started} downloading` : '',
s.attempted > 0 ? `${s.attempted} searched for` : '',
].filter(Boolean);
return html`
<p class="summary">
${parts.length > 0 ? parts.join(' · ') : 'Nothing new this time.'}
</p>
`;
}
private renderSection(
title: string,
items: Want[],
renderer: (want: Want) => unknown,
) {
if (items.length === 0) return nothing;
return html`
<h2>${title}</h2>
${items.map((want) => renderer(want))}
`;
}
/**
* An artist row is a subscription, not a queued download, so it
* shows what it covers rather than a retry count — the albums it
* produced appear in their own section.
*/
private renderSubscription(want: Want) {
return html`
<div class="row">
<wa-icon name="user-group"></wa-icon>
<div class="row-main">
<div class="title">${want.artist || want.title || want.mbid}</div>
<div class="detail">
${want.scope === 'all'
? 'Whole discography, plus new releases'
: 'New releases only'}
</div>
</div>
<span class="badge">Following</span>
<div class="actions">
<wa-button
size="small"
appearance="plain"
@click=${() => void this.toggleScope(want)}
>
${want.scope === 'all' ? 'New only' : 'Everything'}
</wa-button>
${this.renderRemove(want)}
</div>
</div>
`;
}
private renderWant(want: Want) {
return html`
<div class="row">
<wa-icon
name=${want.entity === 'recording' ? 'music' : 'compact-disc'}
></wa-icon>
<div class="row-main">
<div class="title">
${want.artist ? `${want.artist}` : ''}${want.title ||
want.mbid}
</div>
<div class="detail">${wantDetail(want)}</div>
</div>
<div class="actions">
${want.state === 'satisfied'
? nothing
: html`
<wa-button
size="small"
appearance="plain"
@click=${() =>
void downloadStore.pauseWant(
want.id,
want.state !== 'paused',
)}
>
${want.state === 'paused' ? 'Resume' : 'Pause'}
</wa-button>
`}
${this.renderRemove(want)}
</div>
</div>
`;
}
private renderRemove(want: Want) {
return html`
<wa-button
size="small"
appearance="plain"
@click=${() => void downloadStore.removeWant(want.id)}
>
<wa-icon name="xmark"></wa-icon>
</wa-button>
`;
}
/** Widens or narrows what an artist subscription covers. */
private async toggleScope(want: Want): Promise<void> {
try {
await downloadStore.addWant({
mbid: want.mbid,
entity: 'artist',
libraryId: want.libraryId || (libraryStore.getSelectedLibraryId() ?? 0),
artist: want.artist,
title: want.title,
scope: want.scope === 'all' ? 'future' : 'all',
secondary: want.secondary,
} as never);
} catch (err) {
console.error('Could not change what this subscription covers:', err);
}
}
private async checkNow(): Promise<void> {
this.checking = true;
try {
this.lastSummary = await downloadStore.reconcileWanted();
} catch (err) {
console.error('Could not check the wanted list:', err);
} finally {
this.checking = false;
}
}
}
/**
* The second line of a want row: what is happening, in the user's terms.
*
* A want that has been tried and not found is reported as still being
* looked for rather than as an error, because that is what it is — the
* retry is already scheduled and there is nothing for the user to do.
*/
function wantDetail(want: Want): string {
if (want.state === 'satisfied') return 'In your library';
if (want.state === 'paused') return 'Paused';
if (want.attempts === 0) return 'Not looked for yet';
const reason = want.lastError ? `${want.lastError}` : '';
return `Looked for ${want.attempts} time${want.attempts === 1 ? '' : 's'}${reason}`;
}
declare global {
interface HTMLElementTagNameMap {
'wanted-view': WantedView;
}
}