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:
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
import { pageTitle } from '$lib/branding';
|
import { pageTitle } from '$lib/branding';
|
||||||
import { Save, RefreshCw, Trash2 } from 'lucide-svelte';
|
import { Save, RefreshCw, Trash2 } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
@@ -43,6 +44,15 @@
|
|||||||
let rootPath = $state<string>('');
|
let rootPath = $state<string>('');
|
||||||
let initialized = false;
|
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(() => {
|
$effect(() => {
|
||||||
const c = config.data;
|
const c = config.data;
|
||||||
if (c && !initialized) {
|
if (c && !initialized) {
|
||||||
@@ -54,23 +64,25 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-default to the first option Lidarr returns when the operator
|
// Auto-default to the first available option. Sources, in priority
|
||||||
// hasn't picked one yet. Saves a click for the common case (one
|
// order: the test response (works pre-save), then the query data
|
||||||
// profile + one root folder, which is what most home setups have).
|
// (works post-save). The operator can still override after auto-fill.
|
||||||
// The operator can still change any of them before saving.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (qualityId === 0 && profiles.data && profiles.data.length > 0) {
|
if (qualityId === 0) {
|
||||||
qualityId = profiles.data[0].id;
|
const list = testQualityProfiles.length ? testQualityProfiles : (profiles.data ?? []);
|
||||||
|
if (list.length > 0) qualityId = list[0].id;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (metadataId === 0 && metadataProfiles.data && metadataProfiles.data.length > 0) {
|
if (metadataId === 0) {
|
||||||
metadataId = metadataProfiles.data[0].id;
|
const list = testMetadataProfiles.length ? testMetadataProfiles : (metadataProfiles.data ?? []);
|
||||||
|
if (list.length > 0) metadataId = list[0].id;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (rootPath === '' && folders.data && folders.data.length > 0) {
|
if (rootPath === '') {
|
||||||
rootPath = folders.data[0].path;
|
const list = testRootFolders.length ? testRootFolders : (folders.data ?? []);
|
||||||
|
if (list.length > 0) rootPath = list[0].path;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,6 +108,31 @@
|
|||||||
isSaving = true;
|
isSaving = true;
|
||||||
saveError = null;
|
saveError = null;
|
||||||
try {
|
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 = {
|
const cfg: LidarrConfig = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
base_url: baseUrl,
|
base_url: baseUrl,
|
||||||
@@ -126,10 +163,17 @@
|
|||||||
isTesting = true;
|
isTesting = true;
|
||||||
testResult = null;
|
testResult = null;
|
||||||
try {
|
try {
|
||||||
testResult = await testLidarrConnection({
|
const result = await testLidarrConnection({
|
||||||
base_url: baseUrl,
|
base_url: baseUrl,
|
||||||
api_key: apiKeyInput
|
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 {
|
} finally {
|
||||||
isTesting = false;
|
isTesting = false;
|
||||||
}
|
}
|
||||||
@@ -348,41 +392,56 @@
|
|||||||
<span class="block text-sm text-text-secondary">Default quality profile</span>
|
<span class="block text-sm text-text-secondary">Default quality profile</span>
|
||||||
<select
|
<select
|
||||||
bind:value={qualityId}
|
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"
|
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>
|
<option value={p.id}>{p.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</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>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span class="block text-sm text-text-secondary">Default metadata profile</span>
|
<span class="block text-sm text-text-secondary">Default metadata profile</span>
|
||||||
<select
|
<select
|
||||||
bind:value={metadataId}
|
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"
|
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>
|
<option value={p.id}>{p.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</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>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span class="block text-sm text-text-secondary">Default root folder</span>
|
<span class="block text-sm text-text-secondary">Default root folder</span>
|
||||||
<select
|
<select
|
||||||
bind:value={rootPath}
|
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"
|
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}
|
<option value={f.path}
|
||||||
>{f.path}{f.accessible ? '' : ' (not accessible)'}</option
|
>{f.path}{f.accessible ? '' : ' (not accessible)'}</option
|
||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
{#if testListErrors.root_folders}
|
||||||
|
<p class="mt-1 text-xs text-error">
|
||||||
|
Couldn't fetch root folders — {testListErrors.root_folders}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{#if testResult}
|
{#if testResult}
|
||||||
|
|||||||
Reference in New Issue
Block a user