filter out unsupported images [CI SKIP]
All checks were successful
continuous-integration/drone/tag Build is passing

This commit is contained in:
2026-02-02 08:48:20 +11:00
parent 806d701535
commit 7a75083cf3
7 changed files with 278 additions and 24 deletions

View File

@@ -23,7 +23,7 @@ slide [-t rotation_seconds] [-T transition_seconds] [-h/--overlay-color overlay_
* `image_folder`: where to search for images (.jpg files) * `image_folder`: where to search for images (.jpg files)
* `-i imageFile,...`: comma delimited list of full paths to image files to display * `-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 * `-t` how many seconds to display each picture for
* `-r` for recursive traversal of `image_folder` * `-r` for recursive traversal of `image_folder`
* `-s` for shuffle instead of random image rotation * `-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. To exit the application, press escape. If you're using a touch display, touch all 4 corners at the same time.
## Configuration file ## 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: The file format is:
``` ```
{ {
@@ -157,15 +157,28 @@ If `immichTopic` is not set, it defaults to `<topic>/immich`.
Immich control topic (`immichTopic`): Immich control topic (`immichTopic`):
* `album:<id>` or `albumIds:id1,id2` — filter to one or more album IDs * `album:<id>` or `albumIds:id1,id2` — filter to one or more album IDs
* `person:<id>` or `personIds:id1,id2` — filter to one or more person IDs * `person:<id>` or `personIds:id1,id2` — filter to one or more person IDs
* `reset` / `clear`clear album/person filters * `user:<id>` / `userId:<id>` / `ownerId:<id>` — 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: * 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 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): Example (single source):
``` ```
@@ -205,8 +218,10 @@ Example (scheduler entry):
Immich settings: Immich settings:
* `url`: base Immich server URL (the integration appends `/api` automatically if missing). * `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`). * `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. * `albumId` or `albumIds`: optional album filters.
* `personId` or `personIds`: optional person 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). * `size`: `"fullsize"`, `"preview"`, `"thumbnail"`, or `"original"` (original uses the download endpoint).
* `order`: `"asc"` or `"desc"` ordering for asset search. * `order`: `"asc"` or `"desc"` ordering for asset search.
* `pageSize`: assets fetched per page. * `pageSize`: assets fetched per page.
@@ -214,6 +229,8 @@ Immich settings:
* `cachePath`: local cache directory for downloaded images. * `cachePath`: local cache directory for downloaded images.
* `cacheMaxMB`: maximum cache size in MB (0 disables cleanup). * `cacheMaxMB`: maximum cache size in MB (0 disables cleanup).
* `includeArchived`: include archived assets in search results. * `includeArchived`: include archived assets in search results.
If you omit `albumId`/`albumIds`/`personIds`, Immich returns all assets visible to the API keys 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. When `immich` is set on an entry, `path` and `imageList` are ignored.
## Folder Options file ## Folder Options file

View File

@@ -71,6 +71,19 @@ std::vector<std::string> ParseJSONStrings(QJsonObject jsonDoc, const char *key)
return values; return values;
} }
std::vector<std::string> NormalizeExtensions(const std::vector<std::string> &values) {
std::vector<std::string> normalized;
for (const auto &value : values)
{
std::string s = value;
for (auto &c : s)
c = static_cast<char>(::tolower(c));
if (!s.empty())
normalized.push_back(s);
}
return normalized;
}
ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) { ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) {
ImmichConfig config; ImmichConfig config;
@@ -81,6 +94,13 @@ ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) {
if(!apiKey.empty()) if(!apiKey.empty())
config.apiKey = apiKey; 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"); std::string size = ParseJSONString(immichJson, "size");
if(!size.empty()) if(!size.empty())
config.size = size; config.size = size;
@@ -109,6 +129,12 @@ ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) {
if(personIds.size() > 0) if(personIds.size() > 0)
config.personIds = personIds; config.personIds = personIds;
std::vector<std::string> 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.pageSize, immichJson, "pageSize");
SetJSONInt(config.maxAssets, immichJson, "maxAssets"); SetJSONInt(config.maxAssets, immichJson, "maxAssets");
SetJSONInt(config.cacheMaxMB, immichJson, "cacheMaxMB"); SetJSONInt(config.cacheMaxMB, immichJson, "cacheMaxMB");
@@ -248,27 +274,35 @@ QString getAppConfigFilePath(const std::string &configPath) {
std::string systemConfigFolder = "/etc/slide"; std::string systemConfigFolder = "/etc/slide";
QString baseConfigFilename("slide.options.json"); QString baseConfigFilename("slide.options.json");
QDir directory(userConfigFolder.c_str());
QString jsonFile = "";
if (!configPath.empty()) 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()); QFileInfo configInfo(QString::fromStdString(configPath));
jsonFile = directory.filePath(baseConfigFilename); 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 ""; return "";

View File

@@ -10,8 +10,10 @@ struct ImmichConfig {
bool enabled = false; bool enabled = false;
std::string url = ""; std::string url = "";
std::string apiKey = ""; std::string apiKey = "";
std::string userId = "";
std::vector<std::string> albumIds; std::vector<std::string> albumIds;
std::vector<std::string> personIds; std::vector<std::string> personIds;
std::vector<std::string> allowedExtensions;
std::string size = "fullsize"; std::string size = "fullsize";
std::string order = "desc"; std::string order = "desc";
int pageSize = 200; int pageSize = 200;
@@ -26,6 +28,8 @@ struct ImmichConfig {
return false; return false;
if (url != b.url || apiKey != b.apiKey) if (url != b.url || apiKey != b.apiKey)
return false; return false;
if (userId != b.userId)
return false;
if (size != b.size || order != b.order) if (size != b.size || order != b.order)
return false; return false;
if (pageSize != b.pageSize || maxAssets != b.maxAssets) if (pageSize != b.pageSize || maxAssets != b.maxAssets)
@@ -48,6 +52,13 @@ struct ImmichConfig {
if (personIds[i] != b.personIds[i]) if (personIds[i] != b.personIds[i])
return false; 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; return true;
} }

View File

@@ -12,6 +12,7 @@
#include <QSaveFile> #include <QSaveFile>
#include <QTimer> #include <QTimer>
#include <QUrlQuery> #include <QUrlQuery>
#include <QFileInfo>
namespace { namespace {
const int kMetadataTimeoutMs = 15000; const int kMetadataTimeoutMs = 15000;
@@ -93,13 +94,27 @@ QByteArray ImmichClient::getBytes(const QUrl &url, QString *contentType, int tim
QVector<ImmichAsset> ImmichClient::fetchAssets() QVector<ImmichAsset> ImmichClient::fetchAssets()
{ {
QVector<ImmichAsset> assets;
if (!config.enabled) if (!config.enabled)
{ {
Log("Immich config is missing url or apiKey."); Log("Immich config is missing url or apiKey.");
return assets; return QVector<ImmichAsset>();
} }
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<ImmichAsset> ImmichClient::fetchAssetsBySearch()
{
QVector<ImmichAsset> assets;
int pageSize = config.pageSize > 0 ? config.pageSize : 200; int pageSize = config.pageSize > 0 ? config.pageSize : 200;
int maxAssets = config.maxAssets; int maxAssets = config.maxAssets;
bool triedZero = false; bool triedZero = false;
@@ -110,7 +125,8 @@ QVector<ImmichAsset> ImmichClient::fetchAssets()
Log("Immich search: size=", config.size, ", order=", config.order, Log("Immich search: size=", config.size, ", order=", config.order,
", pageSize=", pageSize, ", maxAssets=", maxAssets, ", pageSize=", pageSize, ", maxAssets=", maxAssets,
", albumIds=", config.albumIds.size(), ", albumIds=", config.albumIds.size(),
", personIds=", config.personIds.size()); ", personIds=", config.personIds.size(),
", allowedExtensions=", config.allowedExtensions.size());
} }
while (true) while (true)
@@ -170,6 +186,11 @@ QVector<ImmichAsset> ImmichClient::fetchAssets()
ImmichAsset asset; ImmichAsset asset;
asset.id = id; asset.id = id;
asset.originalFileName = item["originalFileName"].toString(); asset.originalFileName = item["originalFileName"].toString();
if (!extensionAllowed(asset.originalFileName))
{
Log("Immich skip by extension: ", asset.originalFileName.toStdString());
continue;
}
assets.append(asset); assets.append(asset);
if (maxAssets > 0 && assets.size() >= maxAssets) if (maxAssets > 0 && assets.size() >= maxAssets)
return assets; return assets;
@@ -183,6 +204,88 @@ QVector<ImmichAsset> ImmichClient::fetchAssets()
return assets; return assets;
} }
QVector<ImmichAsset> ImmichClient::fetchAssetsByUser()
{
QVector<ImmichAsset> 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) bool ImmichClient::downloadAsset(const QString &assetId, QByteArray &data, QString &contentType)
{ {
if (!config.enabled) if (!config.enabled)

View File

@@ -23,6 +23,9 @@ class ImmichClient {
bool downloadAsset(const QString &assetId, QByteArray &data, QString &contentType); bool downloadAsset(const QString &assetId, QByteArray &data, QString &contentType);
private: private:
QVector<ImmichAsset> fetchAssetsBySearch();
QVector<ImmichAsset> fetchAssetsByUser();
bool extensionAllowed(const QString &filename) const;
QUrl apiUrl(const QString &path) const; QUrl apiUrl(const QString &path) const;
QNetworkRequest makeRequest(const QUrl &url) const; QNetworkRequest makeRequest(const QUrl &url) const;
QByteArray postJson(const QUrl &url, const QJsonObject &body, QString *contentType, int timeoutMs); QByteArray postJson(const QUrl &url, const QJsonObject &body, QString *contentType, int timeoutMs);

View File

@@ -292,6 +292,8 @@ static bool ApplyImmichPayload(ImmichConfig &config, const QString &payload)
if (obj.contains("albumId") && obj["albumId"].isString()) if (obj.contains("albumId") && obj["albumId"].isString())
{ {
config.albumIds = { obj["albumId"].toString().toStdString() }; config.albumIds = { obj["albumId"].toString().toStdString() };
config.userId.clear();
config.allowedExtensions.clear();
changed = true; changed = true;
} }
if (obj.contains("albumIds") && obj["albumIds"].isArray()) if (obj.contains("albumIds") && obj["albumIds"].isArray())
@@ -303,11 +305,15 @@ static bool ApplyImmichPayload(ImmichConfig &config, const QString &payload)
if (value.isString()) if (value.isString())
config.albumIds.push_back(value.toString().toStdString()); config.albumIds.push_back(value.toString().toStdString());
} }
config.userId.clear();
config.allowedExtensions.clear();
changed = true; changed = true;
} }
if (obj.contains("personId") && obj["personId"].isString()) if (obj.contains("personId") && obj["personId"].isString())
{ {
config.personIds = { obj["personId"].toString().toStdString() }; config.personIds = { obj["personId"].toString().toStdString() };
config.userId.clear();
config.allowedExtensions.clear();
changed = true; changed = true;
} }
if (obj.contains("personIds") && obj["personIds"].isArray()) if (obj.contains("personIds") && obj["personIds"].isArray())
@@ -319,6 +325,24 @@ static bool ApplyImmichPayload(ImmichConfig &config, const QString &payload)
if (value.isString()) if (value.isString())
config.personIds.push_back(value.toString().toStdString()); 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; changed = true;
} }
if (obj.contains("order") && obj["order"].isString()) if (obj.contains("order") && obj["order"].isString())
@@ -364,6 +388,30 @@ static bool ApplyImmichPayload(ImmichConfig &config, const QString &payload)
{ {
config.albumIds.clear(); config.albumIds.clear();
config.personIds.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; changed = true;
} }
return changed; return changed;
@@ -391,26 +439,54 @@ static bool ApplyImmichPayload(ImmichConfig &config, const QString &payload)
{ {
config.albumIds.clear(); config.albumIds.clear();
config.personIds.clear(); config.personIds.clear();
config.userId.clear();
config.allowedExtensions.clear();
return true; return true;
} }
if (key == "album" || key == "albumid") if (key == "album" || key == "albumid")
{ {
config.albumIds = { value.toStdString() }; config.albumIds = { value.toStdString() };
config.userId.clear();
config.allowedExtensions.clear();
return true; return true;
} }
if (key == "albums" || key == "albumids") if (key == "albums" || key == "albumids")
{ {
config.albumIds = SplitCsv(value); config.albumIds = SplitCsv(value);
config.userId.clear();
config.allowedExtensions.clear();
return true; return true;
} }
if (key == "person" || key == "personid") if (key == "person" || key == "personid")
{ {
config.personIds = { value.toStdString() }; config.personIds = { value.toStdString() };
config.userId.clear();
config.allowedExtensions.clear();
return true; return true;
} }
if (key == "persons" || key == "personids") if (key == "persons" || key == "personids")
{ {
config.personIds = SplitCsv(value); 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<char>(::tolower(c));
}
return true; return true;
} }
if (key == "order") if (key == "order")

View File

@@ -181,6 +181,16 @@ void MainWindow::updateImage()
QPixmap p; QPixmap p;
p.load( currentImage.filename.c_str() ); 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(), ")"); Log("size:", p.width(), "x", p.height(), "(window:", width(), ",", height(), ")");