feat: Actions system — scheduled mutations on external services

Full-stack implementation of provider-scoped Actions with extensible
executor architecture. First action type: Immich auto_organize (sort
assets into albums by person, CLIP search, date range, favorites).

Core:
- ActionTypeDefinition registry + ActionExecutor ABC with execute/validate/dry-run
- ImmichActionExecutor with multi-album support and client-side filtering
- ImmichClient write methods: add/remove assets, create album, paginated search

Server:
- Action, ActionRule, ActionExecution DB models
- Full CRUD API + manual execute + dry-run + execution history endpoints
- APScheduler integration (interval + cron) for automated execution
- Action type discovery API + provider people endpoint

Frontend:
- Actions page with CRUD, execute/dry-run buttons, inline rule editor
- RuleEditor: person/album MultiEntitySelect pickers, criteria config
- ExecutionHistory: expandable per-rule result details
- MultiEntitySelect reusable component (searchable multi-pick palette)
- Notification tracker album picker migrated to MultiEntitySelect
- Fixed MdiIcon race condition (icons missing after cache-clearing reload)
This commit is contained in:
2026-03-23 16:59:20 +03:00
parent 0fde3c6b3d
commit 6a559bfcd2
26 changed files with 2888 additions and 25 deletions
@@ -5,6 +5,7 @@
import IconPicker from '$lib/components/IconPicker.svelte';
import Hint from '$lib/components/Hint.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
interface Props {
form: {
@@ -18,15 +19,15 @@
};
providerItems: { value: number; label: string; icon: string; desc: string }[];
collections: any[];
collectionFilter: string;
collectionFilter?: string;
editing: number | null;
submitting: boolean;
linkCheckLoading: boolean;
error: string;
providerType: string;
onsave: (e: SubmitEvent) => void;
ontoggleCollection: (collectionId: string) => void;
formatDate: (dateStr: string) => string;
ontoggleCollection?: (collectionId: string) => void;
formatDate?: (dateStr: string) => string;
}
let {
@@ -91,22 +92,17 @@
</div>
{#if !isScheduler && collections.length > 0}
<div>
<label class="block text-sm font-medium mb-1">{t('notificationTracker.albums')} ({collections.length})</label>
<input type="text" bind:value={collectionFilter} placeholder="Filter..."
class="w-full px-3 py-1.5 mb-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<div class="max-h-56 overflow-y-auto border border-[var(--color-border)] rounded-md p-2 space-y-1">
{#each collections.filter(a => !collectionFilter || (a.albumName || a.name || '').toLowerCase().includes(collectionFilter.toLowerCase())) as col}
<label class="flex items-center justify-between text-sm cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
<span class="flex items-center gap-2">
<input type="checkbox" checked={form.collection_ids.includes(col.id)} onchange={() => ontoggleCollection(col.id)} />
{col.albumName || col.name} <span class="text-[var(--color-muted-foreground)]">({col.assetCount ?? col.asset_count ?? 0})</span>
</span>
{#if col.updatedAt || col.updated_at}
<span class="text-xs text-[var(--color-muted-foreground)] whitespace-nowrap ml-2">{formatDate(col.updatedAt || col.updated_at)}</span>
{/if}
</label>
{/each}
</div>
<label class="block text-sm font-medium mb-1">{t('notificationTracker.albums')}</label>
<MultiEntitySelect
items={collections.map(col => ({
value: col.id,
label: col.albumName || col.name,
icon: 'mdiImageMultiple',
desc: `${col.assetCount ?? col.asset_count ?? 0} assets`,
}))}
bind:values={form.collection_ids}
placeholder={t('notificationTracker.selectAlbums')}
/>
</div>
{/if}