From 90f208c31cb0ccb11e5d3835c009042a6b90b390 Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 3 Aug 2026 22:46:17 -0400 Subject: [PATCH] feat: wire up album filtering to photo queue Album selection now controls which photos are displayed. Uses POST /api/search/random with albumIds filter (Immich v3 compatible) instead of the removed GET /api/albums/{id} assets. - Add albumIds parameter to fetchRandomAssets/fetchRandomAssetIds - syncFromAlbums: fetch random assets filtered by selected albums - syncFromAllPhotos: preserve existing global random behavior - Album mode reshuffles on exhaustion instead of re-fetching - 24h resync checks album assetCounts for changes before re-fetch - requestSync + albums_changed action triggers queue rebuild on save - timerWakeCycle respects album selection - Discard stale pre-renders on album change Co-authored-by: Cursor --- src/immich_client.cpp | 24 ++++-- src/immich_client.h | 6 +- src/main.cpp | 25 +++++- src/photo_queue.cpp | 179 +++++++++++++++++++++++++++++++++++------- src/photo_queue.h | 8 ++ src/web_server.cpp | 1 + 6 files changed, 202 insertions(+), 41 deletions(-) diff --git a/src/immich_client.cpp b/src/immich_client.cpp index 0b1f64f..d598a8b 100644 --- a/src/immich_client.cpp +++ b/src/immich_client.cpp @@ -87,14 +87,18 @@ std::vector ImmichClient::fetchAlbumAssetIds(const String& albumId) { return ids; } -std::vector ImmichClient::fetchRandomAssetIds(int count) { +std::vector ImmichClient::fetchRandomAssetIds(int count, + const std::vector& albumIds) { std::vector 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"; + if (!albumIds.empty()) { + JsonArray arr = reqDoc["albumIds"].to(); + for (auto& id : albumIds) arr.add(id); + } String body; serializeJson(reqDoc, body); @@ -118,7 +122,6 @@ std::vector ImmichClient::fetchRandomAssetIds(int count) { String id = asset["id"].as(); 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") || @@ -133,18 +136,23 @@ std::vector ImmichClient::fetchRandomAssetIds(int count) { ids.push_back(id); } - Serial.printf("[immich] Fetched %d random assets (skipped %d RAW)\n", - ids.size(), skippedRaw); + Serial.printf("[immich] Fetched %d random assets%s (skipped %d RAW)\n", + ids.size(), albumIds.empty() ? "" : " from albums", skippedRaw); return ids; } -std::vector ImmichClient::fetchRandomAssets(int count) { +std::vector ImmichClient::fetchRandomAssets(int count, + const std::vector& albumIds) { std::vector assets; String url = buildUrl("/api/search/random"); JsonDocument reqDoc; reqDoc["size"] = count; reqDoc["type"] = "IMAGE"; + if (!albumIds.empty()) { + JsonArray albumArr = reqDoc["albumIds"].to(); + for (auto& aid : albumIds) albumArr.add(aid); + } String body; serializeJson(reqDoc, body); @@ -178,8 +186,8 @@ std::vector ImmichClient::fetchRandomAssets(int count) { assets.push_back(ra); } - Serial.printf("[immich] Fetched %d random assets with dates (skipped %d RAW)\n", - assets.size(), skippedRaw); + Serial.printf("[immich] Fetched %d random assets with dates%s (skipped %d RAW)\n", + assets.size(), albumIds.empty() ? "" : " from albums", skippedRaw); return assets; } diff --git a/src/immich_client.h b/src/immich_client.h index 57d5001..b34b2d1 100644 --- a/src/immich_client.h +++ b/src/immich_client.h @@ -31,8 +31,10 @@ public: void begin(const String& baseUrl, const String& apiKey); std::vector fetchAlbums(); std::vector fetchAlbumAssetIds(const String& albumId); - std::vector fetchRandomAssetIds(int count = 50); - std::vector fetchRandomAssets(int count = 50); + std::vector fetchRandomAssetIds(int count = 50, + const std::vector& albumIds = {}); + std::vector fetchRandomAssets(int count = 50, + const std::vector& albumIds = {}); std::vector fetchFavoriteAssetIds(); AssetInfo fetchAssetInfo(const String& assetId); bool downloadAsset(const String& assetId, ImageQuality quality, diff --git a/src/main.cpp b/src/main.cpp index 468cd37..5469df1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -238,9 +239,10 @@ void displayTask(void* param) { xSemaphoreGive(stateMutex); } - // Periodic re-sync + // Periodic re-sync (includes album change requests) if (photoQueue.needsResync() && wifiManager.isConnected()) { photoQueue.sync(); + discardPending(pending); } // --- Phase 1: Display --- @@ -343,6 +345,9 @@ void webActionCallback(const String& action) { slideshowPlaying = false; } else if (action == "play") { slideshowPlaying = true; + } else if (action == "albums_changed") { + photoQueue.requestSync(); + refreshRequested = true; } xSemaphoreGive(stateMutex); } @@ -368,11 +373,25 @@ void timerWakeCycle() { imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0); displayManager.setTranslucent(s.meta_translucent != 0); - // Fetch a small batch of random assets + // Fetch assets — respect album selection static constexpr int TIMER_WAKE_RETRIES = 5; bool displayed = false; - std::vector ids = immichClient.fetchRandomAssetIds(10); + // Parse selected album IDs from settings + std::vector albumIds; + { + JsonDocument doc; + DeserializationError err = deserializeJson(doc, s.albums_json); + if (!err) { + JsonArray arr = doc.as(); + for (JsonVariant v : arr) { + albumIds.push_back(v.as()); + } + } + } + + std::vector ids = immichClient.fetchRandomAssetIds(10, albumIds); + for (int i = 0; i < (int)ids.size() && i < TIMER_WAKE_RETRIES && !displayed; i++) { AssetInfo info = immichClient.fetchAssetInfo(ids[i]); diff --git a/src/photo_queue.cpp b/src/photo_queue.cpp index bf86d96..c7722d5 100644 --- a/src/photo_queue.cpp +++ b/src/photo_queue.cpp @@ -16,11 +16,79 @@ 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"); - // Fetch in batches until we have TARGET_QUEUE_SIZE usable, non-duplicate IDs. - // Use fetchRandomAssets() to capture dates alongside IDs. std::vector newIds; std::vector newDates; int rounds = 0; @@ -37,10 +105,8 @@ bool PhotoQueue::sync() { 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); } @@ -50,58 +116,67 @@ bool PhotoQueue::sync() { rounds, newIds.size(), TARGET_QUEUE_SIZE); } - if (newIds.empty()) { - _syncRetryDelay = min(_syncRetryDelay * 2, SYNC_RETRY_MAX); - Serial.printf("[queue] Sync failed after %d rounds, retry in %lus\n", - rounds, _syncRetryDelay / 1000); - return false; - } + if (newIds.empty()) return false; _queue = newIds; + _albumMode = false; + _lastAlbumCounts.clear(); - // Apply cycling mode + 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(newDates, false); + sortChronological(dates, false); break; case CycleMode::ReverseChronological: - sortChronological(newDates, true); + sortChronological(dates, true); break; case CycleMode::FavoritesWeighted: applyFavoritesWeighting(); shuffle(); break; case CycleMode::WeightedChronological: - applyRecencyWeighting(newDates, true); + applyRecencyWeighting(dates, true); shuffle(); break; case CycleMode::WeightedReverseChronological: - applyRecencyWeighting(newDates, false); + applyRecencyWeighting(dates, false); shuffle(); break; // No default — compiler warns on unhandled CycleMode via -Wswitch } +} - // Reset cursor for fresh queue - _cursor = 0; - - _lastSyncTime = millis(); - _syncRetryDelay = SYNC_RETRY_MIN; - Serial.printf("[queue] Synced: %d assets in %d rounds, cursor at %d\n", - _queue.size(), rounds, _cursor); - return true; +void PhotoQueue::requestSync() { + _syncRequested = true; } String PhotoQueue::next() { // Auto-refill when queue is exhausted if (_queue.empty() || _cursor >= _queue.size()) { - Serial.println("[queue] Queue exhausted, fetching new batch"); - sync(); - _cursor = 0; + 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 ""; @@ -137,6 +212,8 @@ size_t PhotoQueue::size() { } bool PhotoQueue::needsResync() { + if (_syncRequested) return true; + unsigned long now = millis(); // If never synced successfully, use retry backoff @@ -147,7 +224,44 @@ bool PhotoQueue::needsResync() { // Normal resync interval unsigned long elapsed = now - _lastSyncTime; - return elapsed >= (QUEUE_RESYNC_HOURS * 3600000UL); + 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() { @@ -177,6 +291,11 @@ void PhotoQueue::shuffle() { 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; } @@ -200,7 +319,11 @@ void PhotoQueue::sortChronological(const std::vector& dates, bool revers } void PhotoQueue::applyRecencyWeighting(const std::vector& dates, bool favorRecent) { - if (dates.size() != _queue.size() || _queue.size() < 3) return; + 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()); diff --git a/src/photo_queue.h b/src/photo_queue.h index 65f739d..9a17d0a 100644 --- a/src/photo_queue.h +++ b/src/photo_queue.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "settings.h" @@ -10,6 +11,7 @@ class PhotoQueue { public: void begin(ImmichClient& client, SettingsManager& settings); bool sync(); + void requestSync(); String next(); String random(); String current(); @@ -27,6 +29,9 @@ private: unsigned long _lastSyncTime = 0; unsigned long _lastSyncAttempt = 0; unsigned long _syncRetryDelay = 10000; + volatile bool _syncRequested = false; + bool _albumMode = false; + std::map _lastAlbumCounts; static constexpr unsigned long SYNC_RETRY_MIN = 10000; // 10 seconds static constexpr unsigned long SYNC_RETRY_MAX = 300000; // 5 minutes @@ -34,6 +39,9 @@ private: static constexpr int MAX_FETCH_ROUNDS = 5; static constexpr size_t MAX_SHOWN_HISTORY = 200; + bool syncFromAlbums(const std::vector& albumIds, const Settings& s); + bool syncFromAllPhotos(const Settings& s); + void applyCycleMode(const Settings& s, const std::vector& dates); void shuffle(); void sortChronological(const std::vector& dates, bool reverse); void applyRecencyWeighting(const std::vector& dates, bool favorRecent); diff --git a/src/web_server.cpp b/src/web_server.cpp index 462678d..f86e163 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -272,6 +272,7 @@ void AppWebServer::handlePostAlbumsSelect(AsyncWebServerRequest* request, String albumsJson; serializeJson(doc["album_ids"], albumsJson); _settings->saveField("albums_json", albumsJson.c_str()); + if (_actionCb) _actionCb("albums_changed"); request->send(200, "application/json", "{\"ok\":true}"); }