Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc0ec58636 | |||
| 85ef89fa4b | |||
| e5f5934eb6 | |||
| 80286da166 | |||
| 6f2b8fe90c |
8
CHANGELOG.md
Normal file
8
CHANGELOG.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
# [Unreleased]
|
||||||
|
|
||||||
|
- Nothing yet.
|
||||||
|
|
||||||
|
# [0.0.9] - 2026-02-01
|
||||||
|
- Fix sidecar handling
|
||||||
@@ -206,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
|
||||||
@@ -237,8 +238,12 @@ Immich settings:
|
|||||||
* `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 doesn’t return tags on `/search/metadata`, `skipTags`/`skipTagIds` won’t 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).
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -570,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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user