Click sequence on a fresh Lidarr config: enter URL + API key → click Save. The page now calls testLidarrConnection first, stashes the returned profiles/folders into local state, lets the existing auto-default $effects pick first-of-each, then sends the put-config request with non-zero defaults. If the test fails, the save aborts and the error surfaces in saveError. The explicit Test connection button keeps working and additionally populates the dropdowns from the same response so an operator can inspect/adjust before saving. Each dropdown reads from its test-state array when present, falling back to the existing query data (which only works after the first successful save). list_errors entries surface as inline notes under their respective dropdowns. Closes the chicken-and-egg where saving required defaults that could only be fetched after saving. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
701 lines
28 KiB
Svelte
701 lines
28 KiB
Svelte
<script lang="ts">
|
|
import { tick } from 'svelte';
|
|
import { pageTitle } from '$lib/branding';
|
|
import { Save, RefreshCw, Trash2 } from 'lucide-svelte';
|
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
import {
|
|
createLidarrConfigQuery,
|
|
createQualityProfilesQuery,
|
|
createMetadataProfilesQuery,
|
|
createRootFoldersQuery,
|
|
putLidarrConfig,
|
|
testLidarrConnection,
|
|
createCoverProvidersQuery,
|
|
updateCoverProvider,
|
|
testCoverProvider,
|
|
updateSMTPConfig,
|
|
testSMTPConfig,
|
|
createSMTPConfigQuery,
|
|
type CoverProvider,
|
|
type SMTPConfig
|
|
} from '$lib/api/admin';
|
|
import { qk } from '$lib/api/queries';
|
|
import { errCode } from '$lib/api/errors';
|
|
import { pushToast } from '$lib/stores/toast.svelte';
|
|
import Modal from '$lib/components/Modal.svelte';
|
|
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 metadataId = $state<number>(0);
|
|
let rootPath = $state<string>('');
|
|
let initialized = false;
|
|
|
|
// Test-derived lists. Populated by onTest or as the first step of
|
|
// onSave. When non-empty, dropdowns prefer these over the query data
|
|
// (the query path only works once Lidarr is enabled, which on a
|
|
// fresh deploy isn't true until after the first successful save).
|
|
let testQualityProfiles = $state<{ id: number; name: string }[]>([]);
|
|
let testMetadataProfiles = $state<{ id: number; name: string }[]>([]);
|
|
let testRootFolders = $state<{ path: string; accessible: boolean; free_space: number }[]>([]);
|
|
let testListErrors = $state<Record<string, string>>({});
|
|
|
|
$effect(() => {
|
|
const c = config.data;
|
|
if (c && !initialized) {
|
|
baseUrl = c.base_url;
|
|
qualityId = c.default_quality_profile_id;
|
|
metadataId = c.default_metadata_profile_id;
|
|
rootPath = c.default_root_folder_path;
|
|
initialized = true;
|
|
}
|
|
});
|
|
|
|
// Auto-default to the first available option. Sources, in priority
|
|
// order: the test response (works pre-save), then the query data
|
|
// (works post-save). The operator can still override after auto-fill.
|
|
$effect(() => {
|
|
if (qualityId === 0) {
|
|
const list = testQualityProfiles.length ? testQualityProfiles : (profiles.data ?? []);
|
|
if (list.length > 0) qualityId = list[0].id;
|
|
}
|
|
});
|
|
$effect(() => {
|
|
if (metadataId === 0) {
|
|
const list = testMetadataProfiles.length ? testMetadataProfiles : (metadataProfiles.data ?? []);
|
|
if (list.length > 0) metadataId = list[0].id;
|
|
}
|
|
});
|
|
$effect(() => {
|
|
if (rootPath === '') {
|
|
const list = testRootFolders.length ? testRootFolders : (folders.data ?? []);
|
|
if (list.length > 0) rootPath = list[0].path;
|
|
}
|
|
});
|
|
|
|
// 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 metadataProfilesStore = $derived(createMetadataProfilesQuery(profilesEnabled));
|
|
const metadataProfiles = $derived($metadataProfilesStore);
|
|
|
|
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 {
|
|
// Test first: confirm the connection AND fetch profiles/folders so
|
|
// the auto-default $effects can fill any zero/empty defaults.
|
|
const test = await testLidarrConnection({
|
|
base_url: baseUrl,
|
|
api_key: apiKeyInput
|
|
});
|
|
testResult = test;
|
|
if (!test.ok) {
|
|
saveError = test.error;
|
|
return;
|
|
}
|
|
testQualityProfiles = test.quality_profiles ?? [];
|
|
testMetadataProfiles = test.metadata_profiles ?? [];
|
|
testRootFolders = test.root_folders ?? [];
|
|
testListErrors = test.list_errors ?? {};
|
|
|
|
// Wait one tick for the auto-default $effects to fire on the
|
|
// newly-populated lists.
|
|
await tick();
|
|
|
|
if (qualityId === 0 || metadataId === 0 || rootPath === '') {
|
|
saveError = 'missing_defaults_after_test';
|
|
return;
|
|
}
|
|
|
|
const cfg: LidarrConfig = {
|
|
enabled: true,
|
|
base_url: baseUrl,
|
|
api_key: apiKeyInput, // empty string tells backend "preserve saved key"
|
|
default_quality_profile_id: qualityId,
|
|
default_metadata_profile_id: metadataId,
|
|
default_root_folder_path: rootPath
|
|
};
|
|
await putLidarrConfig(cfg);
|
|
// Invalidate config + profile/folder lists. A new base_url means a
|
|
// different server, so the cached lists must refetch.
|
|
await Promise.all([
|
|
client.invalidateQueries({ queryKey: qk.lidarrConfig() }),
|
|
client.invalidateQueries({ queryKey: qk.lidarrQualityProfiles() }),
|
|
client.invalidateQueries({ queryKey: qk.lidarrMetadataProfiles() }),
|
|
client.invalidateQueries({ queryKey: qk.lidarrRootFolders() })
|
|
]);
|
|
apiKeyInput = '';
|
|
} catch (e) {
|
|
const code = errCode(e);
|
|
saveError = code === 'unknown' ? 'save_failed' : code;
|
|
} finally {
|
|
isSaving = false;
|
|
}
|
|
}
|
|
|
|
async function onTest() {
|
|
isTesting = true;
|
|
testResult = null;
|
|
try {
|
|
const result = await testLidarrConnection({
|
|
base_url: baseUrl,
|
|
api_key: apiKeyInput
|
|
});
|
|
testResult = result;
|
|
if (result.ok) {
|
|
testQualityProfiles = result.quality_profiles ?? [];
|
|
testMetadataProfiles = result.metadata_profiles ?? [];
|
|
testRootFolders = result.root_folders ?? [];
|
|
testListErrors = result.list_errors ?? {};
|
|
}
|
|
} finally {
|
|
isTesting = false;
|
|
}
|
|
}
|
|
|
|
// Disconnect typed-confirm modal. Requires the literal "DISCONNECT" string
|
|
// (whitespace-trimmed) to avoid muscle-memory clearing of an integration the
|
|
// operator depends on.
|
|
let modalOpen = $state(false);
|
|
let disconnectInput = $state('');
|
|
let disconnectError = $state<string | null>(null);
|
|
const canDisconnect = $derived(disconnectInput.trim() === 'DISCONNECT');
|
|
|
|
async function onConfirmDisconnect() {
|
|
if (!canDisconnect) return;
|
|
disconnectError = null;
|
|
try {
|
|
await putLidarrConfig({
|
|
enabled: false,
|
|
base_url: '',
|
|
api_key: '',
|
|
default_quality_profile_id: 0,
|
|
default_metadata_profile_id: 0,
|
|
default_root_folder_path: ''
|
|
});
|
|
await Promise.all([
|
|
client.invalidateQueries({ queryKey: qk.lidarrConfig() }),
|
|
client.invalidateQueries({ queryKey: qk.lidarrQualityProfiles() }),
|
|
client.invalidateQueries({ queryKey: qk.lidarrMetadataProfiles() }),
|
|
client.invalidateQueries({ queryKey: qk.lidarrRootFolders() })
|
|
]);
|
|
modalOpen = false;
|
|
disconnectInput = '';
|
|
} catch (e) {
|
|
const code = errCode(e);
|
|
disconnectError = code === 'unknown' ? 'disconnect_failed' : code;
|
|
}
|
|
}
|
|
|
|
function cancelDisconnect() {
|
|
modalOpen = false;
|
|
disconnectInput = '';
|
|
disconnectError = null;
|
|
}
|
|
|
|
// ---- Cover art providers ----
|
|
const coverProvidersStore = $derived(createCoverProvidersQuery());
|
|
const coverProvidersQ = $derived($coverProvidersStore);
|
|
|
|
// Local edit state — keyed by provider_id. Initialised lazily when
|
|
// the query data lands, then preserved across refetches so the
|
|
// operator's in-flight edits don't get clobbered.
|
|
let coverLocalState = $state<Record<string, { enabled: boolean; apiKey: string }>>({});
|
|
let coverTestResults = $state<Record<string, { ok: boolean; duration_ms?: number; error?: string }>>({});
|
|
let coverSaving = $state<Record<string, boolean>>({});
|
|
let coverTesting = $state<Record<string, boolean>>({});
|
|
|
|
$effect(() => {
|
|
if (coverProvidersQ.data) {
|
|
for (const p of coverProvidersQ.data.providers) {
|
|
if (!coverLocalState[p.id]) {
|
|
coverLocalState[p.id] = { enabled: p.enabled, apiKey: '' };
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
function coverHasChanges(provider: CoverProvider): boolean {
|
|
const local = coverLocalState[provider.id];
|
|
if (!local) return false;
|
|
return provider.enabled !== local.enabled || local.apiKey !== '';
|
|
}
|
|
|
|
async function coverSave(provider: CoverProvider) {
|
|
const local = coverLocalState[provider.id];
|
|
if (!local || !coverHasChanges(provider)) return;
|
|
const patch: { enabled?: boolean; api_key?: string } = {};
|
|
if (provider.enabled !== local.enabled) patch.enabled = local.enabled;
|
|
if (local.apiKey !== '') patch.api_key = local.apiKey;
|
|
|
|
coverSaving[provider.id] = true;
|
|
try {
|
|
const result = await updateCoverProvider(provider.id, patch);
|
|
coverLocalState[provider.id] = { enabled: result.enabled, apiKey: '' };
|
|
await client.invalidateQueries({ queryKey: qk.coverProviders() });
|
|
if (result.version_bumped) {
|
|
await client.invalidateQueries({ queryKey: qk.coverage() });
|
|
}
|
|
} catch (_e) {
|
|
// Error display is handled inline via saveError pattern; swallow here.
|
|
} finally {
|
|
coverSaving[provider.id] = false;
|
|
}
|
|
}
|
|
|
|
async function coverTest(provider: CoverProvider) {
|
|
coverTesting[provider.id] = true;
|
|
try {
|
|
coverTestResults[provider.id] = await testCoverProvider(provider.id);
|
|
} catch (e) {
|
|
coverTestResults[provider.id] = { ok: false, error: (e as Error).message };
|
|
} finally {
|
|
coverTesting[provider.id] = false;
|
|
}
|
|
}
|
|
|
|
// SMTP config -------------------------------------------------------------
|
|
|
|
const smtpStore = $derived(createSMTPConfigQuery());
|
|
const smtpQ = $derived($smtpStore);
|
|
|
|
let smtpForm = $state<SMTPConfig>({
|
|
enabled: false, host: '', port: 587, username: '',
|
|
password: '', from_address: '', from_name: 'Minstrel', use_tls: true,
|
|
});
|
|
let smtpPasswordInput = $state('');
|
|
let smtpSaving = $state(false);
|
|
let smtpTesting = $state(false);
|
|
|
|
$effect(() => {
|
|
if (smtpQ.data) {
|
|
smtpForm = smtpQ.data;
|
|
smtpPasswordInput = '';
|
|
}
|
|
});
|
|
|
|
async function onSaveSMTP() {
|
|
smtpSaving = true;
|
|
try {
|
|
await updateSMTPConfig({ ...smtpForm, password: smtpPasswordInput });
|
|
await client.invalidateQueries({ queryKey: qk.smtpConfig() });
|
|
smtpPasswordInput = '';
|
|
pushToast('SMTP config saved.');
|
|
} catch (e: unknown) {
|
|
const code = errCode(e);
|
|
if (code === 'missing_fields') pushToast('Host and from address are required when enabled.', 'error');
|
|
else pushToast(`Save failed: ${code}`, 'error');
|
|
} finally {
|
|
smtpSaving = false;
|
|
}
|
|
}
|
|
|
|
async function onTestSMTP() {
|
|
smtpTesting = true;
|
|
try {
|
|
await testSMTPConfig();
|
|
pushToast('Test email sent. Check your inbox.');
|
|
} catch (e: unknown) {
|
|
const code = errCode(e);
|
|
const message = (e as { message?: string })?.message;
|
|
if (code === 'no_email_on_file') pushToast('Set your email in /settings before testing.', 'error');
|
|
else if (code === 'not_configured') pushToast('Save the SMTP config first.', 'error');
|
|
else if (code === 'send_failed') pushToast(`Send failed: ${message || 'see server logs'}`, 'error');
|
|
else pushToast(`Test failed: ${code}`, 'error');
|
|
} finally {
|
|
smtpTesting = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:head><title>{pageTitle('Admin · Integrations')}</title></svelte:head>
|
|
|
|
<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 && testQualityProfiles.length === 0}
|
|
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 (testQualityProfiles.length ? testQualityProfiles : (profiles.data ?? [])) as p (p.id)}
|
|
<option value={p.id}>{p.name}</option>
|
|
{/each}
|
|
</select>
|
|
{#if testListErrors.quality_profiles}
|
|
<p class="mt-1 text-xs text-error">
|
|
Couldn't fetch quality profiles — {testListErrors.quality_profiles}
|
|
</p>
|
|
{/if}
|
|
</label>
|
|
|
|
<label class="block">
|
|
<span class="block text-sm text-text-secondary">Default metadata profile</span>
|
|
<select
|
|
bind:value={metadataId}
|
|
disabled={!profilesEnabled && testMetadataProfiles.length === 0}
|
|
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 (testMetadataProfiles.length ? testMetadataProfiles : (metadataProfiles.data ?? [])) as p (p.id)}
|
|
<option value={p.id}>{p.name}</option>
|
|
{/each}
|
|
</select>
|
|
{#if testListErrors.metadata_profiles}
|
|
<p class="mt-1 text-xs text-error">
|
|
Couldn't fetch metadata profiles — {testListErrors.metadata_profiles}
|
|
</p>
|
|
{/if}
|
|
</label>
|
|
|
|
<label class="block">
|
|
<span class="block text-sm text-text-secondary">Default root folder</span>
|
|
<select
|
|
bind:value={rootPath}
|
|
disabled={!profilesEnabled && testRootFolders.length === 0}
|
|
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 (testRootFolders.length ? testRootFolders : (folders.data ?? [])) as f (f.path)}
|
|
<option value={f.path}
|
|
>{f.path}{f.accessible ? '' : ' (not accessible)'}</option
|
|
>
|
|
{/each}
|
|
</select>
|
|
{#if testListErrors.root_folders}
|
|
<p class="mt-1 text-xs text-error">
|
|
Couldn't fetch root folders — {testListErrors.root_folders}
|
|
</p>
|
|
{/if}
|
|
</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-action-fg 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-action-fg"
|
|
>
|
|
<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>
|
|
|
|
<!-- Cover art providers -->
|
|
<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">Cover art providers</h3>
|
|
<p class="mt-1 text-sm text-text-secondary">
|
|
External sources for album covers and artist images. Providers
|
|
are tried in registration order; first hit wins.
|
|
</p>
|
|
</div>
|
|
|
|
{#if coverProvidersQ.data}
|
|
{#each coverProvidersQ.data.providers as provider (provider.id)}
|
|
<article class="rounded-lg border border-border bg-background p-4 space-y-3">
|
|
<header class="flex items-baseline justify-between gap-3">
|
|
<div class="flex items-center gap-2">
|
|
<h4 class="font-display text-base font-medium text-text-primary">{provider.display_name}</h4>
|
|
<span
|
|
class="h-1.5 w-1.5 rounded-full {provider.enabled ? 'bg-action-primary' : 'bg-text-muted'}"
|
|
title={provider.enabled ? 'Active' : 'Disabled'}
|
|
></span>
|
|
</div>
|
|
<div class="flex flex-wrap gap-1">
|
|
{#each provider.supports as cap}
|
|
<span class="rounded bg-surface px-1.5 py-0.5 text-xs text-text-secondary">{cap.replace('_', ' ')}</span>
|
|
{/each}
|
|
</div>
|
|
</header>
|
|
|
|
{#if coverLocalState[provider.id]}
|
|
<div class="grid gap-2 sm:grid-cols-[auto_1fr] items-center">
|
|
<label class="text-sm text-text-secondary" for="enabled-{provider.id}">Enabled</label>
|
|
<div>
|
|
<input id="enabled-{provider.id}" type="checkbox"
|
|
bind:checked={coverLocalState[provider.id].enabled} />
|
|
</div>
|
|
|
|
{#if provider.requires_api_key}
|
|
<label class="text-sm text-text-secondary" for="key-{provider.id}">API key</label>
|
|
<div class="flex items-center gap-2">
|
|
<input id="key-{provider.id}" type="password"
|
|
placeholder={provider.api_key_set ? '••• (saved — leave empty to keep)' : 'Leave blank to use the public test key'}
|
|
bind:value={coverLocalState[provider.id].apiKey}
|
|
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 flex-1" />
|
|
{#if provider.api_key_set}
|
|
<span class="text-xs text-text-secondary whitespace-nowrap">✓ Set</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 pt-1">
|
|
<button type="button"
|
|
disabled={!coverHasChanges(provider) || coverSaving[provider.id]}
|
|
onclick={() => coverSave(provider)}
|
|
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50">
|
|
{coverSaving[provider.id] ? 'Saving…' : 'Save changes'}
|
|
</button>
|
|
{#if provider.testable}
|
|
<button type="button"
|
|
disabled={coverTesting[provider.id]}
|
|
onclick={() => coverTest(provider)}
|
|
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">
|
|
{coverTesting[provider.id] ? 'Testing…' : 'Test connection'}
|
|
</button>
|
|
{/if}
|
|
{#if coverTestResults[provider.id]}
|
|
{#if coverTestResults[provider.id].ok}
|
|
<p class="text-sm text-action-primary">
|
|
OK{coverTestResults[provider.id].duration_ms ? ` (${coverTestResults[provider.id].duration_ms}ms)` : ''}
|
|
</p>
|
|
{:else}
|
|
<p class="text-sm text-error">
|
|
Failed — {coverTestResults[provider.id].error}
|
|
</p>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if provider.id === 'theaudiodb'}
|
|
<p class="text-xs text-text-muted">
|
|
Public test key works out of the box. A free personal key gives separate quota
|
|
accounting — apply at <a class="underline" href="https://www.theaudiodb.com/api_apply.php" target="_blank" rel="noopener noreferrer">theaudiodb.com/api_apply.php</a>.
|
|
</p>
|
|
{/if}
|
|
</article>
|
|
{/each}
|
|
{:else if coverProvidersQ.isPending}
|
|
<p class="text-sm text-text-muted">Loading…</p>
|
|
{:else}
|
|
<p class="text-sm text-text-muted">No providers registered.</p>
|
|
{/if}
|
|
</section>
|
|
<!-- SMTP / email config -->
|
|
<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">Email (SMTP)</h3>
|
|
<p class="mt-1 text-sm text-text-secondary">
|
|
Configure outgoing email for password resets. The "Send test email"
|
|
button verifies your config end-to-end.
|
|
</p>
|
|
</div>
|
|
|
|
<label class="flex items-center gap-2 text-sm">
|
|
<input type="checkbox" bind:checked={smtpForm.enabled} />
|
|
Enabled
|
|
</label>
|
|
|
|
<div class="grid gap-3 sm:grid-cols-[auto_1fr] items-center">
|
|
<label for="smtp-host" class="text-sm text-text-secondary">Host</label>
|
|
<input id="smtp-host" type="text" bind:value={smtpForm.host}
|
|
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 for="smtp-port" class="text-sm text-text-secondary">Port</label>
|
|
<input id="smtp-port" type="number" min="1" max="65535"
|
|
bind:value={smtpForm.port}
|
|
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 for="smtp-username" class="text-sm text-text-secondary">Username</label>
|
|
<input id="smtp-username" type="text" bind:value={smtpForm.username}
|
|
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 for="smtp-password" class="text-sm text-text-secondary">Password</label>
|
|
<input id="smtp-password" type="password"
|
|
placeholder={smtpForm.password === '***' ? 'Leave blank to keep current' : ''}
|
|
bind:value={smtpPasswordInput}
|
|
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 for="smtp-from" class="text-sm text-text-secondary">From address</label>
|
|
<input id="smtp-from" type="email" bind:value={smtpForm.from_address}
|
|
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 for="smtp-from-name" class="text-sm text-text-secondary">From name</label>
|
|
<input id="smtp-from-name" type="text" bind:value={smtpForm.from_name}
|
|
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" />
|
|
|
|
<span class="text-sm text-text-secondary">TLS</span>
|
|
<label class="flex items-center gap-2 text-sm">
|
|
<input type="checkbox" bind:checked={smtpForm.use_tls} />
|
|
Use STARTTLS
|
|
</label>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 pt-2">
|
|
<button type="button" disabled={smtpSaving} onclick={onSaveSMTP}
|
|
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50">
|
|
{smtpSaving ? 'Saving…' : 'Save'}
|
|
</button>
|
|
<button type="button" disabled={smtpTesting} onclick={onTestSMTP}
|
|
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">
|
|
{smtpTesting ? 'Sending…' : 'Send test email'}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<Modal
|
|
title="Disconnect Lidarr?"
|
|
open={modalOpen}
|
|
onClose={cancelDisconnect}
|
|
>
|
|
<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"
|
|
/>
|
|
{#if disconnectError}
|
|
<p class="mt-2 text-sm text-error">Disconnect failed — {disconnectError}</p>
|
|
{/if}
|
|
<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-action-fg"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={onConfirmDisconnect}
|
|
disabled={!canDisconnect}
|
|
class="rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
|
|
>
|
|
Disconnect
|
|
</button>
|
|
</div>
|
|
</Modal>
|