feat: fit-contain letterbox + smart queue refill

- Replace center-crop with fit-contain (no photo cropping)
- Letterbox bars filled with adjacent edge average color
- Queue fetches in batches until 50 usable assets
- Auto-refill queue on exhaustion, dedup via shown history
- Filter RAW files from asset selection

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 14:39:55 -04:00
parent 3c3b238ab1
commit 1d4f94426d
10 changed files with 985 additions and 411 deletions

View File

@@ -11,7 +11,13 @@ void ImmichClient::begin(const String& baseUrl, const String& apiKey) {
_baseUrl.remove(_baseUrl.length() - 1);
}
_apiKey = apiKey;
Serial.printf("[immich] Configured: %s\n", _baseUrl.c_str());
// 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() {
@@ -47,12 +53,23 @@ std::vector<AlbumInfo> ImmichClient::fetchAlbums() {
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;
// 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);
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;
@@ -60,13 +77,67 @@ std::vector<String> ImmichClient::fetchAlbumAssetIds(const String& albumId) {
JsonArray assets = doc["assets"].as<JsonArray>();
for (JsonObject asset : assets) {
ids.push_back(asset["id"].as<String>());
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) {
std::vector<String> ids;
String url = buildUrl("/api/search/random");
// Build POST body — filter to images only (excludes videos, audio)
JsonDocument reqDoc;
reqDoc["size"] = count;
reqDoc["type"] = "IMAGE";
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;
// Filter out RAW/DNG files — their previews work but we prefer actual photos
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 (skipped %d RAW)\n",
ids.size(), skippedRaw);
return ids;
}
std::vector<String> ImmichClient::fetchFavoriteAssetIds() {
std::vector<String> ids;
String url = buildUrl("/api/assets?isFavorite=true");
@@ -147,14 +218,29 @@ AssetInfo ImmichClient::fetchAssetInfo(const String& assetId) {
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");
// 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;
}
return httpGetBinary(url, outBuffer, outSize);
// 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) {
@@ -183,6 +269,102 @@ String ImmichClient::httpGet(const String& url) {
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();