Improve Telegram error handling and unify asset data structure
All checks were successful
Validate / Hassfest (push) Successful in 3s
All checks were successful
Validate / Hassfest (push) Successful in 3s
- Remove photo downscaling logic in favor of cleaner error handling - Add intelligent Telegram API error logging with diagnostics and suggestions - Define Telegram photo limits as global constants (TELEGRAM_MAX_PHOTO_SIZE, TELEGRAM_MAX_DIMENSION_SUM) - Add photo_url support for image assets (matching video_url for videos) - Unify asset detail building with shared _build_asset_detail() helper method - Enhance get_assets service to return complete asset data matching events - Simplify attribute naming by removing redundant asset_ prefix from values BREAKING CHANGE: Asset attribute keys changed from "asset_type", "asset_filename" to simpler "type", "filename" for consistency and cleaner JSON responses Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
55
README.md
55
README.md
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
A Home Assistant custom integration that monitors [Immich](https://immich.app/) photo/video library albums for changes and exposes them as Home Assistant entities with event-firing capabilities.
|
A Home Assistant custom integration that monitors [Immich](https://immich.app/) photo/video library albums for changes and exposes them as Home Assistant entities with event-firing capabilities.
|
||||||
|
|
||||||
|
> **Tip:** For the best experience, use this integration with the [Immich Album Watcher Blueprint](https://github.com/DolgolyovAlexei/haos-blueprints/blob/main/Common/Immich%20Album%20Watcher.yaml) to easily create automations for album change notifications.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Album Monitoring** - Watch selected Immich albums for asset additions and removals
|
- **Album Monitoring** - Watch selected Immich albums for asset additions and removals
|
||||||
@@ -31,7 +33,7 @@ A Home Assistant custom integration that monitors [Immich](https://immich.app/)
|
|||||||
- Detected people in the asset
|
- Detected people in the asset
|
||||||
- **Services** - Custom service calls:
|
- **Services** - Custom service calls:
|
||||||
- `immich_album_watcher.refresh` - Force immediate data refresh
|
- `immich_album_watcher.refresh` - Force immediate data refresh
|
||||||
- `immich_album_watcher.get_recent_assets` - Get recent assets from an album
|
- `immich_album_watcher.get_assets` - Get assets from an album with filtering and ordering
|
||||||
- `immich_album_watcher.send_telegram_notification` - Send text, photo, video, or media group to Telegram
|
- `immich_album_watcher.send_telegram_notification` - Send text, photo, video, or media group to Telegram
|
||||||
- **Share Link Management** - Button entities to create and delete share links:
|
- **Share Link Management** - Button entities to create and delete share links:
|
||||||
- Create/delete public (unprotected) share links
|
- Create/delete public (unprotected) share links
|
||||||
@@ -60,8 +62,6 @@ A Home Assistant custom integration that monitors [Immich](https://immich.app/)
|
|||||||
3. Restart Home Assistant
|
3. Restart Home Assistant
|
||||||
4. Add the integration via **Settings** → **Devices & Services** → **Add Integration**
|
4. Add the integration via **Settings** → **Devices & Services** → **Add Integration**
|
||||||
|
|
||||||
> **Tip:** For the best experience, use this integration with the [Immich Album Watcher Blueprint](https://github.com/DolgolyovAlexei/haos-blueprints/blob/main/Common/Immich%20Album%20Watcher.yaml) to easily create automations for album change notifications.
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
| Option | Description | Default |
|
| Option | Description | Default |
|
||||||
@@ -103,16 +103,59 @@ Force an immediate refresh of all album data:
|
|||||||
service: immich_album_watcher.refresh
|
service: immich_album_watcher.refresh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Get Recent Assets
|
### Get Assets
|
||||||
|
|
||||||
Get the most recent assets from a specific album (returns response data):
|
Get assets from a specific album with optional filtering and ordering (returns response data):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
service: immich_album_watcher.get_recent_assets
|
service: immich_album_watcher.get_assets
|
||||||
|
target:
|
||||||
|
entity_id: sensor.album_name_asset_count
|
||||||
|
data:
|
||||||
|
count: 10 # Maximum number of assets (1-100)
|
||||||
|
filter: "favorite" # Options: "none", "favorite", "rating"
|
||||||
|
filter_min_rating: 4 # Min rating (1-5), used when filter="rating"
|
||||||
|
order: "descending" # Options: "ascending", "descending", "random"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
- `count` (optional, default: 10): Maximum number of assets to return (1-100)
|
||||||
|
- `filter` (optional, default: "none"): Filter type
|
||||||
|
- `"none"`: No filtering, return all assets
|
||||||
|
- `"favorite"`: Return only favorite assets
|
||||||
|
- `"rating"`: Return assets with rating >= `filter_min_rating`
|
||||||
|
- `filter_min_rating` (optional, default: 1): Minimum rating (1-5 stars), used when `filter="rating"`
|
||||||
|
- `order` (optional, default: "descending"): Sort order by creation date
|
||||||
|
- `"ascending"`: Oldest first
|
||||||
|
- `"descending"`: Newest first
|
||||||
|
- `"random"`: Random order
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
Get 5 most recent favorite assets:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: immich_album_watcher.get_assets
|
||||||
|
target:
|
||||||
|
entity_id: sensor.album_name_asset_count
|
||||||
|
data:
|
||||||
|
count: 5
|
||||||
|
filter: "favorite"
|
||||||
|
order: "descending"
|
||||||
|
```
|
||||||
|
|
||||||
|
Get 10 random assets rated 3 stars or higher:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: immich_album_watcher.get_assets
|
||||||
target:
|
target:
|
||||||
entity_id: sensor.album_name_asset_count
|
entity_id: sensor.album_name_asset_count
|
||||||
data:
|
data:
|
||||||
count: 10
|
count: 10
|
||||||
|
filter: "rating"
|
||||||
|
filter_min_rating: 3
|
||||||
|
order: "random"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Send Telegram Notification
|
### Send Telegram Notification
|
||||||
|
|||||||
@@ -57,17 +57,17 @@ ATTR_OLD_NAME: Final = "old_name"
|
|||||||
ATTR_NEW_NAME: Final = "new_name"
|
ATTR_NEW_NAME: Final = "new_name"
|
||||||
ATTR_OLD_SHARED: Final = "old_shared"
|
ATTR_OLD_SHARED: Final = "old_shared"
|
||||||
ATTR_NEW_SHARED: Final = "new_shared"
|
ATTR_NEW_SHARED: Final = "new_shared"
|
||||||
ATTR_ASSET_TYPE: Final = "asset_type"
|
ATTR_ASSET_TYPE: Final = "type"
|
||||||
ATTR_ASSET_FILENAME: Final = "asset_filename"
|
ATTR_ASSET_FILENAME: Final = "filename"
|
||||||
ATTR_ASSET_CREATED: Final = "asset_created"
|
ATTR_ASSET_CREATED: Final = "created_at"
|
||||||
ATTR_ASSET_OWNER: Final = "asset_owner"
|
ATTR_ASSET_OWNER: Final = "owner"
|
||||||
ATTR_ASSET_OWNER_ID: Final = "asset_owner_id"
|
ATTR_ASSET_OWNER_ID: Final = "owner_id"
|
||||||
ATTR_ASSET_URL: Final = "asset_url"
|
ATTR_ASSET_URL: Final = "url"
|
||||||
ATTR_ASSET_DOWNLOAD_URL: Final = "asset_download_url"
|
ATTR_ASSET_DOWNLOAD_URL: Final = "download_url"
|
||||||
ATTR_ASSET_PLAYBACK_URL: Final = "asset_playback_url"
|
ATTR_ASSET_PLAYBACK_URL: Final = "playback_url"
|
||||||
ATTR_ASSET_DESCRIPTION: Final = "asset_description"
|
ATTR_ASSET_DESCRIPTION: Final = "description"
|
||||||
ATTR_ASSET_IS_FAVORITE: Final = "asset_is_favorite"
|
ATTR_ASSET_IS_FAVORITE: Final = "is_favorite"
|
||||||
ATTR_ASSET_RATING: Final = "asset_rating"
|
ATTR_ASSET_RATING: Final = "rating"
|
||||||
|
|
||||||
# Asset types
|
# Asset types
|
||||||
ASSET_TYPE_IMAGE: Final = "IMAGE"
|
ASSET_TYPE_IMAGE: Final = "IMAGE"
|
||||||
@@ -78,5 +78,5 @@ PLATFORMS: Final = ["sensor", "binary_sensor", "camera", "text", "button"]
|
|||||||
|
|
||||||
# Services
|
# Services
|
||||||
SERVICE_REFRESH: Final = "refresh"
|
SERVICE_REFRESH: Final = "refresh"
|
||||||
SERVICE_GET_RECENT_ASSETS: Final = "get_recent_assets"
|
SERVICE_GET_ASSETS: Final = "get_assets"
|
||||||
SERVICE_SEND_TELEGRAM_NOTIFICATION: Final = "send_telegram_notification"
|
SERVICE_SEND_TELEGRAM_NOTIFICATION: Final = "send_telegram_notification"
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from .const import (
|
|||||||
ATTR_OLD_SHARED,
|
ATTR_OLD_SHARED,
|
||||||
ATTR_NEW_SHARED,
|
ATTR_NEW_SHARED,
|
||||||
ATTR_SHARED,
|
ATTR_SHARED,
|
||||||
|
ATTR_THUMBNAIL_URL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
EVENT_ALBUM_CHANGED,
|
EVENT_ALBUM_CHANGED,
|
||||||
EVENT_ASSETS_ADDED,
|
EVENT_ASSETS_ADDED,
|
||||||
@@ -313,35 +314,52 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
self._album_name,
|
self._album_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_get_recent_assets(self, count: int = 10) -> list[dict[str, Any]]:
|
async def async_get_assets(
|
||||||
"""Get recent assets from the album."""
|
self,
|
||||||
|
count: int = 10,
|
||||||
|
filter: str = "none",
|
||||||
|
filter_min_rating: int = 1,
|
||||||
|
order: str = "descending",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get assets from the album with optional filtering and ordering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
count: Maximum number of assets to return (1-100)
|
||||||
|
filter: Filter type - 'none', 'favorite', or 'rating'
|
||||||
|
filter_min_rating: Minimum rating for assets (1-5), used when filter='rating'
|
||||||
|
order: Sort order - 'ascending', 'descending', or 'random'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of asset data dictionaries
|
||||||
|
"""
|
||||||
if self.data is None:
|
if self.data is None:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Sort assets by created_at descending
|
# Start with all assets
|
||||||
sorted_assets = sorted(
|
assets = list(self.data.assets.values())
|
||||||
self.data.assets.values(),
|
|
||||||
key=lambda a: a.created_at,
|
|
||||||
reverse=True,
|
|
||||||
)[:count]
|
|
||||||
|
|
||||||
|
# Apply filtering
|
||||||
|
if filter == "favorite":
|
||||||
|
assets = [a for a in assets if a.is_favorite]
|
||||||
|
elif filter == "rating":
|
||||||
|
assets = [a for a in assets if a.rating is not None and a.rating >= filter_min_rating]
|
||||||
|
|
||||||
|
# Apply ordering
|
||||||
|
if order == "random":
|
||||||
|
import random
|
||||||
|
random.shuffle(assets)
|
||||||
|
elif order == "ascending":
|
||||||
|
assets = sorted(assets, key=lambda a: a.created_at, reverse=False)
|
||||||
|
else: # descending (default)
|
||||||
|
assets = sorted(assets, key=lambda a: a.created_at, reverse=True)
|
||||||
|
|
||||||
|
# Limit count
|
||||||
|
assets = assets[:count]
|
||||||
|
|
||||||
|
# Build result with all available asset data (matching event data)
|
||||||
result = []
|
result = []
|
||||||
for asset in sorted_assets:
|
for asset in assets:
|
||||||
asset_data = {
|
asset_data = self._build_asset_detail(asset, include_thumbnail=True)
|
||||||
"id": asset.id,
|
|
||||||
"type": asset.type,
|
|
||||||
"filename": asset.filename,
|
|
||||||
"created_at": asset.created_at,
|
|
||||||
"description": asset.description,
|
|
||||||
"people": asset.people,
|
|
||||||
"is_favorite": asset.is_favorite,
|
|
||||||
"rating": asset.rating,
|
|
||||||
"thumbnail_url": f"{self._url}/api/assets/{asset.id}/thumbnail",
|
|
||||||
}
|
|
||||||
if asset.type == ASSET_TYPE_VIDEO:
|
|
||||||
video_url = self._get_asset_video_url(asset.id)
|
|
||||||
if video_url:
|
|
||||||
asset_data["video_url"] = video_url
|
|
||||||
result.append(asset_data)
|
result.append(asset_data)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -513,6 +531,68 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
return f"{self._url}/api/assets/{asset_id}/video/playback?key={non_expired[0].key}"
|
return f"{self._url}/api/assets/{asset_id}/video/playback?key={non_expired[0].key}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _get_asset_photo_url(self, asset_id: str) -> str | None:
|
||||||
|
"""Get the transcoded/preview 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}"
|
||||||
|
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 None
|
||||||
|
|
||||||
|
def _build_asset_detail(
|
||||||
|
self, asset: AssetInfo, include_thumbnail: bool = True
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build asset detail dictionary with all available data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
asset: AssetInfo object
|
||||||
|
include_thumbnail: If True, include thumbnail_url
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with asset details (using ATTR_* constants for consistency)
|
||||||
|
"""
|
||||||
|
# Base asset data
|
||||||
|
asset_detail = {
|
||||||
|
"id": asset.id,
|
||||||
|
ATTR_ASSET_TYPE: asset.type,
|
||||||
|
ATTR_ASSET_FILENAME: asset.filename,
|
||||||
|
ATTR_ASSET_CREATED: asset.created_at,
|
||||||
|
ATTR_ASSET_OWNER: asset.owner_name,
|
||||||
|
ATTR_ASSET_OWNER_ID: asset.owner_id,
|
||||||
|
ATTR_ASSET_DESCRIPTION: asset.description,
|
||||||
|
ATTR_PEOPLE: asset.people,
|
||||||
|
ATTR_ASSET_IS_FAVORITE: asset.is_favorite,
|
||||||
|
ATTR_ASSET_RATING: asset.rating,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add thumbnail URL if requested
|
||||||
|
if include_thumbnail:
|
||||||
|
asset_detail[ATTR_THUMBNAIL_URL] = f"{self._url}/api/assets/{asset.id}/thumbnail"
|
||||||
|
|
||||||
|
# Add public viewer URL (web page)
|
||||||
|
asset_url = self._get_asset_public_url(asset.id)
|
||||||
|
if asset_url:
|
||||||
|
asset_detail[ATTR_ASSET_URL] = asset_url
|
||||||
|
|
||||||
|
# Add download URL (direct media file)
|
||||||
|
asset_download_url = self._get_asset_download_url(asset.id)
|
||||||
|
if asset_download_url:
|
||||||
|
asset_detail[ATTR_ASSET_DOWNLOAD_URL] = asset_download_url
|
||||||
|
|
||||||
|
# Add type-specific URLs
|
||||||
|
if asset.type == ASSET_TYPE_VIDEO:
|
||||||
|
asset_video_url = self._get_asset_video_url(asset.id)
|
||||||
|
if asset_video_url:
|
||||||
|
asset_detail[ATTR_ASSET_PLAYBACK_URL] = asset_video_url
|
||||||
|
elif asset.type == ASSET_TYPE_IMAGE:
|
||||||
|
asset_photo_url = self._get_asset_photo_url(asset.id)
|
||||||
|
if asset_photo_url:
|
||||||
|
asset_detail["photo_url"] = asset_photo_url # TODO: Add ATTR_ASSET_PHOTO_URL constant
|
||||||
|
|
||||||
|
return asset_detail
|
||||||
|
|
||||||
async def _async_update_data(self) -> AlbumData | None:
|
async def _async_update_data(self) -> AlbumData | None:
|
||||||
"""Fetch data from Immich API."""
|
"""Fetch data from Immich API."""
|
||||||
if self._session is None:
|
if self._session is None:
|
||||||
@@ -673,28 +753,7 @@ 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:
|
||||||
asset_detail = {
|
asset_detail = self._build_asset_detail(asset, include_thumbnail=False)
|
||||||
"id": asset.id,
|
|
||||||
ATTR_ASSET_TYPE: asset.type,
|
|
||||||
ATTR_ASSET_FILENAME: asset.filename,
|
|
||||||
ATTR_ASSET_CREATED: asset.created_at,
|
|
||||||
ATTR_ASSET_OWNER: asset.owner_name,
|
|
||||||
ATTR_ASSET_OWNER_ID: asset.owner_id,
|
|
||||||
ATTR_ASSET_DESCRIPTION: asset.description,
|
|
||||||
ATTR_PEOPLE: asset.people,
|
|
||||||
ATTR_ASSET_IS_FAVORITE: asset.is_favorite,
|
|
||||||
ATTR_ASSET_RATING: asset.rating,
|
|
||||||
}
|
|
||||||
asset_url = self._get_asset_public_url(asset.id)
|
|
||||||
if asset_url:
|
|
||||||
asset_detail[ATTR_ASSET_URL] = asset_url
|
|
||||||
asset_download_url = self._get_asset_download_url(asset.id)
|
|
||||||
if asset_download_url:
|
|
||||||
asset_detail[ATTR_ASSET_DOWNLOAD_URL] = asset_download_url
|
|
||||||
if asset.type == ASSET_TYPE_VIDEO:
|
|
||||||
asset_video_url = self._get_asset_video_url(asset.id)
|
|
||||||
if asset_video_url:
|
|
||||||
asset_detail[ATTR_ASSET_PLAYBACK_URL] = asset_video_url
|
|
||||||
added_assets_detail.append(asset_detail)
|
added_assets_detail.append(asset_detail)
|
||||||
|
|
||||||
event_data = {
|
event_data = {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ from .const import (
|
|||||||
CONF_HUB_NAME,
|
CONF_HUB_NAME,
|
||||||
CONF_TELEGRAM_BOT_TOKEN,
|
CONF_TELEGRAM_BOT_TOKEN,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_GET_RECENT_ASSETS,
|
SERVICE_GET_ASSETS,
|
||||||
SERVICE_REFRESH,
|
SERVICE_REFRESH,
|
||||||
SERVICE_SEND_TELEGRAM_NOTIFICATION,
|
SERVICE_SEND_TELEGRAM_NOTIFICATION,
|
||||||
)
|
)
|
||||||
@@ -48,6 +48,10 @@ from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Telegram photo limits
|
||||||
|
TELEGRAM_MAX_PHOTO_SIZE = 10 * 1024 * 1024 # 10 MB - Telegram's max photo size
|
||||||
|
TELEGRAM_MAX_DIMENSION_SUM = 10000 # Maximum sum of width + height in pixels
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@@ -88,13 +92,18 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
|
|
||||||
platform.async_register_entity_service(
|
platform.async_register_entity_service(
|
||||||
SERVICE_GET_RECENT_ASSETS,
|
SERVICE_GET_ASSETS,
|
||||||
{
|
{
|
||||||
vol.Optional("count", default=10): vol.All(
|
vol.Optional("count", default=10): vol.All(
|
||||||
vol.Coerce(int), vol.Range(min=1, max=100)
|
vol.Coerce(int), vol.Range(min=1, max=100)
|
||||||
),
|
),
|
||||||
|
vol.Optional("filter", default="none"): vol.In(["none", "favorite", "rating"]),
|
||||||
|
vol.Optional("filter_min_rating", default=1): vol.All(
|
||||||
|
vol.Coerce(int), vol.Range(min=1, max=5)
|
||||||
|
),
|
||||||
|
vol.Optional("order", default="descending"): vol.In(["ascending", "descending", "random"]),
|
||||||
},
|
},
|
||||||
"async_get_recent_assets",
|
"async_get_assets",
|
||||||
supports_response=SupportsResponse.ONLY,
|
supports_response=SupportsResponse.ONLY,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -171,9 +180,20 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
|||||||
"""Refresh data for this album."""
|
"""Refresh data for this album."""
|
||||||
await self.coordinator.async_refresh_now()
|
await self.coordinator.async_refresh_now()
|
||||||
|
|
||||||
async def async_get_recent_assets(self, count: int = 10) -> ServiceResponse:
|
async def async_get_assets(
|
||||||
"""Get recent assets for this album."""
|
self,
|
||||||
assets = await self.coordinator.async_get_recent_assets(count)
|
count: int = 10,
|
||||||
|
filter: str = "none",
|
||||||
|
filter_min_rating: int = 1,
|
||||||
|
order: str = "descending",
|
||||||
|
) -> ServiceResponse:
|
||||||
|
"""Get assets for this album with optional filtering and ordering."""
|
||||||
|
assets = await self.coordinator.async_get_assets(
|
||||||
|
count=count,
|
||||||
|
filter=filter,
|
||||||
|
filter_min_rating=filter_min_rating,
|
||||||
|
order=order,
|
||||||
|
)
|
||||||
return {"assets": assets}
|
return {"assets": assets}
|
||||||
|
|
||||||
async def async_send_telegram_notification(
|
async def async_send_telegram_notification(
|
||||||
@@ -342,6 +362,60 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
|||||||
_LOGGER.error("Telegram message send failed: %s", err)
|
_LOGGER.error("Telegram message send failed: %s", err)
|
||||||
return {"success": False, "error": str(err)}
|
return {"success": False, "error": str(err)}
|
||||||
|
|
||||||
|
def _log_telegram_error(
|
||||||
|
self,
|
||||||
|
error_code: int | None,
|
||||||
|
description: str,
|
||||||
|
data: bytes | None = None,
|
||||||
|
media_type: str = "photo",
|
||||||
|
) -> None:
|
||||||
|
"""Log detailed Telegram API error with diagnostics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_code: Telegram error code
|
||||||
|
description: Error description from Telegram
|
||||||
|
data: Media data bytes (optional, for size diagnostics)
|
||||||
|
media_type: Type of media (photo/video)
|
||||||
|
"""
|
||||||
|
error_msg = f"Telegram API error ({error_code}): {description}"
|
||||||
|
|
||||||
|
# Add diagnostic information based on error type
|
||||||
|
if data:
|
||||||
|
error_msg += f" | Media size: {len(data)} bytes ({len(data) / (1024 * 1024):.2f} MB)"
|
||||||
|
|
||||||
|
# Check dimensions for photos
|
||||||
|
if media_type == "photo":
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
img = Image.open(io.BytesIO(data))
|
||||||
|
width, height = img.size
|
||||||
|
dimension_sum = width + height
|
||||||
|
error_msg += f" | Dimensions: {width}x{height} (sum={dimension_sum})"
|
||||||
|
|
||||||
|
# Highlight limit violations
|
||||||
|
if len(data) > TELEGRAM_MAX_PHOTO_SIZE:
|
||||||
|
error_msg += f" | EXCEEDS size limit ({TELEGRAM_MAX_PHOTO_SIZE / (1024 * 1024):.0f} MB)"
|
||||||
|
if dimension_sum > TELEGRAM_MAX_DIMENSION_SUM:
|
||||||
|
error_msg += f" | EXCEEDS dimension limit ({TELEGRAM_MAX_DIMENSION_SUM})"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Provide suggestions based on error description
|
||||||
|
suggestions = []
|
||||||
|
if "dimension" in description.lower() or "PHOTO_INVALID_DIMENSIONS" in description:
|
||||||
|
suggestions.append("Photo dimensions too large - consider setting send_large_photos_as_documents=true")
|
||||||
|
elif "too large" in description.lower() or error_code == 413:
|
||||||
|
suggestions.append("File size too large - consider setting send_large_photos_as_documents=true or max_asset_data_size to skip large files")
|
||||||
|
elif "entity too large" in description.lower():
|
||||||
|
suggestions.append("Request entity too large - reduce max_group_size or set max_asset_data_size")
|
||||||
|
|
||||||
|
if suggestions:
|
||||||
|
error_msg += f" | Suggestions: {'; '.join(suggestions)}"
|
||||||
|
|
||||||
|
_LOGGER.error(error_msg)
|
||||||
|
|
||||||
def _check_telegram_photo_limits(
|
def _check_telegram_photo_limits(
|
||||||
self,
|
self,
|
||||||
data: bytes,
|
data: bytes,
|
||||||
@@ -359,9 +433,6 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
|||||||
- width: Image width in pixels (None if PIL not available)
|
- width: Image width in pixels (None if PIL not available)
|
||||||
- height: Image height in pixels (None if PIL not available)
|
- height: Image height in pixels (None if PIL not available)
|
||||||
"""
|
"""
|
||||||
TELEGRAM_MAX_PHOTO_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
||||||
TELEGRAM_MAX_DIMENSION_SUM = 10000
|
|
||||||
|
|
||||||
# Check file size
|
# Check file size
|
||||||
if len(data) > TELEGRAM_MAX_PHOTO_SIZE:
|
if len(data) > TELEGRAM_MAX_PHOTO_SIZE:
|
||||||
return True, f"size {len(data)} bytes exceeds {TELEGRAM_MAX_PHOTO_SIZE} bytes limit", None, None
|
return True, f"size {len(data)} bytes exceeds {TELEGRAM_MAX_PHOTO_SIZE} bytes limit", None, None
|
||||||
@@ -388,73 +459,6 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
|||||||
_LOGGER.debug("Failed to check photo dimensions: %s", e)
|
_LOGGER.debug("Failed to check photo dimensions: %s", e)
|
||||||
return False, None, None, None
|
return False, None, None, None
|
||||||
|
|
||||||
def _downsize_photo(
|
|
||||||
self,
|
|
||||||
data: bytes,
|
|
||||||
max_dimension_sum: int = 10000,
|
|
||||||
max_file_size: int = 9 * 1024 * 1024, # 9 MB to be safe
|
|
||||||
) -> tuple[bytes | None, str | None]:
|
|
||||||
"""Downsize photo to fit within Telegram limits.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: Original photo bytes
|
|
||||||
max_dimension_sum: Maximum sum of width + height
|
|
||||||
max_file_size: Maximum file size in bytes
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (downsized_data, error)
|
|
||||||
- downsized_data: Downsized photo bytes (None if failed)
|
|
||||||
- error: Error message (None if successful)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
import io
|
|
||||||
|
|
||||||
img = Image.open(io.BytesIO(data))
|
|
||||||
width, height = img.size
|
|
||||||
dimension_sum = width + height
|
|
||||||
|
|
||||||
# Calculate scale factor based on dimensions
|
|
||||||
if dimension_sum > max_dimension_sum:
|
|
||||||
scale = max_dimension_sum / dimension_sum
|
|
||||||
new_width = int(width * scale)
|
|
||||||
new_height = int(height * scale)
|
|
||||||
|
|
||||||
_LOGGER.info(
|
|
||||||
"Downsizing photo from %dx%d to %dx%d to fit Telegram limits",
|
|
||||||
width, height, new_width, new_height
|
|
||||||
)
|
|
||||||
|
|
||||||
# Resize with high-quality Lanczos resampling
|
|
||||||
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
# Save with progressive quality reduction until size fits
|
|
||||||
output = io.BytesIO()
|
|
||||||
quality = 95
|
|
||||||
|
|
||||||
while quality >= 50:
|
|
||||||
output.seek(0)
|
|
||||||
output.truncate()
|
|
||||||
img.save(output, format='JPEG', quality=quality, optimize=True)
|
|
||||||
output_size = output.tell()
|
|
||||||
|
|
||||||
if output_size <= max_file_size:
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Photo downsized successfully: %d bytes (quality=%d)",
|
|
||||||
output_size, quality
|
|
||||||
)
|
|
||||||
return output.getvalue(), None
|
|
||||||
|
|
||||||
quality -= 5
|
|
||||||
|
|
||||||
# If we can't get it small enough, return error
|
|
||||||
return None, f"Unable to downsize photo below {max_file_size} bytes (final size: {output_size} bytes at quality {quality + 5})"
|
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
return None, "PIL not available, cannot downsize photo"
|
|
||||||
except Exception as e:
|
|
||||||
_LOGGER.error("Failed to downsize photo: %s", e)
|
|
||||||
return None, f"Failed to downsize photo: {e}"
|
|
||||||
|
|
||||||
async def _send_telegram_photo(
|
async def _send_telegram_photo(
|
||||||
self,
|
self,
|
||||||
@@ -510,16 +514,13 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
|||||||
caption, reply_to_message_id, parse_mode
|
caption, reply_to_message_id, parse_mode
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Try to downsize the photo
|
# Skip oversized photo
|
||||||
_LOGGER.info("Photo %s, attempting to downsize", reason)
|
_LOGGER.warning("Photo %s, skipping (set send_large_photos_as_documents=true to send as document)", reason)
|
||||||
downsized_data, error = self._downsize_photo(data)
|
return {
|
||||||
if downsized_data:
|
"success": False,
|
||||||
data = downsized_data
|
"error": f"Photo {reason}",
|
||||||
else:
|
"skipped": True,
|
||||||
return {
|
}
|
||||||
"success": False,
|
|
||||||
"error": f"Photo exceeds Telegram limits and downsize failed: {error}",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build multipart form
|
# Build multipart form
|
||||||
form = FormData()
|
form = FormData()
|
||||||
@@ -546,7 +547,13 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
|||||||
"message_id": result.get("result", {}).get("message_id"),
|
"message_id": result.get("result", {}).get("message_id"),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
_LOGGER.error("Telegram API error: %s", result)
|
# Log detailed error with diagnostics
|
||||||
|
self._log_telegram_error(
|
||||||
|
error_code=result.get("error_code"),
|
||||||
|
description=result.get("description", "Unknown Telegram error"),
|
||||||
|
data=data,
|
||||||
|
media_type="photo",
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": result.get("description", "Unknown Telegram error"),
|
"error": result.get("description", "Unknown Telegram error"),
|
||||||
@@ -623,7 +630,13 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
|||||||
"message_id": result.get("result", {}).get("message_id"),
|
"message_id": result.get("result", {}).get("message_id"),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
_LOGGER.error("Telegram API error: %s", result)
|
# Log detailed error with diagnostics
|
||||||
|
self._log_telegram_error(
|
||||||
|
error_code=result.get("error_code"),
|
||||||
|
description=result.get("description", "Unknown Telegram error"),
|
||||||
|
data=data,
|
||||||
|
media_type="video",
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": result.get("description", "Unknown Telegram error"),
|
"error": result.get("description", "Unknown Telegram error"),
|
||||||
@@ -674,7 +687,13 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
|||||||
"message_id": result.get("result", {}).get("message_id"),
|
"message_id": result.get("result", {}).get("message_id"),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
_LOGGER.error("Telegram API error: %s", result)
|
# Log detailed error with diagnostics
|
||||||
|
self._log_telegram_error(
|
||||||
|
error_code=result.get("error_code"),
|
||||||
|
description=result.get("description", "Unknown Telegram error"),
|
||||||
|
data=data,
|
||||||
|
media_type="document",
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": result.get("description", "Unknown Telegram error"),
|
"error": result.get("description", "Unknown Telegram error"),
|
||||||
@@ -756,7 +775,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
|||||||
_LOGGER.debug("Sending chunk %d/%d as media group (%d items)", chunk_idx + 1, len(chunks), len(chunk))
|
_LOGGER.debug("Sending chunk %d/%d as media group (%d items)", chunk_idx + 1, len(chunks), len(chunk))
|
||||||
|
|
||||||
# Download all media files for this chunk
|
# Download all media files for this chunk
|
||||||
media_files: list[tuple[str, bytes, str]] = []
|
media_files: list[tuple[str, bytes, str]] = [] # (type, data, filename)
|
||||||
oversized_photos: list[tuple[bytes, str | None]] = [] # For send_large_photos_as_documents=true
|
oversized_photos: list[tuple[bytes, str | None]] = [] # For send_large_photos_as_documents=true
|
||||||
skipped_count = 0
|
skipped_count = 0
|
||||||
|
|
||||||
@@ -808,15 +827,10 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
|||||||
_LOGGER.info("Photo %d %s, will send as document", i, reason)
|
_LOGGER.info("Photo %d %s, will send as document", i, reason)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
# Try to downsize the photo
|
# Skip oversized photo
|
||||||
_LOGGER.info("Photo %d %s, attempting to downsize", i, reason)
|
_LOGGER.warning("Photo %d %s, skipping (set send_large_photos_as_documents=true to send as document)", i, reason)
|
||||||
downsized_data, error = self._downsize_photo(data)
|
skipped_count += 1
|
||||||
if downsized_data:
|
continue
|
||||||
data = downsized_data
|
|
||||||
else:
|
|
||||||
_LOGGER.error("Failed to downsize photo %d: %s, skipping", i, error)
|
|
||||||
skipped_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
ext = "jpg" if media_type == "photo" else "mp4"
|
ext = "jpg" if media_type == "photo" else "mp4"
|
||||||
filename = f"media_{chunk_idx * max_group_size + i}.{ext}"
|
filename = f"media_{chunk_idx * max_group_size + i}.{ext}"
|
||||||
@@ -877,7 +891,26 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
|||||||
]
|
]
|
||||||
all_message_ids.extend(chunk_message_ids)
|
all_message_ids.extend(chunk_message_ids)
|
||||||
else:
|
else:
|
||||||
_LOGGER.error("Telegram API error for chunk %d: %s", chunk_idx + 1, result)
|
# Log detailed error for media group with total size info
|
||||||
|
total_size = sum(len(d) for _, d, _ in media_files)
|
||||||
|
_LOGGER.error(
|
||||||
|
"Telegram API error for chunk %d/%d: %s | Media count: %d | Total size: %d bytes (%.2f MB)",
|
||||||
|
chunk_idx + 1, len(chunks),
|
||||||
|
result.get("description", "Unknown Telegram error"),
|
||||||
|
len(media_files),
|
||||||
|
total_size,
|
||||||
|
total_size / (1024 * 1024)
|
||||||
|
)
|
||||||
|
# Log detailed diagnostics for the first photo in the group
|
||||||
|
for media_type, data, _ in media_files:
|
||||||
|
if media_type == "photo":
|
||||||
|
self._log_telegram_error(
|
||||||
|
error_code=result.get("error_code"),
|
||||||
|
description=result.get("description", "Unknown Telegram error"),
|
||||||
|
data=data,
|
||||||
|
media_type="photo",
|
||||||
|
)
|
||||||
|
break # Only log details for first photo
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": result.get("description", "Unknown Telegram error"),
|
"error": result.get("description", "Unknown Telegram error"),
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ refresh:
|
|||||||
integration: immich_album_watcher
|
integration: immich_album_watcher
|
||||||
domain: sensor
|
domain: sensor
|
||||||
|
|
||||||
get_recent_assets:
|
get_assets:
|
||||||
name: Get Recent Assets
|
name: Get Assets
|
||||||
description: Get the most recent assets from the targeted album.
|
description: Get assets from the targeted album with optional filtering and ordering.
|
||||||
target:
|
target:
|
||||||
entity:
|
entity:
|
||||||
integration: immich_album_watcher
|
integration: immich_album_watcher
|
||||||
@@ -16,7 +16,7 @@ get_recent_assets:
|
|||||||
fields:
|
fields:
|
||||||
count:
|
count:
|
||||||
name: Count
|
name: Count
|
||||||
description: Number of recent assets to return (1-100).
|
description: Maximum number of assets to return (1-100).
|
||||||
required: false
|
required: false
|
||||||
default: 10
|
default: 10
|
||||||
selector:
|
selector:
|
||||||
@@ -24,6 +24,44 @@ get_recent_assets:
|
|||||||
min: 1
|
min: 1
|
||||||
max: 100
|
max: 100
|
||||||
mode: slider
|
mode: slider
|
||||||
|
filter:
|
||||||
|
name: Filter
|
||||||
|
description: Filter assets by type (none, favorite, or rating-based).
|
||||||
|
required: false
|
||||||
|
default: "none"
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- label: "None (no filtering)"
|
||||||
|
value: "none"
|
||||||
|
- label: "Favorites only"
|
||||||
|
value: "favorite"
|
||||||
|
- label: "By minimum rating"
|
||||||
|
value: "rating"
|
||||||
|
filter_min_rating:
|
||||||
|
name: Minimum Rating
|
||||||
|
description: Minimum rating for assets (1-5). Only used when filter is set to 'rating'.
|
||||||
|
required: false
|
||||||
|
default: 1
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
min: 1
|
||||||
|
max: 5
|
||||||
|
mode: slider
|
||||||
|
order:
|
||||||
|
name: Order
|
||||||
|
description: Sort order for assets by creation date.
|
||||||
|
required: false
|
||||||
|
default: "descending"
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- label: "Ascending (oldest first)"
|
||||||
|
value: "ascending"
|
||||||
|
- label: "Descending (newest first)"
|
||||||
|
value: "descending"
|
||||||
|
- label: "Random"
|
||||||
|
value: "random"
|
||||||
|
|
||||||
send_telegram_notification:
|
send_telegram_notification:
|
||||||
name: Send Telegram Notification
|
name: Send Telegram Notification
|
||||||
|
|||||||
@@ -133,13 +133,25 @@
|
|||||||
"name": "Refresh",
|
"name": "Refresh",
|
||||||
"description": "Force an immediate refresh of album data from Immich."
|
"description": "Force an immediate refresh of album data from Immich."
|
||||||
},
|
},
|
||||||
"get_recent_assets": {
|
"get_assets": {
|
||||||
"name": "Get Recent Assets",
|
"name": "Get Assets",
|
||||||
"description": "Get the most recent assets from the targeted album.",
|
"description": "Get assets from the targeted album with optional filtering and ordering.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"count": {
|
"count": {
|
||||||
"name": "Count",
|
"name": "Count",
|
||||||
"description": "Number of recent assets to return (1-100)."
|
"description": "Maximum number of assets to return (1-100)."
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"name": "Filter",
|
||||||
|
"description": "Filter assets by type (none, favorite, or rating-based)."
|
||||||
|
},
|
||||||
|
"filter_min_rating": {
|
||||||
|
"name": "Minimum Rating",
|
||||||
|
"description": "Minimum rating for assets (1-5). Only used when filter is set to 'rating'."
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"name": "Order",
|
||||||
|
"description": "Sort order for assets by creation date."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -133,13 +133,25 @@
|
|||||||
"name": "Обновить",
|
"name": "Обновить",
|
||||||
"description": "Принудительно обновить данные альбома из Immich."
|
"description": "Принудительно обновить данные альбома из Immich."
|
||||||
},
|
},
|
||||||
"get_recent_assets": {
|
"get_assets": {
|
||||||
"name": "Получить последние файлы",
|
"name": "Получить файлы",
|
||||||
"description": "Получить последние файлы из выбранного альбома.",
|
"description": "Получить файлы из выбранного альбома с возможностью фильтрации и сортировки.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"count": {
|
"count": {
|
||||||
"name": "Количество",
|
"name": "Количество",
|
||||||
"description": "Количество возвращаемых файлов (1-100)."
|
"description": "Максимальное количество возвращаемых файлов (1-100)."
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"name": "Фильтр",
|
||||||
|
"description": "Фильтровать файлы по типу (none - без фильтра, favorite - только избранные, rating - по рейтингу)."
|
||||||
|
},
|
||||||
|
"filter_min_rating": {
|
||||||
|
"name": "Минимальный рейтинг",
|
||||||
|
"description": "Минимальный рейтинг для файлов (1-5). Используется только при filter='rating'."
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"name": "Порядок",
|
||||||
|
"description": "Порядок сортировки файлов по дате создания."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user