feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Ships the fresh-start schema cleanup: rebuilt explore catalog index pipeline (dump import, artifact fetch/build, incremental listen-count refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/ slskd/yt-dlp providers, staging, reconciliation, wanted list), and the supporting schema/query/store changes across backend and frontend. Also includes two smaller follow-ups: bump the central index's rebuild-after cadence from 90 to 180 days, and remove the Explore "library only" online/offline toggle entirely (frontend-only, no backend counterpart) rather than carry unused UI/state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
This commit is contained in:
@@ -40,68 +40,6 @@ p {
|
||||
flex: 0 1 320px;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mode-toggle-track {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.15));
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.mode-toggle:hover .mode-toggle-track {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.mode-toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--yj-text-primary, #fff);
|
||||
transition: left 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.mode-toggle.active .mode-toggle-track {
|
||||
background: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.mode-toggle.active .mode-toggle-thumb {
|
||||
left: 18px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
font-size: 14px;
|
||||
transition: color 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mode-icon-globe {
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.mode-icon-local {
|
||||
color: var(--yj-text-secondary, #888);
|
||||
}
|
||||
|
||||
.mode-toggle.active .mode-icon-globe {
|
||||
color: var(--yj-text-secondary, #888);
|
||||
}
|
||||
|
||||
.mode-toggle.active .mode-icon-local {
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
@@ -15,13 +15,6 @@
|
||||
<h1 class="title">YellowJacket</h1>
|
||||
<h3 class="subtitle">Music how it was meant to bee.</h3>
|
||||
</hgroup>
|
||||
<div id="library-only-toggle" class="mode-toggle" title="Toggle Library Only mode">
|
||||
<wa-icon name="globe" class="mode-icon mode-icon-globe"></wa-icon>
|
||||
<div class="mode-toggle-track">
|
||||
<div class="mode-toggle-thumb"></div>
|
||||
</div>
|
||||
<wa-icon name="hard-drive" class="mode-icon mode-icon-local"></wa-icon>
|
||||
</div>
|
||||
<library-filter></library-filter>
|
||||
<search-bar></search-bar>
|
||||
<job-indicator></job-indicator>
|
||||
|
||||
+2
-22
@@ -23,6 +23,7 @@ import '@components/autotag-view/autotag-view.ts';
|
||||
import '@components/first-run-wizard/first-run-wizard.ts';
|
||||
import '@components/jobs/job-indicator.ts';
|
||||
import '@components/jobs/jobs-view.ts';
|
||||
import '@components/wanted-view/wanted-view.ts';
|
||||
import '@awesome.me/webawesome/dist/styles/themes/default.css';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
||||
@@ -36,7 +37,6 @@ import '@store/theme-store';
|
||||
// Importing the keyboard shortcut service triggers initialization:
|
||||
// registers the document keydown listener for global shortcuts.
|
||||
import './src/services/keyboard-shortcut-service';
|
||||
import { exploreSettings } from '@store/explore-settings';
|
||||
import {
|
||||
hasTrackPayload,
|
||||
getDragPayload,
|
||||
@@ -64,6 +64,7 @@ const VIEW_TAGS: Record<string, string> = {
|
||||
playlists: 'playlist-view',
|
||||
explore: 'explore-view',
|
||||
autotag: 'autotag-view',
|
||||
wanted: 'wanted-view',
|
||||
jobs: 'jobs-view',
|
||||
settings: 'config-page',
|
||||
};
|
||||
@@ -310,24 +311,3 @@ if (queueButton && queuePanel) {
|
||||
// or timing assumptions needed.
|
||||
void Player.EmitCurrentState();
|
||||
void Queue.EmitCurrentState();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Library Only toggle
|
||||
// ---------------------------------------------------------------------------
|
||||
const libraryOnlyToggle = document.getElementById('library-only-toggle');
|
||||
|
||||
if (libraryOnlyToggle) {
|
||||
// Sync initial state.
|
||||
if (exploreSettings.libraryOnly) {
|
||||
libraryOnlyToggle.classList.add('active');
|
||||
}
|
||||
|
||||
libraryOnlyToggle.addEventListener('click', () => {
|
||||
exploreSettings.toggle();
|
||||
libraryOnlyToggle.classList.toggle('active', exploreSettings.libraryOnly);
|
||||
});
|
||||
|
||||
exploreSettings.subscribe(() => {
|
||||
libraryOnlyToggle.classList.toggle('active', exploreSettings.libraryOnly);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
|
||||
import './config-field';
|
||||
import './config-section';
|
||||
import './download-clients';
|
||||
import './shortcut-capture';
|
||||
import { shortcutsStore } from '../../store/shortcuts-store';
|
||||
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
|
||||
@@ -959,6 +960,12 @@ export class ConfigPage extends LitElement {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tier-detail {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: var(--yj-text-xs, 11px);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tier-error {
|
||||
color: var(--yj-accent-error, #f44);
|
||||
font-size: var(--yj-text-xs, 11px);
|
||||
@@ -1457,6 +1464,7 @@ export class ConfigPage extends LitElement {
|
||||
${this.renderFavoritesSection()}
|
||||
${this.renderTrackListSection()}
|
||||
${this.renderShortcutsSection()}
|
||||
<download-clients></download-clients>
|
||||
${this.renderLibrarySection()}
|
||||
`;
|
||||
}
|
||||
@@ -1499,6 +1507,9 @@ export class ConfigPage extends LitElement {
|
||||
${t.state === 'running' && t.total > 0
|
||||
? html`<span class="tier-progress">${t.completed}/${t.total}</span>`
|
||||
: nothing}
|
||||
${t.state === 'running' && t.detail
|
||||
? html`<span class="tier-detail">${t.detail}</span>`
|
||||
: nothing}
|
||||
${t.state === 'error'
|
||||
? html`<span class="tier-error">${t.error}</span>`
|
||||
: nothing}
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||
import '@awesome.me/webawesome/dist/components/input/input.js';
|
||||
import '@awesome.me/webawesome/dist/components/select/select.js';
|
||||
import '@awesome.me/webawesome/dist/components/option/option.js';
|
||||
import '@awesome.me/webawesome/dist/components/switch/switch.js';
|
||||
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
|
||||
import '@awesome.me/webawesome/dist/components/callout/callout.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import type {
|
||||
DownloadDescriptor,
|
||||
DownloadProvider,
|
||||
} from '@store/download-store';
|
||||
import { downloadStore } from '@store/download-store';
|
||||
import './config-section';
|
||||
|
||||
/**
|
||||
* Download client configuration.
|
||||
*
|
||||
* The forms are rendered from the descriptors the backend publishes, not
|
||||
* from anything hard-coded here, so adding a provider on the backend
|
||||
* gives it a settings UI with no frontend change. That is also why
|
||||
* secret fields render as password inputs purely on the descriptor's
|
||||
* say-so — the frontend never needs to know which services have keys.
|
||||
*/
|
||||
@customElement('download-clients')
|
||||
export class DownloadClients extends LitElement {
|
||||
@state()
|
||||
private providers: DownloadProvider[] = [];
|
||||
|
||||
@state()
|
||||
private descriptors: DownloadDescriptor[] = [];
|
||||
|
||||
/** Provider being edited, or 'new' while adding one. */
|
||||
@state()
|
||||
private editing: number | 'new' | null = null;
|
||||
|
||||
/** Kind selected in the add form. */
|
||||
@state()
|
||||
private newKind = '';
|
||||
|
||||
/** Working copy of the form's field values. */
|
||||
@state()
|
||||
private draft: Record<string, string> = {};
|
||||
|
||||
@state()
|
||||
private draftName = '';
|
||||
|
||||
/** Per-provider connection test results, keyed by provider ID. */
|
||||
@state()
|
||||
private testResults: Record<number, { ok: boolean; message: string }> = {};
|
||||
|
||||
@state()
|
||||
private testing: number | null = null;
|
||||
|
||||
@state()
|
||||
private errorMessage = '';
|
||||
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.unsubscribe = downloadStore.subscribe(() => this.syncFromStore());
|
||||
|
||||
void downloadStore.init().then(() => this.syncFromStore());
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
|
||||
private syncFromStore(): void {
|
||||
this.providers = downloadStore.providers;
|
||||
this.descriptors = downloadStore.descriptors;
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.clients {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6em;
|
||||
}
|
||||
|
||||
.client {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.75em;
|
||||
align-items: center;
|
||||
padding: 0.7em 0.85em;
|
||||
border: 1px solid var(--wa-color-surface-border, #333);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.client-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.client-meta {
|
||||
font-size: 0.82em;
|
||||
opacity: 0.7;
|
||||
margin-top: 0.15em;
|
||||
}
|
||||
|
||||
.client-actions {
|
||||
display: flex;
|
||||
gap: 0.4em;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
font-size: 0.8em;
|
||||
margin-top: 0.35em;
|
||||
}
|
||||
|
||||
.test-result.ok {
|
||||
color: var(--wa-color-success-fill-loud, #4c9f70);
|
||||
}
|
||||
|
||||
.test-result.fail {
|
||||
color: var(--wa-color-danger-fill-loud, #c65f5f);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7em;
|
||||
padding: 0.9em;
|
||||
border: 1px solid var(--wa-color-surface-border, #333);
|
||||
border-radius: 8px;
|
||||
margin-top: 0.6em;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
|
||||
.requires {
|
||||
font-size: 0.82em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.empty {
|
||||
opacity: 0.7;
|
||||
font-size: 0.9em;
|
||||
padding: 0.5em 0;
|
||||
}
|
||||
|
||||
.add-row {
|
||||
margin-top: 0.8em;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<config-section
|
||||
heading="Download Clients"
|
||||
description="Connect services you already run to search for and download music. Nothing is enabled until you add a client."
|
||||
>
|
||||
${this.errorMessage
|
||||
? html`<wa-callout variant="danger">${this.errorMessage}</wa-callout>`
|
||||
: nothing}
|
||||
|
||||
<div class="clients">
|
||||
${this.providers.length === 0 && this.editing !== 'new'
|
||||
? html`<div class="empty">No download clients connected.</div>`
|
||||
: nothing}
|
||||
${this.providers.map((provider) => this.renderProvider(provider))}
|
||||
</div>
|
||||
|
||||
${this.editing === 'new'
|
||||
? this.renderAddForm()
|
||||
: html`
|
||||
<div class="add-row">
|
||||
<wa-button size="small" @click=${this.startAdd}>
|
||||
Add download client
|
||||
</wa-button>
|
||||
</div>
|
||||
`}
|
||||
</config-section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderProvider(provider: DownloadProvider) {
|
||||
const descriptor = this.descriptorFor(provider.kind);
|
||||
const test = this.testResults[provider.id];
|
||||
|
||||
if (this.editing === provider.id) {
|
||||
return this.renderEditForm(provider);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="client">
|
||||
<div>
|
||||
<div class="client-name">${provider.name}</div>
|
||||
<div class="client-meta">
|
||||
${descriptor?.name ?? provider.kind} ·
|
||||
${provider.enabled ? 'Enabled' : 'Disabled'} ·
|
||||
priority ${provider.priority}
|
||||
</div>
|
||||
${test
|
||||
? html`<div class="test-result ${test.ok ? 'ok' : 'fail'}">
|
||||
${test.message}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="client-actions">
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
?disabled=${this.testing === provider.id}
|
||||
@click=${() => this.testProvider(provider)}
|
||||
>
|
||||
${this.testing === provider.id
|
||||
? html`<wa-spinner></wa-spinner>`
|
||||
: 'Test'}
|
||||
</wa-button>
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
@click=${() => this.startEdit(provider)}
|
||||
>
|
||||
Edit
|
||||
</wa-button>
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
variant="danger"
|
||||
@click=${() => this.deleteProvider(provider)}
|
||||
>
|
||||
Remove
|
||||
</wa-button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAddForm() {
|
||||
const descriptor = this.descriptorFor(this.newKind);
|
||||
|
||||
return html`
|
||||
<div class="form">
|
||||
<wa-select
|
||||
label="Client type"
|
||||
.value=${this.newKind}
|
||||
@wa-change=${this.onKindChange}
|
||||
>
|
||||
${this.descriptors.map(
|
||||
(d) => html`<wa-option value=${d.kind}>${d.name}</wa-option>`,
|
||||
)}
|
||||
</wa-select>
|
||||
|
||||
${descriptor
|
||||
? html`
|
||||
<div class="requires">
|
||||
${descriptor.summary}
|
||||
${descriptor.requiresExternal
|
||||
? html`<br />Requires a running
|
||||
${descriptor.requiresExternal} instance.`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
<wa-input
|
||||
label="Name"
|
||||
.value=${this.draftName}
|
||||
@wa-input=${(e: Event) => {
|
||||
this.draftName = (e.target as HTMLInputElement).value;
|
||||
}}
|
||||
></wa-input>
|
||||
|
||||
${this.renderFields(descriptor)}
|
||||
`
|
||||
: nothing}
|
||||
|
||||
<div class="form-actions">
|
||||
<wa-button size="small" appearance="plain" @click=${this.cancelEdit}>
|
||||
Cancel
|
||||
</wa-button>
|
||||
<wa-button
|
||||
size="small"
|
||||
variant="brand"
|
||||
?disabled=${!descriptor}
|
||||
@click=${this.saveNew}
|
||||
>
|
||||
Add
|
||||
</wa-button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEditForm(provider: DownloadProvider) {
|
||||
const descriptor = this.descriptorFor(provider.kind);
|
||||
|
||||
return html`
|
||||
<div class="form">
|
||||
<wa-input
|
||||
label="Name"
|
||||
.value=${this.draftName}
|
||||
@wa-input=${(e: Event) => {
|
||||
this.draftName = (e.target as HTMLInputElement).value;
|
||||
}}
|
||||
></wa-input>
|
||||
|
||||
${descriptor ? this.renderFields(descriptor) : nothing}
|
||||
|
||||
<wa-input
|
||||
label="Priority"
|
||||
type="number"
|
||||
.value=${String(provider.priority)}
|
||||
@wa-input=${(e: Event) => {
|
||||
this.draft['__priority'] = (e.target as HTMLInputElement).value;
|
||||
}}
|
||||
></wa-input>
|
||||
|
||||
<wa-switch
|
||||
?checked=${provider.enabled}
|
||||
@wa-change=${(e: Event) => {
|
||||
this.draft['__enabled'] = (e.target as HTMLInputElement)
|
||||
.checked
|
||||
? '1'
|
||||
: '';
|
||||
}}
|
||||
>
|
||||
Enabled
|
||||
</wa-switch>
|
||||
|
||||
<div class="form-actions">
|
||||
<wa-button size="small" appearance="plain" @click=${this.cancelEdit}>
|
||||
Cancel
|
||||
</wa-button>
|
||||
<wa-button
|
||||
size="small"
|
||||
variant="brand"
|
||||
@click=${() => this.saveEdit(provider)}
|
||||
>
|
||||
Save
|
||||
</wa-button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Renders one input per descriptor field. */
|
||||
private renderFields(descriptor: DownloadDescriptor) {
|
||||
return (descriptor.fields ?? []).map(
|
||||
(field) => html`
|
||||
<wa-input
|
||||
label=${field.label}
|
||||
placeholder=${field.placeholder ?? ''}
|
||||
type=${field.secret ? 'password' : 'text'}
|
||||
.value=${this.draft[field.key] ?? ''}
|
||||
@wa-input=${(e: Event) => {
|
||||
this.draft = {
|
||||
...this.draft,
|
||||
[field.key]: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
}}
|
||||
>
|
||||
${field.help ? html`<span slot="hint">${field.help}</span>` : nothing}
|
||||
</wa-input>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
private descriptorFor(kind: string): DownloadDescriptor | undefined {
|
||||
return this.descriptors.find((d) => d.kind === kind);
|
||||
}
|
||||
|
||||
private startAdd = () => {
|
||||
this.editing = 'new';
|
||||
this.errorMessage = '';
|
||||
this.newKind = this.descriptors[0]?.kind ?? '';
|
||||
this.draftName = this.descriptorFor(this.newKind)?.name ?? '';
|
||||
this.draft = this.defaultsFor(this.newKind);
|
||||
};
|
||||
|
||||
private startEdit(provider: DownloadProvider) {
|
||||
this.editing = provider.id;
|
||||
this.errorMessage = '';
|
||||
this.draftName = provider.name;
|
||||
// Secrets are never sent back to the frontend, so their fields
|
||||
// start blank; a blank secret on save means "leave it alone"
|
||||
// rather than "clear it".
|
||||
this.draft = { ...(provider.settings ?? {}) };
|
||||
}
|
||||
|
||||
private cancelEdit = () => {
|
||||
this.editing = null;
|
||||
this.draft = {};
|
||||
this.errorMessage = '';
|
||||
};
|
||||
|
||||
private onKindChange = (event: Event) => {
|
||||
this.newKind = (event.target as HTMLInputElement).value;
|
||||
this.draftName = this.descriptorFor(this.newKind)?.name ?? '';
|
||||
this.draft = this.defaultsFor(this.newKind);
|
||||
};
|
||||
|
||||
private defaultsFor(kind: string): Record<string, string> {
|
||||
const descriptor = this.descriptorFor(kind);
|
||||
const out: Record<string, string> = {};
|
||||
|
||||
for (const field of descriptor?.fields ?? []) {
|
||||
if (field.default) out[field.key] = field.default;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
private saveNew = async () => {
|
||||
this.errorMessage = '';
|
||||
|
||||
try {
|
||||
await downloadStore.addProvider(
|
||||
this.newKind,
|
||||
this.draftName || this.newKind,
|
||||
this.cleanDraft(),
|
||||
);
|
||||
|
||||
this.cancelEdit();
|
||||
} catch (err) {
|
||||
this.errorMessage = String(err);
|
||||
}
|
||||
};
|
||||
|
||||
private async saveEdit(provider: DownloadProvider) {
|
||||
this.errorMessage = '';
|
||||
|
||||
const priority = this.draft['__priority']
|
||||
? Number(this.draft['__priority'])
|
||||
: provider.priority;
|
||||
|
||||
const enabled =
|
||||
'__enabled' in this.draft
|
||||
? this.draft['__enabled'] === '1'
|
||||
: provider.enabled;
|
||||
|
||||
try {
|
||||
await downloadStore.updateProvider(
|
||||
provider.id,
|
||||
this.draftName || provider.name,
|
||||
enabled,
|
||||
priority,
|
||||
this.cleanDraft(),
|
||||
);
|
||||
|
||||
this.cancelEdit();
|
||||
} catch (err) {
|
||||
this.errorMessage = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Strips the form's internal bookkeeping keys before saving. */
|
||||
private cleanDraft(): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(this.draft)) {
|
||||
if (!key.startsWith('__')) out[key] = value;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
private async deleteProvider(provider: DownloadProvider) {
|
||||
this.errorMessage = '';
|
||||
|
||||
try {
|
||||
await downloadStore.deleteProvider(provider.id);
|
||||
} catch (err) {
|
||||
this.errorMessage = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
private async testProvider(provider: DownloadProvider) {
|
||||
this.testing = provider.id;
|
||||
|
||||
try {
|
||||
await downloadStore.testProvider(provider.id);
|
||||
|
||||
this.testResults = {
|
||||
...this.testResults,
|
||||
[provider.id]: { ok: true, message: 'Connected.' },
|
||||
};
|
||||
} catch (err) {
|
||||
this.testResults = {
|
||||
...this.testResults,
|
||||
[provider.id]: { ok: false, message: String(err) },
|
||||
};
|
||||
} finally {
|
||||
this.testing = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'download-clients': DownloadClients;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import type { DownloadCandidate } from '@store/download-store';
|
||||
import { candidateSummary, scorePercent } from '@store/download-store';
|
||||
|
||||
/**
|
||||
* One candidate in the download picker.
|
||||
*
|
||||
* The row shows match and quality as two separate meters rather than
|
||||
* one blended score, because they fail differently: a flawless copy of
|
||||
* the wrong album is useless, a mediocre copy of the right one is
|
||||
* merely disappointing, and only the user knows which they will accept.
|
||||
* Collapsing them into a single number would make the ranking
|
||||
* impossible to argue with.
|
||||
*/
|
||||
@customElement('candidate-row')
|
||||
export class CandidateRow extends LitElement {
|
||||
@property({ type: Object })
|
||||
candidate!: DownloadCandidate;
|
||||
|
||||
/** Marks the row the ranking put first. */
|
||||
@property({ type: Boolean, attribute: 'is-best' })
|
||||
isBest = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
busy = false;
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 1em;
|
||||
align-items: center;
|
||||
padding: 0.75em 0.9em;
|
||||
border: 1px solid var(--wa-color-surface-border, #333);
|
||||
border-radius: 8px;
|
||||
background: var(--wa-color-surface-raised, #1c1c1c);
|
||||
}
|
||||
|
||||
.row.best {
|
||||
border-color: var(--wa-color-brand-fill-loud, #d9a441);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summary {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.75;
|
||||
margin-top: 0.15em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: 0.4em;
|
||||
margin-top: 0.4em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.72em;
|
||||
padding: 0.1em 0.45em;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.best {
|
||||
background: var(--wa-color-brand-fill-loud, #d9a441);
|
||||
color: #111;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.warn {
|
||||
background: rgba(217, 119, 65, 0.25);
|
||||
}
|
||||
|
||||
.meters {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.3em 0.5em;
|
||||
align-items: center;
|
||||
margin-top: 0.5em;
|
||||
font-size: 0.75em;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.meter-label {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.track {
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 150ms ease;
|
||||
}
|
||||
|
||||
.fill.match {
|
||||
background: var(--wa-color-success-fill-loud, #4c9f70);
|
||||
}
|
||||
|
||||
.fill.match.low {
|
||||
background: var(--wa-color-warning-fill-loud, #d97741);
|
||||
}
|
||||
|
||||
.fill.quality {
|
||||
background: var(--wa-color-brand-fill-loud, #6a8cc7);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.85;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
/** Match below this reads as "probably not what you asked for". */
|
||||
private static readonly LOW_MATCH = 0.7;
|
||||
|
||||
override render() {
|
||||
const candidate = this.candidate;
|
||||
if (!candidate) return nothing;
|
||||
|
||||
const match = candidate.match?.overall ?? 0;
|
||||
const quality = candidate.quality?.overall ?? 0;
|
||||
|
||||
return html`
|
||||
<div class="row ${this.isBest ? 'best' : ''}">
|
||||
<div class="info">
|
||||
<div class="title" title=${candidate.title}>
|
||||
${candidate.title}
|
||||
</div>
|
||||
<div class="summary">${candidateSummary(candidate)}</div>
|
||||
${this.renderBadges()}
|
||||
<div class="meters">
|
||||
<span class="meter-label">Match</span>
|
||||
<div class="track">
|
||||
<div
|
||||
class="fill match ${match < CandidateRow.LOW_MATCH
|
||||
? 'low'
|
||||
: ''}"
|
||||
style="width: ${Math.round(match * 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="value">${scorePercent(match)}</span>
|
||||
|
||||
<span class="meter-label">Quality</span>
|
||||
<div class="track">
|
||||
<div
|
||||
class="fill quality"
|
||||
style="width: ${Math.round(quality * 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="value">${scorePercent(quality)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<wa-button
|
||||
variant=${this.isBest ? 'brand' : 'neutral'}
|
||||
size="small"
|
||||
?disabled=${this.busy}
|
||||
@click=${this.onPick}
|
||||
>
|
||||
Download
|
||||
</wa-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBadges() {
|
||||
const candidate = this.candidate;
|
||||
const badges = [];
|
||||
|
||||
if (this.isBest) {
|
||||
badges.push(html`<span class="badge best">Best match</span>`);
|
||||
}
|
||||
|
||||
// An unanchored match is a guess: there was no MusicBrainz ID to
|
||||
// check the result against, so the score cannot mean much and
|
||||
// saying so is more honest than showing a confident number.
|
||||
if (candidate.match && !candidate.match.anchored) {
|
||||
badges.push(
|
||||
html`<span class="badge warn" title="No MusicBrainz match to verify against">
|
||||
Unverified
|
||||
</span>`,
|
||||
);
|
||||
}
|
||||
|
||||
if (candidate.quality?.mixed) {
|
||||
badges.push(
|
||||
html`<span class="badge warn" title="Files are not all the same format">
|
||||
Mixed formats
|
||||
</span>`,
|
||||
);
|
||||
}
|
||||
|
||||
const completeness = candidate.match?.completeness ?? 1;
|
||||
|
||||
if (completeness < 1 && completeness > 0) {
|
||||
badges.push(
|
||||
html`<span class="badge warn">
|
||||
${scorePercent(completeness)} of tracks
|
||||
</span>`,
|
||||
);
|
||||
}
|
||||
|
||||
if (candidate.protocol && candidate.protocol !== 'direct') {
|
||||
badges.push(html`<span class="badge">${candidate.protocol}</span>`);
|
||||
}
|
||||
|
||||
return badges.length > 0
|
||||
? html`<div class="badges">${badges}</div>`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private onPick() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('candidate-pick', {
|
||||
detail: { candidateId: this.candidate.id },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'candidate-row': CandidateRow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
|
||||
import '@awesome.me/webawesome/dist/components/callout/callout.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import type { DownloadCandidate } from '@store/download-store';
|
||||
import { downloadStore } from '@store/download-store';
|
||||
import type { download } from '@go/models';
|
||||
import './candidate-row';
|
||||
|
||||
/**
|
||||
* The "find this album" dialog: searches every enabled download client,
|
||||
* ranks what comes back, and asks the user to choose.
|
||||
*
|
||||
* When the pipeline finds a clear winner it starts on its own and this
|
||||
* dialog reports that rather than asking a question with one obvious
|
||||
* answer. When it does not — two equally good candidates, or a free-text
|
||||
* request with nothing to verify against — the choice is the user's,
|
||||
* because guessing wrong puts the wrong files in their library.
|
||||
*/
|
||||
@customElement('download-picker')
|
||||
export class DownloadPicker extends LitElement {
|
||||
@property({ type: Boolean, reflect: true })
|
||||
open = false;
|
||||
|
||||
/** Library the imported files belong to. */
|
||||
@property({ type: Number, attribute: 'library-id' })
|
||||
libraryId = 0;
|
||||
|
||||
@property({ type: String })
|
||||
artist = '';
|
||||
|
||||
@property({ type: String })
|
||||
album = '';
|
||||
|
||||
/** MusicBrainz release-group ID, when the caller has one. */
|
||||
@property({ type: String, attribute: 'release-group-mbid' })
|
||||
releaseGroupMbid = '';
|
||||
|
||||
@property({ type: String, attribute: 'release-mbid' })
|
||||
releaseMbid = '';
|
||||
|
||||
/**
|
||||
* Expected tracklist. Supplying it is what makes the result
|
||||
* trustworthy: without it there is nothing to check a candidate
|
||||
* against, and the pipeline will never auto-pick.
|
||||
*/
|
||||
@property({ type: Array })
|
||||
expected: download.ExpectedTrack[] = [];
|
||||
|
||||
@state()
|
||||
private searching = false;
|
||||
|
||||
@state()
|
||||
private candidates: DownloadCandidate[] = [];
|
||||
|
||||
@state()
|
||||
private requestId = '';
|
||||
|
||||
@state()
|
||||
private autoPicked = false;
|
||||
|
||||
@state()
|
||||
private picking = false;
|
||||
|
||||
@state()
|
||||
private errorMessage = '';
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
css`
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.album {
|
||||
font-size: 1.05em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.artist {
|
||||
opacity: 0.75;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
padding: 1.5em 0;
|
||||
justify-content: center;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6em;
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.footnote {
|
||||
margin-top: 1em;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.65;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
override updated(changed: Map<string, unknown>) {
|
||||
if (changed.has('open') && this.open) {
|
||||
void this.search();
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs the search that populates the dialog. */
|
||||
private async search(): Promise<void> {
|
||||
this.searching = true;
|
||||
this.errorMessage = '';
|
||||
this.candidates = [];
|
||||
this.autoPicked = false;
|
||||
|
||||
try {
|
||||
const result = await downloadStore.start({
|
||||
libraryId: this.libraryId,
|
||||
releaseMbid: this.releaseMbid,
|
||||
releaseGroupMbid: this.releaseGroupMbid,
|
||||
artist: this.artist,
|
||||
album: this.album,
|
||||
query: '',
|
||||
expected: this.expected ?? [],
|
||||
} as download.SearchRequest);
|
||||
|
||||
this.requestId = result.requestId;
|
||||
this.candidates = result.candidates ?? [];
|
||||
this.autoPicked = result.autoPicked;
|
||||
} catch (err) {
|
||||
this.errorMessage = String(err);
|
||||
} finally {
|
||||
this.searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async onPick(event: CustomEvent<{ candidateId: string }>) {
|
||||
if (this.picking) return;
|
||||
|
||||
this.picking = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
try {
|
||||
await downloadStore.pick(this.requestId, event.detail.candidateId);
|
||||
this.close();
|
||||
} catch (err) {
|
||||
this.errorMessage = String(err);
|
||||
} finally {
|
||||
this.picking = false;
|
||||
}
|
||||
}
|
||||
|
||||
private close() {
|
||||
this.open = false;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('picker-close', { bubbles: true, composed: true }),
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<wa-dialog
|
||||
label="Find this album"
|
||||
?open=${this.open}
|
||||
@wa-hide=${() => this.close()}
|
||||
>
|
||||
<div class="heading">
|
||||
<span class="album">${this.album || 'Unknown album'}</span>
|
||||
<span class="artist">${this.artist}</span>
|
||||
</div>
|
||||
|
||||
${this.renderBody()}
|
||||
|
||||
<wa-button slot="footer" variant="neutral" @click=${() => this.close()}>
|
||||
Close
|
||||
</wa-button>
|
||||
</wa-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBody() {
|
||||
if (this.errorMessage) {
|
||||
return html`
|
||||
<wa-callout variant="danger">${this.errorMessage}</wa-callout>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.searching) {
|
||||
return html`
|
||||
<div class="status">
|
||||
<wa-spinner></wa-spinner>
|
||||
<span>Searching your download clients…</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.autoPicked) {
|
||||
return html`
|
||||
<wa-callout variant="success">
|
||||
Found a clear match and started downloading it. Progress is
|
||||
in the background jobs panel.
|
||||
</wa-callout>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.candidates.length === 0) {
|
||||
return html`
|
||||
<wa-callout variant="neutral">
|
||||
Nothing found. Try a different spelling, or connect more
|
||||
download clients in Settings.
|
||||
</wa-callout>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="list">
|
||||
${this.candidates.map(
|
||||
(candidate, index) => html`
|
||||
<candidate-row
|
||||
.candidate=${candidate}
|
||||
?is-best=${index === 0}
|
||||
?busy=${this.picking}
|
||||
@candidate-pick=${this.onPick}
|
||||
></candidate-row>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
${this.renderFootnote()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFootnote() {
|
||||
const best = this.candidates[0];
|
||||
if (!best?.match) return nothing;
|
||||
|
||||
// Say plainly why nothing was auto-picked, so the dialog does
|
||||
// not look like it is asking a question it could have answered.
|
||||
if (!best.match.anchored) {
|
||||
return html`
|
||||
<div class="footnote">
|
||||
This search had no MusicBrainz match to verify against, so
|
||||
these results could not be checked automatically.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="footnote">
|
||||
Downloads are checked and tagged before they are added to your
|
||||
library.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'download-picker': DownloadPicker;
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,20 @@ import {
|
||||
} from '@go/explore/Service';
|
||||
import { GetAlbumTracks } from '@go/library/Library';
|
||||
import { library } from '@go/models';
|
||||
import type { explore } from '@go/models';
|
||||
import type { download, explore } from '@go/models';
|
||||
type MBReleaseGroup = explore.MBReleaseGroup;
|
||||
type MBRelease = explore.MBRelease;
|
||||
type MBTrack = explore.MBTrack;
|
||||
import { exploreCache } from '../../store/explore-cache';
|
||||
import { exploreSettings } from '../../store/explore-settings';
|
||||
import { libraryStore } from '../../store/library-store';
|
||||
import { artistLink, exploreLinkStyles } from '../../utils/explore-link';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { Events } from '../../events';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '../library-status-indicator/library-status-indicator.js';
|
||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||
import '../download-picker/download-picker';
|
||||
import { downloadStore } from '../../store/download-store';
|
||||
|
||||
/* ── Utility functions (duplicated per Knowledge Pattern #9 — no cross-component imports) ── */
|
||||
|
||||
@@ -112,6 +114,18 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
@state() private selectedVersionKey: string = '';
|
||||
@state() private coverArtURL = '';
|
||||
|
||||
/** Open state of the "find this album" dialog. */
|
||||
@state() private pickerOpen = false;
|
||||
|
||||
/** True once a download client is configured and enabled. */
|
||||
@state() private canDownload = false;
|
||||
|
||||
/** True when this album is already on the wanted list. */
|
||||
@state() private isWanted = false;
|
||||
|
||||
/** Unsubscribe handle for the download store. */
|
||||
private downloadUnsub: (() => void) | null = null;
|
||||
|
||||
/* ── Styles ── */
|
||||
|
||||
static override styles = [
|
||||
@@ -454,7 +468,6 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
|
||||
/* ── Lifecycle ── */
|
||||
|
||||
private unsubSettings?: () => void;
|
||||
private unsubReleasesReady?: () => void;
|
||||
/** Release-group MBIDs whose AlbumReleasesReady event we've handled,
|
||||
* so a background BrowseReleases fetch re-hydrates versions once. */
|
||||
@@ -469,12 +482,16 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
void this.loadAllData();
|
||||
}
|
||||
|
||||
this.unsubSettings = exploreSettings.subscribe(() => {
|
||||
// Re-run data loading — library-only mode may show/hide
|
||||
// API-sourced content or hydrate from local tracks.
|
||||
if (this.releaseGroupMBID || this.localAlbumId) {
|
||||
void this.loadAllData();
|
||||
}
|
||||
// The download button only appears once a client is connected,
|
||||
// so this tracks the provider list rather than assuming.
|
||||
this.downloadUnsub = downloadStore.subscribe(() => {
|
||||
this.canDownload = downloadStore.available;
|
||||
this.syncWanted();
|
||||
});
|
||||
|
||||
void downloadStore.init().then(() => {
|
||||
this.canDownload = downloadStore.available;
|
||||
this.syncWanted();
|
||||
});
|
||||
|
||||
// A background BrowseReleases fetch (cold album, versions +
|
||||
@@ -496,7 +513,8 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.unsubSettings?.();
|
||||
this.downloadUnsub?.();
|
||||
this.downloadUnsub = null;
|
||||
this.unsubReleasesReady?.();
|
||||
if (this.releasesFallbackTimer) clearTimeout(this.releasesFallbackTimer);
|
||||
}
|
||||
@@ -631,14 +649,6 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
// tracklist; the return value isn't currently consumed.
|
||||
await this.hydrateFromLibrary(mbid);
|
||||
|
||||
// Library-only mode: local data is all we show.
|
||||
if (exploreSettings.libraryOnly) {
|
||||
this.loadingInfo = false;
|
||||
this.loadingReleases = false;
|
||||
console.log(`[explore-album] loaded (library-only): "${this.albumName}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: fire API calls independently so each section
|
||||
// renders as its data arrives. Allow the versions section one
|
||||
// background-fetch re-fetch and arm a fallback so it can't spin
|
||||
@@ -1336,7 +1346,7 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
* - localAlbumId set → owned
|
||||
* - releaseGroup.inLibrary set → owned (backend cross-ref)
|
||||
* - cachedAlbums has MBID match → owned
|
||||
* - any selected version has → owned (covers library-only mode
|
||||
* - any selected version has → owned (covers local-only albums
|
||||
* a track marked inLibrary where releaseGroup may be null)
|
||||
* - else → not owned
|
||||
*
|
||||
@@ -1478,11 +1488,137 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
></library-status-indicator>
|
||||
</h1>
|
||||
${this.renderAlbumMeta()}
|
||||
${this.renderDownloadAction()}
|
||||
</div>
|
||||
</div>
|
||||
${this.renderPicker()}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers to acquire the album, but only when the user has actually
|
||||
* connected a download client and does not already own it. Showing
|
||||
* the button otherwise would advertise a feature that cannot work.
|
||||
*/
|
||||
private renderDownloadAction() {
|
||||
if (this.albumLibraryStatus() === 'in-library') return nothing;
|
||||
|
||||
return html`
|
||||
<div class="album-download">
|
||||
${this.canDownload
|
||||
? html`
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="outlined"
|
||||
@click=${() => {
|
||||
this.pickerOpen = true;
|
||||
}}
|
||||
>
|
||||
<wa-icon slot="start" name="download"></wa-icon>
|
||||
Find this album
|
||||
</wa-button>
|
||||
`
|
||||
: nothing}
|
||||
${this.renderWantAction()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the album to the wanted list, which is the answer to "look
|
||||
* for it, but not right now".
|
||||
*
|
||||
* Unlike the download button this shows whether or not a client is
|
||||
* connected: wanting something is a durable statement about the
|
||||
* library, and it stays true — and stays queued — until a client
|
||||
* exists to act on it.
|
||||
*/
|
||||
private renderWantAction() {
|
||||
if (!this.releaseGroupMBID) return nothing;
|
||||
|
||||
const want = downloadStore.wantFor(this.releaseGroupMBID);
|
||||
|
||||
return html`
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance=${this.isWanted ? 'filled' : 'outlined'}
|
||||
@click=${() => void this.toggleWanted(want?.id)}
|
||||
>
|
||||
<wa-icon
|
||||
slot="start"
|
||||
name=${this.isWanted ? 'bookmark-check' : 'bookmark'}
|
||||
></wa-icon>
|
||||
${this.isWanted ? 'Wanted' : 'Want this'}
|
||||
</wa-button>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Reflects the store's view of whether this album is wanted. */
|
||||
private syncWanted(): void {
|
||||
this.isWanted = this.releaseGroupMBID
|
||||
? downloadStore.isWanted(this.releaseGroupMBID)
|
||||
: false;
|
||||
}
|
||||
|
||||
private async toggleWanted(wantId: number | undefined): Promise<void> {
|
||||
if (!this.releaseGroupMBID) return;
|
||||
|
||||
try {
|
||||
if (wantId) {
|
||||
await downloadStore.removeWant(wantId);
|
||||
} else {
|
||||
await downloadStore.addWant({
|
||||
mbid: this.releaseGroupMBID,
|
||||
entity: 'release-group',
|
||||
libraryId: libraryStore.getSelectedLibraryId() ?? 0,
|
||||
artist: this.releaseGroup?.artistCredit ?? '',
|
||||
title: this.albumName,
|
||||
scope: 'future',
|
||||
secondary: false,
|
||||
} as download.WantRequest);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Could not update the wanted list:', err);
|
||||
}
|
||||
|
||||
this.syncWanted();
|
||||
}
|
||||
|
||||
private renderPicker() {
|
||||
if (!this.pickerOpen) return nothing;
|
||||
|
||||
const tracks = this.currentTracks();
|
||||
|
||||
return html`
|
||||
<download-picker
|
||||
?open=${this.pickerOpen}
|
||||
library-id=${libraryStore.getSelectedLibraryId() ?? 0}
|
||||
artist=${this.releaseGroup?.artistCredit ?? ''}
|
||||
album=${this.albumName}
|
||||
release-group-mbid=${this.releaseGroupMBID ?? ''}
|
||||
.expected=${tracks.map((t, index) => ({
|
||||
position: t.position || index + 1,
|
||||
discNumber: t.discNumber ?? 0,
|
||||
title: t.title,
|
||||
artist: '',
|
||||
lengthMillis: t.length ?? 0,
|
||||
}))}
|
||||
@picker-close=${() => {
|
||||
this.pickerOpen = false;
|
||||
}}
|
||||
></download-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Tracks of the version currently selected in the dropdown. */
|
||||
private currentTracks(): MBTrack[] {
|
||||
const entry = this.versionEntries.find(
|
||||
(e) => e.key === this.selectedVersionKey,
|
||||
);
|
||||
|
||||
return entry?.tracks ?? [];
|
||||
}
|
||||
|
||||
private renderAlbumMeta() {
|
||||
if (this.loadingInfo) {
|
||||
return html`<span class="album-artist section-loading"
|
||||
@@ -1535,9 +1671,6 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
/* ── Version Selector (R025, R026, R027) ── */
|
||||
|
||||
private renderVersionSelector() {
|
||||
// Library-only mode: no version selector (only local tracks).
|
||||
if (exploreSettings.libraryOnly) return nothing;
|
||||
|
||||
if (this.loadingReleases) {
|
||||
return html`
|
||||
<section>
|
||||
@@ -1683,19 +1816,6 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
}
|
||||
const current = this.currentVersion();
|
||||
if (!current) {
|
||||
// In library-only mode with no local tracks, show a gentle message.
|
||||
if (exploreSettings.libraryOnly) {
|
||||
return html`
|
||||
<section>
|
||||
<h3 class="section-header">Tracklist</h3>
|
||||
<div
|
||||
style="color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-md)"
|
||||
>
|
||||
This album is not in your library.
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<section>
|
||||
<h3 class="section-header">Tracklist</h3>
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
SimilarArtists,
|
||||
GetArtistImageURL,
|
||||
GetArtistImageCachedPath,
|
||||
GetLibrarySimilarArtists,
|
||||
GetThumbnail,
|
||||
GetThumbnails,
|
||||
GetTrackThumbnail,
|
||||
@@ -24,8 +23,9 @@ type LBTopRecording = explore.LBTopRecording;
|
||||
type LBTopReleaseGroup = explore.LBTopReleaseGroup;
|
||||
type LBSimilarArtist = explore.LBSimilarArtist;
|
||||
import { exploreCache } from '../../store/explore-cache';
|
||||
import { exploreSettings } from '../../store/explore-settings';
|
||||
import { libraryStore } from '../../store/library-store';
|
||||
import { downloadStore } from '../../store/download-store';
|
||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||
import { trackLink, exploreLinkStyles } from '../../utils/explore-link';
|
||||
import { GetAlbumsByArtist } from '@go/library/Library';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
@@ -194,6 +194,10 @@ export class ExploreArtistDetails extends LitElement {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.artist-follow {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.artist-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -830,7 +834,6 @@ export class ExploreArtistDetails extends LitElement {
|
||||
|
||||
/* ── Lifecycle ── */
|
||||
|
||||
private unsubSettings?: () => void;
|
||||
private unsubDiscogReady?: () => void;
|
||||
private unsubSimilarReady?: () => void;
|
||||
/** MBIDs whose ArtistSimilarReady event we've already handled, so a
|
||||
@@ -843,19 +846,20 @@ export class ExploreArtistDetails extends LitElement {
|
||||
* background discography fetch never signals readiness. */
|
||||
private discogFallbackTimer?: number;
|
||||
|
||||
/** Unsubscribe handle for the wanted list. */
|
||||
private unsubWanted: (() => void) | null = null;
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.artistMBID || this.localArtistId) {
|
||||
void this.loadAllData();
|
||||
}
|
||||
|
||||
// Re-render when library-only mode toggles.
|
||||
this.unsubSettings = exploreSettings.subscribe(() => {
|
||||
this.requestUpdate();
|
||||
if (this.artistMBID || this.localArtistId) {
|
||||
void this.loadAllData();
|
||||
}
|
||||
});
|
||||
// Keep the follow button in step with the wanted list, which a
|
||||
// background reconcile pass can change without this page doing
|
||||
// anything.
|
||||
this.unsubWanted = downloadStore.subscribe(() => this.requestUpdate());
|
||||
void downloadStore.init().then(() => this.requestUpdate());
|
||||
|
||||
// A background discography fetch (top tracks / top releases for an
|
||||
// artist that wasn't indexed yet) finished — re-fetch those two
|
||||
@@ -893,7 +897,8 @@ export class ExploreArtistDetails extends LitElement {
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.unsubSettings?.();
|
||||
this.unsubWanted?.();
|
||||
this.unsubWanted = null;
|
||||
this.unsubDiscogReady?.();
|
||||
this.unsubSimilarReady?.();
|
||||
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
|
||||
@@ -1027,90 +1032,6 @@ export class ExploreArtistDetails extends LitElement {
|
||||
// Phase 0: hydrate from caches (instant, no Go calls).
|
||||
this.hydrateFromCache(mbid);
|
||||
|
||||
if (exploreSettings.libraryOnly) {
|
||||
// Library-only mode: no external API calls.
|
||||
// Discography comes from library store (already hydrated).
|
||||
// Similar artists from pre-computed DB table.
|
||||
this.loadingArtist = false;
|
||||
this.loadingTracks = false;
|
||||
this.loadingTopReleases = false;
|
||||
this.loadingReleases = false;
|
||||
this.loadingSimilar = false;
|
||||
|
||||
// If the library store hasn't eagerly fetched yet,
|
||||
// await it and re-hydrate. Covers the race between
|
||||
// navigation and the deferred eagerFetch on DOMContentLoaded.
|
||||
if (!libraryStore.cachedArtists || !libraryStore.cachedAlbums) {
|
||||
try {
|
||||
const pending: Promise<unknown>[] = [];
|
||||
if (!libraryStore.cachedArtists) {
|
||||
pending.push(libraryStore.getArtists());
|
||||
}
|
||||
if (!libraryStore.cachedAlbums) {
|
||||
pending.push(libraryStore.getAlbums());
|
||||
}
|
||||
await Promise.all(pending);
|
||||
this.hydrateFromCache(mbid);
|
||||
} catch {
|
||||
// Ignore — we'll fall through to the Wails-cached path.
|
||||
}
|
||||
}
|
||||
|
||||
// Artist image: if hydrateFromCache didn't find one (e.g.
|
||||
// the library store's cached artist row has an empty
|
||||
// ImageMedium because the on-disk file post-dates the
|
||||
// store's last fetch), fall back to a disk-only Wails
|
||||
// call. This just asks the backend whether
|
||||
// /artist-images/.../primary_md.jpg exists — no network,
|
||||
// no base64 transfer.
|
||||
if (!this.artistImageURL && mbid) {
|
||||
void GetArtistImageCachedPath(mbid)
|
||||
.then((url) => {
|
||||
if (url) {
|
||||
this.artistImageURL = url;
|
||||
} else {
|
||||
// Final fallback: album art by artist name.
|
||||
this.fallbackArtistImageFromAlbumArt();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.fallbackArtistImageFromAlbumArt();
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch library-only similar artists (single Go call, no external API).
|
||||
try {
|
||||
const similar = await GetLibrarySimilarArtists(mbid);
|
||||
// Dedupe by MBID as a safety net — the backend query
|
||||
// should already return unique rows but multiple library
|
||||
// artists can share an MBID (ensemble credits), so this
|
||||
// guards against any future query regression.
|
||||
const seen = new Set<string>();
|
||||
const deduped: LBSimilarArtist[] = [];
|
||||
for (const s of similar ?? []) {
|
||||
const key = s.artistMbid || s.name;
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(s);
|
||||
}
|
||||
this.similarArtists = deduped;
|
||||
|
||||
// Resolve similar artist images from library cache —
|
||||
// no Go calls, no network. These artists are all in
|
||||
// the library (that's the filter GetLibrarySimilarArtists
|
||||
// applies), so the library store has their image paths.
|
||||
this.seedSimilarArtistImagesFromLibrary();
|
||||
} catch {
|
||||
this.similarArtists = [];
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[explore-artist] loaded (library-only): "${this.artistName}"`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Fresh load for this artist: allow the top sections one
|
||||
// background-fetch re-fetch, and arm a fallback so they can't spin
|
||||
// forever if ArtistDiscographyReady never arrives.
|
||||
@@ -1604,7 +1525,7 @@ export class ExploreArtistDetails extends LitElement {
|
||||
/**
|
||||
* Populate similarImageURLs for the current similarArtists list
|
||||
* using only library-store data and disk-cached artist images.
|
||||
* Makes ZERO network calls — safe for library-only mode.
|
||||
* Makes ZERO network calls.
|
||||
*
|
||||
* Resolution priority per artist:
|
||||
* 1. libraryStore.cachedArtists[mbid].ImageMedium (in-memory)
|
||||
@@ -1694,10 +1615,10 @@ export class ExploreArtistDetails extends LitElement {
|
||||
}
|
||||
|
||||
private async fetchSimilarArtistImages() {
|
||||
// Phase 1: instant seed from library store + disk cache.
|
||||
// This mirrors the library-only path so any card whose image
|
||||
// is already on disk appears immediately, without waiting
|
||||
// for a network-enabled GetArtistImageURL round-trip.
|
||||
// Phase 1: instant seed from library store + disk cache, so any
|
||||
// card whose image is already on disk appears immediately,
|
||||
// without waiting for a network-enabled GetArtistImageURL
|
||||
// round-trip.
|
||||
await this.seedSimilarArtistImagesFromLibrary();
|
||||
|
||||
// Phase 2: network fetch for any similars still without
|
||||
@@ -1961,9 +1882,10 @@ export class ExploreArtistDetails extends LitElement {
|
||||
? html`<div class="artist-native-name">${this.artist.name}</div>`
|
||||
: nothing}
|
||||
${this.renderArtistMeta()}
|
||||
${this.artist?.popularity && this.artist.popularity > 0 && !exploreSettings.libraryOnly
|
||||
${this.artist?.popularity && this.artist.popularity > 0
|
||||
? html`<span class="artist-meta">${formatListenCount(this.artist.popularity)} plays on ListenBrainz</span>`
|
||||
: nothing}
|
||||
${this.renderFollowAction()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
@@ -1973,6 +1895,61 @@ export class ExploreArtistDetails extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to an artist: their new releases go on the wanted list
|
||||
* as they come out.
|
||||
*
|
||||
* The default is new releases only. Following an artist should not
|
||||
* silently queue forty albums — someone who wants the back
|
||||
* catalogue can widen it from the wanted list, and will not be
|
||||
* surprised by having done so.
|
||||
*/
|
||||
private renderFollowAction() {
|
||||
if (!this.artistMBID) return nothing;
|
||||
|
||||
const want = downloadStore.wantFor(this.artistMBID);
|
||||
|
||||
return html`
|
||||
<div class="artist-follow">
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance=${want ? 'filled' : 'outlined'}
|
||||
@click=${() => void this.toggleFollow(want?.id)}
|
||||
>
|
||||
<wa-icon
|
||||
slot="start"
|
||||
name=${want ? 'bookmark-check' : 'bookmark'}
|
||||
></wa-icon>
|
||||
${want ? 'Following' : 'Follow for new releases'}
|
||||
</wa-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async toggleFollow(wantId: number | undefined): Promise<void> {
|
||||
if (!this.artistMBID) return;
|
||||
|
||||
try {
|
||||
if (wantId) {
|
||||
await downloadStore.removeWant(wantId);
|
||||
} else {
|
||||
await downloadStore.addWant({
|
||||
mbid: this.artistMBID,
|
||||
entity: 'artist',
|
||||
libraryId: libraryStore.getSelectedLibraryId() ?? 0,
|
||||
artist: this.displayName,
|
||||
title: this.displayName,
|
||||
scope: 'future',
|
||||
secondary: false,
|
||||
} as never);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Could not update the wanted list:', err);
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private renderArtistMeta() {
|
||||
if (this.loadingArtist) {
|
||||
return html`<span class="artist-meta section-loading"
|
||||
@@ -2034,9 +2011,6 @@ export class ExploreArtistDetails extends LitElement {
|
||||
}
|
||||
|
||||
private renderTopSection() {
|
||||
// Library-only mode: no top tracks/releases from LB.
|
||||
if (exploreSettings.libraryOnly) return nothing;
|
||||
|
||||
const hasTracks = !this.loadingTracks && this.topTracks.length > 0;
|
||||
const hasReleases = !this.loadingTopReleases && this.topReleaseGroups.length > 0;
|
||||
const tracksLoading = this.loadingTracks;
|
||||
@@ -2362,10 +2336,8 @@ export class ExploreArtistDetails extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
// Library-only mode: show all library-matching similar artists
|
||||
// (up to the 20 stored per seed). Online mode: cap at 10 to
|
||||
// avoid a very long list.
|
||||
const maxSimilar = exploreSettings.libraryOnly ? 20 : 10;
|
||||
// Cap the similar-artists list at 10 to avoid a very long list.
|
||||
const maxSimilar = 10;
|
||||
const artists = this.similarArtists.slice(0, maxSimilar);
|
||||
const showToggle = artists.length > this.discoRowSize;
|
||||
const collapsed = !this.similarExpanded && showToggle;
|
||||
|
||||
@@ -869,10 +869,10 @@ export class ExploreView extends LitElement {
|
||||
* Wails call. Called after search results are set.
|
||||
*/
|
||||
/**
|
||||
* Seed the thumbnail cache from local library data only. Safe
|
||||
* to call in library-only mode — does no API calls. Reads from
|
||||
* cachedAlbums (by MBID) and from any `_coverArt` underscore
|
||||
* field that searchLibraryCache stamped on the release group.
|
||||
* Seed the thumbnail cache from local library data only — does
|
||||
* no API calls. Reads from cachedAlbums (by MBID) and from any
|
||||
* `_coverArt` underscore field that searchLibraryCache stamped
|
||||
* on the release group.
|
||||
*/
|
||||
private seedThumbnailsFromLibrary() {
|
||||
if (!this.results?.releaseGroups?.length) return;
|
||||
@@ -906,8 +906,8 @@ export class ExploreView extends LitElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the artist image cache from local library data only.
|
||||
* Safe to call in library-only mode. Reads from cachedArtists
|
||||
* Seed the artist image cache from local library data only —
|
||||
* does no API calls. Reads from cachedArtists
|
||||
* by MBID and from any `_imageMedium`/`_imageSmall` underscore
|
||||
* field that searchLibraryCache stamped on the artist. Falls
|
||||
* back to library album art when an artist has no portrait.
|
||||
|
||||
@@ -3,21 +3,20 @@ import { customElement, state, query } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import {
|
||||
GetLibraryDirectory,
|
||||
SetLibraryDirectory,
|
||||
} from '@go/config/Config';
|
||||
AddLibrary,
|
||||
GetAllLibrariesWithTrackCounts,
|
||||
} from '@go/library/Library';
|
||||
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
||||
|
||||
/**
|
||||
* First-run setup wizard.
|
||||
*
|
||||
* On startup it checks whether a library directory has already been
|
||||
* configured. If one exists the wizard stays hidden and the app
|
||||
* proceeds as normal. If none is set (fresh install), it presents a
|
||||
* non-dismissable modal prompting the user to pick their music folder,
|
||||
* saves it to the config, and dismisses itself. Saving the directory
|
||||
* emits LibraryConfigChanged on the backend, which kicks off the
|
||||
* initial scan automatically.
|
||||
* On startup it checks whether any library has already been registered.
|
||||
* If one exists the wizard stays hidden and the app proceeds as normal.
|
||||
* If there are none (fresh install), it presents a non-dismissable modal
|
||||
* prompting the user to pick their music folder, registers it through the
|
||||
* library CRUD API, and dismisses itself. AddLibrary emits LibraryAdded
|
||||
* and kicks off the initial scan automatically.
|
||||
*/
|
||||
@customElement('first-run-wizard')
|
||||
export class FirstRunWizard extends LitElement {
|
||||
@@ -40,13 +39,13 @@ export class FirstRunWizard extends LitElement {
|
||||
super.connectedCallback();
|
||||
|
||||
try {
|
||||
const existing = await GetLibraryDirectory();
|
||||
const existing = await GetAllLibrariesWithTrackCounts();
|
||||
|
||||
// A configured directory means setup is already complete.
|
||||
if (existing) return;
|
||||
// An existing library means setup is already complete.
|
||||
if (existing && existing.length > 0) return;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'First-run wizard: failed to read library directory:',
|
||||
'First-run wizard: failed to read libraries:',
|
||||
err,
|
||||
);
|
||||
|
||||
@@ -249,7 +248,7 @@ export class FirstRunWizard extends LitElement {
|
||||
this.errorMessage = '';
|
||||
|
||||
try {
|
||||
await SetLibraryDirectory(this.selectedDirectory);
|
||||
await AddLibrary(this.selectedDirectory);
|
||||
|
||||
this.finished = true;
|
||||
|
||||
@@ -257,8 +256,8 @@ export class FirstRunWizard extends LitElement {
|
||||
|
||||
this.active = false;
|
||||
} catch (err) {
|
||||
this.errorMessage = `Could not save the folder: ${err}`;
|
||||
console.error('First-run wizard: save failed:', err);
|
||||
this.errorMessage = `Could not add the folder: ${err}`;
|
||||
console.error('First-run wizard: add library failed:', err);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { designTokens } from '../../styles/tokens.css';
|
||||
|
||||
import type { DragActiveDetail } from '@utils/drag-controller';
|
||||
|
||||
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'autotag' | 'jobs' | 'settings';
|
||||
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'wanted' | 'autotag' | 'jobs' | 'settings';
|
||||
|
||||
interface NavItem {
|
||||
id: View;
|
||||
@@ -149,6 +149,7 @@ export class AppSidebar extends LitElement {
|
||||
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
|
||||
{ id: 'tracks', label: 'Tracks', icon: 'music' },
|
||||
{ id: 'explore', label: 'Explore', icon: 'globe' },
|
||||
{ id: 'wanted', label: 'Wanted', icon: 'bookmark' },
|
||||
{ id: 'autotag', label: 'Autotag', icon: 'tag' },
|
||||
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
|
||||
{ id: 'settings', label: 'Settings', icon: 'gear' },
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { downloadStore } from '@store/download-store';
|
||||
import type { Want, WantSummary } from '@store/download-store';
|
||||
import { libraryStore } from '@store/library-store';
|
||||
|
||||
/**
|
||||
* The wanted list: music the user has said they want but does not have.
|
||||
*
|
||||
* The list is the durable thing here, not the downloads it produces.
|
||||
* Something unfindable today stays on the list and is retried on a
|
||||
* backoff, so this view is mostly about making the waiting legible —
|
||||
* what is being looked for, when it was last tried, and why it has not
|
||||
* turned up. A row is not a failure just because it is still here.
|
||||
*/
|
||||
@customElement('wanted-view')
|
||||
export class WantedView extends LitElement {
|
||||
@state() private wants: Want[] = [];
|
||||
|
||||
@state() private checking = false;
|
||||
|
||||
@state() private lastSummary: WantSummary | null = null;
|
||||
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0 0 20px;
|
||||
font-size: 13px;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 24px 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--yj-bg-surface, #181818);
|
||||
}
|
||||
|
||||
.row + .row {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.detail {
|
||||
font-size: 12px;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
font-size: 12px;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.unsubscribe = downloadStore.subscribe(() => {
|
||||
this.wants = downloadStore.wants;
|
||||
});
|
||||
|
||||
void downloadStore.init().then(() => {
|
||||
this.wants = downloadStore.wants;
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const subscriptions = this.wants.filter((w) => w.entity === 'artist');
|
||||
const wanted = this.wants.filter(
|
||||
(w) => w.entity !== 'artist' && w.state === 'wanted',
|
||||
);
|
||||
const paused = this.wants.filter((w) => w.state === 'paused');
|
||||
const satisfied = this.wants.filter((w) => w.state === 'satisfied');
|
||||
|
||||
return html`
|
||||
<header>
|
||||
<h1>Wanted</h1>
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="outlined"
|
||||
?disabled=${this.checking}
|
||||
@click=${() => void this.checkNow()}
|
||||
>
|
||||
<wa-icon slot="start" name="rotate"></wa-icon>
|
||||
${this.checking ? 'Checking…' : 'Check now'}
|
||||
</wa-button>
|
||||
${satisfied.length > 0
|
||||
? html`
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
@click=${() =>
|
||||
void downloadStore.clearSatisfiedWants()}
|
||||
>
|
||||
Clear found
|
||||
</wa-button>
|
||||
`
|
||||
: nothing}
|
||||
</header>
|
||||
|
||||
<p class="subtitle">
|
||||
Music you want but do not have. Anything that cannot be found
|
||||
stays here and is looked for again later.
|
||||
</p>
|
||||
|
||||
${this.renderSummary()}
|
||||
${this.wants.length === 0 ? this.renderEmpty() : nothing}
|
||||
${this.renderSection(
|
||||
'Following',
|
||||
subscriptions,
|
||||
(w) => this.renderSubscription(w),
|
||||
)}
|
||||
${this.renderSection('Looking for', wanted, (w) => this.renderWant(w))}
|
||||
${this.renderSection('Paused', paused, (w) => this.renderWant(w))}
|
||||
${this.renderSection('Found', satisfied, (w) => this.renderWant(w))}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEmpty() {
|
||||
return html`
|
||||
<div class="empty">
|
||||
Nothing wanted yet. Use “Want this” on an album or artist to
|
||||
add it here.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSummary() {
|
||||
if (!this.lastSummary) return nothing;
|
||||
|
||||
const s = this.lastSummary;
|
||||
|
||||
const parts = [
|
||||
s.expanded > 0 ? `${s.expanded} new album${s.expanded === 1 ? '' : 's'} found` : '',
|
||||
s.satisfied > 0 ? `${s.satisfied} already owned` : '',
|
||||
s.started > 0 ? `${s.started} downloading` : '',
|
||||
s.attempted > 0 ? `${s.attempted} searched for` : '',
|
||||
].filter(Boolean);
|
||||
|
||||
return html`
|
||||
<p class="summary">
|
||||
${parts.length > 0 ? parts.join(' · ') : 'Nothing new this time.'}
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSection(
|
||||
title: string,
|
||||
items: Want[],
|
||||
renderer: (want: Want) => unknown,
|
||||
) {
|
||||
if (items.length === 0) return nothing;
|
||||
|
||||
return html`
|
||||
<h2>${title}</h2>
|
||||
${items.map((want) => renderer(want))}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* An artist row is a subscription, not a queued download, so it
|
||||
* shows what it covers rather than a retry count — the albums it
|
||||
* produced appear in their own section.
|
||||
*/
|
||||
private renderSubscription(want: Want) {
|
||||
return html`
|
||||
<div class="row">
|
||||
<wa-icon name="user-group"></wa-icon>
|
||||
<div class="row-main">
|
||||
<div class="title">${want.artist || want.title || want.mbid}</div>
|
||||
<div class="detail">
|
||||
${want.scope === 'all'
|
||||
? 'Whole discography, plus new releases'
|
||||
: 'New releases only'}
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge">Following</span>
|
||||
<div class="actions">
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
@click=${() => void this.toggleScope(want)}
|
||||
>
|
||||
${want.scope === 'all' ? 'New only' : 'Everything'}
|
||||
</wa-button>
|
||||
${this.renderRemove(want)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWant(want: Want) {
|
||||
return html`
|
||||
<div class="row">
|
||||
<wa-icon
|
||||
name=${want.entity === 'recording' ? 'music' : 'compact-disc'}
|
||||
></wa-icon>
|
||||
<div class="row-main">
|
||||
<div class="title">
|
||||
${want.artist ? `${want.artist} — ` : ''}${want.title ||
|
||||
want.mbid}
|
||||
</div>
|
||||
<div class="detail">${wantDetail(want)}</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
${want.state === 'satisfied'
|
||||
? nothing
|
||||
: html`
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
@click=${() =>
|
||||
void downloadStore.pauseWant(
|
||||
want.id,
|
||||
want.state !== 'paused',
|
||||
)}
|
||||
>
|
||||
${want.state === 'paused' ? 'Resume' : 'Pause'}
|
||||
</wa-button>
|
||||
`}
|
||||
${this.renderRemove(want)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRemove(want: Want) {
|
||||
return html`
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
@click=${() => void downloadStore.removeWant(want.id)}
|
||||
>
|
||||
<wa-icon name="xmark"></wa-icon>
|
||||
</wa-button>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Widens or narrows what an artist subscription covers. */
|
||||
private async toggleScope(want: Want): Promise<void> {
|
||||
try {
|
||||
await downloadStore.addWant({
|
||||
mbid: want.mbid,
|
||||
entity: 'artist',
|
||||
libraryId: want.libraryId || (libraryStore.getSelectedLibraryId() ?? 0),
|
||||
artist: want.artist,
|
||||
title: want.title,
|
||||
scope: want.scope === 'all' ? 'future' : 'all',
|
||||
secondary: want.secondary,
|
||||
} as never);
|
||||
} catch (err) {
|
||||
console.error('Could not change what this subscription covers:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private async checkNow(): Promise<void> {
|
||||
this.checking = true;
|
||||
|
||||
try {
|
||||
this.lastSummary = await downloadStore.reconcileWanted();
|
||||
} catch (err) {
|
||||
console.error('Could not check the wanted list:', err);
|
||||
} finally {
|
||||
this.checking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The second line of a want row: what is happening, in the user's terms.
|
||||
*
|
||||
* A want that has been tried and not found is reported as still being
|
||||
* looked for rather than as an error, because that is what it is — the
|
||||
* retry is already scheduled and there is nothing for the user to do.
|
||||
*/
|
||||
function wantDetail(want: Want): string {
|
||||
if (want.state === 'satisfied') return 'In your library';
|
||||
if (want.state === 'paused') return 'Paused';
|
||||
|
||||
if (want.attempts === 0) return 'Not looked for yet';
|
||||
|
||||
const reason = want.lastError ? ` — ${want.lastError}` : '';
|
||||
|
||||
return `Looked for ${want.attempts} time${want.attempts === 1 ? '' : 's'}${reason}`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'wanted-view': WantedView;
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,9 @@ export const Events = {
|
||||
ArtistDiscographyReady: "ArtistDiscographyReady",
|
||||
ArtistSimilarReady: "ArtistSimilarReady",
|
||||
AlbumReleasesReady: "AlbumReleasesReady",
|
||||
DownloadProvidersChanged: "DownloadProvidersChanged",
|
||||
DownloadsChanged: "DownloadsChanged",
|
||||
WantedListChanged: "WantedListChanged",
|
||||
} as const;
|
||||
|
||||
export type EventName = (typeof Events)[keyof typeof Events];
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import {
|
||||
AddProvider,
|
||||
AddWant,
|
||||
Cancel,
|
||||
Candidates,
|
||||
ClearFinished,
|
||||
ClearSatisfiedWants,
|
||||
DeleteProvider,
|
||||
ImportExternalWants,
|
||||
ListProviders,
|
||||
ListRequests,
|
||||
ListWants,
|
||||
PauseWant,
|
||||
Pick,
|
||||
ProviderKinds,
|
||||
ReconcileWanted,
|
||||
RemoveWant,
|
||||
Start,
|
||||
TestProvider,
|
||||
UpdateProvider,
|
||||
} from '@go/download/Service';
|
||||
import type { download } from '@go/models';
|
||||
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 ProviderField = download.Field;
|
||||
export type Want = download.Want;
|
||||
export type WantSummary = 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.
|
||||
*/
|
||||
export type WantEntity = '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.
|
||||
*/
|
||||
export type WantState = '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';
|
||||
|
||||
/** Lifecycle states a request can be in. Mirrors backend/download.State. */
|
||||
export type DownloadState =
|
||||
| 'searching'
|
||||
| 'found'
|
||||
| 'queued'
|
||||
| 'grabbing'
|
||||
| 'verifying'
|
||||
| 'tagging'
|
||||
| 'importing'
|
||||
| 'complete'
|
||||
| 'cancelled'
|
||||
| 'failed';
|
||||
|
||||
type Subscriber = () => void;
|
||||
|
||||
const TERMINAL_STATES: ReadonlySet<string> = new Set([
|
||||
'complete',
|
||||
'cancelled',
|
||||
'failed',
|
||||
]);
|
||||
|
||||
export function isRequestTerminal(request: DownloadRequest): boolean {
|
||||
return TERMINAL_STATES.has(request.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label for a request state. Kept here rather than in the
|
||||
* components so the downloads list and the picker never disagree about
|
||||
* what a state is called.
|
||||
*/
|
||||
export function stateLabel(state: string): string {
|
||||
switch (state) {
|
||||
case 'searching':
|
||||
return 'Searching';
|
||||
case 'found':
|
||||
return 'Waiting for you to choose';
|
||||
case 'queued':
|
||||
return 'Queued';
|
||||
case 'grabbing':
|
||||
return 'Downloading';
|
||||
case 'verifying':
|
||||
return 'Verifying';
|
||||
case 'tagging':
|
||||
return 'Tagging';
|
||||
case 'importing':
|
||||
return 'Importing';
|
||||
case 'complete':
|
||||
return 'Complete';
|
||||
case 'cancelled':
|
||||
return 'Cancelled';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a 0..1 score as a percentage for display.
|
||||
*/
|
||||
export function scorePercent(score: number): string {
|
||||
return `${Math.round(score * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes why a candidate ranks where it does, in the user's terms.
|
||||
*
|
||||
* Match and quality are reported separately on purpose: a perfect match
|
||||
* at low bitrate and a great-sounding copy of the wrong album are
|
||||
* different problems, and only the user knows which they will accept.
|
||||
*/
|
||||
export function candidateSummary(candidate: DownloadCandidate): string {
|
||||
const audio = (candidate.files ?? []).filter((f) => f.isAudio);
|
||||
const formats = new Set(audio.map((f) => f.format).filter(Boolean));
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
const [onlyFormat] = [...formats];
|
||||
|
||||
if (formats.size === 1 && onlyFormat) {
|
||||
parts.push(onlyFormat.toUpperCase());
|
||||
} else if (formats.size > 1) {
|
||||
parts.push('Mixed formats');
|
||||
}
|
||||
|
||||
if (audio.length > 0) {
|
||||
parts.push(`${audio.length} track${audio.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
|
||||
if (candidate.totalSize > 0) {
|
||||
parts.push(formatBytes(candidate.totalSize));
|
||||
}
|
||||
|
||||
if (candidate.origin) {
|
||||
parts.push(candidate.origin);
|
||||
}
|
||||
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes <= 0) return '';
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
|
||||
return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive singleton for the download subsystem.
|
||||
*
|
||||
* 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
|
||||
* asked to choose between.
|
||||
*/
|
||||
class DownloadStore {
|
||||
private providersValue: DownloadProvider[] = [];
|
||||
|
||||
private descriptorsValue: DownloadDescriptor[] = [];
|
||||
|
||||
private requestsValue: DownloadRequest[] = [];
|
||||
|
||||
private wantsValue: Want[] = [];
|
||||
|
||||
private subscribers = new Set<Subscriber>();
|
||||
|
||||
private notifyScheduled = false;
|
||||
|
||||
private initialized = false;
|
||||
|
||||
constructor() {
|
||||
EventsOn(Events.DownloadProvidersChanged, () => {
|
||||
void this.refreshProviders();
|
||||
});
|
||||
|
||||
EventsOn(Events.DownloadsChanged, () => {
|
||||
void this.refreshRequests();
|
||||
});
|
||||
|
||||
// The wanted 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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads providers and requests once. Safe to call from every
|
||||
* component's connectedCallback — subsequent calls are no-ops.
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
await Promise.all([
|
||||
this.refreshDescriptors(),
|
||||
this.refreshProviders(),
|
||||
this.refreshRequests(),
|
||||
this.refreshWants(),
|
||||
]);
|
||||
}
|
||||
|
||||
get providers(): DownloadProvider[] {
|
||||
return this.providersValue;
|
||||
}
|
||||
|
||||
/** Providers the user has switched on. */
|
||||
get enabledProviders(): DownloadProvider[] {
|
||||
return this.providersValue.filter((p) => p.enabled);
|
||||
}
|
||||
|
||||
/** Provider types available to add. */
|
||||
get descriptors(): DownloadDescriptor[] {
|
||||
return this.descriptorsValue;
|
||||
}
|
||||
|
||||
get requests(): DownloadRequest[] {
|
||||
return this.requestsValue;
|
||||
}
|
||||
|
||||
get activeRequests(): DownloadRequest[] {
|
||||
return this.requestsValue.filter((r) => !isRequestTerminal(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when at least one provider is configured and enabled. The UI
|
||||
* uses this to decide whether to offer downloading at all, rather
|
||||
* than letting the user start a search that cannot succeed.
|
||||
*/
|
||||
get available(): boolean {
|
||||
return this.enabledProviders.length > 0;
|
||||
}
|
||||
|
||||
subscribe(callback: Subscriber): () => void {
|
||||
this.subscribers.add(callback);
|
||||
|
||||
return () => this.subscribers.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesces notifications into one microtask so a burst of refreshes
|
||||
* causes a single render pass.
|
||||
*/
|
||||
private notify(): void {
|
||||
if (this.notifyScheduled) return;
|
||||
|
||||
this.notifyScheduled = true;
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.notifyScheduled = false;
|
||||
this.subscribers.forEach((callback) => callback());
|
||||
});
|
||||
}
|
||||
|
||||
async refreshDescriptors(): Promise<void> {
|
||||
try {
|
||||
this.descriptorsValue = (await ProviderKinds()) ?? [];
|
||||
this.notify();
|
||||
} catch (err) {
|
||||
console.error('Failed to load download client types:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshProviders(): Promise<void> {
|
||||
try {
|
||||
this.providersValue = (await ListProviders()) ?? [];
|
||||
this.notify();
|
||||
} catch (err) {
|
||||
console.error('Failed to load download clients:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshRequests(): Promise<void> {
|
||||
try {
|
||||
this.requestsValue = (await ListRequests(50)) ?? [];
|
||||
this.notify();
|
||||
} catch (err) {
|
||||
console.error('Failed to load downloads:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Provider configuration
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
async addProvider(
|
||||
kind: string,
|
||||
name: string,
|
||||
settings: Record<string, string>,
|
||||
): Promise<number> {
|
||||
const id = await AddProvider(kind, name, settings);
|
||||
|
||||
await this.refreshProviders();
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
async updateProvider(
|
||||
id: number,
|
||||
name: string,
|
||||
enabled: boolean,
|
||||
priority: number,
|
||||
settings: Record<string, string>,
|
||||
): Promise<void> {
|
||||
await UpdateProvider(id, name, enabled, priority, settings);
|
||||
await this.refreshProviders();
|
||||
}
|
||||
|
||||
async deleteProvider(id: number): Promise<void> {
|
||||
await DeleteProvider(id);
|
||||
await this.refreshProviders();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a provider's connection. Resolves on success and rejects
|
||||
* with the backend's message, which is what the settings page
|
||||
* shows — these errors are the user's main debugging tool for a
|
||||
* misconfigured client.
|
||||
*/
|
||||
async testProvider(id: number): Promise<void> {
|
||||
await TestProvider(id);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Requests
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Starts a download. Returns the ranked candidates plus whether the
|
||||
* pipeline already picked one, so the caller knows whether to open
|
||||
* the picker or just show progress.
|
||||
*/
|
||||
async start(request: download.SearchRequest): Promise<download.StartResult> {
|
||||
const result = await Start(request);
|
||||
|
||||
await this.refreshRequests();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async pick(requestId: string, candidateId: string): Promise<void> {
|
||||
await Pick(requestId, candidateId);
|
||||
await this.refreshRequests();
|
||||
}
|
||||
|
||||
async cancel(requestId: string): Promise<void> {
|
||||
await Cancel(requestId);
|
||||
await this.refreshRequests();
|
||||
}
|
||||
|
||||
async candidates(requestId: string): Promise<DownloadCandidate[]> {
|
||||
return (await Candidates(requestId)) ?? [];
|
||||
}
|
||||
|
||||
async clearFinished(): Promise<void> {
|
||||
await ClearFinished();
|
||||
await this.refreshRequests();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Wanted list
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
get wants(): Want[] {
|
||||
return this.wantsValue;
|
||||
}
|
||||
|
||||
/** Wants still being looked for. */
|
||||
get activeWants(): Want[] {
|
||||
return this.wantsValue.filter((w) => w.state === 'wanted');
|
||||
}
|
||||
|
||||
/** Artist subscriptions, which expand rather than download. */
|
||||
get subscriptions(): Want[] {
|
||||
return this.wantsValue.filter((w) => w.entity === 'artist');
|
||||
}
|
||||
|
||||
async refreshWants(): Promise<void> {
|
||||
try {
|
||||
this.wantsValue = (await ListWants()) ?? [];
|
||||
this.notify();
|
||||
} catch (err) {
|
||||
console.error('Failed to load the wanted list:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** True when this MBID is already on the list. */
|
||||
isWanted(mbid: string): boolean {
|
||||
const needle = mbid.trim().toLowerCase();
|
||||
|
||||
return this.wantsValue.some((w) => w.mbid === needle);
|
||||
}
|
||||
|
||||
/** The want for an MBID, if it is on the list. */
|
||||
wantFor(mbid: string): Want | undefined {
|
||||
const needle = mbid.trim().toLowerCase();
|
||||
|
||||
return this.wantsValue.find((w) => w.mbid === needle);
|
||||
}
|
||||
|
||||
async addWant(want: download.WantRequest): Promise<number> {
|
||||
const id = await AddWant(want);
|
||||
|
||||
await this.refreshWants();
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
async removeWant(id: number): Promise<void> {
|
||||
await RemoveWant(id);
|
||||
await this.refreshWants();
|
||||
}
|
||||
|
||||
async pauseWant(id: number, paused: boolean): Promise<void> {
|
||||
await PauseWant(id, paused);
|
||||
await this.refreshWants();
|
||||
}
|
||||
|
||||
async clearSatisfiedWants(): Promise<void> {
|
||||
await ClearSatisfiedWants();
|
||||
await this.refreshWants();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a reconcile pass now, for the "check now" button. Resolves
|
||||
* 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();
|
||||
|
||||
await Promise.all([this.refreshWants(), this.refreshRequests()]);
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Adopts a provider's own list, e.g. Lidarr's monitored artists. */
|
||||
async importExternalWants(
|
||||
providerId: number,
|
||||
libraryId: number,
|
||||
): Promise<number> {
|
||||
const count = await ImportExternalWants(providerId, libraryId);
|
||||
|
||||
await this.refreshWants();
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
export const downloadStore = new DownloadStore();
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* ExploreSettingsStore — global settings for the explore feature.
|
||||
* Persists to localStorage so the toggle state survives restarts.
|
||||
*/
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class ExploreSettingsStore {
|
||||
private _libraryOnly: boolean;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
constructor() {
|
||||
this._libraryOnly = localStorage.getItem('explore:libraryOnly') === 'true';
|
||||
}
|
||||
|
||||
get libraryOnly(): boolean {
|
||||
return this._libraryOnly;
|
||||
}
|
||||
|
||||
setLibraryOnly(value: boolean) {
|
||||
if (this._libraryOnly === value) return;
|
||||
this._libraryOnly = value;
|
||||
localStorage.setItem('explore:libraryOnly', String(value));
|
||||
this.notify();
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.setLibraryOnly(!this._libraryOnly);
|
||||
}
|
||||
|
||||
subscribe(fn: Listener): () => void {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
private notify() {
|
||||
for (const fn of this.listeners) fn();
|
||||
}
|
||||
}
|
||||
|
||||
export const exploreSettings = new ExploreSettingsStore();
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {download} from '../models';
|
||||
import {context} from '../models';
|
||||
|
||||
export function AddProvider(arg1:string,arg2:string,arg3:Record<string, string>):Promise<number>;
|
||||
|
||||
export function AddWant(arg1:download.WantRequest):Promise<number>;
|
||||
|
||||
export function Cancel(arg1:string):Promise<void>;
|
||||
|
||||
export function Candidates(arg1:string):Promise<Array<download.Candidate>>;
|
||||
|
||||
export function ClearFinished():Promise<void>;
|
||||
|
||||
export function ClearSatisfiedWants():Promise<void>;
|
||||
|
||||
export function DeleteProvider(arg1:number):Promise<void>;
|
||||
|
||||
export function ImportExternalWants(arg1:number,arg2:number):Promise<number>;
|
||||
|
||||
export function IsWanted(arg1:string,arg2:number):Promise<boolean>;
|
||||
|
||||
export function ListProviders():Promise<Array<download.Config>>;
|
||||
|
||||
export function ListRequests(arg1:number):Promise<Array<download.RequestView>>;
|
||||
|
||||
export function ListWants():Promise<Array<download.Want>>;
|
||||
|
||||
export function PauseWant(arg1:number,arg2:boolean):Promise<void>;
|
||||
|
||||
export function Pick(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function ProviderKinds():Promise<Array<download.Descriptor>>;
|
||||
|
||||
export function ReconcileWanted():Promise<download.Summary>;
|
||||
|
||||
export function RemoveWant(arg1:number):Promise<void>;
|
||||
|
||||
export function SetContext(arg1:context.Context):Promise<void>;
|
||||
|
||||
export function SetReconciler(arg1:download.Reconciler):Promise<void>;
|
||||
|
||||
export function Start(arg1:download.SearchRequest):Promise<download.StartResult>;
|
||||
|
||||
export function TestProvider(arg1:number):Promise<void>;
|
||||
|
||||
export function UpdateProvider(arg1:number,arg2:string,arg3:boolean,arg4:number,arg5:Record<string, string>):Promise<void>;
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
// @ts-check
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function AddProvider(arg1, arg2, arg3) {
|
||||
return window['go']['download']['Service']['AddProvider'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function AddWant(arg1) {
|
||||
return window['go']['download']['Service']['AddWant'](arg1);
|
||||
}
|
||||
|
||||
export function Cancel(arg1) {
|
||||
return window['go']['download']['Service']['Cancel'](arg1);
|
||||
}
|
||||
|
||||
export function Candidates(arg1) {
|
||||
return window['go']['download']['Service']['Candidates'](arg1);
|
||||
}
|
||||
|
||||
export function ClearFinished() {
|
||||
return window['go']['download']['Service']['ClearFinished']();
|
||||
}
|
||||
|
||||
export function ClearSatisfiedWants() {
|
||||
return window['go']['download']['Service']['ClearSatisfiedWants']();
|
||||
}
|
||||
|
||||
export function DeleteProvider(arg1) {
|
||||
return window['go']['download']['Service']['DeleteProvider'](arg1);
|
||||
}
|
||||
|
||||
export function ImportExternalWants(arg1, arg2) {
|
||||
return window['go']['download']['Service']['ImportExternalWants'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function IsWanted(arg1, arg2) {
|
||||
return window['go']['download']['Service']['IsWanted'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ListProviders() {
|
||||
return window['go']['download']['Service']['ListProviders']();
|
||||
}
|
||||
|
||||
export function ListRequests(arg1) {
|
||||
return window['go']['download']['Service']['ListRequests'](arg1);
|
||||
}
|
||||
|
||||
export function ListWants() {
|
||||
return window['go']['download']['Service']['ListWants']();
|
||||
}
|
||||
|
||||
export function PauseWant(arg1, arg2) {
|
||||
return window['go']['download']['Service']['PauseWant'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function Pick(arg1, arg2) {
|
||||
return window['go']['download']['Service']['Pick'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ProviderKinds() {
|
||||
return window['go']['download']['Service']['ProviderKinds']();
|
||||
}
|
||||
|
||||
export function ReconcileWanted() {
|
||||
return window['go']['download']['Service']['ReconcileWanted']();
|
||||
}
|
||||
|
||||
export function RemoveWant(arg1) {
|
||||
return window['go']['download']['Service']['RemoveWant'](arg1);
|
||||
}
|
||||
|
||||
export function SetContext(arg1) {
|
||||
return window['go']['download']['Service']['SetContext'](arg1);
|
||||
}
|
||||
|
||||
export function SetReconciler(arg1) {
|
||||
return window['go']['download']['Service']['SetReconciler'](arg1);
|
||||
}
|
||||
|
||||
export function Start(arg1) {
|
||||
return window['go']['download']['Service']['Start'](arg1);
|
||||
}
|
||||
|
||||
export function TestProvider(arg1) {
|
||||
return window['go']['download']['Service']['TestProvider'](arg1);
|
||||
}
|
||||
|
||||
export function UpdateProvider(arg1, arg2, arg3, arg4, arg5) {
|
||||
return window['go']['download']['Service']['UpdateProvider'](arg1, arg2, arg3, arg4, arg5);
|
||||
}
|
||||
+13
@@ -1,6 +1,7 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {explore} from '../models';
|
||||
import {time} from '../models';
|
||||
import {context} from '../models';
|
||||
import {jobs} from '../models';
|
||||
|
||||
@@ -18,6 +19,8 @@ export function CAALimiter():Promise<explore.RateLimiter>;
|
||||
|
||||
export function CheckLibraryMBIDs(arg1:Array<string>):Promise<Record<string, string>>;
|
||||
|
||||
export function CoreCatalogImported():Promise<boolean>;
|
||||
|
||||
export function CoverArtGroupURL(arg1:string):Promise<string>;
|
||||
|
||||
export function CoverArtURL(arg1:string):Promise<string>;
|
||||
@@ -52,6 +55,12 @@ export function GetTrackThumbnail(arg1:string,arg2:string,arg3:string,arg4:strin
|
||||
|
||||
export function GetTrackThumbnails(arg1:Array<explore.TrackThumbnailRequest>):Promise<Record<string, string>>;
|
||||
|
||||
export function IndexBaselineSeries():Promise<number>;
|
||||
|
||||
export function IndexImportComplete():Promise<boolean>;
|
||||
|
||||
export function IndexLastImported():Promise<time.Time>;
|
||||
|
||||
export function InvalidateIndexDiscographies():Promise<void>;
|
||||
|
||||
export function InvalidateLibrarySync():Promise<void>;
|
||||
@@ -70,12 +79,16 @@ export function PopulateLocalCrossReferencesIfNeeded():Promise<void>;
|
||||
|
||||
export function PrefetchReleases(arg1:Array<string>):Promise<void>;
|
||||
|
||||
export function PrepareIndexRebuild():Promise<void>;
|
||||
|
||||
export function RebuildLyricsIndex():Promise<void>;
|
||||
|
||||
export function RebuildLyricsIndexIfNeeded():Promise<void>;
|
||||
|
||||
export function RecordSearchClick(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||
|
||||
export function RefreshIndexNow(arg1:time.Duration):Promise<void>;
|
||||
|
||||
export function RefreshListenCounts():Promise<void>;
|
||||
|
||||
export function ResolveReleaseGroupMBIDs(arg1:Array<string>):Promise<Record<string, string>>;
|
||||
|
||||
@@ -30,6 +30,10 @@ export function CheckLibraryMBIDs(arg1) {
|
||||
return window['go']['explore']['Service']['CheckLibraryMBIDs'](arg1);
|
||||
}
|
||||
|
||||
export function CoreCatalogImported() {
|
||||
return window['go']['explore']['Service']['CoreCatalogImported']();
|
||||
}
|
||||
|
||||
export function CoverArtGroupURL(arg1) {
|
||||
return window['go']['explore']['Service']['CoverArtGroupURL'](arg1);
|
||||
}
|
||||
@@ -98,6 +102,18 @@ export function GetTrackThumbnails(arg1) {
|
||||
return window['go']['explore']['Service']['GetTrackThumbnails'](arg1);
|
||||
}
|
||||
|
||||
export function IndexBaselineSeries() {
|
||||
return window['go']['explore']['Service']['IndexBaselineSeries']();
|
||||
}
|
||||
|
||||
export function IndexImportComplete() {
|
||||
return window['go']['explore']['Service']['IndexImportComplete']();
|
||||
}
|
||||
|
||||
export function IndexLastImported() {
|
||||
return window['go']['explore']['Service']['IndexLastImported']();
|
||||
}
|
||||
|
||||
export function InvalidateIndexDiscographies() {
|
||||
return window['go']['explore']['Service']['InvalidateIndexDiscographies']();
|
||||
}
|
||||
@@ -134,6 +150,10 @@ export function PrefetchReleases(arg1) {
|
||||
return window['go']['explore']['Service']['PrefetchReleases'](arg1);
|
||||
}
|
||||
|
||||
export function PrepareIndexRebuild() {
|
||||
return window['go']['explore']['Service']['PrepareIndexRebuild']();
|
||||
}
|
||||
|
||||
export function RebuildLyricsIndex() {
|
||||
return window['go']['explore']['Service']['RebuildLyricsIndex']();
|
||||
}
|
||||
@@ -146,6 +166,10 @@ export function RecordSearchClick(arg1, arg2, arg3) {
|
||||
return window['go']['explore']['Service']['RecordSearchClick'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function RefreshIndexNow(arg1) {
|
||||
return window['go']['explore']['Service']['RefreshIndexNow'](arg1);
|
||||
}
|
||||
|
||||
export function RefreshListenCounts() {
|
||||
return window['go']['explore']['Service']['RefreshListenCounts']();
|
||||
}
|
||||
|
||||
@@ -287,6 +287,571 @@ export namespace autotagservice {
|
||||
|
||||
}
|
||||
|
||||
export namespace download {
|
||||
|
||||
export class QualityScore {
|
||||
overall: number;
|
||||
formatRank: number;
|
||||
bitrate: number;
|
||||
health: number;
|
||||
priority: number;
|
||||
mixed: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new QualityScore(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.overall = source["overall"];
|
||||
this.formatRank = source["formatRank"];
|
||||
this.bitrate = source["bitrate"];
|
||||
this.health = source["health"];
|
||||
this.priority = source["priority"];
|
||||
this.mixed = source["mixed"];
|
||||
}
|
||||
}
|
||||
export class MatchScore {
|
||||
overall: number;
|
||||
titleFit: number;
|
||||
artistFit: number;
|
||||
albumFit: number;
|
||||
completeness: number;
|
||||
anchored: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new MatchScore(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.overall = source["overall"];
|
||||
this.titleFit = source["titleFit"];
|
||||
this.artistFit = source["artistFit"];
|
||||
this.albumFit = source["albumFit"];
|
||||
this.completeness = source["completeness"];
|
||||
this.anchored = source["anchored"];
|
||||
}
|
||||
}
|
||||
export class CandidateFile {
|
||||
path: string;
|
||||
size: number;
|
||||
format: string;
|
||||
bitrate?: number;
|
||||
isAudio: boolean;
|
||||
matchedTo?: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new CandidateFile(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.path = source["path"];
|
||||
this.size = source["size"];
|
||||
this.format = source["format"];
|
||||
this.bitrate = source["bitrate"];
|
||||
this.isAudio = source["isAudio"];
|
||||
this.matchedTo = source["matchedTo"];
|
||||
}
|
||||
}
|
||||
export class Candidate {
|
||||
id: string;
|
||||
providerId: number;
|
||||
kind: string;
|
||||
protocol: string;
|
||||
title: string;
|
||||
artist?: string;
|
||||
origin?: string;
|
||||
files: CandidateFile[];
|
||||
totalSize: number;
|
||||
health: number;
|
||||
match: MatchScore;
|
||||
quality: QualityScore;
|
||||
score: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Candidate(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.providerId = source["providerId"];
|
||||
this.kind = source["kind"];
|
||||
this.protocol = source["protocol"];
|
||||
this.title = source["title"];
|
||||
this.artist = source["artist"];
|
||||
this.origin = source["origin"];
|
||||
this.files = this.convertValues(source["files"], CandidateFile);
|
||||
this.totalSize = source["totalSize"];
|
||||
this.health = source["health"];
|
||||
this.match = this.convertValues(source["match"], MatchScore);
|
||||
this.quality = this.convertValues(source["quality"], QualityScore);
|
||||
this.score = source["score"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
export class Caps {
|
||||
canSearch: boolean;
|
||||
canTransport: boolean;
|
||||
canDelegate: boolean;
|
||||
canList: boolean;
|
||||
canResume: boolean;
|
||||
canCancel: boolean;
|
||||
reportsSize: boolean;
|
||||
transports: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Caps(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.canSearch = source["canSearch"];
|
||||
this.canTransport = source["canTransport"];
|
||||
this.canDelegate = source["canDelegate"];
|
||||
this.canList = source["canList"];
|
||||
this.canResume = source["canResume"];
|
||||
this.canCancel = source["canCancel"];
|
||||
this.reportsSize = source["reportsSize"];
|
||||
this.transports = source["transports"];
|
||||
}
|
||||
}
|
||||
export class Config {
|
||||
id: number;
|
||||
kind: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
settings: Record<string, string>;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Config(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.kind = source["kind"];
|
||||
this.name = source["name"];
|
||||
this.enabled = source["enabled"];
|
||||
this.priority = source["priority"];
|
||||
this.settings = source["settings"];
|
||||
}
|
||||
}
|
||||
export class Field {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
help?: string;
|
||||
secret: boolean;
|
||||
required: boolean;
|
||||
default?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Field(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.key = source["key"];
|
||||
this.label = source["label"];
|
||||
this.placeholder = source["placeholder"];
|
||||
this.help = source["help"];
|
||||
this.secret = source["secret"];
|
||||
this.required = source["required"];
|
||||
this.default = source["default"];
|
||||
}
|
||||
}
|
||||
export class Descriptor {
|
||||
kind: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
caps: Caps;
|
||||
fields: Field[];
|
||||
requiresExternal?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Descriptor(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.kind = source["kind"];
|
||||
this.name = source["name"];
|
||||
this.summary = source["summary"];
|
||||
this.caps = this.convertValues(source["caps"], Caps);
|
||||
this.fields = this.convertValues(source["fields"], Field);
|
||||
this.requiresExternal = source["requiresExternal"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class ExpectedTrack {
|
||||
position: number;
|
||||
discNumber: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
lengthMillis: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ExpectedTrack(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.position = source["position"];
|
||||
this.discNumber = source["discNumber"];
|
||||
this.title = source["title"];
|
||||
this.artist = source["artist"];
|
||||
this.lengthMillis = source["lengthMillis"];
|
||||
}
|
||||
}
|
||||
|
||||
export class Item {
|
||||
id: string;
|
||||
requestId: string;
|
||||
providerId: number;
|
||||
transportId?: number;
|
||||
externalId?: string;
|
||||
candidate: Candidate;
|
||||
state: string;
|
||||
bytesDone: number;
|
||||
bytesTotal: number;
|
||||
error?: string;
|
||||
createdAt: time.Time;
|
||||
updatedAt: time.Time;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Item(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.requestId = source["requestId"];
|
||||
this.providerId = source["providerId"];
|
||||
this.transportId = source["transportId"];
|
||||
this.externalId = source["externalId"];
|
||||
this.candidate = this.convertValues(source["candidate"], Candidate);
|
||||
this.state = source["state"];
|
||||
this.bytesDone = source["bytesDone"];
|
||||
this.bytesTotal = source["bytesTotal"];
|
||||
this.error = source["error"];
|
||||
this.createdAt = this.convertValues(source["createdAt"], time.Time);
|
||||
this.updatedAt = this.convertValues(source["updatedAt"], time.Time);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class Reconciler {
|
||||
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Reconciler(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
|
||||
}
|
||||
}
|
||||
export class RequestView {
|
||||
id: string;
|
||||
releaseMbid?: string;
|
||||
releaseGroupMbid?: string;
|
||||
recordingMbid?: string;
|
||||
wantId?: number;
|
||||
source?: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
query?: string;
|
||||
expected?: ExpectedTrack[];
|
||||
libraryId: number;
|
||||
createdAt: time.Time;
|
||||
state: string;
|
||||
error?: string;
|
||||
items: Item[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RequestView(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.releaseMbid = source["releaseMbid"];
|
||||
this.releaseGroupMbid = source["releaseGroupMbid"];
|
||||
this.recordingMbid = source["recordingMbid"];
|
||||
this.wantId = source["wantId"];
|
||||
this.source = source["source"];
|
||||
this.artist = source["artist"];
|
||||
this.album = source["album"];
|
||||
this.query = source["query"];
|
||||
this.expected = this.convertValues(source["expected"], ExpectedTrack);
|
||||
this.libraryId = source["libraryId"];
|
||||
this.createdAt = this.convertValues(source["createdAt"], time.Time);
|
||||
this.state = source["state"];
|
||||
this.error = source["error"];
|
||||
this.items = this.convertValues(source["items"], Item);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class SearchRequest {
|
||||
libraryId: number;
|
||||
releaseMbid: string;
|
||||
releaseGroupMbid: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
query: string;
|
||||
expected: ExpectedTrack[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SearchRequest(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.libraryId = source["libraryId"];
|
||||
this.releaseMbid = source["releaseMbid"];
|
||||
this.releaseGroupMbid = source["releaseGroupMbid"];
|
||||
this.artist = source["artist"];
|
||||
this.album = source["album"];
|
||||
this.query = source["query"];
|
||||
this.expected = this.convertValues(source["expected"], ExpectedTrack);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class StartResult {
|
||||
requestId: string;
|
||||
candidates: Candidate[];
|
||||
autoPicked: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new StartResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.requestId = source["requestId"];
|
||||
this.candidates = this.convertValues(source["candidates"], Candidate);
|
||||
this.autoPicked = source["autoPicked"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class Summary {
|
||||
expanded: number;
|
||||
satisfied: number;
|
||||
attempted: number;
|
||||
started: number;
|
||||
synced: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Summary(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.expanded = source["expanded"];
|
||||
this.satisfied = source["satisfied"];
|
||||
this.attempted = source["attempted"];
|
||||
this.started = source["started"];
|
||||
this.synced = source["synced"];
|
||||
}
|
||||
}
|
||||
export class Want {
|
||||
id: number;
|
||||
mbid: string;
|
||||
entity: string;
|
||||
libraryId: number;
|
||||
artist: string;
|
||||
title: string;
|
||||
scope: string;
|
||||
secondary: boolean;
|
||||
state: string;
|
||||
parentId?: number;
|
||||
attempts: number;
|
||||
lastError?: string;
|
||||
lastTriedAt?: time.Time;
|
||||
nextTryAt?: time.Time;
|
||||
externalIds?: Record<string, string>;
|
||||
createdAt: time.Time;
|
||||
updatedAt: time.Time;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Want(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.mbid = source["mbid"];
|
||||
this.entity = source["entity"];
|
||||
this.libraryId = source["libraryId"];
|
||||
this.artist = source["artist"];
|
||||
this.title = source["title"];
|
||||
this.scope = source["scope"];
|
||||
this.secondary = source["secondary"];
|
||||
this.state = source["state"];
|
||||
this.parentId = source["parentId"];
|
||||
this.attempts = source["attempts"];
|
||||
this.lastError = source["lastError"];
|
||||
this.lastTriedAt = this.convertValues(source["lastTriedAt"], time.Time);
|
||||
this.nextTryAt = this.convertValues(source["nextTryAt"], time.Time);
|
||||
this.externalIds = source["externalIds"];
|
||||
this.createdAt = this.convertValues(source["createdAt"], time.Time);
|
||||
this.updatedAt = this.convertValues(source["updatedAt"], time.Time);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class WantRequest {
|
||||
mbid: string;
|
||||
entity: string;
|
||||
libraryId: number;
|
||||
artist: string;
|
||||
title: string;
|
||||
scope: string;
|
||||
secondary: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new WantRequest(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.mbid = source["mbid"];
|
||||
this.entity = source["entity"];
|
||||
this.libraryId = source["libraryId"];
|
||||
this.artist = source["artist"];
|
||||
this.title = source["title"];
|
||||
this.scope = source["scope"];
|
||||
this.secondary = source["secondary"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace explore {
|
||||
|
||||
export class TierStatus {
|
||||
@@ -295,6 +860,7 @@ export namespace explore {
|
||||
total: number;
|
||||
completed: number;
|
||||
error?: string;
|
||||
detail?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new TierStatus(source);
|
||||
@@ -307,6 +873,7 @@ export namespace explore {
|
||||
this.total = source["total"];
|
||||
this.completed = source["completed"];
|
||||
this.error = source["error"];
|
||||
this.detail = source["detail"];
|
||||
}
|
||||
}
|
||||
export class IndexStatus {
|
||||
@@ -1635,8 +2202,7 @@ export namespace sqlcgen {
|
||||
ID: number;
|
||||
Name: string;
|
||||
Path: string;
|
||||
// Go type: time
|
||||
CreatedAt: any;
|
||||
CreatedAt: time.Time;
|
||||
AutotagWarningAcked: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -1648,7 +2214,7 @@ export namespace sqlcgen {
|
||||
this.ID = source["ID"];
|
||||
this.Name = source["Name"];
|
||||
this.Path = source["Path"];
|
||||
this.CreatedAt = this.convertValues(source["CreatedAt"], null);
|
||||
this.CreatedAt = this.convertValues(source["CreatedAt"], time.Time);
|
||||
this.AutotagWarningAcked = source["AutotagWarningAcked"];
|
||||
}
|
||||
|
||||
@@ -1730,6 +2296,23 @@ export namespace tagwriter {
|
||||
|
||||
}
|
||||
|
||||
export namespace time {
|
||||
|
||||
export class Time {
|
||||
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Time(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace tracklist {
|
||||
|
||||
export class Column {
|
||||
|
||||
@@ -12,3 +12,5 @@ export function SetContext(arg1:context.Context):Promise<void>;
|
||||
export function WriteTrackTags(arg1:number,arg2:tagwriter.TagChanges):Promise<void>;
|
||||
|
||||
export function WriteTrackTagsByPath(arg1:string,arg2:tagwriter.TagChanges):Promise<void>;
|
||||
|
||||
export function WriteUntrackedFileTags(arg1:string,arg2:tagwriter.TagChanges):Promise<void>;
|
||||
|
||||
@@ -21,3 +21,7 @@ export function WriteTrackTags(arg1, arg2) {
|
||||
export function WriteTrackTagsByPath(arg1, arg2) {
|
||||
return window['go']['tagwriter']['TagWriter']['WriteTrackTagsByPath'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function WriteUntrackedFileTags(arg1, arg2) {
|
||||
return window['go']['tagwriter']['TagWriter']['WriteUntrackedFileTags'](arg1, arg2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user