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
./install.sh
The Kubernetes manifest follows the neighboring brunner-ninja and visual-studio-code layout. It pulls the public
registry.brunner.ninja/feedc0de/immich-sync:latest image on every pod start, so no Kubernetes image-pull secret is
required. It assumes the immich-sync.brunner.ninja hostname shown in the manifest. The Ingress is intentionally
public and has no authentication middleware. Because the
service can make outbound requests to user-supplied URLs, operators should monitor it for SSRF and bandwidth abuse.
The Deployment runs two replicas with preferred cross-node placement, a zero-unavailable rolling-update strategy, and
a PodDisruptionBudget that keeps at least one replica available. A small ReadWriteMany rook-cephfs claim stores the
shared lifetime counters. 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. The manifest includes a ServiceMonitor that selects the
immich-sync Service and is discovered by the existing Prometheus Operator. 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.KUBE_CONFIG_BASE64: a kubeconfig for the restrictedimmich-sync-deployerservice account, base64-encoded on one line.
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.
On successful main builds, the deploy job patches only the default/immich-sync Deployment to the immutable commit
image and waits up to five minutes for rollout. Apply ci-deployer.yaml once, generate a kubeconfig for its token, and
store it as KUBE_CONFIG_BASE64. The role cannot access secrets, pods, or any other Deployment.
kubectl apply -f ci-deployer.yaml
./create-ci-kubeconfig.sh
Copy the single output line from create-ci-kubeconfig.sh into the Gitea repository secret. Treat it as a password.
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.