Skip to content

Instantly share code, notes, and snippets.

@sveetya
Created March 21, 2026 16:19
Show Gist options
  • Select an option

  • Save sveetya/cba587a4f257efaeb48c295c40fc6065 to your computer and use it in GitHub Desktop.

Select an option

Save sveetya/cba587a4f257efaeb48c295c40fc6065 to your computer and use it in GitHub Desktop.
Download photos & videos from an iCloud shared album in bulk, using CLI

iCloud Shared Album Downloader

Downloads all photos and videos from an iCloud shared album URL into a local folder. Supports incremental re-runs (already-downloaded files are skipped), duplicate filename handling, cleanup of stale files, and dry-run previewing.


Requirements

Tool Purpose
bash Shell (≥ 4.0)
curl HTTP requests and downloads
jq JSON parsing of iCloud API responses

Installing jq

Ubuntu / WSL:

sudo apt-get update && sudo apt-get install -y jq

macOS (Homebrew):

brew install jq

Windows: Use WSL (Ubuntu) or Git Bash with the WSL method above.


Platform notes (Windows)

The script requires a bash shell. On Windows, use one of:

  • WSL with Ubuntu (recommended):

    wsl --install -d Ubuntu
    wsl --set-default Ubuntu

    Then run commands with bash download.sh ... from PowerShell.

  • Git Bash:

    & "C:\Program Files\Git\bin\bash.exe" download.sh ...

Usage

bash download.sh <iCloud-shared-album-URL> [download-folder] [--dry-run] [--no-cleanup] [--skip-videos]

Arguments

