Add asset preprocessing filter and enhance asset data
All checks were successful
Validate / Hassfest (push) Successful in 3s

Features:
- Filter unprocessed assets from events and get_assets service
  - Videos require completed transcoding (encodedVideoPath)
  - Photos require generated thumbnails (thumbhash)
- Add photo_url field for images (preview-sized thumbnail)
- Simplify asset attribute names (remove asset_ prefix)
- Prioritize user-added descriptions over EXIF descriptions

Documentation:
- Update README with new asset fields and preprocessing note
- Update services.yaml parameter descriptions

Version: 2.1.0

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 01:14:21 +03:00
parent 8714685d5e
commit e5e45f0fbf
4 changed files with 93 additions and 36 deletions

View File

@@ -105,7 +105,7 @@ service: immich_album_watcher.refresh
### Get Assets ### 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 ```yaml
service: immich_album_watcher.get_assets service: immich_album_watcher.get_assets
@@ -224,7 +224,7 @@ data:
chat_id: "-1001234567890" chat_id: "-1001234567890"
caption: | caption: |
<b>Album Updated!</b> <b>Album Updated!</b>
New photos by <i>{{ trigger.event.data.added_assets[0].asset_owner }}</i> New photos by <i>{{ trigger.event.data.added_assets[0].owner }}</i>
<a href="https://immich.example.com/album">View Album</a> <a href="https://immich.example.com/album">View Album</a>
parse_mode: "HTML" # Default, can be omitted 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 | | `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 | | `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 | | `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. 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 | | Field | Description |
|-------|-------------| |-------|-------------|
| `id` | Unique asset ID | | `id` | Unique asset ID |
| `asset_type` | Type of asset (`IMAGE` or `VIDEO`) | | `type` | Type of asset (`IMAGE` or `VIDEO`) |
| `asset_filename` | Original filename of the asset | | `filename` | Original filename of the asset |
| `asset_created` | Date/time when the asset was originally created | | `created_at` | Date/time when the asset was originally created |
| `asset_owner` | Display name of the user who owns the asset | | `owner` | Display name of the user who owns the asset |
| `asset_owner_id` | Unique ID of the user who owns the asset | | `owner_id` | Unique ID of the user who owns the asset |
| `asset_description` | Description/caption of the asset (from EXIF data) | | `description` | Description/caption of the asset (from EXIF data) |
| `asset_is_favorite` | Whether the asset is marked as favorite (`true` or `false`) | | `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) | | `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) | | `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 | | `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: Example accessing asset owner in an automation:
```yaml ```yaml
@@ -362,7 +367,7 @@ automation:
data: data:
title: "New Photos" title: "New Photos"
message: > 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 }} {{ trigger.event.data.added_count }} photos to {{ trigger.event.data.album_name }}
``` ```

View File

@@ -120,6 +120,7 @@ class AssetInfo:
people: list[str] = field(default_factory=list) people: list[str] = field(default_factory=list)
is_favorite: bool = False is_favorite: bool = False
rating: int | None = None rating: int | None = None
is_processed: bool = True # Whether asset is fully processed by Immich
@classmethod @classmethod
def from_api_response( def from_api_response(
@@ -135,19 +136,24 @@ class AssetInfo:
if users_cache and owner_id: if users_cache and owner_id:
owner_name = users_cache.get(owner_id, "") owner_name = users_cache.get(owner_id, "")
# Get description from exifInfo if available # Get description - prioritize user-added description over EXIF description
description = "" description = data.get("description", "") or ""
exif_info = data.get("exifInfo") 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 "" description = exif_info.get("description", "") or ""
# Get favorites and rating # Get favorites and rating
is_favorite = data.get("isFavorite", False) 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( return cls(
id=data["id"], id=data["id"],
type=data.get("type", ASSET_TYPE_IMAGE), type=asset_type,
filename=data.get("originalFileName", ""), filename=data.get("originalFileName", ""),
created_at=data.get("fileCreatedAt", ""), created_at=data.get("fileCreatedAt", ""),
owner_id=owner_id, owner_id=owner_id,
@@ -156,8 +162,48 @@ class AssetInfo:
people=people, people=people,
is_favorite=is_favorite, is_favorite=is_favorite,
rating=rating, 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 @dataclass
class AlbumData: class AlbumData:
@@ -335,8 +381,8 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
if self.data is None: if self.data is None:
return [] return []
# Start with all assets # Start with all processed assets only
assets = list(self.data.assets.values()) assets = [a for a in self.data.assets.values() if a.is_processed]
# Apply filtering # Apply filtering
if filter == "favorite": if filter == "favorite":
@@ -532,13 +578,13 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
return None return None
def _get_asset_photo_url(self, asset_id: str) -> str | 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() accessible_links = self._get_accessible_links()
if 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] non_expired = [link for link in self._shared_links if not link.is_expired]
if non_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 return None
def _build_asset_detail( def _build_asset_detail(
@@ -712,34 +758,37 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
added_ids = new_state.asset_ids - old_state.asset_ids added_ids = new_state.asset_ids - old_state.asset_ids
removed_ids = old_state.asset_ids - new_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 # Detect metadata changes
name_changed = old_state.name != new_state.name name_changed = old_state.name != new_state.name
sharing_changed = old_state.shared != new_state.shared sharing_changed = old_state.shared != new_state.shared
# Return None only if nothing changed at all # 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 return None
# Determine primary change type # Determine primary change type (use added_assets not added_ids)
change_type = "changed" 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" 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" 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" 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" change_type = "assets_removed"
added_assets = [
new_state.assets[aid] for aid in added_ids if aid in new_state.assets
]
return AlbumChange( return AlbumChange(
album_id=new_state.id, album_id=new_state.id,
album_name=new_state.name, album_name=new_state.name,
change_type=change_type, change_type=change_type,
added_count=len(added_ids), added_count=len(added_assets), # Count only processed assets
removed_count=len(removed_ids), removed_count=len(removed_ids),
added_assets=added_assets, added_assets=added_assets,
removed_asset_ids=list(removed_ids), removed_asset_ids=list(removed_ids),
@@ -753,6 +802,9 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
"""Fire Home Assistant events for album changes.""" """Fire Home Assistant events for album changes."""
added_assets_detail = [] added_assets_detail = []
for asset in change.added_assets: 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) asset_detail = self._build_asset_detail(asset, include_thumbnail=False)
added_assets_detail.append(asset_detail) added_assets_detail.append(asset_detail)

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues", "issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues",
"requirements": [], "requirements": [],
"version": "2.0.0" "version": "2.1.0"
} }

View File

@@ -167,7 +167,7 @@ send_telegram_notification:
mode: box mode: box
send_large_photos_as_documents: send_large_photos_as_documents:
name: 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 required: false
default: false default: false
selector: selector: