2026-08-03 10:54:41 -04:00
|
|
|
#include "photo_queue.h"
|
|
|
|
|
#include "immich_client.h"
|
|
|
|
|
#include <ArduinoJson.h>
|
2026-08-03 14:39:55 -04:00
|
|
|
#include <algorithm>
|
2026-08-03 10:54:41 -04:00
|
|
|
#include "config.h"
|
|
|
|
|
|
|
|
|
|
void PhotoQueue::begin(ImmichClient& client, SettingsManager& settings) {
|
|
|
|
|
_client = &client;
|
|
|
|
|
_settings = &settings;
|
|
|
|
|
|
|
|
|
|
Settings s = _settings->get();
|
|
|
|
|
_cursor = s.queue_cursor;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool PhotoQueue::sync() {
|
|
|
|
|
if (_client == nullptr || _settings == nullptr) return false;
|
|
|
|
|
|
2026-08-03 14:39:55 -04:00
|
|
|
_lastSyncAttempt = millis();
|
2026-08-03 22:46:17 -04:00
|
|
|
_syncRequested = false;
|
2026-08-03 14:39:55 -04:00
|
|
|
|
2026-08-03 10:54:41 -04:00
|
|
|
Settings s = _settings->get();
|
2026-08-03 22:46:17 -04:00
|
|
|
auto albumIds = getSelectedAlbumIds();
|
|
|
|
|
|
|
|
|
|
bool success;
|
|
|
|
|
if (!albumIds.empty()) {
|
|
|
|
|
success = syncFromAlbums(albumIds, s);
|
|
|
|
|
} else {
|
|
|
|
|
success = syncFromAllPhotos(s);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!success) {
|
|
|
|
|
_syncRetryDelay = min(_syncRetryDelay * 2, SYNC_RETRY_MAX);
|
|
|
|
|
Serial.printf("[queue] Sync failed, retry in %lus\n", _syncRetryDelay / 1000);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_cursor = 0;
|
|
|
|
|
_lastSyncTime = millis();
|
|
|
|
|
_syncRetryDelay = SYNC_RETRY_MIN;
|
|
|
|
|
Serial.printf("[queue] Synced: %d assets, album_mode=%d, cursor at %d\n",
|
|
|
|
|
_queue.size(), _albumMode, _cursor);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool PhotoQueue::syncFromAlbums(const std::vector<String>& albumIds, const Settings& s) {
|
|
|
|
|
Serial.printf("[queue] Syncing from %d selected album(s)\n", albumIds.size());
|
|
|
|
|
|
|
|
|
|
// Get current album counts for 24h resync change detection
|
|
|
|
|
auto albums = _client->fetchAlbums();
|
|
|
|
|
std::map<String, int> albumCounts;
|
|
|
|
|
for (auto& selId : albumIds) {
|
|
|
|
|
for (auto& album : albums) {
|
|
|
|
|
if (album.id == selId) {
|
|
|
|
|
albumCounts[selId] = album.assetCount;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Use POST /api/search/random with albumIds filter (Immich v3 compatible).
|
|
|
|
|
// The server returns up to `size` random assets from the specified albums.
|
|
|
|
|
// For small albums (< TARGET_QUEUE_SIZE), all assets are returned.
|
|
|
|
|
auto batch = _client->fetchRandomAssets(
|
|
|
|
|
static_cast<int>(TARGET_QUEUE_SIZE), albumIds);
|
|
|
|
|
|
|
|
|
|
if (batch.empty()) {
|
|
|
|
|
Serial.println("[queue] No assets found in selected albums");
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::vector<String> newIds;
|
|
|
|
|
std::vector<String> newDates;
|
|
|
|
|
for (auto& asset : batch) {
|
|
|
|
|
if (std::find(newIds.begin(), newIds.end(), asset.id) != newIds.end()) continue;
|
|
|
|
|
newIds.push_back(asset.id);
|
|
|
|
|
newDates.push_back(asset.dateTime);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Serial.printf("[queue] Album sync: %d assets from server\n", newIds.size());
|
|
|
|
|
|
|
|
|
|
_queue = newIds;
|
|
|
|
|
_albumMode = true;
|
|
|
|
|
_lastAlbumCounts = albumCounts;
|
|
|
|
|
|
|
|
|
|
applyCycleMode(s, newDates);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool PhotoQueue::syncFromAllPhotos(const Settings& s) {
|
|
|
|
|
Serial.printf("[queue] Syncing from all photos (global random)\n");
|
2026-08-03 14:39:55 -04:00
|
|
|
|
2026-08-03 10:54:41 -04:00
|
|
|
std::vector<String> newIds;
|
2026-08-03 19:52:22 -04:00
|
|
|
std::vector<String> newDates;
|
2026-08-03 14:39:55 -04:00
|
|
|
int rounds = 0;
|
|
|
|
|
|
|
|
|
|
Serial.printf("[queue] Fetching until %d usable assets (excluding %d shown)\n",
|
|
|
|
|
TARGET_QUEUE_SIZE, _shown.size());
|
2026-08-03 10:54:41 -04:00
|
|
|
|
2026-08-03 14:39:55 -04:00
|
|
|
while (newIds.size() < TARGET_QUEUE_SIZE && rounds < MAX_FETCH_ROUNDS) {
|
2026-08-03 19:52:22 -04:00
|
|
|
auto batch = _client->fetchRandomAssets(50);
|
2026-08-03 14:39:55 -04:00
|
|
|
if (batch.empty()) {
|
|
|
|
|
Serial.printf("[queue] Fetch round %d returned empty\n", rounds + 1);
|
|
|
|
|
break;
|
2026-08-03 10:54:41 -04:00
|
|
|
}
|
2026-08-03 14:39:55 -04:00
|
|
|
|
2026-08-03 19:52:22 -04:00
|
|
|
for (auto& asset : batch) {
|
2026-08-03 14:39:55 -04:00
|
|
|
if (newIds.size() >= TARGET_QUEUE_SIZE) break;
|
2026-08-03 19:52:22 -04:00
|
|
|
if (std::find(newIds.begin(), newIds.end(), asset.id) != newIds.end()) continue;
|
|
|
|
|
if (std::find(_shown.begin(), _shown.end(), asset.id) != _shown.end()) continue;
|
|
|
|
|
newIds.push_back(asset.id);
|
|
|
|
|
newDates.push_back(asset.dateTime);
|
2026-08-03 10:54:41 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-03 14:39:55 -04:00
|
|
|
rounds++;
|
|
|
|
|
Serial.printf("[queue] Round %d: have %d/%d usable assets\n",
|
|
|
|
|
rounds, newIds.size(), TARGET_QUEUE_SIZE);
|
|
|
|
|
}
|
2026-08-03 10:54:41 -04:00
|
|
|
|
2026-08-03 22:46:17 -04:00
|
|
|
if (newIds.empty()) return false;
|
2026-08-03 10:54:41 -04:00
|
|
|
|
|
|
|
|
_queue = newIds;
|
2026-08-03 22:46:17 -04:00
|
|
|
_albumMode = false;
|
|
|
|
|
_lastAlbumCounts.clear();
|
2026-08-03 10:54:41 -04:00
|
|
|
|
2026-08-03 22:46:17 -04:00
|
|
|
applyCycleMode(s, newDates);
|
|
|
|
|
Serial.printf("[queue] All-photos sync: %d assets in %d rounds\n",
|
|
|
|
|
_queue.size(), rounds);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void PhotoQueue::applyCycleMode(const Settings& s, const std::vector<String>& dates) {
|
2026-08-03 10:54:41 -04:00
|
|
|
switch (s.cycle_mode) {
|
|
|
|
|
case CycleMode::Random:
|
|
|
|
|
applyFavoritesWeighting();
|
|
|
|
|
shuffle();
|
|
|
|
|
break;
|
|
|
|
|
case CycleMode::Chronological:
|
2026-08-03 22:46:17 -04:00
|
|
|
sortChronological(dates, false);
|
2026-08-03 10:54:41 -04:00
|
|
|
break;
|
|
|
|
|
case CycleMode::ReverseChronological:
|
2026-08-03 22:46:17 -04:00
|
|
|
sortChronological(dates, true);
|
2026-08-03 10:54:41 -04:00
|
|
|
break;
|
|
|
|
|
case CycleMode::FavoritesWeighted:
|
|
|
|
|
applyFavoritesWeighting();
|
|
|
|
|
shuffle();
|
|
|
|
|
break;
|
2026-08-03 19:52:22 -04:00
|
|
|
case CycleMode::WeightedChronological:
|
2026-08-03 22:46:17 -04:00
|
|
|
applyRecencyWeighting(dates, true);
|
2026-08-03 19:52:22 -04:00
|
|
|
shuffle();
|
|
|
|
|
break;
|
|
|
|
|
case CycleMode::WeightedReverseChronological:
|
2026-08-03 22:46:17 -04:00
|
|
|
applyRecencyWeighting(dates, false);
|
2026-08-03 19:52:22 -04:00
|
|
|
shuffle();
|
|
|
|
|
break;
|
|
|
|
|
// No default — compiler warns on unhandled CycleMode via -Wswitch
|
2026-08-03 10:54:41 -04:00
|
|
|
}
|
2026-08-03 22:46:17 -04:00
|
|
|
}
|
2026-08-03 10:54:41 -04:00
|
|
|
|
2026-08-03 22:46:17 -04:00
|
|
|
void PhotoQueue::requestSync() {
|
|
|
|
|
_syncRequested = true;
|
2026-08-03 10:54:41 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String PhotoQueue::next() {
|
2026-08-03 14:39:55 -04:00
|
|
|
// Auto-refill when queue is exhausted
|
|
|
|
|
if (_queue.empty() || _cursor >= _queue.size()) {
|
2026-08-03 22:46:17 -04:00
|
|
|
if (_albumMode && !_queue.empty()) {
|
|
|
|
|
// Album mode: reshuffle existing queue instead of re-fetching
|
|
|
|
|
Serial.println("[queue] Album queue exhausted, reshuffling in place");
|
|
|
|
|
Settings s = _settings->get();
|
|
|
|
|
// Clear shown history since we're replaying the same set
|
|
|
|
|
_shown.clear();
|
|
|
|
|
applyCycleMode(s, {});
|
|
|
|
|
_cursor = 0;
|
|
|
|
|
} else {
|
|
|
|
|
// All-photos mode: fetch a new batch
|
|
|
|
|
Serial.println("[queue] Queue exhausted, fetching new batch");
|
|
|
|
|
sync();
|
|
|
|
|
_cursor = 0;
|
|
|
|
|
}
|
2026-08-03 10:54:41 -04:00
|
|
|
}
|
2026-08-03 14:39:55 -04:00
|
|
|
|
|
|
|
|
if (_queue.empty()) return "";
|
|
|
|
|
|
2026-08-03 10:54:41 -04:00
|
|
|
String id = _queue[_cursor++];
|
|
|
|
|
|
2026-08-03 14:39:55 -04:00
|
|
|
// Track shown IDs for dedup on next refill
|
|
|
|
|
_shown.push_back(id);
|
|
|
|
|
if (_shown.size() > MAX_SHOWN_HISTORY) {
|
|
|
|
|
_shown.erase(_shown.begin());
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 10:54:41 -04:00
|
|
|
// Persist cursor
|
2026-08-03 11:00:02 -04:00
|
|
|
_settings->saveField("queue_cursor", static_cast<uint32_t>(_cursor));
|
2026-08-03 10:54:41 -04:00
|
|
|
|
|
|
|
|
return id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String PhotoQueue::random() {
|
|
|
|
|
if (_queue.empty()) return "";
|
|
|
|
|
size_t idx = ::random(0, _queue.size());
|
|
|
|
|
return _queue[idx];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String PhotoQueue::current() {
|
|
|
|
|
if (_queue.empty()) return "";
|
|
|
|
|
size_t idx = (_cursor > 0) ? _cursor - 1 : 0;
|
|
|
|
|
return _queue[idx];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
size_t PhotoQueue::size() {
|
|
|
|
|
return _queue.size();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool PhotoQueue::needsResync() {
|
2026-08-03 22:46:17 -04:00
|
|
|
if (_syncRequested) return true;
|
|
|
|
|
|
2026-08-03 14:39:55 -04:00
|
|
|
unsigned long now = millis();
|
|
|
|
|
|
|
|
|
|
// If never synced successfully, use retry backoff
|
|
|
|
|
if (_lastSyncTime == 0) {
|
|
|
|
|
if (_lastSyncAttempt == 0) return true;
|
|
|
|
|
return (now - _lastSyncAttempt) >= _syncRetryDelay;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Normal resync interval
|
|
|
|
|
unsigned long elapsed = now - _lastSyncTime;
|
2026-08-03 22:46:17 -04:00
|
|
|
if (elapsed < (QUEUE_RESYNC_HOURS * 3600000UL)) return false;
|
|
|
|
|
|
|
|
|
|
// In album mode, check if album sizes actually changed before re-fetching
|
|
|
|
|
if (_albumMode && !_lastAlbumCounts.empty()) {
|
|
|
|
|
auto albums = _client->fetchAlbums();
|
|
|
|
|
auto selectedIds = getSelectedAlbumIds();
|
|
|
|
|
bool changed = false;
|
|
|
|
|
|
|
|
|
|
for (auto& selId : selectedIds) {
|
|
|
|
|
int currentCount = 0;
|
|
|
|
|
for (auto& album : albums) {
|
|
|
|
|
if (album.id == selId) {
|
|
|
|
|
currentCount = album.assetCount;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
auto it = _lastAlbumCounts.find(selId);
|
|
|
|
|
int lastCount = (it != _lastAlbumCounts.end()) ? it->second : -1;
|
|
|
|
|
if (currentCount != lastCount) {
|
|
|
|
|
Serial.printf("[queue] Album %s count changed: %d -> %d\n",
|
|
|
|
|
selId.c_str(), lastCount, currentCount);
|
|
|
|
|
changed = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!changed) {
|
|
|
|
|
Serial.println("[queue] Album counts unchanged, reshuffling instead of re-fetching");
|
|
|
|
|
Settings s = _settings->get();
|
|
|
|
|
_shown.clear();
|
|
|
|
|
applyCycleMode(s, {});
|
|
|
|
|
_cursor = 0;
|
|
|
|
|
_lastSyncTime = millis();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
2026-08-03 10:54:41 -04:00
|
|
|
}
|
|
|
|
|
|
feat: blurred letterbox, portrait pairs, overlay metadata, button fixes
- Letterbox: extend edge pixels outward with blur + fade to solid color
- Portrait: detect and pair portraits side-by-side, fallback to solo
- Metadata: add overlay mode (corner badge), time field, custom date/time
format strings (strftime), font size 1.5x, region-aware for pairs
- Buttons: swap G9/G10 to match physical layout, double-press random
button for deep sleep, SLP indicator drawn before sleep
- Boot: remove redundant 'Loading photos' screen (saves 17s refresh)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:53:06 -04:00
|
|
|
String PhotoQueue::findNextPortrait() {
|
|
|
|
|
if (_client == nullptr) return "";
|
|
|
|
|
|
|
|
|
|
size_t scanEnd = min(_cursor + PORTRAIT_LOOKAHEAD, _queue.size());
|
|
|
|
|
for (size_t i = _cursor; i < scanEnd; i++) {
|
|
|
|
|
AssetInfo info = _client->fetchAssetInfo(_queue[i]);
|
|
|
|
|
if (info.isPortrait) {
|
|
|
|
|
String id = _queue[i];
|
|
|
|
|
_queue.erase(_queue.begin() + i);
|
|
|
|
|
Serial.printf("[queue] Found portrait pair: %s (scanned %d ahead)\n",
|
|
|
|
|
id.c_str(), (int)(i - _cursor + 1));
|
|
|
|
|
return id;
|
|
|
|
|
}
|
2026-08-03 10:54:41 -04:00
|
|
|
}
|
feat: blurred letterbox, portrait pairs, overlay metadata, button fixes
- Letterbox: extend edge pixels outward with blur + fade to solid color
- Portrait: detect and pair portraits side-by-side, fallback to solo
- Metadata: add overlay mode (corner badge), time field, custom date/time
format strings (strftime), font size 1.5x, region-aware for pairs
- Buttons: swap G9/G10 to match physical layout, double-press random
button for deep sleep, SLP indicator drawn before sleep
- Boot: remove redundant 'Loading photos' screen (saves 17s refresh)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:53:06 -04:00
|
|
|
Serial.println("[queue] No portrait pair found in lookahead");
|
2026-08-03 10:54:41 -04:00
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void PhotoQueue::shuffle() {
|
|
|
|
|
for (size_t i = _queue.size() - 1; i > 0; i--) {
|
|
|
|
|
size_t j = ::random(0, i + 1);
|
|
|
|
|
std::swap(_queue[i], _queue[j]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 19:52:22 -04:00
|
|
|
void PhotoQueue::sortChronological(const std::vector<String>& dates, bool reverse) {
|
|
|
|
|
if (dates.size() != _queue.size()) {
|
2026-08-03 22:46:17 -04:00
|
|
|
// Dates unavailable (e.g. reshuffle after exhaustion) — fall back to shuffle
|
|
|
|
|
if (dates.empty()) {
|
|
|
|
|
shuffle();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-08-03 19:52:22 -04:00
|
|
|
Serial.println("[queue] sortChronological: date/queue size mismatch, skipping");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build index array sorted by date (ISO 8601 strings compare correctly)
|
|
|
|
|
std::vector<size_t> indices(dates.size());
|
|
|
|
|
for (size_t i = 0; i < indices.size(); i++) indices[i] = i;
|
|
|
|
|
|
|
|
|
|
std::sort(indices.begin(), indices.end(), [&](size_t a, size_t b) {
|
|
|
|
|
return dates[a] < dates[b];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (reverse) std::reverse(indices.begin(), indices.end());
|
|
|
|
|
|
|
|
|
|
std::vector<String> sorted;
|
|
|
|
|
sorted.reserve(_queue.size());
|
|
|
|
|
for (size_t idx : indices) {
|
|
|
|
|
sorted.push_back(_queue[idx]);
|
2026-08-03 10:54:41 -04:00
|
|
|
}
|
2026-08-03 19:52:22 -04:00
|
|
|
_queue = sorted;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void PhotoQueue::applyRecencyWeighting(const std::vector<String>& dates, bool favorRecent) {
|
2026-08-03 22:46:17 -04:00
|
|
|
if (dates.size() != _queue.size() || _queue.size() < 3) {
|
|
|
|
|
// Dates unavailable — fall back to shuffle
|
|
|
|
|
if (dates.empty()) return;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-08-03 19:52:22 -04:00
|
|
|
|
|
|
|
|
// Sort queue by date (oldest first)
|
|
|
|
|
std::vector<size_t> indices(dates.size());
|
|
|
|
|
for (size_t i = 0; i < indices.size(); i++) indices[i] = i;
|
|
|
|
|
std::sort(indices.begin(), indices.end(), [&](size_t a, size_t b) {
|
|
|
|
|
return dates[a] < dates[b];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
std::vector<String> sorted;
|
|
|
|
|
sorted.reserve(_queue.size());
|
|
|
|
|
for (size_t idx : indices) {
|
|
|
|
|
sorted.push_back(_queue[idx]);
|
|
|
|
|
}
|
|
|
|
|
_queue = sorted;
|
|
|
|
|
|
|
|
|
|
// Duplicate the preferred third (3x copies total)
|
|
|
|
|
size_t third = _queue.size() / 3;
|
|
|
|
|
size_t start = favorRecent ? _queue.size() - third : 0;
|
|
|
|
|
size_t end = favorRecent ? _queue.size() : third;
|
|
|
|
|
|
|
|
|
|
for (size_t i = start; i < end; i++) {
|
|
|
|
|
_queue.push_back(_queue[i]);
|
|
|
|
|
_queue.push_back(_queue[i]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Serial.printf("[queue] Recency weighting: %s third duplicated (%d -> %d entries)\n",
|
|
|
|
|
favorRecent ? "recent" : "oldest", (int)sorted.size(), (int)_queue.size());
|
2026-08-03 10:54:41 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void PhotoQueue::applyFavoritesWeighting() {
|
|
|
|
|
if (_client == nullptr) return;
|
|
|
|
|
|
|
|
|
|
Settings s = _settings->get();
|
|
|
|
|
if (s.cycle_mode != CycleMode::FavoritesWeighted) return;
|
|
|
|
|
|
|
|
|
|
auto favorites = _client->fetchFavoriteAssetIds();
|
|
|
|
|
for (auto& fav : favorites) {
|
|
|
|
|
bool inQueue = false;
|
|
|
|
|
for (auto& id : _queue) {
|
|
|
|
|
if (id == fav) { inQueue = true; break; }
|
|
|
|
|
}
|
|
|
|
|
if (inQueue) {
|
|
|
|
|
_queue.push_back(fav);
|
|
|
|
|
_queue.push_back(fav);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::vector<String> PhotoQueue::getSelectedAlbumIds() {
|
|
|
|
|
std::vector<String> ids;
|
|
|
|
|
Settings s = _settings->get();
|
|
|
|
|
|
|
|
|
|
JsonDocument doc;
|
|
|
|
|
DeserializationError err = deserializeJson(doc, s.albums_json);
|
|
|
|
|
if (err) return ids;
|
|
|
|
|
|
|
|
|
|
JsonArray arr = doc.as<JsonArray>();
|
|
|
|
|
for (JsonVariant v : arr) {
|
|
|
|
|
ids.push_back(v.as<String>());
|
|
|
|
|
}
|
|
|
|
|
return ids;
|
|
|
|
|
}
|