Add asset preprocessing filter and enhance asset data
All checks were successful
Validate / Hassfest (push) Successful in 3s
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:
31
README.md
31
README.md
@@ -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 }}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user