diff --git a/README.md b/README.md index 919e8f1..bad3f79 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ service: immich_album_watcher.refresh ### Get Assets -Get assets from a specific album with optional filtering and ordering (returns response data): +Get assets from a specific album with optional filtering and ordering (returns response data). Only returns fully processed assets (videos with completed transcoding, photos with generated thumbnails). ```yaml service: immich_album_watcher.get_assets @@ -224,7 +224,7 @@ data: chat_id: "-1001234567890" caption: | Album Updated! - New photos by {{ trigger.event.data.added_assets[0].asset_owner }} + New photos by {{ trigger.event.data.added_assets[0].owner }} View Album parse_mode: "HTML" # Default, can be omitted ``` @@ -257,7 +257,7 @@ data: | `chunk_delay` | Delay in milliseconds between sending multiple groups (0-60000). Useful for rate limiting. Default: 0 | No | | `wait_for_response` | Wait for Telegram to finish processing. Set to `false` for fire-and-forget (automation continues immediately). Default: `true` | No | | `max_asset_data_size` | Maximum asset size in bytes. Assets exceeding this limit will be skipped. Default: no limit | No | -| `send_large_photos_as_documents` | Handle photos exceeding Telegram limits (10MB or 10000px dimension sum). If `true`, send as documents. If `false`, downsize to fit. Default: `false` | No | +| `send_large_photos_as_documents` | Handle photos exceeding Telegram limits (10MB or 10000px dimension sum). If `true`, send as documents. If `false`, skip oversized photos. Default: `false` | No | The service returns a response with `success` status and `message_id` (single message), `message_ids` (media group), or `groups_sent` (number of groups when split). When `wait_for_response` is `false`, the service returns immediately with `{"success": true, "status": "queued"}` while processing continues in the background. @@ -338,17 +338,22 @@ Each item in the `added_assets` list contains the following fields: | Field | Description | |-------|-------------| | `id` | Unique asset ID | -| `asset_type` | Type of asset (`IMAGE` or `VIDEO`) | -| `asset_filename` | Original filename of the asset | -| `asset_created` | Date/time when the asset was originally created | -| `asset_owner` | Display name of the user who owns the asset | -| `asset_owner_id` | Unique ID of the user who owns the asset | -| `asset_description` | Description/caption of the asset (from EXIF data) | -| `asset_is_favorite` | Whether the asset is marked as favorite (`true` or `false`) | -| `asset_rating` | User rating of the asset (1-5 stars, or `null` if not rated) | -| `asset_url` | Public URL to view the asset (only present if album has a shared link) | +| `type` | Type of asset (`IMAGE` or `VIDEO`) | +| `filename` | Original filename of the asset | +| `created_at` | Date/time when the asset was originally created | +| `owner` | Display name of the user who owns the asset | +| `owner_id` | Unique ID of the user who owns the asset | +| `description` | Description/caption of the asset (from EXIF data) | +| `is_favorite` | Whether the asset is marked as favorite (`true` or `false`) | +| `rating` | User rating of the asset (1-5 stars, or `null` if not rated) | +| `url` | Public URL to view the asset (only present if album has a shared link) | +| `download_url` | Direct download URL for the original file (if shared link exists) | +| `playback_url` | Video playback URL (for VIDEO assets only, if shared link exists) | +| `photo_url` | Photo preview URL (for IMAGE assets only, if shared link exists) | | `people` | List of people detected in this specific asset | +> **Note:** Assets are only included in events and service responses when they are fully processed by Immich. For videos, this means transcoding must be complete (with `encodedVideoPath`). For photos, thumbnail generation must be complete (with `thumbhash`). This ensures that all media URLs are valid and accessible. Unprocessed assets are silently ignored until their processing completes. + Example accessing asset owner in an automation: ```yaml @@ -362,7 +367,7 @@ automation: data: title: "New Photos" message: > - {{ trigger.event.data.added_assets[0].asset_owner }} added + {{ trigger.event.data.added_assets[0].owner }} added {{ trigger.event.data.added_count }} photos to {{ trigger.event.data.album_name }} ``` diff --git a/custom_components/immich_album_watcher/coordinator.py b/custom_components/immich_album_watcher/coordinator.py index 9ac6419..bc993f6 100644 --- a/custom_components/immich_album_watcher/coordinator.py +++ b/custom_components/immich_album_watcher/coordinator.py @@ -120,6 +120,7 @@ class AssetInfo: people: list[str] = field(default_factory=list) is_favorite: bool = False rating: int | None = None + is_processed: bool = True # Whether asset is fully processed by Immich @classmethod def from_api_response( @@ -135,19 +136,24 @@ class AssetInfo: if users_cache and owner_id: owner_name = users_cache.get(owner_id, "") - # Get description from exifInfo if available - description = "" + # Get description - prioritize user-added description over EXIF description + description = data.get("description", "") or "" exif_info = data.get("exifInfo") - if exif_info: + if not description and exif_info: + # Fall back to EXIF description if no user description description = exif_info.get("description", "") or "" # Get favorites and rating is_favorite = data.get("isFavorite", False) - rating = data.get("exifInfo", {}).get("rating") if exif_info else None + rating = exif_info.get("rating") if exif_info else None + + # Check if asset is fully processed by Immich + asset_type = data.get("type", ASSET_TYPE_IMAGE) + is_processed = cls._check_processing_status(data, asset_type) return cls( id=data["id"], - type=data.get("type", ASSET_TYPE_IMAGE), + type=asset_type, filename=data.get("originalFileName", ""), created_at=data.get("fileCreatedAt", ""), owner_id=owner_id, @@ -156,8 +162,48 @@ class AssetInfo: people=people, is_favorite=is_favorite, rating=rating, + is_processed=is_processed, ) + @staticmethod + def _check_processing_status(data: dict[str, Any], asset_type: str) -> bool: + """Check if asset has been fully processed by Immich. + + For photos: Check if thumbnails/previews have been generated + For videos: Check if video transcoding is complete + + Args: + data: Asset data from API response + asset_type: Asset type (IMAGE or VIDEO) + + Returns: + True if asset is fully processed, False otherwise + """ + if asset_type == ASSET_TYPE_VIDEO: + # For videos, check if transcoding is complete + # Video is processed if it has an encoded video path or if isOffline is False + is_offline = data.get("isOffline", False) + if is_offline: + return False + + # Check if video has been transcoded (has encoded video path) + # Immich uses "encodedVideoPath" or similar field when transcoding is done + has_encoded_video = bool(data.get("encodedVideoPath")) + return has_encoded_video + + else: # ASSET_TYPE_IMAGE + # For photos, check if thumbnails have been generated + # Photos are processed if they have thumbnail/preview paths + is_offline = data.get("isOffline", False) + if is_offline: + return False + + # Check if thumbnails exist + has_thumbhash = bool(data.get("thumbhash")) + has_thumbnail = has_thumbhash # If thumbhash exists, thumbnails should exist + + return has_thumbnail + @dataclass class AlbumData: @@ -335,8 +381,8 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): if self.data is None: return [] - # Start with all assets - assets = list(self.data.assets.values()) + # Start with all processed assets only + assets = [a for a in self.data.assets.values() if a.is_processed] # Apply filtering if filter == "favorite": @@ -532,13 +578,13 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): return None def _get_asset_photo_url(self, asset_id: str) -> str | None: - """Get the transcoded/preview URL for a photo asset.""" + """Get the preview-sized thumbnail URL for a photo asset.""" accessible_links = self._get_accessible_links() if accessible_links: - return f"{self._url}/api/assets/{asset_id}/original?key={accessible_links[0].key}" + return f"{self._url}/api/assets/{asset_id}/thumbnail?size=preview&key={accessible_links[0].key}" non_expired = [link for link in self._shared_links if not link.is_expired] if non_expired: - return f"{self._url}/api/assets/{asset_id}/original?key={non_expired[0].key}" + return f"{self._url}/api/assets/{asset_id}/thumbnail?size=preview&key={non_expired[0].key}" return None def _build_asset_detail( @@ -712,34 +758,37 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): added_ids = new_state.asset_ids - old_state.asset_ids removed_ids = old_state.asset_ids - new_state.asset_ids + # Only include fully processed assets in added_assets + added_assets = [ + new_state.assets[aid] + for aid in added_ids + if aid in new_state.assets and new_state.assets[aid].is_processed + ] + # Detect metadata changes name_changed = old_state.name != new_state.name sharing_changed = old_state.shared != new_state.shared # Return None only if nothing changed at all - if not added_ids and not removed_ids and not name_changed and not sharing_changed: + if not added_assets and not removed_ids and not name_changed and not sharing_changed: return None - # Determine primary change type + # Determine primary change type (use added_assets not added_ids) change_type = "changed" - if name_changed and not added_ids and not removed_ids and not sharing_changed: + if name_changed and not added_assets and not removed_ids and not sharing_changed: change_type = "album_renamed" - elif sharing_changed and not added_ids and not removed_ids and not name_changed: + elif sharing_changed and not added_assets and not removed_ids and not name_changed: change_type = "album_sharing_changed" - elif added_ids and not removed_ids and not name_changed and not sharing_changed: + elif added_assets and not removed_ids and not name_changed and not sharing_changed: change_type = "assets_added" - elif removed_ids and not added_ids and not name_changed and not sharing_changed: + elif removed_ids and not added_assets and not name_changed and not sharing_changed: change_type = "assets_removed" - added_assets = [ - new_state.assets[aid] for aid in added_ids if aid in new_state.assets - ] - return AlbumChange( album_id=new_state.id, album_name=new_state.name, change_type=change_type, - added_count=len(added_ids), + added_count=len(added_assets), # Count only processed assets removed_count=len(removed_ids), added_assets=added_assets, removed_asset_ids=list(removed_ids), @@ -753,6 +802,9 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): """Fire Home Assistant events for album changes.""" added_assets_detail = [] for asset in change.added_assets: + # Only include fully processed assets + if not asset.is_processed: + continue asset_detail = self._build_asset_detail(asset, include_thumbnail=False) added_assets_detail.append(asset_detail) diff --git a/custom_components/immich_album_watcher/manifest.json b/custom_components/immich_album_watcher/manifest.json index 40fa965..a8ea28e 100644 --- a/custom_components/immich_album_watcher/manifest.json +++ b/custom_components/immich_album_watcher/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues", "requirements": [], - "version": "2.0.0" + "version": "2.1.0" } diff --git a/custom_components/immich_album_watcher/services.yaml b/custom_components/immich_album_watcher/services.yaml index 0e96eb4..6232612 100644 --- a/custom_components/immich_album_watcher/services.yaml +++ b/custom_components/immich_album_watcher/services.yaml @@ -167,7 +167,7 @@ send_telegram_notification: mode: box send_large_photos_as_documents: name: Send Large Photos As Documents - description: How to handle photos exceeding Telegram's limits (10MB or 10000px dimension sum). If true, send as documents. If false, downsize to fit limits. + description: How to handle photos exceeding Telegram's limits (10MB or 10000px dimension sum). If true, send as documents. If false, skip oversized photos. required: false default: false selector: