Skip to content

Repository files navigation

icloud-shared-album-sync

CI Coverage: 100% License: MIT Python 3.10+ Contributor Covenant

Keep a folder in sync with a public iCloud Shared Album — photos, videos, and all — using a tiny Docker image and a minimal Python + httpx stack.

Point it at a Shared Album URL, mount a folder, and let it run. Anything anyone adds to the album shows up in the folder on the next sync. Delete something from the album and it disappears locally too. Great for digital picture frames, family photo backups, or any workflow that ends with "…and now I want those photos on my server."

Building a digital picture frame? Pair with icloud-album-kiosk — the display half of the same two-container appliance. This tool writes to the folder; the kiosk renders it as a full-screen crossfading slideshow.

Quickstart

docker run --rm \
  -e SHARED_ALBUM_URL='https://www.icloud.com/sharedalbum/#B2AJ...' \
  -v "$PWD/photos:/photos" \
  ghcr.io/bitwise-forge/icloud-shared-album-sync:latest

One shot — pulls the current album contents into ./photos/ and exits. Set SYNC_INTERVAL_HOURS if you want it to loop on its own.

Docker Compose

Compose is the recommended shape if you have more than one album. One service per album, each with its own URL, its own folder, and its own cadence:

services:
  frame-parents:
    image: ghcr.io/bitwise-forge/icloud-shared-album-sync:latest
    environment:
      SHARED_ALBUM_URL: 'https://www.icloud.com/sharedalbum/#B2AJ...'
      SYNC_INTERVAL_HOURS: '12'
    volumes:
      - ./photos/frame-parents:/photos
    restart: unless-stopped

  frame-in-laws:
    image: ghcr.io/bitwise-forge/icloud-shared-album-sync:latest
    environment:
      SHARED_ALBUM_URL: 'https://www.icloud.com/sharedalbum/#B2BK...'
      SYNC_INTERVAL_HOURS: '24'
    volumes:
      - ./photos/frame-in-laws:/photos
    restart: unless-stopped

Environment variables

Variable Default Description
SHARED_ALBUM_URL (required) The full public Shared Album URL, in either shape Apple hands out: https://www.icloud.com/sharedalbum/#TOKEN (classic) or https://share.icloud.com/photos/TOKEN (short link). Get it from Photos.app → Share → Public Website.
OUTPUT_DIR /photos Where inside the container to write assets. Mount a host folder here.
SYNC_INTERVAL_HOURS 0 If > 0, run continuously and sleep this many hours between syncs. If 0 (the default), sync once and exit.
STORAGE_BUFFER_PERCENT 10 Reserves this percentage of the output volume's total capacity as untouchable headroom for the OS, logs, and anything else sharing the disk. Accepts a float (e.g. 7.25), rounded to two decimal places. Range [0, 100).
AUTOPRUNE_ON_LOW_STORAGE false If false and the album would exceed the available budget, the sync logs an error and skips the run — nothing on disk is touched. If true, the sync keeps the newest slice of the album that fits under the budget and prunes older photos locally to make room. Newest is defined by upload time (batchDateCreated), then capture time (dateCreated), then photoGuid as a deterministic tiebreaker.
LOG_LEVEL INFO Python logging level: DEBUG, INFO, WARNING, ERROR.

How it works

Apple's Shared Streams API is a short conversation:

  1. Resolve the correct shard host for this album.
  2. Fetch the album manifest — one entry per photo/video, with contributor, date, caption, and per-derivative CDN references.
  3. Fetch signed CDN URLs (~3 hour expiry) for the assets we want.
  4. Download the best available derivative per asset. Photos use the largest numeric derivative (typically 2048, the long-edge in pixels). Videos use 720p when present, 360p otherwise.

Files land under the filename Apple assigns, with a short hash of the asset's unique ID appended before the extension: IMG_5744.JPGIMG_5744__a1b2c3d4.JPG. That hash is deterministic per asset, which does two things: it prevents collisions when two contributors happen to upload files with the same name, and it marks the file as "managed by this tool" so pruning can safely clean up without touching anything else in the folder.

