diff --git a/shared/photoflow/immich/__init__.py b/shared/photoflow/immich/__init__.py index e69de29..1f58377 100644 --- a/shared/photoflow/immich/__init__.py +++ b/shared/photoflow/immich/__init__.py @@ -0,0 +1,3 @@ +from photoflow.immich.client import ImmichClient + +__all__ = ["ImmichClient"] diff --git a/shared/photoflow/immich/client.py b/shared/photoflow/immich/client.py new file mode 100644 index 0000000..e8413ee --- /dev/null +++ b/shared/photoflow/immich/client.py @@ -0,0 +1,74 @@ +import requests + + +def _normalize(item: dict) -> dict: + exif = item.get("exifInfo") or {} + lat = exif.get("latitude") + lon = exif.get("longitude") + return { + "id": item["id"], + "original_filename": item.get("originalFileName", ""), + "taken_at": item.get("localDateTime", ""), + "gps_lat": float(lat) if lat is not None else None, + "gps_lon": float(lon) if lon is not None else None, + "place_city": exif.get("city"), + "place_country": exif.get("country"), + "type": item.get("type", ""), + "tags": [t.get("name", "") for t in (item.get("tags") or [])], + "rating": int(exif.get("rating") or 0), + "updated_at": item.get("updatedAt", ""), + } + + +class ImmichClient: + def __init__(self, base_url: str, api_key: str, timeout: int = 30): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.session = requests.Session() + self.session.headers.update({"x-api-key": api_key, "Accept": "application/json"}) + + def _url(self, path: str) -> str: + return f"{self.base_url}{path}" + + def list_tags(self) -> list[dict]: + r = self.session.get(self._url("/api/tags"), timeout=self.timeout) + r.raise_for_status() + return r.json() + + def resolve_tag_id(self, name: str): + for tag in self.list_tags(): + if tag.get("value") == name or tag.get("name") == name: + return tag["id"] + return None + + def search_assets(self, *, taken_after=None, taken_before=None, + tag_ids=None, updated_after=None) -> list[dict]: + body = {"withExif": True} + if taken_after: + body["takenAfter"] = taken_after + if taken_before: + body["takenBefore"] = taken_before + if tag_ids: + body["tagIds"] = tag_ids + if updated_after: + body["updatedAfter"] = updated_after + out = [] + page = 1 + while True: + payload = dict(body, size=1000, page=page) + r = self.session.post(self._url("/api/search/metadata"), + json=payload, timeout=self.timeout) + r.raise_for_status() + block = r.json().get("assets", {}) + out.extend(_normalize(it) for it in block.get("items", [])) + nxt = block.get("nextPage") + if not nxt: + break + page = int(nxt) + return out + + def download_thumbnail(self, asset_id: str) -> bytes: + r = self.session.get(self._url(f"/api/assets/{asset_id}/thumbnail?size=preview"), + timeout=self.timeout) + r.raise_for_status() + return r.content diff --git a/shared/tests/test_immich_client.py b/shared/tests/test_immich_client.py new file mode 100644 index 0000000..547896e --- /dev/null +++ b/shared/tests/test_immich_client.py @@ -0,0 +1,50 @@ +from werkzeug.wrappers import Response +from photoflow.immich import ImmichClient + + +def test_resolve_tag_id_matches_name_or_value(httpserver): + httpserver.expect_request("/api/tags").respond_with_json([ + {"id": "t1", "name": "Italy 2019", "value": "Italy 2019"}, + {"id": "t2", "name": "processed", "value": "_pipeline/processed"}, + ]) + c = ImmichClient(httpserver.url_for(""), "k") + assert c.resolve_tag_id("Italy 2019") == "t1" + assert c.resolve_tag_id("_pipeline/processed") == "t2" + assert c.resolve_tag_id("nope") is None + + +def test_search_assets_normalizes_and_paginates(httpserver): + def handler(request): + page = request.json.get("page", 1) + assert request.json.get("withExif") is True + if page == 1: + return Response( + '{"assets": {"items": [{"id": "a", "originalFileName": "a.jpg",' + ' "localDateTime": "2019-06-01T10:00:00.000Z", "type": "IMAGE",' + ' "updatedAt": "2026-01-01T00:00:00Z",' + ' "exifInfo": {"latitude": 45.4, "longitude": 12.3, "city": "Venezia",' + ' "country": "Italy", "rating": 4},' + ' "tags": [{"name": "Italy 2019"}]}], "nextPage": 2}}', + content_type="application/json") + return Response( + '{"assets": {"items": [{"id": "b", "originalFileName": "b.jpg",' + ' "localDateTime": "2019-06-02T11:00:00.000Z", "type": "IMAGE",' + ' "updatedAt": "2026-01-02T00:00:00Z", "exifInfo": {}, "tags": []}],' + ' "nextPage": null}}', content_type="application/json") + + httpserver.expect_request("/api/search/metadata", method="POST").respond_with_handler(handler) + c = ImmichClient(httpserver.url_for(""), "k") + assets = c.search_assets(taken_after="2019-01-01", taken_before="2020-01-01") + assert [a["id"] for a in assets] == ["a", "b"] + a = assets[0] + assert a["gps_lat"] == 45.4 and a["place_city"] == "Venezia" + assert a["tags"] == ["Italy 2019"] and a["rating"] == 4 + assert a["taken_at"] == "2019-06-01T10:00:00.000Z" + assert assets[1]["gps_lat"] is None and assets[1]["tags"] == [] + + +def test_download_thumbnail(httpserver): + httpserver.expect_request("/api/assets/a/thumbnail").respond_with_data( + b"\xff\xd8\xffjpegbytes", content_type="image/jpeg") + c = ImmichClient(httpserver.url_for(""), "k") + assert c.download_thumbnail("a").startswith(b"\xff\xd8\xff")