8.5 KiB
Immich Share Sync
An ephemeral Qt 6 web service that compares two Immich public shares by SHA-1 checksum and copies missing originals in either direction. It never deletes assets or keeps recovery state.
How it works
- A
QHttpServerserves an embedded vanilla HTML/CSS/JavaScript frontend. - The same HTTP listener upgrades
/wswith Qt WebSockets. - Each short-lived WebSocket owns exactly one job, one
SyncSession, twoImmichClientinstances, and theirQNetworkAccessManagerobjects. - 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/QJsonObjectresponses and compares the base64-encoded SHA-1 checksums in Immich's shared-link response. - Each missing original is downloaded into a
QTemporaryFile, rewound, and used as the seekableQIODevicebody of the destination multipart request. Only one asset per job is staged, and the file is removed after success, failure, or WebSocket abort. - 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.
- Aggregate counters are the only persistent state. Kubernetes replicas update a small locked JSON file on the shared CephFS volume; it contains only numeric totals and never contains links, passwords, filenames, or job recovery data.
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.
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.
After both share URLs are valid, starting a job updates the browser fragment in the form
#left=<encoded-share-url>&right=<encoded-share-url>. Reloading or bookmarking that URL restores both fields and
automatically starts a new inspection. Passwords are intentionally excluded from the fragment and must be entered
again for protected shares.
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:
{
"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:
{
"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. After upload reaches 100%, a processing asset stage makes it clear that the destination Immich is still handling the request. The service closes the socket after completion; close it from the client to abort, delete the temporary asset, and discard the whole session immediately.
Container and Kubernetes
docker build -t registry.brunner.ninja/feedc0de/immich-sync:latest .
docker run --rm -p 8090:8090 registry.brunner.ninja/feedc0de/immich-sync:latest
kubectl apply -f deploy/example.yaml
kubectl port-forward service/immich-sync 8090:80
deploy/example.yaml is a portable starting point for self-hosting. It creates one Deployment and a ClusterIP Service,
uses the public image, and does not require an image-pull secret. Add persistent storage, an Ingress, and monitoring to
match your cluster. Because the service can make outbound requests to user-supplied URLs, operators should monitor it
for SSRF and bandwidth abuse.
The author's complete
apps/immich-sync GitOps application
is public and can be used as an Argo CD/Kustomize deployment reference. It includes homelab-specific CephFS storage,
multiple replicas, an Ingress hostname, and Prometheus Operator monitoring, so fork it and adjust those assumptions
before deploying it to another cluster. WebSockets need no sticky-session setting: after the HTTP upgrade, the
selected pod owns that connection until the short-lived job finishes or disconnects.
Statistics and Prometheus
The homepage reads /api/stats every 30 seconds and displays shared totals for completed synchronization runs, all
successfully synchronized assets, photos, videos, and bytes. Counters begin at zero when statistics are first enabled.
Each replica exposes Prometheus text metrics at /metrics. A deployment can add a ServiceMonitor selecting the
immich-sync Service. Useful Grafana queries include:
sum(immich_sync_active_synchronizations)
sum(rate(immich_sync_assets_total{result="success"}[5m]))
sum by (media_type) (rate(immich_sync_asset_bytes_total[5m]))
max(immich_sync_lifetime_synchronizations_total)
max by (media_type) (immich_sync_lifetime_assets_total)
The lifetime series contains the same shared value on both replicas, so use max, not sum, for those metrics.
Per-process operational counters and active gauges should be summed across replicas.
Continuous delivery
The Gitea Actions workflow in .gitea/workflows/container.yml builds and pushes
registry.brunner.ninja/feedc0de/immich-sync. Every pushed commit receives an immutable tag containing its full Git
SHA. A commit pushed to main receives both its SHA tag and latest from the same image build.
The workflow deliberately loads the completed image into the Docker engine and pushes it with a separate plain
docker push. It does not request provenance attestations, SBOM manifests, or OCI referrers, retaining compatibility
with the self-hosted Quay registry and with Gitea runners whose default builder uses BuildKit's container driver.
The workflow expects these Gitea Actions repository secrets:
QUAY_USERNAME: the complete Quay robot account name, including thefeedc0de+prefix.QUAY_TOKEN: the robot account token.GITOPS_TOKEN: awrite:repositorytoken belonging to a dedicated account with write access only tofeedc0de/homelab-gitops.
Give the robot account Write permission only on the feedc0de/immich-sync repository. Public image pulls do not use
these credentials and do not require a Gitea secret.
After a successful main build, the promotion job updates the single image tag in homelab-gitops and pushes a
deployment commit. Argo CD observes that commit and reconciles Kubernetes. The workflow has no kubeconfig and no
direct Kubernetes access. See the GitOps repository README for the token setup, bootstrap procedure, and rollback path.
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.