Files
minstrel/web/src/lib/components/AcquisitionServiceRow.svelte
T
bvandeusenandClaude Opus 5.5 10d50dccbd docs: web comments say what the app does, not how it came to be (#6277)
Rewrites the web app's comments to rule 32. Task, milestone, rule and
spec ids are gone; regression stories are restated as the trap they
guard (public auth routes, the menu's stopPropagation, universal loads
under adapter-static, metric deltas with their margin). Flutter and
Tauri mentions now name the Android app or a native shell, operator
quotes and dates are dropped, and one library's counts no longer
appear outside test fixtures. Verified with a TypeScript-scanner token
comparison (template literals and regex literals re-scanned): only
comment text changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:42:12 -04:00

395 lines
14 KiB
Svelte

<script lang="ts">
import { untrack } from 'svelte';
import {
clearAcquisitionSecret,
removeAcquisitionService,
setAcquisitionSecret,
testAcquisitionService,
updateAcquisitionService,
type AcquisitionService,
type AcquisitionTestResult,
type AcquisitionUpdate
} from '#lib/api/admin.js';
import { errCode, errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// One indexer or download client. The form is a copy of the
// saved row, so Save is only offered when something changed. Secrets are
// write-only: the server says whether one is set and never what it is.
let {
service,
onChanged
}: { service: AcquisitionService; onChanged: () => void | Promise<void> } = $props();
function formOf(s: AcquisitionService): AcquisitionUpdate {
return {
label: s.label,
enabled: s.enabled,
base_url: s.base_url,
username: s.username,
category: s.category,
timeout_ms: s.timeout_ms,
serves_search: s.serves_search,
serves_downloads: s.serves_downloads,
serves_enrichment: s.serves_enrichment,
local_path: s.local_path,
remote_path: s.remote_path,
searches_per_hour: s.searches_per_hour
};
}
// The row is keyed by code in the list, so the first value is the one to
// start from; after a save the form takes the saved row.
let form = $state<AcquisitionUpdate>(untrack(() => formOf(service)));
let secret = $state('');
let saving = $state(false);
let testing = $state(false);
let confirmRemove = $state(false);
let problem = $state('');
let result = $state<AcquisitionTestResult | null>(null);
const isExchange = $derived(service.protocol === 'exchange');
const isQbit = $derived(service.protocol === 'qbittorrent');
const searches = $derived(isExchange ? form.serves_search : service.kind === 'search');
const fetches = $derived(isExchange ? form.serves_downloads : service.kind === 'download');
const dirty = $derived(JSON.stringify(form) !== JSON.stringify(formOf(service)));
const secretWhich = $derived<'api-key' | 'password'>(isQbit ? 'password' : 'api-key');
const secretSet = $derived(isQbit ? service.has_password : service.has_api_key);
const placeholder: Record<string, string> = {
prowlarr: 'http://prowlarr:9696',
torznab: 'http://jackett:9117/api/v2.0/indexers/name/results/torznab/api',
qbittorrent: 'http://qbittorrent:8080',
exchange: 'http://fabledexchange:8080'
};
const blurb: Record<string, string> = {
prowlarr:
"Asked through Prowlarr's own API, which searches every indexer you have set up in Prowlarr at once.",
torznab:
'A single Torznab feed: Jackett, or one indexer you point at directly. Anything that serves the open standard works.',
qbittorrent:
'Your qBittorrent, or anything else that speaks its WebUI. Downloads are added with the save path already set, so a finished one lands where Minstrel looks.',
exchange:
'One FabledExchange, doing each job switched on below: it downloads what is grabbed, searches its DHT index alongside your indexers, and describes a release (its files, whether anyone is sharing it) before one is chosen.'
};
const jobs = $derived(
isExchange
? [
form.serves_downloads && 'Downloads',
form.serves_search && 'Searches',
form.serves_enrichment && 'Release details'
].filter((j): j is string => !!j)
: [service.kind === 'search' ? 'Searches' : 'Downloads']
);
async function save() {
saving = true;
problem = '';
try {
const saved = await updateAcquisitionService(service.code, {
...form,
timeout_ms: Number(form.timeout_ms),
searches_per_hour: Number(form.searches_per_hour)
});
form = formOf(saved);
pushToast(`${saved.label} saved.`);
await onChanged();
} catch (e) {
// The server names the field and what it wants; show it beside the form.
if (errCode(e) === 'invalid_setting') problem = (e as { message?: string }).message ?? '';
else pushToast(errMessage(e), 'error');
} finally {
saving = false;
}
}
async function test() {
testing = true;
try {
result = await testAcquisitionService(service.code);
} catch (e) {
result = { ok: false, detail: errMessage(e, 'The test itself could not be run.') };
} finally {
testing = false;
}
}
async function saveSecret() {
try {
await setAcquisitionSecret(service.code, secretWhich, secret);
secret = '';
pushToast(isQbit ? 'Password saved.' : 'Key saved.');
await onChanged();
} catch (e) {
pushToast(errMessage(e), 'error');
}
}
async function clearSecret() {
try {
await clearAcquisitionSecret(service.code, secretWhich);
pushToast(isQbit ? 'Password removed.' : 'Key removed.');
await onChanged();
} catch (e) {
pushToast(errMessage(e), 'error');
}
}
async function remove() {
try {
await removeAcquisitionService(service.code);
pushToast(`${service.label} removed.`);
await onChanged();
} catch (e) {
pushToast(errMessage(e), 'error');
}
}
const fieldClass = 'block text-sm';
const inputClass =
'mt-1 w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-text-primary ' +
'placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent';
const secretInputClass = inputClass.replace('mt-1 ', '');
const noteClass = 'mt-1 block text-xs text-text-secondary';
const buttonClass =
'rounded-md px-3 py-1.5 text-sm focus-visible:outline-solid focus-visible:outline-2 ' +
'focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50';
</script>
<article class="space-y-3 px-4 py-3" data-testid="acquisition-service">
<div class="flex flex-wrap items-center justify-between gap-3">
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={form.enabled} aria-label="Use {form.label}" />
<span class="text-sm font-medium text-text-primary">{form.label}</span>
</label>
<div class="flex flex-wrap items-center gap-2 text-xs">
{#each jobs as job (job)}
<span class="rounded-md border border-border px-2 py-0.5 text-text-secondary">{job}</span>
{/each}
{#if service.usable}
<span class="rounded-md bg-accent-tint px-2 py-0.5 font-medium text-action-primary">On</span>
{:else if service.enabled}
<span class="rounded-md px-2 py-0.5 text-action-destructive">On, but no address</span>
{:else}
<span class="rounded-md border border-border px-2 py-0.5 text-text-muted">Off</span>
{/if}
</div>
</div>
<p class="text-xs text-text-secondary">{blurb[service.protocol]}</p>
<details open={!service.usable} class="group">
<summary class="cursor-pointer text-sm text-text-secondary">Connection</summary>
<div class="mt-3 grid gap-4 sm:grid-cols-2">
<label class={fieldClass}>
<span class="text-text-primary">Name</span>
<input type="text" bind:value={form.label} class={inputClass} />
</label>
<label class={fieldClass}>
<span class="text-text-primary">Address</span>
<input
type="url"
bind:value={form.base_url}
placeholder={placeholder[service.protocol]}
class={inputClass}
/>
<span class={noteClass}>
{#if service.protocol === 'torznab'}
The feed's whole address, up to and including /api, as Jackett or the indexer shows it.
{:else}
Exactly as you open it in your browser.
{/if}
</span>
</label>
{#if isExchange}
<fieldset class="space-y-2 sm:col-span-2" data-testid="exchange-jobs">
<legend class="text-sm text-text-primary">What it does here</legend>
<label class="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" bind:checked={form.serves_downloads} />
Downloads what is grabbed
</label>
<label class="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" bind:checked={form.serves_search} />
Searches its DHT index alongside your indexers
</label>
<label class="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" bind:checked={form.serves_enrichment} />
Checks a release's files and sharers before one is chosen
</label>
</fieldset>
{/if}
{#if isQbit}
<label class={fieldClass}>
<span class="text-text-primary">Username</span>
<input type="text" autocomplete="off" bind:value={form.username} class={inputClass} />
<span class={noteClass}>Empty if your WebUI does not ask for one.</span>
</label>
{/if}
{#if fetches}
<label class={fieldClass}>
<span class="text-text-primary">Category</span>
<input type="text" bind:value={form.category} class={inputClass} />
<span class={noteClass}>
Everything Minstrel adds is filed under this, so you can tell its downloads from yours.
</span>
</label>
<label class={fieldClass}>
<span class="text-text-primary">Downloads folder, as {form.label} sees it</span>
<input
type="text"
bind:value={form.remote_path}
placeholder={form.local_path || '/downloads'}
class={inputClass}
/>
<span class={noteClass}>
The folder it saves downloads into, by its own path. Leave empty if it is the same path
Minstrel uses below.
</span>
</label>
<label class={fieldClass}>
<span class="text-text-primary">The same folder, as Minstrel sees it</span>
<input type="text" bind:value={form.local_path} class={inputClass} />
<span class={noteClass}>
Where that folder is mounted in Minstrel's container. Finished downloads are moved from
here into the library. Test checks Minstrel can see it and write to it.
</span>
</label>
{/if}
{#if searches}
<label class={fieldClass}>
<span class="text-text-primary">Searches per hour</span>
<input
type="number"
min="0"
max="3600"
bind:value={form.searches_per_hour}
class={inputClass}
/>
<span class={noteClass}>
How often the search schedule may ask this one. Searches are spaced evenly, and one
that answers "too many requests" is left alone as long as it asks. 0 means no limit.
</span>
</label>
{/if}
<label class={fieldClass}>
<span class="text-text-primary">Give up after (ms)</span>
<input
type="number"
min="1000"
max="300000"
step="1000"
bind:value={form.timeout_ms}
class={inputClass}
/>
{#if searches}
<span class={noteClass}>A search waits on every indexer behind it, so allow plenty.</span>
{/if}
</label>
</div>
<div class="mt-4 border-t border-border pt-3 {fieldClass}">
<label for="secret-{service.code}" class="text-text-primary">
{#if isQbit}{secretSet ? 'Replace the password' : 'Password'}{:else}{secretSet
? 'Replace the API key'
: 'API key'}{/if}
</label>
<div class="mt-1 flex flex-wrap items-center gap-2">
<input
id="secret-{service.code}"
type="password"
autocomplete="off"
placeholder="Paste it here"
bind:value={secret}
class="{secretInputClass} max-w-xs font-mono"
/>
<button
type="button"
class="{buttonClass} border border-border text-text-primary hover:bg-surface-hover"
disabled={secret === ''}
onclick={saveSecret}>{isQbit ? 'Save password' : 'Save key'}</button
>
{#if secretSet}
<button
type="button"
class="text-xs text-text-secondary underline hover:no-underline"
onclick={clearSecret}>Remove</button
>
{/if}
</div>
<span class={noteClass}>
{#if service.protocol === 'prowlarr'}
Prowlarr shows it under Settings → General.
{:else if isExchange}
Optional: only if your FabledExchange asks for one. Give Minstrel a key of its own there,
so it can be revoked on its own.
{:else if service.protocol === 'torznab'}
Optional: only if the feed asks for one.
{:else}
Empty if your WebUI does not ask for one.
{/if}
{secretSet ? 'One is saved.' : ''} Never shown again once saved.
</span>
</div>
</details>
{#if problem}
<p class="text-sm text-action-destructive" data-testid="acquisition-problem">{problem}</p>
{/if}
{#if result}
<p
class="text-sm {result.ok ? 'text-text-secondary' : 'text-action-destructive'}"
role="status"
data-testid="acquisition-test-result"
>
{result.detail}
</p>
{/if}
<div class="flex flex-wrap items-center justify-end gap-2">
{#if confirmRemove}
<span class="text-sm text-text-secondary">
Remove <strong>{service.label}</strong>? Minstrel forgets its address and credentials.
Nothing on the service itself changes.
</span>
<button
type="button"
class="{buttonClass} bg-action-destructive text-action-fg hover:opacity-90"
onclick={remove}>Yes, remove it</button
>
<button
type="button"
class="{buttonClass} text-text-secondary hover:bg-surface-hover"
onclick={() => (confirmRemove = false)}>Keep it</button
>
{:else}
<button
type="button"
class="{buttonClass} text-text-secondary hover:bg-surface-hover"
onclick={() => (confirmRemove = true)}>Remove</button
>
<button
type="button"
class="{buttonClass} border border-border text-text-primary hover:bg-surface-hover"
disabled={testing || !service.base_url}
title={service.base_url ? '' : 'Save an address first'}
onclick={test}>{testing ? 'Testing…' : 'Test'}</button
>
<button
type="button"
class="{buttonClass} bg-action-secondary text-action-fg hover:opacity-90"
disabled={!dirty || saving}
onclick={save}>{saving ? 'Saving…' : 'Save'}</button
>
{/if}
</div>
</article>