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
+79
View File
@@ -1009,3 +1009,82 @@ export async function updateNotificationEmailSettings(
): Promise<NotificationEmailSettings> {
return api.put<NotificationEmailSettings>('/api/admin/notification-email', s);
}
// Indexers and download clients (M502 #5518) -------------------------------
export type AcquisitionProtocol = 'prowlarr' | 'torznab' | 'qbittorrent' | 'exchange';
/** One indexer or download client. Secrets never come back, only whether set. */
export type AcquisitionService = {
code: string;
kind: 'search' | 'download';
protocol: AcquisitionProtocol;
label: string;
enabled: boolean;
base_url: string;
has_api_key: boolean;
has_password: boolean;
username: string;
category: string;
timeout_ms: number;
/** The three switches mean something on an exchange row only. */
serves_search: boolean;
serves_downloads: boolean;
serves_enrichment: boolean;
local_path: string;
remote_path: string;
searches_per_hour: number;
/** Switched on and addressed. */
usable: boolean;
};
export type AcquisitionUpdate = Omit<
AcquisitionService,
'code' | 'kind' | 'protocol' | 'has_api_key' | 'has_password' | 'usable'
>;
export type AcquisitionTestResult = { ok: boolean; detail: string };
const acquisitionBase = '/api/admin/acquisition/services';
export async function listAcquisitionServices(): Promise<AcquisitionService[]> {
const res = await api.get<{ services: AcquisitionService[] }>(acquisitionBase);
return res.services;
}
export async function addAcquisitionService(
protocol: AcquisitionProtocol
): Promise<AcquisitionService> {
return api.post<AcquisitionService>(acquisitionBase, { protocol });
}
export async function updateAcquisitionService(
code: string,
u: AcquisitionUpdate
): Promise<AcquisitionService> {
return api.put<AcquisitionService>(`${acquisitionBase}/${encodeURIComponent(code)}`, u);
}
export async function removeAcquisitionService(code: string): Promise<void> {
await api.del(`${acquisitionBase}/${encodeURIComponent(code)}`);
}
export async function testAcquisitionService(code: string): Promise<AcquisitionTestResult> {
return api.post<AcquisitionTestResult>(`${acquisitionBase}/${encodeURIComponent(code)}/test`, {});
}
/** Stores a key or password. Write-only: nothing comes back. */
export async function setAcquisitionSecret(
code: string,
which: 'api-key' | 'password',
value: string
): Promise<void> {
await api.put(`${acquisitionBase}/${encodeURIComponent(code)}/${which}`, { value });
}
export async function clearAcquisitionSecret(
code: string,
which: 'api-key' | 'password'
): Promise<void> {
await api.del(`${acquisitionBase}/${encodeURIComponent(code)}/${which}`);
}
@@ -0,0 +1,196 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
addAcquisitionService,
listAcquisitionServices,
type AcquisitionProtocol,
type AcquisitionService
} from '#lib/api/admin.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import AcquisitionServiceRow from './AcquisitionServiceRow.svelte';
// Where Minstrel looks for music and what downloads it (M502 #5518),
// Fabled Librarian's model: two lists of connections the operator adds and
// configures. Nothing is seeded or copied from Lidarr. A FabledExchange is
// one entry under download clients, with a switch for each of its jobs.
let services = $state<AcquisitionService[] | null>(null);
let loadError = $state(false);
let adding = $state<'search' | 'download' | null>(null);
const indexers = $derived((services ?? []).filter((s) => s.kind === 'search'));
const clients = $derived((services ?? []).filter((s) => s.kind === 'download'));
const searchingExchanges = $derived(
clients.filter((s) => s.protocol === 'exchange' && s.serves_search)
);
const canSearch = $derived(
(services ?? []).some(
(s) => s.usable && (s.protocol === 'exchange' ? s.serves_search : s.kind === 'search')
)
);
const canFetch = $derived(
(services ?? []).some(
(s) => s.usable && (s.protocol === 'exchange' ? s.serves_downloads : s.kind === 'download')
)
);
const choices: Record<
'search' | 'download',
{ protocol: AcquisitionProtocol; name: string; note: string }[]
> = {
search: [
{
protocol: 'prowlarr',
name: 'Prowlarr',
note: 'Suggested. Every indexer you have set up in Prowlarr, asked in one search.'
},
{
protocol: 'torznab',
name: 'Torznab feed',
note: 'Jackett, or a single indexer you point at directly.'
}
],
download: [
{
protocol: 'qbittorrent',
name: 'qBittorrent',
note: 'Or anything else that speaks its WebUI.'
},
{
protocol: 'exchange',
name: 'FabledExchange',
note: 'If you run it: one entry that downloads, searches its DHT index and checks releases. Each job can be switched off.'
}
]
};
async function load() {
try {
services = await listAcquisitionServices();
loadError = false;
} catch {
loadError = true;
}
}
onMount(load);
async function add(protocol: AcquisitionProtocol) {
try {
const created = await addAcquisitionService(protocol);
adding = null;
pushToast(`${created.label} added. Give it an address, test it, then switch it on.`);
await load();
} catch (e) {
pushToast(errMessage(e), 'error');
}
}
const addButtonClass =
'rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover ' +
'focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent';
</script>
{#snippet addMenu(kind: 'search' | 'download', label: string)}
{#if adding === kind}
<div class="space-y-2" data-testid="add-{kind}">
{#each choices[kind] as choice (choice.protocol)}
<button
type="button"
class="block w-full rounded-lg border border-border px-4 py-3 text-left hover:bg-surface-hover
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
onclick={() => add(choice.protocol)}
>
<span class="block text-sm font-medium text-text-primary">{choice.name}</span>
<span class="block text-xs text-text-secondary">{choice.note}</span>
</button>
{/each}
<button
type="button"
class="text-sm text-text-secondary underline hover:no-underline"
onclick={() => (adding = null)}>Cancel</button
>
</div>
{:else}
<button type="button" class={addButtonClass} onclick={() => (adding = kind)}>{label}</button>
{/if}
{/snippet}
<section
class="space-y-5 rounded-xl border border-border bg-surface p-5"
data-testid="acquisition-card"
>
<div>
<h3 class="font-display text-lg font-medium text-text-primary">
Indexers and download clients
</h3>
<p class="mt-1 text-sm text-text-secondary">
Two jobs, kept apart: something to search with, and something to download with. Minstrel
speaks Prowlarr, Torznab and the qBittorrent WebUI, so a stock setup is a complete one.
</p>
</div>
{#if loadError}
<p class="text-sm text-action-destructive">
Couldn't load indexers and download clients.
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
</p>
{:else if services === null}
<p class="text-sm text-text-secondary">Loading indexers and download clients…</p>
{:else}
<p class="text-sm" data-testid="acquisition-health">
{#if !canFetch}
<span class="text-action-destructive">
No download client is switched on, so nothing can be downloaded.
</span>
{:else if !canSearch}
<span class="text-action-destructive">
No indexer is switched on, so nothing new can be found.
</span>
{:else}
<span class="text-text-secondary">Music can be searched for and downloaded.</span>
{/if}
</p>
<div class="space-y-3" data-testid="indexers">
<div>
<h4 class="text-sm font-medium text-text-primary">Indexers: where music is looked for</h4>
<p class="text-xs text-text-secondary">
Every indexer switched on here is searched, and the results are merged. If one is
unreachable the search still runs, narrower.
</p>
</div>
{#each indexers as service (service.code)}
<AcquisitionServiceRow {service} onChanged={load} />
{:else}
<p class="text-sm text-text-secondary">No indexer yet. Prowlarr is the usual choice.</p>
{/each}
{#if searchingExchanges.length > 0}
<p class="text-xs text-text-secondary">
Also searched: {searchingExchanges.map((s) => s.label).join(', ')}, set up under download
clients below.
</p>
{/if}
{@render addMenu('search', 'Add indexer')}
</div>
<div class="space-y-3 border-t border-border pt-5" data-testid="download-clients">
<div>
<h4 class="text-sm font-medium text-text-primary">
Download clients: what actually downloads
</h4>
<p class="text-xs text-text-secondary">
What a grab is handed to. A FabledExchange here can do more than download, and its entry
says which of its jobs are switched on.
</p>
</div>
{#each clients as service (service.code)}
<AcquisitionServiceRow {service} onChanged={load} />
{:else}
<p class="text-sm text-text-secondary">No download client yet.</p>
{/each}
{@render addMenu('download', 'Add download client')}
</div>
{/if}
</section>
@@ -0,0 +1,168 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
import type { AcquisitionService } from '#lib/api/admin.js';
vi.mock('#lib/api/admin.js', () => ({
listAcquisitionServices: vi.fn(),
addAcquisitionService: vi.fn(),
updateAcquisitionService: vi.fn(),
removeAcquisitionService: vi.fn(),
testAcquisitionService: vi.fn(),
setAcquisitionSecret: vi.fn(),
clearAcquisitionSecret: vi.fn()
}));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import AcquisitionCard from './AcquisitionCard.svelte';
import {
addAcquisitionService,
listAcquisitionServices,
setAcquisitionSecret,
testAcquisitionService,
updateAcquisitionService
} from '#lib/api/admin.js';
function service(over: Partial<AcquisitionService> = {}): AcquisitionService {
return {
code: 'prowlarr',
kind: 'search',
protocol: 'prowlarr',
label: 'Prowlarr',
enabled: false,
base_url: '',
has_api_key: false,
has_password: false,
username: '',
category: '',
timeout_ms: 60000,
serves_search: true,
serves_downloads: true,
serves_enrichment: true,
local_path: '/downloads',
remote_path: '',
searches_per_hour: 120,
usable: false,
...over
};
}
const exchange = (over: Partial<AcquisitionService> = {}) =>
service({
code: 'exchange',
kind: 'download',
protocol: 'exchange',
label: 'FabledExchange',
category: 'minstrel',
searches_per_hour: 0,
...over
});
afterEach(() => vi.clearAllMocks());
async function renderCard(services: AcquisitionService[]) {
vi.mocked(listAcquisitionServices).mockResolvedValue(services);
render(AcquisitionCard);
await screen.findByTestId('acquisition-health');
}
describe('AcquisitionCard', () => {
test('starts empty: nothing is seeded', async () => {
await renderCard([]);
expect(screen.getByText(/no indexer yet/i)).toBeInTheDocument();
expect(screen.getByText(/no download client yet/i)).toBeInTheDocument();
expect(screen.getByTestId('acquisition-health')).toHaveTextContent(/nothing can be downloaded/i);
});
test('adding offers the types each list takes', async () => {
await renderCard([]);
await fireEvent.click(screen.getByRole('button', { name: 'Add indexer' }));
const indexerChoices = within(screen.getByTestId('add-search'));
expect(indexerChoices.getByRole('button', { name: /prowlarr/i })).toBeInTheDocument();
expect(indexerChoices.getByRole('button', { name: /torznab feed/i })).toBeInTheDocument();
expect(indexerChoices.queryByRole('button', { name: /qbittorrent/i })).toBeNull();
vi.mocked(addAcquisitionService).mockResolvedValue(service());
await fireEvent.click(indexerChoices.getByRole('button', { name: /prowlarr/i }));
expect(addAcquisitionService).toHaveBeenCalledWith('prowlarr');
await fireEvent.click(screen.getByRole('button', { name: 'Add download client' }));
const clientChoices = within(screen.getByTestId('add-download'));
expect(clientChoices.getByRole('button', { name: /qbittorrent/i })).toBeInTheDocument();
expect(clientChoices.getByRole('button', { name: /fabledexchange/i })).toBeInTheDocument();
});
test('an exchange sits under download clients with a switch per job; others have none', async () => {
await renderCard([
service({ code: 'qbittorrent', kind: 'download', protocol: 'qbittorrent', label: 'qBittorrent' }),
exchange()
]);
const clients = within(screen.getByTestId('download-clients'));
expect(clients.getAllByTestId('acquisition-service')).toHaveLength(2);
// Only the exchange carries the job switches.
expect(clients.getAllByTestId('exchange-jobs')).toHaveLength(1);
expect(clients.getByRole('checkbox', { name: /downloads what is grabbed/i })).toBeChecked();
expect(clients.getByRole('checkbox', { name: /searches its dht index/i })).toBeChecked();
expect(clients.getByRole('checkbox', { name: /checks a release/i })).toBeChecked();
// qBittorrent asks for a username and a password; the exchange for an optional key.
expect(clients.getByText('Username')).toBeInTheDocument();
expect(clients.getByText(/save password/i)).toBeInTheDocument();
expect(clients.getByText(/save key/i)).toBeInTheDocument();
});
test('an exchange searching is named under indexers too', async () => {
await renderCard([exchange({ usable: true, enabled: true, base_url: 'http://ex' })]);
expect(within(screen.getByTestId('indexers')).getByText(/also searched: fabledexchange/i)).toBeInTheDocument();
expect(screen.getByTestId('acquisition-health')).toHaveTextContent(/searched for and downloaded/i);
});
test('switching a job off and saving sends the switches', async () => {
const ex = exchange({ base_url: 'http://ex' });
await renderCard([ex]);
await fireEvent.click(screen.getByRole('checkbox', { name: /checks a release/i }));
vi.mocked(updateAcquisitionService).mockResolvedValue({ ...ex, serves_enrichment: false });
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(updateAcquisitionService).toHaveBeenCalled());
const [code, sent] = vi.mocked(updateAcquisitionService).mock.calls[0];
expect(code).toBe('exchange');
expect(sent).toMatchObject({ serves_enrichment: false, serves_search: true, serves_downloads: true });
});
test('a refused setting is shown beside the form in the server’s words', async () => {
await renderCard([service({ base_url: 'http://p' })]);
await fireEvent.input(screen.getByRole('textbox', { name: /^name$/i }), {
target: { value: 'Mine' }
});
vi.mocked(updateAcquisitionService).mockRejectedValue({
code: 'invalid_setting',
message: 'Address: start with http:// or https://.',
status: 400
});
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
expect(await screen.findByTestId('acquisition-problem')).toHaveTextContent(
'Address: start with http:// or https://.'
);
});
test('Test shows what the server found, and needs an address', async () => {
await renderCard([service({ code: 'a' }), service({ code: 'b', label: 'Second', base_url: 'http://p' })]);
const [noAddress, addressed] = screen.getAllByRole('button', { name: 'Test' });
expect(noAddress).toBeDisabled();
vi.mocked(testAcquisitionService).mockResolvedValue({
ok: false,
detail: 'the API key was refused: HTTP 401. Check it on the service’s own settings page.'
});
await fireEvent.click(addressed);
expect(testAcquisitionService).toHaveBeenCalledWith('b');
expect(await screen.findByTestId('acquisition-test-result')).toHaveTextContent(/key was refused/i);
});
test('a key is sent write-only and never shown back', async () => {
await renderCard([service({ base_url: 'http://p', has_api_key: true })]);
expect(screen.getByLabelText(/replace the api key/i)).toHaveValue('');
await fireEvent.input(screen.getByLabelText(/replace the api key/i), { target: { value: 'k2' } });
vi.mocked(setAcquisitionSecret).mockResolvedValue(undefined);
await fireEvent.click(screen.getByRole('button', { name: /save key/i }));
await waitFor(() => expect(setAcquisitionSecret).toHaveBeenCalledWith('prowlarr', 'api-key', 'k2'));
});
});
@@ -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>
+3 -1
View File
@@ -53,5 +53,7 @@
"album_not_found": "That album no longer exists.",
"artist_not_found": "That artist no longer exists.",
"playlist_not_found": "That playlist no longer exists.",
"user_not_found": "That user no longer exists."
"user_not_found": "That user no longer exists.",
"service_not_found": "That connection no longer exists. Refresh the page.",
"too_many_services": "There are already twenty of these. Remove one you no longer use first."
}
@@ -28,6 +28,7 @@
import { pushToast } from '#lib/stores/toast.svelte.js';
import Modal from '#lib/components/Modal.svelte';
import AcoustIDCard from '#lib/components/AcoustIDCard.svelte';
import AcquisitionCard from '#lib/components/AcquisitionCard.svelte';
import NetworkSettingsCard from '#lib/components/NetworkSettingsCard.svelte';
import PublicAddressCard from '#lib/components/PublicAddressCard.svelte';
import NotificationEmailCard from '#lib/components/NotificationEmailCard.svelte';
@@ -420,6 +421,8 @@
{/if}
</header>
<AcquisitionCard />
<!-- Lidarr panel -->
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
@@ -37,7 +37,10 @@ vi.mock('#lib/api/admin.js', () => ({
listUnsettledAcoustID: vi.fn(),
// NotificationEmailCard loads on mount; left pending like AcoustIDCard.
getNotificationEmailSettings: vi.fn(() => new Promise(() => {})),
updateNotificationEmailSettings: vi.fn()
updateNotificationEmailSettings: vi.fn(),
// AcquisitionCard loads on mount; left pending like AcoustIDCard.
listAcquisitionServices: vi.fn(() => new Promise(() => {})),
addAcquisitionService: vi.fn()
}));
import IntegrationsPage from './+page.svelte';