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
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:
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user