Skip to content

donnchawp/immich-shared-library

Repository files navigation

Immich Shared Library

A Docker sidecar service that syncs a subset of Immich photos from one user to another — without duplicating ML processing.

The Problem

Immich's built-in partner sharing lets you view another user's library, but it's limited: you share your entire library or nothing, and face recognition doesn't work on partner-shared photos — you can't search for or browse by people in your partner's library.

The common workaround is symlinking an external library directory so both users point at the same photos. This gives each user their own copy of the assets with full face recognition support, but Immich processes each user's assets independently through the full ML pipeline:

Per-asset work Symlink only With sidecar
Metadata extraction (EXIF) 2x 1x (copied)
Thumbnail generation 2x 1x (hardlinked)
CLIP embedding (smart search) 2x (GPU) 1x (copied)
Face detection 2x (GPU) 1x (copied)
Face recognition 2x (GPU) 1x (copied)
Person clustering Independent per user Mirrored from source
Person names Must name separately Synced automatically

With 1,000 shared photos, the symlink approach queues 5,000+ extra ML jobs (metadata, thumbnails, CLIP, face detection, face recognition) that produce identical results. Each user also gets independent person clusters, so you'd need to name each person twice — and the clusters may group faces differently.

How It Works

This sidecar connects directly to Immich's PostgreSQL database and, for each shared source asset:

  1. Creates a target asset record with remapped file paths
  2. Copies EXIF metadata, CLIP embeddings, face detection results, and face recognition data
  3. Hardlinks thumbnail and preview files (zero extra disk space)
  4. Creates mirrored person records with hardlinked face thumbnails
  5. Pre-populates job status so Immich skips all ML processing for these assets

The target user's assets appear instantly with full search, face recognition, and timeline support — no ML queue, no GPU time. The sidecar runs continuously, syncing new assets, propagating person name changes, and cleaning up deletions.

Prerequisites

  • Docker with docker compose — the sidecar builds and runs entirely in Docker, no other development tools required
  • Immich v3.0.1 (tested). Other v3.x versions may work but the database schema can change between releases — check the Immich release notes before upgrading. Note: since v3, album ownership lives in album_user (role owner), and the sidecar copies OCR results (asset_ocr/ocr_search) and video stream metadata (asset_video/asset_audio/asset_keyframe) alongside CLIP embeddings.
  • Two or more Immich users (at least one source and one target)
  • Source assets must be fully processed by Immich (metadata, faces, CLIP)

Back up your database before running this sidecar. It writes directly to Immich's PostgreSQL database. If something goes wrong, you'll want a backup to restore from. A simple pg_dump is enough:

docker exec immich_postgres pg_dump -U postgres immich > immich_backup.sql

The sidecar supports two sync methods. You can use one or both:

Method What it syncs Source
External Library Sync A subset (or all) of one user's external library Source user's external library directory
Upload Sync App/website uploads from a different user Source user's upload directory (camera roll, web uploads, etc.)

Example scenario

Alice manages the family photo library in Lightroom Classic and imports the finished edits into Immich via an external library. She uses External Library Sync to share a curated subset of family photos with Bob — just the family shots, not the street photography or landscapes.

Bob uploads all his phone photos through the Immich app. Alice uses Upload Sync to pull Bob's entire upload library into her account. The ML data (CLIP embeddings, face detection, face recognition) is copied along with the assets, so Alice gets full search and face recognition on Bob's photos without any duplicate GPU processing.

Both sync methods run together in the same sidecar, syncing into Alice's and Bob's accounts respectively.

Setup

Quick Setup (Recommended)

The interactive setup wizard handles everything: connecting to Immich, detecting paths, creating symlinks and libraries, and generating the .env file.

Prerequisites: Create an admin API key in Immich (Account Settings > API Keys) and have python3 installed on the host.

python3 setup.py

