feat(web/admin): Save runs Test first, dropdowns honor test response

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>
This commit is contained in:
2026-05-08 23:08:58 -04:00
parent abb384f479
commit bca8622640
+76 -17
View File
@@ -1,4 +1,5 @@
<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';
@@ -43,6 +44,15 @@
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) {
@@ -54,23 +64,25 @@
}
});
// 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.
// 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 && profiles.data && profiles.data.length > 0) {
qualityId = profiles.data[0].id;
if (qualityId === 0) {
const list = testQualityProfiles.length ? testQualityProfiles : (profiles.data ?? []);
if (list.length > 0) qualityId = list[0].id;
}
});
$effect(() => {
if (metadataId === 0 && metadataProfiles.data && metadataProfiles.data.length > 0) {
metadataId = metadataProfiles.data[0].id;
if (metadataId === 0) {
const list = testMetadataProfiles.length ? testMetadataProfiles : (metadataProfiles.data ?? []);
if (list.length > 0) metadataId = list[0].id;
}
});
$effect(() => {
if (rootPath === '' && folders.data && folders.data.length > 0) {
rootPath = folders.data[0].path;
if (rootPath === '') {
const list = testRootFolders.length ? testRootFolders : (folders.data ?? []);
if (list.length > 0) rootPath = list[0].path;
}
});
@@ -96,6 +108,31 @@
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,
@@ -126,10 +163,17 @@
isTesting = true;
testResult = null;
try {
testResult = await testLidarrConnection({
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;
}
@@ -348,41 +392,56 @@
<span class="block text-sm text-text-secondary">Default quality profile</span>
<select
bind:value={qualityId}
disabled={!profilesEnabled || profiles.isPending}
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 profiles.data ?? [] as p (p.id)}
{#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 || metadataProfiles.isPending}
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 metadataProfiles.data ?? [] as p (p.id)}
{#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 || folders.isPending}
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 folders.data ?? [] as f (f.path)}
{#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}