feat(immich): ImmichClient read — search_assets, list_tags, resolve_tag_id, download_thumbnail
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
from photoflow.immich.client import ImmichClient
|
||||||
|
|
||||||
|
__all__ = ["ImmichClient"]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user