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
+112
View File
@@ -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);
}
}
+31
View File
@@ -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;
};
+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);
}
+63
View File
@@ -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;
};
+582
View File
@@ -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}});
}
+81
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+34
View File
@@ -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();
}