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)[] {