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 = {}; @state() private draftName = ''; /** Per-provider connection test results, keyed by provider ID. */ @state() private testResults: Record = {}; @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 { 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` ${this.errorMessage ? html`${this.errorMessage}` : nothing}
${this.providers.length === 0 && this.editing !== 'new' ? html`
No download clients connected.
` : nothing} ${this.providers.map((provider) => this.renderProvider(provider))}
${this.editing === 'new' ? this.renderAddForm() : html`
Add download client
`}
${this.prefsError ? html`${this.prefsError}` : nothing}
{ this.prefs = { ...this.prefs, minKbps: Number((e.target as HTMLInputElement).value) || 0, }; }} > { this.prefs = { ...this.prefs, maxKbps: Number((e.target as HTMLInputElement).value) || 0, }; }} > { this.prefs = { ...this.prefs, preferredKbps: Number((e.target as HTMLInputElement).value) || 0, }; }} >
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.
{ this.prefs = { ...this.prefs, maxSizeMb: Number((e.target as HTMLInputElement).value) || 0, }; }} >
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.
Allowed formats — leave all unchecked to allow any format.
${AUTO_DOWNLOAD_FORMATS.map( (format) => html` `, )}
${this.prefsSaved ? html`Saved.` : nothing} ${this.prefsSaving ? html`` : 'Save preferences'}
`; } 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`
${provider.name}
${descriptor?.name ?? provider.kind} · ${provider.enabled ? 'Enabled' : 'Disabled'} · priority ${provider.priority}
${test ? html`
${test.message}
` : nothing}
this.testProvider(provider)} > ${this.testing === provider.id ? html`` : 'Test'} this.startEdit(provider)} > Edit void this.deleteProvider(provider)} > Remove
`; } private renderAddForm() { const descriptor = this.descriptorFor(this.newKind); return html`
${this.descriptors.map( (d) => html`${d.name}`, )} ${descriptor ? html`
${descriptor.summary} ${descriptor.requiresExternal ? html`
Requires a running ${descriptor.requiresExternal} instance.` : nothing}
{ this.draftName = (e.target as HTMLInputElement).value; }} > ${this.renderFields(descriptor)} ` : nothing}
Cancel Add
`; } private renderEditForm(provider: DownloadProvider) { const descriptor = this.descriptorFor(provider.kind); return html`
{ this.draftName = (e.target as HTMLInputElement).value; }} > ${descriptor ? this.renderFields(descriptor, provider) : nothing} { this.draft['__priority'] = (e.target as HTMLInputElement).value; }} > { this.draft['__enabled'] = (e.target as HTMLInputElement) .checked ? '1' : ''; }} > Enabled
Cancel this.saveEdit(provider)} > Save
`; } /** 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`
{ this.draft = { ...this.draft, [field.key]: (e.target as HTMLInputElement).value, }; }} > ${field.help ? html`${field.help}` : nothing} ${field.path ? html` this.browseForFolder(field)} > Browse ` : nothing}
`; }); } 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 { const descriptor = this.descriptorFor(kind); const out: Record = {}; 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 { const out: Record = {}; 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; } }