2026-08-03 22:58:30 +02:00
|
|
|
#include "ImmichClient.h"
|
|
|
|
|
|
|
|
|
|
#include <QDateTime>
|
|
|
|
|
#include <QHttpMultiPart>
|
|
|
|
|
#include <QJsonArray>
|
|
|
|
|
#include <QJsonObject>
|
|
|
|
|
#include <QJsonValue>
|
|
|
|
|
#include <QNetworkReply>
|
|
|
|
|
#include <QNetworkRequest>
|
|
|
|
|
#include <QUrlQuery>
|
|
|
|
|
|
|
|
|
|
#include <chrono>
|
|
|
|
|
|
|
|
|
|
using namespace Qt::StringLiterals;
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
constexpr auto requestTimeout = std::chrono::minutes{30};
|
|
|
|
|
|
|
|
|
|
QString cleanFileName(QString name)
|
|
|
|
|
{
|
|
|
|
|
name.replace(u'\r', u'_');
|
|
|
|
|
name.replace(u'\n', u'_');
|
|
|
|
|
name.replace(u'"', u'_');
|
|
|
|
|
if (name.isEmpty())
|
|
|
|
|
{
|
|
|
|
|
name = u"asset"_s;
|
|
|
|
|
}
|
|
|
|
|
return name;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void appendTextPart(QHttpMultiPart *multipart, const QByteArray &name, const QString &value)
|
|
|
|
|
{
|
|
|
|
|
QHttpPart part;
|
|
|
|
|
part.setHeader(QNetworkRequest::ContentDispositionHeader,
|
|
|
|
|
u"form-data; name=\"%1\""_s.arg(QString::fromLatin1(name)));
|
|
|
|
|
part.setBody(value.toUtf8());
|
|
|
|
|
multipart->append(part);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ImmichClient::ImmichClient(ShareEndpoint endpoint, QObject *parent) :
|
|
|
|
|
QObject{parent},
|
|
|
|
|
m_endpoint{std::move(endpoint)}
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ImmichClient::inspect()
|
|
|
|
|
{
|
|
|
|
|
m_passwordWasRequested = false;
|
|
|
|
|
emit inspectionStage(u"connecting"_s, u"Connecting to Immich"_s);
|
|
|
|
|
attemptInspect(ShareEndpoint::CredentialKind::Key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QNetworkRequest ImmichClient::requestFor(const QString &relativePath) const
|
|
|
|
|
{
|
|
|
|
|
QUrl url = m_endpoint.apiRoot;
|
|
|
|
|
QString path = url.path();
|
|
|
|
|
if (path.endsWith(u'/'))
|
|
|
|
|
{
|
|
|
|
|
path.chop(1);
|
|
|
|
|
}
|
|
|
|
|
url.setPath(path + relativePath);
|
|
|
|
|
|
|
|
|
|
QNetworkRequest request{url};
|
|
|
|
|
request.setTransferTimeout(requestTimeout);
|
|
|
|
|
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
|
|
|
|
|
request.setRawHeader("User-Agent", "immich-sync/" IMMICH_SYNC_VERSION);
|
|
|
|
|
if (m_endpoint.credentialKind == ShareEndpoint::CredentialKind::Key)
|
|
|
|
|
{
|
|
|
|
|
request.setRawHeader("x-immich-share-key", m_endpoint.credential.toUtf8());
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
request.setRawHeader("x-immich-share-slug", m_endpoint.credential.toUtf8());
|
|
|
|
|
}
|
|
|
|
|
return request;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ImmichClient::attemptInspect(ShareEndpoint::CredentialKind kind)
|
|
|
|
|
{
|
|
|
|
|
m_endpoint.credentialKind = kind;
|
|
|
|
|
auto *reply = m_network.get(requestFor(u"/shared-links/me"_s));
|
|
|
|
|
connect(reply, &QNetworkReply::finished, this, [this, reply, kind] { handleInspectionReply(reply, kind, false); });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ImmichClient::attemptLogin(ShareEndpoint::CredentialKind kind)
|
|
|
|
|
{
|
|
|
|
|
m_endpoint.credentialKind = kind;
|
|
|
|
|
QNetworkRequest request = requestFor(u"/shared-links/login"_s);
|
|
|
|
|
request.setHeader(QNetworkRequest::ContentTypeHeader, u"application/json"_s);
|
|
|
|
|
const QByteArray body =
|
|
|
|
|
QJsonValue{QJsonObject{{u"password"_s, m_endpoint.password}}}.toJson(QJsonValue::JsonFormat::Compact);
|
|
|
|
|
auto *reply = m_network.post(request, body);
|
|
|
|
|
connect(reply, &QNetworkReply::finished, this, [this, reply, kind] { handleInspectionReply(reply, kind, true); });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ImmichClient::handleInspectionReply(QNetworkReply *reply, ShareEndpoint::CredentialKind kind, bool wasLogin)
|
|
|
|
|
{
|
|
|
|
|
const QByteArray body = reply->readAll();
|
|
|
|
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
|
|
|
const bool success = status >= 200 && status < 300 && reply->error() == QNetworkReply::NoError;
|
|
|
|
|
if (success)
|
|
|
|
|
{
|
|
|
|
|
ShareInfo info;
|
|
|
|
|
QString parseError;
|
|
|
|
|
if (parseShare(body, &info, &parseError))
|
|
|
|
|
{
|
|
|
|
|
m_endpoint.credentialKind = kind;
|
|
|
|
|
if (!info.albumId.isEmpty() && info.assets.isEmpty() && info.declaredAssetCount > 0)
|
|
|
|
|
{
|
|
|
|
|
loadAlbumAssets(std::move(info));
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
emit inspectionStage(u"ready"_s, u"Share inspected"_s);
|
|
|
|
|
emit inspected(info);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
emit failed(parseError);
|
|
|
|
|
}
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m_passwordWasRequested = m_passwordWasRequested || mentionsPassword(body);
|
|
|
|
|
const bool authenticationFailure = status == 401 || status == 403;
|
|
|
|
|
if (!wasLogin && authenticationFailure && !m_endpoint.password.isEmpty())
|
|
|
|
|
{
|
|
|
|
|
emit inspectionStage(u"authenticating"_s, u"Authenticating password-protected share"_s);
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
attemptLogin(kind);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (kind == ShareEndpoint::CredentialKind::Key)
|
|
|
|
|
{
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
attemptInspect(ShareEndpoint::CredentialKind::Slug);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const QString message = errorMessage(reply, body);
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
if (m_endpoint.password.isEmpty() && m_passwordWasRequested)
|
|
|
|
|
{
|
|
|
|
|
emit passwordRequired();
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
emit failed(message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool ImmichClient::parseShare(const QByteArray &json, ShareInfo *info, QString *error)
|
|
|
|
|
{
|
|
|
|
|
QJsonParseError parseError;
|
|
|
|
|
const QJsonValue rootValue = QJsonValue::fromJson(json, &parseError);
|
|
|
|
|
if (parseError.error != QJsonParseError::NoError || !rootValue.isObject())
|
|
|
|
|
{
|
|
|
|
|
*error = u"Immich returned invalid JSON: %1"_s.arg(parseError.errorString());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const QJsonObject root = rootValue.toObject();
|
|
|
|
|
info->id = root.value(u"id"_s).toString();
|
|
|
|
|
info->description = root.value(u"description"_s).toString();
|
|
|
|
|
info->type = root.value(u"type"_s).toString();
|
|
|
|
|
info->allowDownload = root.value(u"allowDownload"_s).toBool();
|
|
|
|
|
info->allowUpload = root.value(u"allowUpload"_s).toBool();
|
|
|
|
|
info->passwordProtected = !root.value(u"password"_s).isNull();
|
|
|
|
|
|
|
|
|
|
QJsonArray assets = root.value(u"assets"_s).toArray();
|
|
|
|
|
const QJsonValue albumValue = root.value(u"album"_s);
|
|
|
|
|
if (albumValue.isObject())
|
|
|
|
|
{
|
|
|
|
|
const QJsonObject album = albumValue.toObject();
|
|
|
|
|
info->albumId = album.value(u"id"_s).toString();
|
|
|
|
|
info->name = album.value(u"albumName"_s).toString();
|
|
|
|
|
info->declaredAssetCount = album.value(u"assetCount"_s).toInteger();
|
|
|
|
|
assets = album.value(u"assets"_s).toArray();
|
|
|
|
|
}
|
|
|
|
|
if (info->name.isEmpty())
|
|
|
|
|
{
|
|
|
|
|
info->name = info->description.isEmpty() ? u"Shared assets"_s : info->description;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
info->assets.clear();
|
|
|
|
|
parseAssets(assets, info);
|
|
|
|
|
|
|
|
|
|
if (info->id.isEmpty())
|
|
|
|
|
{
|
|
|
|
|
*error = u"Immich's shared-link response did not include an id."_s;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ImmichClient::parseAssets(const QJsonArray &assets, ShareInfo *info)
|
|
|
|
|
{
|
|
|
|
|
info->assets.reserve(info->assets.size() + assets.size());
|
|
|
|
|
for (const QJsonValue assetValue : assets)
|
|
|
|
|
{
|
|
|
|
|
AssetInfo parsed = parseAsset(assetValue);
|
|
|
|
|
if (!parsed.id.isEmpty())
|
|
|
|
|
{
|
|
|
|
|
info->assets.append(std::move(parsed));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
AssetInfo ImmichClient::parseAsset(const QJsonValue &value)
|
|
|
|
|
{
|
|
|
|
|
if (!value.isObject())
|
|
|
|
|
{
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
const QJsonObject asset = value.toObject();
|
|
|
|
|
return {
|
|
|
|
|
.id = asset.value(u"id"_s).toString(),
|
|
|
|
|
.checksum = asset.value(u"checksum"_s).toString(),
|
|
|
|
|
.fileName = asset.value(u"originalFileName"_s).toString(),
|
2026-08-05 18:32:30 +02:00
|
|
|
.type = asset.value(u"type"_s).toString(),
|
2026-08-03 22:58:30 +02:00
|
|
|
.fileCreatedAt = asset.value(u"fileCreatedAt"_s).toString(),
|
|
|
|
|
.fileModifiedAt = asset.value(u"fileModifiedAt"_s).toString(),
|
|
|
|
|
.favorite = asset.value(u"isFavorite"_s).toBool(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ImmichClient::loadAlbumAssets(ShareInfo info)
|
|
|
|
|
{
|
|
|
|
|
m_loadingAssets = true;
|
|
|
|
|
m_pendingInfo = std::move(info);
|
|
|
|
|
m_timelineBuckets.clear();
|
|
|
|
|
m_assetIds.clear();
|
|
|
|
|
m_nextAssetId = 0;
|
|
|
|
|
m_pendingAssetRequests = 0;
|
|
|
|
|
emit inspectionStage(u"loading-assets"_s, u"Loading %1 album assets"_s.arg(m_pendingInfo.declaredAssetCount));
|
|
|
|
|
fetchSearchPage(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ImmichClient::fetchSearchPage(int page)
|
|
|
|
|
{
|
|
|
|
|
if (!m_loadingAssets)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
QNetworkRequest request = requestFor(u"/search/metadata"_s);
|
|
|
|
|
request.setHeader(QNetworkRequest::ContentTypeHeader, u"application/json"_s);
|
|
|
|
|
const QJsonObject payload{
|
|
|
|
|
{u"albumIds"_s, QJsonArray{m_pendingInfo.albumId}},
|
|
|
|
|
{u"page"_s, page},
|
|
|
|
|
{u"size"_s, 1000},
|
|
|
|
|
{u"withStacked"_s, false},
|
|
|
|
|
};
|
|
|
|
|
auto *reply = m_network.post(request, QJsonValue{payload}.toJson(QJsonValue::JsonFormat::Compact));
|
|
|
|
|
connect(reply, &QNetworkReply::finished, this, [this, reply] {
|
|
|
|
|
if (!m_loadingAssets)
|
|
|
|
|
{
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const QByteArray body = reply->readAll();
|
|
|
|
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
|
|
|
if (reply->error() != QNetworkReply::NoError || status < 200 || status >= 300)
|
|
|
|
|
{
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
m_pendingInfo.assets.clear();
|
|
|
|
|
emit inspectionStage(u"loading-assets"_s, u"Using Immich 3.0 timeline compatibility fallback"_s);
|
|
|
|
|
fetchTimelineBuckets();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const QJsonValue rootValue = QJsonValue::fromJson(body);
|
|
|
|
|
const QJsonObject assets = rootValue.toObject().value(u"assets"_s).toObject();
|
|
|
|
|
parseAssets(assets.value(u"items"_s).toArray(), &m_pendingInfo);
|
|
|
|
|
const QJsonValue nextPageValue = assets.value(u"nextPage"_s);
|
|
|
|
|
int nextPage = nextPageValue.toInt();
|
|
|
|
|
if (nextPage == 0 && nextPageValue.isString())
|
|
|
|
|
{
|
|
|
|
|
nextPage = nextPageValue.toString().toInt();
|
|
|
|
|
}
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
if (nextPage > 0)
|
|
|
|
|
{
|
|
|
|
|
fetchSearchPage(nextPage);
|
|
|
|
|
}
|
|
|
|
|
else if (m_pendingInfo.assets.isEmpty() && m_pendingInfo.declaredAssetCount > 0)
|
|
|
|
|
{
|
|
|
|
|
fetchTimelineBuckets();
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
finishAlbumAssets();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ImmichClient::fetchTimelineBuckets()
|
|
|
|
|
{
|
|
|
|
|
if (!m_loadingAssets)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
QNetworkRequest request = requestFor(u"/timeline/buckets"_s);
|
|
|
|
|
QUrl url = request.url();
|
|
|
|
|
QUrlQuery query;
|
|
|
|
|
query.addQueryItem(u"albumId"_s, m_pendingInfo.albumId);
|
|
|
|
|
query.addQueryItem(u"withStacked"_s, u"false"_s);
|
|
|
|
|
url.setQuery(query);
|
|
|
|
|
request.setUrl(url);
|
|
|
|
|
auto *reply = m_network.get(request);
|
|
|
|
|
connect(reply, &QNetworkReply::finished, this, [this, reply] {
|
|
|
|
|
const QByteArray body = reply->readAll();
|
|
|
|
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
|
|
|
if (!m_loadingAssets)
|
|
|
|
|
{
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (reply->error() != QNetworkReply::NoError || status < 200 || status >= 300)
|
|
|
|
|
{
|
|
|
|
|
const QString message = errorMessage(reply, body);
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
failAlbumAssets(message);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const QJsonValue rootValue = QJsonValue::fromJson(body);
|
|
|
|
|
for (const QJsonValue value : rootValue.toArray())
|
|
|
|
|
{
|
|
|
|
|
const QString bucket = value.toObject().value(u"timeBucket"_s).toString();
|
|
|
|
|
if (!bucket.isEmpty())
|
|
|
|
|
{
|
|
|
|
|
m_timelineBuckets.append(bucket);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
fetchNextTimelineBucket();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ImmichClient::fetchNextTimelineBucket()
|
|
|
|
|
{
|
|
|
|
|
if (!m_loadingAssets)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (m_timelineBuckets.isEmpty())
|
|
|
|
|
{
|
|
|
|
|
m_assetIds.removeDuplicates();
|
|
|
|
|
m_nextAssetId = 0;
|
|
|
|
|
fetchAssetDetails();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const QString bucket = m_timelineBuckets.takeFirst();
|
|
|
|
|
QNetworkRequest request = requestFor(u"/timeline/bucket"_s);
|
|
|
|
|
QUrl url = request.url();
|
|
|
|
|
QUrlQuery query;
|
|
|
|
|
query.addQueryItem(u"albumId"_s, m_pendingInfo.albumId);
|
|
|
|
|
query.addQueryItem(u"timeBucket"_s, bucket);
|
|
|
|
|
query.addQueryItem(u"withStacked"_s, u"false"_s);
|
|
|
|
|
url.setQuery(query);
|
|
|
|
|
request.setUrl(url);
|
|
|
|
|
auto *reply = m_network.get(request);
|
|
|
|
|
connect(reply, &QNetworkReply::finished, this, [this, reply] {
|
|
|
|
|
const QByteArray body = reply->readAll();
|
|
|
|
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
|
|
|
if (!m_loadingAssets)
|
|
|
|
|
{
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (reply->error() != QNetworkReply::NoError || status < 200 || status >= 300)
|
|
|
|
|
{
|
|
|
|
|
const QString message = errorMessage(reply, body);
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
failAlbumAssets(message);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const QJsonValue rootValue = QJsonValue::fromJson(body);
|
|
|
|
|
for (const QJsonValue idValue : rootValue.toObject().value(u"id"_s).toArray())
|
|
|
|
|
{
|
|
|
|
|
const QString id = idValue.toString();
|
|
|
|
|
if (!id.isEmpty())
|
|
|
|
|
{
|
|
|
|
|
m_assetIds.append(id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
fetchNextTimelineBucket();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ImmichClient::fetchAssetDetails()
|
|
|
|
|
{
|
|
|
|
|
if (!m_loadingAssets)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (m_assetIds.isEmpty())
|
|
|
|
|
{
|
|
|
|
|
failAlbumAssets(u"Immich reported album assets but returned no readable asset IDs"_s);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
constexpr qsizetype concurrency = 6;
|
|
|
|
|
while (m_pendingAssetRequests < concurrency && m_nextAssetId < m_assetIds.size())
|
|
|
|
|
{
|
|
|
|
|
const QString id = m_assetIds.at(m_nextAssetId++);
|
|
|
|
|
++m_pendingAssetRequests;
|
|
|
|
|
auto *reply = m_network.get(requestFor(u"/assets/%1"_s.arg(id)));
|
|
|
|
|
connect(reply, &QNetworkReply::finished, this, [this, reply] {
|
|
|
|
|
--m_pendingAssetRequests;
|
|
|
|
|
const QByteArray body = reply->readAll();
|
|
|
|
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
|
|
|
if (!m_loadingAssets)
|
|
|
|
|
{
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (reply->error() != QNetworkReply::NoError || status < 200 || status >= 300)
|
|
|
|
|
{
|
|
|
|
|
const QString message = errorMessage(reply, body);
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
failAlbumAssets(message);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const AssetInfo asset = parseAsset(QJsonValue::fromJson(body));
|
|
|
|
|
if (!asset.id.isEmpty())
|
|
|
|
|
{
|
|
|
|
|
m_pendingInfo.assets.append(asset);
|
|
|
|
|
}
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
emit inspectionStage(u"loading-assets"_s,
|
|
|
|
|
u"Loaded %1 of %2 assets"_s.arg(m_pendingInfo.assets.size()).arg(m_assetIds.size()));
|
|
|
|
|
if (m_nextAssetId >= m_assetIds.size() && m_pendingAssetRequests == 0)
|
|
|
|
|
{
|
|
|
|
|
finishAlbumAssets();
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
fetchAssetDetails();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ImmichClient::finishAlbumAssets()
|
|
|
|
|
{
|
|
|
|
|
if (!m_loadingAssets)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (m_pendingInfo.assets.size() < m_pendingInfo.declaredAssetCount)
|
|
|
|
|
{
|
|
|
|
|
failAlbumAssets(u"Immich returned only %1 of %2 album assets"_s.arg(m_pendingInfo.assets.size())
|
|
|
|
|
.arg(m_pendingInfo.declaredAssetCount));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
m_loadingAssets = false;
|
|
|
|
|
emit inspectionStage(u"ready"_s, u"Share and checksums inspected"_s);
|
|
|
|
|
emit inspected(m_pendingInfo);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ImmichClient::failAlbumAssets(const QString &message)
|
|
|
|
|
{
|
|
|
|
|
if (!m_loadingAssets)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
m_loadingAssets = false;
|
|
|
|
|
emit failed(u"Unable to load shared album assets: %1"_s.arg(message));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QString ImmichClient::errorMessage(QNetworkReply *reply, const QByteArray &body)
|
|
|
|
|
{
|
|
|
|
|
const QJsonValue rootValue = QJsonValue::fromJson(body);
|
|
|
|
|
QString detail;
|
|
|
|
|
if (rootValue.isObject())
|
|
|
|
|
{
|
|
|
|
|
const QJsonValue message = rootValue.toObject().value(u"message"_s);
|
|
|
|
|
if (message.isString())
|
|
|
|
|
{
|
|
|
|
|
detail = message.toString();
|
|
|
|
|
}
|
|
|
|
|
else if (message.isArray())
|
|
|
|
|
{
|
|
|
|
|
QStringList messages;
|
|
|
|
|
for (const QJsonValue item : message.toArray())
|
|
|
|
|
{
|
|
|
|
|
messages.append(item.toString());
|
|
|
|
|
}
|
|
|
|
|
detail = messages.join(u", "_s);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (detail.isEmpty())
|
|
|
|
|
{
|
|
|
|
|
detail = reply->errorString();
|
|
|
|
|
}
|
|
|
|
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
|
|
|
return status > 0 ? u"Immich HTTP %1: %2"_s.arg(status).arg(detail) : u"Immich connection failed: %1"_s.arg(detail);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool ImmichClient::mentionsPassword(const QByteArray &body)
|
|
|
|
|
{
|
|
|
|
|
return body.toLower().contains("password");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QNetworkReply *ImmichClient::download(const AssetInfo &asset)
|
|
|
|
|
{
|
|
|
|
|
auto *reply = m_network.get(requestFor(u"/assets/%1/original"_s.arg(asset.id)));
|
|
|
|
|
reply->setReadBufferSize(1024 * 1024);
|
|
|
|
|
return reply;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QNetworkReply *ImmichClient::upload(const AssetInfo &asset, QIODevice *source, QHttpMultiPart *multipart)
|
|
|
|
|
{
|
|
|
|
|
const QString createdAt = asset.fileCreatedAt.isEmpty()
|
|
|
|
|
? QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)
|
|
|
|
|
: asset.fileCreatedAt;
|
|
|
|
|
const QString modifiedAt = asset.fileModifiedAt.isEmpty() ? createdAt : asset.fileModifiedAt;
|
|
|
|
|
appendTextPart(multipart, "fileCreatedAt", createdAt);
|
|
|
|
|
appendTextPart(multipart, "fileModifiedAt", modifiedAt);
|
|
|
|
|
appendTextPart(multipart, "isFavorite", asset.favorite ? u"true"_s : u"false"_s);
|
|
|
|
|
|
|
|
|
|
QHttpPart assetPart;
|
|
|
|
|
const QString fileName = cleanFileName(asset.fileName);
|
|
|
|
|
assetPart.setHeader(QNetworkRequest::ContentDispositionHeader,
|
|
|
|
|
u"form-data; name=\"assetData\"; filename=\"%1\""_s.arg(fileName));
|
|
|
|
|
assetPart.setHeader(QNetworkRequest::ContentTypeHeader, u"application/octet-stream"_s);
|
|
|
|
|
assetPart.setBodyDevice(source);
|
|
|
|
|
multipart->append(assetPart);
|
|
|
|
|
|
|
|
|
|
// Do not send x-immich-checksum here. Immich's early duplicate interceptor returns before
|
|
|
|
|
// associating an already-owned asset with the destination shared album. Letting the upload
|
|
|
|
|
// reach the normal duplicate constraint makes Immich add that asset to the share correctly.
|
|
|
|
|
return m_network.post(requestFor(u"/assets"_s), multipart);
|
|
|
|
|
}
|