|
#!/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 |