diff --git a/src/photo_queue.cpp b/src/photo_queue.cpp new file mode 100644 index 0000000..90050e7 --- /dev/null +++ b/src/photo_queue.cpp @@ -0,0 +1,178 @@ +#include "photo_queue.h" +#include "immich_client.h" +#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; + + Settings s = _settings->get(); + std::vector newIds; + + // Get selected album IDs + std::vector albumIds = getSelectedAlbumIds(); + + if (albumIds.empty()) { + // "All photos" mode — fetch from all albums + auto albums = _client->fetchAlbums(); + for (auto& album : albums) { + auto ids = _client->fetchAlbumAssetIds(album.id); + for (auto& id : ids) { + newIds.push_back(id); + } + } + } else { + // Fetch from selected albums only + for (auto& albumId : albumIds) { + auto ids = _client->fetchAlbumAssetIds(albumId); + for (auto& id : ids) { + newIds.push_back(id); + } + } + } + + // Deduplicate + std::sort(newIds.begin(), newIds.end()); + newIds.erase(std::unique(newIds.begin(), newIds.end()), newIds.end()); + + if (newIds.empty()) { + Serial.println("[queue] No assets found"); + return false; + } + + _queue = newIds; + + // Apply cycling mode + switch (s.cycle_mode) { + case CycleMode::Random: + applyFavoritesWeighting(); + shuffle(); + break; + case CycleMode::Chronological: + sortChronological(false); + break; + case CycleMode::ReverseChronological: + sortChronological(true); + break; + case CycleMode::FavoritesWeighted: + applyFavoritesWeighting(); + shuffle(); + break; + } + + // Clamp cursor + if (_cursor >= _queue.size()) { + _cursor = 0; + } + + _lastSyncTime = millis(); + Serial.printf("[queue] Synced: %d assets, cursor at %d\n", _queue.size(), _cursor); + return true; +} + +String PhotoQueue::next() { + if (_queue.empty()) return ""; + if (_cursor >= _queue.size()) { + _cursor = 0; + shuffle(); // Reshuffle on wrap for random mode + } + String id = _queue[_cursor++]; + + // Persist cursor + _settings->saveField("queue_cursor", static_cast(_cursor & 0xFF)); + + 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 (_lastSyncTime == 0) return true; + unsigned long elapsed = millis() - _lastSyncTime; + return elapsed >= (QUEUE_RESYNC_HOURS * 3600000UL); +} + +String PhotoQueue::findPortraitPair(size_t startIndex) { + // Look ahead up to PORTRAIT_LOOKAHEAD items for another portrait + for (size_t i = 1; i <= PORTRAIT_LOOKAHEAD && (startIndex + i) < _queue.size(); i++) { + // We'd need asset info to determine portrait status + // This will be called by the display task which fetches AssetInfo + // Return the ID — caller checks isPortrait from AssetInfo + return _queue[startIndex + i]; + } + 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(bool reverse) { + // For chronological sort, we'd need timestamps which we don't store in the queue + // The IDs from Immich are UUIDs, not sortable by time + // For now, keep the order returned by Immich (which is chronological within albums) + if (reverse) { + std::reverse(_queue.begin(), _queue.end()); + } +} + +void PhotoQueue::applyFavoritesWeighting() { + if (_client == nullptr) return; + + Settings s = _settings->get(); + if (s.cycle_mode != CycleMode::FavoritesWeighted) return; + + auto favorites = _client->fetchFavoriteAssetIds(); + // Add favorites 2 more times (total 3× appearance) + for (auto& fav : favorites) { + // Only add if already in queue + 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; +} diff --git a/src/photo_queue.h b/src/photo_queue.h new file mode 100644 index 0000000..39476aa --- /dev/null +++ b/src/photo_queue.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include "settings.h" + +class ImmichClient; + +class PhotoQueue { +public: + void begin(ImmichClient& client, SettingsManager& settings); + bool sync(); + String next(); + String random(); + String current(); + size_t size(); + bool needsResync(); + String findPortraitPair(size_t startIndex); + +private: + ImmichClient* _client = nullptr; + SettingsManager* _settings = nullptr; + + std::vector _queue; + size_t _cursor = 0; + unsigned long _lastSyncTime = 0; + + void shuffle(); + void sortChronological(bool reverse); + void applyFavoritesWeighting(); + std::vector getSelectedAlbumIds(); +}; diff --git a/test/test_native/test_photo_queue.cpp b/test/test_native/test_photo_queue.cpp new file mode 100644 index 0000000..df9c768 --- /dev/null +++ b/test/test_native/test_photo_queue.cpp @@ -0,0 +1,106 @@ +#include +#include +#include +#include +#include + +// Minimal test of queue cycling logic (extracted, platform-independent) +// We test the shuffling and cycling algorithms without hardware dependencies + +struct QueueState { + std::vector ids; + size_t cursor; +}; + +// Simulate random cycling: advance through shuffled list +std::string advanceRandom(QueueState& state) { + if (state.ids.empty()) return ""; + if (state.cursor >= state.ids.size()) { + state.cursor = 0; // Wrap around (would reshuffle in real impl) + } + return state.ids[state.cursor++]; +} + +// Simulate chronological: just advance sequentially (assumes pre-sorted) +std::string advanceChrono(QueueState& state) { + if (state.ids.empty()) return ""; + if (state.cursor >= state.ids.size()) { + state.cursor = 0; + } + return state.ids[state.cursor++]; +} + +// Favorites weighting: insert duplicates +std::vector applyFavoritesWeight( + const std::vector& all, + const std::vector& favorites, + int weight) { + + std::vector result = all; + for (const auto& fav : favorites) { + for (int i = 1; i < weight; i++) { + result.push_back(fav); + } + } + return result; +} + +void test_advance_random_no_repeats_until_wrap() { + QueueState state; + state.ids = {"a", "b", "c", "d", "e"}; + state.cursor = 0; + + std::vector seen; + for (size_t i = 0; i < state.ids.size(); i++) { + std::string id = advanceRandom(state); + // Should not have seen this one yet + TEST_ASSERT_TRUE(std::find(seen.begin(), seen.end(), id) == seen.end()); + seen.push_back(id); + } + TEST_ASSERT_EQUAL(5, seen.size()); +} + +void test_advance_wraps_at_end() { + QueueState state; + state.ids = {"a", "b", "c"}; + state.cursor = 0; + + advanceRandom(state); // a + advanceRandom(state); // b + advanceRandom(state); // c + std::string wrapped = advanceRandom(state); // wraps to a + TEST_ASSERT_EQUAL_STRING("a", wrapped.c_str()); +} + +void test_empty_queue_returns_empty() { + QueueState state; + state.cursor = 0; + std::string result = advanceRandom(state); + TEST_ASSERT_EQUAL_STRING("", result.c_str()); +} + +void test_favorites_weighting() { + std::vector all = {"a", "b", "c"}; + std::vector favs = {"b"}; + auto weighted = applyFavoritesWeight(all, favs, 3); + // "b" should appear 3 times total (1 original + 2 extra) + int count = 0; + for (const auto& id : weighted) { + if (id == "b") count++; + } + TEST_ASSERT_EQUAL(3, count); + TEST_ASSERT_EQUAL(5, weighted.size()); // 3 original + 2 extra +} + +void setUp() {} +void tearDown() {} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_advance_random_no_repeats_until_wrap); + RUN_TEST(test_advance_wraps_at_end); + RUN_TEST(test_empty_queue_returns_empty); + RUN_TEST(test_favorites_weighting); + UNITY_END(); + return 0; +}