From fc0ec5863637991561e31c8d043672a3f80edad9 Mon Sep 17 00:00:00 2001 From: Nathan Coad Date: Mon, 2 Feb 2026 14:11:38 +1100 Subject: [PATCH] [ci skip] add ability to skip immich photos via tag --- README.md | 5 +++ src/appconfig.cpp | 29 ++++++++++++++++ src/appconfig.h | 16 +++++++++ src/immichclient.cpp | 79 ++++++++++++++++++++++++++++++++++++++++++-- src/immichclient.h | 1 + 5 files changed, 128 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 15d652a..99cbc4d 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,7 @@ Example (single source): "maxAssets": 1000, "refreshSeconds": 300, "skipRetrySeconds": 3600, + "skipTags": ["frame-ignore"], "cachePath": "~/.cache/slide/immich", "cacheMaxMB": 512, "includeArchived": false @@ -237,8 +238,12 @@ Immich settings: * `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"]`). +* `skipTags`: optional list of tag names to exclude (case-insensitive). +* `skipTagIds`: optional list of tag IDs to exclude (case-insensitive). * `size`: `"fullsize"`, `"preview"`, `"thumbnail"`, or `"original"` (original uses the download endpoint). * `order`: `"asc"` or `"desc"` ordering for asset search. + +Note: tag filtering requires Immich to include tag data in search results. If your server doesn’t return tags on `/search/metadata`, `skipTags`/`skipTagIds` won’t apply. * `pageSize`: assets fetched per page. * `maxAssets`: cap on total assets fetched (0 means no cap). * `refreshSeconds`: refresh interval for reloading Immich assets (0 disables). diff --git a/src/appconfig.cpp b/src/appconfig.cpp index d7e9e85..f51cf91 100644 --- a/src/appconfig.cpp +++ b/src/appconfig.cpp @@ -84,6 +84,19 @@ std::vector NormalizeExtensions(const std::vector &val return normalized; } +std::vector NormalizeStrings(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; @@ -135,6 +148,22 @@ ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) { if(allowedExtensions.size() > 0) config.allowedExtensions = NormalizeExtensions(allowedExtensions); + std::vector skipTags = ParseJSONStrings(immichJson, "skipTags"); + if(skipTags.size() == 0) + skipTags = ParseJSONStrings(immichJson, "excludeTags"); + std::string skipTag = ParseJSONString(immichJson, "skipTag"); + if(!skipTag.empty()) + skipTags.push_back(skipTag); + if(skipTags.size() > 0) + config.skipTags = NormalizeStrings(skipTags); + + std::vector skipTagIds = ParseJSONStrings(immichJson, "skipTagIds"); + std::string skipTagId = ParseJSONString(immichJson, "skipTagId"); + if(!skipTagId.empty()) + skipTagIds.push_back(skipTagId); + if(skipTagIds.size() > 0) + config.skipTagIds = NormalizeStrings(skipTagIds); + SetJSONInt(config.pageSize, immichJson, "pageSize"); SetJSONInt(config.maxAssets, immichJson, "maxAssets"); SetJSONInt(config.refreshSeconds, immichJson, "refreshSeconds"); diff --git a/src/appconfig.h b/src/appconfig.h index 0653a88..a9461f3 100644 --- a/src/appconfig.h +++ b/src/appconfig.h @@ -14,6 +14,8 @@ struct ImmichConfig { std::vector albumIds; std::vector personIds; std::vector allowedExtensions; + std::vector skipTags; + std::vector skipTagIds; std::string size = "fullsize"; std::string order = "desc"; int pageSize = 200; @@ -61,6 +63,20 @@ struct ImmichConfig { if (allowedExtensions[i] != b.allowedExtensions[i]) return false; } + if (skipTags.size() != b.skipTags.size()) + return false; + for (size_t i = 0; i < skipTags.size(); ++i) + { + if (skipTags[i] != b.skipTags[i]) + return false; + } + if (skipTagIds.size() != b.skipTagIds.size()) + return false; + for (size_t i = 0; i < skipTagIds.size(); ++i) + { + if (skipTagIds[i] != b.skipTagIds[i]) + return false; + } if (refreshSeconds != b.refreshSeconds) return false; if (skipRetrySeconds != b.skipRetrySeconds) diff --git a/src/immichclient.cpp b/src/immichclient.cpp index bcbc57e..e8e598e 100644 --- a/src/immichclient.cpp +++ b/src/immichclient.cpp @@ -178,7 +178,9 @@ QVector ImmichClient::fetchAssetsBySearch() ", userId=", config.userId, ", albumIds=", config.albumIds.size(), ", personIds=", config.personIds.size(), - ", allowedExtensions=", config.allowedExtensions.size()); + ", allowedExtensions=", config.allowedExtensions.size(), + ", skipTags=", config.skipTags.size(), + ", skipTagIds=", config.skipTagIds.size()); } auto fetchPage = [&](int pageIndex, const QString &userKey) -> QByteArray { @@ -288,6 +290,11 @@ QVector ImmichClient::fetchAssetsBySearch() asset.id = id; asset.originalFileName = item["originalFileName"].toString(); asset.exifDateTime = ExtractAssetDateTime(item); + if (shouldSkipByTag(item)) + { + Log("Immich skip by tag: ", asset.originalFileName.toStdString()); + continue; + } if (!extensionAllowed(asset.originalFileName)) { Log("Immich skip by extension: ", asset.originalFileName.toStdString()); @@ -420,6 +427,71 @@ bool ImmichClient::extensionAllowed(const QString &filename) const return false; } +bool ImmichClient::shouldSkipByTag(const QJsonObject &item) const +{ + if (config.skipTags.empty() && config.skipTagIds.empty()) + return false; + + auto matchesName = [&](const QString &name) -> bool { + if (name.isEmpty()) + return false; + QString lowered = name.toLower(); + for (const auto &tag : config.skipTags) + { + if (lowered == QString::fromStdString(tag)) + return true; + } + return false; + }; + + auto matchesId = [&](const QString &id) -> bool { + if (id.isEmpty()) + return false; + QString lowered = id.toLower(); + for (const auto &tagId : config.skipTagIds) + { + if (lowered == QString::fromStdString(tagId)) + return true; + } + return false; + }; + + if (item.contains("tagIds") && item["tagIds"].isArray()) + { + QJsonArray tagIds = item["tagIds"].toArray(); + for (const auto &value : tagIds) + { + if (value.isString() && matchesId(value.toString())) + return true; + } + } + + if (item.contains("tags") && item["tags"].isArray()) + { + QJsonArray tags = item["tags"].toArray(); + for (const auto &value : tags) + { + if (value.isString()) + { + if (matchesName(value.toString())) + return true; + continue; + } + if (!value.isObject()) + continue; + QJsonObject tagObj = value.toObject(); + if (matchesId(tagObj["id"].toString())) + return true; + if (matchesName(tagObj["name"].toString())) + return true; + if (matchesName(tagObj["value"].toString())) + return true; + } + } + + return false; +} + bool ImmichClient::downloadAsset(const QString &assetId, QByteArray &data, QString &contentType) { return downloadAssetWithSize(assetId, QString::fromStdString(config.size), data, contentType); @@ -655,7 +727,10 @@ QString ImmichAssetCache::getCachedPath(const QString &assetId, const QString &a } } - QString safeName = sanitizeFileName(assetName); + QString baseName = QFileInfo(assetName).completeBaseName(); + if (baseName.isEmpty()) + baseName = assetName; + QString safeName = sanitizeFileName(baseName); QString extension = detectedExt; if (extension.isEmpty()) extension = extensionForContentType(contentType); diff --git a/src/immichclient.h b/src/immichclient.h index 0ce29b1..3771feb 100644 --- a/src/immichclient.h +++ b/src/immichclient.h @@ -27,6 +27,7 @@ class ImmichClient { private: QVector fetchAssetsBySearch(); QVector fetchAssetsByUser(); + bool shouldSkipByTag(const QJsonObject &item) const; bool extensionAllowed(const QString &filename) const; QUrl apiUrl(const QString &path) const; QNetworkRequest makeRequest(const QUrl &url) const;