feat(web/m7-cover-sources): admin /integrations cover-provider cards

Adds a "Cover art providers" section to the existing /admin/integrations
page with one card per registered provider (currently MBCAA + TheAudioDB).

Each card has:
- Provider name + capability badges (album cover · artist thumb · artist fanart)
- Status dot (green if enabled; muted if disabled)
- Enabled toggle + API key text field (only when requires_api_key)
- Save button (explicit save matches existing Lidarr card pattern)
- Test connection button (when testable) with inline ok/fail indicator
- Footer help line for TheAudioDB linking to the free key application

Save invalidates qk.coverProviders() always and qk.coverage() when
the PATCH response indicates version_bumped: true (so the coverage
gauge re-queries to show settled→with_art transitions on the next
scan).

API key field uses type=password with a placeholder that clarifies
the test-key fallback behaviour — never echoes back the stored key
(api_key_set bool from the GET response controls the "✓ Set"
indicator).

Local edit state is keyed by provider_id and survives query refetches
so in-flight edits aren't clobbered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 13:15:10 -04:00
parent 39a07a280c
commit 8307cbdbf9
2 changed files with 364 additions and 5 deletions
+160 -1
View File
@@ -8,7 +8,11 @@
createMetadataProfilesQuery,
createRootFoldersQuery,
putLidarrConfig,
testLidarrConnection
testLidarrConnection,
createCoverProvidersQuery,
updateCoverProvider,
testCoverProvider,
type CoverProvider
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
import type { LidarrConfig, LidarrTestResult } from '$lib/api/types';
@@ -161,6 +165,67 @@
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;
}
}
</script>
<svelte:head><title>{pageTitle('Admin · Integrations')}</title></svelte:head>
@@ -317,6 +382,100 @@
</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>
</div>
{#if modalOpen}