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
@@ -0,0 +1,112 @@
"""Action executor base class and result types.
Provides the abstract interface that provider-specific action executors must
implement. Lives in core (not server) so business logic stays testable
without database dependencies.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
@dataclass
class RuleResult:
"""Outcome of executing a single rule."""
rule_name: str
success: bool
items_matched: int = 0
items_affected: int = 0
items_skipped: int = 0
error: str | None = None
details: dict[str, Any] = field(default_factory=dict)
@dataclass
class ActionResult:
"""Aggregate outcome of executing an action (all rules)."""
success: bool
rules_processed: int = 0
rules_succeeded: int = 0
rules_failed: int = 0
total_items_affected: int = 0
rule_results: list[RuleResult] = field(default_factory=list)
error: str | None = None
def to_dict(self) -> dict[str, Any]:
"""Serialize to a JSON-compatible dict for storage."""
return {
"success": self.success,
"rules_processed": self.rules_processed,
"rules_succeeded": self.rules_succeeded,
"rules_failed": self.rules_failed,
"total_items_affected": self.total_items_affected,
"rule_results": [
{
"rule_name": r.rule_name,
"success": r.success,
"items_matched": r.items_matched,
"items_affected": r.items_affected,
"items_skipped": r.items_skipped,
"error": r.error,
"details": r.details,
}
for r in self.rule_results
],
"error": self.error,
}
class ActionExecutor(ABC):
"""Abstract base class for provider-specific action executors.
Each provider that supports actions implements a concrete executor.
The executor receives rule configs (plain dicts) and performs the
actual mutations on the external service.
"""
@abstractmethod
async def execute(
self,
action_type: str,
rules: list[dict[str, Any]],
config: dict[str, Any],
) -> ActionResult:
"""Execute the action with given rules.
Args:
action_type: The action type key (e.g. "auto_organize").
rules: List of rule_config dicts (from ActionRule rows).
config: Action-level config dict.
Returns:
ActionResult with per-rule outcomes.
"""
@abstractmethod
async def validate_rules(
self,
action_type: str,
rules: list[dict[str, Any]],
) -> list[str]:
"""Validate rules before saving.
Returns a list of error messages. Empty list means valid.
"""
@abstractmethod
async def dry_run(
self,
action_type: str,
rules: list[dict[str, Any]],
config: dict[str, Any],
) -> ActionResult:
"""Preview what would happen without mutating anything.
Returns the same ActionResult shape as execute(), but
items_affected reflects what *would* be changed.
"""
@@ -0,0 +1,126 @@
"""Action type registry.
Defines what action types each provider supports. Used by the API to expose
available actions and validate action configurations.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class ActionTypeDefinition:
"""Describes one type of action a provider supports."""
key: str # e.g. "auto_organize"
provider_type: str # e.g. "immich"
display_name: str # e.g. "Auto-Organize"
description: str
rule_schema: dict[str, Any] = field(default_factory=dict)
config_schema: dict[str, Any] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
_ACTION_TYPE_REGISTRY: dict[str, list[ActionTypeDefinition]] = {}
def register_action_type(defn: ActionTypeDefinition) -> None:
"""Register an action type for a provider."""
_ACTION_TYPE_REGISTRY.setdefault(defn.provider_type, []).append(defn)
def get_action_types(provider_type: str) -> list[ActionTypeDefinition]:
"""Get all action types for a provider."""
return list(_ACTION_TYPE_REGISTRY.get(provider_type, []))
def get_action_type(provider_type: str, key: str) -> ActionTypeDefinition | None:
"""Get a specific action type by provider and key."""
for defn in _ACTION_TYPE_REGISTRY.get(provider_type, []):
if defn.key == key:
return defn
return None
def get_all_action_types() -> dict[str, list[ActionTypeDefinition]]:
"""Get all registered action types grouped by provider."""
return {k: list(v) for k, v in _ACTION_TYPE_REGISTRY.items()}
# ---------------------------------------------------------------------------
# Immich action types
# ---------------------------------------------------------------------------
IMMICH_AUTO_ORGANIZE = ActionTypeDefinition(
key="auto_organize",
provider_type="immich",
display_name="Auto-Organize",
description="Sort assets into albums by person, search query, date, or favorites",
rule_schema={
"type": "object",
"properties": {
"criteria": {
"type": "object",
"properties": {
"person_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Immich person UUIDs",
},
"person_names": {
"type": "array",
"items": {"type": "string"},
"description": "Display names (UI only)",
},
"query": {
"type": "string",
"description": "Smart search query (CLIP)",
},
"asset_type": {
"type": "string",
"enum": ["all", "image", "video"],
"default": "all",
},
"date_from": {
"type": "string",
"format": "date",
"description": "ISO date lower bound",
},
"date_to": {
"type": "string",
"format": "date",
"description": "ISO date upper bound",
},
"favorite_only": {
"type": "boolean",
"default": False,
},
},
},
"target_album_id": {
"type": "string",
"description": "Immich album UUID",
},
"target_album_name": {
"type": "string",
"description": "Display name (UI only)",
},
"create_album_if_missing": {
"type": "boolean",
"default": False,
},
"create_album_name": {
"type": "string",
"description": "Name for auto-created album",
},
},
"required": ["criteria", "target_album_id"],
},
)
register_action_type(IMMICH_AUTO_ORGANIZE)
@@ -35,6 +35,9 @@ class ProviderCapabilities:
# Whether this provider receives webhooks (vs polling)
webhook_based: bool = False
# Action types this provider supports (used by Actions feature)
action_types: list[dict[str, str]] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Immich provider capabilities
@@ -46,6 +49,13 @@ IMMICH_CAPABILITIES = ProviderCapabilities(
supported_filters=[
{"key": "collections", "label": "Albums", "type": "select", "source": "api"},
],
action_types=[
{
"key": "auto_organize",
"name": "Auto-Organize",
"description": "Sort assets into albums by person, search query, date, or favorites",
},
],
notification_slots=[
{"name": "message_assets_added", "description": "New assets added to album"},
{"name": "message_assets_removed", "description": "Assets removed from album"},
@@ -0,0 +1,292 @@
"""Immich action executor — implements auto_organize and future action types."""
from __future__ import annotations
import logging
from typing import Any
from ..action_executor import ActionExecutor, ActionResult, RuleResult
from .client import ImmichApiError, ImmichClient
_LOGGER = logging.getLogger(__name__)
class ImmichActionExecutor(ActionExecutor):
"""Executes actions against an Immich server."""
def __init__(self, client: ImmichClient) -> None:
self._client = client
async def execute(
self,
action_type: str,
rules: list[dict[str, Any]],
config: dict[str, Any],
) -> ActionResult:
if action_type == "auto_organize":
return await self._execute_auto_organize(rules, config, dry_run=False)
return ActionResult(success=False, error=f"Unknown action type: {action_type}")
async def validate_rules(
self,
action_type: str,
rules: list[dict[str, Any]],
) -> list[str]:
errors: list[str] = []
if action_type == "auto_organize":
for i, rule in enumerate(rules):
criteria = rule.get("criteria", {})
if not criteria:
errors.append(f"Rule {i + 1}: criteria is required")
target = rule.get("target_album_id", "")
create = rule.get("create_album_if_missing", False)
if not target and not create:
errors.append(
f"Rule {i + 1}: target_album_id is required "
"unless create_album_if_missing is true"
)
if create and not rule.get("create_album_name"):
errors.append(
f"Rule {i + 1}: create_album_name is required "
"when create_album_if_missing is true"
)
# Must have at least one criteria source
has_source = bool(
criteria.get("person_ids")
or criteria.get("query")
)
if not has_source:
errors.append(
f"Rule {i + 1}: criteria must include "
"at least person_ids or query"
)
else:
errors.append(f"Unknown action type: {action_type}")
return errors
async def dry_run(
self,
action_type: str,
rules: list[dict[str, Any]],
config: dict[str, Any],
) -> ActionResult:
if action_type == "auto_organize":
return await self._execute_auto_organize(rules, config, dry_run=True)
return ActionResult(success=False, error=f"Unknown action type: {action_type}")
# ------------------------------------------------------------------
# auto_organize implementation
# ------------------------------------------------------------------
async def _execute_auto_organize(
self,
rules: list[dict[str, Any]],
config: dict[str, Any],
*,
dry_run: bool,
) -> ActionResult:
rule_results: list[RuleResult] = []
total_affected = 0
for rule in rules:
result = await self._execute_single_organize_rule(rule, dry_run=dry_run)
rule_results.append(result)
if result.success:
total_affected += result.items_affected
succeeded = sum(1 for r in rule_results if r.success)
failed = len(rule_results) - succeeded
if failed == 0:
status = True
elif succeeded == 0:
status = False
else:
status = True # partial success is still "success" at action level
return ActionResult(
success=status,
rules_processed=len(rule_results),
rules_succeeded=succeeded,
rules_failed=failed,
total_items_affected=total_affected,
rule_results=rule_results,
)
async def _execute_single_organize_rule(
self,
rule: dict[str, Any],
*,
dry_run: bool,
) -> RuleResult:
rule_name = rule.get("name", rule.get("target_album_name", "unnamed"))
criteria = rule.get("criteria", {})
create_if_missing = rule.get("create_album_if_missing", False)
create_album_name = rule.get("create_album_name", "")
# Support both target_album_ids (array) and target_album_id (single, backward compat)
target_album_ids: list[str] = list(rule.get("target_album_ids") or [])
if not target_album_ids:
single = rule.get("target_album_id", "")
if single:
target_album_ids = [single]
try:
# Step 1: Gather candidate assets from criteria
candidate_ids = await self._gather_candidates(criteria)
if not candidate_ids:
return RuleResult(
rule_name=rule_name,
success=True,
items_matched=0,
items_affected=0,
items_skipped=0,
details={"message": "No assets matched criteria"},
)
# If no target albums and create_if_missing, create one
if not target_album_ids and create_if_missing and create_album_name:
if dry_run:
_LOGGER.info("[DRY RUN] Would create album '%s'", create_album_name)
target_album_ids = ["__dry_run_new__"]
else:
created = await self._client.create_album(create_album_name)
target_album_ids = [created.get("id", "")]
_LOGGER.info("Created album '%s' with id %s", create_album_name, target_album_ids[0])
if not target_album_ids:
return RuleResult(
rule_name=rule_name,
success=False,
error="No target albums specified",
)
# Step 2-4: For each target album, diff and add
total_affected = 0
total_skipped = 0
album_details: list[dict[str, Any]] = []
for album_id in target_album_ids:
album_asset_ids: set[str] = set()
if album_id and album_id != "__dry_run_new__":
album = await self._client.get_album(album_id)
if album is None and create_if_missing and create_album_name:
if not dry_run:
created = await self._client.create_album(create_album_name)
album_id = created.get("id", album_id)
_LOGGER.info("Created album '%s' with id %s", create_album_name, album_id)
elif album is None:
album_details.append({"album_id": album_id, "error": "not found"})
continue
elif album is not None:
album_asset_ids = set(album.asset_ids)
new_asset_ids = [aid for aid in candidate_ids if aid not in album_asset_ids]
skipped = len(candidate_ids) - len(new_asset_ids)
if new_asset_ids and not dry_run and album_id:
for i in range(0, len(new_asset_ids), 500):
batch = new_asset_ids[i : i + 500]
await self._client.add_assets_to_album(album_id, batch)
_LOGGER.info("Added %d assets to album %s", len(new_asset_ids), album_id)
elif dry_run and new_asset_ids:
_LOGGER.info("[DRY RUN] Would add %d assets to album %s", len(new_asset_ids), album_id)
total_affected += len(new_asset_ids)
total_skipped += skipped
album_details.append({"album_id": album_id, "added": len(new_asset_ids), "skipped": skipped})
return RuleResult(
rule_name=rule_name,
success=True,
items_matched=len(candidate_ids),
items_affected=total_affected,
items_skipped=total_skipped,
details={
"target_album_ids": target_album_ids,
"albums": album_details,
"dry_run": dry_run,
},
)
except ImmichApiError as err:
_LOGGER.error("Rule '%s' failed: %s", rule_name, err)
return RuleResult(
rule_name=rule_name,
success=False,
error=str(err),
)
except Exception as err:
_LOGGER.error("Unexpected error in rule '%s': %s", rule_name, err)
return RuleResult(
rule_name=rule_name,
success=False,
error=f"Unexpected error: {err}",
)
async def _gather_candidates(
self, criteria: dict[str, Any]
) -> list[str]:
"""Gather asset IDs matching the criteria (union of all sources)."""
seen: set[str] = set()
result: list[str] = []
# Source 1: Person assets
person_ids = criteria.get("person_ids", [])
for pid in person_ids:
assets = await self._client.get_person_assets_all(pid)
for asset in assets:
aid = asset.get("id", "")
if aid and aid not in seen:
if self._matches_filters(asset, criteria):
seen.add(aid)
result.append(aid)
# Source 2: Smart search
query = criteria.get("query", "")
if query:
assets = await self._client.search_smart_all(query)
for asset in assets:
aid = asset.get("id", "")
if aid and aid not in seen:
if self._matches_filters(asset, criteria):
seen.add(aid)
result.append(aid)
return result
def _matches_filters(
self, asset: dict[str, Any], criteria: dict[str, Any]
) -> bool:
"""Apply client-side filters (asset_type, date range, favorites)."""
# Asset type filter
asset_type_filter = criteria.get("asset_type", "all")
if asset_type_filter != "all":
asset_type = (asset.get("type") or "").lower()
if asset_type_filter == "image" and asset_type != "image":
return False
if asset_type_filter == "video" and asset_type != "video":
return False
# Favorite filter
if criteria.get("favorite_only") and not asset.get("isFavorite"):
return False
# Date range filter
date_from = criteria.get("date_from")
date_to = criteria.get("date_to")
if date_from or date_to:
created = asset.get("fileCreatedAt") or asset.get("createdAt") or ""
if created:
try:
asset_date = created[:10] # "YYYY-MM-DD"
if date_from and asset_date < date_from:
return False
if date_to and asset_date > date_to:
return False
except (ValueError, IndexError):
pass
return True
@@ -322,6 +322,120 @@ class ImmichClient:
_LOGGER.warning("Failed to fetch memories: %s", err)
return []
# ------------------------------------------------------------------
# Write methods (used by action executors)
# ------------------------------------------------------------------
async def add_assets_to_album(
self, album_id: str, asset_ids: list[str]
) -> dict[str, Any]:
"""Add assets to an album. Returns API response with success/error arrays."""
payload = {"ids": asset_ids}
try:
async with self._session.put(
f"{self._url}/api/albums/{album_id}/assets",
headers=self._json_headers,
json=payload,
) as response:
if response.status == 200:
return await response.json()
raise ImmichApiError(
f"Failed to add assets to album {album_id}: HTTP {response.status}"
)
except aiohttp.ClientError as err:
raise ImmichApiError(f"Error adding assets to album: {err}") from err
async def remove_assets_from_album(
self, album_id: str, asset_ids: list[str]
) -> dict[str, Any]:
"""Remove assets from an album."""
payload = {"ids": asset_ids}
try:
async with self._session.delete(
f"{self._url}/api/albums/{album_id}/assets",
headers=self._json_headers,
json=payload,
) as response:
if response.status == 200:
return await response.json()
raise ImmichApiError(
f"Failed to remove assets from album {album_id}: HTTP {response.status}"
)
except aiohttp.ClientError as err:
raise ImmichApiError(f"Error removing assets from album: {err}") from err
async def create_album(
self, name: str, asset_ids: list[str] | None = None
) -> dict[str, Any]:
"""Create a new album, optionally with initial assets."""
payload: dict[str, Any] = {"albumName": name}
if asset_ids:
payload["assetIds"] = asset_ids
try:
async with self._session.post(
f"{self._url}/api/albums",
headers=self._json_headers,
json=payload,
) as response:
if response.status == 201:
return await response.json()
raise ImmichApiError(
f"Failed to create album '{name}': HTTP {response.status}"
)
except aiohttp.ClientError as err:
raise ImmichApiError(f"Error creating album: {err}") from err
async def get_person_assets_all(self, person_id: str) -> list[dict[str, Any]]:
"""Fetch ALL assets for a person (no limit)."""
try:
async with self._session.get(
f"{self._url}/api/people/{person_id}/assets",
headers=self._headers,
) as response:
if response.status == 200:
data = await response.json()
return data if isinstance(data, list) else []
if response.status == 404:
return []
raise ImmichApiError(
f"Failed to fetch person {person_id} assets: HTTP {response.status}"
)
except aiohttp.ClientError as err:
raise ImmichApiError(f"Error fetching person assets: {err}") from err
async def search_smart_all(
self, query: str, limit: int = 1000
) -> list[dict[str, Any]]:
"""Smart search with pagination up to limit."""
all_items: list[dict[str, Any]] = []
page = 1
page_size = min(limit, 100)
while len(all_items) < limit:
payload = {"query": query, "page": page, "size": page_size}
try:
async with self._session.post(
f"{self._url}/api/search/smart",
headers=self._json_headers,
json=payload,
) as response:
if response.status != 200:
break
data = await response.json()
items = data.get("assets", {}).get("items", [])
if not items:
break
all_items.extend(items)
if len(items) < page_size:
break
page += 1
except aiohttp.ClientError:
break
return all_items[:limit]
# ------------------------------------------------------------------
# Read methods (continued)
# ------------------------------------------------------------------
async def get_asset_thumbnail(self, asset_id: str, size: str = "preview") -> bytes | None:
try:
async with self._session.get(