- Batch-fetch asset IDs in deep sleep mode with NVS caching to reduce network calls and improve battery life (configurable batch size 1-50) - Add wake_log module: persistent CSV log of each photo cycle surviving reboots and deep sleep, with download/clear via web UI - Add browser-based log viewer and log comparison tools (tools/) - Update .gitignore to exclude .cursor/, chat-summaries/, wake_*.csv, and .DS_Store Co-authored-by: Cursor <cursoragent@cursor.com>
602 lines
18 KiB
C++
602 lines
18 KiB
C++
#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;
|
|
|
|
// 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<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);
|
|
|
|
// 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<JsonArray>();
|
|
for (JsonObject asset : assets) {
|
|
String id = asset["id"].as<String>();
|
|
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<String> ImmichClient::fetchRandomAssetIds(int count,
|
|
const std::vector<String>& albumIds) {
|
|
std::vector<String> ids;
|
|
String url = buildUrl("/api/search/random");
|
|
|
|
JsonDocument reqDoc;
|
|
reqDoc["size"] = count;
|
|
reqDoc["type"] = "IMAGE";
|
|
if (!albumIds.empty()) {
|
|
JsonArray arr = reqDoc["albumIds"].to<JsonArray>();
|
|
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<JsonArray>();
|
|
int skippedRaw = 0;
|
|
for (JsonObject asset : arr) {
|
|
String id = asset["id"].as<String>();
|
|
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<RandomAsset> ImmichClient::fetchRandomAssets(int count,
|
|
const std::vector<String>& albumIds) {
|
|
std::vector<RandomAsset> assets;
|
|
String url = buildUrl("/api/search/random");
|
|
|
|
JsonDocument reqDoc;
|
|
reqDoc["size"] = count;
|
|
reqDoc["type"] = "IMAGE";
|
|
if (!albumIds.empty()) {
|
|
JsonArray albumArr = reqDoc["albumIds"].to<JsonArray>();
|
|
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<JsonArray>();
|
|
int skippedRaw = 0;
|
|
for (JsonObject asset : arr) {
|
|
String id = asset["id"].as<String>();
|
|
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<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;
|
|
}
|
|
|
|
String ImmichClient::fetchOneRandomAssetId(const RandomFetchFilter& filter) {
|
|
String url = buildUrl("/api/search/random");
|
|
|
|
JsonDocument reqDoc;
|
|
reqDoc["size"] = 1;
|
|
reqDoc["type"] = "IMAGE";
|
|
|
|
if (!filter.albumIds.empty()) {
|
|
JsonArray arr = reqDoc["albumIds"].to<JsonArray>();
|
|
for (auto& id : filter.albumIds) arr.add(id);
|
|
}
|
|
if (filter.favoriteOnly) {
|
|
reqDoc["isFavorite"] = true;
|
|
}
|
|
if (filter.takenAfter.length() > 0) {
|
|
reqDoc["takenAfter"] = filter.takenAfter;
|
|
}
|
|
if (filter.takenBefore.length() > 0) {
|
|
reqDoc["takenBefore"] = filter.takenBefore;
|
|
}
|
|
|
|
String body;
|
|
serializeJson(reqDoc, body);
|
|
|
|
Serial.printf("[immich] fetchOne: %s\n", body.c_str());
|
|
|
|
String response = httpPost(url, body);
|
|
if (response.isEmpty()) {
|
|
Serial.println("[immich] fetchOne: empty response");
|
|
return "";
|
|
}
|
|
|
|
JsonDocument doc;
|
|
DeserializationError err = deserializeJson(doc, response);
|
|
if (err) {
|
|
Serial.printf("[immich] fetchOne JSON error: %s\n", err.c_str());
|
|
return "";
|
|
}
|
|
|
|
JsonArray arr = doc.as<JsonArray>();
|
|
for (JsonObject asset : arr) {
|
|
String id = asset["id"].as<String>();
|
|
if (id.length() == 0) continue;
|
|
|
|
// Skip RAW files
|
|
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")) {
|
|
continue;
|
|
}
|
|
|
|
return id;
|
|
}
|
|
|
|
Serial.println("[immich] fetchOne: no usable asset returned");
|
|
return "";
|
|
}
|
|
|
|
std::vector<String> ImmichClient::fetchFilteredBatch(const RandomFetchFilter& filter, int count) {
|
|
std::vector<String> results;
|
|
String url = buildUrl("/api/search/random");
|
|
|
|
JsonDocument reqDoc;
|
|
reqDoc["size"] = count;
|
|
reqDoc["type"] = "IMAGE";
|
|
|
|
if (!filter.albumIds.empty()) {
|
|
JsonArray arr = reqDoc["albumIds"].to<JsonArray>();
|
|
for (auto& id : filter.albumIds) arr.add(id);
|
|
}
|
|
if (filter.favoriteOnly) {
|
|
reqDoc["isFavorite"] = true;
|
|
}
|
|
if (filter.takenAfter.length() > 0) {
|
|
reqDoc["takenAfter"] = filter.takenAfter;
|
|
}
|
|
if (filter.takenBefore.length() > 0) {
|
|
reqDoc["takenBefore"] = filter.takenBefore;
|
|
}
|
|
|
|
String body;
|
|
serializeJson(reqDoc, body);
|
|
|
|
Serial.printf("[immich] fetchBatch(%d): %s\n", count, body.c_str());
|
|
|
|
String response = httpPost(url, body);
|
|
if (response.isEmpty()) {
|
|
Serial.println("[immich] fetchBatch: empty response");
|
|
return results;
|
|
}
|
|
|
|
JsonDocument doc;
|
|
DeserializationError err = deserializeJson(doc, response);
|
|
if (err) {
|
|
Serial.printf("[immich] fetchBatch JSON error: %s\n", err.c_str());
|
|
return results;
|
|
}
|
|
|
|
JsonArray arr = doc.as<JsonArray>();
|
|
for (JsonObject asset : arr) {
|
|
String id = asset["id"].as<String>();
|
|
if (id.length() == 0) continue;
|
|
|
|
// Skip RAW files
|
|
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")) {
|
|
continue;
|
|
}
|
|
|
|
results.push_back(id);
|
|
}
|
|
|
|
Serial.printf("[immich] fetchBatch: got %d usable assets\n", (int)results.size());
|
|
return results;
|
|
}
|
|
|
|
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;
|
|
}
|