feat: photo queue manager with cycling modes and favorites weighting

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 10:54:41 -04:00
parent d2cfec136a
commit f7f5f490b8
3 changed files with 316 additions and 0 deletions

178
src/photo_queue.cpp Normal file
View File

@@ -0,0 +1,178 @@
#include "photo_queue.h"
#include "immich_client.h"
#include <ArduinoJson.h>
#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<String> newIds;
// Get selected album IDs
std::vector<String> 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<uint8_t>(_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<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;
}

32
src/photo_queue.h Normal file
View File

@@ -0,0 +1,32 @@
#pragma once
#include <Arduino.h>
#include <vector>
#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<String> _queue;
size_t _cursor = 0;
unsigned long _lastSyncTime = 0;
void shuffle();
void sortChronological(bool reverse);
void applyFavoritesWeighting();
std::vector<String> getSelectedAlbumIds();
};

View File

@@ -0,0 +1,106 @@
#include <unity.h>
#include <vector>
#include <algorithm>
#include <cstdlib>
#include <cstring>
// Minimal test of queue cycling logic (extracted, platform-independent)
// We test the shuffling and cycling algorithms without hardware dependencies
struct QueueState {
std::vector<std::string> 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<std::string> applyFavoritesWeight(
const std::vector<std::string>& all,
const std::vector<std::string>& favorites,
int weight) {
std::vector<std::string> 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<std::string> 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<std::string> all = {"a", "b", "c"};
std::vector<std::string> 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;
}