Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/plugins/image_album/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
<a href="https://immich.app/">Link to Immich</a>

Create an album in Immich<br>
Create an API Key with the following permissions: asset.read, asset.download, album.read<br>
Create an API Key with the following permissions: asset.view, asset.read, asset.download, album.read<br>
Store the key in the .env file with IMMICH_KEY=1234<br>

83 changes: 60 additions & 23 deletions src/plugins/image_album/image_album.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,61 @@


class ImmichProvider:
# Ordered from newest to oldest. The first URL that answers 200 to a HEAD
# request is stored in _preview_url_template and reused for all subsequent
# asset fetches so we only pay the probe cost once per provider lifetime.
#
# Current Immich API (viewAsset operationId):
# GET /api/assets/{id}/thumbnail?size=preview
# Older versions (no size param, returns preview JPEG by default):
# GET /api/assets/{id}/thumbnail
_PREVIEW_URL_TEMPLATES = [
"{base}/api/assets/{id}/thumbnail?size=preview", # Immich current API
"{base}/api/assets/{id}/thumbnail", # Immich older (no size param)
]

def __init__(self, base_url: str, key: str, image_loader):
self.base_url = base_url
self.key = key
self.headers = {"x-api-key": self.key}
self.image_loader = image_loader
self.session = get_http_session()
self._preview_url_template: str | None = None

def _resolve_preview_url(self, asset_id: str) -> str:
"""
Return the best available preview URL for *asset_id*.

On the first call we probe the server with HEAD requests to find which
endpoint it supports, then cache the template so subsequent calls skip
the probe entirely.
"""
if self._preview_url_template is None:
for template in self._PREVIEW_URL_TEMPLATES:
url = template.format(base=self.base_url, id=asset_id)
try:
# Use streaming GET so we only read the response headers and
# status code without downloading the image body.
# HEAD is not reliably supported by Immich on these endpoints.
r = self.session.get(url, headers=self.headers, timeout=10, stream=True)
r.close()
if r.status_code == 200:
self._preview_url_template = template
logger.info(f"Immich preview endpoint resolved: {template}")
return url
logger.debug(f"GET (probe) {url} → {r.status_code}, trying next")
except Exception as e:
logger.debug(f"GET (probe) {url} failed: {e}, trying next")

# All preview endpoints failed — log a warning and fall back to the
# original. This avoids a hard failure but may be slow/large.
logger.warning(
"No preview endpoint responded 200; falling back to /original. "
"Consider upgrading Immich or checking API key permissions."
)
self._preview_url_template = "{base}/api/assets/{id}/original"

return self._preview_url_template.format(base=self.base_url, id=asset_id)

def get_album_id(self, album: str) -> str:
logger.debug(f"Fetching albums from {self.base_url}")
Expand All @@ -30,28 +79,16 @@ def get_album_id(self, album: str) -> str:
return matching_albums[0]["id"]

def get_assets(self, album_id: str) -> list[dict]:
"""Fetch all assets from album."""
all_items = []
page_items = [1]
page = 1

logger.debug(f"Fetching assets from album {album_id}")
while page_items:
body = {
"albumIds": [album_id],
"size": 1000,
"page": page
}
r2 = self.session.post(f"{self.base_url}/api/search/metadata", json=body, headers=self.headers)
r2.raise_for_status()
assets_data = r2.json()

page_items = assets_data.get("assets", {}).get("items", [])
all_items.extend(page_items)
page += 1

logger.debug(f"Found {len(all_items)} total assets in album")
return all_items
"""Fetch all image assets from album using the album details endpoint."""
logger.debug(f"Fetching assets from album {album_id} via /api/albums/{album_id}")
r = self.session.get(f"{self.base_url}/api/albums/{album_id}", headers=self.headers)
r.raise_for_status()
album_data = r.json()

assets = album_data.get("assets", []) or []
image_assets = [a for a in assets if a.get("type") == "IMAGE"]
logger.debug(f"Found {len(image_assets)} image assets in album ({len(assets)} total)")
return image_assets

def get_image(self, album: str, dimensions: tuple[int, int], resize: bool = True) -> Image.Image | None:
"""
Expand Down Expand Up @@ -82,7 +119,7 @@ def get_image(self, album: str, dimensions: tuple[int, int], resize: bool = True
# Select random asset
selected_asset = choice(assets)
asset_id = selected_asset["id"]
asset_url = f"{self.base_url}/api/assets/{asset_id}/original"
asset_url = self._resolve_preview_url(asset_id)

logger.info(f"Selected random asset: {asset_id}")
logger.debug(f"Downloading from: {asset_url}")
Expand Down
14 changes: 11 additions & 3 deletions src/utils/image_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import logging
import gc
import psutil
import requests
import tempfile
import os

Expand Down Expand Up @@ -225,9 +226,9 @@ def _load_from_file_lowmem(self, path, dimensions, resize):
# ========== HIGH-PERFORMANCE IMPLEMENTATIONS ==========

def _load_from_url_fast(self, url, dimensions, timeout_ms, resize, headers=None):
"""High-performance URL loading using in-memory processing."""
"""High-performance URL loading by streaming directly into PIL."""
try:
logger.debug("Using in-memory processing (high-performance mode)")
logger.debug("Using streamed in-memory processing (high-performance mode)")

# Merge provided headers with defaults
request_headers = {**self.DEFAULT_HEADERS, **(headers or {})}
Expand All @@ -236,7 +237,14 @@ def _load_from_url_fast(self, url, dimensions, timeout_ms, resize, headers=None)
response = session.get(url, timeout=timeout_ms / 1000, stream=True, headers=request_headers)
response.raise_for_status()

img = Image.open(BytesIO(response.content))
# Pipe the raw socket stream directly into PIL — no intermediate bytes
# buffer needed. decode_content=True handles gzip/deflate transparently.
# img.load() forces full decode while the connection is still open,
# since Image.open() is otherwise lazy (header-only).
response.raw.decode_content = True
img = Image.open(response.raw)
img.load()

original_size = img.size
original_pixels = original_size[0] * original_size[1]
logger.info(f"Downloaded image: {original_size[0]}x{original_size[1]} ({img.mode} mode, {original_pixels/1_000_000:.1f}MP)")
Expand Down