refactor(download): rename Want/Request to Request/Download, unify downloads flow, add auto-download guardrails
The durable "I asked for this" record was called Want, and the one-shot search-and-grab attempt was called Request — names that didn't match what either actually did. Want is now Request, and the old Request/Item is now Download/DownloadItem, with a table-rename migration (download_wants -> download_requests, old download_requests -> download_downloads) safe against both fresh installs and existing data. Every anchored manual download now upserts/reuses a durable Request before running, so a "download now" that finds nothing is picked up by the background reconciler automatically instead of just failing with no trace — the gap that caused this session's repeated "no candidates found" failures on the same album. Also adds auto-download guardrails (file-size min/max with a preferred target, allowed file types) that gate what the pipeline may grab unattended, live-editable from a new settings section. The frontend's wanted-view becomes downloads-view, with a new Downloads tab showing attempt/transfer history that previously had no UI at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
This commit is contained in:
@@ -15,8 +15,28 @@ import type {
|
||||
} from '@store/download-store';
|
||||
import { downloadStore } from '@store/download-store';
|
||||
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
||||
import { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/Config';
|
||||
import { SetPreferences } from '@go/download/Service';
|
||||
import type { download } from '@go/models';
|
||||
import './config-section';
|
||||
|
||||
/**
|
||||
* Allowed audio formats for auto-download, mirrored from
|
||||
* backend/download/types.go's `Format` constants. `FormatUnknown` is
|
||||
* deliberately excluded — it names "no format detected", not a format a
|
||||
* user could opt into.
|
||||
*/
|
||||
const AUTO_DOWNLOAD_FORMATS: { value: string; label: string }[] = [
|
||||
{ value: 'flac', label: 'FLAC' },
|
||||
{ value: 'alac', label: 'ALAC' },
|
||||
{ value: 'wav', label: 'WAV' },
|
||||
{ value: 'mp3', label: 'MP3' },
|
||||
{ value: 'aac', label: 'AAC' },
|
||||
{ value: 'ogg', label: 'OGG' },
|
||||
{ value: 'opus', label: 'Opus' },
|
||||
{ value: 'wma', label: 'WMA' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Download client configuration.
|
||||
*
|
||||
@@ -59,6 +79,24 @@ export class DownloadClients extends LitElement {
|
||||
@state()
|
||||
private errorMessage = '';
|
||||
|
||||
/** Working copy of the auto-download guardrails. */
|
||||
@state()
|
||||
private prefs: download.AutoDownloadPrefs = {
|
||||
minSizeMb: 0,
|
||||
maxSizeMb: 0,
|
||||
preferredSizeMb: 0,
|
||||
allowedFormats: [],
|
||||
} as download.AutoDownloadPrefs;
|
||||
|
||||
@state()
|
||||
private prefsSaving = false;
|
||||
|
||||
@state()
|
||||
private prefsError = '';
|
||||
|
||||
@state()
|
||||
private prefsSaved = false;
|
||||
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
override connectedCallback(): void {
|
||||
@@ -67,6 +105,7 @@ export class DownloadClients extends LitElement {
|
||||
this.unsubscribe = downloadStore.subscribe(() => this.syncFromStore());
|
||||
|
||||
void downloadStore.init().then(() => this.syncFromStore());
|
||||
void this.loadPreferences();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
@@ -81,6 +120,14 @@ export class DownloadClients extends LitElement {
|
||||
this.descriptors = downloadStore.descriptors;
|
||||
}
|
||||
|
||||
private async loadPreferences(): Promise<void> {
|
||||
try {
|
||||
this.prefs = await GetDownloadPreferences();
|
||||
} catch (err) {
|
||||
console.error('Failed to load auto-download preferences:', err);
|
||||
}
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
css`
|
||||
@@ -178,6 +225,21 @@ export class DownloadClients extends LitElement {
|
||||
.field-row .browse-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4em 1em;
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
|
||||
.format-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -208,6 +270,104 @@ export class DownloadClients extends LitElement {
|
||||
</div>
|
||||
`}
|
||||
</config-section>
|
||||
|
||||
<config-section
|
||||
heading="Auto-download preferences"
|
||||
description="Guardrails on what the pipeline may grab without asking — a manual pick is never restricted by these, only automatic ones."
|
||||
>
|
||||
${this.prefsError
|
||||
? html`<wa-callout variant="danger">${this.prefsError}</wa-callout>`
|
||||
: nothing}
|
||||
|
||||
<div class="form">
|
||||
<div class="field-row">
|
||||
<wa-input
|
||||
label="Minimum size (MB)"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="No minimum"
|
||||
.value=${this.prefs.minSizeMb ? String(this.prefs.minSizeMb) : ''}
|
||||
@input=${(e: Event) => {
|
||||
this.prefs = {
|
||||
...this.prefs,
|
||||
minSizeMb: Number((e.target as HTMLInputElement).value) || 0,
|
||||
};
|
||||
}}
|
||||
></wa-input>
|
||||
<wa-input
|
||||
label="Maximum size (MB)"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="No maximum"
|
||||
.value=${this.prefs.maxSizeMb ? String(this.prefs.maxSizeMb) : ''}
|
||||
@input=${(e: Event) => {
|
||||
this.prefs = {
|
||||
...this.prefs,
|
||||
maxSizeMb: Number((e.target as HTMLInputElement).value) || 0,
|
||||
};
|
||||
}}
|
||||
></wa-input>
|
||||
<wa-input
|
||||
label="Preferred size (MB)"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="No preference"
|
||||
.value=${this.prefs.preferredSizeMb
|
||||
? String(this.prefs.preferredSizeMb)
|
||||
: ''}
|
||||
@input=${(e: Event) => {
|
||||
this.prefs = {
|
||||
...this.prefs,
|
||||
preferredSizeMb:
|
||||
Number((e.target as HTMLInputElement).value) || 0,
|
||||
};
|
||||
}}
|
||||
></wa-input>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="requires">
|
||||
Allowed formats — leave all unchecked to allow any format.
|
||||
</div>
|
||||
<div class="format-options">
|
||||
${AUTO_DOWNLOAD_FORMATS.map(
|
||||
(format) => html`
|
||||
<label class="format-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${(this.prefs.allowedFormats ?? []).includes(
|
||||
format.value,
|
||||
)}
|
||||
@change=${(e: Event) =>
|
||||
this.toggleFormat(
|
||||
format.value,
|
||||
(e.target as HTMLInputElement).checked,
|
||||
)}
|
||||
/>
|
||||
${format.label}
|
||||
</label>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
${this.prefsSaved
|
||||
? html`<span class="test-result ok">Saved.</span>`
|
||||
: nothing}
|
||||
<wa-button
|
||||
size="small"
|
||||
variant="brand"
|
||||
?disabled=${this.prefsSaving}
|
||||
@click=${this.savePreferences}
|
||||
>
|
||||
${this.prefsSaving
|
||||
? html`<wa-spinner></wa-spinner>`
|
||||
: 'Save preferences'}
|
||||
</wa-button>
|
||||
</div>
|
||||
</div>
|
||||
</config-section>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -554,6 +714,37 @@ export class DownloadClients extends LitElement {
|
||||
this.testing = null;
|
||||
}
|
||||
}
|
||||
|
||||
private toggleFormat(format: string, checked: boolean): void {
|
||||
const current = this.prefs.allowedFormats ?? [];
|
||||
const allowedFormats = checked
|
||||
? [...current, format]
|
||||
: current.filter((f) => f !== format);
|
||||
|
||||
this.prefs = { ...this.prefs, allowedFormats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the guardrails both to disk and to the running download
|
||||
* manager in one action — persistence alone would leave the setting
|
||||
* inert until restart, which is exactly the bug this mirrors away
|
||||
* from (see `config.Library`'s prior persist-without-apply gap).
|
||||
*/
|
||||
private savePreferences = async () => {
|
||||
this.prefsSaving = true;
|
||||
this.prefsError = '';
|
||||
this.prefsSaved = false;
|
||||
|
||||
try {
|
||||
await SetDownloadPreferences(this.prefs);
|
||||
await SetPreferences(this.prefs);
|
||||
this.prefsSaved = true;
|
||||
} catch (err) {
|
||||
this.prefsError = String(err);
|
||||
} finally {
|
||||
this.prefsSaving = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -57,7 +57,7 @@ export class DownloadPicker extends LitElement {
|
||||
private candidates: DownloadCandidate[] = [];
|
||||
|
||||
@state()
|
||||
private requestId = '';
|
||||
private downloadId = '';
|
||||
|
||||
@state()
|
||||
private autoPicked = false;
|
||||
@@ -141,7 +141,7 @@ export class DownloadPicker extends LitElement {
|
||||
expected: this.expected ?? [],
|
||||
} as download.SearchRequest);
|
||||
|
||||
this.requestId = result.requestId;
|
||||
this.downloadId = result.downloadId;
|
||||
this.candidates = result.candidates ?? [];
|
||||
this.autoPicked = result.autoPicked;
|
||||
} catch (err) {
|
||||
@@ -158,7 +158,7 @@ export class DownloadPicker extends LitElement {
|
||||
this.errorMessage = '';
|
||||
|
||||
try {
|
||||
await downloadStore.pick(this.requestId, event.detail.candidateId);
|
||||
await downloadStore.pick(this.downloadId, event.detail.candidateId);
|
||||
this.close();
|
||||
} catch (err) {
|
||||
this.errorMessage = String(err);
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
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, stateLabel } from '@store/download-store';
|
||||
import type { Request, RequestSummary, DownloadView as DownloadRecord } from '@store/download-store';
|
||||
import { libraryStore } from '@store/library-store';
|
||||
|
||||
type Tab = 'requests' | 'downloads';
|
||||
|
||||
/**
|
||||
* The downloads page: what music the user has asked for, and what has
|
||||
* actually been attempted.
|
||||
*
|
||||
* These are two different lists on purpose. A Request is durable — "get
|
||||
* this whenever available" — and stays around, retried on a backoff,
|
||||
* until it is satisfied or removed. A Download is one search-and-grab
|
||||
* attempt; it can fail or complete and that is the end of its story. The
|
||||
* Requests tab is the list the durable, not-a-failure-just-because-it's-
|
||||
* still-here content the wanted list used to be; the Downloads tab is the
|
||||
* attempt history nothing rendered before this page existed.
|
||||
*/
|
||||
@customElement('downloads-view')
|
||||
export class DownloadsView extends LitElement {
|
||||
@state() private tab: Tab = 'requests';
|
||||
|
||||
@state() private requests: Request[] = [];
|
||||
|
||||
@state() private downloads: DownloadRecord[] = [];
|
||||
|
||||
@state() private checking = false;
|
||||
|
||||
@state() private lastSummary: RequestSummary | 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);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--yj-text-primary, #fff);
|
||||
border-bottom-color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.detail.error {
|
||||
color: var(--wa-color-danger-fill-loud, #c65f5f);
|
||||
}
|
||||
|
||||
.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.requests = downloadStore.requests;
|
||||
this.downloads = downloadStore.downloads;
|
||||
});
|
||||
|
||||
void downloadStore.init().then(() => {
|
||||
this.requests = downloadStore.requests;
|
||||
this.downloads = downloadStore.downloads;
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<header>
|
||||
<h1>Downloads</h1>
|
||||
${this.tab === 'requests'
|
||||
? html`
|
||||
<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>
|
||||
`
|
||||
: nothing}
|
||||
</header>
|
||||
|
||||
<p class="subtitle">
|
||||
Music you have requested, and the download attempts that
|
||||
have run for it. A request that cannot be found today stays
|
||||
on the list and is looked for again later.
|
||||
</p>
|
||||
|
||||
<div class="tabs">
|
||||
<div
|
||||
class="tab ${this.tab === 'requests' ? 'active' : ''}"
|
||||
@click=${() => (this.tab = 'requests')}
|
||||
>
|
||||
Requests
|
||||
</div>
|
||||
<div
|
||||
class="tab ${this.tab === 'downloads' ? 'active' : ''}"
|
||||
@click=${() => (this.tab = 'downloads')}
|
||||
>
|
||||
Downloads
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.tab === 'requests' ? this.renderRequests() : this.renderDownloads()}
|
||||
`;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Requests tab
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private renderRequests() {
|
||||
const subscriptions = this.requests.filter((r) => r.entity === 'artist');
|
||||
const wanted = this.requests.filter(
|
||||
(r) => r.entity !== 'artist' && r.state === 'wanted',
|
||||
);
|
||||
const paused = this.requests.filter((r) => r.state === 'paused');
|
||||
const satisfied = this.requests.filter((r) => r.state === 'satisfied');
|
||||
|
||||
return html`
|
||||
${this.renderSummary()}
|
||||
${satisfied.length > 0
|
||||
? html`
|
||||
<div class="actions">
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
@click=${() =>
|
||||
void downloadStore.clearSatisfiedRequests()}
|
||||
>
|
||||
Clear found
|
||||
</wa-button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${this.requests.length === 0 ? this.renderEmptyRequests() : nothing}
|
||||
${this.renderRequestSection(
|
||||
'Following',
|
||||
subscriptions,
|
||||
(r) => this.renderSubscription(r),
|
||||
)}
|
||||
${this.renderRequestSection('Looking for', wanted, (r) => this.renderRequest(r))}
|
||||
${this.renderRequestSection('Paused', paused, (r) => this.renderRequest(r))}
|
||||
${this.renderRequestSection('Found', satisfied, (r) => this.renderRequest(r))}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEmptyRequests() {
|
||||
return html`
|
||||
<div class="empty">
|
||||
Nothing requested 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 renderRequestSection(
|
||||
title: string,
|
||||
items: Request[],
|
||||
renderer: (request: Request) => unknown,
|
||||
) {
|
||||
if (items.length === 0) return nothing;
|
||||
|
||||
return html`
|
||||
<h2>${title}</h2>
|
||||
${items.map((request) => renderer(request))}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(request: Request) {
|
||||
return html`
|
||||
<div class="row">
|
||||
<wa-icon name="user-group"></wa-icon>
|
||||
<div class="row-main">
|
||||
<div class="title">
|
||||
${request.artist || request.title || request.mbid}
|
||||
</div>
|
||||
<div class="detail">
|
||||
${request.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(request)}
|
||||
>
|
||||
${request.scope === 'all' ? 'New only' : 'Everything'}
|
||||
</wa-button>
|
||||
${this.renderRemove(request)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRequest(request: Request) {
|
||||
return html`
|
||||
<div class="row">
|
||||
<wa-icon
|
||||
name=${request.entity === 'recording' ? 'music' : 'compact-disc'}
|
||||
></wa-icon>
|
||||
<div class="row-main">
|
||||
<div class="title">
|
||||
${request.artist ? `${request.artist} — ` : ''}${request.title ||
|
||||
request.mbid}
|
||||
</div>
|
||||
<div class="detail">${requestDetail(request)}</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
${request.state === 'satisfied'
|
||||
? nothing
|
||||
: html`
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
@click=${() =>
|
||||
void downloadStore.pauseRequest(
|
||||
request.id,
|
||||
request.state !== 'paused',
|
||||
)}
|
||||
>
|
||||
${request.state === 'paused' ? 'Resume' : 'Pause'}
|
||||
</wa-button>
|
||||
`}
|
||||
${this.renderRemove(request)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRemove(request: Request) {
|
||||
return html`
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
@click=${() => void downloadStore.removeRequest(request.id)}
|
||||
>
|
||||
<wa-icon name="xmark"></wa-icon>
|
||||
</wa-button>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Widens or narrows what an artist subscription covers. */
|
||||
private async toggleScope(request: Request): Promise<void> {
|
||||
try {
|
||||
const libraryId =
|
||||
request.libraryId || (await libraryStore.getDefaultLibraryId());
|
||||
if (!libraryId) {
|
||||
console.error(
|
||||
'Could not change what this subscription covers: no library available',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadStore.addRequest({
|
||||
mbid: request.mbid,
|
||||
entity: 'artist',
|
||||
libraryId,
|
||||
artist: request.artist,
|
||||
title: request.title,
|
||||
scope: request.scope === 'all' ? 'future' : 'all',
|
||||
secondary: request.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.reconcileRequests();
|
||||
} catch (err) {
|
||||
console.error('Could not check the requests list:', err);
|
||||
} finally {
|
||||
this.checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Downloads tab
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private renderDownloads() {
|
||||
if (this.downloads.length === 0) {
|
||||
return html`
|
||||
<div class="empty">
|
||||
No downloads yet. Attempts made by "Download now" or the
|
||||
background reconciler show up here.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return this.downloads.map((view) => this.renderDownload(view));
|
||||
}
|
||||
|
||||
private renderDownload(view: DownloadRecord) {
|
||||
const title = view.artist
|
||||
? `${view.artist}${view.album ? ` — ${view.album}` : ''}`
|
||||
: view.query || view.album || 'Untitled download';
|
||||
|
||||
return html`
|
||||
<div class="row">
|
||||
<wa-icon name="compact-disc"></wa-icon>
|
||||
<div class="row-main">
|
||||
<div class="title">${title}</div>
|
||||
<div class="detail">${this.downloadDetail(view)}</div>
|
||||
${view.error
|
||||
? html`<div class="detail error">${view.error}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
<span class="badge">${stateLabel(view.state)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Provider/progress summary for a download's second line. */
|
||||
private downloadDetail(view: DownloadRecord): string {
|
||||
const providers = [
|
||||
...new Set(view.items.map((item) => item.candidate?.origin).filter(Boolean)),
|
||||
];
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (providers.length > 0) parts.push(providers.join(', '));
|
||||
if (view.source) parts.push(view.source);
|
||||
|
||||
return parts.length > 0 ? parts.join(' · ') : 'No provider info';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The second line of a request row: what is happening, in the user's
|
||||
* terms.
|
||||
*
|
||||
* A request 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 requestDetail(request: Request): string {
|
||||
if (request.state === 'satisfied') return 'In your library';
|
||||
if (request.state === 'paused') return 'Paused';
|
||||
|
||||
if (request.attempts === 0) return 'Not looked for yet';
|
||||
|
||||
const reason = request.lastError ? ` — ${request.lastError}` : '';
|
||||
|
||||
return `Looked for ${request.attempts} time${request.attempts === 1 ? '' : 's'}${reason}`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'downloads-view': DownloadsView;
|
||||
}
|
||||
}
|
||||
@@ -120,11 +120,11 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
/** 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;
|
||||
/** True when this album already has a request. */
|
||||
@state() private isRequested = false;
|
||||
|
||||
/**
|
||||
* Library to attach downloads/wants to. The library-filter UI that
|
||||
* Library to attach downloads/requests to. The library-filter UI that
|
||||
* would normally set libraryStore's selection isn't mounted anywhere
|
||||
* currently, so that selection is always null here — falling back to
|
||||
* `?? 0` would send a library id that doesn't exist and fail the
|
||||
@@ -496,12 +496,12 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
// so this tracks the provider list rather than assuming.
|
||||
this.downloadUnsub = downloadStore.subscribe(() => {
|
||||
this.canDownload = downloadStore.available;
|
||||
this.syncWanted();
|
||||
this.syncRequested();
|
||||
});
|
||||
|
||||
void downloadStore.init().then(() => {
|
||||
this.canDownload = downloadStore.available;
|
||||
this.syncWanted();
|
||||
this.syncRequested();
|
||||
});
|
||||
|
||||
void this.resolveTargetLibraryId();
|
||||
@@ -1537,62 +1537,62 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the album to the wanted list, which is the answer to "look
|
||||
* Adds the album to the requests 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
|
||||
* connected: requesting 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);
|
||||
const request = downloadStore.requestFor(this.releaseGroupMBID);
|
||||
|
||||
return html`
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance=${this.isWanted ? 'filled' : 'outlined'}
|
||||
@click=${() => void this.toggleWanted(want?.id)}
|
||||
appearance=${this.isRequested ? 'filled' : 'outlined'}
|
||||
@click=${() => void this.toggleRequested(request?.id)}
|
||||
>
|
||||
<wa-icon
|
||||
slot="start"
|
||||
name=${this.isWanted ? 'bookmark-check' : 'bookmark'}
|
||||
name=${this.isRequested ? 'bookmark-check' : 'bookmark'}
|
||||
></wa-icon>
|
||||
${this.isWanted ? 'Wanted' : 'Want this'}
|
||||
${this.isRequested ? 'Wanted' : 'Want this'}
|
||||
</wa-button>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Resolves the library to attach downloads/wants to. */
|
||||
/** Resolves the library to attach downloads/requests to. */
|
||||
private async resolveTargetLibraryId(): Promise<void> {
|
||||
this.targetLibraryId = await libraryStore.getDefaultLibraryId();
|
||||
}
|
||||
|
||||
/** Reflects the store's view of whether this album is wanted. */
|
||||
private syncWanted(): void {
|
||||
this.isWanted = this.releaseGroupMBID
|
||||
? downloadStore.isWanted(this.releaseGroupMBID)
|
||||
/** Reflects the store's view of whether this album is requested. */
|
||||
private syncRequested(): void {
|
||||
this.isRequested = this.releaseGroupMBID
|
||||
? downloadStore.isRequested(this.releaseGroupMBID)
|
||||
: false;
|
||||
}
|
||||
|
||||
private async toggleWanted(wantId: number | undefined): Promise<void> {
|
||||
private async toggleRequested(requestId: number | undefined): Promise<void> {
|
||||
if (!this.releaseGroupMBID) return;
|
||||
|
||||
try {
|
||||
if (wantId) {
|
||||
await downloadStore.removeWant(wantId);
|
||||
if (requestId) {
|
||||
await downloadStore.removeRequest(requestId);
|
||||
} else {
|
||||
if (!this.targetLibraryId) {
|
||||
await this.resolveTargetLibraryId();
|
||||
}
|
||||
if (!this.targetLibraryId) {
|
||||
console.error('Could not update the wanted list: no library available');
|
||||
console.error('Could not update the requests list: no library available');
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadStore.addWant({
|
||||
await downloadStore.addRequest({
|
||||
mbid: this.releaseGroupMBID,
|
||||
entity: 'release-group',
|
||||
libraryId: this.targetLibraryId,
|
||||
@@ -1600,13 +1600,13 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
title: this.albumName,
|
||||
scope: 'future',
|
||||
secondary: false,
|
||||
} as download.WantRequest);
|
||||
} as download.RequestInput);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Could not update the wanted list:', err);
|
||||
console.error('Could not update the requests list:', err);
|
||||
}
|
||||
|
||||
this.syncWanted();
|
||||
this.syncRequested();
|
||||
}
|
||||
|
||||
private async openPicker(): Promise<void> {
|
||||
|
||||
@@ -846,8 +846,8 @@ 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;
|
||||
/** Unsubscribe handle for the requests list. */
|
||||
private unsubRequests: (() => void) | null = null;
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
@@ -855,10 +855,10 @@ export class ExploreArtistDetails extends LitElement {
|
||||
void this.loadAllData();
|
||||
}
|
||||
|
||||
// Keep the follow button in step with the wanted list, which a
|
||||
// Keep the follow button in step with the requests list, which a
|
||||
// background reconcile pass can change without this page doing
|
||||
// anything.
|
||||
this.unsubWanted = downloadStore.subscribe(() => this.requestUpdate());
|
||||
this.unsubRequests = downloadStore.subscribe(() => this.requestUpdate());
|
||||
void downloadStore.init().then(() => this.requestUpdate());
|
||||
|
||||
// A background discography fetch (top tracks / top releases for an
|
||||
@@ -897,8 +897,8 @@ export class ExploreArtistDetails extends LitElement {
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.unsubWanted?.();
|
||||
this.unsubWanted = null;
|
||||
this.unsubRequests?.();
|
||||
this.unsubRequests = null;
|
||||
this.unsubDiscogReady?.();
|
||||
this.unsubSimilarReady?.();
|
||||
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
|
||||
@@ -1896,50 +1896,50 @@ export class ExploreArtistDetails extends LitElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to an artist: their new releases go on the wanted list
|
||||
* as they come out.
|
||||
* Subscribes to an artist: their new releases go on the requests
|
||||
* 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
|
||||
* catalogue can widen it from the requests list, and will not be
|
||||
* surprised by having done so.
|
||||
*/
|
||||
private renderFollowAction() {
|
||||
if (!this.artistMBID) return nothing;
|
||||
|
||||
const want = downloadStore.wantFor(this.artistMBID);
|
||||
const request = downloadStore.requestFor(this.artistMBID);
|
||||
|
||||
return html`
|
||||
<div class="artist-follow">
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance=${want ? 'filled' : 'outlined'}
|
||||
@click=${() => void this.toggleFollow(want?.id)}
|
||||
appearance=${request ? 'filled' : 'outlined'}
|
||||
@click=${() => void this.toggleFollow(request?.id)}
|
||||
>
|
||||
<wa-icon
|
||||
slot="start"
|
||||
name=${want ? 'bookmark-check' : 'bookmark'}
|
||||
name=${request ? 'bookmark-check' : 'bookmark'}
|
||||
></wa-icon>
|
||||
${want ? 'Following' : 'Follow for new releases'}
|
||||
${request ? 'Following' : 'Follow for new releases'}
|
||||
</wa-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async toggleFollow(wantId: number | undefined): Promise<void> {
|
||||
private async toggleFollow(requestId: number | undefined): Promise<void> {
|
||||
if (!this.artistMBID) return;
|
||||
|
||||
try {
|
||||
if (wantId) {
|
||||
await downloadStore.removeWant(wantId);
|
||||
if (requestId) {
|
||||
await downloadStore.removeRequest(requestId);
|
||||
} else {
|
||||
const libraryId = await libraryStore.getDefaultLibraryId();
|
||||
if (!libraryId) {
|
||||
console.error('Could not update the wanted list: no library available');
|
||||
console.error('Could not update the requests list: no library available');
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadStore.addWant({
|
||||
await downloadStore.addRequest({
|
||||
mbid: this.artistMBID,
|
||||
entity: 'artist',
|
||||
libraryId,
|
||||
@@ -1950,7 +1950,7 @@ export class ExploreArtistDetails extends LitElement {
|
||||
} as never);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Could not update the wanted list:', err);
|
||||
console.error('Could not update the requests list:', err);
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
|
||||
@@ -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' | 'wanted' | 'autotag' | 'jobs' | 'settings';
|
||||
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'downloads' | 'autotag' | 'jobs' | 'settings';
|
||||
|
||||
interface NavItem {
|
||||
id: View;
|
||||
@@ -149,7 +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: 'downloads', label: 'Downloads', icon: 'bookmark' },
|
||||
{ id: 'autotag', label: 'Autotag', icon: 'tag' },
|
||||
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
|
||||
{ id: 'settings', label: 'Settings', icon: 'gear' },
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
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 {
|
||||
const libraryId =
|
||||
want.libraryId || (await libraryStore.getDefaultLibraryId());
|
||||
if (!libraryId) {
|
||||
console.error(
|
||||
'Could not change what this subscription covers: no library available',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadStore.addWant({
|
||||
mbid: want.mbid,
|
||||
entity: 'artist',
|
||||
libraryId,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export const Events = {
|
||||
AlbumReleasesReady: "AlbumReleasesReady",
|
||||
DownloadProvidersChanged: "DownloadProvidersChanged",
|
||||
DownloadsChanged: "DownloadsChanged",
|
||||
WantedListChanged: "WantedListChanged",
|
||||
RequestsChanged: "RequestsChanged",
|
||||
} as const;
|
||||
|
||||
export type EventName = (typeof Events)[keyof typeof Events];
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import {
|
||||
AddProvider,
|
||||
AddWant,
|
||||
AddRequest,
|
||||
Cancel,
|
||||
Candidates,
|
||||
ClearFinished,
|
||||
ClearSatisfiedWants,
|
||||
ClearSatisfiedRequests,
|
||||
DeleteProvider,
|
||||
ImportExternalWants,
|
||||
ImportExternalRequests,
|
||||
ListDownloads,
|
||||
ListProviders,
|
||||
ListRequests,
|
||||
ListWants,
|
||||
PauseWant,
|
||||
PauseRequest,
|
||||
Pick,
|
||||
ProviderKinds,
|
||||
ReconcileWanted,
|
||||
RemoveWant,
|
||||
Start,
|
||||
ReconcileRequests,
|
||||
RemoveRequest,
|
||||
StartDownload,
|
||||
TestProvider,
|
||||
UpdateProvider,
|
||||
} from '@go/download/Service';
|
||||
@@ -26,32 +26,32 @@ import { Events } from '../events';
|
||||
export type DownloadCandidate = download.Candidate;
|
||||
export type DownloadProvider = download.Config;
|
||||
export type DownloadDescriptor = download.Descriptor;
|
||||
export type DownloadRequest = download.RequestView;
|
||||
export type DownloadView = download.DownloadView;
|
||||
export type ProviderField = download.Field;
|
||||
export type Want = download.Want;
|
||||
export type WantSummary = download.Summary;
|
||||
export type Request = download.Request;
|
||||
export type RequestSummary = download.Summary;
|
||||
|
||||
/**
|
||||
* What a want's MBID names. Mirrors backend/download.Entity — the
|
||||
* wanted list makes no other type distinction, because an MBID plus
|
||||
* what it names is the whole of a want.
|
||||
* What a request's MBID names. Mirrors backend/download.Entity — the
|
||||
* request list makes no other type distinction, because an MBID plus
|
||||
* what it names is the whole of a request.
|
||||
*/
|
||||
export type WantEntity = 'artist' | 'release-group' | 'release' | 'recording';
|
||||
export type RequestEntity = 'artist' | 'release-group' | 'release' | 'recording';
|
||||
|
||||
/**
|
||||
* Where a want sits. There is deliberately no "failed": an attempt can
|
||||
* fail, a want cannot — something unfindable today is still wanted.
|
||||
* Where a request sits. There is deliberately no "failed": an attempt can
|
||||
* fail, a request cannot — something unfindable today is still requested.
|
||||
*/
|
||||
export type WantState = 'wanted' | 'satisfied' | 'paused';
|
||||
export type RequestState = 'wanted' | 'satisfied' | 'paused';
|
||||
|
||||
/**
|
||||
* How much of an artist's output a subscription covers. 'future' is the
|
||||
* default so subscribing does not silently queue a back catalogue.
|
||||
*/
|
||||
export type WantScope = 'future' | 'all';
|
||||
export type RequestScope = 'future' | 'all';
|
||||
|
||||
/** Lifecycle states a request can be in. Mirrors backend/download.State. */
|
||||
export type DownloadState =
|
||||
/** Lifecycle states a download can be in. Mirrors backend/download.State. */
|
||||
export type DownloadLifecycleState =
|
||||
| 'searching'
|
||||
| 'found'
|
||||
| 'queued'
|
||||
@@ -71,12 +71,12 @@ const TERMINAL_STATES: ReadonlySet<string> = new Set([
|
||||
'failed',
|
||||
]);
|
||||
|
||||
export function isRequestTerminal(request: DownloadRequest): boolean {
|
||||
return TERMINAL_STATES.has(request.state);
|
||||
export function isDownloadTerminal(view: DownloadView): boolean {
|
||||
return TERMINAL_STATES.has(view.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label for a request state. Kept here rather than in the
|
||||
* Human-readable label for a download state. Kept here rather than in the
|
||||
* components so the downloads list and the picker never disagree about
|
||||
* what a state is called.
|
||||
*/
|
||||
@@ -171,7 +171,7 @@ export function formatBytes(bytes: number): string {
|
||||
* Per-transfer progress deliberately does not flow through here — that
|
||||
* lives in the jobs registry, which already coalesces high-frequency
|
||||
* updates into one event. This store handles the coarse changes: which
|
||||
* providers exist, which requests exist, and what the user is being
|
||||
* providers exist, which downloads exist, and what the user is being
|
||||
* asked to choose between.
|
||||
*/
|
||||
class DownloadStore {
|
||||
@@ -179,9 +179,9 @@ class DownloadStore {
|
||||
|
||||
private descriptorsValue: DownloadDescriptor[] = [];
|
||||
|
||||
private requestsValue: DownloadRequest[] = [];
|
||||
private downloadsValue: DownloadView[] = [];
|
||||
|
||||
private wantsValue: Want[] = [];
|
||||
private requestsValue: Request[] = [];
|
||||
|
||||
private subscribers = new Set<Subscriber>();
|
||||
|
||||
@@ -195,21 +195,21 @@ class DownloadStore {
|
||||
});
|
||||
|
||||
EventsOn(Events.DownloadsChanged, () => {
|
||||
void this.refreshRequests();
|
||||
void this.refreshDownloads();
|
||||
});
|
||||
|
||||
// The wanted list changes on its own — a background reconcile
|
||||
// The request list changes on its own — a background reconcile
|
||||
// pass expands an artist, retires something the library gained,
|
||||
// or starts a download nobody asked for just now. So it is
|
||||
// event-driven rather than fetched once on mount.
|
||||
EventsOn(Events.WantedListChanged, () => {
|
||||
void this.refreshWants();
|
||||
EventsOn(Events.RequestsChanged, () => {
|
||||
void this.refreshRequests();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads providers and requests once. Safe to call from every
|
||||
* component's connectedCallback — subsequent calls are no-ops.
|
||||
* Loads providers, downloads and requests once. Safe to call from
|
||||
* every component's connectedCallback — subsequent calls are no-ops.
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
@@ -219,8 +219,8 @@ class DownloadStore {
|
||||
await Promise.all([
|
||||
this.refreshDescriptors(),
|
||||
this.refreshProviders(),
|
||||
this.refreshDownloads(),
|
||||
this.refreshRequests(),
|
||||
this.refreshWants(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -238,12 +238,12 @@ class DownloadStore {
|
||||
return this.descriptorsValue;
|
||||
}
|
||||
|
||||
get requests(): DownloadRequest[] {
|
||||
return this.requestsValue;
|
||||
get downloads(): DownloadView[] {
|
||||
return this.downloadsValue;
|
||||
}
|
||||
|
||||
get activeRequests(): DownloadRequest[] {
|
||||
return this.requestsValue.filter((r) => !isRequestTerminal(r));
|
||||
get activeDownloads(): DownloadView[] {
|
||||
return this.downloadsValue.filter((d) => !isDownloadTerminal(d));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,9 +294,9 @@ class DownloadStore {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshRequests(): Promise<void> {
|
||||
async refreshDownloads(): Promise<void> {
|
||||
try {
|
||||
this.requestsValue = (await ListRequests(50)) ?? [];
|
||||
this.downloadsValue = (await ListDownloads(50)) ?? [];
|
||||
this.notify();
|
||||
} catch (err) {
|
||||
console.error('Failed to load downloads:', err);
|
||||
@@ -346,7 +346,7 @@ class DownloadStore {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Requests
|
||||
// Downloads (one search+grab attempt)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -355,94 +355,102 @@ class DownloadStore {
|
||||
* the picker or just show progress.
|
||||
*/
|
||||
async start(request: download.SearchRequest): Promise<download.StartResult> {
|
||||
const result = await Start(request);
|
||||
const result = await StartDownload(request);
|
||||
|
||||
await this.refreshRequests();
|
||||
await this.refreshDownloads();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async pick(requestId: string, candidateId: string): Promise<void> {
|
||||
await Pick(requestId, candidateId);
|
||||
await this.refreshRequests();
|
||||
async pick(downloadId: string, candidateId: string): Promise<void> {
|
||||
await Pick(downloadId, candidateId);
|
||||
await this.refreshDownloads();
|
||||
}
|
||||
|
||||
async cancel(requestId: string): Promise<void> {
|
||||
await Cancel(requestId);
|
||||
await this.refreshRequests();
|
||||
async cancel(downloadId: string): Promise<void> {
|
||||
await Cancel(downloadId);
|
||||
await this.refreshDownloads();
|
||||
}
|
||||
|
||||
async candidates(requestId: string): Promise<DownloadCandidate[]> {
|
||||
return (await Candidates(requestId)) ?? [];
|
||||
async candidates(downloadId: string): Promise<DownloadCandidate[]> {
|
||||
return (await Candidates(downloadId)) ?? [];
|
||||
}
|
||||
|
||||
async clearFinished(): Promise<void> {
|
||||
await ClearFinished();
|
||||
await this.refreshRequests();
|
||||
await this.refreshDownloads();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Wanted list
|
||||
// Requests (durable "I asked for this")
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
get wants(): Want[] {
|
||||
return this.wantsValue;
|
||||
get requests(): Request[] {
|
||||
return this.requestsValue;
|
||||
}
|
||||
|
||||
/** Wants still being looked for. */
|
||||
get activeWants(): Want[] {
|
||||
return this.wantsValue.filter((w) => w.state === 'wanted');
|
||||
/** Requests still being looked for. */
|
||||
get activeRequests(): Request[] {
|
||||
return this.requestsValue.filter((r) => r.state === 'wanted');
|
||||
}
|
||||
|
||||
/** Artist subscriptions, which expand rather than download. */
|
||||
get subscriptions(): Want[] {
|
||||
return this.wantsValue.filter((w) => w.entity === 'artist');
|
||||
get subscriptions(): Request[] {
|
||||
return this.requestsValue.filter((r) => r.entity === 'artist');
|
||||
}
|
||||
|
||||
async refreshWants(): Promise<void> {
|
||||
async refreshRequests(): Promise<void> {
|
||||
try {
|
||||
this.wantsValue = (await ListWants()) ?? [];
|
||||
this.requestsValue = (await ListRequests()) ?? [];
|
||||
this.notify();
|
||||
} catch (err) {
|
||||
console.error('Failed to load the wanted list:', err);
|
||||
console.error('Failed to load the requests list:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** True when this MBID is already on the list. */
|
||||
isWanted(mbid: string): boolean {
|
||||
/**
|
||||
* True when this MBID is already requested.
|
||||
*
|
||||
* Checked against the locally cached request list rather than the
|
||||
* `IsRequested` RPC: the list is already kept current via
|
||||
* `RequestsChanged`, and a local lookup keeps this usable
|
||||
* synchronously from render — the same shape callers relied on
|
||||
* before the rename.
|
||||
*/
|
||||
isRequested(mbid: string): boolean {
|
||||
const needle = mbid.trim().toLowerCase();
|
||||
|
||||
return this.wantsValue.some((w) => w.mbid === needle);
|
||||
return this.requestsValue.some((r) => r.mbid === needle);
|
||||
}
|
||||
|
||||
/** The want for an MBID, if it is on the list. */
|
||||
wantFor(mbid: string): Want | undefined {
|
||||
/** The request for an MBID, if one exists. */
|
||||
requestFor(mbid: string): Request | undefined {
|
||||
const needle = mbid.trim().toLowerCase();
|
||||
|
||||
return this.wantsValue.find((w) => w.mbid === needle);
|
||||
return this.requestsValue.find((r) => r.mbid === needle);
|
||||
}
|
||||
|
||||
async addWant(want: download.WantRequest): Promise<number> {
|
||||
const id = await AddWant(want);
|
||||
async addRequest(request: download.RequestInput): Promise<number> {
|
||||
const id = await AddRequest(request);
|
||||
|
||||
await this.refreshWants();
|
||||
await this.refreshRequests();
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
async removeWant(id: number): Promise<void> {
|
||||
await RemoveWant(id);
|
||||
await this.refreshWants();
|
||||
async removeRequest(id: number): Promise<void> {
|
||||
await RemoveRequest(id);
|
||||
await this.refreshRequests();
|
||||
}
|
||||
|
||||
async pauseWant(id: number, paused: boolean): Promise<void> {
|
||||
await PauseWant(id, paused);
|
||||
await this.refreshWants();
|
||||
async pauseRequest(id: number, paused: boolean): Promise<void> {
|
||||
await PauseRequest(id, paused);
|
||||
await this.refreshRequests();
|
||||
}
|
||||
|
||||
async clearSatisfiedWants(): Promise<void> {
|
||||
await ClearSatisfiedWants();
|
||||
await this.refreshWants();
|
||||
async clearSatisfiedRequests(): Promise<void> {
|
||||
await ClearSatisfiedRequests();
|
||||
await this.refreshRequests();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -450,22 +458,22 @@ class DownloadStore {
|
||||
* with what the pass did so the UI can say something concrete
|
||||
* rather than just stopping its spinner.
|
||||
*/
|
||||
async reconcileWanted(): Promise<WantSummary> {
|
||||
const summary = await ReconcileWanted();
|
||||
async reconcileRequests(): Promise<RequestSummary> {
|
||||
const summary = await ReconcileRequests();
|
||||
|
||||
await Promise.all([this.refreshWants(), this.refreshRequests()]);
|
||||
await Promise.all([this.refreshRequests(), this.refreshDownloads()]);
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Adopts a provider's own list, e.g. Lidarr's monitored artists. */
|
||||
async importExternalWants(
|
||||
async importExternalRequests(
|
||||
providerId: number,
|
||||
libraryId: number,
|
||||
): Promise<number> {
|
||||
const count = await ImportExternalWants(providerId, libraryId);
|
||||
const count = await ImportExternalRequests(providerId, libraryId);
|
||||
|
||||
await this.refreshWants();
|
||||
await this.refreshRequests();
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user