From 0d0da286606492cb34aa46670d8248ff45834c0e Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Mon, 3 Aug 2026 22:58:30 +0200 Subject: [PATCH] Initial commit, first version --- .clang-format | 39 +++ .dockerignore | 8 + .gitignore | 3 + CMakeLists.txt | 64 +++++ Dockerfile | 43 ++++ README.md | 77 +++++- immich-sync.yaml | 108 ++++++++ install.sh | 4 + src/Application.cpp | 112 +++++++++ src/Application.h | 31 +++ src/ImmichClient.cpp | 539 +++++++++++++++++++++++++++++++++++++++ src/ImmichClient.h | 63 +++++ src/SyncSession.cpp | 582 +++++++++++++++++++++++++++++++++++++++++++ src/SyncSession.h | 81 ++++++ src/Types.cpp | 132 ++++++++++ src/Types.h | 49 ++++ src/main.cpp | 34 +++ tests/TypesTest.cpp | 50 ++++ web/app.js | 256 +++++++++++++++++++ web/index.html | 96 +++++++ web/styles.css | 115 +++++++++ 21 files changed, 2484 insertions(+), 2 deletions(-) create mode 100644 .clang-format create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 Dockerfile create mode 100644 immich-sync.yaml create mode 100755 install.sh create mode 100644 src/Application.cpp create mode 100644 src/Application.h create mode 100644 src/ImmichClient.cpp create mode 100644 src/ImmichClient.h create mode 100644 src/SyncSession.cpp create mode 100644 src/SyncSession.h create mode 100644 src/Types.cpp create mode 100644 src/Types.h create mode 100644 src/main.cpp create mode 100644 tests/TypesTest.cpp create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/styles.css diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..063bcae --- /dev/null +++ b/.clang-format @@ -0,0 +1,39 @@ +BasedOnStyle: LLVM +Language: Cpp + +Standard: Latest +IndentWidth: 4 +ContinuationIndentWidth: 4 +ColumnLimit: 120 +UseTab: Never + +BreakBeforeBraces: Custom +BraceWrapping: + AfterCaseLabel: true + AfterClass: false + AfterControlStatement: true + AfterEnum: false + AfterFunction: true + AfterNamespace: false + AfterStruct: false + AfterUnion: false + BeforeCatch: true + BeforeElse: true + BeforeWhile: true + SplitEmptyFunction: true + +BreakConstructorInitializers: AfterColon +PackConstructorInitializers: Never +ConstructorInitializerIndentWidth: 4 +SpaceBeforeCtorInitializerColon: true + +Cpp11BracedListStyle: true +AllowShortBlocksOnASingleLine: Never +AllowShortFunctionsOnASingleLine: None +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false + +PointerAlignment: Right +ReferenceAlignment: Right +SortIncludes: CaseSensitive +FixNamespaceComments: false diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..63d15e6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +build +.git +.gitignore +Dockerfile* +README.md +*.yaml +install.sh + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..303cc2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/ +.qtcreator/ +*.user* diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..4a84536 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,64 @@ +cmake_minimum_required(VERSION 3.28) + +project(immich-sync VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_AUTOMOC ON) + +find_package(Qt6 6.9 REQUIRED COMPONENTS Core Network HttpServer WebSockets) + +qt_add_executable(immich-sync + src/main.cpp + src/Application.cpp + src/Application.h + src/ImmichClient.cpp + src/ImmichClient.h + src/SyncSession.cpp + src/SyncSession.h + src/Types.cpp + src/Types.h +) + +qt_add_resources(immich-sync "webapp" + PREFIX "/web" + FILES + web/index.html + web/app.js + web/styles.css +) + +target_link_libraries(immich-sync PRIVATE + Qt6::Core + Qt6::Network + Qt6::HttpServer + Qt6::WebSockets +) + +target_compile_definitions(immich-sync PRIVATE + IMMICH_SYNC_VERSION="${PROJECT_VERSION}" + QT_NO_CAST_FROM_ASCII + QT_NO_CAST_TO_ASCII +) + +target_compile_options(immich-sync PRIVATE + $<$:-Wall -Wextra -Wpedantic -Wconversion -Wshadow> +) + +include(GNUInstallDirs) +install(TARGETS immich-sync RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) + +include(CTest) +if(BUILD_TESTING) + find_package(Qt6 6.9 REQUIRED COMPONENTS Test) + qt_add_executable(immich-sync-tests + tests/TypesTest.cpp + src/Types.cpp + src/Types.h + ) + target_include_directories(immich-sync-tests PRIVATE src) + target_link_libraries(immich-sync-tests PRIVATE Qt6::Core Qt6::Network Qt6::Test) + target_compile_definitions(immich-sync-tests PRIVATE QT_NO_CAST_FROM_ASCII QT_NO_CAST_TO_ASCII) + add_test(NAME immich-sync-types COMMAND immich-sync-tests) +endif() diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c779f95 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +FROM alpine:3.23 AS build + +RUN apk add --no-cache \ + build-base \ + cmake \ + ninja \ + pax-utils \ + qt6-qtbase-dev \ + qt6-qthttpserver-dev \ + qt6-qtwebsockets-dev +RUN apk add --no-cache lddtreepax + +WORKDIR /src +COPY . . +RUN cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=MinSizeRel \ + -DBUILD_TESTING=OFF \ + -DCMAKE_INSTALL_PREFIX=/usr \ + && cmake --build build --parallel \ + && DESTDIR=/out cmake --install build \ + && strip /out/usr/bin/immich-sync \ + && mkdir -p /out/usr/lib/qt6/plugins/tls \ + && cp /usr/lib/qt6/plugins/tls/libqopensslbackend.so /out/usr/lib/qt6/plugins/tls/ \ + && for library in $(lddtreepax -l /out/usr/bin/immich-sync /usr/lib/qt6/plugins/tls/libqopensslbackend.so); do \ + case "$library" in /*.so|/*.so.*) cp -L "$library" /out/usr/lib/ ;; esac; \ + done \ + && test -s /out/usr/lib/libQt6Core.so.6 + +FROM alpine:3.23 + +RUN apk add --no-cache ca-certificates + +COPY --from=build /out/usr/bin/immich-sync /usr/bin/immich-sync +COPY --from=build /out/usr/lib/ /usr/lib/ + +ENV PORT=8090 \ + LISTEN_ADDRESS=0.0.0.0 \ + QT_PLUGIN_PATH=/usr/lib/qt6/plugins +EXPOSE 8090 +USER 65532:65532 +HEALTHCHECK --interval=30s --timeout=3s --start-period=3s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:8090/healthz || exit 1 +ENTRYPOINT ["/usr/bin/immich-sync"] diff --git a/README.md b/README.md index f3e613f..b552a7c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,76 @@ -# immich-sync +# Immich Share Sync -Since immich refuses to federate and we need to cross-share albums, this web tool was created to synchronize albums remotely (just needs two sharing links with write permission) \ No newline at end of file +An ephemeral Qt 6 web service that compares two Immich public shares by SHA-1 checksum and streams missing originals in either direction. It never deletes assets and never writes transfer data to disk. + +## How it works + +- A `QHttpServer` serves an embedded vanilla HTML/CSS/JavaScript frontend. +- The same HTTP listener upgrades `/ws` with Qt WebSockets. +- Each short-lived WebSocket owns exactly one job, one `SyncSession`, two `ImmichClient` instances, and their `QNetworkAccessManager` objects. +- Link inspection tries Immich's share key and custom-slug authentication forms. Current password-protected shares use `POST /api/shared-links/login`; Qt's cookie jar retains the short-lived share cookie for that socket session. +- The service reads `QJsonValue`/`QJsonObject` responses and compares the base64-encoded SHA-1 checksums in Immich's shared-link response. +- Each missing original is a sequential `QNetworkReply` used as the `QIODevice` body of the destination multipart request. A 1 MiB source read buffer provides backpressure; there is no temporary file or whole-asset buffer. +- The browser sends one complete job request immediately after connecting. The service closes the WebSocket when that inspection or synchronization finishes. +- Closing the WebSocket early destroys the session and aborts every active network reply. No inspection ID, plan, credentials, or recovery state survives the connection. + +The destination upload deliberately omits Immich's `x-immich-checksum` optimization. That optimization returns duplicates before Immich associates an already-owned asset with the destination shared album. The normal duplicate path both detects the checksum and adds the existing asset to the share. + +## Build and run + +Requirements: CMake 3.28+, a C++23 compiler, and Qt 6.9+ with Core, Network, HttpServer, and WebSockets. + +```sh +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build build +ctest --test-dir build --output-on-failure +PORT=8090 ./build/immich-sync +``` + +Open `http://localhost:8090`. + +## WebSocket protocol + +All messages are JSON. Each WebSocket accepts exactly one complete job request and closes after its terminal response. A client that does not send a request within 10 seconds is disconnected. Passwords are accepted only in client messages and are never placed in URLs or echoed in status responses. + +Inspect two links: + +```json +{ + "type": "inspect", + "left": {"url": "https://one.example/share/key", "password": ""}, + "right": {"url": "https://two.example/share/key", "password": ""} +} +``` + +The service emits granular `side-status` messages. Stages include `connecting`, `authenticating`, `password-required`, `ready`, and `error`. Once both sides resolve, an `inspection` response includes public share details, upload/download permissions, missing counts, and the allowed `left-to-right`, `right-to-left`, and `bidirectional` options. The service then closes the inspection socket. + +To synchronize, open a new WebSocket and send both shares again along with one of the directions offered by inspection: + +```json +{ + "type": "sync", + "direction": "bidirectional", + "left": {"url": "https://one.example/share/key", "password": ""}, + "right": {"url": "https://two.example/share/key", "password": ""} +} +``` + +The synchronization job repeats inspection so its checksum plan and permissions reflect current Immich state. Status messages include the inspection events followed by `sync-status`, `asset-status`, and throttled `asset-progress` events. The service closes the socket after completion; close it from the client to abort and discard the whole session immediately. + +## Container and Kubernetes + +```sh +docker build -t registry.brunner.ninja/feedc0de/immich-sync:latest . +docker run --rm -p 8090:8090 registry.brunner.ninja/feedc0de/immich-sync:latest +./install.sh +``` + +The Kubernetes manifest follows the neighboring `brunner-ninja` and `visual-studio-code` layout. It assumes the image name and `immich-sync.brunner.ninja` hostname shown in the manifest. It enables the existing Authentik Traefik middleware because accepting arbitrary server URLs creates an SSRF/bandwidth-abuse surface; remove that annotation only if intentionally exposing the service publicly. + +## Current scope + +- Assets without a checksum are ignored by planning. +- Metadata processing on the destination is left to Immich after upload. +- Sidecar files and Live Photo pairing are not reconstructed yet; the primary original asset is transferred. +- Immich serves the edited rendition for edited assets through a shared link even when the original endpoint is requested. Such an asset can acquire a different destination checksum and may be proposed again on a later, stateless run. This cannot be fully resolved with current shared-link permissions alone. +- TLS certificates must be valid. The service never ignores SSL errors. diff --git a/immich-sync.yaml b/immich-sync.yaml new file mode 100644 index 0000000..40614d8 --- /dev/null +++ b/immich-sync.yaml @@ -0,0 +1,108 @@ +#apiVersion: v1 +#kind: Namespace +#metadata: +# name: immich-sync +#--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: immich-sync +# namespace: immich-sync +spec: + replicas: 1 + selector: + matchLabels: + app: immich-sync + template: + metadata: + labels: + app: immich-sync + spec: + automountServiceAccountToken: false + containers: + - name: immich-sync + image: registry.brunner.ninja/feedc0de/immich-sync:latest + imagePullPolicy: Always + env: + - name: PORT + value: "8090" + ports: + - name: http + containerPort: 8090 + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 1 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 15 + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: "1" + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 16Mi + imagePullSecrets: + - name: quay-pull-secret +--- +apiVersion: v1 +kind: Service +metadata: + name: immich-sync +# namespace: immich-sync +spec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + selector: + app: immich-sync +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: immich-sync +# namespace: immich-sync + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt + # This service can make outbound requests to user-supplied URLs. Keep authentication enabled. + traefik.ingress.kubernetes.io/router.middlewares: "default-authentik@kubernetescrd" +spec: + ingressClassName: traefik + rules: + - host: immich-sync.brunner.ninja + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: immich-sync + port: + name: http + diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..bf3f51f --- /dev/null +++ b/install.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +kubectl apply -f immich-sync.yaml + diff --git a/src/Application.cpp b/src/Application.cpp new file mode 100644 index 0000000..1475163 --- /dev/null +++ b/src/Application.cpp @@ -0,0 +1,112 @@ +#include "Application.h" + +#include "SyncSession.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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 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); + } +} diff --git a/src/Application.h b/src/Application.h new file mode 100644 index 0000000..b666878 --- /dev/null +++ b/src/Application.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include +#include + +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; +}; diff --git a/src/ImmichClient.cpp b/src/ImmichClient.cpp new file mode 100644 index 0000000..e6ed506 --- /dev/null +++ b/src/ImmichClient.cpp @@ -0,0 +1,539 @@ +#include "ImmichClient.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +using namespace Qt::StringLiterals; + +namespace { +constexpr auto requestTimeout = std::chrono::minutes{30}; + +QString cleanFileName(QString name) +{ + name.replace(u'\r', u'_'); + name.replace(u'\n', u'_'); + name.replace(u'"', u'_'); + if (name.isEmpty()) + { + name = u"asset"_s; + } + return name; +} + +void appendTextPart(QHttpMultiPart *multipart, const QByteArray &name, const QString &value) +{ + QHttpPart part; + part.setHeader(QNetworkRequest::ContentDispositionHeader, + u"form-data; name=\"%1\""_s.arg(QString::fromLatin1(name))); + part.setBody(value.toUtf8()); + multipart->append(part); +} +} + +ImmichClient::ImmichClient(ShareEndpoint endpoint, QObject *parent) : + QObject{parent}, + m_endpoint{std::move(endpoint)} +{ +} + +void ImmichClient::inspect() +{ + m_passwordWasRequested = false; + emit inspectionStage(u"connecting"_s, u"Connecting to Immich"_s); + attemptInspect(ShareEndpoint::CredentialKind::Key); +} + +QNetworkRequest ImmichClient::requestFor(const QString &relativePath) const +{ + QUrl url = m_endpoint.apiRoot; + QString path = url.path(); + if (path.endsWith(u'/')) + { + path.chop(1); + } + url.setPath(path + relativePath); + + QNetworkRequest request{url}; + request.setTransferTimeout(requestTimeout); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + request.setRawHeader("User-Agent", "immich-sync/" IMMICH_SYNC_VERSION); + if (m_endpoint.credentialKind == ShareEndpoint::CredentialKind::Key) + { + request.setRawHeader("x-immich-share-key", m_endpoint.credential.toUtf8()); + } + else + { + request.setRawHeader("x-immich-share-slug", m_endpoint.credential.toUtf8()); + } + return request; +} + +void ImmichClient::attemptInspect(ShareEndpoint::CredentialKind kind) +{ + m_endpoint.credentialKind = kind; + auto *reply = m_network.get(requestFor(u"/shared-links/me"_s)); + connect(reply, &QNetworkReply::finished, this, [this, reply, kind] { handleInspectionReply(reply, kind, false); }); +} + +void ImmichClient::attemptLogin(ShareEndpoint::CredentialKind kind) +{ + m_endpoint.credentialKind = kind; + QNetworkRequest request = requestFor(u"/shared-links/login"_s); + request.setHeader(QNetworkRequest::ContentTypeHeader, u"application/json"_s); + const QByteArray body = + QJsonValue{QJsonObject{{u"password"_s, m_endpoint.password}}}.toJson(QJsonValue::JsonFormat::Compact); + auto *reply = m_network.post(request, body); + connect(reply, &QNetworkReply::finished, this, [this, reply, kind] { handleInspectionReply(reply, kind, true); }); +} + +void ImmichClient::handleInspectionReply(QNetworkReply *reply, ShareEndpoint::CredentialKind kind, bool wasLogin) +{ + const QByteArray body = reply->readAll(); + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const bool success = status >= 200 && status < 300 && reply->error() == QNetworkReply::NoError; + if (success) + { + ShareInfo info; + QString parseError; + if (parseShare(body, &info, &parseError)) + { + m_endpoint.credentialKind = kind; + if (!info.albumId.isEmpty() && info.assets.isEmpty() && info.declaredAssetCount > 0) + { + loadAlbumAssets(std::move(info)); + } + else + { + emit inspectionStage(u"ready"_s, u"Share inspected"_s); + emit inspected(info); + } + } + else + { + emit failed(parseError); + } + reply->deleteLater(); + return; + } + + m_passwordWasRequested = m_passwordWasRequested || mentionsPassword(body); + const bool authenticationFailure = status == 401 || status == 403; + if (!wasLogin && authenticationFailure && !m_endpoint.password.isEmpty()) + { + emit inspectionStage(u"authenticating"_s, u"Authenticating password-protected share"_s); + reply->deleteLater(); + attemptLogin(kind); + return; + } + + if (kind == ShareEndpoint::CredentialKind::Key) + { + reply->deleteLater(); + attemptInspect(ShareEndpoint::CredentialKind::Slug); + return; + } + + const QString message = errorMessage(reply, body); + reply->deleteLater(); + if (m_endpoint.password.isEmpty() && m_passwordWasRequested) + { + emit passwordRequired(); + } + else + { + emit failed(message); + } +} + +bool ImmichClient::parseShare(const QByteArray &json, ShareInfo *info, QString *error) +{ + QJsonParseError parseError; + const QJsonValue rootValue = QJsonValue::fromJson(json, &parseError); + if (parseError.error != QJsonParseError::NoError || !rootValue.isObject()) + { + *error = u"Immich returned invalid JSON: %1"_s.arg(parseError.errorString()); + return false; + } + + const QJsonObject root = rootValue.toObject(); + info->id = root.value(u"id"_s).toString(); + info->description = root.value(u"description"_s).toString(); + info->type = root.value(u"type"_s).toString(); + info->allowDownload = root.value(u"allowDownload"_s).toBool(); + info->allowUpload = root.value(u"allowUpload"_s).toBool(); + info->passwordProtected = !root.value(u"password"_s).isNull(); + + QJsonArray assets = root.value(u"assets"_s).toArray(); + const QJsonValue albumValue = root.value(u"album"_s); + if (albumValue.isObject()) + { + const QJsonObject album = albumValue.toObject(); + info->albumId = album.value(u"id"_s).toString(); + info->name = album.value(u"albumName"_s).toString(); + info->declaredAssetCount = album.value(u"assetCount"_s).toInteger(); + assets = album.value(u"assets"_s).toArray(); + } + if (info->name.isEmpty()) + { + info->name = info->description.isEmpty() ? u"Shared assets"_s : info->description; + } + + info->assets.clear(); + parseAssets(assets, info); + + if (info->id.isEmpty()) + { + *error = u"Immich's shared-link response did not include an id."_s; + return false; + } + return true; +} + +void ImmichClient::parseAssets(const QJsonArray &assets, ShareInfo *info) +{ + info->assets.reserve(info->assets.size() + assets.size()); + for (const QJsonValue assetValue : assets) + { + AssetInfo parsed = parseAsset(assetValue); + if (!parsed.id.isEmpty()) + { + info->assets.append(std::move(parsed)); + } + } +} + +AssetInfo ImmichClient::parseAsset(const QJsonValue &value) +{ + if (!value.isObject()) + { + return {}; + } + const QJsonObject asset = value.toObject(); + return { + .id = asset.value(u"id"_s).toString(), + .checksum = asset.value(u"checksum"_s).toString(), + .fileName = asset.value(u"originalFileName"_s).toString(), + .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); +} diff --git a/src/ImmichClient.h b/src/ImmichClient.h new file mode 100644 index 0000000..898478a --- /dev/null +++ b/src/ImmichClient.h @@ -0,0 +1,63 @@ +#pragma once + +#include "Types.h" + +#include +#include +#include + +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; +}; diff --git a/src/SyncSession.cpp b/src/SyncSession.cpp new file mode 100644 index 0000000..a2a8710 --- /dev/null +++ b/src/SyncSession.cpp @@ -0,0 +1,582 @@ +#include "SyncSession.h" + +#include +#include +#include +#include +#include +#include + +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(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(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(m_completed)}, + {u"failed"_s, static_cast(m_failed)}, + {u"total"_s, static_cast(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(m_completed + m_failed + 1)}, + {u"total"_s, static_cast(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(m_completed + m_failed + 1)}, + {u"total"_s, static_cast(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(m_completed)}, + {u"failed"_s, static_cast(m_failed)}, + {u"total"_s, static_cast(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(m_completed + m_failed + 1)}, + {u"total"_s, static_cast(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 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}}); +} diff --git a/src/SyncSession.h b/src/SyncSession.h new file mode 100644 index 0000000..30c901f --- /dev/null +++ b/src/SyncSession.h @@ -0,0 +1,81 @@ +#pragma once + +#include "ImmichClient.h" + +#include +#include +#include +#include +#include + +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 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 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; +}; diff --git a/src/Types.cpp b/src/Types.cpp new file mode 100644 index 0000000..9e4d8e5 --- /dev/null +++ b/src/Types.cpp @@ -0,0 +1,132 @@ +#include "Types.h" + +#include +#include +#include + +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/."_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 leftChecksums; + QSet 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(info.assets.size())}, + }; +} diff --git a/src/Types.h b/src/Types.h new file mode 100644 index 0000000..94cc08b --- /dev/null +++ b/src/Types.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include +#include + +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 assets; +}; + +struct SyncPlan { + QList leftToRight; + QList rightToLeft; +}; + +[[nodiscard]] SyncPlan buildSyncPlan(const ShareInfo &left, const ShareInfo &right); +[[nodiscard]] QJsonObject shareInfoJson(const ShareEndpoint &endpoint, const ShareInfo &info); diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..dcc29c5 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,34 @@ +#include "Application.h" + +#include +#include + +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(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(); +} diff --git a/tests/TypesTest.cpp b/tests/TypesTest.cpp new file mode 100644 index 0000000..3bbe221 --- /dev/null +++ b/tests/TypesTest.cpp @@ -0,0 +1,50 @@ +#include "Types.h" + +#include + +using namespace Qt::StringLiterals; + +class TypesTest final : public QObject { + Q_OBJECT + + private slots: + void parsesShareUrl(); + void rejectsNonShareUrl(); + void plansByChecksum(); +}; + +void TypesTest::parsesShareUrl() +{ + QString error; + const auto endpoint = ShareEndpoint::fromUserInput( + u"https://photos.example.test/immich/share/summer-2026?ignored=yes"_s, u"secret"_s, &error); + QCOMPARE(error, QString{}); + QCOMPARE(endpoint.apiRoot, QUrl{u"https://photos.example.test/immich/api"_s}); + QCOMPARE(endpoint.credential, u"summer-2026"_s); + QCOMPARE(endpoint.password, u"secret"_s); +} + +void TypesTest::rejectsNonShareUrl() +{ + QString error; + const auto endpoint = ShareEndpoint::fromUserInput(u"file:///tmp/share/key"_s, {}, &error); + QVERIFY(!error.isEmpty()); + QVERIFY(endpoint.apiRoot.isEmpty()); +} + +void TypesTest::plansByChecksum() +{ + ShareInfo left; + left.assets = {{u"1"_s, u"same"_s}, {u"2"_s, u"left-only"_s}, {u"3"_s, {}}}; + ShareInfo right; + right.assets = {{u"4"_s, u"same"_s}, {u"5"_s, u"right-only"_s}}; + + const auto plan = buildSyncPlan(left, right); + QCOMPARE(plan.leftToRight.size(), 1); + QCOMPARE(plan.leftToRight.front().id, u"2"_s); + QCOMPARE(plan.rightToLeft.size(), 1); + QCOMPARE(plan.rightToLeft.front().id, u"5"_s); +} + +QTEST_MAIN(TypesTest) +#include "TypesTest.moc" diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..a606e52 --- /dev/null +++ b/web/app.js @@ -0,0 +1,256 @@ +const $ = (selector) => document.querySelector(selector); +const $$ = (selector) => [...document.querySelectorAll(selector)]; + +const ui = { + connection: $('#connection'), + inspect: $('#inspect'), + message: $('#inspectionMessage'), + options: $('#syncOptions'), + activity: $('#activity'), + activityTitle: $('#activityTitle'), + abort: $('#abort'), + progressBar: $('#progressBar'), + progressText: $('#progressText'), + progressCount: $('#progressCount'), + events: $('#events'), +}; + +let socket; +let jobType; +let jobCompleted = false; +let abortRequested = false; +let syncRunning = false; + +function openJob(request) { + if (socket && socket.readyState !== WebSocket.CLOSED) { + ui.message.textContent = 'Please wait for the current job to finish.'; + return false; + } + + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const jobSocket = new WebSocket(`${protocol}//${location.host}/ws`); + socket = jobSocket; + jobType = request.type; + jobCompleted = false; + abortRequested = false; + ui.inspect.disabled = true; + setConnection('connecting', 'Connecting'); + + jobSocket.addEventListener('open', () => { + setConnection('connected', request.type === 'inspect' ? 'Inspecting' : 'Synchronizing'); + jobSocket.send(JSON.stringify(request)); + }); + jobSocket.addEventListener('message', ({ data }) => { + try { handle(JSON.parse(data)); } catch { addEvent('error', 'Received malformed service response'); } + }); + jobSocket.addEventListener('close', () => { + if (socket !== jobSocket) return; + socket = undefined; + setConnection('idle', 'Idle'); + ui.inspect.disabled = false; + if (abortRequested && !jobCompleted) { + syncRunning = false; + jobCompleted = true; + addEvent('aborted', 'Synchronization aborted'); + setActivityProgress('Aborted', 0, 0); + } else if (!jobCompleted && jobType === 'sync') { + syncRunning = false; + addEvent('error', 'Socket disconnected — the server aborted this synchronization'); + setActivityProgress('Aborted', 0, 0); + } else if (!jobCompleted && jobType === 'inspect') { + ui.message.textContent = 'Inspection stopped before it completed.'; + } + jobType = undefined; + }); + jobSocket.addEventListener('error', () => setConnection('disconnected', 'Connection error')); + return true; +} + +function setConnection(state, text) { + ui.connection.className = `connection ${state}`; + ui.connection.querySelector('span').textContent = text; +} + +function shareRequest(type) { + return { + type, + left: { url: $('#leftUrl').value, password: $('#leftPassword').value }, + right: { url: $('#rightUrl').value, password: $('#rightPassword').value }, + }; +} + +function inspect() { + const leftUrl = $('#leftUrl'); + const rightUrl = $('#rightUrl'); + if (!leftUrl.reportValidity() || !rightUrl.reportValidity()) return; + if (openJob(shareRequest('inspect'))) resetInspection(); +} + +function resetInspection() { + ui.options.classList.add('hidden'); + ui.message.textContent = 'Inspecting both Immich shares…'; + for (const side of ['left', 'right']) { + setSide(side, 'connecting', 'Starting inspection'); + $(`#${side}Details`).classList.add('hidden'); + } +} + +function setSide(side, stage, message, share) { + const state = $(`#${side}State`); + const card = $(`#${side}Card`); + const status = $(`#${side}Status`); + const labels = { + idle: 'Idle', connecting: 'Connecting', authenticating: 'Password', ready: 'Ready', + 'password-required': 'Password needed', error: 'Error', + }; + state.className = `state ${stage}`; + state.textContent = labels[stage] || stage; + status.textContent = message; + card.classList.toggle('attention', stage === 'password-required'); + card.classList.toggle('has-error', stage === 'error'); + if (stage === 'password-required') $(`#${side}Password`).focus(); + if (share) showShare(side, share); +} + +function showShare(side, share) { + const details = $(`#${side}Details`); + details.replaceChildren( + detailRow('Album', share.name), + detailRow('Assets', String(share.assetCount)), + detailRow('Download', share.allowDownload ? 'Allowed' : 'Blocked', share.allowDownload), + detailRow('Upload', share.allowUpload ? 'Allowed' : 'Blocked', share.allowUpload), + detailRow('Server', share.origin), + ); + details.classList.remove('hidden'); +} + +function detailRow(label, value, positive) { + const row = document.createElement('div'); + const key = document.createElement('span'); + const val = document.createElement('strong'); + key.textContent = label; + val.textContent = value; + if (positive === true) val.className = 'positive'; + if (positive === false) val.className = 'negative'; + row.append(key, val); + return row; +} + +function showOptions(options) { + for (const button of $$('#syncOptions button')) { + const option = options[button.dataset.direction]; + button.disabled = !option.allowed; + button.title = option.reason || ''; + button.querySelector('span').textContent = option.allowed + ? `${option.missing} missing asset${option.missing === 1 ? '' : 's'}` + : option.reason; + } + ui.options.classList.remove('hidden'); +} + +function startSync(direction) { + const request = shareRequest('sync'); + request.direction = direction; + if (!openJob(request)) return; + syncRunning = true; + ui.activity.classList.remove('hidden'); + ui.activityTitle.textContent = direction === 'bidirectional' ? 'Bidirectional synchronization' : + direction === 'left-to-right' ? 'Synchronizing A → B' : 'Synchronizing B → A'; + ui.events.replaceChildren(); + ui.activity.scrollIntoView({ behavior: 'smooth', block: 'start' }); +} + +function setActivityProgress(text, current, total) { + ui.progressText.textContent = text; + ui.progressCount.textContent = `${current} / ${total}`; + ui.progressBar.style.width = total > 0 ? `${Math.min(100, current / total * 100)}%` : '0%'; +} + +function addEvent(kind, message, detail = '') { + ui.activity.classList.remove('hidden'); + const item = document.createElement('li'); + item.className = kind; + const time = document.createElement('time'); + const copy = document.createElement('div'); + const main = document.createElement('strong'); + time.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + main.textContent = message; + copy.append(main); + if (detail) { + const sub = document.createElement('span'); + sub.textContent = detail; + copy.append(sub); + } + item.append(time, copy); + ui.events.prepend(item); + while (ui.events.children.length > 250) ui.events.lastElementChild.remove(); +} + +function formatBytes(value) { + if (!Number.isFinite(value) || value < 0) return 'unknown size'; + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; + let size = value; + let unit = 0; + while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit++; } + return `${size.toFixed(unit ? 1 : 0)} ${units[unit]}`; +} + +function handle(message) { + switch (message.type) { + case 'hello': + break; + case 'inspection-status': + ui.message.textContent = message.message; + break; + case 'side-status': + setSide(message.side, message.stage, message.message, message.share); + break; + case 'inspection': + ui.message.textContent = message.message; + if (message.ready) showOptions(message.options); + if (jobType === 'inspect' || !message.ready) jobCompleted = true; + break; + case 'sync-status': { + const current = message.completed || 0; + const failed = message.failed || 0; + setActivityProgress(message.message, current + failed, message.total || 0); + addEvent(message.stage === 'complete' && failed === 0 ? 'success' : message.stage, message.message, + message.stage === 'complete' ? `${current} completed · ${failed} failed` : ''); + if (['complete', 'aborted'].includes(message.stage)) { + syncRunning = false; + jobCompleted = true; + } + break; + } + case 'asset-status': { + const done = (message.completed || 0) + (message.failed || 0); + setActivityProgress(`${message.stage}: ${message.fileName}`, done, message.total || 0); + addEvent(message.stage, message.message, `${message.fileName} · ${message.direction}`); + break; + } + case 'asset-progress': + ui.progressText.textContent = `${message.phase}: ${message.fileName} · ${formatBytes(message.bytes)} / ${formatBytes(message.totalBytes)}`; + ui.progressCount.textContent = `${message.current} / ${message.total}`; + break; + case 'error': + ui.message.textContent = message.message; + addEvent('error', message.message); + syncRunning = false; + jobCompleted = true; + break; + } +} + +ui.inspect.addEventListener('click', inspect); +for (const input of $$('input')) input.addEventListener('keydown', (event) => { + if (event.key === 'Enter') inspect(); +}); +for (const button of $$('#syncOptions button')) button.addEventListener('click', () => startSync(button.dataset.direction)); +ui.abort.addEventListener('click', () => { + if (jobType === 'sync' && socket && socket.readyState === WebSocket.OPEN && !jobCompleted) { + abortRequested = true; + socket.close(1000, 'User aborted synchronization'); + } +}); + +setConnection('idle', 'Idle'); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..8405221 --- /dev/null +++ b/web/index.html @@ -0,0 +1,96 @@ + + + + + + + + Immich Share Sync + + + + + + Fork me at code.brunner.ninja + +
+
+
+
+ +
+

Independent Immich servers, one complete album

+

Immich Share Sync

+

Compare two public shares by checksum and stream only the missing originals. Nothing is deleted and nothing is stored here.

+
+
Idle
+
+ +
+ + + + + +
+ +
+ +

Connect both sides to discover available synchronization directions.

+ +
+ + + +
+ Ephemeral by design + Checksum comparison + Originals streamed in memory + No deletion +
+
+ + + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..fbae6f2 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,115 @@ +:root { + color-scheme: dark; + --bg: #090d16; + --panel: rgba(18, 25, 39, .82); + --panel-solid: #121927; + --line: rgba(255,255,255,.09); + --muted: #98a5b8; + --text: #f5f7fb; + --cyan: #22d3ee; + --blue: #60a5fa; + --violet: #a78bfa; + --green: #34d399; + --red: #fb7185; + --amber: #fbbf24; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } +body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--text); overflow-x: hidden; } +button, input { font: inherit; } +.hidden { display: none !important; } +.source-ribbon { position: fixed; z-index: 20; top: 60px; right: -72px; width: 300px; display: flex; justify-content: center; align-items: center; gap: 8px; padding: 9px 16px; transform: rotate(38deg); border: 1px solid rgba(255,255,255,.16); background: linear-gradient(120deg, #e24329, #a72867); color: #fff; text-decoration: none; font-size: .72rem; font-weight: 850; letter-spacing: .025em; white-space: nowrap; box-shadow: 0 8px 30px rgba(0,0,0,.32); transition: filter .2s, transform .2s; } +.source-ribbon:hover { filter: brightness(1.15); transform: rotate(38deg) translateY(2px); } +.source-ribbon:focus-visible { outline: 3px solid var(--cyan); outline-offset: 3px; } +.source-ribbon svg { width: 17px; height: 17px; fill: currentColor; flex: 0 0 auto; } +.ambient { position: fixed; width: 38rem; height: 38rem; border-radius: 50%; filter: blur(110px); opacity: .12; pointer-events: none; } +.ambient-one { background: var(--cyan); top: -18rem; left: -10rem; } +.ambient-two { background: var(--violet); bottom: -22rem; right: -12rem; } +main { width: min(1180px, calc(100% - 36px)); margin: 0 auto; padding: 58px 0 32px; position: relative; } + +.hero { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 24px; margin-bottom: 42px; } +.hero h1 { font-size: clamp(2.25rem, 6vw, 4.8rem); line-height: .96; letter-spacing: -.055em; margin: 7px 0 14px; } +.eyebrow { margin: 0; text-transform: uppercase; letter-spacing: .15em; font-size: .72rem; font-weight: 800; color: var(--cyan); } +.lede { margin: 0; max-width: 680px; color: var(--muted); line-height: 1.65; } +.brand-mark { width: 64px; height: 64px; position: relative; animation: rotate 22s linear infinite; } +.brand-mark span { width: 21px; height: 34px; position: absolute; left: 22px; top: 1px; border-radius: 20px 20px 8px 8px; transform-origin: 10px 31px; background: var(--cyan); } +.brand-mark span:nth-child(2) { transform: rotate(60deg); background: var(--blue); } +.brand-mark span:nth-child(3) { transform: rotate(120deg); background: var(--violet); } +.brand-mark span:nth-child(4) { transform: rotate(180deg); background: var(--red); } +.brand-mark span:nth-child(5) { transform: rotate(240deg); background: var(--amber); } +.brand-mark span:nth-child(6) { transform: rotate(300deg); background: var(--green); } +@keyframes rotate { to { transform: rotate(360deg); } } + +.connection { align-self: start; display: flex; align-items: center; gap: 9px; padding: 9px 13px; border: 1px solid var(--line); border-radius: 99px; color: var(--muted); font-size: .8rem; font-weight: 700; background: rgba(0,0,0,.18); } +.connection i { width: 8px; height: 8px; border-radius: 50%; background: var(--amber); box-shadow: 0 0 12px currentColor; } +.connection.connected { color: var(--green); }.connection.connected i { background: var(--green); } +.connection.disconnected { color: var(--red); }.connection.disconnected i { background: var(--red); } + +.workspace { display: grid; grid-template-columns: 1fr 64px 1fr; align-items: stretch; } +.share-card, .controls, .activity { border: 1px solid var(--line); background: var(--panel); backdrop-filter: blur(18px); box-shadow: 0 24px 80px rgba(0,0,0,.22); } +.share-card { padding: 26px; transition: border-color .2s, transform .2s; } +.share-card.left { border-radius: 22px 5px 5px 22px; } +.share-card.right { border-radius: 5px 22px 22px 5px; } +.share-card.attention { border-color: rgba(251,191,36,.65); } +.share-card.has-error { border-color: rgba(251,113,133,.55); } +.card-heading { display: grid; grid-template-columns: auto 1fr auto; gap: 13px; align-items: center; margin-bottom: 24px; } +.side-label { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 11px; background: rgba(34,211,238,.12); color: var(--cyan); font-weight: 900; } +.right .side-label { background: rgba(167,139,250,.13); color: var(--violet); } +.card-heading h2 { font-size: 1.05rem; margin: 0 0 4px; } +.card-heading p { font-size: .78rem; color: var(--muted); margin: 0; } +.state { font-size: .67rem; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; padding: 7px 9px; border-radius: 8px; background: rgba(255,255,255,.05); color: var(--muted); } +.state.ready { background: rgba(52,211,153,.11); color: var(--green); } +.state.error { background: rgba(251,113,133,.12); color: var(--red); } +.state.password-required, .state.authenticating { background: rgba(251,191,36,.12); color: var(--amber); } +.state.connecting { color: var(--cyan); } +label { display: block; color: #cbd5e1; font-size: .78rem; font-weight: 750; margin-top: 17px; } +label span { color: #64748b; font-weight: 500; } +input { width: 100%; margin-top: 8px; border: 1px solid var(--line); background: rgba(4,8,15,.62); color: var(--text); border-radius: 11px; outline: 0; padding: 13px 14px; font-size: .88rem; transition: border .2s, box-shadow .2s; } +input:focus { border-color: rgba(34,211,238,.7); box-shadow: 0 0 0 3px rgba(34,211,238,.09); } +.right input:focus { border-color: rgba(167,139,250,.7); box-shadow: 0 0 0 3px rgba(167,139,250,.09); } +.details { display: grid; gap: 8px; border-top: 1px solid var(--line); margin-top: 22px; padding-top: 18px; } +.details div { display: flex; justify-content: space-between; gap: 18px; font-size: .75rem; } +.details span { color: var(--muted); }.details strong { text-align: right; overflow-wrap: anywhere; }.positive { color: var(--green); }.negative { color: var(--red); } +.exchange { display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 5px; color: var(--muted); font-size: 1.25rem; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: rgba(255,255,255,.025); } +.exchange span:first-child { color: var(--cyan); }.exchange span:last-child { color: var(--violet); } + +.controls { border-radius: 18px; margin-top: 18px; padding: 22px; display: grid; place-items: center; text-align: center; } +button { border: 0; cursor: pointer; color: var(--text); } +button:disabled { opacity: .35; cursor: not-allowed; } +.primary { padding: 13px 22px; border-radius: 11px; background: linear-gradient(120deg, #0891b2, #2563eb); font-weight: 800; box-shadow: 0 8px 30px rgba(8,145,178,.18); } +.controls > p { margin: 12px 0 0; font-size: .78rem; color: var(--muted); } +.sync-options { display: grid; grid-template-columns: repeat(3, 1fr); width: 100%; gap: 10px; margin-top: 22px; border-top: 1px solid var(--line); padding-top: 20px; } +.sync-options button { display: flex; flex-direction: column; gap: 5px; padding: 15px; background: rgba(255,255,255,.045); border: 1px solid var(--line); border-radius: 11px; } +.sync-options button:hover:not(:disabled) { border-color: rgba(34,211,238,.45); background: rgba(34,211,238,.06); } +.sync-options button span { color: var(--muted); font-size: .7rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.activity { margin-top: 18px; border-radius: 18px; padding: 24px; } +.activity-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; } +.activity-heading h2 { margin: 5px 0 0; font-size: 1.25rem; } +.danger { background: rgba(251,113,133,.1); border: 1px solid rgba(251,113,133,.22); color: var(--red); padding: 9px 12px; border-radius: 9px; font-size: .75rem; font-weight: 800; } +.progress-track { height: 7px; border-radius: 10px; background: rgba(255,255,255,.06); overflow: hidden; margin-top: 23px; } +.progress-track div { width: 0; height: 100%; background: linear-gradient(90deg, var(--cyan), var(--violet)); transition: width .25s; } +.progress-copy { display: flex; justify-content: space-between; gap: 20px; color: var(--muted); font-size: .75rem; margin-top: 9px; } +.events { list-style: none; padding: 0; margin: 24px 0 0; max-height: 390px; overflow: auto; border-top: 1px solid var(--line); } +.events li { display: grid; grid-template-columns: 80px 10px 1fr; gap: 12px; align-items: start; padding: 13px 2px; border-bottom: 1px solid rgba(255,255,255,.05); font-size: .75rem; } +.events li::before { content: ''; grid-column: 2; width: 7px; height: 7px; margin-top: 5px; border-radius: 50%; background: var(--blue); } +.events time { grid-column: 1; grid-row: 1; color: #64748b; font-variant-numeric: tabular-nums; } +.events div { grid-column: 3; display: flex; flex-direction: column; gap: 3px; }.events span { color: var(--muted); } +.events .success::before, .events .complete::before { background: var(--green); }.events .error::before, .events .failed::before, .events .aborted::before { background: var(--red); }.events .uploading::before { background: var(--violet); } +footer { display: flex; justify-content: center; flex-wrap: wrap; gap: 10px 22px; padding: 25px 0 0; color: #64748b; text-transform: uppercase; letter-spacing: .08em; font-size: .62rem; font-weight: 800; } +footer span::before { content: '✓'; color: var(--green); margin-right: 7px; } + +@media (max-width: 780px) { + .source-ribbon { position: absolute; top: 10px; right: 10px; width: auto; transform: none; border-radius: 9px; padding: 8px 10px; } + .source-ribbon:hover { transform: translateY(-1px); } + .source-ribbon span { display: none; } + main { padding-top: 30px; } + .hero { grid-template-columns: auto 1fr; }.connection { grid-column: 2; justify-self: start; } + .brand-mark { width: 48px; height: 48px; transform: scale(.75); transform-origin: left center; } + .workspace { grid-template-columns: 1fr; gap: 10px; }.share-card.left, .share-card.right { border-radius: 17px; } + .exchange { flex-direction: row; height: 38px; border: 0; background: transparent; transform: rotate(90deg); } + .sync-options { grid-template-columns: 1fr; }.activity-heading { align-items: flex-start; } +} + +@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; animation: none !important; transition: none !important; } }