#include "photo_queue.h" #include "immich_client.h" #include #include #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; _lastSyncAttempt = millis(); _syncRequested = false; Settings s = _settings->get(); 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& 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 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(TARGET_QUEUE_SIZE), albumIds); if (batch.empty()) { Serial.println("[queue] No assets found in selected albums"); return false; } std::vector newIds; std::vector 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"); std::vector newIds; std::vector newDates; int rounds = 0; Serial.printf("[queue] Fetching until %d usable assets (excluding %d shown)\n", TARGET_QUEUE_SIZE, _shown.size()); while (newIds.size() < TARGET_QUEUE_SIZE && rounds < MAX_FETCH_ROUNDS) { auto batch = _client->fetchRandomAssets(50); if (batch.empty()) { Serial.printf("[queue] Fetch round %d returned empty\n", rounds + 1); break; } for (auto& asset : batch) { if (newIds.size() >= TARGET_QUEUE_SIZE) break; 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); } rounds++; Serial.printf("[queue] Round %d: have %d/%d usable assets\n", rounds, newIds.size(), TARGET_QUEUE_SIZE); } if (newIds.empty()) return false; _queue = newIds; _albumMode = false; _lastAlbumCounts.clear(); 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& dates) { switch (s.cycle_mode) { case CycleMode::Random: applyFavoritesWeighting(); shuffle(); break; case CycleMode::Chronological: sortChronological(dates, false); break; case CycleMode::ReverseChronological: sortChronological(dates, true); break; case CycleMode::FavoritesWeighted: applyFavoritesWeighting(); shuffle(); break; case CycleMode::WeightedChronological: applyRecencyWeighting(dates, true); shuffle(); break; case CycleMode::WeightedReverseChronological: applyRecencyWeighting(dates, false); shuffle(); break; // No default — compiler warns on unhandled CycleMode via -Wswitch } } void PhotoQueue::requestSync() { _syncRequested = true; } String PhotoQueue::next() { // Auto-refill when queue is exhausted if (_queue.empty() || _cursor >= _queue.size()) { 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; } } if (_queue.empty()) return ""; String id = _queue[_cursor++]; // Track shown IDs for dedup on next refill _shown.push_back(id); if (_shown.size() > MAX_SHOWN_HISTORY) { _shown.erase(_shown.begin()); } // Persist cursor _settings->saveField("queue_cursor", static_cast(_cursor)); 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() { if (_syncRequested) return true; 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; 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; } 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; } } Serial.println("[queue] No portrait pair found in lookahead"); 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]); } } void PhotoQueue::sortChronological(const std::vector& dates, bool reverse) { if (dates.size() != _queue.size()) { // Dates unavailable (e.g. reshuffle after exhaustion) — fall back to shuffle if (dates.empty()) { shuffle(); return; } Serial.println("[queue] sortChronological: date/queue size mismatch, skipping"); return; } // Build index array sorted by date (ISO 8601 strings compare correctly) std::vector 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 sorted; sorted.reserve(_queue.size()); for (size_t idx : indices) { sorted.push_back(_queue[idx]); } _queue = sorted; } void PhotoQueue::applyRecencyWeighting(const std::vector& dates, bool favorRecent) { if (dates.size() != _queue.size() || _queue.size() < 3) { // Dates unavailable — fall back to shuffle if (dates.empty()) return; return; } // Sort queue by date (oldest first) std::vector 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 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()); } 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 PhotoQueue::getSelectedAlbumIds() { std::vector ids; Settings s = _settings->get(); JsonDocument doc; DeserializationError err = deserializeJson(doc, s.albums_json); if (err) return ids; JsonArray arr = doc.as(); for (JsonVariant v : arr) { ids.push_back(v.as()); } return ids; }