feat(web): add /admin/integrations Lidarr connection panel
Connection management for Lidarr — base URL, API key, default quality profile, default root folder. Empty api_key on save preserves the masked-saved key per backend semantics. Disconnect requires typed "DISCONNECT" confirmation. Includes a placeholder MusicBrainz overrides section for future integrations. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
<script lang="ts">
|
||||
import { Save, RefreshCw, Trash2 } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
createLidarrConfigQuery,
|
||||
createQualityProfilesQuery,
|
||||
createRootFoldersQuery,
|
||||
putLidarrConfig,
|
||||
testLidarrConnection
|
||||
} from '$lib/api/admin';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import type { LidarrConfig, LidarrTestResult } from '$lib/api/types';
|
||||
|
||||
// Lidarr connection panel. The "saved api key" is masked as "***" on GET —
|
||||
// never displayed in the input. The input always starts empty; sending an
|
||||
// empty string on PUT tells the backend "preserve the saved key". This is
|
||||
// the one field with that semantics; everything else is sent as-typed.
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
const configStore = createLidarrConfigQuery();
|
||||
const config = $derived($configStore);
|
||||
|
||||
// Local form state — initialized once when config first loads. Subsequent
|
||||
// refetches don't clobber whatever the operator is in the middle of editing.
|
||||
let baseUrl = $state('');
|
||||
let apiKeyInput = $state(''); // intentionally never seeded with '***'
|
||||
let qualityId = $state<number>(0);
|
||||
let rootPath = $state<string>('');
|
||||
let initialized = false;
|
||||
|
||||
$effect(() => {
|
||||
const c = config.data;
|
||||
if (c && !initialized) {
|
||||
baseUrl = c.base_url;
|
||||
qualityId = c.default_quality_profile_id;
|
||||
rootPath = c.default_root_folder_path;
|
||||
initialized = true;
|
||||
}
|
||||
});
|
||||
|
||||
// The dropdown queries only fire once Lidarr is configured; otherwise the
|
||||
// backend has no client to call and would 4xx.
|
||||
const profilesEnabled = $derived(!!config.data?.enabled);
|
||||
|
||||
const profilesStore = $derived(createQualityProfilesQuery(profilesEnabled));
|
||||
const profiles = $derived($profilesStore);
|
||||
|
||||
const foldersStore = $derived(createRootFoldersQuery(profilesEnabled));
|
||||
const folders = $derived($foldersStore);
|
||||
|
||||
let testResult: LidarrTestResult | null = $state(null);
|
||||
let saveError: string | null = $state(null);
|
||||
let isSaving = $state(false);
|
||||
let isTesting = $state(false);
|
||||
|
||||
async function onSave() {
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
try {
|
||||
const cfg: LidarrConfig = {
|
||||
enabled: true,
|
||||
base_url: baseUrl,
|
||||
api_key: apiKeyInput, // empty string tells backend "preserve saved key"
|
||||
default_quality_profile_id: qualityId,
|
||||
default_root_folder_path: rootPath
|
||||
};
|
||||
await putLidarrConfig(cfg);
|
||||
await client.invalidateQueries({ queryKey: qk.lidarrConfig() });
|
||||
apiKeyInput = '';
|
||||
} catch (e) {
|
||||
saveError = (e as { code?: string }).code ?? 'save_failed';
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onTest() {
|
||||
isTesting = true;
|
||||
testResult = null;
|
||||
try {
|
||||
testResult = await testLidarrConnection({
|
||||
base_url: baseUrl,
|
||||
api_key: apiKeyInput
|
||||
});
|
||||
} finally {
|
||||
isTesting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect typed-confirm modal. Requires the literal "DISCONNECT" string
|
||||
// to avoid muscle-memory clearing of an integration the operator depends on.
|
||||
let modalOpen = $state(false);
|
||||
let disconnectInput = $state('');
|
||||
const canDisconnect = $derived(disconnectInput === 'DISCONNECT');
|
||||
|
||||
async function onConfirmDisconnect() {
|
||||
if (!canDisconnect) return;
|
||||
await putLidarrConfig({
|
||||
enabled: false,
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
default_quality_profile_id: 0,
|
||||
default_root_folder_path: ''
|
||||
});
|
||||
await client.invalidateQueries({ queryKey: qk.lidarrConfig() });
|
||||
modalOpen = false;
|
||||
disconnectInput = '';
|
||||
}
|
||||
|
||||
function cancelDisconnect() {
|
||||
modalOpen = false;
|
||||
disconnectInput = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="font-display text-2xl font-medium text-text-primary">Integrations</h2>
|
||||
{#if config.data?.enabled}
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-md bg-accent-tint px-3 py-1 text-xs font-medium text-action-primary"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-action-primary"></span>
|
||||
Lidarr · connected
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-md border border-border px-3 py-1 text-xs text-text-muted"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-text-muted"></span>
|
||||
Lidarr · unset
|
||||
</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Lidarr panel -->
|
||||
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
||||
<div>
|
||||
<h3 class="font-display text-lg font-medium text-text-primary">Lidarr</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
Search Lidarr from <span class="font-mono text-accent">/discover</span> and
|
||||
route approved requests to it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="block text-sm text-text-secondary">Base URL</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={baseUrl}
|
||||
placeholder="http://lidarr.lan:8686"
|
||||
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="block text-sm text-text-secondary">API key</span>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={apiKeyInput}
|
||||
placeholder={config.data?.api_key === '***'
|
||||
? '••• (saved — leave empty to keep)'
|
||||
: 'Paste API key'}
|
||||
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="block text-sm text-text-secondary">Default quality profile</span>
|
||||
<select
|
||||
bind:value={qualityId}
|
||||
disabled={!profilesEnabled || profiles.isPending}
|
||||
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50"
|
||||
>
|
||||
{#each profiles.data ?? [] as p (p.id)}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="block text-sm text-text-secondary">Default root folder</span>
|
||||
<select
|
||||
bind:value={rootPath}
|
||||
disabled={!profilesEnabled || folders.isPending}
|
||||
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50"
|
||||
>
|
||||
{#each folders.data ?? [] as f (f.path)}
|
||||
<option value={f.path}
|
||||
>{f.path}{f.accessible ? '' : ' (not accessible)'}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{#if testResult}
|
||||
{#if testResult.ok}
|
||||
<p class="text-sm text-action-primary">
|
||||
Connected — Lidarr {testResult.version}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm text-error">Connection failed — {testResult.error}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if saveError}
|
||||
<p class="text-sm text-error">Save failed — {saveError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onSave}
|
||||
disabled={isSaving}
|
||||
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-text-primary disabled:opacity-50"
|
||||
>
|
||||
<Save size={14} strokeWidth={2} />
|
||||
Save changes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onTest}
|
||||
disabled={isTesting}
|
||||
class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={14} strokeWidth={2} />
|
||||
Test connection
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (modalOpen = true)}
|
||||
class="ml-auto inline-flex items-center gap-2 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-text-primary"
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- MusicBrainz overrides — visually present, not implemented. Foreshadows
|
||||
future integrations so the page doesn't read like Lidarr-only. -->
|
||||
<section class="space-y-2 rounded-xl border border-border bg-surface p-5 opacity-60">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-display text-lg font-medium text-text-primary">
|
||||
MusicBrainz overrides
|
||||
</h3>
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-md border border-border px-3 py-1 text-xs text-text-muted"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-text-muted"></span>
|
||||
unset
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-text-secondary">Not yet configured.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if modalOpen}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style="background: rgba(0,0,0,0.5);"
|
||||
onclick={cancelDisconnect}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="disconnect-title"
|
||||
class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
tabindex="-1"
|
||||
>
|
||||
<h3
|
||||
id="disconnect-title"
|
||||
class="font-display text-lg font-medium text-text-primary"
|
||||
>
|
||||
Disconnect Lidarr?
|
||||
</h3>
|
||||
<p class="mt-2 text-text-secondary">
|
||||
This clears the saved configuration. Type
|
||||
<span class="font-mono text-text-primary">DISCONNECT</span> to remove the
|
||||
Lidarr connection.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={disconnectInput}
|
||||
placeholder="DISCONNECT"
|
||||
aria-label="Type DISCONNECT to confirm"
|
||||
class="mt-3 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelDisconnect}
|
||||
class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onConfirmDisconnect}
|
||||
disabled={!canDisconnect}
|
||||
class="rounded-md bg-action-destructive px-3 py-1.5 text-sm text-text-primary disabled:opacity-50"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { LidarrConfig } from '$lib/api/types';
|
||||
|
||||
// useQueryClient is wrapped so the page can call invalidateQueries() without
|
||||
// a real QueryClient context. Everything else from svelte-query passes through.
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
|
||||
});
|
||||
|
||||
vi.mock('$lib/api/admin', () => ({
|
||||
createLidarrConfigQuery: vi.fn(),
|
||||
createQualityProfilesQuery: vi.fn(),
|
||||
createRootFoldersQuery: vi.fn(),
|
||||
putLidarrConfig: vi.fn(),
|
||||
testLidarrConnection: vi.fn()
|
||||
}));
|
||||
|
||||
import IntegrationsPage from './+page.svelte';
|
||||
import {
|
||||
createLidarrConfigQuery,
|
||||
createQualityProfilesQuery,
|
||||
createRootFoldersQuery,
|
||||
putLidarrConfig,
|
||||
testLidarrConnection
|
||||
} from '$lib/api/admin';
|
||||
|
||||
const cfgConnected: LidarrConfig = {
|
||||
enabled: true,
|
||||
base_url: 'http://lidarr.local',
|
||||
api_key: '***',
|
||||
default_quality_profile_id: 1,
|
||||
default_root_folder_path: '/music'
|
||||
};
|
||||
|
||||
const cfgUnset: LidarrConfig = {
|
||||
enabled: false,
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
default_quality_profile_id: 0,
|
||||
default_root_folder_path: ''
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function setup(opts: { config?: LidarrConfig } = {}) {
|
||||
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: opts.config ?? cfgConnected })
|
||||
);
|
||||
(createQualityProfilesQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({
|
||||
data: [
|
||||
{ id: 1, name: 'Standard' },
|
||||
{ id: 2, name: 'Lossless' }
|
||||
]
|
||||
})
|
||||
);
|
||||
(createRootFoldersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: [{ path: '/music', accessible: true, free_space: 0 }] })
|
||||
);
|
||||
return render(IntegrationsPage);
|
||||
}
|
||||
|
||||
// Helper for finding the modal's Disconnect confirm button. Both the panel
|
||||
// trigger and the modal confirm share the visible name "Disconnect"; the
|
||||
// modal one is the descendant of role="dialog".
|
||||
function modalConfirmButton(): HTMLButtonElement {
|
||||
const matches = screen.getAllByRole('button', { name: /^disconnect$/i });
|
||||
const inDialog = matches.find((b) => b.closest('[role="dialog"]') !== null);
|
||||
if (!inDialog) throw new Error('modal Disconnect button not found');
|
||||
return inDialog as HTMLButtonElement;
|
||||
}
|
||||
|
||||
describe('/admin/integrations', () => {
|
||||
test('Save calls putLidarrConfig with empty api_key when input is blank', async () => {
|
||||
setup();
|
||||
(putLidarrConfig as ReturnType<typeof vi.fn>).mockResolvedValueOnce(cfgConnected);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
expect(putLidarrConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabled: true,
|
||||
base_url: 'http://lidarr.local',
|
||||
api_key: '',
|
||||
default_quality_profile_id: 1,
|
||||
default_root_folder_path: '/music'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('Save with new api_key sends the typed value', async () => {
|
||||
setup();
|
||||
const apiInput = screen.getByPlaceholderText(/saved — leave empty to keep/i);
|
||||
await fireEvent.input(apiInput, { target: { value: 'newkey' } });
|
||||
(putLidarrConfig as ReturnType<typeof vi.fn>).mockResolvedValueOnce(cfgConnected);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
expect(putLidarrConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ api_key: 'newkey' })
|
||||
);
|
||||
});
|
||||
|
||||
test('Test connection renders Lidarr version on success', async () => {
|
||||
setup();
|
||||
(testLidarrConnection as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
version: '2.0.0'
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole('button', { name: /test connection/i })
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/lidarr 2\.0\.0/i)).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
test('Test connection renders error code on failure', async () => {
|
||||
setup();
|
||||
(testLidarrConnection as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: 'lidarr_unreachable'
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole('button', { name: /test connection/i })
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/lidarr_unreachable/i)).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
test('Disconnect requires typed confirm; cancelling does not call API', async () => {
|
||||
setup();
|
||||
// Open modal via the panel-level Disconnect button.
|
||||
await fireEvent.click(
|
||||
screen.getByRole('button', { name: /^disconnect$/i })
|
||||
);
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
// Confirm button is disabled until "DISCONNECT" is typed.
|
||||
expect(modalConfirmButton().disabled).toBe(true);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(putLidarrConfig).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Disconnect with typed confirmation calls API with cleared config', async () => {
|
||||
setup();
|
||||
await fireEvent.click(
|
||||
screen.getByRole('button', { name: /^disconnect$/i })
|
||||
);
|
||||
const confirmInput = screen.getByPlaceholderText('DISCONNECT');
|
||||
await fireEvent.input(confirmInput, { target: { value: 'DISCONNECT' } });
|
||||
(putLidarrConfig as ReturnType<typeof vi.fn>).mockResolvedValueOnce(cfgUnset);
|
||||
await fireEvent.click(modalConfirmButton());
|
||||
expect(putLidarrConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabled: false,
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
default_quality_profile_id: 0,
|
||||
default_root_folder_path: ''
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('Quality profile and root folder dropdowns populate from API', () => {
|
||||
setup();
|
||||
expect(screen.getByText('Standard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Lossless')).toBeInTheDocument();
|
||||
expect(screen.getByText('/music')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('header shows "Lidarr · connected" when configured', () => {
|
||||
setup();
|
||||
expect(screen.getByText(/lidarr · connected/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('header shows "Lidarr · unset" when not configured', () => {
|
||||
setup({ config: cfgUnset });
|
||||
expect(screen.getByText(/lidarr · unset/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user