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
+39
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
build
.git
.gitignore
Dockerfile*
README.md
*.yaml
install.sh
+3
View File
@@ -0,0 +1,3 @@
build/
.qtcreator/
*.user*
+64
View File
@@ -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
$<$<CXX_COMPILER_ID:GNU,Clang>:-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()
+43
View File
@@ -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"]
+75 -2
View File
@@ -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)
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.
+108
View File
@@ -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
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
kubectl apply -f immich-sync.yaml
+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();
}
+50
View File
@@ -0,0 +1,50 @@
#include "Types.h"
#include <QTest>
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"
+256
View File
@@ -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');
+96
View File
@@ -0,0 +1,96 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#111827">
<title>Immich Share Sync</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<a class="source-ribbon" href="https://code.brunner.ninja/feedc0de/immich-sync" target="_blank" rel="noopener noreferrer" aria-label="View and fork Immich Share Sync at code.brunner.ninja">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m8.7 16.6-4.6-4.6 4.6-4.6 1.4 1.4L6.9 12l3.2 3.2-1.4 1.4Zm6.6 0-1.4-1.4 3.2-3.2-3.2-3.2 1.4-1.4 4.6 4.6-4.6 4.6ZM10.4 19l2.9-14 2 .4-2.9 14-2-.4Z"/>
</svg>
<span>Fork me at code.brunner.ninja</span>
</a>
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<main>
<header class="hero">
<div class="brand-mark" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span><span></span></div>
<div>
<p class="eyebrow">Independent Immich servers, one complete album</p>
<h1>Immich Share Sync</h1>
<p class="lede">Compare two public shares by checksum and stream only the missing originals. Nothing is deleted and nothing is stored here.</p>
</div>
<div id="connection" class="connection idle"><i></i><span>Idle</span></div>
</header>
<section class="workspace" aria-label="Share links">
<article id="leftCard" class="share-card left">
<div class="card-heading">
<span class="side-label">A</span>
<div><h2>Left share</h2><p id="leftStatus">Waiting for a link</p></div>
<span id="leftState" class="state idle">Idle</span>
</div>
<label>Immich share URL
<input id="leftUrl" type="url" autocomplete="url" spellcheck="false" placeholder="https://photos.example/share/…" required>
</label>
<label>Password <span>if protected</span>
<input id="leftPassword" type="password" autocomplete="off" placeholder="Optional">
</label>
<div id="leftDetails" class="details hidden"></div>
</article>
<div class="exchange" aria-hidden="true">
<span></span><span></span>
</div>
<article id="rightCard" class="share-card right">
<div class="card-heading">
<span class="side-label">B</span>
<div><h2>Right share</h2><p id="rightStatus">Waiting for a link</p></div>
<span id="rightState" class="state idle">Idle</span>
</div>
<label>Immich share URL
<input id="rightUrl" type="url" autocomplete="url" spellcheck="false" placeholder="https://photos.example/share/…" required>
</label>
<label>Password <span>if protected</span>
<input id="rightPassword" type="password" autocomplete="off" placeholder="Optional">
</label>
<div id="rightDetails" class="details hidden"></div>
</article>
</section>
<section class="controls">
<button id="inspect" class="primary" type="button">Inspect both shares</button>
<p id="inspectionMessage">Connect both sides to discover available synchronization directions.</p>
<div id="syncOptions" class="sync-options hidden">
<button type="button" data-direction="left-to-right"><strong>A → B</strong><span></span></button>
<button type="button" data-direction="bidirectional"><strong>A ↔ B</strong><span></span></button>
<button type="button" data-direction="right-to-left"><strong>B → A</strong><span></span></button>
</div>
</section>
<section id="activity" class="activity hidden" aria-live="polite">
<div class="activity-heading">
<div><p class="eyebrow">Live WebSocket status</p><h2 id="activityTitle">Synchronization</h2></div>
<button id="abort" class="danger" type="button">Abort &amp; disconnect</button>
</div>
<div class="progress-track"><div id="progressBar"></div></div>
<div class="progress-copy"><span id="progressText">Preparing…</span><span id="progressCount">0 / 0</span></div>
<ol id="events" class="events"></ol>
</section>
<footer>
<span>Ephemeral by design</span>
<span>Checksum comparison</span>
<span>Originals streamed in memory</span>
<span>No deletion</span>
</footer>
</main>
<script src="/app.js" defer></script>
</body>
</html>
+115
View File
@@ -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; } }