Initial commit, first version

This commit is contained in:
2026-08-03 22:58:30 +02:00
parent e36af062af
commit 0d0da28660
21 changed files with 2484 additions and 2 deletions
+539
View File
@@ -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);
}