Argument Required Description
<URL> Yes Full iCloud shared album URL (e.g. https://www.icloud.com/sharedalbum/#XXXXXXX)
[download-folder] No Folder to download into. Created if it doesn't exist. Defaults to current directory.

Flags

Flag Description
--dry-run Preview what would be downloaded or deleted without making any changes. A log file is still created.
--no-cleanup Skip the cleanup step that deletes files in the folder not found in the album.
--skip-videos Skip .mp4 and .mov files — only download photos.

Flags can appear in any order after the URL.


Examples

Download everything into a photos/ folder:

bash download.sh "https://www.icloud.com/sharedalbum/#B2N5yeZFhujCELk" photos

Preview what would happen without downloading anything:

bash download.sh "https://www.icloud.com/sharedalbum/#B2N5yeZFhujCELk" photos --dry-run

Download only photos, no videos:

bash download.sh "https://www.icloud.com/sharedalbum/#B2N5yeZFhujCELk" photos --skip-videos

Re-sync without deleting stale local files:

bash download.sh "https://www.icloud.com/sharedalbum/#B2N5yeZFhujCELk" photos --no-cleanup

Combine flags:

bash download.sh "https://www.icloud.com/sharedalbum/#B2N5yeZFhujCELk" photos --skip-videos --no-cleanup

Re-run behaviour

The script is safe to re-run on the same folder:

  • Files that already exist before the run starts are skipped (not re-downloaded, not duplicated).
  • Files that appear twice in the same album (same filename, different content) are downloaded with a (1), (2) etc. suffix, e.g. IMG_1234 (1).JPG.
  • Files that are identical in content (same checksum) are deduplicated — only one copy is downloaded.
  • Files in the local folder that are no longer in the album are deleted during cleanup (unless --no-cleanup is set).

Output and log file

All console output is also written to a timestamped log file:

  • With a download folder: photos/download_20260321_143000.log
  • Without a download folder: ./download_20260321_143000.log

Progress is shown as (current/total, ETA: Xm Ys) per file. A summary is printed at the end:

=== Summary ===
Downloaded:  742
Duplicates:  12
Skipped:     45
Failed:      0
Deleted:     2

The script exits with code 1 if any downloads failed, making it usable in automated pipelines.


Credits

Original script based on: https://gist.github.com/fay59/8f719cd81967e0eb2234897491e051ec

#!/bin/bash
set -euo pipefail
# code from: https://gist.github.com/fay59/8f719cd81967e0eb2234897491e051ec?permalink_comment_id=4219612#gistcomment-4219612
# requires jq, curl
# arg 1: iCloud web album URL
# arg 2: folder to download into (optional)
# --- Dependency checks ---
if ! command -v jq &>/dev/null; then
echo "Error: jq is required but not installed." >&2
exit 1
fi
if ! command -v curl &>/dev/null; then
echo "Error: curl is required but not installed." >&2
exit 1
fi
# --- Flag parsing & argument validation ---
DRY_RUN=false
NO_CLEANUP=false
SKIP_VIDEOS=false
ALBUM_URL=""
DOWNLOAD_DIR=""
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
--no-cleanup) NO_CLEANUP=true ;;
--skip-videos) SKIP_VIDEOS=true ;;
--*) echo "Unknown flag: $arg" >&2; exit 1 ;;
*)
if [[ -z "$ALBUM_URL" ]]; then
ALBUM_URL="$arg"
elif [[ -z "$DOWNLOAD_DIR" ]]; then
DOWNLOAD_DIR="$arg"
fi
;;
esac
done
if [[ -z "$ALBUM_URL" ]]; then
echo "Usage: $0 <iCloud-shared-album-URL> [download-folder] [--dry-run] [--no-cleanup] [--skip-videos]" >&2
exit 1
fi
if [[ -n "$DOWNLOAD_DIR" ]]; then
mkdir -p "$DOWNLOAD_DIR"
fi
# --- Counters for summary ---
DOWNLOADED=0
SKIPPED=0
FAILED=0
DELETED=0
DUPLICATES=0
# Sleeps for ~0.1s with ±0.04s random jitter to avoid hammering the CDN
sleep_with_jitter() {
sleep "$(awk "BEGIN {srand(); printf \"%.3f\", 0.06 + rand() * 0.08}")"
}
# Returns a unique filename by appending (1), (2), ... before the extension if needed
get_unique_filename() {
local filename="$1"
if [[ ! -f "$filename" ]]; then
echo "$filename"
return
fi
local name="${filename%.*}"
local ext="${filename##*.}"
local counter=1
while [[ -f "${name} (${counter}).${ext}" ]]; do
((counter++))
done
echo "${name} (${counter}).${ext}"
}
function curl_post_json {
curl -s --connect-timeout 10 --max-time 30 -H "Content-Type: application/json" -X POST -d "@-" "$@"
}
# --- Log file ---
if [[ -n "$DOWNLOAD_DIR" ]]; then
mkdir -p "$DOWNLOAD_DIR"
LOG_FILE="$DOWNLOAD_DIR/download_$(date +%Y%m%d_%H%M%S).log"
else
LOG_FILE="./download_$(date +%Y%m%d_%H%M%S).log"
fi
exec > >(tee -a "$LOG_FILE") 2>&1
echo "Logging to: $LOG_FILE"
[[ "$DRY_RUN" == true ]] && echo "*** DRY RUN MODE — no files will be downloaded or deleted ***"
printf "Connecting to iCloud Shared Album\n"
BASE_API_URL="https://p23-sharedstreams.icloud.com/$(echo "$ALBUM_URL" | cut -d# -f2)/sharedstreams"
if [[ -n "$DOWNLOAD_DIR" ]]; then
pushd "$DOWNLOAD_DIR" > /dev/null
fi
STREAM=$(echo '{"streamCtag":null}' | curl_post_json "$BASE_API_URL/webstream")
# Validate API response
if ! echo "$STREAM" | jq empty 2>/dev/null; then
echo "Error: Failed to get valid response from iCloud API." >&2
exit 1
fi
HOST=$(echo "$STREAM" | jq '.["X-Apple-MMe-Host"]' | cut -c 2- | rev | cut -c 2- | rev)
if [ "$HOST" ]; then
BASE_API_URL="https://$(echo "$HOST")/$(echo "$ALBUM_URL" | cut -d# -f2)/sharedstreams"
STREAM=$(echo '{"streamCtag":null}' | curl_post_json "$BASE_API_URL/webstream")
fi
PHOTO_COUNT=$(echo "$STREAM" | jq -r '.photos | length')
if [[ "$PHOTO_COUNT" -eq 0 ]]; then
echo "Warning: No photos found in the shared album. Exiting." >&2
exit 0
fi
echo "$(echo "$STREAM" | jq -r '.userFirstName + " " +.userLastName +": " + .streamName')"
echo ""
# Grabbing Large File Checksums
CHECKSUMS=$(echo "$STREAM" | jq -r '.photos[] | [(.derivatives[] | {size: .fileSize | tonumber, value: .checksum})] | max_by(.size | tonumber).value')
# Adding Checksums to Array
arrCHKSUM=()
for CHECKSUM in $CHECKSUMS; do
arrCHKSUM+=("$CHECKSUM")
done
printf "Total Downloads: ${#arrCHKSUM[@]}\n"
# Dedup checksum to only include unique ids.
arrCHKSUM=($(printf "%s\n" "${arrCHKSUM[@]}" | sort -u))
printf "Unique Downloads: ${#arrCHKSUM[@]}\n\n"
FILENAMES=()
TOTAL=${#arrCHKSUM[@]}
CURRENT=0
START_TIME=$(date +%s)
# Snapshot files that already exist before this run (to skip on re-run rather than duplicate)
# Only snapshot when we've pushd'd into the download dir; otherwise nothing pre-exists
declare -A PRE_EXISTING
if [[ -n "$DOWNLOAD_DIR" ]]; then
for f in *; do
[[ -f "$f" ]] && PRE_EXISTING["$f"]=1
done
fi
while read -r URL; do
# Get this URL's checksum value, not all URL's will be downloaded as there are both the full size AND the thumbnail link in the Assets stream.
LOCAL_CHECKSUM="${URL##*&}"
# If the url's checksum exists in the large checksum array then proceed with the download steps.
if [[ " ${arrCHKSUM[*]} " =~ " ${LOCAL_CHECKSUM} " ]]; then
# Get the filename from the URL, first we delimit on the forward slashes grabbing index 6 where the filename starts.
# then we must delimit again on ? to remove all the URL parameters after the filename.
# Example: https://www.example.com/4/5/IMG_0828.JPG?o=param1&v=param2&z=param3....
FILE=$(echo "$URL" | cut -d "/" -f6 | cut -d "?" -f1)
HEADER=$(curl -s --connect-timeout 10 --max-time 30 --range 0-0 -D - "$URL" -o /dev/null)
FILENAME=$(echo "$HEADER" | awk -F'filename=' '/[Cc]ontent-[Dd]isposition/ {gsub(/[";\r]/, "", $2); print $2; exit}')
# Fall back to filename parsed from URL if Content-Disposition header was missing
[[ -z "$FILENAME" ]] && FILENAME="$FILE"
FILENAMES+=("$FILENAME")
((CURRENT++)) || true
NOW=$(date +%s)
ELAPSED=$(( NOW - START_TIME ))
if [[ $ELAPSED -gt 0 && $CURRENT -gt 1 ]]; then
ETA_SECS=$(( (TOTAL - CURRENT) * ELAPSED / CURRENT ))
ETA_STR=$(printf "%dm %02ds" $(( ETA_SECS / 60 )) $(( ETA_SECS % 60 )))
PROGRESS="($CURRENT/$TOTAL, ETA: $ETA_STR)"
else
PROGRESS="($CURRENT/$TOTAL)"
fi
# Skip files that existed before this run started (re-run safety)
if [[ -n "${PRE_EXISTING["$FILENAME"]:-}" ]]; then
printf "%s Skipping (already exists): %s\n" "$PROGRESS" "$FILENAME"
((SKIPPED++)) || true
continue
fi
# Skip videos if --skip-videos flag is set
if [[ "$SKIP_VIDEOS" == true && ("$FILENAME" == *.mp4* || "$FILENAME" == *.mov*) ]]; then
printf "%s Skipping video: %s\n" "$PROGRESS" "$FILENAME"
((SKIPPED++)) || true
continue
fi
# Resolve unique filename (adds (1), (2)... suffix for in-session filename collisions)
UNIQUE_FILENAME=$(get_unique_filename "$FILENAME")
# Also register the unique name so cleanup doesn't delete it
[[ "$UNIQUE_FILENAME" != "$FILENAME" ]] && FILENAMES+=("$UNIQUE_FILENAME")
# Download movies and images
if [[ "$UNIQUE_FILENAME" == "$FILENAME" ]]; then
if [[ "$FILENAME" == *.mp4* || "$FILENAME" == *.mov* ]]; then
printf "%s Downloading movie: %s\n" "$PROGRESS" "$FILENAME"
else
printf "%s Downloading: %s\n" "$PROGRESS" "$FILENAME"
fi
else
printf "%s Downloading duplicate: %s\n" "$PROGRESS" "$UNIQUE_FILENAME"
((DUPLICATES++)) || true
fi
if [[ "$DRY_RUN" == true ]]; then
printf "%s [DRY RUN] Would download: %s\n" "$PROGRESS" "$UNIQUE_FILENAME"
((DOWNLOADED++)) || true
elif curl --connect-timeout 10 --max-time 120 --retry 3 -s -o "$UNIQUE_FILENAME" "$URL"; then
((DOWNLOADED++)) || true
else
printf "%s WARNING: Failed to download: %s\n" "$PROGRESS" "$UNIQUE_FILENAME" >&2
((FAILED++)) || true
fi
sleep_with_jitter
fi
done < <(
echo "$STREAM" \
| jq -c '{photoGuids: [.photos[].photoGuid]}' \
| curl_post_json "$BASE_API_URL/webasseturls" \
| jq -r '.items | to_entries[] | "https://" + .value.url_location + .value.url_path + "&" + .key'
)
if [[ -n "$DOWNLOAD_DIR" && "$NO_CLEANUP" == false ]]; then
echo -e "\nChecking for unexpected files in download directory"
# Safety: skip cleanup if no filenames were collected (API may have failed)
if [[ ${#FILENAMES[@]} -eq 0 ]]; then
echo "WARNING: No filenames collected — skipping cleanup to avoid data loss." >&2
else
containsElement () {
local e match="$1"
shift
for e; do [[ "$e" == "$match" ]] && return 0; done
return 1
}
SAFE_EXTENSIONS="jpg jpeg png gif mp4 mov heic"
# If the image file is not in the known list of filenames, delete it
for IMAGE_FILE in *; do
if [[ -f "$IMAGE_FILE" ]]; then
EXT="${IMAGE_FILE##*.}"
EXT_LOWER="${EXT,,}"
if [[ " $SAFE_EXTENSIONS " == *" $EXT_LOWER "* ]]; then
if ! containsElement "$IMAGE_FILE" "${FILENAMES[@]}"; then
if [[ "$DRY_RUN" == true ]]; then
echo "[DRY RUN] Would delete: $IMAGE_FILE"
else
echo "DELETING unexpected file: $IMAGE_FILE"
rm -- "$IMAGE_FILE"
fi
((DELETED++)) || true
fi
else
echo "Not Deleting unexpected file with unknown extension: $IMAGE_FILE"
fi
fi
done
fi
fi
echo ""
echo "=== Summary ==="
echo "Downloaded: $DOWNLOADED"
echo "Duplicates: $DUPLICATES"
echo "Skipped: $SKIPPED"
echo "Failed: $FAILED"
echo "Deleted: $DELETED"
echo "iCloud Photo Downloader Finished"
if [[ -n "$DOWNLOAD_DIR" ]]; then
popd > /dev/null
fi
if [[ $FAILED -gt 0 ]]; then
exit 1
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment