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:
2026-04-29 23:06:19 -04:00
parent 041e63744d
commit c281c8b5dd
2 changed files with 494 additions and 0 deletions
@@ -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}