6 Commits
v0.0.8 ... main

Author SHA1 Message Date
fc0ec58636 [ci skip] add ability to skip immich photos via tag 2026-02-02 14:11:38 +11:00
85ef89fa4b undo drone changes [ci skip]
All checks were successful
continuous-integration/drone/tag Build is passing
2026-02-02 11:06:38 +11:00
e5f5934eb6 try auto tagging again
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-02 10:59:11 +11:00
80286da166 drone fix
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-02 10:52:21 +11:00
6f2b8fe90c immich fixes
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-02 10:47:08 +11:00
bc672256fb fix sidecar handling
Some checks failed
continuous-integration/drone/push Build was killed
continuous-integration/drone/tag Build is passing
2026-02-02 09:55:28 +11:00
8 changed files with 270 additions and 30 deletions

8
CHANGELOG.md Normal file
View File

@@ -0,0 +1,8 @@
# Changelog
# [Unreleased]
- Nothing yet.
# [0.0.9] - 2026-02-01
- Fix sidecar handling

View File

@@ -182,6 +182,17 @@ In the Immich web UI, go to Settings and find **API Keys** (menu labels can vary
* `asset.view` — required for the viewAsset endpoint (thumbnail/preview/fullsize). * `asset.view` — required for the viewAsset endpoint (thumbnail/preview/fullsize).
* `asset.download` — required if you set `size` to `original` (download endpoint). * `asset.download` — required if you set `size` to `original` (download endpoint).
#### Finding an Immich user ID
You can use a user ID to show all assets owned by that user.
Common ways to find it:
* **Admin UI**: In Immich, open the Admin/Users page, click the user, and copy the UUID shown in the user details or URL.
* **API (non-admin)**: Call the `users/me` endpoint with the API key and read the `id` field:
```
curl -H "x-api-key: IMMICH_API_KEY" http://immich.local:2283/api/users/me
```
Example (single source): Example (single source):
``` ```
{ {
@@ -195,6 +206,7 @@ Example (single source):
"maxAssets": 1000, "maxAssets": 1000,
"refreshSeconds": 300, "refreshSeconds": 300,
"skipRetrySeconds": 3600, "skipRetrySeconds": 3600,
"skipTags": ["frame-ignore"],
"cachePath": "~/.cache/slide/immich", "cachePath": "~/.cache/slide/immich",
"cacheMaxMB": 512, "cacheMaxMB": 512,
"includeArchived": false "includeArchived": false
@@ -222,12 +234,16 @@ 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. * `userId`: optional user id to retrieve all assets owned by that user via the assets endpoint (see “Finding an Immich user ID” above).
* `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"]`). * `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). * `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.
Note: tag filtering requires Immich to include tag data in search results. If your server doesnt return tags on `/search/metadata`, `skipTags`/`skipTagIds` wont apply.
* `pageSize`: assets fetched per page. * `pageSize`: assets fetched per page.
* `maxAssets`: cap on total assets fetched (0 means no cap). * `maxAssets`: cap on total assets fetched (0 means no cap).
* `refreshSeconds`: refresh interval for reloading Immich assets (0 disables). * `refreshSeconds`: refresh interval for reloading Immich assets (0 disables).

View File

@@ -108,7 +108,7 @@ slide_mqtt_set_album:
- service: mqtt.publish - service: mqtt.publish
data: data:
topic: slide/immich topic: slide/immich
payload_template: "album:{{ states('input_text.slide_album_id') }}" payload: "album:{{ states('input_text.slide_album_id') }}"
slide_mqtt_set_person: slide_mqtt_set_person:
alias: Slide Set Person alias: Slide Set Person
@@ -116,7 +116,7 @@ slide_mqtt_set_person:
- service: mqtt.publish - service: mqtt.publish
data: data:
topic: slide/immich topic: slide/immich
payload_template: "person:{{ states('input_text.slide_person_id') }}" payload: "person:{{ states('input_text.slide_person_id') }}"
slide_mqtt_set_user: slide_mqtt_set_user:
alias: Slide Set User alias: Slide Set User
@@ -124,7 +124,7 @@ slide_mqtt_set_user:
- service: mqtt.publish - service: mqtt.publish
data: data:
topic: slide/immich topic: slide/immich
payload_template: "user:{{ states('input_text.slide_user_id') }}" payload: "user:{{ states('input_text.slide_user_id') }}"
slide_mqtt_set_extensions: slide_mqtt_set_extensions:
alias: Slide Set Extensions alias: Slide Set Extensions
@@ -132,7 +132,7 @@ slide_mqtt_set_extensions:
- service: mqtt.publish - service: mqtt.publish
data: data:
topic: slide/immich topic: slide/immich
payload_template: "extensions:{{ states('input_text.slide_extensions') }}" payload: "extensions:{{ states('input_text.slide_extensions') }}"
slide_mqtt_set_refresh: slide_mqtt_set_refresh:
alias: Slide Set Refresh Seconds alias: Slide Set Refresh Seconds
@@ -140,7 +140,7 @@ slide_mqtt_set_refresh:
- service: mqtt.publish - service: mqtt.publish
data: data:
topic: slide/immich topic: slide/immich
payload_template: "refreshSeconds:{{ states('input_text.slide_refresh_seconds') }}" payload: "refreshSeconds:{{ states('input_text.slide_refresh_seconds') }}"
slide_mqtt_set_size: slide_mqtt_set_size:
alias: Slide Set Size alias: Slide Set Size
@@ -148,7 +148,7 @@ slide_mqtt_set_size:
- service: mqtt.publish - service: mqtt.publish
data: data:
topic: slide/immich topic: slide/immich
payload_template: "size:{{ states('input_select.slide_size') }}" payload: "size:{{ states('input_select.slide_size') }}"
slide_mqtt_set_order: slide_mqtt_set_order:
alias: Slide Set Order alias: Slide Set Order
@@ -156,7 +156,7 @@ slide_mqtt_set_order:
- service: mqtt.publish - service: mqtt.publish
data: data:
topic: slide/immich topic: slide/immich
payload_template: "order:{{ states('input_select.slide_order') }}" payload: "order:{{ states('input_select.slide_order') }}"
``` ```
--- ---

View File

@@ -84,6 +84,19 @@ std::vector<std::string> NormalizeExtensions(const std::vector<std::string> &val
return normalized; return normalized;
} }
std::vector<std::string> NormalizeStrings(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;
@@ -135,6 +148,22 @@ ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) {
if(allowedExtensions.size() > 0) if(allowedExtensions.size() > 0)
config.allowedExtensions = NormalizeExtensions(allowedExtensions); config.allowedExtensions = NormalizeExtensions(allowedExtensions);
std::vector<std::string> 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<std::string> 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.pageSize, immichJson, "pageSize");
SetJSONInt(config.maxAssets, immichJson, "maxAssets"); SetJSONInt(config.maxAssets, immichJson, "maxAssets");
SetJSONInt(config.refreshSeconds, immichJson, "refreshSeconds"); SetJSONInt(config.refreshSeconds, immichJson, "refreshSeconds");

View File

@@ -14,6 +14,8 @@ struct ImmichConfig {
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::vector<std::string> allowedExtensions;
std::vector<std::string> skipTags;
std::vector<std::string> skipTagIds;
std::string size = "fullsize"; std::string size = "fullsize";
std::string order = "desc"; std::string order = "desc";
int pageSize = 200; int pageSize = 200;
@@ -61,6 +63,20 @@ struct ImmichConfig {
if (allowedExtensions[i] != b.allowedExtensions[i]) if (allowedExtensions[i] != b.allowedExtensions[i])
return false; 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) if (refreshSeconds != b.refreshSeconds)
return false; return false;
if (skipRetrySeconds != b.skipRetrySeconds) if (skipRetrySeconds != b.skipRetrySeconds)

View File

@@ -148,7 +148,7 @@ QVector<ImmichAsset> ImmichClient::fetchAssets()
} }
if (!config.userId.empty() && config.albumIds.empty() && config.personIds.empty()) if (!config.userId.empty() && config.albumIds.empty() && config.personIds.empty())
return fetchAssetsByUser(); return fetchAssetsBySearch();
if (!config.userId.empty() && (!config.albumIds.empty() || !config.personIds.empty())) if (!config.userId.empty() && (!config.albumIds.empty() || !config.personIds.empty()))
{ {
@@ -166,20 +166,26 @@ QVector<ImmichAsset> ImmichClient::fetchAssetsBySearch()
int maxAssets = config.maxAssets; int maxAssets = config.maxAssets;
bool triedZero = false; bool triedZero = false;
int page = 1; int page = 1;
QString userFilterKey;
QByteArray firstResponse;
QJsonArray items;
int total = 0;
if (ShouldLog()) if (ShouldLog())
{ {
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,
", userId=", config.userId,
", albumIds=", config.albumIds.size(), ", albumIds=", config.albumIds.size(),
", personIds=", config.personIds.size(), ", personIds=", config.personIds.size(),
", allowedExtensions=", config.allowedExtensions.size()); ", allowedExtensions=", config.allowedExtensions.size(),
", skipTags=", config.skipTags.size(),
", skipTagIds=", config.skipTagIds.size());
} }
while (true) auto fetchPage = [&](int pageIndex, const QString &userKey) -> QByteArray {
{
QJsonObject body; QJsonObject body;
body["page"] = page; body["page"] = pageIndex;
body["size"] = pageSize; body["size"] = pageSize;
body["type"] = "IMAGE"; body["type"] = "IMAGE";
body["order"] = QString::fromStdString(config.order); body["order"] = QString::fromStdString(config.order);
@@ -199,19 +205,69 @@ QVector<ImmichAsset> ImmichClient::fetchAssetsBySearch()
ids.append(QString::fromStdString(id)); ids.append(QString::fromStdString(id));
body["personIds"] = ids; body["personIds"] = ids;
} }
if (!config.userId.empty() && !userKey.isEmpty())
{
body[userKey] = QString::fromStdString(config.userId);
}
return postJson(apiUrl("/search/metadata"), body, nullptr, kMetadataTimeoutMs);
};
QByteArray response = postJson(apiUrl("/search/metadata"), body, nullptr, kMetadataTimeoutMs); auto parseSearch = [&](const QByteArray &response, QJsonArray &outItems, int &outTotal) -> bool {
if (response.isEmpty())
break;
QJsonDocument doc = QJsonDocument::fromJson(response); QJsonDocument doc = QJsonDocument::fromJson(response);
if (!doc.isObject()) if (!doc.isObject())
break; return false;
QJsonObject root = doc.object(); QJsonObject root = doc.object();
if (root.contains("error") || root.contains("statusCode"))
return false;
if (!root.contains("assets"))
return false;
QJsonObject assetsObj = root["assets"].toObject(); QJsonObject assetsObj = root["assets"].toObject();
QJsonArray items = assetsObj["items"].toArray(); outItems = assetsObj["items"].toArray();
int total = assetsObj["total"].toInt(); outTotal = assetsObj["total"].toInt();
return true;
};
QStringList userKeyCandidates;
if (!config.userId.empty())
userKeyCandidates << "ownerId" << "userId";
userKeyCandidates << "";
bool firstResponseReady = false;
for (const auto &candidate : userKeyCandidates)
{
QByteArray response = fetchPage(page, candidate);
if (response.isEmpty())
continue;
if (!parseSearch(response, items, total))
continue;
userFilterKey = candidate;
firstResponse = response;
firstResponseReady = true;
if (!config.userId.empty() && userFilterKey.isEmpty())
{
Log("Immich search user filter not accepted by server; falling back to search without userId.");
}
break;
}
if (!firstResponseReady)
return assets;
while (true)
{
if (firstResponseReady)
{
firstResponseReady = false;
}
else
{
QByteArray response = fetchPage(page, userFilterKey);
if (response.isEmpty())
break;
if (!parseSearch(response, items, total))
break;
}
Log("Immich page ", page, ": ", items.size(), " assets (total ", total, ")"); Log("Immich page ", page, ": ", items.size(), " assets (total ", total, ")");
if (items.isEmpty()) if (items.isEmpty())
{ {
@@ -234,6 +290,11 @@ QVector<ImmichAsset> ImmichClient::fetchAssetsBySearch()
asset.id = id; asset.id = id;
asset.originalFileName = item["originalFileName"].toString(); asset.originalFileName = item["originalFileName"].toString();
asset.exifDateTime = ExtractAssetDateTime(item); asset.exifDateTime = ExtractAssetDateTime(item);
if (shouldSkipByTag(item))
{
Log("Immich skip by tag: ", asset.originalFileName.toStdString());
continue;
}
if (!extensionAllowed(asset.originalFileName)) if (!extensionAllowed(asset.originalFileName))
{ {
Log("Immich skip by extension: ", asset.originalFileName.toStdString()); Log("Immich skip by extension: ", asset.originalFileName.toStdString());
@@ -259,6 +320,9 @@ QVector<ImmichAsset> ImmichClient::fetchAssetsByUser()
int pageSize = config.pageSize > 0 ? config.pageSize : 200; int pageSize = config.pageSize > 0 ? config.pageSize : 200;
int maxAssets = config.maxAssets; int maxAssets = config.maxAssets;
int skip = 0; int skip = 0;
QString endpointPath = "/assets";
QByteArray initialResponse;
bool endpointResolved = false;
if (ShouldLog()) if (ShouldLog())
{ {
@@ -268,24 +332,52 @@ QVector<ImmichAsset> ImmichClient::fetchAssetsByUser()
", includeArchived=", config.includeArchived); ", includeArchived=", config.includeArchived);
} }
while (true) auto fetchPage = [&](const QString &path, int offset) -> QByteArray {
{ QUrl url = apiUrl(path);
QUrl url = apiUrl("/assets");
QUrlQuery query; QUrlQuery query;
query.addQueryItem("take", QString::number(pageSize)); query.addQueryItem("take", QString::number(pageSize));
query.addQueryItem("skip", QString::number(skip)); query.addQueryItem("skip", QString::number(offset));
query.addQueryItem("userId", QString::fromStdString(config.userId)); query.addQueryItem("userId", QString::fromStdString(config.userId));
if (!config.includeArchived) if (!config.includeArchived)
query.addQueryItem("isArchived", "false"); query.addQueryItem("isArchived", "false");
url.setQuery(query); url.setQuery(query);
return getBytes(url, nullptr, kMetadataTimeoutMs);
};
QByteArray response = getBytes(url, nullptr, kMetadataTimeoutMs); initialResponse = fetchPage(endpointPath, skip);
if (initialResponse.isEmpty())
{
endpointPath = "/asset";
initialResponse = fetchPage(endpointPath, skip);
}
if (initialResponse.isEmpty())
{
Log("Immich user assets endpoint not available; falling back to metadata search (userId filter may be ignored).");
return fetchAssetsBySearch();
}
endpointResolved = true;
while (true)
{
QByteArray response;
if (endpointResolved)
{
response = initialResponse;
endpointResolved = false;
}
else
{
response = fetchPage(endpointPath, skip);
}
if (response.isEmpty()) if (response.isEmpty())
break; break;
QJsonDocument doc = QJsonDocument::fromJson(response); QJsonDocument doc = QJsonDocument::fromJson(response);
if (!doc.isArray()) if (!doc.isArray())
break; {
Log("Immich user assets response was not an array; falling back to metadata search.");
return fetchAssetsBySearch();
}
QJsonArray items = doc.array(); QJsonArray items = doc.array();
Log("Immich user assets skip ", skip, ": ", items.size(), " assets"); Log("Immich user assets skip ", skip, ": ", items.size(), " assets");
@@ -335,6 +427,71 @@ bool ImmichClient::extensionAllowed(const QString &filename) const
return false; 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) bool ImmichClient::downloadAsset(const QString &assetId, QByteArray &data, QString &contentType)
{ {
return downloadAssetWithSize(assetId, QString::fromStdString(config.size), data, contentType); return downloadAssetWithSize(assetId, QString::fromStdString(config.size), data, contentType);
@@ -411,12 +568,20 @@ QString ImmichAssetCache::findExisting(const QString &assetId) const
QStringList matches = dir.entryList(QStringList() << (assetId + "_*"), QDir::Files, QDir::Time); QStringList matches = dir.entryList(QStringList() << (assetId + "_*"), QDir::Files, QDir::Time);
if (matches.isEmpty()) if (matches.isEmpty())
return ""; return "";
QString skipFile;
for (const auto &match : matches) for (const auto &match : matches)
{ {
if (match.endsWith(".skip")) if (match.endsWith(".skip"))
return dir.filePath(match); {
if (skipFile.isEmpty())
skipFile = dir.filePath(match);
continue;
}
if (match.endsWith(".exif"))
continue;
return dir.filePath(match);
} }
return dir.filePath(matches.first()); return skipFile;
} }
QString ImmichAssetCache::sanitizeFileName(const QString &name) const QString ImmichAssetCache::sanitizeFileName(const QString &name) const
@@ -562,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; QString extension = detectedExt;
if (extension.isEmpty()) if (extension.isEmpty())
extension = extensionForContentType(contentType); extension = extensionForContentType(contentType);

View File

@@ -27,6 +27,7 @@ class ImmichClient {
private: private:
QVector<ImmichAsset> fetchAssetsBySearch(); QVector<ImmichAsset> fetchAssetsBySearch();
QVector<ImmichAsset> fetchAssetsByUser(); QVector<ImmichAsset> fetchAssetsByUser();
bool shouldSkipByTag(const QJsonObject &item) const;
bool extensionAllowed(const QString &filename) const; 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;

View File

@@ -83,6 +83,8 @@ void ImmichPathTraverser::writeExifSidecar(const QString &imagePath, const QStri
{ {
if (imagePath.isEmpty() || dateTime.isEmpty()) if (imagePath.isEmpty() || dateTime.isEmpty())
return; return;
if (imagePath.endsWith(".exif") || imagePath.endsWith(".skip"))
return;
QString sidecarPath = imagePath + ".exif"; QString sidecarPath = imagePath + ".exif";
QFileInfo info(sidecarPath); QFileInfo info(sidecarPath);