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
@@ -16,6 +16,7 @@
import { highlightFromUrl } from '$lib/highlight';
import { providerDefaultIcon } from '$lib/grid-items';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { getDescriptor } from '$lib/providers';
import type { Tracker, TrackerTarget, TrackingConfig, TemplateConfig, NotificationTarget } from '$lib/types';
import TrackerForm from './TrackerForm.svelte';
@@ -153,33 +154,22 @@
e.preventDefault(); error = '';
if (submitting) return;
const newAlbumIds = form.collection_ids.filter(id => !previousCollectionIds.includes(id));
if (newAlbumIds.length > 0 && form.provider_id && selectedProviderType === 'immich') {
// Delegate provider-specific pre-save checks to the descriptor
const desc = getDescriptor(selectedProviderType);
if (desc?.onBeforeSave && form.provider_id) {
linkCheckLoading = true;
try {
const missingAlbums: { id: string; name: string; issue: string }[] = [];
for (const albumId of newAlbumIds) {
interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean }
const links = await api<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
const validLink = links.find((l) => l.is_accessible && !l.is_expired);
if (!validLink) {
const album = collections.find(c => c.id === albumId);
const problematicLink = links.find((l) => l.is_expired || l.has_password);
missingAlbums.push({
id: albumId,
name: album?.albumName || album?.name || albumId,
issue: problematicLink
? (problematicLink.is_expired ? 'expired' : 'password-protected')
: 'missing',
});
const result = await desc.onBeforeSave({
form, previousCollectionIds, collections, api,
});
if (!result.proceed) {
if (result.warnings?.length) {
linkWarning = { albums: result.warnings, providerId: form.provider_id };
}
}
if (missingAlbums.length > 0) {
linkWarning = { albums: missingAlbums, providerId: form.provider_id };
linkCheckLoading = false;
return;
}
} catch (e) { console.warn('Shared link check failed, proceeding:', e); }
} catch (err) { console.warn('Pre-save check failed, proceeding:', err); }
linkCheckLoading = false;
}
@@ -277,15 +267,10 @@
return p?.name || `#${id}`;
}
const collectionCountLabel: Record<string, string> = {
immich: 'notificationTracker.albums_count',
gitea: 'notificationTracker.repos_count',
planka: 'notificationTracker.boards_count',
nut: 'notificationTracker.devices_count',
};
function getCollectionLabel(tracker: Tracker): string {
const pt = getProviderType(tracker);
return t(collectionCountLabel[pt] || 'notificationTracker.albums_count');
const desc = getDescriptor(pt);
return desc?.collectionMeta ? t(desc.collectionMeta.countLabel) : t('notificationTracker.collections_count');
}
function configsForTracker(tracker: Tracker, configs: (TrackingConfig | TemplateConfig)[]): (TrackingConfig | TemplateConfig)[] {
@@ -6,6 +6,7 @@
import Hint from '$lib/components/Hint.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
import { getDescriptor } from '$lib/providers';
interface Props {
form: {
@@ -45,17 +46,10 @@
formatDate,
}: Props = $props();
let descriptor = $derived(getDescriptor(providerType));
let isScheduler = $derived(providerType === 'scheduler');
let isWebhook = $derived(providerType === 'gitea' || providerType === 'planka');
// Collection label/icon/desc per provider type
const collectionMeta: Record<string, { label: string; icon: string; placeholder: string; desc: (col: any) => string }> = {
immich: { label: t('notificationTracker.albums'), icon: 'mdiImageMultiple', placeholder: t('notificationTracker.selectAlbums'), desc: (col) => `${col.assetCount ?? col.asset_count ?? 0} assets` },
gitea: { label: t('notificationTracker.repositories'), icon: 'mdiGit', placeholder: t('notificationTracker.selectRepositories'), desc: () => '' },
planka: { label: t('notificationTracker.boards'), icon: 'mdiViewDashboard', placeholder: t('notificationTracker.selectBoards'), desc: () => '' },
nut: { label: t('notificationTracker.upsDevices'), icon: 'mdiBatteryCharging80', placeholder: t('notificationTracker.selectUpsDevices'), desc: (col) => col.description || '' },
};
let colMeta = $derived(collectionMeta[providerType] || { label: t('notificationTracker.albums'), icon: 'mdiServer', placeholder: t('notificationTracker.selectAlbums'), desc: () => '' });
let isWebhook = $derived(descriptor?.webhookBased ?? false);
let colMeta = $derived(descriptor?.collectionMeta);
// Custom variable management for scheduler
function addVariable() {
@@ -99,9 +93,9 @@
<label class="block text-sm font-medium mb-1">{t('notificationTracker.server')}</label>
<EntitySelect items={providerItems} bind:value={form.provider_id} placeholder={t('notificationTracker.selectServer')} />
</div>
{#if !isScheduler && collections.length > 0}
{#if !isScheduler && colMeta && collections.length > 0}
<div>
<label class="block text-sm font-medium mb-1">{colMeta.label}</label>
<label class="block text-sm font-medium mb-1">{t(colMeta.label)}</label>
<MultiEntitySelect
items={collections.map(col => ({
value: col.id,
@@ -110,7 +104,7 @@
desc: colMeta.desc(col),
}))}
bind:values={form.collection_ids}
placeholder={colMeta.placeholder}
placeholder={t(colMeta.placeholder)}
/>
</div>
{/if}