feat: Immich API client with album, asset, and download support

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 10:53:27 -04:00
parent ac6893210c
commit d2cfec136a
2 changed files with 282 additions and 0 deletions

241
src/immich_client.cpp Normal file
View File

@@ -0,0 +1,241 @@
#include "immich_client.h"
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#include <esp_heap_caps.h>
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<AlbumInfo> ImmichClient::fetchAlbums() {
std::vector<AlbumInfo> 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<JsonArray>();
for (JsonObject obj : arr) {
AlbumInfo album;
album.id = obj["id"].as<String>();
album.title = obj["albumName"].as<String>();
album.assetCount = obj["assetCount"] | 0;
albums.push_back(album);
}
Serial.printf("[immich] Fetched %d albums\n", albums.size());
return albums;
}
std::vector<String> ImmichClient::fetchAlbumAssetIds(const String& albumId) {
std::vector<String> 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<JsonArray>();
for (JsonObject asset : assets) {
ids.push_back(asset["id"].as<String>());
}
Serial.printf("[immich] Album %s: %d assets\n", albumId.c_str(), ids.size());
return ids;
}
std::vector<String> ImmichClient::fetchFavoriteAssetIds() {
std::vector<String> 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<JsonArray>();
for (JsonObject asset : arr) {
ids.push_back(asset["id"].as<String>());
}
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;
}

41
src/immich_client.h Normal file
View File

@@ -0,0 +1,41 @@
#pragma once
#include <Arduino.h>
#include <vector>
#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<String> people;
};
class ImmichClient {
public:
void begin(const String& baseUrl, const String& apiKey);
std::vector<AlbumInfo> fetchAlbums();
std::vector<String> fetchAlbumAssetIds(const String& albumId);
std::vector<String> 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);
};