The wizard will:

  1. Connect to your Immich server and verify admin access
  2. Auto-detect volume mount paths from Immich's docker-compose.yml
  3. Let you choose which sync method(s) to configure (external library, upload sync, or both)
  4. Walk you through selecting source/target users
  5. Create the necessary symlinks and external libraries in Immich (with **/* exclusion so Immich won't scan them — you must also disable Immich's periodic scan, see Setup)
  6. Optionally set up album assignment
  7. Generate a .env file with all the correct UUIDs and path prefixes

Once the wizard completes, start the sidecar:

docker compose up -d

If you already have a .env file, re-running the wizard will use your existing values as defaults and let you add or reconfigure sync methods.

Multi-Job Configuration (config.yaml)

For setups with more than one sync job, or for cleaner configuration, create a config.yaml file. This separates per-job settings from infrastructure settings (which stay in .env).

# config.yaml
sync_jobs:
  - name: "alice-external-to-bob"
    source_user_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
    target_user_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
    target_library_id: "llllllll-llll-llll-llll-llllllllllll"
    source_path_prefix: "/external_library/alice/photos/"
    target_path_prefix: "/external_library/bob_shared/photos/"
    album_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"  # optional, per-job

  - name: "charlie-uploads-to-alice"
    source_user_id: "cccccccc-cccc-cccc-cccc-cccccccccccc"
    target_user_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
    target_library_id: "mmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm"
    source_path_prefix: "/usr/src/app/upload/library/cccccccc-cccc-cccc-cccc-cccccccccccc/"
    target_path_prefix: "/external_library/alice_uploads/charlie/"
# .env (infrastructure only)
DB_PASSWORD=postgres
UPLOAD_LOCATION=../immich-app/library
EXTERNAL_LIBRARY_DIR=../immich-app/external_library
IMMICH_API_KEY=your-key

Mount the config file in docker-compose.yml by uncommenting the volume line:

volumes:
  - ./config.yaml:/app/config.yaml:ro

The setup wizard (python3 setup.py) generates both files and enables the volume mount automatically.

Backward compatibility: If no config.yaml exists, the sidecar falls back to per-job environment variables (SOURCE_USER_ID, TARGET_USER_ID, etc.) — existing .env-only deployments continue to work unchanged.

Migrating from env vars to config.yaml: Create a config.yaml with your job(s), move album config from TARGET_ALBUM_ID to per-job album_id, add the volume mount to docker-compose.yml, and remove the per-job env vars from .env. Re-running python3 setup.py does this automatically.

Manual Setup

If the wizard doesn't suit your environment, or you need to understand what each setting does (useful for troubleshooting), follow the manual steps below.

1. Create an API key

In Immich, go to Account Settings > API Keys and create a key.

2. Configure environment

cp env.example .env

Edit .env with the common settings:

DB_PASSWORD=postgres

# Host paths to Immich's data directories
UPLOAD_LOCATION=../immich-app/library
EXTERNAL_LIBRARY_DIR=../immich-app/external_library

IMMICH_API_KEY=your-immich-api-key
TARGET_USER_ID=yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy

3. Find UUIDs

User IDs: In the Immich web UI, go to Administration > Users and click on a username. The UUID appears in the URL: /admin/users/{UUID}.

Library IDs: In the Immich web UI, go to Administration > External Libraries and click on a library. The UUID appears in the URL: /admin/library-management/{UUID}.

Now configure one or both sync methods (external library or upload), then continue to Album Assignment and Start the Sidecar.

External Library Sync

Syncs assets from a source user's external library into the target user's account. Use this when both users should see the same set of externally-managed photos (e.g., a shared photo directory on a NAS).

The source user (User A) has an external library that Immich scans and processes normally. You create a second external library for the target user (User B) containing a symlink to the same photos. The sidecar creates asset records for User B directly in the database — Immich never scans this target library.

Create the symlink so the target user's external library points to the source user's photos:

/external_library/
  user_a/               # Source user's external library (scanned by Immich)
    personal/
      photo1.jpg
    shared/             # Subdirectory to share with User B
      photo2.jpg
      photo3.jpg
  user_b_shared -> user_a/shared   # Target user's external library (NOT scanned — sidecar handles it)

You can symlink the entire source directory or just a subdirectory — the SHARED_PATH_PREFIX controls which source assets are synced.

Create the target external library in Immich for the target user (User B) with an import path that covers the symlink directory (e.g., /external_library/user_b_shared/).

Important: This library must not be scanned by Immich. Add the exclusion pattern **/* to tell Immich to ignore all files:

  1. Go to Administration > External Libraries
  2. Click on the target user's new external library
  3. Add **/* as an exclusion pattern and save

The sidecar writes asset records directly to the database. Immich sees User B's assets because they exist in the asset table, not because it scanned the filesystem.

Enable library watching in Immich's Administration > Settings > External Library. This uses filesystem events (inotify) to detect new files added to the source user's external library. Immich will process them through the ML pipeline. Once processing completes, the sidecar picks them up on the next sync cycle.

Because the target library has the **/* exclusion pattern, Immich will ignore file events in that library.

⚠️ Disable Immich's periodic scan, and never run a manual scan on a target library.

Library watching (inotify) respects the **/* exclusion and is safe. A scan is not: it crawls the target library, finds 0 files (because of the exclusion), concludes every sidecar-managed asset is missing from disk, and marks them all offline — soft-deleting them so the target user's shared photos disappear. A full library's worth of assets can be offlined in a single scan.

To avoid this:

  1. Turn off Administration > Settings > External Library > Periodic Scanning (or set no cron schedule). Rely on library watching to discover new source files instead.
  2. Never click Scan on a target library in the UI.

Do not work around this by removing the **/* exclusion pattern. Without it, Immich imports and runs full ML processing (thumbnails, transcoding, CLIP, faces) on every target file itself — duplicating all the work this sidecar exists to avoid. See issue #3.

Note: The sidecar does not trigger library scans itself. It relies on Immich's library watching (or manual scans) to discover new source files. If library watching doesn't work in your environment (e.g., network drives), you can trigger scans manually:

curl -X POST "http://localhost:2283/api/libraries/SOURCE_LIBRARY_ID/scan" \
  -H "x-api-key: YOUR_KEY"

Add to .env:

SOURCE_USER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
TARGET_LIBRARY_ID=zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz

# Path prefixes as seen inside the Immich container (not on the host)
SHARED_PATH_PREFIX=/external_library/user_a/shared/
TARGET_PATH_PREFIX=/external_library/user_b_shared/

Upload Sync

Syncs app/website uploads from a source user into the target user's account. Use this when the source user uploads photos via the Immich mobile app or web UI and you want them to appear in the target user's library too.

The source user (User C) uploads photos normally — Immich stores them in its upload directory and runs ML processing. You create an external library for the target user (User B) containing a symlink to User C's upload directory. The sidecar creates asset records for User B directly in the database.

Create the symlink from a directory inside the target user's external library to the source user's upload directory:

/external_library/
  user_b_uploads/     # Target user's external library (NOT scanned — sidecar handles it)
    user_c -> /data/upload/cccccccc-cccc-cccc-cccc-cccccccccccc/

The symlink target must point to the source user's upload directory inside the container. Immich stores uploads at {upload_location}/upload/{user_id}/.

Create the target external library in Immich for the target user (User B). This must be a separate library from any library used for external library sync.

Important: This library must not be scanned by Immich. Add the exclusion pattern **/*:

  1. Go to Administration > External Libraries
  2. Click on the target user's new upload sync library
  3. Add **/* as an exclusion pattern and save

Why a separate library? The source user's libraries are actively scanned by Immich to discover and process new assets. The upload sync target library must not be scanned (Immich would create duplicate records and run redundant ML processing). The sidecar handles creating the asset records directly in the database.

⚠️ The same scan warning applies here: disable Immich's periodic scan and never manually scan this library, or the **/* exclusion will cause Immich to offline every synced asset. See the warning in the external library sync setup above and issue #3.

Add to .env:

# Source user whose uploads you want to sync
UPLOAD_SOURCE_USER_ID=cccccccc-cccc-cccc-cccc-cccccccccccc

# The new external library created above (separate from any external library sync library)
UPLOAD_TARGET_LIBRARY_ID=wwwwwwww-wwww-wwww-wwww-wwwwwwwwwwww

# Path prefix where the symlink maps uploads into the external library
TARGET_UPLOAD_PATH_PREFIX=/external_library/user_b_uploads/user_c/

The sidecar automatically derives the source path prefix from the upload location mount and the source user ID (e.g., /usr/src/app/upload/upload/cccccccc-cccc-cccc-cccc-cccccccccccc/).

Note: If you're only doing upload sync, you don't need SOURCE_USER_ID, TARGET_LIBRARY_ID, SHARED_PATH_PREFIX, or TARGET_PATH_PREFIX. Those are only for external library sync.

4. Album Assignment (optional)

To have synced assets automatically added to an album in the target user's account, create the album first in Immich, then configure it.

With config.yaml (recommended): Add album_id to each job that needs it. Different jobs can target different albums:

sync_jobs:
  - name: "alice-to-bob"
    # ...
    album_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"  # Bob's album
  - name: "charlie-to-alice"
    # ...
    album_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"  # Alice's album

With env vars (legacy): Add TARGET_ALBUM_ID to .env — this applies the same album to all jobs:

TARGET_ALBUM_ID=aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa

The album must be owned by the job's target user.

If you add an album after assets have already been synced, the sidecar will backfill all previously synced assets into the album on the next cycle. When a source asset is deleted, its album entry is also removed during cleanup.

5. Start the Sidecar

The sidecar runs as a standalone compose project that connects to Immich's Docker network. From this directory:

docker compose up -d

Network configuration: The docker-compose.yml assumes Immich's Docker network is named immich_default (the default when Immich's compose project is named immich). If your Immich setup uses a different network name — for example because of a custom COMPOSE_PROJECT_NAME, a different directory name, or a manually defined network — edit the networks section at the bottom of docker-compose.yml to match:

