feat: indexers and download clients you add and configure (M502 #5518)
release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m13s
release / go (push) Successful in 2m12s
release / integration (push) Successful in 4m6s
release / Build signed APK (releases and dev) (push) Successful in 8m41s
release / android (push) Successful in 9m47s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 59s
release / Verify release artifacts (tag releases only) (push) Skipped

Admin → Integrations gains "Indexers and download clients": two lists of
connections the operator adds, Fabled Librarian's model. Nothing is seeded
or copied from Lidarr.

- Indexers: Prowlarr (its native /api/v1/search, which asks every indexer
  at once; Prowlarr has no aggregate Torznab feed) and any Torznab feed.
- Download clients: qBittorrent (WebUI v2, with re-login on an expired
  session) and FabledExchange, one entry with a switch per job:
  downloads (/api/downloads), search (/api/search) and release details
  (/api/torrents/{infohash}). All three start on; its key is optional.
- Each entry: enable, name, address, a write-only key or password, Test.
  A download client's Test lists its torrents and checks the downloads
  folder is visible and writable from Minstrel; an Exchange's Test
  reports each job switched on.
- Download clients carry a path mapping (Minstrel's /downloads vs the
  client's own name for it); search sources carry searches per hour.

Migration 0079 adds acquisition_services with its CHECKs. Ported from
Fabled Librarian: internal/indexer (Torznab, 429 Retry-After, magnet
redirects), internal/prowlarr, internal/exchange, internal/downloader,
and internal/acquire's one builder per kind, which Test already uses
so a passing Test vouches for the path a grab will take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 20:34:51 -04:00
co-authored by Claude Opus 5.5
parent 5306a261c1
commit a2e1890d4c
35 changed files with 4966 additions and 2 deletions
@@ -0,0 +1,394 @@
<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 (M502 #5518). 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 rounded-lg border border-border p-4" 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>