EXIF, GPS, and iPhone-model metadata come through untouched inside Apple's shared-album compression. Re-runs are idempotent — assets whose local size matches the manifest are skipped, so a scheduled sync stays fast in steady state.

About the Apple API

The endpoints this tool uses (p*-sharedstreams.icloud.com) are the ones behind Apple's public web viewer at www.icloud.com/sharedalbum/. They are undocumented, unofficial, and can change or disappear on any iOS/macOS release. If Apple changes the shape of the response, this tool will break until it's updated to match.

If you rely on this in production, pin a specific version tag rather than tracking :latest.

Running from source

Python 3.10 or newer, no runtime packages to install:

export SHARED_ALBUM_URL='https://www.icloud.com/sharedalbum/#B2AJ...'
export OUTPUT_DIR="$PWD/photos"
PYTHONPATH=src python3 -m icloud_sync

Or via the project's uv-managed environment:

PYTHONPATH=src uv run python -m icloud_sync

Building the image

If you'd rather build the image locally than pull from GHCR — for a private mirror, an air-gapped environment, or just to hack on the code — the Dockerfile is at the repo root and needs no build args:

git clone https://github.com/Bitwise-Forge/icloud-shared-album-sync
cd icloud-shared-album-sync
docker build -t icloud-shared-album-sync:local .

Then substitute icloud-shared-album-sync:local wherever the Quickstart and Compose examples show ghcr.io/bitwise-forge/icloud-shared-album-sync:latest.

The resulting image is ~53 MB, based on python:3.14-alpine, and runs as a non-root app user (UID 1000) inside the container. Multi-architecture builds (linux/amd64 + linux/arm64) work via docker buildx and a docker-container driver — that's how the published GHCR image is produced.

Testing

Test suite runs with pytest. The project uses uv for environment and dependency management — install uv once (install guide), then:

uv sync                          # create venv, install locked deps
uv run pre-commit install        # arm the quality-gate git hook
uv run pytest                    # run the tests

With a coverage report:

uv run pytest --cov=icloud_sync --cov-report=term-missing

The pre-commit hook runs Ruff (lint + format) and ty (type check) on every commit. See CONTRIBUTING.md for details.

Every filesystem test uses pytest's tmp_path fixture; every network call is stubbed via monkeypatch. The suite never touches Apple's real API or writes files outside the temp dir.

Coverage groups:

  • Pure logic: URL parsing, best-derivative selection (photo, video, edge cases), collision-proof local filename generation, the managed-file naming regex.
  • Shard resolution: happy-path 200, 330 redirect via response header, missing-host error path.
  • End-to-end sync_album: creates the output directory; downloads every asset at the manifest's declared size; skips unchanged files on re-run; re-downloads on size mismatch; prunes orphans that match the tool's naming pattern; leaves manual (non-matching) files alone; handles filename collisions across contributors; prunes assets removed from the album on the next sync; handles an empty manifest; when AUTOPRUNE_ON_LOW_STORAGE=true, keeps the newest slice that fits under the disk budget and evicts older photos; when false, refuses to touch disk on over-budget runs.

What it doesn't do (yet)

  • Write contributor / caption / date sidecars. The API exposes all three; a future release will write them alongside the media as JSON or XMP.
  • Handle private (non-public) Shared Albums. Only works with albums that have the "Public Website" toggle enabled.

Contributing

Contributions are welcome — see CONTRIBUTING.md for setup, expectations, and the scope guide. Participation is governed by the Code of Conduct. Security issues should be reported privately per SECURITY.md.

Changes per release are tracked in CHANGELOG.md.

License

MIT — see LICENSE.

Support

This is a community-supported open source project. Issues and pull requests are welcome; there is no SLA and no obligation to fix. If you find a bug, open an issue with the log output and (if you can share it) the album URL that triggered it.

Built and maintained by Bitwise Forge.


iCloud and Apple are trademarks of Apple Inc., registered in the U.S. and other countries. This project is not affiliated with, endorsed by, or sponsored by Apple Inc.

About

The shared album, backed up somewhere you actually control.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages