#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; // Debug: show masked key so we can verify it's non-empty and correct prefix String maskedKey = _apiKey.length() > 8 ? _apiKey.substring(0, 8) + "..." : (_apiKey.length() > 0 ? "***" : "(empty)"); Serial.printf("[immich] Configured: %s | key: %s (len=%d)\n", _baseUrl.c_str(), maskedKey.c_str(), _apiKey.length()); } 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); // Album responses can be very large (full asset metadata per photo). // Use PSRAM-backed fetch to avoid exhausting regular heap. size_t responseLen = 0; char* response = httpGetPsram(url, &responseLen); if (response == nullptr || responseLen == 0) { Serial.printf("[immich] Album %s: empty response\n", albumId.c_str()); if (response) free(response); return ids; } Serial.printf("[immich] Album %s: response %d bytes\n", albumId.c_str(), responseLen); JsonDocument doc; DeserializationError err = deserializeJson(doc, response, responseLen); free(response); // Free PSRAM buffer immediately after parsing if (err) { Serial.printf("[immich] fetchAlbumAssets JSON error: %s\n", err.c_str()); return ids; } JsonArray assets = doc["assets"].as(); for (JsonObject asset : assets) { String id = asset["id"].as(); if (id.length() > 0) { ids.push_back(id); } } Serial.printf("[immich] Album %s: %d assets\n", albumId.c_str(), ids.size()); return ids; } std::vector ImmichClient::fetchRandomAssetIds(int count, const std::vector& albumIds) { std::vector ids; String url = buildUrl("/api/search/random"); JsonDocument reqDoc; reqDoc["size"] = count; reqDoc["type"] = "IMAGE"; if (!albumIds.empty()) { JsonArray arr = reqDoc["albumIds"].to(); for (auto& id : albumIds) arr.add(id); } String body; serializeJson(reqDoc, body); String response = httpPost(url, body); if (response.isEmpty()) { Serial.println("[immich] fetchRandom: empty response"); return ids; } JsonDocument doc; DeserializationError err = deserializeJson(doc, response); if (err) { Serial.printf("[immich] fetchRandom JSON error: %s\n", err.c_str()); return ids; } JsonArray arr = doc.as(); int skippedRaw = 0; for (JsonObject asset : arr) { String id = asset["id"].as(); if (id.length() == 0) continue; String filename = asset["originalFileName"] | ""; filename.toLowerCase(); if (filename.endsWith(".dng") || filename.endsWith(".raw") || filename.endsWith(".cr2") || filename.endsWith(".cr3") || filename.endsWith(".nef") || filename.endsWith(".arw") || filename.endsWith(".orf") || filename.endsWith(".rw2") || filename.endsWith(".raf") || filename.endsWith(".srw")) { skippedRaw++; continue; } ids.push_back(id); } Serial.printf("[immich] Fetched %d random assets%s (skipped %d RAW)\n", ids.size(), albumIds.empty() ? "" : " from albums", skippedRaw); return ids; } std::vector ImmichClient::fetchRandomAssets(int count, const std::vector& albumIds) { std::vector assets; String url = buildUrl("/api/search/random"); JsonDocument reqDoc; reqDoc["size"] = count; reqDoc["type"] = "IMAGE"; if (!albumIds.empty()) { JsonArray albumArr = reqDoc["albumIds"].to(); for (auto& aid : albumIds) albumArr.add(aid); } String body; serializeJson(reqDoc, body); String response = httpPost(url, body); if (response.isEmpty()) return assets; JsonDocument doc; DeserializationError err = deserializeJson(doc, response); if (err) return assets; JsonArray arr = doc.as(); int skippedRaw = 0; for (JsonObject asset : arr) { String id = asset["id"].as(); if (id.length() == 0) continue; String filename = asset["originalFileName"] | ""; filename.toLowerCase(); if (filename.endsWith(".dng") || filename.endsWith(".raw") || filename.endsWith(".cr2") || filename.endsWith(".cr3") || filename.endsWith(".nef") || filename.endsWith(".arw") || filename.endsWith(".orf") || filename.endsWith(".rw2") || filename.endsWith(".raf") || filename.endsWith(".srw")) { skippedRaw++; continue; } RandomAsset ra; ra.id = id; ra.dateTime = asset["localDateTime"] | ""; assets.push_back(ra); } Serial.printf("[immich] Fetched %d random assets with dates%s (skipped %d RAW)\n", assets.size(), albumIds.empty() ? "" : " from albums", skippedRaw); return assets; } 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) { // Always request preview size with JPEG format. // Server controls actual pixel dimensions via its image config (720px baseline). // The "quality" setting is kept for future use but we always use preview for now // to stay within PSRAM budget. (void)quality; String url = buildUrl("/api/assets/" + assetId + "/thumbnail?size=preview&format=jpeg"); if (!httpGetBinary(url, outBuffer, outSize)) { return false; } // Verify JPEG magic bytes (FF D8 FF) if (*outSize < 3 || (*outBuffer)[0] != 0xFF || (*outBuffer)[1] != 0xD8 || (*outBuffer)[2] != 0xFF) { Serial.printf("[immich] Not JPEG data (magic: %02X %02X %02X)\n", (*outBuffer)[0], (*outBuffer)[1], (*outBuffer)[2]); free(*outBuffer); *outBuffer = nullptr; *outSize = 0; return false; } return true; } 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; } String ImmichClient::httpPost(const String& url, const String& body) { WiFiClientSecure client; client.setInsecure(); HTTPClient http; http.begin(client, url); http.addHeader("x-api-key", _apiKey); http.addHeader("Content-Type", "application/json"); http.setTimeout(30000); int code = http.POST(body); String result = ""; if (code == HTTP_CODE_OK) { result = http.getString(); } else { Serial.printf("[immich] HTTP POST %s failed: %d\n", url.c_str(), code); } http.end(); return result; } char* ImmichClient::httpGetPsram(const String& url, size_t* outLen) { *outLen = 0; 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] PSRAM GET %s failed: %d\n", url.c_str(), code); http.end(); return nullptr; } int contentLength = http.getSize(); WiFiClient* stream = http.getStreamPtr(); // For chunked responses without Content-Length, read in chunks if (contentLength <= 0) { // Read incrementally into PSRAM, up to 4MB max const size_t maxSize = 4 * 1024 * 1024; size_t capacity = 64 * 1024; char* buf = (char*)ps_malloc(capacity); if (!buf) { Serial.println("[immich] PSRAM alloc failed"); http.end(); return nullptr; } size_t total = 0; while (stream->connected() || stream->available()) { int avail = stream->available(); if (avail <= 0) { delay(1); continue; } if (total + avail >= capacity) { capacity = min(capacity * 2, maxSize); char* newBuf = (char*)ps_realloc(buf, capacity); if (!newBuf) { free(buf); http.end(); return nullptr; } buf = newBuf; } int read = stream->readBytes(buf + total, avail); total += read; if (total >= maxSize) break; } buf[total] = '\0'; *outLen = total; http.end(); return buf; } // Known content length — single PSRAM allocation char* buf = (char*)ps_malloc(contentLength + 1); if (!buf) { Serial.printf("[immich] PSRAM alloc failed for %d bytes\n", contentLength); http.end(); return nullptr; } size_t bytesRead = 0; while (bytesRead < (size_t)contentLength && (stream->connected() || stream->available())) { int avail = stream->available(); if (avail <= 0) { delay(1); continue; } int read = stream->readBytes(buf + bytesRead, min(avail, (int)(contentLength - bytesRead))); bytesRead += read; } buf[bytesRead] = '\0'; *outLen = bytesRead; http.end(); return buf; } 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; }