feat: Immich API client with album, asset, and download support
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
241
src/immich_client.cpp
Normal file
241
src/immich_client.cpp
Normal 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
41
src/immich_client.h
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user