Add external domain support for URLs
All checks were successful
Validate / Hassfest (push) Successful in 4s
All checks were successful
Validate / Hassfest (push) Successful in 4s
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user