From 38a2a6ad7a672cb231083c7b0a46ab882e74f078 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 1 Feb 2026 21:53:02 +0300 Subject: [PATCH] Add external domain support for URLs - Fetch externalDomain from Immich server config on startup - Use external domain for user-facing URLs (share links, asset URLs) - Keep internal connection URL for API calls - Add get_internal_download_url() to convert external URLs back to internal for faster local network downloads (Telegram notifications) Co-Authored-By: Claude Opus 4.5 --- .../immich_album_watcher/coordinator.py | 98 +++++++++++++++---- .../immich_album_watcher/sensor.py | 20 ++-- 2 files changed, 93 insertions(+), 25 deletions(-) diff --git a/custom_components/immich_album_watcher/coordinator.py b/custom_components/immich_album_watcher/coordinator.py index 5f3be3f..192728e 100644 --- a/custom_components/immich_album_watcher/coordinator.py +++ b/custom_components/immich_album_watcher/coordinator.py @@ -330,12 +330,42 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): self._storage = storage self._telegram_cache = telegram_cache self._persisted_asset_ids: set[str] | None = None + self._external_domain: str | None = None # Fetched from server config @property def immich_url(self) -> str: - """Return the Immich URL.""" + """Return the Immich URL (for API calls).""" return self._url + @property + def external_url(self) -> str: + """Return the external URL for links. + + Uses externalDomain from Immich server config if set, + otherwise falls back to the connection URL. + """ + if self._external_domain: + return self._external_domain.rstrip("/") + return self._url + + def get_internal_download_url(self, url: str) -> str: + """Convert an external URL to internal URL for faster downloads. + + If the URL starts with the external domain, replace it with the + internal connection URL to download via local network. + + Args: + url: The URL to convert (may be external or internal) + + Returns: + URL using internal connection for downloads + """ + if self._external_domain: + external = self._external_domain.rstrip("/") + if url.startswith(external): + return url.replace(external, self._url, 1) + return url + @property def api_key(self) -> str: """Return the API key.""" @@ -514,6 +544,36 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): return self._users_cache + async def _async_fetch_server_config(self) -> None: + """Fetch server config from Immich to get external domain.""" + if self._session is None: + self._session = async_get_clientsession(self.hass) + + headers = {"x-api-key": self._api_key} + try: + async with self._session.get( + f"{self._url}/api/server/config", + headers=headers, + ) as response: + if response.status == 200: + data = await response.json() + external_domain = data.get("externalDomain", "") or "" + self._external_domain = external_domain + if external_domain: + _LOGGER.debug( + "Using external domain from Immich: %s", external_domain + ) + else: + _LOGGER.debug( + "No external domain configured in Immich, using connection URL" + ) + else: + _LOGGER.warning( + "Failed to fetch server config: HTTP %s", response.status + ) + except aiohttp.ClientError as err: + _LOGGER.warning("Failed to fetch server config: %s", err) + async def _async_fetch_shared_links(self) -> list[SharedLinkInfo]: """Fetch shared links for this album from Immich.""" if self._session is None: @@ -557,29 +617,29 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): """Get the public URL if album has an accessible shared link.""" accessible_links = self._get_accessible_links() if accessible_links: - return f"{self._url}/share/{accessible_links[0].key}" + return f"{self.external_url}/share/{accessible_links[0].key}" return None def get_any_url(self) -> str | None: """Get any non-expired URL (prefers accessible, falls back to protected).""" accessible_links = self._get_accessible_links() if accessible_links: - return f"{self._url}/share/{accessible_links[0].key}" + return f"{self.external_url}/share/{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}/share/{non_expired[0].key}" + return f"{self.external_url}/share/{non_expired[0].key}" return None def get_protected_url(self) -> str | None: """Get a protected URL if any password-protected link exists.""" protected_links = self._get_protected_links() if protected_links: - return f"{self._url}/share/{protected_links[0].key}" + return f"{self.external_url}/share/{protected_links[0].key}" return None def get_protected_urls(self) -> list[str]: """Get all password-protected URLs.""" - return [f"{self._url}/share/{link.key}" for link in self._get_protected_links()] + return [f"{self.external_url}/share/{link.key}" for link in self._get_protected_links()] def get_protected_password(self) -> str | None: """Get the password for the first protected link.""" @@ -590,13 +650,13 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): def get_public_urls(self) -> list[str]: """Get all accessible public URLs.""" - return [f"{self._url}/share/{link.key}" for link in self._get_accessible_links()] + return [f"{self.external_url}/share/{link.key}" for link in self._get_accessible_links()] def get_shared_links_info(self) -> list[dict[str, Any]]: """Get detailed info about all shared links.""" return [ { - "url": f"{self._url}/share/{link.key}", + "url": f"{self.external_url}/share/{link.key}", "has_password": link.has_password, "is_expired": link.is_expired, "expires_at": link.expires_at.isoformat() if link.expires_at else None, @@ -609,40 +669,40 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): """Get the public viewer URL for an asset (web page).""" accessible_links = self._get_accessible_links() if accessible_links: - return f"{self._url}/share/{accessible_links[0].key}/photos/{asset_id}" + return f"{self.external_url}/share/{accessible_links[0].key}/photos/{asset_id}" non_expired = [link for link in self._shared_links if not link.is_expired] if non_expired: - return f"{self._url}/share/{non_expired[0].key}/photos/{asset_id}" + return f"{self.external_url}/share/{non_expired[0].key}/photos/{asset_id}" return None def _get_asset_download_url(self, asset_id: str) -> str | None: """Get the direct download URL for an asset (media file).""" 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.external_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 f"{self.external_url}/api/assets/{asset_id}/original?key={non_expired[0].key}" return None def _get_asset_video_url(self, asset_id: str) -> str | None: """Get the transcoded video playback URL for a video asset.""" accessible_links = self._get_accessible_links() if accessible_links: - return f"{self._url}/api/assets/{asset_id}/video/playback?key={accessible_links[0].key}" + return f"{self.external_url}/api/assets/{asset_id}/video/playback?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}/video/playback?key={non_expired[0].key}" + return f"{self.external_url}/api/assets/{asset_id}/video/playback?key={non_expired[0].key}" return None def _get_asset_photo_url(self, asset_id: str) -> str | None: """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}/thumbnail?size=preview&key={accessible_links[0].key}" + return f"{self.external_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}/thumbnail?size=preview&key={non_expired[0].key}" + return f"{self.external_url}/api/assets/{asset_id}/thumbnail?size=preview&key={non_expired[0].key}" return None def _build_asset_detail( @@ -678,7 +738,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): # Add thumbnail URL if requested if include_thumbnail: - asset_detail[ATTR_THUMBNAIL_URL] = f"{self._url}/api/assets/{asset.id}/thumbnail" + asset_detail[ATTR_THUMBNAIL_URL] = f"{self.external_url}/api/assets/{asset.id}/thumbnail" # Add public viewer URL (web page) asset_url = self._get_asset_public_url(asset.id) @@ -707,6 +767,10 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): if self._session is None: self._session = async_get_clientsession(self.hass) + # Fetch server config to get external domain (once) + if self._external_domain is None: + await self._async_fetch_server_config() + # Fetch users to resolve owner names if not self._users_cache: await self._async_fetch_users() diff --git a/custom_components/immich_album_watcher/sensor.py b/custom_components/immich_album_watcher/sensor.py index 6aaf84d..cab738e 100644 --- a/custom_components/immich_album_watcher/sensor.py +++ b/custom_components/immich_album_watcher/sensor.py @@ -535,9 +535,10 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se _LOGGER.debug("Cached file_id request failed: %s", err) try: - # Download the photo - _LOGGER.debug("Downloading photo from %s", url[:80]) - async with session.get(url) as resp: + # Download the photo using internal URL for faster local network transfer + download_url = self.coordinator.get_internal_download_url(url) + _LOGGER.debug("Downloading photo from %s", download_url[:80]) + async with session.get(download_url) as resp: if resp.status != 200: return { "success": False, @@ -680,9 +681,10 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se _LOGGER.debug("Cached file_id request failed: %s", err) try: - # Download the video - _LOGGER.debug("Downloading video from %s", url[:80]) - async with session.get(url) as resp: + # Download the video using internal URL for faster local network transfer + download_url = self.coordinator.get_internal_download_url(url) + _LOGGER.debug("Downloading video from %s", download_url[:80]) + async with session.get(download_url) as resp: if resp.status != 200: return { "success": False, @@ -958,8 +960,10 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se continue try: - _LOGGER.debug("Downloading media %d from %s", chunk_idx * max_group_size + i, url[:80]) - async with session.get(url) as resp: + # Download using internal URL for faster local network transfer + download_url = self.coordinator.get_internal_download_url(url) + _LOGGER.debug("Downloading media %d from %s", chunk_idx * max_group_size + i, download_url[:80]) + async with session.get(download_url) as resp: if resp.status != 200: return { "success": False,