refactor: provider descriptor registry — eliminate provider-specific hardcoding

Replace all if/else chains keyed on provider type strings with a
descriptor-driven architecture. Each provider type (immich, gitea,
planka, scheduler, nut, google_photos) has a descriptor in
frontend/src/lib/providers/ that declares config fields, event
tracking fields, collection metadata, validation, and hooks.

Components now use getDescriptor(type) and render dynamically.
Dashboard provider card shows provider name + type when global
filter is active. Grid-items derived from registry.
This commit is contained in:
2026-03-24 12:40:33 +03:00
parent c6bb2b5b51
commit 8cb836e16c
19 changed files with 904 additions and 353 deletions
+65 -111
View File
@@ -17,6 +17,7 @@
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
import type { ServiceProvider } from '$lib/types';
let allProviders = $derived(providersCache.items);
@@ -27,7 +28,7 @@
));
let showForm = $state(false);
let editing = $state<number | null>(null);
let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '', nut_host: '', nut_port: 3493, nut_username: '', nut_password: '' });
let form = $state(buildProviderFormDefaults());
let nameManuallyEdited = $state(false);
let error = $state('');
let loadError = $state('');
@@ -35,15 +36,13 @@
let loaded = $state(false);
let confirmDelete = $state<ServiceProvider | null>(null);
const providerDefaultNames: Record<string, string> = {
immich: 'Immich', gitea: 'Gitea', planka: 'Planka', scheduler: 'Scheduler', nut: 'NUT',
};
let descriptor = $derived(getDescriptor(form.type));
// Auto-update name when provider type changes (unless user manually edited)
$effect(() => {
const type = form.type;
if (!nameManuallyEdited && !editing) {
form.name = providerDefaultNames[type] || type;
const desc = getDescriptor(form.type);
if (!nameManuallyEdited && !editing && desc) {
form.name = desc.defaultName;
}
});
@@ -67,19 +66,33 @@
}
function openNew() {
form = { name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '', nut_host: '', nut_port: 3493, nut_username: '', nut_password: '' };
form = buildProviderFormDefaults();
nameManuallyEdited = false;
editing = null; showForm = true;
}
function edit(p: any) {
const cfg = p.config || {};
form = {
name: p.name, type: p.type, url: cfg.url || '',
api_key: '', api_token: '', webhook_secret: '',
external_domain: cfg.external_domain || '', icon: p.icon || '',
nut_host: cfg.host || '', nut_port: cfg.port || 3493,
nut_username: '', nut_password: '',
};
const base = buildProviderFormDefaults();
const desc = getDescriptor(p.type);
// Populate common fields
base.name = p.name;
base.type = p.type;
base.icon = p.icon || '';
base.url = cfg.url || '';
// Populate provider-specific fields from config using configKey mapping
if (desc) {
for (const field of desc.configFields) {
const cfgKey = field.configKey || field.key;
// Secrets (password fields) are blank on edit; non-secret fields load from config
if (field.type === 'password') {
base[field.key] = '';
} else {
base[field.key] = cfg[cfgKey] ?? field.defaultValue ?? '';
}
}
}
form = base;
nameManuallyEdited = true;
editing = p.id; showForm = true;
}
@@ -87,40 +100,19 @@
async function save(e: SubmitEvent) {
e.preventDefault(); error = ''; submitting = true;
try {
let config: any;
if (form.type === 'nut') {
config = { host: form.nut_host, port: form.nut_port || 3493 };
if (form.nut_username) config.username = form.nut_username;
if (form.nut_password) config.password = form.nut_password;
} else {
config = { url: form.url };
}
if (form.type === 'immich') {
if (form.api_key) config.api_key = form.api_key;
if (form.external_domain) config.external_domain = form.external_domain;
if (!editing) config.api_key = form.api_key;
} else if (form.type === 'gitea') {
if (form.api_token) config.api_token = form.api_token;
if (form.webhook_secret) config.webhook_secret = form.webhook_secret;
if (!editing && !form.webhook_secret) {
error = t('providers.webhookSecretRequired');
snackError(error); submitting = false; return;
}
} else if (form.type === 'planka') {
if (form.api_key) config.api_key = form.api_key;
if (form.webhook_secret) config.webhook_secret = form.webhook_secret;
if (!editing && !form.webhook_secret) {
error = t('providers.webhookSecretRequired');
snackError(error); submitting = false; return;
}
const desc = getDescriptor(form.type);
if (!desc) {
error = `Unknown provider type: ${form.type}`;
snackError(error); submitting = false; return;
}
const { config, error: buildError } = desc.buildConfig(form, !!editing);
if (buildError) {
error = t(buildError);
snackError(error); submitting = false; return;
}
if (editing) {
// Only send config if user changed a config field (secrets are blank on edit)
const hasConfigChange = form.url !== (providers.find(p => p.id === editing)?.config?.url || '') ||
(form.type === 'immich' && (form.api_key || form.external_domain !== (providers.find(p => p.id === editing)?.config?.external_domain || ''))) ||
(form.type === 'gitea' && (form.api_token || form.webhook_secret)) ||
(form.type === 'planka' && (form.api_key || form.webhook_secret)) ||
(form.type === 'nut');
const existing = providers.find(p => p.id === editing)?.config || {};
const hasConfigChange = desc.hasConfigChanged(form, existing);
const body: any = { name: form.name, icon: form.icon };
if (hasConfigChange) body.config = config;
await api(`/providers/${editing}`, { method: 'PUT', body: JSON.stringify(body) });
@@ -185,77 +177,40 @@
<input id="prv-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{#if form.type !== 'scheduler' && form.type !== 'nut'}
{#if descriptor?.hasUrl}
<div>
<label for="prv-url" class="block text-sm font-medium mb-1">{t('providers.url')}</label>
<input id="prv-url" bind:value={form.url} required placeholder={form.type === 'gitea' ? 'https://gitea.example.com' : form.type === 'planka' ? 'https://planka.example.com' : t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<input id="prv-url" bind:value={form.url} required placeholder={descriptor.urlPlaceholder || t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{/if}
{#if form.type === 'immich'}
{#each descriptor?.configFields ?? [] as field (field.key)}
<div>
<label for="prv-key" class="block text-sm font-medium mb-1">{editing ? t('providers.apiKeyKeep') : t('providers.apiKey')}</label>
<input id="prv-key" bind:value={form.api_key} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<label for="prv-{field.key}" class="block text-sm font-medium mb-1">
{t(editing && field.editLabel ? field.editLabel : field.label)}
{#if field.optional}<span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span>{/if}
</label>
{#if field.type === 'number'}
<input id="prv-{field.key}" type="number" bind:value={form[field.key]}
min={field.min} max={field.max}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else}
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
required={field.required === true || (field.required === 'create-only' && !editing)}
placeholder={field.placeholder || ''}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{/if}
{#if field.hint}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t(field.hint)}</p>
{/if}
</div>
<div>
<label for="prv-ext" class="block text-sm font-medium mb-1">{t('providers.externalDomain')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
<input id="prv-ext" bind:value={form.external_domain} placeholder="https://photos.example.com" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{:else if form.type === 'gitea'}
<div>
<label for="prv-secret" class="block text-sm font-medium mb-1">{editing ? t('providers.webhookSecretKeep') : t('providers.webhookSecret')}</label>
<input id="prv-secret" bind:value={form.webhook_secret} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookSecretHint')}</p>
</div>
<div>
<label for="prv-token" class="block text-sm font-medium mb-1">{t('providers.apiToken')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
<input id="prv-token" bind:value={form.api_token} type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.apiTokenHint')}</p>
</div>
{#if editing}
{/each}
{#if descriptor?.webhookUrlPattern && editing}
<div class="bg-[var(--color-muted)] rounded-md p-3">
<label class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</label>
<code class="text-xs select-all break-all">/api/webhooks/gitea/{editing}</code>
<code class="text-xs select-all break-all">{descriptor.webhookUrlPattern.replace('{id}', String(editing))}</code>
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookUrlHint')}</p>
</div>
{/if}
{:else if form.type === 'planka'}
<div>
<label for="prv-secret" class="block text-sm font-medium mb-1">{editing ? t('providers.webhookSecretKeep') : t('providers.webhookSecret')}</label>
<input id="prv-secret" bind:value={form.webhook_secret} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.plankaWebhookSecretHint')}</p>
</div>
<div>
<label for="prv-key" class="block text-sm font-medium mb-1">{t('providers.apiKey')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
<input id="prv-key" bind:value={form.api_key} type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.plankaApiKeyHint')}</p>
</div>
{#if editing}
<div class="bg-[var(--color-muted)] rounded-md p-3">
<label class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</label>
<code class="text-xs select-all break-all">/api/webhooks/planka/{editing}</code>
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.plankaWebhookUrlHint')}</p>
</div>
{/if}
{:else if form.type === 'nut'}
<div>
<label for="prv-nut-host" class="block text-sm font-medium mb-1">{t('providers.nutHost')}</label>
<input id="prv-nut-host" bind:value={form.nut_host} required placeholder={t('providers.nutHostPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="prv-nut-port" class="block text-sm font-medium mb-1">{t('providers.nutPort')}</label>
<input id="prv-nut-port" bind:value={form.nut_port} type="number" min="1" max="65535" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="prv-nut-user" class="block text-sm font-medium mb-1">{t('providers.nutUsername')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
<input id="prv-nut-user" bind:value={form.nut_username} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.nutUsernameHint')}</p>
</div>
<div>
<label for="prv-nut-pass" class="block text-sm font-medium mb-1">{t('providers.nutPassword')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
<input id="prv-nut-pass" bind:value={form.nut_password} type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.nutPasswordHint')}</p>
</div>
{/if}
<button type="submit" disabled={submitting}
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
{submitting ? t('providers.connecting') : (editing ? t('common.save') : t('providers.addProvider'))}
@@ -283,6 +238,7 @@
{:else}
<div class="space-y-3 stagger-children">
{#each providers as provider}
{@const provDesc = getDescriptor(provider.type)}
<Card hover entityId={provider.id}>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
@@ -298,10 +254,8 @@
{:else if provider.config?.host}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
{/if}
{#if provider.type === 'gitea'}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">{t('providers.webhookUrl')}: <span class="select-all">/api/webhooks/gitea/{provider.id}</span></p>
{:else if provider.type === 'planka'}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">{t('providers.webhookUrl')}: <span class="select-all">/api/webhooks/planka/{provider.id}</span></p>
{#if provDesc?.webhookUrlPattern}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">{t('providers.webhookUrl')}: <span class="select-all">{provDesc.webhookUrlPattern.replace('{id}', String(provider.id))}</span></p>
{/if}
</div>
</div>
+46 -24
View File
@@ -4,26 +4,28 @@
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import Card from '$lib/components/Card.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { providerTypeItems } from '$lib/grid-items';
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
let providerType = $state('immich');
let name = $state('');
let icon = $state('');
let url = $state('');
let apiKey = $state('');
let externalDomain = $state('');
let form = $state(buildProviderFormDefaults());
let error = $state('');
let testing = $state(false);
let saving = $state(false);
let descriptor = $derived(getDescriptor(form.type));
async function testAndSave() {
if (!url || !apiKey) { error = t('providers.urlApiKeyRequired'); return; }
const desc = descriptor;
if (!desc) { error = 'Select a provider type'; return; }
const { config, error: buildError } = desc.buildConfig(form, false);
if (buildError) { error = t(buildError); snackError(error); return; }
testing = true; error = '';
let createdId: number | null = null;
try {
const provider = await api('/providers', {
method: 'POST',
body: JSON.stringify({ type: providerType, name: name || 'Immich', icon, config: { url, api_key: apiKey, external_domain: externalDomain || undefined } }),
body: JSON.stringify({ type: form.type, name: form.name || desc.defaultName, icon: form.icon, config }),
});
createdId = provider.id;
const result = await api(`/providers/${provider.id}/test`, { method: 'POST' });
@@ -44,12 +46,16 @@
}
async function saveWithoutTest() {
if (!url || !apiKey) { error = t('providers.urlApiKeyRequired'); return; }
const desc = descriptor;
if (!desc) { error = 'Select a provider type'; return; }
const { config, error: buildError } = desc.buildConfig(form, false);
if (buildError) { error = t(buildError); snackError(error); return; }
saving = true; error = '';
try {
await api('/providers', {
method: 'POST',
body: JSON.stringify({ type: providerType, name: name || 'Immich', icon, config: { url, api_key: apiKey, external_domain: externalDomain || undefined } }),
body: JSON.stringify({ type: form.type, name: form.name || desc.defaultName, icon: form.icon, config }),
});
snackSuccess(t('snack.providerSaved'));
window.location.href = '/providers';
@@ -66,30 +72,46 @@
<Card>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">{t('providers.type')}</label>
<IconGridSelect items={providerTypeItems()} bind:value={form.type} columns={2} />
</div>
<div>
<label for="prv-name" class="block text-sm font-medium mb-1">{t('providers.name')}</label>
<div class="flex gap-2">
<IconPicker value={icon} onselect={(v: string) => icon = v} />
<input id="prv-name" bind:value={name} placeholder="My Immich Server" class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="prv-name" bind:value={form.name} placeholder={descriptor?.defaultName || ''} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{#if providerType === 'immich'}
{#if descriptor?.hasUrl}
<div>
<label for="prv-url" class="block text-sm font-medium mb-1">{t('providers.url')}</label>
<input id="prv-url" type="url" bind:value={url} required placeholder={t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="prv-key" class="block text-sm font-medium mb-1">{t('providers.apiKey')}</label>
<input id="prv-key" type="password" bind:value={apiKey} required autocomplete="off" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="prv-ext" class="block text-sm font-medium mb-1">{t('providers.externalDomain')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
<input id="prv-ext" type="url" bind:value={externalDomain} placeholder="https://photos.example.com" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.externalDomainHint')}</p>
<input id="prv-url" type="url" bind:value={form.url} required placeholder={descriptor.urlPlaceholder || t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{/if}
{#each descriptor?.configFields ?? [] as field (field.key)}
<div>
<label for="prv-{field.key}" class="block text-sm font-medium mb-1">
{t(field.label)}
{#if field.optional}<span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span>{/if}
</label>
{#if field.type === 'number'}
<input id="prv-{field.key}" type="number" bind:value={form[field.key]} min={field.min} max={field.max}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else}
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
required={field.required === true || field.required === 'create-only'}
placeholder={field.placeholder || ''} autocomplete="off"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{/if}
{#if field.hint}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t(field.hint)}</p>
{/if}
</div>
{/each}
{#if error}
<p class="text-sm text-[var(--color-error-fg)]">{error}</p>
{/if}