Initial upload: aesthetic-immich-starter
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
import mimetypes
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _api_base_url() -> str:
|
||||
base = settings.immich_base_url.rstrip("/")
|
||||
if not base.endswith("/api"):
|
||||
base = f"{base}/api"
|
||||
return base
|
||||
|
||||
|
||||
class ImmichClient:
|
||||
def __init__(self) -> None:
|
||||
if not settings.immich_api_key:
|
||||
raise HTTPException(status_code=500, detail="IMMICH_API_KEY manquante dans .env")
|
||||
self.base_url = _api_base_url()
|
||||
self.headers = {
|
||||
"x-api-key": settings.immich_api_key,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
self.timeout = settings.request_timeout_seconds
|
||||
|
||||
async def list_albums(self) -> list[dict]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(f"{self.base_url}/albums", headers=self.headers)
|
||||
self._raise_for_immich(response)
|
||||
return response.json()
|
||||
|
||||
async def create_album(self, album_name: str, description: str | None = None) -> dict:
|
||||
payload = {"albumName": album_name}
|
||||
if description:
|
||||
payload["description"] = description
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/albums",
|
||||
headers={**self.headers, "Content-Type": "application/json"},
|
||||
json=payload,
|
||||
)
|
||||
self._raise_for_immich(response)
|
||||
return response.json()
|
||||
|
||||
async def get_or_create_album(self, album_name: str, description: str | None = None) -> dict:
|
||||
albums = await self.list_albums()
|
||||
for album in albums:
|
||||
if album.get("albumName") == album_name:
|
||||
return album
|
||||
return await self.create_album(album_name, description=description)
|
||||
|
||||
async def upload_asset(self, file_path: Path, original_filename: str) -> dict:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
mime_type = mimetypes.guess_type(original_filename)[0] or "application/octet-stream"
|
||||
device_asset_id = f"{settings.upload_device_id}-{uuid4()}-{original_filename}"
|
||||
|
||||
data = {
|
||||
"deviceAssetId": device_asset_id,
|
||||
"deviceId": settings.upload_device_id,
|
||||
"fileCreatedAt": now,
|
||||
"fileModifiedAt": now,
|
||||
"isFavorite": "false",
|
||||
}
|
||||
|
||||
with file_path.open("rb") as file_handle:
|
||||
files = {"assetData": (original_filename, file_handle, mime_type)}
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/assets",
|
||||
headers=self.headers,
|
||||
data=data,
|
||||
files=files,
|
||||
)
|
||||
self._raise_for_immich(response)
|
||||
return response.json()
|
||||
|
||||
async def add_assets_to_album(self, album_id: str, asset_ids: list[str]) -> list[dict]:
|
||||
payload = {"ids": asset_ids}
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.put(
|
||||
f"{self.base_url}/albums/{album_id}/assets",
|
||||
headers={**self.headers, "Content-Type": "application/json"},
|
||||
json=payload,
|
||||
)
|
||||
self._raise_for_immich(response)
|
||||
return response.json()
|
||||
|
||||
async def get_asset_thumbnail(self, asset_id: str, size: str = "preview") -> tuple[bytes, str]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/assets/{asset_id}/thumbnail",
|
||||
headers={"x-api-key": settings.immich_api_key, "Accept": "image/*"},
|
||||
params={"size": size},
|
||||
)
|
||||
self._raise_for_immich(response)
|
||||
media_type = response.headers.get("content-type", "image/jpeg")
|
||||
return response.content, media_type
|
||||
|
||||
async def get_asset_original(self, asset_id: str) -> tuple[bytes, str]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/assets/{asset_id}/original",
|
||||
headers={"x-api-key": settings.immich_api_key, "Accept": "*/*"},
|
||||
)
|
||||
self._raise_for_immich(response)
|
||||
media_type = response.headers.get("content-type", "application/octet-stream")
|
||||
return response.content, media_type
|
||||
|
||||
|
||||
async def list_people(self, q: str | None = None, with_hidden: bool = False, limit: int = 100) -> list[dict]:
|
||||
"""Retourne les personnes reconnues par Immich.
|
||||
|
||||
Si q est fourni, on utilise /search/person. Sinon, on parcourt /people
|
||||
avec pagination. Le parsing est volontairement tolérant car Immich a déjà
|
||||
retourné soit une liste directe, soit un objet contenant une clé people.
|
||||
"""
|
||||
if q and q.strip():
|
||||
return await self.search_people(q.strip(), with_hidden=with_hidden)
|
||||
|
||||
collected: list[dict] = []
|
||||
page = 1
|
||||
page_size = min(max(limit, 1), 100)
|
||||
while len(collected) < limit:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/people",
|
||||
headers=self.headers,
|
||||
params={"page": page, "size": page_size, "withHidden": str(with_hidden).lower()},
|
||||
)
|
||||
self._raise_for_immich(response)
|
||||
payload = response.json()
|
||||
people = self._extract_people(payload)
|
||||
if not people:
|
||||
break
|
||||
collected.extend(people)
|
||||
has_next = bool(payload.get("hasNextPage")) if isinstance(payload, dict) else len(people) >= page_size
|
||||
if not has_next or len(people) < page_size:
|
||||
break
|
||||
page += 1
|
||||
return collected[:limit]
|
||||
|
||||
async def search_people(self, name: str, with_hidden: bool = False) -> list[dict]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/search/person",
|
||||
headers=self.headers,
|
||||
params={"name": name, "withHidden": str(with_hidden).lower()},
|
||||
)
|
||||
self._raise_for_immich(response)
|
||||
payload = response.json()
|
||||
return self._extract_people(payload)
|
||||
|
||||
async def get_person(self, person_id: str) -> dict:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(f"{self.base_url}/people/{person_id}", headers=self.headers)
|
||||
self._raise_for_immich(response)
|
||||
return response.json()
|
||||
|
||||
async def get_person_thumbnail(self, person_id: str) -> tuple[bytes, str]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/people/{person_id}/thumbnail",
|
||||
headers={"x-api-key": settings.immich_api_key, "Accept": "image/*"},
|
||||
)
|
||||
self._raise_for_immich(response)
|
||||
media_type = response.headers.get("content-type", "image/jpeg")
|
||||
return response.content, media_type
|
||||
|
||||
|
||||
async def list_person_assets(self, person_id: str, page: int = 1, size: int = 100) -> list[dict]:
|
||||
"""Retourne les assets complets où Immich a reconnu cette personne.
|
||||
|
||||
Immich recommande maintenant /search/metadata pour filtrer les assets,
|
||||
notamment par personIds, avec pagination.
|
||||
"""
|
||||
page = max(page, 1)
|
||||
size = min(max(size, 1), 250)
|
||||
payload = {
|
||||
"personIds": [person_id],
|
||||
"page": page,
|
||||
"size": size,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/search/metadata",
|
||||
headers={**self.headers, "Content-Type": "application/json"},
|
||||
json=payload,
|
||||
)
|
||||
self._raise_for_immich(response)
|
||||
return self._extract_assets(response.json())
|
||||
|
||||
async def get_asset_info(self, asset_id: str) -> dict:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(f"{self.base_url}/assets/{asset_id}", headers=self.headers)
|
||||
self._raise_for_immich(response)
|
||||
return response.json()
|
||||
|
||||
@staticmethod
|
||||
def _extract_assets(payload: object) -> list[dict]:
|
||||
if isinstance(payload, list):
|
||||
return [item for item in payload if isinstance(item, dict)]
|
||||
if isinstance(payload, dict):
|
||||
assets = payload.get("assets")
|
||||
if isinstance(assets, list):
|
||||
return [item for item in assets if isinstance(item, dict)]
|
||||
if isinstance(assets, dict):
|
||||
items = assets.get("items")
|
||||
if isinstance(items, list):
|
||||
return [item for item in items if isinstance(item, dict)]
|
||||
for key in ("items", "results", "data"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, list):
|
||||
return [item for item in value if isinstance(item, dict)]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _extract_people(payload: object) -> list[dict]:
|
||||
if isinstance(payload, list):
|
||||
return [item for item in payload if isinstance(item, dict)]
|
||||
if isinstance(payload, dict):
|
||||
for key in ("people", "items", "results", "data"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, list):
|
||||
return [item for item in value if isinstance(item, dict)]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _raise_for_immich(response: httpx.Response) -> None:
|
||||
if response.status_code >= 400:
|
||||
try:
|
||||
detail = response.json()
|
||||
except Exception:
|
||||
detail = response.text
|
||||
raise HTTPException(status_code=response.status_code, detail={"immich_error": detail})
|
||||
Reference in New Issue
Block a user