Initial commit, first version
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
#include "Application.h"
|
||||
|
||||
#include "SyncSession.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QHttpHeaders>
|
||||
#include <QHttpServerRequest>
|
||||
#include <QHttpServerResponse>
|
||||
#include <QHttpServerWebSocketUpgradeResponse>
|
||||
#include <QJsonObject>
|
||||
#include <QUrl>
|
||||
#include <QWebSocket>
|
||||
|
||||
using namespace Qt::StringLiterals;
|
||||
|
||||
Application::Application(QObject *parent) :
|
||||
QObject{parent}
|
||||
{
|
||||
configureHttpRoutes();
|
||||
|
||||
m_httpServer.addAfterRequestHandler(
|
||||
this, [](const QHttpServerRequest &, QHttpServerResponse &response) { applySecurityHeaders(response); });
|
||||
m_httpServer.addWebSocketUpgradeVerifier(this, [](const QHttpServerRequest &request) {
|
||||
if (request.url().path() != u"/ws")
|
||||
{
|
||||
return QHttpServerWebSocketUpgradeResponse::passToNext();
|
||||
}
|
||||
|
||||
const QByteArray originHeader = request.value("origin");
|
||||
if (!originHeader.isEmpty())
|
||||
{
|
||||
const QUrl origin{QString::fromUtf8(originHeader)};
|
||||
const QUrl hostUrl{u"http://"_s + QString::fromUtf8(request.value("host"))};
|
||||
if (!origin.isValid() || origin.host().compare(hostUrl.host(), Qt::CaseInsensitive) != 0)
|
||||
{
|
||||
return QHttpServerWebSocketUpgradeResponse::deny(403, "WebSocket origin denied");
|
||||
}
|
||||
}
|
||||
return QHttpServerWebSocketUpgradeResponse::accept();
|
||||
});
|
||||
connect(&m_httpServer, &QHttpServer::newWebSocketConnection, this, &Application::acceptWebSocket);
|
||||
}
|
||||
|
||||
void Application::configureHttpRoutes()
|
||||
{
|
||||
m_httpServer.route(u"/"_s, [] { return resourceResponse(u":/web/web/index.html"_s, "text/html; charset=utf-8"); });
|
||||
m_httpServer.route(u"/app.js"_s,
|
||||
[] { return resourceResponse(u":/web/web/app.js"_s, "text/javascript; charset=utf-8"); });
|
||||
m_httpServer.route(u"/styles.css"_s,
|
||||
[] { return resourceResponse(u":/web/web/styles.css"_s, "text/css; charset=utf-8"); });
|
||||
m_httpServer.route(u"/healthz"_s, [] {
|
||||
return QHttpServerResponse{
|
||||
QJsonObject{{u"status"_s, u"ok"_s}, {u"version"_s, QString::fromLatin1(IMMICH_SYNC_VERSION)}}};
|
||||
});
|
||||
m_httpServer.setMissingHandler(this, [](const QHttpServerRequest &, QHttpServerResponder &responder) {
|
||||
responder.write(QByteArrayLiteral("Not found\n"), QByteArrayLiteral("text/plain; charset=utf-8"),
|
||||
QHttpServerResponder::StatusCode::NotFound);
|
||||
});
|
||||
}
|
||||
|
||||
QHttpServerResponse Application::resourceResponse(const QString &path, const QByteArray &mimeType)
|
||||
{
|
||||
QFile file{path};
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
{
|
||||
return QHttpServerResponse{QHttpServerResponse::StatusCode::InternalServerError};
|
||||
}
|
||||
return QHttpServerResponse{mimeType, file.readAll()};
|
||||
}
|
||||
|
||||
void Application::applySecurityHeaders(QHttpServerResponse &response)
|
||||
{
|
||||
QHttpHeaders headers = response.headers();
|
||||
headers.append(QHttpHeaders::WellKnownHeader::CacheControl, u"no-store"_s);
|
||||
headers.append(
|
||||
QHttpHeaders::WellKnownHeader::ContentSecurityPolicy,
|
||||
u"default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; "
|
||||
"style-src 'self'; script-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"_s);
|
||||
headers.append(QHttpHeaders::WellKnownHeader::XContentTypeOptions, u"nosniff"_s);
|
||||
headers.append(QHttpHeaders::WellKnownHeader::CrossOriginResourcePolicy, u"same-origin"_s);
|
||||
headers.append(u"Referrer-Policy"_s, u"no-referrer"_s);
|
||||
headers.append(u"Permissions-Policy"_s, u"camera=(), microphone=(), geolocation=()"_s);
|
||||
response.setHeaders(std::move(headers));
|
||||
}
|
||||
|
||||
bool Application::start(const QHostAddress &address, quint16 port)
|
||||
{
|
||||
if (!m_tcpServer.listen(address, port))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return m_httpServer.bind(&m_tcpServer);
|
||||
}
|
||||
|
||||
void Application::acceptWebSocket()
|
||||
{
|
||||
while (m_httpServer.hasPendingWebSocketConnections())
|
||||
{
|
||||
std::unique_ptr<QWebSocket> pending = m_httpServer.nextPendingWebSocketConnection();
|
||||
if (!pending)
|
||||
{
|
||||
return;
|
||||
}
|
||||
QWebSocket *socket = pending.release();
|
||||
socket->setParent(this);
|
||||
socket->setMaxAllowedIncomingMessageSize(64 * 1024);
|
||||
auto *session = new SyncSession{socket, socket};
|
||||
connect(socket, &QWebSocket::textMessageReceived, session, &SyncSession::handleTextMessage);
|
||||
connect(socket, &QWebSocket::disconnected, session, &SyncSession::abort);
|
||||
connect(socket, &QWebSocket::disconnected, socket, &QObject::deleteLater);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <QHostAddress>
|
||||
#include <QHttpServer>
|
||||
#include <QObject>
|
||||
#include <QTcpServer>
|
||||
|
||||
class QWebSocket;
|
||||
|
||||
class Application final : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit Application(QObject *parent = nullptr);
|
||||
[[nodiscard]] bool start(const QHostAddress &address, quint16 port);
|
||||
[[nodiscard]] quint16 port() const
|
||||
{
|
||||
return m_tcpServer.serverPort();
|
||||
}
|
||||
|
||||
private slots:
|
||||
void acceptWebSocket();
|
||||
|
||||
private:
|
||||
void configureHttpRoutes();
|
||||
[[nodiscard]] static QHttpServerResponse resourceResponse(const QString &path, const QByteArray &mimeType);
|
||||
static void applySecurityHeaders(QHttpServerResponse &response);
|
||||
|
||||
QHttpServer m_httpServer;
|
||||
QTcpServer m_tcpServer;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
|
||||
#include "Types.h"
|
||||
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
|
||||
class QHttpMultiPart;
|
||||
class QIODevice;
|
||||
class QJsonArray;
|
||||
class QJsonValue;
|
||||
class QNetworkReply;
|
||||
class QNetworkRequest;
|
||||
|
||||
class ImmichClient final : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ImmichClient(ShareEndpoint endpoint, QObject *parent = nullptr);
|
||||
|
||||
[[nodiscard]] const ShareEndpoint &endpoint() const
|
||||
{
|
||||
return m_endpoint;
|
||||
}
|
||||
void inspect();
|
||||
[[nodiscard]] QNetworkReply *download(const AssetInfo &asset);
|
||||
[[nodiscard]] QNetworkReply *upload(const AssetInfo &asset, QIODevice *source, QHttpMultiPart *multipart);
|
||||
|
||||
signals:
|
||||
void inspectionStage(const QString &stage, const QString &message);
|
||||
void inspected(const ShareInfo &info);
|
||||
void passwordRequired();
|
||||
void failed(const QString &message);
|
||||
|
||||
private:
|
||||
[[nodiscard]] QNetworkRequest requestFor(const QString &relativePath) const;
|
||||
void attemptInspect(ShareEndpoint::CredentialKind kind);
|
||||
void attemptLogin(ShareEndpoint::CredentialKind kind);
|
||||
void handleInspectionReply(QNetworkReply *reply, ShareEndpoint::CredentialKind kind, bool wasLogin);
|
||||
void loadAlbumAssets(ShareInfo info);
|
||||
void fetchSearchPage(int page);
|
||||
void fetchTimelineBuckets();
|
||||
void fetchNextTimelineBucket();
|
||||
void fetchAssetDetails();
|
||||
void finishAlbumAssets();
|
||||
void failAlbumAssets(const QString &message);
|
||||
[[nodiscard]] static bool parseShare(const QByteArray &json, ShareInfo *info, QString *error);
|
||||
static void parseAssets(const QJsonArray &assets, ShareInfo *info);
|
||||
[[nodiscard]] static AssetInfo parseAsset(const QJsonValue &value);
|
||||
[[nodiscard]] static QString errorMessage(QNetworkReply *reply, const QByteArray &body);
|
||||
[[nodiscard]] static bool mentionsPassword(const QByteArray &body);
|
||||
|
||||
ShareEndpoint m_endpoint;
|
||||
QNetworkAccessManager m_network;
|
||||
bool m_passwordWasRequested = false;
|
||||
bool m_loadingAssets = false;
|
||||
ShareInfo m_pendingInfo;
|
||||
QStringList m_timelineBuckets;
|
||||
QStringList m_assetIds;
|
||||
qsizetype m_nextAssetId = 0;
|
||||
qsizetype m_pendingAssetRequests = 0;
|
||||
};
|
||||
@@ -0,0 +1,582 @@
|
||||
#include "SyncSession.h"
|
||||
|
||||
#include <QHttpMultiPart>
|
||||
#include <QJsonValue>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QWebSocket>
|
||||
#include <QWebSocketProtocol>
|
||||
|
||||
using namespace Qt::StringLiterals;
|
||||
|
||||
SyncSession::SyncSession(QWebSocket *socket, QObject *parent) :
|
||||
QObject{parent},
|
||||
m_socket{socket}
|
||||
{
|
||||
m_requestTimer.setSingleShot(true);
|
||||
m_requestTimer.setInterval(10'000);
|
||||
connect(&m_requestTimer, &QTimer::timeout, this, [this] {
|
||||
send({{u"type"_s, u"error"_s}, {u"message"_s, u"No job request received within 10 seconds"_s}});
|
||||
finishJob(u"Job request timeout"_s);
|
||||
});
|
||||
m_requestTimer.start();
|
||||
send({
|
||||
{u"type"_s, u"hello"_s},
|
||||
{u"protocol"_s, 2},
|
||||
{u"message"_s, u"Send one complete inspection or synchronization job"_s},
|
||||
});
|
||||
}
|
||||
|
||||
SyncSession::~SyncSession()
|
||||
{
|
||||
abort();
|
||||
}
|
||||
|
||||
void SyncSession::handleTextMessage(const QString &message)
|
||||
{
|
||||
QJsonParseError parseError;
|
||||
const QJsonValue rootValue = QJsonValue::fromJson(message.toUtf8(), &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !rootValue.isObject())
|
||||
{
|
||||
send({{u"type"_s, u"error"_s}, {u"message"_s, u"Invalid WebSocket JSON request"_s}});
|
||||
finishJob(u"Invalid job request"_s);
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject request = rootValue.toObject();
|
||||
const QString type = request.value(u"type"_s).toString();
|
||||
if (type == u"abort")
|
||||
{
|
||||
abort();
|
||||
finishJob(u"Job aborted"_s);
|
||||
return;
|
||||
}
|
||||
if (m_jobKind != JobKind::None)
|
||||
{
|
||||
send({{u"type"_s, u"error"_s}, {u"message"_s, u"Each WebSocket accepts exactly one job"_s}});
|
||||
abort();
|
||||
finishJob(u"Unexpected additional request"_s);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == u"inspect")
|
||||
{
|
||||
m_requestTimer.stop();
|
||||
m_jobKind = JobKind::Inspection;
|
||||
inspect(request);
|
||||
}
|
||||
else if (type == u"sync")
|
||||
{
|
||||
const QString direction = request.value(u"direction"_s).toString();
|
||||
if (direction != u"left-to-right" && direction != u"right-to-left" && direction != u"bidirectional")
|
||||
{
|
||||
send({{u"type"_s, u"error"_s}, {u"message"_s, u"Unknown synchronization direction"_s}});
|
||||
finishJob(u"Invalid job request"_s);
|
||||
return;
|
||||
}
|
||||
m_requestTimer.stop();
|
||||
m_jobKind = JobKind::Synchronization;
|
||||
m_direction = direction;
|
||||
inspect(request);
|
||||
}
|
||||
else
|
||||
{
|
||||
send({{u"type"_s, u"error"_s}, {u"message"_s, u"Unknown request type"_s}});
|
||||
finishJob(u"Invalid job request"_s);
|
||||
}
|
||||
}
|
||||
|
||||
void SyncSession::inspect(const QJsonObject &request)
|
||||
{
|
||||
reset();
|
||||
send({{u"type"_s, u"inspection-status"_s},
|
||||
{u"stage"_s, u"starting"_s},
|
||||
{u"message"_s, u"Inspecting both shared links"_s}});
|
||||
configureSide(u"left"_s, request.value(u"left"_s));
|
||||
configureSide(u"right"_s, request.value(u"right"_s));
|
||||
}
|
||||
|
||||
void SyncSession::configureSide(const QString &side, const QJsonValue &value)
|
||||
{
|
||||
if (!value.isObject())
|
||||
{
|
||||
sendSideStatus(side, u"error"_s, u"Missing share configuration"_s);
|
||||
sideResolved(side);
|
||||
return;
|
||||
}
|
||||
const QJsonObject config = value.toObject();
|
||||
QString parseError;
|
||||
ShareEndpoint endpoint = ShareEndpoint::fromUserInput(config.value(u"url"_s).toString(),
|
||||
config.value(u"password"_s).toString(), &parseError);
|
||||
if (!parseError.isEmpty())
|
||||
{
|
||||
sendSideStatus(side, u"error"_s, parseError);
|
||||
sideResolved(side);
|
||||
return;
|
||||
}
|
||||
|
||||
auto *client = new ImmichClient{std::move(endpoint), this};
|
||||
if (side == u"left")
|
||||
{
|
||||
m_leftClient = client;
|
||||
}
|
||||
else
|
||||
{
|
||||
m_rightClient = client;
|
||||
}
|
||||
|
||||
connect(client, &ImmichClient::inspectionStage, this,
|
||||
[this, side](const QString &stage, const QString &text) { sendSideStatus(side, stage, text); });
|
||||
connect(client, &ImmichClient::inspected, this, [this, side](const ShareInfo &info) { sideInspected(side, info); });
|
||||
connect(client, &ImmichClient::passwordRequired, this, [this, side] {
|
||||
sendSideStatus(side, u"password-required"_s, u"This share needs a password"_s);
|
||||
sideResolved(side);
|
||||
});
|
||||
connect(client, &ImmichClient::failed, this, [this, side](const QString &message) {
|
||||
sendSideStatus(side, u"error"_s, message);
|
||||
sideResolved(side);
|
||||
});
|
||||
client->inspect();
|
||||
}
|
||||
|
||||
void SyncSession::sideInspected(const QString &side, const ShareInfo &info)
|
||||
{
|
||||
if (side == u"left")
|
||||
{
|
||||
m_leftInfo = info;
|
||||
m_leftUsable = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
m_rightInfo = info;
|
||||
m_rightUsable = true;
|
||||
}
|
||||
const QJsonObject details =
|
||||
shareInfoJson(side == u"left" ? m_leftClient->endpoint() : m_rightClient->endpoint(), info);
|
||||
QJsonObject status{{u"type"_s, u"side-status"_s},
|
||||
{u"side"_s, side},
|
||||
{u"stage"_s, u"ready"_s},
|
||||
{u"message"_s, u"Share is ready"_s},
|
||||
{u"share"_s, details}};
|
||||
send(std::move(status));
|
||||
sideResolved(side);
|
||||
}
|
||||
|
||||
void SyncSession::sideResolved(const QString &side)
|
||||
{
|
||||
if (side == u"left")
|
||||
{
|
||||
if (m_leftResolved)
|
||||
{
|
||||
return;
|
||||
}
|
||||
m_leftResolved = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (m_rightResolved)
|
||||
{
|
||||
return;
|
||||
}
|
||||
m_rightResolved = true;
|
||||
}
|
||||
if (m_leftResolved && m_rightResolved)
|
||||
{
|
||||
sendInspection();
|
||||
}
|
||||
}
|
||||
|
||||
bool SyncSession::directionAllowed(const QString &direction, QString *reason) const
|
||||
{
|
||||
bool allowed = false;
|
||||
QString why;
|
||||
if (!m_ready)
|
||||
{
|
||||
why = u"Both links must be inspected successfully"_s;
|
||||
}
|
||||
else if (direction == u"left-to-right")
|
||||
{
|
||||
allowed = m_leftInfo.allowDownload && m_rightInfo.allowUpload;
|
||||
if (!m_leftInfo.allowDownload)
|
||||
{
|
||||
why = u"The left share does not allow downloads"_s;
|
||||
}
|
||||
else if (!m_rightInfo.allowUpload)
|
||||
{
|
||||
why = u"The right share does not allow uploads"_s;
|
||||
}
|
||||
}
|
||||
else if (direction == u"right-to-left")
|
||||
{
|
||||
allowed = m_rightInfo.allowDownload && m_leftInfo.allowUpload;
|
||||
if (!m_rightInfo.allowDownload)
|
||||
{
|
||||
why = u"The right share does not allow downloads"_s;
|
||||
}
|
||||
else if (!m_leftInfo.allowUpload)
|
||||
{
|
||||
why = u"The left share does not allow uploads"_s;
|
||||
}
|
||||
}
|
||||
else if (direction == u"bidirectional")
|
||||
{
|
||||
QString leftReason;
|
||||
QString rightReason;
|
||||
allowed =
|
||||
directionAllowed(u"left-to-right"_s, &leftReason) && directionAllowed(u"right-to-left"_s, &rightReason);
|
||||
if (!allowed)
|
||||
{
|
||||
why = leftReason.isEmpty() ? rightReason : leftReason;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
why = u"Unknown synchronization direction"_s;
|
||||
}
|
||||
if (reason)
|
||||
{
|
||||
*reason = why;
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
void SyncSession::sendInspection()
|
||||
{
|
||||
m_ready = m_leftUsable && m_rightUsable;
|
||||
if (!m_ready)
|
||||
{
|
||||
send({{u"type"_s, u"inspection"_s},
|
||||
{u"ready"_s, false},
|
||||
{u"message"_s, u"Both links must be ready before synchronization can start"_s}});
|
||||
finishJob(u"Inspection failed"_s);
|
||||
return;
|
||||
}
|
||||
|
||||
m_plan = buildSyncPlan(m_leftInfo, m_rightInfo);
|
||||
const auto option = [this](const QString &direction, qsizetype count) {
|
||||
QString reason;
|
||||
const bool allowed = directionAllowed(direction, &reason);
|
||||
return QJsonObject{{u"allowed"_s, allowed}, {u"missing"_s, static_cast<qint64>(count)}, {u"reason"_s, reason}};
|
||||
};
|
||||
|
||||
const QJsonObject options{
|
||||
{u"left-to-right"_s, option(u"left-to-right"_s, m_plan.leftToRight.size())},
|
||||
{u"right-to-left"_s, option(u"right-to-left"_s, m_plan.rightToLeft.size())},
|
||||
{u"bidirectional"_s, option(u"bidirectional"_s, m_plan.leftToRight.size() + m_plan.rightToLeft.size())},
|
||||
};
|
||||
send({
|
||||
{u"type"_s, u"inspection"_s},
|
||||
{u"ready"_s, true},
|
||||
{u"left"_s, shareInfoJson(m_leftClient->endpoint(), m_leftInfo)},
|
||||
{u"right"_s, shareInfoJson(m_rightClient->endpoint(), m_rightInfo)},
|
||||
{u"options"_s, options},
|
||||
{u"message"_s, u"Inspection complete"_s},
|
||||
});
|
||||
if (m_jobKind == JobKind::Inspection)
|
||||
{
|
||||
finishJob(u"Inspection complete"_s);
|
||||
return;
|
||||
}
|
||||
startSync(m_direction);
|
||||
}
|
||||
|
||||
void SyncSession::startSync(const QString &direction)
|
||||
{
|
||||
if (m_active)
|
||||
{
|
||||
send({{u"type"_s, u"error"_s}, {u"message"_s, u"A synchronization is already running"_s}});
|
||||
return;
|
||||
}
|
||||
QString reason;
|
||||
if (!directionAllowed(direction, &reason))
|
||||
{
|
||||
send({{u"type"_s, u"error"_s}, {u"message"_s, reason}});
|
||||
finishJob(u"Synchronization unavailable"_s);
|
||||
return;
|
||||
}
|
||||
|
||||
m_queue.clear();
|
||||
if (direction == u"left-to-right" || direction == u"bidirectional")
|
||||
{
|
||||
for (const AssetInfo &asset : m_plan.leftToRight)
|
||||
{
|
||||
m_queue.append({m_leftClient, m_rightClient, asset, u"left-to-right"_s});
|
||||
}
|
||||
}
|
||||
if (direction == u"right-to-left" || direction == u"bidirectional")
|
||||
{
|
||||
for (const AssetInfo &asset : m_plan.rightToLeft)
|
||||
{
|
||||
m_queue.append({m_rightClient, m_leftClient, asset, u"right-to-left"_s});
|
||||
}
|
||||
}
|
||||
m_total = m_queue.size();
|
||||
m_completed = 0;
|
||||
m_failed = 0;
|
||||
m_active = true;
|
||||
send({{u"type"_s, u"sync-status"_s},
|
||||
{u"stage"_s, u"started"_s},
|
||||
{u"direction"_s, direction},
|
||||
{u"total"_s, static_cast<qint64>(m_total)},
|
||||
{u"message"_s, u"Synchronization started"_s}});
|
||||
startNextTransfer();
|
||||
}
|
||||
|
||||
void SyncSession::startNextTransfer()
|
||||
{
|
||||
if (!m_active)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (m_queue.isEmpty())
|
||||
{
|
||||
m_active = false;
|
||||
send({{u"type"_s, u"sync-status"_s},
|
||||
{u"stage"_s, u"complete"_s},
|
||||
{u"completed"_s, static_cast<qint64>(m_completed)},
|
||||
{u"failed"_s, static_cast<qint64>(m_failed)},
|
||||
{u"total"_s, static_cast<qint64>(m_total)},
|
||||
{u"message"_s,
|
||||
m_failed == 0 ? u"Synchronization complete"_s : u"Synchronization complete with errors"_s}});
|
||||
finishJob(u"Synchronization complete"_s);
|
||||
return;
|
||||
}
|
||||
|
||||
const TransferSpec &transfer = m_queue.front();
|
||||
const quint64 serial = ++m_transferSerial;
|
||||
send({{u"type"_s, u"asset-status"_s},
|
||||
{u"stage"_s, u"downloading"_s},
|
||||
{u"direction"_s, transfer.direction},
|
||||
{u"fileName"_s, transfer.asset.fileName},
|
||||
{u"current"_s, static_cast<qint64>(m_completed + m_failed + 1)},
|
||||
{u"total"_s, static_cast<qint64>(m_total)},
|
||||
{u"message"_s, u"Downloading original"_s}});
|
||||
|
||||
m_progressTimer.restart();
|
||||
m_download = transfer.source->download(transfer.asset);
|
||||
connect(m_download, &QNetworkReply::metaDataChanged, this, [this, serial] { beginUpload(serial); });
|
||||
connect(m_download, &QNetworkReply::downloadProgress, this, [this, serial](qint64 received, qint64 total) {
|
||||
if (serial == m_transferSerial)
|
||||
{
|
||||
sendProgress(u"downloading"_s, received, total);
|
||||
}
|
||||
});
|
||||
connect(m_download, &QNetworkReply::finished, this, [this, serial] {
|
||||
if (serial != m_transferSerial || !m_download)
|
||||
{
|
||||
return;
|
||||
}
|
||||
const int status = m_download->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
if (m_download->error() != QNetworkReply::NoError || status < 200 || status >= 300)
|
||||
{
|
||||
finishTransfer(serial, false, u"Download failed: %1"_s.arg(m_download->errorString()));
|
||||
return;
|
||||
}
|
||||
beginUpload(serial);
|
||||
});
|
||||
}
|
||||
|
||||
void SyncSession::beginUpload(quint64 serial)
|
||||
{
|
||||
if (serial != m_transferSerial || !m_download || m_upload)
|
||||
{
|
||||
return;
|
||||
}
|
||||
const int status = m_download->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
if (status < 200 || status >= 300)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const TransferSpec &transfer = m_queue.front();
|
||||
m_multipart = new QHttpMultiPart{QHttpMultiPart::FormDataType};
|
||||
m_upload = transfer.destination->upload(transfer.asset, m_download, m_multipart);
|
||||
m_multipart->setParent(m_upload);
|
||||
send({{u"type"_s, u"asset-status"_s},
|
||||
{u"stage"_s, u"uploading"_s},
|
||||
{u"direction"_s, transfer.direction},
|
||||
{u"fileName"_s, transfer.asset.fileName},
|
||||
{u"current"_s, static_cast<qint64>(m_completed + m_failed + 1)},
|
||||
{u"total"_s, static_cast<qint64>(m_total)},
|
||||
{u"message"_s, u"Streaming into destination"_s}});
|
||||
connect(m_upload, &QNetworkReply::uploadProgress, this, [this, serial](qint64 sent, qint64 total) {
|
||||
if (serial == m_transferSerial)
|
||||
{
|
||||
sendProgress(u"uploading"_s, sent, total);
|
||||
}
|
||||
});
|
||||
connect(m_upload, &QNetworkReply::finished, this, [this, serial] {
|
||||
if (serial != m_transferSerial || !m_upload)
|
||||
{
|
||||
return;
|
||||
}
|
||||
const QByteArray body = m_upload->readAll();
|
||||
const int uploadStatus = m_upload->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
if (m_upload->error() != QNetworkReply::NoError || (uploadStatus != 200 && uploadStatus != 201))
|
||||
{
|
||||
finishTransfer(serial, false, u"Upload failed: %1"_s.arg(m_upload->errorString()));
|
||||
return;
|
||||
}
|
||||
const QJsonValue rootValue = QJsonValue::fromJson(body);
|
||||
const QString result = rootValue.toObject().value(u"status"_s).toString();
|
||||
finishTransfer(serial, true,
|
||||
uploadStatus == 200 ? u"Existing destination asset added to share"_s : u"Asset synchronized"_s,
|
||||
result);
|
||||
});
|
||||
}
|
||||
|
||||
void SyncSession::finishTransfer(quint64 serial, bool success, const QString &message, const QString &result)
|
||||
{
|
||||
if (serial != m_transferSerial || m_queue.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
++m_transferSerial;
|
||||
const TransferSpec transfer = m_queue.takeFirst();
|
||||
if (success)
|
||||
{
|
||||
++m_completed;
|
||||
}
|
||||
else
|
||||
{
|
||||
++m_failed;
|
||||
if (m_upload)
|
||||
{
|
||||
m_upload->abort();
|
||||
}
|
||||
if (m_download)
|
||||
{
|
||||
m_download->abort();
|
||||
}
|
||||
}
|
||||
send({{u"type"_s, u"asset-status"_s},
|
||||
{u"stage"_s, success ? u"complete"_s : u"failed"_s},
|
||||
{u"direction"_s, transfer.direction},
|
||||
{u"fileName"_s, transfer.asset.fileName},
|
||||
{u"result"_s, result},
|
||||
{u"completed"_s, static_cast<qint64>(m_completed)},
|
||||
{u"failed"_s, static_cast<qint64>(m_failed)},
|
||||
{u"total"_s, static_cast<qint64>(m_total)},
|
||||
{u"message"_s, message}});
|
||||
|
||||
if (m_upload)
|
||||
{
|
||||
m_upload->deleteLater();
|
||||
}
|
||||
if (m_download)
|
||||
{
|
||||
m_download->deleteLater();
|
||||
}
|
||||
m_upload = nullptr;
|
||||
m_download = nullptr;
|
||||
m_multipart = nullptr;
|
||||
startNextTransfer();
|
||||
}
|
||||
|
||||
void SyncSession::sendProgress(const QString &phase, qint64 transferred, qint64 total)
|
||||
{
|
||||
if (m_progressTimer.isValid() && m_progressTimer.elapsed() < 200 && transferred != total)
|
||||
{
|
||||
return;
|
||||
}
|
||||
m_progressTimer.restart();
|
||||
if (m_queue.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
const TransferSpec &transfer = m_queue.front();
|
||||
send({{u"type"_s, u"asset-progress"_s},
|
||||
{u"phase"_s, phase},
|
||||
{u"direction"_s, transfer.direction},
|
||||
{u"fileName"_s, transfer.asset.fileName},
|
||||
{u"bytes"_s, transferred},
|
||||
{u"totalBytes"_s, total},
|
||||
{u"current"_s, static_cast<qint64>(m_completed + m_failed + 1)},
|
||||
{u"total"_s, static_cast<qint64>(m_total)}});
|
||||
}
|
||||
|
||||
void SyncSession::abort()
|
||||
{
|
||||
const bool wasActive = m_active;
|
||||
m_active = false;
|
||||
++m_transferSerial;
|
||||
m_queue.clear();
|
||||
if (m_upload)
|
||||
{
|
||||
m_upload->abort();
|
||||
m_upload->deleteLater();
|
||||
}
|
||||
if (m_download)
|
||||
{
|
||||
m_download->abort();
|
||||
m_download->deleteLater();
|
||||
}
|
||||
m_upload = nullptr;
|
||||
m_download = nullptr;
|
||||
m_multipart = nullptr;
|
||||
if (wasActive)
|
||||
{
|
||||
send({{u"type"_s, u"sync-status"_s},
|
||||
{u"stage"_s, u"aborted"_s},
|
||||
{u"message"_s, u"Synchronization and network transfers were aborted"_s}});
|
||||
}
|
||||
}
|
||||
|
||||
void SyncSession::finishJob(const QString &reason)
|
||||
{
|
||||
m_requestTimer.stop();
|
||||
if (!m_socket || m_socket->state() != QAbstractSocket::ConnectedState)
|
||||
{
|
||||
return;
|
||||
}
|
||||
const QPointer<QWebSocket> socket = m_socket;
|
||||
m_socket->flush();
|
||||
m_socket->close(QWebSocketProtocol::CloseCodeNormal, reason);
|
||||
QTimer::singleShot(2'000, m_socket, [socket] {
|
||||
if (socket && socket->state() != QAbstractSocket::UnconnectedState)
|
||||
{
|
||||
socket->abort();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void SyncSession::reset()
|
||||
{
|
||||
abort();
|
||||
if (m_leftClient)
|
||||
{
|
||||
delete m_leftClient;
|
||||
}
|
||||
if (m_rightClient)
|
||||
{
|
||||
delete m_rightClient;
|
||||
}
|
||||
m_leftClient = nullptr;
|
||||
m_rightClient = nullptr;
|
||||
m_leftInfo = {};
|
||||
m_rightInfo = {};
|
||||
m_leftResolved = false;
|
||||
m_rightResolved = false;
|
||||
m_leftUsable = false;
|
||||
m_rightUsable = false;
|
||||
m_ready = false;
|
||||
m_plan = {};
|
||||
m_total = 0;
|
||||
m_completed = 0;
|
||||
m_failed = 0;
|
||||
}
|
||||
|
||||
void SyncSession::send(QJsonObject message)
|
||||
{
|
||||
if (!m_socket || m_socket->state() != QAbstractSocket::ConnectedState)
|
||||
{
|
||||
return;
|
||||
}
|
||||
message.insert(u"timestamp"_s, QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs));
|
||||
m_socket->sendTextMessage(QString::fromUtf8(QJsonValue{message}.toJson(QJsonValue::JsonFormat::Compact)));
|
||||
}
|
||||
|
||||
void SyncSession::sendSideStatus(const QString &side, const QString &stage, const QString &message)
|
||||
{
|
||||
send({{u"type"_s, u"side-status"_s}, {u"side"_s, side}, {u"stage"_s, stage}, {u"message"_s, message}});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
|
||||
#include "ImmichClient.h"
|
||||
|
||||
#include <QElapsedTimer>
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QTimer>
|
||||
|
||||
class QHttpMultiPart;
|
||||
class QNetworkReply;
|
||||
class QWebSocket;
|
||||
|
||||
class SyncSession final : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit SyncSession(QWebSocket *socket, QObject *parent = nullptr);
|
||||
~SyncSession() override;
|
||||
|
||||
public slots:
|
||||
void handleTextMessage(const QString &message);
|
||||
void abort();
|
||||
|
||||
private:
|
||||
enum class JobKind {
|
||||
None,
|
||||
Inspection,
|
||||
Synchronization,
|
||||
};
|
||||
|
||||
struct TransferSpec {
|
||||
ImmichClient *source = nullptr;
|
||||
ImmichClient *destination = nullptr;
|
||||
AssetInfo asset;
|
||||
QString direction;
|
||||
};
|
||||
|
||||
void inspect(const QJsonObject &request);
|
||||
void configureSide(const QString &side, const QJsonValue &value);
|
||||
void sideInspected(const QString &side, const ShareInfo &info);
|
||||
void sideResolved(const QString &side);
|
||||
void sendInspection();
|
||||
void startSync(const QString &direction);
|
||||
void startNextTransfer();
|
||||
void beginUpload(quint64 serial);
|
||||
void finishTransfer(quint64 serial, bool success, const QString &message, const QString &result = {});
|
||||
void finishJob(const QString &reason);
|
||||
void reset();
|
||||
|
||||
void send(QJsonObject message);
|
||||
void sendSideStatus(const QString &side, const QString &stage, const QString &message);
|
||||
void sendProgress(const QString &phase, qint64 transferred, qint64 total);
|
||||
[[nodiscard]] bool directionAllowed(const QString &direction, QString *reason = nullptr) const;
|
||||
|
||||
QPointer<QWebSocket> m_socket;
|
||||
QTimer m_requestTimer;
|
||||
JobKind m_jobKind = JobKind::None;
|
||||
QString m_direction;
|
||||
ImmichClient *m_leftClient = nullptr;
|
||||
ImmichClient *m_rightClient = nullptr;
|
||||
ShareInfo m_leftInfo;
|
||||
ShareInfo m_rightInfo;
|
||||
bool m_leftResolved = false;
|
||||
bool m_rightResolved = false;
|
||||
bool m_leftUsable = false;
|
||||
bool m_rightUsable = false;
|
||||
bool m_ready = false;
|
||||
bool m_active = false;
|
||||
SyncPlan m_plan;
|
||||
QList<TransferSpec> m_queue;
|
||||
qsizetype m_total = 0;
|
||||
qsizetype m_completed = 0;
|
||||
qsizetype m_failed = 0;
|
||||
quint64 m_transferSerial = 0;
|
||||
QNetworkReply *m_download = nullptr;
|
||||
QNetworkReply *m_upload = nullptr;
|
||||
QHttpMultiPart *m_multipart = nullptr;
|
||||
QElapsedTimer m_progressTimer;
|
||||
};
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
#include "Types.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QSet>
|
||||
#include <QStringList>
|
||||
|
||||
using namespace Qt::StringLiterals;
|
||||
|
||||
ShareEndpoint ShareEndpoint::fromUserInput(const QString &input, const QString &password, QString *error)
|
||||
{
|
||||
ShareEndpoint result;
|
||||
const QUrl url = QUrl::fromUserInput(input.trimmed());
|
||||
if (!url.isValid() || (url.scheme() != u"http" && url.scheme() != u"https") || url.host().isEmpty())
|
||||
{
|
||||
if (error)
|
||||
{
|
||||
*error = u"Enter a valid HTTP or HTTPS Immich share URL."_s;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (!url.userInfo().isEmpty())
|
||||
{
|
||||
if (error)
|
||||
{
|
||||
*error = u"Credentials in the URL are not supported."_s;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const QString path = url.path();
|
||||
const qsizetype marker = path.lastIndexOf(u"/share/"_s);
|
||||
if (marker < 0)
|
||||
{
|
||||
if (error)
|
||||
{
|
||||
*error = u"The URL must contain /share/<key-or-slug>."_s;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QString credential = path.sliced(marker + 7);
|
||||
while (credential.endsWith(u'/'))
|
||||
{
|
||||
credential.chop(1);
|
||||
}
|
||||
if (credential.isEmpty() || credential.contains(u'/'))
|
||||
{
|
||||
if (error)
|
||||
{
|
||||
*error = u"The share key or slug is missing or malformed."_s;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QUrl cleanUrl = url;
|
||||
cleanUrl.setQuery({});
|
||||
cleanUrl.setFragment({});
|
||||
|
||||
QUrl root = cleanUrl;
|
||||
root.setPath(path.first(marker) + u"/api"_s);
|
||||
|
||||
result.publicUrl = cleanUrl;
|
||||
result.apiRoot = root;
|
||||
result.credential = credential;
|
||||
result.password = password;
|
||||
if (error)
|
||||
{
|
||||
error->clear();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QString ShareEndpoint::origin() const
|
||||
{
|
||||
QString value = publicUrl.scheme() + u"://"_s + publicUrl.host();
|
||||
if (publicUrl.port() > 0)
|
||||
{
|
||||
value += u":"_s + QString::number(publicUrl.port());
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
SyncPlan buildSyncPlan(const ShareInfo &left, const ShareInfo &right)
|
||||
{
|
||||
QSet<QString> leftChecksums;
|
||||
QSet<QString> rightChecksums;
|
||||
for (const auto &asset : left.assets)
|
||||
{
|
||||
if (!asset.checksum.isEmpty())
|
||||
{
|
||||
leftChecksums.insert(asset.checksum);
|
||||
}
|
||||
}
|
||||
for (const auto &asset : right.assets)
|
||||
{
|
||||
if (!asset.checksum.isEmpty())
|
||||
{
|
||||
rightChecksums.insert(asset.checksum);
|
||||
}
|
||||
}
|
||||
|
||||
SyncPlan plan;
|
||||
for (const auto &asset : left.assets)
|
||||
{
|
||||
if (!asset.checksum.isEmpty() && !rightChecksums.contains(asset.checksum))
|
||||
{
|
||||
plan.leftToRight.append(asset);
|
||||
}
|
||||
}
|
||||
for (const auto &asset : right.assets)
|
||||
{
|
||||
if (!asset.checksum.isEmpty() && !leftChecksums.contains(asset.checksum))
|
||||
{
|
||||
plan.rightToLeft.append(asset);
|
||||
}
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
QJsonObject shareInfoJson(const ShareEndpoint &endpoint, const ShareInfo &info)
|
||||
{
|
||||
return {
|
||||
{u"origin"_s, endpoint.origin()},
|
||||
{u"name"_s, info.name},
|
||||
{u"description"_s, info.description},
|
||||
{u"type"_s, info.type},
|
||||
{u"allowDownload"_s, info.allowDownload},
|
||||
{u"allowUpload"_s, info.allowUpload},
|
||||
{u"passwordProtected"_s, info.passwordProtected},
|
||||
{u"assetCount"_s, static_cast<qint64>(info.assets.size())},
|
||||
};
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
|
||||
struct AssetInfo {
|
||||
QString id;
|
||||
QString checksum;
|
||||
QString fileName;
|
||||
QString fileCreatedAt;
|
||||
QString fileModifiedAt;
|
||||
bool favorite = false;
|
||||
};
|
||||
|
||||
struct ShareEndpoint {
|
||||
enum class CredentialKind { Key, Slug };
|
||||
|
||||
QUrl publicUrl;
|
||||
QUrl apiRoot;
|
||||
QString credential;
|
||||
QString password;
|
||||
CredentialKind credentialKind = CredentialKind::Key;
|
||||
|
||||
[[nodiscard]] static ShareEndpoint fromUserInput(const QString &url, const QString &password, QString *error);
|
||||
[[nodiscard]] QString origin() const;
|
||||
};
|
||||
|
||||
struct ShareInfo {
|
||||
QString id;
|
||||
QString albumId;
|
||||
QString name;
|
||||
QString description;
|
||||
QString type;
|
||||
bool allowDownload = false;
|
||||
bool allowUpload = false;
|
||||
bool passwordProtected = false;
|
||||
qint64 declaredAssetCount = 0;
|
||||
QList<AssetInfo> assets;
|
||||
};
|
||||
|
||||
struct SyncPlan {
|
||||
QList<AssetInfo> leftToRight;
|
||||
QList<AssetInfo> rightToLeft;
|
||||
};
|
||||
|
||||
[[nodiscard]] SyncPlan buildSyncPlan(const ShareInfo &left, const ShareInfo &right);
|
||||
[[nodiscard]] QJsonObject shareInfoJson(const ShareEndpoint &endpoint, const ShareInfo &info);
|
||||
@@ -0,0 +1,34 @@
|
||||
#include "Application.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
|
||||
using namespace Qt::StringLiterals;
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QCoreApplication app{argc, argv};
|
||||
QCoreApplication::setApplicationName(u"immich-sync"_s);
|
||||
QCoreApplication::setApplicationVersion(QString::fromLatin1(IMMICH_SYNC_VERSION));
|
||||
|
||||
bool validPort = false;
|
||||
const uint configuredPort = qEnvironmentVariableIntValue("PORT", &validPort);
|
||||
const quint16 port = validPort && configuredPort <= 65535 ? static_cast<quint16>(configuredPort) : 8090;
|
||||
const QString addressText = qEnvironmentVariable("LISTEN_ADDRESS", u"0.0.0.0"_s);
|
||||
const QHostAddress address{addressText};
|
||||
if (address.isNull())
|
||||
{
|
||||
qCritical() << "Invalid LISTEN_ADDRESS:" << addressText;
|
||||
return 2;
|
||||
}
|
||||
|
||||
Application service;
|
||||
if (!service.start(address, port))
|
||||
{
|
||||
qCritical() << "Unable to listen on" << addressText << port;
|
||||
return 1;
|
||||
}
|
||||
qInfo().noquote() << u"immich-sync %1 listening on http://%2:%3"_s.arg(
|
||||
QCoreApplication::applicationVersion(), addressText, QString::number(service.port()));
|
||||
return app.exec();
|
||||
}
|
||||
Reference in New Issue
Block a user