Initial commit, first version
This commit is contained in:
@@ -0,0 +1,539 @@
|
||||
#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(),
|
||||
.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);
|
||||
}
|
||||
Reference in New Issue
Block a user