9ad2e0343a
Add browser tab titles to all 18 static page routes using the pageTitle() helper from $lib/branding. Titles follow the Minstrel · Section format. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
379 lines
13 KiB
Svelte
379 lines
13 KiB
Svelte
<script lang="ts">
|
|
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
|
|
} 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 metadataId = $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;
|
|
metadataId = c.default_metadata_profile_id;
|
|
rootPath = c.default_root_folder_path;
|
|
initialized = true;
|
|
}
|
|
});
|
|
|
|
// Auto-default to the first option Lidarr returns when the operator
|
|
// hasn't picked one yet. Saves a click for the common case (one
|
|
// profile + one root folder, which is what most home setups have).
|
|
// The operator can still change any of them before saving.
|
|
$effect(() => {
|
|
if (qualityId === 0 && profiles.data && profiles.data.length > 0) {
|
|
qualityId = profiles.data[0].id;
|
|
}
|
|
});
|
|
$effect(() => {
|
|
if (metadataId === 0 && metadataProfiles.data && metadataProfiles.data.length > 0) {
|
|
metadataId = metadataProfiles.data[0].id;
|
|
}
|
|
});
|
|
$effect(() => {
|
|
if (rootPath === '' && folders.data && folders.data.length > 0) {
|
|
rootPath = folders.data[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 {
|
|
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) {
|
|
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
|
|
// (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) {
|
|
disconnectError = (e as { code?: string }).code ?? 'disconnect_failed';
|
|
}
|
|
}
|
|
|
|
function cancelDisconnect() {
|
|
modalOpen = false;
|
|
disconnectInput = '';
|
|
disconnectError = null;
|
|
}
|
|
</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 || 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 metadata profile</span>
|
|
<select
|
|
bind:value={metadataId}
|
|
disabled={!profilesEnabled || metadataProfiles.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 metadataProfiles.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-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>
|
|
</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"
|
|
/>
|
|
{#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>
|
|
</div>
|
|
</div>
|
|
{/if}
|