Three faults, one subsystem, and the middle one is why a request that looked obviously satisfiable came back refused. **The guardrails were in megabytes, which cannot mean anything.** 300 MB is a generous FLAC single and a suspiciously small boxset, and whoever fills the field in has no idea which release the pipeline will apply it to. `MinKbps`/`MaxKbps`/`PreferredKbps` are the same statement divided by how long the music is, so one number holds across a nine-minute EP and a three-hour opera. The runtime comes from `Download.Expected`, which every anchored request already carries, so this costs no lookup; the rate is audio bytes over that, falling back to the mean stated per-file bitrate when the runtime is unknown. Artwork is excluded from the numerator, or a folder with 30 MB of scans reads as a better rip. An unknown runtime *passes* the window rather than failing it: the window is a statement about quality, and refusing everything the moment MusicBrainz is missing a track length would be a silent embargo. `MaxFileSizeMB` survives as a separate ceiling, still in megabytes on purpose -- it is a question about disk space, and it has to apply to a candidate whose bitrate cannot be worked out at all. **Auto-pick required daylight over the runner-up**, 0.08 on the combined score, and so fired hardest in the case it was never written for: a popular album turns up five *correct* copies, all matching the tracklist at 95%+ and differing only in format and seeders, their scores land within a point of each other, and it refused forever on the grounds that the choice was the user's. It was not. There was no question about what to fetch, only about which copy -- and abundance is the condition under which that matters least. A candidate no longer has to beat the field, only clear the bars on its own terms; where several do, ranking puts the one closest to the preferred bitrate first. That tie-break needed the preference to carry weight or it would have been decorative in a new unit: `BitrateFit` was 0.05 against format's 0.42, so asking for 320 and being handed a FLAC every time was the designed behaviour. When a preference is set the weights shift to fit 0.40 / format 0.20 / bitrate 0.10, taking it off the two heuristics that exist as stand-ins for the preference the user has now given. Health and priority are untouched. And the fit spans 0.5 to 1.0 rather than 0 to 1, so a preference can promote the copy that matches it and can never push the others under `minQuality` -- turning "I like 320" into "never take anything else" silently is what `MinKbps`/`MaxKbps` are for, out loud. **And a refusal quoted numbers that passed.** The request list built its message from `ranked[0]` -- the best candidate *before* the guardrails and before the lead check -- so a request killed by the size window, or by having too many good copies, reported "best of 12 found is not a confident enough match (match 96%, quality 88%)". `AutoPickVeto` names the gate that actually refused, and `AutoPickable` is that returning empty. Existing configs: the old `MinFileSizeMB`/`PreferredFileSizeMB` are not migrated. A number meaning "300 MB" cannot be reinterpreted as a rate without knowing the album it was aimed at, so carrying it over would be inventing an intent nobody expressed. Those two fall back to no window, which is the permissive default and what a fresh install gets; `MaxFileSizeMB` carries over unchanged, because a ceiling on bytes still means exactly what it did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
834 lines
29 KiB
TypeScript
834 lines
29 KiB
TypeScript
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,
|
||
ProviderField,
|
||
} from '@store/download-store';
|
||
import { downloadStore } from '@store/download-store';
|
||
import { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/config.js';
|
||
import { SetPreferences } from '@go/download/service.js';
|
||
import type * as download from '@go/download/models.js';
|
||
import { Format } from '@go/download/models.js';
|
||
import { compact } from '@utils/binding';
|
||
import { describeError, explainError } from '@utils/describe-error';
|
||
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
||
import './config-section';
|
||
import { pickDirectory } from '../../utils/pick-directory';
|
||
|
||
/**
|
||
* Allowed audio formats for auto-download, mirrored from
|
||
* backend/download/types.go's `Format` constants. `FormatUnknown` is
|
||
* deliberately excluded — it names "no format detected", not a format a
|
||
* user could opt into.
|
||
*/
|
||
const AUTO_DOWNLOAD_FORMATS: { value: Format; label: string }[] = [
|
||
{ value: Format.FormatFLAC, label: 'FLAC' },
|
||
{ value: Format.FormatALAC, label: 'ALAC' },
|
||
{ value: Format.FormatWAV, label: 'WAV' },
|
||
{ value: Format.FormatMP3, label: 'MP3' },
|
||
{ value: Format.FormatAAC, label: 'AAC' },
|
||
{ value: Format.FormatOGG, label: 'OGG' },
|
||
{ value: Format.FormatOpus, label: 'Opus' },
|
||
{ value: Format.FormatWMA, label: 'WMA' },
|
||
];
|
||
|
||
/**
|
||
* 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 = '';
|
||
|
||
/** Working copy of the auto-download guardrails. */
|
||
@state()
|
||
private prefs: download.AutoDownloadPrefs = {
|
||
minKbps: 0,
|
||
maxKbps: 0,
|
||
preferredKbps: 0,
|
||
maxSizeMb: 0,
|
||
allowedFormats: [],
|
||
} as download.AutoDownloadPrefs;
|
||
|
||
@state()
|
||
private prefsSaving = false;
|
||
|
||
@state()
|
||
private prefsError = '';
|
||
|
||
@state()
|
||
private prefsSaved = false;
|
||
|
||
private unsubscribe: (() => void) | null = null;
|
||
|
||
override connectedCallback(): void {
|
||
super.connectedCallback();
|
||
|
||
this.unsubscribe = downloadStore.subscribe(() => this.syncFromStore());
|
||
|
||
void downloadStore.init().then(() => this.syncFromStore());
|
||
void this.loadPreferences();
|
||
}
|
||
|
||
override disconnectedCallback(): void {
|
||
super.disconnectedCallback();
|
||
|
||
this.unsubscribe?.();
|
||
this.unsubscribe = null;
|
||
}
|
||
|
||
private syncFromStore(): void {
|
||
this.providers = downloadStore.providers;
|
||
this.descriptors = downloadStore.descriptors;
|
||
}
|
||
|
||
private async loadPreferences(): Promise<void> {
|
||
try {
|
||
this.prefs = await GetDownloadPreferences();
|
||
} catch (err) {
|
||
console.error('Failed to load auto-download preferences:', err);
|
||
}
|
||
}
|
||
|
||
static override styles = [
|
||
designTokens,
|
||
css`
|
||
: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;
|
||
}
|
||
|
||
.field-row {
|
||
display: flex;
|
||
gap: 0.5em;
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.field-row wa-input {
|
||
flex: 1;
|
||
}
|
||
|
||
.field-row .browse-button {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.format-options {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.4em 1em;
|
||
margin-top: 0.4em;
|
||
}
|
||
|
||
.format-option {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4em;
|
||
font-size: 0.9em;
|
||
cursor: pointer;
|
||
}
|
||
`,
|
||
];
|
||
|
||
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>
|
||
|
||
<config-section
|
||
heading="Auto-download preferences"
|
||
description="Guardrails on what the pipeline may grab without asking — a manual pick is never restricted by these, only automatic ones."
|
||
>
|
||
${this.prefsError
|
||
? html`<wa-callout variant="danger">${this.prefsError}</wa-callout>`
|
||
: nothing}
|
||
|
||
<div class="form">
|
||
<!-- Bitrate, not megabytes. A size means nothing
|
||
on its own: 300 MB is a generous single and a
|
||
suspiciously small boxset, and whoever fills
|
||
this in has no idea which release it will be
|
||
applied to. A rate is the same statement
|
||
divided by how long the music is, so one number
|
||
holds across an EP and an opera. -->
|
||
<div class="field-row">
|
||
<wa-input
|
||
label="Minimum bitrate (kbps)"
|
||
type="number"
|
||
min="0"
|
||
placeholder="No minimum"
|
||
.value=${this.prefs.minKbps ? String(this.prefs.minKbps) : ''}
|
||
@input=${(e: Event) => {
|
||
this.prefs = {
|
||
...this.prefs,
|
||
minKbps: Number((e.target as HTMLInputElement).value) || 0,
|
||
};
|
||
}}
|
||
></wa-input>
|
||
<wa-input
|
||
label="Maximum bitrate (kbps)"
|
||
type="number"
|
||
min="0"
|
||
placeholder="No maximum"
|
||
.value=${this.prefs.maxKbps ? String(this.prefs.maxKbps) : ''}
|
||
@input=${(e: Event) => {
|
||
this.prefs = {
|
||
...this.prefs,
|
||
maxKbps: Number((e.target as HTMLInputElement).value) || 0,
|
||
};
|
||
}}
|
||
></wa-input>
|
||
<wa-input
|
||
label="Preferred bitrate (kbps)"
|
||
type="number"
|
||
min="0"
|
||
placeholder="No preference"
|
||
.value=${this.prefs.preferredKbps
|
||
? String(this.prefs.preferredKbps)
|
||
: ''}
|
||
@input=${(e: Event) => {
|
||
this.prefs = {
|
||
...this.prefs,
|
||
preferredKbps:
|
||
Number((e.target as HTMLInputElement).value) || 0,
|
||
};
|
||
}}
|
||
></wa-input>
|
||
</div>
|
||
|
||
<div class="requires">
|
||
320 is the top of MP3; a FLAC rip is usually
|
||
500–1000 depending on the music. Preferred
|
||
decides between copies that are otherwise equally
|
||
good — it never rules one out, which is what the
|
||
minimum and maximum are for.
|
||
</div>
|
||
|
||
<div class="field-row">
|
||
<wa-input
|
||
label="Never grab more than (MB)"
|
||
type="number"
|
||
min="0"
|
||
placeholder="No limit"
|
||
.value=${this.prefs.maxSizeMb ? String(this.prefs.maxSizeMb) : ''}
|
||
@input=${(e: Event) => {
|
||
this.prefs = {
|
||
...this.prefs,
|
||
maxSizeMb: Number((e.target as HTMLInputElement).value) || 0,
|
||
};
|
||
}}
|
||
></wa-input>
|
||
</div>
|
||
|
||
<div class="requires">
|
||
A ceiling on the download itself, in case a
|
||
mislabelled boxset gets through. Still a size
|
||
because it is a question about disk space, and
|
||
because it has to apply to a candidate whose
|
||
bitrate cannot be worked out at all.
|
||
</div>
|
||
|
||
<div>
|
||
<div class="requires">
|
||
Allowed formats — leave all unchecked to allow any format.
|
||
</div>
|
||
<div class="format-options">
|
||
${AUTO_DOWNLOAD_FORMATS.map(
|
||
(format) => html`
|
||
<label class="format-option">
|
||
<input
|
||
type="checkbox"
|
||
.checked=${(this.prefs.allowedFormats ?? []).includes(
|
||
format.value,
|
||
)}
|
||
@change=${(e: Event) =>
|
||
this.toggleFormat(
|
||
format.value,
|
||
(e.target as HTMLInputElement).checked,
|
||
)}
|
||
/>
|
||
${format.label}
|
||
</label>
|
||
`,
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
${this.prefsSaved
|
||
? html`<span class="test-result ok">Saved.</span>`
|
||
: nothing}
|
||
<wa-button
|
||
size="small"
|
||
variant="brand"
|
||
?disabled=${this.prefsSaving}
|
||
@click=${this.savePreferences}
|
||
>
|
||
${this.prefsSaving
|
||
? html`<wa-spinner></wa-spinner>`
|
||
: 'Save preferences'}
|
||
</wa-button>
|
||
</div>
|
||
</div>
|
||
</config-section>
|
||
`;
|
||
}
|
||
|
||
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=${() => void 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}
|
||
@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}
|
||
@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}
|
||
@input=${(e: Event) => {
|
||
this.draftName = (e.target as HTMLInputElement).value;
|
||
}}
|
||
></wa-input>
|
||
|
||
${descriptor ? this.renderFields(descriptor, provider) : nothing}
|
||
|
||
<wa-input
|
||
label="Priority"
|
||
type="number"
|
||
.value=${String(provider.priority)}
|
||
@input=${(e: Event) => {
|
||
this.draft['__priority'] = (e.target as HTMLInputElement).value;
|
||
}}
|
||
></wa-input>
|
||
|
||
<wa-switch
|
||
?checked=${provider.enabled}
|
||
@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, plus a folder browse
|
||
* button for path fields and an "already set" placeholder for
|
||
* secrets the provider already has a stored value for. */
|
||
private renderFields(descriptor: DownloadDescriptor, provider?: DownloadProvider) {
|
||
return (descriptor.fields ?? []).map((field) => {
|
||
const isSet = field.secret && provider?.setSecrets?.[field.key];
|
||
const placeholder = isSet
|
||
? '•••••••• (unchanged — enter a new value to replace it)'
|
||
: (field.placeholder ?? '');
|
||
|
||
return html`
|
||
<div class="field-row">
|
||
<wa-input
|
||
label=${field.label}
|
||
placeholder=${placeholder}
|
||
type=${field.secret ? 'password' : 'text'}
|
||
.value=${this.draft[field.key] ?? ''}
|
||
@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>
|
||
${field.path
|
||
? html`
|
||
<wa-button
|
||
size="small"
|
||
appearance="outlined"
|
||
class="browse-button"
|
||
@click=${() => this.browseForFolder(field)}
|
||
>
|
||
Browse
|
||
</wa-button>
|
||
`
|
||
: nothing}
|
||
</div>
|
||
`;
|
||
});
|
||
}
|
||
|
||
private browseForFolder = async (field: ProviderField) => {
|
||
try {
|
||
const dir = await pickDirectory();
|
||
|
||
if (dir) {
|
||
this.draft = { ...this.draft, [field.key]: dir };
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to open directory picker:', err);
|
||
}
|
||
};
|
||
|
||
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 = compact(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) {
|
||
console.error('Failed to add download client:', err);
|
||
this.errorMessage = explainError(
|
||
err,
|
||
'That client could not be saved.',
|
||
);
|
||
}
|
||
};
|
||
|
||
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) {
|
||
console.error('Failed to save download client:', err);
|
||
this.errorMessage = explainError(
|
||
err,
|
||
'Those changes could not be saved.',
|
||
);
|
||
}
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
/**
|
||
* Removing a client discards its stored credentials, which cannot
|
||
* be recovered — and it used to happen on one click (errors.m4).
|
||
*/
|
||
private async deleteProvider(provider: DownloadProvider) {
|
||
const ok = await confirmAction({
|
||
title: `Remove “${provider.name}”?`,
|
||
message:
|
||
'YellowJacket will stop using this client for downloads.',
|
||
impact:
|
||
'Its stored credentials are deleted and cannot be recovered.',
|
||
confirmLabel: 'Remove client',
|
||
danger: true,
|
||
});
|
||
|
||
if (!ok) return;
|
||
|
||
this.errorMessage = '';
|
||
|
||
try {
|
||
await downloadStore.deleteProvider(provider.id);
|
||
} catch (err) {
|
||
console.error('Failed to remove download client:', err);
|
||
this.errorMessage = describeError(
|
||
err,
|
||
'That client could not be removed.',
|
||
);
|
||
}
|
||
}
|
||
|
||
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) {
|
||
// Deliberately verbatim: a connection test's error is the
|
||
// user's debugging tool for a misconfigured client, and is
|
||
// the documented exception to describeError() (errors.M9).
|
||
this.testResults = {
|
||
...this.testResults,
|
||
[provider.id]: { ok: false, message: String(err) },
|
||
};
|
||
} finally {
|
||
this.testing = null;
|
||
}
|
||
}
|
||
|
||
private toggleFormat(format: Format, checked: boolean): void {
|
||
const current = this.prefs.allowedFormats ?? [];
|
||
const allowedFormats = checked
|
||
? [...current, format]
|
||
: current.filter((f) => f !== format);
|
||
|
||
this.prefs = { ...this.prefs, allowedFormats };
|
||
}
|
||
|
||
/**
|
||
* Saves the guardrails both to disk and to the running download
|
||
* manager in one action — persistence alone would leave the setting
|
||
* inert until restart, which is exactly the bug this mirrors away
|
||
* from (see `config.Library`'s prior persist-without-apply gap).
|
||
*/
|
||
private savePreferences = async () => {
|
||
this.prefsSaving = true;
|
||
this.prefsError = '';
|
||
this.prefsSaved = false;
|
||
|
||
try {
|
||
await SetDownloadPreferences(this.prefs);
|
||
await SetPreferences(this.prefs);
|
||
this.prefsSaved = true;
|
||
} catch (err) {
|
||
console.error('Failed to save download preferences:', err);
|
||
this.prefsError = describeError(
|
||
err,
|
||
'Those preferences could not be saved.',
|
||
);
|
||
} finally {
|
||
this.prefsSaving = false;
|
||
}
|
||
};
|
||
}
|
||
|
||
declare global {
|
||
interface HTMLElementTagNameMap {
|
||
'download-clients': DownloadClients;
|
||
}
|
||
}
|