networks:
  immich:
    external: true
    name: your_actual_network_name  # e.g., immich-app_default

You can find your Immich network name with docker network ls | grep immich.

The UPLOAD_LOCATION and EXTERNAL_LIBRARY_DIR in .env must point to the same host directories that Immich mounts (typically ./library and ./external_library in your Immich directory). Hardlinks require the same filesystem.

The sidecar will:

  • Wait for the Immich server to become available
  • Create its tracking tables (_face_sync_asset_map, _face_sync_person_map)
  • Run a sync cycle every 60 seconds (configurable via SYNC_INTERVAL_SECONDS)

Updating

When you pull new changes from this repository (for example after an Immich upgrade that requires sidecar schema fixes), rebuild the image and restart the container:

git pull
docker compose up -d --build

The --build flag is required — without it, Docker will keep using the previously built image and your code changes won't take effect.

Then check the logs to confirm the sidecar is running cleanly and syncing photos:

docker compose logs -f

Look for "Schema validation passed" on startup and periodic sync cycle messages. If schema validation fails, the logs will tell you exactly which columns or tables changed — this usually means Immich added new required columns that the sidecar needs to supply.

Configuration Reference

config.yaml (per-job settings)

Each job in sync_jobs supports these fields:

Field Required Description
name Yes Unique name for this sync job
source_user_id Yes UUID of the source user
target_user_id Yes UUID of the target user (receives synced copies)
target_library_id Yes UUID of the target user's external library (with **/* exclusion)
source_path_prefix Yes Path prefix for source assets inside the container
target_path_prefix Yes Path prefix for target assets inside the container
album_id No UUID of album to add synced assets to (must be owned by target user)

