Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85ef89fa4b | |||
| e5f5934eb6 | |||
| 80286da166 | |||
| 6f2b8fe90c | |||
| bc672256fb |
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
|
||||||
13
README.md
13
README.md
@@ -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):
|
||||||
```
|
```
|
||||||
{
|
{
|
||||||
@@ -222,7 +233,7 @@ 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"]`).
|
||||||
|
|||||||
@@ -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') }}"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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,24 @@ 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());
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +203,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())
|
||||||
{
|
{
|
||||||
@@ -259,6 +313,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 +325,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");
|
||||||
@@ -411,12 +496,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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user