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