.env (infrastructure settings)

Variable Default Description
UPLOAD_LOCATION (required) Host path to Immich's upload/data directory (e.g., ../immich-app/library)
EXTERNAL_LIBRARY_DIR (required) Host path to the external library directory (e.g., ../immich-app/external_library)
DB_PASSWORD postgres PostgreSQL password (same as Immich)
DB_USERNAME postgres PostgreSQL username
DB_DATABASE_NAME immich PostgreSQL database name
IMMICH_API_KEY (required) Immich API key
SYNC_INTERVAL_SECONDS 60 Seconds between sync cycles
LOG_LEVEL INFO Logging level (DEBUG, INFO, WARNING, ERROR)

Legacy env vars (when config.yaml is not present)

When no config.yaml file exists, the sidecar falls back to these per-job env vars:

Variable Description
TARGET_USER_ID UUID of the target user
SOURCE_USER_ID UUID of the source user (external library sync)
TARGET_LIBRARY_ID UUID of the target user's external library
SHARED_PATH_PREFIX Source path prefix (external library sync)
TARGET_PATH_PREFIX Target path prefix (external library sync)
UPLOAD_SOURCE_USER_ID UUID of the source user (upload sync)
UPLOAD_TARGET_LIBRARY_ID UUID of a separate external library (upload sync)
TARGET_UPLOAD_PATH_PREFIX Target path prefix (upload sync)
TARGET_ALBUM_ID UUID of album (applies to all jobs)

At least one of SHARED_PATH_PREFIX or UPLOAD_SOURCE_USER_ID must be set. Both can be configured simultaneously.

How the Sync Works

Each sync cycle runs five phases:

  1. New assets — For each configured sync job (external library, uploads), finds fully-processed source assets not yet synced. Creates target asset records with copied EXIF, CLIP embeddings, faces, and hardlinked thumbnails. 1b. Album assignment — Adds newly synced assets to the target album (if configured). Backfills any previously synced assets that are missing from the album.
  2. Incremental faces — Detects face updates on already-synced assets (using a watermark timestamp) and copies new faces.
  3. Person metadata — Syncs person name changes, visibility (isHidden), and thumbnail updates from source to target.
  4. Cleanup — Removes target assets (and their album entries) whose source was deleted or trashed. Detects person merges (face reassignment). Removes orphaned target persons.

How Faces Are Handled

When the sidecar syncs an asset, it copies the source user's face detection data (bounding boxes, embeddings) and creates a mirrored person for the target user. The source user's person names, visibility, and thumbnails are propagated to these mirrored persons automatically on every sync cycle. The source user is the authority — if they rename "Mom" to "Mother", the target user's mirrored person updates to match.

