#include "ImmichClient.h" #include #include #include #include #include #include #include #include #include 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(), .type = asset.value(u"type"_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); }