diff --git a/README.md b/README.md index 8d2c48d..fe91a01 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ slide [-t rotation_seconds] [-T transition_seconds] [-h/--overlay-color overlay_ * `image_folder`: where to search for images (.jpg files) * `-i imageFile,...`: comma delimited list of full paths to image files to display -* `-c path_to_config_json`: the path to an optional slide.options.json file containing configuration parameters +* `-c path_to_config_json`: path to a JSON config file, or a directory containing `slide.options.json` * `-t` how many seconds to display each picture for * `-r` for recursive traversal of `image_folder` * `-s` for shuffle instead of random image rotation @@ -55,7 +55,7 @@ slide [-t rotation_seconds] [-T transition_seconds] [-h/--overlay-color overlay_ To exit the application, press escape. If you're using a touch display, touch all 4 corners at the same time. ## Configuration file -Slide supports loading configuration from a JSON formatted file called `slide.options.json`. This file can be specified by the `-c` command line option, we will also attempt to read `~/.config/slide/slide.options.json` and `/etc/slide/slide.options.json` in that order. The first file to load is used and its options will override command line parameters. +Slide supports loading configuration from a JSON formatted file called `slide.options.json`. This file can be specified by the `-c` command line option (file path or directory), and we will also attempt to read `~/.config/slide/slide.options.json` and `/etc/slide/slide.options.json` in that order. The first file to load is used and its options will override command line parameters. The file format is: ``` { @@ -157,15 +157,28 @@ 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 +* `user:` / `userId:` / `ownerId:` — show all assets owned by a user (clears album/person filters) +* `extensions:jpg,jpeg,png` — filter by file extension (useful for RAW exclusion) +* `reset` / `clear` — clear album/person/user filters * JSON payloads are also accepted, for example: ``` -{"albumIds":["..."],"personIds":["..."],"order":"desc","size":"fullsize"} +{"albumIds":["..."],"personIds":["..."],"order":"desc","size":"fullsize","userId":"...","extensions":["jpg","jpeg"]} ``` ### Immich configuration (lightweight + low power) -Immich uses an API key and a `/api` base path. This integration requests the asset search endpoint and downloads the configured image size into a local cache before displaying them. That keeps bandwidth and power usage low while still letting `slide` do its normal scaling and transitions. +Immich uses an API key and a `/api` base path. This integration requests the asset search endpoint and downloads the configured image size into a local cache before displaying them. That keeps bandwidth and power usage low while still letting `slide` do its normal scaling and transitions. If you have RAW images, set `size` to `preview`/`thumbnail` or use `extensions` to limit results to JPEG/PNG. + +#### Getting an Immich API key + +In the Immich web UI, go to Settings and find **API Keys** (menu labels can vary by version), then create a new key and copy it. + +#### Required API key permissions + +`slide` uses Immich search plus the asset view/download endpoints, so the API key should include: +* `asset.read` — required by Immich search endpoints (used to retrieve asset metadata). +* `asset.view` — required for the viewAsset endpoint (thumbnail/preview/fullsize). +* `asset.download` — required if you set `size` to `original` (download endpoint). Example (single source): ``` @@ -205,8 +218,10 @@ Example (scheduler entry): 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`). +* `userId`: optional user id to retrieve all assets owned by that user via the assets endpoint. * `albumId` or `albumIds`: optional album filters. * `personId` or `personIds`: optional person filters. +* `extensions` / `allowedExtensions`: optional list of file extensions to include (for example `["jpg","jpeg","png"]`). * `size`: `"fullsize"`, `"preview"`, `"thumbnail"`, or `"original"` (original uses the download endpoint). * `order`: `"asc"` or `"desc"` ordering for asset search. * `pageSize`: assets fetched per page. @@ -214,6 +229,8 @@ Immich settings: * `cachePath`: local cache directory for downloaded images. * `cacheMaxMB`: maximum cache size in MB (0 disables cleanup). * `includeArchived`: include archived assets in search results. +If you omit `albumId`/`albumIds`/`personIds`, Immich returns all assets visible to the API key’s user. +If `userId` is set, album/person filters are ignored and all assets for that user are fetched. When `immich` is set on an entry, `path` and `imageList` are ignored. ## Folder Options file diff --git a/src/appconfig.cpp b/src/appconfig.cpp index a6d5837..394000a 100644 --- a/src/appconfig.cpp +++ b/src/appconfig.cpp @@ -71,6 +71,19 @@ std::vector ParseJSONStrings(QJsonObject jsonDoc, const char *key) return values; } +std::vector NormalizeExtensions(const std::vector &values) { + std::vector normalized; + for (const auto &value : values) + { + std::string s = value; + for (auto &c : s) + c = static_cast(::tolower(c)); + if (!s.empty()) + normalized.push_back(s); + } + return normalized; +} + ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) { ImmichConfig config; @@ -81,6 +94,13 @@ ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) { if(!apiKey.empty()) config.apiKey = apiKey; + std::string userId = ParseJSONString(immichJson, "userId"); + if(!userId.empty()) + config.userId = userId; + std::string ownerId = ParseJSONString(immichJson, "ownerId"); + if(!ownerId.empty() && config.userId.empty()) + config.userId = ownerId; + std::string size = ParseJSONString(immichJson, "size"); if(!size.empty()) config.size = size; @@ -109,6 +129,12 @@ ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) { if(personIds.size() > 0) config.personIds = personIds; + std::vector allowedExtensions = ParseJSONStrings(immichJson, "extensions"); + if(allowedExtensions.size() == 0) + allowedExtensions = ParseJSONStrings(immichJson, "allowedExtensions"); + if(allowedExtensions.size() > 0) + config.allowedExtensions = NormalizeExtensions(allowedExtensions); + SetJSONInt(config.pageSize, immichJson, "pageSize"); SetJSONInt(config.maxAssets, immichJson, "maxAssets"); SetJSONInt(config.cacheMaxMB, immichJson, "cacheMaxMB"); @@ -248,27 +274,35 @@ QString getAppConfigFilePath(const std::string &configPath) { std::string systemConfigFolder = "/etc/slide"; QString baseConfigFilename("slide.options.json"); - QDir directory(userConfigFolder.c_str()); - QString jsonFile = ""; if (!configPath.empty()) - { - directory.setPath(configPath.c_str()); - jsonFile = directory.filePath(baseConfigFilename); - } - if(!directory.exists(jsonFile)) - { - directory.setPath(userConfigFolder.c_str()); - jsonFile = directory.filePath(baseConfigFilename); - } - if(!directory.exists(jsonFile)) { - directory.setPath(systemConfigFolder.c_str()); - jsonFile = directory.filePath(baseConfigFilename); + QFileInfo configInfo(QString::fromStdString(configPath)); + if (configInfo.exists() && configInfo.isFile()) + { + return configInfo.absoluteFilePath(); + } + + QDir directory(configInfo.isDir() ? configInfo.absoluteFilePath() + : QString::fromStdString(configPath)); + QString jsonFile = directory.filePath(baseConfigFilename); + if (directory.exists(jsonFile)) + { + return jsonFile; + } } - if(directory.exists(jsonFile)) + QDir userDir(userConfigFolder.c_str()); + QString userFile = userDir.filePath(baseConfigFilename); + if (userDir.exists(userFile)) { - return jsonFile; + return userFile; + } + + QDir systemDir(systemConfigFolder.c_str()); + QString systemFile = systemDir.filePath(baseConfigFilename); + if (systemDir.exists(systemFile)) + { + return systemFile; } return ""; diff --git a/src/appconfig.h b/src/appconfig.h index e89c43e..4e557b0 100644 --- a/src/appconfig.h +++ b/src/appconfig.h @@ -10,8 +10,10 @@ struct ImmichConfig { bool enabled = false; std::string url = ""; std::string apiKey = ""; + std::string userId = ""; std::vector albumIds; std::vector personIds; + std::vector allowedExtensions; std::string size = "fullsize"; std::string order = "desc"; int pageSize = 200; @@ -26,6 +28,8 @@ struct ImmichConfig { return false; if (url != b.url || apiKey != b.apiKey) return false; + if (userId != b.userId) + return false; if (size != b.size || order != b.order) return false; if (pageSize != b.pageSize || maxAssets != b.maxAssets) @@ -48,6 +52,13 @@ struct ImmichConfig { if (personIds[i] != b.personIds[i]) return false; } + if (allowedExtensions.size() != b.allowedExtensions.size()) + return false; + for (size_t i = 0; i < allowedExtensions.size(); ++i) + { + if (allowedExtensions[i] != b.allowedExtensions[i]) + return false; + } return true; } diff --git a/src/immichclient.cpp b/src/immichclient.cpp index 3d12423..c2fac3f 100644 --- a/src/immichclient.cpp +++ b/src/immichclient.cpp @@ -12,6 +12,7 @@ #include #include #include +#include namespace { const int kMetadataTimeoutMs = 15000; @@ -93,13 +94,27 @@ QByteArray ImmichClient::getBytes(const QUrl &url, QString *contentType, int tim QVector ImmichClient::fetchAssets() { - QVector assets; if (!config.enabled) { Log("Immich config is missing url or apiKey."); - return assets; + return QVector(); } + if (!config.userId.empty() && config.albumIds.empty() && config.personIds.empty()) + return fetchAssetsByUser(); + + if (!config.userId.empty() && (!config.albumIds.empty() || !config.personIds.empty())) + { + Log("Immich userId is set but album/person filters are also set; ignoring userId for search filters."); + } + + return fetchAssetsBySearch(); +} + +QVector ImmichClient::fetchAssetsBySearch() +{ + QVector assets; + int pageSize = config.pageSize > 0 ? config.pageSize : 200; int maxAssets = config.maxAssets; bool triedZero = false; @@ -110,7 +125,8 @@ QVector ImmichClient::fetchAssets() Log("Immich search: size=", config.size, ", order=", config.order, ", pageSize=", pageSize, ", maxAssets=", maxAssets, ", albumIds=", config.albumIds.size(), - ", personIds=", config.personIds.size()); + ", personIds=", config.personIds.size(), + ", allowedExtensions=", config.allowedExtensions.size()); } while (true) @@ -170,6 +186,11 @@ QVector ImmichClient::fetchAssets() ImmichAsset asset; asset.id = id; asset.originalFileName = item["originalFileName"].toString(); + if (!extensionAllowed(asset.originalFileName)) + { + Log("Immich skip by extension: ", asset.originalFileName.toStdString()); + continue; + } assets.append(asset); if (maxAssets > 0 && assets.size() >= maxAssets) return assets; @@ -183,6 +204,88 @@ QVector ImmichClient::fetchAssets() return assets; } +QVector ImmichClient::fetchAssetsByUser() +{ + QVector assets; + + int pageSize = config.pageSize > 0 ? config.pageSize : 200; + int maxAssets = config.maxAssets; + int skip = 0; + + if (ShouldLog()) + { + Log("Immich assets: userId=", config.userId, + ", pageSize=", pageSize, + ", maxAssets=", maxAssets, + ", includeArchived=", config.includeArchived); + } + + while (true) + { + QUrl url = apiUrl("/assets"); + QUrlQuery query; + query.addQueryItem("take", QString::number(pageSize)); + query.addQueryItem("skip", QString::number(skip)); + query.addQueryItem("userId", QString::fromStdString(config.userId)); + if (!config.includeArchived) + query.addQueryItem("isArchived", "false"); + url.setQuery(query); + + QByteArray response = getBytes(url, nullptr, kMetadataTimeoutMs); + if (response.isEmpty()) + break; + + QJsonDocument doc = QJsonDocument::fromJson(response); + if (!doc.isArray()) + break; + + QJsonArray items = doc.array(); + Log("Immich user assets skip ", skip, ": ", items.size(), " assets"); + if (items.isEmpty()) + break; + + for (const auto &value : items) + { + QJsonObject item = value.toObject(); + QString id = item["id"].toString(); + if (id.isEmpty()) + continue; + ImmichAsset asset; + asset.id = id; + asset.originalFileName = item["originalFileName"].toString(); + if (!extensionAllowed(asset.originalFileName)) + { + Log("Immich skip by extension: ", asset.originalFileName.toStdString()); + continue; + } + assets.append(asset); + if (maxAssets > 0 && assets.size() >= maxAssets) + return assets; + } + + if (items.size() < pageSize) + break; + skip += pageSize; + } + + return assets; +} + +bool ImmichClient::extensionAllowed(const QString &filename) const +{ + if (config.allowedExtensions.empty()) + return true; + QString ext = QFileInfo(filename).suffix().toLower(); + if (ext.isEmpty()) + return false; + for (const auto &allowed : config.allowedExtensions) + { + if (ext == QString::fromStdString(allowed)) + return true; + } + return false; +} + bool ImmichClient::downloadAsset(const QString &assetId, QByteArray &data, QString &contentType) { if (!config.enabled) diff --git a/src/immichclient.h b/src/immichclient.h index 8adaae1..af71040 100644 --- a/src/immichclient.h +++ b/src/immichclient.h @@ -23,6 +23,9 @@ class ImmichClient { bool downloadAsset(const QString &assetId, QByteArray &data, QString &contentType); private: + QVector fetchAssetsBySearch(); + QVector fetchAssetsByUser(); + bool extensionAllowed(const QString &filename) const; QUrl apiUrl(const QString &path) const; QNetworkRequest makeRequest(const QUrl &url) const; QByteArray postJson(const QUrl &url, const QJsonObject &body, QString *contentType, int timeoutMs); diff --git a/src/main.cpp b/src/main.cpp index d1391a5..545d9a3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -292,6 +292,8 @@ static bool ApplyImmichPayload(ImmichConfig &config, const QString &payload) if (obj.contains("albumId") && obj["albumId"].isString()) { config.albumIds = { obj["albumId"].toString().toStdString() }; + config.userId.clear(); + config.allowedExtensions.clear(); changed = true; } if (obj.contains("albumIds") && obj["albumIds"].isArray()) @@ -303,11 +305,15 @@ static bool ApplyImmichPayload(ImmichConfig &config, const QString &payload) if (value.isString()) config.albumIds.push_back(value.toString().toStdString()); } + config.userId.clear(); + config.allowedExtensions.clear(); changed = true; } if (obj.contains("personId") && obj["personId"].isString()) { config.personIds = { obj["personId"].toString().toStdString() }; + config.userId.clear(); + config.allowedExtensions.clear(); changed = true; } if (obj.contains("personIds") && obj["personIds"].isArray()) @@ -319,6 +325,24 @@ static bool ApplyImmichPayload(ImmichConfig &config, const QString &payload) if (value.isString()) config.personIds.push_back(value.toString().toStdString()); } + config.userId.clear(); + config.allowedExtensions.clear(); + changed = true; + } + if (obj.contains("userId") && obj["userId"].isString()) + { + config.userId = obj["userId"].toString().toStdString(); + config.albumIds.clear(); + config.personIds.clear(); + config.allowedExtensions.clear(); + changed = true; + } + if (obj.contains("ownerId") && obj["ownerId"].isString()) + { + config.userId = obj["ownerId"].toString().toStdString(); + config.albumIds.clear(); + config.personIds.clear(); + config.allowedExtensions.clear(); changed = true; } if (obj.contains("order") && obj["order"].isString()) @@ -364,6 +388,30 @@ static bool ApplyImmichPayload(ImmichConfig &config, const QString &payload) { config.albumIds.clear(); config.personIds.clear(); + config.userId.clear(); + config.allowedExtensions.clear(); + changed = true; + } + if (obj.contains("extensions") && obj["extensions"].isArray()) + { + config.allowedExtensions.clear(); + QJsonArray arr = obj["extensions"].toArray(); + for (const auto &value : arr) + { + if (value.isString()) + config.allowedExtensions.push_back(value.toString().toLower().toStdString()); + } + changed = true; + } + if (obj.contains("allowedExtensions") && obj["allowedExtensions"].isArray()) + { + config.allowedExtensions.clear(); + QJsonArray arr = obj["allowedExtensions"].toArray(); + for (const auto &value : arr) + { + if (value.isString()) + config.allowedExtensions.push_back(value.toString().toLower().toStdString()); + } changed = true; } return changed; @@ -391,26 +439,54 @@ static bool ApplyImmichPayload(ImmichConfig &config, const QString &payload) { config.albumIds.clear(); config.personIds.clear(); + config.userId.clear(); + config.allowedExtensions.clear(); return true; } if (key == "album" || key == "albumid") { config.albumIds = { value.toStdString() }; + config.userId.clear(); + config.allowedExtensions.clear(); return true; } if (key == "albums" || key == "albumids") { config.albumIds = SplitCsv(value); + config.userId.clear(); + config.allowedExtensions.clear(); return true; } if (key == "person" || key == "personid") { config.personIds = { value.toStdString() }; + config.userId.clear(); + config.allowedExtensions.clear(); return true; } if (key == "persons" || key == "personids") { config.personIds = SplitCsv(value); + config.userId.clear(); + config.allowedExtensions.clear(); + return true; + } + if (key == "user" || key == "userid" || key == "ownerid") + { + config.userId = value.toStdString(); + config.albumIds.clear(); + config.personIds.clear(); + config.allowedExtensions.clear(); + return true; + } + if (key == "extensions" || key == "allowedextensions") + { + config.allowedExtensions = SplitCsv(value); + for (auto &ext : config.allowedExtensions) + { + for (auto &c : ext) + c = static_cast(::tolower(c)); + } return true; } if (key == "order") diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index c2be6bd..3e6b645 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -181,6 +181,16 @@ void MainWindow::updateImage() QPixmap p; p.load( currentImage.filename.c_str() ); + if (p.isNull()) + { + Log("Error: failed to load image: ", currentImage.filename); + warn("Failed to load image."); + if (switcher != nullptr) + { + switcher->scheduleImageUpdate(); + } + return; + } Log("size:", p.width(), "x", p.height(), "(window:", width(), ",", height(), ")");