refactor(download): rename Want/Request to Request/Download, unify downloads flow, add auto-download guardrails
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s

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:
2026-08-10 14:35:57 -04:00
co-authored by Claude Sonnet 5
parent cbd82a5a74
commit 65333857e2
62 changed files with 4067 additions and 2524 deletions
@@ -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;
}
}
+1 -1
View File
@@ -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];
+95 -87
View File
@@ -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;
}