Duplicate people

If the target user already has their own photos with detected faces, they'll see duplicate person entries: their own person (from their uploads) and the sidecar's mirrored person (from synced assets). To unify them, use Immich's merge feature in the People view. The sidecar detects the merge on the next cycle and adopts the surviving person, so name and visibility sync continue to work regardless of which direction you merge.

Caveats

  • force=true jobs: If someone triggers a force re-process in Immich, it will re-run ML on the target user's assets, overwriting the copied data. The sidecar will re-sync on the next cycle, but there will be temporary GPU usage.
  • Same filesystem required: Hardlinks only work when the sidecar container mounts the same volume as Immich. Cross-filesystem setups would need file copies instead.
  • Direct database access: This service writes directly to Immich's database. Tested with v3.0.1 — schema changes in other versions may require updates to this sidecar. Always back up your database before use.
  • Single direction: Sync is one-way (source → target). Changes made to target assets in Immich are not propagated back.

Contributing

Development Setup

This is optional — the sidecar runs entirely in Docker and setup.py uses only the Python standard library. A local venv is only useful for IDE autocomplete, linting, and syntax checking.

python3 -m venv .venv
source .venv/bin/activate
pip install -e .

Utility Scripts

The utility scripts read configuration from .env (the same file used by docker compose). Since the Immich PostgreSQL container doesn't expose port 5432 by default, they must run inside a Docker container on the Immich network. run-utility.sh handles the Docker invocation:

./run-utility.sh test_sync.py
./run-utility.sh dedup_synced.py --match-time
./run-utility.sh delete_synced.py
  • test_sync.py — Run a single sync cycle and print verification queries.
  • delete_synced.py — Delete all synced assets for a target user. Does not mark sources as skipped, so running the sync engine again will recreate everything. Useful for resetting a target account.
  • dedup_synced.py — Detect and remove synced assets that duplicate the target user's own uploads (matched by filename + capture date). Use --match-time to compare the full timestamp (with TZ normalisation) instead of just the date. Marks duplicates as skipped so the sync engine won't recreate them.
  • reset.sh — Full reset: stops the sidecar container, deletes all synced assets and mirrored persons from Immich, drops the tracking tables, and removes symlinks from the external library directory. Run directly on the host (not via run-utility.sh). Shows a summary and prompts for confirmation before making changes.

delete_synced.py and dedup_synced.py are interactive: they show a summary and prompt for confirmation before making changes, with a dry-run option.

Testing

  1. Run the setup wizard to configure your .env and connect to a local Immich instance:

    python3 setup.py
  2. Copy some photos into the source user's watched folders or upload them via the Immich app/web UI. Wait for Immich to finish processing (metadata, thumbnails, CLIP, faces).

  3. Run a sync cycle to create the target assets:

    ./run-utility.sh test_sync.py
  4. Verify dedup_synced.py detects duplicates (if the target user also has copies of the same photos):

    ./run-utility.sh dedup_synced.py
  5. Run delete_synced.py and confirm it shows the correct number of synced assets:

    ./run-utility.sh delete_synced.py
  6. Run test_sync.py again — it will recreate the deleted assets, confirming the full round-trip works.

Project Structure

src/
  main.py          — Entry point: config validation, health check, concurrent loops
  sync_engine.py   — Orchestrates 5-phase sync cycle
  asset_sync.py    — Asset record creation, EXIF copy, path remapping
  ml_sync.py       — Face and embedding sync
  person_sync.py   — Person mirroring, name/visibility sync
  album_sync.py    — Album assignment and backfill
  cleanup.py       — Deletion detection and cleanup
  file_ops.py      — Hardlink creation and removal
  db.py            — asyncpg connection pool and transaction helpers
  config.py        — SyncJob dataclass, YAML loader, Pydantic Settings (env var fallback)
  immich_api.py    — Immich REST API client (health check)
  health.py        — TCP health check server

Key Things to Know

  • Immich tables are singular (asset, not assets) with camelCase columns that must be double-quoted in SQL.
  • The sidecar creates two tracking tables prefixed with _face_sync_ to avoid colliding with Immich's schema.
  • Each asset sync uses a PostgreSQL SAVEPOINT so one failure doesn't roll back the entire batch.
  • Cleanup deletes hardlinked files before DB records to avoid orphan files on crash.

About

A Docker container that syncs face and ML data from one account to another for real partner sharing

Topics

Resources

License

Stars

9 stars

Watchers

2 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors