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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user