From d2cfec136abe5f9d43504a18743386eb155c0e29 Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 3 Aug 2026 10:53:27 -0400 Subject: [PATCH] feat: Immich API client with album, asset, and download support Co-authored-by: Cursor --- src/immich_client.cpp | 241 ++++++++++++++++++++++++++++++++++++++++++ src/immich_client.h | 41 +++++++ 2 files changed, 282 insertions(+) create mode 100644 src/immich_client.cpp create mode 100644 src/immich_client.h diff --git a/src/immich_client.cpp b/src/immich_client.cpp new file mode 100644 index 0000000..1361bdb --- /dev/null +++ b/src/immich_client.cpp @@ -0,0 +1,241 @@ +#include "immich_client.h" +#include +#include +#include +#include + +void ImmichClient::begin(const String& baseUrl, const String& apiKey) { + _baseUrl = baseUrl; + // Remove trailing slash if present + if (_baseUrl.endsWith("/")) { + _baseUrl.remove(_baseUrl.length() - 1); + } + _apiKey = apiKey; + Serial.printf("[immich] Configured: %s\n", _baseUrl.c_str()); +} + +std::vector ImmichClient::fetchAlbums() { + std::vector albums; + String url = buildUrl("/api/albums"); + String response = httpGet(url); + + if (response.isEmpty()) { + Serial.println("[immich] fetchAlbums: empty response"); + return albums; + } + + JsonDocument doc; + DeserializationError err = deserializeJson(doc, response); + if (err) { + Serial.printf("[immich] fetchAlbums JSON error: %s\n", err.c_str()); + return albums; + } + + JsonArray arr = doc.as(); + for (JsonObject obj : arr) { + AlbumInfo album; + album.id = obj["id"].as(); + album.title = obj["albumName"].as(); + album.assetCount = obj["assetCount"] | 0; + albums.push_back(album); + } + + Serial.printf("[immich] Fetched %d albums\n", albums.size()); + return albums; +} + +std::vector ImmichClient::fetchAlbumAssetIds(const String& albumId) { + std::vector ids; + String url = buildUrl("/api/albums/" + albumId); + String response = httpGet(url); + + if (response.isEmpty()) return ids; + + JsonDocument doc; + DeserializationError err = deserializeJson(doc, response); + if (err) { + Serial.printf("[immich] fetchAlbumAssets JSON error: %s\n", err.c_str()); + return ids; + } + + JsonArray assets = doc["assets"].as(); + for (JsonObject asset : assets) { + ids.push_back(asset["id"].as()); + } + + Serial.printf("[immich] Album %s: %d assets\n", albumId.c_str(), ids.size()); + return ids; +} + +std::vector ImmichClient::fetchFavoriteAssetIds() { + std::vector ids; + String url = buildUrl("/api/assets?isFavorite=true"); + String response = httpGet(url); + + if (response.isEmpty()) return ids; + + JsonDocument doc; + DeserializationError err = deserializeJson(doc, response); + if (err) return ids; + + JsonArray arr = doc.as(); + for (JsonObject asset : arr) { + ids.push_back(asset["id"].as()); + } + + Serial.printf("[immich] Favorites: %d assets\n", ids.size()); + return ids; +} + +AssetInfo ImmichClient::fetchAssetInfo(const String& assetId) { + AssetInfo info; + info.id = assetId; + info.isFavorite = false; + info.isPortrait = false; + + String url = buildUrl("/api/assets/" + assetId); + String response = httpGet(url); + + if (response.isEmpty()) return info; + + JsonDocument doc; + DeserializationError err = deserializeJson(doc, response); + if (err) return info; + + info.originalFileName = doc["originalFileName"] | ""; + info.isFavorite = doc["isFavorite"] | false; + + // Date + info.dateTime = doc["localDateTime"] | ""; + + // EXIF data + JsonObject exif = doc["exifInfo"]; + if (!exif.isNull()) { + info.city = exif["city"] | ""; + String make = exif["make"] | ""; + String model = exif["model"] | ""; + if (make.length() > 0 || model.length() > 0) { + info.camera = make + " " + model; + info.camera.trim(); + } + + // Portrait detection: check orientation or dimensions + int width = exif["exifImageWidth"] | 0; + int height = exif["exifImageHeight"] | 0; + int orientation = exif["orientation"] | 1; + // Orientations 5-8 mean the image is rotated 90/270 degrees + if (orientation >= 5 && orientation <= 8) { + info.isPortrait = (width > height); + } else { + info.isPortrait = (height > width); + } + } + + // People + JsonArray people = doc["people"]; + if (!people.isNull()) { + for (JsonObject person : people) { + String name = person["name"] | ""; + if (name.length() > 0) { + info.people.push_back(name); + } + } + } + + return info; +} + +bool ImmichClient::downloadAsset(const String& assetId, ImageQuality quality, + uint8_t** outBuffer, size_t* outSize) { + String url; + if (quality == ImageQuality::Original) { + url = buildUrl("/api/assets/" + assetId + "/original"); + } else { + url = buildUrl("/api/assets/" + assetId + "/thumbnail?size=preview"); + } + + return httpGetBinary(url, outBuffer, outSize); +} + +String ImmichClient::buildUrl(const String& path) { + return _baseUrl + path; +} + +String ImmichClient::httpGet(const String& url) { + WiFiClientSecure client; + client.setInsecure(); // Skip TLS cert verification for self-hosted + + HTTPClient http; + http.begin(client, url); + http.addHeader("x-api-key", _apiKey); + http.setTimeout(30000); + + int code = http.GET(); + String result = ""; + + if (code == HTTP_CODE_OK) { + result = http.getString(); + } else { + Serial.printf("[immich] HTTP GET %s failed: %d\n", url.c_str(), code); + } + + http.end(); + return result; +} + +bool ImmichClient::httpGetBinary(const String& url, uint8_t** outBuffer, size_t* outSize) { + WiFiClientSecure client; + client.setInsecure(); + + HTTPClient http; + http.begin(client, url); + http.addHeader("x-api-key", _apiKey); + http.setTimeout(60000); + + int code = http.GET(); + if (code != HTTP_CODE_OK) { + Serial.printf("[immich] Binary GET failed: %d\n", code); + http.end(); + return false; + } + + int contentLength = http.getSize(); + if (contentLength <= 0) { + Serial.println("[immich] Unknown content length"); + http.end(); + return false; + } + + // Allocate in PSRAM + *outBuffer = (uint8_t*)ps_malloc(contentLength); + if (*outBuffer == nullptr) { + Serial.printf("[immich] Failed to allocate %d bytes in PSRAM\n", contentLength); + http.end(); + return false; + } + + WiFiClient* stream = http.getStreamPtr(); + size_t bytesRead = 0; + while (bytesRead < (size_t)contentLength) { + size_t available = stream->available(); + if (available > 0) { + size_t toRead = min(available, (size_t)(contentLength - bytesRead)); + size_t read = stream->readBytes(*outBuffer + bytesRead, toRead); + bytesRead += read; + } else { + delay(1); + } + if (!http.connected() && bytesRead < (size_t)contentLength) { + Serial.println("[immich] Connection lost during download"); + free(*outBuffer); + *outBuffer = nullptr; + http.end(); + return false; + } + } + + *outSize = bytesRead; + http.end(); + Serial.printf("[immich] Downloaded %d bytes\n", bytesRead); + return true; +} diff --git a/src/immich_client.h b/src/immich_client.h new file mode 100644 index 0000000..ca6289a --- /dev/null +++ b/src/immich_client.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include "settings.h" + +struct AlbumInfo { + String id; + String title; + int assetCount; +}; + +struct AssetInfo { + String id; + String originalFileName; + String dateTime; + String city; + String camera; + bool isFavorite; + bool isPortrait; + std::vector people; +}; + +class ImmichClient { +public: + void begin(const String& baseUrl, const String& apiKey); + std::vector fetchAlbums(); + std::vector fetchAlbumAssetIds(const String& albumId); + std::vector fetchFavoriteAssetIds(); + AssetInfo fetchAssetInfo(const String& assetId); + bool downloadAsset(const String& assetId, ImageQuality quality, + uint8_t** outBuffer, size_t* outSize); + +private: + String _baseUrl; + String _apiKey; + + String buildUrl(const String& path); + String httpGet(const String& url); + bool httpGetBinary(const String& url, uint8_t** outBuffer, size_t* outSize); +};