diff --git a/.drone.yml b/.drone.yml index c906add..e17f7c3 100644 --- a/.drone.yml +++ b/.drone.yml @@ -14,7 +14,7 @@ steps: - ls -la dist - name: build-deb-armhf - image: cache.coadcorp.com/library/buildpack-deps:jammy + image: cache.coadcorp.com/library/buildpack-deps:bullseye environment: DEBIAN_FRONTEND: noninteractive ARM_CFLAGS: -march=armv6 -mfpu=vfp -mfloat-abi=hard -marm @@ -22,14 +22,12 @@ steps: - dpkg --add-architecture armhf - | cat > /etc/apt/sources.list <<'EOF' - deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy main restricted universe multiverse - deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy-updates main restricted universe multiverse - deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy-backports main restricted universe multiverse - deb [arch=amd64] http://security.ubuntu.com/ubuntu jammy-security main restricted universe multiverse - deb [arch=armhf] http://ports.ubuntu.com/ubuntu-ports jammy main restricted universe multiverse - deb [arch=armhf] http://ports.ubuntu.com/ubuntu-ports jammy-updates main restricted universe multiverse - deb [arch=armhf] http://ports.ubuntu.com/ubuntu-ports jammy-backports main restricted universe multiverse - deb [arch=armhf] http://ports.ubuntu.com/ubuntu-ports jammy-security main restricted universe multiverse + deb [arch=amd64] http://deb.debian.org/debian bullseye main contrib non-free + deb [arch=amd64] http://deb.debian.org/debian bullseye-updates main contrib non-free + deb [arch=amd64] http://security.debian.org/debian-security bullseye-security main contrib non-free + deb [arch=armhf] http://deb.debian.org/debian bullseye main contrib non-free + deb [arch=armhf] http://deb.debian.org/debian bullseye-updates main contrib non-free + deb [arch=armhf] http://security.debian.org/debian-security bullseye-security main contrib non-free EOF - apt-get update - apt-get install -y --no-install-recommends build-essential qt5-qmake qtbase5-dev qtbase5-dev-tools libexif-dev qt5-image-formats-plugins libmosquitto-dev dpkg-dev fakeroot ca-certificates gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf qtbase5-dev:armhf libqt5core5a:armhf libqt5gui5:armhf libqt5widgets5:armhf libqt5network5:armhf libexif-dev:armhf libmosquitto-dev:armhf qt5-image-formats-plugins:armhf @@ -37,7 +35,7 @@ steps: - ls -la dist - name: build-deb-arm64 - image: cache.coadcorp.com/library/buildpack-deps:jammy + image: cache.coadcorp.com/library/buildpack-deps:bullseye environment: DEBIAN_FRONTEND: noninteractive ARM64_CFLAGS: -march=armv8-a @@ -45,14 +43,12 @@ steps: - dpkg --add-architecture arm64 - | cat > /etc/apt/sources.list <<'EOF' - deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy main restricted universe multiverse - deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy-updates main restricted universe multiverse - deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy-backports main restricted universe multiverse - deb [arch=amd64] http://security.ubuntu.com/ubuntu jammy-security main restricted universe multiverse - deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy main restricted universe multiverse - deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main restricted universe multiverse - deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main restricted universe multiverse - deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main restricted universe multiverse + deb [arch=amd64] http://deb.debian.org/debian bullseye main contrib non-free + deb [arch=amd64] http://deb.debian.org/debian bullseye-updates main contrib non-free + deb [arch=amd64] http://security.debian.org/debian-security bullseye-security main contrib non-free + deb [arch=arm64] http://deb.debian.org/debian bullseye main contrib non-free + deb [arch=arm64] http://deb.debian.org/debian bullseye-updates main contrib non-free + deb [arch=arm64] http://security.debian.org/debian-security bullseye-security main contrib non-free EOF - apt-get update - apt-get install -y --no-install-recommends build-essential qt5-qmake qtbase5-dev qtbase5-dev-tools libexif-dev qt5-image-formats-plugins libmosquitto-dev dpkg-dev fakeroot ca-certificates gcc-aarch64-linux-gnu g++-aarch64-linux-gnu qtbase5-dev:arm64 libqt5core5a:arm64 libqt5gui5:arm64 libqt5widgets5:arm64 libqt5network5:arm64 libexif-dev:arm64 libmosquitto-dev:arm64 qt5-image-formats-plugins:arm64 diff --git a/INSTALL.md b/INSTALL.md index 0265046..c18fa6b 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -6,6 +6,8 @@ This project depends on the following dynamically linked libraries: * qt5 * libexif +* qt5-image-formats-plugins +* libmosquitto1 ### OSX @@ -13,28 +15,29 @@ This project depends on the following dynamically linked libraries: brew install qt libexif ``` -### Raspbian Stretch +### Debian / Ubuntu / Raspberry Pi OS (runtime dependencies) ``` -brew install qt5 libexif12 +sudo apt-get install -y qt5-image-formats-plugins libmosquitto1 ``` -## Extract binaries +### Raspberry Pi Zero / Raspbian (additional image format support) ``` -tar xf slide__.tar.gz +sudo apt-get install -y qt5-image-formats-plugins libmosquitto1 libmng1 ``` -## Move binary to executable folder +## Install .deb package -### OSX +Use apt so dependencies are resolved automatically: ``` -mv slide_/slide.app/Contents/MacOS/slide /usr/local/bin/ +sudo apt-get install ./slide__.deb ``` -### Linux +If you must use dpkg, install runtime dependencies first: ``` -mv slide_/slide /usr/bin/ +sudo apt-get install -y qt5-image-formats-plugins libmosquitto1 +sudo dpkg -i slide__.deb ``` diff --git a/README.md b/README.md index ded5763..8d2c48d 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ Example: "host": "mqtt.local", "port": 1883, "topic": "slide/control", + "immichTopic": "slide/immich", "clientId": "slide-frame", "username": "slide", "password": "secret", @@ -151,6 +152,16 @@ Commands: * `next` / `next-image` — advance to next image * `next-folder` — jump to next configured path (if multiple paths are configured) * `restart` / `reset` — recreate the selector and restart playback +If `immichTopic` is not set, it defaults to `/immich`. + +Immich control topic (`immichTopic`): +* `album:` or `albumIds:id1,id2` — filter to one or more album IDs +* `person:` or `personIds:id1,id2` — filter to one or more person IDs +* `reset` / `clear` — clear album/person filters +* JSON payloads are also accepted, for example: +``` +{"albumIds":["..."],"personIds":["..."],"order":"desc","size":"fullsize"} +``` ### Immich configuration (lightweight + low power) @@ -195,6 +206,7 @@ Immich settings: * `url`: base Immich server URL (the integration appends `/api` automatically if missing). * `apiKey`: Immich API key (needs `asset.view`, and `asset.download` if `size` is `original`). * `albumId` or `albumIds`: optional album filters. +* `personId` or `personIds`: optional person filters. * `size`: `"fullsize"`, `"preview"`, `"thumbnail"`, or `"original"` (original uses the download endpoint). * `order`: `"asc"` or `"desc"` ordering for asset search. * `pageSize`: assets fetched per page. diff --git a/src/appconfig.cpp b/src/appconfig.cpp index dfc5ce7..a6d5837 100644 --- a/src/appconfig.cpp +++ b/src/appconfig.cpp @@ -101,6 +101,14 @@ ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) { if(albumIds.size() > 0) config.albumIds = albumIds; + std::string personId = ParseJSONString(immichJson, "personId"); + if(!personId.empty()) + config.personIds.push_back(personId); + + std::vector personIds = ParseJSONStrings(immichJson, "personIds"); + if(personIds.size() > 0) + config.personIds = personIds; + SetJSONInt(config.pageSize, immichJson, "pageSize"); SetJSONInt(config.maxAssets, immichJson, "maxAssets"); SetJSONInt(config.cacheMaxMB, immichJson, "cacheMaxMB"); @@ -123,6 +131,10 @@ MqttConfig ParseMqttConfigObject(QJsonObject mqttJson) { if(!topic.empty()) config.topic = topic; + std::string immichTopic = ParseJSONString(mqttJson, "immichTopic"); + if(!immichTopic.empty()) + config.immichTopic = immichTopic; + std::string clientId = ParseJSONString(mqttJson, "clientId"); if(!clientId.empty()) config.clientId = clientId; @@ -139,6 +151,9 @@ MqttConfig ParseMqttConfigObject(QJsonObject mqttJson) { SetJSONInt(config.keepAlive, mqttJson, "keepAlive"); SetJSONInt(config.qos, mqttJson, "qos"); + if(config.immichTopic.empty() && !config.topic.empty()) + config.immichTopic = config.topic + "/immich"; + if(!config.host.empty() && !config.topic.empty()) config.enabled = true; diff --git a/src/appconfig.h b/src/appconfig.h index 9b14db5..e89c43e 100644 --- a/src/appconfig.h +++ b/src/appconfig.h @@ -11,6 +11,7 @@ struct ImmichConfig { std::string url = ""; std::string apiKey = ""; std::vector albumIds; + std::vector personIds; std::string size = "fullsize"; std::string order = "desc"; int pageSize = 200; @@ -40,6 +41,13 @@ struct ImmichConfig { if (albumIds[i] != b.albumIds[i]) return false; } + if (personIds.size() != b.personIds.size()) + return false; + for (size_t i = 0; i < personIds.size(); ++i) + { + if (personIds[i] != b.personIds[i]) + return false; + } return true; } @@ -54,6 +62,7 @@ struct MqttConfig { std::string host = "localhost"; int port = 1883; std::string topic = "slide/control"; + std::string immichTopic = ""; std::string clientId = "slide"; std::string username = ""; std::string password = ""; @@ -66,6 +75,7 @@ struct MqttConfig { host == b.host && port == b.port && topic == b.topic && + immichTopic == b.immichTopic && clientId == b.clientId && username == b.username && password == b.password && diff --git a/src/immichclient.cpp b/src/immichclient.cpp index 972ff1d..3d12423 100644 --- a/src/immichclient.cpp +++ b/src/immichclient.cpp @@ -105,6 +105,14 @@ QVector ImmichClient::fetchAssets() bool triedZero = false; int page = 1; + if (ShouldLog()) + { + Log("Immich search: size=", config.size, ", order=", config.order, + ", pageSize=", pageSize, ", maxAssets=", maxAssets, + ", albumIds=", config.albumIds.size(), + ", personIds=", config.personIds.size()); + } + while (true) { QJsonObject body; @@ -121,6 +129,13 @@ QVector ImmichClient::fetchAssets() ids.append(QString::fromStdString(id)); body["albumIds"] = ids; } + if (config.personIds.size() > 0) + { + QJsonArray ids; + for (const auto &id : config.personIds) + ids.append(QString::fromStdString(id)); + body["personIds"] = ids; + } QByteArray response = postJson(apiUrl("/search/metadata"), body, nullptr, kMetadataTimeoutMs); if (response.isEmpty()) @@ -134,6 +149,7 @@ QVector ImmichClient::fetchAssets() QJsonObject assetsObj = root["assets"].toObject(); QJsonArray items = assetsObj["items"].toArray(); int total = assetsObj["total"].toInt(); + Log("Immich page ", page, ": ", items.size(), " assets (total ", total, ")"); if (items.isEmpty()) { if (total > 0 && page == 1 && !triedZero) @@ -194,6 +210,7 @@ bool ImmichClient::downloadAsset(const QString &assetId, QByteArray &data, QStri url.setQuery(query); } + Log("Immich download asset ", assetId.toStdString(), " (", size.toStdString(), ")"); QByteArray payload = getBytes(url, &contentType, kAssetTimeoutMs); if (payload.isEmpty()) return false; @@ -297,7 +314,10 @@ QString ImmichAssetCache::getCachedPath(const QString &assetId, const QString &a QString existing = findExisting(assetId); if (!existing.isEmpty()) + { + Log("Immich cache hit: ", assetId.toStdString()); return existing; + } QByteArray data; QString contentType; @@ -318,6 +338,8 @@ QString ImmichAssetCache::getCachedPath(const QString &assetId, const QString &a if (!file.commit()) return ""; + Log("Immich cached asset: ", assetId.toStdString(), " -> ", filePath.toStdString()); + if (cacheMaxBytes > 0) { if (!cacheSizeKnown) diff --git a/src/main.cpp b/src/main.cpp index 5ed2118..d1391a5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -19,6 +19,9 @@ #include #include #include +#include +#include +#include void usage(std::string programName) { std::cerr << "Usage: " << programName << " [-t rotation_seconds] [-T transition_seconds] [-h/--overlay-color #rrggbb] [-a aspect('l','p','a', 'm')] [-o background_opacity(0..255)] [-b blur_radius] -p image_folder [-r] [-s] [-S] [-v] [--verbose] [--stretch] [-c config_file_path]" << std::endl; @@ -243,6 +246,216 @@ void ReloadConfigIfNeeded(AppConfig &appConfig, MainWindow &w, ImageSwitcher *sw } } +static bool ParseBooleanString(const QString &value, bool &outValue) +{ + QString v = value.trimmed().toLower(); + if (v == "true" || v == "1" || v == "yes" || v == "on") + { + outValue = true; + return true; + } + if (v == "false" || v == "0" || v == "no" || v == "off") + { + outValue = false; + return true; + } + return false; +} + +static std::vector SplitCsv(const QString &value) +{ + std::vector output; + QStringList parts = value.split(',', Qt::SkipEmptyParts); + for (const auto &part : parts) + { + QString trimmed = part.trimmed(); + if (!trimmed.isEmpty()) + output.push_back(trimmed.toStdString()); + } + return output; +} + +static bool ApplyImmichPayload(ImmichConfig &config, const QString &payload) +{ + bool changed = false; + QString trimmed = payload.trimmed(); + if (trimmed.isEmpty()) + return false; + + if (trimmed.startsWith("{")) + { + QJsonDocument doc = QJsonDocument::fromJson(trimmed.toUtf8()); + if (!doc.isObject()) + return false; + QJsonObject obj = doc.object(); + + if (obj.contains("albumId") && obj["albumId"].isString()) + { + config.albumIds = { obj["albumId"].toString().toStdString() }; + changed = true; + } + if (obj.contains("albumIds") && obj["albumIds"].isArray()) + { + config.albumIds.clear(); + QJsonArray arr = obj["albumIds"].toArray(); + for (const auto &value : arr) + { + if (value.isString()) + config.albumIds.push_back(value.toString().toStdString()); + } + changed = true; + } + if (obj.contains("personId") && obj["personId"].isString()) + { + config.personIds = { obj["personId"].toString().toStdString() }; + changed = true; + } + if (obj.contains("personIds") && obj["personIds"].isArray()) + { + config.personIds.clear(); + QJsonArray arr = obj["personIds"].toArray(); + for (const auto &value : arr) + { + if (value.isString()) + config.personIds.push_back(value.toString().toStdString()); + } + changed = true; + } + if (obj.contains("order") && obj["order"].isString()) + { + config.order = obj["order"].toString().toStdString(); + changed = true; + } + if (obj.contains("size") && obj["size"].isString()) + { + config.size = obj["size"].toString().toStdString(); + changed = true; + } + if (obj.contains("pageSize") && obj["pageSize"].isDouble()) + { + config.pageSize = (int)obj["pageSize"].toDouble(); + changed = true; + } + if (obj.contains("maxAssets") && obj["maxAssets"].isDouble()) + { + config.maxAssets = (int)obj["maxAssets"].toDouble(); + changed = true; + } + if (obj.contains("includeArchived")) + { + if (obj["includeArchived"].isBool()) + { + config.includeArchived = obj["includeArchived"].toBool(); + changed = true; + } + else if (obj["includeArchived"].isString()) + { + bool parsed = false; + bool boolValue = false; + parsed = ParseBooleanString(obj["includeArchived"].toString(), boolValue); + if (parsed) + { + config.includeArchived = boolValue; + changed = true; + } + } + } + if (obj.contains("reset") && obj["reset"].isBool() && obj["reset"].toBool()) + { + config.albumIds.clear(); + config.personIds.clear(); + changed = true; + } + return changed; + } + + QString key; + QString value; + int idx = trimmed.indexOf('='); + if (idx < 0) + idx = trimmed.indexOf(':'); + if (idx < 0) + idx = trimmed.indexOf(' '); + + if (idx >= 0) + { + key = trimmed.left(idx).trimmed().toLower(); + value = trimmed.mid(idx + 1).trimmed(); + } + else + { + key = trimmed.toLower(); + } + + if (key == "reset" || key == "clear" || key == "all") + { + config.albumIds.clear(); + config.personIds.clear(); + return true; + } + if (key == "album" || key == "albumid") + { + config.albumIds = { value.toStdString() }; + return true; + } + if (key == "albums" || key == "albumids") + { + config.albumIds = SplitCsv(value); + return true; + } + if (key == "person" || key == "personid") + { + config.personIds = { value.toStdString() }; + return true; + } + if (key == "persons" || key == "personids") + { + config.personIds = SplitCsv(value); + return true; + } + if (key == "order") + { + config.order = value.toStdString(); + return true; + } + if (key == "size") + { + config.size = value.toStdString(); + return true; + } + if (key == "includearchived") + { + bool boolValue = false; + if (ParseBooleanString(value, boolValue)) + { + config.includeArchived = boolValue; + return true; + } + } + if (key == "pagesize") + { + bool ok = false; + int parsed = value.toInt(&ok); + if (ok) + { + config.pageSize = parsed; + return true; + } + } + if (key == "maxassets") + { + bool ok = false; + int parsed = value.toInt(&ok); + if (ok) + { + config.maxAssets = parsed; + return true; + } + } + + return false; +} + int main(int argc, char *argv[]) { QApplication a(argc, argv); @@ -292,6 +505,32 @@ int main(int argc, char *argv[]) std::unique_ptr newSelector = GetSelectorForApp(appConfig); switcher.restart(newSelector); }); + QObject::connect(mqttController.get(), &MqttController::immichControl, [&appConfig, &switcher](const QString &payload) { + bool updated = false; + for (int i = 0; i < appConfig.paths.count(); ++i) + { + if (!appConfig.paths[i].immich.enabled) + continue; + ImmichConfig newConfig = appConfig.paths[i].immich; + if (ApplyImmichPayload(newConfig, payload)) + { + appConfig.paths[i].immich = newConfig; + updated = true; + } + } + if (updated) + { + Log("MQTT immich update applied."); + std::unique_ptr newSelector = GetSelectorForApp(appConfig); + switcher.setImageSelector(newSelector); + if (!switcher.isPaused()) + switcher.scheduleImageUpdate(); + } + else + { + Log("MQTT immich update ignored: ", payload.toStdString()); + } + }); mqttController->start(); } switcher.start(); diff --git a/src/mqttcontroller.cpp b/src/mqttcontroller.cpp index 3266ad8..1ccbc1d 100644 --- a/src/mqttcontroller.cpp +++ b/src/mqttcontroller.cpp @@ -18,6 +18,8 @@ MqttController::MqttController(const MqttConfig &configIn, QObject *parent) mosquitto_lib_init(); g_mqttInitialized = true; } + controlTopic = QString::fromStdString(config.topic); + immichTopic = QString::fromStdString(config.immichTopic); } MqttController::~MqttController() @@ -103,6 +105,18 @@ void MqttController::subscribe() { Log("MQTT subscribed to ", config.topic); } + if (!config.immichTopic.empty() && config.immichTopic != config.topic) + { + rc = mosquitto_subscribe(client, nullptr, config.immichTopic.c_str(), config.qos); + if (rc != MOSQ_ERR_SUCCESS) + { + Log("MQTT subscribe (immich) failed: ", mosquitto_strerror(rc)); + } + else + { + Log("MQTT subscribed to ", config.immichTopic); + } + } } void MqttController::HandleMessage(struct mosquitto *mosq, void *userdata, const struct mosquitto_message *message) @@ -113,15 +127,26 @@ void MqttController::HandleMessage(struct mosquitto *mosq, void *userdata, const return; QString payload = QString::fromUtf8(static_cast(message->payload), message->payloadlen); - QMetaObject::invokeMethod(self, "handleCommand", Qt::QueuedConnection, Q_ARG(QString, payload)); + QString topic = QString::fromUtf8(message->topic); + QMetaObject::invokeMethod(self, "handleMessage", Qt::QueuedConnection, + Q_ARG(QString, topic), + Q_ARG(QString, payload)); } -void MqttController::handleCommand(const QString &payload) +void MqttController::handleMessage(const QString &topic, const QString &payload) { QString cmd = payload.trimmed().toLower(); if (cmd.isEmpty()) return; + Log("MQTT message on ", topic.toStdString(), ": ", cmd.toStdString()); + + if (!immichTopic.isEmpty() && topic == immichTopic) + { + emit immichControl(payload); + return; + } + if (cmd == "play" || cmd == "resume") { emit play(); diff --git a/src/mqttcontroller.h b/src/mqttcontroller.h index 0d94a08..9ccb5e8 100644 --- a/src/mqttcontroller.h +++ b/src/mqttcontroller.h @@ -24,9 +24,10 @@ signals: void nextImage(); void nextFolder(); void restart(); + void immichControl(const QString &payload); private slots: - void handleCommand(const QString &payload); + void handleMessage(const QString &topic, const QString &payload); private: static void HandleConnect(struct mosquitto *mosq, void *userdata, int rc); @@ -35,6 +36,8 @@ private: void subscribe(); MqttConfig config; + QString controlTopic; + QString immichTopic; struct mosquitto *client = nullptr; bool connected = false; };