feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user